@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.
- package/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- package/src/wordAt.ts +49 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Definition indexer for vanilla + parent-mod + mod folders, driven by the
|
|
3
|
+
* schema table (packages/server/src/schema): every schema entry maps one folder to a
|
|
4
|
+
* definition kind and an extraction mode.
|
|
5
|
+
*
|
|
6
|
+
* No `vscode` imports here: this module is unit-tested in plain Node. The
|
|
7
|
+
* FileSystemWatcher wiring lives in the client.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import type { DefKind, Definition, DefSource, IndexStats } from "@px-lsp/protocol/types";
|
|
12
|
+
import { listFiles, walkDir } from "@px-lsp/protocol/fsWalk";
|
|
13
|
+
import { pushAll } from "@px-lsp/protocol/arrays";
|
|
14
|
+
import { activeProfile } from "../games/active";
|
|
15
|
+
import type { SchemaEntry } from "../schema/types";
|
|
16
|
+
import { EVENT_ID, extractDefinitions, extractLocDefinitions } from "./extract";
|
|
17
|
+
|
|
18
|
+
export { listFiles, EVENT_ID };
|
|
19
|
+
export type { IndexStats };
|
|
20
|
+
|
|
21
|
+
/** Priority when several sources define the same name (higher shadows lower). */
|
|
22
|
+
const SOURCE_RANK: Record<DefSource, number> = { mod: 2, parent: 1, vanilla: 0 };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The one kind whose names are tracked incrementally (perf campaign §B2): the
|
|
26
|
+
* scope model is refed every scripted list on EVERY index change, a save
|
|
27
|
+
* included, and walking all 1.9M names to find the ~50 that are scripted lists
|
|
28
|
+
* cost 65-85 ms per save (measured, recipe 1).
|
|
29
|
+
*/
|
|
30
|
+
const TRACKED_KIND = "scripted_list";
|
|
31
|
+
|
|
32
|
+
// Back-compat helpers used by unit tests and older callers.
|
|
33
|
+
export function parseScriptDefinitions(
|
|
34
|
+
content: string,
|
|
35
|
+
kind: DefKind,
|
|
36
|
+
file: string,
|
|
37
|
+
source: DefSource
|
|
38
|
+
): Definition[] {
|
|
39
|
+
const entry: SchemaEntry = {
|
|
40
|
+
path: "",
|
|
41
|
+
kind,
|
|
42
|
+
extraction: kind === "event" ? "event-id" : "top-level-key",
|
|
43
|
+
};
|
|
44
|
+
return extractDefinitions(content, entry, file, source);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseLocDefinitions(content: string, file: string, source: DefSource): Definition[] {
|
|
48
|
+
return extractLocDefinitions(content, file, source);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normFile(file: string): string {
|
|
52
|
+
const n = path.normalize(file);
|
|
53
|
+
return process.platform === "win32" ? n.toLowerCase() : n;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class DefinitionIndex {
|
|
57
|
+
private byName = new Map<string, Definition[]>();
|
|
58
|
+
private byFile = new Map<string, Definition[]>();
|
|
59
|
+
/** Names carrying at least one TRACKED_KIND definition, refcounted so
|
|
60
|
+
* removeFile drops the name again (§B2). */
|
|
61
|
+
private trackedNames = new Map<string, number>();
|
|
62
|
+
/** Bumped on every mutation; used by providers to invalidate caches. */
|
|
63
|
+
revision = 0;
|
|
64
|
+
|
|
65
|
+
addAll(defs: Definition[]): void {
|
|
66
|
+
for (const def of defs) {
|
|
67
|
+
let list = this.byName.get(def.name);
|
|
68
|
+
if (!list) this.byName.set(def.name, (list = []));
|
|
69
|
+
list.push(def);
|
|
70
|
+
if (def.kind === TRACKED_KIND)
|
|
71
|
+
this.trackedNames.set(def.name, (this.trackedNames.get(def.name) ?? 0) + 1);
|
|
72
|
+
const fkey = normFile(def.file);
|
|
73
|
+
let flist = this.byFile.get(fkey);
|
|
74
|
+
if (!flist) this.byFile.set(fkey, (flist = []));
|
|
75
|
+
flist.push(def);
|
|
76
|
+
this.total++;
|
|
77
|
+
this.bump(this.byKind, def.kind, 1);
|
|
78
|
+
this.bump(this.bySource, def.source, 1);
|
|
79
|
+
}
|
|
80
|
+
if (defs.length > 0) this.revision++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
removeFile(file: string): void {
|
|
84
|
+
const fkey = normFile(file);
|
|
85
|
+
const defs = this.byFile.get(fkey);
|
|
86
|
+
if (!defs) return;
|
|
87
|
+
this.byFile.delete(fkey);
|
|
88
|
+
for (const def of defs) {
|
|
89
|
+
this.total--;
|
|
90
|
+
this.bump(this.byKind, def.kind, -1);
|
|
91
|
+
this.bump(this.bySource, def.source, -1);
|
|
92
|
+
const list = this.byName.get(def.name);
|
|
93
|
+
if (!list) continue;
|
|
94
|
+
const filtered = list.filter((d) => d !== def);
|
|
95
|
+
if (filtered.length === 0) this.byName.delete(def.name);
|
|
96
|
+
else this.byName.set(def.name, filtered);
|
|
97
|
+
if (def.kind === TRACKED_KIND) {
|
|
98
|
+
const left = (this.trackedNames.get(def.name) ?? 0) - 1;
|
|
99
|
+
if (left <= 0) this.trackedNames.delete(def.name);
|
|
100
|
+
else this.trackedNames.set(def.name, left);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.revision++;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Only the highest-ranked source present: mod shadows parent shadows vanilla. */
|
|
107
|
+
private shadowResolve(list: Definition[]): Definition[] {
|
|
108
|
+
let best = -1;
|
|
109
|
+
for (const d of list) best = Math.max(best, SOURCE_RANK[d.source]);
|
|
110
|
+
return list.filter((d) => SOURCE_RANK[d.source] === best);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** All definitions for a name after shadow resolution. */
|
|
114
|
+
lookup(name: string): Definition[] {
|
|
115
|
+
const list = this.byName.get(name);
|
|
116
|
+
if (!list || list.length === 0) return [];
|
|
117
|
+
return this.shadowResolve(list);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** All definitions for a name, all sources (for "peek all"). */
|
|
121
|
+
lookupAll(name: string): Definition[] {
|
|
122
|
+
return this.byName.get(name) ?? [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Iterate one shadow-resolved definition per name, optionally filtered. */
|
|
126
|
+
*entries(filter?: (def: Definition) => boolean): IterableIterator<Definition> {
|
|
127
|
+
for (const list of this.byName.values()) {
|
|
128
|
+
// Apply the filter across ALL shadow-resolved defs of the name, not just
|
|
129
|
+
// the first: a name can carry several kinds (vanilla `brave` is a loc_key
|
|
130
|
+
// AND a trait), and a kind-filtered caller must still see the matching one.
|
|
131
|
+
const resolved = this.shadowResolve(list);
|
|
132
|
+
const def = filter ? resolved.find(filter) : resolved[0];
|
|
133
|
+
if (def) yield def;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Scripted-list definitions, shadow-resolved. Identical to
|
|
139
|
+
* `entries((d) => d.kind === "scripted_list")` — same shadow resolution over
|
|
140
|
+
* the full name list, so a mod loc_key still shadows a vanilla scripted list
|
|
141
|
+
* of the same name — but it only visits the names that carry one (§B2).
|
|
142
|
+
*/
|
|
143
|
+
*scriptedLists(): IterableIterator<Definition> {
|
|
144
|
+
for (const name of this.trackedNames.keys()) {
|
|
145
|
+
const list = this.byName.get(name);
|
|
146
|
+
if (!list || list.length === 0) continue;
|
|
147
|
+
const def = this.shadowResolve(list).find((d) => d.kind === TRACKED_KIND);
|
|
148
|
+
if (def) yield def;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Running totals, maintained by addAll/removeFile. The status notification
|
|
154
|
+
* asks for stats() on every index change, and one full walk of a 1.9M
|
|
155
|
+
* definition index cost 61-74 ms per save (§B2). Counters are dropped at
|
|
156
|
+
* zero so the result equals a full rebuild's, key for key.
|
|
157
|
+
*/
|
|
158
|
+
private total = 0;
|
|
159
|
+
private byKind = new Map<string, number>();
|
|
160
|
+
private bySource = new Map<string, number>();
|
|
161
|
+
|
|
162
|
+
private bump(counts: Map<string, number>, key: string, by: number): void {
|
|
163
|
+
const n = (counts.get(key) ?? 0) + by;
|
|
164
|
+
if (n <= 0) counts.delete(key);
|
|
165
|
+
else counts.set(key, n);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
stats(): IndexStats {
|
|
169
|
+
return {
|
|
170
|
+
total: this.total,
|
|
171
|
+
files: this.byFile.size,
|
|
172
|
+
byKind: Object.fromEntries(this.byKind),
|
|
173
|
+
bySource: Object.fromEntries(this.bySource),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
allDefinitions(): Definition[] {
|
|
178
|
+
const out: Definition[] = [];
|
|
179
|
+
for (const list of this.byFile.values()) pushAll(out, list);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Definitions in one file. */
|
|
184
|
+
inFile(file: string): Definition[] {
|
|
185
|
+
return this.byFile.get(normFile(file)) ?? [];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Which schema entry a file contributes to under `root`, or null if not indexed. */
|
|
190
|
+
export function classifyFile(
|
|
191
|
+
root: string,
|
|
192
|
+
file: string,
|
|
193
|
+
entries: SchemaEntry[] = activeProfile().schema
|
|
194
|
+
): SchemaEntry | null {
|
|
195
|
+
const rel = path.relative(root, file);
|
|
196
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
197
|
+
const relFwd = rel.split(path.sep).join("/").toLowerCase();
|
|
198
|
+
let best: SchemaEntry | null = null;
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
const ext = entry.ext ?? ".txt";
|
|
201
|
+
if (relFwd.startsWith(entry.path + "/") && relFwd.endsWith(ext)) {
|
|
202
|
+
// Longest path wins (common/culture/traditions over a hypothetical common/culture).
|
|
203
|
+
if (!best || entry.path.length > best.path.length) best = entry;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return best;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True if a localization file belongs to the configured language. */
|
|
210
|
+
export function isWantedLocFile(relPath: string, locLanguage: string): boolean {
|
|
211
|
+
const lower = relPath.toLowerCase().split(path.sep).join("/");
|
|
212
|
+
if (lower.includes(`l_${locLanguage}`)) return true;
|
|
213
|
+
return lower.split("/").includes(locLanguage);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function parseFile(file: string, entry: SchemaEntry, source: DefSource): Definition[] {
|
|
217
|
+
let content: string;
|
|
218
|
+
try {
|
|
219
|
+
content = fs.readFileSync(file, "utf8");
|
|
220
|
+
} catch {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
content = content.replace(/^/, "");
|
|
224
|
+
return extractDefinitions(content, entry, file, source);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ScanOptions {
|
|
228
|
+
locLanguage: string;
|
|
229
|
+
entries?: SchemaEntry[];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Scan every schema folder under `root` and return the definitions found. */
|
|
233
|
+
export function scanRoot(root: string, source: DefSource, opts: ScanOptions): Definition[] {
|
|
234
|
+
const defs: Definition[] = [];
|
|
235
|
+
for (const entry of opts.entries ?? activeProfile().schema) {
|
|
236
|
+
const dir = path.join(root, ...entry.path.split("/"));
|
|
237
|
+
const files: string[] = [];
|
|
238
|
+
walkDir(dir, entry.ext ?? ".txt", files);
|
|
239
|
+
for (const file of files) {
|
|
240
|
+
if (entry.kind === "loc_key" && !isWantedLocFile(path.relative(root, file), opts.locLanguage)) continue;
|
|
241
|
+
pushAll(defs, parseFile(file, entry, source));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return defs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Vanilla index cache (compact JSON, keyed by game version)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
// Bumped to 4 for §E doc/tags columns; a v3 cache is silently rejected and rebuilt.
|
|
252
|
+
// Bumped to 5 when engine-layer (jomini) definitions joined the vanilla scan;
|
|
253
|
+
// older caches lack them and must rebuild.
|
|
254
|
+
// Bumped to 6 when event files started contributing inline
|
|
255
|
+
// scripted_trigger/scripted_effect declarations (#5); older caches lack them.
|
|
256
|
+
const INDEX_CACHE_FORMAT = 6;
|
|
257
|
+
|
|
258
|
+
/** Compact "absent" marker inside cache rows. */
|
|
259
|
+
type Absent = 0;
|
|
260
|
+
|
|
261
|
+
/** Tags serialized as a compact array; absent → 0. */
|
|
262
|
+
type CachedTags = Array<[string, string]> | Absent;
|
|
263
|
+
|
|
264
|
+
interface IndexCacheFile {
|
|
265
|
+
cacheFormat: number;
|
|
266
|
+
gameVersion: string;
|
|
267
|
+
/** Kind string table (schema-driven, open set). */
|
|
268
|
+
kinds: string[];
|
|
269
|
+
files: string[];
|
|
270
|
+
// [name, kindIdx, fileIdx, line, value|0, paramsSpaceJoined|0, doc|0, tags|0]
|
|
271
|
+
defs: Array<
|
|
272
|
+
[string, number, number, number, string | Absent, string | Absent, string | Absent, CachedTags]
|
|
273
|
+
>;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Both halves of the cache round trip build ONE contiguous JSON string and block
|
|
278
|
+
* the event loop for its whole duration. Measured with 460k definitions (the
|
|
279
|
+
* vanilla tree per vitest.config.ts): a 26.3 MB file, 528 ms to save, 271 ms to
|
|
280
|
+
* load. That is the deliberate trade the compact row format above exists to
|
|
281
|
+
* make: the alternative it replaces is a multi-second rescan on every start.
|
|
282
|
+
*
|
|
283
|
+
* The ceiling is V8's ~512 MB single-string cap, roughly 20x the measured size.
|
|
284
|
+
* A tree that reached it throws RangeError, which both sides already degrade
|
|
285
|
+
* from: the caller of saveIndexCache logs and continues, loadIndexCache returns
|
|
286
|
+
* null and the caller rescans. So the failure mode is "no cache", not a crash.
|
|
287
|
+
* If the save or the load ever shows up in the perf trace, the answer is
|
|
288
|
+
* newline-delimited rows parsed in batches inside the scan's yieldNow rhythm.
|
|
289
|
+
*/
|
|
290
|
+
export function saveIndexCache(cacheFile: string, gameVersion: string, defs: Definition[]): void {
|
|
291
|
+
const kindIdx = new Map<string, number>();
|
|
292
|
+
const kinds: string[] = [];
|
|
293
|
+
const fileIdx = new Map<string, number>();
|
|
294
|
+
const files: string[] = [];
|
|
295
|
+
const rows: IndexCacheFile["defs"] = [];
|
|
296
|
+
for (const def of defs) {
|
|
297
|
+
let ki = kindIdx.get(def.kind);
|
|
298
|
+
if (ki === undefined) {
|
|
299
|
+
ki = kinds.length;
|
|
300
|
+
kinds.push(def.kind);
|
|
301
|
+
kindIdx.set(def.kind, ki);
|
|
302
|
+
}
|
|
303
|
+
let fi = fileIdx.get(def.file);
|
|
304
|
+
if (fi === undefined) {
|
|
305
|
+
fi = files.length;
|
|
306
|
+
files.push(def.file);
|
|
307
|
+
fileIdx.set(def.file, fi);
|
|
308
|
+
}
|
|
309
|
+
const tags: CachedTags = def.tags && def.tags.length > 0 ? def.tags.map((t) => [t.tag, t.text]) : 0;
|
|
310
|
+
rows.push([
|
|
311
|
+
def.name,
|
|
312
|
+
ki,
|
|
313
|
+
fi,
|
|
314
|
+
def.line,
|
|
315
|
+
def.value ?? 0,
|
|
316
|
+
def.params ? def.params.join(" ") : 0,
|
|
317
|
+
def.doc ?? 0,
|
|
318
|
+
tags,
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
321
|
+
const payload: IndexCacheFile = { cacheFormat: INDEX_CACHE_FORMAT, gameVersion, kinds, files, defs: rows };
|
|
322
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
323
|
+
fs.writeFileSync(cacheFile, JSON.stringify(payload));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function loadIndexCache(cacheFile: string, expectedGameVersion: string): Definition[] | null {
|
|
327
|
+
let payload: IndexCacheFile;
|
|
328
|
+
try {
|
|
329
|
+
payload = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (payload.cacheFormat !== INDEX_CACHE_FORMAT || payload.gameVersion !== expectedGameVersion) return null;
|
|
334
|
+
if (!Array.isArray(payload.files) || !Array.isArray(payload.defs) || !Array.isArray(payload.kinds))
|
|
335
|
+
return null;
|
|
336
|
+
const defs: Definition[] = [];
|
|
337
|
+
for (const [name, kindIdx, fileIdx, line, value, params, doc, tags] of payload.defs) {
|
|
338
|
+
const kind = payload.kinds[kindIdx];
|
|
339
|
+
const file = payload.files[fileIdx];
|
|
340
|
+
if (!kind || !file) return null;
|
|
341
|
+
const def: Definition = { name, kind, file, line, source: "vanilla" };
|
|
342
|
+
if (typeof value === "string") def.value = value;
|
|
343
|
+
if (typeof params === "string" && params !== "") def.params = params.split(" ");
|
|
344
|
+
if (typeof doc === "string" && doc !== "") def.doc = doc;
|
|
345
|
+
if (Array.isArray(tags) && tags.length > 0) def.tags = tags.map(([tag, text]) => ({ tag, text }));
|
|
346
|
+
defs.push(def);
|
|
347
|
+
}
|
|
348
|
+
return defs;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Game version used as the vanilla cache key: `rawVersion` from
|
|
353
|
+
* launcher/launcher-settings.json, falling back to the game folder mtime.
|
|
354
|
+
*/
|
|
355
|
+
export function detectGameVersion(gamePath: string): string {
|
|
356
|
+
try {
|
|
357
|
+
const launcherSettings = path.join(path.dirname(gamePath), "launcher", "launcher-settings.json");
|
|
358
|
+
const parsed = JSON.parse(fs.readFileSync(launcherSettings, "utf8"));
|
|
359
|
+
if (typeof parsed.rawVersion === "string" && parsed.rawVersion !== "") return parsed.rawVersion;
|
|
360
|
+
if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
|
|
361
|
+
} catch {
|
|
362
|
+
// fall through
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
return `mtime-${fs.statSync(gamePath).mtimeMs}`;
|
|
366
|
+
} catch {
|
|
367
|
+
return "unknown";
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String sharing for everything that escapes into the index (perf campaign §C2).
|
|
3
|
+
*
|
|
4
|
+
* The parser hands out `content.slice(start, end)` for every key and value, and
|
|
5
|
+
* V8 represents a substring of 13+ characters as a SlicedString that keeps its
|
|
6
|
+
* PARENT alive. One definition or reference name therefore pinned the whole
|
|
7
|
+
* file's text for as long as the index held it: the index was carrying a second
|
|
8
|
+
* copy of every tree it scanned. Retained heap, post-GC, before -> after:
|
|
9
|
+
*
|
|
10
|
+
* vanilla scan 462,886 defs 408 MB -> 193 MB
|
|
11
|
+
* AGOT scan 345,557 defs 429 MB -> 145 MB
|
|
12
|
+
* AGOT references 4,150,494 refs 1017 MB -> 590 MB
|
|
13
|
+
* game + AGOT x2 (both, in one) 3153 MB -> 1412 MB (budgets.test.ts §C1)
|
|
14
|
+
*
|
|
15
|
+
* The CPU it costs is inside noise on the definition scan (3.83 -> 3.73 s for
|
|
16
|
+
* vanilla) and under 10% on the reference scan (14.4 -> 15.2 s for AGOT).
|
|
17
|
+
*
|
|
18
|
+
* `copyOf` gives a string that owns its characters, so the parent goes away.
|
|
19
|
+
* `intern` also SHARES them, which is the bigger half on the reference side:
|
|
20
|
+
* the AGOT corpus names 4.15M usage sites with 197,873 distinct names.
|
|
21
|
+
*
|
|
22
|
+
* Identifiers (names, containers, params, key chains, namespaces) go through
|
|
23
|
+
* `intern`. Prose and loc values are near-unique per site, so they are copied
|
|
24
|
+
* but not kept in the table. `kind`, `file` and `source` are already one shared
|
|
25
|
+
* string per schema entry / file / literal and need neither.
|
|
26
|
+
*
|
|
27
|
+
* No `vscode` imports here: unit-tested in plain Node.
|
|
28
|
+
*/
|
|
29
|
+
import type { Definition, Reference } from "@px-lsp/protocol/types";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A fresh string that owns its characters. utf16le rather than utf8 because it
|
|
33
|
+
* round-trips every JS string exactly (lone surrogates included); V8 still
|
|
34
|
+
* stores the result one byte per character when it is ASCII.
|
|
35
|
+
*/
|
|
36
|
+
export function copyOf(s: string): string {
|
|
37
|
+
return Buffer.from(s, "utf16le").toString("utf16le");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let table = new Map<string, string>();
|
|
41
|
+
|
|
42
|
+
/** The shared copy of `s`, creating it on first sight. */
|
|
43
|
+
export function intern(s: string): string {
|
|
44
|
+
const hit = table.get(s);
|
|
45
|
+
if (hit !== undefined) return hit;
|
|
46
|
+
// Keyed by the COPY: keying by `s` would leave the table itself holding the
|
|
47
|
+
// sliced string, i.e. the file text this exists to release.
|
|
48
|
+
const copy = copyOf(s);
|
|
49
|
+
table.set(copy, copy);
|
|
50
|
+
return copy;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drop the shared copies. Called when the index is rebuilt from scratch: the
|
|
55
|
+
* old index's strings die with it, and keeping their table entries alive would
|
|
56
|
+
* retain a whole workspace's identifiers for nothing.
|
|
57
|
+
*/
|
|
58
|
+
export function resetInternTable(): void {
|
|
59
|
+
table = new Map();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Distinct identifiers currently shared (perf trace). */
|
|
63
|
+
export function internedCount(): number {
|
|
64
|
+
return table.size;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A definition `value` is either a short hint that repeats across the workspace
|
|
69
|
+
* (`chain:immediate`, a scripted list's `base`) or a loc line, which is unique.
|
|
70
|
+
*/
|
|
71
|
+
function shareValue(s: string): string {
|
|
72
|
+
return s.length <= 32 ? intern(s) : copyOf(s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Share the strings a batch of definitions will hold for the index's lifetime. */
|
|
76
|
+
export function shareDefinitionStrings(defs: Definition[]): Definition[] {
|
|
77
|
+
for (const def of defs) {
|
|
78
|
+
def.name = intern(def.name);
|
|
79
|
+
if (def.value !== undefined) def.value = shareValue(def.value);
|
|
80
|
+
if (def.container !== undefined) def.container = intern(def.container);
|
|
81
|
+
if (def.params) for (let i = 0; i < def.params.length; i++) def.params[i] = intern(def.params[i]);
|
|
82
|
+
if (def.doc !== undefined) def.doc = copyOf(def.doc);
|
|
83
|
+
if (def.tags) {
|
|
84
|
+
for (const tag of def.tags) {
|
|
85
|
+
tag.tag = intern(tag.tag);
|
|
86
|
+
tag.text = copyOf(tag.text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (def.entryMode !== undefined) def.entryMode = intern(def.entryMode);
|
|
90
|
+
}
|
|
91
|
+
return defs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Share the strings a batch of references will hold for the index's lifetime. */
|
|
95
|
+
export function shareReferenceStrings(refs: Reference[]): Reference[] {
|
|
96
|
+
for (const ref of refs) {
|
|
97
|
+
ref.name = intern(ref.name);
|
|
98
|
+
if (ref.chain !== undefined) ref.chain = intern(ref.chain);
|
|
99
|
+
}
|
|
100
|
+
return refs;
|
|
101
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand reference search over roots that are NOT reference-indexed up
|
|
3
|
+
* front: vanilla and read-only dependency parents (rework plan AD-4). Keeping
|
|
4
|
+
* those out of the persistent ReferenceIndex is a memory guard (vanilla alone
|
|
5
|
+
* holds hundreds of thousands of usage sites); find-references still has to
|
|
6
|
+
* SHOW them (#3), so this scanner greps the roots for one name at a time.
|
|
7
|
+
*
|
|
8
|
+
* The scan is textual (exact-token match per line, comments stripped), not
|
|
9
|
+
* schema-driven: script values, trigger/effect arguments and loc-key values
|
|
10
|
+
* are all real usage sites even where the schema has no ref-field for the
|
|
11
|
+
* enclosing key. Column-0 keys are skipped — those are the definition sites,
|
|
12
|
+
* which the provider appends separately under includeDeclaration.
|
|
13
|
+
*
|
|
14
|
+
* Costs are bounded three ways: the per-root file list is enumerated once,
|
|
15
|
+
* lines are tokenized only when a cheap substring test hits, and results are
|
|
16
|
+
* memoized per name until the roots change. First lookup of a name pays one
|
|
17
|
+
* pass of disk reads; repeats are O(1).
|
|
18
|
+
*
|
|
19
|
+
* No `vscode` imports here: unit-tested in plain Node.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
import type { DefSource, Reference } from "@px-lsp/protocol/types";
|
|
23
|
+
import { listFiles } from "@px-lsp/protocol/fsWalk";
|
|
24
|
+
import { isStructuralKeyword } from "../contextKeywords";
|
|
25
|
+
|
|
26
|
+
export interface LazyRefRoot {
|
|
27
|
+
root: string;
|
|
28
|
+
source: DefSource;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Memoized names kept per roots generation (FIFO eviction). */
|
|
32
|
+
const CACHE_CAP = 64;
|
|
33
|
+
/** Files read per event-loop turn. */
|
|
34
|
+
const BATCH = 200;
|
|
35
|
+
/** Result cap per name: protects the peek widget and memory from a lookup on
|
|
36
|
+
* a near-universal name; real identifiers stay far below it. */
|
|
37
|
+
const MAX_REFS = 5000;
|
|
38
|
+
|
|
39
|
+
const NAME_OK = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
|
|
40
|
+
/** Identifier characters: a hit is a token only when NOT flanked by these. */
|
|
41
|
+
const IDENT = /[A-Za-z0-9_.-]/;
|
|
42
|
+
|
|
43
|
+
const yieldNow = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
44
|
+
|
|
45
|
+
export class LazyReferenceScanner {
|
|
46
|
+
private roots: LazyRefRoot[] = [];
|
|
47
|
+
private isEngineToken?: (name: string) => boolean;
|
|
48
|
+
private fileLists = new Map<string, string[]>();
|
|
49
|
+
private cache = new Map<string, Promise<Reference[]>>();
|
|
50
|
+
|
|
51
|
+
/** Reconfigure (on reindex/settings change): drops all memoized results. */
|
|
52
|
+
setRoots(roots: LazyRefRoot[], isEngineToken?: (name: string) => boolean): void {
|
|
53
|
+
this.roots = roots;
|
|
54
|
+
this.isEngineToken = isEngineToken;
|
|
55
|
+
this.fileLists.clear();
|
|
56
|
+
this.cache.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** All usage sites of `name` across the configured roots. Engine tokens and
|
|
60
|
+
* structural grammar keywords are excluded: they appear in nearly every
|
|
61
|
+
* file, and their documentation comes from script_docs, not the index.
|
|
62
|
+
* (isStructuralKeyword, not classifyKeyword: the latter's naming-convention
|
|
63
|
+
* buckets would swallow every *_trigger-named scripted trigger, #5.) */
|
|
64
|
+
lookup(name: string): Promise<Reference[]> {
|
|
65
|
+
if (
|
|
66
|
+
this.roots.length === 0 ||
|
|
67
|
+
name.length < 2 ||
|
|
68
|
+
!NAME_OK.test(name) ||
|
|
69
|
+
isStructuralKeyword(name) ||
|
|
70
|
+
this.isEngineToken?.(name)
|
|
71
|
+
) {
|
|
72
|
+
return Promise.resolve([]);
|
|
73
|
+
}
|
|
74
|
+
let pending = this.cache.get(name);
|
|
75
|
+
if (!pending) {
|
|
76
|
+
pending = this.scan(name);
|
|
77
|
+
// FIFO eviction: Map iteration order is insertion order.
|
|
78
|
+
if (this.cache.size >= CACHE_CAP) {
|
|
79
|
+
const oldest = this.cache.keys().next().value;
|
|
80
|
+
if (oldest !== undefined) this.cache.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
this.cache.set(name, pending);
|
|
83
|
+
}
|
|
84
|
+
return pending;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private filesOf(root: string): string[] {
|
|
88
|
+
let files = this.fileLists.get(root);
|
|
89
|
+
if (!files) this.fileLists.set(root, (files = listFiles(root, ".txt")));
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async scan(name: string): Promise<Reference[]> {
|
|
94
|
+
const out: Reference[] = [];
|
|
95
|
+
for (const { root } of this.roots) {
|
|
96
|
+
const files = this.filesOf(root);
|
|
97
|
+
for (let i = 0; i < files.length; i += BATCH) {
|
|
98
|
+
for (const file of files.slice(i, i + BATCH)) {
|
|
99
|
+
let content: string;
|
|
100
|
+
try {
|
|
101
|
+
content = fs.readFileSync(file, "utf8");
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!content.includes(name)) continue;
|
|
106
|
+
scanContent(content, name, file, out);
|
|
107
|
+
if (out.length >= MAX_REFS) return out.slice(0, MAX_REFS);
|
|
108
|
+
}
|
|
109
|
+
await yieldNow();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Exact-token occurrences of `name` in `content`, appended to `out`. */
|
|
117
|
+
export function scanContent(content: string, name: string, file: string, out: Reference[]): void {
|
|
118
|
+
const lines = content.split("\n");
|
|
119
|
+
for (let lineNo = 0; lineNo < lines.length; lineNo++) {
|
|
120
|
+
let line = lines[lineNo];
|
|
121
|
+
if (!line.includes(name)) continue;
|
|
122
|
+
// Strip comments; # inside a string is rare enough to accept the miss.
|
|
123
|
+
const hash = line.indexOf("#");
|
|
124
|
+
if (hash >= 0) line = line.slice(0, hash);
|
|
125
|
+
let from = 0;
|
|
126
|
+
for (;;) {
|
|
127
|
+
const at = line.indexOf(name, from);
|
|
128
|
+
if (at < 0) break;
|
|
129
|
+
from = at + name.length;
|
|
130
|
+
if (at > 0 && IDENT.test(line[at - 1])) continue;
|
|
131
|
+
const after = line[at + name.length];
|
|
132
|
+
if (after !== undefined && IDENT.test(after)) continue;
|
|
133
|
+
// Column-0 keys are definition sites (top-level assignments), not uses.
|
|
134
|
+
if (at === 0) continue;
|
|
135
|
+
out.push({
|
|
136
|
+
name,
|
|
137
|
+
kinds: [],
|
|
138
|
+
file,
|
|
139
|
+
line: lineNo,
|
|
140
|
+
startChar: at,
|
|
141
|
+
endChar: at + name.length,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin labels for definitions: the owning mod's display name (descriptor.mod
|
|
3
|
+
* `name=`) instead of the generic "mod"/"parent" source tag, so a hover in a
|
|
4
|
+
* multi-mod workspace says WHICH of the 20 open mods a definition comes from.
|
|
5
|
+
*
|
|
6
|
+
* No `vscode` imports: unit-tested in plain Node.
|
|
7
|
+
*/
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { readModName } from "@px-lsp/protocol/modName";
|
|
10
|
+
|
|
11
|
+
/** Hover head lines stay compact: clip pathological descriptor names. */
|
|
12
|
+
const MAX_LABEL = 40;
|
|
13
|
+
|
|
14
|
+
function clip(name: string): string {
|
|
15
|
+
return name.length > MAX_LABEL ? `${name.slice(0, MAX_LABEL - 1)}…` : name;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ModOriginResolver {
|
|
19
|
+
/** Longest prefix first, so a mod nested under another root wins. */
|
|
20
|
+
private roots: Array<{ root: string; prefix: string; label: string }> = [];
|
|
21
|
+
|
|
22
|
+
/** `modRoots`: every mod root (primary mod, workspace mods, parents).
|
|
23
|
+
* Descriptor names are read once here, not per lookup. */
|
|
24
|
+
setRoots(modRoots: string[]): void {
|
|
25
|
+
this.roots = modRoots
|
|
26
|
+
.map((root) => ({
|
|
27
|
+
root,
|
|
28
|
+
prefix:
|
|
29
|
+
path
|
|
30
|
+
.normalize(root)
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[\\/]+$/, "") + path.sep,
|
|
33
|
+
label: clip(readModName(root)),
|
|
34
|
+
}))
|
|
35
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private entryFor(file: string): { root: string; prefix: string; label: string } | null {
|
|
39
|
+
if (!file || !path.isAbsolute(file)) return null;
|
|
40
|
+
const lower = path.normalize(file).toLowerCase();
|
|
41
|
+
for (const r of this.roots) {
|
|
42
|
+
if (lower.startsWith(r.prefix)) return r;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Display label for a definition's origin. Falls back to `source` when the
|
|
48
|
+
* file lies under no known mod root (vanilla, engine, synthetic defs). */
|
|
49
|
+
labelFor(file: string, source: string): string {
|
|
50
|
+
return this.entryFor(file)?.label ?? source;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The mod root (as configured) a file lives under, or null. */
|
|
54
|
+
rootFor(file: string): string | null {
|
|
55
|
+
return this.entryFor(file)?.root ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Display label for a known mod root ("Mod Alpha" for its path). */
|
|
59
|
+
labelForRoot(root: string): string {
|
|
60
|
+
const lower = path
|
|
61
|
+
.normalize(root)
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[\\/]+$/, "");
|
|
64
|
+
for (const r of this.roots) {
|
|
65
|
+
if (r.prefix === lower + path.sep) return r.label;
|
|
66
|
+
}
|
|
67
|
+
return clip(readModName(root));
|
|
68
|
+
}
|
|
69
|
+
}
|