From cfdbb4b0b296b8748302f7fd451ce6a9f0b146f2 Mon Sep 17 00:00:00 2001 From: Curtis Myzie Date: Fri, 8 May 2026 11:24:38 -0400 Subject: [PATCH] Consolidate special-form metadata and document ?? non-decision Collapse the three parallel lists describing higher-order forms (higherOrderForms dispatch, higherOrderNames suggester candidates, formCallHints signatures) into a single userForms slice in higher_order.go. Adding a form now means appending one entry; the derived tables stay in lockstep automatically. Also note in spec.md and llms.txt that ?? is intentionally not provided, since the question keeps coming up. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reference/spec.md | 6 ++++ higher_order.go | 81 ++++++++++++++++++++++++++---------------- llms.txt | 4 +++ suggest.go | 26 +++++--------- 4 files changed, 69 insertions(+), 48 deletions(-) diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 3c993d4..b9cfd87 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -443,6 +443,12 @@ appear in error chains for diagnostic purposes. Strings, runes, and comments are not rewritten — `?.` written inside `"..."` or a comment is preserved verbatim. +A nil-coalescing `??` operator is **not** provided. The combination +of `?.` / `?[`, operand-returning `||`, and `try(value, default)` +covers the same use cases. When the LHS is a meaningful falsey value +that should be kept (`0`, `""`, `[]`), use `try(x, default)` +explicitly. + ## Helpful errors expr annotates "not found" errors with a short hint drawn from the diff --git a/higher_order.go b/higher_order.go index a46d754..7c3522b 100644 --- a/higher_order.go +++ b/higher_order.go @@ -28,45 +28,66 @@ type itEnv struct { // special form so evalCall can dispatch via a single map lookup. type higherOrderForm func(*Program, context.Context, *ast.CallExpr, any, int) (any, error) -// higherOrderForms maps builtin names to their special-form +// userForm describes one user-visible higher-order special form. It +// is the single source of truth that drives the dispatch table, the +// suggester's "did you mean" candidate set, and the "is a special +// form" hint shown for bare-identifier references. Adding a form +// means appending one entry here; the three derived tables stay in +// lockstep automatically. +type userForm struct { + // name is the spelling users type. For `map` this differs from + // the dispatch key because Go's parser reserves `map`; the + // engine rewrites the spelling to mapFormName before parsing. + name string + // internal is the key under which fn is registered in + // higherOrderForms. Equal to name for every form except `map`. + internal string + // callHint is the signature shown in the "is a special form" + // suggester message, e.g. `map(xs, predicate)`. + callHint string + fn higherOrderForm +} + +// userForms enumerates every user-visible special form. Order is +// stable for deterministic iteration in the suggester. Populated in +// init for the same reason as higherOrderForms: the fn references +// reach back through the evaluator into specialFormHint, which reads +// this slice — Go's initialization-cycle check flags that as a +// direct cycle. +var userForms []userForm + +// higherOrderForms maps dispatch keys to their special-form // evaluators. Unlike ordinary functions in the engine's funcs map, // these receive the raw *ast.CallExpr so they can re-evaluate the // predicate argument per element with `it`/`index` in scope. // -// These names are active by default but are not reserved: a -// user-registered function or an env entry of the same name shadows -// the form, matching the identifier-resolution order used everywhere -// else in expr. See the dispatch in evalCall. -// -// Populated in init rather than as a var initializer because the -// function bodies transitively reach p.evalCall, which reads this -// map — Go's initialization-cycle check flags that as a direct cycle. +// User-visible form names are not reserved: a user-registered +// function or an env entry of the same name shadows the form, +// matching the identifier-resolution order used everywhere else in +// expr. See the dispatch in evalCall. var higherOrderForms map[string]higherOrderForm func init() { - higherOrderForms = map[string]higherOrderForm{ - // map is rewritten by preprocessSource to the internal - // identifier before parsing because `map` is a Go keyword. - mapFormName: formMap, - "filter": formFilter, - "any": formAny, - "all": formAll, - "find": formFind, - "count": formCount, - "try": formTry, - // Sentinel forms emitted by the optaccess pre-parse rewrite. - // Users do not type these names; they appear only as the - // callee of synthesized CallExpr nodes. - trySelectFormName: formTrySelect, - tryIndexFormName: formTryIndex, - } + userForms = []userForm{ + {name: "map", internal: mapFormName, callHint: "map(xs, predicate)", fn: formMap}, + {name: "filter", internal: "filter", callHint: "filter(xs, predicate)", fn: formFilter}, + {name: "any", internal: "any", callHint: "any(xs, predicate)", fn: formAny}, + {name: "all", internal: "all", callHint: "all(xs, predicate)", fn: formAll}, + {name: "find", internal: "find", callHint: "find(xs, predicate)", fn: formFind}, + {name: "count", internal: "count", callHint: "count(xs, predicate)", fn: formCount}, + {name: "try", internal: "try", callHint: "try(value, default)", fn: formTry}, + } + higherOrderForms = make(map[string]higherOrderForm, len(userForms)+2) + for _, f := range userForms { + higherOrderForms[f.internal] = f.fn + } + // Sentinel forms emitted by the optaccess pre-parse rewrite. + // Users do not type these names; they appear only as the + // callee of synthesized CallExpr nodes. + higherOrderForms[trySelectFormName] = formTrySelect + higherOrderForms[tryIndexFormName] = formTryIndex } -// higherOrderNames is the user-visible list of special-form builtins. -// It drives "did you mean" hints so users see the spelling they would -// actually type (`map`, not the internal rewritten name). -var higherOrderNames = []string{"map", "filter", "any", "all", "find", "count", "try"} - // iterItems evaluates collExpr and converts the result to a []any for // predicate iteration. nil is treated as an empty list so // `map(nil, it)` / `filter(nil, it > 0)` return empty without error. diff --git a/llms.txt b/llms.txt index 4207c94..c629677 100644 --- a/llms.txt +++ b/llms.txt @@ -169,6 +169,10 @@ config?.feature?.enabled The rewrite is token-level and skips strings, runes, and comments, so `"obj?.field"` and `// obj?.field` survive untouched. +There is no `??` operator. Use `?.` / `?[` with operand-returning +`||` for nil-or-fallback, and `try(x, default)` when the LHS may be +a meaningful falsey value you want to keep. + ## Custom functions ```go diff --git a/suggest.go b/suggest.go index 565fa2c..5ee54d4 100644 --- a/suggest.go +++ b/suggest.go @@ -32,24 +32,12 @@ func identHint(env any, funcs map[string]any, name string, fieldTags *structTagC // 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 + for _, f := range userForms { + if f.name == name { + return fmt.Sprintf(" (%q is a special form, did you mean to call %s?)", name, f.callHint), true + } } - 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)", + return "", false } // fieldHint is identHint's counterpart for selector and index @@ -178,7 +166,9 @@ func availableIdents(env any, funcs map[string]any, fieldTags *structTagConfig) } // Include the higher-order special forms under their user-visible // names so a typo of `map` or `filter` suggests the right thing. - names = append(names, higherOrderNames...) + for _, f := range userForms { + names = append(names, f.name) + } return names }