@spintax/core 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -15,8 +15,14 @@
15
15
  * the AST here.
16
16
  * Permutation's `<config>` + per-element separators are fully parsed (PR-11b).
17
17
  */
18
- /** Bumped only on a breaking change to the node shape (independent of syntax v1). */
19
- declare const AST_VERSION = 1;
18
+ /**
19
+ * Bumped only on a breaking change to the node shape (independent of syntax v1).
20
+ *
21
+ * 2 — `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries no `#def` map, so
22
+ * rendering it would silently drop every definition; the version guard turns that into an
23
+ * `AstVersionError` instead.
24
+ */
25
+ declare const AST_VERSION = 2;
20
26
  /** Opaque public handle (re-exported as `Ast` from the package index). */
21
27
  interface Ast {
22
28
  readonly astVersion: typeof AST_VERSION;
@@ -166,7 +172,10 @@ interface Diagnostic {
166
172
  }
167
173
  interface ExtractResult {
168
174
  refs: string[];
175
+ /** Names defined by `#set` — macros, re-picked at every reference. */
169
176
  sets: string[];
177
+ /** Names defined by `#def` — rolled once per render and held. */
178
+ defs: string[];
170
179
  includes: string[];
171
180
  }
172
181
  interface Analysis extends ExtractResult {
package/dist/index.d.ts CHANGED
@@ -15,8 +15,14 @@
15
15
  * the AST here.
16
16
  * Permutation's `<config>` + per-element separators are fully parsed (PR-11b).
17
17
  */
18
- /** Bumped only on a breaking change to the node shape (independent of syntax v1). */
19
- declare const AST_VERSION = 1;
18
+ /**
19
+ * Bumped only on a breaking change to the node shape (independent of syntax v1).
20
+ *
21
+ * 2 — `ParsedAst` gained `defDefs`. An `Ast` cached by an older version carries no `#def` map, so
22
+ * rendering it would silently drop every definition; the version guard turns that into an
23
+ * `AstVersionError` instead.
24
+ */
25
+ declare const AST_VERSION = 2;
20
26
  /** Opaque public handle (re-exported as `Ast` from the package index). */
21
27
  interface Ast {
22
28
  readonly astVersion: typeof AST_VERSION;
@@ -166,7 +172,10 @@ interface Diagnostic {
166
172
  }
167
173
  interface ExtractResult {
168
174
  refs: string[];
175
+ /** Names defined by `#set` — macros, re-picked at every reference. */
169
176
  sets: string[];
177
+ /** Names defined by `#def` — rolled once per render and held. */
178
+ defs: string[];
170
179
  includes: string[];
171
180
  }
172
181
  interface Analysis extends ExtractResult {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/internal/ast.ts
2
- var AST_VERSION = 1;
2
+ var AST_VERSION = 2;
3
3
  function walk(nodes, visit) {
4
4
  for (const n of nodes) {
5
5
  visit(n);
@@ -23,21 +23,39 @@ function isParsedAst(value) {
23
23
 
24
24
  // src/internal/parser.ts
25
25
  var VARIABLE_RE = /^%(\w+)%/;
26
- var SET_DIRECTIVE_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
26
+ var DIRECTIVE_RE = /^[ \t]*#(set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)[ \t]*\r?$/gmu;
27
27
  var CONDITIONAL_NAME_RE = /^[A-Za-z_]\w*/;
28
28
  var PLURAL_PREFIX = "plural ";
29
29
  function parseTemplate(src) {
30
- const { body, setDefs } = extractSetDirectives(stripComments(src));
31
- return { astVersion: AST_VERSION, source: src, setDefs, nodes: parseSequence(body) };
30
+ const { body, setDefs, defDefs } = extractDirectives(stripComments(src));
31
+ return { astVersion: AST_VERSION, source: src, setDefs, defDefs, nodes: parseSequence(body) };
32
32
  }
33
- function extractSetDirectives(text) {
33
+ function extractDirectives(text) {
34
34
  const setDefs = {};
35
- SET_DIRECTIVE_RE.lastIndex = 0;
36
- const stripped = text.replace(SET_DIRECTIVE_RE, (_full, name, value) => {
37
- setDefs[name.toLowerCase()] = value;
38
- return "";
39
- });
40
- return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs };
35
+ const defDefs = {};
36
+ const occurrences = [];
37
+ DIRECTIVE_RE.lastIndex = 0;
38
+ const stripped = text.replace(
39
+ DIRECTIVE_RE,
40
+ (full, kind, rawName, value, offset) => {
41
+ const name = rawName.toLowerCase();
42
+ occurrences.push({
43
+ kind,
44
+ name,
45
+ value,
46
+ line: countLines(text, offset)
47
+ });
48
+ if (kind === "def") defDefs[name] = value;
49
+ else setDefs[name] = value;
50
+ return "";
51
+ }
52
+ );
53
+ return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
54
+ }
55
+ function countLines(text, offset) {
56
+ let line = 1;
57
+ for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
58
+ return line;
41
59
  }
42
60
  function stripComments(text) {
43
61
  return text.replace(/\/#[\s\S]*?#\//g, "");
@@ -376,7 +394,7 @@ function validateTemplate(src, opts = {}) {
376
394
  const diagnostics = [];
377
395
  const text = stripComments(src);
378
396
  checkBrackets(text, diagnostics);
379
- checkSetDirectives(text, diagnostics);
397
+ checkDirectives(text, diagnostics);
380
398
  checkPermutationConfigs(text, diagnostics);
381
399
  checkPlurals(text, opts.locale, diagnostics);
382
400
  checkVariableReferences(text, opts.knownVariables, diagnostics);
@@ -435,17 +453,43 @@ function checkBrackets(text, out) {
435
453
  out.push(err("bracket.unclosed", `Unclosed '${unclosed.char}'.`, { line: unclosed.line, column: unclosed.column, endLine: unclosed.line, endColumn: unclosed.column + 1, data: { bracket: unclosed.char } }));
436
454
  }
437
455
  }
438
- function checkSetDirectives(text, out) {
456
+ function checkDirectives(text, out) {
439
457
  const lines = text.split("\n");
440
458
  lines.forEach((lineText, idx) => {
441
459
  const trimmed = lineText.replace(/^[ \t]+/, "");
442
- if (!trimmed.startsWith("#set ") && !trimmed.startsWith("#set ")) return;
443
- if (!/^#set\s+%(\w+)%\s*=\s*(.+)$/u.test(trimmed)) {
460
+ const kind = ["#set", "#def"].find(
461
+ (candidate) => trimmed.startsWith(`${candidate} `) || trimmed.startsWith(`${candidate} `)
462
+ );
463
+ if (!kind) return;
464
+ DIRECTIVE_RE.lastIndex = 0;
465
+ if (!DIRECTIVE_RE.test(trimmed)) {
444
466
  const column = lineText.length - trimmed.length + 1;
445
467
  const line = idx + 1;
446
- out.push(err("set.malformed", "Malformed #set. Expected: #set %name% = value", { line, column, endLine: line, endColumn: lineText.length + 1 }));
468
+ const code = kind === "#def" ? "def.malformed" : "set.malformed";
469
+ out.push(err(code, `Malformed ${kind}. Expected: ${kind} %name% = value`, { line, column, endLine: line, endColumn: lineText.length + 1 }));
447
470
  }
448
471
  });
472
+ const { occurrences } = extractDirectives(text);
473
+ const seen = /* @__PURE__ */ new Map();
474
+ for (const occurrence of occurrences) {
475
+ const first = seen.get(occurrence.name);
476
+ if (first !== void 0) {
477
+ out.push(err(
478
+ "definition.duplicate-name",
479
+ `Variable '${occurrence.name}' is defined more than once (first on line ${first}). A name belongs to one directive, once.`,
480
+ { line: occurrence.line, column: 1 }
481
+ ));
482
+ } else {
483
+ seen.set(occurrence.name, occurrence.line);
484
+ }
485
+ if (occurrence.kind === "def" && /#include\b/u.test(occurrence.value)) {
486
+ out.push(err(
487
+ "def.include-in-value",
488
+ `#include cannot appear in a #def value ('${occurrence.name}'): includes resolve after the value is frozen. Use #set, or put the #include in the body.`,
489
+ { line: occurrence.line, column: 1 }
490
+ ));
491
+ }
492
+ }
449
493
  }
450
494
  function checkPermutationConfigs(text, out) {
451
495
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
@@ -483,10 +527,20 @@ function checkPermutationConfigs(text, out) {
483
527
  function checkPlurals(text, locale, out) {
484
528
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
485
529
  const arity = base !== "" ? pluralArity(base) : 0;
530
+ const tainted = macroTaintedNames(text);
486
531
  for (const block of findPluralBlocks(text)) {
487
532
  const at = span(text, block.start, block.end - block.start);
533
+ for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
534
+ const name = (m[1] ?? "").toLowerCase();
535
+ if (!tainted.has(name)) continue;
536
+ out.push(err(
537
+ "plural.count-macro",
538
+ `{plural ...}: the count '${m[1]}' is a #set macro, so it is still unresolved spintax when the plural is decided and the block renders empty. Define it with #def instead.`,
539
+ at
540
+ ));
541
+ }
488
542
  if (/[{}[\]]/.test(block.formsRaw)) {
489
- out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets.", at));
543
+ out.push(err("plural.nested-brackets", "{plural ...}: forms must not contain nested spintax brackets. Extract via #def first \u2014 a #set is substituted verbatim and would put the brackets straight back.", at));
490
544
  continue;
491
545
  }
492
546
  if (arity > 0) {
@@ -501,11 +555,33 @@ function checkPlurals(text, locale, out) {
501
555
  }
502
556
  }
503
557
  }
558
+ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
559
+ function macroTaintedNames(text) {
560
+ const macros = extractDirectives(text).setDefs;
561
+ const tainted = /* @__PURE__ */ new Set();
562
+ for (const [name, value] of Object.entries(macros)) {
563
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
564
+ }
565
+ let grew = true;
566
+ while (grew) {
567
+ grew = false;
568
+ for (const [name, value] of Object.entries(macros)) {
569
+ if (tainted.has(name)) continue;
570
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
571
+ if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
572
+ tainted.add(name);
573
+ grew = true;
574
+ break;
575
+ }
576
+ }
577
+ }
578
+ return tainted;
579
+ }
504
580
  function checkVariableReferences(text, known, out) {
505
581
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
506
582
  const defs = /* @__PURE__ */ new Map();
507
583
  const defPos = /* @__PURE__ */ new Map();
508
- for (const m of text.matchAll(/^[ \t]*#set[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
584
+ for (const m of text.matchAll(/^[ \t]*#(?:set|def)[ \t]+%(\w+)%[ \t]*=[ \t]*(.*?)$/gmu)) {
509
585
  const name = (m[1] ?? "").toLowerCase();
510
586
  defs.set(name, m[2] ?? "");
511
587
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
@@ -520,7 +596,7 @@ function checkVariableReferences(text, known, out) {
520
596
  for (const name of defs.keys()) {
521
597
  detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
522
598
  }
523
- const body = text.replace(/^[ \t]*#set[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
599
+ const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
524
600
  const seen = /* @__PURE__ */ new Set();
525
601
  const undefinedAt = (name, offset, length) => {
526
602
  const key = name.toLowerCase();
@@ -567,19 +643,21 @@ function checkIncludeTargets(text, known, out) {
567
643
 
568
644
  // src/internal/extract.ts
569
645
  var SET_DEF_RE = /^[ \t]*#set[ \t]+%(\w+)%[ \t]*=/gmu;
570
- var SET_LHS_RE = /^[ \t]*#set[ \t]+%\w+%[ \t]*=/gmu;
646
+ var DEF_DEF_RE = /^[ \t]*#def[ \t]+%(\w+)%[ \t]*=/gmu;
647
+ var DEFINITION_LHS_RE = /^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=/gmu;
571
648
  var INCLUDE_RE2 = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
572
649
  var VARIABLE_RE2 = /%(\w+)%/gu;
573
650
  var CONDITIONAL_REF_RE = /\{\?!?([A-Za-z_]\w*)\?/gu;
574
651
  function extractFromSource(src) {
575
652
  const text = stripComments(src);
576
653
  const sets = collect(text, SET_DEF_RE, true);
654
+ const defs = collect(text, DEF_DEF_RE, true);
577
655
  const includes = collect(text, INCLUDE_RE2, false);
578
- const body = text.replace(SET_LHS_RE, "");
656
+ const body = text.replace(DEFINITION_LHS_RE, "");
579
657
  const refs = /* @__PURE__ */ new Set();
580
658
  addAll(refs, body, VARIABLE_RE2, true);
581
659
  addAll(refs, body, CONDITIONAL_REF_RE, true);
582
- return { refs: [...refs], sets, includes };
660
+ return { refs: [...refs], sets, defs, includes };
583
661
  }
584
662
  function collect(text, re, fold) {
585
663
  const seen = /* @__PURE__ */ new Set();
@@ -651,14 +729,15 @@ function stripSentinels(text) {
651
729
  var MAX_VARIABLE_DEPTH = 50;
652
730
  var INCLUDE_LINE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
653
731
  function renderAst(ast, ctx) {
654
- const vars = buildVars(ast.setDefs, ctx.runtimeContext, ctx.rng);
655
- const text = renderNodes(ast.nodes, {
656
- vars,
732
+ const base = buildVars(ast.setDefs, ctx.runtimeContext);
733
+ const walkOpts = {
657
734
  rng: ctx.rng,
658
735
  locale: ctx.locale,
659
736
  depth: 0,
660
737
  onPluralError: ctx.onPluralError
661
- });
738
+ };
739
+ const vars = Object.keys(ast.defDefs).length > 0 ? { ...base, ...rollDefinitions(ast.defDefs, base, ctx.runtimeContext, walkOpts) } : base;
740
+ const text = renderNodes(ast.nodes, { ...walkOpts, vars });
662
741
  return ctx.resolver ? resolveIncludes(text, ctx) : text;
663
742
  }
664
743
  function resolveIncludes(text, ctx) {
@@ -680,33 +759,65 @@ function resolveIncludes(text, ctx) {
680
759
  });
681
760
  });
682
761
  }
683
- function buildVars(setDefs, context, rng) {
762
+ function buildVars(setDefs, context) {
684
763
  const vars = {};
685
764
  for (const [name, value] of Object.entries(setDefs)) {
686
- vars[name] = collapseSetValue(value, rng);
765
+ vars[name] = value;
687
766
  }
688
767
  for (const [name, value] of Object.entries(context)) {
689
768
  vars[name.toLowerCase()] = value;
690
769
  }
691
770
  return vars;
692
771
  }
693
- function collapseSetValue(value, rng) {
694
- if (!value.includes("{")) return value;
695
- if (value.includes("{?") || value.includes("{plural ")) return value;
696
- return resolveEnumerationsString(value, rng);
697
- }
698
- function resolveEnumerationsString(text, rng) {
699
- let out = text;
700
- for (let guard = 0; guard < 1e3; guard += 1) {
701
- let changed = false;
702
- out = out.replace(/\{([^{}]*)\}/gu, (_m, inner) => {
703
- changed = true;
704
- const options = splitTopLevelPipes(inner);
705
- return options.length === 0 ? "" : options[randomInt(rng, 0, options.length - 1)] ?? "";
772
+ function rollDefinitions(defDefs, vars, context, opts) {
773
+ const outranked = new Set(Object.keys(context).map((key) => key.toLowerCase()));
774
+ const rolled = {};
775
+ const aliases = {};
776
+ for (const [name, value] of Object.entries(vars)) {
777
+ if (name in defDefs && !outranked.has(name)) continue;
778
+ aliases[name] = value;
779
+ }
780
+ for (const name of orderDefinitions(defDefs, aliases)) {
781
+ if (outranked.has(name)) continue;
782
+ const value = defDefs[name] ?? "";
783
+ rolled[name] = renderNodes(parseSequence(value), { ...opts, vars: { ...vars, ...rolled } });
784
+ }
785
+ return rolled;
786
+ }
787
+ function orderDefinitions(defDefs, aliases) {
788
+ const names = Object.keys(defDefs);
789
+ const blocked = /* @__PURE__ */ new Map();
790
+ for (const name of names) {
791
+ const reached = referencedNames(defDefs[name] ?? "", aliases);
792
+ blocked.set(name, new Set(names.filter((candidate) => reached.has(candidate))));
793
+ }
794
+ const ordered = [];
795
+ let pending = names;
796
+ while (pending.length > 0) {
797
+ const ready = pending.filter((name) => {
798
+ const deps = blocked.get(name);
799
+ return !deps || ![...deps].some((dep) => dep !== name && pending.includes(dep));
706
800
  });
707
- if (!changed) break;
801
+ if (ready.length === 0) return [...ordered, ...pending];
802
+ ordered.push(...ready);
803
+ pending = pending.filter((name) => !ready.includes(name));
708
804
  }
709
- return out;
805
+ return ordered;
806
+ }
807
+ function referencedNames(value, aliases) {
808
+ const seen = /* @__PURE__ */ new Set();
809
+ const queue = directReferences(value);
810
+ while (queue.length > 0) {
811
+ const name = queue.shift();
812
+ if (seen.has(name)) continue;
813
+ seen.add(name);
814
+ const alias = aliases[name];
815
+ if (alias !== void 0) queue.push(...directReferences(alias));
816
+ }
817
+ return seen;
818
+ }
819
+ function directReferences(text) {
820
+ return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
710
821
  }
711
822
  function renderNodes(nodes, opts) {
712
823
  let out = "";
@@ -865,26 +976,6 @@ function padSeparator(sep) {
865
976
  if (/^\p{L}+$/u.test(trimmed)) return ` ${trimmed} `;
866
977
  return sep;
867
978
  }
868
- function splitTopLevelPipes(inner) {
869
- const parts = [];
870
- let brace = 0;
871
- let bracket = 0;
872
- let cur = "";
873
- for (const ch of inner) {
874
- if (ch === "{") brace += 1;
875
- else if (ch === "}") brace -= 1;
876
- else if (ch === "[") bracket += 1;
877
- else if (ch === "]") bracket -= 1;
878
- if (ch === "|" && brace === 0 && bracket === 0) {
879
- parts.push(cur);
880
- cur = "";
881
- } else {
882
- cur += ch;
883
- }
884
- }
885
- parts.push(cur);
886
- return parts;
887
- }
888
979
  var PHP_LTRIM_RE2 = /^[ \t\n\r\0\x0B]+/u;
889
980
  var PHP_RTRIM_RE2 = /[ \t\n\r\0\x0B]+$/u;
890
981
  function phpTrim2(s) {