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>
201 lines
5.8 KiB
Go
201 lines
5.8 KiB
Go
package climain
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
// ----------------------------------------------------------------
|
|
// Levenshtein
|
|
|
|
func TestLevenshteinIdentical(t *testing.T) {
|
|
if d := levenshtein("filter", "filter"); d != 0 {
|
|
t.Errorf("identical strings: got %d, want 0", d)
|
|
}
|
|
}
|
|
|
|
func TestLevenshteinEmpty(t *testing.T) {
|
|
if d := levenshtein("", "abc"); d != 3 {
|
|
t.Errorf("empty a: got %d, want 3", d)
|
|
}
|
|
if d := levenshtein("abc", ""); d != 3 {
|
|
t.Errorf("empty b: got %d, want 3", d)
|
|
}
|
|
}
|
|
|
|
func TestLevenshteinSubstitution(t *testing.T) {
|
|
// "fliter" → "filter": swap l↔i at positions 1-2 → distance 2
|
|
d := levenshtein("fliter", "filter")
|
|
if d != 2 {
|
|
t.Errorf("fliter/filter: got %d, want 2", d)
|
|
}
|
|
}
|
|
|
|
func TestLevenshteinInsertion(t *testing.T) {
|
|
// "--jsonn" → "--json": 1 deletion
|
|
if d := levenshtein("--jsonn", "--json"); d != 1 {
|
|
t.Errorf("--jsonn/--json: got %d, want 1", d)
|
|
}
|
|
}
|
|
|
|
func TestLevenshteinDeletion(t *testing.T) {
|
|
// "--complemment" → "--complement": 1 extra m
|
|
if d := levenshtein("--complemment", "--complement"); d != 1 {
|
|
t.Errorf("--complemment/--complement: got %d, want 1", d)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// topMatches
|
|
|
|
func TestTopMatchesBasic(t *testing.T) {
|
|
// "filtter" is distance 1 from "filter" (extra t), clearly the top match.
|
|
candidates := []string{"filter", "flatten", "fraction", "grep", "head"}
|
|
matches := topMatches("filtter", candidates, 3, 2)
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected at least one match for 'filtter'")
|
|
}
|
|
if matches[0] != "filter" {
|
|
t.Errorf("expected 'filter' as top match, got %q", matches[0])
|
|
}
|
|
}
|
|
|
|
func TestTopMatchesNoneWithinThreshold(t *testing.T) {
|
|
candidates := []string{"aaaaa", "bbbbb", "ccccc"}
|
|
matches := topMatches("xyz", candidates, 3, 1)
|
|
if len(matches) != 0 {
|
|
t.Errorf("expected no matches, got %v", matches)
|
|
}
|
|
}
|
|
|
|
func TestTopMatchesRespectsCap(t *testing.T) {
|
|
// All candidates are within distance 1 of "a"; cap at 2
|
|
candidates := []string{"aa", "ab", "ac", "ad", "ae"}
|
|
matches := topMatches("a", candidates, 2, 2)
|
|
if len(matches) > 2 {
|
|
t.Errorf("expected at most 2 matches, got %d", len(matches))
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// levenshteinThreshold
|
|
|
|
func TestThresholdShortQuery(t *testing.T) {
|
|
if levenshteinThreshold("ab") != 1 {
|
|
t.Error("2-char query should have threshold 1")
|
|
}
|
|
}
|
|
|
|
func TestThresholdMediumQuery(t *testing.T) {
|
|
if levenshteinThreshold("filter") != 2 {
|
|
t.Error("6-char query should have threshold 2")
|
|
}
|
|
}
|
|
|
|
func TestThresholdLongQuery(t *testing.T) {
|
|
if levenshteinThreshold("--complement") != 3 {
|
|
t.Error("12-char query should have threshold 3")
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// WantErrorsJSON
|
|
|
|
func TestWantErrorsJSONFlag(t *testing.T) {
|
|
if !WantErrorsJSON([]string{"mlr", "--errors-json", "flitre"}) {
|
|
t.Error("should detect --errors-json")
|
|
}
|
|
if !WantErrorsJSON([]string{"mlr", "flitre", "--errors-json"}) {
|
|
t.Error("should detect --errors-json in any position")
|
|
}
|
|
if WantErrorsJSON([]string{"mlr", "flitre"}) {
|
|
t.Error("should not detect --errors-json when absent")
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// CLIError round-trip
|
|
|
|
func TestCLIErrorInterface(t *testing.T) {
|
|
err := &CLIError{Kind: "unknown-verb", Token: "foo", Msg: "mlr: verb \"foo\" not found"}
|
|
if err.Error() != err.Msg {
|
|
t.Errorf("Error() should return Msg, got %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// categorize
|
|
|
|
func TestCategorizeUnknownVerb(t *testing.T) {
|
|
err := &CLIError{Kind: "unknown-verb", Token: "flitre", Msg: "mlr: verb \"flitre\" not found"}
|
|
se := categorize(err)
|
|
if se.Kind != "unknown-verb" {
|
|
t.Errorf("kind: got %q, want unknown-verb", se.Kind)
|
|
}
|
|
if se.Token != "flitre" {
|
|
t.Errorf("token: got %q, want flitre", se.Token)
|
|
}
|
|
if se.Hint == "" {
|
|
t.Error("hint should be non-empty for unknown-verb")
|
|
}
|
|
}
|
|
|
|
func TestCategorizeUnknownFlag(t *testing.T) {
|
|
err := &CLIError{Kind: "unknown-flag", Token: "--jsonn", Msg: "mlr: option \"--jsonn\" not recognized"}
|
|
se := categorize(err)
|
|
if se.Kind != "unknown-flag" {
|
|
t.Errorf("kind: got %q, want unknown-flag", se.Kind)
|
|
}
|
|
if se.Hint == "" {
|
|
t.Error("hint should be non-empty for unknown-flag")
|
|
}
|
|
}
|
|
|
|
func TestCategorizeVerbOptionError(t *testing.T) {
|
|
err := &CLIError{
|
|
Kind: "verb-option-error", Token: "--bad", Verb: "cut",
|
|
Msg: "mlr cut: option \"--bad\" not recognized",
|
|
}
|
|
se := categorize(err)
|
|
if se.Kind != "verb-option-error" {
|
|
t.Errorf("kind: got %q, want verb-option-error", se.Kind)
|
|
}
|
|
if se.Verb != "cut" {
|
|
t.Errorf("verb: got %q, want cut", se.Verb)
|
|
}
|
|
if se.Hint == "" {
|
|
t.Error("hint should be non-empty for verb-option-error")
|
|
}
|
|
}
|
|
|
|
func TestCategorizeGenericFallback(t *testing.T) {
|
|
err := fmt.Errorf("some unexpected error")
|
|
se := categorize(err)
|
|
if se.Kind != "generic" {
|
|
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")
|
|
}
|
|
}
|