@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/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 { type DslDocument, type Span, blockLabelAt, parseDsl } from "./parse";
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 bare identifier — userId() is a compile error.",
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 kind = blockLabelAt(parsed.tokens, i);
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 (kind && !isParamDecl) {
370
+ if (label && !isParamDecl) {
274
371
  add(
275
- { start: token.start, end: parsed.tokens[i + 1]!.end },
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.replace(/\\(.)/g, "$1");
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 { CONTROL_KEYWORDS, type Token, tokenize, stringValue } from "./lexer";
5
+ import {
6
+ CONTROL_KEYWORDS,
7
+ type Token,
8
+ stringValue,
9
+ templateChunkValue,
10
+ tokenize,
11
+ } from "./lexer";
6
12
 
7
- export type BlockKind =
8
- | "params"
9
- | "canExecute"
10
- | "schema"
11
- | "onBeforeStart"
12
- | "execute";
13
-
14
- /** In the order `ActionDslCompiler.ParseBlocks` expects them in a file. */
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?: { value: string; span: Span };
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(tokens: Token[], i: number): BlockKind | null {
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
- return BLOCK_BY_LOWER.get(t.text.toLowerCase()) ?? null;
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 kind = blockLabelAt(tokens, i);
553
- if (!kind) continue;
554
- labels.push({ kind, span: { start: t.start, end: tokens[i + 1]!.end } });
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) => ({