diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 7914e59..c20cf91 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -422,6 +422,13 @@ lists the available names. When neither condition is useful, the original error is returned unchanged so callers can still pattern- match on it. +A higher-order form referenced as a value gets a tailored hint with +the call signature, so users see what shape the form expects rather +than a self-referential "did you mean": + +- `undefined identifier "count" ("count" is a special form, did you mean to call count(xs, predicate)?)` +- `undefined identifier "try" ("try" is a special form, did you mean to call try(value, default)?)` + ## Limits and safety expr is meant to evaluate untrusted expression text without panicking. diff --git a/llms.txt b/llms.txt index 2646e53..a1cec89 100644 --- a/llms.txt +++ b/llms.txt @@ -282,6 +282,10 @@ All parse failures wrap `ErrCompile`. All runtime failures wrap are wrapped so `errors.Is` and `errors.As` still find the original cause. Unknown identifiers, fields, and keys get a Levenshtein-based "did you mean...?" hint drawn from what's actually in scope. +Higher-order forms (`map`, `filter`, `any`, `all`, `find`, `count`, +`try`) referenced as bare identifiers get a tailored hint showing +their call signature, e.g. `"count" is a special form, did you +mean to call count(xs, predicate)?`. ```go if errors.Is(err, expr.ErrCompile) { /* ... */ } diff --git a/suggest.go b/suggest.go index 94522af..565fa2c 100644 --- a/suggest.go +++ b/suggest.go @@ -14,10 +14,44 @@ import ( // to list, the hint reads `(available: a, b, c)`. When neither // condition holds the hint is empty, so the original error message is // unchanged. +// +// Higher-order forms get a tailored hint instead. If the unresolved +// name is itself a form (`count`, `filter`, `try`, ...), the hint +// reports it as a special form and shows the call signature so the +// user knows it must be invoked as `count(xs, predicate)` rather +// than referenced as a value. func identHint(env any, funcs map[string]any, name string, fieldTags *structTagConfig) string { + if hint, ok := specialFormHint(name); ok { + return hint + } return formatHint(name, availableIdents(env, funcs, fieldTags)) } +// specialFormHint returns the "is a special form" message when name +// matches one of the higher-order forms exactly. The signature shown +// matches the form's actual arity so users see the correct shape +// (`try(value, default)` vs `count(xs, predicate)`). +func specialFormHint(name string) (string, bool) { + sig, ok := formCallHints[name] + if !ok { + return "", false + } + return fmt.Sprintf(" (%q is a special form, did you mean to call %s?)", name, sig), true +} + +// formCallHints maps each higher-order form to its call signature for +// use in "is a special form" hints. Kept in lockstep with +// higherOrderForms in higher_order.go. +var formCallHints = map[string]string{ + "map": "map(xs, predicate)", + "filter": "filter(xs, predicate)", + "any": "any(xs, predicate)", + "all": "all(xs, predicate)", + "find": "find(xs, predicate)", + "count": "count(xs, predicate)", + "try": "try(value, default)", +} + // fieldHint is identHint's counterpart for selector and index // lookups: it reports the names reachable on a specific receiver // value (struct fields/methods or the keys of a string-keyed map). @@ -66,6 +100,15 @@ func closestName(name string, candidates []string) (string, bool) { best := -1 var bestName string for _, c := range candidates { + // Skip exact matches: a hint of `did you mean "count"?` is + // noise when the user already typed that name. Exact-match + // candidates only reach this loop for higher-order forms + // (whose names appear in the candidate set even though the + // evaluator could not resolve them as identifiers). + // specialFormHint handles those cases separately. + if c == name { + continue + } d := levenshtein(name, c) if d <= threshold && (best == -1 || d < best) { best = d diff --git a/suggest_test.go b/suggest_test.go index 5804a75..aa87dad 100644 --- a/suggest_test.go +++ b/suggest_test.go @@ -32,6 +32,32 @@ func TestSuggest_UndefinedIdentAvailableList(t *testing.T) { require.Contains(t, err.Error(), "alpha") } +func TestSuggest_SpecialFormUsedAsValue(t *testing.T) { + // A bare reference to a special form like `count` can't resolve as + // an identifier; the hint should explain it is a special form and + // show the call signature, not loop back to "did you mean count?". + _, err := evalExpr(t.Context(), "count", nil) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), `undefined identifier "count"`) + require.Contains(t, err.Error(), `"count" is a special form, did you mean to call count(xs, predicate)?`) +} + +func TestSuggest_SpecialFormTrySignature(t *testing.T) { + // `try` has a different signature than the other forms; the hint + // should reflect that. + _, err := evalExpr(t.Context(), "try", nil) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), `"try" is a special form, did you mean to call try(value, default)?`) +} + +func TestSuggest_SpecialFormMap(t *testing.T) { + // `map` is rewritten internally before parsing; the user-facing + // hint must still display "map", not the rewritten sentinel. + _, err := evalExpr(t.Context(), "map", nil) + require.ErrorIs(t, err, ErrEvaluate) + require.Contains(t, err.Error(), `"map" is a special form, did you mean to call map(xs, predicate)?`) +} + func TestSuggest_MissingStructField(t *testing.T) { p := person{Name: "Alice", Age: 30} env := map[string]any{"p": p}