18 KiB
Plan: auto-infer input format from file extension
Feature request: issue #1188 — given
mlr ... mydata.csv, default to --icsv without the user typing it; likewise .tsv →
--itsv, etc.
This is at heart a refactor of pkg/stream/stream.go and the record-reader layer, not a
CLI-flag tweak. Today a single record-reader is constructed once and handed the full list
of file names; with this feature, the reader (and its format-dependent option defaults)
must be chosen per input file.
Scope: input side only. Output-format inference is discussed under Open Questions but is recommended out of scope for v1, with one carve-out for in-place mode (see complication C9, which is a data-loss footgun if ignored).
Current architecture (survey)
Where readers are constructed — one per process, not per file:
pkg/stream/stream.go:51—input.Create(&options.ReaderOptions, ...)once, thengo recordReader.Read(fileNames, ...)atstream.go:88.pkg/input/record_reader_factory.go:9—input.Createswitches onreaderOptions.InputFileFormat(csv, csvlite, dkvp, dkvpx, json, yaml, nidx, markdown, pprint, tsv, xtab, dcf, recutils, gen).- Other
input.Createcall sites, each needing its own treatment:pkg/transformers/join.go:510andpkg/transformers/utils/join_bucket_keeper.go:163— the join verb's left file, with its ownjoinFlagOptions.ReaderOptions.pkg/terminals/repl/session.go:49— REPL.pkg/terminals/script/runner.go:24—mlr scriptrunner.
How readers consume files — each reader owns the whole file loop:
IRecordReader.Read(filenames []string, initialContext types.Context, ...)(pkg/input/record_reader.go:14) takes the list; every concrete reader duplicates the same boilerplate:filenames == nil→ no input (mlr -n);len == 0→ stdin vialib.OpenStdin; else loop withlib.OpenFileForRead, calling a per-fileprocessHandle(handle, filename, &context, ...)that resets per-file reader state and callscontext.UpdateForStartOfFile(see e.g.pkg/input/record_reader_csv.go:54-108). EachReadsends exactly one end-of-stream marker after all files.- AWK-ish variables:
types.Context.UpdateForStartOfFile(pkg/types/context.go:132) increments FILENUM and resets FNR; NR accumulates across files. This continuity must survive the move to per-file readers. downstreamDoneChannel(mlr head fast-exit): readers poll it per batch (record_reader_csv.go:171,line_reader.go:202). The signal is a one-shot buffered send; whichever per-file scanner consumes it stops, and the file loop must remember done-ness so it doesn't open remaining files. (Today the loop just proceeds to the next file, whose scanner will never see the already-consumed signal — a latent inefficiency the refactor should fix, not replicate.)
Where format-dependent defaults are applied — once, at CLI-parse time:
cli.FinalizeReaderOptions(pkg/cli/option_parse.go:31) fills in IFS/IPS/IRS and AllowRepeatIFS defaults keyed byInputFileFormat(with a special NIDX whitespace-regex case), and un-hexes/un-backslashes the separator strings by mutating the one sharedTReaderOptions. Per-file formats mean per-format finalization; the un-escaping steps must not be re-applied to already-processed values.- Reader constructors also validate format-specific constraints at construction time
(e.g.
NewRecordReaderCSVatrecord_reader_csv.go:34-43: single-char IFS, IRS restrictions, comment-string length) and cache derived state (ifs0).
File opening / compression:
lib.OpenFileForRead(pkg/lib/file_readers.go:49): prepipe → popen; else encoding flag; else suffix-sniffed decompression for.gz,.bz2,.zst(openEncodedHandleForRead).lib.PathToHandlealso supportshttp://,https://,file://URLs.- In-place mode (
pkg/entrypoint/entrypoint.go:127-207) already runs one fullStream()per file, re-parsing the command line each time, and infers input compression from the file name (lib.FindInputEncoding).
Proposed design
1. Extension→format inference (pure, name-based — no content sniffing)
New function, e.g. input.InferFormatFromFileName(path string) (format string, ok bool):
-
If the path is a URL (
http://,https://,file://), strip scheme and any?query/#fragmentbefore looking at the suffix. -
Strip one trailing compression suffix (
.gz,.bz2,.zst) — mirrorsopenEncodedHandleForRead— sodata.csv.gzinfers csv. -
Map the remaining extension, case-insensitively:
extension format .csvcsv.tsvtsv.json,.jsonl,.ndjsonjson(the JSON reader already handles both).yaml,.ymlyaml.md,.markdownmarkdown.dkvpdkvp.nidxnidx.xtabxtab.pprintpprint.recrecutils.dcfdcfDeliberately unmapped:
.txt,.dat,.log, and anything else ambiguous — those returnok=falseand take the fallback (below)..csvmaps to fullcsv, notcsvlite(users who want csvlite say so explicitly).
Because inference is name-based only, all per-file formats can be resolved up front, before any goroutine starts — so option-validation errors surface before any output is produced, not mid-stream after file 3 of 7.
2. Policy: stdin and fallback
- stdin: no inference (per maintainer).
len(filenames)==0uses the fallback format. - Fallback for stdin and unmapped extensions: the default format,
dkvp. (Open question Q3 discusses erroring instead.) mlr -n(nil filenames): unaffected.
3. CLI surface: opt-in flag, not a default flip
- Accept
autoas anInputFileFormatvalue:-i auto, plus a dedicated--iautoflag inFileFormatFlagSection(pkg/cli/option_parse.go:844). Last-one-wins with other format flags, per existing CLI semantics:--icsv --iautomeans auto. .mlrrcgives users a "make it my default" path (iautoon a line by itself), since .mlrrc lines are flags without leading dashes. This is the adoption path in lieu of changing the built-in default, which would silently break existing scripts that rely onmlr cat foo.csvparsing as DKVP (see Q1).-o auto/--io autoare errors in v1 (output can't be name-inferred; it usually goes to stdout). Revisit under Q2.
4. The stream.go / record-reader refactor
Two-layer split — this is the bulk of the work, and is a worthwhile cleanup even independent of the feature (it deletes ~13 copies of the same stdin/loop/open boilerplate):
-
Per-file readers: each concrete reader keeps its constructor and its
processHandle-shaped method; theRead(filenames, ...)file loop is deleted from all of them. New narrower interface, roughly:type IFileRecordReader interface { ProcessHandle(handle io.Reader, filename string, context *types.Context, readerChannel chan<- []*types.RecordAndContext, errorChannel chan error, downstreamDoneChannel <-chan bool) }(Most readers already have exactly this method; the change is mostly mechanical. Signature detail to settle during implementation: some readers need to report "downstream done" back to the caller so the driver stops opening further files — probably a
boolreturn.) -
Driver: one new
FileStreamReaderinpkg/inputimplementing the existingIRecordReaderinterface, owning: the nil/stdin/file-list branching, per-fileOpenFileForRead/OpenStdin+ close,context.UpdateForStartOfFile, the downstream-done latch across files, and the single end-of-stream marker.It is constructed with a pre-resolved plan:
[]struct{ fileName string; reader IFileRecordReader }(plus a stdin entry when applicable). In non-auto mode that's the same reader for every file — behavior identical to today. In auto mode it's built by running inference per file at setup time. -
genpseudo-reader (pseudo_reader_gen.go) reads no files; it keeps implementingIRecordReaderdirectly and bypasses the driver. -
Factory:
input.Creategrows a companion, e.g.input.CreateForFileNames(readerOptions, recordsPerBatch, fileNames) (IRecordReader, error), which handles the auto/non-auto/gen dispatch and becomes whatstream.go, join, REPL, and script-runner call.
5. Per-format reader options
Refactor FinalizeReaderOptions so format-dependent defaulting is a pure derivation
rather than a one-shot mutation:
- Split into (a) a once-only un-escaping step on user-supplied separator strings
(unhex/unbackslash — must not run twice), done at CLI-parse time as now; and (b)
deriveReaderOptionsForFormat(base *TReaderOptions, format string) (*TReaderOptions, error)which returns a copy with IFS/IPS/IRS/AllowRepeatIFS defaults (and the NIDX whitespace-regex special case) applied for that format, honoring the*WasSpecifiedbooleans so explicit--ifsetc. still win for every inferred format. - In non-auto mode, (b) is called once with the single format — same net behavior as
today. In auto mode, called once per distinct inferred format (cache in a
map[string]*TReaderOptions), then one concrete reader constructed per distinct format (readers already reset per-file state inprocessHandle, so sharing one reader across same-format files is safe and matches current cross-file behavior). - When
InputFileFormat == "auto"reaches finalize-time, skip the format-keyed lookups (they'd fail on thedefaultFSesmap) — defaults are applied per derived format instead.
Complications inventory
Ones already flagged by the maintainer:
- C1 — one reader for all files → per-file (per-format) construction; addressed by §4.
- C2 — stdin → no inference, fallback format; addressed by §2.
Additional ones surfaced by this survey:
- C3 — format-dependent option defaults are baked in at CLI-parse time.
IFS/IPS/IRS/AllowRepeatIFS defaults differ per format and are applied by mutating the
single shared
TReaderOptions(option_parse.go:31-84).mlr --iauto cat a.csv b.nidxneeds comma-IFS for one file and whitespace-regex-IFS for the other. Needs the derive-per-format refactor (§5), including not double-applying unhex/unbackslash. - C4 — constructor-time validation and cached state. Reader constructors validate
and cache options (
record_reader_csv.go:34-51). Mitigation: resolve formats and construct all readers eagerly at stream setup (possible because inference is name-based), somlr --iauto --ifs ';;' cat a.csvfails before any records flow. - C5 — AWK-variable continuity across heterogeneous readers. FILENUM/NR must keep
accumulating when consecutive files use different readers. Solved by the driver owning
one
types.Contextand passing it into each per-file read; also exactly one end-of-stream marker, sent by the driver. - C6 —
downstreamDoneChannelis a one-shot signal. Once a per-file scanner consumes it, later files can't see it. The driver must latch done-ness (via the per-file return, C4's interface note) and stop opening subsequent files. Today's per-reader loops appear to keep reading subsequent files afterheadis satisfied — the refactor should fix this, and it's worth a regression test (mlr head -n 1 big1.csv big2.csvshould not read big2 to completion). - C7 — compressed and URL inputs.
data.csv.gzmust infer csv (strip compression suffix, mirroringopenEncodedHandleForRead); URLs need scheme/query stripping before suffix inspection.--prepipe 'unzip -qc' foo.zip→.zipunmapped → fallback (fine; prepipe users can state the format explicitly). - C8 — the join verb reads its own file.
join -f left.csvviaingestLeftFile(join.go:506) and the half-streamingjoin_bucket_keeper.go:163usejoinFlagOptions.ReaderOptions, which inherit main reader options unless overridden by join's own-i. If the inherited/derived format isauto, these paths must run the same resolve-then-create helper (trivial: single known file name, resolvable up front). Without this,-i autoat main level would hitinput.Create'sdefault:error ("input file format "auto" not found") inside join. - C9 — in-place mode (
mlr -I) is a data-loss footgun with auto.-Iwrites output back over the input file using the output format.mlr -I --iauto put ... foo.csvwith default DKVP output would silently rewrite a CSV file as DKVP. v1 must do one of: (a) error on-I+ auto unless an explicit output format is given, or (b) per file, set the writer format to the inferred reader format when no explicit-owas given — natural sinceprocessFileInPlacealready re-parses options per file (entrypoint.go:161). Recommend (b); (a) is an acceptable stopgap. Either way this must not ship as "whatever falls out". - C10 — REPL and
mlr script.repl/session.go:49constructs its reader at session start, before any:open file— with auto, resolution has to happen per opened file. Simplest v1: REPL/script reject or ignoreautowith a clear message, or resolve at:opentime using the same helper. Decide during implementation; don't leave it crashing on the factorydefault:case. - C11 — mixed formats in one run are now easy to trigger. Heterogeneous records are
native to Miller, so
mlr --iauto cat a.csv b.json"just works", but users will see format-specific side effects (e.g. CSV output emitting new header blocks on schema change). Docs should show a mixed-format example. Also note format-specific input flags (--allow-ragged-csv-input,--csv-trim-leading-space, implicit-header, etc.) apply whenever the inferred format is csv/tsv — harmless for other formats, worth a doc sentence. - C12 — surprise on the output side.
mlr --iauto cat data.csvprints DKVP to stdout. Harmless (visible immediately) but guaranteed to generate "it doesn't work" reports from the very users #1188 is for. See Q2. - C13 — factory/validation error paths.
"auto"must be handled everywhereInputFileFormatis switch/map-keyed:input.Createdefault case,FinalizeReaderOptionsmap lookups,pkg/cli/flatten_unflatten.go:99(auto-flatten decides based on input format — with auto, the per-format derived options must carry the resolved format so flatten/unflatten heuristics seecsv, notauto; audit otherInputFileFormatconsumers withgrep -rn InputFileFormat pkg/). - C14 — the
dkvpx/fixed-width/barred-pprint variants. Inference only ever selects canonical formats; variant selection (BarredPprintInput,FixedWidthSpec,dkvpx) stays explicit-flag-only. No.pprint-barred inference. - C15 — case/AV edge cases in names. Uppercase extensions (
.CSV), files with no extension, dotfiles (.csvas an entire filename — treat as extensionless), a literal-filename (Miller doesn't special-case it today; keep it that way), Windows path separators. All belong in the inference unit tests.
Phased implementation
Phases are separately mergeable, each leaving make check green.
- Reader-loop extraction (pure refactor, no behavior change).
Introduce
IFileRecordReader+FileStreamReaderdriver; delete the per-reader file loops; keepinput.Createsignature; fix the C6 done-latch as part of the driver. This is the risky/bulky phase — all 13 readers touched mechanically. Regression suite is the safety net; add the C6 test here. - Inference function + options derivation.
InferFormatFromFileNamewith unit tests (C7, C15 cases);deriveReaderOptionsForFormatrefactor ofFinalizeReaderOptionswith unit tests proving explicit--ifs/--ips/--irsstill override per derived format. - Wire up
-i auto/--iautofor the main stream. Flag-table entry,"auto"handling at all C13 sites, eager per-file resolution in stream setup, fallback policy (§2). Regression cases: per-extension inference, mixed formats,.csv.gz, unmapped extension → dkvp, stdin → dkvp, explicit separator overrides under auto. - Secondary consumers: join, in-place, REPL/script. C8 (join left file), C9 (in-place policy — implement (b) or (a)), C10 (REPL/script). Regression cases for each, including the C9 "must not rewrite csv as dkvp" case.
- Docs.
docs/src/file-formats.md.insection on auto-inference (extension table, stdin/ fallback rules, .mlrrc adoption tip, mixed-format example); flag help text (feeds the auto-generatedreference-main-flag-list);mlr helptopics; man page regen viamake dev.
Open questions for the maintainer
- Q1 — default-on vs opt-in. #1188 asks for this as default behavior. That flips
parsing of
mlr cat foo.csvfrom DKVP to CSV — behavior-breaking for scripts (however few) that depend on it. Recommendation: ship opt-in (--iauto, .mlrrc-able) now; consider flipping the default in a major release after the machinery has soaked. - Q2 — output-side inference. Options: (a) none (v1 recommendation, minus the C9
in-place carve-out); (b)
--autoconvenience flag = infer input per file and, when no explicit output format was given and all inputs infer to a single common format, use that for output too (resolvable up front since inference is name-based; must define behavior for mixed inputs — probably fall back to dkvp or error). (b) is what #1188-style users likely actually want day-to-day; fine as a fast-follow. - Q3 — unmapped extension under auto: fallback to dkvp, or error? Fallback is
forgiving and matches the stdin story; erroring is more predictable ("you asked for
auto and I can't tell what
.datis"). Recommendation: fallback + document; a strict variant can come later if requested. - Q4 — reuse one reader per distinct format vs one per file. Plan assumes per-format reuse (matches today's cross-file behavior exactly, since readers reset state per file). Per-file construction is marginally simpler to reason about but re-runs validation redundantly. Low stakes either way; decide in phase 3.