ttsc 0.20.1 → 0.22.0

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 (95) hide show
  1. package/cmd/ttscserver/main.go +79 -3
  2. package/cmd/ttscserver/run_lsp_uses_native_plugin_manifest_source_test.go +96 -16
  3. package/driver/lsp.go +4 -0
  4. package/internal/lspserver/lsp_document_diagnostics_omits_cached_project_when_current_result_has_none_test.go +48 -0
  5. package/internal/lspserver/lsp_executable_project_input_change_requests_launcher_restart_test.go +98 -0
  6. package/internal/lspserver/lsp_external_watched_change_retains_program_test.go +53 -0
  7. package/internal/lspserver/lsp_hints_refresh_scheduled_by_editor_events_test.go +9 -0
  8. package/internal/lspserver/lsp_native_plugin_source.go +171 -55
  9. package/internal/lspserver/lsp_native_resident.go +110 -9
  10. package/internal/lspserver/lsp_plugin.go +6 -0
  11. package/internal/lspserver/lsp_project_context_transport_identity_test.go +43 -0
  12. package/internal/lspserver/lsp_project_diagnostics.go +235 -0
  13. package/internal/lspserver/lsp_project_diagnostics_capability_is_independent_test.go +67 -0
  14. package/internal/lspserver/lsp_project_diagnostics_program_input_overlap_refreshes_all_producers_test.go +78 -0
  15. package/internal/lspserver/lsp_project_diagnostics_refresh_accumulates_owner_freshness_test.go +65 -0
  16. package/internal/lspserver/lsp_project_diagnostics_refresh_keeps_each_producer_last_good_test.go +90 -0
  17. package/internal/lspserver/lsp_project_diagnostics_refreshes_only_input_owners_test.go +62 -0
  18. package/internal/lspserver/lsp_project_diagnostics_resident_unsupported_falls_back_test.go +123 -0
  19. package/internal/lspserver/lsp_project_diagnostics_resume_preserves_generation_test.go +52 -0
  20. package/internal/lspserver/lsp_project_diagnostics_translates_relative_client_project_test.go +90 -0
  21. package/internal/lspserver/lsp_project_input_ownership_resolves_declared_spellings_test.go +95 -0
  22. package/internal/lspserver/lsp_project_input_registration.go +504 -0
  23. package/internal/lspserver/lsp_project_input_registration_rechecks_selection_baseline_test.go +71 -0
  24. package/internal/lspserver/lsp_project_input_registration_reconciles_rejected_pending_request_test.go +268 -0
  25. package/internal/lspserver/lsp_project_input_registration_shapes_patterns_test.go +94 -0
  26. package/internal/lspserver/lsp_project_input_watchers_anchor_missing_paths_test.go +48 -0
  27. package/internal/lspserver/lsp_project_inputs.go +944 -0
  28. package/internal/lspserver/lsp_project_inputs_match_missing_exact_and_glob_test.go +129 -0
  29. package/internal/lspserver/lsp_project_inputs_reject_invalid_and_foreign_snapshots_test.go +110 -0
  30. package/internal/lspserver/lsp_proxy.go +591 -28
  31. package/internal/lspserver/lsp_reload_directories_compare_immediate_topology_test.go +188 -0
  32. package/internal/lspserver/lsp_reload_file_fingerprint_preserves_raw_symlink_target_test.go +47 -0
  33. package/internal/lspserver/lsp_resident_falls_back_when_serve_unsupported_test.go +1 -1
  34. package/internal/lspserver/lsp_resident_invalidation_broadcasts_program_input_overlap_test.go +60 -0
  35. package/internal/lspserver/lsp_resident_invalidation_targets_project_input_owners_test.go +51 -0
  36. package/internal/lspserver/lsp_same_transport_entries_are_deduplicated_test.go +66 -0
  37. package/lib/compiler/internal/project/readProjectConfig.d.ts +5 -3
  38. package/lib/compiler/internal/project/readProjectConfig.js +46 -14
  39. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  40. package/lib/compiler/internal/residentCheckProcess.d.ts +46 -0
  41. package/lib/compiler/internal/residentCheckProcess.js +227 -0
  42. package/lib/compiler/internal/residentCheckProcess.js.map +1 -0
  43. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +2 -1
  44. package/lib/compiler/internal/resolveEmittedJavaScript.js +35 -18
  45. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  46. package/lib/compiler/internal/runBuild.d.ts +64 -1
  47. package/lib/compiler/internal/runBuild.js +484 -7
  48. package/lib/compiler/internal/runBuild.js.map +1 -1
  49. package/lib/compiler/internal/runSingleFileEmit.js +1 -0
  50. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  51. package/lib/flags/parser.js +13 -17
  52. package/lib/flags/parser.js.map +1 -1
  53. package/lib/flags/schema.d.ts +10 -3
  54. package/lib/flags/schema.js +99 -0
  55. package/lib/flags/schema.js.map +1 -1
  56. package/lib/internal/projectInputPathIdentity.d.ts +23 -0
  57. package/lib/internal/projectInputPathIdentity.js +222 -0
  58. package/lib/internal/projectInputPathIdentity.js.map +1 -0
  59. package/lib/launcher/internal/runTtsc.d.ts +17 -0
  60. package/lib/launcher/internal/runTtsc.js +121 -88
  61. package/lib/launcher/internal/runTtsc.js.map +1 -1
  62. package/lib/launcher/internal/runTtscserver.d.ts +13 -1
  63. package/lib/launcher/internal/runTtscserver.js +415 -45
  64. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  65. package/lib/launcher/internal/singleFileOutput.d.ts +16 -0
  66. package/lib/launcher/internal/singleFileOutput.js +103 -0
  67. package/lib/launcher/internal/singleFileOutput.js.map +1 -0
  68. package/lib/launcher/internal/watchTopology.d.ts +214 -2
  69. package/lib/launcher/internal/watchTopology.js +1560 -41
  70. package/lib/launcher/internal/watchTopology.js.map +1 -1
  71. package/lib/plugin/internal/loadProjectPlugins.d.ts +1 -0
  72. package/lib/plugin/internal/loadProjectPlugins.js +5 -0
  73. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  74. package/lib/structures/ITtscPluginCapabilities.d.ts +31 -0
  75. package/lib/structures/internal/ITtscProjectInputSnapshot.d.ts +29 -0
  76. package/lib/structures/internal/ITtscProjectInputSnapshot.js +3 -0
  77. package/lib/structures/internal/ITtscProjectInputSnapshot.js.map +1 -0
  78. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +5 -0
  79. package/package.json +8 -8
  80. package/src/compiler/internal/project/readProjectConfig.ts +62 -8
  81. package/src/compiler/internal/residentCheckProcess.ts +279 -0
  82. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -21
  83. package/src/compiler/internal/runBuild.ts +728 -12
  84. package/src/compiler/internal/runSingleFileEmit.ts +1 -0
  85. package/src/flags/parser.ts +17 -17
  86. package/src/flags/schema.ts +111 -3
  87. package/src/internal/projectInputPathIdentity.ts +273 -0
  88. package/src/launcher/internal/runTtsc.ts +141 -117
  89. package/src/launcher/internal/runTtscserver.ts +561 -54
  90. package/src/launcher/internal/singleFileOutput.ts +133 -0
  91. package/src/launcher/internal/watchTopology.ts +2008 -59
  92. package/src/plugin/internal/loadProjectPlugins.ts +8 -0
  93. package/src/structures/ITtscPluginCapabilities.ts +34 -0
  94. package/src/structures/internal/ITtscProjectInputSnapshot.ts +29 -0
  95. package/src/structures/internal/TtscSingleFileEmitOptions.ts +5 -0
@@ -0,0 +1,944 @@
1
+ package lspserver
2
+
3
+ import (
4
+ "crypto/sha256"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "path"
9
+ "path/filepath"
10
+ "runtime"
11
+ "sort"
12
+ "strings"
13
+ )
14
+
15
+ // LSPProjectInputSnapshot is the normalized external filesystem topology
16
+ // published by project-rule contributors.
17
+ type LSPProjectInputSnapshot struct {
18
+ Root string `json:"root"`
19
+ Files []string `json:"files"`
20
+ Globs []string `json:"globs"`
21
+ ReloadFiles []string `json:"reloadFiles,omitempty"`
22
+ ReloadDirectories []string `json:"reloadDirectories,omitempty"`
23
+ ReloadFileDigests map[string]string `json:"reloadFileDigests,omitempty"`
24
+ ReloadDirectoryDigests map[string]string `json:"reloadDirectoryDigests,omitempty"`
25
+ }
26
+
27
+ type projectInputRecord struct {
28
+ generation uint64
29
+ snapshot LSPProjectInputSnapshot
30
+ }
31
+
32
+ // ProjectInputs returns a stable copy of the current merged dependency
33
+ // snapshot.
34
+ func (s *NativePluginSource) ProjectInputs() LSPProjectInputSnapshot {
35
+ if s == nil {
36
+ return LSPProjectInputSnapshot{}
37
+ }
38
+ s.projectInputsMu.RLock()
39
+ defer s.projectInputsMu.RUnlock()
40
+ return copyProjectInputSnapshot(s.projectInputs)
41
+ }
42
+
43
+ // ProjectInputReloadFingerprintsAreCurrent reports whether the retained
44
+ // selection-time baseline still matches the filesystem. The proxy checks this
45
+ // after the client confirms dynamic watcher registration, closing the interval
46
+ // between construction-time validation and active event delivery.
47
+ func (s *NativePluginSource) ProjectInputReloadFingerprintsAreCurrent() bool {
48
+ if s == nil {
49
+ return true
50
+ }
51
+ s.projectInputsMu.RLock()
52
+ snapshot := copyProjectInputSnapshot(s.projectInputs)
53
+ s.projectInputsMu.RUnlock()
54
+ return projectInputReloadFingerprintsAreCurrent(snapshot)
55
+ }
56
+
57
+ // ProjectInputMatchesURI reports whether a watched-file URI belongs to a
58
+ // declared exact dependency or glob population.
59
+ func (s *NativePluginSource) ProjectInputMatchesURI(uri string) bool {
60
+ if s == nil {
61
+ return false
62
+ }
63
+ location, ok := filePathFromURI(uri)
64
+ if !ok {
65
+ return false
66
+ }
67
+ candidate := projectInputPathKey(realProjectInputPath(location))
68
+ s.projectInputsMu.RLock()
69
+ defer s.projectInputsMu.RUnlock()
70
+ return projectInputSnapshotMatchesCandidate(s.projectInputs, candidate)
71
+ }
72
+
73
+ // ProjectInputReloadMatchesURI reports whether uri names an exact reload file,
74
+ // a reload directory, or one of that directory's immediate entries. Callers
75
+ // with an LSP change event should use ProjectInputReloadMatchesChange so an
76
+ // ordinary content edit inside a topology directory does not force a restart.
77
+ func (s *NativePluginSource) ProjectInputReloadMatchesURI(uri string) bool {
78
+ if s == nil {
79
+ return false
80
+ }
81
+ location, ok := filePathFromURI(uri)
82
+ if !ok {
83
+ return false
84
+ }
85
+ candidate := realProjectInputPath(location)
86
+ candidateEntry := realProjectInputEntryPath(location)
87
+ candidateKey := projectInputPathKey(candidate)
88
+ candidateEntryKey := projectInputPathKey(candidateEntry)
89
+ s.projectInputsMu.RLock()
90
+ defer s.projectInputsMu.RUnlock()
91
+ for _, file := range s.projectInputs.ReloadFiles {
92
+ // The candidate is resolved physically, so the declaration has to be too;
93
+ // a Windows short component or a symlinked ancestor otherwise never matches.
94
+ fileKey := projectInputPathKey(realProjectInputPath(file))
95
+ if fileKey == candidateKey || fileKey == candidateEntryKey {
96
+ return true
97
+ }
98
+ }
99
+ for _, directory := range s.projectInputs.ReloadDirectories {
100
+ if projectInputDirectoryContainsImmediate(directory, candidateEntry) {
101
+ return true
102
+ }
103
+ }
104
+ return false
105
+ }
106
+
107
+ // ProjectInputReloadMatchesChange reports whether an LSP filesystem change
108
+ // invalidates executable plugin selection. Exact reload files are content
109
+ // inputs. Reload directories are topology inputs, so only a change to the
110
+ // directory itself or an immediate entry can qualify, and it qualifies only
111
+ // when the current name/type/symlink-target digest differs from the snapshot.
112
+ func (s *NativePluginSource) ProjectInputReloadMatchesChange(
113
+ uri string,
114
+ changeType *int,
115
+ ) bool {
116
+ if s == nil {
117
+ return false
118
+ }
119
+ location, ok := filePathFromURI(uri)
120
+ if !ok {
121
+ return false
122
+ }
123
+ candidate := realProjectInputPath(location)
124
+ candidateEntry := realProjectInputEntryPath(location)
125
+ candidateKey := projectInputPathKey(candidate)
126
+ candidateEntryKey := projectInputPathKey(candidateEntry)
127
+ s.projectInputsMu.RLock()
128
+ snapshot := copyProjectInputSnapshot(s.projectInputs)
129
+ s.projectInputsMu.RUnlock()
130
+ for _, file := range snapshot.ReloadFiles {
131
+ // The candidate is resolved physically, so the declaration has to be too;
132
+ // a Windows short component or a symlinked ancestor otherwise never matches.
133
+ fileKey := projectInputPathKey(realProjectInputPath(file))
134
+ if fileKey == candidateKey || fileKey == candidateEntryKey {
135
+ return true
136
+ }
137
+ }
138
+ matched := false
139
+ for _, directory := range snapshot.ReloadDirectories {
140
+ if !projectInputDirectoryContainsImmediate(directory, candidateEntry) {
141
+ continue
142
+ }
143
+ key := projectInputPathKey(directory)
144
+ // The digest map is keyed by the declared spelling, so that lookup keeps
145
+ // it. The identity comparison is against a physically resolved candidate
146
+ // and has to resolve too, or a directory recreated with identical topology
147
+ // under an aliased spelling would fall through to the digest compare and
148
+ // report no change at all.
149
+ if projectInputPathKey(realProjectInputPath(directory)) ==
150
+ candidateEntryKey &&
151
+ (changeType == nil || *changeType != fileChangeTypeChanged) {
152
+ return true
153
+ }
154
+ if projectInputReloadDirectoryDigest(directory) !=
155
+ snapshot.ReloadDirectoryDigests[key] {
156
+ matched = true
157
+ }
158
+ }
159
+ return matched
160
+ }
161
+
162
+ // ProjectInputOwnersForURI returns the stable plugin keys whose latest
163
+ // successful snapshots match uri. Ownership is retained past the flattened
164
+ // client registration so an external edit refreshes only the contributors that
165
+ // declared it.
166
+ func (s *NativePluginSource) ProjectInputOwnersForURI(uri string) []string {
167
+ if s == nil {
168
+ return nil
169
+ }
170
+ location, ok := filePathFromURI(uri)
171
+ if !ok {
172
+ return nil
173
+ }
174
+ candidate := projectInputPathKey(realProjectInputPath(location))
175
+ s.projectInputsMu.RLock()
176
+ defer s.projectInputsMu.RUnlock()
177
+ owners := []string{}
178
+ for _, plugin := range selectPluginTransports(
179
+ s.plugins,
180
+ nil,
181
+ s.projectContextJSON,
182
+ ) {
183
+ key := pluginKey(plugin, s.projectContextJSON)
184
+ record, ok := s.pluginProjectInputs[key]
185
+ if !ok || !projectInputSnapshotMatchesCandidate(record.snapshot, candidate) {
186
+ continue
187
+ }
188
+ owners = append(owners, key)
189
+ }
190
+ return owners
191
+ }
192
+
193
+ // projectInputSnapshotMatchesCandidate compares a declaration against a
194
+ // candidate both sides of which have been resolved to one filesystem identity.
195
+ //
196
+ // The candidate arrives from an editor URI and is resolved physically, so a
197
+ // declaration compared lexically can never match it wherever the two spellings
198
+ // differ: a Windows short (8.3) component, which `%TEMP%` routinely carries, or
199
+ // a symlinked ancestor such as macOS `/var`. Normalization already resolves
200
+ // every lane, so this resolution is idempotent for any snapshot that arrived
201
+ // through it; it is what makes a snapshot stored directly, as the unit cases
202
+ // do, answer on the same terms as one the ingestion boundary normalized.
203
+ func projectInputSnapshotMatchesCandidate(
204
+ snapshot LSPProjectInputSnapshot,
205
+ candidate string,
206
+ ) bool {
207
+ for _, file := range snapshot.Files {
208
+ if projectInputPathKey(realProjectInputPath(file)) == candidate {
209
+ return true
210
+ }
211
+ }
212
+ for _, pattern := range snapshot.Globs {
213
+ // A pattern has no filesystem object of its own; realProjectInputPath
214
+ // resolves its longest existing prefix and rejoins the wildcards.
215
+ if matchProjectInputGlob(
216
+ strings.Split(projectInputPathKey(realProjectInputPath(pattern)), "/"),
217
+ strings.Split(candidate, "/"),
218
+ ) {
219
+ return true
220
+ }
221
+ }
222
+ return false
223
+ }
224
+
225
+ // RefreshProjectInputs schedules a coalesced dependency rediscovery after a
226
+ // configuration input changes.
227
+ func (s *NativePluginSource) RefreshProjectInputs() {
228
+ if s == nil {
229
+ return
230
+ }
231
+ s.projectInputsRefresh.schedule(s.discoverProjectInputs)
232
+ }
233
+
234
+ // SetProjectInputsObserver registers the proxy callback that replaces the
235
+ // client's dynamic watched-file registration after a successful topology
236
+ // change.
237
+ func (s *NativePluginSource) SetProjectInputsObserver(observer func()) {
238
+ if s == nil {
239
+ return
240
+ }
241
+ s.projectInputsMu.Lock()
242
+ s.projectInputsObserver = observer
243
+ s.projectInputsMu.Unlock()
244
+ }
245
+
246
+ func (s *NativePluginSource) discoverProjectInputs(generation uint64) {
247
+ changed := false
248
+ for _, plugin := range selectPluginTransports(
249
+ s.plugins,
250
+ func(plugin NativeLSPPluginEntry) bool {
251
+ return plugin.ProjectInputs
252
+ },
253
+ s.projectContextJSON,
254
+ ) {
255
+ body, err := s.run(plugin, "project-inputs")
256
+ if err != nil {
257
+ s.log("%v", err)
258
+ continue
259
+ }
260
+ var snapshot LSPProjectInputSnapshot
261
+ if err := json.Unmarshal(body, &snapshot); err != nil {
262
+ s.log(
263
+ "ttscserver: %s project-inputs returned invalid JSON: %v",
264
+ pluginLabel(plugin),
265
+ err,
266
+ )
267
+ continue
268
+ }
269
+ snapshot, err = normalizeLSPProjectInputSnapshot(snapshot, s.cwd)
270
+ if err != nil {
271
+ s.log(
272
+ "ttscserver: %s project-inputs returned an invalid snapshot: %v",
273
+ pluginLabel(plugin),
274
+ err,
275
+ )
276
+ continue
277
+ }
278
+ if s.storeProjectInputs(plugin, generation, snapshot) {
279
+ changed = true
280
+ }
281
+ }
282
+ if changed {
283
+ s.projectInputsMu.RLock()
284
+ observer := s.projectInputsObserver
285
+ s.projectInputsMu.RUnlock()
286
+ if observer != nil {
287
+ observer()
288
+ }
289
+ }
290
+ }
291
+
292
+ func (s *NativePluginSource) storeProjectInputs(
293
+ plugin NativeLSPPluginEntry,
294
+ generation uint64,
295
+ snapshot LSPProjectInputSnapshot,
296
+ ) bool {
297
+ key := pluginKey(plugin, s.projectContextJSON)
298
+ s.projectInputsMu.Lock()
299
+ defer s.projectInputsMu.Unlock()
300
+ if existing, ok := s.pluginProjectInputs[key]; ok &&
301
+ generation < existing.generation {
302
+ return false
303
+ }
304
+ if s.pluginProjectInputs == nil {
305
+ s.pluginProjectInputs = map[string]projectInputRecord{}
306
+ }
307
+ existing, existed := s.pluginProjectInputs[key]
308
+ if existed {
309
+ preserveProjectInputReloadFingerprints(existing.snapshot, &snapshot)
310
+ }
311
+ s.pluginProjectInputs[key] = projectInputRecord{
312
+ generation: generation,
313
+ snapshot: snapshot,
314
+ }
315
+ if existed && projectInputSnapshotsEqual(existing.snapshot, snapshot) {
316
+ return false
317
+ }
318
+ s.projectInputs = s.flattenProjectInputsLocked()
319
+ return true
320
+ }
321
+
322
+ func (s *NativePluginSource) flattenProjectInputsLocked() LSPProjectInputSnapshot {
323
+ files := map[string]string{}
324
+ globs := map[string]string{}
325
+ reloadFiles := map[string]string{}
326
+ reloadDirectories := map[string]string{}
327
+ reloadFileDigests := map[string]string{}
328
+ reloadDirectoryDigests := map[string]string{}
329
+ root := ""
330
+ for _, plugin := range selectPluginTransports(
331
+ s.plugins,
332
+ nil,
333
+ s.projectContextJSON,
334
+ ) {
335
+ key := pluginKey(plugin, s.projectContextJSON)
336
+ snapshot := s.pluginProjectInputs[key].snapshot
337
+ if root == "" && snapshot.Root != "" {
338
+ root = snapshot.Root
339
+ }
340
+ for _, file := range snapshot.Files {
341
+ files[projectInputPathKey(file)] = file
342
+ }
343
+ for _, pattern := range snapshot.Globs {
344
+ globs[projectInputPathKey(pattern)] = pattern
345
+ }
346
+ for _, file := range snapshot.ReloadFiles {
347
+ fileKey := projectInputPathKey(file)
348
+ reloadFiles[fileKey] = file
349
+ reloadFileDigests[fileKey] = snapshot.ReloadFileDigests[fileKey]
350
+ }
351
+ for _, directory := range snapshot.ReloadDirectories {
352
+ directoryKey := projectInputPathKey(directory)
353
+ reloadDirectories[directoryKey] = directory
354
+ reloadDirectoryDigests[directoryKey] =
355
+ snapshot.ReloadDirectoryDigests[directoryKey]
356
+ }
357
+ }
358
+ out := LSPProjectInputSnapshot{
359
+ Root: root,
360
+ ReloadFileDigests: reloadFileDigests,
361
+ ReloadDirectoryDigests: reloadDirectoryDigests,
362
+ }
363
+ for _, file := range files {
364
+ out.Files = append(out.Files, file)
365
+ }
366
+ for _, pattern := range globs {
367
+ out.Globs = append(out.Globs, pattern)
368
+ }
369
+ for _, file := range reloadFiles {
370
+ out.ReloadFiles = append(out.ReloadFiles, file)
371
+ }
372
+ for _, directory := range reloadDirectories {
373
+ out.ReloadDirectories = append(out.ReloadDirectories, directory)
374
+ }
375
+ sort.Strings(out.Files)
376
+ sort.Strings(out.Globs)
377
+ sort.Strings(out.ReloadFiles)
378
+ sort.Strings(out.ReloadDirectories)
379
+ return out
380
+ }
381
+
382
+ func normalizeLSPProjectInputSnapshot(
383
+ snapshot LSPProjectInputSnapshot,
384
+ expectedRoot string,
385
+ ) (LSPProjectInputSnapshot, error) {
386
+ if strings.TrimSpace(snapshot.Root) == "" ||
387
+ !isAbsoluteLocalLSPProjectInputPath(snapshot.Root, runtime.GOOS) {
388
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
389
+ "root %q is not an absolute local path",
390
+ snapshot.Root,
391
+ )
392
+ }
393
+ root := filepath.ToSlash(realProjectInputPath(snapshot.Root))
394
+ if strings.TrimSpace(expectedRoot) != "" &&
395
+ projectInputPathKey(root) !=
396
+ projectInputPathKey(realProjectInputPath(expectedRoot)) {
397
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
398
+ "root %q differs from selected project root %q",
399
+ root,
400
+ filepath.ToSlash(realProjectInputPath(expectedRoot)),
401
+ )
402
+ }
403
+ files := map[string]string{}
404
+ for _, file := range snapshot.Files {
405
+ if strings.TrimSpace(file) == "" ||
406
+ !isAbsoluteLocalLSPProjectInputPath(file, runtime.GOOS) {
407
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
408
+ "file %q is not an absolute local path",
409
+ file,
410
+ )
411
+ }
412
+ normalized := filepath.ToSlash(realProjectInputPath(file))
413
+ files[projectInputPathKey(normalized)] = normalized
414
+ }
415
+ reloadFiles := map[string]string{}
416
+ for _, file := range snapshot.ReloadFiles {
417
+ if strings.TrimSpace(file) == "" ||
418
+ !isAbsoluteLocalLSPProjectInputPath(file, runtime.GOOS) {
419
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
420
+ "reload file %q is not an absolute local path",
421
+ file,
422
+ )
423
+ }
424
+ normalized := filepath.ToSlash(realProjectInputEntryPath(file))
425
+ reloadFiles[projectInputPathKey(normalized)] = normalized
426
+ }
427
+ reloadDirectories := map[string]string{}
428
+ for _, directory := range snapshot.ReloadDirectories {
429
+ if strings.TrimSpace(directory) == "" ||
430
+ !isAbsoluteLocalLSPProjectInputPath(directory, runtime.GOOS) {
431
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
432
+ "reload directory %q is not an absolute local path",
433
+ directory,
434
+ )
435
+ }
436
+ normalized := filepath.ToSlash(realProjectInputPath(directory))
437
+ reloadDirectories[projectInputPathKey(normalized)] = normalized
438
+ }
439
+ globs := map[string]string{}
440
+ for _, pattern := range snapshot.Globs {
441
+ if strings.TrimSpace(pattern) == "" ||
442
+ !isAbsoluteLocalLSPProjectInputPath(pattern, runtime.GOOS) {
443
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
444
+ "glob %q is not an absolute local path",
445
+ pattern,
446
+ )
447
+ }
448
+ // Every other lane in this function resolves its declaration physically,
449
+ // and a glob has to as well or its population is compared against events
450
+ // that were. A pattern has no filesystem object of its own, so resolution
451
+ // takes its longest existing prefix and rejoins the wildcards, which leaves
452
+ // the pattern's shape untouched while its root gains one identity.
453
+ normalized := filepath.ToSlash(realProjectInputPath(pattern))
454
+ globs[projectInputPathKey(normalized)] = normalized
455
+ }
456
+ out := LSPProjectInputSnapshot{
457
+ Root: root,
458
+ ReloadFileDigests: map[string]string{},
459
+ ReloadDirectoryDigests: map[string]string{},
460
+ }
461
+ for _, file := range files {
462
+ out.Files = append(out.Files, file)
463
+ }
464
+ for _, pattern := range globs {
465
+ out.Globs = append(out.Globs, pattern)
466
+ }
467
+ for _, file := range reloadFiles {
468
+ out.ReloadFiles = append(out.ReloadFiles, file)
469
+ key := projectInputPathKey(file)
470
+ digest, ok := projectInputSnapshotDigest(snapshot.ReloadFileDigests, file)
471
+ if !ok {
472
+ if snapshot.ReloadFileDigests != nil {
473
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
474
+ "reload file %q is missing its selection fingerprint",
475
+ file,
476
+ )
477
+ }
478
+ digest = projectInputReloadFileDigest(file)
479
+ }
480
+ if !validProjectInputFingerprint(digest) {
481
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
482
+ "reload file %q has an invalid fingerprint",
483
+ file,
484
+ )
485
+ }
486
+ out.ReloadFileDigests[key] = digest
487
+ }
488
+ for _, directory := range reloadDirectories {
489
+ out.ReloadDirectories = append(out.ReloadDirectories, directory)
490
+ key := projectInputPathKey(directory)
491
+ digest, ok := projectInputSnapshotDigest(
492
+ snapshot.ReloadDirectoryDigests,
493
+ directory,
494
+ )
495
+ if !ok {
496
+ if snapshot.ReloadDirectoryDigests != nil {
497
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
498
+ "reload directory %q is missing its selection fingerprint",
499
+ directory,
500
+ )
501
+ }
502
+ digest = projectInputReloadDirectoryDigest(directory)
503
+ }
504
+ if !validProjectInputFingerprint(digest) {
505
+ return LSPProjectInputSnapshot{}, fmt.Errorf(
506
+ "reload directory %q has an invalid fingerprint",
507
+ directory,
508
+ )
509
+ }
510
+ out.ReloadDirectoryDigests[key] = digest
511
+ }
512
+ sort.Strings(out.Files)
513
+ sort.Strings(out.Globs)
514
+ sort.Strings(out.ReloadFiles)
515
+ sort.Strings(out.ReloadDirectories)
516
+ return out, nil
517
+ }
518
+
519
+ func isAbsoluteLocalLSPProjectInputPath(
520
+ location string,
521
+ goos string,
522
+ ) bool {
523
+ if strings.ContainsRune(location, '\x00') {
524
+ return false
525
+ }
526
+ if goos != "windows" {
527
+ return path.IsAbs(location)
528
+ }
529
+ normalized := strings.ReplaceAll(location, "/", `\`)
530
+ if strings.HasPrefix(normalized, `\\?\`) {
531
+ extended := strings.TrimPrefix(normalized, `\\?\`)
532
+ if isWindowsDrivePath(extended) {
533
+ return true
534
+ }
535
+ if strings.HasPrefix(strings.ToLower(extended), `unc\`) {
536
+ return isWindowsUNCPath(extended[4:])
537
+ }
538
+ return false
539
+ }
540
+ if strings.HasPrefix(normalized, `\\.\`) {
541
+ return false
542
+ }
543
+ return isWindowsDrivePath(normalized) ||
544
+ (strings.HasPrefix(normalized, `\\`) &&
545
+ isWindowsUNCPath(strings.TrimPrefix(normalized, `\\`)))
546
+ }
547
+
548
+ func isWindowsDrivePath(location string) bool {
549
+ return len(location) >= 3 &&
550
+ ((location[0] >= 'A' && location[0] <= 'Z') ||
551
+ (location[0] >= 'a' && location[0] <= 'z')) &&
552
+ location[1] == ':' &&
553
+ location[2] == '\\'
554
+ }
555
+
556
+ func isWindowsUNCPath(location string) bool {
557
+ components := strings.Split(location, `\`)
558
+ return len(components) >= 2 &&
559
+ isWindowsUNCVolumeSegment(components[0]) &&
560
+ isWindowsUNCVolumeSegment(components[1])
561
+ }
562
+
563
+ func isWindowsUNCVolumeSegment(segment string) bool {
564
+ return segment != "" &&
565
+ segment != "." &&
566
+ segment != ".." &&
567
+ !strings.ContainsAny(segment, "\x00<>:\"/\\|?*")
568
+ }
569
+
570
+ func copyProjectInputSnapshot(
571
+ snapshot LSPProjectInputSnapshot,
572
+ ) LSPProjectInputSnapshot {
573
+ copied := LSPProjectInputSnapshot{
574
+ Root: snapshot.Root,
575
+ Files: append([]string(nil), snapshot.Files...),
576
+ Globs: append([]string(nil), snapshot.Globs...),
577
+ ReloadFiles: append([]string(nil), snapshot.ReloadFiles...),
578
+ ReloadDirectories: append([]string(nil), snapshot.ReloadDirectories...),
579
+ }
580
+ if snapshot.ReloadFileDigests != nil {
581
+ copied.ReloadFileDigests = make(
582
+ map[string]string,
583
+ len(snapshot.ReloadFileDigests),
584
+ )
585
+ for key, digest := range snapshot.ReloadFileDigests {
586
+ copied.ReloadFileDigests[key] = digest
587
+ }
588
+ }
589
+ if snapshot.ReloadDirectoryDigests != nil {
590
+ copied.ReloadDirectoryDigests = make(
591
+ map[string]string,
592
+ len(snapshot.ReloadDirectoryDigests),
593
+ )
594
+ for key, digest := range snapshot.ReloadDirectoryDigests {
595
+ copied.ReloadDirectoryDigests[key] = digest
596
+ }
597
+ }
598
+ return copied
599
+ }
600
+
601
+ func projectInputSnapshotsEqual(
602
+ left LSPProjectInputSnapshot,
603
+ right LSPProjectInputSnapshot,
604
+ ) bool {
605
+ if projectInputPathKey(left.Root) != projectInputPathKey(right.Root) ||
606
+ len(left.Files) != len(right.Files) ||
607
+ len(left.Globs) != len(right.Globs) ||
608
+ len(left.ReloadFiles) != len(right.ReloadFiles) ||
609
+ len(left.ReloadDirectories) != len(right.ReloadDirectories) {
610
+ return false
611
+ }
612
+ for index := range left.Files {
613
+ if projectInputPathKey(left.Files[index]) !=
614
+ projectInputPathKey(right.Files[index]) {
615
+ return false
616
+ }
617
+ }
618
+ for index := range left.Globs {
619
+ if projectInputPathKey(left.Globs[index]) !=
620
+ projectInputPathKey(right.Globs[index]) {
621
+ return false
622
+ }
623
+ }
624
+ for index := range left.ReloadFiles {
625
+ if projectInputPathKey(left.ReloadFiles[index]) !=
626
+ projectInputPathKey(right.ReloadFiles[index]) {
627
+ return false
628
+ }
629
+ key := projectInputPathKey(left.ReloadFiles[index])
630
+ if left.ReloadFileDigests[key] != right.ReloadFileDigests[key] {
631
+ return false
632
+ }
633
+ }
634
+ for index := range left.ReloadDirectories {
635
+ if projectInputPathKey(left.ReloadDirectories[index]) !=
636
+ projectInputPathKey(right.ReloadDirectories[index]) {
637
+ return false
638
+ }
639
+ key := projectInputPathKey(left.ReloadDirectories[index])
640
+ if left.ReloadDirectoryDigests[key] != right.ReloadDirectoryDigests[key] {
641
+ return false
642
+ }
643
+ }
644
+ return true
645
+ }
646
+
647
+ func projectInputDirectoryContainsImmediate(
648
+ directory string,
649
+ candidate string,
650
+ ) bool {
651
+ // Both operands are resolved physically before they are compared. Callers
652
+ // pass a candidate that already went through that resolution, so leaving the
653
+ // declared directory lexical would never match it under a Windows short
654
+ // component or a symlinked ancestor.
655
+ resolvedDirectory := realProjectInputPath(directory)
656
+ relative, err := filepath.Rel(
657
+ projectInputFilesystemPath(resolvedDirectory),
658
+ projectInputFilesystemPath(candidate),
659
+ )
660
+ if err != nil || filepath.IsAbs(relative) {
661
+ return false
662
+ }
663
+ relativeKey := projectInputPathKey(relative)
664
+ if relativeKey == "." {
665
+ return projectInputPathKey(resolvedDirectory) ==
666
+ projectInputPathKey(candidate)
667
+ }
668
+ return relativeKey != ".." &&
669
+ !strings.HasPrefix(relativeKey, "../") &&
670
+ !strings.Contains(relativeKey, "/")
671
+ }
672
+
673
+ func preserveProjectInputReloadFingerprints(
674
+ baseline LSPProjectInputSnapshot,
675
+ current *LSPProjectInputSnapshot,
676
+ ) {
677
+ if current == nil {
678
+ return
679
+ }
680
+ for _, file := range current.ReloadFiles {
681
+ key := projectInputPathKey(file)
682
+ if digest := baseline.ReloadFileDigests[key]; digest != "" {
683
+ current.ReloadFileDigests[key] = digest
684
+ }
685
+ }
686
+ for _, directory := range current.ReloadDirectories {
687
+ key := projectInputPathKey(directory)
688
+ if digest := baseline.ReloadDirectoryDigests[key]; digest != "" {
689
+ current.ReloadDirectoryDigests[key] = digest
690
+ }
691
+ }
692
+ }
693
+
694
+ func projectInputReloadFingerprintsAreCurrent(
695
+ snapshot LSPProjectInputSnapshot,
696
+ ) bool {
697
+ for _, file := range snapshot.ReloadFiles {
698
+ key := projectInputPathKey(file)
699
+ if snapshot.ReloadFileDigests[key] != projectInputReloadFileDigest(file) {
700
+ return false
701
+ }
702
+ }
703
+ for _, directory := range snapshot.ReloadDirectories {
704
+ key := projectInputPathKey(directory)
705
+ if snapshot.ReloadDirectoryDigests[key] !=
706
+ projectInputReloadDirectoryDigest(directory) {
707
+ return false
708
+ }
709
+ }
710
+ return true
711
+ }
712
+
713
+ func projectInputSnapshotDigest(
714
+ fingerprints map[string]string,
715
+ location string,
716
+ ) (string, bool) {
717
+ if fingerprints == nil {
718
+ return "", false
719
+ }
720
+ if digest, ok := fingerprints[location]; ok {
721
+ return digest, true
722
+ }
723
+ wanted := projectInputPathKey(location)
724
+ for candidate, digest := range fingerprints {
725
+ if projectInputPathKey(realProjectInputPath(candidate)) == wanted ||
726
+ projectInputPathKey(realProjectInputEntryPath(candidate)) == wanted {
727
+ return digest, true
728
+ }
729
+ }
730
+ return "", false
731
+ }
732
+
733
+ func validProjectInputFingerprint(digest string) bool {
734
+ if len(digest) != sha256.Size*2 || strings.ToLower(digest) != digest {
735
+ return false
736
+ }
737
+ for _, char := range digest {
738
+ if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
739
+ return false
740
+ }
741
+ }
742
+ return true
743
+ }
744
+
745
+ func projectInputReloadDirectoryDigest(directory string) string {
746
+ entries, err := os.ReadDir(projectInputFilesystemPath(directory))
747
+ if err != nil {
748
+ missing := sha256.Sum256([]byte("missing\x00"))
749
+ return fmt.Sprintf("%x", missing[:])
750
+ }
751
+ digest := sha256.New()
752
+ for index, entry := range entries {
753
+ kind := "other"
754
+ info, err := entry.Info()
755
+ if err != nil {
756
+ missing := sha256.Sum256([]byte("missing\x00"))
757
+ return fmt.Sprintf("%x", missing[:])
758
+ }
759
+ switch {
760
+ case info.Mode()&os.ModeSymlink != 0:
761
+ kind = "symlink"
762
+ case info.IsDir():
763
+ kind = "directory"
764
+ case info.Mode().IsRegular():
765
+ kind = "file"
766
+ }
767
+ target := ""
768
+ if kind == "symlink" {
769
+ target, err = os.Readlink(
770
+ filepath.Join(projectInputFilesystemPath(directory), entry.Name()),
771
+ )
772
+ if err != nil {
773
+ target = "<unreadable>"
774
+ }
775
+ }
776
+ digest.Write([]byte(entry.Name()))
777
+ digest.Write([]byte{0})
778
+ digest.Write([]byte(kind))
779
+ digest.Write([]byte{0})
780
+ digest.Write([]byte(target))
781
+ if index+1 != len(entries) {
782
+ digest.Write([]byte{0})
783
+ }
784
+ }
785
+ return fmt.Sprintf("%x", digest.Sum(nil))
786
+ }
787
+
788
+ func projectInputReloadFileDigest(location string) string {
789
+ native := projectInputFilesystemPath(location)
790
+ info, err := os.Lstat(native)
791
+ if err != nil {
792
+ missing := sha256.Sum256([]byte("missing\x00"))
793
+ return fmt.Sprintf("%x", missing[:])
794
+ }
795
+ if info.Mode()&os.ModeSymlink != 0 {
796
+ target, err := os.Readlink(native)
797
+ if err != nil {
798
+ target = "<unreadable>"
799
+ }
800
+ content := []byte("missing\x00")
801
+ if body, readErr := os.ReadFile(native); readErr == nil {
802
+ content = append([]byte("file\x00"), body...)
803
+ }
804
+ digest := sha256.New()
805
+ digest.Write([]byte("symlink\x00"))
806
+ digest.Write([]byte(target))
807
+ digest.Write([]byte{0})
808
+ digest.Write(content)
809
+ return fmt.Sprintf("%x", digest.Sum(nil))
810
+ }
811
+ if info.Mode().IsRegular() {
812
+ body, err := os.ReadFile(native)
813
+ if err != nil {
814
+ missing := sha256.Sum256([]byte("missing\x00"))
815
+ return fmt.Sprintf("%x", missing[:])
816
+ }
817
+ digest := sha256.New()
818
+ digest.Write([]byte("file\x00"))
819
+ digest.Write(body)
820
+ return fmt.Sprintf("%x", digest.Sum(nil))
821
+ }
822
+ other := sha256.Sum256([]byte("other\x00"))
823
+ return fmt.Sprintf("%x", other[:])
824
+ }
825
+
826
+ func realProjectInputPath(location string) string {
827
+ absolute, err := filepath.Abs(projectInputFilesystemPath(location))
828
+ if err != nil {
829
+ return filepath.Clean(filepath.FromSlash(location))
830
+ }
831
+ probe := absolute
832
+ suffix := []string{}
833
+ for {
834
+ resolved, err := filepath.EvalSymlinks(probe)
835
+ if err == nil {
836
+ for index := len(suffix) - 1; index >= 0; index-- {
837
+ resolved = filepath.Join(resolved, suffix[index])
838
+ }
839
+ return filepath.Clean(resolved)
840
+ }
841
+ parent := filepath.Dir(probe)
842
+ if parent == probe {
843
+ return filepath.Clean(absolute)
844
+ }
845
+ suffix = append(suffix, filepath.Base(probe))
846
+ probe = parent
847
+ }
848
+ }
849
+
850
+ func realProjectInputEntryPath(location string) string {
851
+ native := projectInputFilesystemPath(location)
852
+ return filepath.Join(
853
+ realProjectInputPath(filepath.Dir(native)),
854
+ filepath.Base(native),
855
+ )
856
+ }
857
+
858
+ func projectInputFilesystemPath(location string) string {
859
+ if runtime.GOOS != "windows" {
860
+ return filepath.FromSlash(location)
861
+ }
862
+ normalized := strings.ReplaceAll(location, "/", `\`)
863
+ if strings.HasPrefix(strings.ToLower(normalized), `\\?\unc\`) {
864
+ return `\\` + normalized[8:]
865
+ }
866
+ if strings.HasPrefix(normalized, `\\?\`) &&
867
+ isWindowsDrivePath(normalized[4:]) {
868
+ return normalized[4:]
869
+ }
870
+ return normalized
871
+ }
872
+
873
+ func projectInputPathKey(location string) string {
874
+ key := filepath.ToSlash(filepath.Clean(filepath.FromSlash(location)))
875
+ if runtime.GOOS == "windows" {
876
+ key = strings.ToLower(key)
877
+ }
878
+ return key
879
+ }
880
+
881
+ func matchProjectInputGlob(pattern []string, candidate []string) bool {
882
+ type position struct {
883
+ pattern int
884
+ candidate int
885
+ }
886
+ memo := map[position]bool{}
887
+ visited := map[position]bool{}
888
+ var visit func(int, int) bool
889
+ visit = func(patternIndex int, candidateIndex int) bool {
890
+ key := position{pattern: patternIndex, candidate: candidateIndex}
891
+ if visited[key] {
892
+ return memo[key]
893
+ }
894
+ visited[key] = true
895
+ var matched bool
896
+ switch {
897
+ case patternIndex == len(pattern):
898
+ matched = candidateIndex == len(candidate)
899
+ case pattern[patternIndex] == "**":
900
+ matched = visit(patternIndex+1, candidateIndex) ||
901
+ (candidateIndex != len(candidate) &&
902
+ visit(patternIndex, candidateIndex+1))
903
+ case candidateIndex != len(candidate):
904
+ matched = matchProjectInputGlobSegment(
905
+ pattern[patternIndex],
906
+ candidate[candidateIndex],
907
+ ) && visit(patternIndex+1, candidateIndex+1)
908
+ }
909
+ memo[key] = matched
910
+ return matched
911
+ }
912
+ return visit(0, 0)
913
+ }
914
+
915
+ func matchProjectInputGlobSegment(pattern string, candidate string) bool {
916
+ expression := []rune(pattern)
917
+ input := []rune(candidate)
918
+ matches := make([][]bool, len(expression)+1)
919
+ for index := range matches {
920
+ matches[index] = make([]bool, len(input)+1)
921
+ }
922
+ matches[0][0] = true
923
+ for patternIndex, char := range expression {
924
+ if char == '*' {
925
+ matches[patternIndex+1][0] = matches[patternIndex][0]
926
+ }
927
+ for inputIndex := range input {
928
+ switch char {
929
+ case '*':
930
+ matches[patternIndex+1][inputIndex+1] =
931
+ matches[patternIndex][inputIndex+1] ||
932
+ matches[patternIndex+1][inputIndex]
933
+ case '?':
934
+ matches[patternIndex+1][inputIndex+1] =
935
+ matches[patternIndex][inputIndex]
936
+ default:
937
+ matches[patternIndex+1][inputIndex+1] =
938
+ char == input[inputIndex] &&
939
+ matches[patternIndex][inputIndex]
940
+ }
941
+ }
942
+ }
943
+ return matches[len(expression)][len(input)]
944
+ }