mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
* Tier-2 structured verb options: OptionSpec, initial migration (#2098) PR 3 of the AI-friendly roadmap (plans/plan-2098-llm.md). Infrastructure: - Add OptionSpec{Flag,Arg,Type,Desc,Repeatable,Values} to pkg/transformers/aaa_record_transformer.go alongside TransformerSetup. Type is one of: bool, string, int, float, csv-list, regex, filename, format, enum. For type=="enum", Values lists the valid choices. - Add Options []OptionSpec to TransformerSetup (nil = not yet migrated). - Emit Options in VerbInfoForJSON (omitempty so unmigrated verbs stay backward-compatible; agents check key presence for Tier-2 availability). UsageText is always present as the Tier-1 prose fallback. - Add VerbOptionsNilCheck() in aaa_verb_options_check.go: progress report of migrated vs. unmigrated verbs, analogous to FLAG_TABLE.NilCheck(). - Wire verb-options-nil-check into mlr help (internal/docgen section). Initial migration (5/70 verbs): - nothing: empty Options (no verb-specific options, explicitly migrated) - cat: -n (bool), -N (string), -g (csv-list), --filename, --filenum (bool) - head: -g (csv-list), -n (int) - tail: -g (csv-list), -n (int) - tee: -a, -p (bool) Tests: - 5 new unit tests in aaa_transformer_json_test.go covering migrated/ unmigrated paths, field population, JSON round-trip, and key-presence. - Regression test case 0003: mlr help verb-options-nil-check golden output. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Migrate all 70 verbs to structured OptionSpec; bump catalog schema to v2 Completes the Tier-2 migration started in the previous commit. Every verb in TRANSFORMER_LOOKUP_TABLE now has a non-nil Options field. - Workflow-migrated all 65 remaining verbs. Each Setup var now carries Options: []OptionSpec{...} with Flag/Arg/Type/Desc fields. Verbs with no verb-specific options (altkv, check, group-like, nothing, etc.) use an empty slice to signal "migrated but no options." - Drop `omitempty` from VerbInfoForJSON.Options: empty slices were silently dropped, making migrated-no-option verbs indistinguishable from unmigrated ones in JSON. Without omitempty: null=unmigrated, []=migrated-no-options, [...]= migrated-with-options. Bump catalogSchemaVersion 1→2 for this shape change. - Replace the two "unmigrated-verb" unit tests (which used stats1 as an example) with TestAllVerbsFullyMigrated (asserts every verb has non-nil Options) and TestAllVerbsHaveOptionsKeyInJSON (asserts every migrated verb emits the "options" key in JSON). - Regenerate test/cases/cli-help/0003/expout: now reads "Verb options migration: 70/70 migrated." Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * Add --errors-json structured error output (#2098) PR 4 of the AI-friendly roadmap (plans/plan-2098-llm.md). Agents previously had to regex-match English prose to branch on error kind; this PR lets them do it structurally. Interface: - mlr --errors-json <bad command> emits a JSON object to stderr and exits 1; same behavior as prose path but machine-readable. - MLR_ERRORS_JSON=1 (truthy) is the env-var equivalent. - Without the flag, prose output is byte-for-byte unchanged. JSON shape: {error, kind, token, verb, hint, did_you_mean[]} Error kinds: unknown-verb, unknown-flag, verb-option-error, generic. Implementation: - New pkg/climain/errors_json.go: CLIError typed error, StructuredError DTO, WantErrorsJSON pre-scan, Levenshtein edit distance, topMatches, EmitStructuredError. - Convert two direct os.Exit sites in parseCommandLinePassOne (unknown verb, unknown flag) to return CLIError values; verb-option-error likewise returns CLIError from pass-one ParseCLIFunc handling. - parseCommandLinePassOne gains an error return; ParseCommandLine propagates it. - entrypoint.go pre-scans os.Args for --errors-json before calling ParseCommandLine; routes errors to EmitStructuredError or printError. - did_you_mean: Levenshtein nearest-match over verb catalog, flag catalog, or the verb own OptionSpec flags (PR3 catalog) for verb-option-error. Closes the self-correction loop the catalog enables. - --errors-json registered in Miscellaneous flags so it appears in mlr --help and is not treated as an unrecognized flag. Tests: 16 unit tests covering Levenshtein, topMatches, threshold, WantErrorsJSON, CLIError interface, and all categorize paths. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
453 lines
15 KiB
Go
453 lines
15 KiB
Go
// Miller main command-line parsing.
|
|
//
|
|
// Before Miller 6 the ordering was:
|
|
// * mlr
|
|
// * main flags like --icsv --ojson
|
|
// * verbs and their flags like cat -n
|
|
// * data-file names
|
|
// and the command-line parser was one-pass.
|
|
//
|
|
// In Miller 6 we have as keystroke-reducers 'mlr -s', for '#!mlr -s',
|
|
// or simply better support for mlr inside of '#!/bin/sh' scripts:
|
|
//
|
|
// mlr {flags} {verbs} -- [more flags] [more verbs] {data file names}
|
|
// [the part inside a script file] [the part outside]
|
|
//
|
|
// For example, suppose someone wants to reuse the following:
|
|
// mlr --icsv --json head -n 10
|
|
// either via a #!mlr -s script, maybe "peek.mlr":
|
|
// #!/usr/bin/env mlr -s
|
|
// --icsv --json head -n 10
|
|
// or a #!/bin/bash script, maybe "peek.sh"
|
|
// #!/bin/bash
|
|
// mlr --icsv --json head -n 10 -- "$@"
|
|
// Then they can do 'peek.mlr myfile.csv' or 'peek.sh myfile.csv' which is great.
|
|
//
|
|
// But suppose they want to do
|
|
// peek.sh --jlistwrap myfile.csv
|
|
// Then the Miller command line received here is
|
|
// mlr --icsv --json head -n 10 -- --jlistwrap myfile.csv
|
|
// Or, maybe their part inside the '#!mlr' or '#!/bin/sh' file is all verbs,
|
|
// and they want to specify format-flags like '--icsv --ojson' outside of that
|
|
// script. It's very reasonable for them to want to put the --jlistwrap,
|
|
// --icsv, --ojson, etc. after their keystroke-saver script. But this now means
|
|
// that there can be main-flags (and/or 'then someotherverb') *after* the verb
|
|
// chain from inside the keystroke-saver.
|
|
//
|
|
// Also, verbs/transformers must be constructed *after* all main-flags are
|
|
// parsed -- since some of them depend on main-flags, e.g. join, put/filter,
|
|
// and tee which use things like --csv for their I/O options.
|
|
//
|
|
// Therefore the command-line parsing is now two-pass.
|
|
// * Pass 1:
|
|
// o 'mlr' is first
|
|
// o Split the []args into "sequences" of main-flags, verbs and their flags,
|
|
// and data-file names.
|
|
// o For example in the above 'mlr --icsv --json head -n 10 -- --jlistwrap myfile.csv'
|
|
// we have
|
|
// main-flag sequences ['--icsv'] ['--json'] [--jlistwrap],
|
|
// verb-seqeunce ['head' '-n' '10']
|
|
// data-file names ['myfile.csv'].
|
|
// o Any exiting flags like --version or --help are dispatched here.
|
|
// o To do that splitting we invoke the flag-table parser with throwaway options struct,
|
|
// and we invoke the transformers' ParseCLI functions with doConstruct = false.
|
|
// * Pass 2:
|
|
// o Process the flag-sequences in the order they were encountered, into a
|
|
// for-real-use options struct.
|
|
// o Process the verb-sequences in the order they were encountered, and construct
|
|
// transformers.
|
|
// o Some jargon from programming languages we can use here for illustration
|
|
// is that we are "hoisting" the main-flags as if they had been written on
|
|
// the command line before the verbs.
|
|
//
|
|
// We need to require a '--' between a verb and a main-flag so the main-flag
|
|
// doesn't look like a verb flag. For example, in 'mlr head -n 10 --csv
|
|
// foo.csv' the '--csv' looks like it belongs to the 'head' verb. When people
|
|
// use '#!/bin/sh' scripts they need to insert the '--' in 'mlr head -n 10 --
|
|
// --csv foo.csv'; for 'mlr -s' we insert the '--' for them.
|
|
|
|
package climain
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/cli"
|
|
"github.com/johnkerl/miller/v6/pkg/lib"
|
|
"github.com/johnkerl/miller/v6/pkg/mlrval"
|
|
"github.com/johnkerl/miller/v6/pkg/terminals"
|
|
"github.com/johnkerl/miller/v6/pkg/terminals/help"
|
|
"github.com/johnkerl/miller/v6/pkg/terminals/registry"
|
|
"github.com/johnkerl/miller/v6/pkg/transformers"
|
|
"github.com/johnkerl/miller/v6/pkg/version"
|
|
)
|
|
|
|
// ParseCommandLine is the entrypoint for handling the Miller command line:
|
|
// flags, verbs and their flags, and input file name(s).
|
|
func ParseCommandLine(
|
|
args []string,
|
|
) (
|
|
options *cli.TOptions,
|
|
recordTransformers []transformers.RecordTransformer,
|
|
err error,
|
|
) {
|
|
// mlr -s scriptfile {data-file names ...} means take the contents of
|
|
// scriptfile as if it were command-line items.
|
|
|
|
args, err = maybeInterpolateDashS(args)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Expand "-xyz" into "-x -y -z" while leaving "--xyz" intact. This is a
|
|
// keystroke-saver for the user.
|
|
//
|
|
// This is OK to do globally here since Miller is quite consistent (in
|
|
// main, verbs, and auxents) that multi-character options start with two
|
|
// dashes, e.g. "--csv". (The sole exception is the sort verb's -nf/-nr
|
|
// which are handled specially there.)
|
|
args = lib.Getoptify(args)
|
|
|
|
// Pass one as described at the top of this file.
|
|
flagSequences, terminalSequence, verbSequences, dataFileNames, passOneErr := parseCommandLinePassOne(args)
|
|
if passOneErr != nil {
|
|
return nil, nil, passOneErr
|
|
}
|
|
|
|
// Pass two as described at the top of this file.
|
|
return parseCommandLinePassTwo(flagSequences, terminalSequence, verbSequences, dataFileNames)
|
|
}
|
|
|
|
// parseCommandLinePassOne is as described at the top of this file.
|
|
func parseCommandLinePassOne(
|
|
args []string,
|
|
) (
|
|
flagSequences [][]string,
|
|
terminalSequence []string,
|
|
verbSequences [][]string,
|
|
dataFileNames []string,
|
|
parseErr error,
|
|
) {
|
|
flagSequences = [][]string{}
|
|
terminalSequence = nil
|
|
verbSequences = [][]string{}
|
|
dataFileNames = []string{}
|
|
|
|
// All verbs after the first must be preceded with "then"
|
|
onFirst := true
|
|
|
|
// Throwaway options as described above: passed into the flag-table parser
|
|
// but we'll use for-real-use options in pass two.
|
|
options := cli.DefaultOptions()
|
|
|
|
argi := 1
|
|
argc := len(args)
|
|
|
|
for argi < argc /* variable increment within loop body */ {
|
|
|
|
// Old argi is at start of sequence; argi will be after.
|
|
oargi := argi
|
|
|
|
if args[argi][0] == '-' {
|
|
if args[argi] == registry.VersionFlag {
|
|
// Exiting flag: handle it immediately.
|
|
fmt.Printf("mlr %s\n", version.STRING)
|
|
os.Exit(0)
|
|
} else if args[argi] == registry.BareVersionFlag {
|
|
// Exiting flag: handle it immediately.
|
|
fmt.Printf("%s\n", version.STRING)
|
|
os.Exit(0)
|
|
} else if help.ParseTerminalUsage(args[argi]) {
|
|
// Exiting flag: handle it immediately.
|
|
// Most help is in the 'mlr help' terminal but there are a few
|
|
// shorthands like 'mlr -h' and 'mlr -F'.
|
|
os.Exit(0)
|
|
|
|
} else if args[argi] == "--norc" {
|
|
argi += 1
|
|
flagSequences = append(flagSequences, args[oargi:argi])
|
|
|
|
} else if cli.FLAG_TABLE.Parse(args, argc, &argi, options) {
|
|
flagSequences = append(flagSequences, args[oargi:argi])
|
|
|
|
} else if args[argi] == "--" {
|
|
// This separates a main-flag from the verb/verb-flags before it
|
|
argi += 1
|
|
|
|
} else {
|
|
// Unrecognized main-flag: return a structured error so the
|
|
// caller can emit JSON or prose depending on --errors-json.
|
|
parseErr = &CLIError{
|
|
Kind: "unknown-flag",
|
|
Token: args[argi],
|
|
Msg: fmt.Sprintf("mlr: option \"%s\" not recognized. Please run \"mlr --help\" for usage information.", args[argi]),
|
|
}
|
|
return
|
|
}
|
|
|
|
} else if onFirst && terminals.Dispatchable(args[argi]) {
|
|
// mlr help, mlr regtest, etc -- _everything_ on the command line after this
|
|
// will be handled by that terminal
|
|
terminalSequence = args[argi:]
|
|
break
|
|
|
|
} else if onFirst || args[argi] == "then" || args[argi] == "+" {
|
|
// The first verb in the then-chain can *optionally* be preceded by
|
|
// 'then'. The others one *must* be.
|
|
if args[argi] == "then" || args[argi] == "+" {
|
|
cli.CheckArgCount(args, argi, argc, 1)
|
|
oargi++
|
|
argi++
|
|
}
|
|
if argi >= argc {
|
|
fmt.Fprintln(os.Stderr, "mlr: 'then' must have a verb after it.")
|
|
os.Exit(1)
|
|
}
|
|
verb := args[argi]
|
|
onFirst = false
|
|
|
|
transformerSetup := transformers.LookUp(verb)
|
|
if transformerSetup == nil {
|
|
parseErr = &CLIError{
|
|
Kind: "unknown-verb",
|
|
Token: verb,
|
|
Msg: fmt.Sprintf("mlr: verb \"%s\" not found. Please use \"mlr -l\" for a list.", verb),
|
|
}
|
|
return
|
|
}
|
|
|
|
// ParseCLIFunc returns (nil, nil) on pass-one success, (nil, err) on failure.
|
|
transformer, err := transformerSetup.ParseCLIFunc(
|
|
&argi,
|
|
argc,
|
|
args,
|
|
options,
|
|
false, // false for first pass of CLI-parse, true for second pass -- this is the first pass
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, cli.ErrHelpRequested) {
|
|
os.Exit(0)
|
|
}
|
|
if errors.Is(err, cli.ErrUsagePrinted) {
|
|
os.Exit(1)
|
|
}
|
|
// Return a structured error so the caller can emit JSON or
|
|
// prose depending on --errors-json.
|
|
parseErr = &CLIError{
|
|
Kind: "verb-option-error",
|
|
Token: extractTokenFromVerbError(err.Error()),
|
|
Verb: verb,
|
|
Msg: err.Error(), // already "mlr {verb}: ..."
|
|
}
|
|
return
|
|
}
|
|
// For pass one we want the verbs to identify the arg-sequences
|
|
// they own within the command line, but not construct
|
|
// transformers.
|
|
lib.InternalCodingErrorIf(transformer != nil)
|
|
|
|
verbSequences = append(verbSequences, args[oargi:argi])
|
|
|
|
} else {
|
|
// After main-flag sequences and verb sequences, data-file names
|
|
// still come last on the command line.
|
|
break
|
|
}
|
|
}
|
|
|
|
if terminalSequence == nil {
|
|
|
|
for ; argi < argc; argi++ {
|
|
dataFileNames = append(dataFileNames, args[argi])
|
|
}
|
|
|
|
if len(verbSequences) == 0 {
|
|
// Bare 'mlr' with no flags, no verb, and no files: keep the
|
|
// friendly usage banner. Anything else (e.g. 'mlr --c2j' on a
|
|
// piped stdin) defaults to 'cat'.
|
|
if argc == 1 {
|
|
help.MainUsage(os.Stderr)
|
|
os.Exit(1)
|
|
}
|
|
verbSequences = append(verbSequences, []string{"cat"})
|
|
}
|
|
}
|
|
|
|
return flagSequences, terminalSequence, verbSequences, dataFileNames, nil
|
|
}
|
|
|
|
// parseCommandLinePassTwo is as described at the top of this file.
|
|
func parseCommandLinePassTwo(
|
|
flagSequences [][]string,
|
|
terminalSequence []string,
|
|
verbSequences [][]string,
|
|
dataFileNames []string,
|
|
) (
|
|
options *cli.TOptions,
|
|
recordTransformers []transformers.RecordTransformer,
|
|
err error,
|
|
) {
|
|
// Options take in-code defaults, then overridden by .mlrrc (if any and if
|
|
// desired), then those in turn overridden by command-line flags.
|
|
options = cli.DefaultOptions()
|
|
|
|
// This is important for multi-platform regression testing, wherein default floating-point
|
|
// output format has varying numbers of decimal places between the platform where
|
|
// the expected results were generated, and the platform where the actual values are being
|
|
// computed. For regression-test we OFMT from an environment variable.
|
|
mlr_ofmt := os.Getenv("MLR_OFMT")
|
|
if mlr_ofmt != "" {
|
|
options.WriterOptions.FPOFMT = mlr_ofmt
|
|
}
|
|
|
|
if os.Getenv("MLR_FAIL_ON_DATA_ERROR") != "" {
|
|
options.WriterOptions.FailOnDataError = true
|
|
}
|
|
|
|
recordTransformers = []transformers.RecordTransformer{}
|
|
ignoresInput := false
|
|
|
|
// Load a .mlrrc file unless --norc was a main-flag on the command line.
|
|
loadMlrrc := true
|
|
for _, flagSequence := range flagSequences {
|
|
lib.InternalCodingErrorIf(len(flagSequence) < 1)
|
|
if flagSequence[0] == "--norc" {
|
|
loadMlrrc = false
|
|
break
|
|
}
|
|
}
|
|
if loadMlrrc {
|
|
loadMlrrcOrDie(options)
|
|
}
|
|
|
|
// Process the flag-sequences in order from pass one. We assume all the
|
|
// exiting flags like --help and --version were already processed, so all
|
|
// main-flags making it here to pass two are for the flag-table parser.
|
|
for _, flagSequence := range flagSequences {
|
|
argi := 0
|
|
args := flagSequence
|
|
argc := len(args)
|
|
lib.InternalCodingErrorIf(argc == 0)
|
|
|
|
// Parse the main-flag into the options struct.
|
|
rc := cli.FLAG_TABLE.Parse(args, argc, &argi, options)
|
|
|
|
// Should have been parsed OK in pass one.
|
|
lib.InternalCodingErrorIf(!rc)
|
|
// Make sure we consumed the entire flag sequence as parsed by pass one.
|
|
lib.InternalCodingErrorIf(argi != argc)
|
|
}
|
|
|
|
// Check now to avoid confusing timezone-library behavior later on
|
|
err = lib.SetTZFromEnv()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
err = cli.FinalizeReaderOptions(&options.ReaderOptions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
err = cli.FinalizeWriterOptions(&options.WriterOptions)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Set an optional global formatter for floating-point values
|
|
if options.WriterOptions.FPOFMT != "" {
|
|
err = mlrval.SetFloatOutputFormat(options.WriterOptions.FPOFMT)
|
|
if err != nil {
|
|
return options, recordTransformers, err
|
|
}
|
|
}
|
|
|
|
if terminalSequence != nil {
|
|
terminals.Dispatch(terminalSequence)
|
|
// They are expected to exit the process
|
|
panic("mlr: internal coding error: terminal did not exit the process")
|
|
}
|
|
|
|
// Now process the verb-sequences from pass one, with options-struct set up
|
|
// and finalized.
|
|
for i, verbSequence := range verbSequences {
|
|
argi := 0 // xxx needed?
|
|
args := verbSequence
|
|
argc := len(args)
|
|
lib.InternalCodingErrorIf(argc == 0)
|
|
|
|
// Non-existent verbs should have been fatalled in pass one.
|
|
transformerSetup := transformers.LookUp(args[0])
|
|
lib.InternalCodingErrorIf(transformerSetup == nil)
|
|
|
|
transformer, err := transformerSetup.ParseCLIFunc(
|
|
&argi,
|
|
argc,
|
|
args,
|
|
options,
|
|
true, // false for first pass of CLI-parse, true for second pass -- this is pass two
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, cli.ErrHelpRequested) {
|
|
os.Exit(0)
|
|
}
|
|
if errors.Is(err, cli.ErrUsagePrinted) {
|
|
os.Exit(1)
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
// Unparsable verb-setups should have been found in pass one.
|
|
lib.InternalCodingErrorIf(transformer == nil)
|
|
// Make sure we consumed the entire verb sequence as parsed by pass one.
|
|
lib.InternalCodingErrorIf(argi != argc)
|
|
|
|
// E.g. then-chain begins with seqgen
|
|
if i == 0 && transformerSetup.IgnoresInput {
|
|
ignoresInput = true
|
|
}
|
|
|
|
recordTransformers = append(recordTransformers, transformer)
|
|
}
|
|
|
|
if ignoresInput {
|
|
options.NoInput = true // e.g. then-chain begins with seqgen
|
|
}
|
|
|
|
if cli.DecideFinalFlatten(&options.WriterOptions) {
|
|
// E.g. '{"req": {"method": "GET", "path": "/api/check"}}' becomes
|
|
// req.method=GET,req.path=/api/check.
|
|
transformer, err := transformers.NewTransformerFlatten(options.WriterOptions.FLATSEP, options, nil)
|
|
lib.InternalCodingErrorIf(err != nil)
|
|
lib.InternalCodingErrorIf(transformer == nil)
|
|
recordTransformers = append(recordTransformers, transformer)
|
|
}
|
|
|
|
if cli.DecideFinalUnflatten(options, verbSequences) {
|
|
// E.g. req.method=GET,req.path=/api/check becomes
|
|
// '{"req": {"method": "GET", "path": "/api/check"}}'
|
|
transformer, err := transformers.NewTransformerUnflatten(options.WriterOptions.FLATSEP, options, nil)
|
|
lib.InternalCodingErrorIf(err != nil)
|
|
lib.InternalCodingErrorIf(transformer == nil)
|
|
recordTransformers = append(recordTransformers, transformer)
|
|
}
|
|
|
|
// There may already be one or more because of --from on the command line,
|
|
// so append.
|
|
options.FileNames = append(options.FileNames, dataFileNames...)
|
|
|
|
// E.g. mlr -n put -v '...'
|
|
if options.NoInput {
|
|
options.FileNames = nil
|
|
}
|
|
|
|
if options.DoInPlace && len(options.FileNames) == 0 {
|
|
fmt.Fprintf(os.Stderr, "%s: -I option (in-place operation) requires input files.\n", "mlr")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if options.HaveRandSeed {
|
|
lib.SeedRandom(int64(options.RandSeed))
|
|
}
|
|
|
|
return options, recordTransformers, nil
|
|
}
|