@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,603 @@
1
+ // Implements the source-writer design of Sage's Clausewitz Studio; behavior contract in docs/gui-designer/parity-checklist.md. GPL-3.0-or-later.
2
+ /**
3
+ * The span-recording .gui source model (G1 stage 1: reads only, no ops).
4
+ *
5
+ * A source-preserving writer edits BYTES, so it needs a model that knows where
6
+ * every byte came from: the raw span of every key, operator and value (quotes,
7
+ * pipes and interior spacing verbatim), the braces of every body, and the
8
+ * block-structure facts that decide what a reorder or a delete is allowed to
9
+ * move. Every read a later stage needs is recorded here once, so no operation
10
+ * has to re-tokenize the document to answer a question about it.
11
+ *
12
+ * This is a second VIEW of the toolkit's own tolerant CST (`parseScript`), not
13
+ * a second parser: the CST supplies the structure and the token ranges, and
14
+ * this module adds the line-granular facts a text editor works in.
15
+ *
16
+ * Contract rows: W01 (spans), S01 (every span re-tokenizes to its model value),
17
+ * S06 (every body's braces land on braces). The structure facts the later
18
+ * stages consume are W12 (blank separators belong to the block above), W13
19
+ * (attached comments travel with their widget), W14/S03 (interleaved bodies),
20
+ * W24 (the append point) and W06/W15/W20 (newline and indent unit).
21
+ *
22
+ * No `vscode` imports: unit-tested in plain Node.
23
+ */
24
+ import {
25
+ LineIndex,
26
+ parseScript,
27
+ type AssignmentNode,
28
+ type BlockNode,
29
+ type CommentNode,
30
+ type Operator,
31
+ type ParseError,
32
+ type Range,
33
+ type ScalarNode,
34
+ type Statement,
35
+ type ValueNode,
36
+ } from "../parser";
37
+ import { DECL_MARKERS, SLOT_KEYS } from "./declMarkers";
38
+ import { PROPERTY_BLOCKS } from "./layoutEngine";
39
+
40
+ /** A raw byte range in the document (UTF-16 code units, like the CST). */
41
+ export type GuiSpan = Range;
42
+
43
+ /**
44
+ * `widget` is a child widget declaration (the inverse rule: a block child is a
45
+ * widget unless its key is a known attribute block), `decl` is a
46
+ * template/type/block declaration introduced by a marker word, `property` is
47
+ * everything else, including the attribute BLOCKS (`size = { 100 200 }`),
48
+ * whose value happens to be a block.
49
+ */
50
+ export type GuiEntryKind = "widget" | "decl" | "property";
51
+
52
+ /**
53
+ * `DECL_MARKERS` and `SLOT_KEYS` come from `declMarkers.ts` because the layout
54
+ * engine counts the same entries for `srcIndex`. The slot form
55
+ * (`blockoverride = "name" { ... }`) is normalized into the marker form here:
56
+ * the key becomes the marker, the tag becomes the key, and the value is the
57
+ * block alone.
58
+ */
59
+
60
+ /** One `key [op] value` statement, with every raw span the writer addresses it by. */
61
+ export interface GuiEntry {
62
+ kind: GuiEntryKind;
63
+ /** Key text as authored, without the quotes of a quoted key. */
64
+ key: string;
65
+ keyLower: string;
66
+ /** The key token's raw bytes, quotes included. */
67
+ keySpan: GuiSpan;
68
+ keyQuoted: boolean;
69
+ /** null for the operator-less GUI form `widget { ... }`. */
70
+ op: Operator | null;
71
+ opSpan: GuiSpan | null;
72
+ /**
73
+ * The value normalized for comparison: a quoted scalar without its quotes, a
74
+ * bare scalar verbatim (`top|left` stays one value), a block rendered
75
+ * canonically. Re-tokenizing `valueSpan` alone reproduces exactly this (S01).
76
+ */
77
+ value: string | null;
78
+ /** The value's raw bytes: quotes, pipes, braces and interior spacing verbatim. */
79
+ valueSpan: GuiSpan | null;
80
+ valueKind: "none" | "scalar" | "block";
81
+ valueQuoted: boolean;
82
+ /** The tag of `type px_x = widget { ... }`, else null. */
83
+ base: string | null;
84
+ /** The declaration marker word (lowercased), else null. */
85
+ marker: string | null;
86
+ markerSpan: GuiSpan | null;
87
+ /**
88
+ * The entry's own bytes: header start (the marker when there is one, else the
89
+ * key) through the end of the value. Key then value, in order.
90
+ */
91
+ span: GuiSpan;
92
+ /** 0-based line of `span.start`. */
93
+ line: number;
94
+ /** 0-based line of the last byte of `span`. */
95
+ endLine: number;
96
+ /** Whitespace between the start of `line` and `span.start` (empty when the entry shares a line). */
97
+ indent: string;
98
+ /**
99
+ * The entry starts its line and nothing but a comment follows it on its last
100
+ * line. `commentSpan`/`lineSpan`/`blockSpan` are only meaningful when true;
101
+ * a line-sharing declaration can only be addressed by `span` (W14, W16).
102
+ */
103
+ ownLine: boolean;
104
+ /** The run of comment-only lines directly above, with no blank line between (W13). */
105
+ commentSpan: GuiSpan | null;
106
+ /** A comment after the entry on its last line: information the line still carries (W04). */
107
+ trailingComment: GuiSpan | null;
108
+ /** Attached comments plus the entry's own lines, trailing newline included. */
109
+ lineSpan: GuiSpan;
110
+ /** `lineSpan` plus the blank lines below it, which belong to the block above (W12). */
111
+ blockSpan: GuiSpan;
112
+ /** Present when the value is a block (attribute blocks included). */
113
+ body: GuiBody | null;
114
+ /**
115
+ * The CST block `body` was built from. The model is a second VIEW of the
116
+ * toolkit's CST, so keeping the node costs nothing and lets a reader that
117
+ * needs the parser's own shape (template/type expansion) start from the same
118
+ * entry the writer addresses, instead of re-finding the widget its own way.
119
+ */
120
+ block: BlockNode | null;
121
+ parent: GuiEntry | null;
122
+ }
123
+
124
+ /** A `{ ... }` body, or the document root, with the facts a structural edit needs. */
125
+ export interface GuiBody {
126
+ /** Offset of `{`, or -1 for the document root. */
127
+ open: number;
128
+ /** Offset of `}`, or null when the brace is missing (root, or a parse error). */
129
+ close: number | null;
130
+ /** Between the braces (the whole document for the root). */
131
+ inner: GuiSpan;
132
+ /** Every `key [op] value` statement of this body, in source order. */
133
+ entries: GuiEntry[];
134
+ /** The subset that declares something: the reorder/insert/delete sibling list. */
135
+ children: GuiEntry[];
136
+ /** Open and close brace on the same line. */
137
+ singleLine: boolean;
138
+ /** No statements at all (`{}` or `{ }`). */
139
+ empty: boolean;
140
+ /** Nothing but whitespace before `}` on its line, so a new line can be inserted above it. */
141
+ closeOwnLine: boolean;
142
+ /** The body's own indent, from its first line-owning entry; null when it has none. */
143
+ indent: string | null;
144
+ /**
145
+ * The children tile the body with nothing between them, so a reorder is a
146
+ * pure permutation. False for an interleaved body, whose round trip is
147
+ * legitimately not the identity (W14, S03).
148
+ */
149
+ contiguous: boolean;
150
+ /**
151
+ * Where a new child is appended: after the last child's own lines (NOT after
152
+ * its blank separators, so an insert and a delete are exact inverses), or,
153
+ * with no children, backed up over a trailing comment run so an insert never
154
+ * lands below commented-out code (W24).
155
+ */
156
+ appendAfter: number;
157
+ }
158
+
159
+ export interface GuiSourceFile {
160
+ text: string;
161
+ /** The file's own line ending: every inserted line follows it (W06). */
162
+ newline: "\n" | "\r\n";
163
+ /** The file's own indent unit: a tab, or the narrowest space indent it uses (W06, W15, W20). */
164
+ indentUnit: string;
165
+ root: GuiBody;
166
+ /** Every entry in the document, depth first, in source order. */
167
+ entries: GuiEntry[];
168
+ /** Parse errors from the CST; a file with errors is not safe to edit. */
169
+ errors: ParseError[];
170
+ lines: LineIndex;
171
+ }
172
+
173
+ interface BuildCtx {
174
+ text: string;
175
+ lines: LineIndex;
176
+ /** Per line: blank, comment-only, or code. */
177
+ lineKind: Uint8Array;
178
+ commentsByLine: Map<number, CommentNode[]>;
179
+ entries: GuiEntry[];
180
+ }
181
+
182
+ const LINE_BLANK = 0;
183
+ const LINE_COMMENT = 1;
184
+ const LINE_CODE = 2;
185
+
186
+ export function parseGuiSource(text: string): GuiSourceFile {
187
+ const parsed = parseScript(text);
188
+ const lines = new LineIndex(text);
189
+ const commentsByLine = new Map<number, CommentNode[]>();
190
+ for (const c of parsed.comments) {
191
+ const list = commentsByLine.get(c.line);
192
+ if (list) list.push(c);
193
+ else commentsByLine.set(c.line, [c]);
194
+ }
195
+ const ctx: BuildCtx = {
196
+ text,
197
+ lines,
198
+ lineKind: classifyLines(text, lines, commentsByLine),
199
+ commentsByLine,
200
+ entries: [],
201
+ };
202
+ const root = buildBody(parsed.root.statements, -1, null, { start: 0, end: text.length }, null, ctx);
203
+ return {
204
+ text,
205
+ newline: detectNewline(text),
206
+ indentUnit: detectIndentUnit(text, lines),
207
+ root,
208
+ entries: ctx.entries,
209
+ errors: parsed.errors,
210
+ lines,
211
+ };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Reads the later stages are built on
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * The declaration whose statement starts on `line`: the same 0-based line the
220
+ * layout engine reports for a node, so a preview selection maps to its source.
221
+ * Depth first, outermost first. Null means the node has no source here (a
222
+ * template-expanded or otherwise synthetic node), which is a refusal (W09).
223
+ */
224
+ export function findWidgetAtLine(file: GuiSourceFile, line: number): GuiEntry | null {
225
+ for (const entry of file.entries) {
226
+ if (entry.kind === "property") continue;
227
+ if (entry.line === line) return entry;
228
+ if (file.lines.positionAt(entry.keySpan.start).line === line) return entry;
229
+ }
230
+ return null;
231
+ }
232
+
233
+ /**
234
+ * A body's entry for `key`, matched case-insensitively. The LAST occurrence
235
+ * wins: duplicate keys inside one body resolve last-in-wins in the engine, so
236
+ * that is the one a write has to rewrite (W02, W07).
237
+ */
238
+ export function findEntry(body: GuiBody, key: string): GuiEntry | null {
239
+ const lower = key.toLowerCase();
240
+ let found: GuiEntry | null = null;
241
+ for (const entry of body.entries) {
242
+ if (entry.keyLower === lower) found = entry;
243
+ }
244
+ return found;
245
+ }
246
+
247
+ /** The indent a new entry inside `body` takes: the body's own, else one unit deeper than its owner. */
248
+ export function bodyIndent(file: GuiSourceFile, body: GuiBody, owner: GuiEntry | null): string {
249
+ if (body.indent !== null) return body.indent;
250
+ return (owner?.ownLine ? owner.indent : "") + file.indentUnit;
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Construction
255
+ // ---------------------------------------------------------------------------
256
+
257
+ function buildBody(
258
+ statements: Statement[],
259
+ open: number,
260
+ close: number | null,
261
+ inner: GuiSpan,
262
+ owner: GuiEntry | null,
263
+ ctx: BuildCtx
264
+ ): GuiBody {
265
+ const entries: GuiEntry[] = [];
266
+ let marker: { text: string; span: GuiSpan } | null = null;
267
+ for (const stmt of statements) {
268
+ if (stmt.kind === "value") {
269
+ // A bare `template` / `type` / `blockoverride` word labels the next
270
+ // assignment; anything else clears the label.
271
+ marker =
272
+ stmt.value.kind === "scalar" && DECL_MARKERS.has(stmt.value.text.toLowerCase())
273
+ ? { text: stmt.value.text.toLowerCase(), span: stmt.value.range }
274
+ : null;
275
+ continue;
276
+ }
277
+ const own = marker;
278
+ marker = null;
279
+ entries.push(buildEntry(stmt, own, owner, ctx));
280
+ }
281
+
282
+ const children = entries.filter((e) => e.kind !== "property");
283
+ const closeLine = close === null ? -1 : ctx.lines.positionAt(close).line;
284
+ const closeOwnLine =
285
+ close !== null && /^[ \t]*$/.test(ctx.text.slice(ctx.lines.lineStart(closeLine), close));
286
+ let indent: string | null = null;
287
+ for (const entry of entries) {
288
+ if (entry.ownLine) {
289
+ indent = entry.indent;
290
+ break;
291
+ }
292
+ }
293
+
294
+ let contiguous = true;
295
+ for (let i = 0; i + 1 < children.length; i++) {
296
+ if (
297
+ !children[i].ownLine ||
298
+ !children[i + 1].ownLine ||
299
+ children[i].blockSpan.end !== children[i + 1].blockSpan.start
300
+ ) {
301
+ contiguous = false;
302
+ break;
303
+ }
304
+ }
305
+
306
+ return {
307
+ open,
308
+ close,
309
+ inner,
310
+ entries,
311
+ children,
312
+ singleLine: open >= 0 && close !== null && ctx.lines.positionAt(open).line === closeLine,
313
+ empty: statements.length === 0,
314
+ closeOwnLine,
315
+ indent,
316
+ contiguous,
317
+ appendAfter: appendPoint(children, close, closeLine, closeOwnLine, ctx),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * W24: append after the last child's own lines, not after its blank
323
+ * separators, or an insert and its delete would disagree about one blank line.
324
+ * With no children, back up from the closing brace over a run of comment-only
325
+ * lines, so a new widget lands above commented-out code instead of below it.
326
+ */
327
+ function appendPoint(
328
+ children: GuiEntry[],
329
+ close: number | null,
330
+ closeLine: number,
331
+ closeOwnLine: boolean,
332
+ ctx: BuildCtx
333
+ ): number {
334
+ const last = children[children.length - 1];
335
+ if (last && last.ownLine) return last.lineSpan.end;
336
+ // A body whose `}` shares a line with content has no line to insert above.
337
+ if (close !== null && !closeOwnLine) return close;
338
+ // The document root ends at the last line that carries anything.
339
+ let line = closeLine;
340
+ if (close === null) {
341
+ line = ctx.lines.lineCount;
342
+ while (line > 0 && ctx.lineKind[line - 1] === LINE_BLANK) line--;
343
+ }
344
+ while (line > 0 && ctx.lineKind[line - 1] === LINE_COMMENT) line--;
345
+ return ctx.lines.lineStart(line);
346
+ }
347
+
348
+ function buildEntry(
349
+ stmt: AssignmentNode,
350
+ declMarker: { text: string; span: GuiSpan } | null,
351
+ parent: GuiEntry | null,
352
+ ctx: BuildCtx
353
+ ): GuiEntry {
354
+ const { text, lines } = ctx;
355
+ const opSpan = operatorSpan(text, stmt);
356
+ const slot = slotForm(stmt, declMarker);
357
+ const marker = slot?.marker ?? declMarker;
358
+ const key = slot?.key ?? stmt.key;
359
+ const value = slot?.value ?? stmt.value;
360
+ const block = blockOf(value);
361
+ const start = marker ? marker.span.start : key.range.start;
362
+ const end = value ? value.range.end : stmt.range.end;
363
+ const span: GuiSpan = { start, end };
364
+ const line = lines.positionAt(start).line;
365
+ const endLine = lines.positionAt(Math.max(start, end - 1)).line;
366
+ const lineStart = lines.lineStart(line);
367
+ const indentText = text.slice(lineStart, start);
368
+ const startsLine = /^[ \t]*$/.test(indentText);
369
+
370
+ // A comment after the entry does not break line ownership, but it is
371
+ // information the line still carries, so a remove is entry-granular (W04).
372
+ const lineEnd = lines.lineStart(endLine + 1);
373
+ let trailingComment: GuiSpan | null = null;
374
+ let endsLine = /^[ \t\r\n]*$/.test(text.slice(end, lineEnd));
375
+ if (!endsLine) {
376
+ for (const c of ctx.commentsByLine.get(endLine) ?? []) {
377
+ if (c.range.start < end) continue;
378
+ if (!/^[ \t]*$/.test(text.slice(end, c.range.start))) break;
379
+ trailingComment = c.range;
380
+ endsLine = true;
381
+ break;
382
+ }
383
+ }
384
+ const ownLine = startsLine && endsLine;
385
+
386
+ const entry: GuiEntry = {
387
+ kind: classify(key, marker, block),
388
+ key: key.text,
389
+ keyLower: key.text.toLowerCase(),
390
+ keySpan: key.range,
391
+ keyQuoted: key.quoted,
392
+ op: stmt.op,
393
+ opSpan,
394
+ value: value ? normalizeValue(text, value) : null,
395
+ valueSpan: value ? value.range : null,
396
+ valueKind: value === null ? "none" : value.kind === "scalar" ? "scalar" : "block",
397
+ valueQuoted: value?.kind === "scalar" ? value.quoted : false,
398
+ base: value?.kind === "tagged-block" ? value.tag.text : null,
399
+ marker: marker?.text ?? null,
400
+ markerSpan: marker?.span ?? null,
401
+ span,
402
+ line,
403
+ endLine,
404
+ indent: startsLine ? indentText : "",
405
+ ownLine,
406
+ commentSpan: null,
407
+ trailingComment,
408
+ lineSpan: span,
409
+ blockSpan: span,
410
+ body: null,
411
+ block,
412
+ parent,
413
+ };
414
+
415
+ if (ownLine) {
416
+ let first = line;
417
+ while (first > 0 && ctx.lineKind[first - 1] === LINE_COMMENT) first--;
418
+ const from = lines.lineStart(first);
419
+ entry.commentSpan = first === line ? null : { start: from, end: lineStart };
420
+ entry.lineSpan = { start: from, end: lineEnd };
421
+ // Blank lines below belong to the block above them, which is what makes a
422
+ // move a pure permutation and a move-and-move-back the identity (W12).
423
+ let below = endLine + 1;
424
+ while (below < ctx.lineKind.length && ctx.lineKind[below] === LINE_BLANK) below++;
425
+ entry.blockSpan = { start: from, end: lines.lineStart(below) };
426
+ }
427
+
428
+ ctx.entries.push(entry);
429
+ if (block) {
430
+ entry.body = buildBody(
431
+ block.statements,
432
+ block.openBrace,
433
+ block.closeBrace,
434
+ { start: block.openBrace + 1, end: block.closeBrace ?? block.range.end },
435
+ entry,
436
+ ctx
437
+ );
438
+ }
439
+ return entry;
440
+ }
441
+
442
+ /**
443
+ * The inverse rule, taken from the layout engine's own attribute-block set so
444
+ * the writer and the preview can never disagree about what is a widget: a
445
+ * block child is a widget unless its key is a known attribute block.
446
+ */
447
+ function classify(key: ScalarNode, marker: { text: string } | null, block: BlockNode | null): GuiEntryKind {
448
+ if (marker) return "decl";
449
+ if (!block) return "property";
450
+ const lower = key.text.toLowerCase();
451
+ if (PROPERTY_BLOCKS.has(lower) || lower.startsWith("@")) return "property";
452
+ return "widget";
453
+ }
454
+
455
+ /** The `blockoverride = "name" { ... }` spelling, re-read as marker + name + body. */
456
+ function slotForm(
457
+ stmt: AssignmentNode,
458
+ marker: { text: string; span: GuiSpan } | null
459
+ ): { marker: { text: string; span: GuiSpan }; key: ScalarNode; value: BlockNode } | null {
460
+ if (marker || stmt.value?.kind !== "tagged-block") return null;
461
+ const lower = stmt.key.text.toLowerCase();
462
+ if (!SLOT_KEYS.has(lower)) return null;
463
+ return {
464
+ marker: { text: lower, span: stmt.key.range },
465
+ key: stmt.value.tag,
466
+ value: stmt.value.block,
467
+ };
468
+ }
469
+
470
+ function blockOf(value: ValueNode | null): BlockNode | null {
471
+ if (!value) return null;
472
+ if (value.kind === "block") return value;
473
+ if (value.kind === "tagged-block") return value.block;
474
+ return null;
475
+ }
476
+
477
+ /**
478
+ * The operator token's own bytes. The CST records the operator's TEXT but not
479
+ * its range; it is the first content between the key and the value, so finding
480
+ * it costs one trivia skip rather than a re-tokenize.
481
+ */
482
+ function operatorSpan(text: string, stmt: AssignmentNode): GuiSpan | null {
483
+ if (stmt.op === null) return null;
484
+ const at = skipTrivia(text, stmt.key.range.end);
485
+ if (!text.startsWith(stmt.op, at)) return null;
486
+ return { start: at, end: at + stmt.op.length };
487
+ }
488
+
489
+ function skipTrivia(text: string, from: number): number {
490
+ let i = from;
491
+ while (i < text.length) {
492
+ const c = text.charCodeAt(i);
493
+ if (c === 32 || c === 9 || c === 10 || c === 13 || c === 12 || c === 11) {
494
+ i++;
495
+ continue;
496
+ }
497
+ if (c === 35 /* # */) {
498
+ while (i < text.length && text.charCodeAt(i) !== 10 && text.charCodeAt(i) !== 13) i++;
499
+ continue;
500
+ }
501
+ break;
502
+ }
503
+ return i;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Value normalization (S01)
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /**
511
+ * The comparison form of a value. A quoted scalar loses its quotes (the model
512
+ * value is the string, the span keeps the bytes); a bare scalar is verbatim, so
513
+ * a compound `top|left` stays ONE value and its span covers both sides of the
514
+ * pipe (W08); a block is rendered from its tokens with single spaces, so the
515
+ * rendering is independent of the interior whitespace the span preserves.
516
+ */
517
+ export function normalizeValue(text: string, value: ValueNode): string {
518
+ if (value.kind === "scalar") return value.text;
519
+ return renderValue(text, value);
520
+ }
521
+
522
+ function renderValue(text: string, value: ValueNode): string {
523
+ if (value.kind === "scalar") return text.slice(value.range.start, value.range.end);
524
+ if (value.kind === "tagged-block") {
525
+ return `${text.slice(value.tag.range.start, value.tag.range.end)} ${renderValue(text, value.block)}`;
526
+ }
527
+ const parts = value.statements.map((s) => renderStatement(text, s));
528
+ return parts.length === 0 ? "{}" : `{ ${parts.join(" ")} }`;
529
+ }
530
+
531
+ function renderStatement(text: string, stmt: Statement): string {
532
+ if (stmt.kind === "value") return renderValue(text, stmt.value);
533
+ const key = text.slice(stmt.key.range.start, stmt.key.range.end);
534
+ const value = stmt.value ? renderValue(text, stmt.value) : "";
535
+ if (stmt.op === null) return stmt.value ? `${key} ${value}` : key;
536
+ return stmt.value ? `${key} ${stmt.op} ${value}` : `${key} ${stmt.op}`;
537
+ }
538
+
539
+ // ---------------------------------------------------------------------------
540
+ // Line facts
541
+ // ---------------------------------------------------------------------------
542
+
543
+ function classifyLines(
544
+ text: string,
545
+ lines: LineIndex,
546
+ commentsByLine: Map<number, CommentNode[]>
547
+ ): Uint8Array {
548
+ const kinds = new Uint8Array(lines.lineCount);
549
+ for (let line = 0; line < lines.lineCount; line++) {
550
+ const start = lines.lineStart(line);
551
+ const end = lines.lineStart(line + 1);
552
+ const raw = text.slice(start, end);
553
+ if (/^\s*$/.test(raw)) {
554
+ kinds[line] = LINE_BLANK;
555
+ continue;
556
+ }
557
+ // Comment-only means the first content on the line is a comment token; a
558
+ // `#` inside a quoted string is not one, which is why this reads the
559
+ // parser's comment list instead of searching the text.
560
+ const contentAt = start + (/^[ \t]*/.exec(raw)?.[0].length ?? 0);
561
+ const isComment = (commentsByLine.get(line) ?? []).some((c) => c.range.start === contentAt);
562
+ kinds[line] = isComment ? LINE_COMMENT : LINE_CODE;
563
+ }
564
+ return kinds;
565
+ }
566
+
567
+ /** The file's line ending, by majority: a CRLF file gets CRLF inserts (W06). */
568
+ function detectNewline(text: string): "\n" | "\r\n" {
569
+ let lf = 0;
570
+ let crlf = 0;
571
+ for (let i = 0; i < text.length; i++) {
572
+ if (text.charCodeAt(i) !== 10) continue;
573
+ lf++;
574
+ if (i > 0 && text.charCodeAt(i - 1) === 13) crlf++;
575
+ }
576
+ return crlf * 2 >= lf && crlf > 0 ? "\r\n" : "\n";
577
+ }
578
+
579
+ /**
580
+ * The file's indent unit: a tab when tabs lead at least as many indented lines
581
+ * as spaces do (vanilla .gui is tab-indented, and a stray aligned comment must
582
+ * not flip a tabbed file), otherwise the narrowest space indent in the file.
583
+ * Per-BODY indent is a string copied verbatim from the body (GuiBody.indent);
584
+ * this unit is only for going one level deeper than a body that has none.
585
+ */
586
+ function detectIndentUnit(text: string, lines: LineIndex): string {
587
+ let tabs = 0;
588
+ let spaces = 0;
589
+ let narrowest = Infinity;
590
+ for (let line = 0; line < lines.lineCount; line++) {
591
+ const start = lines.lineStart(line);
592
+ const raw = text.slice(start, lines.lineStart(line + 1));
593
+ if (/^\s*$/.test(raw)) continue;
594
+ const indent = /^[ \t]*/.exec(raw)?.[0] ?? "";
595
+ if (indent.startsWith("\t")) tabs++;
596
+ else if (indent.length > 0) {
597
+ spaces++;
598
+ narrowest = Math.min(narrowest, indent.length);
599
+ }
600
+ }
601
+ if (tabs >= spaces || !Number.isFinite(narrowest)) return "\t";
602
+ return " ".repeat(narrowest);
603
+ }