miller/pkg/transformers/sort.go
John Kerl f637633420
Tier-2 structured verb options: OptionSpec, initial migration (#2098) (#2111)
* 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>

* remove a transitional helper

* git rms

* Render verb usage Options blocks from structured OptionSpec

Each verb's usage message and its Tier-2 OptionSpec list previously
duplicated the option text. New WriteVerbOptions (aaa_verb_usage.go)
renders the "Options:" block from the specs: aligned flag column,
descriptions word-wrapped at 80, uniform trailing -h|--help line.

- OptionSpec gains Aliases (JSON "aliases") so long-form spellings
  like join's --lk|--left-keep-field-names survive in both outputs
- All 70 verbs migrated; options literals hoisted to package-level
  vars (usage funcs can't reference their Setup var without a Go
  init cycle)
- Hand-written per-option details the specs had condensed away are
  merged into Desc, enriching the JSON catalog
- Non-option prose (examples, cross-references, dynamic accumulator
  listings) kept verbatim
- Regenerated the six usage-embedding regression expectations and
  the two affected doc pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix pre-existing usage-text bugs surfaced by the OptionSpec migration

- gap: usage said "One of -f or -g is required" but the parser takes
  -n or -g
- seqgen: drop description line copy-pasted from cat ("Passes input
  records directly to output...") which contradicted "Discards the
  input record stream"
- utf8-to-latin1: description read inverted ("from Latin-1 to UTF-8")
- sec2gmtdate: usage said "../c/mlr" instead of "mlr"
- top: document the accepted-but-undocumented --max flag
- stats2: add linreg-pca to the -a enum values, matching the runtime
  accumulator table

Regression expectations and docs regenerated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix check usage sentence order; stats1 usage blank line to usage stream

- check: the description's second and third lines were swapped,
  reading "Consumes records without printing any output, / Useful for
  doing a well-formatted check on input data. / with the exception
  that warnings are printed to stderr."
- stats1: a bare fmt.Println() in the usage func wrote its blank line
  to process stdout instead of the usage output stream

Regression expectation and docs regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-07-03 14:27:23 -04:00

486 lines
16 KiB
Go

// OVERVIEW
//
// * Suppose we are sorting records lexically ascending on field "a" and then
// numerically descending on field "x".
//
// * CLI syntax is "mlr sort -f a -nr x".
//
// * We first consume all input records and for each extract the string values
// of fields a and x. For each uniq combination of a-value (e.g. "red",
// "green", "blue") and x-value (e.g. "1", "1.0", "2.4") -- e.g.
// pairs ["red","1"], and so on -- we keep a linked list of all the records
// having those sort-key values, in the order encountered.
//
// * For each of those unique sort-key-value combinations, we also parse the
// numerical fields at this point into an array of union-string-double.
// E.g. the list ["red", "1.0"] maps to the array ["red", 1.0].
//
// * The pairing of parsed-value array the linked list of same-key-value records
// is called a *bucket* or a *group*. E.g the records
// {"a":"red","b":"circle","x":"1.0","y":"3.9"}
// {"a":"red","b":"square","x":"1.0","z":"5.7", "q":"even"}
// would both land in the ["red","1.0"] group.
//
// * Groups are retained in a hash map: the key is the string-list of the form
// ["red","1.0"] and the value is the pairing of parsed-value array ["red",1.0]
// and linked list of records.
//
// * Once all the input records are ingested into this hash map, we copy the
// group-pointers into an array and sort it: this being the pairing of
// parsed-value array and linked list of records. The comparator callback for
// the sort walks through the parsed-value arrays one slot at a time,
// looking at the first difference, e.g. if one has "a"="red" and the other
// has "a"="blue". If the first field matches then the sort moves to the
// second field, and so on.
//
// * Note in particular that string keys ["a":"red","x":"1"] and
// ["a":"red","x":"1.0"] map to different groups, but will sort equally.
//
package transformers
import (
"fmt"
"os"
"sort"
"strings"
"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/types"
)
const verbNameSort = "sort"
var sortOptions = []OptionSpec{
{Flag: "-f", Arg: "{a,b,c}", Type: "csv-list", Desc: "Lexical ascending sort on the specified field names.", Repeatable: true},
{Flag: "-r", Arg: "{a,b,c}", Type: "csv-list", Desc: "Lexical descending sort on the specified field names.", Repeatable: true},
{Flag: "-c", Arg: "{a,b,c}", Type: "csv-list", Desc: "Case-folded lexical ascending sort on the specified field names.", Repeatable: true},
{Flag: "-cr", Arg: "{a,b,c}", Type: "csv-list", Desc: "Case-folded lexical descending sort on the specified field names.", Repeatable: true},
{Flag: "-n", Arg: "{a,b,c}", Type: "csv-list", Desc: "Numerical ascending sort on the specified field names; nulls sort last.", Repeatable: true},
{Flag: "-nf", Arg: "{a,b,c}", Type: "csv-list", Desc: "Same as -n.", Repeatable: true},
{Flag: "-nr", Arg: "{a,b,c}", Type: "csv-list", Desc: "Numerical descending sort on the specified field names; nulls sort first.", Repeatable: true},
{Flag: "-t", Arg: "{a,b,c}", Type: "csv-list", Desc: "Natural ascending sort on the specified field names.", Repeatable: true},
{Flag: "-b", Type: "bool", Desc: "Move sort fields to start of record, as in reorder -b."},
{Flag: "-tr", Aliases: []string{"-rt"}, Arg: "{a,b,c}", Type: "csv-list", Desc: "Natural descending sort on the specified field names.", Repeatable: true},
}
var SortSetup = TransformerSetup{
Verb: verbNameSort,
UsageFunc: transformerSortUsage,
ParseCLIFunc: transformerSortParseCLI,
IgnoresInput: false,
Options: sortOptions,
}
func transformerSortUsage(
o *os.File,
) {
fmt.Fprintf(o, "Usage: %s %s {flags}\n", "mlr", verbNameSort)
fmt.Fprintf(o, "Sorts records primarily by the first specified field, secondarily by the second\n")
fmt.Fprintf(o, "field, and so on. (Any records not having all specified sort keys will appear\n")
fmt.Fprintf(o, "at the end of the output, in the order they were encountered, regardless of the\n")
fmt.Fprintf(o, "specified sort order.) The sort is stable: records that compare equal will sort\n")
fmt.Fprintf(o, "in the order they were encountered in the input record stream.\n")
fmt.Fprintf(o, "\n")
WriteVerbOptions(o, sortOptions)
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Example:\n")
fmt.Fprintf(o, " %s %s -f a,b -nr x,y,z\n", "mlr", verbNameSort)
fmt.Fprintf(o, "which is the same as:\n")
fmt.Fprintf(o, " %s %s -f a -f b -nr x -nr y -nr z\n", "mlr", verbNameSort)
}
func transformerSortParseCLI(
pargi *int,
argc int,
args []string,
_ *cli.TOptions,
doConstruct bool, // false for first pass of CLI-parse, true for second pass
) (RecordTransformer, error) {
// Skip the verb name from the current spot in the mlr command line
argi := *pargi
verb := args[argi]
argi++
groupByFieldNames := []string{}
comparatorFuncs := []mlrval.CmpFuncInt{}
doMoveToHead := false
for argi < argc /* variable increment: 1 or 2 depending on flag */ {
opt := args[argi]
if !strings.HasPrefix(opt, "-") {
break // No more flag options to process
}
if args[argi] == "--" {
break // All transformers must do this so main-flags can follow verb-flags
}
argi++
switch opt {
case "-h", "--help":
transformerSortUsage(os.Stdout)
return nil, cli.ErrHelpRequested
case "-f":
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.LexicalAscendingComparator)
}
case "-c":
// See comments over "-n" -- similar hack.
if args[argi] == "-r" {
// Treat like "-cr"
if err := cli.VerbCheckArgCount(verb, args[argi], args, argi, argc, 1); err != nil {
return nil, err
}
argi++
subList, err := cli.VerbGetStringArrayArg(verb, "-nr", args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.CaseFoldDescendingComparator)
}
} else {
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.CaseFoldAscendingComparator)
}
}
case "-t":
// See comments over "-n" -- similar hack.
if err := cli.VerbCheckArgCount(verb, opt, args, argi, argc, 1); err != nil {
return nil, err
}
if args[argi] == "-r" {
// Treat like "-tr"
if err := cli.VerbCheckArgCount(verb, args[argi], args, argi, argc, 1); err != nil {
return nil, err
}
argi++
subList, err := cli.VerbGetStringArrayArg(verb, "-tr", args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NaturalAscendingComparator)
}
} else {
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NaturalDescendingComparator)
}
}
case "-r":
// See comments over "-n" -- similar hack.
if err := cli.VerbCheckArgCount(verb, opt, args, argi, argc, 1); err != nil {
return nil, err
}
if args[argi] == "-t" {
// Treat like "-rt" which is same as "-tr"
if err := cli.VerbCheckArgCount(verb, args[argi], args, argi, argc, 1); err != nil {
return nil, err
}
argi++
subList, err := cli.VerbGetStringArrayArg(verb, "-tr", args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NaturalAscendingComparator)
}
} else {
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.LexicalDescendingComparator)
}
}
case "-n":
// This is a bit of a hack.
//
// As of Miller 6 we have a getoptish feature wherein "-xyz" is
// expanded to "-x -y -z" while "--xyz" is left intact. This is OK
// to do globally (before any verb such as this one sees the
// command line) since Miller is quite consistent (in main, verbs,
// auxents, and terminals) that multi-character options start with
// two dashes, e.g. "--csv" ...
//
// ... with the sole exception being -nf/-nr, right here. This goes
// back to the very start of Miller, and we don't want to break the
// command-line interface to sort.
//
// Before Miller 6, opt and next arg would have been "-nf x,y,z" or
// "-nr x,y,z". Now they're split into "-n -f x,y,z" or "-n -r
// x,y,z", respectively. Note that "-n x,y,z" and "-f x,y,z" and
// "-r x,y,z" are also valid. This means -n needs a field-name list
// after it unless it's followed immediately by -r or -f.
//
// So here we special-case this: if "-n" is followed immediately by
// "-f", we treat it the same as "-nf". Likewise, "-n" followed by
// "-r" is treated like "-nr".
if err := cli.VerbCheckArgCount(verb, opt, args, argi, argc, 1); err != nil {
return nil, err
}
switch args[argi] {
case "-f":
// Treat like "-nf"
argi++
subList, err := cli.VerbGetStringArrayArg(verb, "-nf", args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NumericAscendingComparator)
}
case "-r":
// Treat like "-nr"
argi++
subList, err := cli.VerbGetStringArrayArg(verb, "-nr", args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NumericDescendingComparator)
}
default:
// Treat like "-n"
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NumericAscendingComparator)
}
}
case "-nf":
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NumericAscendingComparator)
}
case "-nr":
subList, err := cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
for _, item := range subList {
groupByFieldNames = append(groupByFieldNames, item)
comparatorFuncs = append(comparatorFuncs, mlrval.NumericDescendingComparator)
}
case "-b":
doMoveToHead = true
default:
return nil, cli.VerbErrorf(verb, "option \"%s\" not recognized", opt)
}
}
if len(groupByFieldNames) == 0 {
return nil, cli.VerbErrorf(verb, "-f sort fields required")
}
*pargi = argi
if !doConstruct { // All transformers must do this for main command-line parsing
return nil, nil
}
transformer, err := NewTransformerSort(
groupByFieldNames,
comparatorFuncs,
doMoveToHead,
)
if err != nil {
return nil, err
}
return transformer, nil
}
// Example:
// * mlr sort -f a -n i
// * group-by field-name list is "a,i"
// * input record 'a=pan,b=pan,i=1,x=0.3467,y=0.7268'
// o values at a,i are "pan",1
// o grouping key for the ordered map from string to record-group is the string "pan,1"
// o we also need a map from "pan,1" to the array of mlrvals ["pan", 1].
// * next input record 'a=eks,b=pan,i=2,x=0.7586,y=0.5221'
// o values at a,i are "eks",2
// o grouping key for the ordered map from string to record-group is the string "eks,2"
// o we also need a map from "eks,2" to the array of mlrvals ["eks", 2].
// * what gets sorted are the group-heading arrays of mlrvals:
// o make an array [ ("pan,1", ["pan", 1]), ("eks,2", ["eks", 2])
// o sort that
// * output is simply for each slot in the array, emit each record in the group
type TransformerSort struct {
// -- Input
groupByFieldNames []string
comparatorFuncs []mlrval.CmpFuncInt
doMoveToHead bool
// -- State
// Map from string to record slices:
recordListsByGroup *lib.OrderedMap[*[]*types.RecordAndContext]
// Map from string to []*lib.Mlrval:
groupHeads *lib.OrderedMap[[]*mlrval.Mlrval]
spillGroup []*types.RecordAndContext // e.g. sort by field "a" -- this is for records lacking a field named "a"
}
func NewTransformerSort(
groupByFieldNames []string,
comparatorFuncs []mlrval.CmpFuncInt,
doMoveToHead bool,
) (*TransformerSort, error) {
tr := &TransformerSort{
groupByFieldNames: groupByFieldNames,
comparatorFuncs: comparatorFuncs,
doMoveToHead: doMoveToHead,
recordListsByGroup: lib.NewOrderedMap[*[]*types.RecordAndContext](),
groupHeads: lib.NewOrderedMap[[]*mlrval.Mlrval](),
spillGroup: []*types.RecordAndContext{},
}
return tr, nil
}
type GroupingKeysAndMlrvals struct {
groupingKey string
mlrvals []*mlrval.Mlrval
}
func (tr *TransformerSort) Transform(
inrecAndContext *types.RecordAndContext,
outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
inputDownstreamDoneChannel <-chan bool,
outputDownstreamDoneChannel chan<- bool,
) {
HandleDefaultDownstreamDone(inputDownstreamDoneChannel, outputDownstreamDoneChannel)
if !inrecAndContext.EndOfStream {
inrec := inrecAndContext.Record
if tr.doMoveToHead {
n := len(tr.groupByFieldNames)
for i := n - 1; i >= 0; i-- {
inrec.MoveToHead(tr.groupByFieldNames[i])
}
}
groupingKey, selectedValues, ok := inrec.GetSelectedValuesAndJoined(
tr.groupByFieldNames,
)
if !ok {
tr.spillGroup = append(tr.spillGroup, inrecAndContext)
return
}
recordListForGroup := tr.recordListsByGroup.Get(groupingKey)
if recordListForGroup == nil {
records := []*types.RecordAndContext{}
recordListForGroup = &records
tr.recordListsByGroup.Put(groupingKey, recordListForGroup)
tr.groupHeads.Put(groupingKey, selectedValues)
}
*recordListForGroup = append(*recordListForGroup, inrecAndContext)
} else { // End of record stream
// At this point, in the above example, groupHeads is:
//
// {
// "pan,1" : ["pan", 1],
// "eks,2" : ["eks", 2]
// }
//
// We need to make an array like
//
// [
// [ "pan,1", ["pan', 1],
// [ "eks,2", ["eks', 2]
// ]
groupingKeysAndMlrvals := groupHeadsToArray(tr.groupHeads)
// Go sort API: for ascending sort, return true if element i < element j.
sort.Slice(groupingKeysAndMlrvals, func(i, j int) bool {
for k, comparator := range tr.comparatorFuncs {
result := comparator(
groupingKeysAndMlrvals[i].mlrvals[k],
groupingKeysAndMlrvals[j].mlrvals[k],
)
if result < 0 {
return true
} else if result > 0 {
return false
}
}
return false
})
// Now output the groups
for _, groupingKeyAndMlrvals := range groupingKeysAndMlrvals {
recordsInGroup := tr.recordListsByGroup.Get(groupingKeyAndMlrvals.groupingKey)
*outputRecordsAndContexts = append(*outputRecordsAndContexts, *recordsInGroup...)
}
*outputRecordsAndContexts = append(*outputRecordsAndContexts, tr.spillGroup...)
*outputRecordsAndContexts = append(*outputRecordsAndContexts, inrecAndContext) // end-of-stream marker
}
}
func groupHeadsToArray(groupHeads *lib.OrderedMap[[]*mlrval.Mlrval]) []GroupingKeysAndMlrvals {
retval := make([]GroupingKeysAndMlrvals, groupHeads.FieldCount)
i := 0
for entry := groupHeads.Head; entry != nil; entry = entry.Next {
retval[i] = GroupingKeysAndMlrvals{
groupingKey: entry.Key,
mlrvals: entry.Value,
}
i++
}
return retval
}