@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.
- package/LICENSE +674 -0
- package/README.md +334 -0
- package/THIRD-PARTY-NOTICES.md +83 -0
- package/data/ck3/dataTypes.json +2195 -0
- package/data/ck3/data_types/data_types_common.txt +2040 -0
- package/data/ck3/data_types/data_types_gui.txt +5264 -0
- package/data/ck3/data_types/data_types_internalclausewitzgui.txt +14843 -0
- package/data/ck3/data_types/data_types_script.txt +4251 -0
- package/data/ck3/data_types/data_types_uncategorized.txt +109984 -0
- package/data/ck3/freqs.json +1 -0
- package/data/ck3/guiSchema.json +6344 -0
- package/data/ck3/script_docs/effects.log +16059 -0
- package/data/ck3/script_docs/event_targets.log +2098 -0
- package/data/ck3/script_docs/modifiers.log +2228 -0
- package/data/ck3/script_docs/on_actions.log +5275 -0
- package/data/ck3/script_docs/triggers.log +11991 -0
- package/data/ck3/structures.json +9743 -0
- package/data/ck3/wikidocs/ATTRIBUTION.md +18 -0
- package/data/ck3/wikidocs/Data_types.md +2568 -0
- package/data/ck3/wikidocs/Effects_list.md +1176 -0
- package/data/ck3/wikidocs/Scopes_list.md +341 -0
- package/data/ck3/wikidocs/Triggers_list.md +1097 -0
- package/data/eu5/data_types/data_types_common.txt +2087 -0
- package/data/eu5/data_types/data_types_gui.txt +6732 -0
- package/data/eu5/data_types/data_types_internalclausewitzgui.txt +19276 -0
- package/data/eu5/data_types/data_types_script.txt +5688 -0
- package/data/eu5/data_types/data_types_uncategorized.txt +135569 -0
- package/data/vic3/data_types/data_types_common.txt +2021 -0
- package/data/vic3/data_types/data_types_gui.txt +5592 -0
- package/data/vic3/data_types/data_types_internalclausewitzgui.txt +17304 -0
- package/data/vic3/data_types/data_types_script.txt +2817 -0
- package/data/vic3/data_types/data_types_uncategorized.txt +84354 -0
- package/data/vic3/freqs.json +1 -0
- package/data/vic3/guiSchema.json +5578 -0
- package/data/vic3/script_docs/effects.log +38135 -0
- package/data/vic3/script_docs/event_targets.log +2028 -0
- package/data/vic3/script_docs/modifiers.log +18954 -0
- package/data/vic3/script_docs/on_actions.log +1561 -0
- package/data/vic3/script_docs/triggers.log +15738 -0
- package/data/vic3/structures.json +10189 -0
- package/dist/server.js +63668 -0
- package/media/px-lsp.svg +12 -0
- package/package.json +50 -0
- package/src/clientMode.ts +60 -0
- package/src/coa/coa.ts +184 -0
- package/src/coa/coaParse.ts +267 -0
- package/src/context.ts +78 -0
- package/src/contextKeywords.ts +224 -0
- package/src/data/dataBindingMacros.ts +82 -0
- package/src/data/dataFnDocs.ts +152 -0
- package/src/data/dataFnUsage.ts +431 -0
- package/src/data/dataTypes.ts +279 -0
- package/src/data/defines.ts +123 -0
- package/src/data/docsParser.ts +453 -0
- package/src/data/keywordDocs.ts +98 -0
- package/src/data/modifierTemplates.ts +143 -0
- package/src/data/textFormatting.ts +165 -0
- package/src/data/wikiDocs.ts +187 -0
- package/src/dds/decoder.ts +1007 -0
- package/src/dds/encode.ts +235 -0
- package/src/dds/index.ts +58 -0
- package/src/dds/png.ts +96 -0
- package/src/dds/tga.ts +62 -0
- package/src/documents.ts +35 -0
- package/src/features/assetPaths.ts +169 -0
- package/src/features/codeActions.ts +148 -0
- package/src/features/colors.ts +244 -0
- package/src/features/completion.ts +961 -0
- package/src/features/datafunction.ts +729 -0
- package/src/features/definition.ts +84 -0
- package/src/features/diagnostics.ts +244 -0
- package/src/features/folding.ts +106 -0
- package/src/features/formatting.ts +60 -0
- package/src/features/guiLanguage.ts +366 -0
- package/src/features/guiNavigation.ts +140 -0
- package/src/features/guiTree.ts +97 -0
- package/src/features/hover.ts +817 -0
- package/src/features/hoverRender.ts +222 -0
- package/src/features/inlayHints.ts +147 -0
- package/src/features/locFormatting.ts +127 -0
- package/src/features/references.ts +70 -0
- package/src/features/rename.ts +135 -0
- package/src/features/scopeAt.ts +65 -0
- package/src/features/semanticTokens.ts +188 -0
- package/src/features/signatureHelp.ts +72 -0
- package/src/features/symbols.ts +241 -0
- package/src/features/textureHover.ts +143 -0
- package/src/features/workspaceSymbols.ts +69 -0
- package/src/games/active.ts +19 -0
- package/src/games/ck3/ambientScopes.ts +273 -0
- package/src/games/ck3/index.ts +38 -0
- package/src/games/ck3/meta.ts +28 -0
- package/src/games/ck3/modifierPlaceholders.ts +61 -0
- package/src/games/ck3/saveSchema.ts +134 -0
- package/src/games/ck3/scaffolds.ts +197 -0
- package/src/games/ck3/schema.ts +422 -0
- package/src/games/ck3/structures.ts +887 -0
- package/src/games/eu5/index.ts +75 -0
- package/src/games/eu5/meta.ts +44 -0
- package/src/games/eu5/scaffolds.ts +49 -0
- package/src/games/eu5/schema.generated.ts +1043 -0
- package/src/games/jomini/variables.ts +134 -0
- package/src/games/profile.ts +205 -0
- package/src/games/registry.ts +27 -0
- package/src/games/vic3/index.ts +52 -0
- package/src/games/vic3/meta.ts +55 -0
- package/src/games/vic3/saveSchema.ts +77 -0
- package/src/games/vic3/scaffolds.ts +135 -0
- package/src/games/vic3/schema.ts +650 -0
- package/src/games/vic3/structures.ts +33 -0
- package/src/gui/anchorSpec.ts +66 -0
- package/src/gui/declMarkers.ts +30 -0
- package/src/gui/fillGeometry.ts +101 -0
- package/src/gui/guiDefs.ts +386 -0
- package/src/gui/guiDependencies.ts +352 -0
- package/src/gui/guiLinks.ts +64 -0
- package/src/gui/layoutEngine.ts +1998 -0
- package/src/gui/layoutService.ts +221 -0
- package/src/gui/measuredMetrics.ts +21 -0
- package/src/gui/previewService.ts +89 -0
- package/src/gui/saveSchema.ts +220 -0
- package/src/gui/saveValues.ts +399 -0
- package/src/gui/saveZip.ts +60 -0
- package/src/gui/sourceEdit.ts +535 -0
- package/src/gui/sourceEditService.ts +439 -0
- package/src/gui/sourceModel.ts +603 -0
- package/src/gui/textResolve.ts +145 -0
- package/src/gui/textureInfo.ts +106 -0
- package/src/gui/vocabulary.ts +149 -0
- package/src/gui/widgetEdit.ts +52 -0
- package/src/gui/widgetInfo.ts +245 -0
- package/src/index/docComments.ts +103 -0
- package/src/index/extract.ts +252 -0
- package/src/index/indexer.ts +369 -0
- package/src/index/intern.ts +101 -0
- package/src/index/lazyRefs.ts +145 -0
- package/src/index/modOrigin.ts +69 -0
- package/src/index/references.ts +534 -0
- package/src/overview/dependencies.ts +240 -0
- package/src/overview/eventBanner.ts +95 -0
- package/src/overview/eventDetail.ts +482 -0
- package/src/overview/eventGraph.ts +617 -0
- package/src/overview/eventVocabulary.ts +214 -0
- package/src/overview/locCoverage.ts +138 -0
- package/src/overview/modOverview.ts +29 -0
- package/src/overview/overrides.ts +89 -0
- package/src/parseCache.ts +81 -0
- package/src/parser/cst.ts +257 -0
- package/src/parser/encoding.ts +106 -0
- package/src/parser/index.ts +7 -0
- package/src/parser/lexer.ts +245 -0
- package/src/parser/locParser.ts +276 -0
- package/src/parser/parser.ts +360 -0
- package/src/schema/freqs.ts +70 -0
- package/src/schema/loader.ts +113 -0
- package/src/schema/types.ts +142 -0
- package/src/scopes/inference.ts +478 -0
- package/src/scopes/model.ts +148 -0
- package/src/scopes/varTypes.ts +290 -0
- package/src/server.ts +1894 -0
- package/src/serverData.ts +98 -0
- package/src/structure.ts +56 -0
- 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
|
+
}
|