@px-lsp/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- package/src/wordAt.ts +49 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Parser for Paradox's localization "YAML" dialect (it is NOT real YAML).
|
|
2
|
+
//
|
|
3
|
+
// Shape:
|
|
4
|
+
// l_english:
|
|
5
|
+
// key:0 "value with $variables$, [GetPlayer.GetName], #bold#!, £gold£"
|
|
6
|
+
// other_key: "no version number" # trailing comment
|
|
7
|
+
//
|
|
8
|
+
// Never throws. Offsets are UTF-16 code-unit offsets into the ORIGINAL text
|
|
9
|
+
// (including the BOM, if present) so ranges map back onto the source buffer.
|
|
10
|
+
|
|
11
|
+
import { Range } from "./cst.js";
|
|
12
|
+
|
|
13
|
+
export interface LocEntry {
|
|
14
|
+
key: string;
|
|
15
|
+
keyRange: Range;
|
|
16
|
+
version: number | null;
|
|
17
|
+
value: string; // text between the opening quote and the LAST quote on the line, verbatim
|
|
18
|
+
valueRange: Range; // covers the text inside the quotes
|
|
19
|
+
line: number; // 0-based
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LocErrorCode =
|
|
23
|
+
"no-header" | "bad-entry" | "tab-indent" | "unterminated-value" | "content-before-header";
|
|
24
|
+
|
|
25
|
+
export interface LocError {
|
|
26
|
+
code: LocErrorCode;
|
|
27
|
+
message: string;
|
|
28
|
+
range: Range;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LocParseResult {
|
|
32
|
+
language: string | null; // "english" from `l_english:`; null if none found
|
|
33
|
+
headerRange: Range | null;
|
|
34
|
+
entries: LocEntry[];
|
|
35
|
+
errors: LocError[];
|
|
36
|
+
hadBom: boolean; // text started with the UTF-8 BOM char U+FEFF
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A header line: optional leading whitespace, `l_<name>:`, then only trailing
|
|
40
|
+
// whitespace / comment.
|
|
41
|
+
const HEADER_RE = /^[ \t]*l_([A-Za-z_]+):[ \t]*(#.*)?$/;
|
|
42
|
+
|
|
43
|
+
// Key characters: letters, digits, `_ . - '`.
|
|
44
|
+
function isKeyChar(c: string): boolean {
|
|
45
|
+
return (
|
|
46
|
+
(c >= "a" && c <= "z") ||
|
|
47
|
+
(c >= "A" && c <= "Z") ||
|
|
48
|
+
(c >= "0" && c <= "9") ||
|
|
49
|
+
c === "_" ||
|
|
50
|
+
c === "." ||
|
|
51
|
+
c === "-" ||
|
|
52
|
+
c === "'"
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseLoc(text: string): LocParseResult {
|
|
57
|
+
const hadBom = text.length > 0 && text.charCodeAt(0) === 0xfeff;
|
|
58
|
+
// Work on the raw text but skip the BOM offset when scanning line content.
|
|
59
|
+
// Offsets we emit are relative to the ORIGINAL text.
|
|
60
|
+
const entries: LocEntry[] = [];
|
|
61
|
+
const errors: LocError[] = [];
|
|
62
|
+
let language: string | null = null;
|
|
63
|
+
let headerRange: Range | null = null;
|
|
64
|
+
|
|
65
|
+
const len = text.length;
|
|
66
|
+
let lineStart = 0;
|
|
67
|
+
let lineNo = 0;
|
|
68
|
+
let headerFound = false;
|
|
69
|
+
|
|
70
|
+
while (lineStart <= len) {
|
|
71
|
+
// Find end of this line (exclusive of the newline).
|
|
72
|
+
let lineEnd = lineStart;
|
|
73
|
+
while (lineEnd < len) {
|
|
74
|
+
const cc = text.charCodeAt(lineEnd);
|
|
75
|
+
if (cc === 10 || cc === 13) break;
|
|
76
|
+
lineEnd++;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Compute where the next line starts (handle \r\n).
|
|
80
|
+
let nextStart = lineEnd;
|
|
81
|
+
if (nextStart < len) {
|
|
82
|
+
if (text.charCodeAt(nextStart) === 13 && nextStart + 1 < len && text.charCodeAt(nextStart + 1) === 10) {
|
|
83
|
+
nextStart += 2;
|
|
84
|
+
} else {
|
|
85
|
+
nextStart += 1;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
nextStart = len + 1; // ensure the loop terminates after the last line
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Slice the line; strip a leading BOM on the very first line.
|
|
92
|
+
let sliceStart = lineStart;
|
|
93
|
+
if (lineNo === 0 && hadBom && text.charCodeAt(sliceStart) === 0xfeff) {
|
|
94
|
+
sliceStart += 1;
|
|
95
|
+
}
|
|
96
|
+
const rawLine = text.slice(sliceStart, lineEnd);
|
|
97
|
+
|
|
98
|
+
processLine(rawLine, sliceStart);
|
|
99
|
+
|
|
100
|
+
lineStart = nextStart;
|
|
101
|
+
lineNo++;
|
|
102
|
+
if (lineStart > len) break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!headerFound) {
|
|
106
|
+
errors.push({
|
|
107
|
+
code: "no-header",
|
|
108
|
+
message: "No localization header line (e.g. `l_english:`) found.",
|
|
109
|
+
range: { start: hadBom ? 1 : 0, end: hadBom ? 1 : 0 },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { language, headerRange, entries, errors, hadBom };
|
|
114
|
+
|
|
115
|
+
// ---- inner helpers (closures over the accumulators) ----
|
|
116
|
+
|
|
117
|
+
function processLine(line: string, base: number): void {
|
|
118
|
+
// base = offset in `text` of `line[0]`.
|
|
119
|
+
// Determine indentation and whether tabs were used for it.
|
|
120
|
+
let i = 0;
|
|
121
|
+
let sawTab = false;
|
|
122
|
+
while (i < line.length) {
|
|
123
|
+
const ch = line[i];
|
|
124
|
+
if (ch === " ") {
|
|
125
|
+
i++;
|
|
126
|
+
} else if (ch === "\t") {
|
|
127
|
+
sawTab = true;
|
|
128
|
+
i++;
|
|
129
|
+
} else {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const contentStart = i; // index within `line`
|
|
135
|
+
// Blank line?
|
|
136
|
+
if (contentStart >= line.length) return;
|
|
137
|
+
// Comment-only line?
|
|
138
|
+
if (line[contentStart] === "#") return;
|
|
139
|
+
|
|
140
|
+
const trimmed = line.slice(contentStart);
|
|
141
|
+
|
|
142
|
+
// Header?
|
|
143
|
+
if (!headerFound) {
|
|
144
|
+
const m = HEADER_RE.exec(line);
|
|
145
|
+
if (m) {
|
|
146
|
+
headerFound = true;
|
|
147
|
+
language = m[1];
|
|
148
|
+
// headerRange covers the `l_<name>:` token.
|
|
149
|
+
const idx = line.indexOf("l_");
|
|
150
|
+
const start = base + idx;
|
|
151
|
+
const colon = line.indexOf(":", idx);
|
|
152
|
+
const end = base + (colon >= 0 ? colon + 1 : line.length);
|
|
153
|
+
headerRange = { start, end };
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Non-blank, non-comment content before the header.
|
|
157
|
+
errors.push({
|
|
158
|
+
code: "content-before-header",
|
|
159
|
+
message: "Content appears before the localization header (e.g. `l_english:`).",
|
|
160
|
+
range: { start: base + contentStart, end: base + line.length },
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A second header-looking line after the first is just ignored as content;
|
|
166
|
+
// but if it matches the header pattern we skip it silently.
|
|
167
|
+
if (HEADER_RE.test(line)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Tab indentation is rejected by the game.
|
|
172
|
+
if (sawTab) {
|
|
173
|
+
errors.push({
|
|
174
|
+
code: "tab-indent",
|
|
175
|
+
message: "Tabs are not allowed for indentation in localization files.",
|
|
176
|
+
range: { start: base, end: base + contentStart },
|
|
177
|
+
});
|
|
178
|
+
// Continue trying to parse the entry anyway (best-effort).
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Parse an entry: key (:version)? "value"
|
|
182
|
+
parseEntry(trimmed, base + contentStart, line, base);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseEntry(entryText: string, entryBase: number, fullLine: string, lineBase: number): void {
|
|
186
|
+
// entryBase = offset in `text` of entryText[0].
|
|
187
|
+
let j = 0;
|
|
188
|
+
// Read key.
|
|
189
|
+
while (j < entryText.length && isKeyChar(entryText[j])) j++;
|
|
190
|
+
if (j === 0) {
|
|
191
|
+
badEntry(fullLine, lineBase);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const key = entryText.slice(0, j);
|
|
195
|
+
const keyRange: Range = { start: entryBase, end: entryBase + j };
|
|
196
|
+
|
|
197
|
+
// Expect a colon.
|
|
198
|
+
if (j >= entryText.length || entryText[j] !== ":") {
|
|
199
|
+
badEntry(fullLine, lineBase);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
j++; // consume ':'
|
|
203
|
+
|
|
204
|
+
// Optional version number (digits).
|
|
205
|
+
let version: number | null = null;
|
|
206
|
+
const verStart = j;
|
|
207
|
+
while (j < entryText.length && entryText[j] >= "0" && entryText[j] <= "9") {
|
|
208
|
+
j++;
|
|
209
|
+
}
|
|
210
|
+
if (j > verStart) {
|
|
211
|
+
version = Number(entryText.slice(verStart, j));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Skip whitespace before the opening quote.
|
|
215
|
+
while (j < entryText.length && (entryText[j] === " " || entryText[j] === "\t")) {
|
|
216
|
+
j++;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Expect opening quote.
|
|
220
|
+
if (j >= entryText.length || entryText[j] !== '"') {
|
|
221
|
+
badEntry(fullLine, lineBase);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const quoteOpen = j;
|
|
225
|
+
const valueInnerStart = entryBase + quoteOpen + 1;
|
|
226
|
+
|
|
227
|
+
// The game closes the value at the LAST quote on the line. Inner quotes
|
|
228
|
+
// are literal — vanilla wraps direct speech as `""…""` and embeds bare
|
|
229
|
+
// quotes mid-value without escaping — so scanning to the first close would
|
|
230
|
+
// read those values as empty/truncated.
|
|
231
|
+
const quoteClose = entryText.lastIndexOf('"');
|
|
232
|
+
|
|
233
|
+
if (quoteClose === quoteOpen) {
|
|
234
|
+
errors.push({
|
|
235
|
+
code: "unterminated-value",
|
|
236
|
+
message: "Unterminated localization value (missing closing quote).",
|
|
237
|
+
range: {
|
|
238
|
+
start: entryBase + quoteOpen,
|
|
239
|
+
end: lineBase + fullLine.length,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
// Still record the entry with whatever value we captured.
|
|
243
|
+
const valueInnerEnd = entryBase + entryText.length;
|
|
244
|
+
entries.push({
|
|
245
|
+
key,
|
|
246
|
+
keyRange,
|
|
247
|
+
version,
|
|
248
|
+
value: entryText.slice(quoteOpen + 1),
|
|
249
|
+
valueRange: { start: valueInnerStart, end: valueInnerEnd },
|
|
250
|
+
line: lineNo,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const valueInnerEnd = entryBase + quoteClose;
|
|
256
|
+
const value = entryText.slice(quoteOpen + 1, quoteClose);
|
|
257
|
+
// Anything after the closing quote is ignored (trailing comments etc.).
|
|
258
|
+
|
|
259
|
+
entries.push({
|
|
260
|
+
key,
|
|
261
|
+
keyRange,
|
|
262
|
+
version,
|
|
263
|
+
value,
|
|
264
|
+
valueRange: { start: valueInnerStart, end: valueInnerEnd },
|
|
265
|
+
line: lineNo,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function badEntry(fullLine: string, lineBase: number): void {
|
|
270
|
+
errors.push({
|
|
271
|
+
code: "bad-entry",
|
|
272
|
+
message: 'Malformed localization entry; expected `key: "value"`.',
|
|
273
|
+
range: { start: lineBase, end: lineBase + fullLine.length },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Recursive-descent, error-tolerant parser for Paradox script.
|
|
2
|
+
//
|
|
3
|
+
// Always produces a usable CST plus a list of structural errors. Never throws.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
AssignmentNode,
|
|
7
|
+
BlockNode,
|
|
8
|
+
CommentNode,
|
|
9
|
+
Operator,
|
|
10
|
+
ParseError,
|
|
11
|
+
ParseResult,
|
|
12
|
+
RootNode,
|
|
13
|
+
ScalarNode,
|
|
14
|
+
Statement,
|
|
15
|
+
TaggedBlockNode,
|
|
16
|
+
ValueNode,
|
|
17
|
+
ValueStatementNode,
|
|
18
|
+
} from "./cst.js";
|
|
19
|
+
import { Token, tokenize } from "./lexer.js";
|
|
20
|
+
// Display prose only (game name in the unclosed-brace hint) — the parser
|
|
21
|
+
// itself stays game-agnostic.
|
|
22
|
+
import { activeProfile } from "../games/active";
|
|
23
|
+
|
|
24
|
+
const OPERATORS: ReadonlySet<string> = new Set(["=", "?=", "==", "!=", "<", "<=", ">", ">="]);
|
|
25
|
+
|
|
26
|
+
class Parser {
|
|
27
|
+
private readonly text: string;
|
|
28
|
+
private readonly tokens: Token[];
|
|
29
|
+
private pos = 0;
|
|
30
|
+
private readonly errors: ParseError[] = [];
|
|
31
|
+
private readonly comments: CommentNode[] = [];
|
|
32
|
+
|
|
33
|
+
constructor(text: string) {
|
|
34
|
+
this.text = text;
|
|
35
|
+
this.tokens = tokenize(text);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
parse(): ParseResult {
|
|
39
|
+
const statements: Statement[] = [];
|
|
40
|
+
while (!this.atEof()) {
|
|
41
|
+
const tok = this.peek();
|
|
42
|
+
if (tok.kind === "rbrace") {
|
|
43
|
+
// Stray close at root level.
|
|
44
|
+
this.errors.push({
|
|
45
|
+
code: "stray-close",
|
|
46
|
+
message: "Unexpected '}' with no matching open brace.",
|
|
47
|
+
range: { start: tok.start, end: tok.end },
|
|
48
|
+
});
|
|
49
|
+
this.advance();
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const stmt = this.parseStatement();
|
|
53
|
+
if (stmt) {
|
|
54
|
+
statements.push(stmt);
|
|
55
|
+
} else if (!this.atEof()) {
|
|
56
|
+
// Defensive: nothing consumed — skip a token to guarantee progress.
|
|
57
|
+
this.advance();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root: RootNode = {
|
|
62
|
+
kind: "root",
|
|
63
|
+
statements,
|
|
64
|
+
range: { start: 0, end: this.text.length },
|
|
65
|
+
};
|
|
66
|
+
return { root, errors: this.errors, comments: this.comments };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- token stream helpers (skip comments transparently) ---
|
|
70
|
+
|
|
71
|
+
private peek(): Token {
|
|
72
|
+
this.skipComments();
|
|
73
|
+
return this.tokens[this.pos];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private peekAhead(): Token {
|
|
77
|
+
// Peek the token AFTER the current significant token, skipping comments.
|
|
78
|
+
this.skipComments();
|
|
79
|
+
let j = this.pos + 1;
|
|
80
|
+
while (j < this.tokens.length && this.tokens[j].kind === "comment") j++;
|
|
81
|
+
return this.tokens[j] ?? this.tokens[this.tokens.length - 1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private advance(): Token {
|
|
85
|
+
this.skipComments();
|
|
86
|
+
const t = this.tokens[this.pos];
|
|
87
|
+
if (this.pos < this.tokens.length - 1) this.pos++;
|
|
88
|
+
return t;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private skipComments(): void {
|
|
92
|
+
while (this.pos < this.tokens.length && this.tokens[this.pos].kind === "comment") {
|
|
93
|
+
const c = this.tokens[this.pos];
|
|
94
|
+
this.comments.push({
|
|
95
|
+
text: this.text.slice(c.start, c.end),
|
|
96
|
+
range: { start: c.start, end: c.end },
|
|
97
|
+
line: this.lineAt(c.start),
|
|
98
|
+
});
|
|
99
|
+
this.pos++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Lazily-built newline offset table for cheap 0-based line lookups.
|
|
104
|
+
private newlineOffsets: number[] | null = null;
|
|
105
|
+
|
|
106
|
+
private lineAt(offset: number): number {
|
|
107
|
+
if (this.newlineOffsets === null) {
|
|
108
|
+
const nl: number[] = [];
|
|
109
|
+
for (let k = 0; k < this.text.length; k++) {
|
|
110
|
+
if (this.text.charCodeAt(k) === 10 /* \n */) nl.push(k);
|
|
111
|
+
}
|
|
112
|
+
this.newlineOffsets = nl;
|
|
113
|
+
}
|
|
114
|
+
const nl = this.newlineOffsets;
|
|
115
|
+
// Count newlines strictly before `offset` via binary search.
|
|
116
|
+
let lo = 0;
|
|
117
|
+
let hi = nl.length;
|
|
118
|
+
while (lo < hi) {
|
|
119
|
+
const mid = (lo + hi) >> 1;
|
|
120
|
+
if (nl[mid] < offset) lo = mid + 1;
|
|
121
|
+
else hi = mid;
|
|
122
|
+
}
|
|
123
|
+
return lo;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private atEof(): boolean {
|
|
127
|
+
this.skipComments();
|
|
128
|
+
return this.tokens[this.pos].kind === "eof";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- grammar ---
|
|
132
|
+
|
|
133
|
+
// statement := key (op value | block)? | value
|
|
134
|
+
private parseStatement(): Statement | null {
|
|
135
|
+
const tok = this.peek();
|
|
136
|
+
|
|
137
|
+
if (tok.kind === "eof" || tok.kind === "rbrace") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A block starting here is an anonymous list element (ValueStatementNode).
|
|
142
|
+
if (tok.kind === "lbrace") {
|
|
143
|
+
const block = this.parseBlock();
|
|
144
|
+
const vs: ValueStatementNode = {
|
|
145
|
+
kind: "value",
|
|
146
|
+
value: block,
|
|
147
|
+
range: { ...block.range },
|
|
148
|
+
};
|
|
149
|
+
return vs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Otherwise the statement starts with a scalar (word or string).
|
|
153
|
+
if (tok.kind === "word" || tok.kind === "string") {
|
|
154
|
+
const key = this.makeScalar(this.advance());
|
|
155
|
+
const next = this.peek();
|
|
156
|
+
|
|
157
|
+
// key op value
|
|
158
|
+
if (next.kind === "op") {
|
|
159
|
+
const opTok = this.advance();
|
|
160
|
+
const op = opTok.value as Operator;
|
|
161
|
+
const value = this.parseValueAfterOperator();
|
|
162
|
+
if (value === null) {
|
|
163
|
+
this.errors.push({
|
|
164
|
+
code: "missing-value",
|
|
165
|
+
message: `Missing value after '${op}'.`,
|
|
166
|
+
range: { start: opTok.start, end: opTok.end },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const end = value ? value.range.end : opTok.end;
|
|
170
|
+
const assign: AssignmentNode = {
|
|
171
|
+
kind: "assignment",
|
|
172
|
+
key,
|
|
173
|
+
op,
|
|
174
|
+
value,
|
|
175
|
+
range: { start: key.range.start, end },
|
|
176
|
+
};
|
|
177
|
+
return assign;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// key block (GUI style, no operator) — e.g. `template MyT { ... }`
|
|
181
|
+
if (next.kind === "lbrace") {
|
|
182
|
+
const block = this.parseBlock();
|
|
183
|
+
const assign: AssignmentNode = {
|
|
184
|
+
kind: "assignment",
|
|
185
|
+
key,
|
|
186
|
+
op: null,
|
|
187
|
+
value: block,
|
|
188
|
+
range: { start: key.range.start, end: block.range.end },
|
|
189
|
+
};
|
|
190
|
+
return assign;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Bare scalar — list element.
|
|
194
|
+
const vs: ValueStatementNode = {
|
|
195
|
+
kind: "value",
|
|
196
|
+
value: key,
|
|
197
|
+
range: { ...key.range },
|
|
198
|
+
};
|
|
199
|
+
return vs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Unexpected token kind (shouldn't normally happen). Consume and skip.
|
|
203
|
+
this.advance();
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// value := scalar | block | scalar-immediately-followed-by-`{` (TaggedBlock)
|
|
208
|
+
// Used after an operator. Returns null if no value is parseable.
|
|
209
|
+
private parseValueAfterOperator(): ValueNode | null {
|
|
210
|
+
const tok = this.peek();
|
|
211
|
+
|
|
212
|
+
if (tok.kind === "lbrace") {
|
|
213
|
+
return this.parseBlock();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// `OPERATOR = <=` and friends: a comparison operator used as a VALUE.
|
|
217
|
+
// Vanilla uses this in list/any triggers (RELATION/OPERATOR/COUNT blocks).
|
|
218
|
+
// Accept the operator token as a bare scalar value.
|
|
219
|
+
if (tok.kind === "op") {
|
|
220
|
+
const opTok = this.advance();
|
|
221
|
+
return {
|
|
222
|
+
kind: "scalar",
|
|
223
|
+
text: this.text.slice(opTok.start, opTok.end),
|
|
224
|
+
quoted: false,
|
|
225
|
+
range: { start: opTok.start, end: opTok.end },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (tok.kind === "word" || tok.kind === "string") {
|
|
230
|
+
// Disambiguate `key =\n nextKey = ...`: if the scalar here is immediately
|
|
231
|
+
// followed by an operator, it is the NEXT statement's key, not this
|
|
232
|
+
// value. Treat the current value as missing so the next statement parses.
|
|
233
|
+
// But `word op word` where the trailing op is a value (rare) is handled
|
|
234
|
+
// by the caller re-entering; here we only bail when the following token
|
|
235
|
+
// is an operator AND the current scalar is a plausible key.
|
|
236
|
+
const after = this.peekAhead();
|
|
237
|
+
if (after.kind === "op") {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const scalar = this.makeScalar(this.advance());
|
|
241
|
+
// Tagged block: scalar immediately followed by `{`.
|
|
242
|
+
const next = this.peek();
|
|
243
|
+
if (next.kind === "lbrace") {
|
|
244
|
+
const block = this.parseBlock();
|
|
245
|
+
const tb: TaggedBlockNode = {
|
|
246
|
+
kind: "tagged-block",
|
|
247
|
+
tag: scalar,
|
|
248
|
+
block,
|
|
249
|
+
range: { start: scalar.range.start, end: block.range.end },
|
|
250
|
+
};
|
|
251
|
+
return tb;
|
|
252
|
+
}
|
|
253
|
+
return scalar;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// op / rbrace / eof → no value.
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// block := `{` statement* `}`
|
|
261
|
+
private parseBlock(): BlockNode {
|
|
262
|
+
const open = this.advance(); // consume `{`
|
|
263
|
+
const openBrace = open.start;
|
|
264
|
+
const statements: Statement[] = [];
|
|
265
|
+
let closeBrace: number | null = null;
|
|
266
|
+
let end = open.end;
|
|
267
|
+
|
|
268
|
+
while (true) {
|
|
269
|
+
const tok = this.peek();
|
|
270
|
+
if (tok.kind === "eof") {
|
|
271
|
+
// Unclosed block: report at the opening brace, swallow the rest.
|
|
272
|
+
this.errors.push({
|
|
273
|
+
code: "unclosed-brace",
|
|
274
|
+
message:
|
|
275
|
+
"Unclosed '{': the rest of the file is swallowed by this block. " +
|
|
276
|
+
`${activeProfile().shortName} silently ignores everything after an unbalanced brace.`,
|
|
277
|
+
range: { start: openBrace, end: openBrace + 1 },
|
|
278
|
+
});
|
|
279
|
+
closeBrace = null;
|
|
280
|
+
end = tok.start;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
if (tok.kind === "rbrace") {
|
|
284
|
+
closeBrace = tok.start;
|
|
285
|
+
end = tok.end;
|
|
286
|
+
this.advance();
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
const stmt = this.parseStatement();
|
|
290
|
+
if (stmt) {
|
|
291
|
+
statements.push(stmt);
|
|
292
|
+
} else if (this.peek().kind !== "rbrace" && this.peek().kind !== "eof") {
|
|
293
|
+
// Guarantee progress.
|
|
294
|
+
this.advance();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
kind: "block",
|
|
300
|
+
statements,
|
|
301
|
+
range: { start: openBrace, end },
|
|
302
|
+
openBrace,
|
|
303
|
+
closeBrace,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- helpers ---
|
|
308
|
+
|
|
309
|
+
private makeScalar(tok: Token): ScalarNode {
|
|
310
|
+
if (tok.kind === "string") {
|
|
311
|
+
// Strip surrounding quotes for `text`; keep range covering the quotes.
|
|
312
|
+
// For unterminated strings there's only an opening quote.
|
|
313
|
+
let inner: string;
|
|
314
|
+
if (tok.unterminated) {
|
|
315
|
+
// Unterminated: report the diagnostic and take everything after `"`.
|
|
316
|
+
this.errors.push({
|
|
317
|
+
code: "unterminated-string",
|
|
318
|
+
message: "Unterminated string; recovered at end of line.",
|
|
319
|
+
range: { start: tok.start, end: tok.end },
|
|
320
|
+
});
|
|
321
|
+
inner = this.text.slice(tok.start + 1, tok.end);
|
|
322
|
+
} else {
|
|
323
|
+
inner = this.text.slice(tok.start + 1, tok.end - 1);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
kind: "scalar",
|
|
327
|
+
text: inner,
|
|
328
|
+
quoted: true,
|
|
329
|
+
range: { start: tok.start, end: tok.end },
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// word
|
|
333
|
+
return {
|
|
334
|
+
kind: "scalar",
|
|
335
|
+
text: this.text.slice(tok.start, tok.end),
|
|
336
|
+
quoted: false,
|
|
337
|
+
range: { start: tok.start, end: tok.end },
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function parseScript(text: string): ParseResult {
|
|
343
|
+
try {
|
|
344
|
+
return new Parser(text).parse();
|
|
345
|
+
} catch {
|
|
346
|
+
// Absolute last-resort guard: should be unreachable, but the contract is
|
|
347
|
+
// "never throw on any input".
|
|
348
|
+
return {
|
|
349
|
+
root: {
|
|
350
|
+
kind: "root",
|
|
351
|
+
statements: [],
|
|
352
|
+
range: { start: 0, end: text.length },
|
|
353
|
+
},
|
|
354
|
+
errors: [],
|
|
355
|
+
comments: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export { OPERATORS };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled corpus-frequency tables (update plan v1.1 §C3), the ranking input for
|
|
3
|
+
* Workstream C. Emitted by scripts/build-freqs.ts from a vanilla scan (+ optional
|
|
4
|
+
* mod corpus) and shipped as packages/server/data/<game>/freqs.json. Loaded fail-soft: a
|
|
5
|
+
* missing/corrupt file yields empty tables and the extension ranks with a neutral
|
|
6
|
+
* frequency bucket everywhere.
|
|
7
|
+
*
|
|
8
|
+
* `contexts` are the same six completion contexts the eval harness reports on;
|
|
9
|
+
* each maps a key name to its occurrence count in that context. `tokens` is a
|
|
10
|
+
* context-independent global table (a key's count summed across all contexts),
|
|
11
|
+
* used for effect/trigger tokens and definitions that aren't structural keys.
|
|
12
|
+
*
|
|
13
|
+
* No `vscode` imports: plain data + a Node fs loader.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
|
|
18
|
+
/** The frequency contexts. Mirrors the eval harness's EvalContext set. */
|
|
19
|
+
export type FreqContext =
|
|
20
|
+
"event_top" | "event_option" | "interaction_top" | "decision_top" | "effect_block" | "trigger_block";
|
|
21
|
+
|
|
22
|
+
export const FREQ_CONTEXTS: FreqContext[] = [
|
|
23
|
+
"event_top",
|
|
24
|
+
"event_option",
|
|
25
|
+
"interaction_top",
|
|
26
|
+
"decision_top",
|
|
27
|
+
"effect_block",
|
|
28
|
+
"trigger_block",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export interface FreqData {
|
|
32
|
+
/** Provenance note (which corpora, when) — display only. */
|
|
33
|
+
meta?: { generated?: string; sources?: string[] };
|
|
34
|
+
/** Per-context name -> count (top-N per context). */
|
|
35
|
+
contexts: Record<FreqContext, Record<string, number>>;
|
|
36
|
+
/** Global name -> count across all contexts (top-N). */
|
|
37
|
+
tokens: Record<string, number>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function emptyFreqData(): FreqData {
|
|
41
|
+
const contexts = {} as Record<FreqContext, Record<string, number>>;
|
|
42
|
+
for (const c of FREQ_CONTEXTS) contexts[c] = {};
|
|
43
|
+
return { contexts, tokens: {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load the bundled freqs.json from `dir`. Fail-soft: any error (missing file,
|
|
48
|
+
* bad JSON, wrong shape) returns empty tables so completion still works.
|
|
49
|
+
*/
|
|
50
|
+
export function loadFreqs(dir: string): FreqData {
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(path.join(dir, "freqs.json"), "utf8");
|
|
53
|
+
const parsed = JSON.parse(raw) as Partial<FreqData>;
|
|
54
|
+
if (!parsed || typeof parsed !== "object" || !parsed.contexts || !parsed.tokens) {
|
|
55
|
+
return emptyFreqData();
|
|
56
|
+
}
|
|
57
|
+
const out = emptyFreqData();
|
|
58
|
+
for (const c of FREQ_CONTEXTS) {
|
|
59
|
+
const table = (parsed.contexts as Record<string, unknown>)[c];
|
|
60
|
+
if (table && typeof table === "object") {
|
|
61
|
+
out.contexts[c] = table as Record<string, number>;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (parsed.tokens && typeof parsed.tokens === "object") out.tokens = parsed.tokens;
|
|
65
|
+
if (parsed.meta) out.meta = parsed.meta;
|
|
66
|
+
return out;
|
|
67
|
+
} catch {
|
|
68
|
+
return emptyFreqData();
|
|
69
|
+
}
|
|
70
|
+
}
|