@plurnk/plurnk-parser 1.18.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 +21 -0
- package/README.md +71 -0
- package/SPEC.md +35 -0
- package/bin/plurnk-parser.js +43 -0
- package/dist/AstBuilder.d.ts +21 -0
- package/dist/AstBuilder.d.ts.map +1 -0
- package/dist/AstBuilder.js +871 -0
- package/dist/AstBuilder.js.map +1 -0
- package/dist/PlurnkErrorStrategy.d.ts +11 -0
- package/dist/PlurnkErrorStrategy.d.ts.map +1 -0
- package/dist/PlurnkErrorStrategy.js +288 -0
- package/dist/PlurnkErrorStrategy.js.map +1 -0
- package/dist/PlurnkParser.d.ts +15 -0
- package/dist/PlurnkParser.d.ts.map +1 -0
- package/dist/PlurnkParser.js +324 -0
- package/dist/PlurnkParser.js.map +1 -0
- package/dist/RecordingListener.d.ts +9 -0
- package/dist/RecordingListener.d.ts.map +1 -0
- package/dist/RecordingListener.js +37 -0
- package/dist/RecordingListener.js.map +1 -0
- package/dist/generated/plurnkLexer.d.ts +172 -0
- package/dist/generated/plurnkLexer.d.ts.map +1 -0
- package/dist/generated/plurnkLexer.js +1168 -0
- package/dist/generated/plurnkLexer.js.map +1 -0
- package/dist/generated/plurnkParser.d.ts +437 -0
- package/dist/generated/plurnkParser.d.ts.map +1 -0
- package/dist/generated/plurnkParser.js +2974 -0
- package/dist/generated/plurnkParser.js.map +1 -0
- package/dist/generated/plurnkParserVisitor.d.ts +263 -0
- package/dist/generated/plurnkParserVisitor.d.ts.map +1 -0
- package/dist/generated/plurnkParserVisitor.js +227 -0
- package/dist/generated/plurnkParserVisitor.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
/// <reference path="./json-p3-esm.d.ts" />
|
|
2
|
+
import { ParserRuleContext, TerminalNode } from "antlr4ng";
|
|
3
|
+
import * as xpath from "xpath";
|
|
4
|
+
import { JSONPathEnvironment } from "json-p3/dist/json-p3.esm.js";
|
|
5
|
+
import { BodyContext, LineMarkerContext, MetadataContext, DispositionStatementContext, SendStatementContext, TargetContext, } from "./generated/plurnkParser.js";
|
|
6
|
+
import { plurnkLexer } from "./generated/plurnkLexer.js";
|
|
7
|
+
import { PathSyntax, PlanValue, PlurnkParseError, TurnDisposition } from "@plurnk/plurnk-contracts";
|
|
8
|
+
export default class AstBuilder {
|
|
9
|
+
// {§misplaced-aside-advisory} — advisories raised while building one statement; the
|
|
10
|
+
// parser drains them right after the statement so the model sees WHAT it did on the first try.
|
|
11
|
+
static #advisories = [];
|
|
12
|
+
static takeAdvisories() {
|
|
13
|
+
const taken = AstBuilder.#advisories;
|
|
14
|
+
AstBuilder.#advisories = [];
|
|
15
|
+
return taken;
|
|
16
|
+
}
|
|
17
|
+
// A body that is solely an HTML comment can never be a matcher. Preserve it
|
|
18
|
+
// as the operation aside and report only that deterministic normalization.
|
|
19
|
+
static #asideBody(op, aside, raw, position) {
|
|
20
|
+
if (raw === null)
|
|
21
|
+
return { aside, raw };
|
|
22
|
+
const comment = /^\s*<!--([\s\S]*?)-->\s*$/u.exec(raw);
|
|
23
|
+
if (comment === null)
|
|
24
|
+
return { aside, raw };
|
|
25
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `The ${op} body contained only an HTML comment; it was applied as the operation aside.`, "warning"));
|
|
26
|
+
return { aside: aside ?? (comment[1] ?? "").trim(), raw: null };
|
|
27
|
+
}
|
|
28
|
+
// {§matcher-option} — `pattern` is the language's key inside `[metadata]`: lifted into the
|
|
29
|
+
// statement's `matcher`, classified exactly as a body matcher was ({§matcher-prefix-claims}).
|
|
30
|
+
// A block that carries only `pattern` leaves no metadata for the owner; beside other keys the
|
|
31
|
+
// block stays for the owner, whose reader skips the reserved key. The block's shape stays the
|
|
32
|
+
// owner's business ({§scheme-metadata-modifier}): a second block or malformed JSON lifts
|
|
33
|
+
// nothing and reaches the owner's 400 untouched; only a present `pattern` that is not a
|
|
34
|
+
// string, or a malformed matcher, is the language's own positioned diagnostic.
|
|
35
|
+
// {§naked-pattern} — one line of matcher text, its trailing aside split back out. A sigil
|
|
36
|
+
// (`/`, `//`, `$`, `~`, `&`, `^`) is a matcher wherever it stands; a sigil-less glob or literal
|
|
37
|
+
// is one only on the heading line of FIND, READ or KILL, where the text can mean nothing else.
|
|
38
|
+
// {§trailing-slots} — the slots after the matcher peel off the right end of the heading text,
|
|
39
|
+
// aside, scope and option block in any order, until what remains is the matcher.
|
|
40
|
+
static #bareMatcher(raw, op, inline, position, carried = { scope: false, metadata: false }) {
|
|
41
|
+
if (raw === null)
|
|
42
|
+
return null;
|
|
43
|
+
let text = raw.trim();
|
|
44
|
+
if (text === "" || text.includes("\n"))
|
|
45
|
+
return null;
|
|
46
|
+
let aside = null;
|
|
47
|
+
let scope = null;
|
|
48
|
+
let metadata = null;
|
|
49
|
+
// A slot the heading already carries is not peeled: a second one is trailing text.
|
|
50
|
+
let scopeFree = !carried.scope;
|
|
51
|
+
let metadataFree = !carried.metadata;
|
|
52
|
+
const scopeTail = op === "FIND" ? AstBuilder.#TAIL_POSITIONS : AstBuilder.#TAIL_TEXT_SCOPE;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const trailingAside = /\s*<!--([\s\S]*?)-->\s*$/u.exec(text);
|
|
55
|
+
if (trailingAside !== null && aside === null) {
|
|
56
|
+
aside = (trailingAside[1] ?? "").trim();
|
|
57
|
+
text = text.slice(0, trailingAside.index).trim();
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const trailingScope = scopeTail.exec(text);
|
|
61
|
+
if (trailingScope !== null && scopeFree && trailingScope.index > 0) {
|
|
62
|
+
scope = trailingScope[1];
|
|
63
|
+
scopeFree = false;
|
|
64
|
+
text = text.slice(0, trailingScope.index).trim();
|
|
65
|
+
AstBuilder.#adviseTrailing(position, `\`${scope}\` after the pattern was read as the scope; the scope goes before the pattern.`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const trailingBlock = /\s*\[(\{[\s\S]*\})\]\s*$/u.exec(text);
|
|
69
|
+
if (trailingBlock !== null && metadataFree && trailingBlock.index > 0 && AstBuilder.#isJsonArrayOfObjects(trailingBlock[1])) {
|
|
70
|
+
metadata = trailingBlock[1];
|
|
71
|
+
metadataFree = false;
|
|
72
|
+
text = text.slice(0, trailingBlock.index).trim();
|
|
73
|
+
AstBuilder.#adviseTrailing(position, `\`[${metadata}]\` after the pattern was read as the option block; options go before the pattern.`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (text === "")
|
|
79
|
+
return null;
|
|
80
|
+
if (AstBuilder.#SIGIL.test(text))
|
|
81
|
+
return { text, aside, scope, metadata };
|
|
82
|
+
if (!inline || (op !== "FIND" && op !== "READ" && op !== "KILL"))
|
|
83
|
+
return null;
|
|
84
|
+
return { text, aside, scope, metadata };
|
|
85
|
+
}
|
|
86
|
+
static #adviseTrailing(position, message) {
|
|
87
|
+
if (position === undefined)
|
|
88
|
+
return;
|
|
89
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", message, "warning"));
|
|
90
|
+
}
|
|
91
|
+
static #isJsonArrayOfObjects(inner) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(`[${inner}]`);
|
|
94
|
+
return Array.isArray(parsed) && parsed.every((element) => typeof element === "object" && element !== null && !Array.isArray(element));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
static #SIGIL = /^(\/|\$|~|&|\^)/u;
|
|
101
|
+
// The scope shapes the lexer admits, matched at the right end of the heading text.
|
|
102
|
+
static #TAIL_POSITIONS = /\s*(<-?[0-9]+(?:\.[0-9]+)?(?:(?:,\s?|-)-?[0-9]+(?:\.[0-9]+)?)*>)\s*$/u;
|
|
103
|
+
static #TAIL_TEXT_SCOPE = /\s*(<(?:-?[0-9]+(?:\.[0-9]+)?|@[0-9A-Za-z]{5}(?:[: ][1-9][0-9]*)?|@[0-9]{1,4})(?:(?:,\s?|-)(?:-?[0-9]+(?:\.[0-9]+)?|@[0-9A-Za-z]{5}(?:[: ][1-9][0-9]*)?|@[0-9]{1,4}))*>)\s*$/u;
|
|
104
|
+
// The body text that opened on the heading line itself, split from the lines beneath it.
|
|
105
|
+
static #splitInlineBody(ctx, position) {
|
|
106
|
+
const text = AstBuilder.#bodyTextOf(ctx);
|
|
107
|
+
if (text === null)
|
|
108
|
+
return { inline: null, below: null };
|
|
109
|
+
const body = AstBuilder.#findFirst(ctx, BodyContext);
|
|
110
|
+
if (body?.start === null || body?.start === undefined || body.start.line !== position.line)
|
|
111
|
+
return { inline: null, below: text };
|
|
112
|
+
const eol = text.search(/\r?\n/u);
|
|
113
|
+
if (eol === -1)
|
|
114
|
+
return { inline: text, below: null };
|
|
115
|
+
const below = text.slice(eol).replace(/^\r?\n/u, "");
|
|
116
|
+
return { inline: text.slice(0, eol), below: below === "" ? null : below };
|
|
117
|
+
}
|
|
118
|
+
static #liftMatcher(op, metadata, position, raw = null, inline = false, carriedScope = false) {
|
|
119
|
+
if (metadata === null || metadata.length !== 1) {
|
|
120
|
+
const bare = AstBuilder.#bareMatcher(raw, op, inline, position, { scope: carriedScope, metadata: metadata !== null });
|
|
121
|
+
return bare === null
|
|
122
|
+
? { matcher: null, metadata, aside: null, scope: null }
|
|
123
|
+
: {
|
|
124
|
+
matcher: AstBuilder.#parseMatcherBody(bare.text, position),
|
|
125
|
+
metadata: metadata ?? (bare.metadata === null ? null : [bare.metadata]),
|
|
126
|
+
aside: bare.aside,
|
|
127
|
+
scope: bare.scope,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(`[${metadata[0]}]`);
|
|
133
|
+
}
|
|
134
|
+
catch (cause) {
|
|
135
|
+
if (!(cause instanceof SyntaxError))
|
|
136
|
+
throw cause;
|
|
137
|
+
return { matcher: null, metadata, aside: null, scope: null };
|
|
138
|
+
}
|
|
139
|
+
const elements = parsed;
|
|
140
|
+
if (elements.some((element) => typeof element !== "object" || element === null || Array.isArray(element))) {
|
|
141
|
+
return { matcher: null, metadata, aside: null, scope: null };
|
|
142
|
+
}
|
|
143
|
+
const options = Object.assign({}, ...elements);
|
|
144
|
+
if (!Object.hasOwn(options, "pattern"))
|
|
145
|
+
return { matcher: null, metadata, aside: null, scope: null };
|
|
146
|
+
const pattern = options.pattern;
|
|
147
|
+
if (typeof pattern !== "string") {
|
|
148
|
+
throw new PlurnkParseError(position.line, position.column, "visitor", `${op} "pattern" must be a string matcher, e.g. [{"pattern": "/needle/i"}].`);
|
|
149
|
+
}
|
|
150
|
+
const matcher = AstBuilder.#parseMatcherBody(pattern, position);
|
|
151
|
+
const others = Object.keys(options).filter((key) => key !== "pattern");
|
|
152
|
+
return { matcher, metadata: others.length === 0 ? null : metadata, aside: null, scope: null };
|
|
153
|
+
}
|
|
154
|
+
// {§matcher-option} — a text or log operation's body is never a matcher; it is ignored with one
|
|
155
|
+
// advisory naming the option form, and the operation still runs (operator, 2026-09-12: warn, never
|
|
156
|
+
// strike, over a body the model was taught not to write).
|
|
157
|
+
static #adviseBody(op, raw, position) {
|
|
158
|
+
if (raw === null || raw.trim() === "")
|
|
159
|
+
return;
|
|
160
|
+
if (AstBuilder.#bareMatcher(raw, op, false) !== null)
|
|
161
|
+
return;
|
|
162
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `${op} takes no body; the body was ignored. A pattern belongs on the opening fence line after the path.`, "warning"));
|
|
163
|
+
}
|
|
164
|
+
static #SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
165
|
+
// Compile-only RFC 9535 admission using the runtime's JSONPath engine. {§matcher-prefix-claims}
|
|
166
|
+
static #JSONPATH = new JSONPathEnvironment();
|
|
167
|
+
static #GRAPH_MATCHER = /^&[<>]?[^\s<>]\S*$/u;
|
|
168
|
+
static build(ctx) {
|
|
169
|
+
// Disposition and SEND contexts can arrive without a statement wrapper.
|
|
170
|
+
if (ctx instanceof DispositionStatementContext)
|
|
171
|
+
return AstBuilder.#buildDisposition(ctx);
|
|
172
|
+
if (ctx instanceof SendStatementContext)
|
|
173
|
+
return AstBuilder.#buildSend(ctx);
|
|
174
|
+
const send = ctx.sendStatement();
|
|
175
|
+
if (send)
|
|
176
|
+
return AstBuilder.#buildSend(send);
|
|
177
|
+
const find = ctx.findStatement();
|
|
178
|
+
if (find)
|
|
179
|
+
return AstBuilder.#buildFind(find);
|
|
180
|
+
const read = ctx.readStatement();
|
|
181
|
+
if (read)
|
|
182
|
+
return AstBuilder.#buildRead(read);
|
|
183
|
+
const edit = ctx.editStatement();
|
|
184
|
+
if (edit)
|
|
185
|
+
return AstBuilder.#buildEdit(edit);
|
|
186
|
+
const copy = ctx.copyStatement();
|
|
187
|
+
if (copy)
|
|
188
|
+
return AstBuilder.#buildCopy(copy);
|
|
189
|
+
const move = ctx.moveStatement();
|
|
190
|
+
if (move)
|
|
191
|
+
return AstBuilder.#buildMove(move);
|
|
192
|
+
const exec = ctx.execStatement();
|
|
193
|
+
if (exec)
|
|
194
|
+
return AstBuilder.#buildExec(exec);
|
|
195
|
+
const bare = ctx.bareStatement();
|
|
196
|
+
if (bare)
|
|
197
|
+
return AstBuilder.#buildBare(bare);
|
|
198
|
+
const work = ctx.workStatement();
|
|
199
|
+
if (work)
|
|
200
|
+
return AstBuilder.#buildWork(work);
|
|
201
|
+
const fork = ctx.forkStatement();
|
|
202
|
+
if (fork)
|
|
203
|
+
return AstBuilder.#buildFork(fork);
|
|
204
|
+
const kill = ctx.killStatement();
|
|
205
|
+
if (kill)
|
|
206
|
+
return AstBuilder.#buildKill(kill);
|
|
207
|
+
if ("dispositionStatement" in ctx) {
|
|
208
|
+
const disposition = ctx.dispositionStatement();
|
|
209
|
+
if (disposition)
|
|
210
|
+
return AstBuilder.#buildDisposition(disposition);
|
|
211
|
+
}
|
|
212
|
+
throw new Error("statement context has no recognized alternative");
|
|
213
|
+
}
|
|
214
|
+
static #buildFind(ctx) {
|
|
215
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
216
|
+
const split = AstBuilder.#splitInlineBody(ctx, position);
|
|
217
|
+
const bodied = AstBuilder.#asideBody("FIND", AstBuilder.#asideOf(ctx), split.below, position);
|
|
218
|
+
return AstBuilder.#buildFindFrom(ctx, bodied.aside, split.inline, bodied.raw);
|
|
219
|
+
}
|
|
220
|
+
static #buildFindFrom(ctx, aside, inline, below) {
|
|
221
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
222
|
+
const slots = AstBuilder.#extractSlots(ctx.slotModifiers(), position);
|
|
223
|
+
AstBuilder.#adviseBody("FIND", below, position);
|
|
224
|
+
const lifted = AstBuilder.#liftMatcher("FIND", slots.metadata, position, inline ?? below, inline !== null, slots.lineMarker !== null);
|
|
225
|
+
return {
|
|
226
|
+
op: "FIND",
|
|
227
|
+
aside: aside ?? lifted.aside,
|
|
228
|
+
...slots,
|
|
229
|
+
lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseLineMarker(lifted.scope)),
|
|
230
|
+
metadata: lifted.metadata,
|
|
231
|
+
matcher: lifted.matcher,
|
|
232
|
+
body: null,
|
|
233
|
+
position,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// Client-tier dispatch (parseClient). A `clientStatement` is either a protocol `statement`
|
|
237
|
+
// (delegated to build, returning a PlurnkStatement — which IS a ClientStatement) or one of
|
|
238
|
+
// the two client-only ops. Kept separate from build() so the protocol return type stays the
|
|
239
|
+
// closed PlurnkStatement and client ops never leak into it.
|
|
240
|
+
static buildClient(ctx) {
|
|
241
|
+
const statement = ctx.statement();
|
|
242
|
+
if (statement)
|
|
243
|
+
return AstBuilder.build(statement);
|
|
244
|
+
const look = ctx.lookStatement();
|
|
245
|
+
if (look)
|
|
246
|
+
return AstBuilder.#buildLook(look);
|
|
247
|
+
throw new Error("clientStatement context has no recognized alternative");
|
|
248
|
+
}
|
|
249
|
+
// LOOK is the client-tier matcher observation. It shares the tag slots and parses
|
|
250
|
+
// its matcher body directly for its client-owned lifecycle.
|
|
251
|
+
static #buildLook(ctx) {
|
|
252
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
253
|
+
const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
|
|
254
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
255
|
+
return {
|
|
256
|
+
op: "LOOK",
|
|
257
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
258
|
+
...slots,
|
|
259
|
+
body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
|
|
260
|
+
position,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
static #buildRead(ctx) {
|
|
264
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
265
|
+
const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
|
|
266
|
+
const split = AstBuilder.#splitInlineBody(ctx, position);
|
|
267
|
+
const bodied = AstBuilder.#asideBody("READ", AstBuilder.#asideOf(ctx), split.below, position);
|
|
268
|
+
AstBuilder.#adviseBody("READ", bodied.raw, position);
|
|
269
|
+
const lifted = AstBuilder.#liftMatcher("READ", slots.metadata, position, split.inline ?? bodied.raw, split.inline !== null, slots.lineMarker !== null);
|
|
270
|
+
const aside = bodied.aside ?? lifted.aside;
|
|
271
|
+
// {§read-find-normalization} — a READ is never rewritten: a glob target is the runtime's
|
|
272
|
+
// fan-out over every matching path, with or without a matcher (core {§read-fan-out}).
|
|
273
|
+
return {
|
|
274
|
+
op: "READ",
|
|
275
|
+
aside,
|
|
276
|
+
...slots,
|
|
277
|
+
lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
|
|
278
|
+
metadata: lifted.metadata,
|
|
279
|
+
matcher: lifted.matcher,
|
|
280
|
+
body: null,
|
|
281
|
+
position,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
static #buildEdit(ctx) {
|
|
285
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
286
|
+
const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
|
|
287
|
+
// {§naked-pattern} — a sigil on the heading line is the matcher; the lines beneath are the
|
|
288
|
+
// replacement (none deletes each match). Any other heading-line text is the body it always was.
|
|
289
|
+
const split = AstBuilder.#splitInlineBody(ctx, position);
|
|
290
|
+
const lifted = AstBuilder.#liftMatcher("EDIT", slots.metadata, position, split.inline, true, slots.lineMarker !== null);
|
|
291
|
+
return {
|
|
292
|
+
op: "EDIT",
|
|
293
|
+
aside: AstBuilder.#asideOf(ctx) ?? lifted.aside,
|
|
294
|
+
...slots,
|
|
295
|
+
lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
|
|
296
|
+
metadata: lifted.metadata,
|
|
297
|
+
matcher: lifted.matcher,
|
|
298
|
+
body: lifted.matcher === null || split.inline === null ? AstBuilder.#bodyTextOf(ctx) : split.below,
|
|
299
|
+
position,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
static #buildCopy(ctx) {
|
|
303
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
304
|
+
const modifier = ctx.transferModifiers();
|
|
305
|
+
const selections = modifier.resourceSelection();
|
|
306
|
+
if (selections.length !== 2)
|
|
307
|
+
throw new Error("COPY grammar did not produce two resource selections");
|
|
308
|
+
return {
|
|
309
|
+
op: "COPY",
|
|
310
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
311
|
+
source: AstBuilder.#resourceSelectionFromCtx(selections[0], position),
|
|
312
|
+
destination: AstBuilder.#resourceSelectionFromCtx(selections[1], position),
|
|
313
|
+
position,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
static #buildMove(ctx) {
|
|
317
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
318
|
+
const modifier = ctx.transferModifiers();
|
|
319
|
+
const selections = modifier.resourceSelection();
|
|
320
|
+
if (selections.length !== 2)
|
|
321
|
+
throw new Error("MOVE grammar did not produce two resource selections");
|
|
322
|
+
return {
|
|
323
|
+
op: "MOVE",
|
|
324
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
325
|
+
source: AstBuilder.#resourceSelectionFromCtx(selections[0], position),
|
|
326
|
+
destination: AstBuilder.#resourceSelectionFromCtx(selections[1], position),
|
|
327
|
+
position,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
static #buildDisposition(ctx) {
|
|
331
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
332
|
+
const op = (ctx.start?.text ?? "").replace(/^`+[0-9]*/, "");
|
|
333
|
+
if (!TurnDisposition.isOp(op))
|
|
334
|
+
throw new Error(`Unknown disposition operation: ${op}`);
|
|
335
|
+
// {§one-line-turn} — an inventory written as a block on the heading line is the body when
|
|
336
|
+
// nothing sits beneath the heading, with one advisory naming where it belongs.
|
|
337
|
+
const below = AstBuilder.#bodyTextOf(ctx);
|
|
338
|
+
const inline = ctx.metadata()?.getText() ?? null;
|
|
339
|
+
if (inline !== null && (below === null || below.trim() === "")) {
|
|
340
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `${op}'s inventory was read from the heading line; it belongs in the body.`, "warning"));
|
|
341
|
+
}
|
|
342
|
+
const raw = below !== null && below.trim() !== "" ? below : inline;
|
|
343
|
+
return {
|
|
344
|
+
op,
|
|
345
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
346
|
+
target: null,
|
|
347
|
+
metadata: null,
|
|
348
|
+
lineMarker: AstBuilder.#lineMarkerFromCtx(ctx.lineMarker()),
|
|
349
|
+
body: PlanValue.admit(raw ?? "", (message) => AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "visitor", message, "warning"))),
|
|
350
|
+
position,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// A mid-turn SEND is a message to its recipient path, or to the user when it names none.
|
|
354
|
+
static #buildSend(ctx) {
|
|
355
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
356
|
+
const slots = AstBuilder.#extractSlots(ctx.resourceSelection(), position);
|
|
357
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
358
|
+
return {
|
|
359
|
+
op: "SEND",
|
|
360
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
361
|
+
...slots,
|
|
362
|
+
body: raw !== null ? AstBuilder.#parseSendBody(raw) : null,
|
|
363
|
+
position,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
static #buildExec(ctx) {
|
|
367
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
368
|
+
const runtime = AstBuilder.#executorOf(ctx);
|
|
369
|
+
const slots = AstBuilder.#extractExecSlots(ctx.execModifiers(), position, runtime);
|
|
370
|
+
return {
|
|
371
|
+
runtime,
|
|
372
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
373
|
+
...slots,
|
|
374
|
+
body: AstBuilder.#bodyTextOf(ctx),
|
|
375
|
+
position,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// {§executor-case} — the AST carries the registered spelling; the tag may be written in any case.
|
|
379
|
+
static executorSpellings = new Map();
|
|
380
|
+
static #executorOf(ctx) {
|
|
381
|
+
const name = ctx.OPEN_EXEC().getText().replace(/^`+[0-9]*/, "").toLowerCase();
|
|
382
|
+
return (AstBuilder.executorSpellings.get(name) ?? name);
|
|
383
|
+
}
|
|
384
|
+
static #buildBare(ctx) {
|
|
385
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
386
|
+
const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
|
|
387
|
+
return {
|
|
388
|
+
op: "BARE",
|
|
389
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
390
|
+
target: slots.target,
|
|
391
|
+
metadata: slots.metadata,
|
|
392
|
+
lineMarker: null,
|
|
393
|
+
body: AstBuilder.#requiredBodyTextOf(ctx),
|
|
394
|
+
position,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
static #buildKill(ctx) {
|
|
398
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
399
|
+
// {§kill-scope} — the scope names lines of a log body or of an entry; null kills the whole target.
|
|
400
|
+
const slots = AstBuilder.#extractTextSlots(ctx.slotModifiers(), position);
|
|
401
|
+
const split = AstBuilder.#splitInlineBody(ctx, position);
|
|
402
|
+
AstBuilder.#adviseBody("KILL", split.below, position);
|
|
403
|
+
const lifted = AstBuilder.#liftMatcher("KILL", slots.metadata, position, split.inline ?? split.below, split.inline !== null, slots.lineMarker !== null);
|
|
404
|
+
return {
|
|
405
|
+
op: "KILL",
|
|
406
|
+
aside: AstBuilder.#asideOf(ctx) ?? lifted.aside,
|
|
407
|
+
...slots,
|
|
408
|
+
lineMarker: slots.lineMarker ?? (lifted.scope === null ? null : AstBuilder.#parseTextLineMarker(lifted.scope, position)),
|
|
409
|
+
metadata: lifted.metadata,
|
|
410
|
+
matcher: lifted.matcher,
|
|
411
|
+
body: null,
|
|
412
|
+
position,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
static #buildWork(ctx) {
|
|
416
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
417
|
+
const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
|
|
418
|
+
return {
|
|
419
|
+
op: "WORK",
|
|
420
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
421
|
+
...slots,
|
|
422
|
+
lineMarker: null,
|
|
423
|
+
body: AstBuilder.#requiredBodyTextOf(ctx),
|
|
424
|
+
position,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
static #buildFork(ctx) {
|
|
428
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
429
|
+
const slots = AstBuilder.#extractBranchSlots(ctx.targetWithMetadata(), position);
|
|
430
|
+
return {
|
|
431
|
+
op: "FORK",
|
|
432
|
+
aside: AstBuilder.#asideOf(ctx),
|
|
433
|
+
...slots,
|
|
434
|
+
lineMarker: null,
|
|
435
|
+
body: AstBuilder.#requiredBodyTextOf(ctx),
|
|
436
|
+
position,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
static #extractBranchSlots(ctx, pos) {
|
|
440
|
+
return {
|
|
441
|
+
target: AstBuilder.#targetFromCtx(AstBuilder.#findFirst(ctx, TargetContext), pos),
|
|
442
|
+
metadata: AstBuilder.#metadataFromCtx(ctx),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
static #singleMarker(ctx, pos) {
|
|
446
|
+
const found = AstBuilder.#findAll(ctx, LineMarkerContext);
|
|
447
|
+
if (found.length > 1)
|
|
448
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", "A resource selection takes at most one scope.");
|
|
449
|
+
return found[0] ?? null;
|
|
450
|
+
}
|
|
451
|
+
static #extractSlots(modCtx, pos) {
|
|
452
|
+
return {
|
|
453
|
+
target: AstBuilder.#targetFromCtx(AstBuilder.#findFirst(modCtx, TargetContext), pos),
|
|
454
|
+
metadata: AstBuilder.#metadataFromCtx(modCtx),
|
|
455
|
+
lineMarker: AstBuilder.#lineMarkerFromCtx(AstBuilder.#singleMarker(modCtx, pos)),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
static #extractTextSlots(modCtx, pos) {
|
|
459
|
+
return {
|
|
460
|
+
target: AstBuilder.#targetFromCtx(AstBuilder.#findFirst(modCtx, TargetContext), pos),
|
|
461
|
+
metadata: AstBuilder.#metadataFromCtx(modCtx),
|
|
462
|
+
lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#singleMarker(modCtx, pos)),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
// Depth-first search for the first terminal of `tokenType`; returns its text or null.
|
|
466
|
+
static #findToken(root, tokenType) {
|
|
467
|
+
if (root === null)
|
|
468
|
+
return null;
|
|
469
|
+
for (const child of root.children ?? []) {
|
|
470
|
+
if (child instanceof TerminalNode && child.symbol.type === tokenType)
|
|
471
|
+
return child.getText();
|
|
472
|
+
if (child instanceof ParserRuleContext) {
|
|
473
|
+
const found = AstBuilder.#findToken(child, tokenType);
|
|
474
|
+
if (found !== null)
|
|
475
|
+
return found;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
static #extractExecSlots(modCtx, pos, executor) {
|
|
481
|
+
// {§exec-executor-slot} — every slot at most once; the grammar admits any order.
|
|
482
|
+
const once = (type, slot) => {
|
|
483
|
+
const found = AstBuilder.#findAll(modCtx, type);
|
|
484
|
+
if (found.length > 1) {
|
|
485
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `${executor} accepts ${slot} at most once`);
|
|
486
|
+
}
|
|
487
|
+
return found[0] ?? null;
|
|
488
|
+
};
|
|
489
|
+
return {
|
|
490
|
+
target: AstBuilder.#targetFromCtx(once(TargetContext, "one `(program)` path"), pos),
|
|
491
|
+
metadata: AstBuilder.#metadataFromCtx(modCtx),
|
|
492
|
+
lineMarker: AstBuilder.#lineMarkerFromCtx(once(LineMarkerContext, "one `<scope>`")),
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
static #findAll(root, type) {
|
|
496
|
+
if (root === null)
|
|
497
|
+
return [];
|
|
498
|
+
if (root instanceof type)
|
|
499
|
+
return [root];
|
|
500
|
+
return (root.children ?? []).flatMap((child) => child instanceof ParserRuleContext ? AstBuilder.#findAll(child, type) : []);
|
|
501
|
+
}
|
|
502
|
+
static #findFirst(root, type) {
|
|
503
|
+
if (root === null)
|
|
504
|
+
return null;
|
|
505
|
+
if (root instanceof type)
|
|
506
|
+
return root;
|
|
507
|
+
const children = root.children;
|
|
508
|
+
if (!children)
|
|
509
|
+
return null;
|
|
510
|
+
for (const child of children) {
|
|
511
|
+
if (child instanceof ParserRuleContext) {
|
|
512
|
+
const found = AstBuilder.#findFirst(child, type);
|
|
513
|
+
if (found !== null)
|
|
514
|
+
return found;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
static #targetFromCtx(ctx, pos) {
|
|
520
|
+
if (ctx === null)
|
|
521
|
+
return null;
|
|
522
|
+
const nestedScope = ctx.lineMarker();
|
|
523
|
+
if (nestedScope !== null) {
|
|
524
|
+
const point = AstBuilder.#positionOf(nestedScope);
|
|
525
|
+
AstBuilder.#advisories.push(new PlurnkParseError(point.line, point.column, "parser", "The scope was inside the target slot; it was applied as the operation scope.", "warning"));
|
|
526
|
+
}
|
|
527
|
+
const text = ctx.TARGET_TEXT().map((token) => token.getText()).join("");
|
|
528
|
+
return AstBuilder.parsePath(text, pos);
|
|
529
|
+
}
|
|
530
|
+
static #metadataFromCtx(ctx) {
|
|
531
|
+
const blocks = AstBuilder.#findAll(ctx, MetadataContext);
|
|
532
|
+
return blocks.length === 0
|
|
533
|
+
? null
|
|
534
|
+
: blocks.map((block) => block.METADATA_TEXT().map((token) => token.getText()).join(""));
|
|
535
|
+
}
|
|
536
|
+
static #resourceSelectionFromCtx(ctx, pos) {
|
|
537
|
+
const target = AstBuilder.#targetFromCtx(AstBuilder.#findFirst(ctx, TargetContext), pos);
|
|
538
|
+
if (target === null)
|
|
539
|
+
throw new Error("resource selection grammar did not produce a target");
|
|
540
|
+
const lifted = AstBuilder.#liftMatcher("COPY/MOVE", AstBuilder.#metadataFromCtx(ctx), pos);
|
|
541
|
+
return {
|
|
542
|
+
target,
|
|
543
|
+
metadata: lifted.metadata,
|
|
544
|
+
lineMarker: AstBuilder.#textLineMarkerFromCtx(AstBuilder.#singleMarker(ctx, pos)),
|
|
545
|
+
matcher: lifted.matcher,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
static #lineMarkerFromCtx(ctx) {
|
|
549
|
+
if (ctx === null)
|
|
550
|
+
return null;
|
|
551
|
+
const text = ctx.L_MARKER()?.getText() ?? "";
|
|
552
|
+
return AstBuilder.#parseLineMarker(text);
|
|
553
|
+
}
|
|
554
|
+
static #textLineMarkerFromCtx(ctx) {
|
|
555
|
+
if (ctx === null)
|
|
556
|
+
return null;
|
|
557
|
+
const text = ctx.L_MARKER()?.getText() ?? "";
|
|
558
|
+
return AstBuilder.#parseTextLineMarker(text, AstBuilder.#positionOf(ctx));
|
|
559
|
+
}
|
|
560
|
+
static #parseTextLineMarker(text, position) {
|
|
561
|
+
if (!text.includes("@"))
|
|
562
|
+
return AstBuilder.#parseLineMarker(text);
|
|
563
|
+
const marks = text.slice(1, -1).split(/, ?/).map((component) => {
|
|
564
|
+
// {§anchor-digits} — `@210` is the line number 210 with the anchor's sigil, not a hash.
|
|
565
|
+
if (/^@[0-9]{1,4}$/u.test(component)) {
|
|
566
|
+
if (position !== undefined) {
|
|
567
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `\`${component}\` was read as line ${component.slice(1)}; an anchor is five characters (\`@abcde\`).`, "warning"));
|
|
568
|
+
}
|
|
569
|
+
return Number.parseInt(component.slice(1), 10);
|
|
570
|
+
}
|
|
571
|
+
// {§combined-anchor-tolerance} — `@abcde 42` / `@abcde:42` is the displayed prefix copied whole;
|
|
572
|
+
// the anchor is the coordinate and the number is dropped.
|
|
573
|
+
const combined = /^(@[0-9A-Za-z]{5})[: ][1-9][0-9]*$/u.exec(component);
|
|
574
|
+
if (combined !== null) {
|
|
575
|
+
if (position !== undefined) {
|
|
576
|
+
AstBuilder.#advisories.push(new PlurnkParseError(position.line, position.column, "parser", `\`${component}\` was read as the anchor \`${combined[1]}\`; a scope position takes the anchor without its displayed line number.`, "warning"));
|
|
577
|
+
}
|
|
578
|
+
return combined[1];
|
|
579
|
+
}
|
|
580
|
+
return component.startsWith("@") ? component : Number.parseFloat(component);
|
|
581
|
+
});
|
|
582
|
+
return { marks: marks };
|
|
583
|
+
}
|
|
584
|
+
static #positionOf(ctx) {
|
|
585
|
+
const start = ctx.start;
|
|
586
|
+
return { line: start?.line ?? 0, column: start?.column ?? 0 };
|
|
587
|
+
}
|
|
588
|
+
static #asideOf(ctx) {
|
|
589
|
+
const token = AstBuilder.#findToken(ctx, plurnkLexer.ASIDE);
|
|
590
|
+
if (token === null)
|
|
591
|
+
return null;
|
|
592
|
+
const inner = token.endsWith("-->") ? token.slice("<!--".length, -"-->".length) : token.slice("<!--".length);
|
|
593
|
+
return inner.trim();
|
|
594
|
+
}
|
|
595
|
+
// {§closer-fallback} — without a real closer (the block ended at the next heading or at the end
|
|
596
|
+
// of the input) the body is cut back to its last bare fence line, which is the closer the model
|
|
597
|
+
// meant, and one terminating line ending goes with it. A synthetic SECTION_END carries no backtick.
|
|
598
|
+
static #bodyTextOf(ctx) {
|
|
599
|
+
const text = AstBuilder.#findFirst(ctx, BodyContext)?.getText() ?? null;
|
|
600
|
+
if (text === null)
|
|
601
|
+
return null;
|
|
602
|
+
const closer = AstBuilder.#findToken(ctx, plurnkLexer.SECTION_END);
|
|
603
|
+
if (closer !== null && closer.includes("`"))
|
|
604
|
+
return text;
|
|
605
|
+
const lines = text.split("\n");
|
|
606
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
607
|
+
if (/^[ \t]*`{3,}[0-9]*[ \t]*\r?$/u.test(lines[index] ?? "")) {
|
|
608
|
+
const kept = lines.slice(0, index).join("\n");
|
|
609
|
+
return kept === "" ? null : kept;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const trimmed = text.replace(/\r?\n$/u, "");
|
|
613
|
+
return trimmed === "" ? null : trimmed;
|
|
614
|
+
}
|
|
615
|
+
static #requiredBodyTextOf(ctx) {
|
|
616
|
+
return AstBuilder.#bodyTextOf(ctx) ?? "";
|
|
617
|
+
}
|
|
618
|
+
static #isDigit(c) {
|
|
619
|
+
return c !== undefined && c >= "0" && c <= "9";
|
|
620
|
+
}
|
|
621
|
+
// Scans `<scope>` into ordered numeric components. Separators are `,`
|
|
622
|
+
// (with an optional space) or `-`; a `-` immediately starting a component is its
|
|
623
|
+
// sign, not a separator. The operation owner assigns roles. {§scope-marker-forms}
|
|
624
|
+
static #parseLineMarker(text) {
|
|
625
|
+
const inner = text.slice(1, -1);
|
|
626
|
+
const marks = [];
|
|
627
|
+
let i = 0;
|
|
628
|
+
while (i < inner.length) {
|
|
629
|
+
let j = i;
|
|
630
|
+
if (inner[j] === "-")
|
|
631
|
+
j++;
|
|
632
|
+
while (AstBuilder.#isDigit(inner[j]))
|
|
633
|
+
j++;
|
|
634
|
+
if (inner[j] === "." && AstBuilder.#isDigit(inner[j + 1])) {
|
|
635
|
+
j++;
|
|
636
|
+
while (AstBuilder.#isDigit(inner[j]))
|
|
637
|
+
j++;
|
|
638
|
+
}
|
|
639
|
+
marks.push(Number.parseFloat(inner.slice(i, j)));
|
|
640
|
+
i = j;
|
|
641
|
+
if (inner[i] === ",") {
|
|
642
|
+
i++;
|
|
643
|
+
if (inner[i] === " ")
|
|
644
|
+
i++;
|
|
645
|
+
}
|
|
646
|
+
else if (inner[i] === "-") {
|
|
647
|
+
i++;
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// L_MARKER always matches at least one number, so marks is non-empty.
|
|
654
|
+
return { marks: marks };
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Apply target-slot decomposition without round-tripping through a statement.
|
|
658
|
+
* Returns null for empty input and throws PlurnkParseError when a scheme URL
|
|
659
|
+
* fails WHATWG admission. {§path-syntax}
|
|
660
|
+
*/
|
|
661
|
+
static parsePath(raw, pos = { line: 0, column: 0 }) {
|
|
662
|
+
if (raw.length === 0)
|
|
663
|
+
return null;
|
|
664
|
+
const target = PathSyntax.unescapeTarget(raw);
|
|
665
|
+
if (!AstBuilder.#SCHEME_PATTERN.test(target)) {
|
|
666
|
+
// {§local-path-fragment} — `#channel` after a bare path is the channel, exactly as on a
|
|
667
|
+
// URL; a spelling that opens with `#` names no path, so it stays whole.
|
|
668
|
+
const hash = target.indexOf("#");
|
|
669
|
+
if (hash < 1)
|
|
670
|
+
return { kind: "local", raw: target };
|
|
671
|
+
return { kind: "local", raw: target.slice(0, hash), fragment: target.slice(hash + 1) };
|
|
672
|
+
}
|
|
673
|
+
const protectedTarget = AstBuilder.#protectPathBraces(target);
|
|
674
|
+
let url;
|
|
675
|
+
try {
|
|
676
|
+
url = new URL(protectedTarget.value);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", "invalid URI in path");
|
|
680
|
+
}
|
|
681
|
+
// Uniform WHATWG decomposition — no per-scheme authority allowlist. `://`
|
|
682
|
+
// introduces an authority for every scheme; an authority-less reference
|
|
683
|
+
// writes the empty-authority form `scheme:///path` (host parses empty).
|
|
684
|
+
// Whether a given scheme should carry an authority is a runtime concern,
|
|
685
|
+
// not the grammar's; the parser just reports what the standard parsed.
|
|
686
|
+
const parsed = {
|
|
687
|
+
kind: "url",
|
|
688
|
+
raw: target,
|
|
689
|
+
scheme: url.protocol.replace(/:$/, ""),
|
|
690
|
+
username: url.username || null,
|
|
691
|
+
password: url.password || null,
|
|
692
|
+
hostname: url.hostname || null,
|
|
693
|
+
port: url.port ? Number.parseInt(url.port, 10) : null,
|
|
694
|
+
pathname: protectedTarget.restore(url.pathname),
|
|
695
|
+
query: AstBuilder.#queryFrom(url),
|
|
696
|
+
fragment: url.hash ? url.hash.slice(1) : null,
|
|
697
|
+
};
|
|
698
|
+
return parsed;
|
|
699
|
+
}
|
|
700
|
+
// WHATWG percent-encodes raw braces. Protect only authored path braces while
|
|
701
|
+
// decomposing so brace globs remain distinguishable from authored `%7B`/`%7D`
|
|
702
|
+
// literal path data. Query and fragment spelling remain untouched.
|
|
703
|
+
static #protectPathBraces(target) {
|
|
704
|
+
const authorityStart = target.indexOf("://") + 3;
|
|
705
|
+
const pathStart = target.indexOf("/", authorityStart);
|
|
706
|
+
if (pathStart < 0)
|
|
707
|
+
return { value: target, restore: (pathname) => pathname };
|
|
708
|
+
const queryStart = target.indexOf("?", pathStart);
|
|
709
|
+
const fragmentStart = target.indexOf("#", pathStart);
|
|
710
|
+
const endings = [queryStart, fragmentStart].filter((index) => index >= 0);
|
|
711
|
+
const pathEnd = endings.length === 0 ? target.length : Math.min(...endings);
|
|
712
|
+
const rawPath = target.slice(pathStart, pathEnd);
|
|
713
|
+
if (!/[{}]/u.test(rawPath))
|
|
714
|
+
return { value: target, restore: (pathname) => pathname };
|
|
715
|
+
const sentinels = [];
|
|
716
|
+
for (let codePoint = 0xF0000; sentinels.length < 2; codePoint += 1) {
|
|
717
|
+
const character = String.fromCodePoint(codePoint);
|
|
718
|
+
const encoded = encodeURIComponent(character);
|
|
719
|
+
if (!target.includes(character) && !target.toUpperCase().includes(encoded))
|
|
720
|
+
sentinels.push(encoded);
|
|
721
|
+
}
|
|
722
|
+
const [open, close] = sentinels;
|
|
723
|
+
const protectedPath = rawPath.replaceAll("{", open).replaceAll("}", close);
|
|
724
|
+
return {
|
|
725
|
+
value: target.slice(0, pathStart) + protectedPath + target.slice(pathEnd),
|
|
726
|
+
restore: (pathname) => pathname.replaceAll(open, "{").replaceAll(close, "}"),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
static #queryFrom(url) {
|
|
730
|
+
const queryStart = url.href.indexOf("?");
|
|
731
|
+
if (queryStart === -1)
|
|
732
|
+
return null;
|
|
733
|
+
const fragmentStart = url.href.indexOf("#", queryStart);
|
|
734
|
+
return url.href.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
|
|
735
|
+
}
|
|
736
|
+
// The leading prefix claims its dialect; failed claimed syntax never falls back
|
|
737
|
+
// to glob. XPath's `//` is classified before regex `/`. {§matcher-prefix-claims}
|
|
738
|
+
static #parseMatcherBody(body, pos) {
|
|
739
|
+
// At statement EOF ANTLR retains one ordinary terminating line ending in
|
|
740
|
+
// BODY_TEXT; before a following heading the lexer consumes that same EOL as
|
|
741
|
+
// SECTION_END. Normalize the equivalent surfaces before enforcing one line.
|
|
742
|
+
const raw = body.replace(/(?:\r\n|\r|\n)$/u, "");
|
|
743
|
+
const lineCount = raw.split(/\r\n|\r|\n/u).length;
|
|
744
|
+
if (lineCount !== 1) {
|
|
745
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `Matcher has ${lineCount} lines; expected 1.`);
|
|
746
|
+
}
|
|
747
|
+
if (raw.startsWith("//")) {
|
|
748
|
+
try {
|
|
749
|
+
xpath.parse(raw);
|
|
750
|
+
}
|
|
751
|
+
catch (e) {
|
|
752
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `pattern leads with \`//\` but is not a valid xpath selector - ${AstBuilder.#detail(e)}`);
|
|
753
|
+
}
|
|
754
|
+
return { dialect: "xpath", raw };
|
|
755
|
+
}
|
|
756
|
+
// {§naked-pattern} — a matcher opening with `^` is a regex written without slashes or flags.
|
|
757
|
+
if (raw.startsWith("^")) {
|
|
758
|
+
const inline = AstBuilder.#liftInlineFlags(raw.slice(1), "", pos);
|
|
759
|
+
const pattern = `^${inline.pattern}`;
|
|
760
|
+
try {
|
|
761
|
+
new RegExp(pattern, inline.flags);
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `pattern leads with \`^\` but is not a valid regex - ${AstBuilder.#detail(e)}`);
|
|
765
|
+
}
|
|
766
|
+
return { dialect: "regex", raw, pattern, flags: inline.flags };
|
|
767
|
+
}
|
|
768
|
+
if (raw.startsWith("/")) {
|
|
769
|
+
const regex = AstBuilder.#tryParseSlashRegex(raw, pos);
|
|
770
|
+
if (regex.ok)
|
|
771
|
+
return { dialect: "regex", raw, pattern: regex.pattern, flags: regex.flags };
|
|
772
|
+
if (regex.reason === "trailing") {
|
|
773
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", "Regex matcher has trailing text after `/pattern/flags`.");
|
|
774
|
+
}
|
|
775
|
+
const slashRecovery = regex.reason === "invalid"
|
|
776
|
+
&& regex.detail.includes("Invalid flags supplied")
|
|
777
|
+
? " - use only ECMAScript flags after the closing `/`; escape a literal `/` inside the pattern as `\\/`"
|
|
778
|
+
: "";
|
|
779
|
+
// Quote the offending matcher so a multi-op emission's failure is
|
|
780
|
+
// unambiguous about WHICH body failed (a correct sibling regex must
|
|
781
|
+
// not take the blame for a broken one).
|
|
782
|
+
const excerpt = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
783
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", regex.reason === "unclosed"
|
|
784
|
+
? `regex matcher must use \`/pattern/flags\`; this matcher has no closing \`/\`: \`${excerpt}\``
|
|
785
|
+
: `pattern leads with \`/\` but is not a valid \`/pattern/flags\` regex - ${regex.detail}${slashRecovery}: \`${excerpt}\``);
|
|
786
|
+
}
|
|
787
|
+
if (raw.startsWith("$")) {
|
|
788
|
+
// Compile-only RFC 9535 admission through the shared json-p3 engine.
|
|
789
|
+
try {
|
|
790
|
+
AstBuilder.#JSONPATH.compile(raw);
|
|
791
|
+
}
|
|
792
|
+
catch (e) {
|
|
793
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `pattern leads with \`$\` but is not a valid jsonpath - ${AstBuilder.#detail(e)}`);
|
|
794
|
+
}
|
|
795
|
+
return { dialect: "jsonpath", raw };
|
|
796
|
+
}
|
|
797
|
+
if (raw.startsWith("~"))
|
|
798
|
+
return { dialect: "fts", raw };
|
|
799
|
+
if (raw.startsWith("&")) {
|
|
800
|
+
if (!AstBuilder.#GRAPH_MATCHER.test(raw)) {
|
|
801
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", "Malformed graph matcher; expected `&symbol`, `&<symbol`, or `&>symbol`.");
|
|
802
|
+
}
|
|
803
|
+
return { dialect: "graph", raw };
|
|
804
|
+
}
|
|
805
|
+
return { dialect: "glob", raw };
|
|
806
|
+
}
|
|
807
|
+
static #detail(e) {
|
|
808
|
+
return e instanceof Error ? e.message : String(e);
|
|
809
|
+
}
|
|
810
|
+
// Splits an ECMAScript `/pattern/flags` literal. Backslash escapes and character
|
|
811
|
+
// classes keep a slash inside the pattern; the first unescaped slash outside a
|
|
812
|
+
// class closes it. The native constructor owns pattern and flag validity.
|
|
813
|
+
// {§inline-flag-tolerance} — a leading PCRE inline modifier such as `(?i)` is the pretrained
|
|
814
|
+
// spelling of a flag; ECMAScript refuses the group, so it is lifted into the flags with one
|
|
815
|
+
// advisory rather than refused.
|
|
816
|
+
static #liftInlineFlags(pattern, flags, pos) {
|
|
817
|
+
const inline = /^\(\?([ims]+)\)/u.exec(pattern);
|
|
818
|
+
if (inline === null)
|
|
819
|
+
return { pattern, flags };
|
|
820
|
+
const lifted = [...new Set([...flags, ...inline[1]])].join("");
|
|
821
|
+
AstBuilder.#advisories.push(new PlurnkParseError(pos.line, pos.column, "parser", `\`${inline[0]}\` was read as the \`${inline[1]}\` flag; an ECMAScript regex takes its flags after the closing \`/\`.`, "warning"));
|
|
822
|
+
return { pattern: pattern.slice(inline[0].length), flags: lifted };
|
|
823
|
+
}
|
|
824
|
+
static #tryParseSlashRegex(raw, pos) {
|
|
825
|
+
let i = 1;
|
|
826
|
+
let inClass = false;
|
|
827
|
+
while (i < raw.length) {
|
|
828
|
+
if (raw[i] === "\\") {
|
|
829
|
+
i += 2;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (raw[i] === "[") {
|
|
833
|
+
inClass = true;
|
|
834
|
+
i++;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (raw[i] === "]" && inClass) {
|
|
838
|
+
inClass = false;
|
|
839
|
+
i++;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
if (raw[i] === "/" && !inClass)
|
|
843
|
+
break;
|
|
844
|
+
i++;
|
|
845
|
+
}
|
|
846
|
+
if (i >= raw.length)
|
|
847
|
+
return { ok: false, reason: "unclosed" };
|
|
848
|
+
const authored = raw.slice(i + 1);
|
|
849
|
+
const trailing = /^([A-Za-z]*)[\t ]/u.exec(authored);
|
|
850
|
+
const inline = AstBuilder.#liftInlineFlags(raw.slice(1, i), trailing?.[1] ?? authored, pos);
|
|
851
|
+
const { pattern, flags } = inline;
|
|
852
|
+
try {
|
|
853
|
+
new RegExp(pattern, flags);
|
|
854
|
+
}
|
|
855
|
+
catch (e) {
|
|
856
|
+
return { ok: false, reason: "invalid", detail: AstBuilder.#detail(e), flags };
|
|
857
|
+
}
|
|
858
|
+
if (trailing !== null)
|
|
859
|
+
return { ok: false, reason: "trailing" };
|
|
860
|
+
return { ok: true, pattern, flags };
|
|
861
|
+
}
|
|
862
|
+
static #parseSendBody(raw) {
|
|
863
|
+
let json = null;
|
|
864
|
+
try {
|
|
865
|
+
json = JSON.parse(raw);
|
|
866
|
+
}
|
|
867
|
+
catch { /* best-effort */ }
|
|
868
|
+
return { raw, json };
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
//# sourceMappingURL=AstBuilder.js.map
|