@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,439 @@
|
|
|
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
|
+
* paradox/guiSourceEdit backend: one request that turns a designer gesture into
|
|
4
|
+
* text edits over a `.gui` document, or into an honest REFUSAL.
|
|
5
|
+
*
|
|
6
|
+
* The edits themselves are `sourceEdit.ts`'s, over the spans `sourceModel.ts`
|
|
7
|
+
* recorded, so untouched bytes stay byte-identical. What this layer adds is the
|
|
8
|
+
* part a preview cannot see: whether the gesture would do what it looks like it
|
|
9
|
+
* does. A property the engine ignores looks applied in the preview and does
|
|
10
|
+
* nothing in game, which is the worst outcome a visual editor can produce, so
|
|
11
|
+
* the guards below turn those gestures down with a reason instead of writing
|
|
12
|
+
* them (W10, W18, S09).
|
|
13
|
+
*
|
|
14
|
+
* Every layout fact a guard reads comes from the engine itself
|
|
15
|
+
* (`widgetClassOf`) or from the type chain (`typeBaseChain`), never re-derived
|
|
16
|
+
* here: a guard that disagreed with the engine would refuse writes the preview
|
|
17
|
+
* had just shown working.
|
|
18
|
+
*
|
|
19
|
+
* No `vscode` imports: unit-tested in plain Node.
|
|
20
|
+
*/
|
|
21
|
+
import type {
|
|
22
|
+
GuiSourceEditResult,
|
|
23
|
+
GuiSourceOp,
|
|
24
|
+
GuiSourceOpResult,
|
|
25
|
+
GuiTextEdit,
|
|
26
|
+
GuiNewWidget,
|
|
27
|
+
} from "@px-lsp/protocol/protocol";
|
|
28
|
+
import {
|
|
29
|
+
findEntry,
|
|
30
|
+
findWidgetAtLine,
|
|
31
|
+
parseGuiSource,
|
|
32
|
+
type GuiEntry,
|
|
33
|
+
type GuiSourceFile,
|
|
34
|
+
} from "./sourceModel";
|
|
35
|
+
import {
|
|
36
|
+
blockText,
|
|
37
|
+
deleteWidget,
|
|
38
|
+
duplicateWidget,
|
|
39
|
+
insertChild,
|
|
40
|
+
insertProperties,
|
|
41
|
+
insertRawChild,
|
|
42
|
+
removeProperty,
|
|
43
|
+
reorderChild,
|
|
44
|
+
setValue,
|
|
45
|
+
wrapInContainer,
|
|
46
|
+
type GuiEdit,
|
|
47
|
+
} from "./sourceEdit";
|
|
48
|
+
import { typeBaseChain, type GuiDefs } from "./guiDefs";
|
|
49
|
+
import { widgetClassOf, type WidgetClass } from "./layoutEngine";
|
|
50
|
+
import type { BlockNode, Statement } from "../parser";
|
|
51
|
+
|
|
52
|
+
// ── Refusal guards ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** hbox / vbox / flowcontainer: the container places its children itself. */
|
|
55
|
+
function isLayoutContainer(cls: WidgetClass): boolean {
|
|
56
|
+
return cls === "box" || cls === "flow";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The engine's class for an instance key, resolved through the TYPE CHAIN. */
|
|
60
|
+
function classOf(key: string, defs: GuiDefs): WidgetClass {
|
|
61
|
+
const chain = typeBaseChain(key, [defs]);
|
|
62
|
+
return widgetClassOf(chain.length > 0 ? chain[chain.length - 1] : key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why a `position` write on this widget would be ignored, or null when it
|
|
67
|
+
* lands: a layout container owns its children's slots, so a drag inside one
|
|
68
|
+
* changes the file without changing the screen (W10).
|
|
69
|
+
*
|
|
70
|
+
* The engine here still ADDS a box child's position as an offset, and the two
|
|
71
|
+
* engines disagree about that (parity-checklist L23, disputed, one in-game
|
|
72
|
+
* probe outstanding). The writer takes the measured side: the Studio's engine
|
|
73
|
+
* drops it, spec.md is silent, and this engine's own note calls its choice
|
|
74
|
+
* unmeasured. Refusing to write a property the game most likely ignores is the
|
|
75
|
+
* safe half of the disagreement; settling L23 the other way removes this guard.
|
|
76
|
+
*/
|
|
77
|
+
export function positionIgnoredReason(entry: GuiEntry, defs: GuiDefs): string | null {
|
|
78
|
+
const parent = entry.parent;
|
|
79
|
+
if (!parent) return null;
|
|
80
|
+
const cls = classOf(parent.keyLower, defs);
|
|
81
|
+
if (!isLayoutContainer(cls)) return null;
|
|
82
|
+
return `${parent.key} places its children itself, so a position on this child is dropped: the move would change the file without changing anything on screen.`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A size write that is refused outright, or written with one axis warned about. */
|
|
86
|
+
export interface SizeGuard {
|
|
87
|
+
refused?: string;
|
|
88
|
+
warning?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether a `size` write lands (W10, S09). An hbox/vbox sizes to its children
|
|
93
|
+
* whatever the file says; a flowcontainer KEEPS an authored size (in-game
|
|
94
|
+
* probe 2026-08-02, L13e), so it is NOT refused here; a child expanding on
|
|
95
|
+
* BOTH axes inside a layout container has both taken from it; one expanding
|
|
96
|
+
* axis writes and says which axis the container owns. Outside a layout
|
|
97
|
+
* container the policy means nothing and the guard must NOT fire, or it
|
|
98
|
+
* starts refusing resizes the engine would have honoured.
|
|
99
|
+
*/
|
|
100
|
+
export function sizeIgnoredReason(entry: GuiEntry, defs: GuiDefs): SizeGuard {
|
|
101
|
+
const cls = classOf(entry.keyLower, defs);
|
|
102
|
+
if (cls === "box") {
|
|
103
|
+
return {
|
|
104
|
+
refused: `${entry.key} is content-sized: it takes its size from its children and ignores an explicit size.`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const parent = entry.parent;
|
|
108
|
+
if (!parent || !isLayoutContainer(classOf(parent.keyLower, defs))) return {};
|
|
109
|
+
|
|
110
|
+
const horizontal = policyThroughChain(entry, "layoutpolicy_horizontal", defs) === "expanding";
|
|
111
|
+
const vertical = policyThroughChain(entry, "layoutpolicy_vertical", defs) === "expanding";
|
|
112
|
+
if (horizontal && vertical) {
|
|
113
|
+
return {
|
|
114
|
+
refused: `this widget is expanding on both axes inside ${parent.key}, which takes its size from the container: neither axis would change.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (horizontal || vertical) {
|
|
118
|
+
const axis = horizontal ? "width" : "height";
|
|
119
|
+
return {
|
|
120
|
+
warning: `${parent.key} owns the ${axis} of an expanding child, so only the other axis will change.`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A scalar property as the engine resolves it: the instance's own entry first,
|
|
128
|
+
* then the type chain, derived-most first. A `layoutpolicy_*` usually lives in
|
|
129
|
+
* the type definition rather than at the use site, and a guard that only read
|
|
130
|
+
* the instance would miss it.
|
|
131
|
+
*/
|
|
132
|
+
function policyThroughChain(entry: GuiEntry, key: string, defs: GuiDefs): string | null {
|
|
133
|
+
const local = entry.body ? findEntry(entry.body, key) : null;
|
|
134
|
+
if (local?.value != null && local.valueKind === "scalar") return local.value.toLowerCase();
|
|
135
|
+
for (const name of [entry.keyLower, ...typeBaseChain(entry.keyLower, [defs])]) {
|
|
136
|
+
const def = defs.types.get(name.toLowerCase());
|
|
137
|
+
if (!def) continue;
|
|
138
|
+
const found = lastScalar(def.block, key);
|
|
139
|
+
if (found !== null) return found.toLowerCase();
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The last `key = <scalar>` directly in a definition body (last-in-wins). */
|
|
145
|
+
function lastScalar(block: BlockNode, key: string): string | null {
|
|
146
|
+
let found: string | null = null;
|
|
147
|
+
for (const stmt of block.statements as Statement[]) {
|
|
148
|
+
if (stmt.kind !== "assignment" || stmt.value?.kind !== "scalar") continue;
|
|
149
|
+
if (stmt.key.text.toLowerCase() === key) found = stmt.value.text;
|
|
150
|
+
}
|
|
151
|
+
return found;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A `type` definition's body. Other files instantiate it, so a structural edit
|
|
156
|
+
* there reaches further than the preview it was made in shows (W18).
|
|
157
|
+
*/
|
|
158
|
+
function insideTypeDefinition(entry: GuiEntry): boolean {
|
|
159
|
+
for (let e: GuiEntry | null = entry; e; e = e.parent) {
|
|
160
|
+
if (e.marker === "type" || e.marker === "types") return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── The op API ──────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const NO_WIDGET =
|
|
168
|
+
"no editable widget on that line: a node spliced in from a template or a type has no source of its own here.";
|
|
169
|
+
const IN_TYPE =
|
|
170
|
+
"this is inside a type definition, and other files instantiate it: edit the type's own file deliberately rather than through one instance's preview.";
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Answers one {@link GuiSourceOp} against `text`. Null means the request itself
|
|
174
|
+
* makes no sense (an unknown op kind); everything a user can legitimately ask
|
|
175
|
+
* for comes back as edits or as a refusal with a reason.
|
|
176
|
+
*/
|
|
177
|
+
export function computeGuiSourceEdit(
|
|
178
|
+
text: string,
|
|
179
|
+
op: GuiSourceOp | null | undefined,
|
|
180
|
+
defs: GuiDefs
|
|
181
|
+
): GuiSourceEditResult | null {
|
|
182
|
+
if (!op || typeof op.kind !== "string") return null;
|
|
183
|
+
const file = parseGuiSource(text);
|
|
184
|
+
const unparseable = parseRefusal(file);
|
|
185
|
+
if (unparseable) return unparseable;
|
|
186
|
+
return runOp(file, op, defs);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Answers a BATCH against the one authoritative `text`: every op is computed
|
|
191
|
+
* against the SAME source model, so one gesture over several widgets is one
|
|
192
|
+
* edit set, one document change and one undo step.
|
|
193
|
+
*
|
|
194
|
+
* The honesty rules the single-op path has, kept per op: a refusal is that op's
|
|
195
|
+
* own answer and skips only it, and an op whose bytes an EARLIER one already
|
|
196
|
+
* changes is refused rather than dropped, because `applyAll` drops an
|
|
197
|
+
* overlapping edit silently and an op reported as applied must have been.
|
|
198
|
+
*/
|
|
199
|
+
export function computeGuiSourceEdits(
|
|
200
|
+
text: string,
|
|
201
|
+
ops: readonly GuiSourceOp[] | null | undefined,
|
|
202
|
+
defs: GuiDefs
|
|
203
|
+
): GuiSourceEditResult | null {
|
|
204
|
+
if (!Array.isArray(ops)) return null;
|
|
205
|
+
if (ops.length === 0) return { refused: "that gesture named no widgets to change." };
|
|
206
|
+
const file = parseGuiSource(text);
|
|
207
|
+
const unparseable = parseRefusal(file);
|
|
208
|
+
if (unparseable) return unparseable;
|
|
209
|
+
|
|
210
|
+
const results: GuiSourceOpResult[] = [];
|
|
211
|
+
const edits: GuiTextEdit[] = [];
|
|
212
|
+
const warnings: string[] = [];
|
|
213
|
+
for (const op of ops) {
|
|
214
|
+
const answer = !op || typeof op.kind !== "string" ? null : runOp(file, op, defs);
|
|
215
|
+
if (!answer) {
|
|
216
|
+
results.push({ refused: "the server has no such edit.", edits: [] });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const own = answer.edits ?? [];
|
|
220
|
+
if (answer.refused || own.length === 0) {
|
|
221
|
+
results.push({
|
|
222
|
+
refused: answer.refused,
|
|
223
|
+
warning: answer.warning,
|
|
224
|
+
edits: [],
|
|
225
|
+
blockText: answer.blockText,
|
|
226
|
+
});
|
|
227
|
+
if (answer.warning) warnings.push(answer.warning);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (overlapping([...edits, ...own])) {
|
|
231
|
+
results.push({
|
|
232
|
+
refused:
|
|
233
|
+
"another change in the same gesture already rewrites those bytes, so this one was left out: make it on its own.",
|
|
234
|
+
edits: [],
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
edits.push(...own);
|
|
239
|
+
results.push({ warning: answer.warning, edits: own, blockText: answer.blockText });
|
|
240
|
+
if (answer.warning) warnings.push(answer.warning);
|
|
241
|
+
}
|
|
242
|
+
return { edits, results, warning: warnings.length > 0 ? [...new Set(warnings)].join(" ") : undefined };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A document with parse errors has no trustworthy offset, single op or batch. */
|
|
246
|
+
function parseRefusal(file: GuiSourceFile): GuiSourceEditResult | null {
|
|
247
|
+
if (file.errors.length === 0) return null;
|
|
248
|
+
return {
|
|
249
|
+
refused: `this document has ${file.errors.length} parse error(s), so no offset in it can be trusted: fix the syntax first.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function runOp(file: GuiSourceFile, op: GuiSourceOp, defs: GuiDefs): GuiSourceEditResult | null {
|
|
254
|
+
switch (op.kind) {
|
|
255
|
+
case "setProperties":
|
|
256
|
+
return withTarget(file, op.line, (target) => setProperties(file, target, op.properties ?? [], defs));
|
|
257
|
+
|
|
258
|
+
case "reorder":
|
|
259
|
+
return withTarget(file, op.line, (target) =>
|
|
260
|
+
structural(target, () => {
|
|
261
|
+
const edit = reorderChild(file, target, op.from, op.to);
|
|
262
|
+
return edit
|
|
263
|
+
? { edits: [edit] }
|
|
264
|
+
: {
|
|
265
|
+
refused:
|
|
266
|
+
"these children cannot be reordered: the body has fewer than two source children, a declaration shares a line with another, or the move is a no-op.",
|
|
267
|
+
};
|
|
268
|
+
})
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
case "insert":
|
|
272
|
+
return withTarget(file, op.line, (target) =>
|
|
273
|
+
structural(target, () =>
|
|
274
|
+
single(insertChild(file, target, widgetOf(op.widget), indexOf(op.index)), INSERT_REFUSED)
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
case "insertRaw":
|
|
279
|
+
return withTarget(file, op.line, (target) =>
|
|
280
|
+
structural(target, () =>
|
|
281
|
+
single(
|
|
282
|
+
insertRawChild(file, target, op.fragment ?? "", indexOf(op.index)),
|
|
283
|
+
"that text cannot be pasted here: it is blank, it does not parse as widget declarations, or the destination body is a single line a paste would explode."
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
case "delete":
|
|
289
|
+
return withTarget(file, op.line, (target) =>
|
|
290
|
+
structural(target, () => {
|
|
291
|
+
if (!target.parent && file.root.children.length <= 1) {
|
|
292
|
+
return {
|
|
293
|
+
refused: "this is the document's only root widget: deleting it would leave an empty file.",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { edits: [deleteWidget(file, target)] };
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
case "duplicate":
|
|
301
|
+
return withTarget(file, op.line, (target) =>
|
|
302
|
+
structural(target, () =>
|
|
303
|
+
single(
|
|
304
|
+
duplicateWidget(file, target, op.name),
|
|
305
|
+
"this widget cannot be duplicated: it shares its line with another declaration, or the rename has no name entry to rewrite."
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
case "wrap":
|
|
311
|
+
return wrap(file, op.lines ?? [], widgetOf(op.container));
|
|
312
|
+
|
|
313
|
+
case "blockText": {
|
|
314
|
+
return withTarget(file, op.line, (target) => {
|
|
315
|
+
const copied = blockText(file, target);
|
|
316
|
+
return copied === null
|
|
317
|
+
? { refused: "this declaration shares its line with another one, so it has no block to copy." }
|
|
318
|
+
: { edits: [], blockText: copied };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
default:
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const INSERT_REFUSED =
|
|
328
|
+
"a child cannot be inserted here: the widget has no body, or its closing brace shares a line with its last content and there is no line to write on.";
|
|
329
|
+
|
|
330
|
+
function withTarget(
|
|
331
|
+
file: GuiSourceFile,
|
|
332
|
+
line: number,
|
|
333
|
+
run: (target: GuiEntry) => GuiSourceEditResult
|
|
334
|
+
): GuiSourceEditResult {
|
|
335
|
+
const target = findWidgetAtLine(file, line);
|
|
336
|
+
return target ? run(target) : { refused: NO_WIDGET };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Every structural op refuses inside a type definition (W18). */
|
|
340
|
+
function structural(target: GuiEntry, run: () => GuiSourceEditResult): GuiSourceEditResult {
|
|
341
|
+
return insideTypeDefinition(target) ? { refused: IN_TYPE } : run();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function single(edit: GuiEdit | null, refused: string): GuiSourceEditResult {
|
|
345
|
+
return edit ? { edits: [edit] } : { refused };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function widgetOf(widget: GuiNewWidget | undefined): GuiNewWidget {
|
|
349
|
+
return { type: widget?.type ?? "", properties: widget?.properties ?? [] };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function indexOf(index: number | undefined): number {
|
|
353
|
+
return typeof index === "number" ? index : Infinity;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A property batch: rewrites in place where the key already exists, one shared
|
|
358
|
+
* insert for the keys that do not, and the guards run per property so a single
|
|
359
|
+
* ignored write refuses the whole gesture rather than half-applying it.
|
|
360
|
+
*/
|
|
361
|
+
function setProperties(
|
|
362
|
+
file: GuiSourceFile,
|
|
363
|
+
target: GuiEntry,
|
|
364
|
+
properties: readonly { key: string; value: string | null }[],
|
|
365
|
+
defs: GuiDefs
|
|
366
|
+
): GuiSourceEditResult {
|
|
367
|
+
const edits: GuiEdit[] = [];
|
|
368
|
+
const warnings: string[] = [];
|
|
369
|
+
const missing: [string, string][] = [];
|
|
370
|
+
|
|
371
|
+
for (const { key, value } of properties) {
|
|
372
|
+
const lower = key.trim().toLowerCase();
|
|
373
|
+
if (lower.length === 0) continue;
|
|
374
|
+
if (lower === "position") {
|
|
375
|
+
const reason = positionIgnoredReason(target, defs);
|
|
376
|
+
if (reason) return { refused: reason };
|
|
377
|
+
}
|
|
378
|
+
if (lower === "size") {
|
|
379
|
+
const guard = sizeIgnoredReason(target, defs);
|
|
380
|
+
if (guard.refused) return { refused: guard.refused };
|
|
381
|
+
if (guard.warning) warnings.push(guard.warning);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const existing = target.body ? findEntry(target.body, key) : null;
|
|
385
|
+
const local = existing?.kind === "property" ? existing : null;
|
|
386
|
+
if (value === null) {
|
|
387
|
+
const edit = local ? removeProperty(file, local) : null;
|
|
388
|
+
if (edit) edits.push(edit);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (local) {
|
|
392
|
+
const edit = setValue(file, local, value);
|
|
393
|
+
if (edit) edits.push(edit);
|
|
394
|
+
} else if (target.body && target.body.close !== null) {
|
|
395
|
+
// No local entry: a template- or type-supplied value is overridden HERE,
|
|
396
|
+
// and the definition's own bytes are untouched (W09).
|
|
397
|
+
missing.push([key, value]);
|
|
398
|
+
} else {
|
|
399
|
+
return { refused: "this widget has no body to write a property into." };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const insert = missing.length > 0 ? insertProperties(file, target, missing) : null;
|
|
404
|
+
if (insert) edits.push(insert);
|
|
405
|
+
if (overlapping(edits)) {
|
|
406
|
+
return {
|
|
407
|
+
refused:
|
|
408
|
+
"these changes cannot be expressed as one batch of independent edits: apply them one at a time.",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return { edits, warning: warnings.length > 0 ? warnings.join(" ") : undefined };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Wrap: every line must resolve to a widget, and they must be siblings (W22). */
|
|
415
|
+
function wrap(file: GuiSourceFile, lines: readonly number[], container: GuiNewWidget): GuiSourceEditResult {
|
|
416
|
+
const members: GuiEntry[] = [];
|
|
417
|
+
for (const line of lines) {
|
|
418
|
+
const entry = findWidgetAtLine(file, line);
|
|
419
|
+
if (!entry) return { refused: NO_WIDGET };
|
|
420
|
+
if (insideTypeDefinition(entry)) return { refused: IN_TYPE };
|
|
421
|
+
members.push(entry);
|
|
422
|
+
}
|
|
423
|
+
const edits = wrapInContainer(file, members, container);
|
|
424
|
+
return edits
|
|
425
|
+
? { edits }
|
|
426
|
+
: {
|
|
427
|
+
refused:
|
|
428
|
+
"these widgets cannot be wrapped: the selection is empty, they are not children of the same body, or one shares its line with another declaration.",
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Two edits that touch the same bytes cannot both be applied (W05, W23). */
|
|
433
|
+
function overlapping(edits: readonly GuiTextEdit[]): boolean {
|
|
434
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start);
|
|
435
|
+
for (let i = 0; i + 1 < sorted.length; i++) {
|
|
436
|
+
if (sorted[i].end > sorted[i + 1].start) return true;
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|