Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 76 additions & 23 deletions src/commands/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,30 +647,79 @@ export async function root(argv: string[]) {
budget: num(flags.budget, 0, { flag: '--budget', kind: 'positive integer', fail }),
offset: num(flags.offset, 0, { flag: '--offset', kind: 'non-negative integer', fail }),
}
const envelopeModifiers = [
['--attr', flags.attr],
['--row', flags.row],
['--locate', flags.locate],
['--where', flags.where],
] as const
const jsonEnvelope = flags['json-envelope'] === true
const optionsEnd = argv.indexOf('--')
const missingEnvelopeValue =
(jsonEnvelope ? envelopeModifiers.find(([, value]) => value === true)?.[0] : undefined) ??
envelopeModifiers.find(([flag]) =>
argv.some(
(arg, index) =>
(optionsEnd === -1 || index < optionsEnd) &&
arg === '--json-envelope' &&
argv[index - 1] === flag
)
)?.[0]
if (missingEnvelopeValue) {
const optionArgs = argv.slice(0, optionsEnd === -1 ? argv.length : optionsEnd)
const outputValueFlags = [
['--attr', ['--attr']],
['--row', ['--row']],
['--locate', ['--locate']],
['--where', ['--where']],
['--output', ['--output', '-o']],
] as const
const outputFlagTokens = new Set<string>([
...outputValueFlags.flatMap(([, spellings]) => spellings),
'--md',
'--outline',
'--table',
'--count',
'--text',
'--html',
'--body',
'--tsv',
'--json',
'--json-envelope',
])
const isOutputFlagToken = (arg: string) =>
outputFlagTokens.has(arg) ||
[...outputFlagTokens].some((flag) =>
flag.startsWith('--')
? arg.startsWith(`${flag}=`)
: arg.startsWith(flag) && arg.length > flag.length
)
const missingOutputValue = outputValueFlags.find(([, spellings]) =>
optionArgs.some(
(arg, index) =>
spellings.some((spelling) => spelling === arg) &&
(optionArgs[index + 1] === undefined || isOutputFlagToken(optionArgs[index + 1]!))
)
)?.[0]
if (missingOutputValue) fail(`${missingOutputValue} requires a value`)

const outputModes = [
['--md', flags.md === true],
['--outline', flags.outline === true],
['--locate', typeof flags.locate === 'string'],
['--table', flags.table === true],
['--count', flags.count === true],
['--row', typeof flags.row === 'string'],
['--text', flags.text === true],
['--attr', typeof flags.attr === 'string'],
['--html', flags.html === true],
['--output', typeof flags.output === 'string'],
['--body', flags.body === true],
['--tsv', flags.tsv === true],
['--json', flags.json === true],
] as const
const activeOutputModes = outputModes.filter(([, active]) => active).map(([flag]) => flag)
const formatModifierOnly =
activeOutputModes.length === 2 &&
((activeOutputModes.includes('--json') &&
activeOutputModes.some((flag) => ['--locate', '--table', '--row'].includes(flag))) ||
(activeOutputModes.includes('--tsv') &&
activeOutputModes.some((flag) => ['--table', '--row'].includes(flag))))
if (activeOutputModes.length > 1 && !formatModifierOnly) {
fail(
`${missingEnvelopeValue} requires a value`,
'pass the modifier value before --json-envelope'
`conflicting output modes: ${activeOutputModes.join(', ')}`,
'choose one output mode; --json may modify --row, --table, or --locate'
)
}
const jsonEnvelope = flags['json-envelope'] === true
if (flags.json === true && jsonEnvelope) {
fail('conflicting output modes: --json, --json-envelope', 'choose one JSON output format')
}
if (typeof flags.where === 'string' && typeof flags.row !== 'string' && flags.table !== true) {
fail('--where requires --row or --table')
}
const emitStructured = (value: unknown[]) =>
jsonEnvelope ? emitJsonEnvelope(value, opts) : emitJson(value, opts)
const isUrl = /^https?:\/\//.test(src!)
Expand All @@ -687,9 +736,13 @@ export async function root(argv: string[]) {
? '--html'
: typeof flags.attr === 'string'
? '--attr'
: flags.body === true
? '--body'
: null
: typeof flags.output === 'string'
? '--output'
: flags.body === true
? '--body'
: flags.tsv === true
? '--tsv'
: null
if (jsonEnvelope && envelopeConflict) {
const hint =
envelopeConflict === '--md'
Expand Down
54 changes: 54 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ test('extract: --row defaults to TSV, --json for JSON rows', () => {
const tsv = ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv']).out
expect(tsv.split('\n')[0]).toBe('title\thref\tlv')
expect(tsv.split('\n')[1]).toBe('One bold\t/1.htm\tA1')
expect(ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv', '--tsv']).out).toBe(tsv)
const rows = JSON.parse(
ax(['page.html', '.card', '--row', 'title=a, href=a@href, lv=.lv', '--json']).out
)
Expand Down Expand Up @@ -538,6 +539,57 @@ test('numeric output flags preserve valid boundary values', () => {
expect(budget.out).toBe('One boldA1')
})

test('conflicting output modes fail before source I/O', () => {
const cases = [
['--md', '--outline'],
['--row', 'title=a', '--table'],
['--count', '--html'],
['--attr', 'href', '--json'],
['--json', '--json-envelope'],
['-o', 'out.bin', '--body'],
['--row', 'title=a', '--json', '--tsv'],
]

for (const flags of cases) {
const r = ax(['missing.html', '.x', ...flags])
expect(r.code).toBe(1)
expect(r.out).toBe('')
expect(r.err).toContain('ax: error: conflicting output modes:')
expect(r.err).not.toContain('ENOENT')
}
})

test('output string flags do not swallow a conflicting flag as their value', () => {
const cases: [string[], string][] = [
[['--attr', '--html'], '--attr'],
[['--row', '--table'], '--row'],
[['--locate', '--count'], '--locate'],
[['-o', '--body'], '--output'],
[['--row', '--json'], '--row'],
[['--attr', '--row=title=a'], '--attr'],
[['--row', '--attr=href'], '--row'],
[['--output', '--row=title=a'], '--output'],
[['--row', '-oout.bin'], '--row'],
[['--row', '-o=out.bin'], '--row'],
]

for (const [flags, missing] of cases) {
const r = ax(['missing.html', '.x', ...flags])
expect(r.code).toBe(1)
expect(r.out).toBe('')
expect(r.err).toContain(`ax: error: ${missing} requires a value`)
expect(r.err).not.toContain('ENOENT')
}
})

test('--where fails outside --row or --table before source I/O', () => {
const r = ax(['missing.html', '.x', '--where', 'name == "x"'])
expect(r.code).toBe(1)
expect(r.out).toBe('')
expect(r.err).toContain('ax: error: --where requires --row or --table')
expect(r.err).not.toContain('ENOENT')
})

test('cap: default limit with stderr note', () => {
const r = ax(['many.html', '.x'])
expect(r.out.split('\n')).toHaveLength(50)
Expand Down Expand Up @@ -703,7 +755,9 @@ test('json envelope: unsupported modes fail before reading the source', () => {
['missing.html', '.x', '--text', '--json-envelope'],
['missing.html', '.x', '--html', '--json-envelope'],
['missing.html', '.x', '--attr', 'href', '--json-envelope'],
['missing.html', '.x', '--output', 'out.bin', '--json-envelope'],
['missing.html', '.x', '--body', '--json-envelope'],
['missing.html', '.x', '--row', 'title=a', '--tsv', '--json-envelope'],
]

for (const args of cases) {
Expand Down
Loading