@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
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Vanilla-usage harvest for [ ... ] datafunction expressions: scans the game's
3
+ * gui/ tree and localization/<language>/ for every bracketed expression and
4
+ * records what real code does with each name — usage counts, immediate
5
+ * members, call arities, quoted literal arguments, formatting suffixes and
6
+ * example sites. This is the "deduce it from how it's used" layer: it covers
7
+ * names newer than the bundled wiki tables and enriches everything with
8
+ * ground-truth examples. Cached in the storage dir (one full-text scan of
9
+ * ~35 MB per game patch is too slow to repeat every start).
10
+ *
11
+ * No vscode imports: unit-tested in plain Node.
12
+ */
13
+ import * as fs from "fs";
14
+ import * as path from "path";
15
+ import { listFiles } from "@px-lsp/protocol/fsWalk";
16
+
17
+ export interface DataFnExample {
18
+ /** The full bracketed expression, capped for display. */
19
+ text: string;
20
+ /** Game-relative file path and 1-based line, for provenance in hovers. */
21
+ file: string;
22
+ line: number;
23
+ }
24
+
25
+ export interface DataFnUsage {
26
+ /** Chain-start name (PascalCase/ALL_CAPS only) -> number of uses. */
27
+ starts: Map<string, number>;
28
+ /** Chain-start name -> immediate `.member` -> count (datacontext-style pairs). */
29
+ pairs: Map<string, Map<string, number>>;
30
+ /** Any post-dot segment name -> count, across all chains (unresolved-chain fallback). */
31
+ memberPool: Map<string, number>;
32
+ /** Called name -> arity -> count. */
33
+ argCounts: Map<string, Map<number, number>>;
34
+ /** Called name -> quoted literal argument -> count. */
35
+ literals: Map<string, Map<string, number>>;
36
+ /** `|X` formatting suffix -> count. */
37
+ formats: Map<string, number>;
38
+ /** Segment name -> up to MAX_EXAMPLES shortest real expressions using it. */
39
+ examples: Map<string, DataFnExample[]>;
40
+ files: number;
41
+ exprs: number;
42
+ }
43
+
44
+ export function emptyUsage(): DataFnUsage {
45
+ return {
46
+ starts: new Map(),
47
+ pairs: new Map(),
48
+ memberPool: new Map(),
49
+ argCounts: new Map(),
50
+ literals: new Map(),
51
+ formats: new Map(),
52
+ examples: new Map(),
53
+ files: 0,
54
+ exprs: 0,
55
+ };
56
+ }
57
+
58
+ const MAX_EXAMPLES = 2;
59
+ const MAX_EXAMPLE_LEN = 140;
60
+ const MAX_LITERAL_LEN = 48;
61
+ const MAX_LITERALS_PER_FN = 40;
62
+
63
+ // ---- expression parsing -----------------------------------------------------
64
+
65
+ interface Seg {
66
+ name: string;
67
+ /** Argument list when called with parens; null when a plain segment. */
68
+ args: Arg[] | null;
69
+ }
70
+ type Arg = { kind: "literal"; value: string } | { kind: "chain"; segments: Seg[] } | { kind: "number" };
71
+
72
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*/;
73
+
74
+ /**
75
+ * Tolerant recursive-descent parse of one bracketed expression body (without
76
+ * the [ ]). Returns the top-level chain plus any nested argument chains, or
77
+ * null when the text is not a datafunction expression (loc escapes, junk).
78
+ */
79
+ export function parseDataFnExpr(text: string): { chain: Seg[]; format: string | null } | null {
80
+ let pos = 0;
81
+ const skipWs = () => {
82
+ while (pos < text.length && (text[pos] === " " || text[pos] === "\t")) pos++;
83
+ };
84
+
85
+ function parseChain(): Seg[] | null {
86
+ const segments: Seg[] = [];
87
+ for (;;) {
88
+ skipWs();
89
+ const m = IDENT.exec(text.slice(pos));
90
+ if (!m) return null;
91
+ pos += m[0].length;
92
+ skipWs();
93
+ let args: Arg[] | null = null;
94
+ if (text[pos] === "(") {
95
+ pos++;
96
+ args = [];
97
+ skipWs();
98
+ if (text[pos] === ")") pos++;
99
+ else {
100
+ for (;;) {
101
+ const arg = parseArg();
102
+ if (!arg) return null;
103
+ args.push(arg);
104
+ skipWs();
105
+ if (text[pos] === ",") {
106
+ pos++;
107
+ continue;
108
+ }
109
+ if (text[pos] === ")") {
110
+ pos++;
111
+ break;
112
+ }
113
+ return null;
114
+ }
115
+ }
116
+ }
117
+ segments.push({ name: m[0], args });
118
+ skipWs();
119
+ if (text[pos] === ".") {
120
+ pos++;
121
+ continue;
122
+ }
123
+ return segments;
124
+ }
125
+ }
126
+
127
+ function parseArg(): Arg | null {
128
+ skipWs();
129
+ const ch = text[pos];
130
+ if (ch === "'") {
131
+ const close = text.indexOf("'", pos + 1);
132
+ if (close < 0) return null;
133
+ const value = text.slice(pos + 1, close);
134
+ pos = close + 1;
135
+ return { kind: "literal", value };
136
+ }
137
+ if (/[0-9-]/.test(ch)) {
138
+ const m = /^-?[0-9]+(\.[0-9]+)?/.exec(text.slice(pos));
139
+ if (!m) return null;
140
+ pos += m[0].length;
141
+ return { kind: "number" };
142
+ }
143
+ const segments = parseChain();
144
+ return segments ? { kind: "chain", segments } : null;
145
+ }
146
+
147
+ const chain = parseChain();
148
+ if (!chain) return null;
149
+ skipWs();
150
+ let format: string | null = null;
151
+ if (text[pos] === "|") {
152
+ format = text.slice(pos + 1).trim();
153
+ pos = text.length;
154
+ }
155
+ skipWs();
156
+ if (pos < text.length) return null;
157
+ return { chain, format };
158
+ }
159
+
160
+ // ---- recording ---------------------------------------------------------------
161
+
162
+ function bump<K>(map: Map<K, number>, key: K): void {
163
+ map.set(key, (map.get(key) ?? 0) + 1);
164
+ }
165
+
166
+ function addExample(usage: DataFnUsage, name: string, ex: DataFnExample): void {
167
+ let list = usage.examples.get(name);
168
+ if (!list) usage.examples.set(name, (list = []));
169
+ if (list.some((e) => e.text === ex.text)) return;
170
+ if (list.length < MAX_EXAMPLES) {
171
+ list.push(ex);
172
+ return;
173
+ }
174
+ // Prefer the shortest examples: they read best in a hover.
175
+ let longest = 0;
176
+ for (let i = 1; i < list.length; i++) if (list[i].text.length > list[longest].text.length) longest = i;
177
+ if (ex.text.length < list[longest].text.length) list[longest] = ex;
178
+ }
179
+
180
+ function recordChain(usage: DataFnUsage, chain: Seg[], ex: DataFnExample, topLevel: boolean): void {
181
+ const first = chain[0];
182
+ // Chain starts: types / global promotes / global functions are PascalCase or
183
+ // ALL_CAPS in vanilla. Lowercase starts in loc are script-scope bindings
184
+ // (owner.GetName) — not completable names, but their members still count.
185
+ if (/^[A-Z]/.test(first.name)) {
186
+ bump(usage.starts, first.name);
187
+ addExample(usage, first.name, ex);
188
+ if (chain.length > 1) {
189
+ let members = usage.pairs.get(first.name);
190
+ if (!members) usage.pairs.set(first.name, (members = new Map()));
191
+ bump(members, chain[1].name);
192
+ }
193
+ }
194
+ for (let i = 0; i < chain.length; i++) {
195
+ const seg = chain[i];
196
+ if (i > 0) {
197
+ bump(usage.memberPool, seg.name);
198
+ addExample(usage, seg.name, ex);
199
+ }
200
+ if (seg.args !== null) {
201
+ let arities = usage.argCounts.get(seg.name);
202
+ if (!arities) usage.argCounts.set(seg.name, (arities = new Map()));
203
+ bump(arities, seg.args.length);
204
+ for (const arg of seg.args) {
205
+ if (arg.kind === "literal") {
206
+ if (arg.value.length === 0 || arg.value.length > MAX_LITERAL_LEN) continue;
207
+ let lits = usage.literals.get(seg.name);
208
+ if (!lits) usage.literals.set(seg.name, (lits = new Map()));
209
+ if (lits.size < MAX_LITERALS_PER_FN || lits.has(arg.value)) bump(lits, arg.value);
210
+ } else if (arg.kind === "chain") {
211
+ recordChain(usage, arg.segments, ex, false);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ if (topLevel) usage.exprs++;
217
+ }
218
+
219
+ /** Bracketed expressions in one line of text; `\[` (loc escape) is skipped. */
220
+ const EXPR_RE = /\[([^\][\r\n]+)\]/g;
221
+
222
+ export function harvestLine(usage: DataFnUsage, lineText: string, file: string, lineNo: number): void {
223
+ EXPR_RE.lastIndex = 0;
224
+ let m: RegExpExecArray | null;
225
+ while ((m = EXPR_RE.exec(lineText)) !== null) {
226
+ if (m.index > 0 && lineText[m.index - 1] === "\\") continue;
227
+ const parsed = parseDataFnExpr(m[1]);
228
+ if (!parsed) continue;
229
+ const text = `[${m[1]}]`;
230
+ const ex: DataFnExample = {
231
+ text: text.length > MAX_EXAMPLE_LEN ? text.slice(0, MAX_EXAMPLE_LEN - 1) + "…" : text,
232
+ file,
233
+ line: lineNo,
234
+ };
235
+ recordChain(usage, parsed.chain, ex, true);
236
+ if (parsed.format) {
237
+ // Suffix may itself chain formats; count the leading token only.
238
+ const fmt = /^[A-Za-z0-9+\-=*%.]+/.exec(parsed.format)?.[0];
239
+ if (fmt && fmt.length <= 3) bump(usage.formats, fmt);
240
+ }
241
+ }
242
+ }
243
+
244
+ // ---- scanning + cache ---------------------------------------------------------
245
+
246
+ function harvestFile(usage: DataFnUsage, filePath: string, relPath: string): void {
247
+ let text: string;
248
+ try {
249
+ text = fs.readFileSync(filePath, "utf8");
250
+ } catch {
251
+ return;
252
+ }
253
+ if (!text.includes("[")) {
254
+ usage.files++;
255
+ return;
256
+ }
257
+ const lines = text.split(/\r?\n/);
258
+ for (let i = 0; i < lines.length; i++) {
259
+ if (lines[i].includes("[")) harvestLine(usage, lines[i], relPath, i + 1);
260
+ }
261
+ usage.files++;
262
+ }
263
+
264
+ /** The two scanned trees under a game root: the gui/ tree and one localization language folder. */
265
+ function scanRoots(gamePath: string, locLanguage: string): ReadonlyArray<readonly [string, string]> {
266
+ return [
267
+ [path.join(gamePath, "gui"), ".gui"],
268
+ [path.join(gamePath, "localization", locLanguage), ".yml"],
269
+ ];
270
+ }
271
+
272
+ export function harvestGameUsage(gamePath: string, locLanguage = "english"): DataFnUsage {
273
+ const usage = emptyUsage();
274
+ for (const [root, ext] of scanRoots(gamePath, locLanguage)) {
275
+ for (const file of listFiles(root, ext)) {
276
+ harvestFile(usage, file, path.relative(gamePath, file).replace(/\\/g, "/"));
277
+ }
278
+ }
279
+ return usage;
280
+ }
281
+
282
+ // JSON cache: bump CACHE_VERSION whenever the harvest shape or logic changes.
283
+ const CACHE_VERSION = 1;
284
+
285
+ interface CacheShape {
286
+ version: number;
287
+ stamp: string;
288
+ starts: Record<string, number>;
289
+ pairs: Record<string, Record<string, number>>;
290
+ memberPool: Record<string, number>;
291
+ argCounts: Record<string, Record<string, number>>;
292
+ literals: Record<string, Record<string, number>>;
293
+ formats: Record<string, number>;
294
+ examples: Record<string, DataFnExample[]>;
295
+ files: number;
296
+ exprs: number;
297
+ }
298
+
299
+ /**
300
+ * Cheap change stamp: file count + total size of both scanned trees. A game
301
+ * patch always changes it; it costs one directory walk, not 35 MB of reads.
302
+ */
303
+ function usageStamp(gamePath: string, locLanguage: string): string {
304
+ let count = 0;
305
+ let size = 0;
306
+ for (const [root, ext] of scanRoots(gamePath, locLanguage)) {
307
+ for (const file of listFiles(root, ext)) {
308
+ count++;
309
+ try {
310
+ size += fs.statSync(file).size;
311
+ } catch {
312
+ /* unreadable file: stamp from the rest */
313
+ }
314
+ }
315
+ }
316
+ return `${CACHE_VERSION}:${gamePath}:${locLanguage}:${count}:${size}`;
317
+ }
318
+
319
+ function toObj<V>(map: Map<string, V>): Record<string, V> {
320
+ return Object.fromEntries(map);
321
+ }
322
+ function toMap<V>(obj: Record<string, V> | undefined): Map<string, V> {
323
+ return new Map(Object.entries(obj ?? {}));
324
+ }
325
+
326
+ function serialize(usage: DataFnUsage, stamp: string): CacheShape {
327
+ return {
328
+ version: CACHE_VERSION,
329
+ stamp,
330
+ starts: toObj(usage.starts),
331
+ pairs: toObj(new Map([...usage.pairs].map(([k, v]) => [k, toObj(v)]))),
332
+ memberPool: toObj(usage.memberPool),
333
+ argCounts: toObj(
334
+ new Map(
335
+ [...usage.argCounts].map(([k, v]) => [k, toObj(new Map([...v].map(([n, c]) => [String(n), c])))])
336
+ )
337
+ ),
338
+ literals: toObj(new Map([...usage.literals].map(([k, v]) => [k, toObj(v)]))),
339
+ formats: toObj(usage.formats),
340
+ examples: toObj(usage.examples),
341
+ files: usage.files,
342
+ exprs: usage.exprs,
343
+ };
344
+ }
345
+
346
+ function deserialize(cache: CacheShape): DataFnUsage {
347
+ return {
348
+ starts: toMap(cache.starts),
349
+ pairs: new Map(Object.entries(cache.pairs ?? {}).map(([k, v]) => [k, toMap(v)])),
350
+ memberPool: toMap(cache.memberPool),
351
+ argCounts: new Map(
352
+ Object.entries(cache.argCounts ?? {}).map(([k, v]) => [
353
+ k,
354
+ new Map(Object.entries(v).map(([n, c]) => [Number(n), c])),
355
+ ])
356
+ ),
357
+ literals: new Map(Object.entries(cache.literals ?? {}).map(([k, v]) => [k, toMap(v)])),
358
+ formats: toMap(cache.formats),
359
+ examples: toMap(cache.examples),
360
+ files: cache.files ?? 0,
361
+ exprs: cache.exprs ?? 0,
362
+ };
363
+ }
364
+
365
+ export interface UsageLoadResult {
366
+ usage: DataFnUsage;
367
+ fromCache: boolean;
368
+ }
369
+
370
+ /** Harvest with a stamp-validated JSON cache; empty usage when no game path. */
371
+ export function loadDataFnUsage(
372
+ gamePath: string | null,
373
+ locLanguage: string,
374
+ cacheFile: string | null,
375
+ force = false
376
+ ): UsageLoadResult {
377
+ if (!gamePath) return { usage: emptyUsage(), fromCache: false };
378
+ const stamp = usageStamp(gamePath, locLanguage);
379
+ const cached = readCache(cacheFile, stamp, force);
380
+ if (cached) return { usage: cached, fromCache: true };
381
+ const usage = harvestGameUsage(gamePath, locLanguage);
382
+ writeCache(cacheFile, usage, stamp);
383
+ return { usage, fromCache: false };
384
+ }
385
+
386
+ /**
387
+ * Async variant for server startup: the uncached first harvest reads ~35 MB
388
+ * (20s on a cold OS cache), so it yields to the event loop between files
389
+ * instead of blocking every LSP request. Cache hits return without yielding.
390
+ */
391
+ export async function loadDataFnUsageAsync(
392
+ gamePath: string | null,
393
+ locLanguage: string,
394
+ cacheFile: string | null,
395
+ force = false
396
+ ): Promise<UsageLoadResult> {
397
+ if (!gamePath) return { usage: emptyUsage(), fromCache: false };
398
+ const stamp = usageStamp(gamePath, locLanguage);
399
+ const cached = readCache(cacheFile, stamp, force);
400
+ if (cached) return { usage: cached, fromCache: true };
401
+ const usage = emptyUsage();
402
+ const yieldNow = () => new Promise<void>((resolve) => setImmediate(resolve));
403
+ for (const [root, ext] of scanRoots(gamePath, locLanguage)) {
404
+ for (const file of listFiles(root, ext)) {
405
+ harvestFile(usage, file, path.relative(gamePath, file).replace(/\\/g, "/"));
406
+ if (usage.files % 20 === 0) await yieldNow();
407
+ }
408
+ }
409
+ writeCache(cacheFile, usage, stamp);
410
+ return { usage, fromCache: false };
411
+ }
412
+
413
+ function readCache(cacheFile: string | null, stamp: string, force: boolean): DataFnUsage | null {
414
+ if (!cacheFile || force) return null;
415
+ try {
416
+ const cache = JSON.parse(fs.readFileSync(cacheFile, "utf8")) as CacheShape;
417
+ if (cache.version === CACHE_VERSION && cache.stamp === stamp) return deserialize(cache);
418
+ } catch {
419
+ /* no/invalid cache: harvest */
420
+ }
421
+ return null;
422
+ }
423
+
424
+ function writeCache(cacheFile: string | null, usage: DataFnUsage, stamp: string): void {
425
+ if (!cacheFile || usage.exprs === 0) return;
426
+ try {
427
+ fs.writeFileSync(cacheFile, JSON.stringify(serialize(usage, stamp)));
428
+ } catch {
429
+ /* cache write is best-effort */
430
+ }
431
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Data types for [ ... ] data-function expressions in .gui and localization
3
+ * files: global promotes/functions (GetPlayer, …) and per-type members
4
+ * (Character.IsAlive, …).
5
+ *
6
+ * Two sources, script_docs-style:
7
+ * - bundled baseline harvested from the modding wiki's Data types page
8
+ * (packages/server/data/<game>/dataTypes.json, built by scripts/build-data-types-json.ts);
9
+ * - the user's own `data_types.log`, written by the game's `DumpDataTypes`
10
+ * console command — complete and version-exact, so its entries win.
11
+ *
12
+ * No vscode imports: unit-tested in plain Node.
13
+ */
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import { activeProfile } from "../games/active";
17
+
18
+ export interface DataTypeMember {
19
+ /** Return type name; null when unknown (wiki lists some as [unregistered]). */
20
+ ret: string | null;
21
+ /** Argument type names from the dump header; null when unknown/none recorded. */
22
+ args: string[] | null;
23
+ kind: "promote" | "function";
24
+ /** Description prose from the dump, when the entry carries one. */
25
+ desc?: string;
26
+ /** Which source produced this entry (provenance shown in hovers). */
27
+ src?: "wiki" | "dump" | "macro";
28
+ }
29
+
30
+ export interface DataTypesData {
31
+ /** Chain-start names: global promotes and global functions. */
32
+ globals: Map<string, DataTypeMember>;
33
+ /** Type name -> member name -> member. */
34
+ types: Map<string, Map<string, DataTypeMember>>;
35
+ /** Lowercased type name -> canonical casing (tolerant hover/completion). */
36
+ typeNamesLower: Map<string, string>;
37
+ /** Where the (majority of the) data came from. */
38
+ source: "bundled wiki" | "data_types.log";
39
+ /** Total member count, for the status log line. */
40
+ count: number;
41
+ }
42
+
43
+ interface BundledShape {
44
+ globalPromotes: Record<string, string | null>;
45
+ globalFunctions: Record<string, string | null>;
46
+ types: Record<string, Record<string, string | null>>;
47
+ }
48
+
49
+ export function emptyDataTypes(): DataTypesData {
50
+ return {
51
+ globals: new Map(),
52
+ types: new Map(),
53
+ typeNamesLower: new Map(),
54
+ source: "bundled wiki",
55
+ count: 0,
56
+ };
57
+ }
58
+
59
+ function typeMembers(data: DataTypesData, type: string): Map<string, DataTypeMember> {
60
+ let members = data.types.get(type);
61
+ if (!members) {
62
+ data.types.set(type, (members = new Map()));
63
+ data.typeNamesLower.set(type.toLowerCase(), type);
64
+ }
65
+ return members;
66
+ }
67
+
68
+ export function loadBundledDataTypes(): DataTypesData {
69
+ const data = emptyDataTypes();
70
+ const bundled = activeProfile().bundledDataTypes as BundledShape | undefined;
71
+ if (!bundled) return data;
72
+ for (const [name, ret] of Object.entries(bundled.globalPromotes)) {
73
+ data.globals.set(name, { ret, args: null, kind: "promote", src: "wiki" });
74
+ data.count++;
75
+ }
76
+ for (const [name, ret] of Object.entries(bundled.globalFunctions)) {
77
+ if (!data.globals.has(name)) data.globals.set(name, { ret, args: null, kind: "function", src: "wiki" });
78
+ data.count++;
79
+ }
80
+ for (const [type, members] of Object.entries(bundled.types)) {
81
+ const map = typeMembers(data, type);
82
+ for (const [name, ret] of Object.entries(members)) {
83
+ map.set(name, { ret, args: null, kind: "function", src: "wiki" });
84
+ data.count++;
85
+ }
86
+ }
87
+ return data;
88
+ }
89
+
90
+ /**
91
+ * Parse the game's DumpDataTypes output. Entries are separated by dashed
92
+ * lines; each entry is a header (`Name`, `Type.Name`, or `Name( arg, arg )`)
93
+ * followed by `Definition type: <Global promote|Global function|Promote|
94
+ * Function|Type|Global macro>` and `Return type: <name>` lines. Tolerant: an
95
+ * entry missing any expected part is skipped.
96
+ */
97
+ export function parseDataTypesDump(text: string, into?: DataTypesData): DataTypesData {
98
+ const data = into ?? emptyDataTypes();
99
+ for (const rawEntry of text.split(/\r?\n-{4,}\r?\n/)) {
100
+ const entry = rawEntry.trim();
101
+ if (entry.length === 0) continue;
102
+ const lines = entry.split(/\r?\n/);
103
+ const header = lines[0].trim();
104
+ let defType: string | null = null;
105
+ let ret: string | null = null;
106
+ const descLines: string[] = [];
107
+ for (const line of lines.slice(1)) {
108
+ const def = /^Definition type:\s*(.+)$/.exec(line.trim());
109
+ if (def) {
110
+ defType = def[1].trim();
111
+ continue;
112
+ }
113
+ const rt = /^Return type:\s*(.+)$/.exec(line.trim());
114
+ if (rt) {
115
+ ret = rt[1].trim();
116
+ continue;
117
+ }
118
+ // Descriptions come as "Description: ..." lines; older dumps used bare
119
+ // prose. Either way, strip the prefix and keep the text. A dash run is
120
+ // a separator (reachable here when the file lacks a trailing newline).
121
+ const prose = line.trim().replace(/^Description:\s*/, "");
122
+ if (prose.length > 0 && !/^-{4,}$/.test(prose)) descLines.push(prose);
123
+ }
124
+ if (!defType) continue;
125
+ const kind = /promote/i.test(defType) ? "promote" : "function";
126
+ if (!/^(Global promote|Global function|Promote|Function)$/i.test(defType)) continue;
127
+
128
+ // Header: strip an argument list, split an owning type off the name.
129
+ let signature = header;
130
+ let args: string[] | null = null;
131
+ const paren = signature.indexOf("(");
132
+ if (paren >= 0) {
133
+ const argText = signature.slice(
134
+ paren + 1,
135
+ signature.lastIndexOf(")") >= 0 ? signature.lastIndexOf(")") : undefined
136
+ );
137
+ args = argText
138
+ .split(",")
139
+ .map((a) => a.trim())
140
+ .filter((a) => a.length > 0);
141
+ signature = signature.slice(0, paren).trim();
142
+ }
143
+ if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(signature)) continue;
144
+
145
+ // "[unregistered]" is the dump's "no registered return type" marker — a
146
+ // null ret (fallback completion) beats a bogus type name (dead chain).
147
+ const member: DataTypeMember = {
148
+ ret: ret === "void" || ret === "[unregistered]" ? null : ret,
149
+ args,
150
+ kind,
151
+ src: "dump",
152
+ };
153
+ const desc = descLines.join(" ").slice(0, 300);
154
+ // "Jomini Script System" is per-entry boilerplate, not a description.
155
+ if (desc.length > 0 && desc !== "Jomini Script System") member.desc = desc;
156
+ const isGlobal = /^Global/i.test(defType);
157
+ const dot = signature.indexOf(".");
158
+ if (!isGlobal && dot > 0) {
159
+ const owner = signature.slice(0, dot);
160
+ const name = signature.slice(dot + 1);
161
+ if (name.length === 0 || name.includes(".")) continue;
162
+ insertMember(data, typeMembers(data, owner), name, member);
163
+ } else if (isGlobal && dot < 0) {
164
+ insertMember(data, data.globals, signature, member);
165
+ }
166
+ }
167
+ return data;
168
+ }
169
+
170
+ /**
171
+ * Insert a dump entry without losing information: the dump lists some members
172
+ * twice (e.g. `Character.GetFather` once as a Promote with a real return type
173
+ * and once as a Function returning "[unregistered]"), and the wiki baseline
174
+ * may already hold a typed entry. An entry with a known return type is never
175
+ * clobbered by one without; desc/args fill in whichever survivor lacks them.
176
+ */
177
+ function insertMember(
178
+ data: DataTypesData,
179
+ map: Map<string, DataTypeMember>,
180
+ name: string,
181
+ member: DataTypeMember
182
+ ): void {
183
+ const prev = map.get(name);
184
+ if (!prev) {
185
+ map.set(name, member);
186
+ data.count++;
187
+ return;
188
+ }
189
+ const keep = prev.ret !== null && member.ret === null ? prev : member;
190
+ const other = keep === prev ? member : prev;
191
+ if (!keep.desc && other.desc) keep.desc = other.desc;
192
+ if (!keep.args && other.args) keep.args = other.args;
193
+ map.set(name, keep);
194
+ }
195
+
196
+ /** The dump files a directory holds, in any of the three shapes different
197
+ * game versions have written: `data_types.log`, `data_type*.txt` files, and a
198
+ * `data_types/` subfolder of per-category files. */
199
+ function dumpFilesIn(dir: string): string[] {
200
+ const files: string[] = [];
201
+ const logFile = path.join(dir, "data_types.log");
202
+ if (fs.existsSync(logFile)) files.push(logFile);
203
+ try {
204
+ for (const name of fs.readdirSync(dir)) {
205
+ if (/^data_type.*\.txt$/i.test(name)) files.push(path.join(dir, name));
206
+ }
207
+ } catch {
208
+ /* dir unreadable */
209
+ }
210
+ const dumpDir = path.join(dir, "data_types");
211
+ try {
212
+ for (const name of fs.readdirSync(dumpDir)) {
213
+ const file = path.join(dumpDir, name);
214
+ if (fs.statSync(file).isFile()) files.push(file);
215
+ }
216
+ } catch {
217
+ /* no data_types/ subfolder */
218
+ }
219
+ return files;
220
+ }
221
+
222
+ /**
223
+ * Bundled baseline upgraded by dumps. `dirs` are searched in order and later
224
+ * directories win on conflicts, so callers list them lowest priority first
225
+ * (bundled dump, then the user's folders). A single string is accepted for
226
+ * convenience. Games whose script_docs live outside logs/ (newer Jomini
227
+ * titles) dump data types to logs/ anyway, so callers pass both folders.
228
+ */
229
+ export function loadDataTypes(dirs: string | null | Array<string | null>): DataTypesData {
230
+ const data = loadBundledDataTypes();
231
+ const list = (Array.isArray(dirs) ? dirs : [dirs]).filter((d): d is string => d !== null);
232
+ const dumpFiles = list.flatMap(dumpFilesIn);
233
+ if (dumpFiles.length === 0) return data;
234
+ const before = data.count;
235
+ for (const file of dumpFiles) {
236
+ try {
237
+ parseDataTypesDump(fs.readFileSync(file, "utf8"), data);
238
+ } catch {
239
+ /* unreadable dump: keep what we have */
240
+ }
241
+ }
242
+ if (data.count > before) data.source = "data_types.log";
243
+ return data;
244
+ }
245
+
246
+ /** Member lookup on a type, exact first then case-insensitive canonicalization. */
247
+ export function membersOf(data: DataTypesData, typeName: string): Map<string, DataTypeMember> | null {
248
+ const direct = data.types.get(typeName);
249
+ if (direct) return direct;
250
+ const canonical = data.typeNamesLower.get(typeName.toLowerCase());
251
+ return canonical ? (data.types.get(canonical) ?? null) : null;
252
+ }
253
+
254
+ /**
255
+ * Resolve a dotted chain (["Character","GetFather"]) to the type the NEXT
256
+ * segment completes against. The first segment may be a data type name (the
257
+ * datacontext style: Character.GetName) or a global promote/function. Returns
258
+ * null when any segment is unknown.
259
+ */
260
+ export function resolveChainType(data: DataTypesData, segments: string[]): string | null {
261
+ if (segments.length === 0) return null;
262
+ let current: string | null = null;
263
+ const first = segments[0];
264
+ if (membersOf(data, first)) {
265
+ current = data.typeNamesLower.get(first.toLowerCase()) ?? first;
266
+ } else {
267
+ const global = data.globals.get(first);
268
+ if (!global || !global.ret) return null;
269
+ current = global.ret;
270
+ }
271
+ for (const segment of segments.slice(1)) {
272
+ if (current === null) return null;
273
+ const members = membersOf(data, current);
274
+ const member = members?.get(segment);
275
+ if (!member || !member.ret) return null;
276
+ current = member.ret;
277
+ }
278
+ return current;
279
+ }