Skip to content

Commit 55a76e0

Browse files
timfennisclaude
andcommitted
docs: add analyser readability review πŸ“
Point-in-time assessment of ndc_analyser: verdict that no major refactor is mandatory (one optional, low-ROI theme β€” separating resolution from slot allocation β€” to keep behind the DefId work), plus a prioritized, behaviour- preserving incremental readability backlog. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 98be07d commit 55a76e0

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Analyser readability review (`ndc_analyser`)
2+
3+
> Point-in-time review captured alongside the LSP work (PR #164). File/line
4+
> references are a snapshot and will drift as the code changes; treat them as
5+
> starting points, not exact coordinates.
6+
7+
## Context
8+
9+
The semantic analyser is the hardest part of the project to hold in one's head.
10+
This review answers two questions, with the upcoming DefId/LSP-resolution work in
11+
mind: (1) are there any *big* issues that only a major refactor can fix, and (2)
12+
what *incremental* readability improvements are worth making? It proposes no
13+
behaviour changes β€” it is an assessment plus a backlog. Sizes at time of writing:
14+
`analyser.rs` ~930 lines, `scope.rs` ~1432 (~1038 production + ~394 tests),
15+
`lib.rs` 5.
16+
17+
## Headline verdict
18+
19+
**No mandatory major refactor.** The architecture is sound for the language's feature
20+
set: `Analyser` is a thin client over `ScopeTree`; types flow through side tables
21+
(`expr_types`, `inferred_return_types`) plus in-place AST annotation (`resolved`,
22+
`captures`, `inferred_type`); the compiler consumes the annotated AST. Nothing is
23+
boxed-in. The difficulty is **accumulated local complexity**, which is entirely
24+
addressable incrementally.
25+
26+
---
27+
28+
## Part 1 β€” Big issues
29+
30+
### The one architectural theme: `ScopeTree` conflates resolution + slot allocation
31+
32+
`ScopeTree` (`scope.rs`) does two jobs at once: **lexical name resolution** (which
33+
declaration a name means) and **VM stack-slot allocation** (the concrete `usize`
34+
`ResolvedVar::{Local,Upvalue,Global}{slot}`). This entanglement is the root of both
35+
the intricacy (the `base_offset` / `function_scope_idx` / upvalue-hoisting math
36+
threaded through every lookup) and the "slot isn't a stable identity" limitation that
37+
bit the LSP.
38+
39+
**Feasibility (investigated):**
40+
- The compiler is already **semi-independent** of analyser slots: it keeps its own
41+
`num_locals` and only `max`es it against declared slots (`compiler.rs:513,758,792,811`),
42+
and allocates its own temporaries (`compiler.rs:280-282,483-484`). So *local* slot
43+
numbering could plausibly move to the compiler.
44+
- **Globals** are purely positional in the `FunctionRegistry` iteration order
45+
(`interpreter/src/lib.rs:221-229`, `scope.rs:370-384`, `vm.rs:204`) β€” moving their
46+
assignment needs a stable name→slot map handed compiler-side. Contained, not hard.
47+
- **Upvalues/captures are the hard, tightly-coupled part** and *not cleanly
48+
separable*: the analyser computes `CaptureSource::{Local,Upvalue}(index)`
49+
(`scope.rs:474-480,988-1025`), the compiler embeds it verbatim into `OpCode::Closure`
50+
(`compiler.rs:736-742`), and the VM indexes `upvalues[slot]` directly (`vm.rs:491-532`).
51+
Critically, **discovering captures *is* a name-resolution activity** (you must
52+
resolve names across function boundaries to know what escapes), and the index *is*
53+
the layout β€” so "separating resolution from layout" buys little here.
54+
- Other consumers: REPL resume leans on `Compiler::num_locals` (`interpreter/src/lib.rs`
55+
~251/257/276); the LSP reads `ResolvedVar` but not slot numbers. Coupling surface is
56+
small (~6 files, ~70 lines).
57+
58+
**Verdict: feasible but low-ROI β€” recommend shelving.** It would mostly relocate
59+
local-slot bookkeeping; it would *not* simplify the genuinely hard code (upvalue
60+
hoisting, overload resolution β€” both intrinsic). It also touches the runtime hot path
61+
(closure creation, REPL resume) for moderate risk. The planned **DefId side-table**
62+
gives the LSP the stable identity it needs *without* this refactor, and would be the
63+
natural seam if this is ever revisited. **Do DefId first; reconsider this only if a
64+
concrete need appears.**
65+
66+
### Not-big, but worth knowing
67+
- `resolve_call` / `scalar_walk` (the 5-case overload + tuple-broadcast cascade,
68+
`scope.rs:531-715`) is the most complex algorithm, but the complexity is *intrinsic*
69+
(overloading Γ— vectorization Γ— closures). It is well-documented; it can be made more
70+
readable (Part 2) but not fundamentally simpler without dropping features.
71+
72+
---
73+
74+
## Part 2 β€” Incremental readability backlog (prioritized)
75+
76+
All behaviour-preserving. Ordered by (value Γ· risk). Each is independently shippable.
77+
78+
### Batch 1 β€” High value, near-zero risk (pure moves/renames/docs)
79+
1. **Extract big `analyse_inner` arms into methods.** `analyse_inner` is ~367 lines
80+
(`analyser.rs:104-470`). Move `FunctionDeclaration` (`282-371`, ~90 lines) β†’
81+
`analyse_function_declaration`, `OpAssignment` (`194-281`, ~88 lines) β†’
82+
`analyse_op_assignment`, `Assignment` (`169-193`) β†’ `analyse_assignment`. Leaves the
83+
dispatcher a scannable table of one-liners. **Biggest single win.**
84+
2. **Fix `span` shadowing** in `resolve_lvalue_declarative` (`analyser.rs:721-757`): the
85+
`Lvalue::Identifier { span, .. }` destructure shadows the method's `span` param β€”
86+
rename one. Genuine footgun.
87+
3. **Module-level orientation docs.** Add a short "how analysis works" header to
88+
`analyser.rs` and `scope.rs` (two-phase function pre-registration; slot numbering &
89+
`base_offset`; upvalue hoisting; the 5-case resolution). Document the `base_offset` /
90+
`function_scope_idx` / `env_scopes` invariants once at their definitions
91+
(`scope.rs:128-135`). Highest orientation ROI.
92+
4. **Fix the `TOOD` typo** (`analyser.rs:538`) and capture the "get this from the AST
93+
when the parser adds it" note as a real TODO.md entry / issue.
94+
95+
### Batch 2 β€” Dedupe tricky logic (low risk, removes copy-paste)
96+
5. **Unify "widen binding or error".** The same widen-then-check-annotation block
97+
appears 3Γ—: `analyser.rs:178-189` (Assignment), `244-255` (OpAssignment ident),
98+
`265-272` (OpAssignment index). Extract one helper
99+
`widen_binding(target, widened, value_type, span)`.
100+
6. **Extract the upvalue-chain follower** in `scope.rs`. The `CaptureSource::Local |
101+
Upvalue` walk is duplicated in `get_type` (`387-413`) and `get_binding_mut`
102+
(`898-932`), and echoed in `hoist_upvalue` (`988-1025`). A `follow_upvalue_chain`
103+
helper removes the worst `scope.rs` duplication.
104+
7. **Naming pass.** `sig`/`type_sig` β†’ consistent `arg_types`; `loose` β†’
105+
`compatible_candidates`; `scope_ptr` β†’ `scope_idx`; `env_scopes` β†’
106+
`crossed_fn_boundaries` (+ doc). Cheap, high comprehension value.
107+
108+
### Batch 3 β€” Structural tidy (small risk, needs tests first)
109+
8. **Collapse dual error storage.** `Analyser.errors` (`analyser.rs:37`) duplicates
110+
`AnalysisResult.errors`, reconciled in `take_result` (`60-64`). Emit straight into
111+
`result.errors` and drop the field (check `emit`/`emit_external`/`has_errors`).
112+
9. **Split `scalar_walk`** (`scope.rs:639-715`): factor the per-scope body
113+
(find-exact / collect-loose / collect-all-by-name) into a `scan_scope` helper used by
114+
both the loop and the global-scope fallback, removing the duplicated fallback block
115+
(`647-651` vs `683-688`).
116+
10. **Extract `resolve_lvalue_declarative`'s Sequence arm** (`analyser.rs:~762-819`)
117+
into `resolve_sequence_lvalue`; it's long, nested, and has a shadow of `found_type`.
118+
11. *(Optional)* **Error-constructor boilerplate** (`analyser.rs:~853-929`): 11
119+
`Self { text: format!(...), span }` constructors β€” a tiny macro or `new(span, msg)`
120+
helper trims repetition. Low priority (currently readable).
121+
122+
### Supporting: characterization tests (do before Batch 3)
123+
`scope.rs` tests (~`1039-1432`, 20 cases) cover scope/upvalue mechanics well but **omit
124+
`resolve_call`'s vec/dynamic paths** (`resolve_vec`, `VecResolution`, `Binding::Dynamic`,
125+
`dynamic_return_type`, `extend_dedup`). Add characterization tests for the 5 resolution
126+
cases first β€” they document behaviour *and* de-risk items 8–10.
127+
128+
---
129+
130+
## Recommended sequence & verification
131+
132+
If/when executed: Batch 1 β†’ Batch 2 β†’ (add resolution tests) β†’ Batch 3, one small PR
133+
per item, each gated on `cargo test` (workspace), `cargo clippy` clean, `cargo fmt`. The
134+
functional suite (`tests/functional`) plus the `scope.rs` unit tests are the safety net;
135+
the new characterization tests harden the riskiest area before it's touched. No item
136+
changes runtime behaviour, so a green suite is sufficient verification.

0 commit comments

Comments
Β (0)