@px-lsp/protocol 0.1.0 → 0.2.1
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 -674
- package/README.md +25 -25
- package/dist/calendar.d.ts +71 -0
- package/dist/calendar.js +182 -0
- package/dist/calendarFile.d.ts +26 -0
- package/dist/calendarFile.js +109 -0
- package/dist/calendarLoc.d.ts +60 -0
- package/dist/calendarLoc.js +101 -0
- package/dist/configDir.d.ts +17 -0
- package/dist/configDir.js +79 -0
- package/dist/descriptorMod.d.ts +21 -0
- package/dist/descriptorMod.js +61 -5
- package/dist/kinds.d.ts +72 -0
- package/dist/kinds.js +186 -0
- package/dist/protocol.d.ts +661 -4
- package/dist/protocol.js +159 -2
- package/dist/workshopMeta.d.ts +40 -0
- package/dist/workshopMeta.js +146 -0
- package/package.json +1 -1
- package/src/arrays.ts +16 -16
- package/src/calendar.ts +183 -0
- package/src/calendarFile.ts +81 -0
- package/src/calendarLoc.ts +159 -0
- package/src/configDir.ts +47 -0
- package/src/constants.ts +12 -12
- package/src/descriptorMetadata.ts +101 -101
- package/src/descriptorMod.ts +414 -354
- package/src/errorLogParser.ts +136 -136
- package/src/fsWalk.ts +126 -126
- package/src/kinds.ts +205 -0
- package/src/locProperties.ts +43 -43
- package/src/locRefs.ts +38 -38
- package/src/modName.ts +18 -18
- package/src/protocol.ts +2156 -1459
- package/src/regex.ts +19 -19
- package/src/suppression.ts +178 -178
- package/src/tigerParser.ts +79 -79
- package/src/translationCore.ts +140 -140
- package/src/types.ts +90 -90
- package/src/workshopMeta.ts +127 -0
package/src/errorLogParser.ts
CHANGED
|
@@ -1,136 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,126 +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
|
-
}
|
|
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
|
+
}
|