-
-
Notifications
You must be signed in to change notification settings - Fork 117
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
361 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,207 @@ | ||
#!/usr/bin/env node | ||
|
||
import { parseArgs } from 'node:util' | ||
|
||
import { type Token, prettyToken } from './parse/cst.js' | ||
import { Lexer } from './parse/lexer.js' | ||
import { Parser } from './parse/parser.js' | ||
import { Composer } from './compose/composer.js' | ||
import { LineCounter } from './parse/line-counter.js' | ||
import { type Document } from './doc/Document.js' | ||
import { prettifyError } from './errors.js' | ||
|
||
const help = `\ | ||
yaml: A command-line YAML processor and inspector | ||
Reads stdin and writes output to stdout and errors & warnings to stderr. | ||
Usage: | ||
yaml Process a YAML stream, outputting it as YAML | ||
yaml cst Parse the CST of a YAML stream | ||
yaml lex Parse the lexical tokens of a YAML stream | ||
yaml valid Validate a YAML stream, returning 0 on success | ||
Options: | ||
--help, -h Show this message. | ||
--json, -j Output JSON. | ||
Additional options for bare "yaml" command: | ||
--doc, -d Output pretty-printed JS Document objects. | ||
--single, -1 Require the input to consist of a single YAML document. | ||
--strict, -s Stop on errors. | ||
--yaml 1.1 Set the YAML version. (default: 1.2)` | ||
|
||
class UserError extends Error { | ||
static ARGS = 2 | ||
static SINGLE = 3 | ||
code: number | ||
constructor(code: number, message: string) { | ||
super(`Error: ${message}`) | ||
this.code = code | ||
} | ||
} | ||
|
||
if (require.main === module) | ||
main(process.stdin, error => { | ||
if (error instanceof UserError) { | ||
console.error(`${help}\n\n${error.message}`) | ||
process.exitCode = error.code | ||
} else if (error) throw error | ||
}) | ||
|
||
export function main( | ||
stdin: NodeJS.ReadableStream, | ||
done: (error?: Error) => void, | ||
argv?: string[] | ||
) { | ||
let args | ||
try { | ||
args = parseArgs({ | ||
args: argv, | ||
allowPositionals: true, | ||
options: { | ||
doc: { type: 'boolean', short: 'd' }, | ||
help: { type: 'boolean', short: 'h' }, | ||
json: { type: 'boolean', short: 'j' }, | ||
single: { type: 'boolean', short: '1' }, | ||
strict: { type: 'boolean', short: 's' }, | ||
yaml: { type: 'string', default: '1.2' } | ||
} | ||
}) | ||
} catch (error) { | ||
return done(new UserError(UserError.ARGS, (error as Error).message)) | ||
} | ||
|
||
const { | ||
positionals: [mode], | ||
values: opt | ||
} = args | ||
|
||
stdin.setEncoding('utf-8') | ||
|
||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing | ||
switch (opt.help || mode) { | ||
case true: // --help | ||
console.log(help) | ||
break | ||
|
||
case 'lex': { | ||
const lexer = new Lexer() | ||
const data: string[] = [] | ||
const add = (tok: string) => { | ||
if (opt.json) data.push(tok) | ||
else console.log(prettyToken(tok)) | ||
} | ||
stdin.on('data', (chunk: string) => { | ||
for (const tok of lexer.lex(chunk, true)) add(tok) | ||
}) | ||
stdin.on('end', () => { | ||
for (const tok of lexer.lex('', false)) add(tok) | ||
if (opt.json) console.log(JSON.stringify(data)) | ||
done() | ||
}) | ||
break | ||
} | ||
|
||
case 'cst': { | ||
const parser = new Parser() | ||
const data: Token[] = [] | ||
const add = (tok: Token) => { | ||
if (opt.json) data.push(tok) | ||
else console.dir(tok, { depth: null }) | ||
} | ||
stdin.on('data', (chunk: string) => { | ||
for (const tok of parser.parse(chunk, true)) add(tok) | ||
}) | ||
stdin.on('end', () => { | ||
for (const tok of parser.parse('', false)) add(tok) | ||
if (opt.json) console.log(JSON.stringify(data)) | ||
done() | ||
}) | ||
break | ||
} | ||
|
||
case undefined: | ||
case 'valid': { | ||
const lineCounter = new LineCounter() | ||
const parser = new Parser(lineCounter.addNewLine) | ||
// @ts-expect-error Version is validated at runtime | ||
const composer = new Composer({ version: opt.yaml }) | ||
let source = '' | ||
let hasDoc = false | ||
let reqDocEnd = false | ||
const data: Document[] = [] | ||
const add = (doc: Document) => { | ||
if (hasDoc && opt.single) { | ||
return done( | ||
new UserError( | ||
UserError.SINGLE, | ||
'Input stream contains multiple documents' | ||
) | ||
) | ||
} | ||
for (const error of doc.errors) { | ||
prettifyError(source, lineCounter)(error) | ||
if (opt.strict || mode === 'valid') throw error | ||
console.error(error) | ||
} | ||
for (const warning of doc.warnings) { | ||
prettifyError(source, lineCounter)(warning) | ||
console.error(warning) | ||
} | ||
if (mode === 'valid') doc.toJS() | ||
else if (opt.json) data.push(doc) | ||
else if (opt.doc) { | ||
Object.defineProperties(doc, { | ||
options: { enumerable: false }, | ||
schema: { enumerable: false } | ||
}) | ||
console.dir(doc, { depth: null }) | ||
} else { | ||
if (reqDocEnd) console.log('...') | ||
try { | ||
const str = String(doc) | ||
console.log(str.endsWith('\n') ? str.slice(0, -1) : str) | ||
} catch (error) { | ||
done(error as Error) | ||
} | ||
} | ||
hasDoc = true | ||
reqDocEnd = !doc.directives?.docEnd | ||
} | ||
stdin.on('data', (chunk: string) => { | ||
source += chunk | ||
for (const tok of parser.parse(chunk, true)) { | ||
for (const doc of composer.next(tok)) add(doc) | ||
} | ||
}) | ||
stdin.on('end', () => { | ||
for (const tok of parser.parse('', false)) { | ||
for (const doc of composer.next(tok)) add(doc) | ||
} | ||
for (const doc of composer.end(false)) add(doc) | ||
if (opt.single && !hasDoc) { | ||
return done( | ||
new UserError( | ||
UserError.SINGLE, | ||
'Input stream contained no documents' | ||
) | ||
) | ||
} | ||
if (mode !== 'valid' && opt.json) { | ||
console.log(JSON.stringify(opt.single ? data[0] : data)) | ||
} | ||
done() | ||
}) | ||
break | ||
} | ||
|
||
default: | ||
done( | ||
new UserError( | ||
UserError.ARGS, | ||
`Unknown command: ${JSON.stringify(mode)}` | ||
) | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
import { Readable } from 'node:stream' | ||
import { main } from 'yaml/cli' | ||
|
||
describe('CLI', () => { | ||
const stdout: unknown[] = [] | ||
const stderr: unknown[] = [] | ||
beforeAll(() => { | ||
jest.spyOn(global.console, 'log').mockImplementation(thing => { | ||
stdout.push(thing) | ||
}) | ||
jest.spyOn(global.console, 'dir').mockImplementation(thing => { | ||
stdout.push(thing) | ||
}) | ||
jest.spyOn(global.console, 'error').mockImplementation(thing => { | ||
stderr.push(thing) | ||
}) | ||
}) | ||
|
||
function ok( | ||
name: string, | ||
input: string, | ||
args: string[], | ||
output: unknown[], | ||
errors: unknown[] = [] | ||
) { | ||
test(name, done => { | ||
stdout.length = 0 | ||
stderr.length = 0 | ||
main( | ||
Readable.from([input]), | ||
error => { | ||
try { | ||
expect(stdout).toMatchObject(output) | ||
expect(stderr).toMatchObject(errors) | ||
expect(error).toBeUndefined() | ||
} finally { | ||
done() | ||
} | ||
}, | ||
args | ||
) | ||
}) | ||
} | ||
|
||
function fail( | ||
name: string, | ||
input: string, | ||
args: string[], | ||
errors: unknown[] | ||
) { | ||
test(name, done => { | ||
stderr.length = 0 | ||
let doned = false | ||
main( | ||
Readable.from([input]), | ||
error => { | ||
if (doned) return | ||
try { | ||
expect(stderr).toMatchObject(errors) | ||
expect(error).not.toBeUndefined() | ||
} finally { | ||
done() | ||
doned = true | ||
} | ||
}, | ||
args | ||
) | ||
}) | ||
} | ||
|
||
describe('Stream processing', () => { | ||
ok('basic', 'hello: world', [], ['hello: world']) | ||
fail('error', 'hello: world: 2', [], [{ name: 'YAMLParseError' }]) | ||
ok( | ||
'multiple', | ||
'hello: world\n---\n42', | ||
[], | ||
['hello: world', '...', '---\n42'] | ||
) | ||
describe('--json', () => { | ||
ok('basic', 'hello: world', ['--json'], ['[{"hello":"world"}]']) | ||
ok( | ||
'--single', | ||
'hello: world', | ||
['--json', '--single'], | ||
['{"hello":"world"}'] | ||
) | ||
ok( | ||
'multiple', | ||
'hello: world\n---\n42', | ||
['--json'], | ||
['[{"hello":"world"},42]'] | ||
) | ||
}) | ||
describe('--doc', () => { | ||
ok('basic', 'hello: world', ['--doc'], [{ contents: { items: [{}] } }]) | ||
ok( | ||
'multiple', | ||
'hello: world\n---\n42', | ||
['--doc'], | ||
[{ contents: { items: [{}] } }, { contents: { value: 42 } }] | ||
) | ||
ok( | ||
'error', | ||
'hello: world: 2', | ||
['--doc'], | ||
[{ contents: { items: [{}] } }], | ||
[{ name: 'YAMLParseError' }] | ||
) | ||
}) | ||
}) | ||
|
||
describe('CST parser', () => { | ||
ok('basic', 'hello: world', ['cst'], [{ type: 'document' }]) | ||
ok( | ||
'multiple', | ||
'hello: world\n---\n42', | ||
['cst'], | ||
[{ type: 'document' }, { type: 'document' }] | ||
) | ||
}) | ||
|
||
describe('Lexer', () => { | ||
ok( | ||
'basic', | ||
'hello: world', | ||
['lex'], | ||
['<DOC>', '<SCALAR>', '"hello"', '":"', '" "', '<SCALAR>', '"world"'] | ||
) | ||
ok( | ||
'--json', | ||
'hello: world', | ||
['lex', '--json'], | ||
['["\\u0002","\\u001f","hello",":"," ","\\u001f","world"]'] | ||
) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters