mt-lang 0.3.38 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 952280dd2ad14bc3b5751fc7e8a3d29b39363293b1436cc1b3b546c31c938427
4
- data.tar.gz: e0bdf3468681b07914143c32b0b9cc2470cdbeaf55cf81994186003dc406261b
3
+ metadata.gz: 5d9af5865fb54b82fbf9169cdf8f8feef652af6a2b84cbd8e63e35f99b33ac08
4
+ data.tar.gz: 2c5d57827cc19e8de3ee5fff7e576860aa4ae30fa5f7b588eba80c9e95227e02
5
5
  SHA512:
6
- metadata.gz: 9dcf6f6c955f1428b5374315782fda121669ae3c12a8c84d683205cb44bb4ca7903465e70447860e89c0753695f01af513903e93b82987838fa724be19fc7b2f
7
- data.tar.gz: 67c259de17bdd81a4f5785d7dbe05cf39ab959ea51c1f27ed2b05a516ed97ce91a4b2291aa415e0abaa695337f1fc1825ead4acdfceb480a9be4db3cd3a24dd4
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.38"
6
+ VERSION = "0.3.39"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -31,8 +31,10 @@ module MilkTea
31
31
  # Parse
32
32
  begin
33
33
  parse_start = total_start ? monotonic_time : nil
34
+ parse_errors = []
34
35
  ast = if path && File.file?(path)
35
36
  parse_result = Parser.parse_collecting_errors(content, path: uri)
37
+ parse_errors = parse_result.errors
36
38
  parse_result.errors.each { |error| diagnostics << format_error(error) }
37
39
  parse_result.ast
38
40
  else
@@ -63,6 +65,10 @@ module MilkTea
63
65
  source_overrides: source_overrides,
64
66
  workspace_root_path: workspace_root_path,
65
67
  content: content,
68
+ # A file whose own parse recovered with errors can poison the loader's
69
+ # program-check cache and yield no facts on the standard path; skip the
70
+ # program check and resolve imports directly for those files.
71
+ skip_program_check: !parse_errors.empty?,
66
72
  )
67
73
  imports_ms = elapsed_ms(imports_start) if imports_start
68
74
  unresolved_import_paths = imported_modules.fetch(:unresolved_import_paths)
@@ -167,7 +173,7 @@ module MilkTea
167
173
 
168
174
  private
169
175
 
170
- def self.resolve_imported_modules(uri, ast, diagnostics, resolution:, effective_platform:, shared_module_cache: nil, source_overrides: nil, workspace_root_path: nil, content: nil)
176
+ def self.resolve_imported_modules(uri, ast, diagnostics, resolution:, effective_platform:, shared_module_cache: nil, source_overrides: nil, workspace_root_path: nil, content: nil, skip_program_check: false)
171
177
  path = uri_to_path(uri)
172
178
  return { modules: {}, unresolved_import_paths: [], module_name: nil } unless path && File.file?(path)
173
179
 
@@ -186,10 +192,14 @@ module MilkTea
186
192
  # cached analysis and resolves correctly. If the program check fails
187
193
  # (e.g. missing module), fall through to the standard resolution
188
194
  # path so that prelude modules and regular errors are still reported.
189
- begin
190
- loader.check_program_collecting(path)
191
- rescue ModuleLoadError, PackageLockError
192
- # best-effort — standard path below handles diagnostics
195
+ # Skipped when the file's own parse recovered with errors: the program
196
+ # check poisons the loader cache and yields no facts on that path.
197
+ unless skip_program_check
198
+ begin
199
+ loader.check_program_collecting(path)
200
+ rescue ModuleLoadError, PackageLockError
201
+ # best-effort — standard path below handles diagnostics
202
+ end
193
203
  end
194
204
 
195
205
  resolution_result = loader.imported_modules_for_ast_collecting_errors(ast, importer_path: path)