@px-lsp/server 0.1.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 (163) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +334 -0
  3. package/THIRD-PARTY-NOTICES.md +83 -0
  4. package/data/ck3/dataTypes.json +2195 -0
  5. package/data/ck3/data_types/data_types_common.txt +2040 -0
  6. package/data/ck3/data_types/data_types_gui.txt +5264 -0
  7. package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
  8. package/data/ck3/data_types/data_types_script.txt +4251 -0
  9. package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
  10. package/data/ck3/freqs.json +1 -0
  11. package/data/ck3/guiSchema.json +6344 -0
  12. package/data/ck3/script_docs/effects.log +16059 -0
  13. package/data/ck3/script_docs/event_targets.log +2098 -0
  14. package/data/ck3/script_docs/modifiers.log +2228 -0
  15. package/data/ck3/script_docs/on_actions.log +5275 -0
  16. package/data/ck3/script_docs/triggers.log +11991 -0
  17. package/data/ck3/structures.json +9743 -0
  18. package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
  19. package/data/ck3/wikidocs/Data_types.md +2568 -0
  20. package/data/ck3/wikidocs/Effects_list.md +1176 -0
  21. package/data/ck3/wikidocs/Scopes_list.md +341 -0
  22. package/data/ck3/wikidocs/Triggers_list.md +1097 -0
  23. package/data/eu5/data_types/data_types_common.txt +2087 -0
  24. package/data/eu5/data_types/data_types_gui.txt +6732 -0
  25. package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
  26. package/data/eu5/data_types/data_types_script.txt +5688 -0
  27. package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
  28. package/data/vic3/data_types/data_types_common.txt +2021 -0
  29. package/data/vic3/data_types/data_types_gui.txt +5592 -0
  30. package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
  31. package/data/vic3/data_types/data_types_script.txt +2817 -0
  32. package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
  33. package/data/vic3/freqs.json +1 -0
  34. package/data/vic3/guiSchema.json +5578 -0
  35. package/data/vic3/script_docs/effects.log +38135 -0
  36. package/data/vic3/script_docs/event_targets.log +2028 -0
  37. package/data/vic3/script_docs/modifiers.log +18954 -0
  38. package/data/vic3/script_docs/on_actions.log +1561 -0
  39. package/data/vic3/script_docs/triggers.log +15738 -0
  40. package/data/vic3/structures.json +10189 -0
  41. package/dist/server.js +63668 -0
  42. package/media/px-lsp.svg +12 -0
  43. package/package.json +50 -0
  44. package/src/clientMode.ts +60 -0
  45. package/src/coa/coa.ts +184 -0
  46. package/src/coa/coaParse.ts +267 -0
  47. package/src/context.ts +78 -0
  48. package/src/contextKeywords.ts +224 -0
  49. package/src/data/dataBindingMacros.ts +82 -0
  50. package/src/data/dataFnDocs.ts +152 -0
  51. package/src/data/dataFnUsage.ts +431 -0
  52. package/src/data/dataTypes.ts +279 -0
  53. package/src/data/defines.ts +123 -0
  54. package/src/data/docsParser.ts +453 -0
  55. package/src/data/keywordDocs.ts +98 -0
  56. package/src/data/modifierTemplates.ts +143 -0
  57. package/src/data/textFormatting.ts +165 -0
  58. package/src/data/wikiDocs.ts +187 -0
  59. package/src/dds/decoder.ts +1007 -0
  60. package/src/dds/encode.ts +235 -0
  61. package/src/dds/index.ts +58 -0
  62. package/src/dds/png.ts +96 -0
  63. package/src/dds/tga.ts +62 -0
  64. package/src/documents.ts +35 -0
  65. package/src/features/assetPaths.ts +169 -0
  66. package/src/features/codeActions.ts +148 -0
  67. package/src/features/colors.ts +244 -0
  68. package/src/features/completion.ts +961 -0
  69. package/src/features/datafunction.ts +729 -0
  70. package/src/features/definition.ts +84 -0
  71. package/src/features/diagnostics.ts +244 -0
  72. package/src/features/folding.ts +106 -0
  73. package/src/features/formatting.ts +60 -0
  74. package/src/features/guiLanguage.ts +366 -0
  75. package/src/features/guiNavigation.ts +140 -0
  76. package/src/features/guiTree.ts +97 -0
  77. package/src/features/hover.ts +817 -0
  78. package/src/features/hoverRender.ts +222 -0
  79. package/src/features/inlayHints.ts +147 -0
  80. package/src/features/locFormatting.ts +127 -0
  81. package/src/features/references.ts +70 -0
  82. package/src/features/rename.ts +135 -0
  83. package/src/features/scopeAt.ts +65 -0
  84. package/src/features/semanticTokens.ts +188 -0
  85. package/src/features/signatureHelp.ts +72 -0
  86. package/src/features/symbols.ts +241 -0
  87. package/src/features/textureHover.ts +143 -0
  88. package/src/features/workspaceSymbols.ts +69 -0
  89. package/src/games/active.ts +19 -0
  90. package/src/games/ck3/ambientScopes.ts +273 -0
  91. package/src/games/ck3/index.ts +38 -0
  92. package/src/games/ck3/meta.ts +28 -0
  93. package/src/games/ck3/modifierPlaceholders.ts +61 -0
  94. package/src/games/ck3/saveSchema.ts +134 -0
  95. package/src/games/ck3/scaffolds.ts +197 -0
  96. package/src/games/ck3/schema.ts +422 -0
  97. package/src/games/ck3/structures.ts +887 -0
  98. package/src/games/eu5/index.ts +75 -0
  99. package/src/games/eu5/meta.ts +44 -0
  100. package/src/games/eu5/scaffolds.ts +49 -0
  101. package/src/games/eu5/schema.generated.ts +1043 -0
  102. package/src/games/jomini/variables.ts +134 -0
  103. package/src/games/profile.ts +205 -0
  104. package/src/games/registry.ts +27 -0
  105. package/src/games/vic3/index.ts +52 -0
  106. package/src/games/vic3/meta.ts +55 -0
  107. package/src/games/vic3/saveSchema.ts +77 -0
  108. package/src/games/vic3/scaffolds.ts +135 -0
  109. package/src/games/vic3/schema.ts +650 -0
  110. package/src/games/vic3/structures.ts +33 -0
  111. package/src/gui/anchorSpec.ts +66 -0
  112. package/src/gui/declMarkers.ts +30 -0
  113. package/src/gui/fillGeometry.ts +101 -0
  114. package/src/gui/guiDefs.ts +386 -0
  115. package/src/gui/guiDependencies.ts +352 -0
  116. package/src/gui/guiLinks.ts +64 -0
  117. package/src/gui/layoutEngine.ts +1998 -0
  118. package/src/gui/layoutService.ts +221 -0
  119. package/src/gui/measuredMetrics.ts +21 -0
  120. package/src/gui/previewService.ts +89 -0
  121. package/src/gui/saveSchema.ts +220 -0
  122. package/src/gui/saveValues.ts +399 -0
  123. package/src/gui/saveZip.ts +60 -0
  124. package/src/gui/sourceEdit.ts +535 -0
  125. package/src/gui/sourceEditService.ts +439 -0
  126. package/src/gui/sourceModel.ts +603 -0
  127. package/src/gui/textResolve.ts +145 -0
  128. package/src/gui/textureInfo.ts +106 -0
  129. package/src/gui/vocabulary.ts +149 -0
  130. package/src/gui/widgetEdit.ts +52 -0
  131. package/src/gui/widgetInfo.ts +245 -0
  132. package/src/index/docComments.ts +103 -0
  133. package/src/index/extract.ts +252 -0
  134. package/src/index/indexer.ts +369 -0
  135. package/src/index/intern.ts +101 -0
  136. package/src/index/lazyRefs.ts +145 -0
  137. package/src/index/modOrigin.ts +69 -0
  138. package/src/index/references.ts +534 -0
  139. package/src/overview/dependencies.ts +240 -0
  140. package/src/overview/eventBanner.ts +95 -0
  141. package/src/overview/eventDetail.ts +482 -0
  142. package/src/overview/eventGraph.ts +617 -0
  143. package/src/overview/eventVocabulary.ts +214 -0
  144. package/src/overview/locCoverage.ts +138 -0
  145. package/src/overview/modOverview.ts +29 -0
  146. package/src/overview/overrides.ts +89 -0
  147. package/src/parseCache.ts +81 -0
  148. package/src/parser/cst.ts +257 -0
  149. package/src/parser/encoding.ts +106 -0
  150. package/src/parser/index.ts +7 -0
  151. package/src/parser/lexer.ts +245 -0
  152. package/src/parser/locParser.ts +276 -0
  153. package/src/parser/parser.ts +360 -0
  154. package/src/schema/freqs.ts +70 -0
  155. package/src/schema/loader.ts +113 -0
  156. package/src/schema/types.ts +142 -0
  157. package/src/scopes/inference.ts +478 -0
  158. package/src/scopes/model.ts +148 -0
  159. package/src/scopes/varTypes.ts +290 -0
  160. package/src/server.ts +1894 -0
  161. package/src/serverData.ts +98 -0
  162. package/src/structure.ts +56 -0
  163. package/src/wordAt.ts +49 -0
package/src/server.ts ADDED
@@ -0,0 +1,1894 @@
1
+ /**
2
+ * Language server entry point: owns the script_docs token data and the
3
+ * definition index, and answers completion/hover/definition/semantic-token/
4
+ * inlay-hint/code-action requests plus the paradox/* custom protocol.
5
+ * Game knowledge comes from the active GameProfile (games/).
6
+ *
7
+ * All heavy work (vanilla scan) runs here, out of the editor's extension host,
8
+ * chunked so requests stay responsive, with LSP work-done progress.
9
+ */
10
+ import {
11
+ createConnection,
12
+ DidChangeWatchedFilesNotification,
13
+ MarkupKind,
14
+ ProposedFeatures,
15
+ TextDocuments,
16
+ TextDocumentSyncKind,
17
+ type InitializeParams,
18
+ type InitializeResult,
19
+ } from "vscode-languageserver/node";
20
+ import { TextDocument } from "vscode-languageserver-textdocument";
21
+ import * as fs from "fs";
22
+ import * as os from "os";
23
+ import * as path from "path";
24
+ import { createHash } from "crypto";
25
+ // Named import: the bundler inlines just the version string (not the whole
26
+ // manifest), and the same source works when tests import from src.
27
+ import { version as SERVER_VERSION } from "../package.json";
28
+ import type { Definition } from "@px-lsp/protocol/types";
29
+ import { pushAll } from "@px-lsp/protocol/arrays";
30
+ import { iterFiles } from "@px-lsp/protocol/fsWalk";
31
+ import {
32
+ configChangedNotification,
33
+ indexChangedNotification,
34
+ indexStatsRequest,
35
+ lookupLocRequest,
36
+ modFileChangedNotification,
37
+ progressNotification,
38
+ reloadDocsRequest,
39
+ statusNotification,
40
+ type ParadoxInitOptions,
41
+ type ParadoxSettings,
42
+ type StatusPayload,
43
+ type LocEntryInfo,
44
+ type LookupLocParams,
45
+ type ModFileChangeParams,
46
+ type ModScopedParams,
47
+ type ReloadDocsParams,
48
+ type ReloadDocsResult,
49
+ eventBannerRequest,
50
+ eventDetailRequest,
51
+ eventGraphRequest,
52
+ eventValueOptionsRequest,
53
+ eventVocabularyRequest,
54
+ guiTreeRequest,
55
+ locCoverageRequest,
56
+ modOverviewRequest,
57
+ overridesRequest,
58
+ type EventBannerParams,
59
+ type EventDetailParams,
60
+ type EventGraphParams,
61
+ type EventValueOptionsParams,
62
+ type EventVocabularyParams,
63
+ type GuiTreeParams,
64
+ guiLayoutRequest,
65
+ type GuiLayoutParams,
66
+ guiWidgetEditRequest,
67
+ type GuiWidgetEditParams,
68
+ guiSourceEditRequest,
69
+ type GuiSourceEditParams,
70
+ guiWidgetInfoRequest,
71
+ type GuiWidgetInfoParams,
72
+ guiDependenciesRequest,
73
+ type GuiDependenciesParams,
74
+ guiVocabularyRequest,
75
+ guiPreviewRequest,
76
+ GUI_PREVIEW_MAX,
77
+ type GuiPreviewParams,
78
+ type GuiPreviewResult,
79
+ guiSaveValuesRequest,
80
+ type GuiSaveValuesParams,
81
+ type GuiSaveValuesResult,
82
+ type GuiVocabularyParams,
83
+ dependenciesRequest,
84
+ type DependenciesParams,
85
+ scopeAtRequest,
86
+ type ScopeAtParams,
87
+ type ScopeAtResult,
88
+ } from "@px-lsp/protocol/protocol";
89
+ import { buildGuiTree } from "./features/guiTree";
90
+ import { resolveGuiText, type ResolvedText } from "./gui/textResolve";
91
+ import { previewEntries } from "./gui/previewService";
92
+ import { readSaveValues } from "./gui/saveValues";
93
+ import {
94
+ computeGuiLayoutResult,
95
+ getGuiDefs,
96
+ profileMeasurer,
97
+ getGuiScriptLinks,
98
+ invalidateGuiDefsCache,
99
+ observeGuiStoreBuild,
100
+ VIEWPORT,
101
+ } from "./gui/layoutService";
102
+ import { computeGuiWidgetEdit } from "./gui/widgetEdit";
103
+ import { computeGuiSourceEdit, computeGuiSourceEdits } from "./gui/sourceEditService";
104
+ import { computeGuiVocabulary } from "./gui/vocabulary";
105
+ import { computeGuiWidgetInfo } from "./gui/widgetInfo";
106
+ import { computeGuiDependencies, computeGuiUses } from "./gui/guiDependencies";
107
+ import { provideGuiCompletion, provideGuiHover } from "./features/guiLanguage";
108
+ import { provideGuiDefinition, type GuiPaths } from "./features/guiNavigation";
109
+ import { provideDataFnCompletion, provideDataFnHover, provideDataFnSignature } from "./features/datafunction";
110
+ import { getLineText, isScriptLanguage } from "./documents";
111
+ import { computeEventDetail } from "./overview/eventDetail";
112
+ import { loadTokenData, parseOnActionsLog } from "./data/docsParser";
113
+ import { loadDataTypes } from "./data/dataTypes";
114
+ import { loadDataBindingMacros } from "./data/dataBindingMacros";
115
+ import { DefinesIndex } from "./data/defines";
116
+ import { TextFormattingIndex } from "./data/textFormatting";
117
+ import { provideFormatTagCompletion, provideFormatTagHover } from "./features/locFormatting";
118
+ import { loadDataFnUsageAsync } from "./data/dataFnUsage";
119
+ import { loadWikiTokens, mergeWikiTokens } from "./data/wikiDocs";
120
+ import { loadFreqs } from "./schema/freqs";
121
+ import {
122
+ DefinitionIndex,
123
+ classifyFile,
124
+ detectGameVersion,
125
+ isWantedLocFile,
126
+ loadIndexCache,
127
+ saveIndexCache,
128
+ } from "./index/indexer";
129
+ import { internedCount, resetInternTable } from "./index/intern";
130
+ import { extractDefinitions } from "./index/extract";
131
+ import { extractReferences } from "./index/references";
132
+ import { LazyReferenceScanner, type LazyRefRoot } from "./index/lazyRefs";
133
+ import { ModOriginResolver } from "./index/modOrigin";
134
+ import { loadSchema, type SchemaData } from "./schema/loader";
135
+ import { VARIABLE_KINDS } from "./games/jomini/variables";
136
+ import { activeProfile, setActiveProfile } from "./games/active";
137
+ import { resolveProfile } from "./games/registry";
138
+ import type { SchemaEntry } from "./schema/types";
139
+ import { URI } from "vscode-uri";
140
+ import { ServerData } from "./serverData";
141
+ import { CompletionFeature } from "./features/completion";
142
+ import { provideHover } from "./features/hover";
143
+ import { provideTextureHover } from "./features/textureHover";
144
+ import { provideDefinition, provideLocDefinition } from "./features/definition";
145
+ import { SEMANTIC_LEGEND, provideSemanticTokens } from "./features/semanticTokens";
146
+ import { provideInlayHints } from "./features/inlayHints";
147
+ import { computeScopeAt } from "./features/scopeAt";
148
+ import { provideCodeActions } from "./features/codeActions";
149
+ import { provideSignatureHelp } from "./features/signatureHelp";
150
+ import { provideDocumentSymbols } from "./features/symbols";
151
+ import { provideFoldingRanges } from "./features/folding";
152
+ import { provideColorPresentations, provideDocumentColors } from "./features/colors";
153
+ import { provideFormattingEdits } from "./features/formatting";
154
+ import {
155
+ computeLocDiagnostics,
156
+ computeReferenceDiagnostics,
157
+ computeRequiredLocDiagnostics,
158
+ computeScriptDiagnostics,
159
+ type FileContext,
160
+ } from "./features/diagnostics";
161
+ import { provideReferences } from "./features/references";
162
+ import { prepareRename, provideRename } from "./features/rename";
163
+ import { provideWorkspaceSymbols } from "./features/workspaceSymbols";
164
+ import { evictParse, getLocParse, getParse } from "./parseCache";
165
+ import { resolveClientCapabilities, setClientCapabilities } from "./clientMode";
166
+ import { isIgnoredByConfig, isSuppressedInline, scanInlineSuppressions } from "@px-lsp/protocol/suppression";
167
+ import { computeModOverview } from "./overview/modOverview";
168
+ import { computeLocCoverage } from "./overview/locCoverage";
169
+ import { computeOverrides } from "./overview/overrides";
170
+ import { computeEventGraph } from "./overview/eventGraph";
171
+ import { computeEventVocabulary, computeValueOptions } from "./overview/eventVocabulary";
172
+ import { computeEventBanner } from "./overview/eventBanner";
173
+ import { computeDependencies } from "./overview/dependencies";
174
+ import { wordRangeAt } from "./wordAt";
175
+
176
+ // The px-lsp bin, before the connection claims stdio:
177
+ // - `px-lsp --version` answers and exits, so health checks and install
178
+ // scripts do not need an LSP handshake.
179
+ // - A bare `px-lsp` defaults to --stdio instead of dying with a stack trace
180
+ // ("Connection input stream is not set"). Editors that spawn over node-ipc
181
+ // (the VS Code client) pass --node-ipc themselves, and an ipc fork is
182
+ // recognizable by process.send, so the default cannot misfire there.
183
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
184
+ process.stdout.write(SERVER_VERSION + "\n");
185
+ process.exit(0);
186
+ }
187
+ const TRANSPORT_FLAGS = ["--node-ipc", "--stdio", "--socket", "--pipe"];
188
+ if (
189
+ !process.send &&
190
+ !process.argv.some((arg) => TRANSPORT_FLAGS.some((flag) => arg === flag || arg.startsWith(flag + "=")))
191
+ ) {
192
+ process.argv.push("--stdio");
193
+ }
194
+
195
+ const connection = createConnection(ProposedFeatures.all);
196
+ const documents = new TextDocuments(TextDocument);
197
+
198
+ // ---- crash visibility (perf campaign §A1) -----------------------------------
199
+
200
+ /**
201
+ * A throw on the scan path used to kill this process silently: the client
202
+ * restarts it five times and then everything LSP-backed is dead for the rest
203
+ * of the session while TextMate highlighting keeps working — the "only syntax
204
+ * highlighting works" field reports. Both handlers log through the connection
205
+ * AND raw stderr (the client pipes the server's stderr into the same output
206
+ * channel), so a death leaves a stack behind instead of silence.
207
+ */
208
+ function logFatal(what: string, err: unknown): void {
209
+ const detail = err instanceof Error ? (err.stack ?? `${err.name}: ${err.message}`) : String(err);
210
+ const line = `FATAL ${what}: ${detail}`;
211
+ try {
212
+ connection.console.error(line);
213
+ } catch {
214
+ // connection already torn down; stderr below still reaches the channel
215
+ }
216
+ try {
217
+ process.stderr.write(`[px-lsp] ${line}\n`);
218
+ } catch {
219
+ /* nothing left to log to */
220
+ }
221
+ }
222
+
223
+ process.on("unhandledRejection", (reason) => logFatal("unhandledRejection", reason));
224
+ process.on("uncaughtException", (err) => {
225
+ logFatal("uncaughtException", err);
226
+ // The process still dies (the client's restart logic stays the recovery
227
+ // path); the delay only gives the log line time to reach the client.
228
+ setTimeout(() => process.exit(1), 250);
229
+ });
230
+
231
+ /**
232
+ * Fault injection for the crash-visibility test (§A1), read once at startup:
233
+ * "sync" throws inside a folder scan, "async" throws from a timer during one
234
+ * (the process-killing shape). Unset in every real client.
235
+ */
236
+ const faultScan = process.env.PX_FAULT_SCAN ?? "";
237
+
238
+ function injectScanFault(): void {
239
+ const err = new Error(`px fault injection: scan throw (PX_FAULT_SCAN=${faultScan})`);
240
+ if (faultScan === "async")
241
+ setImmediate(() => {
242
+ throw err;
243
+ });
244
+ else throw err;
245
+ }
246
+
247
+ function defaultSettings(): ParadoxSettings {
248
+ return {
249
+ gamePath: null,
250
+ logsPath: null,
251
+ modPath: null,
252
+ parentPaths: [],
253
+ workspaceMods: [],
254
+ locLanguage: "english",
255
+ scopeInlayHints: false,
256
+ diagnosticsIgnore: [],
257
+ diagnosticsIgnorePatterns: [],
258
+ diagnosticsVanilla: false,
259
+ tracePerf: false,
260
+ };
261
+ }
262
+ let settings: ParadoxSettings = defaultSettings();
263
+ let storageDir = "";
264
+ let wikidocsDir = "";
265
+ let freqsDir = "";
266
+ let tokensFromScriptDocs = false;
267
+ let tokensFromBundledDumps = false;
268
+ let indexing = false;
269
+ /** Bumped whenever paths change; in-flight scans abort when superseded. */
270
+ let scanGeneration = 0;
271
+ /** Bundled schema merged with the workspace overlay; reloaded on path changes. */
272
+ let schema: SchemaData = loadSchema(null);
273
+ /** namespace declarations per mod file, folded into data.modNamespaces. */
274
+ const namespacesByFile = new Map<string, string[]>();
275
+
276
+ const data = new ServerData();
277
+ const completion = new CompletionFeature(data, () => schema);
278
+ /** Mod display names for hover/completion origin labels ("· My Mod" instead of
279
+ * "· mod"); roots re-resolved on path changes and descriptor.mod edits. */
280
+ const modOrigin = new ModOriginResolver();
281
+ data.originLabel = (def) => modOrigin.labelFor(def.file, def.source);
282
+ data.modRootOf = (file) => modOrigin.rootFor(file);
283
+
284
+ function refreshModOrigin(): void {
285
+ modOrigin.setRoots([...(settings.modPath ? [settings.modPath] : []), ...parentRoots()]);
286
+ }
287
+
288
+ /** On-demand reference search over the roots buildIndex leaves out of the
289
+ * ReferenceIndex: read-only dependency parents and vanilla (#3, AD-4). */
290
+ const lazyRefs = new LazyReferenceScanner();
291
+
292
+ function refreshLazyRefs(): void {
293
+ const roots: LazyRefRoot[] = dependencyParentRoots().map((root) => ({
294
+ root,
295
+ source: "parent" as const,
296
+ }));
297
+ if (settings.gamePath) roots.push({ root: settings.gamePath, source: "vanilla" });
298
+ lazyRefs.setRoots(roots, isEngineToken);
299
+ }
300
+
301
+ /** Focus predicate for the mod-scoped overview requests: with a `modRoot`
302
+ * param only that workspace mod's files pass; without one, every mod file. */
303
+ function focusFilter(modRoot: string | null | undefined): (file: string) => boolean {
304
+ if (!modRoot) return () => true;
305
+ const wanted = modRoot.toLowerCase();
306
+ return (file) => modOrigin.rootFor(file)?.toLowerCase() === wanted;
307
+ }
308
+ /** Engine-token test for call-reference extraction: engine effect/trigger call
309
+ * sites stay out of the reference index (memory guard for AGOT-sized mods). */
310
+ const isEngineToken = (name: string) => data.tokenMap.has(name);
311
+
312
+ /** Ordered parent-mod roots: settings (parent-mods setting / workspace
313
+ * folders) merged with every workspace mod's <configDir>/playset.json, minus
314
+ * mod/game roots. */
315
+ function parentRoots(): string[] {
316
+ const roots: string[] = [];
317
+ const seen = new Set<string>();
318
+ const add = (p: string) => {
319
+ const key = p.toLowerCase();
320
+ if (seen.has(key)) return;
321
+ if (settings.modPath && key === settings.modPath.toLowerCase()) return;
322
+ if (settings.gamePath && key === settings.gamePath.toLowerCase()) return;
323
+ seen.add(key);
324
+ roots.push(p);
325
+ };
326
+ for (const p of settings.parentPaths ?? []) add(p);
327
+ for (const mod of [...(settings.modPath ? [settings.modPath] : []), ...workspaceModRoots()]) {
328
+ for (const p of readPlaysetCached(mod)) add(p);
329
+ }
330
+ return roots;
331
+ }
332
+
333
+ /** parentRoots() minus the workspace mods: the read-only dependency layer,
334
+ * which the game loads after vanilla and before the mods being edited. */
335
+ function dependencyParentRoots(): string[] {
336
+ const wsMods = new Set(workspaceModRoots().map((r) => r.toLowerCase()));
337
+ return parentRoots().filter((r) => !wsMods.has(r.toLowerCase()));
338
+ }
339
+
340
+ /** parentRoots() runs on every request (via contentRoots); with 20 workspace
341
+ * mods the per-mod playset fs probes need a cache. Cleared on reindex. */
342
+ const playsetCache = new Map<string, string[]>();
343
+ function readPlaysetCached(modRoot: string): string[] {
344
+ const key = modRoot.toLowerCase();
345
+ let v = playsetCache.get(key);
346
+ if (!v) playsetCache.set(key, (v = readPlayset(modRoot)));
347
+ return v;
348
+ }
349
+
350
+ /** Content roots in precedence order: mod, parents, vanilla. */
351
+ function contentRoots(): string[] {
352
+ return [settings.modPath, ...parentRoots(), settings.gamePath].filter((r): r is string => r !== null);
353
+ }
354
+
355
+ /** Engine-layer roots shipped next to `<game>`, lowest content priority
356
+ * (real load order: clausewitz → jomini → game → mods). Only jomini is
357
+ * included: it holds real script/gui content (trigger_localization, defines,
358
+ * base textformatting, notification gui). clausewitz is deliberately excluded
359
+ * because it contains only Paradox tooling UI (gui_editor, node_editor,
360
+ * profilers) that no game or mod file references. */
361
+ function engineRoots(): string[] {
362
+ if (!settings.gamePath) return [];
363
+ const jomini = path.join(path.dirname(settings.gamePath), "jomini");
364
+ return fs.existsSync(jomini) ? [jomini] : [];
365
+ }
366
+
367
+ /** Workspace mod roots beyond modPath: mods being edited (multi-mod workspaces).
368
+ * They get reference indexing and reference diagnostics like the mod itself. */
369
+ function workspaceModRoots(): string[] {
370
+ const mods: string[] = [];
371
+ for (const p of settings.workspaceMods ?? []) {
372
+ if (settings.modPath && p.toLowerCase() === settings.modPath.toLowerCase()) continue;
373
+ mods.push(p);
374
+ }
375
+ return mods;
376
+ }
377
+
378
+ /** The workspace mod root a file lives under, or null. Every workspace mod is
379
+ * a first-class editable mod (source "mod"); there is no primary-mod special
380
+ * case — dependency parents (parent-mods setting / playset) stay "parent". */
381
+ function workspaceRootOf(fsPath: string): string | null {
382
+ const lower = fsPath.toLowerCase();
383
+ if (settings.modPath && lower.startsWith(settings.modPath.toLowerCase())) return settings.modPath;
384
+ return workspaceModRoots().find((r) => lower.startsWith(r.toLowerCase())) ?? null;
385
+ }
386
+
387
+ /** Schema entry for the folder a file lives in (structure/ambient/root-scope seed). */
388
+ function schemaEntryForFile(fsPath: string): SchemaEntry | null {
389
+ const lower = fsPath.toLowerCase();
390
+ for (const root of contentRoots()) {
391
+ if (lower.startsWith(root.toLowerCase())) return classifyFile(root, fsPath, schema.entries);
392
+ }
393
+ return null;
394
+ }
395
+
396
+ /** Schema-declared root scopes for the folder a file lives in (AD-5 seed). */
397
+ function rootScopesForFile(fsPath: string): Set<string> | null {
398
+ const entry = schemaEntryForFile(fsPath);
399
+ if (!entry?.rootScopes || entry.rootScopes.length === 0) return null;
400
+ return new Set(entry.rootScopes.map((s) => s.toLowerCase()));
401
+ }
402
+ // Static variable-type resolution (scopes/varTypes.ts) resolves root-anchored
403
+ // set_variable values through the set-file's schema root scopes.
404
+ data.rootScopesForFile = rootScopesForFile;
405
+
406
+ function log(msg: string): void {
407
+ connection.console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
408
+ }
409
+
410
+ // ---- perf tracing (§A2) ------------------------------------------------------
411
+
412
+ /** `px.trace.perf`: wall clock for requests, rescans, index changes and scan
413
+ * phases into the output channel, so a slow save yields a ms timeline. */
414
+ function perfOn(): boolean {
415
+ return settings.tracePerf === true;
416
+ }
417
+
418
+ function perf(msg: string): void {
419
+ if (perfOn()) log(`perf ${msg}`);
420
+ }
421
+
422
+ /** Time `fn` and trace `<label> <ms>`; a no-op wrapper when tracing is off. */
423
+ function perfSpan<T>(label: string, fn: () => T): T {
424
+ if (!perfOn()) return fn();
425
+ const t0 = performance.now();
426
+ try {
427
+ return fn();
428
+ } finally {
429
+ log(`perf ${label} ${(performance.now() - t0).toFixed(1)}ms`);
430
+ }
431
+ }
432
+
433
+ /** Filename for trace labels, without paying path.basename when tracing is off. */
434
+ function perfName(uriOrPath: string): string {
435
+ return perfOn()
436
+ ? uriOrPath.slice(Math.max(uriOrPath.lastIndexOf("/"), uriOrPath.lastIndexOf("\\")) + 1)
437
+ : "";
438
+ }
439
+
440
+ /** Every index-change fan-out goes through here so the trace attributes it. */
441
+ function indexChanged(phase: string): void {
442
+ perfSpan(`indexChanged (${phase})`, () => data.notifyIndexChanged());
443
+ }
444
+
445
+ // ---- status / refresh plumbing ---------------------------------------------
446
+
447
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
448
+
449
+ let lastLoggedStatus = "";
450
+
451
+ function sendStatus(): void {
452
+ // stats() walks every definition in the index; it runs on every index change,
453
+ // so its own cost is part of the trace (§A2).
454
+ const total = perfSpan("sendStatus stats()", () => data.index.stats().total);
455
+ const payload: StatusPayload = {
456
+ tokens: data.tokens.length,
457
+ tokensFromScriptDocs,
458
+ tokensFromBundledDumps,
459
+ definitions: total,
460
+ indexing,
461
+ };
462
+ void connection.sendNotification(statusNotification, payload);
463
+ // Mirror into window/logMessage so bare clients (no paradox/status handler)
464
+ // can tell an empty index from a cold one. Only on transitions: scans fire
465
+ // many status updates, but the interesting line is start/end of indexing.
466
+ const source = payload.tokensFromBundledDumps
467
+ ? "bundled script_docs"
468
+ : payload.tokensFromScriptDocs
469
+ ? "script_docs"
470
+ : "bundled";
471
+ const line = `status: ${payload.tokens} tokens (${source}), ${
472
+ payload.definitions
473
+ } definitions${payload.indexing ? ", indexing…" : ""}`;
474
+ const key = `${payload.indexing}|${payload.tokens === 0}|${payload.definitions === 0}`;
475
+ if (key !== lastLoggedStatus) {
476
+ lastLoggedStatus = key;
477
+ log(line);
478
+ }
479
+ }
480
+
481
+ /**
482
+ * One coarse phase of the cold-start work, for the client's status bar. Phases
483
+ * are named on the wire, never numbered: the client owns the ordering it shows.
484
+ */
485
+ function sendProgress(phase: string, state: "start" | "done", detail?: string): void {
486
+ void connection.sendNotification(progressNotification, { phase, state, detail });
487
+ }
488
+
489
+ // The template/type store is built lazily, on the first request that needs it,
490
+ // so the phase is reported from where it happens rather than from a call site.
491
+ observeGuiStoreBuild((state) =>
492
+ sendProgress("guiStore", state, state === "start" ? "building the GUI template store…" : undefined)
493
+ );
494
+
495
+ /**
496
+ * Idle debounce for the global refresh (§B4). Raised from 300ms: one fire makes
497
+ * EVERY visible editor re-request full-document semantic tokens and inlay
498
+ * hints, and every server-backed sidebar view re-query the index.
499
+ */
500
+ const REFRESH_DEBOUNCE_MS = 500;
501
+
502
+ function cancelRefreshTimer(): void {
503
+ if (refreshTimer) clearTimeout(refreshTimer);
504
+ refreshTimer = null;
505
+ }
506
+
507
+ function fireRefresh(reason: string): void {
508
+ cancelRefreshTimer();
509
+ perf(`refresh fired (semanticTokens + inlayHint, ${reason})`);
510
+ void connection.sendNotification(indexChangedNotification);
511
+ connection.languages.semanticTokens.refresh().catch(() => {});
512
+ connection.languages.inlayHint.refresh().catch(() => {});
513
+ }
514
+
515
+ data.onDidChange(() => {
516
+ sendStatus();
517
+ // §B4: a scan fires an index change per root, and each refresh puts a
518
+ // full-document token request per visible editor plus every sidebar view's
519
+ // index walk behind an already-saturated event loop — the "semantic
520
+ // highlighting never arrives" reports. buildIndex's finally fires exactly
521
+ // one refresh when the index is complete instead.
522
+ if (indexing) return;
523
+ // Debounce editor refreshes and the index-changed signal: scans fire many changes.
524
+ if (refreshTimer) clearTimeout(refreshTimer);
525
+ refreshTimer = setTimeout(() => {
526
+ refreshTimer = null;
527
+ fireRefresh("idle");
528
+ }, REFRESH_DEBOUNCE_MS);
529
+ });
530
+
531
+ // ---- data loading -----------------------------------------------------------
532
+
533
+ function loadDocs(force: boolean): void {
534
+ tokensFromScriptDocs = false;
535
+ tokensFromBundledDumps = false;
536
+ let scriptTokens = [] as ReturnType<typeof loadTokenData>["tokens"];
537
+ let modifierTemplates = [] as ReturnType<typeof loadTokenData>["templates"];
538
+ if (settings.logsPath) {
539
+ const t0 = Date.now();
540
+ const docsCacheFile = path.join(storageDir, `docsCache${activeProfile().cacheSuffix}.json`);
541
+ const result = loadTokenData(settings.logsPath, docsCacheFile, force);
542
+ scriptTokens = result.tokens;
543
+ modifierTemplates = result.templates;
544
+ tokensFromScriptDocs = scriptTokens.length > 0;
545
+ if (result.fromCache)
546
+ log(`loaded token data from cache (${result.tokens.length} tokens, ${Date.now() - t0}ms)`);
547
+ else log(`parsed script_docs logs (${result.tokens.length} tokens, ${Date.now() - t0}ms)`);
548
+ if (result.missing.length > 0) {
549
+ log(
550
+ `missing log files in ${settings.logsPath}: ${result.missing.join(", ")} (run script_docs in the game console)`
551
+ );
552
+ }
553
+ }
554
+ // No usable user dump: fall back to the bundled script_docs snapshot (shipped
555
+ // per game under data/<gameId>/script_docs). The user's own dump, once it
556
+ // exists, wins outright — it matches their exact game version.
557
+ if (scriptTokens.length === 0 && bundledDumpsDir) {
558
+ const t0 = Date.now();
559
+ const cacheFile = path.join(storageDir, `docsCacheBundled${activeProfile().cacheSuffix}.json`);
560
+ const result = loadTokenData(bundledDumpsDir, cacheFile, force);
561
+ scriptTokens = result.tokens;
562
+ modifierTemplates = result.templates;
563
+ tokensFromScriptDocs = scriptTokens.length > 0;
564
+ tokensFromBundledDumps = tokensFromScriptDocs;
565
+ log(
566
+ `bundled script_docs snapshot: ${result.tokens.length} tokens ` +
567
+ `(${result.fromCache ? "cache" : "parsed"}, ${Date.now() - t0}ms; ` +
568
+ `dump your own script_docs to match your exact game version)`
569
+ );
570
+ }
571
+ if (scriptTokens.length === 0 && !settings.logsPath) {
572
+ log("script_docs logs path not found; engine tokens come from the bundled wiki docs only.");
573
+ }
574
+ data.setModifierTemplates(modifierTemplates);
575
+ if (data.modifierTemplates.length > 0) {
576
+ log(
577
+ `templated modifiers: ${data.modifierTemplates.length} templates expand against the definition index`
578
+ );
579
+ }
580
+ const t1 = Date.now();
581
+ const wikiTokens = loadWikiTokens(wikidocsDir);
582
+ const merged = mergeWikiTokens(scriptTokens, wikiTokens);
583
+ data.setTokens(merged);
584
+ log(`wiki docs: ${wikiTokens.length} tokens, merged total ${merged.length} (${Date.now() - t1}ms)`);
585
+
586
+ // on_actions.log sits next to the other script_docs dumps; same fallback.
587
+ const onActionsDir =
588
+ settings.logsPath && fs.existsSync(path.join(settings.logsPath, "on_actions.log"))
589
+ ? settings.logsPath
590
+ : bundledDumpsDir || settings.logsPath;
591
+ data.onActionScopes = onActionsDir ? parseOnActionsLog(onActionsDir) : new Map();
592
+ if (data.onActionScopes.size > 0) log(`on_actions.log: ${data.onActionScopes.size} on_action root scopes`);
593
+
594
+ // Lowest priority first: bundled snapshot, then the user's folders. Games
595
+ // whose script_docs live outside logs/ (newer Jomini titles) still dump data
596
+ // types to logs/, so the sibling logs folder of a docs-style logsPath is probed too.
597
+ const dataTypeDirs: Array<string | null> = [bundledDataTypesDir || null];
598
+ if (settings.logsPath) {
599
+ const sibling = path.resolve(settings.logsPath, "..", "logs");
600
+ if (sibling.toLowerCase() !== path.resolve(settings.logsPath).toLowerCase()) dataTypeDirs.push(sibling);
601
+ dataTypeDirs.push(settings.logsPath);
602
+ }
603
+ data.dataTypes = loadDataTypes(dataTypeDirs);
604
+ // Promote game, dependency-parent and mod data_binding macros as global
605
+ // [ … ] functions, in load order (a framework mod's macros are the case that
606
+ // matters: the mod being edited calls them but does not define them).
607
+ const macroRoots = [
608
+ settings.gamePath,
609
+ ...dependencyParentRoots(),
610
+ settings.modPath,
611
+ ...workspaceModRoots(),
612
+ ].filter((r): r is string => r !== null);
613
+ const macros = loadDataBindingMacros(macroRoots, data.dataTypes);
614
+ if (macros > 0) log(`data_binding macros: ${macros} promoted into data-function completion/hover`);
615
+ if (data.dataTypes.source === "bundled wiki") {
616
+ log(
617
+ `data types: ${data.dataTypes.count} entries from the bundled wiki tables ` +
618
+ `(run "${activeProfile().dataTypesCommand ?? "DumpDataTypes"}" in the game console for the ` +
619
+ `complete, version-exact set)`
620
+ );
621
+ } else {
622
+ log(`data types: ${data.dataTypes.count} entries incl. data-type dumps`);
623
+ }
624
+
625
+ const t2 = Date.now();
626
+ const usageCache = storageDir
627
+ ? path.join(storageDir, `dataFnUsage${activeProfile().cacheSuffix}.json`)
628
+ : null;
629
+ const generation = ++usageGeneration;
630
+ void loadDataFnUsageAsync(settings.gamePath, settings.locLanguage, usageCache, force)
631
+ .then((result) => {
632
+ if (generation !== usageGeneration) return; // superseded by a newer load
633
+ data.dataFnUsage = result.usage;
634
+ if (result.usage.exprs > 0) {
635
+ log(
636
+ `data-function usage: ${result.usage.exprs} expressions, ${result.usage.starts.size} chain starts ` +
637
+ `from ${result.usage.files} vanilla files (${result.fromCache ? "cache" : "scan"}, ${Date.now() - t2}ms)`
638
+ );
639
+ }
640
+ })
641
+ .catch((e) => log(`data-function usage harvest failed: ${String(e)}`));
642
+ }
643
+
644
+ let usageGeneration = 0;
645
+
646
+ /** Path bundle for gui navigation/hover (FIOS template/type store). */
647
+ function guiPaths(): GuiPaths {
648
+ return {
649
+ gamePath: settings.gamePath,
650
+ modPath: settings.modPath,
651
+ parentPaths: settings.parentPaths ?? [],
652
+ engineRoots: engineRoots(),
653
+ };
654
+ }
655
+
656
+ /**
657
+ * In-memory harvest of engine/game/parent/mod `define:` constants and `#tag` loc
658
+ * text-formats (small — a few thousand entries; not persisted into the vanilla
659
+ * index cache). Rebuilt fresh so a paths change / mod edit cannot leave stale
660
+ * layers. Engine (jomini) is the lowest layer, the mod the highest (last-wins).
661
+ */
662
+ function harvestEngineData(): void {
663
+ sendProgress("engine", "start", "harvesting engine tokens…");
664
+ const t0 = Date.now();
665
+ // Every workspace mod is a "mod" layer (multi-mod workspaces), added after
666
+ // engine + game + dependency parents so mod definitions win.
667
+ const parentLayerRoots = dependencyParentRoots();
668
+ const modLayerRoots = [...(settings.modPath ? [settings.modPath] : []), ...workspaceModRoots()];
669
+ const defines = new DefinesIndex();
670
+ for (const root of engineRoots()) defines.addLayer(root, "jomini");
671
+ if (settings.gamePath) defines.addLayer(settings.gamePath, "game");
672
+ for (const root of parentLayerRoots) defines.addLayer(root, "parent");
673
+ for (const root of modLayerRoots) defines.addLayer(root, "mod");
674
+ data.defines = defines;
675
+ const tDef = Date.now() - t0;
676
+
677
+ const t1 = Date.now();
678
+ const textFormatting = new TextFormattingIndex();
679
+ for (const root of engineRoots()) textFormatting.addLayer(root, "jomini");
680
+ if (settings.gamePath) textFormatting.addLayer(settings.gamePath, "game");
681
+ for (const root of parentLayerRoots) textFormatting.addLayer(root, "parent");
682
+ for (const root of modLayerRoots) textFormatting.addLayer(root, "mod");
683
+ data.textFormatting = textFormatting;
684
+ log(
685
+ `harvested defines: ${defines.count} constants (${tDef}ms), ` +
686
+ `loc text formats: ${textFormatting.count} tags (${Date.now() - t1}ms)`
687
+ );
688
+ sendProgress("engine", "done");
689
+ }
690
+
691
+ const yieldNow = () => new Promise<void>((resolve) => setImmediate(resolve));
692
+
693
+ function readFileStripBom(file: string): string | null {
694
+ try {
695
+ return fs.readFileSync(file, "utf8").replace(/^/, "");
696
+ } catch {
697
+ return null;
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Chunked schema-driven folder scan: yields to the event loop between file
703
+ * batches so requests keep flowing, reports progress and aborts when a newer
704
+ * scan supersedes it.
705
+ */
706
+ async function scanRootChunked(
707
+ root: string,
708
+ source: "vanilla" | "parent" | "mod",
709
+ generation: number,
710
+ onProgress?: (percent: number, message: string) => void
711
+ ): Promise<Definition[] | null> {
712
+ if (faultScan) injectScanFault();
713
+ const tList = Date.now();
714
+ const defs: Definition[] = [];
715
+ const work: Array<{ entry: SchemaData["entries"][number]; files: string[] }> = [];
716
+ let totalFiles = 0;
717
+ for (const entry of schema.entries) {
718
+ const dir = path.join(root, ...entry.path.split("/"));
719
+ // The listing shares the read loop's yield budget below. It used to run to
720
+ // completion first, and for its whole duration the server answered no
721
+ // request: completion and hover stalled behind it.
722
+ let files: string[] = [];
723
+ for (const file of iterFiles(dir, entry.ext ?? ".txt")) {
724
+ if (file === null) {
725
+ if (generation !== scanGeneration) return null; // superseded
726
+ await yieldNow();
727
+ } else {
728
+ files.push(file);
729
+ }
730
+ }
731
+ if (entry.kind === "loc_key") {
732
+ files = files.filter((f) => isWantedLocFile(path.relative(root, f), settings.locLanguage));
733
+ }
734
+ work.push({ entry, files });
735
+ totalFiles += files.length;
736
+ }
737
+ perf(`scan ${path.basename(root)} listed ${totalFiles} files ${Date.now() - tList}ms`);
738
+ const tRead = Date.now();
739
+ let done = 0;
740
+ const BATCH = 150;
741
+ for (const { entry, files } of work) {
742
+ for (let i = 0; i < files.length; i += BATCH) {
743
+ if (generation !== scanGeneration) return null; // superseded
744
+ const batch = files.slice(i, i + BATCH);
745
+ for (const file of batch) {
746
+ const content = readFileStripBom(file);
747
+ if (content !== null) pushAll(defs, extractDefinitions(content, entry, file, source));
748
+ }
749
+ done += batch.length;
750
+ onProgress?.(totalFiles === 0 ? 100 : Math.round((done / totalFiles) * 100), entry.path);
751
+ await yieldNow();
752
+ }
753
+ }
754
+ perf(
755
+ `scan ${path.basename(root)} (${source}) read+extract ${defs.length} defs ` +
756
+ `from ${totalFiles} files ${Date.now() - tRead}ms`
757
+ );
758
+ return defs;
759
+ }
760
+
761
+ /** Reference pass over every .txt in a workspace mod root (references live
762
+ * everywhere, not just schema folders). Runs for the mod AND every other
763
+ * workspace mod, so find-references/usage counts span multi-mod workspaces. */
764
+ async function scanModReferences(
765
+ root: string,
766
+ source: "mod" | "parent",
767
+ generation: number
768
+ ): Promise<boolean> {
769
+ const t0 = Date.now();
770
+ // A mod root is walked whole here, gfx/ and all, so the listing yields on the
771
+ // same rhythm as the read loop below rather than blocking through it.
772
+ const files: string[] = [];
773
+ for (const file of iterFiles(root, ".txt")) {
774
+ if (file === null) {
775
+ if (generation !== scanGeneration) return false;
776
+ await yieldNow();
777
+ } else {
778
+ files.push(file);
779
+ }
780
+ }
781
+ const BATCH = 150;
782
+ let refCount = 0;
783
+ for (let i = 0; i < files.length; i += BATCH) {
784
+ if (generation !== scanGeneration) return false;
785
+ for (const file of files.slice(i, i + BATCH)) {
786
+ const content = readFileStripBom(file);
787
+ if (content === null) continue;
788
+ const extracted = extractReferences(content, file, source, schema, isEngineToken);
789
+ data.refIndex.addAll(extracted.references);
790
+ if (extracted.implicitDefs.length > 0) data.index.addAll(extracted.implicitDefs);
791
+ if (extracted.namespaces.length > 0) namespacesByFile.set(file.toLowerCase(), extracted.namespaces);
792
+ refCount += extracted.references.length;
793
+ }
794
+ await yieldNow();
795
+ }
796
+ rebuildModNamespaces();
797
+ log(
798
+ `indexed ${path.basename(root)} references: ` +
799
+ `${refCount} usage sites in ${files.length} files (${Date.now() - t0}ms)`
800
+ );
801
+ return true;
802
+ }
803
+
804
+ function rebuildModNamespaces(): void {
805
+ data.modNamespaces.clear();
806
+ for (const list of namespacesByFile.values()) {
807
+ for (const ns of list) data.modNamespaces.add(ns);
808
+ }
809
+ }
810
+
811
+ /** Ordered parent-mod roots from <mod>/<configDir>/playset.json, if present. */
812
+ function readPlayset(modPath: string): string[] {
813
+ const file = path.join(modPath, activeProfile().configDirName, "playset.json");
814
+ try {
815
+ if (!fs.existsSync(file)) return [];
816
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
817
+ const list: unknown[] = Array.isArray(parsed)
818
+ ? parsed
819
+ : Array.isArray(parsed?.parents)
820
+ ? parsed.parents
821
+ : [];
822
+ const roots: string[] = [];
823
+ for (const p of list) {
824
+ if (typeof p !== "string") continue;
825
+ const abs = path.isAbsolute(p) ? p : path.join(modPath, p);
826
+ if (fs.existsSync(abs)) roots.push(abs);
827
+ else log(`playset parent not found, skipped: ${p}`);
828
+ }
829
+ return roots;
830
+ } catch (err) {
831
+ log(`playset.json ignored: ${String(err)}`);
832
+ return [];
833
+ }
834
+ }
835
+
836
+ async function buildIndex(): Promise<void> {
837
+ const tBuild = Date.now();
838
+ const generation = ++scanGeneration;
839
+ playsetCache.clear();
840
+ schema = loadSchema([...(settings.modPath ? [settings.modPath] : []), ...workspaceModRoots()], log);
841
+ data.completableKinds = new Set([
842
+ ...schema.entries.filter((e) => e.completable !== false).map((e) => e.kind),
843
+ "saved_scope",
844
+ ...VARIABLE_KINDS,
845
+ ]);
846
+ data.index = new DefinitionIndex();
847
+ // The shared identifiers of the index we are replacing die with it (§C2).
848
+ resetInternTable();
849
+ data.refIndex.clear();
850
+ namespacesByFile.clear();
851
+ data.modNamespaces.clear();
852
+ refreshModOrigin();
853
+ refreshLazyRefs();
854
+ harvestEngineData();
855
+ rescanDigests.clear();
856
+ indexing = true;
857
+ sendProgress("index", "start", "indexing mod definitions…");
858
+ // A refresh queued before the rebuild would land mid-scan (§B4).
859
+ cancelRefreshTimer();
860
+ sendStatus();
861
+
862
+ try {
863
+ if (settings.modPath) {
864
+ const t0 = Date.now();
865
+ const defs = await scanRootChunked(settings.modPath, "mod", generation);
866
+ if (defs === null) return;
867
+ data.index.addAll(defs);
868
+ if (!(await scanModReferences(settings.modPath, "mod", generation))) return;
869
+ indexChanged("mod scan");
870
+ log(`indexed mod: ${defs.length} definitions (${Date.now() - t0}ms)`);
871
+ }
872
+
873
+ const wsMods = new Set(workspaceModRoots().map((r) => r.toLowerCase()));
874
+ for (const parent of parentRoots()) {
875
+ const t1 = Date.now();
876
+ // Workspace mods are edited mods like any other: source "mod" (full
877
+ // reference indexing, views, ranking). Only dependency parents from
878
+ // the parent-mods setting / playset.json are read-only "parent" context.
879
+ const isWorkspaceMod = wsMods.has(parent.toLowerCase());
880
+ const parentDefs = await scanRootChunked(parent, isWorkspaceMod ? "mod" : "parent", generation);
881
+ if (parentDefs === null) return;
882
+ data.index.addAll(parentDefs);
883
+ if (isWorkspaceMod) {
884
+ if (!(await scanModReferences(parent, "mod", generation))) return;
885
+ }
886
+ indexChanged(`parent scan ${path.basename(parent)}`);
887
+ log(
888
+ `indexed ${isWorkspaceMod ? "workspace mod" : "parent mod"} ${path.basename(parent)}: ` +
889
+ `${parentDefs.length} definitions (${Date.now() - t1}ms)`
890
+ );
891
+ }
892
+
893
+ if (settings.gamePath) {
894
+ const gamePath = settings.gamePath;
895
+ const t0 = Date.now();
896
+ const version = detectGameVersion(gamePath);
897
+ const cacheFile = path.join(
898
+ storageDir,
899
+ `vanillaIndex${activeProfile().cacheSuffix}-${settings.locLanguage}.json`
900
+ );
901
+ let defs = loadIndexCache(cacheFile, version);
902
+ if (defs) {
903
+ log(
904
+ `loaded vanilla index from cache: ${defs.length} definitions, game ${version} (${Date.now() - t0}ms)`
905
+ );
906
+ } else {
907
+ log(`indexing vanilla (game ${version})...`);
908
+ const progress = await connection.window.createWorkDoneProgress();
909
+ progress.begin(`${activeProfile().shortName}: indexing vanilla`, 0, "scanning...", false);
910
+ try {
911
+ // Engine layer first so game definitions come later (game shadows
912
+ // jomini, matching load order). Cached together with vanilla.
913
+ const engineDefs: Definition[] = [];
914
+ for (const engine of engineRoots()) {
915
+ const d = await scanRootChunked(engine, "vanilla", generation);
916
+ if (d === null) return;
917
+ pushAll(engineDefs, d);
918
+ }
919
+ defs = await scanRootChunked(gamePath, "vanilla", generation, (pct, msg) =>
920
+ progress.report(pct, msg)
921
+ );
922
+ if (defs !== null && engineDefs.length > 0) {
923
+ log(`indexed engine layer (jomini): ${engineDefs.length} definitions`);
924
+ // Appended in place (engine first, so game shadows jomini): a third
925
+ // array of a million definitions is pure heap pressure.
926
+ pushAll(engineDefs, defs);
927
+ defs = engineDefs;
928
+ }
929
+ } finally {
930
+ progress.done();
931
+ }
932
+ if (defs === null) return; // superseded
933
+ try {
934
+ saveIndexCache(cacheFile, version, defs);
935
+ } catch (err) {
936
+ log(`could not write vanilla index cache: ${String(err)}`);
937
+ }
938
+ log(`indexed vanilla: ${defs.length} definitions (${Date.now() - t0}ms)`);
939
+ }
940
+ if (generation !== scanGeneration) return;
941
+ data.index.addAll(defs);
942
+ indexChanged("vanilla scan");
943
+ }
944
+ } finally {
945
+ if (generation === scanGeneration) {
946
+ indexing = false;
947
+ sendProgress("index", "done");
948
+ sendStatus();
949
+ // §B4: the build's one and only refresh, fired from the finally so a scan
950
+ // that returned null (superseded root) or threw cannot strand the open
951
+ // editors on TextMate colours forever.
952
+ fireRefresh("index built");
953
+ if (perfOn()) {
954
+ // Post-GC only when the server was started with --expose-gc (the bench
955
+ // harness does; a normal client does not, and reads a live-heap number).
956
+ const gc = (globalThis as { gc?: () => void }).gc;
957
+ gc?.();
958
+ const mem = process.memoryUsage();
959
+ perf(
960
+ `index built: ${data.index.stats().total} definitions, ` +
961
+ `${internedCount()} shared identifiers, ` +
962
+ `heap ${(mem.heapUsed / 1048576).toFixed(0)} MB${gc ? " (post-gc)" : ""}, ` +
963
+ `rss ${(mem.rss / 1048576).toFixed(0)} MB, ${Date.now() - tBuild}ms`
964
+ );
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ /** Fire-and-forget index build whose failure is logged and attributable (§A1):
971
+ * a throw here used to leave the server alive with an empty index, or dead. */
972
+ function startIndexBuild(reason: string): void {
973
+ void buildIndex().catch((err) => {
974
+ logFatal(`index build failed (${reason})`, err);
975
+ });
976
+ }
977
+
978
+ /**
979
+ * Digest of the bytes the index currently holds per rescanned file (§B3).
980
+ * A save fires the watcher more than once (the write plus its metadata update,
981
+ * and once per overlapping watcher root), and re-parsing identical bytes only
982
+ * buys another index-changed fan-out. Cleared whenever the index is rebuilt.
983
+ */
984
+ const rescanDigests = new Map<string, string>();
985
+
986
+ function contentDigest(content: string | null): string {
987
+ if (content === null) return "<deleted>";
988
+ return `${content.length}:${createHash("sha1").update(content).digest("base64")}`;
989
+ }
990
+
991
+ function rescanModFile(fsPath: string): void {
992
+ const lower = fsPath.toLowerCase();
993
+ const wsRoot = workspaceRootOf(fsPath);
994
+ const parentRoot = wsRoot ? null : parentRoots().find((r) => lower.startsWith(r.toLowerCase()));
995
+ if (!wsRoot && !parentRoot) return;
996
+ const root = wsRoot ?? parentRoot!;
997
+ const source = wsRoot ? ("mod" as const) : ("parent" as const);
998
+
999
+ const entry = classifyFile(root, fsPath, schema.entries);
1000
+ const isScript = lower.endsWith(".txt");
1001
+ if (!entry && !isScript) return;
1002
+ if (entry?.kind === "loc_key" && !isWantedLocFile(path.relative(root, fsPath), settings.locLanguage))
1003
+ return;
1004
+
1005
+ const tParse = Date.now();
1006
+ const content = fs.existsSync(fsPath) ? readFileStripBom(fsPath) : null;
1007
+ const digest = contentDigest(content);
1008
+ if (rescanDigests.get(lower) === digest) {
1009
+ perf(`rescan ${path.basename(fsPath)} unchanged bytes, skipped ${Date.now() - tParse}ms`);
1010
+ return;
1011
+ }
1012
+ rescanDigests.set(lower, digest);
1013
+ // Sub-spans: the whole rescan is 3ms on a 1.9M-definition index and ~500ms on
1014
+ // the AGOT-sized one for the SAME file, so a save trace has to say which part
1015
+ // of it scales with the workspace (§B2).
1016
+ perfSpan("rescan defs", () => {
1017
+ data.index.removeFile(fsPath);
1018
+ if (entry && content !== null) {
1019
+ data.index.addAll(extractDefinitions(content, entry, fsPath, source));
1020
+ }
1021
+ });
1022
+ // References and namespaces are tracked for every workspace mod (matching
1023
+ // buildIndex); read-only dependency parents stay definition-only.
1024
+ const isWorkspaceMod = wsRoot !== null;
1025
+ if (isWorkspaceMod && isScript) {
1026
+ perfSpan("rescan refs remove", () => data.refIndex.removeFile(fsPath));
1027
+ namespacesByFile.delete(fsPath.toLowerCase());
1028
+ if (content !== null) {
1029
+ const extracted = perfSpan("rescan refs extract", () =>
1030
+ extractReferences(content, fsPath, source, schema, isEngineToken)
1031
+ );
1032
+ perfSpan("rescan refs add", () => {
1033
+ data.refIndex.addAll(extracted.references);
1034
+ data.index.addAll(extracted.implicitDefs);
1035
+ });
1036
+ if (extracted.namespaces.length > 0) namespacesByFile.set(fsPath.toLowerCase(), extracted.namespaces);
1037
+ }
1038
+ perfSpan("rescan namespaces", () => rebuildModNamespaces());
1039
+ }
1040
+ perf(`rescan ${path.basename(fsPath)} parse+extract ${Date.now() - tParse}ms`);
1041
+ indexChanged(`rescan ${path.basename(fsPath)}`);
1042
+ log(`re-indexed ${path.basename(fsPath)}`);
1043
+ }
1044
+
1045
+ // ---- lifecycle ---------------------------------------------------------------
1046
+
1047
+ /**
1048
+ * Bundled-data locations for the ACTIVE profile: the per-game folder is
1049
+ * <root>/<gameId>/, where root is the client's dataDir or data/ next to the
1050
+ * bundle (dist/server.js sits next to data/ in the repo checkout, the .vsix
1051
+ * and the release tarball alike). wikidocs/ and freqs.json are derived
1052
+ * independently from it: a game may ship freqs without a wiki mirror, and the
1053
+ * deprecated wikidocsDir override moves the wiki mirror alone. Both fail soft
1054
+ * when the game bundles no data. Re-derived whenever the game profile changes.
1055
+ */
1056
+ function deriveBundledDataDirs(): void {
1057
+ const gameDir = path.join(clientDataDir || path.resolve(__dirname, "..", "data"), activeProfile().id);
1058
+ wikidocsDir = clientWikidocsDir;
1059
+ if (!wikidocsDir) {
1060
+ const bundled = path.join(gameDir, "wikidocs");
1061
+ if (fs.existsSync(bundled)) wikidocsDir = bundled;
1062
+ }
1063
+ freqsDir = fs.existsSync(path.join(gameDir, "freqs.json")) ? gameDir : "";
1064
+ // Bundled script_docs / data-type dumps (data/<gameId>/script_docs,
1065
+ // data/<gameId>/data_types): the out-of-box fallback when the user has not
1066
+ // dumped their own. The user's own dumps always win.
1067
+ const dumps = path.join(gameDir, "script_docs");
1068
+ bundledDumpsDir = fs.existsSync(dumps) ? dumps : "";
1069
+ const dataTypes = path.join(gameDir, "data_types");
1070
+ bundledDataTypesDir = fs.existsSync(dataTypes) ? dataTypes : "";
1071
+ }
1072
+ let clientDataDir = "";
1073
+ let clientWikidocsDir = "";
1074
+ let bundledDumpsDir = "";
1075
+ let bundledDataTypesDir = "";
1076
+ let clientOwnFileWatcher = false;
1077
+ let clientWatchedFilesDynamic = false;
1078
+
1079
+ connection.onInitialize((params: InitializeParams): InitializeResult => {
1080
+ const init = (params.initializationOptions ?? {}) as Partial<ParadoxInitOptions>;
1081
+ storageDir = init.storageDir ?? "";
1082
+ clientDataDir = init.dataDir ?? "";
1083
+ clientWikidocsDir = init.wikidocsDir ?? "";
1084
+ // Client capabilities (PROTOCOL.md §Initialization): rich hover markup,
1085
+ // command links and command actions are emitted only where the client
1086
+ // declared it implements them.
1087
+ const clientCaps = resolveClientCapabilities(init);
1088
+ setClientCapabilities(clientCaps);
1089
+ clientOwnFileWatcher = clientCaps.ownFileWatcher;
1090
+ clientWatchedFilesDynamic =
1091
+ params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
1092
+ // Merge onto the defaults: bare clients may send partial settings (e.g.
1093
+ // only gameId), and every downstream consumer assumes the full shape.
1094
+ if (init.settings) settings = { ...defaultSettings(), ...init.settings };
1095
+ setActiveProfile(resolveProfile(settings.gameId));
1096
+ deriveBundledDataDirs();
1097
+ if (!storageDir) {
1098
+ storageDir = path.join(os.tmpdir(), "px-lsp");
1099
+ try {
1100
+ fs.mkdirSync(storageDir, { recursive: true });
1101
+ } catch {
1102
+ storageDir = "";
1103
+ }
1104
+ }
1105
+ if (!settings.modPath && !settings.workspaceMods?.length) {
1106
+ const rootUri = params.workspaceFolders?.[0]?.uri ?? params.rootUri ?? null;
1107
+ if (rootUri?.startsWith("file:")) settings.modPath = URI.parse(rootUri).fsPath;
1108
+ }
1109
+
1110
+ return {
1111
+ serverInfo: { name: "px-lsp", version: SERVER_VERSION },
1112
+ capabilities: {
1113
+ textDocumentSync: {
1114
+ openClose: true,
1115
+ change: TextDocumentSyncKind.Incremental,
1116
+ save: true,
1117
+ },
1118
+ completionProvider: { resolveProvider: true, triggerCharacters: [":", ".", "[", "'", "|", "#", "/"] },
1119
+ signatureHelpProvider: { triggerCharacters: ["{", "("], retriggerCharacters: ["=", ","] },
1120
+ hoverProvider: true,
1121
+ definitionProvider: true,
1122
+ codeActionProvider: true,
1123
+ inlayHintProvider: true,
1124
+ documentSymbolProvider: true,
1125
+ foldingRangeProvider: true,
1126
+ colorProvider: true,
1127
+ referencesProvider: true,
1128
+ documentFormattingProvider: true,
1129
+ renameProvider: { prepareProvider: true },
1130
+ workspaceSymbolProvider: true,
1131
+ semanticTokensProvider: {
1132
+ legend: SEMANTIC_LEGEND,
1133
+ full: true,
1134
+ range: false,
1135
+ },
1136
+ },
1137
+ };
1138
+ });
1139
+
1140
+ connection.onInitialized(() => {
1141
+ // Self-diagnosis for bare clients: the resolved bundled-data locations are
1142
+ // the difference between "knows the engine" and silent degraded mode.
1143
+ if (wikidocsDir || freqsDir) {
1144
+ log(`bundled data for '${activeProfile().id}': ${wikidocsDir || freqsDir}`);
1145
+ } else {
1146
+ log(
1147
+ `no bundled data found for '${activeProfile().id}' (looked next to the server bundle); ` +
1148
+ `engine tokens come from script_docs logs only`
1149
+ );
1150
+ }
1151
+ // A client declaring ownFileWatcher runs its own tuned watcher and pushes
1152
+ // paradox/modFileChanged; for every other client, watch the workspace
1153
+ // ourselves when the client supports dynamic registration.
1154
+ if (!clientOwnFileWatcher && clientWatchedFilesDynamic) {
1155
+ void connection.client.register(DidChangeWatchedFilesNotification.type, {
1156
+ watchers: [{ globPattern: "**/*.{txt,yml,gui,mod}" }, { globPattern: "**/metadata.json" }],
1157
+ });
1158
+ }
1159
+ // Bundled frequency tables for completion ranking (§C3); fail-soft to empty.
1160
+ completion.setFreqs(loadFreqs(freqsDir));
1161
+ completion.setSettings(settings);
1162
+ loadDocs(false);
1163
+ startIndexBuild("startup");
1164
+ });
1165
+
1166
+ connection.onDidChangeWatchedFiles((params) => {
1167
+ for (const change of params.changes) {
1168
+ if (!change.uri.startsWith("file:")) continue;
1169
+ handleModFileChange(URI.parse(change.uri).fsPath);
1170
+ }
1171
+ });
1172
+
1173
+ // ---- custom protocol ----------------------------------------------------------
1174
+
1175
+ connection.onNotification(configChangedNotification, (incoming: ParadoxSettings) => {
1176
+ const newSettings: ParadoxSettings = { ...defaultSettings(), ...incoming };
1177
+ const gameChanged = resolveProfile(newSettings.gameId) !== activeProfile();
1178
+ if (gameChanged) {
1179
+ setActiveProfile(resolveProfile(newSettings.gameId));
1180
+ // Bundled wiki/freqs are per-game; re-derive and re-rank for the new one.
1181
+ deriveBundledDataDirs();
1182
+ completion.setFreqs(loadFreqs(freqsDir));
1183
+ }
1184
+ const pathsChanged =
1185
+ gameChanged ||
1186
+ newSettings.gamePath !== settings.gamePath ||
1187
+ newSettings.logsPath !== settings.logsPath ||
1188
+ newSettings.modPath !== settings.modPath ||
1189
+ JSON.stringify(newSettings.parentPaths ?? []) !== JSON.stringify(settings.parentPaths ?? []) ||
1190
+ JSON.stringify(newSettings.workspaceMods ?? []) !== JSON.stringify(settings.workspaceMods ?? []) ||
1191
+ newSettings.locLanguage !== settings.locLanguage;
1192
+ const diagChanged =
1193
+ JSON.stringify(newSettings.diagnosticsIgnore) !== JSON.stringify(settings.diagnosticsIgnore) ||
1194
+ JSON.stringify(newSettings.diagnosticsIgnorePatterns) !==
1195
+ JSON.stringify(settings.diagnosticsIgnorePatterns) ||
1196
+ newSettings.diagnosticsVanilla !== settings.diagnosticsVanilla;
1197
+ settings = newSettings;
1198
+ completion.setSettings(settings);
1199
+ if (pathsChanged) {
1200
+ log("paths changed; rebuilding data...");
1201
+ loadDocs(false);
1202
+ startIndexBuild("paths changed");
1203
+ }
1204
+ // Re-validate open documents so suppression/vanilla changes apply immediately.
1205
+ if (diagChanged || pathsChanged) {
1206
+ for (const doc of documents.all()) validateDocument(doc);
1207
+ }
1208
+ });
1209
+
1210
+ /**
1211
+ * Per-path debounce for watcher events (§B3). One Ctrl+S produces several:
1212
+ * the editor's write, the metadata update behind it, and one more per watcher
1213
+ * root that contains the file. ~150ms also keeps a half-written large file
1214
+ * from being parsed into the index and immediately parsed again.
1215
+ */
1216
+ const MOD_CHANGE_DEBOUNCE_MS = 150;
1217
+ const pendingModChanges = new Map<string, { fsPath: string; timer: ReturnType<typeof setTimeout> }>();
1218
+
1219
+ function handleModFileChange(fsPath: string): void {
1220
+ perf(`modFileChanged ${perfName(fsPath)}`);
1221
+ const key = fsPath.toLowerCase();
1222
+ const pending = pendingModChanges.get(key);
1223
+ if (pending) clearTimeout(pending.timer);
1224
+ pendingModChanges.set(key, {
1225
+ fsPath,
1226
+ timer: setTimeout(() => {
1227
+ pendingModChanges.delete(key);
1228
+ applyModFileChange(fsPath);
1229
+ }, MOD_CHANGE_DEBOUNCE_MS),
1230
+ });
1231
+ }
1232
+
1233
+ /**
1234
+ * Freshness guard (§B3): a request that reads the index runs the pending
1235
+ * rescans first, so the debounce can never answer out of a stale index. Costs
1236
+ * a map size check except in the ~150ms after a save. The view/webview
1237
+ * requests do not call this: they are refreshed by the index-changed
1238
+ * notification the rescan itself fires.
1239
+ */
1240
+ function flushModFileChanges(): void {
1241
+ if (pendingModChanges.size === 0) return;
1242
+ const pending = [...pendingModChanges.values()];
1243
+ pendingModChanges.clear();
1244
+ for (const { fsPath, timer } of pending) {
1245
+ clearTimeout(timer);
1246
+ applyModFileChange(fsPath);
1247
+ }
1248
+ }
1249
+
1250
+ /** A traced handler that READS the index: pending rescans land first (§B3). */
1251
+ function indexRead<T>(label: string, fn: () => T): T {
1252
+ flushModFileChanges();
1253
+ return perfSpan(label, fn);
1254
+ }
1255
+
1256
+ function mentionsTextFormatting(fsPath: string): boolean {
1257
+ try {
1258
+ return fs.readFileSync(fsPath, "utf8").includes("textformatting");
1259
+ } catch {
1260
+ return false;
1261
+ }
1262
+ }
1263
+
1264
+ function applyModFileChange(fsPath: string): void {
1265
+ rescanModFile(fsPath);
1266
+ const lower = fsPath.toLowerCase();
1267
+ if (lower.endsWith(".mod") || lower.endsWith("metadata.json")) refreshModOrigin();
1268
+ if (lower.endsWith(".gui")) invalidateGuiDefsCache();
1269
+ // Full re-harvest when a mod defines file or a gui file WITH textformatting
1270
+ // changed. Not on every .gui: the harvest reads only those out of gui/, and
1271
+ // autosave fires this after every GUI editor gesture.
1272
+ if (
1273
+ lower.replace(/\\/g, "/").includes("common/defines/") ||
1274
+ (lower.endsWith(".gui") && mentionsTextFormatting(fsPath))
1275
+ )
1276
+ harvestEngineData();
1277
+ }
1278
+
1279
+ connection.onNotification(modFileChangedNotification, (params: ModFileChangeParams) => {
1280
+ handleModFileChange(params.fsPath);
1281
+ });
1282
+
1283
+ connection.onRequest(reloadDocsRequest, (params: ReloadDocsParams): ReloadDocsResult => {
1284
+ loadDocs(params.force);
1285
+ return { tokens: data.tokens.length };
1286
+ });
1287
+
1288
+ connection.onRequest(indexStatsRequest, () => data.index.stats());
1289
+
1290
+ connection.onRequest(modOverviewRequest, (params: ModScopedParams | null) =>
1291
+ computeModOverview(data, focusFilter(params?.modRoot))
1292
+ );
1293
+
1294
+ connection.onRequest(locCoverageRequest, (params: ModScopedParams | null) => {
1295
+ // Coverage is inherently per-mod: default to the first workspace mod when
1296
+ // the client sends no focus (older clients, tests).
1297
+ const root = params?.modRoot ?? settings.modPath ?? workspaceModRoots()[0] ?? null;
1298
+ return computeLocCoverage(data, root, settings.locLanguage, schema.entries, focusFilter(root));
1299
+ });
1300
+
1301
+ connection.onRequest(overridesRequest, (params: ModScopedParams | null) =>
1302
+ computeOverrides(data, settings.gamePath, focusFilter(params?.modRoot))
1303
+ );
1304
+
1305
+ connection.onRequest(eventGraphRequest, (params: EventGraphParams) =>
1306
+ computeEventGraph(data, params ?? {}, focusFilter(params?.modRoot))
1307
+ );
1308
+
1309
+ // What an event editor may offer: the profile's structure table, the schema's
1310
+ // reference fields resolved through the index, and the script_docs tokens.
1311
+ // Never a hand-written name list.
1312
+ connection.onRequest(eventVocabularyRequest, (params: EventVocabularyParams | null) =>
1313
+ computeEventVocabulary(data, schema, focusFilter(params?.modRoot))
1314
+ );
1315
+
1316
+ // The value set one VALUE belongs to, for the graph inspector's nested rows:
1317
+ // resolve the value through the index, answer every definition of its kind.
1318
+ connection.onRequest(eventValueOptionsRequest, (params: EventValueOptionsParams | null) =>
1319
+ computeValueOptions(data, params?.value ?? "", focusFilter(params?.modRoot))
1320
+ );
1321
+
1322
+ // The theme's illustration, through the game's own event_themes ->
1323
+ // event_backgrounds hops. Answering "nothing resolved" is a real answer.
1324
+ connection.onRequest(eventBannerRequest, (params: EventBannerParams) =>
1325
+ computeEventBanner(data, params?.theme ?? "")
1326
+ );
1327
+
1328
+ connection.onRequest(guiTreeRequest, (params: GuiTreeParams) => buildGuiTree(params.text ?? ""));
1329
+
1330
+ /** Loc keys resolve through the index (configured language, english files as fallback). */
1331
+ function locValue(key: string): string | undefined {
1332
+ return data.index.lookup(key).find((d) => d.kind === "loc_key" && d.value !== undefined)?.value;
1333
+ }
1334
+
1335
+ function guiTextResolver(params: GuiLayoutParams): ((raw: string) => ResolvedText) | undefined {
1336
+ if (params.loc === "raw") return undefined;
1337
+ return (raw) => resolveGuiText(raw, { loc: locValue, previewValues: params.previewValues });
1338
+ }
1339
+
1340
+ connection.onRequest(guiLayoutRequest, (params: GuiLayoutParams) =>
1341
+ computeGuiLayoutResult(
1342
+ params.text ?? "",
1343
+ settings.gamePath,
1344
+ settings.modPath,
1345
+ settings.parentPaths,
1346
+ engineRoots(),
1347
+ params.visibility,
1348
+ guiTextResolver(params)
1349
+ )
1350
+ );
1351
+
1352
+ /** The type chain a size guard resolves through is the same store the preview lays out with. */
1353
+ function guiDefsForEdits() {
1354
+ return getGuiDefs(settings.gamePath, settings.modPath, settings.parentPaths, engineRoots());
1355
+ }
1356
+
1357
+ /** Texture paths resolve the way the game loads assets: mod over parents over game. */
1358
+ function textureRoots() {
1359
+ return {
1360
+ gamePath: settings.gamePath,
1361
+ modPath: settings.modPath,
1362
+ parentPaths: settings.parentPaths,
1363
+ engineRoots: engineRoots(),
1364
+ };
1365
+ }
1366
+
1367
+ /** The `GetScriptedGui(...)` index, cached alongside the template/type store. */
1368
+ function guiLinks() {
1369
+ return getGuiScriptLinks(settings.gamePath, settings.modPath, settings.parentPaths, engineRoots());
1370
+ }
1371
+
1372
+ // One op or a batch, never both: a request carrying the two shapes cannot say
1373
+ // which one the caller meant, and guessing would write the wrong set.
1374
+ connection.onRequest(guiSourceEditRequest, (params: GuiSourceEditParams) => {
1375
+ const text = params.text ?? "";
1376
+ const defs = guiDefsForEdits();
1377
+ if (params.ops) return params.op ? null : computeGuiSourceEdits(text, params.ops, defs);
1378
+ return computeGuiSourceEdit(text, params.op, defs);
1379
+ });
1380
+
1381
+ // The inspector reads through the same store the preview lays out with, so a
1382
+ // row it shows is a value the canvas used.
1383
+ connection.onRequest(guiWidgetInfoRequest, (params: GuiWidgetInfoParams) =>
1384
+ computeGuiWidgetInfo(params.text ?? "", params.line, guiDefsForEdits(), {
1385
+ placement: params.placement === true,
1386
+ roots: textureRoots(),
1387
+ viewport: VIEWPORT,
1388
+ })
1389
+ );
1390
+
1391
+ // What a designer palette may offer: the bundled harvest for the active game
1392
+ // plus this document's own declarations. Never a hand-written name list.
1393
+ connection.onRequest(guiVocabularyRequest, (params: GuiVocabularyParams) =>
1394
+ computeGuiVocabulary(params.text ?? "", activeProfile().guiSchema)
1395
+ );
1396
+
1397
+ // Library tiles: one instance per entry, same store and measurer as the canvas.
1398
+ connection.onRequest(guiPreviewRequest, (params: GuiPreviewParams): GuiPreviewResult => ({
1399
+ previews: previewEntries(
1400
+ params.text ?? "",
1401
+ (params.entries ?? []).slice(0, GUI_PREVIEW_MAX),
1402
+ guiDefsForEdits(),
1403
+ profileMeasurer()
1404
+ ),
1405
+ }));
1406
+
1407
+ // Real values for the designer's `[...]` chips, read out of a save game.
1408
+ // Cached per file and mtime: a re-layout asks again and pays nothing, while a
1409
+ // save written since the last read is picked up on its own.
1410
+ let saveValues: { key: string; result: GuiSaveValuesResult } | null = null;
1411
+ connection.onRequest(
1412
+ guiSaveValuesRequest,
1413
+ async (params: GuiSaveValuesParams): Promise<GuiSaveValuesResult> => {
1414
+ const file = params?.path ?? "";
1415
+ let key = file;
1416
+ try {
1417
+ key = `${file}:${fs.statSync(file).mtimeMs}`;
1418
+ } catch {
1419
+ // Unreadable: the read below reports why, and every attempt asks again.
1420
+ }
1421
+ if (saveValues?.key === key) return saveValues.result;
1422
+ const profile = activeProfile();
1423
+ const result = await readSaveValues(file, {
1424
+ gameId: profile.id,
1425
+ schema: profile.saveSchema,
1426
+ loc: locValue,
1427
+ });
1428
+ saveValues = { key, result };
1429
+ return result;
1430
+ }
1431
+ );
1432
+
1433
+ // The GUI half of the dependency explorer. Same document text the canvas is
1434
+ // showing, so a selection answers about what the editor has, not what disk has.
1435
+ connection.onRequest(guiDependenciesRequest, (params: GuiDependenciesParams) =>
1436
+ computeGuiDependencies(data, schema, params.text ?? "", params.line, guiLinks())
1437
+ );
1438
+
1439
+ // Deprecated: the narrow position/size shape, over the same core.
1440
+ connection.onRequest(guiWidgetEditRequest, (params: GuiWidgetEditParams) =>
1441
+ computeGuiWidgetEdit(params.text ?? "", params.line, params.property, params.values, guiDefsForEdits())
1442
+ );
1443
+
1444
+ connection.onRequest(eventDetailRequest, (params: EventDetailParams) =>
1445
+ params?.id ? computeEventDetail(data, schema, params.id) : null
1446
+ );
1447
+
1448
+ connection.onRequest(dependenciesRequest, (params: DependenciesParams) => {
1449
+ let name = params?.name;
1450
+ const kind = params?.kind;
1451
+ // Cursor-driven: resolve the word under the position in the open document.
1452
+ if (!name && params?.uri && params.position) {
1453
+ const doc = documents.get(params.uri);
1454
+ if (doc) {
1455
+ const range = wordRangeAt(getLineText(doc, params.position.line), params.position.character);
1456
+ if (range) name = range.word;
1457
+ }
1458
+ }
1459
+ const guiUses = params?.guiUses
1460
+ ? (target: string) => computeGuiUses(data, schema, guiLinks(), target)
1461
+ : undefined;
1462
+ if (!name) {
1463
+ return { def: null, dependents: [], dependencies: [], ...(guiUses ? { guiUses: [] } : {}) };
1464
+ }
1465
+ return computeDependencies(data, schema, name, kind, guiUses);
1466
+ });
1467
+
1468
+ connection.onRequest(scopeAtRequest, (params: ScopeAtParams): ScopeAtResult | null => {
1469
+ // Open documents only: the client's text is the authority, and a status bar
1470
+ // asking about a closed/loc/gui document gets "nothing to show", not an error.
1471
+ const doc = params?.uri ? documents.get(params.uri) : undefined;
1472
+ if (!doc || !isScriptLanguage(doc.languageId) || !params.position) return null;
1473
+ const entry = schemaEntryForFile(URI.parse(doc.uri).fsPath);
1474
+ return computeScopeAt(
1475
+ data,
1476
+ doc,
1477
+ params.position,
1478
+ entry?.rootScopes?.length ? new Set(entry.rootScopes.map((s) => s.toLowerCase())) : null,
1479
+ entry
1480
+ );
1481
+ });
1482
+
1483
+ connection.onRequest(lookupLocRequest, (params: LookupLocParams): LocEntryInfo[] => {
1484
+ return data.index
1485
+ .lookup(params.key)
1486
+ .filter((d) => d.kind === "loc_key")
1487
+ .map((d) => ({ file: d.file, line: d.line, source: d.source, value: d.value }));
1488
+ });
1489
+
1490
+ // ---- language features ----------------------------------------------------------
1491
+
1492
+ connection.onCompletion((params) =>
1493
+ indexRead(`completion ${perfName(params.textDocument.uri)}`, () => {
1494
+ const doc = documents.get(params.textDocument.uri);
1495
+ if (!doc) return [];
1496
+ if (doc.languageId === "paradox-gui") {
1497
+ const result = provideGuiCompletion(data, doc, doc.offsetAt(params.position), settings);
1498
+ return { isIncomplete: result.isIncomplete, items: result.items };
1499
+ }
1500
+ if (doc.languageId === "paradox-loc") {
1501
+ // Loc lines complete inside [ ... ] datafunction expressions and #tag formats.
1502
+ const linePrefix = doc.getText({
1503
+ start: { line: params.position.line, character: 0 },
1504
+ end: params.position,
1505
+ });
1506
+ const result =
1507
+ provideDataFnCompletion(data.dataTypes, data.dataFnUsage, linePrefix, data.index, params.position) ??
1508
+ provideFormatTagCompletion(data.textFormatting, linePrefix);
1509
+ return result ? { isIncomplete: result.isIncomplete, items: result.items } : [];
1510
+ }
1511
+ if (!isScriptLanguage(doc.languageId)) return [];
1512
+ const entry = schemaEntryForFile(URI.parse(doc.uri).fsPath);
1513
+ const result = completion.provide(
1514
+ doc,
1515
+ doc.offsetAt(params.position),
1516
+ entry?.rootScopes?.length ? new Set(entry.rootScopes.map((s) => s.toLowerCase())) : null,
1517
+ entry
1518
+ );
1519
+ return { isIncomplete: result.isIncomplete, items: result.items };
1520
+ })
1521
+ );
1522
+
1523
+ connection.onCompletionResolve((item) => completion.resolve(item));
1524
+
1525
+ connection.onHover((params) =>
1526
+ indexRead(`hover ${perfName(params.textDocument.uri)}`, () => {
1527
+ const doc = documents.get(params.textDocument.uri);
1528
+ if (!doc) return null;
1529
+ if (doc.languageId === "paradox-gui") {
1530
+ const texture = provideTextureHover(settings, doc, params.position);
1531
+ if (texture) return texture;
1532
+ return provideGuiHover(data, doc, params.position, guiPaths());
1533
+ }
1534
+ if (doc.languageId === "paradox-loc") {
1535
+ const lineText = doc.getText({
1536
+ start: { line: params.position.line, character: 0 },
1537
+ end: { line: params.position.line + 1, character: 0 },
1538
+ });
1539
+ const flat = lineText.replace(/\r?\n$/, "");
1540
+ const dataFn =
1541
+ provideDataFnHover(
1542
+ data.dataTypes,
1543
+ data.dataFnUsage,
1544
+ flat,
1545
+ params.position.character,
1546
+ settings.gamePath
1547
+ ) ?? provideFormatTagHover(data.textFormatting, flat, params.position.character);
1548
+ if (!dataFn) return null;
1549
+ return {
1550
+ contents: { kind: MarkupKind.Markdown, value: dataFn.markdown },
1551
+ range: {
1552
+ start: { line: params.position.line, character: dataFn.start },
1553
+ end: { line: params.position.line, character: dataFn.end },
1554
+ },
1555
+ };
1556
+ }
1557
+ if (!isScriptLanguage(doc.languageId)) return null;
1558
+ const fsPath = URI.parse(doc.uri).fsPath;
1559
+ const entry = schemaEntryForFile(fsPath);
1560
+ const texture = provideTextureHover(settings, doc, params.position, entry?.kind);
1561
+ if (texture) return texture;
1562
+ return provideHover(
1563
+ data,
1564
+ doc,
1565
+ params.position,
1566
+ entry?.rootScopes?.length ? new Set(entry.rootScopes.map((s) => s.toLowerCase())) : null,
1567
+ entry,
1568
+ () => schema,
1569
+ (word) => docLocalDefs(doc).filter((d) => d.name === word)
1570
+ );
1571
+ })
1572
+ );
1573
+
1574
+ /** Definitions extracted from an OPEN document, memoized per uri+version: the
1575
+ * index-free net behind hover/definition for same-file declarations — inline
1576
+ * scripted_triggers keep answering even when the vanilla index is stale,
1577
+ * missing, or still building (#5). */
1578
+ const docDefsCache = new Map<string, Definition[]>();
1579
+ function docLocalDefs(doc: TextDocument): Definition[] {
1580
+ const key = `${doc.uri}|${doc.version}`;
1581
+ let defs = docDefsCache.get(key);
1582
+ if (!defs) {
1583
+ if (docDefsCache.size >= 16) docDefsCache.clear();
1584
+ const fsPath = URI.parse(doc.uri).fsPath;
1585
+ // Outside every known root (unset gamePath, stray copy), an events-shaped
1586
+ // scan still nets the inline declarations plain files carry.
1587
+ const entry = schemaEntryForFile(fsPath) ?? {
1588
+ path: "events",
1589
+ kind: "event",
1590
+ extraction: "event-id" as const,
1591
+ };
1592
+ defs = extractDefinitions(doc.getText(), entry, fsPath, "vanilla");
1593
+ docDefsCache.set(key, defs);
1594
+ }
1595
+ return defs;
1596
+ }
1597
+
1598
+ connection.onDefinition((params) =>
1599
+ indexRead(`definition ${perfName(params.textDocument.uri)}`, () => {
1600
+ const doc = documents.get(params.textDocument.uri);
1601
+ if (!doc) return [];
1602
+ // Loc files: navigate [ ... ] datafunction names (custom loc, saved scopes).
1603
+ // Plain loc-key jumps stay with the client-side script-usage provider.
1604
+ if (doc.languageId === "paradox-loc") return provideLocDefinition(data, doc, params.position);
1605
+ if (!isScriptLanguage(doc.languageId) && doc.languageId !== "paradox-gui") return [];
1606
+ if (doc.languageId === "paradox-gui") {
1607
+ // Types, templates and blockoverride targets resolve through the FIOS
1608
+ // store first (what the game actually uses); loc keys etc. fall through.
1609
+ const gui = provideGuiDefinition(doc, params.position, guiPaths());
1610
+ if (gui) return gui;
1611
+ }
1612
+ return provideDefinition(data, doc, params.position, (word) =>
1613
+ docLocalDefs(doc).filter((d) => d.name === word)
1614
+ );
1615
+ })
1616
+ );
1617
+
1618
+ connection.onSignatureHelp((params) => {
1619
+ flushModFileChanges();
1620
+ const doc = documents.get(params.textDocument.uri);
1621
+ if (!doc) return null;
1622
+ if (doc.languageId === "paradox-gui" || doc.languageId === "paradox-loc") {
1623
+ const lineText = getLineText(doc, params.position.line);
1624
+ return provideDataFnSignature(data.dataTypes, data.dataFnUsage, lineText, params.position.character);
1625
+ }
1626
+ if (!isScriptLanguage(doc.languageId)) return null;
1627
+ return provideSignatureHelp(data, doc, params.position);
1628
+ });
1629
+
1630
+ connection.onCodeAction((params) => {
1631
+ flushModFileChanges();
1632
+ const doc = documents.get(params.textDocument.uri);
1633
+ if (!doc || !isScriptLanguage(doc.languageId)) return [];
1634
+ return provideCodeActions(data, doc, params.range, params.context.diagnostics, {
1635
+ locLanguage: settings.locLanguage,
1636
+ modRootOf: workspaceRootOf,
1637
+ locRoots: schema.entries.filter((e) => e.kind === "loc_key").map((e) => e.path),
1638
+ });
1639
+ });
1640
+
1641
+ connection.languages.inlayHint.on((params) =>
1642
+ indexRead(`inlayHint ${perfName(params.textDocument.uri)}`, () => {
1643
+ const doc = documents.get(params.textDocument.uri);
1644
+ if (!doc) return [];
1645
+ const entry = schemaEntryForFile(URI.parse(doc.uri).fsPath);
1646
+ const rootScopes = entry?.rootScopes?.length
1647
+ ? new Set(entry.rootScopes.map((s) => s.toLowerCase()))
1648
+ : null;
1649
+ return provideInlayHints(data, settings, doc, params.range, rootScopes, entry);
1650
+ })
1651
+ );
1652
+
1653
+ connection.languages.semanticTokens.on((params) =>
1654
+ indexRead(`semanticTokens ${perfName(params.textDocument.uri)}`, () => {
1655
+ const doc = documents.get(params.textDocument.uri);
1656
+ // gui files benefit too: template/type names classify via the index.
1657
+ if (!doc || (!isScriptLanguage(doc.languageId) && doc.languageId !== "paradox-gui")) return { data: [] };
1658
+ const entry = isScriptLanguage(doc.languageId) ? schemaEntryForFile(URI.parse(doc.uri).fsPath) : null;
1659
+ return provideSemanticTokens(data, doc, schema.refFields, entry, schema.structures);
1660
+ })
1661
+ );
1662
+
1663
+ connection.onReferences((params) =>
1664
+ indexRead(`references ${perfName(params.textDocument.uri)}`, () => {
1665
+ const doc = documents.get(params.textDocument.uri);
1666
+ // Loc files too: references on a loc key line list its script usage sites.
1667
+ if (!doc || (!isScriptLanguage(doc.languageId) && doc.languageId !== "paradox-loc")) return [];
1668
+ return provideReferences(data, doc, params.position, params.context.includeDeclaration, (name) =>
1669
+ lazyRefs.lookup(name)
1670
+ );
1671
+ })
1672
+ );
1673
+
1674
+ connection.onPrepareRename((params) => {
1675
+ flushModFileChanges();
1676
+ const doc = documents.get(params.textDocument.uri);
1677
+ if (!doc || !isScriptLanguage(doc.languageId)) return null;
1678
+ return prepareRename(data, doc, params.position);
1679
+ });
1680
+
1681
+ connection.onRenameRequest((params) => {
1682
+ flushModFileChanges();
1683
+ const doc = documents.get(params.textDocument.uri);
1684
+ if (!doc || !isScriptLanguage(doc.languageId)) return null;
1685
+ return provideRename(data, doc, params.position, params.newName, (uri) => documents.get(uri));
1686
+ });
1687
+
1688
+ connection.onWorkspaceSymbol((params) => {
1689
+ flushModFileChanges();
1690
+ return provideWorkspaceSymbols(data, params.query);
1691
+ });
1692
+
1693
+ connection.onDocumentSymbol((params) => {
1694
+ const doc = documents.get(params.textDocument.uri);
1695
+ if (!doc) return [];
1696
+ return provideDocumentSymbols(doc);
1697
+ });
1698
+
1699
+ connection.onDocumentFormatting((params) => {
1700
+ const doc = documents.get(params.textDocument.uri);
1701
+ if (!doc || !isScriptLanguage(doc.languageId)) return [];
1702
+ return provideFormattingEdits(doc);
1703
+ });
1704
+
1705
+ connection.onFoldingRanges((params) => {
1706
+ const doc = documents.get(params.textDocument.uri);
1707
+ if (!doc) return [];
1708
+ return provideFoldingRanges(doc);
1709
+ });
1710
+
1711
+ // Color swatches + the native picker (issue #11). Script and .gui only: the
1712
+ // descriptor and format-doc languages carry no colors.
1713
+ function colorLanguage(languageId: string): boolean {
1714
+ return isScriptLanguage(languageId) || languageId === "paradox-gui";
1715
+ }
1716
+ connection.onDocumentColor((params) => {
1717
+ const doc = documents.get(params.textDocument.uri);
1718
+ if (!doc || !colorLanguage(doc.languageId)) return [];
1719
+ return provideDocumentColors(doc);
1720
+ });
1721
+ connection.onColorPresentation((params) => {
1722
+ const doc = documents.get(params.textDocument.uri);
1723
+ if (!doc || !colorLanguage(doc.languageId)) return [];
1724
+ return provideColorPresentations(doc, params.color, params.range);
1725
+ });
1726
+
1727
+ // ---- structural diagnostics -----------------------------------------------------
1728
+
1729
+ /** BOM state per open document, read from disk (editors strip the BOM from the buffer). */
1730
+ const bomByUri = new Map<string, boolean | null>();
1731
+ const validationTimers = new Map<string, ReturnType<typeof setTimeout>>();
1732
+ /** What the published diagnostics of a document were computed from (§B3): the
1733
+ * typing debounce and the save that follows it must not parse the same bytes
1734
+ * against the same index twice. */
1735
+ const validatedAt = new Map<string, { version: number; revision: number }>();
1736
+
1737
+ function readBomFromDisk(uri: string): boolean | null {
1738
+ try {
1739
+ const fsPath = URI.parse(uri).fsPath;
1740
+ const fd = fs.openSync(fsPath, "r");
1741
+ try {
1742
+ const buf = Buffer.alloc(3);
1743
+ const n = fs.readSync(fd, buf, 0, 3, 0);
1744
+ return n >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
1745
+ } finally {
1746
+ fs.closeSync(fd);
1747
+ }
1748
+ } catch {
1749
+ return null; // unsaved / unreadable: unknown, no diagnostic
1750
+ }
1751
+ }
1752
+
1753
+ /** Path used to match `ignorePatterns`: mod-relative when possible, else parent/game-relative, else basename. */
1754
+ function relForPatterns(fsPath: string): string {
1755
+ const lower = fsPath.toLowerCase();
1756
+ for (const root of contentRoots()) {
1757
+ if (lower.startsWith(root.toLowerCase())) {
1758
+ return fsPath
1759
+ .slice(root.length)
1760
+ .replace(/^[\\/]+/, "")
1761
+ .replace(/\\/g, "/");
1762
+ }
1763
+ }
1764
+ return fsPath.replace(/\\/g, "/").split("/").pop() ?? "";
1765
+ }
1766
+
1767
+ function validateDocument(doc: TextDocument): void {
1768
+ indexRead(`validate ${perfName(doc.uri)}`, () => validateDocumentNow(doc));
1769
+ }
1770
+
1771
+ function validateDocumentNow(doc: TextDocument): void {
1772
+ validatedAt.set(doc.uri, { version: doc.version, revision: data.index.revision });
1773
+ const fsPath = URI.parse(doc.uri).fsPath;
1774
+ const ctx: FileContext = {
1775
+ fsPath,
1776
+ // Folder-layout checks apply to the workspace mod the file lives in
1777
+ // (multi-mod workspaces), falling back to the configured mod root.
1778
+ modPath: workspaceRootOf(fsPath) ?? settings.modPath,
1779
+ bomOnDisk: bomByUri.get(doc.uri) ?? null,
1780
+ };
1781
+
1782
+ // F8: never diagnose vanilla files unless explicitly opted in.
1783
+ if (
1784
+ !settings.diagnosticsVanilla &&
1785
+ settings.gamePath &&
1786
+ ctx.fsPath.toLowerCase().startsWith(settings.gamePath.toLowerCase())
1787
+ ) {
1788
+ void connection.sendDiagnostics({ uri: doc.uri, diagnostics: [] });
1789
+ return;
1790
+ }
1791
+
1792
+ let diagnostics;
1793
+ if (doc.languageId === "paradox-loc") {
1794
+ const { result, lineIndex } = getLocParse(doc);
1795
+ diagnostics = computeLocDiagnostics(result, lineIndex, ctx);
1796
+ } else if (doc.languageId === "paradox-gui") {
1797
+ // Structural checks only (unbalanced braces silently break FIOS gui files).
1798
+ const { result, lineIndex } = getParse(doc);
1799
+ diagnostics = computeScriptDiagnostics(result, lineIndex, ctx);
1800
+ } else if (isScriptLanguage(doc.languageId)) {
1801
+ const { result, lineIndex } = getParse(doc);
1802
+ diagnostics = computeScriptDiagnostics(result, lineIndex, ctx);
1803
+ // Conservative index-backed checks, for workspace mod files only.
1804
+ const owner = workspaceRootOf(ctx.fsPath);
1805
+ if (owner) {
1806
+ const text = doc.getText();
1807
+ const extracted = extractReferences(text, ctx.fsPath, "mod", schema, isEngineToken);
1808
+ pushAll(diagnostics, computeReferenceDiagnostics(extracted.references, data));
1809
+ const entry = classifyFile(owner, ctx.fsPath, schema.entries);
1810
+ if (entry?.requiredLoc && entry.kind !== "loc_key") {
1811
+ const defs = extractDefinitions(text.replace(/^/, ""), entry, ctx.fsPath, "mod");
1812
+ pushAll(diagnostics, computeRequiredLocDiagnostics(defs, entry, data));
1813
+ }
1814
+ }
1815
+ } else {
1816
+ return;
1817
+ }
1818
+
1819
+ // F1/F2: settings-driven and inline-comment suppression (fail-soft).
1820
+ diagnostics = filterSuppressed(diagnostics, ctx.fsPath, doc.getText());
1821
+ void connection.sendDiagnostics({ uri: doc.uri, diagnostics });
1822
+ }
1823
+
1824
+ /** Drop diagnostics muted by `diagnostics.ignore`/`ignorePatterns` or inline comments. */
1825
+ function filterSuppressed(
1826
+ diagnostics: import("vscode-languageserver/node").Diagnostic[],
1827
+ fsPath: string,
1828
+ text: string
1829
+ ): import("vscode-languageserver/node").Diagnostic[] {
1830
+ const cfg = {
1831
+ ignore: settings.diagnosticsIgnore,
1832
+ ignorePatterns: settings.diagnosticsIgnorePatterns,
1833
+ };
1834
+ const rel = relForPatterns(fsPath);
1835
+ const inline = scanInlineSuppressions(text);
1836
+ return diagnostics.filter((d) => {
1837
+ const code = typeof d.code === "string" ? d.code : d.code !== undefined ? String(d.code) : undefined;
1838
+ if (isIgnoredByConfig(cfg, code, rel)) return false;
1839
+ if (isSuppressedInline(inline, d.range.start.line, code)) return false;
1840
+ return true;
1841
+ });
1842
+ }
1843
+
1844
+ documents.onDidOpen((e) => {
1845
+ bomByUri.set(e.document.uri, readBomFromDisk(e.document.uri));
1846
+ validateDocument(e.document);
1847
+ });
1848
+
1849
+ documents.onDidChangeContent((e) => {
1850
+ const uri = e.document.uri;
1851
+ const existing = validationTimers.get(uri);
1852
+ if (existing) clearTimeout(existing);
1853
+ validationTimers.set(
1854
+ uri,
1855
+ setTimeout(() => {
1856
+ validationTimers.delete(uri);
1857
+ const doc = documents.get(uri);
1858
+ if (doc) validateDocument(doc);
1859
+ }, 300)
1860
+ );
1861
+ });
1862
+
1863
+ documents.onDidSave((e) => {
1864
+ const uri = e.document.uri;
1865
+ perf(`didSave ${perfName(uri)}`);
1866
+ // The typing debounce would otherwise validate the same bytes again 300ms
1867
+ // after the save (§B3).
1868
+ const pendingValidation = validationTimers.get(uri);
1869
+ if (pendingValidation) clearTimeout(pendingValidation);
1870
+ validationTimers.delete(uri);
1871
+ const bomBefore = bomByUri.get(uri);
1872
+ const bom = readBomFromDisk(uri);
1873
+ bomByUri.set(uri, bom);
1874
+ const done = validatedAt.get(uri);
1875
+ if (bom === bomBefore && done?.version === e.document.version && done.revision === data.index.revision) {
1876
+ perf(`didSave ${perfName(uri)} already validated at v${e.document.version}`);
1877
+ return;
1878
+ }
1879
+ validateDocument(e.document);
1880
+ });
1881
+
1882
+ documents.onDidClose((e) => {
1883
+ const uri = e.document.uri;
1884
+ const timer = validationTimers.get(uri);
1885
+ if (timer) clearTimeout(timer);
1886
+ validationTimers.delete(uri);
1887
+ validatedAt.delete(uri);
1888
+ bomByUri.delete(uri);
1889
+ evictParse(uri);
1890
+ void connection.sendDiagnostics({ uri, diagnostics: [] });
1891
+ });
1892
+
1893
+ documents.listen(connection);
1894
+ connection.listen();