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 +4 -4
- data/docs/lsp-performance.md +347 -0
- data/lib/milk_tea/base.rb +1 -1
- data/lib/milk_tea/lsp/diagnostics.rb +15 -5
- data/lib/milk_tea/lsp/server/completion.rb +100 -77
- data/lib/milk_tea/lsp/server/diagnostics_scheduling.rb +4 -6
- data/lib/milk_tea/lsp/server/formatting.rb +13 -3
- data/lib/milk_tea/lsp/server/hover.rb +321 -41
- data/lib/milk_tea/lsp/server/lifecycle.rb +12 -0
- data/lib/milk_tea/lsp/server/references.rb +3 -1
- data/lib/milk_tea/lsp/server/semantic_tokens.rb +55 -16
- data/lib/milk_tea/lsp/server/text_documents.rb +2 -2
- data/lib/milk_tea/lsp/server/type_hierarchy.rb +2 -2
- data/lib/milk_tea/lsp/server/utilities.rb +4 -0
- data/lib/milk_tea/lsp/server.rb +9 -0
- data/lib/milk_tea/lsp/workspace/analysis.rb +14 -3
- data/lib/milk_tea/lsp/workspace/caches.rb +17 -1
- data/lib/milk_tea/lsp/workspace/collection.rb +4 -0
- data/lib/milk_tea/lsp/workspace/module_index.rb +134 -0
- data/lib/milk_tea/lsp/workspace/store.rb +9 -4
- data/lib/milk_tea/lsp/workspace.rb +8 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d9af5865fb54b82fbf9169cdf8f8feef652af6a2b84cbd8e63e35f99b33ac08
|
|
4
|
+
data.tar.gz: 2c5d57827cc19e8de3ee5fff7e576860aa4ae30fa5f7b588eba80c9e95227e02
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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)
|