| … | … | @@ -5,9 +5,10 @@ How a file becomes nodes. |
| 5 | 5 | The node view already climbs `system -> directory -> module -> file -> symbol`, |
| 6 | 6 | and its own contract is that a node at one level opens into a whole graph at the |
| 7 | 7 | next. The symbol level breaks that contract: a symbol node is marked |
| 8 | un-openable and renders its body as text. This document specifies the rung | |
| 9 | below it, where statements and expressions become the graph, and the text stops | |
| 10 | being text. | |
| 8 | un-openable and renders its whole body as text, always. This document specifies | |
| 9 | the rung below it, where statements and expressions become the graph — and the | |
| 10 | code becomes something a node shows when you expand it, rather than the only | |
| 11 | thing a node can be. | |
| 11 | 12 | |
| 12 | 13 | Every rule here is derived from the scan. Nothing is authored. |
| 13 | 14 | |
| … | … | @@ -144,75 +145,105 @@ callees. |
| 144 | 145 | |
| 145 | 146 | --- |
| 146 | 147 | |
| 147 | ## 3. Rendering contract | |
| 148 | ## 3. The level ladder | |
| 148 | 149 | |
| 149 | Adopted from codopticon (Bananaft, bitbucket.org/Bananaft/codopticon), read | |
| 150 | 2026-09-20 at `d6802e7`. We take the display technique, not the source: that | |
| 151 | repo ships no LICENSE. | |
| 150 | **Levels are discrete and entered deliberately.** Zoom moves you around inside a | |
| 151 | level; it does not decide which level you are in. An earlier revision of this | |
| 152 | document said the opposite — that the file's text was the substrate and nodes | |
| 153 | were tints painted onto it, positioned by source offset with no layout step. That | |
| 154 | was codopticon's idea, and codopticon works that way because it has NO MODEL: | |
| 155 | text is all it has, so distance is its only axis. We have a model, so the graph | |
| 156 | is the thing and code lives inside it. | |
| 152 | 157 | |
| 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 | — | | |
| 154 | 165 | |
| 155 | A node IS its byte range — a rectangle in the text grid. Nesting is containment, | |
| 156 | free, because AST ranges nest. This is what makes the floor reachable: the | |
| 157 | layout problem disappears instead of being solved. | |
| 166 | ### L1. A file-level node is a SYMBOL, not a derived function | |
| 158 | 167 | |
| 159 | Measured: 0.41 operations per line, 1,602 lines carrying exactly one, and of | |
| 160 | 12,176 lines only 25 carry more than six (max 10). In-place placement | |
| 161 | essentially never collides. | |
| 168 | Measured on lib/sections.js: the parser reports 5 symbols; the derivation reports | |
| 169 | 16 functions, **12 of them nested inside another** and 8 anonymous callbacks | |
| 170 | handed to `.filter`/`.find`/`.sort`. The 5 are the file level. The other 11 are | |
| 171 | interiors, and because they are themselves openable the nesting gives Blender's | |
| 172 | nested groups for free. | |
| 162 | 173 | |
| 163 | ### V2. Viewport culling replaces model-level LOD | |
| 174 | ### L2. Top-level code gets a module-body node | |
| 164 | 175 | |
| 165 | Because positions are source positions, "what do I build" is a line-range test | |
| 166 | against the camera. The 4,994 nodes are never materialised at once; the visible | |
| 167 | lines are. This only works because of V1. | |
| 176 | Operations with no enclosing function are real and numerous: 87 in index.js, 13 | |
| 177 | in lib/derive.js, 9 in lib/config.js. A file level made only of symbol nodes | |
| 178 | drops them silently. | |
| 168 | 179 | |
| 169 | ### V3. Detail bands | |
| 180 | ### L3. The interior of a symbol is a QUERY, answered by the model | |
| 170 | 181 | |
| 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 | | |
| 182 | Not by range arithmetic in the UI. Range containment is wrong in exactly the case | |
| 183 | that matters — a zone inside a nested closure is also inside its parent's range, | |
| 184 | so the outer symbol would claim it. Every zone, exit, name and operation | |
| 185 | therefore names its container. | |
| 177 | 186 | |
| 178 | Every node kind therefore needs a one-glyph form as well as a full form. | |
| 179 | codopticon's `L` for a loop is the pattern; ours carries the zone's derived | |
| 180 | facts (trip count, carried state) instead of nothing. | |
| 187 | ### L4. Code is expandable and collapsable, per node | |
| 181 | 188 | |
| 182 | ### V4. The gutters are wire canvas | |
| 189 | A node shows its signature until you expand it, then its own source. Expanding | |
| 190 | changes the node's height, which means **relayout** — so layout is not optional | |
| 191 | and zones-as-containers is on the critical path, not beside it. | |
| 183 | 192 | |
| 184 | Blank cells are not drawn, so indentation columns are empty space: vertical wire | |
| 185 | runs belong there, and the ragged right margin carries the effect chain and the | |
| 186 | carried-state rails. This is what keeps in-place wiring from becoming spaghetti | |
| 187 | over text. | |
| 193 | ## 4. Rendering contract | |
| 188 | 194 | |
| 189 | ### V5. The per-character channel carries semantics | |
| 195 | What survives from codopticon (bitbucket.org/Bananaft/codopticon, read | |
| 196 | 2026-09-20 at `d6802e7`; technique only, that repo ships no LICENSE): | |
| 190 | 197 | |
| 191 | The renderer samples a palette index per cell. Same channel, different content: | |
| 192 | zone 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 | |
| 193 | 199 | |
| 194 | ### V6. Tint budget: four levels | |
| 200 | A span of code becomes one quad plus two integer textures — a character grid and | |
| 201 | a per-character semantic byte (`lib/floor/cells.js`). That is what makes code | |
| 202 | affordable inside MANY expanded nodes at once; a DOM or SVG element per character | |
| 203 | is not. | |
| 195 | 204 | |
| 196 | A 4-level ramp covers 96.5% of zones (1,085 of 1,124). The 39 deeper ones take | |
| 197 | their depth from the margin rail, so nesting never becomes mud. Safe because | |
| 198 | only 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 | |
| 199 | 206 | |
| 200 | ### V7. Declutter by displacement | |
| 207 | codopticon colours characters from tree-sitter highlight queries. We have no | |
| 208 | tokenizer and do not need one: the channel carries zone depth, operation span, | |
| 209 | exit and named-wire bits, which is the thing codopticon cannot draw. Four tint | |
| 210 | levels cover 96.5% of zones (1,085 of 1,124); deeper nesting takes its depth from | |
| 211 | a margin rail instead of the fill. | |
| 201 | 212 | |
| 202 | A label that would collide with the previous one is pushed off its true line, | |
| 203 | trading positional accuracy for legibility, exactly as map labels do. This is | |
| 204 | what handles the 25 crowded lines. | |
| 213 | ### V3. Detail follows the LEVEL, not the camera distance | |
| 205 | 214 | |
| 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 | | |
| 207 | 220 | |
| 208 | The 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 | |
| 210 | renders as its source text until expanded on demand. This is the one place the | |
| 211 | in-place rule gets crowded, and the only place we hide structure by default. | |
| 221 | Zoom inside a level pans and scales what is already there. It never swaps one | |
| 222 | level's content for another's. | |
| 212 | 223 | |
| 213 | --- | |
| 224 | ### V4. Three things to fix that codopticon gets wrong | |
| 225 | ||
| 226 | Our 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 | |
| 214 | 233 | |
| 215 | ## 4. Measurements | |
| 234 | ### V5. Labels declutter by displacement | |
| 235 | ||
| 236 | A label that would collide with the one before it is pushed off its true line, | |
| 237 | trading positional accuracy for legibility, exactly as map labels do. Measured: | |
| 238 | of 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 | ||
| 242 | Empty cells are not drawn, so a collapsed file or symbol reads as the shape of | |
| 243 | its code — indentation profile, comment blocks, string runs. Free, because the | |
| 244 | text already has a shape. | |
| 245 | ||
| 246 | ## 5. Measurements | |
| 216 | 247 | |
| 217 | 248 | Every number in this document was produced on 2026-09-20 by parsing this repo's |
| 218 | 249 | own source with the vendored acorn — 25 files, 12,176 lines, excluding `test/`, |
| … | … | @@ -248,7 +279,7 @@ shape to build first (guards, at 40%). |
| 248 | 279 | |
| 249 | 280 | --- |
| 250 | 281 | |
| 251 | ## 5. Language coverage | |
| 282 | ## 6. Language coverage | |
| 252 | 283 | |
| 253 | 284 | The derivation is per-language, and all four parsers keep a full tree, so all |
| 254 | 285 | four can reach the floor. Verified node names: |
| … | … | @@ -318,13 +349,13 @@ error nodes, several loops reported as spanning a single line, and three |
| 318 | 349 | |
| 319 | 350 | --- |
| 320 | 351 | |
| 321 | ## 6. Invariants | |
| 352 | ## 7. Invariants | |
| 322 | 353 | |
| 323 | 354 | **E1. The derivation is pure.** Same source in, same model out. No clock, no |
| 324 | 355 | filesystem, no network, no scan state. |
| 325 | 356 | |
| 326 | 357 | **E2. Every node, zone and exit carries a byte range and a line/column range.** |
| 327 | V1 depends on it; a model entry without a position is a bug. | |
| 358 | Expanding a node shows the code at that range; a model entry without a position is a bug. | |
| 328 | 359 | |
| 329 | 360 | **E3. No orphan exits.** Every exit names a landing site that exists in the same |
| 330 | 361 | model (Z4). An exit whose target cannot be resolved is reported as such, never |
| … | … | @@ -333,6 +364,10 @@ dropped. |
| 333 | 364 | **E4. Zone nesting is a tree.** `parent` is either null or a zone whose range |
| 334 | 365 | strictly contains this one. |
| 335 | 366 | |
| 367 | **E4b. Every interior entry names its container.** An operation, zone, exit and | |
| 368 | name each carry the id of the symbol that contains them, so "what is inside this | |
| 369 | node" is a lookup and never a range comparison (L3). | |
| 370 | ||
| 336 | 371 | **E5. Wires resolve or are marked.** A reference either resolves to a |
| 337 | 372 | declaration in scope, or is recorded as `free` (a global, an import binding, a |
| 338 | 373 | builtin). It is never silently discarded. |
| … | … | @@ -613,7 +613,7 @@ async function cmdDerive () { |
| 613 | 613 | } |
| 614 | 614 | |
| 615 | 615 | const want = (name) => { |
| 616 | const flags = ['--nodes', '--zones', '--exits', '--names', '--functions'] | |
| 616 | const flags = ['--nodes', '--zones', '--exits', '--names', '--functions', '--symbols'] | |
| 617 | 617 | const asked = flags.filter(f => args.includes(f)) |
| 618 | 618 | return asked.length === 0 || asked.includes('--' + name) |
| 619 | 619 | } |
| … | … | @@ -645,11 +645,29 @@ async function cmdDerive () { |
| 645 | 645 | return l.on |
| 646 | 646 | } |
| 647 | 647 | |
| 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 | ||
| 648 | 664 | if (want('functions') && model.functions.length > 0) { |
| 649 | 665 | console.log('\nFUNCTIONS') |
| 650 | 666 | 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) | |
| 653 | 671 | } |
| 654 | 672 | } |
| 655 | 673 |
| … | … | @@ -78,6 +78,7 @@ export function deriveGDScript (source, filePath) { |
| 78 | 78 | const model = { |
| 79 | 79 | filePath, |
| 80 | 80 | language: 'gdscript', |
| 81 | symbols: [], | |
| 81 | 82 | functions: [], |
| 82 | 83 | nodes: [], |
| 83 | 84 | names: [], |
| … | … | @@ -437,6 +438,21 @@ export function deriveGDScript (source, filePath) { |
| 437 | 438 | const declaredAt = new Map() |
| 438 | 439 | const zoneStack = [] |
| 439 | 440 | 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() | |
| 440 | 456 | |
| 441 | 457 | function nearestLoop () { |
| 442 | 458 | for (let i = zoneStack.length - 1; i >= 0; i--) { |
| … | … | @@ -496,15 +512,24 @@ export function deriveGDScript (source, filePath) { |
| 496 | 512 | |
| 497 | 513 | if (FUNCS.has(node.name)) { |
| 498 | 514 | const id = childByName(node, 'Identifier') |
| 515 | const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null | |
| 499 | 516 | const fn = { |
| 500 | 517 | id: idOf(node), |
| 501 | name: id ? textOf(id) : '(lambda)', | |
| 518 | name: id ? textOf(id) : (hintByOffset.get(node.from) || '(lambda)'), | |
| 502 | 519 | kind: node.name, |
| 503 | 520 | range: rangeOf(node), |
| 521 | parent: enclosing ? enclosing.id : null, | |
| 504 | 522 | params: [], |
| 505 | 523 | async: false, |
| 506 | 524 | generator: false |
| 507 | 525 | } |
| 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 | } | |
| 508 | 533 | scopes.push(new Map()) |
| 509 | 534 | pushedScope = true |
| 510 | 535 | const params = childByName(node, 'CallParamsParameterNode') |
| … | … | @@ -524,6 +549,23 @@ export function deriveGDScript (source, filePath) { |
| 524 | 549 | pushedFn = fn |
| 525 | 550 | } |
| 526 | 551 | |
| 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 | ||
| 527 | 569 | if (ZONES.has(node.name)) { |
| 528 | 570 | const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null |
| 529 | 571 | const zone = { |
| … | … | @@ -534,6 +576,7 @@ export function deriveGDScript (source, filePath) { |
| 534 | 576 | range: rangeOf(node), |
| 535 | 577 | depth: parent ? parent.depth + 1 : 1, |
| 536 | 578 | parent: parent ? parent.id : null, |
| 579 | container: container(), | |
| 537 | 580 | condition: conditionOf(node), |
| 538 | 581 | item: itemOf(node), |
| 539 | 582 | bands: bandsOf(node), |
| … | … | @@ -555,7 +598,18 @@ export function deriveGDScript (source, filePath) { |
| 555 | 598 | if (decl) { |
| 556 | 599 | const name = declaredName(decl) |
| 557 | 600 | 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 | } | |
| 559 | 613 | if (value) { |
| 560 | 614 | const inner = unwrap(value).node |
| 561 | 615 | if (inner.name === 'LiteralExpressionNode' || inner.name === 'String' || |
| … | … | @@ -570,12 +624,18 @@ export function deriveGDScript (source, filePath) { |
| 570 | 624 | declaredAt.set(name, node.from) |
| 571 | 625 | } |
| 572 | 626 | } else if (node.name === 'VariableNode' || node.name === 'ConstantNode') { |
| 627 | // `var x` with no value, standing alone — still a class-level field. | |
| 573 | 628 | // `var x` with no value, standing alone. |
| 574 | 629 | const parentIsAssign = node.parent && node.parent.name === 'AssignmentExpressionNode' |
| 575 | 630 | const name = declaredName(node) |
| 576 | 631 | 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 }) | |
| 578 | 633 | 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 | } | |
| 579 | 639 | } |
| 580 | 640 | } else if (OPS.has(node.name)) { |
| 581 | 641 | const kind = OPS.get(node.name) |
| … | … | @@ -585,7 +645,7 @@ export function deriveGDScript (source, filePath) { |
| 585 | 645 | label: labelFor(node, kind), |
| 586 | 646 | range: rangeOf(node), |
| 587 | 647 | zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null, |
| 588 | fn: fnStack.length > 0 ? fnStack[fnStack.length - 1].id : null, | |
| 648 | container: container(), | |
| 589 | 649 | inputs: [] |
| 590 | 650 | } |
| 591 | 651 | for (const [operand, slot] of operandsOf(node)) { |
| … | … | @@ -636,6 +696,7 @@ export function deriveGDScript (source, filePath) { |
| 636 | 696 | node: node.name === 'ReturnNode' ? idOf(node) : null, |
| 637 | 697 | range: rangeOf(node), |
| 638 | 698 | zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null, |
| 699 | container: container(), | |
| 639 | 700 | lands |
| 640 | 701 | }) |
| 641 | 702 | if (zoneStack.length > 0) zoneStack[zoneStack.length - 1].exits.push(exitId) |
| … | … | @@ -653,6 +714,7 @@ export function deriveGDScript (source, filePath) { |
| 653 | 714 | zoneStack.pop() |
| 654 | 715 | } |
| 655 | 716 | if (pushedFn) fnStack.pop() |
| 717 | if (pushedClass) classStack.pop() | |
| 656 | 718 | if (pushedScope) scopes.pop() |
| 657 | 719 | } |
| 658 | 720 | |
| … | … | @@ -660,6 +722,18 @@ export function deriveGDScript (source, filePath) { |
| 660 | 722 | hoist(root) |
| 661 | 723 | walk(root) |
| 662 | 724 | |
| 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 | ||
| 663 | 737 | // A mis-parse is not a parse failure: lezer recovers and hands back a tree with |
| 664 | 738 | // error nodes in it, so the model looks complete while structure has silently |
| 665 | 739 | // gone missing — a `for` whose Body was lost stops being a zone, and the |
| … | … | @@ -13,9 +13,9 @@ |
| 13 | 13 | * That single distinction is the difference between 23,164 nodes and ~4,900 on |
| 14 | 14 | * this repo, which is the difference between a readable graph and a wall. |
| 15 | 15 | * |
| 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). | |
| 19 | 19 | * |
| 20 | 20 | * JS only for now. GDScript, C++ and GLSL all keep a full tree and can reach the |
| 21 | 21 | * floor the same way; they visit only top-level declarations today. |
| … | … | @@ -129,6 +129,34 @@ function labelFor (node) { |
| 129 | 129 | } |
| 130 | 130 | } |
| 131 | 131 | |
| 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 | */ | |
| 140 | function 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 | ||
| 132 | 160 | // `await x` / `...x` wrap the thing that matters. Unwrap to the node underneath |
| 133 | 161 | // and report the markers, so the operand is described once, with its flags. |
| 134 | 162 | function unwrap (node) { |
| … | … | @@ -280,6 +308,7 @@ export function deriveJS (source, filePath) { |
| 280 | 308 | const empty = { |
| 281 | 309 | filePath, |
| 282 | 310 | language: 'javascript', |
| 311 | symbols: [], | |
| 283 | 312 | functions: [], |
| 284 | 313 | nodes: [], |
| 285 | 314 | names: [], |
| … | … | @@ -370,6 +399,21 @@ export function deriveJS (source, filePath) { |
| 370 | 399 | // --- the walk |
| 371 | 400 | let zoneStack = [] |
| 372 | 401 | 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 | } | |
| 373 | 417 | |
| 374 | 418 | function currentLoop () { |
| 375 | 419 | for (let i = zoneStack.length - 1; i >= 0; i--) { |
| … | … | @@ -436,7 +480,7 @@ export function deriveJS (source, filePath) { |
| 436 | 480 | |
| 437 | 481 | const absorbed = new Set() |
| 438 | 482 | |
| 439 | function walk (node) { | |
| 483 | function walk (node, hint) { | |
| 440 | 484 | if (!node || typeof node.type !== 'string') return |
| 441 | 485 | |
| 442 | 486 | let pushedScope = false |
| … | … | @@ -445,15 +489,31 @@ export function deriveJS (source, filePath) { |
| 445 | 489 | |
| 446 | 490 | // --- functions: a new scope, and R6's group interface. |
| 447 | 491 | if (FUNCS.has(node.type)) { |
| 492 | const enclosing = fnStack.length > 0 ? fnStack[fnStack.length - 1] : null | |
| 448 | 493 | const fn = { |
| 449 | 494 | id: idOf(filePath, node), |
| 450 | name: node.id ? node.id.name : '(anonymous)', | |
| 495 | name: node.id ? node.id.name : (hint || '(anonymous)'), | |
| 451 | 496 | kind: node.type, |
| 452 | 497 | range: rangeOf(node), |
| 498 | parent: enclosing ? enclosing.id : null, | |
| 453 | 499 | params: [], |
| 454 | 500 | async: !!node.async, |
| 455 | 501 | generator: !!node.generator |
| 456 | 502 | } |
| 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 | } | |
| 457 | 517 | scopes.push(new Map()) |
| 458 | 518 | pushedScope = true |
| 459 | 519 | for (const p of node.params) { |
| … | … | @@ -467,6 +527,24 @@ export function deriveJS (source, filePath) { |
| 467 | 527 | pushedFn = fn |
| 468 | 528 | } |
| 469 | 529 | |
| 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 | ||
| 470 | 548 | // --- zones (R4). An absorbed `else if` link is a band of its parent. |
| 471 | 549 | if (ZONES.has(node.type) && !absorbed.has(node)) { |
| 472 | 550 | const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null |
| … | … | @@ -478,6 +556,7 @@ export function deriveJS (source, filePath) { |
| 478 | 556 | range: rangeOf(node), |
| 479 | 557 | depth: parent ? parent.depth + 1 : 1, |
| 480 | 558 | parent: parent ? parent.id : null, |
| 559 | container: container(), | |
| 481 | 560 | condition: conditionOf(node, source), |
| 482 | 561 | item: itemOf(node), |
| 483 | 562 | bands: node.type === 'IfStatement' && node.alternate ? bandsOf(node, absorbed) : null, |
| … | … | @@ -511,7 +590,7 @@ export function deriveJS (source, filePath) { |
| 511 | 590 | label: labelFor(node), |
| 512 | 591 | range: rangeOf(node), |
| 513 | 592 | zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null, |
| 514 | fn: fnStack.length > 0 ? fnStack[fnStack.length - 1].id : null, | |
| 593 | container: container(), | |
| 515 | 594 | inputs: [] |
| 516 | 595 | } |
| 517 | 596 | for (const [operand, slot] of operandsOf(node)) { |
| … | … | @@ -544,6 +623,7 @@ export function deriveJS (source, filePath) { |
| 544 | 623 | const entry = { |
| 545 | 624 | name: node.id.name, |
| 546 | 625 | range: rangeOf(node), |
| 626 | container: container(), | |
| 547 | 627 | from: 'none', |
| 548 | 628 | node: null |
| 549 | 629 | } |
| … | … | @@ -568,6 +648,7 @@ export function deriveJS (source, filePath) { |
| 568 | 648 | node: OPS.has(node.type) ? idOf(filePath, node) : null, |
| 569 | 649 | range: rangeOf(node), |
| 570 | 650 | zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null, |
| 651 | container: container(), | |
| 571 | 652 | lands: landingFor(node) |
| 572 | 653 | }) |
| 573 | 654 | if (zoneStack.length > 0) { |
| … | … | @@ -578,14 +659,34 @@ export function deriveJS (source, filePath) { |
| 578 | 659 | // --- reads, for the carried-state pass |
| 579 | 660 | if (node.type === 'Identifier') reads.push({ name: node.name, at: node.range[0] }) |
| 580 | 661 | |
| 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 | |
| 582 | 682 | for (const key of Object.keys(node)) { |
| 583 | 683 | if (SKIP_KEYS.has(key)) continue |
| 584 | 684 | const value = node[key] |
| 685 | const childHint = hintFor(node, key) | |
| 585 | 686 | if (Array.isArray(value)) { |
| 586 | for (const child of value) walk(child) | |
| 687 | for (const child of value) walk(child, childHint) | |
| 587 | 688 | } else if (value && typeof value === 'object' && typeof value.type === 'string') { |
| 588 | walk(value) | |
| 689 | walk(value, childHint) | |
| 589 | 690 | } |
| 590 | 691 | } |
| 591 | 692 | |
| … | … | @@ -594,10 +695,27 @@ export function deriveJS (source, filePath) { |
| 594 | 695 | zoneStack.pop() |
| 595 | 696 | } |
| 596 | 697 | if (pushedFn) fnStack.pop() |
| 698 | if (pushedClass) classStack.pop() | |
| 597 | 699 | if (pushedScope) scopes.pop() |
| 598 | 700 | } |
| 599 | 701 | |
| 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) | |
| 601 | 719 | |
| 602 | 720 | // --- Z3: a zone's outputs are its phis. A name written inside the zone, whose |
| 603 | 721 | // declaration is OUTSIDE it, and which is read after the zone closes, converges |
| … | … | @@ -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 | ||
| 28 | export 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 | ||
| 38 | const 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 | */ | |
| 49 | export 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 | } |
| … | … | @@ -0,0 +1,166 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import fs from 'fs' | |
| 5 | import { deriveFile } from '../lib/derive.js' | |
| 6 | ||
| 7 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 8 | const 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 | ||
| 15 | const 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 | ||
| 45 | function model () { | |
| 46 | return deriveFile(SRC, '/x/levels.js') | |
| 47 | } | |
| 48 | ||
| 49 | test('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 | ||
| 70 | test('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 | ||
| 79 | test('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 | ||
| 89 | test('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 | ||
| 100 | test('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 | ||
| 130 | test('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 | ||
| 143 | test('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 | ||
| 152 | test('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 | }) |
| … | … | @@ -0,0 +1,131 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { buildCells, CELL } from '../lib/floor/cells.js' | |
| 3 | import { 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 | ||
| 10 | function grid (source, path = '/x/t.js') { | |
| 11 | return buildCells(source, deriveFile(source, path)) | |
| 12 | } | |
| 13 | ||
| 14 | function cellAt (g, line, col) { | |
| 15 | return g.cells[(line - 1) * g.cols + col] | |
| 16 | } | |
| 17 | function charAt (g, line, col) { | |
| 18 | return String.fromCharCode(g.text[(line - 1) * g.cols + col]) | |
| 19 | } | |
| 20 | ||
| 21 | test('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 | ||
| 31 | test('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 | ||
| 44 | test('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 | ||
| 54 | test('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 | ||
| 62 | test('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 | ||
| 83 | test('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 | ||
| 97 | test('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 | ||
| 117 | test('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 | ||
| 125 | test('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 | }) |