# E38 — SourceInfo API (design) Cluster 38 of `plans/hs-conformance-to-100.md`. Goal: 4 tests in `hs-upstream-core/sourceInfo` that exercise `_hyperscript.parse(src).sourceFor()` and `.lineFor()`. Upstream reference: `/tmp/hs-upstream/test/core/sourceInfo.js`, `/tmp/hs-upstream/src/parsetree/base.js` (29 lines of impl — `sourceFor()` slices `programSource[startToken.start..endToken.end]`, `lineFor()` returns `programSource.split("\n")[startToken.line-1]`). ## 1. Failing tests All four currently `SKIP (untranslated)` (lines 2434–2442 of `spec/tests/test-hyperscript-behavioral.sx`). | # | Test name | What it asserts | |---|-----------|-----------------| | 1 | `debug` | `parse("").sourceFor() == ""` — single-token round-trip. | | 2 | `get source works for expressions` | 7 separate `parse(…).sourceFor()` checks over `1`, `a.b`, `a.b()`, ``, `x + y`, `'foo'`, `.foo`, `#bar`. Also navigates: `elt.root.sourceFor()` ⇒ `"a"` for `"a.b"`; `elt.root.root` for `"a.b()"`; `elt.lhs`/`elt.rhs` for `"x + y"`. | | 3 | `get source works for statements` | `if true log 'it was true'` and `for x in [1, 2, 3] log x then log x end` each round-trip through `sourceFor()`. | | 4 | `get line works for statements` | `parse("if true\n log 'it was true'\n log 'it was true'")` — `elt.lineFor()` ⇒ `"if true"`, `elt.trueBranch.lineFor()` ⇒ `" log 'it was true'"`, `elt.trueBranch.next.lineFor()` ⇒ `" log 'it was true'"`. | Key demand: the AST must (a) retain a `{start, end, line}` span per node; (b) expose navigable sub-nodes (`root`, `lhs`, `rhs`, `trueBranch`, `next`); (c) provide `sourceFor`/`lineFor` keyed off the original program source. ## 2. Proposed API User-visible surface, kept minimal: ``` (hs-parse-ast "SRC") ; → parsed node (an AST handle, see §3) (hs-source-for NODE) ; → substring of original source (hs-line-for NODE) ; → full source line containing NODE's start (hs-node-get NODE KEY) ; → child AST node at field (root / lhs / rhs / true-branch / next …) ``` `NODE` is a **parsed-but-uncompiled** AST. It is not a compiled handler, not a runtime event. The upstream API mirrors this: `_hyperscript.parse(src)` returns a parse tree, never a closure. Keeping the feature scoped to parser output avoids retro-fitting spans onto bytecode or closures. For the generator's benefit we expose two thin helpers at the test layer only: ``` (hs-src src) ; = (hs-source-for (hs-parse-ast src)) (hs-src-at src field-path) ; = walk (hs-node-get … key) then source-for ``` We do **not** add `(get line thing)` as a DSL keyword. That phrase in the plan row was shorthand — the tests actually call host methods `.sourceFor()` / `.lineFor()`, not hyperscript statements. Keeping this out of the HS grammar keeps the surface area near zero. ## 3. Attach strategy The tokenizer and parser already have the raw material; the information is dropped at two points. ### Walk-through | Stage | File | State today | Change | |-------|------|-------------|--------| | Tokenize | `lib/hyperscript/tokenizer.sx` | Tokens are `{:type T :value V :pos P}`. Only `start` offset tracked; no `end`, no `line`. | Extend `hs-make-token` → `{:type :value :pos :end :line}`. Track a `current-line` counter in `hs-tokenize` that increments on `\n`. `:end` = index after last consumed char. | | Parse | `lib/hyperscript/parser.sx` | `hs-parse` takes `(tokens src)`, returns bare SX lists/symbols. Source offsets are consumed internally (see `collect-sx-source` at path `(0 2 2 69)`) but never stored on the output AST. | For every production that returns a node, attach a span dict: wrap the output in `{:hs-ast true :kind … :start START :end END :line LINE :src SRC :children CHILDREN :fields FIELDS}`. Children preserve the SX list an `hs-compile` downstream currently consumes; `fields` is a small dict mapping `:root :lhs :rhs :true-branch :next …` to sub-nodes. | | Compile | `lib/hyperscript/compiler.sx` | `hs-to-sx` consumes the bare list AST and emits runtime calls. | Add a thin unwrap step at the entry: if the AST is a span-wrapped dict, pull `:children` (or equivalent raw list) and continue. No per-production rewiring — the wrapped form passes through unchanged for every existing callsite. | | Runtime | `lib/hyperscript/runtime.sx` | Compiled code never sees AST nodes. | No change. SourceInfo lives on the parse tree, not on compiled handlers. | ### Side-channel vs inline **Inline wrapper dict is the cheaper option**, because: - Parser output is already heterogeneous (lists, symbols, strings, numbers). A dict wrapper is distinguishable by `(dict? x)` + `(dict-get x :hs-ast)` — no risk of collision. - A side-channel `(map node → span)` would need identity semantics, and SX lists don't have stable identity after any structural transform. We would end up cloning everything. - The compiler's existing `hs-to-sx` dispatch is on `(first ast)`. The unwrap step is a single `cond` branch at its top. ### Field dictionary The parser emits nodes in many shapes. `:fields` names a handful of them so `hs-node-get` can navigate without the caller learning SX shape. Mapping (from the upstream tests): | Upstream accessor | Our field key | Produced by | |-------------------|---------------|-------------| | `.root` | `:root` | symbol-with-member / call expressions (`a.b`, `a.b()`). For `a.b` the root is `a`; for `a.b()` the root is `a.b`. | | `.lhs` / `.rhs` | `:lhs` / `:rhs` | binary operators (`x + y`). | | `.trueBranch` | `:true-branch` | `if` command; the first command in the consequent. | | `.next` | `:next` | any command; the following command in a `CommandList`. | Only these four fields are needed for the 4 tests. Others are deferred. ### Span capture The parser already tracks start offsets via its token cursor; `collect-sx-source` shows the end-substring pattern. Pattern for every production: ``` (let ((start (current-pos)) (start-line (current-line))) (let ((raw (… existing production …))) (let ((end (previous-pos))) (hs-ast-wrap raw :kind "…" :start start :end end :line start-line :src src)))) ``` Two tiny helpers (`current-pos`, `current-line`) added to the parser's inner `let` scope. `hs-ast-wrap` lives alongside `collect-sx-source`. ## 4. Test mock / generator strategy Add one pattern to `tests/playwright/generate-sx-tests.py` (cluster: sourceInfo). Recognise: ```js _hyperscript.parse("SRC").sourceFor() → (hs-src "SRC") _hyperscript.parse("SRC").root.sourceFor() → (hs-src-at "SRC" (list :root)) _hyperscript.parse("SRC").root.root.sourceFor() → (hs-src-at "SRC" (list :root :root)) _hyperscript.parse("SRC").lhs.sourceFor() → (hs-src-at "SRC" (list :lhs)) _hyperscript.parse("SRC").rhs.sourceFor() → (hs-src-at "SRC" (list :rhs)) _hyperscript.parse("SRC").lineFor() → (hs-line-at "SRC" (list)) _hyperscript.parse("SRC").trueBranch.lineFor() → (hs-line-at "SRC" (list :true-branch)) _hyperscript.parse("SRC").trueBranch.next.lineFor() → (hs-line-at "SRC" (list :true-branch :next)) ``` Object-returning patterns (`return { src: …, rootSrc: … }`) become one `assert=` per member. The generator already has the newline escaping infrastructure for string bodies (cluster 17 etc. exercised it). No mock-DOM changes required — SourceInfo does not touch the DOM. `hs-cleanup!` is unused here. ## 5. Test-delta estimate | Test | Sub-assertions | Blockers today | Delta | |------|----------------|----------------|-------| | `debug` | 1 | Parser must accept `` as a full expression (already does — it's a CSS-literal). Needs `sourceFor`. | +1 | | `get source works for expressions` | ~9 | Adds binary operator span (`x + y`) and nested-member navigation (`.root.root`). | +1 (one test, all assertions must pass) | | `get source works for statements` | 2 | Needs statement-level span; `if … log …` and `for … end` already parse. | +1 | | `get line works for statements` | 3 | Needs `:line`, `:true-branch`, `:next` field navigation, and the `lineFor` semantics (newline-indexed string split, not just the node's own source slice). | +1 | Total: **+4** (matches the plan's cluster row). ## 6. Risks - **AST equality.** Wrapping every parser node in a dict changes `equal?` semantics for any caller that does structural comparison on AST output. Mitigation: the compiler's entry unwrap means all downstream code sees the bare form. Only new `hs-parse-ast` callers see the wrapped form. Direct `hs-parse`/`hs-compile`/`hs-to-sx-from-source` keep their existing return shape. - **Serialisation.** If AST nodes are ever sent over the wire (they are not today, but the `spec/tests` runner serialises results for error printing), the wrapper dict grows the payload. Mitigation: keep `:src` as a reference to the shared program source string (one copy) rather than slicing per node; SX dicts share values. - **Memory.** One extra dict per node. The parser currently allocates a list per node; we double that. For the largest test program (`for x in [1, 2, 3] log x then log x end`) this is ~15 nodes. Negligible. - **`lineFor` off-by-one.** Upstream uses `programSource.split("\n")[startToken.line - 1]` and counts lines from 1. Our `current-line` must mirror exactly — increment *after* `\n`, first line is `1`. Unit-test the tokenizer on the `"if true\n log …\n log …"` fixture before wiring the parser. - **Operator associativity and `.root`.** Upstream's `a.b()` gives `.root = (a.b)` and `.root.root = a`. Our parser must record the callee sub-expression as `:root` of a call node, and the receiver as `:root` of a member node. A one-liner slip here would fail test 2 silently. ## 7. Implementation checklist Four commits. Each commit passes the baseline smoke range (0–195) before moving on. 1. **Tokenizer: add `:end` and `:line` to tokens.** Extend `hs-make-token`; track `current-line` in `hs-tokenize`; update every emission site (there are ~20). No parser changes yet. Unit-test via a small ad-hoc `deftest` in the tokenizer's own test fixture (or inline in `behavioral.sx` under a throwaway suite — remove before commit). Commit: `HS: tokenizer tracks :end and :line`. 2. **Parser: wrap output nodes with span dict + fields.** Introduce `hs-ast-wrap`, `current-pos`, `current-line`. Wrap expression and statement productions. Populate `:root :lhs :rhs :true-branch :next` for the handful of node shapes the tests exercise. Add entry-unwrap to `hs-to-sx` so downstream consumers are unaffected. Commit: `HS: parser attaches source spans to AST nodes`. 3. **API: `hs-parse-ast`, `hs-source-for`, `hs-line-for`, `hs-node-get` + test helpers `hs-src`, `hs-src-at`, `hs-line-at`.** Thin functions. Place `hs-parse-ast` in `parser.sx`, accessors in `runtime.sx` (so they're auto-loaded by the behavioral runner), helpers inline in `test-hyperscript-behavioral.sx` via the generator. Commit: `HS: sourceInfo API (sourceFor / lineFor / node-get)`. 4. **Generator: sourceInfo pattern + regenerate 4 tests.** Add the pattern matchers from §4 to `generate-sx-tests.py`. Regenerate `spec/tests/test-hyperscript-behavioral.sx`. Verify `hs-upstream-core/sourceInfo` goes from 0/4 to 4/4 and no regression in the 0–195 smoke range. Remember: `cp lib/hyperscript/.sx shared/static/wasm/sx/hs-.sx` after each `.sx` touch. Commit: `HS: sourceInfo (+4 tests)`. ## Notes - No runtime changes. SourceInfo is purely a parser-side facility. - No changes to the HS DSL grammar. `get line` / `get source` are *not* added as hyperscript keywords — the upstream test file exclusively calls host-side methods on parse-tree objects. - Upstream's impl is 7 lines of host JS. Ours lands in about 30 lines of SX plus a generator pattern.