Skip to content

Commit

Permalink
feat: Add CLI tool
Browse files Browse the repository at this point in the history
  • Loading branch information
eemeli committed Feb 24, 2024
1 parent 9fc76fa commit c571406
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 1 deletion.
1 change: 1 addition & 0 deletions config/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ switch (process.env.npm_lifecycle_event) {
process.env.TRACE_LEVEL = 'log'
moduleNameMapper = {
'^yaml$': '<rootDir>/src/index.ts',
'^yaml/cli$': '<rootDir>/src/cli.ts',
'^yaml/util$': '<rootDir>/src/util.ts'
}
transform['[/\\\\]src[/\\\\].*\\.ts$'] = [
Expand Down
15 changes: 14 additions & 1 deletion config/rollup.node-config.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { chmod, stat } from 'node:fs/promises'
import typescript from '@rollup/plugin-typescript'

export default {
input: {
cli: 'src/cli.ts',
index: 'src/index.ts',
'test-events': 'src/test-events.ts',
util: 'src/util.ts'
Expand All @@ -12,6 +14,17 @@ export default {
esModule: false,
preserveModules: true
},
plugins: [typescript()],
external: ['node:util'],
plugins: [
typescript(),
{
async writeBundle() {
// chmod a+x dist/cli.js
const file = 'dist/cli.js'
const prev = await stat(file)
await chmod(file, prev.mode | 0o111)
}
}
],
treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
],
"type": "commonjs",
"main": "./dist/index.js",
"bin": "./dist/cli.js",
"browser": {
"./dist/index.js": "./browser/index.js",
"./dist/util.js": "./browser/dist/util.js",
Expand Down
207 changes: 207 additions & 0 deletions src/cli.ts
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)}`
)
)
}
}
137 changes: 137 additions & 0 deletions tests/cli.ts
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"]']
)
})
})
1 change: 1 addition & 0 deletions tests/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"declaration": false,
"paths": {
"yaml": ["src/index.ts"],
"yaml/cli": ["src/cli.ts"],
"yaml/util": ["src/util.ts"]
},
"rootDir": "..",
Expand Down

0 comments on commit c571406

Please sign in to comment.