@px-lsp/protocol 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 +25 -0
- package/dist/arrays.d.ts +13 -0
- package/dist/arrays.js +19 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +10 -0
- package/dist/descriptorMetadata.d.ts +51 -0
- package/dist/descriptorMetadata.js +98 -0
- package/dist/descriptorMod.d.ts +66 -0
- package/dist/descriptorMod.js +335 -0
- package/dist/errorLogParser.d.ts +33 -0
- package/dist/errorLogParser.js +125 -0
- package/dist/fsWalk.d.ts +20 -0
- package/dist/fsWalk.js +159 -0
- package/dist/locProperties.d.ts +13 -0
- package/dist/locProperties.js +46 -0
- package/dist/locRefs.d.ts +11 -0
- package/dist/locRefs.js +31 -0
- package/dist/modName.d.ts +6 -0
- package/dist/modName.js +53 -0
- package/dist/protocol.d.ts +1462 -0
- package/dist/protocol.js +201 -0
- package/dist/regex.d.ts +13 -0
- package/dist/regex.js +21 -0
- package/dist/suppression.d.ts +52 -0
- package/dist/suppression.js +173 -0
- package/dist/tigerParser.d.ts +28 -0
- package/dist/tigerParser.js +72 -0
- package/dist/translationCore.d.ts +26 -0
- package/dist/translationCore.js +162 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +3 -0
- package/package.json +39 -0
- package/src/arrays.ts +16 -0
- package/src/constants.ts +12 -0
- package/src/descriptorMetadata.ts +101 -0
- package/src/descriptorMod.ts +354 -0
- package/src/errorLogParser.ts +136 -0
- package/src/fsWalk.ts +126 -0
- package/src/locProperties.ts +43 -0
- package/src/locRefs.ts +38 -0
- package/src/modName.ts +18 -0
- package/src/protocol.ts +1459 -0
- package/src/regex.ts +19 -0
- package/src/suppression.ts +178 -0
- package/src/tigerParser.ts +79 -0
- package/src/translationCore.ts +140 -0
- package/src/types.ts +90 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge table + validator for Paradox launcher `.mod` descriptor files
|
|
3
|
+
* (`descriptor.mod` inside the mod folder, `<name>.mod` next to it).
|
|
4
|
+
*
|
|
5
|
+
* The key set and tag list come from the official launcher docs
|
|
6
|
+
* (the Mod_structure page on the official wiki) cross-checked against 86 real .mod
|
|
7
|
+
* files (launcher-generated + Workshop). No vscode imports: unit-testable.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
|
|
12
|
+
export interface DescriptorField {
|
|
13
|
+
key: string;
|
|
14
|
+
/** Launcher refuses/misbehaves without it. */
|
|
15
|
+
required: boolean;
|
|
16
|
+
/** May appear multiple times (replace_path). */
|
|
17
|
+
repeatable: boolean;
|
|
18
|
+
/** Only meaningful in the outer `<name>.mod` file, ignored in descriptor.mod. */
|
|
19
|
+
outerOnly: boolean;
|
|
20
|
+
/** One-line label shown next to the completion item. */
|
|
21
|
+
summary: string;
|
|
22
|
+
/** Markdown: what the value means and what to put in. */
|
|
23
|
+
doc: string;
|
|
24
|
+
/** VS Code snippet inserted on completion (placeholder = example value). */
|
|
25
|
+
snippet: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const DESCRIPTOR_FIELDS: DescriptorField[] = [
|
|
29
|
+
{
|
|
30
|
+
key: "name",
|
|
31
|
+
required: true,
|
|
32
|
+
repeatable: false,
|
|
33
|
+
outerOnly: false,
|
|
34
|
+
summary: "Display name in the launcher and on Steam Workshop",
|
|
35
|
+
doc:
|
|
36
|
+
"The name players see in the launcher's mod list and on the Workshop page. " +
|
|
37
|
+
"Needs at least 3 characters for a Workshop upload.\n\n" +
|
|
38
|
+
'```\nname="My Mod"\n```',
|
|
39
|
+
snippet: 'name="${1:My Mod}"',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: "version",
|
|
43
|
+
required: true,
|
|
44
|
+
repeatable: false,
|
|
45
|
+
outerOnly: false,
|
|
46
|
+
summary: "Your mod's own version number (NOT the game version)",
|
|
47
|
+
doc:
|
|
48
|
+
"Free-form version string shown in the launcher. Bump it when you release " +
|
|
49
|
+
"an update so players can tell versions apart. This is about your mod, " +
|
|
50
|
+
"not the game - the game version goes in `supported_version`.\n\n" +
|
|
51
|
+
'```\nversion="0.1.0"\n```',
|
|
52
|
+
snippet: 'version="${1:0.1.0}"',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "supported_version",
|
|
56
|
+
required: true,
|
|
57
|
+
repeatable: false,
|
|
58
|
+
outerOnly: false,
|
|
59
|
+
summary: "Newest game version the mod works with",
|
|
60
|
+
doc:
|
|
61
|
+
"The launcher compares this against the installed game and marks the mod " +
|
|
62
|
+
"out of date when the game is newer. A `*` wildcard keeps the mod valid " +
|
|
63
|
+
"for every hotfix of a patch.\n\n" +
|
|
64
|
+
'```\nsupported_version="1.19.*"\n```',
|
|
65
|
+
snippet: 'supported_version="${1:1.19.*}"',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: "tags",
|
|
69
|
+
required: false,
|
|
70
|
+
repeatable: false,
|
|
71
|
+
outerOnly: false,
|
|
72
|
+
summary: "Launcher / Workshop category tags (one quoted tag per line)",
|
|
73
|
+
doc:
|
|
74
|
+
"Categories players can filter by in the launcher and on the Workshop. " +
|
|
75
|
+
"Pick from the launcher's list (completion inside the block offers all of " +
|
|
76
|
+
"them); a Workshop upload needs at least one.\n\n" +
|
|
77
|
+
'```\ntags={\n\t"Gameplay"\n\t"Events"\n}\n```',
|
|
78
|
+
snippet: 'tags={\n\t"${1:Gameplay}"\n}',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: "path",
|
|
82
|
+
required: false,
|
|
83
|
+
repeatable: false,
|
|
84
|
+
outerOnly: true,
|
|
85
|
+
summary: "Where the mod folder is - outer <name>.mod file only",
|
|
86
|
+
doc:
|
|
87
|
+
"Tells the launcher where the mod's files live. Absolute or relative to " +
|
|
88
|
+
"the game's user directory, forward slashes only.\n\n" +
|
|
89
|
+
"**Leave this line out of `descriptor.mod`** - it is ignored there, and a " +
|
|
90
|
+
"copied absolute path breaks when the mod is shared.\n\n" +
|
|
91
|
+
'```\npath="mod/my_mod"\n```',
|
|
92
|
+
snippet: 'path="${1:mod/my_mod}"',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "remote_file_id",
|
|
96
|
+
required: false,
|
|
97
|
+
repeatable: false,
|
|
98
|
+
outerOnly: false,
|
|
99
|
+
summary: "Steam Workshop item ID (set automatically on first upload)",
|
|
100
|
+
doc:
|
|
101
|
+
"Links the local mod to its Workshop page so updates go to the same item. " +
|
|
102
|
+
"The launcher fills this in when you first upload - you only ever set it " +
|
|
103
|
+
"by hand to reconnect a mod to an existing Workshop item. Digits only.\n\n" +
|
|
104
|
+
'```\nremote_file_id="2962333032"\n```',
|
|
105
|
+
snippet: 'remote_file_id="${1:123456789}"',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "picture",
|
|
109
|
+
required: false,
|
|
110
|
+
repeatable: false,
|
|
111
|
+
outerOnly: false,
|
|
112
|
+
summary: "Launcher thumbnail image (file inside the mod folder)",
|
|
113
|
+
doc:
|
|
114
|
+
"Image shown next to the mod in the launcher. Steam Workshop ignores it " +
|
|
115
|
+
"and always uses `thumbnail.png` in the mod root instead (1:1, max 1 MB).\n\n" +
|
|
116
|
+
'```\npicture="thumbnail.png"\n```',
|
|
117
|
+
snippet: 'picture="${1:thumbnail.png}"',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "replace_path",
|
|
121
|
+
required: false,
|
|
122
|
+
repeatable: true,
|
|
123
|
+
outerOnly: false,
|
|
124
|
+
summary: "Unload an entire vanilla folder (repeat per folder)",
|
|
125
|
+
doc:
|
|
126
|
+
"The game skips every vanilla file under this folder, so only your mod's " +
|
|
127
|
+
"version of it exists. One line per folder, forward slashes, relative to " +
|
|
128
|
+
"the game root. Used by total conversions to drop vanilla history, " +
|
|
129
|
+
"titles, cultures etc. wholesale - do not use it for ordinary overrides.\n\n" +
|
|
130
|
+
'```\nreplace_path="history/characters"\nreplace_path="common/landed_titles"\n```',
|
|
131
|
+
snippet: 'replace_path="${1:history/characters}"',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "dependencies",
|
|
135
|
+
required: false,
|
|
136
|
+
repeatable: false,
|
|
137
|
+
outerOnly: false,
|
|
138
|
+
summary: "Mods that must load BEFORE this one",
|
|
139
|
+
doc:
|
|
140
|
+
"The launcher sorts every listed mod above this one in the load order. " +
|
|
141
|
+
"Use the exact `name` of the other mod, one quoted name per line. Mostly " +
|
|
142
|
+
"for submods and compatibility patches.\n\n" +
|
|
143
|
+
'```\ndependencies={\n\t"A Game of Thrones"\n}\n```',
|
|
144
|
+
snippet: 'dependencies={\n\t"${1:Name of the parent mod}"\n}',
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
export const DESCRIPTOR_FIELD_MAP: ReadonlyMap<string, DescriptorField> = new Map(
|
|
149
|
+
DESCRIPTOR_FIELDS.map((f) => [f.key, f])
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
/** The launcher's fixed tag categories (Mod_structure wiki page, launcher UI). */
|
|
153
|
+
export const LAUNCHER_TAGS: string[] = [
|
|
154
|
+
"Alternative History",
|
|
155
|
+
"Balance",
|
|
156
|
+
"Bookmarks",
|
|
157
|
+
"Character Focuses",
|
|
158
|
+
"Character Interactions",
|
|
159
|
+
"Culture",
|
|
160
|
+
"Decisions",
|
|
161
|
+
"Events",
|
|
162
|
+
"Fixes",
|
|
163
|
+
"Gameplay",
|
|
164
|
+
"Graphics",
|
|
165
|
+
"Historical",
|
|
166
|
+
"Map",
|
|
167
|
+
"Portraits",
|
|
168
|
+
"Religion",
|
|
169
|
+
"Schemes",
|
|
170
|
+
"Sound",
|
|
171
|
+
"Total Conversion",
|
|
172
|
+
"Translation",
|
|
173
|
+
"Utilities",
|
|
174
|
+
"Warfare",
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
// ---- parsing -------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
export interface DescriptorEntry {
|
|
180
|
+
key: string;
|
|
181
|
+
/** 0-based line of the key. */
|
|
182
|
+
line: number;
|
|
183
|
+
/** Column range of the key on its line. */
|
|
184
|
+
startCol: number;
|
|
185
|
+
endCol: number;
|
|
186
|
+
/** Raw text right of `=` (trimmed, quotes kept), "" when the value is a block. */
|
|
187
|
+
value: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Line-based parse of the flat key=value format. Only top-level keys are
|
|
192
|
+
* entries; lines inside a `{ }` block (tags, dependencies) are skipped.
|
|
193
|
+
*/
|
|
194
|
+
export function parseDescriptor(text: string): DescriptorEntry[] {
|
|
195
|
+
const entries: DescriptorEntry[] = [];
|
|
196
|
+
let depth = 0;
|
|
197
|
+
const lines = text.split(/\r?\n/);
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
const noComment = line.replace(/#.*$/, "");
|
|
201
|
+
if (depth === 0) {
|
|
202
|
+
// Tolerate a UTF-8 BOM on the first line.
|
|
203
|
+
const m = /^(\uFEFF?\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(noComment);
|
|
204
|
+
if (m) {
|
|
205
|
+
const startCol = m[1].length;
|
|
206
|
+
entries.push({
|
|
207
|
+
key: m[2],
|
|
208
|
+
line: i,
|
|
209
|
+
startCol,
|
|
210
|
+
endCol: startCol + m[2].length,
|
|
211
|
+
value: m[3].trim().startsWith("{") ? "" : m[3].trim(),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const ch of noComment) {
|
|
216
|
+
if (ch === "{") depth++;
|
|
217
|
+
else if (ch === "}") depth = Math.max(0, depth - 1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return entries;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- validation ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
export interface DescriptorIssue {
|
|
226
|
+
code:
|
|
227
|
+
| "descriptor-missing-field"
|
|
228
|
+
| "descriptor-unknown-key"
|
|
229
|
+
| "descriptor-duplicate-key"
|
|
230
|
+
| "descriptor-path-ignored";
|
|
231
|
+
severity: "error" | "warning";
|
|
232
|
+
line: number;
|
|
233
|
+
startCol: number;
|
|
234
|
+
endCol: number;
|
|
235
|
+
message: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Structural checks on a .mod file. Everything here is certain: the key set is
|
|
240
|
+
* closed (launcher docs) and required-ness is the launcher's own behavior.
|
|
241
|
+
*/
|
|
242
|
+
export function validateDescriptor(text: string, opts: { isDescriptorFile: boolean }): DescriptorIssue[] {
|
|
243
|
+
const issues: DescriptorIssue[] = [];
|
|
244
|
+
const entries = parseDescriptor(text);
|
|
245
|
+
const seen = new Map<string, DescriptorEntry>();
|
|
246
|
+
|
|
247
|
+
for (const e of entries) {
|
|
248
|
+
const field = DESCRIPTOR_FIELD_MAP.get(e.key);
|
|
249
|
+
const at = { line: e.line, startCol: e.startCol, endCol: e.endCol };
|
|
250
|
+
if (!field) {
|
|
251
|
+
issues.push({
|
|
252
|
+
code: "descriptor-unknown-key",
|
|
253
|
+
severity: "warning",
|
|
254
|
+
...at,
|
|
255
|
+
message: `'${e.key}' is not a .mod descriptor key; the launcher ignores it.`,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (seen.has(e.key) && !field.repeatable) {
|
|
260
|
+
issues.push({
|
|
261
|
+
code: "descriptor-duplicate-key",
|
|
262
|
+
severity: "warning",
|
|
263
|
+
...at,
|
|
264
|
+
message: `'${e.key}' appears more than once; only the last value counts.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
seen.set(e.key, e);
|
|
268
|
+
if (e.key === "path" && opts.isDescriptorFile) {
|
|
269
|
+
issues.push({
|
|
270
|
+
code: "descriptor-path-ignored",
|
|
271
|
+
severity: "warning",
|
|
272
|
+
...at,
|
|
273
|
+
message:
|
|
274
|
+
"path= belongs in the outer <name>.mod file; inside descriptor.mod the launcher ignores it, " +
|
|
275
|
+
"and a machine-specific path leaks when the mod is shared.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const field of DESCRIPTOR_FIELDS) {
|
|
281
|
+
if (!field.required || seen.has(field.key)) continue;
|
|
282
|
+
// supported_version: the launcher still lists the mod, it just cannot
|
|
283
|
+
// check compatibility - a warning, not an error.
|
|
284
|
+
const isHard = field.key !== "supported_version";
|
|
285
|
+
issues.push({
|
|
286
|
+
code: "descriptor-missing-field",
|
|
287
|
+
severity: isHard ? "error" : "warning",
|
|
288
|
+
line: 0,
|
|
289
|
+
startCol: 0,
|
|
290
|
+
endCol: 200,
|
|
291
|
+
message: isHard
|
|
292
|
+
? `Missing ${field.key}= - the launcher needs it to list the mod.`
|
|
293
|
+
: "Missing supported_version= - the launcher cannot tell which game version the mod is for.",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return issues;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The mod's display name from `<dir>/descriptor.mod` (`name="..."`), or null
|
|
302
|
+
* when the file or field is missing/unreadable. Lets UI surfaces say WHICH mod
|
|
303
|
+
* something comes from ("Community Flavor Pack") instead of a generic "mod".
|
|
304
|
+
*/
|
|
305
|
+
export function readDescriptorName(dir: string): string | null {
|
|
306
|
+
let text: string;
|
|
307
|
+
try {
|
|
308
|
+
text = fs.readFileSync(path.join(dir, "descriptor.mod"), "utf8");
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const entry = parseDescriptor(text).find((e) => e.key === "name");
|
|
313
|
+
if (!entry) return null;
|
|
314
|
+
const value = entry.value.replace(/^"([^]*)"$/, "$1").trim();
|
|
315
|
+
return value === "" ? null : value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The mod names inside `<dir>/descriptor.mod`'s `dependencies={ "A" "B" }`
|
|
320
|
+
* block, in file order; empty when the file or the block is missing. The
|
|
321
|
+
* launcher matches these against the other mods' `name=`, not against their
|
|
322
|
+
* Workshop id, so that is what the caller compares them with.
|
|
323
|
+
*/
|
|
324
|
+
export function readDescriptorDependencies(dir: string): string[] {
|
|
325
|
+
let text: string;
|
|
326
|
+
try {
|
|
327
|
+
text = fs.readFileSync(path.join(dir, "descriptor.mod"), "utf8");
|
|
328
|
+
} catch {
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
// Comments first: a commented-out dependency is not a dependency.
|
|
332
|
+
const block = /(?:^|\n)[ \t]*dependencies[ \t]*=[ \t]*\{([^}]*)\}/.exec(text.replace(/#[^\n]*/g, ""));
|
|
333
|
+
if (!block) return [];
|
|
334
|
+
return [...block[1].matchAll(/"([^"]*)"/g)].map((m) => m[1].trim()).filter((s) => s !== "");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** "1.19.0.6" -> "1.19.*" (the wildcard form that survives hotfixes). */
|
|
338
|
+
export function wildcardVersion(raw: string): string | null {
|
|
339
|
+
const m = /^(\d+)\.(\d+)/.exec(raw.trim());
|
|
340
|
+
return m ? `${m[1]}.${m[2]}.*` : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A launcher-correct starter descriptor.mod. */
|
|
344
|
+
export function scaffoldDescriptor(modName: string, supportedVersion: string): string {
|
|
345
|
+
return [
|
|
346
|
+
'version="0.1.0"',
|
|
347
|
+
"tags={",
|
|
348
|
+
'\t"Gameplay"',
|
|
349
|
+
"}",
|
|
350
|
+
`name="${modName.replace(/"/g, "'")}"`,
|
|
351
|
+
`supported_version="${supportedVersion}"`,
|
|
352
|
+
"",
|
|
353
|
+
].join("\n");
|
|
354
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort parsing of the game's logs/error.log lines. Pure (no vscode),
|
|
3
|
+
* so it stays unit-testable; the tailing/diagnostics wiring lives in the
|
|
4
|
+
* client.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** `... in file: events/x.txt line: 12` / `file: "gui/y.gui" near line: 3` */
|
|
8
|
+
const FILE_LINE =
|
|
9
|
+
/file:\s*"?([^"\r\n]+?\.(?:txt|yml|gui|info|mod|gfx|asset))"?(?:\s+(?:near\s+)?line:?\s*(\d+))?/i;
|
|
10
|
+
/**
|
|
11
|
+
* Newer Jomini titles write the location with no `file:` keyword:
|
|
12
|
+
* `gui/x.gui:110 - Widget cannot have a position in a layout`. The ` - `
|
|
13
|
+
* separator is required; without it any "foo.txt:3" quoted inside a message
|
|
14
|
+
* would be read as a location.
|
|
15
|
+
*/
|
|
16
|
+
const FILE_LINE_BARE = /([\w./\\-]+\.(?:txt|yml|gui)):(\d+)\s+-\s+/;
|
|
17
|
+
/**
|
|
18
|
+
* Timestamp, with an OPTIONAL severity tag: older logs write
|
|
19
|
+
* `[18:33:24][E][x.cpp:1]:`, newer ones `[01:30:39][x.cpp:186]:` and leave the
|
|
20
|
+
* severity to the message text. Untagged entries count as errors.
|
|
21
|
+
*/
|
|
22
|
+
const TIMESTAMP = /^\[\d{2}:\d{2}:\d{2}\](?:\[([EW])\])?/;
|
|
23
|
+
/** The `[time][sev][source.cpp:N]: ` preamble, stripped from messages. */
|
|
24
|
+
const PREAMBLE = /^\[\d{2}:\d{2}:\d{2}\](?:\[[EW]\])?\[[^\]]*\]:\s*/;
|
|
25
|
+
|
|
26
|
+
export interface ParsedGameError {
|
|
27
|
+
message: string;
|
|
28
|
+
relFile: string;
|
|
29
|
+
/** 0-based, or null for file-level entries. */
|
|
30
|
+
line: number | null;
|
|
31
|
+
severity: "error" | "warning";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FileMatch {
|
|
35
|
+
relFile: string;
|
|
36
|
+
line: number | null;
|
|
37
|
+
/** Text of the location match, so callers can drop it from the message. */
|
|
38
|
+
matched: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The file/line a log line names, in either shape; null when it names none. */
|
|
42
|
+
function matchFile(line: string): FileMatch | null {
|
|
43
|
+
const m = FILE_LINE.exec(line);
|
|
44
|
+
if (m) {
|
|
45
|
+
return {
|
|
46
|
+
relFile: m[1].replace(/\\/g, "/"),
|
|
47
|
+
line: m[2] !== undefined ? Math.max(0, parseInt(m[2], 10) - 1) : null,
|
|
48
|
+
matched: "",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const bare = FILE_LINE_BARE.exec(line);
|
|
52
|
+
if (!bare) return null;
|
|
53
|
+
return {
|
|
54
|
+
relFile: bare[1].replace(/\\/g, "/"),
|
|
55
|
+
line: Math.max(0, parseInt(bare[2], 10) - 1),
|
|
56
|
+
matched: bare[0],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Parse one error.log line; null when it names no file. */
|
|
61
|
+
export function parseErrorLogLine(raw: string): ParsedGameError | null {
|
|
62
|
+
const line = raw.replace(/\r$/, "");
|
|
63
|
+
if (line.trim() === "") return null;
|
|
64
|
+
const m = matchFile(line);
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
const sev = TIMESTAMP.exec(line);
|
|
67
|
+
let message = line.replace(PREAMBLE, "").trim();
|
|
68
|
+
// The bare shape puts the location in front of the text; the diagnostic
|
|
69
|
+
// already carries file and line, so it is redundant there.
|
|
70
|
+
if (m.matched !== "" && message.startsWith(m.matched))
|
|
71
|
+
message = message.slice(m.matched.length).trim() || message;
|
|
72
|
+
return {
|
|
73
|
+
message,
|
|
74
|
+
relFile: m.relFile,
|
|
75
|
+
line: m.line,
|
|
76
|
+
severity: sev?.[1] === "W" ? "warning" : "error",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stateful line parser: same as `parseErrorLogLine`, but additionally stitches
|
|
82
|
+
* multi-line `Script system error!` blocks together, where the actual error
|
|
83
|
+
* text and the file location sit on separate indented continuation lines:
|
|
84
|
+
*
|
|
85
|
+
* [18:14:55][E][jomini_script_system.cpp:303]: Script system error!
|
|
86
|
+
* Error: is_cultivator trigger [ Scoped object ... is not valid ]
|
|
87
|
+
* Script location: file: common/script_values/x.txt line: 25 (name)
|
|
88
|
+
*
|
|
89
|
+
* Line-by-line, the location line would become the diagnostic message and the
|
|
90
|
+
* error text would be dropped. Feed EVERY line through `push` in order (state
|
|
91
|
+
* carries across reads); call `reset` when the log is cleared or replaced.
|
|
92
|
+
*/
|
|
93
|
+
export class ErrorLogParser {
|
|
94
|
+
private pendingSeverity: "error" | "warning" | null = null;
|
|
95
|
+
private pendingError: string | null = null;
|
|
96
|
+
|
|
97
|
+
push(raw: string): ParsedGameError | null {
|
|
98
|
+
const line = raw.replace(/\r$/, "");
|
|
99
|
+
if (line.trim() === "") {
|
|
100
|
+
this.reset();
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const sev = TIMESTAMP.exec(line);
|
|
104
|
+
if (sev) {
|
|
105
|
+
// Timestamped entry: single-line entries parse as before; a header that
|
|
106
|
+
// names no file (e.g. "Script system error!") opens a block.
|
|
107
|
+
this.reset();
|
|
108
|
+
const single = parseErrorLogLine(line);
|
|
109
|
+
if (single) return single;
|
|
110
|
+
this.pendingSeverity = sev[1] === "W" ? "warning" : "error"; // untagged = error
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
// Untimestamped continuation line of an open block.
|
|
114
|
+
if (this.pendingSeverity === null) return null;
|
|
115
|
+
const err = /^\s*Error:\s*(.+)$/.exec(line);
|
|
116
|
+
if (err) {
|
|
117
|
+
this.pendingError = err[1].trim();
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const m = matchFile(line);
|
|
121
|
+
if (!m) return null;
|
|
122
|
+
const parsed: ParsedGameError = {
|
|
123
|
+
message: this.pendingError ?? line.trim(),
|
|
124
|
+
relFile: m.relFile,
|
|
125
|
+
line: m.line,
|
|
126
|
+
severity: this.pendingSeverity,
|
|
127
|
+
};
|
|
128
|
+
this.reset();
|
|
129
|
+
return parsed;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
reset(): void {
|
|
133
|
+
this.pendingSeverity = null;
|
|
134
|
+
this.pendingError = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/fsWalk.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive file listing shared by the server indexer and client-side
|
|
3
|
+
* reference scans. No `vscode` imports.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directory entries between two `null` ticks from `iterFiles`. Measured walk
|
|
10
|
+
* throughput on a real tree is 4.2k entries/s cold and 29k/s warm, so 500
|
|
11
|
+
* entries is tens of milliseconds of blocking at worst.
|
|
12
|
+
*/
|
|
13
|
+
export const WALK_TICK = 500;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every file under `dir` (recursive) with the given extension (lowercase
|
|
17
|
+
* match), yielded as it is found, plus a `null` every WALK_TICK entries
|
|
18
|
+
* VISITED.
|
|
19
|
+
*
|
|
20
|
+
* The nulls are what lets an async caller pace the listing: a subtree with no
|
|
21
|
+
* match in it at all (a mod's `gfx/` under a `.txt` scan) still costs one
|
|
22
|
+
* readdirSync per directory, so a caller handed only paths would have nothing
|
|
23
|
+
* to pace itself against and would block for the whole traversal.
|
|
24
|
+
*/
|
|
25
|
+
export function* iterFiles(dir: string, ext: string): Generator<string | null> {
|
|
26
|
+
// One visited-target set per walk: shared across sibling links so two links
|
|
27
|
+
// to the same tree cannot index it twice. The walk runs once per schema folder
|
|
28
|
+
// (tens of times per root), never per directory, so the Set is free.
|
|
29
|
+
yield* walk(dir, ext, new Set<string>(), { count: 0 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** All files under `dir` (recursive) with the given extension (lowercase match). */
|
|
33
|
+
export function listFiles(dir: string, ext: string): string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
walkDir(dir, ext, out);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function walkDir(dir: string, ext: string, out: string[]): void {
|
|
40
|
+
for (const file of iterFiles(dir, ext)) {
|
|
41
|
+
if (file !== null) out.push(file);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function* walk(
|
|
46
|
+
dir: string,
|
|
47
|
+
ext: string,
|
|
48
|
+
visited: Set<string>,
|
|
49
|
+
tick: { count: number }
|
|
50
|
+
): Generator<string | null> {
|
|
51
|
+
let entries: fs.Dirent[];
|
|
52
|
+
try {
|
|
53
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
// Dot-directories (.git, .claude worktrees, …) are never game content and
|
|
59
|
+
// can hold stale copies of the whole mod — indexing them pollutes results.
|
|
60
|
+
if (entry.name.startsWith(".")) continue;
|
|
61
|
+
if (++tick.count % WALK_TICK === 0) yield null;
|
|
62
|
+
const full = path.join(dir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) yield* walk(full, ext, visited, tick);
|
|
64
|
+
else if (entry.isFile()) {
|
|
65
|
+
if (entry.name.toLowerCase().endsWith(ext)) yield full;
|
|
66
|
+
} else if (entry.isSymbolicLink()) yield* followLink(full, ext, visited, tick);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Case-folded on Windows, trailing separator stripped. */
|
|
71
|
+
function norm(p: string): string {
|
|
72
|
+
const n = process.platform === "win32" ? p.toLowerCase() : p;
|
|
73
|
+
return n.replace(/[\\/]+$/, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** True when `inner` is `outer` itself or sits below it. */
|
|
77
|
+
function isSameOrBelow(outer: string, inner: string): boolean {
|
|
78
|
+
const a = norm(outer);
|
|
79
|
+
const b = norm(inner);
|
|
80
|
+
return b === a || b.startsWith(a + path.sep);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A Dirent reports a symlink as neither file nor directory, so links need an
|
|
85
|
+
* explicit stat. Following them is not optional: symlinking a mod into the
|
|
86
|
+
* Paradox `mod/` folder is the standard Linux workflow, and Windows junctions
|
|
87
|
+
* do the same for a redirected Documents folder. Entries keep the LINK path so
|
|
88
|
+
* definitions stay attributed to the root the user configured.
|
|
89
|
+
*
|
|
90
|
+
* Two guards keep a malformed tree from looping or double-indexing: a link
|
|
91
|
+
* resolving to the directory it sits in (or one of its ancestors) is skipped
|
|
92
|
+
* outright, and every followed target is remembered for the rest of the walk.
|
|
93
|
+
*/
|
|
94
|
+
function* followLink(
|
|
95
|
+
full: string,
|
|
96
|
+
ext: string,
|
|
97
|
+
visited: Set<string>,
|
|
98
|
+
tick: { count: number }
|
|
99
|
+
): Generator<string | null> {
|
|
100
|
+
let target: fs.Stats;
|
|
101
|
+
let real: string;
|
|
102
|
+
try {
|
|
103
|
+
target = fs.statSync(full); // follows the link; throws when dangling
|
|
104
|
+
real = fs.realpathSync(full);
|
|
105
|
+
} catch {
|
|
106
|
+
return; // dangling or unreadable link indexes nothing
|
|
107
|
+
}
|
|
108
|
+
if (target.isFile()) {
|
|
109
|
+
if (path.basename(full).toLowerCase().endsWith(ext)) yield full;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!target.isDirectory()) return;
|
|
113
|
+
|
|
114
|
+
let host: string;
|
|
115
|
+
try {
|
|
116
|
+
host = fs.realpathSync(path.dirname(full));
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (isSameOrBelow(real, host)) return; // points back into the tree being walked
|
|
121
|
+
|
|
122
|
+
const key = norm(real);
|
|
123
|
+
if (visited.has(key)) return;
|
|
124
|
+
visited.add(key);
|
|
125
|
+
yield* walk(full, ext, visited, tick);
|
|
126
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script properties whose right-hand side is a localization key.
|
|
3
|
+
*
|
|
4
|
+
* BROAD: properties that often hold a loc key; a resolved inlay hint is shown
|
|
5
|
+
* when the value exists in the loc index, silence otherwise (these keys also
|
|
6
|
+
* hold non-loc values, e.g. `name` on a title history entry).
|
|
7
|
+
*
|
|
8
|
+
* STRICT: properties that virtually always hold a loc key; an unresolved value
|
|
9
|
+
* here renders a `missing loc` hint.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const STRICT_LOC_PROPERTIES = new Set<string>([
|
|
13
|
+
"title",
|
|
14
|
+
"desc",
|
|
15
|
+
"flavor",
|
|
16
|
+
"custom_tooltip",
|
|
17
|
+
"confirm_text",
|
|
18
|
+
"confirm_title",
|
|
19
|
+
"prompt",
|
|
20
|
+
"failure_desc",
|
|
21
|
+
"success_desc",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const BROAD_LOC_PROPERTIES = new Set<string>([
|
|
25
|
+
...STRICT_LOC_PROPERTIES,
|
|
26
|
+
"name",
|
|
27
|
+
"text",
|
|
28
|
+
"tooltip",
|
|
29
|
+
"first_valid",
|
|
30
|
+
"reason",
|
|
31
|
+
"format",
|
|
32
|
+
"header",
|
|
33
|
+
"opinion_text",
|
|
34
|
+
"what",
|
|
35
|
+
"who",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export function isLocProperty(prop: string): "strict" | "broad" | null {
|
|
39
|
+
const p = prop.toLowerCase();
|
|
40
|
+
if (STRICT_LOC_PROPERTIES.has(p)) return "strict";
|
|
41
|
+
if (BROAD_LOC_PROPERTIES.has(p)) return "broad";
|
|
42
|
+
return null;
|
|
43
|
+
}
|
package/src/locRefs.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line-level detection of localization-key references in script and of key
|
|
3
|
+
* definitions in loc yml. Used by the server (inlay hints, code actions) and
|
|
4
|
+
* the client (loc reference tracker), so it lives in shared/.
|
|
5
|
+
*
|
|
6
|
+
* No `vscode` imports here: this module is unit-tested in plain Node.
|
|
7
|
+
*/
|
|
8
|
+
import { isLocProperty } from "./locProperties";
|
|
9
|
+
|
|
10
|
+
const PROP_VALUE = /([A-Za-z_][A-Za-z0-9_.-]*)\s*=\s*("?)([A-Za-z_][A-Za-z0-9_.-]*)\2/g;
|
|
11
|
+
|
|
12
|
+
export interface LocKeyRef {
|
|
13
|
+
prop: string;
|
|
14
|
+
key: string;
|
|
15
|
+
/** Character range of the key on the line. */
|
|
16
|
+
start: number;
|
|
17
|
+
end: number;
|
|
18
|
+
strictness: "strict" | "broad";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function findLocKeyRefs(lineText: string): LocKeyRef[] {
|
|
22
|
+
const refs: LocKeyRef[] = [];
|
|
23
|
+
PROP_VALUE.lastIndex = 0;
|
|
24
|
+
let m: RegExpExecArray | null;
|
|
25
|
+
while ((m = PROP_VALUE.exec(lineText)) !== null) {
|
|
26
|
+
const strictness = isLocProperty(m[1]);
|
|
27
|
+
if (!strictness) continue;
|
|
28
|
+
const end = m.index + m[0].length - (m[2] === '"' ? 1 : 0);
|
|
29
|
+
refs.push({ prop: m[1], key: m[3], start: end - m[3].length, end, strictness });
|
|
30
|
+
}
|
|
31
|
+
return refs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The loc key defined on the given line of a loc yml, if any. */
|
|
35
|
+
export function locKeyOnLine(lineText: string): string | null {
|
|
36
|
+
const m = /^\s*([A-Za-z0-9_.\-']+):\d*\s*"/.exec(lineText.replace(/^/, ""));
|
|
37
|
+
return m ? m[1] : null;
|
|
38
|
+
}
|