@dforge-core/metadata 0.0.23 → 0.0.25
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/CHANGELOG.md +93 -0
- package/dist/dsl/index.d.ts +37 -9
- package/dist/dsl/index.js +340 -12
- package/dist/dsl/index.js.map +1 -1
- package/dist/index.d.ts +24 -3
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dsl/check.ts +391 -7
- package/src/dsl/index.ts +2 -1
- package/src/dsl/lexer.ts +31 -1
- package/src/dsl/parse.ts +93 -19
- package/src/dsl/types.ts +8 -0
- package/src/index.ts +1 -0
- package/src/traits.ts +49 -9
- package/src/types.ts +15 -0
package/src/dsl/check.ts
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
|
|
13
13
|
import { BUILTIN_BY_NAME } from "./builtins";
|
|
14
14
|
import { CONTROL_KEYWORDS, type Token } from "./lexer";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type BlockKind,
|
|
17
|
+
type DslDocument,
|
|
18
|
+
type Span,
|
|
19
|
+
blockLabelAt,
|
|
20
|
+
parseDsl,
|
|
21
|
+
} from "./parse";
|
|
16
22
|
import type { ColumnLookup, DslContext, DslIssue, DslSeverity } from "./types";
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -51,6 +57,94 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
51
57
|
const isBatch = (action?.executionMode ?? "single") === "batch";
|
|
52
58
|
const hasColumn = columnMatcher(entity?.columns);
|
|
53
59
|
|
|
60
|
+
// ── structure ────────────────────────────────────────────────────
|
|
61
|
+
// ActionDslCompiler.ParseBlocks reads the headers before anything else, so
|
|
62
|
+
// a file that loses a block here loses it wholesale: the body still looks
|
|
63
|
+
// like code, and nothing runs.
|
|
64
|
+
if (text.trim() === "") {
|
|
65
|
+
add(
|
|
66
|
+
{ start: 0, end: text.length },
|
|
67
|
+
"A DSL body needs at least an execute: block — an empty file installs an action that does nothing.",
|
|
68
|
+
"error",
|
|
69
|
+
"dsl/empty-script",
|
|
70
|
+
);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A file with no headers at all has no label to point at, and a zero-width
|
|
75
|
+
// span renders as a caret before the first character — so fall back to the
|
|
76
|
+
// first token, which is where the reader is already looking.
|
|
77
|
+
const firstToken = parsed.tokens[0];
|
|
78
|
+
const firstLabel = parsed.blocks[0]?.label ??
|
|
79
|
+
(firstToken ? { start: firstToken.start, end: firstToken.end } : { start: 0, end: text.length });
|
|
80
|
+
if (!parsed.blocks.some((b) => b.kind === "execute")) {
|
|
81
|
+
add(
|
|
82
|
+
firstLabel,
|
|
83
|
+
"No execute: block — it is the only required block, and the compiler runs nothing without it.",
|
|
84
|
+
"error",
|
|
85
|
+
"dsl/missing-execute",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `execute:` is matched as `(.*?)\z`, and its lookahead lists nothing — so it
|
|
90
|
+
// runs to the end of the file and takes every header below it with it. The
|
|
91
|
+
// block's own regex still finds it and it still runs; what breaks is
|
|
92
|
+
// execute:, whose body now holds a header line and a formula or a param
|
|
93
|
+
// list, parsed as JavaScript.
|
|
94
|
+
const executeBlock = parsed.blocks.find((b) => b.kind === "execute");
|
|
95
|
+
if (executeBlock) {
|
|
96
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
97
|
+
const token = parsed.tokens[i]!;
|
|
98
|
+
if (token.start <= executeBlock.label.start) continue;
|
|
99
|
+
if (token.character !== 0) continue;
|
|
100
|
+
const label = blockLabelAt(parsed.tokens, i);
|
|
101
|
+
if (!label) continue;
|
|
102
|
+
// A second execute: cannot be moved above the first. findBlocks
|
|
103
|
+
// stops at the first one, so dsl/duplicate-block never sees this
|
|
104
|
+
// and the wording has to come from here.
|
|
105
|
+
add(
|
|
106
|
+
label.span,
|
|
107
|
+
label.kind === "execute"
|
|
108
|
+
? `"execute:" is declared more than once — the first one's body runs to the end of the file, so this header and everything under it are swallowed into it and parsed as JavaScript. Merge the two into one execute: block.`
|
|
109
|
+
: `"${label.kind}:" is below execute:, whose body runs to the end of the file — the header and everything under it are swallowed into the execute: script and parsed as JavaScript. Move it above execute:.`,
|
|
110
|
+
"error",
|
|
111
|
+
"dsl/block-after-execute",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const seenBlocks = new Set<string>();
|
|
117
|
+
for (let i = 0; i < parsed.blocks.length; i++) {
|
|
118
|
+
const block = parsed.blocks[i]!;
|
|
119
|
+
if (seenBlocks.has(block.kind)) {
|
|
120
|
+
add(
|
|
121
|
+
block.label,
|
|
122
|
+
`"${block.kind}:" is declared more than once — the compiler keeps one and silently drops the other.`,
|
|
123
|
+
"error",
|
|
124
|
+
"dsl/duplicate-block",
|
|
125
|
+
);
|
|
126
|
+
// No block's own lookahead lists itself, so the order rule below
|
|
127
|
+
// would read a duplicate as misplaced and tell the author to move
|
|
128
|
+
// it before itself.
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seenBlocks.add(block.kind);
|
|
132
|
+
|
|
133
|
+
// Only the block immediately above can swallow this one: a block body
|
|
134
|
+
// ends at the first header its own lookahead lists, and the nearest
|
|
135
|
+
// header below it is this one. execute: is left to the rule above, which
|
|
136
|
+
// says the same thing about it in better words.
|
|
137
|
+
const prev = parsed.blocks[i - 1];
|
|
138
|
+
if (prev && prev.kind !== "execute" && !BLOCK_STOPS[prev.kind].has(block.kind)) {
|
|
139
|
+
add(
|
|
140
|
+
block.label,
|
|
141
|
+
`"${block.kind}:" must come before "${prev.kind}:" — the compiler reads ${prev.kind}: up to the next header it recognises, and ${block.kind}: is not one of them, so this header and its body end up inside the ${prev.kind}: body.`,
|
|
142
|
+
"error",
|
|
143
|
+
"dsl/block-order",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
54
148
|
// ── record fields ────────────────────────────────────────────────
|
|
55
149
|
for (const ref of parsed.fieldRefs) {
|
|
56
150
|
// Neither block is rewritten by TransformLine, so brackets in them are
|
|
@@ -207,7 +301,8 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
207
301
|
if (call.name === "userId") {
|
|
208
302
|
add(
|
|
209
303
|
call.nameSpan,
|
|
210
|
-
"userId is a
|
|
304
|
+
"'userId' is a value, not a function — write userId without parentheses, or currentUserId(). " +
|
|
305
|
+
"The compiler's bare-identifier rewrite fires either way, so userId() reaches Jint as a call against a number.",
|
|
211
306
|
"error",
|
|
212
307
|
"dsl/user-id-call",
|
|
213
308
|
);
|
|
@@ -220,7 +315,9 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
220
315
|
if (!arg) continue;
|
|
221
316
|
|
|
222
317
|
// A hyphenated module code can't be schema-qualified, so the rule is
|
|
223
|
-
// skipped there rather than reported as unfixable.
|
|
318
|
+
// skipped there rather than reported as unfixable. Neither can a code
|
|
319
|
+
// half of which is spliced in at run time be read here.
|
|
320
|
+
if (arg.interpolated) continue;
|
|
224
321
|
if (arg.value.includes(".") || ctx.moduleCode?.includes("-")) continue;
|
|
225
322
|
add(
|
|
226
323
|
arg.span,
|
|
@@ -263,17 +360,17 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
263
360
|
}
|
|
264
361
|
|
|
265
362
|
if (depth === 0 && token.character > 0) {
|
|
266
|
-
const
|
|
363
|
+
const label = blockLabelAt(parsed.tokens, i);
|
|
267
364
|
// `execute: number` inside params: declares a param called execute.
|
|
268
365
|
// RxParamDecl takes any `\w+`, so a block name is a legal param
|
|
269
366
|
// name — and a declaration the parser already read is not a header.
|
|
270
367
|
const isParamDecl = parsed.params.some(
|
|
271
368
|
(p) => p.nameSpan.start === token.start,
|
|
272
369
|
);
|
|
273
|
-
if (
|
|
370
|
+
if (label && !isParamDecl) {
|
|
274
371
|
add(
|
|
275
|
-
|
|
276
|
-
`Block headers start at column 0. Indented, "${kind}:" is body text — the compiler never opens the block, and it runs as if empty.`,
|
|
372
|
+
label.span,
|
|
373
|
+
`Block headers start at column 0. Indented, "${label.kind}:" is body text — the compiler never opens the block, and it runs as if empty.`,
|
|
277
374
|
"error",
|
|
278
375
|
"dsl/indented-block-header",
|
|
279
376
|
);
|
|
@@ -299,6 +396,182 @@ export function checkDsl(text: string, ctx: DslContext = {}): DslIssue[] {
|
|
|
299
396
|
);
|
|
300
397
|
}
|
|
301
398
|
|
|
399
|
+
// ── formula-only functions in a JavaScript block ─────────────────
|
|
400
|
+
// execute: and onBeforeStart: compile to a bare script and run on Jint;
|
|
401
|
+
// the rest of the DSL is the formula engine. Install rejects these with
|
|
402
|
+
// "'TODAY' is not defined".
|
|
403
|
+
for (const call of parsed.calls) {
|
|
404
|
+
const replacement = FORMULA_ONLY.get(call.name);
|
|
405
|
+
if (!replacement) continue;
|
|
406
|
+
if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
|
|
407
|
+
add(
|
|
408
|
+
call.nameSpan,
|
|
409
|
+
`${call.name}() is formula-only — in ${call.block}: it is undefined and install fails with "'${call.name}' is not defined". Write ${replacement}.`,
|
|
410
|
+
"error",
|
|
411
|
+
"dsl/formula-only-function",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Token position by start offset — the two rules below both need to read
|
|
416
|
+
// what sits next to a call, and a scan per call is a scan of the file.
|
|
417
|
+
const tokenAt = new Map(parsed.tokens.map((t, i) => [t.start, i]));
|
|
418
|
+
|
|
419
|
+
// ── SQL placeholders are @name, not :name ────────────────────────
|
|
420
|
+
for (const call of parsed.calls) {
|
|
421
|
+
if (call.name !== "query" && call.name !== "callProc") continue;
|
|
422
|
+
const arg = call.firstStringArg;
|
|
423
|
+
if (!arg) continue; // a variable or a built string — nothing to read
|
|
424
|
+
|
|
425
|
+
// `::text` is a Postgres cast and `a:b` inside a literal is not a
|
|
426
|
+
// placeholder either, so require a non-word, non-colon character before.
|
|
427
|
+
// Quoted data and comments are not SQL to bind against at all —
|
|
428
|
+
// `select ':draft' as status` names no placeholder.
|
|
429
|
+
//
|
|
430
|
+
// The leading character is captured rather than looked behind: lookbehind
|
|
431
|
+
// is ES2018, and an unsupported one is a SyntaxError for the whole module
|
|
432
|
+
// at parse time, not a failure of this rule.
|
|
433
|
+
const bad = /(^|[^:\w]):([a-z][a-z0-9_]*)/i.exec(stripSqlNoise(arg.value));
|
|
434
|
+
if (bad) {
|
|
435
|
+
const name = bad[2];
|
|
436
|
+
add(
|
|
437
|
+
arg.span,
|
|
438
|
+
`SQL binds @name, not :name — rewrite ':${name}' as '@${name}' and pass { ${name}: value } as the params argument.`,
|
|
439
|
+
"error",
|
|
440
|
+
"dsl/sql-placeholder",
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A `${…}` hole is the splice itself; a `+` after the literal is the
|
|
445
|
+
// same splice written the other way.
|
|
446
|
+
if (arg.interpolated || parsed.tokens[arg.endIndex + 1]?.text === "+") {
|
|
447
|
+
const built = arg.interpolated ? "${…} interpolation" : "concatenation";
|
|
448
|
+
add(
|
|
449
|
+
arg.span,
|
|
450
|
+
// callProc's first argument is the procedure name, and an
|
|
451
|
+
// identifier cannot be bound — the @placeholder advice cannot
|
|
452
|
+
// be followed there.
|
|
453
|
+
call.name === "callProc"
|
|
454
|
+
? `Procedure name built by ${built} — a name is an identifier, so it cannot be bound as a @placeholder. Choose between fixed names in the script rather than splicing a value into one.`
|
|
455
|
+
: `SQL built by ${built} — pass @placeholders and a params object instead, so values are bound rather than spliced into the statement.`,
|
|
456
|
+
"warning",
|
|
457
|
+
"dsl/sql-concat",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── unknown host functions ───────────────────────────────────────
|
|
463
|
+
// A warning, not an error: the catalog tracks the host's built-ins by hand,
|
|
464
|
+
// so a name missing from it is more likely our gap than the author's typo.
|
|
465
|
+
//
|
|
466
|
+
// JavaScript blocks only. The catalog is Jint's host surface; canExecute:
|
|
467
|
+
// is the formula engine, with a vocabulary of its own — and its infix
|
|
468
|
+
// operators bring parentheses with them, so `… AND (x OR y)` reads as a
|
|
469
|
+
// call to AND here and is no such thing.
|
|
470
|
+
//
|
|
471
|
+
// Declarations are block-scoped because the compiler is: execute: and
|
|
472
|
+
// onBeforeStart: are compiled to separate scripts and each gets its own
|
|
473
|
+
// `CheckUndefinedReferences` pass, so a helper declared in one is not
|
|
474
|
+
// defined in the other. A declaration above the first header belongs to no
|
|
475
|
+
// block and is left visible to both — nothing legal can sit there.
|
|
476
|
+
// Every block it is declared in, not the last one: a helper both blocks need
|
|
477
|
+
// has to be written out in both, which is the shape this scoping produces.
|
|
478
|
+
const declaredFunctions = new Map<string, Set<BlockKind | null>>();
|
|
479
|
+
for (let i = 0; i < parsed.tokens.length; i++) {
|
|
480
|
+
const token = parsed.tokens[i]!;
|
|
481
|
+
if (token.text !== "function") continue;
|
|
482
|
+
const name = parsed.tokens[i + 1];
|
|
483
|
+
if (name?.kind !== "ident") continue;
|
|
484
|
+
const blocks = declaredFunctions.get(name.text) ?? new Set();
|
|
485
|
+
blocks.add(blockOf(parsed, token.start));
|
|
486
|
+
declaredFunctions.set(name.text, blocks);
|
|
487
|
+
}
|
|
488
|
+
const isDeclaredIn = (name: string, block: BlockKind | null) => {
|
|
489
|
+
const blocks = declaredFunctions.get(name);
|
|
490
|
+
return blocks !== undefined && (blocks.has(null) || blocks.has(block));
|
|
491
|
+
};
|
|
492
|
+
// `parsed.calls` is every `name(` in the body, so a function's own
|
|
493
|
+
// declaration head looks like a call to it: `function f(x)` and the method
|
|
494
|
+
// shorthand `f(x) { … }` alike. Neither is an invocation of anything.
|
|
495
|
+
const reportedUnknown = new Set<string>();
|
|
496
|
+
for (const call of parsed.calls) {
|
|
497
|
+
if (call.block !== "execute" && call.block !== "onBeforeStart") continue;
|
|
498
|
+
const name = call.name;
|
|
499
|
+
const idx = tokenAt.get(call.nameSpan.start);
|
|
500
|
+
if (idx !== undefined) {
|
|
501
|
+
// `new Foo()` names a constructor, not a host function — the
|
|
502
|
+
// catalog never held one and has nothing to say about it.
|
|
503
|
+
const prev = parsed.tokens[idx - 1]?.text;
|
|
504
|
+
if (prev === "function" || prev === "new") continue;
|
|
505
|
+
if (isMethodName(parsed.tokens, idx)) continue;
|
|
506
|
+
}
|
|
507
|
+
if (
|
|
508
|
+
BUILTIN_BY_NAME.has(name) ||
|
|
509
|
+
CONTROL_KEYWORDS.has(name) ||
|
|
510
|
+
KEYWORDS_BEFORE_PAREN.has(name) ||
|
|
511
|
+
JS_GLOBALS.has(name) ||
|
|
512
|
+
FORMULA_ONLY.has(name) ||
|
|
513
|
+
// Its own rule reports this one, with better wording.
|
|
514
|
+
name === "userId" ||
|
|
515
|
+
isDeclaredIn(name, call.block) ||
|
|
516
|
+
parsed.locals.has(name) ||
|
|
517
|
+
reportedUnknown.has(name)
|
|
518
|
+
) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
reportedUnknown.add(name);
|
|
522
|
+
add(
|
|
523
|
+
call.nameSpan,
|
|
524
|
+
`"${name}()" is not a DSL host function, and nothing in this script declares it. Check it against the built-in catalog.`,
|
|
525
|
+
"warning",
|
|
526
|
+
"dsl/unknown-builtin",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── a job's action has no current record ─────────────────────────
|
|
531
|
+
if (ctx.action?.viaJob) {
|
|
532
|
+
// `JobRegistrar.RequiresRecordContext` reads the compiled script for
|
|
533
|
+
// three markers, not one: `__r.` from `[field]`, `__old.` from the
|
|
534
|
+
// trigger-only `old[field]`, and `__records.` from the batch record set.
|
|
535
|
+
// All three are rejected, so all three are reported.
|
|
536
|
+
const inScript = (block: BlockKind | null) =>
|
|
537
|
+
block === "execute" || block === "onBeforeStart";
|
|
538
|
+
const bound: Array<{ span: Span; read: string }> = [];
|
|
539
|
+
|
|
540
|
+
// Same disambiguation the column rule makes, on the same terms: `[id]`
|
|
541
|
+
// is an array literal when the script binds `id`, and `[true]` is one
|
|
542
|
+
// in batch execute:, where no `[field]` rewrite runs to claim it.
|
|
543
|
+
for (const ref of parsed.fieldRefs) {
|
|
544
|
+
if (!inScript(ref.block)) continue;
|
|
545
|
+
if (isBatch && ref.block === "execute" && LITERAL_NAMES.has(ref.name))
|
|
546
|
+
continue;
|
|
547
|
+
if (ref.isCurrentRecord && parsed.locals.has(ref.name)) continue;
|
|
548
|
+
bound.push({ span: ref.outerSpan, read: `[${ref.name}]` });
|
|
549
|
+
}
|
|
550
|
+
for (const ref of parsed.globalRefs) {
|
|
551
|
+
if (ref.global !== "old" || !inScript(ref.block)) continue;
|
|
552
|
+
bound.push({ span: ref.span, read: `old[${ref.property}]` });
|
|
553
|
+
}
|
|
554
|
+
// Shadowed exactly as the execution-mode rule reads it: a script that
|
|
555
|
+
// binds its own `records` is not touching the batch global — and
|
|
556
|
+
// `var records = select(…)` is the very refactor this rule asks for.
|
|
557
|
+
if (!parsed.locals.has("records")) {
|
|
558
|
+
for (const span of parsed.recordsRefs) {
|
|
559
|
+
if (!inScript(blockOf(parsed, span.start))) continue;
|
|
560
|
+
bound.push({ span, read: "records" });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const first = bound.sort((a, b) => a.span.start - b.span.start)[0];
|
|
565
|
+
if (first) {
|
|
566
|
+
add(
|
|
567
|
+
first.span,
|
|
568
|
+
`Action "${ctx.action.code}" is invoked by a scheduled job, which runs as the system user with no record bound — ${first.read} has nothing to read. Fetch rows with select() or query() instead.`,
|
|
569
|
+
"error",
|
|
570
|
+
"dsl/job-record-context",
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
302
575
|
out.push(...inlineAssignmentIssues(parsed, positionAt));
|
|
303
576
|
return out;
|
|
304
577
|
}
|
|
@@ -362,6 +635,55 @@ function opensFunctionBody(
|
|
|
362
635
|
return head?.kind === "ident" && !CONTROL_KEYWORDS.has(head.text);
|
|
363
636
|
}
|
|
364
637
|
|
|
638
|
+
/**
|
|
639
|
+
* The headers each block's regex in `ParseBlocks` stops at — its body runs to
|
|
640
|
+
* the first of these below it, or to the end of the file.
|
|
641
|
+
*
|
|
642
|
+
* The sets are not the same, and none of them is "every other block": a block
|
|
643
|
+
* followed by one its own set omits swallows that block's text. `schema:`, the
|
|
644
|
+
* only one every other set lists, may therefore sit anywhere before `execute:`.
|
|
645
|
+
*/
|
|
646
|
+
const BLOCK_STOPS: Record<BlockKind, ReadonlySet<BlockKind>> = {
|
|
647
|
+
params: new Set(["schema", "canExecute", "onBeforeStart", "execute"]),
|
|
648
|
+
canExecute: new Set(["schema", "onBeforeStart", "execute"]),
|
|
649
|
+
schema: new Set(["params", "canExecute", "onBeforeStart", "execute"]),
|
|
650
|
+
onBeforeStart: new Set(["schema", "execute"]),
|
|
651
|
+
execute: new Set(),
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Formula-engine functions that are undefined in a JavaScript block, mapped to
|
|
656
|
+
* the spelling that works there.
|
|
657
|
+
*
|
|
658
|
+
* A Map, not an object: the key is a name read out of the script, and on an
|
|
659
|
+
* object `toString` and `constructor` would answer from the prototype — a
|
|
660
|
+
* declared `function toString()` would be reported as formula-only, with the
|
|
661
|
+
* native function's source offered as the replacement.
|
|
662
|
+
*/
|
|
663
|
+
const FORMULA_ONLY = new Map<string, string>([
|
|
664
|
+
["TODAY", "now()"],
|
|
665
|
+
["NOW", "now()"],
|
|
666
|
+
["CURRENT_USER_ID", "currentUserId()"],
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Keywords a `(` may legally follow. `parsed.calls` is every `name(` in the
|
|
671
|
+
* body, so these arrive looking like calls — `return (x)`, `typeof (v)` and
|
|
672
|
+
* `for (x of (xs))` included — and are not calls to anything.
|
|
673
|
+
*/
|
|
674
|
+
const KEYWORDS_BEFORE_PAREN = new Set([
|
|
675
|
+
"function", "return", "typeof", "instanceof", "new", "delete", "void",
|
|
676
|
+
"in", "of", "do", "else", "try", "throw", "yield", "await",
|
|
677
|
+
]);
|
|
678
|
+
|
|
679
|
+
/** Globals Jint exposes. Real calls, and none of them ours to report. */
|
|
680
|
+
const JS_GLOBALS = new Set([
|
|
681
|
+
"parseInt", "parseFloat", "isNaN", "isFinite", "encodeURI", "decodeURI",
|
|
682
|
+
"encodeURIComponent", "decodeURIComponent", "String", "Number", "Boolean",
|
|
683
|
+
"Array", "Object", "Date", "Math", "JSON", "RegExp", "Error", "Set", "Map",
|
|
684
|
+
"WeakSet", "WeakMap", "Promise", "Symbol", "BigInt",
|
|
685
|
+
]);
|
|
686
|
+
|
|
365
687
|
/**
|
|
366
688
|
* Keyword literals, which `[…]` can only be holding as array elements. Both
|
|
367
689
|
* spellings: the DSL takes SQL-style `NULL`/`TRUE`/`FALSE` as well as the
|
|
@@ -376,6 +698,68 @@ const LITERAL_NAMES = new Set([
|
|
|
376
698
|
"NULL",
|
|
377
699
|
]);
|
|
378
700
|
|
|
701
|
+
/**
|
|
702
|
+
* SQL with its string literals and comments blanked out, so a scan for
|
|
703
|
+
* placeholders reads only the statement. Same length in, same length out.
|
|
704
|
+
*
|
|
705
|
+
* Quotes double to escape themselves in SQL (`'it''s'`), which is why this
|
|
706
|
+
* cannot be a regex the way the JavaScript lexer's strings can.
|
|
707
|
+
*/
|
|
708
|
+
function stripSqlNoise(sql: string): string {
|
|
709
|
+
let out = "";
|
|
710
|
+
let i = 0;
|
|
711
|
+
while (i < sql.length) {
|
|
712
|
+
const c = sql[i]!;
|
|
713
|
+
|
|
714
|
+
if (c === "'" || c === '"') {
|
|
715
|
+
const quote = c;
|
|
716
|
+
out += " ";
|
|
717
|
+
i++;
|
|
718
|
+
while (i < sql.length) {
|
|
719
|
+
if (sql[i] === quote && sql[i + 1] === quote) {
|
|
720
|
+
out += " ";
|
|
721
|
+
i += 2;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (sql[i] === quote) break;
|
|
725
|
+
out += sql[i] === "\n" ? "\n" : " ";
|
|
726
|
+
i++;
|
|
727
|
+
}
|
|
728
|
+
if (i < sql.length) {
|
|
729
|
+
out += " ";
|
|
730
|
+
i++;
|
|
731
|
+
}
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
736
|
+
while (i < sql.length && sql[i] !== "\n") {
|
|
737
|
+
out += " ";
|
|
738
|
+
i++;
|
|
739
|
+
}
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
744
|
+
out += " ";
|
|
745
|
+
i += 2;
|
|
746
|
+
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) {
|
|
747
|
+
out += sql[i] === "\n" ? "\n" : " ";
|
|
748
|
+
i++;
|
|
749
|
+
}
|
|
750
|
+
if (i < sql.length) {
|
|
751
|
+
out += " ";
|
|
752
|
+
i += 2;
|
|
753
|
+
}
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
out += c;
|
|
758
|
+
i++;
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
|
|
379
763
|
function blockOf(parsed: DslDocument, offset: number) {
|
|
380
764
|
return (
|
|
381
765
|
parsed.blocks.find((b) => offset >= b.body.start && offset < b.body.end)
|
package/src/dsl/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Consumers: the language server (diagnostics, completion, hover,
|
|
9
9
|
// go-to-definition) and the module validator that runs before a pack.
|
|
10
10
|
|
|
11
|
-
export { tokenize, stringValue } from "./lexer";
|
|
11
|
+
export { tokenize, stringValue, templateChunkValue } from "./lexer";
|
|
12
12
|
export type { Token, TokenKind } from "./lexer";
|
|
13
13
|
|
|
14
14
|
export { parseDsl, BLOCK_KINDS } from "./parse";
|
|
@@ -21,6 +21,7 @@ export type {
|
|
|
21
21
|
FieldRef,
|
|
22
22
|
GlobalRef,
|
|
23
23
|
Span,
|
|
24
|
+
StringArg,
|
|
24
25
|
} from "./parse";
|
|
25
26
|
|
|
26
27
|
export { BUILTINS, BUILTIN_BY_NAME, BUILTIN_VALUES } from "./builtins";
|
package/src/dsl/lexer.ts
CHANGED
|
@@ -360,5 +360,35 @@ function scanRegex(text: string, start: number): number {
|
|
|
360
360
|
export function stringValue(token: Token): string {
|
|
361
361
|
if (token.kind !== "string") return token.text;
|
|
362
362
|
const body = token.text.slice(1, token.text.endsWith(token.text[0]!) && token.text.length > 1 ? -1 : undefined);
|
|
363
|
-
return body
|
|
363
|
+
return unescape(body);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The literal text of one template chunk, delimiters removed. A chunk opens on
|
|
368
|
+
* a backtick or on the `}` that closed the hole before it, and closes on a
|
|
369
|
+
* backtick, on the `${` of the next hole, or on the end of the file.
|
|
370
|
+
*/
|
|
371
|
+
export function templateChunkValue(token: Token): string {
|
|
372
|
+
if (token.kind !== "template") return token.text;
|
|
373
|
+
const body = token.text.slice(1);
|
|
374
|
+
if (body.endsWith("${")) return unescape(body.slice(0, -2));
|
|
375
|
+
if (body.endsWith("`")) return unescape(body.slice(0, -1));
|
|
376
|
+
return unescape(body);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A switch, not a lookup table: the escaped character comes out of the script,
|
|
380
|
+
// and an object would answer `\c` from Object.prototype.
|
|
381
|
+
function unescape(body: string): string {
|
|
382
|
+
return body.replace(/\\(.)/g, (_, ch: string) => {
|
|
383
|
+
switch (ch) {
|
|
384
|
+
case "n":
|
|
385
|
+
return "\n";
|
|
386
|
+
case "t":
|
|
387
|
+
return "\t";
|
|
388
|
+
case "r":
|
|
389
|
+
return "\r";
|
|
390
|
+
default:
|
|
391
|
+
return ch;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
364
394
|
}
|
package/src/dsl/parse.ts
CHANGED
|
@@ -2,23 +2,30 @@
|
|
|
2
2
|
// reads, ref navigation chains and built-in calls — everything the diagnostics,
|
|
3
3
|
// completion, hover and definition features need, and nothing else.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CONTROL_KEYWORDS,
|
|
7
|
+
type Token,
|
|
8
|
+
stringValue,
|
|
9
|
+
templateChunkValue,
|
|
10
|
+
tokenize,
|
|
11
|
+
} from "./lexer";
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export const BLOCK_KINDS: BlockKind[] = [
|
|
13
|
+
/**
|
|
14
|
+
* In the order `ActionDslCompiler.ParseBlocks` expects them in a file.
|
|
15
|
+
*
|
|
16
|
+
* `BlockKind` derives from this array rather than standing beside it, so the
|
|
17
|
+
* two cannot drift: the order check ranks a kind by its index here, and a kind
|
|
18
|
+
* the array does not list could not have been produced in the first place.
|
|
19
|
+
*/
|
|
20
|
+
export const BLOCK_KINDS = [
|
|
16
21
|
"params",
|
|
17
22
|
"canExecute",
|
|
18
23
|
"schema",
|
|
19
24
|
"onBeforeStart",
|
|
20
25
|
"execute",
|
|
21
|
-
];
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type BlockKind = (typeof BLOCK_KINDS)[number];
|
|
22
29
|
|
|
23
30
|
const BLOCK_BY_LOWER = new Map(BLOCK_KINDS.map((k) => [k.toLowerCase(), k]));
|
|
24
31
|
|
|
@@ -88,11 +95,23 @@ export interface GlobalRef {
|
|
|
88
95
|
export interface CallRef {
|
|
89
96
|
name: string;
|
|
90
97
|
nameSpan: Span;
|
|
91
|
-
/** First argument when it is a string literal — the entity code, usually. */
|
|
92
|
-
firstStringArg?:
|
|
98
|
+
/** First argument when it is a string or template literal — the entity code, usually. */
|
|
99
|
+
firstStringArg?: StringArg;
|
|
93
100
|
block: BlockKind | null;
|
|
94
101
|
}
|
|
95
102
|
|
|
103
|
+
export interface StringArg {
|
|
104
|
+
value: string;
|
|
105
|
+
span: Span;
|
|
106
|
+
/**
|
|
107
|
+
* A template literal with `${…}` holes, which `value` holds a space in
|
|
108
|
+
* place of: literal text to read, but not a constant.
|
|
109
|
+
*/
|
|
110
|
+
interpolated: boolean;
|
|
111
|
+
/** Index of the last token the literal consumes — its final chunk. */
|
|
112
|
+
endIndex: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
export interface DslDocument {
|
|
97
116
|
blocks: DslBlock[];
|
|
98
117
|
params: DslParam[];
|
|
@@ -237,7 +256,11 @@ export function parseDsl(text: string): DslDocument {
|
|
|
237
256
|
call.firstStringArg = {
|
|
238
257
|
value: stringValue(firstArg),
|
|
239
258
|
span: { start: firstArg.start, end: firstArg.end },
|
|
259
|
+
interpolated: false,
|
|
260
|
+
endIndex: i + 2,
|
|
240
261
|
};
|
|
262
|
+
} else if (firstArg?.kind === "template" && firstArg.text.startsWith("`")) {
|
|
263
|
+
call.firstStringArg = templateArg(tokens, i + 2);
|
|
241
264
|
}
|
|
242
265
|
calls.push(call);
|
|
243
266
|
}
|
|
@@ -275,6 +298,53 @@ function isFieldAccessStart(text: string, start: number): boolean {
|
|
|
275
298
|
return start === 0 || !/\w/.test(text[start - 1]!);
|
|
276
299
|
}
|
|
277
300
|
|
|
301
|
+
/**
|
|
302
|
+
* A template literal, read back from the chunks the lexer split it into. The
|
|
303
|
+
* walk has to count nested templates, because one opened inside a `${…}` hole
|
|
304
|
+
* emits chunks of its own between ours.
|
|
305
|
+
*
|
|
306
|
+
* Each hole becomes a single space. The callers read the text as SQL, and a
|
|
307
|
+
* spliced value is not part of the statement they are reading — but the gap
|
|
308
|
+
* has to stay a gap, or the words either side of it would run together.
|
|
309
|
+
*/
|
|
310
|
+
function templateArg(tokens: Token[], start: number): StringArg {
|
|
311
|
+
const parts: string[] = [];
|
|
312
|
+
let depth = 0;
|
|
313
|
+
let interpolated = false;
|
|
314
|
+
let last = tokens[start]!;
|
|
315
|
+
let endIndex = start;
|
|
316
|
+
|
|
317
|
+
for (let j = start; j < tokens.length; j++) {
|
|
318
|
+
const t = tokens[j]!;
|
|
319
|
+
if (t.kind !== "template") continue;
|
|
320
|
+
|
|
321
|
+
const opens = t.text.startsWith("`");
|
|
322
|
+
if (opens) depth++;
|
|
323
|
+
if (depth === 1) {
|
|
324
|
+
if (!opens) parts.push(" ");
|
|
325
|
+
parts.push(templateChunkValue(t));
|
|
326
|
+
last = t;
|
|
327
|
+
endIndex = j;
|
|
328
|
+
}
|
|
329
|
+
if (t.text.endsWith("${")) {
|
|
330
|
+
if (depth === 1) interpolated = true;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Anything else ends the template: a closing backtick, or the end of
|
|
334
|
+
// the file on an unterminated one.
|
|
335
|
+
depth--;
|
|
336
|
+
if (depth === 0) break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
value: parts.join(""),
|
|
341
|
+
span: { start: tokens[start]!.start, end: last.end },
|
|
342
|
+
interpolated,
|
|
343
|
+
endIndex,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
278
348
|
/**
|
|
279
349
|
* The `[field]` half of a `records[n][field]` read starting at `i`, or null.
|
|
280
350
|
*
|
|
@@ -533,13 +603,17 @@ function collectPattern(tokens: Token[], start: number, locals: Set<string>): nu
|
|
|
533
603
|
* that reports an indented header — body text as far as the compiler is
|
|
534
604
|
* concerned, leaving the block it meant to open empty.
|
|
535
605
|
*/
|
|
536
|
-
export function blockLabelAt(
|
|
606
|
+
export function blockLabelAt(
|
|
607
|
+
tokens: Token[],
|
|
608
|
+
i: number,
|
|
609
|
+
): { kind: BlockKind; span: Span } | null {
|
|
537
610
|
const t = tokens[i];
|
|
538
611
|
if (!t || t.kind !== "ident" || !t.startsLine) return null;
|
|
539
612
|
const colon = tokens[i + 1];
|
|
540
613
|
// `params :` does not match `^params:` either.
|
|
541
614
|
if (colon?.text !== ":" || colon.start !== t.end) return null;
|
|
542
|
-
|
|
615
|
+
const kind = BLOCK_BY_LOWER.get(t.text.toLowerCase());
|
|
616
|
+
return kind ? { kind, span: { start: t.start, end: colon.end } } : null;
|
|
543
617
|
}
|
|
544
618
|
|
|
545
619
|
function findBlocks(text: string, tokens: Token[]): DslBlock[] {
|
|
@@ -549,12 +623,12 @@ function findBlocks(text: string, tokens: Token[]): DslBlock[] {
|
|
|
549
623
|
// Column 0, like the compiler's `^`-anchored headers: an indented
|
|
550
624
|
// `params:` is an object key inside a block body, not a new block.
|
|
551
625
|
if (t.character !== 0) continue;
|
|
552
|
-
const
|
|
553
|
-
if (!
|
|
554
|
-
labels.push(
|
|
626
|
+
const label = blockLabelAt(tokens, i);
|
|
627
|
+
if (!label) continue;
|
|
628
|
+
labels.push(label);
|
|
555
629
|
// `execute:` is `(.*?)\z` — it runs to end-of-file, so a later header
|
|
556
630
|
// is body text.
|
|
557
|
-
if (kind === "execute") break;
|
|
631
|
+
if (label.kind === "execute") break;
|
|
558
632
|
}
|
|
559
633
|
|
|
560
634
|
return labels.map((l, idx) => ({
|