mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
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>
320 lines
8.4 KiB
Go
320 lines
8.4 KiB
Go
// Structured error output for `mlr --errors-json`.
|
|
//
|
|
// When --errors-json is set (or MLR_ERRORS_JSON is truthy), parse-time errors
|
|
// are emitted as a JSON object to stderr instead of a plain text message.
|
|
// This lets AI agents and scripts branch on error kind rather than regex-
|
|
// matching English prose.
|
|
//
|
|
// JSON shape:
|
|
// {
|
|
// "error": "mlr: verb \"flitre\" not found",
|
|
// "kind": "unknown-verb",
|
|
// "token": "flitre",
|
|
// "verb": "", // set for verb-option errors
|
|
// "hint": "Run 'mlr filter --help' for usage.",
|
|
// "did_you_mean": ["filter", "flatten"]
|
|
// }
|
|
|
|
package climain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/johnkerl/miller/v6/pkg/cli"
|
|
"github.com/johnkerl/miller/v6/pkg/dsl/cst"
|
|
"github.com/johnkerl/miller/v6/pkg/transformers"
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// CLIError is the typed error returned by the pass-one parser for
|
|
// errors that carry structured metadata (unknown verb, unknown flag).
|
|
// Other parse errors still flow as plain errors; EmitStructuredError
|
|
// handles both.
|
|
|
|
type CLIError struct {
|
|
Kind string // "unknown-verb" | "unknown-flag"
|
|
Token string // the unrecognized token
|
|
Verb string // enclosing verb context, if any
|
|
Msg string // human-readable message (same as what prose path would print)
|
|
}
|
|
|
|
func (e *CLIError) Error() string { return e.Msg }
|
|
|
|
// ----------------------------------------------------------------
|
|
// StructuredError is the JSON DTO emitted to stderr.
|
|
|
|
type StructuredError struct {
|
|
Error string `json:"error"`
|
|
Kind string `json:"kind"`
|
|
Token string `json:"token,omitempty"`
|
|
Verb string `json:"verb,omitempty"`
|
|
Hint string `json:"hint,omitempty"`
|
|
DidYouMean []string `json:"did_you_mean,omitempty"`
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Opt-in detection
|
|
|
|
// WantErrorsJSON returns true when the caller has opted in via the
|
|
// --errors-json flag anywhere in args, or a truthy MLR_ERRORS_JSON env var.
|
|
// It is called before ParseCommandLine so it can affect how parse errors
|
|
// are reported.
|
|
func WantErrorsJSON(args []string) bool {
|
|
if isTruthyEnv(os.Getenv("MLR_ERRORS_JSON")) {
|
|
return true
|
|
}
|
|
for _, arg := range args {
|
|
if arg == "--errors-json" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isTruthyEnv(v string) bool {
|
|
switch v {
|
|
case "1", "true", "True", "TRUE", "yes", "Yes", "YES":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Error categorization and emission
|
|
|
|
// EmitStructuredError writes a JSON error document to stderr and exits 1.
|
|
// It is called in place of printError when --errors-json is active.
|
|
func EmitStructuredError(err error) {
|
|
se := categorize(err)
|
|
se.DidYouMean = nearMatches(se)
|
|
|
|
b, jerr := json.MarshalIndent(se, "", " ")
|
|
if jerr != nil {
|
|
// Fallback: plain text if we somehow can't marshal
|
|
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
|
|
return
|
|
}
|
|
fmt.Fprintln(os.Stderr, string(b))
|
|
}
|
|
|
|
// categorize turns any error into a StructuredError, using the typed CLIError
|
|
// fields when available and string-matching as a fallback.
|
|
func categorize(err error) StructuredError {
|
|
var cliErr *CLIError
|
|
if errors.As(err, &cliErr) {
|
|
se := StructuredError{
|
|
Error: cliErr.Msg,
|
|
Kind: cliErr.Kind,
|
|
Token: cliErr.Token,
|
|
Verb: cliErr.Verb,
|
|
}
|
|
switch cliErr.Kind {
|
|
case "unknown-verb":
|
|
se.Hint = "Run 'mlr -l' for a list of verbs, or 'mlr help verb <name>' for details."
|
|
case "unknown-flag":
|
|
se.Hint = "Run 'mlr --help' for a list of main flags."
|
|
case "verb-option-error":
|
|
if cliErr.Verb != "" {
|
|
se.Hint = "Run 'mlr " + cliErr.Verb + " --help' for a list of options."
|
|
}
|
|
}
|
|
return se
|
|
}
|
|
|
|
// Fallback: categorize by message pattern.
|
|
msg := err.Error()
|
|
|
|
// "mlr {verb}: option "{flag}" not recognized"
|
|
if strings.Contains(msg, ": option ") && strings.Contains(msg, "not recognized") {
|
|
verb := extractVerbFromMsg(msg)
|
|
token := extractQuotedToken(msg)
|
|
return StructuredError{
|
|
Error: msg,
|
|
Kind: "verb-option-error",
|
|
Token: token,
|
|
Verb: verb,
|
|
Hint: fmt.Sprintf("Run 'mlr %s --help' for a list of options.", verb),
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
Hint: "Run 'mlr put --help' for DSL syntax reference.",
|
|
}
|
|
}
|
|
|
|
return StructuredError{
|
|
Error: msg,
|
|
Kind: "generic",
|
|
}
|
|
}
|
|
|
|
// nearMatches populates did_you_mean for the structured error.
|
|
func nearMatches(se StructuredError) []string {
|
|
if se.Token == "" {
|
|
return nil
|
|
}
|
|
query := strings.ToLower(se.Token)
|
|
|
|
var candidates []string
|
|
switch se.Kind {
|
|
case "unknown-verb":
|
|
candidates = transformers.GetVerbNames()
|
|
|
|
case "unknown-flag":
|
|
candidates = cli.FLAG_TABLE.GetFlagNames()
|
|
|
|
case "verb-option-error":
|
|
// Use structured OptionSpec when available (from PR3 catalog).
|
|
if se.Verb != "" {
|
|
if info := transformers.GetVerbInfoForJSON(se.Verb); info != nil {
|
|
for _, opt := range info.Options {
|
|
candidates = append(candidates, opt.Flag)
|
|
}
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
candidates = cli.FLAG_TABLE.GetFlagNames()
|
|
}
|
|
|
|
case "dsl-parse-error":
|
|
candidates = append(cst.BuiltinFunctionManagerInstance.GetBuiltinFunctionNames(),
|
|
cst.GetKeywordNames()...)
|
|
}
|
|
|
|
return topMatches(query, candidates, 3, levenshteinThreshold(query))
|
|
}
|
|
|
|
// topMatches returns up to n candidates from the list sorted by edit distance,
|
|
// keeping only those within maxDist of the query.
|
|
func topMatches(query string, candidates []string, n, maxDist int) []string {
|
|
type scored struct {
|
|
name string
|
|
dist int
|
|
}
|
|
var scored_ []scored
|
|
for _, c := range candidates {
|
|
d := levenshtein(query, strings.ToLower(c))
|
|
if d <= maxDist {
|
|
scored_ = append(scored_, scored{c, d})
|
|
}
|
|
}
|
|
sort.Slice(scored_, func(i, j int) bool {
|
|
if scored_[i].dist != scored_[j].dist {
|
|
return scored_[i].dist < scored_[j].dist
|
|
}
|
|
return scored_[i].name < scored_[j].name
|
|
})
|
|
result := make([]string, 0, n)
|
|
for i, s := range scored_ {
|
|
if i >= n {
|
|
break
|
|
}
|
|
result = append(result, s.name)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// levenshteinThreshold returns the maximum edit distance to consider a match.
|
|
// Shorter queries use a tighter threshold.
|
|
func levenshteinThreshold(query string) int {
|
|
n := len(query)
|
|
switch {
|
|
case n <= 3:
|
|
return 1
|
|
case n <= 6:
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Levenshtein edit distance (Wagner-Fischer, O(m*n) time, O(n) space)
|
|
|
|
func levenshtein(a, b string) int {
|
|
ra, rb := []rune(a), []rune(b)
|
|
la, lb := len(ra), len(rb)
|
|
if la == 0 {
|
|
return lb
|
|
}
|
|
if lb == 0 {
|
|
return la
|
|
}
|
|
prev := make([]int, lb+1)
|
|
curr := make([]int, lb+1)
|
|
for j := range prev {
|
|
prev[j] = j
|
|
}
|
|
for i, ca := range ra {
|
|
curr[0] = i + 1
|
|
for j, cb := range rb {
|
|
cost := 1
|
|
if ca == cb {
|
|
cost = 0
|
|
}
|
|
curr[j+1] = min3(curr[j]+1, prev[j+1]+1, prev[j]+cost)
|
|
}
|
|
prev, curr = curr, prev
|
|
}
|
|
return prev[lb]
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
if a < b {
|
|
if a < c {
|
|
return a
|
|
}
|
|
return c
|
|
}
|
|
if b < c {
|
|
return b
|
|
}
|
|
return c
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// Helpers for string-based categorization
|
|
|
|
// extractTokenFromVerbError extracts the quoted token from a verb error
|
|
// such as `mlr cut: option "--foo" not recognized`.
|
|
// Used in mlrcli_parse.go to populate CLIError.Token from the error string.
|
|
func extractTokenFromVerbError(msg string) string {
|
|
return extractQuotedToken(msg)
|
|
}
|
|
|
|
// extractVerbFromMsg extracts the verb from "mlr {verb}: ..." error messages.
|
|
func extractVerbFromMsg(msg string) string {
|
|
// Pattern: "mlr {verb}: ..."
|
|
msg = strings.TrimPrefix(msg, "mlr ")
|
|
if i := strings.Index(msg, ":"); i > 0 {
|
|
return msg[:i]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractQuotedToken extracts the first double-quoted token from a message.
|
|
func extractQuotedToken(msg string) string {
|
|
start := strings.Index(msg, "\"")
|
|
if start < 0 {
|
|
return ""
|
|
}
|
|
end := strings.Index(msg[start+1:], "\"")
|
|
if end < 0 {
|
|
return ""
|
|
}
|
|
return msg[start+1 : start+1+end]
|
|
}
|