@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
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();
|