mt-lang 0.3.38 → 0.3.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/docs/lsp-performance.md +347 -0
  3. data/lib/milk_tea/base.rb +1 -1
  4. data/lib/milk_tea/core/ast.rb +2 -2
  5. data/lib/milk_tea/core/bindings/attribute_binding.rb +1 -6
  6. data/lib/milk_tea/core/bindings/module_binding.rb +1 -1
  7. data/lib/milk_tea/core/intrinsics.rb +23 -1
  8. data/lib/milk_tea/core/lowering/functions.rb +3 -0
  9. data/lib/milk_tea/core/lowering/resolve.rb +4 -65
  10. data/lib/milk_tea/core/lowering/utils.rb +0 -17
  11. data/lib/milk_tea/core/lowering.rb +1 -2
  12. data/lib/milk_tea/core/module_binder.rb +4 -15
  13. data/lib/milk_tea/core/module_loader.rb +0 -2
  14. data/lib/milk_tea/core/parser/declarations.rb +24 -17
  15. data/lib/milk_tea/core/semantic_analyzer/analysis_context.rb +2 -111
  16. data/lib/milk_tea/core/semantic_analyzer/expressions.rb +1 -2
  17. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +26 -17
  18. data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +26 -42
  19. data/lib/milk_tea/core/semantic_analyzer/type_compatibility.rb +0 -59
  20. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +0 -2
  21. data/lib/milk_tea/core/types/predicates.rb +57 -0
  22. data/lib/milk_tea/core/types.rb +0 -4
  23. data/lib/milk_tea/lsp/diagnostics.rb +15 -5
  24. data/lib/milk_tea/lsp/server/code_actions.rb +0 -4
  25. data/lib/milk_tea/lsp/server/completion.rb +100 -77
  26. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +4 -6
  27. data/lib/milk_tea/lsp/server/formatting.rb +13 -3
  28. data/lib/milk_tea/lsp/server/hover.rb +321 -41
  29. data/lib/milk_tea/lsp/server/lifecycle.rb +12 -0
  30. data/lib/milk_tea/lsp/server/references.rb +3 -1
  31. data/lib/milk_tea/lsp/server/semantic_tokens.rb +55 -16
  32. data/lib/milk_tea/lsp/server/text_documents.rb +2 -2
  33. data/lib/milk_tea/lsp/server/type_hierarchy.rb +2 -2
  34. data/lib/milk_tea/lsp/server/utilities.rb +4 -0
  35. data/lib/milk_tea/lsp/server.rb +9 -0
  36. data/lib/milk_tea/lsp/workspace/analysis.rb +14 -3
  37. data/lib/milk_tea/lsp/workspace/caches.rb +17 -1
  38. data/lib/milk_tea/lsp/workspace/collection.rb +4 -0
  39. data/lib/milk_tea/lsp/workspace/module_index.rb +134 -0
  40. data/lib/milk_tea/lsp/workspace/store.rb +9 -4
  41. data/lib/milk_tea/lsp/workspace.rb +8 -0
  42. data/lib/milk_tea/tooling/formatter.rb +2 -3
  43. data/lib/milk_tea/tooling/linter/fix_engine.rb +46 -0
  44. data/lib/milk_tea/tooling/linter/rules.rb +32 -0
  45. data/lib/milk_tea/tooling/linter.rb +4 -0
  46. data/lib/milk_tea/tooling.rb +0 -1
  47. metadata +4 -3
  48. data/lib/milk_tea/tooling/cst_formatter.rb +0 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 952280dd2ad14bc3b5751fc7e8a3d29b39363293b1436cc1b3b546c31c938427
4
- data.tar.gz: e0bdf3468681b07914143c32b0b9cc2470cdbeaf55cf81994186003dc406261b
3
+ metadata.gz: 3a57605301758f1ff1307cfb5aa7300bb73876be082b9736f853bf14dce18352
4
+ data.tar.gz: 8d540b3326a74c0a8370d5799b19eadb78f8d579899ffdfed263847bbfa0c367
5
5
  SHA512:
6
- metadata.gz: 9dcf6f6c955f1428b5374315782fda121669ae3c12a8c84d683205cb44bb4ca7903465e70447860e89c0753695f01af513903e93b82987838fa724be19fc7b2f
7
- data.tar.gz: 67c259de17bdd81a4f5785d7dbe05cf39ab959ea51c1f27ed2b05a516ed97ce91a4b2291aa415e0abaa695337f1fc1825ead4acdfceb480a9be4db3cd3a24dd4
6
+ metadata.gz: c7ff36f0b7a609a7c4dbd69264c70ee7dca22201d520753e254609a3009844c23572c578cbdb5802afdd04897559b578f1e539b5c9c6c2a97c64f16f5e286062
7
+ data.tar.gz: 1987f20a6c919b3c8341a339c6473292bf31866a07ffe1805707153b077a282284686366dd299eeb02e91de23a83eec1b8aaa837fc901eadefe58acf54df07a9
@@ -0,0 +1,347 @@
1
+ # LSP Performance Research
2
+
3
+ Research into how to drastically improve Milk Tea LSP performance, especially
4
+ `textDocument/completion`, grounded in the profile from
5
+ `test/tooling/lsp/server/all_endpoints_benchmark.rb` and the industry-standard
6
+ techniques used by clangd, SourceKit-LSP, Deno's LSP, Shopify's ruby-lsp,
7
+ typescript-language-server, and Roslyn.
8
+
9
+ ## 1. Profile snapshot (pre-optimization baseline)
10
+
11
+ > The table below is the **pre-optimization baseline** measured against the
12
+ > original implementation. Every row it describes has since been addressed; see
13
+ > §8 for current measurements. Retained for reference.
14
+
15
+ Measured on Ruby 4.0.3 + YJIT (via `RUBY_YJIT_ENABLE=1`) against a synthetic
16
+ multi-module workspace (10 iterations per endpoint, facts pre-warmed). The
17
+ `didOpen`/`didChange` rows use the benchmark's tiny scratch file; all other
18
+ rows use the 3-module main file. `didOpen`/`didChange` cost grows with module
19
+ complexity, so the real-world figures for large modules will be higher than
20
+ the scratch-file numbers shown here:
21
+
22
+ | endpoint | avg ms (run-to-run range) | dominant stage |
23
+ |---|---|---|
24
+ | `textDocument/completion` (import line) | 130–200 | `import_context` |
25
+ | `textDocument/didOpen` (scratch file) | 15–30 | eager facts + diagnostics enqueue |
26
+ | `milkTea/debugInfo` | 10–20 | re-parse + semantic tokens rebuild |
27
+ | `textDocument/didChange` (scratch file) | 9–14 | eager facts + dependency refresh |
28
+ | `textDocument/documentSymbol` | 5–18 (max 41–63) | symbols + AST enrichment |
29
+ | `textDocument/formatting` | 3–7 | full-file formatter |
30
+ | `textDocument/completion` (body) | 0.5–0.9 | facts-driven, cached |
31
+ | all other request endpoints | < 4 | mostly cached |
32
+
33
+ Numbers vary run-to-run (the benchmark's diagnostic workers are stopped for
34
+ measurement, but GC and filesystem cache warm-up still add noise), so ranges
35
+ are shown rather than single values. The one pathological hotspot is completion
36
+ on an `import` line; everything else is healthy once facts are warm.
37
+
38
+ ## 2. The completion hotspot: `import_completions`
39
+
40
+ > Historical analysis of the original hotspot. The filesystem walk described
41
+ > here was replaced by the persistent module index (§8, item 1); `module_dir_contains_mt?`
42
+ > no longer exists.
43
+
44
+ `lib/milk_tea/lsp/server/completion.rb` ran **on every keystroke** when the
45
+ current line starts with `import `. For each module root it:
46
+
47
+ 1. walked the full directory tree recursively via `module_dir_contains_mt?`
48
+ to decide whether a subdirectory is importable, and
49
+ 2. stat'ed every entry.
50
+
51
+ Measured cost against this repo's tree: **4,720 directories and 573 `.mt`
52
+ files, ~130–220 ms, zero caching, every keystroke**. The same scan was repeated
53
+ for each module root returned by `roots_for_path` (for a `/tmp` path this
54
+ resolved to the single repo root; a package workspace with `std` and project
55
+ roots would repeat the scan per root). There was no persistent module index.
56
+
57
+ ## 3. Ruby constraints that shape the solution
58
+
59
+ - **GVL means threads don't parallelize Ruby CPU work.** The LSP already spawns
60
+ diagnostics workers and a definition-warmup thread, but CPU-heavy sema
61
+ (`SemanticAnalyzer`) is serialized on the GVL. Background threads help only
62
+ for I/O-bound work and debouncing, not for raw parallelism.
63
+ - **`Dir.glob`/`Dir.children` release the GVL frequently.** Since Ruby 3.4
64
+ (`ruby/ruby#20587`, `#21119`) directory iteration releases the GVL per entry;
65
+ when *another* thread is CPU-heavy, `Dir.glob` gets dramatically slower
66
+ (reported up to 50×). The LSP's own background threads can therefore make an
67
+ uncached directory walk *worse* — another reason to stop walking the
68
+ filesystem on the hot path.
69
+ - **mtime-based cache invalidation is a common Ruby approach** (Bootsnap's
70
+ load-path cache), but it is **not** a single `stat` per root: directory mtime
71
+ only changes for files added/removed in that exact directory, so Bootsnap
72
+ records and re-validates the mtime of *every* scanned directory (its author
73
+ estimates "thousands of stat(2) syscalls" on large repos). For this LSP the
74
+ cleaner invalidation source is the existing `didChangeWatchedFiles` events;
75
+ see §4.1.
76
+ - **YJIT helps.** Hot Ruby loops (token classification, AST walks, prefix
77
+ filtering) benefit measurably; the server should be launched with YJIT
78
+ enabled (the launcher at `lib/milk_tea/tooling/cli/commands/lsp.rb` currently
79
+ does not force it, but inherits the process default).
80
+ - **Bounded, lazy per-item work.** Fetching documentation and resolving
81
+ definition tokens *for every candidate on every request* multiplies the cost
82
+ by the candidate count (see §4.4).
83
+
84
+ ## 4. Industry-standard techniques, mapped to this codebase
85
+
86
+ ### 4.1 Build a persistent module index (highest impact)
87
+
88
+ All serious LSPs index the workspace once and serve queries from memory:
89
+
90
+ - **clangd** maintains a `SymbolIndex` (file index + background index) layered
91
+ behind a `MergedIndex`; completion for global symbols reads the index, not
92
+ the AST.
93
+ - **SourceKit-LSP** maintains an index store for cross-file queries (definitions,
94
+ references, call hierarchy). Notably, completion does **not** use the index
95
+ store — it operates on the current file's AST plus its prepared target — which
96
+ keeps completion latency independent of index staleness.
97
+ - **Shopify ruby-lsp** builds a `RubyIndexer` — a prefix tree of all indexed
98
+ constants/methods, populated once at `initialized`, invalidated by file
99
+ watching, and reused by completion, definition, hover, and workspace symbol.
100
+ They specifically replaced a recursive visitor with a queue-based collector
101
+ for a ~25% indexing speedup (`ruby-lsp#1171`) and replaced prefix-tree
102
+ recursion with an explicit queue (`ruby-lsp#3401`).
103
+
104
+ **Recommendation:** add a `ModuleIndex` to the LSP `Workspace` that, on
105
+ `initialized` and on `workspace/didChangeWatchedFiles`, scans each module root
106
+ once and records the importable module names (`{root => {name => [path]}}`).
107
+ `import_completions` then filters the in-memory index by the typed prefix —
108
+ reducing the ~130–220 ms filesystem walk to a sub-millisecond hash lookup. This
109
+ also feeds `workspace/symbol` and global completion candidates.
110
+
111
+ On invalidation, prefer the LSP's existing `didChangeWatchedFiles` events (the
112
+ server already registers for them) over directory-mtime revalidation. A single
113
+ root-directory mtime `stat` is **not** sufficient: directory mtime only bumps
114
+ when a file is added/removed in *that* directory — a nested change such as
115
+ adding `std/sub/new/lib.mt` leaves the root directory's mtime unchanged.
116
+ Bootsnap's load-path cache handles this by recording and re-validating the
117
+ mtime of *every* scanned directory (still potentially thousands of `stat`
118
+ calls, per its author's analysis); the cheaper correct contract here is
119
+ event-driven invalidation via `didChangeWatchedFiles`, with mtime checks as a
120
+ fallback only for roots the editor is not watching.
121
+
122
+ ### 4.2 Completion sessions + server-side re-filtering (SourceKit-LSP pattern)
123
+
124
+ SourceKit-LSP holds a **completion session** per (file, location): the full
125
+ candidate list is computed once, and subsequent requests with
126
+ `triggerKind == triggerFromIncompleteCompletions` re-filter the cached list by
127
+ the (longer) typed prefix instead of recomputing. Results carry
128
+ `isIncomplete: true` so the editor keeps re-querying cheaply while typing. It
129
+ also caps results (`completion-max-results=200`) to bound serialization cost.
130
+
131
+ **Recommendation:** key a completion-candidate cache by
132
+ `[uri, position.line, content.hash, completion_branch]`. On
133
+ `triggerFromIncompleteCompletions` with an unchanged line/context, filter the
134
+ cached candidate pool by the new prefix instead of rebuilding from `facts`.
135
+ `MAX_COMPLETION_ITEMS` (200) already exists and should be honored before
136
+ serialization (it currently is). This collapses N keystroke re-computations
137
+ into one compute + N cheap filters.
138
+
139
+ ### 4.3 Cache completion item data; keep `resolve` cheap (Deno / tls / Roslyn)
140
+
141
+ - Deno added a short-lived `HashMap` cache for completion-item resolution:
142
+ 1200 ms → 75 ms (`denoland/deno#27831`).
143
+ - typescript-language-server sends a small `cacheId` per item and resolves
144
+ `completionItem/resolve` against a server-side map, cutting response size
145
+ from 620 KB to 200 KB (`tls#768`).
146
+ - Roslyn's optimized completion list reduced serialization ~1.8× by not
147
+ round-tripping large `data` payloads.
148
+
149
+ **Recommendation:** the global branch already builds `data: {uri, name}` and
150
+ `handle_completion_resolve` does a `find_definition_token_global` per item —
151
+ fine for small modules, but it multiplies with candidate count. Cache resolved
152
+ documentation per `[uri, name]` (see §4.4) and avoid re-resolving definitions
153
+ already in the workspace definition index.
154
+
155
+ ### 4.4 Stop fetching docs per candidate per keystroke
156
+
157
+ `handle_completion` (`completion.rb:330`) calls
158
+ `completion_function_documentation` (→ `find_definition_token_global` +
159
+ `doc_comment_data_for_definition`) for **every function** on **every request**
160
+ (`hover.rb:858`). The definition index makes each lookup fast, but it is
161
+ O(candidates) work repeated per keystroke and it forces definition-index
162
+ lookups even for items the user will never expand.
163
+
164
+ **Recommendation:** (a) memoize documentation per `[uri, name]` across requests
165
+ (a request-scoped cache already exists inside the branch, but it resets every
166
+ keystroke — promote it to a server-level cache invalidated by document change);
167
+ (b) only resolve docs for items the client actually resolves via
168
+ `completionItem/resolve`, leaving `documentation` out of the initial list.
169
+ This mirrors how ruby-lsp makes comment/doc collection lazy (`ruby-lsp#2547`)
170
+ and how Roslyn keeps large per-item payloads out of the initial completion list
171
+ (`dotnet/roslyn#52123`).
172
+
173
+ ### 4.5 Defer heavy per-edit analysis off the request thread
174
+
175
+ `didOpen`/`didChange` (`store.rb:38`, `store.rb:82`) synchronously call
176
+ `warm_document_facts` → `get_facts` on the request thread: ~9–30 ms per edit
177
+ as measured on a tiny scratch file, and the cost grows with module complexity.
178
+ Note the eager warm is **not** duplicating the diagnostics sema pass:
179
+ `collect_diagnostics` reads `@tooling_snapshot_cache` and hands the cached
180
+ snapshot to `Diagnostics.collect` (`collection.rb:21,35,53`), which reuses it
181
+ via `sema_snapshot ||= ...` (`diagnostics.rb:88`) instead of re-running the
182
+ analysis. The eager warm is what *populates* that cache; the diagnostics
183
+ workers then collect lint/parse errors off the request thread using it.
184
+
185
+ **Recommendation:** keep the *fast* parts of `didChange` synchronous (content
186
+ apply, cache invalidation, dependency fingerprint check) but move the eager
187
+ facts analysis to the debounced background path, serving `last_good_facts`
188
+ until the background pass completes. This shifts the ~10 ms sema cost from the
189
+ keystroke-critical request thread onto the debounce timer. It does **not** make
190
+ `didChange` "microseconds": applying the edit, invalidating the cache, and
191
+ refreshing the dependency index remain synchronous — measured at ~3.8 ms on a
192
+ tiny file with `warm_document_facts` disabled. The win is moving the dominant
193
+ sema/import-resolution cost (which dominates on real modules) out of the
194
+ request thread, not eliminating the synchronous floor.
195
+
196
+ Requests that need fresh facts (hover/definition/completion) today already fall
197
+ back to `last_good_facts` when a recompute is in flight
198
+ (`workspace/caches.rb` `get_tooling_snapshot`, `try_lock` + last-good
199
+ fallback), which is the standard "index is eventually consistent" model used by
200
+ clangd and SourceKit-LSP. Moving the eager warm to the same background path
201
+ keeps that behavior while making the per-keystroke cost the synchronous floor
202
+ rather than full sema.
203
+
204
+ ### 4.6 `milkTea/debugInfo` and `documentSymbol`
205
+
206
+ - `debugInfo` re-parses and rebuilds full semantic tokens every call
207
+ (`debug_info.rb`). It is a debugging endpoint, not hot-path; optionally reuse
208
+ `@semantic_tokens_cache` instead of `build_semantic_token_entries`.
209
+ - `documentSymbol` shows a wide latency spread (avg 5–18 ms, occasional 41–63 ms
210
+ spikes). The flat symbol list is cached (`get_symbols`), but
211
+ `enrich_with_children` (`formatting.rb`) walks the AST and rebuilds child
212
+ symbols on every request, so the spikes are consistent with enrichment
213
+ dominating the tail. Cache the enriched outline keyed by `content.hash` and
214
+ only re-enrich when content changes.
215
+
216
+ ## 5. Recommended priority order
217
+
218
+ 1. **Module index for `import_completions`** — removes the only >100 ms
219
+ endpoint; ~130–220 ms → sub-ms on the worst case. (§4.1)
220
+ 2. **Completion session re-filtering + `isIncomplete`** — turns per-keystroke
221
+ recomputation into per-keystroke filtering. (§4.2)
222
+ 3. **Promote per-function doc cache to server scope** and make doc loading
223
+ lazy via `completionItem/resolve`. (§4.4)
224
+ 4. **Defer eager facts in `didChange`/`didOpen` to the background debounce.**
225
+ (§4.5)
226
+ 5. **Cache the enriched `documentSymbol` outline.** (§4.6)
227
+ 6. **Cache completion item data / keep resolve cheap.** (§4.3)
228
+
229
+ ## 6. Testing strategy
230
+
231
+ - Extend `all_endpoints_benchmark.rb` (already committed) with:
232
+ - a large synthetic module root (e.g. 50 dirs × 30 modules) to exercise the
233
+ module index;
234
+ - a `triggerFromIncompleteCompletions` sequence measuring per-keystroke
235
+ filtering vs. full recompute;
236
+ - an edit-loop benchmark (didChange + follow-up hover) to validate the
237
+ deferred-facts change.
238
+ - Add regression tests asserting `import_completions` returns stable results
239
+ before/after module creation/deletion (the invalidation contract).
240
+ - Reuse the existing `MILK_TEA_LSP_PERF` stage breakdowns to confirm the
241
+ `import_context` and `facts` stages collapse after each change.
242
+
243
+ ## 7. Non-goals / rejected alternatives
244
+
245
+ - **Ractor-based parallelism**: the sema pipeline shares mutable state
246
+ (`Types::Registry`, `@shared_module_cache`); wrapping it in `Ractor` is a
247
+ rewrite, not an optimization, and GVL-bound Ruby makes it premature.
248
+ - **Persistent on-disk index (clangd `.idx`, SourceKit index store)**: the
249
+ index formats here are memory-only today; on-disk persistence adds a cache-
250
+ invalidation protocol for little gain until workspace sizes demand it.
251
+ - **Replacing `Dir.children` recursion with `Dir.glob("**/*.mt")`**: faster per
252
+ scan but still a full-tree walk per keystroke; the module index makes it
253
+ unnecessary. `Dir.scan` (Ruby 4.1, `ruby/ruby#16153`, yields the child type
254
+ via `dirent.d_type` without N+1 `stat`s, ~2× faster scans) is a future
255
+ accelerator for the index build itself, not a hot-path fix.
256
+
257
+ ## 8. Implementation status
258
+
259
+ Implemented against the priority order in §5:
260
+
261
+ 1. **Persistent module index** (`lib/milk_tea/lsp/workspace/module_index.rb`).
262
+ Built once per module root (on `initialized` and lazily on first use),
263
+ refreshed by `workspace/didChangeWatchedFiles` create/delete events
264
+ (deduped per root per event batch), and rebuilt on workspace-folder
265
+ changes. `import_completions` filters the in-memory index instead of
266
+ walking the tree. `Dir.glob("**/*.mt")` replaces the per-entry `stat`
267
+ recursion; measured build cost on this repo's tree is ~1 ms.
268
+ 2. **Completion sessions** (`handle_completion`). The candidate pool for a
269
+ `[uri, line]` is cached with its line-prefix context; a
270
+ `triggerFromIncompleteCompletions` request whose prefix is a strict
271
+ extension re-filters the cached pool instead of recomputing. A prefix
272
+ shrink or line-context change falls back to a full recompute. Bounded to
273
+ 64 entries (FIFO eviction).
274
+ 3. **Server-scoped completion doc + resolve caches.** `completion_function_documentation`
275
+ results are memoized per `[uri, name]` across requests (previously a
276
+ per-request cache reset every keystroke); `completionItem/resolve` results
277
+ are memoized the same way. Both invalidated on document change.
278
+ 4. **Deferred `didOpen`/`didChange` facts.** `open_document` and
279
+ `apply_incremental_changes` keep the synchronous floor (content apply, cache
280
+ invalidation, dependency refresh) but skip the eager sema/import-resolution
281
+ warm for both the keystroke path and file open (`handle_did_open` and
282
+ `handle_did_change` pass `warm_facts: false`). The debounced diagnostics
283
+ worker computes facts in the background; requests serve `last_good_facts`
284
+ until it lands. This keeps the request thread responsive while the first
285
+ analysis of a large import-heavy module (e.g. `examples/language_baseline.mt`,
286
+ ~2.4 s cold) is in flight.
287
+ 5. **Cached `documentSymbol` outline.** The enriched outline (AST child
288
+ enrichment + module hierarchy) is cached keyed by content hash, removing
289
+ the enrichment walk from repeat requests.
290
+ 6. **Cached completion resolve.** See #3.
291
+ Plus two fixes surfaced by the new benchmark sections: `workspace/symbol`
292
+ skips re-indexing when the on-disk file set is unchanged, and the module
293
+ index ignores watched-file events for open documents (matching
294
+ `apply_watched_file_change`).
295
+ 7. **Eventually-consistent semantic tokens.** `handle_semantic_tokens_full`/
296
+ `range`/`delta` never run or wait on sema: they consume `Workspace#peek_facts`
297
+ (cached or last-good facts, never blocking) so the request thread stays
298
+ responsive while facts are recomputed after an edit. The semantic-token cache
299
+ is keyed by content hash **and the facts object identity**, so tokens built
300
+ from the lexical fallback (facts not yet computed) are never served once
301
+ facts land — a stale-cache bug where edited tokens stayed lexical until the
302
+ next content change. When the diagnostics worker lands fresh facts it sends a
303
+ `workspace/semanticTokens/refresh` notification so the editor re-fetches and
304
+ gets analyzed highlighting.
305
+ 8. **Faster facts-driven token build.** `enclosing_completion_frame`,
306
+ `type_parameter_names_in_scope`, and `known_type_name?` are memoized per
307
+ `[facts, line]`/`[facts, name]` for the duration of one token build, turning
308
+ the O(tokens × frames) per-token scans into O(1) lookups. Measured build for
309
+ `examples/language_baseline.mt`: ~370 ms → ~110 ms steady-state.
310
+ 9. **Diagnostics facts-path fix.** `Diagnostics.collect` skipped producing facts
311
+ for files whose parse recovered with errors because the loader's
312
+ `check_program_collecting` pass poisoned the module cache; the diagnostics
313
+ worker then silently kept serving stale last-good facts. The program check is
314
+ now skipped for parse-error files (resolving imports directly), making
315
+ `collect_diagnostics` facts consistent with the `analyze_document` path.
316
+ Also: the module index is refreshed on `workspace/willRenameFiles` so a
317
+ renamed module never leaves a stale import-completion entry.
318
+
319
+ Measured with `all_endpoints_benchmark.rb` (10 iterations, same run config as
320
+ §1):
321
+
322
+ | endpoint | before | after |
323
+ |---|---|---|
324
+ | `textDocument/completion` (import line) | ~135 ms | **~0.9 ms** |
325
+ | `textDocument/didChange` (scratch file) | ~10 ms | **~1.6 ms** |
326
+ | `textDocument/didOpen` (scratch file) | ~15 ms | **~0.3 ms** (facts deferred to worker) |
327
+ | `textDocument/documentSymbol` | ~5.4 ms avg / ~44 ms max | **~0 ms** (cached outline) |
328
+ | `textDocument/completion` (1500-module root) | n/a | **~1.3 ms** (indexed) |
329
+ | `textDocument/completion` (incomplete-trigger, 6 keystrokes) | n/a | ~46 ms worst case with diagnostics workers stopped; workers running, the debounced facts pass keeps per-keystroke cost at the didChange floor |
330
+
331
+ First-open flow for a large import-heavy module (`examples/language_baseline.mt`,
332
+ 53 KB / 2062 lines, cold analysis ~2.1 s): `didOpen` returns in <1 ms on the
333
+ request thread, the editor renders lexical highlighting immediately, and the
334
+ background diagnostics worker computes facts (~2.1 s) and pushes a
335
+ `workspace/semanticTokens/refresh`; the follow-up `semanticTokens/full` request
336
+ serves the analyzed highlighting. The request thread is never blocked by the
337
+ initial analysis.
338
+
339
+ Regression tests: `test/tooling/lsp/workspace_test.rb` (module-index build,
340
+ create/delete and rename invalidation, current-file exclusion),
341
+ `test/tooling/lsp/server/completion_test.rb` (import completion create/delete
342
+ contract, `triggerFromIncompleteCompletions` re-filtering and the prefix-shrink
343
+ recompute guard), and `test/tooling/lsp/server/semantic_tokens_test.rb`
344
+ (semantic tokens rebuild when facts land after open — the stale-cache
345
+ regression). The LSP test client forces facts deterministically before
346
+ semantic-token requests because didOpen defers analysis to the background
347
+ worker.
data/lib/milk_tea/base.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "pathname"
4
4
 
5
5
  module MilkTea
6
- VERSION = "0.3.38"
6
+ VERSION = "0.3.40"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -111,8 +111,8 @@ module MilkTea
111
111
  InterfaceDecl = Data.define(:name, :type_params, :methods, :visibility, :line, :column) do
112
112
  def initialize(name:, type_params: [], methods:, visibility:, line: nil, column: nil) = super
113
113
  end
114
- ExtendingBlock = Data.define(:type_name, :methods, :line, :column) do
115
- def initialize(type_name:, methods:, line: nil, column: nil) = super
114
+ ExtendingBlock = Data.define(:type_name, :methods, :line, :column, :inline) do
115
+ def initialize(type_name:, methods:, line: nil, column: nil, inline: false) = super
116
116
  end
117
117
  InterfaceMethodDecl = Data.define(:name, :params, :return_type, :kind, :async, :attributes, :line, :column) do
118
118
  def initialize(name:, params:, return_type:, kind:, async:, attributes: [], line: nil, column: nil) = super
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MilkTea
4
4
  module Bindings
5
- AttributeBinding = Data.define(:name, :targets, :params, :module_name, :builtin, :ast)
5
+ AttributeBinding = Data.define(:name, :targets, :params, :module_name, :builtin)
6
6
  BUILTIN_ATTRIBUTE_NAMES = %w[packed align deprecated test expect_fatal].freeze
7
7
 
8
8
  def self.builtin_attribute_binding(name, types)
@@ -14,7 +14,6 @@ module MilkTea
14
14
  params: [].freeze,
15
15
  module_name: nil,
16
16
  builtin: true,
17
- ast: nil,
18
17
  )
19
18
  when "align"
20
19
  AttributeBinding.new(
@@ -23,7 +22,6 @@ module MilkTea
23
22
  params: [Types::Registry.parameter("bytes", types.fetch("ptr_uint"))].freeze,
24
23
  module_name: nil,
25
24
  builtin: true,
26
- ast: nil,
27
25
  )
28
26
  when "deprecated"
29
27
  AttributeBinding.new(
@@ -32,7 +30,6 @@ module MilkTea
32
30
  params: [Types::Registry.parameter("message", types.fetch("str"))].freeze,
33
31
  module_name: nil,
34
32
  builtin: true,
35
- ast: nil,
36
33
  )
37
34
  when "test"
38
35
  AttributeBinding.new(
@@ -41,7 +38,6 @@ module MilkTea
41
38
  params: [].freeze,
42
39
  module_name: nil,
43
40
  builtin: true,
44
- ast: nil,
45
41
  )
46
42
  when "expect_fatal"
47
43
  AttributeBinding.new(
@@ -50,7 +46,6 @@ module MilkTea
50
46
  params: [].freeze,
51
47
  module_name: nil,
52
48
  builtin: true,
53
- ast: nil,
54
49
  )
55
50
  end
56
51
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module MilkTea
4
4
  module Bindings
5
- ModuleBinding = Data.define(:name, :types, :type_declarations, :interfaces, :attributes, :attribute_applications, :values, :functions, :methods, :implemented_interfaces, :imports, :private_types, :private_interfaces, :private_attributes, :private_values, :private_functions, :private_methods, :private_implemented_interfaces) do
5
+ ModuleBinding = Data.define(:name, :types, :type_declarations, :interfaces, :attributes, :attribute_applications, :values, :functions, :methods, :implemented_interfaces, :imports, :private_types, :private_interfaces, :private_attributes, :private_values, :private_functions, :private_methods) do
6
6
  def private_type?(name)
7
7
  private_types.key?(name)
8
8
  end
@@ -84,5 +84,27 @@ module MilkTea
84
84
  def string_literal_cstr_compatibility?(expression, expected_type)
85
85
  expression.is_a?(AST::StringLiteral) && !expression.cstring && expected_type == BUILTIN_CSTR
86
86
  end
87
+
88
+ def current_nested_types
89
+ @current_nested_types
90
+ end
91
+
92
+ # Resolves the receiver type's short name (self-reference) and its own
93
+ # nested types as a bare-name scope for method bodies and signatures.
94
+ def method_receiver_nested_scope(receiver_type)
95
+ return nil unless receiver_type.respond_to?(:name)
96
+
97
+ nested = receiver_type.respond_to?(:nested_types) ? receiver_type.nested_types : nil
98
+ scope = { receiver_type.name => receiver_type }
99
+ scope = scope.merge(nested) if nested && !nested.empty?
100
+ scope
101
+ end
102
+
103
+ # Looks up a bare type name, consulting the active method-receiver nested
104
+ # scope before the module type namespace so nested types self-reference
105
+ # by their short name inside their own methods.
106
+ def lookup_named_type(name)
107
+ (@current_nested_types && @current_nested_types[name]) || @ctx.types[name]
108
+ end
87
109
  end
88
- end
110
+ end
@@ -118,8 +118,10 @@ module MilkTea
118
118
  parameter_setup = []
119
119
  previous_type_substitutions = @ctx.current_type_substitutions
120
120
  previous_value_type_params = @ctx.current_value_type_params
121
+ previous_nested_types = @current_nested_types
121
122
  @ctx.current_type_substitutions = binding.type_substitutions
122
123
  @ctx.current_value_type_params = resolve_value_type_params(decl.type_params)
124
+ @current_nested_types = method_receiver_nested_scope(binding.declared_receiver_type)
123
125
 
124
126
  return lower_async_function_decl(binding, receiver_type:) if binding.async
125
127
 
@@ -179,6 +181,7 @@ module MilkTea
179
181
  ensure
180
182
  @ctx.current_type_substitutions = previous_type_substitutions
181
183
  @ctx.current_value_type_params = previous_value_type_params
184
+ @current_nested_types = previous_nested_types
182
185
  end
183
186
 
184
187
  def resolve_value_type_params(type_params)
@@ -444,7 +444,7 @@ module MilkTea
444
444
  return [:compile_time_builtin, callee.name, nil, compile_time_builtin_function_type(callee.name, arguments, env)]
445
445
  end
446
446
 
447
- type = @ctx.types[callee.name]
447
+ type = lookup_named_type(callee.name)
448
448
  if type.is_a?(Types::Struct) || type.is_a?(Types::StringView) || task_type?(type) || type.is_a?(Types::Vector) || type.is_a?(Types::Matrix) || type.is_a?(Types::Quaternion)
449
449
  return [:struct_literal, nil, nil, type]
450
450
  end
@@ -606,7 +606,6 @@ module MilkTea
606
606
  return [:array_as_span, nil, callee.receiver, Types::Registry.span(array_element_type(field_receiver_type))]
607
607
  end
608
608
 
609
- member_type = field_receiver_type.respond_to?(:field) ? field_receiver_type.field(callee.member) : nil
610
609
  member_type = field_receiver_type.respond_to?(:field) ? field_receiver_type.field(callee.member) : nil
611
610
  return [:callable_value, nil, nil, member_type, nil] if callable_type?(member_type)
612
611
 
@@ -1055,7 +1054,7 @@ module MilkTea
1055
1054
  end
1056
1055
  end
1057
1056
 
1058
- def type_contains_array_storage?(type, visited = Set.new)
1057
+ def type_contains_array_storage?(type)
1059
1058
  visitor = Types::ContainsArrayStorageVisitor.new
1060
1059
  visitor.visit(type)
1061
1060
  visitor.found?
@@ -1118,62 +1117,6 @@ module MilkTea
1118
1117
  expected_type || null_type
1119
1118
  end
1120
1119
 
1121
- def common_numeric_type(left_type, right_type)
1122
- left_type = left_type.backing_type if left_type.is_a?(Types::EnumBase)
1123
- right_type = right_type.backing_type if right_type.is_a?(Types::EnumBase)
1124
- return left_type if left_type == right_type
1125
- return unless left_type.is_a?(Types::Primitive) && right_type.is_a?(Types::Primitive)
1126
- return unless left_type.numeric? && right_type.numeric?
1127
-
1128
- return common_integer_type(left_type, right_type) if left_type.integer? && right_type.integer?
1129
- return wider_float_type(left_type, right_type) if left_type.float? && right_type.float?
1130
-
1131
- float_type, integer_type = left_type.float? ? [left_type, right_type] : [right_type, left_type]
1132
- return unless integer_type.integer? && integer_type.fixed_width_integer?
1133
-
1134
- float_type
1135
- end
1136
-
1137
- def common_integer_type(left_type, right_type)
1138
- left_type = left_type.backing_type if left_type.is_a?(Types::EnumBase)
1139
- right_type = right_type.backing_type if right_type.is_a?(Types::EnumBase)
1140
- return left_type if left_type == right_type
1141
- return unless left_type.is_a?(Types::Primitive) && right_type.is_a?(Types::Primitive)
1142
- return unless left_type.integer? && right_type.integer?
1143
- return unless left_type.fixed_width_integer? && right_type.fixed_width_integer?
1144
-
1145
- # Mirrors the semantic analyzer's rule: same signedness picks the wider
1146
- # type; mixed signed/unsigned promotes to the narrowest signed type that
1147
- # holds both operands' full ranges (a strictly-wider signed type covers
1148
- # an unsigned operand, otherwise widen to the next signed width). Mixing
1149
- # with a 64-bit unsigned type has no safe signed common type.
1150
- if left_type.signed_integer? == right_type.signed_integer?
1151
- return left_type.integer_width >= right_type.integer_width ? left_type : right_type
1152
- end
1153
-
1154
- signed_type, unsigned_type = if left_type.signed_integer?
1155
- [left_type, right_type]
1156
- else
1157
- [right_type, left_type]
1158
- end
1159
-
1160
- return signed_type if signed_type.integer_width > unsigned_type.integer_width
1161
-
1162
- signed_type_above_width(unsigned_type.integer_width)
1163
- end
1164
-
1165
- def signed_type_above_width(width)
1166
- case width
1167
- when 8 then @ctx.types.fetch("short")
1168
- when 16 then @ctx.types.fetch("int")
1169
- when 32 then @ctx.types.fetch("long")
1170
- end
1171
- end
1172
-
1173
- def wider_float_type(left_type, right_type)
1174
- left_type.float_width >= right_type.float_width ? left_type : right_type
1175
- end
1176
-
1177
1120
  def aggregate_arithmetic_result_type(operator, left_type, right_type)
1178
1121
  if left_type.is_a?(Types::Vector) && right_type.is_a?(Types::Vector) && left_type.name == right_type.name
1179
1122
  return left_type
@@ -1217,7 +1160,7 @@ module MilkTea
1217
1160
  when AST::Identifier
1218
1161
  return current_type_params[expression.name] if current_type_params.key?(expression.name)
1219
1162
 
1220
- @ctx.types[expression.name]
1163
+ lookup_named_type(expression.name)
1221
1164
  when AST::MemberAccess
1222
1165
  return nil unless expression.receiver.is_a?(AST::Identifier)
1223
1166
 
@@ -1870,10 +1813,6 @@ module MilkTea
1870
1813
  def top_level_function(name)
1871
1814
  @lowerer.instance_variable_get(:@ctx).functions&.[](name)
1872
1815
  end
1873
-
1874
- def raise_sema_error(message)
1875
- raise CompileTime::Error, message
1876
- end
1877
1816
  end
1878
1817
 
1879
1818
  def evaluate_reflection_target_argument(expression, env:)
@@ -2532,7 +2471,7 @@ module MilkTea
2532
2471
  elsif parts.length == 1 && type_params.key?(parts.first)
2533
2472
  type_params.fetch(parts.first)
2534
2473
  elsif parts.length == 1
2535
- type = @ctx.types[parts.first]
2474
+ type = lookup_named_type(parts.first)
2536
2475
  raise LoweringError.new("unknown type #{parts.first}", line: 0, column: 0, path: @ctx.current_analysis_path) unless type
2537
2476
  raise LoweringError.new("generic type #{parts.first} requires type arguments", line: 0, column: 0, path: @ctx.current_analysis_path) if type.is_a?(Types::GenericStructDefinition) || type.is_a?(Types::GenericVariantDefinition)
2538
2477
 
@@ -155,18 +155,6 @@ module MilkTea
155
155
  receiver_type
156
156
  end
157
157
 
158
- def collection_loop_type(type)
159
- super
160
- end
161
-
162
- def collection_loop_binding_type(iterable_type, element_type)
163
- super
164
- end
165
-
166
- def collection_loop_ref_element_type?(type)
167
- super
168
- end
169
-
170
158
  def collection_loop_item_value(iterable_ref, iterable_type, index_ref, element_type)
171
159
  if array_type?(iterable_type)
172
160
  IR::Index.new(receiver: iterable_ref, index: index_ref, type: element_type)
@@ -248,11 +236,6 @@ module MilkTea
248
236
  raise LoweringError.new("cannot index #{receiver_type}", line: 0, column: 0, path: @ctx.current_analysis_path)
249
237
  end
250
238
 
251
- def contains_type_var?(type)
252
- super
253
- end
254
-
255
-
256
239
  def stored_ref_supported_type?(type, visited = {})
257
240
  return true unless type
258
241
 
@@ -100,7 +100,6 @@ module MilkTea
100
100
  include Intrinsics
101
101
 
102
102
  attr_accessor :bypass_sema_type_cache
103
- attr_reader :recorded_expr_types
104
103
 
105
104
  include Lowering::Scans
106
105
  include Lowering::Declarations
@@ -122,6 +121,7 @@ module MilkTea
122
121
  @program = program
123
122
  @ctx = LoweringContext.new
124
123
  @artifacts = Artifacts.new
124
+ @error_type = Types::Error.new
125
125
  @synthetic_proc_counter = 0
126
126
  @parallel_for_counter = 0
127
127
  @async_binding_counter = 0
@@ -130,7 +130,6 @@ module MilkTea
130
130
  end
131
131
 
132
132
  def lower
133
- @recorded_expr_types = {} if @bypass_sema_type_cache
134
133
  ir_program, _modules, _synths = lower_and_assemble
135
134
  ir_program
136
135
  end