@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.
Files changed (163) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +334 -0
  3. package/THIRD-PARTY-NOTICES.md +83 -0
  4. package/data/ck3/dataTypes.json +2195 -0
  5. package/data/ck3/data_types/data_types_common.txt +2040 -0
  6. package/data/ck3/data_types/data_types_gui.txt +5264 -0
  7. package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
  8. package/data/ck3/data_types/data_types_script.txt +4251 -0
  9. package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
  10. package/data/ck3/freqs.json +1 -0
  11. package/data/ck3/guiSchema.json +6344 -0
  12. package/data/ck3/script_docs/effects.log +16059 -0
  13. package/data/ck3/script_docs/event_targets.log +2098 -0
  14. package/data/ck3/script_docs/modifiers.log +2228 -0
  15. package/data/ck3/script_docs/on_actions.log +5275 -0
  16. package/data/ck3/script_docs/triggers.log +11991 -0
  17. package/data/ck3/structures.json +9743 -0
  18. package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
  19. package/data/ck3/wikidocs/Data_types.md +2568 -0
  20. package/data/ck3/wikidocs/Effects_list.md +1176 -0
  21. package/data/ck3/wikidocs/Scopes_list.md +341 -0
  22. package/data/ck3/wikidocs/Triggers_list.md +1097 -0
  23. package/data/eu5/data_types/data_types_common.txt +2087 -0
  24. package/data/eu5/data_types/data_types_gui.txt +6732 -0
  25. package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
  26. package/data/eu5/data_types/data_types_script.txt +5688 -0
  27. package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
  28. package/data/vic3/data_types/data_types_common.txt +2021 -0
  29. package/data/vic3/data_types/data_types_gui.txt +5592 -0
  30. package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
  31. package/data/vic3/data_types/data_types_script.txt +2817 -0
  32. package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
  33. package/data/vic3/freqs.json +1 -0
  34. package/data/vic3/guiSchema.json +5578 -0
  35. package/data/vic3/script_docs/effects.log +38135 -0
  36. package/data/vic3/script_docs/event_targets.log +2028 -0
  37. package/data/vic3/script_docs/modifiers.log +18954 -0
  38. package/data/vic3/script_docs/on_actions.log +1561 -0
  39. package/data/vic3/script_docs/triggers.log +15738 -0
  40. package/data/vic3/structures.json +10189 -0
  41. package/dist/server.js +63668 -0
  42. package/media/px-lsp.svg +12 -0
  43. package/package.json +50 -0
  44. package/src/clientMode.ts +60 -0
  45. package/src/coa/coa.ts +184 -0
  46. package/src/coa/coaParse.ts +267 -0
  47. package/src/context.ts +78 -0
  48. package/src/contextKeywords.ts +224 -0
  49. package/src/data/dataBindingMacros.ts +82 -0
  50. package/src/data/dataFnDocs.ts +152 -0
  51. package/src/data/dataFnUsage.ts +431 -0
  52. package/src/data/dataTypes.ts +279 -0
  53. package/src/data/defines.ts +123 -0
  54. package/src/data/docsParser.ts +453 -0
  55. package/src/data/keywordDocs.ts +98 -0
  56. package/src/data/modifierTemplates.ts +143 -0
  57. package/src/data/textFormatting.ts +165 -0
  58. package/src/data/wikiDocs.ts +187 -0
  59. package/src/dds/decoder.ts +1007 -0
  60. package/src/dds/encode.ts +235 -0
  61. package/src/dds/index.ts +58 -0
  62. package/src/dds/png.ts +96 -0
  63. package/src/dds/tga.ts +62 -0
  64. package/src/documents.ts +35 -0
  65. package/src/features/assetPaths.ts +169 -0
  66. package/src/features/codeActions.ts +148 -0
  67. package/src/features/colors.ts +244 -0
  68. package/src/features/completion.ts +961 -0
  69. package/src/features/datafunction.ts +729 -0
  70. package/src/features/definition.ts +84 -0
  71. package/src/features/diagnostics.ts +244 -0
  72. package/src/features/folding.ts +106 -0
  73. package/src/features/formatting.ts +60 -0
  74. package/src/features/guiLanguage.ts +366 -0
  75. package/src/features/guiNavigation.ts +140 -0
  76. package/src/features/guiTree.ts +97 -0
  77. package/src/features/hover.ts +817 -0
  78. package/src/features/hoverRender.ts +222 -0
  79. package/src/features/inlayHints.ts +147 -0
  80. package/src/features/locFormatting.ts +127 -0
  81. package/src/features/references.ts +70 -0
  82. package/src/features/rename.ts +135 -0
  83. package/src/features/scopeAt.ts +65 -0
  84. package/src/features/semanticTokens.ts +188 -0
  85. package/src/features/signatureHelp.ts +72 -0
  86. package/src/features/symbols.ts +241 -0
  87. package/src/features/textureHover.ts +143 -0
  88. package/src/features/workspaceSymbols.ts +69 -0
  89. package/src/games/active.ts +19 -0
  90. package/src/games/ck3/ambientScopes.ts +273 -0
  91. package/src/games/ck3/index.ts +38 -0
  92. package/src/games/ck3/meta.ts +28 -0
  93. package/src/games/ck3/modifierPlaceholders.ts +61 -0
  94. package/src/games/ck3/saveSchema.ts +134 -0
  95. package/src/games/ck3/scaffolds.ts +197 -0
  96. package/src/games/ck3/schema.ts +422 -0
  97. package/src/games/ck3/structures.ts +887 -0
  98. package/src/games/eu5/index.ts +75 -0
  99. package/src/games/eu5/meta.ts +44 -0
  100. package/src/games/eu5/scaffolds.ts +49 -0
  101. package/src/games/eu5/schema.generated.ts +1043 -0
  102. package/src/games/jomini/variables.ts +134 -0
  103. package/src/games/profile.ts +205 -0
  104. package/src/games/registry.ts +27 -0
  105. package/src/games/vic3/index.ts +52 -0
  106. package/src/games/vic3/meta.ts +55 -0
  107. package/src/games/vic3/saveSchema.ts +77 -0
  108. package/src/games/vic3/scaffolds.ts +135 -0
  109. package/src/games/vic3/schema.ts +650 -0
  110. package/src/games/vic3/structures.ts +33 -0
  111. package/src/gui/anchorSpec.ts +66 -0
  112. package/src/gui/declMarkers.ts +30 -0
  113. package/src/gui/fillGeometry.ts +101 -0
  114. package/src/gui/guiDefs.ts +386 -0
  115. package/src/gui/guiDependencies.ts +352 -0
  116. package/src/gui/guiLinks.ts +64 -0
  117. package/src/gui/layoutEngine.ts +1998 -0
  118. package/src/gui/layoutService.ts +221 -0
  119. package/src/gui/measuredMetrics.ts +21 -0
  120. package/src/gui/previewService.ts +89 -0
  121. package/src/gui/saveSchema.ts +220 -0
  122. package/src/gui/saveValues.ts +399 -0
  123. package/src/gui/saveZip.ts +60 -0
  124. package/src/gui/sourceEdit.ts +535 -0
  125. package/src/gui/sourceEditService.ts +439 -0
  126. package/src/gui/sourceModel.ts +603 -0
  127. package/src/gui/textResolve.ts +145 -0
  128. package/src/gui/textureInfo.ts +106 -0
  129. package/src/gui/vocabulary.ts +149 -0
  130. package/src/gui/widgetEdit.ts +52 -0
  131. package/src/gui/widgetInfo.ts +245 -0
  132. package/src/index/docComments.ts +103 -0
  133. package/src/index/extract.ts +252 -0
  134. package/src/index/indexer.ts +369 -0
  135. package/src/index/intern.ts +101 -0
  136. package/src/index/lazyRefs.ts +145 -0
  137. package/src/index/modOrigin.ts +69 -0
  138. package/src/index/references.ts +534 -0
  139. package/src/overview/dependencies.ts +240 -0
  140. package/src/overview/eventBanner.ts +95 -0
  141. package/src/overview/eventDetail.ts +482 -0
  142. package/src/overview/eventGraph.ts +617 -0
  143. package/src/overview/eventVocabulary.ts +214 -0
  144. package/src/overview/locCoverage.ts +138 -0
  145. package/src/overview/modOverview.ts +29 -0
  146. package/src/overview/overrides.ts +89 -0
  147. package/src/parseCache.ts +81 -0
  148. package/src/parser/cst.ts +257 -0
  149. package/src/parser/encoding.ts +106 -0
  150. package/src/parser/index.ts +7 -0
  151. package/src/parser/lexer.ts +245 -0
  152. package/src/parser/locParser.ts +276 -0
  153. package/src/parser/parser.ts +360 -0
  154. package/src/schema/freqs.ts +70 -0
  155. package/src/schema/loader.ts +113 -0
  156. package/src/schema/types.ts +142 -0
  157. package/src/scopes/inference.ts +478 -0
  158. package/src/scopes/model.ts +148 -0
  159. package/src/scopes/varTypes.ts +290 -0
  160. package/src/server.ts +1894 -0
  161. package/src/serverData.ts +98 -0
  162. package/src/structure.ts +56 -0
  163. package/src/wordAt.ts +49 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Go-to-definition for script identifiers: every source is listed (a mod
3
+ * override AND the vanilla/parent originals), mod definitions first. Showing
4
+ * the shadowed sites too is deliberate — an unintended override of a vanilla
5
+ * or parent-mod name is exactly what a modder wants to notice (#4, #5).
6
+ */
7
+ import type { Location, Position } from "vscode-languageserver/node";
8
+ import type { TextDocument } from "vscode-languageserver-textdocument";
9
+ import { URI } from "vscode-uri";
10
+ import type { Definition, DefSource } from "@px-lsp/protocol/types";
11
+ import type { ServerData } from "../serverData";
12
+ import { wordRangeAt } from "../wordAt";
13
+ import { getLineText } from "../documents";
14
+ import { datafunctionExprAt } from "./datafunction";
15
+
16
+ export function provideDefinition(
17
+ data: ServerData,
18
+ document: TextDocument,
19
+ position: Position,
20
+ /** Definitions extracted from the OPEN document itself: the index-free net
21
+ * for same-file declarations (inline scripted_triggers in a vanilla file
22
+ * whose index is stale, missing, or still building — #5). */
23
+ docDefs?: (word: string) => Definition[]
24
+ ): Location[] {
25
+ const range = wordRangeAt(getLineText(document, position.line), position.character);
26
+ if (!range) return [];
27
+ const locations = lookupLocations(data, range.word);
28
+ if (locations.length > 0 || !docDefs) return locations;
29
+ return docDefs(range.word).map((d) => ({
30
+ uri: document.uri,
31
+ range: { start: { line: d.line, character: 0 }, end: { line: d.line, character: 0 } },
32
+ }));
33
+ }
34
+
35
+ /**
36
+ * Go-to-definition inside localization values, for [ ... ] datafunction
37
+ * expressions only: `Custom2('RelationToMe', …)` jumps to the customizable
38
+ * localization, `scope_name.GetHerHis` chain segments to save sites, etc.
39
+ * Plain loc-key navigation stays with the client-side provider.
40
+ */
41
+ export function provideLocDefinition(
42
+ data: ServerData,
43
+ document: TextDocument,
44
+ position: Position
45
+ ): Location[] {
46
+ const lineText = getLineText(document, position.line);
47
+ // Only inside an unclosed [ before the cursor — i.e. within an expression.
48
+ if (datafunctionExprAt(lineText.slice(0, position.character)) === null) return [];
49
+ const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
50
+ let start = position.character;
51
+ while (start > 0 && isWord(lineText[start - 1])) start--;
52
+ let end = position.character;
53
+ while (end < lineText.length && isWord(lineText[end])) end++;
54
+ const word = lineText.slice(start, end);
55
+ if (word.length === 0) return [];
56
+ const locations = lookupLocations(data, word);
57
+ // Quoted arguments ('RelationToMe') are most often custom loc names: when
58
+ // both meanings exist, prefer the customizable_localization definitions.
59
+ if (lineText[start - 1] === "'" && locations.length > 1) {
60
+ const custom = orderedDefs(data, word).filter((d) => d.kind === "customizable_localization");
61
+ if (custom.length > 0) {
62
+ return custom.map((d) => toLocation(d.file, d.line));
63
+ }
64
+ }
65
+ return locations;
66
+ }
67
+
68
+ /** Mod first, then parent, then vanilla; insertion order within a source. */
69
+ const SOURCE_ORDER: Record<DefSource, number> = { mod: 0, parent: 1, vanilla: 2 };
70
+
71
+ function orderedDefs(data: ServerData, word: string) {
72
+ return [...data.index.lookupAll(word)].sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source]);
73
+ }
74
+
75
+ function lookupLocations(data: ServerData, word: string): Location[] {
76
+ return orderedDefs(data, word).map((d) => toLocation(d.file, d.line));
77
+ }
78
+
79
+ function toLocation(file: string, line: number): Location {
80
+ return {
81
+ uri: URI.file(file).toString(),
82
+ range: { start: { line, character: 0 }, end: { line, character: 0 } },
83
+ };
84
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Structural diagnostics: the silent-failure class that makes the game ignore
3
+ * content with zero error output. Everything here is *certain* — semantic
4
+ * validation stays the tiger validator's job (rework plan AD-5/6).
5
+ *
6
+ * Each diagnostic carries a stable code; docs/diagnostics/<code>.md explains
7
+ * the in-game consequence. The `source` label and game-name prose come from
8
+ * the active profile.
9
+ */
10
+ import { DiagnosticSeverity, type Diagnostic } from "vscode-languageserver/node";
11
+ import type { LineIndex, LocParseResult, ParseResult, Range } from "../parser";
12
+ import type { Definition, Reference } from "@px-lsp/protocol/types";
13
+ import type { SchemaEntry } from "../schema/types";
14
+ import type { ServerData } from "../serverData";
15
+ import { activeProfile } from "../games/active";
16
+
17
+ export interface FileContext {
18
+ /** Absolute path of the file on disk. */
19
+ fsPath: string;
20
+ /** The mod root, when known — folder-layout checks only apply to mod files. */
21
+ modPath: string | null;
22
+ /** Whether the file on disk starts with a UTF-8 BOM; null = unknown/unsaved. */
23
+ bomOnDisk: boolean | null;
24
+ }
25
+
26
+ function isUnder(root: string | null, file: string): boolean {
27
+ if (!root) return false;
28
+ const norm = (p: string) => p.replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
29
+ const f = norm(file);
30
+ const r = norm(root);
31
+ return f.startsWith(r + "/");
32
+ }
33
+
34
+ /** Path of `file` relative to the mod root, forward slashes, lowercase; null when outside. */
35
+ function modRelPath(ctx: FileContext): string | null {
36
+ if (!isUnder(ctx.modPath, ctx.fsPath)) return null;
37
+ return ctx.fsPath
38
+ .replace(/\\/g, "/")
39
+ .slice(ctx.modPath!.replace(/\\/g, "/").replace(/\/+$/, "").length + 1)
40
+ .toLowerCase();
41
+ }
42
+
43
+ function diag(
44
+ range: { start: { line: number; character: number }; end: { line: number; character: number } },
45
+ severity: DiagnosticSeverity,
46
+ code: string,
47
+ message: string
48
+ ): Diagnostic {
49
+ return { range, severity, code, message, source: activeProfile().diagnosticSource };
50
+ }
51
+
52
+ function toRange(lines: LineIndex, range: Range) {
53
+ return { start: lines.positionAt(range.start), end: lines.positionAt(range.end) };
54
+ }
55
+
56
+ const TOP_OF_FILE = { start: { line: 0, character: 0 }, end: { line: 0, character: 200 } };
57
+
58
+ // ---- script files ------------------------------------------------------------
59
+
60
+ const SCRIPT_ERROR_SEVERITY: Record<string, DiagnosticSeverity> = {
61
+ "unclosed-brace": DiagnosticSeverity.Error,
62
+ "stray-close": DiagnosticSeverity.Error,
63
+ "unterminated-string": DiagnosticSeverity.Warning,
64
+ "missing-value": DiagnosticSeverity.Warning,
65
+ };
66
+
67
+ function scriptErrorHint(code: string): string {
68
+ const game = activeProfile().shortName;
69
+ if (code === "unclosed-brace")
70
+ return ` ${game} silently ignores everything in the file after an unbalanced brace.`;
71
+ if (code === "stray-close") return ` ${game} may misread the rest of the file.`;
72
+ return "";
73
+ }
74
+
75
+ export function computeScriptDiagnostics(
76
+ parse: ParseResult,
77
+ lines: LineIndex,
78
+ ctx: FileContext
79
+ ): Diagnostic[] {
80
+ const out: Diagnostic[] = [];
81
+
82
+ for (const err of parse.errors) {
83
+ const severity = SCRIPT_ERROR_SEVERITY[err.code] ?? DiagnosticSeverity.Warning;
84
+ out.push(diag(toRange(lines, err.range), severity, err.code, err.message + scriptErrorHint(err.code)));
85
+ }
86
+
87
+ const rel = modRelPath(ctx);
88
+ if (rel) {
89
+ // Only games whose schema reads common/on_action (singular) get the
90
+ // plural-folder trap check (in other games the plural IS the real folder).
91
+ const singular = activeProfile().schema.some((e) => e.path === "common/on_action");
92
+ if (singular && rel.startsWith("common/on_actions/")) {
93
+ out.push(
94
+ diag(
95
+ TOP_OF_FILE,
96
+ DiagnosticSeverity.Error,
97
+ "wrong-on-action-folder",
98
+ `This file is under common/on_actions/ — ${activeProfile().shortName} reads common/on_action/ (singular). The game silently ignores this file.`
99
+ )
100
+ );
101
+ }
102
+ }
103
+
104
+ return out;
105
+ }
106
+
107
+ // ---- index-backed conservative checks (mod content only) ----------------------
108
+
109
+ /**
110
+ * Unknown event references — only for namespaces the mod itself declares, so
111
+ * vanilla/DLC content can never false-positive (rework plan Phase 2).
112
+ */
113
+ export function computeReferenceDiagnostics(references: Reference[], data: ServerData): Diagnostic[] {
114
+ const out: Diagnostic[] = [];
115
+ for (const ref of references) {
116
+ if (!ref.kinds.includes("event")) continue;
117
+ const dot = ref.name.indexOf(".");
118
+ if (dot <= 0) continue;
119
+ const ns = ref.name.slice(0, dot);
120
+ if (!data.modNamespaces.has(ns)) continue;
121
+ if (data.index.lookupAll(ref.name).length > 0) continue;
122
+ out.push(
123
+ diag(
124
+ {
125
+ start: { line: ref.line, character: ref.startChar },
126
+ end: { line: ref.line, character: ref.endChar },
127
+ },
128
+ DiagnosticSeverity.Warning,
129
+ "unknown-event",
130
+ `Event "${ref.name}" is not defined anywhere, but its namespace "${ns}" belongs to this mod. Triggering it will silently do nothing.`
131
+ )
132
+ );
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Schema-declared required localization keys missing for mod definitions. */
138
+ export function computeRequiredLocDiagnostics(
139
+ defs: Definition[],
140
+ entry: SchemaEntry,
141
+ data: ServerData
142
+ ): Diagnostic[] {
143
+ const patterns = entry.requiredLoc ?? [];
144
+ if (patterns.length === 0) return [];
145
+ const out: Diagnostic[] = [];
146
+ for (const def of defs) {
147
+ if (def.source !== "mod") continue;
148
+ for (const pattern of patterns) {
149
+ const key = pattern.replace(/\$/g, def.name);
150
+ if (data.index.lookup(key).some((d) => d.kind === "loc_key")) continue;
151
+ const d = diag(
152
+ { start: { line: def.line, character: 0 }, end: { line: def.line, character: 200 } },
153
+ DiagnosticSeverity.Warning,
154
+ "missing-required-loc",
155
+ `Missing localization key "${key}" — ${entry.kind.replace(/_/g, " ")} definitions show raw keys in game without it.`
156
+ );
157
+ d.data = { key };
158
+ out.push(d);
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // ---- localization files --------------------------------------------------------
165
+
166
+ const LOC_ERROR_SEVERITY: Record<string, DiagnosticSeverity> = {
167
+ "no-header": DiagnosticSeverity.Error,
168
+ "bad-entry": DiagnosticSeverity.Warning,
169
+ "tab-indent": DiagnosticSeverity.Error,
170
+ "unterminated-value": DiagnosticSeverity.Warning,
171
+ "content-before-header": DiagnosticSeverity.Warning,
172
+ };
173
+
174
+ function locErrorHint(code: string): string {
175
+ if (code === "no-header") return " Without an l_<language>: header the game loads none of these entries.";
176
+ if (code === "tab-indent")
177
+ return ` ${activeProfile().shortName} rejects tab indentation in localization files.`;
178
+ return "";
179
+ }
180
+
181
+ const FILENAME_LANG = /_l_([a-z_]+)\.ya?ml$/i;
182
+
183
+ export function computeLocDiagnostics(loc: LocParseResult, lines: LineIndex, ctx: FileContext): Diagnostic[] {
184
+ const out: Diagnostic[] = [];
185
+
186
+ for (const err of loc.errors) {
187
+ const severity = LOC_ERROR_SEVERITY[err.code] ?? DiagnosticSeverity.Warning;
188
+ out.push(
189
+ diag(toRange(lines, err.range), severity, `loc-${err.code}`, err.message + locErrorHint(err.code))
190
+ );
191
+ }
192
+
193
+ // BOM is checked against the bytes on disk (editors strip it from the buffer text).
194
+ if (ctx.bomOnDisk === false) {
195
+ out.push(
196
+ diag(
197
+ TOP_OF_FILE,
198
+ DiagnosticSeverity.Error,
199
+ "missing-bom",
200
+ `This localization file has no UTF-8 BOM. ${activeProfile().shortName} requires UTF-8 with BOM; without it the game ignores the file. Save with encoding "UTF-8 with BOM".`
201
+ )
202
+ );
203
+ }
204
+
205
+ const basename = ctx.fsPath.replace(/\\/g, "/").split("/").pop() ?? "";
206
+ const fileLang = FILENAME_LANG.exec(basename)?.[1]?.toLowerCase() ?? null;
207
+ if (loc.language !== null && fileLang !== null && loc.language.toLowerCase() !== fileLang) {
208
+ const range = loc.headerRange ? toRange(lines, loc.headerRange) : TOP_OF_FILE;
209
+ out.push(
210
+ diag(
211
+ range,
212
+ DiagnosticSeverity.Error,
213
+ "loc-header-mismatch",
214
+ `Header l_${loc.language}: does not match the filename marker _l_${fileLang}.yml — the game will not load these entries.`
215
+ )
216
+ );
217
+ }
218
+
219
+ const rel = modRelPath(ctx);
220
+ if (rel) {
221
+ if (fileLang === null && rel.startsWith("localization/")) {
222
+ out.push(
223
+ diag(
224
+ TOP_OF_FILE,
225
+ DiagnosticSeverity.Error,
226
+ "loc-bad-filename",
227
+ `Localization files must end in _l_<language>.yml (e.g. ${basename.replace(/\.ya?ml$/i, "")}_l_english.yml); the game silently ignores this file.`
228
+ )
229
+ );
230
+ }
231
+ if (rel.startsWith("localisation/")) {
232
+ out.push(
233
+ diag(
234
+ TOP_OF_FILE,
235
+ DiagnosticSeverity.Error,
236
+ "wrong-localization-folder",
237
+ `This folder is localisation/ (British spelling) — ${activeProfile().shortName} reads localization/. The game silently ignores this file.`
238
+ )
239
+ );
240
+ }
241
+ }
242
+
243
+ return out;
244
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Folding ranges from the CST: every `{}` block spanning multiple lines, plus
3
+ * runs of consecutive comment lines. Serves every brace language the client
4
+ * routes here (script, .gui, and the descriptor/format-doc languages). The
5
+ * provider being registered means VS Code never falls back to indentation
6
+ * folding, so returning [] for a routed language actively disables folding
7
+ * there. Loc files have no braces; they
8
+ * fold the `l_<lang>:` body and comment banners instead.
9
+ */
10
+ import { FoldingRangeKind, type FoldingRange } from "vscode-languageserver/node";
11
+ import type { TextDocument } from "vscode-languageserver-textdocument";
12
+ import { walkStatements, type BlockNode, type Statement } from "../parser";
13
+ import { getLocParse, getParse } from "../parseCache";
14
+
15
+ function blockOf(stmt: Statement): BlockNode | null {
16
+ const v = stmt.value;
17
+ if (!v) return null;
18
+ if (v.kind === "block") return v;
19
+ if (v.kind === "tagged-block") return v.block;
20
+ return null;
21
+ }
22
+
23
+ /** Comment banners: 2+ consecutive full-line comments fold as one region. */
24
+ function commentRuns(lines: { line: number; atLineStart: boolean }[]): FoldingRange[] {
25
+ const ranges: FoldingRange[] = [];
26
+ let runStart = -1;
27
+ let prevLine = -2;
28
+ const flush = (lastLine: number) => {
29
+ if (runStart >= 0 && lastLine > runStart) {
30
+ ranges.push({ startLine: runStart, endLine: lastLine, kind: FoldingRangeKind.Comment });
31
+ }
32
+ runStart = -1;
33
+ };
34
+ for (const c of lines) {
35
+ if (!c.atLineStart) continue;
36
+ if (c.line === prevLine + 1 && runStart >= 0) {
37
+ prevLine = c.line;
38
+ continue;
39
+ }
40
+ flush(prevLine);
41
+ runStart = c.line;
42
+ prevLine = c.line;
43
+ }
44
+ flush(prevLine);
45
+ return ranges;
46
+ }
47
+
48
+ export function provideFoldingRanges(document: TextDocument): FoldingRange[] {
49
+ if (document.languageId === "paradox-loc") return locFoldingRanges(document);
50
+ const { result, lineIndex } = getParse(document);
51
+ const ranges: FoldingRange[] = [];
52
+
53
+ walkStatements(result.root, (stmt) => {
54
+ const block = blockOf(stmt);
55
+ if (!block) return;
56
+ const startLine = lineIndex.positionAt(block.openBrace).line;
57
+ // Keep the closing brace visible when folded; an unclosed block (parser
58
+ // recovery, range.end = EOF) has no brace to keep visible, so it folds
59
+ // through its last line.
60
+ const endLine =
61
+ block.closeBrace != null
62
+ ? lineIndex.positionAt(block.closeBrace).line - 1
63
+ : lineIndex.positionAt(block.range.end).line;
64
+ if (endLine > startLine) ranges.push({ startLine, endLine });
65
+ });
66
+
67
+ ranges.push(
68
+ ...commentRuns(
69
+ result.comments.map((c) => ({
70
+ line: c.line,
71
+ atLineStart: lineIndex.positionAt(c.range.start).character === 0,
72
+ }))
73
+ )
74
+ );
75
+
76
+ return ranges;
77
+ }
78
+
79
+ function locFoldingRanges(document: TextDocument): FoldingRange[] {
80
+ const { result, lineIndex } = getLocParse(document);
81
+ const ranges: FoldingRange[] = [];
82
+ // The language body: header line down to the last entry.
83
+ if (result.headerRange && result.entries.length > 0) {
84
+ const startLine = lineIndex.positionAt(result.headerRange.start).line;
85
+ const last = result.entries[result.entries.length - 1];
86
+ const endLine = lineIndex.positionAt(last.valueRange.end).line;
87
+ if (endLine > startLine) ranges.push({ startLine, endLine });
88
+ }
89
+ // Comment banners, by raw line scan (the loc parser keeps no comment list).
90
+ // Vanilla indents body comments by one space, so any whitespace-then-`#`
91
+ // line counts, matching the loc parser's own comment definition; a line-0
92
+ // BOM is stripped first (line numbers are unaffected).
93
+ const lines = document
94
+ .getText()
95
+ .replace(/^\uFEFF/, "")
96
+ .split("\n");
97
+ ranges.push(
98
+ ...commentRuns(
99
+ lines
100
+ .map((text, line) => ({ line, text }))
101
+ .filter(({ text }) => /^[ \t]*#/.test(text))
102
+ .map(({ line }) => ({ line, atLineStart: true }))
103
+ )
104
+ );
105
+ return ranges;
106
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Conservative formatter (rework plan Phase 5): indentation only — one tab per
3
+ * brace depth, computed from the lexer so strings and comments can never fool
4
+ * it. Nothing but leading whitespace is ever touched, which makes idempotence
5
+ * trivial and the diff reviewable.
6
+ */
7
+ import type { TextEdit } from "vscode-languageserver/node";
8
+ import type { TextDocument } from "vscode-languageserver-textdocument";
9
+ import { LineIndex, tokenize } from "../parser";
10
+
11
+ export function provideFormattingEdits(document: TextDocument): TextEdit[] {
12
+ const text = document.getText();
13
+ const lines = new LineIndex(text);
14
+ const tokens = tokenize(text);
15
+
16
+ // Depth at the start of each line, and whether the line's first token closes.
17
+ const openBefore: number[] = new Array(lines.lineCount).fill(0);
18
+ const closersAtStart: number[] = new Array(lines.lineCount).fill(0);
19
+ let depth = 0;
20
+ let tokenIdx = 0;
21
+ for (let line = 0; line < lines.lineCount; line++) {
22
+ openBefore[line] = depth;
23
+ const lineEnd = line + 1 < lines.lineCount ? lines.lineStart(line + 1) : text.length;
24
+ let leadingClosers = 0;
25
+ let sawNonCloser = false;
26
+ while (tokenIdx < tokens.length && tokens[tokenIdx].start < lineEnd) {
27
+ const t = tokens[tokenIdx];
28
+ if (t.kind === "lbrace") {
29
+ depth++;
30
+ sawNonCloser = true;
31
+ } else if (t.kind === "rbrace") {
32
+ depth = Math.max(0, depth - 1);
33
+ if (!sawNonCloser) leadingClosers++;
34
+ } else if (t.kind !== "eof") {
35
+ sawNonCloser = true;
36
+ }
37
+ tokenIdx++;
38
+ }
39
+ closersAtStart[line] = leadingClosers;
40
+ }
41
+
42
+ const edits: TextEdit[] = [];
43
+ for (let line = 0; line < lines.lineCount; line++) {
44
+ const start = lines.lineStart(line);
45
+ const end = line + 1 < lines.lineCount ? lines.lineStart(line + 1) : text.length;
46
+ const lineText = text.slice(start, end).replace(/\r?\n$/, "");
47
+ if (lineText.trim() === "") continue; // blank lines stay untouched
48
+ const current = /^[\t ]*/.exec(lineText)![0];
49
+ const target = "\t".repeat(Math.max(0, openBefore[line] - closersAtStart[line]));
50
+ if (current === target) continue;
51
+ edits.push({
52
+ range: {
53
+ start: { line, character: 0 },
54
+ end: { line, character: current.length },
55
+ },
56
+ newText: target,
57
+ });
58
+ }
59
+ return edits;
60
+ }