mt-lang 0.3.37 → 0.3.39

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 (41) 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/c_backend/reinterpret.rb +2 -2
  5. data/lib/milk_tea/core/c_backend.rb +12 -0
  6. data/lib/milk_tea/core/compile_time.rb +46 -17
  7. data/lib/milk_tea/core/lexer.rb +12 -0
  8. data/lib/milk_tea/core/lowering/resolve.rb +2 -3
  9. data/lib/milk_tea/core/lowering.rb +12 -0
  10. data/lib/milk_tea/core/module_loader.rb +59 -37
  11. data/lib/milk_tea/core/parser.rb +12 -0
  12. data/lib/milk_tea/core/semantic_analyzer/attributes.rb +2 -1
  13. data/lib/milk_tea/core/semantic_analyzer/function_binding.rb +8 -20
  14. data/lib/milk_tea/core/semantic_analyzer/interface_conformance.rb +2 -1
  15. data/lib/milk_tea/core/semantic_analyzer/statements.rb +10 -10
  16. data/lib/milk_tea/core/semantic_analyzer/top_level.rb +9 -11
  17. data/lib/milk_tea/core/semantic_analyzer/type_declaration.rb +14 -5
  18. data/lib/milk_tea/core/semantic_analyzer.rb +6 -31
  19. data/lib/milk_tea/lsp/diagnostics.rb +15 -5
  20. data/lib/milk_tea/lsp/server/code_actions.rb +12 -2
  21. data/lib/milk_tea/lsp/server/completion.rb +100 -77
  22. data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +4 -6
  23. data/lib/milk_tea/lsp/server/formatting.rb +184 -60
  24. data/lib/milk_tea/lsp/server/hover.rb +321 -41
  25. data/lib/milk_tea/lsp/server/inlay_hints.rb +33 -11
  26. data/lib/milk_tea/lsp/server/lifecycle.rb +12 -0
  27. data/lib/milk_tea/lsp/server/references.rb +3 -1
  28. data/lib/milk_tea/lsp/server/selection_range.rb +4 -4
  29. data/lib/milk_tea/lsp/server/semantic_tokens.rb +63 -20
  30. data/lib/milk_tea/lsp/server/text_documents.rb +2 -2
  31. data/lib/milk_tea/lsp/server/type_hierarchy.rb +7 -7
  32. data/lib/milk_tea/lsp/server/utilities.rb +4 -0
  33. data/lib/milk_tea/lsp/server.rb +9 -0
  34. data/lib/milk_tea/lsp/workspace/analysis.rb +14 -3
  35. data/lib/milk_tea/lsp/workspace/caches.rb +17 -1
  36. data/lib/milk_tea/lsp/workspace/collection.rb +4 -0
  37. data/lib/milk_tea/lsp/workspace/module_index.rb +134 -0
  38. data/lib/milk_tea/lsp/workspace/store.rb +9 -4
  39. data/lib/milk_tea/lsp/workspace.rb +8 -0
  40. data/lib/milk_tea/packages/manifest.rb +9 -0
  41. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 84f8e5a5913811a35cd5cd470c43f3827aee62bfe5b151add730d45fe341bee2
4
- data.tar.gz: fd76e0d6f88abfe0fda004829430effc5b7030957754b6d3d3eebbd6739b96b5
3
+ metadata.gz: 5d9af5865fb54b82fbf9169cdf8f8feef652af6a2b84cbd8e63e35f99b33ac08
4
+ data.tar.gz: 2c5d57827cc19e8de3ee5fff7e576860aa4ae30fa5f7b588eba80c9e95227e02
5
5
  SHA512:
6
- metadata.gz: cc533975db0511ab0f5ac62887ec101ebae699841d3cdbc5079faf1e2a0be877e1ceb71d56b3605ca7c37c60c43019fd3288e690325a21cf87806ca2cff138b5
7
- data.tar.gz: 89d0efc35e9bfec7c1d9c5a612e2f136d3c6fd8f80259f88a657ac46be896ff79c43c0810f98eba8312139151c343c59205b2a3b5fd72af60d2de2fdfc0fd16a
6
+ metadata.gz: 9fc438f51cba2f5a65bd3a8ea3441fb87f26018be991146e060a828b036ba9d5eebde4f5546ce344f276ba68e5f1a99b4fe92b026c839c3f77740b15c108a4eb
7
+ data.tar.gz: 232a3879248bbd638c7710135047c09dbe4a140758da63c2fc5434df4602750c6459faa3fa843abca99801c6628b963ec747509be17de4c73f408626882dfa22
@@ -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.37"
6
+ VERSION = "0.3.39"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -116,13 +116,13 @@ module MilkTea
116
116
  return false if expression.expression.type.is_a?(Types::Null)
117
117
 
118
118
  c_type(expression.target_type) == c_type(expression.expression.type)
119
- rescue StandardError
119
+ rescue CBackendError
120
120
  false
121
121
  end
122
122
 
123
123
  def identity_reinterpret?(target_type, source_type)
124
124
  c_type(target_type) == c_type(source_type)
125
- rescue StandardError
125
+ rescue CBackendError
126
126
  false
127
127
  end
128
128
 
@@ -27,6 +27,18 @@ module MilkTea
27
27
  def code
28
28
  "backend/internal"
29
29
  end
30
+
31
+ def to_diagnostic(path: nil)
32
+ Diagnostic.new(
33
+ path: @path || path,
34
+ line: @line,
35
+ column: @column,
36
+ length: nil,
37
+ code: code,
38
+ message: message,
39
+ severity: :error,
40
+ )
41
+ end
30
42
  end
31
43
 
32
44
  class CBackend
@@ -6,16 +6,27 @@ module MilkTea
6
6
  module CompileTime
7
7
  Layout = ::MilkTea::Types::Layout
8
8
 
9
- class ReturnValue < StandardError
10
- attr_reader :value
9
+ # Carries the value of a `return` statement out of the block evaluator as
10
+ # an ordinary value instead of an exception; callers unwrap it when present.
11
+ ReturnOutcome = Data.define(:value)
11
12
 
12
- def initialize(value)
13
- @value = value
14
- super("return #{value.inspect}")
13
+ class Error < StandardError
14
+ def code
15
+ "compile_time/error"
15
16
  end
16
- end
17
17
 
18
- class Error < StandardError; end
18
+ def to_diagnostic(path: nil)
19
+ Diagnostic.new(
20
+ path: path,
21
+ line: nil,
22
+ column: nil,
23
+ length: nil,
24
+ code: code,
25
+ message: message,
26
+ severity: :error,
27
+ )
28
+ end
29
+ end
19
30
 
20
31
  def self.evaluate(expression, resolve_identifier:, resolve_member_access:, resolve_type_ref: nil, resolve_call: nil)
21
32
  Evaluator.new(
@@ -249,7 +260,10 @@ module MilkTea
249
260
  result = nil
250
261
 
251
262
  statements.each do |statement|
252
- result = evaluate_statement(statement, scopes:)
263
+ outcome = evaluate_statement(statement, scopes:)
264
+ return outcome if outcome.is_a?(ReturnOutcome)
265
+
266
+ result = outcome
253
267
  end
254
268
 
255
269
  result
@@ -261,7 +275,7 @@ module MilkTea
261
275
  evaluate_local_decl(statement, scopes:)
262
276
  when AST::ReturnStmt
263
277
  value = statement.value ? evaluate_expression(statement.value, scopes:) : nil
264
- raise ReturnValue.new(value)
278
+ ReturnOutcome.new(value)
265
279
  when AST::WhileStmt
266
280
  evaluate_while(statement, scopes:)
267
281
  when AST::ForStmt
@@ -276,6 +290,7 @@ module MilkTea
276
290
  evaluate_expression(statement.expression, scopes:)
277
291
  when AST::PassStmt, AST::BreakStmt, AST::ContinueStmt
278
292
  # no-op at compile time
293
+ nil
279
294
  when AST::EmitStmt
280
295
  # emitted declarations are collected during lowering
281
296
  nil
@@ -354,7 +369,10 @@ module MilkTea
354
369
  break unless condition
355
370
  break unless CompileTime.boolean_value?(condition)
356
371
 
357
- statement.body.each { |body_stmt| evaluate_statement(body_stmt, scopes:) }
372
+ statement.body.each do |body_stmt|
373
+ outcome = evaluate_statement(body_stmt, scopes:)
374
+ return outcome if outcome.is_a?(ReturnOutcome)
375
+ end
358
376
  iterations += 1
359
377
  end
360
378
 
@@ -372,7 +390,10 @@ module MilkTea
372
390
 
373
391
  iterable.each do |element|
374
392
  @variables[loop_var_name] = element
375
- statement.body.each { |body_stmt| evaluate_statement(body_stmt, scopes:) }
393
+ statement.body.each do |body_stmt|
394
+ outcome = evaluate_statement(body_stmt, scopes:)
395
+ return outcome if outcome.is_a?(ReturnOutcome)
396
+ end
376
397
  end
377
398
 
378
399
  result
@@ -382,13 +403,19 @@ module MilkTea
382
403
  statement.branches.each do |branch|
383
404
  condition = evaluate_expression(branch.condition, scopes:)
384
405
  if CompileTime.boolean_value?(condition) && condition
385
- branch.body.each { |body_stmt| evaluate_statement(body_stmt, scopes:) }
406
+ branch.body.each do |body_stmt|
407
+ outcome = evaluate_statement(body_stmt, scopes:)
408
+ return outcome if outcome.is_a?(ReturnOutcome)
409
+ end
386
410
  return condition
387
411
  end
388
412
  end
389
413
 
390
414
  if statement.else_body
391
- statement.else_body.each { |body_stmt| evaluate_statement(body_stmt, scopes:) }
415
+ statement.else_body.each do |body_stmt|
416
+ outcome = evaluate_statement(body_stmt, scopes:)
417
+ return outcome if outcome.is_a?(ReturnOutcome)
418
+ end
392
419
  end
393
420
 
394
421
  nil
@@ -401,7 +428,10 @@ module MilkTea
401
428
  statement.arms.each do |arm|
402
429
  wildcard = arm.pattern.is_a?(AST::Identifier) && arm.pattern.name == "_"
403
430
  if wildcard || CompileTime.equality_result(scrutinee, evaluate_expression(arm.pattern, scopes:)) == true
404
- arm.body.each { |body_stmt| evaluate_statement(body_stmt, scopes:) }
431
+ arm.body.each do |body_stmt|
432
+ outcome = evaluate_statement(body_stmt, scopes:)
433
+ return outcome if outcome.is_a?(ReturnOutcome)
434
+ end
405
435
  return scrutinee
406
436
  end
407
437
  end
@@ -451,9 +481,8 @@ module MilkTea
451
481
  initial_vars[param.name] = arg_value
452
482
  end
453
483
  ctx = BlockContext.new(@checker, initial_variables: initial_vars)
454
- ctx.evaluate_block(func.ast.body, scopes:)
455
- rescue ReturnValue => e
456
- e.value
484
+ result = ctx.evaluate_block(func.ast.body, scopes:)
485
+ result.is_a?(ReturnOutcome) ? result.value : result
457
486
  end
458
487
  end
459
488
 
@@ -26,6 +26,18 @@ module MilkTea
26
26
  def code
27
27
  "lex/error"
28
28
  end
29
+
30
+ def to_diagnostic(path: nil)
31
+ Diagnostic.new(
32
+ path: @path || path,
33
+ line: @line,
34
+ column: @column,
35
+ length: nil,
36
+ code: code,
37
+ message: message,
38
+ severity: :error,
39
+ )
40
+ end
29
41
  end
30
42
 
31
43
  class Lexer
@@ -1850,9 +1850,8 @@ module MilkTea
1850
1850
 
1851
1851
  evaluator = ConstFnLowerEvaluator.new(self)
1852
1852
  ctx = CompileTime::BlockContext.new(evaluator, initial_variables: initial_vars)
1853
- ctx.evaluate_block(func.ast.body, scopes: nil)
1854
- rescue CompileTime::ReturnValue => e
1855
- e.value
1853
+ result = ctx.evaluate_block(func.ast.body, scopes: nil)
1854
+ result.is_a?(CompileTime::ReturnOutcome) ? result.value : result
1856
1855
  end
1857
1856
 
1858
1857
  class ConstFnLowerEvaluator
@@ -59,6 +59,18 @@ module MilkTea
59
59
  def code
60
60
  "lowering/internal"
61
61
  end
62
+
63
+ def to_diagnostic(path: nil)
64
+ Diagnostic.new(
65
+ path: @path || path,
66
+ line: @line,
67
+ column: @column,
68
+ length: nil,
69
+ code: code,
70
+ message: message,
71
+ severity: :error,
72
+ )
73
+ end
62
74
  end
63
75
 
64
76
  module Lowering