@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,535 @@
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 .gui source WRITER, built on the stage-1 span model (`sourceModel.ts`).
4
+ *
5
+ * A `.gui` file is hand-authored, comment-rich and inconsistently formatted, so
6
+ * the writer never re-serializes the document: every change is a surgical
7
+ * replace over the exact span the model recorded for that entry, so untouched
8
+ * bytes stay byte-identical. All offsets are into the SAME text the model was
9
+ * parsed from; the host applies the returned edits (host-owns-text, per
10
+ * EMBEDDING.md), which keeps undo and the live preview in the editor's hands.
11
+ *
12
+ * Stage 2 (this section): property operations. `setProperty` rewrites the LAST
13
+ * source entry for a key (the engine's own last-in-wins override order) or inserts one,
14
+ * `setValue` rewrites one entry, `removeProperty` deletes an entry (whole line
15
+ * when alone, entry-only when a comment shares the line), `applyAll` composes a
16
+ * batch computed against the SAME text, `dropNested` collapses overlapping
17
+ * selections to the outermost. Contract rows W02-W09, swept by S02.
18
+ *
19
+ * No `vscode` imports: unit-tested in plain Node.
20
+ */
21
+ import type { GuiSourceFile, GuiEntry, GuiBody } from "./sourceModel";
22
+ import { findEntry, parseGuiSource } from "./sourceModel";
23
+
24
+ /**
25
+ * One surgical replacement: replace `[start, end)` with `newText`. An insert
26
+ * has `start === end`; a delete has an empty `newText`. Offsets are UTF-16 code
27
+ * units into the request's text, the same units the CST and the span model use.
28
+ */
29
+ export interface GuiEdit {
30
+ start: number;
31
+ end: number;
32
+ newText: string;
33
+ }
34
+
35
+ /** Apply one edit to text. */
36
+ export function applyEdit(text: string, edit: GuiEdit): string {
37
+ return text.slice(0, edit.start) + edit.newText + text.slice(edit.end);
38
+ }
39
+
40
+ /**
41
+ * Apply several edits computed against the SAME original text. Later offsets go
42
+ * first so earlier ones stay valid; edits that insert at the same offset keep
43
+ * their list order in the result. Overlapping edits are dropped rather than
44
+ * corrupting the text (W05, W23).
45
+ */
46
+ export function applyAll(text: string, edits: readonly GuiEdit[]): string {
47
+ if (edits.length === 0) return text;
48
+ const ordered = edits
49
+ .map((edit, index) => ({ edit, index }))
50
+ .sort((a, b) => b.edit.start - a.edit.start || b.index - a.index);
51
+
52
+ let lastStart = Infinity;
53
+ for (const { edit } of ordered) {
54
+ if (edit.start < 0 || edit.end > text.length) continue;
55
+ if (edit.end > lastStart) continue; // overlaps a later (further-right) edit
56
+ text = applyEdit(text, edit);
57
+ lastStart = edit.start;
58
+ }
59
+ return text;
60
+ }
61
+
62
+ // ── Property operations ─────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Sets `key` to `value` on `node`: rewrites the LAST source entry with that key
66
+ * (the engine's last-in-wins override order, W02/W07), or inserts a new entry into the
67
+ * body when the key isn't present in this file (W03). Null when the change can't
68
+ * be expressed: a synthetic node has no `body` here, so a `using`-supplied
69
+ * property with no local entry inserts a LOCAL override and the template's bytes
70
+ * are untouched (W09).
71
+ */
72
+ export function setProperty(file: GuiSourceFile, node: GuiEntry, key: string, value: string): GuiEdit | null {
73
+ if (key.trim().length === 0) return null;
74
+ if (!node.body || node.body.close === null) return null;
75
+
76
+ // findEntry is the case-insensitive last-in-wins lookup (W02); a real .gui
77
+ // never names a child widget with a property key, so its match is the
78
+ // property to rewrite.
79
+ const existing = findEntry(node.body, key);
80
+ if (existing && existing.kind === "property") return setValue(file, existing, value);
81
+
82
+ return insertProperty(file, node, key, value);
83
+ }
84
+
85
+ /**
86
+ * Rewrites one specific entry's value in place. Use this rather than
87
+ * `setProperty` when a key appears several times and a particular row was
88
+ * edited. A value equal to the raw source is a no-op, not a churn edit (W02).
89
+ */
90
+ export function setValue(file: GuiSourceFile, entry: GuiEntry, value: string): GuiEdit | null {
91
+ if (!entry.valueSpan || entry.valueSpan.end > file.text.length) return null;
92
+ value = value.trim();
93
+ if (value.length === 0) return null;
94
+ if (file.text.slice(entry.valueSpan.start, entry.valueSpan.end) === value) return null;
95
+ return { start: entry.valueSpan.start, end: entry.valueSpan.end, newText: value };
96
+ }
97
+
98
+ /**
99
+ * Deletes an entry. When it sits alone on its line the whole line goes
100
+ * (indentation and newline included); when a comment shares the line only the
101
+ * `key = value` span is removed, so the information the line still carries
102
+ * survives (W04).
103
+ */
104
+ export function removeProperty(file: GuiSourceFile, entry: GuiEntry): GuiEdit | null {
105
+ if (!entry.valueSpan || entry.valueSpan.end > file.text.length) return null;
106
+ const text = file.text;
107
+
108
+ const lineStart = file.lines.lineStart(entry.line);
109
+ const aloneBefore = isBlank(text, lineStart, entry.keySpan.start);
110
+
111
+ // Everything after the value up to the newline must be blank too: a trailing
112
+ // comment means the line carries information a remove must not eat.
113
+ let after = entry.valueSpan.end;
114
+ while (after < text.length && text[after] !== "\n") after++;
115
+ let contentEnd = after;
116
+ if (contentEnd > entry.valueSpan.end && text[contentEnd - 1] === "\r") contentEnd--;
117
+ const aloneAfter = isBlank(text, entry.valueSpan.end, contentEnd);
118
+
119
+ if (aloneBefore && aloneAfter) {
120
+ const end = after < text.length ? after + 1 : after; // swallow the newline
121
+ return { start: lineStart, end, newText: "" };
122
+ }
123
+ return removeInline(file, entry);
124
+ }
125
+
126
+ /**
127
+ * Removes an entry that shares its line with something else: its exact bytes
128
+ * plus ONE adjacent separator space, the one before it by preference. Taking
129
+ * the space back is what makes a single-line insert and delete exact inverses
130
+ * instead of leaving `{ a }` behind and growing one space per round trip
131
+ * (W16, W25). An entry that is the ONLY thing between a single-line body's
132
+ * braces takes the whole interior, which is the exact inverse of the insert
133
+ * that filled an empty `{}`. "Only thing" is read off the TEXT, not off the
134
+ * entry list: a body like `{ high light_background widget = {} }` has one
135
+ * entry and two bare values, and clearing it would eat them.
136
+ */
137
+ function removeInline(file: GuiSourceFile, entry: GuiEntry): GuiEdit {
138
+ const text = file.text;
139
+ const body = entry.parent?.body;
140
+ if (
141
+ body?.singleLine &&
142
+ isBlank(text, body.open + 1, entry.span.start) &&
143
+ isBlank(text, entry.span.end, body.close!)
144
+ ) {
145
+ return { start: body.open + 1, end: body.close!, newText: "" };
146
+ }
147
+ let { start, end } = entry.span;
148
+ if (text[start - 1] === " ") start--;
149
+ else if (text[end] === " ") end++;
150
+ return { start, end, newText: "" };
151
+ }
152
+
153
+ /**
154
+ * Collapses a selection to its outermost entries: any entry with a selected
155
+ * ancestor is dropped, so a batch built from the result never contains two
156
+ * overlapping edits (W23). Property writes do NOT go through this; only
157
+ * structural batches, where an overlapping edit would be silently lost.
158
+ */
159
+ export function dropNested(entries: readonly GuiEntry[]): GuiEntry[] {
160
+ const set = new Set(entries);
161
+ return entries.filter((e) => {
162
+ for (let p = e.parent; p; p = p.parent) if (set.has(p)) return false;
163
+ return true;
164
+ });
165
+ }
166
+
167
+ // ── Insertion ───────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * Adds `key = value` to a widget's body, on its own line before the closing
171
+ * brace, at the body's own indent. A single-line body stays single-line; an
172
+ * empty `{}` gets a spaced entry (W06, W25).
173
+ */
174
+ function insertProperty(file: GuiSourceFile, node: GuiEntry, key: string, value: string): GuiEdit | null {
175
+ return insertProperties(file, node, [[key, value]]);
176
+ }
177
+
178
+ /**
179
+ * Adds several properties to one body as ONE edit. A batch computed against the
180
+ * same text would otherwise produce two edits at the same point in an empty
181
+ * single-line body, where each is a REPLACE of the interior and the second is
182
+ * dropped as an overlap; writing them together is both correct and what the
183
+ * author would have typed.
184
+ */
185
+ export function insertProperties(
186
+ file: GuiSourceFile,
187
+ node: GuiEntry,
188
+ properties: readonly (readonly [string, string])[]
189
+ ): GuiEdit | null {
190
+ const body = node.body;
191
+ if (!body || body.close === null) return null;
192
+ const entries: string[] = [];
193
+ for (const [key, raw] of properties) {
194
+ const value = raw.trim();
195
+ if (key.trim().length === 0 || value.length === 0) continue;
196
+ entries.push(`${key} = ${value}`);
197
+ }
198
+ if (entries.length === 0) return null;
199
+
200
+ // Single-line body: keep it on one line rather than exploding it into a block
201
+ // the author never wrote.
202
+ if (body.singleLine) return insertInline(file, body, entries.join(" "));
203
+
204
+ const at = closeLine(file, body);
205
+ const indent = childIndent(file, node);
206
+ return { start: at, end: at, newText: entries.map((e) => `${indent}${e}${file.newline}`).join("") };
207
+ }
208
+
209
+ /**
210
+ * Adds one entry inside a single-line body's braces, with exactly one separator
211
+ * space on each side of it. An empty `{}` becomes `{ entry }`; a body that
212
+ * already has content gets ` entry` appended before the closing brace. The
213
+ * separator is what `removeInline` takes back, so the two are exact inverses
214
+ * (W25, the `{ a }` accumulation bug).
215
+ */
216
+ function insertInline(file: GuiSourceFile, body: GuiBody, entry: string): GuiEdit {
217
+ const text = file.text;
218
+ const close = body.close!;
219
+ const innerStart = body.open + 1;
220
+ if (isBlank(text, innerStart, close)) {
221
+ return { start: innerStart, end: close, newText: ` ${entry} ` };
222
+ }
223
+ // Land BEFORE the body's own trailing gap, so the closing brace keeps the
224
+ // spacing the author gave it (`{a}` stays tight, `{ a }` stays spaced) and
225
+ // the delete takes back exactly the one space this adds.
226
+ let at = close;
227
+ while (at > innerStart && isWhitespace(text[at - 1])) at--;
228
+ return { start: at, end: at, newText: ` ${entry}` };
229
+ }
230
+
231
+ /** The start of the line the body's closing brace sits on. */
232
+ function closeLine(file: GuiSourceFile, body: GuiBody): number {
233
+ return file.lines.lineStart(file.lines.positionAt(body.close!).line);
234
+ }
235
+
236
+ /**
237
+ * The indent a new child entry carries: the body's own (copied verbatim from an
238
+ * existing entry, so it matches the author's style exactly), else the closing
239
+ * brace's own indent plus one unit for an otherwise-empty multi-line body.
240
+ */
241
+ export function childIndent(file: GuiSourceFile, node: GuiEntry): string {
242
+ const body = node.body!;
243
+ if (body.indent !== null) return body.indent;
244
+ const text = file.text;
245
+ const from = closeLine(file, body);
246
+ let end = from;
247
+ while (end < body.close! && (text[end] === " " || text[end] === "\t")) end++;
248
+ return text.slice(from, end) + file.indentUnit;
249
+ }
250
+
251
+ // ── The block model ─────────────────────────────────────────────────────────
252
+
253
+ /**
254
+ * The source siblings of `node`: the declarations its body actually holds, in
255
+ * source order. NOT the template-expanded children a preview shows: a
256
+ * `using`-supplied child has no bytes at the use site, so an index taken from
257
+ * the expanded tree would move the wrong block (W09, W14).
258
+ */
259
+ export function sourceChildren(node: GuiEntry): GuiEntry[] {
260
+ return node.body ? node.body.children : [];
261
+ }
262
+
263
+ /**
264
+ * A widget's block, verbatim: attached comments and the nested body included,
265
+ * the trailing blank separators excluded (W19). Null for a declaration that
266
+ * shares its line with another one, which has no well-formed block to hand out.
267
+ */
268
+ export function blockText(file: GuiSourceFile, entry: GuiEntry): string | null {
269
+ if (!entry.ownLine) return null;
270
+ return file.text.slice(entry.lineSpan.start, entry.lineSpan.end);
271
+ }
272
+
273
+ /** A new declaration to write: `type = { properties }`. */
274
+ export interface NewWidget {
275
+ /** The declaration key: `widget`, `vbox`, or a type name. */
276
+ type: string;
277
+ /** Properties for the new body, in the order they are written. */
278
+ properties?: readonly (readonly [string, string])[];
279
+ }
280
+
281
+ // ── Structural operations ───────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Moves the child at `from` to index `to` among its source siblings, as ONE
285
+ * edit over the run between them. The blocks permute and whatever sits BETWEEN
286
+ * them stays exactly where it is, so a move lands correctly relative to the
287
+ * sibling it was aimed at even in an interleaved body (round-trip identity is
288
+ * then legitimately not the identity there, which is why a sweep skips those,
289
+ * W14/S03). Indices out of range clamp, a same-index move is a no-op, and a
290
+ * body with fewer than two source children or a line-sharing declaration in
291
+ * the run is refused (W11, W14).
292
+ */
293
+ export function reorderChild(
294
+ file: GuiSourceFile,
295
+ parent: GuiEntry,
296
+ from: number,
297
+ to: number
298
+ ): GuiEdit | null {
299
+ const children = sourceChildren(parent);
300
+ if (children.length < 2) return null;
301
+ const last = children.length - 1;
302
+ from = Math.min(Math.max(from, 0), last);
303
+ to = Math.min(Math.max(to, 0), last);
304
+ if (from === to) return null;
305
+
306
+ const lo = Math.min(from, to);
307
+ const run = children.slice(lo, Math.max(from, to) + 1);
308
+ if (run.some((c) => !c.ownLine)) return null;
309
+
310
+ // Blocks and the gaps between them: the gaps keep their slots, the blocks
311
+ // permute through them (W12 gives each block its own trailing blank lines,
312
+ // which is what makes a contiguous move a pure permutation).
313
+ const blocks = run.map((c) => file.text.slice(c.blockSpan.start, c.blockSpan.end));
314
+ const gaps = run.slice(0, -1).map((c, i) => file.text.slice(c.blockSpan.end, run[i + 1].blockSpan.start));
315
+ blocks.splice(to - lo, 0, ...blocks.splice(from - lo, 1));
316
+
317
+ let newText = blocks[0];
318
+ for (let i = 1; i < blocks.length; i++) newText += gaps[i - 1] + blocks[i];
319
+ return { start: run[0].blockSpan.start, end: run[run.length - 1].blockSpan.end, newText };
320
+ }
321
+
322
+ /**
323
+ * Adds a child declaration to `parent`'s body at `index` among its source
324
+ * children, or appends when the index is past the end. An append lands at the
325
+ * last child's block end rather than on the closing brace's line, so it never
326
+ * slips below a trailing run of commented-out code (W24). A single-line body
327
+ * stays single-line, a propertyless widget gets an empty `{}` body rather than
328
+ * a malformed one, and the declaration follows the file's newline and the
329
+ * body's own indent (W15, W06).
330
+ */
331
+ export function insertChild(
332
+ file: GuiSourceFile,
333
+ parent: GuiEntry,
334
+ widget: NewWidget,
335
+ index = Infinity
336
+ ): GuiEdit | null {
337
+ const body = parent.body;
338
+ if (!body || body.close === null || widget.type.trim().length === 0) return null;
339
+
340
+ if (body.singleLine) return insertInline(file, body, formatDeclInline(widget));
341
+ const at = insertPoint(file, body, index);
342
+ if (at < 0) return null;
343
+ return { start: at, end: at, newText: formatDeclBlock(file, childIndent(file, parent), widget, "") };
344
+ }
345
+
346
+ /**
347
+ * Pastes copied `.gui` text as a child of `parent`: the fragment's own common
348
+ * leading whitespace is stripped as a string PREFIX, its interior indent LEVELS
349
+ * are converted to the destination's unit (so no tab survives into a
350
+ * space-indented file), its newlines become the destination's, and it lands by
351
+ * the same rules as `insertChild`. Refused for a blank fragment, for text that
352
+ * does not parse as declarations, and for a single-line destination body, which
353
+ * a multi-line paste would explode (W20).
354
+ */
355
+ export function insertRawChild(
356
+ file: GuiSourceFile,
357
+ parent: GuiEntry,
358
+ fragment: string,
359
+ index = Infinity
360
+ ): GuiEdit | null {
361
+ const body = parent.body;
362
+ if (!body || body.close === null || body.singleLine) return null;
363
+
364
+ const frag = parseGuiSource(fragment);
365
+ if (frag.errors.length > 0 || frag.root.entries.length === 0) return null;
366
+ if (frag.root.entries.some((e) => e.kind === "property")) return null;
367
+
368
+ const at = insertPoint(file, body, index);
369
+ if (at < 0) return null;
370
+ return { start: at, end: at, newText: reindent(file, frag, childIndent(file, parent)) };
371
+ }
372
+
373
+ /**
374
+ * Deletes a widget: its whole block, attached comments included and the blank
375
+ * separators below it excluded, so an insert and a delete are exact inverses
376
+ * (W16). A declaration sharing its line loses its exact bytes plus one adjacent
377
+ * space, which cannot corrupt the neighbour.
378
+ */
379
+ export function deleteWidget(file: GuiSourceFile, entry: GuiEntry): GuiEdit {
380
+ if (!entry.ownLine) return removeInline(file, entry);
381
+ return { start: entry.lineSpan.start, end: entry.lineSpan.end, newText: "" };
382
+ }
383
+
384
+ /**
385
+ * Copies a widget's block in as its own next sibling, directly below the
386
+ * original and inside the same body. `newName` renames ONLY the copy, keeping
387
+ * the original's quoting style; without one the copy is byte-identical to the
388
+ * original. Refused for a line-sharing declaration, for a rename with no `name`
389
+ * entry to rewrite, and for a block with no newline of its own to sit on: the
390
+ * last line of a file that does not end in one (W17).
391
+ */
392
+ export function duplicateWidget(file: GuiSourceFile, entry: GuiEntry, newName?: string): GuiEdit | null {
393
+ let copy = blockText(file, entry);
394
+ if (copy === null || !copy.endsWith("\n")) return null;
395
+
396
+ if (newName !== undefined) {
397
+ const name = newName.trim();
398
+ const source = entry.body ? findEntry(entry.body, "name") : null;
399
+ if (name.length === 0 || !source?.valueSpan) return null;
400
+ const start = source.valueSpan.start - entry.lineSpan.start;
401
+ const end = source.valueSpan.end - entry.lineSpan.start;
402
+ copy = copy.slice(0, start) + (source.valueQuoted ? `"${name}"` : name) + copy.slice(end);
403
+ }
404
+ return { start: entry.lineSpan.end, end: entry.lineSpan.end, newText: copy };
405
+ }
406
+
407
+ /**
408
+ * Wraps `members` in a fresh container placed in the FIRST member's slot: the
409
+ * members move inside in selection order, re-indented one unit, each carrying
410
+ * its attached comment, and a skipped sibling of a non-contiguous selection
411
+ * stays exactly where it was (W22). Returns the batch `applyAll` applies;
412
+ * refused for an empty selection, for members of different bodies, and for a
413
+ * line-sharing declaration, which has no block to move.
414
+ */
415
+ export function wrapInContainer(
416
+ file: GuiSourceFile,
417
+ members: readonly GuiEntry[],
418
+ container: NewWidget
419
+ ): GuiEdit[] | null {
420
+ const first = members[0];
421
+ if (!first || container.type.trim().length === 0) return null;
422
+ if (members.some((m) => m.parent !== first.parent || !m.ownLine)) return null;
423
+
424
+ const inner = members
425
+ .map((m) => indentLines(file.text.slice(m.lineSpan.start, m.lineSpan.end), file.indentUnit))
426
+ .join("");
427
+ const edits: GuiEdit[] = [
428
+ {
429
+ start: first.lineSpan.start,
430
+ end: first.lineSpan.end,
431
+ newText: formatDeclBlock(file, first.indent, container, inner),
432
+ },
433
+ ];
434
+ for (const m of members.slice(1)) {
435
+ edits.push({ start: m.lineSpan.start, end: m.lineSpan.end, newText: "" });
436
+ }
437
+ return edits;
438
+ }
439
+
440
+ // ── Formatting a new declaration ────────────────────────────────────────────
441
+
442
+ /** `type = {}` or `type = { k = v }`, for a body that must stay on one line. */
443
+ function formatDeclInline(widget: NewWidget): string {
444
+ const props = properties(widget);
445
+ const head = `${widget.type.trim()} = {`;
446
+ if (props.length === 0) return `${head}}`;
447
+ return `${head} ${props.map(([k, v]) => `${k} = ${v}`).join(" ")} }`;
448
+ }
449
+
450
+ /**
451
+ * `type = { … }` as its own lines at `indent`, one property per line and
452
+ * `inner` (already indented) between them and the closing brace. With neither
453
+ * it stays the one-line empty `{}`, which is what a propertyless insert writes
454
+ * rather than a two-line body the author never asked for.
455
+ */
456
+ function formatDeclBlock(file: GuiSourceFile, indent: string, widget: NewWidget, inner: string): string {
457
+ const props = properties(widget);
458
+ const nl = file.newline;
459
+ const head = `${indent}${widget.type.trim()} = {`;
460
+ if (props.length === 0 && inner.length === 0) return `${head}}${nl}`;
461
+ const body = props.map(([k, v]) => `${indent}${file.indentUnit}${k} = ${v}${nl}`).join("");
462
+ return `${head}${nl}${body}${inner}${indent}}${nl}`;
463
+ }
464
+
465
+ function properties(widget: NewWidget): readonly (readonly [string, string])[] {
466
+ return (widget.properties ?? []).filter(([key]) => key.trim().length > 0);
467
+ }
468
+
469
+ /** Prefixes every non-blank line of a block with one more indent unit. */
470
+ function indentLines(block: string, unit: string): string {
471
+ return block.replace(/^(?=[^\r\n])/gm, unit);
472
+ }
473
+
474
+ /**
475
+ * Re-indents a copied fragment for its destination. The fragment's own common
476
+ * prefix is a STRING (a tab is never mistaken for n columns), what is left is
477
+ * counted in the fragment's OWN unit and re-emitted in the destination's, and
478
+ * the line endings become the destination's (W20).
479
+ */
480
+ function reindent(file: GuiSourceFile, frag: GuiSourceFile, indent: string): string {
481
+ const prefix = frag.root.indent ?? "";
482
+ const unit = frag.indentUnit;
483
+ const lines: string[] = [];
484
+ for (const raw of frag.text.split(/\r?\n/)) {
485
+ if (raw.trim().length === 0) {
486
+ lines.push("");
487
+ continue;
488
+ }
489
+ let rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw.replace(/^[ \t]*/, "");
490
+ const lead = /^[ \t]*/.exec(rest)![0];
491
+ rest = rest.slice(lead.length);
492
+ let levels = 0;
493
+ let extra = lead;
494
+ while (extra.startsWith(unit)) {
495
+ levels++;
496
+ extra = extra.slice(unit.length);
497
+ }
498
+ lines.push(indent + file.indentUnit.repeat(levels) + extra.replace(/\t/g, file.indentUnit) + rest);
499
+ }
500
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
501
+ return lines.map((line) => line + file.newline).join("");
502
+ }
503
+
504
+ /**
505
+ * Where a new child lands: before the block of the child at `index` (its
506
+ * attached comment included), else the body's append point, which is the last
507
+ * child's own lines rather than the closing-brace line (W15, W24). A child that
508
+ * shares its line has no block boundary to insert at, so the new one goes above
509
+ * that whole line. Returns -1 when the point is not the start of a line, which
510
+ * a body whose `}` shares a line with its last content can produce: writing a
511
+ * line there would split that line, and the delete could not put it back.
512
+ */
513
+ function insertPoint(file: GuiSourceFile, body: GuiBody, index: number): number {
514
+ const target = body.children[index];
515
+ const at = !target
516
+ ? body.appendAfter
517
+ : target.ownLine
518
+ ? target.blockSpan.start
519
+ : file.lines.lineStart(target.line);
520
+ const lineStart = file.lines.lineStart(file.lines.positionAt(at).line);
521
+ return isBlank(file.text, lineStart, at) ? at : -1;
522
+ }
523
+
524
+ // ── Text helpers ─────────────────────────────────────────────────────────────
525
+
526
+ export function isBlank(text: string, start: number, end: number): boolean {
527
+ for (let i = start; i < end && i < text.length; i++) {
528
+ if (!isWhitespace(text[i])) return false;
529
+ }
530
+ return true;
531
+ }
532
+
533
+ function isWhitespace(c: string): boolean {
534
+ return c === " " || c === "\t" || c === "\r" || c === "\n" || c === "\f" || c === "\v";
535
+ }