Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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) { /* ... */ }
Expand Down
43 changes: 43 additions & 0 deletions suggest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions suggest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading