Add DSL validate/dry-run: put/filter --explain (#2098 PR5) (#2131)

Lets an agent type-check a DSL expression before spending a full input
pass. `mlr put --explain '...'` (and filter) runs the existing
parse -> ValidateAST -> CST build -> Resolve path, then:

- valid: prints "mlr {put,filter}: DSL expression is valid." and exits 0
- invalid: returns the build error up the normal path, so --errors-json
  emits a structured document; exits 1
- -W with fatal warnings: reports and exits 1

The gate lives in the pass-two constructor, before any input file is
opened, so no input stream is read (verified with a nonexistent input
file still validating OK).

Also categorize bare "parse error: ..." messages from the DSL parser as
kind "dsl-parse-error" rather than "generic" (climain/errors_json.go),
so --explain --errors-json gives an agent a useful error kind. The CSV
reader's "parse error on line ..." is stream-time and never reaches this
command-line-parse categorizer.

Tests: dsl-explain/0001-0004 regression cases (valid put/filter, invalid
plain, invalid --errors-json) and categorize unit tests. Regenerated
verb docs, manpage, and the help usage-verbs golden case.

The older -X ("exit after parsing") still exits 0 even on a parse error;
left as-is since --explain is the correct validation path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-03 18:12:23 -04:00 committed by GitHub
parent 78ed1563db
commit 7f60e7da57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 2335 additions and 1820 deletions

View file

@ -142,8 +142,13 @@ func categorize(err error) StructuredError {
}
}
// DSL parse errors
if strings.Contains(msg, "cannot parse DSL") || strings.Contains(msg, "DSL expression") {
// DSL parse errors. The "parse error:" prefix comes from the DSL parser
// (pkg/parsing/parser); "cannot parse DSL"/"DSL expression" are the wrapper
// messages. (The CSV reader's "parse error on line ..." is a stream-time
// error and never reaches this command-line-parse categorizer.)
if strings.Contains(msg, "cannot parse DSL") ||
strings.Contains(msg, "DSL expression") ||
strings.Contains(msg, "parse error:") {
return StructuredError{
Error: msg,
Kind: "dsl-parse-error",

View file

@ -175,3 +175,27 @@ func TestCategorizeGenericFallback(t *testing.T) {
t.Errorf("kind: got %q, want generic", se.Kind)
}
}
func TestCategorizeDSLParseError(t *testing.T) {
// The DSL parser (pkg/parsing/parser) emits bare "parse error: ..."
// messages; these should categorize as dsl-parse-error, e.g. for
// `mlr put --explain` with --errors-json.
err := fmt.Errorf("parse error: unexpected equals (\"=\")")
se := categorize(err)
if se.Kind != "dsl-parse-error" {
t.Errorf("kind: got %q, want dsl-parse-error", se.Kind)
}
if se.Hint == "" {
t.Error("hint should be non-empty for dsl-parse-error")
}
}
func TestCategorizeDSLParseErrorNotCSV(t *testing.T) {
// The CSV reader's "parse error on line ..." is a stream-time error and
// should not be mistaken for a DSL parse error by the substring match.
err := fmt.Errorf("parse error on line 3, column 5: bare \" in non-quoted-field")
se := categorize(err)
if se.Kind == "dsl-parse-error" {
t.Errorf("kind: got dsl-parse-error, want non-DSL categorization for a CSV parse error")
}
}

View file

@ -32,6 +32,7 @@ var putOptions = []OptionSpec{
{Flag: "-E", Type: "bool", Desc: "Echo DSL expression before printing parse-tree."},
{Flag: "-v", Type: "bool", Desc: "Same as -E -p."},
{Flag: "-X", Type: "bool", Desc: "Exit after parsing but before stream-processing. Useful with -v/-d/-D, if you only want to look at parser information."},
{Flag: "--explain", Type: "bool", Desc: "Parse and type-check the DSL expression, report whether it is valid, and exit without reading the input stream. Exit status is 0 if the expression is valid and non-zero otherwise; combine with --errors-json for a machine-readable error."},
}
var PutSetup = TransformerSetup{
@ -60,6 +61,7 @@ var filterOptions = []OptionSpec{
{Flag: "-E", Type: "bool", Desc: "Echo DSL expression before printing parse-tree."},
{Flag: "-v", Type: "bool", Desc: "Same as -E -p."},
{Flag: "-X", Type: "bool", Desc: "Exit after parsing but before stream-processing. Useful with -v/-d/-D, if you only want to look at parser information."},
{Flag: "--explain", Type: "bool", Desc: "Parse and type-check the DSL expression, report whether it is valid, and exit without reading the input stream. Exit status is 0 if the expression is valid and non-zero otherwise; combine with --errors-json for a machine-readable error."},
}
var FilterSetup = TransformerSetup{
@ -195,6 +197,7 @@ func transformerPutOrFilterParseCLI(
printASTMultiLine := false
printASTSingleLine := false
exitAfterParse := false
doExplain := false
doWarnings := false
warningsAreFatal := false
strictMode := false
@ -301,6 +304,8 @@ func transformerPutOrFilterParseCLI(
printASTSingleLine = true
case "-X":
exitAfterParse = true
case "--explain":
doExplain = true
case "-w":
doWarnings = true
warningsAreFatal = false
@ -375,6 +380,7 @@ func transformerPutOrFilterParseCLI(
printASTMultiLine,
printASTSingleLine,
exitAfterParse,
doExplain,
doWarnings,
warningsAreFatal,
strictMode,
@ -409,6 +415,7 @@ func NewTransformerPut(
printASTMultiLine bool,
printASTSingleLine bool,
exitAfterParse bool,
doExplain bool,
doWarnings bool,
warningsAreFatal bool,
strictMode bool,
@ -449,6 +456,26 @@ func NewTransformerPut(
},
)
// --explain is a validate/dry-run: report whether the DSL parsed and
// type-checked, then exit without reading the input stream. A parse/build
// error is returned so it flows through the normal error path (including
// --errors-json); a valid expression prints a confirmation and exits 0.
if doExplain {
if err != nil {
return nil, err
}
verbName := "put"
if doFilter {
verbName = "filter"
}
if warningsAreFatal && hadWarnings {
fmt.Fprintf(os.Stderr, "mlr %s: DSL expression has warnings treated as fatal.\n", verbName)
os.Exit(1)
}
fmt.Printf("mlr %s: DSL expression is valid.\n", verbName)
os.Exit(0)
}
if warningsAreFatal && hadWarnings {
fmt.Printf(
"%s: Exiting due to warnings treated as fatal.\n",