OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / c11a0b3

Correct the level ladder, and make a symbol's interior a lookup
The spec had drifted into the wrong design and I built toward it. It said the file's TEXT was the substrate, that nodes were tints painted onto it positioned by source offset, and that there was therefore no layout step (old V1/V2). That is codopticon's model, and codopticon works that way because it has none of its own: text is all it has, so distance is its only axis. We have a model. Levels are discrete, the graph is the thing, and code is something a node SHOWS when you expand it. docs/EXPRESSION-FLOOR.md section 3 is now the ladder: system -> module nodes module -> file nodes file -> SYMBOL nodes: one per class, top-level function or const, plus a module body class -> method nodes symbol -> the expression floor: operations, zones as regions, and nested function nodes which open in turn Section 4 keeps only what survives of codopticon: code inside a node drawn as a grid rather than as elements, the per-character semantic byte, decluttering by displacement, ink-as-silhouette, and the three things it gets wrong that our own source would hit (tabs, non-ASCII, one pathological line sizing the grid). ## What the audit found, and this fixes Measured on lib/sections.js: the parser reports 5 symbols, the derivation 16 functions — 12 nested inside another, 8 anonymous callbacks. The 5 are the file level; the other 11 are interiors. So: - `symbols` — the file-level set, derived, in source order, so the view and the model cannot disagree about what a symbol is. kinds: class / function / const / module-body. - `container` on every operation, zone, exit and name (new invariant E4b). Previously only operations carried `fn` and nothing else carried anything, so the interior of a symbol had to be computed by range containment — which is wrong in exactly the case that matters. buildSections contains 24 zones by container and 49 by range: the extra 25 belong to its nested functions. - `parent` on functions, so the nesting is recorded rather than re-derived. - a module-body symbol, because top-level code is real and numerous (87 operations in index.js) and a file level made only of symbols drops it. - a class is a symbol with `members`, which is the rung the old symbol view collapsed into one un-openable node labelled "N methods". ## A bug the audit surfaced Every class method in the model was named `(anonymous)`, because a method's FunctionExpression has no `id`. Names now come from the binding — a method key, a declarator, an object property, an assignment target — so `constructor`, `read` and an arrow bound to `const bump` all carry their real names. GDScript gets the same treatment for a lambda bound to a var. ## Also lib/floor/cells.js turns a span of code into a character grid plus a per-character semantic byte (zone depth in the low bits, operation / exit / named-wire flags above). It was written for the abandoned substrate idea but survives the correction unchanged: it is how code inside an EXPANDED node gets drawn without an element per character. It fixes the three codopticon flaws outright — tabs expanded, non-ASCII given an explicit placeholder slot, pathological lines clipped instead of sizing the grid. `codetree derive <file> --symbols` prints the file level. Tests: 972 asserts, all green. No renderer yet — layout with zones as containers is next, and it is on the critical path because expanding a node changes its height and forces relayout (L4). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c11a0b3283c72d9bd94af1389d77da96d911a905
by plankatron <fleeky@gmail.com> · 2026-09-20T21:52:40.000Z

Changed files (7) +795 -71

docs/EXPRESSION-FLOOR.md +89 -54
……@@ -5,9 +5,10 @@ How a file becomes nodes.
55The node view already climbs `system -> directory -> module -> file -> symbol`,
66and its own contract is that a node at one level opens into a whole graph at the
77next. The symbol level breaks that contract: a symbol node is marked
8un-openable and renders its body as text. This document specifies the rung
9below it, where statements and expressions become the graph, and the text stops
10being text.
8un-openable and renders its whole body as text, always. This document specifies
9the rung below it, where statements and expressions become the graph — and the
10code becomes something a node shows when you expand it, rather than the only
11thing a node can be.
1112
1213Every rule here is derived from the scan. Nothing is authored.
1314
……@@ -144,75 +145,105 @@ callees.
144145
145146---
146147
147## 3. Rendering contract
148## 3. The level ladder
148149
149Adopted from codopticon (Bananaft, bitbucket.org/Bananaft/codopticon), read
1502026-09-20 at `d6802e7`. We take the display technique, not the source: that
151repo ships no LICENSE.
150**Levels are discrete and entered deliberately.** Zoom moves you around inside a
151level; it does not decide which level you are in. An earlier revision of this
152document said the opposite — that the file's text was the substrate and nodes
153were tints painted onto it, positioned by source offset with no layout step. That
154was codopticon's idea, and codopticon works that way because it has NO MODEL:
155text is all it has, so distance is its only axis. We have a model, so the graph
156is the thing and code lives inside it.
152157
153### V1. Position comes from the source; there is no layout step
158| level | nodes | opening a node gives |
159|---|---|---|
160| system | module nodes | that module's files |
161| module | file nodes | that file's symbols |
162| **file** | **symbol nodes** — one per class, top-level function or const, plus one **module body** | that symbol's interior |
163| class | method nodes | that method's interior |
164| symbol | the expression floor: operation nodes, zones as regions, and nested function nodes which open in turn | — |
154165
155A node IS its byte range — a rectangle in the text grid. Nesting is containment,
156free, because AST ranges nest. This is what makes the floor reachable: the
157layout problem disappears instead of being solved.
166### L1. A file-level node is a SYMBOL, not a derived function
158167
159Measured: 0.41 operations per line, 1,602 lines carrying exactly one, and of
16012,176 lines only 25 carry more than six (max 10). In-place placement
161essentially never collides.
168Measured on lib/sections.js: the parser reports 5 symbols; the derivation reports
16916 functions, **12 of them nested inside another** and 8 anonymous callbacks
170handed to `.filter`/`.find`/`.sort`. The 5 are the file level. The other 11 are
171interiors, and because they are themselves openable the nesting gives Blender's
172nested groups for free.
162173
163### V2. Viewport culling replaces model-level LOD
174### L2. Top-level code gets a module-body node
164175
165Because positions are source positions, "what do I build" is a line-range test
166against the camera. The 4,994 nodes are never materialised at once; the visible
167lines are. This only works because of V1.
176Operations with no enclosing function are real and numerous: 87 in index.js, 13
177in lib/derive.js, 9 in lib/config.js. A file level made only of symbol nodes
178drops them silently.
168179
169### V3. Detail bands
180### L3. The interior of a symbol is a QUERY, answered by the model
170181
171| band | drawn |
172|---|---|
173| far | file ink + syntax texture, module labels |
174| mid | margin labels — symbol names, one glyph per zone |
175| near | zone tints over their line spans, nesting as nested tint |
176| nearest | operation boxes on their spans, wires between tokens, literals in sockets |
182Not by range arithmetic in the UI. Range containment is wrong in exactly the case
183that matters — a zone inside a nested closure is also inside its parent's range,
184so the outer symbol would claim it. Every zone, exit, name and operation
185therefore names its container.
177186
178Every node kind therefore needs a one-glyph form as well as a full form.
179codopticon's `L` for a loop is the pattern; ours carries the zone's derived
180facts (trip count, carried state) instead of nothing.
187### L4. Code is expandable and collapsable, per node
181188
182### V4. The gutters are wire canvas
189A node shows its signature until you expand it, then its own source. Expanding
190changes the node's height, which means **relayout** — so layout is not optional
191and zones-as-containers is on the critical path, not beside it.
183192
184Blank cells are not drawn, so indentation columns are empty space: vertical wire
185runs belong there, and the ragged right margin carries the effect chain and the
186carried-state rails. This is what keeps in-place wiring from becoming spaghetti
187over text.
193## 4. Rendering contract
188194
189### V5. The per-character channel carries semantics
195What survives from codopticon (bitbucket.org/Bananaft/codopticon, read
1962026-09-20 at `d6802e7`; technique only, that repo ships no LICENSE):
190197
191The renderer samples a palette index per cell. Same channel, different content:
192zone depth, def/use role, unmated-connector flag. Semantics at every zoom, free.
198### V1. Code inside a node is drawn as a grid, not as elements
193199
194### V6. Tint budget: four levels
200A span of code becomes one quad plus two integer textures — a character grid and
201a per-character semantic byte (`lib/floor/cells.js`). That is what makes code
202affordable inside MANY expanded nodes at once; a DOM or SVG element per character
203is not.
195204
196A 4-level ramp covers 96.5% of zones (1,085 of 1,124). The 39 deeper ones take
197their depth from the margin rail, so nesting never becomes mud. Safe because
198only 31.8% of lines sit inside any zone, with a median span of 3 lines.
205### V2. The per-character channel carries semantics, not syntax
199206
200### V7. Declutter by displacement
207codopticon colours characters from tree-sitter highlight queries. We have no
208tokenizer and do not need one: the channel carries zone depth, operation span,
209exit and named-wire bits, which is the thing codopticon cannot draw. Four tint
210levels cover 96.5% of zones (1,085 of 1,124); deeper nesting takes its depth from
211a margin rail instead of the fill.
201212
202A label that would collide with the previous one is pushed off its true line,
203trading positional accuracy for legibility, exactly as map labels do. This is
204what handles the 25 crowded lines.
213### V3. Detail follows the LEVEL, not the camera distance
205214
206### V8. A condition renders collapsed by default
215| level | a node shows |
216|---|---|
217| system / module | name, counts, its crossing sockets |
218| file | symbol name, kind, one-line signature; code when expanded |
219| symbol | operation label and its sockets; the line(s) it came from when expanded |
207220
208The condition is an expression, so its operations sit on the zone's header line.
209`if (!x)` reads fine; a compound condition would put 3-4 boxes on that line. It
210renders as its source text until expanded on demand. This is the one place the
211in-place rule gets crowded, and the only place we hide structure by default.
221Zoom inside a level pans and scales what is already there. It never swaps one
222level's content for another's.
212223
213---
224### V4. Three things to fix that codopticon gets wrong
225
226Our own source hits all three, so they are requirements, not polish:
227
228- a **tab** is expanded to columns (GDScript is tab-indented; a one-cell tab
229 renders it unindented)
230- a **non-ASCII** character becomes an explicit placeholder glyph rather than
231 indexing off the end of a 128-entry table (our comments are full of em-dashes)
232- one **pathological generated line** cannot size the whole grid
214233
215## 4. Measurements
234### V5. Labels declutter by displacement
235
236A label that would collide with the one before it is pushed off its true line,
237trading positional accuracy for legibility, exactly as map labels do. Measured:
238of 12,176 lines only 25 carry more than six operations, so this is rare.
239
240### V6. A file's ink is its silhouette
241
242Empty cells are not drawn, so a collapsed file or symbol reads as the shape of
243its code — indentation profile, comment blocks, string runs. Free, because the
244text already has a shape.
245
246## 5. Measurements
216247
217248Every number in this document was produced on 2026-09-20 by parsing this repo's
218249own source with the vendored acorn — 25 files, 12,176 lines, excluding `test/`,
……@@ -248,7 +279,7 @@ shape to build first (guards, at 40%).
248279
249280---
250281
251## 5. Language coverage
282## 6. Language coverage
252283
253284The derivation is per-language, and all four parsers keep a full tree, so all
254285four can reach the floor. Verified node names:
……@@ -318,13 +349,13 @@ error nodes, several loops reported as spanning a single line, and three
318349
319350---
320351
321## 6. Invariants
352## 7. Invariants
322353
323354**E1. The derivation is pure.** Same source in, same model out. No clock, no
324355filesystem, no network, no scan state.
325356
326357**E2. Every node, zone and exit carries a byte range and a line/column range.**
327V1 depends on it; a model entry without a position is a bug.
358Expanding a node shows the code at that range; a model entry without a position is a bug.
328359
329360**E3. No orphan exits.** Every exit names a landing site that exists in the same
330361model (Z4). An exit whose target cannot be resolved is reported as such, never
……@@ -333,6 +364,10 @@ dropped.
333364**E4. Zone nesting is a tree.** `parent` is either null or a zone whose range
334365strictly contains this one.
335366
367**E4b. Every interior entry names its container.** An operation, zone, exit and
368name each carry the id of the symbol that contains them, so "what is inside this
369node" is a lookup and never a range comparison (L3).
370
336371**E5. Wires resolve or are marked.** A reference either resolves to a
337372declaration in scope, or is recorded as `free` (a global, an import binding, a
338373builtin). It is never silently discarded.
index.js +21 -3
……@@ -613,7 +613,7 @@ async function cmdDerive () {
613613 }
614614
615615 const want = (name) => {
616 const flags = ['--nodes', '--zones', '--exits', '--names', '--functions']
616 const flags = ['--nodes', '--zones', '--exits', '--names', '--functions', '--symbols']
617617 const asked = flags.filter(f => args.includes(f))
618618 return asked.length === 0 || asked.includes('--' + name)
619619 }
……@@ -645,11 +645,29 @@ async function cmdDerive () {
645645 return l.on
646646 }
647647
648 // The file level: what a node would be if you opened this file (spec L1-L2).
649 if (want('symbols') && model.symbols && model.symbols.length > 0) {
650 console.log('\nFILE-LEVEL SYMBOLS')
651 for (const sym of model.symbols) {
652 // A const symbol has no interior of its own; a class has members; anything
653 // with a body reports what the container query returns for it.
654 const inside = sym.fn
655 ? model.nodes.filter(n => n.container === sym.fn).length + ' ops, ' +
656 model.zones.filter(z => z.container === sym.fn).length + ' zones, ' +
657 model.names.filter(n => n.container === sym.fn).length + ' wires'
658 : (sym.members.length > 0 ? sym.members.length + ' members' : '')
659 console.log(' ' + line(sym.range) + ' ' + sym.kind.padEnd(13) +
660 sym.name.padEnd(26) + inside)
661 }
662 }
663
648664 if (want('functions') && model.functions.length > 0) {
649665 console.log('\nFUNCTIONS')
650666 for (const fn of model.functions) {
651 console.log(' ' + line(fn.range) + ' ' + fn.name + '(' + fn.params.join(', ') + ')' +
652 (fn.async ? ' async' : '') + ' lines ' + fn.range.startLine + '-' + fn.range.endLine)
667 console.log(' ' + line(fn.range) + ' ' + (fn.parent ? ' ' : '') +
668 fn.name + '(' + fn.params.join(', ') + ')' +
669 (fn.async ? ' async' : '') + (fn.parent ? ' (nested)' : '') +
670 ' lines ' + fn.range.startLine + '-' + fn.range.endLine)
653671 }
654672 }
655673
lib/derive-gdscript.js +78 -4
……@@ -78,6 +78,7 @@ export function deriveGDScript (source, filePath) {
7878 const model = {
7979 filePath,
8080 language: 'gdscript',
81 symbols: [],
8182 functions: [],
8283 nodes: [],
8384 names: [],
……@@ -437,6 +438,21 @@ export function deriveGDScript (source, filePath) {
437438 const declaredAt = new Map()
438439 const zoneStack = []
439440 const fnStack = []
441 const classStack = []
442
443 // Top-level code needs a home or it is unreachable from the file view (L2).
444 const moduleBodyId = filePath + ':module-body'
445 let moduleBodyUsed = false
446
447 function container () {
448 if (fnStack.length > 0) return fnStack[fnStack.length - 1].id
449 moduleBodyUsed = true
450 return moduleBodyId
451 }
452
453 // A lambda bound to a name takes that name. The declaration is visited before
454 // its value, so the hint is waiting by the time the lambda is created.
455 const hintByOffset = new Map()
440456
441457 function nearestLoop () {
442458 for (let i = zoneStack.length - 1; i >= 0; i--) {
……@@ -496,15 +512,24 @@ export function deriveGDScript (source, filePath) {
496512
497513 if (FUNCS.has(node.name)) {
498514 const id = childByName(node, 'Identifier')
515 const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null
499516 const fn = {
500517 id: idOf(node),
501 name: id ? textOf(id) : '(lambda)',
518 name: id ? textOf(id) : (hintByOffset.get(node.from) || '(lambda)'),
502519 kind: node.name,
503520 range: rangeOf(node),
521 parent: enclosing ? enclosing.id : null,
504522 params: [],
505523 async: false,
506524 generator: false
507525 }
526 if (classStack.length > 0 && !enclosing) {
527 classStack[classStack.length - 1].members.push(fn.id)
528 } else if (!enclosing && classStack.length === 0) {
529 model.symbols.push({
530 id: fn.id, kind: 'function', name: fn.name, range: fn.range, fn: fn.id, members: []
531 })
532 }
508533 scopes.push(new Map())
509534 pushedScope = true
510535 const params = childByName(node, 'CallParamsParameterNode')
……@@ -524,6 +549,23 @@ export function deriveGDScript (source, filePath) {
524549 pushedFn = fn
525550 }
526551
552 // An inner `class Inner:` is a file-level symbol with method members.
553 let pushedClass = null
554 if (node.name === 'ClassNode') {
555 const cid = childByName(node, 'Identifier')
556 const sym = {
557 id: idOf(node),
558 kind: 'class',
559 name: cid ? textOf(cid) : '(anonymous class)',
560 range: rangeOf(node),
561 fn: null,
562 members: []
563 }
564 if (fnStack.length === 0 && classStack.length === 0) model.symbols.push(sym)
565 classStack.push(sym)
566 pushedClass = sym
567 }
568
527569 if (ZONES.has(node.name)) {
528570 const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
529571 const zone = {
……@@ -534,6 +576,7 @@ export function deriveGDScript (source, filePath) {
534576 range: rangeOf(node),
535577 depth: parent ? parent.depth + 1 : 1,
536578 parent: parent ? parent.id : null,
579 container: container(),
537580 condition: conditionOf(node),
538581 item: itemOf(node),
539582 bands: bandsOf(node),
……@@ -555,7 +598,18 @@ export function deriveGDScript (source, filePath) {
555598 if (decl) {
556599 const name = declaredName(decl)
557600 const value = children(node).filter(c => isExpression(c.name)).pop()
558 const entry = { name, range: rangeOf(node), from: 'none', node: null }
601 const entry = { name, range: rangeOf(node), container: container(), from: 'none', node: null }
602 if (value && FUNCS.has(unwrap(value).node.name)) hintByOffset.set(unwrap(value).node.from, name)
603 // A class-level declaration is a file-level symbol; one inside a function
604 // is just a named wire.
605 if (fnStack.length === 0 && classStack.length === 0 && name) {
606 const inner = value ? unwrap(value).node : null
607 if (!inner || !FUNCS.has(inner.name)) {
608 model.symbols.push({
609 id: idOf(node), kind: 'const', name, range: rangeOf(node), fn: null, members: []
610 })
611 }
612 }
559613 if (value) {
560614 const inner = unwrap(value).node
561615 if (inner.name === 'LiteralExpressionNode' || inner.name === 'String' ||
……@@ -570,12 +624,18 @@ export function deriveGDScript (source, filePath) {
570624 declaredAt.set(name, node.from)
571625 }
572626 } else if (node.name === 'VariableNode' || node.name === 'ConstantNode') {
627 // `var x` with no value, standing alone — still a class-level field.
573628 // `var x` with no value, standing alone.
574629 const parentIsAssign = node.parent && node.parent.name === 'AssignmentExpressionNode'
575630 const name = declaredName(node)
576631 if (!parentIsAssign && name) {
577 model.names.push({ name, range: rangeOf(node), from: 'none', node: null })
632 model.names.push({ name, range: rangeOf(node), container: container(), from: 'none', node: null })
578633 declaredAt.set(name, node.from)
634 if (fnStack.length === 0 && classStack.length === 0) {
635 model.symbols.push({
636 id: idOf(node), kind: 'const', name, range: rangeOf(node), fn: null, members: []
637 })
638 }
579639 }
580640 } else if (OPS.has(node.name)) {
581641 const kind = OPS.get(node.name)
……@@ -585,7 +645,7 @@ export function deriveGDScript (source, filePath) {
585645 label: labelFor(node, kind),
586646 range: rangeOf(node),
587647 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
588 fn: fnStack.length > 0 ? fnStack[fnStack.length - 1].id : null,
648 container: container(),
589649 inputs: []
590650 }
591651 for (const [operand, slot] of operandsOf(node)) {
……@@ -636,6 +696,7 @@ export function deriveGDScript (source, filePath) {
636696 node: node.name === 'ReturnNode' ? idOf(node) : null,
637697 range: rangeOf(node),
638698 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
699 container: container(),
639700 lands
640701 })
641702 if (zoneStack.length > 0) zoneStack[zoneStack.length - 1].exits.push(exitId)
……@@ -653,6 +714,7 @@ export function deriveGDScript (source, filePath) {
653714 zoneStack.pop()
654715 }
655716 if (pushedFn) fnStack.pop()
717 if (pushedClass) classStack.pop()
656718 if (pushedScope) scopes.pop()
657719 }
658720
……@@ -660,6 +722,18 @@ export function deriveGDScript (source, filePath) {
660722 hoist(root)
661723 walk(root)
662724
725 if (moduleBodyUsed) {
726 model.symbols.push({
727 id: moduleBodyId,
728 kind: 'module-body',
729 name: '(module body)',
730 range: rangeOf(root),
731 fn: moduleBodyId,
732 members: []
733 })
734 }
735 model.symbols.sort((a, b) => a.range.startByte - b.range.startByte)
736
663737 // A mis-parse is not a parse failure: lezer recovers and hands back a tree with
664738 // error nodes in it, so the model looks complete while structure has silently
665739 // gone missing — a `for` whose Body was lost stops being a zone, and the
lib/derive.js +128 -10
……@@ -13,9 +13,9 @@
1313 * That single distinction is the difference between 23,164 nodes and ~4,900 on
1414 * this repo, which is the difference between a readable graph and a wall.
1515 *
16 * Everything carries a byte range, because the renderer positions nodes AT their
17 * source location rather than laying them out (spec V1). Ids are derived from
18 * that range and never stored (R7).
16 * Everything carries a byte range, because a node has to be able to show the code
17 * it came from when you expand it, and because ids are derived from that range
18 * and never stored (R7).
1919 *
2020 * JS only for now. GDScript, C++ and GLSL all keep a full tree and can reach the
2121 * floor the same way; they visit only top-level declarations today.
……@@ -129,6 +129,34 @@ function labelFor (node) {
129129 }
130130}
131131
132/**
133 * The name a function takes from the thing it is bound to.
134 *
135 * `const bump = (n) => ...`, `{ read () {} }`, `class S { read () {} }` all give
136 * a function with no `id` of its own. Naming them '(anonymous)' made every class
137 * method in the model anonymous, which makes a class's interior unreadable — so
138 * the binding supplies the name, and the hint travels down one level only.
139 */
140function hintFor (node, key) {
141 if (node.type === 'VariableDeclarator' && key === 'init' && node.id.type === 'Identifier') {
142 return node.id.name
143 }
144 if ((node.type === 'MethodDefinition' || node.type === 'PropertyDefinition') && key === 'value') {
145 if (node.kind === 'constructor') return 'constructor'
146 if (node.key && node.key.type === 'Identifier') return node.key.name
147 if (node.key && node.key.type === 'Literal') return String(node.key.value)
148 }
149 if (node.type === 'Property' && key === 'value') {
150 if (node.key && node.key.type === 'Identifier') return node.key.name
151 if (node.key && node.key.type === 'Literal') return String(node.key.value)
152 }
153 if (node.type === 'AssignmentExpression' && key === 'right') {
154 const p = pathOf(node.left)
155 if (p) return p
156 }
157 return null
158}
159
132160// `await x` / `...x` wrap the thing that matters. Unwrap to the node underneath
133161// and report the markers, so the operand is described once, with its flags.
134162function unwrap (node) {
……@@ -280,6 +308,7 @@ export function deriveJS (source, filePath) {
280308 const empty = {
281309 filePath,
282310 language: 'javascript',
311 symbols: [],
283312 functions: [],
284313 nodes: [],
285314 names: [],
……@@ -370,6 +399,21 @@ export function deriveJS (source, filePath) {
370399 // --- the walk
371400 let zoneStack = []
372401 let fnStack = []
402 let classStack = []
403
404 // Top-level code has to live somewhere or it is unreachable from any node in
405 // the file view (spec L2): index.js has 87 operations outside every function.
406 const moduleBodyId = filePath + ':module-body'
407 let moduleBodyUsed = false
408
409 // The container of anything in the interior: the nearest enclosing function,
410 // else the module body. Never null, so "what is inside this node" is a lookup
411 // and never a range comparison (L3/E4b).
412 function container () {
413 if (fnStack.length > 0) return fnStack[fnStack.length - 1].id
414 moduleBodyUsed = true
415 return moduleBodyId
416 }
373417
374418 function currentLoop () {
375419 for (let i = zoneStack.length - 1; i >= 0; i--) {
……@@ -436,7 +480,7 @@ export function deriveJS (source, filePath) {
436480
437481 const absorbed = new Set()
438482
439 function walk (node) {
483 function walk (node, hint) {
440484 if (!node || typeof node.type !== 'string') return
441485
442486 let pushedScope = false
……@@ -445,15 +489,31 @@ export function deriveJS (source, filePath) {
445489
446490 // --- functions: a new scope, and R6's group interface.
447491 if (FUNCS.has(node.type)) {
492 const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null
448493 const fn = {
449494 id: idOf(filePath, node),
450 name: node.id ? node.id.name : '(anonymous)',
495 name: node.id ? node.id.name : (hint || '(anonymous)'),
451496 kind: node.type,
452497 range: rangeOf(node),
498 parent: enclosing ? enclosing.id : null,
453499 params: [],
454500 async: !!node.async,
455501 generator: !!node.generator
456502 }
503 // A method belongs to its class, not to a function, so it is a member
504 // rather than a file-level symbol.
505 if (classStack.length > 0 && !enclosing) {
506 classStack[classStack.length - 1].members.push(fn.id)
507 } else if (!enclosing && classStack.length === 0) {
508 model.symbols.push({
509 id: fn.id,
510 kind: 'function',
511 name: fn.name,
512 range: fn.range,
513 fn: fn.id,
514 members: []
515 })
516 }
457517 scopes.push(new Map())
458518 pushedScope = true
459519 for (const p of node.params) {
……@@ -467,6 +527,24 @@ export function deriveJS (source, filePath) {
467527 pushedFn = fn
468528 }
469529
530 // --- classes. A class is a file-level symbol with an interior of methods,
531 // which is the rung the old symbol view collapsed into one un-openable node
532 // labelled "N methods".
533 let pushedClass = null
534 if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
535 const sym = {
536 id: idOf(filePath, node),
537 kind: 'class',
538 name: node.id ? node.id.name : (hint || '(anonymous class)'),
539 range: rangeOf(node),
540 fn: null,
541 members: []
542 }
543 if (fnStack.length === 0 && classStack.length === 0) model.symbols.push(sym)
544 classStack.push(sym)
545 pushedClass = sym
546 }
547
470548 // --- zones (R4). An absorbed `else if` link is a band of its parent.
471549 if (ZONES.has(node.type) && !absorbed.has(node)) {
472550 const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
……@@ -478,6 +556,7 @@ export function deriveJS (source, filePath) {
478556 range: rangeOf(node),
479557 depth: parent ? parent.depth + 1 : 1,
480558 parent: parent ? parent.id : null,
559 container: container(),
481560 condition: conditionOf(node, source),
482561 item: itemOf(node),
483562 bands: node.type === 'IfStatement' && node.alternate ? bandsOf(node, absorbed) : null,
……@@ -511,7 +590,7 @@ export function deriveJS (source, filePath) {
511590 label: labelFor(node),
512591 range: rangeOf(node),
513592 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
514 fn: fnStack.length > 0 ? fnStack[fnStack.length - 1].id : null,
593 container: container(),
515594 inputs: []
516595 }
517596 for (const [operand, slot] of operandsOf(node)) {
……@@ -544,6 +623,7 @@ export function deriveJS (source, filePath) {
544623 const entry = {
545624 name: node.id.name,
546625 range: rangeOf(node),
626 container: container(),
547627 from: 'none',
548628 node: null
549629 }
……@@ -568,6 +648,7 @@ export function deriveJS (source, filePath) {
568648 node: OPS.has(node.type) ? idOf(filePath, node) : null,
569649 range: rangeOf(node),
570650 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
651 container: container(),
571652 lands: landingFor(node)
572653 })
573654 if (zoneStack.length > 0) {
……@@ -578,14 +659,34 @@ export function deriveJS (source, filePath) {
578659 // --- reads, for the carried-state pass
579660 if (node.type === 'Identifier') reads.push({ name: node.name, at: node.range[0] })
580661
581 // --- recurse
662 // A top-level declaration whose value is NOT a function is a const symbol.
663 // (The function case already became a symbol when the function was created.)
664 if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' &&
665 fnStack.length === 0 && classStack.length === 0) {
666 const init = node.init ? unwrap(node.init).node : null
667 const isCallable = init && (FUNCS.has(init.type) ||
668 init.type === 'ClassDeclaration' || init.type === 'ClassExpression')
669 if (!isCallable) {
670 model.symbols.push({
671 id: idOf(filePath, node),
672 kind: 'const',
673 name: node.id.name,
674 range: rangeOf(node),
675 fn: null,
676 members: []
677 })
678 }
679 }
680
681 // --- recurse, carrying a name hint exactly one level down
582682 for (const key of Object.keys(node)) {
583683 if (SKIP_KEYS.has(key)) continue
584684 const value = node[key]
685 const childHint = hintFor(node, key)
585686 if (Array.isArray(value)) {
586 for (const child of value) walk(child)
687 for (const child of value) walk(child, childHint)
587688 } else if (value && typeof value === 'object' && typeof value.type === 'string') {
588 walk(value)
689 walk(value, childHint)
589690 }
590691 }
591692
……@@ -594,10 +695,27 @@ export function deriveJS (source, filePath) {
594695 zoneStack.pop()
595696 }
596697 if (pushedFn) fnStack.pop()
698 if (pushedClass) classStack.pop()
597699 if (pushedScope) scopes.pop()
598700 }
599701
600 walk(ast)
702 walk(ast, null)
703
704 // The module body is emitted only if something actually landed in it — a file
705 // of pure declarations has no top-level code to show.
706 if (moduleBodyUsed) {
707 model.symbols.push({
708 id: moduleBodyId,
709 kind: 'module-body',
710 name: '(module body)',
711 range: rangeOf(ast),
712 fn: moduleBodyId,
713 members: []
714 })
715 }
716
717 // Source order, so the file view is stable and diffable.
718 model.symbols.sort((a, b) => a.range.startByte - b.range.startByte)
601719
602720 // --- Z3: a zone's outputs are its phis. A name written inside the zone, whose
603721 // declaration is OUTSIDE it, and which is read after the zone closes, converges
lib/floor/cells.js +182 -0
……@@ -0,0 +1,182 @@
1/**
2 * A file, as a grid of character cells with one byte of derived meaning each.
3 *
4 * This is the bridge between the model and the renderer. A span of code becomes
5 * one quad plus two integer textures — a character grid and a per-character
6 * semantic byte — which is what makes code affordable INSIDE many expanded nodes
7 * at once, where an element per character is not. Specified in
8 * docs/EXPRESSION-FLOOR.md V1-V2. It draws code inside a node; it is NOT a
9 * substrate that the graph is painted onto (an earlier revision of the spec said
10 * that, and it was wrong: levels are discrete and the graph is the thing).
11 *
12 * The byte is a bitfield, because a cell is often several things at once — a
13 * `return` inside two nested zones is depth 2 AND an operation AND an exit:
14 *
15 * bits 0-2 zone depth, 0-7, SATURATING (a deeper nesting clamps, never wraps)
16 * bit 3 inside an operation's span
17 * bit 4 inside an exit's span
18 * bit 5 inside a named wire's span
19 *
20 * Three things codopticon gets wrong that are fixed here, because our own source
21 * would hit all three: a tab is EXPANDED (GDScript is tab-indented, and a
22 * one-cell tab renders it unindented), a non-ASCII character becomes an explicit
23 * placeholder glyph instead of indexing off the end of a 128-entry table
24 * (codetree's comments are full of em-dashes), and one pathological generated
25 * line cannot size the whole grid.
26 */
27
28export const CELL = {
29 DEPTH_MASK: 0x07,
30 OP: 0x08,
31 EXIT: 0x10,
32 NAME: 0x20,
33
34 // The atlas reserves slot 127 for "a character we cannot draw".
35 NON_ASCII_GLYPH: 127
36}
37
38const DEFAULTS = {
39 tabWidth: 2,
40 colLimit: 400
41}
42
43/**
44 * @param {string} source - the file, verbatim
45 * @param {object} model - what deriveFile() returned for it
46 * @returns {object} { cols, rows, text, cells, ... } — text and cells are
47 * Uint8Array(cols*rows), row-major, NUL-padded.
48 */
49export function buildCells (source, model, opts = {}) {
50 const tabWidth = opts.tabWidth || DEFAULTS.tabWidth
51 const colLimit = opts.colLimit || DEFAULTS.colLimit
52
53 // --- pass 1: lay characters out into (row, col), expanding tabs.
54 //
55 // `cellOf` maps a source offset to its flat cell index, so a model range can
56 // be painted later without re-deriving anything. -1 means "this offset has no
57 // cell" — a newline, a carriage return, or a column past the limit.
58 const cellOf = new Int32Array(source.length).fill(-1)
59 const colAt = new Int32Array(source.length).fill(-1)
60 const rowAt = new Int32Array(source.length).fill(-1)
61
62 let row = 0
63 let col = 0
64 let maxCol = 0
65 let truncatedLines = 0
66 let lineTruncated = false
67
68 for (let i = 0; i < source.length; i++) {
69 const ch = source.charCodeAt(i)
70
71 if (ch === 10) { // \n
72 if (col > maxCol) maxCol = col
73 if (lineTruncated) { truncatedLines++; lineTruncated = false }
74 row++
75 col = 0
76 continue
77 }
78 if (ch === 13) continue // \r — never occupies a cell
79
80 if (ch === 9) { // tab
81 const next = Math.floor(col / tabWidth) * tabWidth + tabWidth
82 col = next
83 continue
84 }
85
86 if (col >= colLimit) {
87 lineTruncated = true
88 col++
89 continue
90 }
91
92 rowAt[i] = row
93 colAt[i] = col
94 col++
95 }
96 if (col > maxCol) maxCol = col
97 if (lineTruncated) truncatedLines++
98
99 const rows = row + 1
100 const cols = Math.max(1, Math.min(maxCol, colLimit))
101
102 // --- pass 2: write the glyph grid.
103 const text = new Uint8Array(cols * rows)
104 const cells = new Uint8Array(cols * rows)
105 let nonAscii = 0
106
107 for (let i = 0; i < source.length; i++) {
108 const c = colAt[i]
109 if (c < 0 || c >= cols) continue
110 const idx = rowAt[i] * cols + c
111 cellOf[i] = idx
112
113 const ch = source.charCodeAt(i)
114 if (ch === 32) continue // a space is an empty cell, not a glyph
115 if (ch < 127) {
116 text[idx] = ch
117 } else {
118 text[idx] = CELL.NON_ASCII_GLYPH
119 nonAscii++
120 }
121 }
122
123 // --- pass 3: paint the model onto the grid.
124 //
125 // Painting is per-OFFSET, not per-line, so a zone that starts mid-line tints
126 // only from where it actually starts — the `if` in `} else if (x) {` does not
127 // claim the whole line.
128 function paintDepth (start, end, depth) {
129 const clamped = Math.min(depth, CELL.DEPTH_MASK)
130 for (let i = start; i < end && i < source.length; i++) {
131 const idx = cellOf[i]
132 if (idx < 0) continue
133 // Deepest wins: an inner zone's tint must not be overwritten by the outer
134 // zone painting over it afterwards.
135 if ((cells[idx] & CELL.DEPTH_MASK) < clamped) {
136 cells[idx] = (cells[idx] & ~CELL.DEPTH_MASK) | clamped
137 }
138 }
139 }
140
141 function paintBit (start, end, bit) {
142 for (let i = start; i < end && i < source.length; i++) {
143 const idx = cellOf[i]
144 if (idx < 0) continue
145 cells[idx] |= bit
146 }
147 }
148
149 let maxDepth = 0
150 const zones = model.zones || []
151 // Shallowest first, so `deepest wins` above needs no second thought.
152 const ordered = zones.slice().sort((a, b) => a.depth - b.depth)
153 for (const z of ordered) {
154 if (z.depth > maxDepth) maxDepth = z.depth
155 paintDepth(z.range.startByte, z.range.endByte, z.depth)
156 }
157 for (const n of model.nodes || []) paintBit(n.range.startByte, n.range.endByte, CELL.OP)
158 for (const e of model.exits || []) paintBit(e.range.startByte, e.range.endByte, CELL.EXIT)
159 for (const n of model.names || []) paintBit(n.range.startByte, n.range.endByte, CELL.NAME)
160
161 return {
162 filePath: model.filePath,
163 language: model.language || null,
164 cols,
165 rows,
166 text,
167 cells,
168 tabWidth,
169 colLimit,
170 nonAscii,
171 truncatedLines,
172 maxDepth,
173 partial: !!model.partial,
174 partialReason: model.partialReason || null,
175 counts: {
176 zones: zones.length,
177 nodes: (model.nodes || []).length,
178 exits: (model.exits || []).length,
179 names: (model.names || []).length
180 }
181 }
182}
test/derive-levels.test.js +166 -0
……@@ -0,0 +1,166 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import fs from 'fs'
5import { deriveFile } from '../lib/derive.js'
6
7const HERE = dirname(fileURLToPath(import.meta.url))
8const FIXTURE = resolve(HERE, 'fixtures/derive')
9
10// The LEVEL LADDER (spec section 3). A file-level node is a SYMBOL — a class, a
11// top-level function, a const, or the module body — and "what is inside it" must
12// be a lookup, never a range comparison, because a zone inside a nested closure
13// is also inside its parent's range (L3).
14
15const SRC = [
16 "import { helper } from './h.js'", // 1
17 '', // 2
18 'const LIMIT = 10', // 3 symbol: const
19 '', // 4
20 'export function tally (rows) {', // 5 symbol: function
21 ' let total = 0', // 6
22 ' for (const r of rows) {', // 7 zone, container = tally
23 ' const bump = (n) => n + 1', // 8 nested function, named from its binding
24 ' total = bump(total)', // 9
25 ' }', // 10
26 ' return total', // 11 exit, container = tally
27 '}', // 12
28 '', // 13
29 'export class Store {', // 14 symbol: class
30 ' constructor (dir) {', // 15 member
31 ' this._dir = dir', // 16
32 ' }', // 17
33 '', // 18
34 ' async read (name) {', // 19 member
35 ' if (!name) return null', // 20 zone + exit, container = read
36 ' return helper(this._dir, name)', // 21
37 ' }', // 22
38 '}', // 23
39 '', // 24
40 'const ready = tally([1, 2])', // 25 TOP-LEVEL operation -> module body
41 'console.log(ready, LIMIT)', // 26 top-level too
42 ''
43].join('\n')
44
45function model () {
46 return deriveFile(SRC, '/x/levels.js')
47}
48
49test('levels: the file level is symbols, not derived functions', (t) => {
50 const m = model()
51 t.ok(Array.isArray(m.symbols), 'the model names the file-level set')
52
53 const byName = {}
54 for (const s of m.symbols) byName[s.name] = s
55 // `ready` counts: a top-level declaration whose value is not callable is a
56 // const symbol, exactly as the parser's symbol list has always treated it.
57 t.alike(Object.keys(byName).sort(),
58 ['(module body)', 'LIMIT', 'Store', 'ready', 'tally'].sort(), 'five file-level nodes')
59
60 t.is(byName.LIMIT.kind, 'const', 'LIMIT is a const')
61 t.is(byName.ready.kind, 'const', 'and so is ready')
62 t.is(byName.tally.kind, 'function', 'tally is a function')
63 t.is(byName.Store.kind, 'class', 'Store is a class')
64 t.is(byName['(module body)'].kind, 'module-body', 'and the module body exists')
65
66 // `bump` is nested inside tally, so it is NOT a file-level node (L1).
67 t.absent(byName.bump, 'a nested function is an interior, not a file-level node')
68})
69
70test('levels: a nested function is named from its binding, not anonymous', (t) => {
71 const m = model()
72 const names = m.functions.map(f => f.name)
73 t.ok(names.includes('bump'), 'the arrow bound to `bump` takes that name')
74 t.ok(names.includes('read'), 'a class method takes its key as its name')
75 t.ok(names.includes('constructor'), 'including the constructor')
76 t.absent(names.includes('(anonymous)'), 'nothing in this file is anonymous')
77})
78
79test('levels: functions carry their parent', (t) => {
80 const m = model()
81 const byName = {}
82 for (const f of m.functions) byName[f.name] = f
83
84 t.is(byName.tally.parent, null, 'a top-level function has no parent')
85 t.is(byName.bump.parent, byName.tally.id, 'bump`s parent is tally')
86 t.is(byName.read.parent, null, 'a method is not nested inside another function')
87})
88
89test('levels: a class node has members', (t) => {
90 const m = model()
91 const store = m.symbols.find(s => s.name === 'Store')
92 t.is(store.members.length, 2, 'two members')
93
94 const memberNames = store.members
95 .map(id => m.functions.find(f => f.id === id))
96 .map(f => f.name)
97 t.alike(memberNames.sort(), ['constructor', 'read'], 'constructor and read')
98})
99
100test('levels: L3 — the interior of a symbol is a lookup', (t) => {
101 const m = model()
102 const tally = m.symbols.find(s => s.name === 'tally')
103 const read = m.functions.find(f => f.name === 'read')
104 const bump = m.functions.find(f => f.name === 'bump')
105
106 // Every interior entry names its container (E4b).
107 for (const list of [m.nodes, m.zones, m.exits, m.names]) {
108 for (const item of list) {
109 t.ok('container' in item, 'entry carries a container')
110 }
111 }
112
113 const inTally = m.zones.filter(z => z.container === tally.fn)
114 t.is(inTally.length, 1, 'tally directly contains one zone — the for loop')
115
116 const inRead = m.zones.filter(z => z.container === read.id)
117 t.is(inRead.length, 1, 'read contains one zone — the guard')
118
119 // The discriminating case: range containment would give the WRONG answer for
120 // anything inside `bump`, because bump sits inside tally's range.
121 const bumpOps = m.nodes.filter(n => n.container === bump.id)
122 t.is(bumpOps.length, 1, 'the + belongs to bump')
123 const tallyOpsByRange = m.nodes.filter(n =>
124 n.range.startByte >= tally.range.startByte && n.range.endByte <= tally.range.endByte)
125 const tallyOpsByContainer = m.nodes.filter(n => n.container === tally.fn)
126 t.ok(tallyOpsByRange.length > tallyOpsByContainer.length,
127 'range containment over-claims: ' + tallyOpsByRange.length + ' vs ' + tallyOpsByContainer.length)
128})
129
130test('levels: L2 — top-level code belongs to the module body', (t) => {
131 const m = model()
132 const body = m.symbols.find(s => s.kind === 'module-body')
133
134 const topOps = m.nodes.filter(n => n.container === body.id)
135 t.ok(topOps.length >= 2, 'the top-level call and the log are in the module body')
136 t.absent(m.nodes.find(n => n.container === null), 'no operation is left homeless')
137
138 const topNames = m.names.filter(n => n.container === body.id).map(n => n.name)
139 t.ok(topNames.includes('LIMIT'), 'and so are the top-level named wires')
140 t.ok(topNames.includes('ready'), 'both of them')
141})
142
143test('levels: every symbol has a position and a derived id', (t) => {
144 const m = model()
145 for (const s of m.symbols) {
146 t.ok(Number.isInteger(s.range.startByte), s.name + ' has a range')
147 t.ok(s.range.startLine >= 1, s.name + ' has a line')
148 t.ok(typeof s.id === 'string' && s.id.length > 0, s.name + ' has an id')
149 }
150})
151
152test('levels: gdscript agrees on the ladder', (t) => {
153 const src = fs.readFileSync(resolve(FIXTURE, 'sample.gd'), 'utf8')
154 const m = deriveFile(src, resolve(FIXTURE, 'sample.gd'))
155
156 const kinds = {}
157 for (const s of m.symbols) kinds[s.kind] = (kinds[s.kind] || 0) + 1
158 t.is(kinds.function, 2, 'tally and label are file-level functions')
159 t.ok(kinds['const'] >= 1, 'the class-level var is a file-level const')
160
161 const tally = m.symbols.find(s => s.name === 'tally')
162 const zones = m.zones.filter(z => z.container === tally.fn)
163 t.ok(zones.length > 0, 'and its zones name it as their container')
164
165 for (const f of m.functions) t.ok('parent' in f, 'functions carry parent')
166})
test/floor-cells.test.js +131 -0
……@@ -0,0 +1,131 @@
1import { test } from 'brittle'
2import { buildCells, CELL } from '../lib/floor/cells.js'
3import { deriveFile } from '../lib/derive.js'
4
5// The renderer draws a file as a grid of character cells, and each cell carries
6// ONE byte of derived meaning (spec V5). This is that byte, and it is the only
7// part of the renderer that can be tested without a GPU — so it is where the
8// correctness lives.
9
10function grid (source, path = '/x/t.js') {
11 return buildCells(source, deriveFile(source, path))
12}
13
14function cellAt (g, line, col) {
15 return g.cells[(line - 1) * g.cols + col]
16}
17function charAt (g, line, col) {
18 return String.fromCharCode(g.text[(line - 1) * g.cols + col])
19}
20
21test('floor cells: the grid is the text, verbatim', (t) => {
22 const g = grid('const a = 1\nconst bb = 22\n')
23
24 t.is(g.rows, 3, '3 rows — the trailing newline makes an empty last line')
25 t.is(g.cols, 13, 'cols is the longest line')
26 t.is(charAt(g, 1, 0), 'c', 'first char')
27 t.is(charAt(g, 2, 6), 'b', 'second line, seventh column')
28 t.is(g.text[(1 - 1) * g.cols + 12], 0, 'short lines are padded with NUL, not spaces')
29})
30
31test('floor cells: a tab is expanded, and ranges still land correctly', (t) => {
32 // codopticon draws a tab as ONE cell, so tab-indented code looks unindented.
33 // GDScript is tab-indented, so this is not optional for us.
34 const g = buildCells('if (x) {\n\treturn 1\n}\n', deriveFile('if (x) {\n\treturn 1\n}\n', '/x/t.js'), { tabWidth: 4 })
35
36 // The tab leaves EMPTY cells (NUL) rather than spaces — the renderer discards
37 // empty cells, so emitting spaces would be pure work for no pixels.
38 t.is(g.text[(2 - 1) * g.cols + 0], 0, 'the tab occupies empty cells')
39 t.is(g.text[(2 - 1) * g.cols + 3], 0, 'all four of them')
40 t.is(charAt(g, 2, 4), 'r', 'and `return` starts at column 4')
41 t.is(g.tabWidth, 4, 'reports the width it used')
42})
43
44test('floor cells: non-ASCII is replaced and counted, never mangled', (t) => {
45 // codetree's own source is full of em-dashes. codopticon indexes a 128-entry
46 // glyph table by raw byte, so this is exactly where it would break.
47 const g = grid('// a — b\nconst x = 1\n')
48
49 t.is(g.nonAscii, 1, 'one non-ASCII character counted')
50 t.is(g.text[(1 - 1) * g.cols + 5], CELL.NON_ASCII_GLYPH, 'replaced by the placeholder slot')
51 t.is(charAt(g, 1, 7), 'b', 'and the rest of the line keeps its columns')
52})
53
54test('floor cells: a pathological line is truncated, not allowed to size the grid', (t) => {
55 const long = 'const x = "' + 'y'.repeat(900) + '"\n'
56 const g = buildCells(long + 'const a = 1\n', deriveFile(long + 'const a = 1\n', '/x/t.js'), { colLimit: 120 })
57
58 t.is(g.cols, 120, 'the grid is capped')
59 t.ok(g.truncatedLines > 0, 'and says how many lines it clipped')
60})
61
62test('floor cells: zone depth is in the low bits', (t) => {
63 const src = [
64 'function f (rows) {', // 1
65 ' for (const r of rows) {', // 2 zone depth 1
66 ' if (r.skip) {', // 3 zone depth 2
67 ' continue', // 4 depth 2
68 ' }', // 5
69 ' }', // 6
70 '}', // 7
71 ''
72 ].join('\n')
73 const g = grid(src)
74
75 const depth = (line, col) => cellAt(g, line, col) & CELL.DEPTH_MASK
76 t.is(depth(1, 0), 0, 'the function signature is outside every zone')
77 t.is(depth(2, 2), 1, 'the for line is depth 1')
78 t.is(depth(3, 4), 2, 'the if inside it is depth 2')
79 t.is(depth(4, 6), 2, 'its body is depth 2')
80 t.is(depth(7, 0), 0, 'and we are back out at the end')
81})
82
83test('floor cells: depth saturates instead of overflowing its bits', (t) => {
84 let src = 'function f (a) {\n'
85 for (let i = 0; i < 10; i++) src += ' '.repeat(i + 1) + 'if (a) {\n'
86 src += ' '.repeat(12) + 'return 1\n'
87 for (let i = 10; i > 0; i--) src += ' '.repeat(i) + '}\n'
88 src += '}\n'
89 const g = grid(src)
90
91 let max = 0
92 for (const c of g.cells) max = Math.max(max, c & CELL.DEPTH_MASK)
93 t.is(max, CELL.DEPTH_MASK, 'clamped to the mask, never wrapping to 0')
94 t.ok(g.maxDepth > CELL.DEPTH_MASK, 'while still reporting the real depth: ' + g.maxDepth)
95})
96
97test('floor cells: operation, exit and name bits mark their own spans', (t) => {
98 const src = 'function f (a) {\n const b = a + 1\n return b\n}\n'
99 const g = grid(src)
100
101 // `a + 1` is an operation; the `+` sits at column 14 of line 2.
102 t.ok(cellAt(g, 2, 14) & CELL.OP, 'the + is inside an operation span')
103 t.absent(cellAt(g, 1, 0) & CELL.OP, 'the function keyword is not')
104
105 // `const b = a + 1` names a wire — and the span starts at the IDENTIFIER, not
106 // at `const`: a declarator's range excludes the keyword, which belongs to the
107 // enclosing declaration.
108 t.absent(cellAt(g, 2, 2) & CELL.NAME, 'the `const` keyword is outside the span')
109 t.ok(cellAt(g, 2, 8) & CELL.NAME, 'the name `b` starts it')
110 t.ok(cellAt(g, 2, 14) & CELL.NAME, 'and it runs to the end of the initialiser')
111
112 // `return b` is an exit.
113 t.ok(cellAt(g, 3, 2) & CELL.EXIT, 'the return is marked as an exit')
114 t.absent(cellAt(g, 2, 2) & CELL.EXIT, 'the declaration is not an exit')
115})
116
117test('floor cells: a partial model still renders, and says so', (t) => {
118 const src = 'static func f():\n\tfor r in [1]:\n\t\tcontinue\n'
119 const g = buildCells(src, deriveFile(src, '/x/t.gd'))
120
121 t.ok(g.rows > 0, 'it still produced a grid')
122 t.ok(g.partial, 'and carries the model\'s partial flag forward to the view')
123})
124
125test('floor cells: purity — same input, identical output', (t) => {
126 const src = 'function f (a) {\n if (a) return 1\n return 0\n}\n'
127 const a = grid(src)
128 const b = grid(src)
129 t.alike(Array.from(a.cells), Array.from(b.cells), 'cells identical')
130 t.alike(Array.from(a.text), Array.from(b.text), 'text identical')
131})