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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1035,6 +1035,10 @@ Options:
-v Same as -E -p.
-X Exit after parsing but before stream-processing. Useful with
-v/-d/-D, if you only want to look at parser information.
--explain 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.
-h|--help Show this message.
If you mix -e and -f then the expressions are evaluated in the order encountered.
@ -2436,6 +2440,10 @@ Options:
-v Same as -E -p.
-X Exit after parsing but before stream-processing. Useful with
-v/-d/-D, if you only want to look at parser information.
--explain 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.
-h|--help Show this message.
If you mix -e and -f then the expressions are evaluated in the order encountered.

File diff suppressed because it is too large Load diff

825
man/mlr.1

File diff suppressed because it is too large Load diff

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",

View file

@ -226,7 +226,7 @@ per the issue.)
---
## PR 5 — DSL `--explain` / validate dry-run
## PR 5 — DSL `--explain` / validate dry-run *(landed)*
**Goal.** Validate/type-check a DSL expression *before* spending a full input
pass (a big context saver for agents).
@ -236,6 +236,19 @@ pass (a big context saver for agents).
**without consuming the full input stream**.
- Reuse the existing DSL parse/CST build path; gate it before the record loop.
**Landed.** `--explain` added to put/filter (`put_or_filter.go`): after the
existing `cstRootNode.Build` (which already does parse → ValidateAST → CST build
→ Resolve), a valid expression prints `mlr {put,filter}: DSL expression is
valid.` and exits 0; an invalid one returns the build error up the normal path,
so `--errors-json` yields a structured document. The gate sits in the pass-two
constructor, before any input file is opened, so no input is read. DSL parser
messages (`parse error: ...`) now categorize as `dsl-parse-error` rather than
`generic` (`climain/errors_json.go`). Tests: `dsl-explain/0001-0004` regression
cases (valid put/filter, invalid plain, invalid `--errors-json`) plus categorize
unit tests. Note: the older `-X` ("exit after parsing") still exits 0 even on a
parse error — a pre-existing quirk left as-is since `--explain` is the correct
validation path.
---
## PR 6 — `mlr describe` schema/shape introspection

View file

@ -231,6 +231,10 @@ Options:
-v Same as -E -p.
-X Exit after parsing but before stream-processing. Useful with
-v/-d/-D, if you only want to look at parser information.
--explain 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.
-h|--help Show this message.
If you mix -e and -f then the expressions are evaluated in the order encountered.
@ -759,6 +763,10 @@ Options:
-v Same as -E -p.
-X Exit after parsing but before stream-processing. Useful with
-v/-d/-D, if you only want to look at parser information.
--explain 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.
-h|--help Show this message.
If you mix -e and -f then the expressions are evaluated in the order encountered.

View file

@ -0,0 +1 @@
mlr -n put --explain -f ${CASEDIR}/mlr

View file

View file

@ -0,0 +1 @@
mlr put: DSL expression is valid.

View file

@ -0,0 +1 @@
$y = $x + 1

View file

@ -0,0 +1 @@
mlr -n filter --explain -f ${CASEDIR}/mlr

View file

View file

@ -0,0 +1 @@
mlr filter: DSL expression is valid.

View file

@ -0,0 +1 @@
$x > 0

View file

@ -0,0 +1 @@
mlr -n put --explain -f ${CASEDIR}/mlr

View file

@ -0,0 +1,2 @@
mlr: cannot parse DSL expression.
mlr: parse error: unexpected equals ("=")

View file

View file

@ -0,0 +1 @@
$y = = $x

View file

View file

@ -0,0 +1 @@
mlr --errors-json -n put --explain -f ${CASEDIR}/mlr

View file

@ -0,0 +1,6 @@
mlr: cannot parse DSL expression.
{
"error": "parse error: unexpected equals (\"=\")",
"kind": "dsl-parse-error",
"hint": "Run 'mlr put --help' for DSL syntax reference."
}

View file

View file

@ -0,0 +1 @@
$y = = $x

View file