@plurnk/plurnk-grammar 0.1.1 → 0.2.1
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/README.md +3 -3
- package/bin/plurnk.js +2 -2
- package/package.json +9 -4
- package/plurnk.md +4 -4
- package/schema/Agent.json +18 -0
- package/schema/ChannelContent.json +14 -0
- package/schema/Entry.json +51 -0
- package/schema/LineMarker.json +13 -0
- package/schema/LogEntry.json +100 -0
- package/schema/Loop.json +20 -0
- package/schema/MatcherBody.json +60 -0
- package/schema/Packet.json +64 -0
- package/schema/Params.json +13 -0
- package/schema/ParsedPath.json +51 -0
- package/schema/PlurnkStatement.json +183 -0
- package/schema/Position.json +13 -0
- package/schema/ProviderDeclaration.json +16 -0
- package/schema/Run.json +21 -0
- package/schema/SchemeRegistration.json +37 -0
- package/schema/SendBody.json +13 -0
- package/schema/Session.json +20 -0
- package/schema/Turn.json +30 -0
- package/schema/Visibility.json +17 -0
- package/src/AstBuilder.ts +372 -0
- package/src/PlurnkErrorStrategy.ts +139 -0
- package/src/{errors.ts → PlurnkParseError.ts} +1 -2
- package/src/PlurnkParser.ts +92 -0
- package/src/RecordingListener.ts +34 -0
- package/src/Validator.ts +94 -0
- package/src/generated/plurnkLexer.ts +224 -176
- package/src/generated/plurnkParser.ts +1461 -195
- package/src/generated/plurnkParserVisitor.ts +97 -6
- package/src/index.ts +29 -142
- package/src/types.generated.ts +497 -0
- package/src/types.ts +30 -0
- package/SPEC.md +0 -625
- package/src/ast.ts +0 -348
- package/src/error-strategy.ts +0 -140
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { ParserRuleContext } from "antlr4ng";
|
|
2
|
+
import * as xpath from "xpath";
|
|
3
|
+
import { JSONPath } from "jsonpath-plus";
|
|
4
|
+
import type {
|
|
5
|
+
CopyStatement,
|
|
6
|
+
EditStatement,
|
|
7
|
+
ExecStatement,
|
|
8
|
+
FindStatement,
|
|
9
|
+
HideStatement,
|
|
10
|
+
LineMarker,
|
|
11
|
+
MatcherBody,
|
|
12
|
+
MoveStatement,
|
|
13
|
+
ParsedPath,
|
|
14
|
+
PlurnkOp,
|
|
15
|
+
PlurnkStatement,
|
|
16
|
+
Position,
|
|
17
|
+
ReadStatement,
|
|
18
|
+
SendBody,
|
|
19
|
+
SendStatement,
|
|
20
|
+
ShowStatement,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import type {
|
|
23
|
+
CopyStatementContext,
|
|
24
|
+
EditStatementContext,
|
|
25
|
+
ExecModifiersContext,
|
|
26
|
+
ExecStatementContext,
|
|
27
|
+
FindStatementContext,
|
|
28
|
+
HideStatementContext,
|
|
29
|
+
MoveStatementContext,
|
|
30
|
+
ReadStatementContext,
|
|
31
|
+
SendModifiersContext,
|
|
32
|
+
SendStatementContext,
|
|
33
|
+
ShowStatementContext,
|
|
34
|
+
StatementContext,
|
|
35
|
+
TagOpModifiersContext,
|
|
36
|
+
} from "./generated/plurnkParser.ts";
|
|
37
|
+
import {
|
|
38
|
+
IdentSignalContext,
|
|
39
|
+
IntSignalContext,
|
|
40
|
+
LineMarkerContext,
|
|
41
|
+
PathContext,
|
|
42
|
+
TagSignalContext,
|
|
43
|
+
} from "./generated/plurnkParser.ts";
|
|
44
|
+
import PlurnkParseError from "./PlurnkParseError.ts";
|
|
45
|
+
|
|
46
|
+
// The xpath package's .d.ts omits its `parse` function; augment here.
|
|
47
|
+
declare module "xpath" {
|
|
48
|
+
export function parse(expression: string): unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Ctor<T> = new (...args: any[]) => T;
|
|
52
|
+
|
|
53
|
+
type TagSlots = { signal: string[] | null; path: ParsedPath | null; lineMarker: LineMarker | null };
|
|
54
|
+
type SendSlots = { signal: number | null; path: ParsedPath | null };
|
|
55
|
+
type ExecSlots = { signal: string | null; path: ParsedPath | null };
|
|
56
|
+
|
|
57
|
+
export default class AstBuilder {
|
|
58
|
+
static #SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
59
|
+
|
|
60
|
+
static build(ctx: StatementContext): PlurnkStatement {
|
|
61
|
+
const find = ctx.findStatement(); if (find) return AstBuilder.#buildFind(find);
|
|
62
|
+
const read = ctx.readStatement(); if (read) return AstBuilder.#buildRead(read);
|
|
63
|
+
const edit = ctx.editStatement(); if (edit) return AstBuilder.#buildEdit(edit);
|
|
64
|
+
const copy = ctx.copyStatement(); if (copy) return AstBuilder.#buildCopy(copy);
|
|
65
|
+
const move = ctx.moveStatement(); if (move) return AstBuilder.#buildMove(move);
|
|
66
|
+
const show = ctx.showStatement(); if (show) return AstBuilder.#buildShow(show);
|
|
67
|
+
const hide = ctx.hideStatement(); if (hide) return AstBuilder.#buildHide(hide);
|
|
68
|
+
const send = ctx.sendStatement(); if (send) return AstBuilder.#buildSend(send);
|
|
69
|
+
const exec = ctx.execStatement(); if (exec) return AstBuilder.#buildExec(exec);
|
|
70
|
+
throw new Error("statement context has no recognized alternative");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static #buildFind(ctx: FindStatementContext): FindStatement {
|
|
74
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
75
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
76
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
77
|
+
return {
|
|
78
|
+
op: "FIND",
|
|
79
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_FIND().getText(), "FIND"),
|
|
80
|
+
...slots,
|
|
81
|
+
body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
|
|
82
|
+
position,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static #buildRead(ctx: ReadStatementContext): ReadStatement {
|
|
87
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
88
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
89
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
90
|
+
return {
|
|
91
|
+
op: "READ",
|
|
92
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_READ().getText(), "READ"),
|
|
93
|
+
...slots,
|
|
94
|
+
body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
|
|
95
|
+
position,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static #buildShow(ctx: ShowStatementContext): ShowStatement {
|
|
100
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
101
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
102
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
103
|
+
return {
|
|
104
|
+
op: "SHOW",
|
|
105
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_SHOW().getText(), "SHOW"),
|
|
106
|
+
...slots,
|
|
107
|
+
body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
|
|
108
|
+
position,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static #buildHide(ctx: HideStatementContext): HideStatement {
|
|
113
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
114
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
115
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
116
|
+
return {
|
|
117
|
+
op: "HIDE",
|
|
118
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_HIDE().getText(), "HIDE"),
|
|
119
|
+
...slots,
|
|
120
|
+
body: raw !== null ? AstBuilder.#parseMatcherBody(raw, position) : null,
|
|
121
|
+
position,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
static #buildEdit(ctx: EditStatementContext): EditStatement {
|
|
126
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
127
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
128
|
+
return {
|
|
129
|
+
op: "EDIT",
|
|
130
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_EDIT().getText(), "EDIT"),
|
|
131
|
+
...slots,
|
|
132
|
+
body: AstBuilder.#bodyTextOf(ctx),
|
|
133
|
+
position,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
static #buildCopy(ctx: CopyStatementContext): CopyStatement {
|
|
138
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
139
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
140
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
141
|
+
return {
|
|
142
|
+
op: "COPY",
|
|
143
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_COPY().getText(), "COPY"),
|
|
144
|
+
...slots,
|
|
145
|
+
body: raw !== null ? AstBuilder.#parsePath(raw, position) : null,
|
|
146
|
+
position,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static #buildMove(ctx: MoveStatementContext): MoveStatement {
|
|
151
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
152
|
+
const slots = AstBuilder.#extractTagSlots(ctx.tagOpModifiers(), position);
|
|
153
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
154
|
+
return {
|
|
155
|
+
op: "MOVE",
|
|
156
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_MOVE().getText(), "MOVE"),
|
|
157
|
+
...slots,
|
|
158
|
+
body: raw !== null ? AstBuilder.#parsePath(raw, position) : null,
|
|
159
|
+
position,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
static #buildSend(ctx: SendStatementContext): SendStatement {
|
|
164
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
165
|
+
const slots = AstBuilder.#extractSendSlots(ctx.sendModifiers(), position);
|
|
166
|
+
const raw = AstBuilder.#bodyTextOf(ctx);
|
|
167
|
+
return {
|
|
168
|
+
op: "SEND",
|
|
169
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_SEND().getText(), "SEND"),
|
|
170
|
+
...slots,
|
|
171
|
+
lineMarker: null,
|
|
172
|
+
body: raw !== null ? AstBuilder.#parseSendBody(raw) : null,
|
|
173
|
+
position,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static #buildExec(ctx: ExecStatementContext): ExecStatement {
|
|
178
|
+
const position = AstBuilder.#positionOf(ctx);
|
|
179
|
+
const slots = AstBuilder.#extractExecSlots(ctx.execModifiers(), position);
|
|
180
|
+
return {
|
|
181
|
+
op: "EXEC",
|
|
182
|
+
suffix: AstBuilder.#splitSuffix(ctx.OPEN_EXEC().getText(), "EXEC"),
|
|
183
|
+
...slots,
|
|
184
|
+
lineMarker: null,
|
|
185
|
+
body: AstBuilder.#bodyTextOf(ctx),
|
|
186
|
+
position,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
static #extractTagSlots(modCtx: TagOpModifiersContext | null, pos: Position): TagSlots {
|
|
191
|
+
return {
|
|
192
|
+
signal: AstBuilder.#tagsFromSignal(AstBuilder.#findFirst(modCtx, TagSignalContext)),
|
|
193
|
+
path: AstBuilder.#pathFromCtx(AstBuilder.#findFirst(modCtx, PathContext), pos),
|
|
194
|
+
lineMarker: AstBuilder.#lineMarkerFromCtx(AstBuilder.#findFirst(modCtx, LineMarkerContext)),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
static #extractSendSlots(modCtx: SendModifiersContext | null, pos: Position): SendSlots {
|
|
199
|
+
const intCtx = AstBuilder.#findFirst(modCtx, IntSignalContext);
|
|
200
|
+
const intNode = intCtx?.INT() ?? null;
|
|
201
|
+
return {
|
|
202
|
+
signal: intNode !== null ? Number.parseInt(intNode.getText(), 10) : null,
|
|
203
|
+
path: AstBuilder.#pathFromCtx(AstBuilder.#findFirst(modCtx, PathContext), pos),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
static #extractExecSlots(modCtx: ExecModifiersContext | null, pos: Position): ExecSlots {
|
|
208
|
+
const identCtx = AstBuilder.#findFirst(modCtx, IdentSignalContext);
|
|
209
|
+
const identNode = identCtx?.IDENT() ?? null;
|
|
210
|
+
return {
|
|
211
|
+
signal: identNode !== null ? identNode.getText() : null,
|
|
212
|
+
path: AstBuilder.#pathFromCtx(AstBuilder.#findFirst(modCtx, PathContext), pos),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static #findFirst<T extends ParserRuleContext>(
|
|
217
|
+
root: ParserRuleContext | null,
|
|
218
|
+
type: Ctor<T>,
|
|
219
|
+
): T | null {
|
|
220
|
+
if (root === null) return null;
|
|
221
|
+
if (root instanceof type) return root;
|
|
222
|
+
const children = root.children;
|
|
223
|
+
if (!children) return null;
|
|
224
|
+
for (const child of children) {
|
|
225
|
+
if (child instanceof ParserRuleContext) {
|
|
226
|
+
const found = AstBuilder.#findFirst(child, type);
|
|
227
|
+
if (found !== null) return found;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
static #tagsFromSignal(ctx: TagSignalContext | null): string[] | null {
|
|
234
|
+
if (ctx === null) return null;
|
|
235
|
+
const tags = ctx.TAG();
|
|
236
|
+
return Array.isArray(tags) ? tags.map((t) => t.getText()) : [];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
static #pathFromCtx(ctx: PathContext | null, pos: Position): ParsedPath | null {
|
|
240
|
+
if (ctx === null) return null;
|
|
241
|
+
const text = ctx.PATH_TEXT()?.getText() ?? "";
|
|
242
|
+
return AstBuilder.#parsePath(text, pos);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
static #lineMarkerFromCtx(ctx: LineMarkerContext | null): LineMarker | null {
|
|
246
|
+
if (ctx === null) return null;
|
|
247
|
+
const text = ctx.L_MARKER()?.getText() ?? "";
|
|
248
|
+
return AstBuilder.#parseLineMarker(text);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
static #positionOf(ctx: { start: { line: number; column: number } | null }): Position {
|
|
252
|
+
const start = ctx.start;
|
|
253
|
+
return { line: start?.line ?? 0, column: start?.column ?? 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
static #bodyTextOf(ctx: { body(): { getText(): string } | null }): string | null {
|
|
257
|
+
const bodyCtx = ctx.body();
|
|
258
|
+
return bodyCtx ? bodyCtx.getText() : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
static #splitSuffix(openTagText: string, op: PlurnkOp): string {
|
|
262
|
+
return openTagText.slice(2 + op.length);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static #isDigit(c: string | undefined): boolean {
|
|
266
|
+
return c !== undefined && c >= "0" && c <= "9";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static #parseLineMarker(text: string): LineMarker {
|
|
270
|
+
const inner = text.slice(1, -1);
|
|
271
|
+
let i = 0;
|
|
272
|
+
if (inner[i] === "-") i++;
|
|
273
|
+
while (AstBuilder.#isDigit(inner[i])) i++;
|
|
274
|
+
const first = Number.parseInt(inner.slice(0, i), 10);
|
|
275
|
+
if (i >= inner.length) return { first, last: null };
|
|
276
|
+
i++;
|
|
277
|
+
const last = Number.parseInt(inner.slice(i), 10);
|
|
278
|
+
return { first, last };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
static #parsePath(raw: string, pos: Position): ParsedPath | null {
|
|
282
|
+
if (raw.length === 0) return null;
|
|
283
|
+
if (!AstBuilder.#SCHEME_PATTERN.test(raw)) {
|
|
284
|
+
return { kind: "local", raw };
|
|
285
|
+
}
|
|
286
|
+
let url: URL;
|
|
287
|
+
try {
|
|
288
|
+
url = new URL(raw);
|
|
289
|
+
} catch (e: any) {
|
|
290
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `invalid URI in path: ${e?.message ?? raw}`);
|
|
291
|
+
}
|
|
292
|
+
const params: Record<string, string | string[]> = {};
|
|
293
|
+
for (const [key, value] of url.searchParams) {
|
|
294
|
+
const existing = params[key];
|
|
295
|
+
if (existing === undefined) {
|
|
296
|
+
params[key] = value;
|
|
297
|
+
} else if (Array.isArray(existing)) {
|
|
298
|
+
existing.push(value);
|
|
299
|
+
} else {
|
|
300
|
+
params[key] = [existing, value];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
kind: "url",
|
|
305
|
+
raw,
|
|
306
|
+
scheme: url.protocol.replace(/:$/, ""),
|
|
307
|
+
username: url.username || null,
|
|
308
|
+
password: url.password || null,
|
|
309
|
+
hostname: url.hostname || null,
|
|
310
|
+
port: url.port ? Number.parseInt(url.port, 10) : null,
|
|
311
|
+
pathname: url.pathname,
|
|
312
|
+
params,
|
|
313
|
+
fragment: url.hash ? url.hash.slice(1) : null,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
static #detectMatcherDialect(body: string): "xpath" | "regex" | "jsonpath" | "glob" {
|
|
318
|
+
if (body.startsWith("//")) return "xpath";
|
|
319
|
+
if (body.startsWith("/")) return "regex";
|
|
320
|
+
if (body.startsWith("$")) return "jsonpath";
|
|
321
|
+
return "glob";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static #parseRegexLiteral(body: string, pos: Position): { pattern: string; flags: string } {
|
|
325
|
+
let i = 1;
|
|
326
|
+
while (i < body.length) {
|
|
327
|
+
if (body[i] === "\\") { i += 2; continue; }
|
|
328
|
+
if (body[i] === "/") break;
|
|
329
|
+
i++;
|
|
330
|
+
}
|
|
331
|
+
if (i >= body.length) {
|
|
332
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", "regex body missing closing /");
|
|
333
|
+
}
|
|
334
|
+
return { pattern: body.slice(1, i), flags: body.slice(i + 1) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
static #parseMatcherBody(body: string, pos: Position): MatcherBody {
|
|
338
|
+
const dialect = AstBuilder.#detectMatcherDialect(body);
|
|
339
|
+
if (dialect === "regex") {
|
|
340
|
+
const { pattern, flags } = AstBuilder.#parseRegexLiteral(body, pos);
|
|
341
|
+
try {
|
|
342
|
+
new RegExp(pattern, flags);
|
|
343
|
+
} catch (e: any) {
|
|
344
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `invalid regex: ${e?.message ?? body}`);
|
|
345
|
+
}
|
|
346
|
+
return { dialect: "regex", raw: body, pattern, flags };
|
|
347
|
+
}
|
|
348
|
+
if (dialect === "xpath") {
|
|
349
|
+
try {
|
|
350
|
+
xpath.parse(body);
|
|
351
|
+
} catch (e: any) {
|
|
352
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `invalid xpath: ${e?.message ?? body}`);
|
|
353
|
+
}
|
|
354
|
+
return { dialect: "xpath", raw: body };
|
|
355
|
+
}
|
|
356
|
+
if (dialect === "jsonpath") {
|
|
357
|
+
try {
|
|
358
|
+
JSONPath({ path: body, json: {} });
|
|
359
|
+
} catch (e: any) {
|
|
360
|
+
throw new PlurnkParseError(pos.line, pos.column, "visitor", `invalid jsonpath: ${e?.message ?? body}`);
|
|
361
|
+
}
|
|
362
|
+
return { dialect: "jsonpath", raw: body };
|
|
363
|
+
}
|
|
364
|
+
return { dialect: "glob", raw: body };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
static #parseSendBody(raw: string): SendBody {
|
|
368
|
+
let json: unknown | null = null;
|
|
369
|
+
try { json = JSON.parse(raw); } catch { /* best-effort */ }
|
|
370
|
+
return { raw, json };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultErrorStrategy,
|
|
3
|
+
InputMismatchException,
|
|
4
|
+
NoViableAltException,
|
|
5
|
+
Token,
|
|
6
|
+
type Parser,
|
|
7
|
+
type RecognitionException,
|
|
8
|
+
} from "antlr4ng";
|
|
9
|
+
import { plurnkParser } from "./generated/plurnkParser.ts";
|
|
10
|
+
import { plurnkLexer } from "./generated/plurnkLexer.ts";
|
|
11
|
+
|
|
12
|
+
export default class PlurnkErrorStrategy extends DefaultErrorStrategy {
|
|
13
|
+
static #OFFENDING_CHAR_RE = /at: '([^']*)'$/;
|
|
14
|
+
|
|
15
|
+
static #LEXER_MODE_CONTEXT: Record<string, string> = {
|
|
16
|
+
DEFAULT_MODE: "between statements",
|
|
17
|
+
SLOTS: "in slot region — expected `[signal]`, `(path)`, `<L>`, or `:body:` (any order)",
|
|
18
|
+
SIGNAL_TAGS: "in tag signal — expected tag, `,`, or `]`",
|
|
19
|
+
SIGNAL_INT: "in signal — expected integer for SEND, then `]`",
|
|
20
|
+
SIGNAL_IDENT: "in signal — expected Runtime Tag for EXEC, then `]`",
|
|
21
|
+
PATH: "in path slot — expected path characters or `)`",
|
|
22
|
+
BODY: "in body",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
static #SLOT_BY_TOKEN: Record<number, string> = {
|
|
26
|
+
[plurnkParser.OPEN_FIND]: "open tag `<<OPsuffix`",
|
|
27
|
+
[plurnkParser.OPEN_READ]: "open tag `<<OPsuffix`",
|
|
28
|
+
[plurnkParser.OPEN_EDIT]: "open tag `<<OPsuffix`",
|
|
29
|
+
[plurnkParser.OPEN_COPY]: "open tag `<<OPsuffix`",
|
|
30
|
+
[plurnkParser.OPEN_MOVE]: "open tag `<<OPsuffix`",
|
|
31
|
+
[plurnkParser.OPEN_SHOW]: "open tag `<<OPsuffix`",
|
|
32
|
+
[plurnkParser.OPEN_HIDE]: "open tag `<<OPsuffix`",
|
|
33
|
+
[plurnkParser.OPEN_SEND]: "open tag `<<OPsuffix`",
|
|
34
|
+
[plurnkParser.OPEN_EXEC]: "open tag `<<OPsuffix`",
|
|
35
|
+
[plurnkParser.LBRACKET]: "`[` (signal slot opener)",
|
|
36
|
+
[plurnkParser.RBRACKET]: "`]` (signal slot closer)",
|
|
37
|
+
[plurnkParser.LPAREN]: "`(` (path slot opener)",
|
|
38
|
+
[plurnkParser.RPAREN]: "`)` (path slot closer)",
|
|
39
|
+
[plurnkParser.L_MARKER]: "`<L>` line marker",
|
|
40
|
+
[plurnkParser.COLON]: "`:` (body fence)",
|
|
41
|
+
[plurnkParser.COMMA]: "`,`",
|
|
42
|
+
[plurnkParser.INT]: "integer (SEND signal)",
|
|
43
|
+
[plurnkParser.IDENT]: "Runtime Tag (EXEC signal)",
|
|
44
|
+
[plurnkParser.TAG]: "tag",
|
|
45
|
+
[plurnkParser.PATH_TEXT]: "path content",
|
|
46
|
+
[plurnkParser.BODY_TEXT]: "body content",
|
|
47
|
+
[plurnkParser.CLOSE_TAG]: "close tag `:OPsuffix`",
|
|
48
|
+
[plurnkParser.TEXT]: "text between statements",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
static translateLexerMessage(lexer: plurnkLexer, originalMsg: string): string {
|
|
52
|
+
const modeName = lexer.modeNames[lexer.mode] ?? "DEFAULT_MODE";
|
|
53
|
+
const context = PlurnkErrorStrategy.#LEXER_MODE_CONTEXT[modeName] ?? "between statements";
|
|
54
|
+
const ch = PlurnkErrorStrategy.#extractOffendingChar(originalMsg);
|
|
55
|
+
return `unrecognized character ${ch} ${context}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static #extractOffendingChar(msg: string): string {
|
|
59
|
+
const m = PlurnkErrorStrategy.#OFFENDING_CHAR_RE.exec(msg);
|
|
60
|
+
if (!m) return "input";
|
|
61
|
+
const text = m[1];
|
|
62
|
+
return text === "" ? "end of input" : `'${text}'`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
static #describeToken(tok: Token | null): string {
|
|
66
|
+
if (!tok || tok.type === Token.EOF) return "end of input";
|
|
67
|
+
const slot = PlurnkErrorStrategy.#SLOT_BY_TOKEN[tok.type];
|
|
68
|
+
if (slot) return slot;
|
|
69
|
+
const text = tok.text ?? "";
|
|
70
|
+
return text.length > 0 ? `'${text}'` : "input";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static #describeExpected(e: RecognitionException): string | null {
|
|
74
|
+
const expected = e.getExpectedTokens();
|
|
75
|
+
if (!expected) return null;
|
|
76
|
+
const types: number[] = expected.toArray();
|
|
77
|
+
if (types.length === 0) return null;
|
|
78
|
+
const names = types
|
|
79
|
+
.map((t) => PlurnkErrorStrategy.#SLOT_BY_TOKEN[t])
|
|
80
|
+
.filter((s): s is string => Boolean(s));
|
|
81
|
+
if (names.length === 0) return null;
|
|
82
|
+
if (names.length === 1) return names[0];
|
|
83
|
+
if (names.length === 2) return `${names[0]} or ${names[1]}`;
|
|
84
|
+
return `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public override reportError(recognizer: Parser, e: RecognitionException): void {
|
|
88
|
+
if (this.inErrorRecoveryMode(recognizer)) return;
|
|
89
|
+
this.beginErrorCondition(recognizer);
|
|
90
|
+
|
|
91
|
+
const got = PlurnkErrorStrategy.#describeToken(e.offendingToken);
|
|
92
|
+
const expected = PlurnkErrorStrategy.#describeExpected(e);
|
|
93
|
+
|
|
94
|
+
let msg: string;
|
|
95
|
+
if (e instanceof InputMismatchException || e instanceof NoViableAltException) {
|
|
96
|
+
msg = expected ? `unexpected ${got}; expected ${expected}` : `unexpected ${got}`;
|
|
97
|
+
} else {
|
|
98
|
+
msg = `unexpected ${got}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
recognizer.notifyErrorListeners(msg, e.offendingToken, e);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public override reportMissingToken(recognizer: Parser): void {
|
|
105
|
+
if (this.inErrorRecoveryMode(recognizer)) return;
|
|
106
|
+
this.beginErrorCondition(recognizer);
|
|
107
|
+
const tok = recognizer.getCurrentToken();
|
|
108
|
+
const expectedTokens = this.getExpectedTokens(recognizer);
|
|
109
|
+
const expectedNames = expectedTokens
|
|
110
|
+
.toArray()
|
|
111
|
+
.map((t) => PlurnkErrorStrategy.#SLOT_BY_TOKEN[t])
|
|
112
|
+
.filter((s): s is string => Boolean(s));
|
|
113
|
+
const expected = expectedNames.length > 0
|
|
114
|
+
? (expectedNames.length === 1 ? expectedNames[0] : expectedNames.join(" or "))
|
|
115
|
+
: "more input";
|
|
116
|
+
const got = PlurnkErrorStrategy.#describeToken(tok);
|
|
117
|
+
const msg = `expected ${expected}; got ${got}`;
|
|
118
|
+
recognizer.notifyErrorListeners(msg, tok, null);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public override reportUnwantedToken(recognizer: Parser): void {
|
|
122
|
+
if (this.inErrorRecoveryMode(recognizer)) return;
|
|
123
|
+
this.beginErrorCondition(recognizer);
|
|
124
|
+
const tok = recognizer.getCurrentToken();
|
|
125
|
+
const got = PlurnkErrorStrategy.#describeToken(tok);
|
|
126
|
+
const expectedTokens = this.getExpectedTokens(recognizer);
|
|
127
|
+
const expectedNames = expectedTokens
|
|
128
|
+
.toArray()
|
|
129
|
+
.map((t) => PlurnkErrorStrategy.#SLOT_BY_TOKEN[t])
|
|
130
|
+
.filter((s): s is string => Boolean(s));
|
|
131
|
+
const expected = expectedNames.length > 0
|
|
132
|
+
? (expectedNames.length === 1 ? expectedNames[0] : expectedNames.join(" or "))
|
|
133
|
+
: null;
|
|
134
|
+
const msg = expected
|
|
135
|
+
? `unexpected ${got}; expected ${expected}`
|
|
136
|
+
: `unexpected ${got}`;
|
|
137
|
+
recognizer.notifyErrorListeners(msg, tok, null);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type ErrorSource = "lexer" | "parser" | "visitor";
|
|
2
2
|
|
|
3
|
-
export class PlurnkParseError extends Error {
|
|
3
|
+
export default class PlurnkParseError extends Error {
|
|
4
4
|
readonly line: number;
|
|
5
5
|
readonly column: number;
|
|
6
6
|
readonly source: ErrorSource;
|
|
@@ -13,7 +13,6 @@ export class PlurnkParseError extends Error {
|
|
|
13
13
|
this.source = source;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** JSON serialization — `JSON.stringify` picks this up automatically. */
|
|
17
16
|
toJSON(): { line: number; column: number; source: ErrorSource; message: string } {
|
|
18
17
|
return {
|
|
19
18
|
line: this.line,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { CharStream, CommonTokenStream } from "antlr4ng";
|
|
2
|
+
import { plurnkLexer } from "./generated/plurnkLexer.ts";
|
|
3
|
+
import { plurnkParser } from "./generated/plurnkParser.ts";
|
|
4
|
+
import AstBuilder from "./AstBuilder.ts";
|
|
5
|
+
import PlurnkParseError from "./PlurnkParseError.ts";
|
|
6
|
+
import PlurnkErrorStrategy from "./PlurnkErrorStrategy.ts";
|
|
7
|
+
import RecordingListener from "./RecordingListener.ts";
|
|
8
|
+
import type { ParseItem, ParseResult, Position } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export default class PlurnkParser {
|
|
11
|
+
static parse(input: string): ParseResult {
|
|
12
|
+
const lexer = new plurnkLexer(CharStream.fromString(input));
|
|
13
|
+
const errors: PlurnkParseError[] = [];
|
|
14
|
+
lexer.removeErrorListeners();
|
|
15
|
+
lexer.addErrorListener(new RecordingListener("lexer", errors));
|
|
16
|
+
|
|
17
|
+
const tokenStream = new CommonTokenStream(lexer);
|
|
18
|
+
const parser = new plurnkParser(tokenStream);
|
|
19
|
+
parser.removeErrorListeners();
|
|
20
|
+
parser.addErrorListener(new RecordingListener("parser", errors));
|
|
21
|
+
parser.errorHandler = new PlurnkErrorStrategy();
|
|
22
|
+
|
|
23
|
+
const tree = parser.document();
|
|
24
|
+
|
|
25
|
+
const items: ParseItem[] = [];
|
|
26
|
+
const consumedErrors = new Set<PlurnkParseError>();
|
|
27
|
+
|
|
28
|
+
for (const child of tree.children ?? []) {
|
|
29
|
+
const ctx = child as any;
|
|
30
|
+
const start = ctx.start ?? ctx.symbol;
|
|
31
|
+
const stop = ctx.stop ?? ctx.symbol;
|
|
32
|
+
if (!start) continue;
|
|
33
|
+
|
|
34
|
+
if (ctx.ruleIndex === plurnkParser.RULE_statement) {
|
|
35
|
+
const errForStatement = errors.find(
|
|
36
|
+
(e) => !consumedErrors.has(e) && PlurnkParser.#errorInRange(e, start, stop ?? start),
|
|
37
|
+
);
|
|
38
|
+
if (errForStatement) {
|
|
39
|
+
consumedErrors.add(errForStatement);
|
|
40
|
+
items.push({ kind: "error", error: errForStatement });
|
|
41
|
+
} else {
|
|
42
|
+
try {
|
|
43
|
+
items.push({ kind: "statement", statement: AstBuilder.build(ctx) });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e instanceof PlurnkParseError) {
|
|
46
|
+
items.push({ kind: "error", error: e });
|
|
47
|
+
} else {
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} else if (ctx.symbol?.type === plurnkLexer.TEXT) {
|
|
53
|
+
const position: Position = { line: start.line, column: start.column };
|
|
54
|
+
items.push({ kind: "text", text: ctx.symbol.text ?? "", position });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const err of errors) {
|
|
59
|
+
if (!consumedErrors.has(err)) {
|
|
60
|
+
items.push({ kind: "error", error: err });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let unparsedTail: ParseResult["unparsedTail"];
|
|
65
|
+
if (lexer.mode !== 0) {
|
|
66
|
+
const openTag = lexer.getOpenTag();
|
|
67
|
+
const from = { line: lexer.getOpenTagLine(), column: lexer.getOpenTagColumn() };
|
|
68
|
+
const modeName = lexer.modeNames[lexer.mode] ?? "";
|
|
69
|
+
const reason = modeName === "BODY"
|
|
70
|
+
? `body of \`<<${openTag}\` opened at line ${from.line} but never closed — add \`:${openTag}\` to terminate`
|
|
71
|
+
: modeName === "SIGNAL_TAGS" || modeName === "SIGNAL_INT" || modeName === "SIGNAL_IDENT"
|
|
72
|
+
? `signal slot of \`<<${openTag}\` opened at line ${from.line} but never closed — add \`]\` to terminate the signal`
|
|
73
|
+
: modeName === "PATH"
|
|
74
|
+
? `path slot of \`<<${openTag}\` opened at line ${from.line} but never closed — add \`)\` to terminate the path`
|
|
75
|
+
: `statement \`<<${openTag}\` opened at line ${from.line} but never reached its close tag — add \`:${openTag}\` to terminate`;
|
|
76
|
+
unparsedTail = { from, reason };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { items, unparsedTail };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static #errorInRange(
|
|
83
|
+
err: PlurnkParseError,
|
|
84
|
+
start: { line: number; column: number },
|
|
85
|
+
stop: { line: number; column: number },
|
|
86
|
+
): boolean {
|
|
87
|
+
if (err.line < start.line || err.line > stop.line) return false;
|
|
88
|
+
if (err.line === start.line && err.column < start.column) return false;
|
|
89
|
+
if (err.line === stop.line && err.column > stop.column) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BaseErrorListener,
|
|
3
|
+
type RecognitionException,
|
|
4
|
+
type Recognizer,
|
|
5
|
+
type Token,
|
|
6
|
+
} from "antlr4ng";
|
|
7
|
+
import { plurnkLexer } from "./generated/plurnkLexer.ts";
|
|
8
|
+
import PlurnkParseError from "./PlurnkParseError.ts";
|
|
9
|
+
import PlurnkErrorStrategy from "./PlurnkErrorStrategy.ts";
|
|
10
|
+
|
|
11
|
+
export default class RecordingListener extends BaseErrorListener {
|
|
12
|
+
readonly errors: PlurnkParseError[];
|
|
13
|
+
readonly source: "lexer" | "parser";
|
|
14
|
+
|
|
15
|
+
constructor(source: "lexer" | "parser", errors: PlurnkParseError[]) {
|
|
16
|
+
super();
|
|
17
|
+
this.source = source;
|
|
18
|
+
this.errors = errors;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override syntaxError(
|
|
22
|
+
recognizer: Recognizer<any>,
|
|
23
|
+
_offendingSymbol: Token | null,
|
|
24
|
+
line: number,
|
|
25
|
+
column: number,
|
|
26
|
+
msg: string,
|
|
27
|
+
_e: RecognitionException | null,
|
|
28
|
+
): void {
|
|
29
|
+
const translated = this.source === "lexer"
|
|
30
|
+
? PlurnkErrorStrategy.translateLexerMessage(recognizer as plurnkLexer, msg)
|
|
31
|
+
: msg;
|
|
32
|
+
this.errors.push(new PlurnkParseError(line, column, this.source, translated));
|
|
33
|
+
}
|
|
34
|
+
}
|