@spintax/core 0.3.2 → 0.3.3

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.js CHANGED
@@ -59,6 +59,15 @@ function extractDirectives(text) {
59
59
  const setDefs = {};
60
60
  const defDefs = {};
61
61
  const occurrences = [];
62
+ let cursorOffset = 0;
63
+ let cursorLine = 1;
64
+ const lineAt = (offset) => {
65
+ for (let i = cursorOffset; i < offset; i += 1) {
66
+ if (text.charCodeAt(i) === 10) cursorLine += 1;
67
+ }
68
+ cursorOffset = offset;
69
+ return cursorLine;
70
+ };
62
71
  DIRECTIVE_RE.lastIndex = 0;
63
72
  const stripped = text.replace(
64
73
  DIRECTIVE_RE,
@@ -68,7 +77,7 @@ function extractDirectives(text) {
68
77
  kind,
69
78
  name,
70
79
  value,
71
- line: countLines(text, offset)
80
+ line: lineAt(offset)
72
81
  });
73
82
  if (kind === "def") defDefs[name] = value;
74
83
  else setDefs[name] = value;
@@ -77,11 +86,6 @@ function extractDirectives(text) {
77
86
  );
78
87
  return { body: stripped.replace(/\n{3,}/gu, "\n\n"), setDefs, defDefs, occurrences };
79
88
  }
80
- function countLines(text, offset) {
81
- let line = 1;
82
- for (let i = 0; i < offset; i += 1) if (text[i] === "\n") line += 1;
83
- return line;
84
- }
85
89
  function stripComments(text) {
86
90
  return text.replace(/\/#[\s\S]*?#\//g, "");
87
91
  }
@@ -418,13 +422,14 @@ var INCLUDE_RE = /^[ \t]*#include[ \t\n\r\f\x0B]+"([^"]+)"[ \t\n\r\f\x0B]*$/gmu;
418
422
  function validateTemplate(src, opts = {}) {
419
423
  const diagnostics = [];
420
424
  const text = stripComments(src);
425
+ const idx = buildLineIndex(text);
421
426
  checkBrackets(text, diagnostics);
422
427
  checkDirectives(text, diagnostics);
423
- checkPermutationConfigs(text, diagnostics);
424
- checkPlurals(text, opts.locale, diagnostics);
425
- checkVariableReferences(text, opts.knownVariables, diagnostics);
428
+ checkPermutationConfigs(text, idx, diagnostics);
429
+ checkPlurals(text, idx, opts.locale, diagnostics);
430
+ checkVariableReferences(text, idx, opts.knownVariables, diagnostics);
426
431
  if (opts.knownIncludes && opts.knownIncludes.length > 0) {
427
- checkIncludeTargets(text, opts.knownIncludes, diagnostics);
432
+ checkIncludeTargets(text, idx, opts.knownIncludes, diagnostics);
428
433
  }
429
434
  return diagnostics;
430
435
  }
@@ -434,21 +439,27 @@ function err(code, message, pos) {
434
439
  function warn(code, message, pos) {
435
440
  return { severity: "warning", code, message, ...pos };
436
441
  }
437
- function offsetToLineCol(text, offset) {
438
- let line = 1;
439
- let lineStart = 0;
440
- const end = Math.min(Math.max(offset, 0), text.length);
441
- for (let i = 0; i < end; i += 1) {
442
- if (text.charAt(i) === "\n") {
443
- line += 1;
444
- lineStart = i + 1;
445
- }
442
+ function buildLineIndex(text) {
443
+ const starts = [0];
444
+ for (let i = 0; i < text.length; i += 1) {
445
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
446
+ }
447
+ return { starts, length: text.length };
448
+ }
449
+ function offsetToLineCol(idx, offset) {
450
+ const end = Math.min(Math.max(offset, 0), idx.length);
451
+ let lo = 0;
452
+ let hi = idx.starts.length - 1;
453
+ while (lo < hi) {
454
+ const mid = lo + hi + 1 >> 1;
455
+ if ((idx.starts[mid] ?? 0) <= end) lo = mid;
456
+ else hi = mid - 1;
446
457
  }
447
- return { line, column: end - lineStart + 1 };
458
+ return { line: lo + 1, column: end - (idx.starts[lo] ?? 0) + 1 };
448
459
  }
449
- function span(text, offset, length) {
450
- const s = offsetToLineCol(text, offset);
451
- const e = offsetToLineCol(text, offset + length);
460
+ function span(idx, offset, length) {
461
+ const s = offsetToLineCol(idx, offset);
462
+ const e = offsetToLineCol(idx, offset + length);
452
463
  return { line: s.line, column: s.column, endLine: e.line, endColumn: e.column };
453
464
  }
454
465
  function checkBrackets(text, out) {
@@ -516,7 +527,7 @@ function checkDirectives(text, out) {
516
527
  }
517
528
  }
518
529
  }
519
- function checkPermutationConfigs(text, out) {
530
+ function checkPermutationConfigs(text, idx, out) {
520
531
  for (const m of text.matchAll(/\[<([^>]*?)>/gu)) {
521
532
  const configStr = m[1] ?? "";
522
533
  if (!/\w+\s*=/.test(configStr)) continue;
@@ -527,7 +538,7 @@ function checkPermutationConfigs(text, out) {
527
538
  out.push(err(
528
539
  "permutation.unknown-key",
529
540
  `Unknown permutation config key: '${km[1]}'.`,
530
- { ...span(text, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
541
+ { ...span(idx, configBase + (km.index ?? 0), (km[1] ?? "").length), data: { key: km[1] } }
531
542
  ));
532
543
  }
533
544
  }
@@ -536,7 +547,7 @@ function checkPermutationConfigs(text, out) {
536
547
  out.push(err(
537
548
  "permutation.minsize-not-integer",
538
549
  `minsize must be a positive integer, got '${min[1]}'.`,
539
- { ...span(text, configBase + min.index, min[0].length), data: { value: min[1] } }
550
+ { ...span(idx, configBase + min.index, min[0].length), data: { value: min[1] } }
540
551
  ));
541
552
  }
542
553
  const max = /maxsize\s*=\s*([^;>\s]+)/i.exec(configStr);
@@ -544,17 +555,17 @@ function checkPermutationConfigs(text, out) {
544
555
  out.push(err(
545
556
  "permutation.maxsize-not-integer",
546
557
  `maxsize must be a positive integer, got '${max[1]}'.`,
547
- { ...span(text, configBase + max.index, max[0].length), data: { value: max[1] } }
558
+ { ...span(idx, configBase + max.index, max[0].length), data: { value: max[1] } }
548
559
  ));
549
560
  }
550
561
  }
551
562
  }
552
- function checkPlurals(text, locale, out) {
563
+ function checkPlurals(text, idx, locale, out) {
553
564
  const base = locale && locale !== "" ? normalizeBaseLang(locale) : "";
554
565
  const arity = base !== "" ? pluralArity(base) : 0;
555
566
  const tainted = macroTaintedNames(text);
556
567
  for (const block of findPluralBlocks(text)) {
557
- const at = span(text, block.start, block.end - block.start);
568
+ const at = span(idx, block.start, block.end - block.start);
558
569
  for (const m of block.countSlot.matchAll(/%(\w+)%/gu)) {
559
570
  const name = (m[1] ?? "").toLowerCase();
560
571
  if (!tainted.has(name)) continue;
@@ -584,25 +595,34 @@ var UNRESOLVED_AT_PLURAL_TIME = /\[|\{(?!\?)/u;
584
595
  function macroTaintedNames(text) {
585
596
  const macros = extractDirectives(text).setDefs;
586
597
  const tainted = /* @__PURE__ */ new Set();
598
+ const queue = [];
599
+ for (const [name, value] of Object.entries(macros)) {
600
+ if (UNRESOLVED_AT_PLURAL_TIME.test(value)) {
601
+ tainted.add(name);
602
+ queue.push(name);
603
+ }
604
+ }
605
+ const dependents = /* @__PURE__ */ new Map();
587
606
  for (const [name, value] of Object.entries(macros)) {
588
- if (UNRESOLVED_AT_PLURAL_TIME.test(value)) tainted.add(name);
607
+ for (const m of value.matchAll(/%(\w+)%/gu)) {
608
+ const ref = (m[1] ?? "").toLowerCase();
609
+ const list = dependents.get(ref);
610
+ if (list === void 0) dependents.set(ref, [name]);
611
+ else list.push(name);
612
+ }
589
613
  }
590
- let grew = true;
591
- while (grew) {
592
- grew = false;
593
- for (const [name, value] of Object.entries(macros)) {
594
- if (tainted.has(name)) continue;
595
- for (const m of value.matchAll(/%(\w+)%/gu)) {
596
- if (!tainted.has((m[1] ?? "").toLowerCase())) continue;
614
+ while (queue.length > 0) {
615
+ const source = queue.pop();
616
+ for (const name of dependents.get(source) ?? []) {
617
+ if (!tainted.has(name)) {
597
618
  tainted.add(name);
598
- grew = true;
599
- break;
619
+ queue.push(name);
600
620
  }
601
621
  }
602
622
  }
603
623
  return tainted;
604
624
  }
605
- function checkVariableReferences(text, known, out) {
625
+ function checkVariableReferences(text, idx, known, out) {
606
626
  const knownSet = new Set((known ?? []).map((n) => n.toLowerCase()));
607
627
  const defs = /* @__PURE__ */ new Map();
608
628
  const defPos = /* @__PURE__ */ new Map();
@@ -610,7 +630,7 @@ function checkVariableReferences(text, known, out) {
610
630
  const name = (m[1] ?? "").toLowerCase();
611
631
  defs.set(name, m[2] ?? "");
612
632
  const nameOffset = (m.index ?? 0) + m[0].indexOf("%");
613
- defPos.set(name, span(text, nameOffset, name.length + 2));
633
+ defPos.set(name, span(idx, nameOffset, name.length + 2));
614
634
  }
615
635
  const somewhere = { line: 1, column: 1 };
616
636
  for (const [name, value] of defs) {
@@ -618,8 +638,15 @@ function checkVariableReferences(text, known, out) {
618
638
  out.push(err("variable.self-reference", `Variable '${name}' references itself.`, defPos.get(name) ?? somewhere));
619
639
  }
620
640
  }
641
+ const refsOf = /* @__PURE__ */ new Map();
642
+ for (const [name, value] of defs) {
643
+ const refs = [];
644
+ for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
645
+ refsOf.set(name, refs);
646
+ }
647
+ const reachesCycle = namesThatReachACycle(defs, refsOf);
621
648
  for (const name of defs.keys()) {
622
- detectCycle(name, defs, [name], defPos.get(name) ?? somewhere, out);
649
+ detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
623
650
  }
624
651
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
625
652
  const seen = /* @__PURE__ */ new Set();
@@ -630,7 +657,7 @@ function checkVariableReferences(text, known, out) {
630
657
  out.push(warn(
631
658
  "variable.undefined",
632
659
  `Variable '${name}' is not defined \u2014 may be a runtime variable.`,
633
- { ...span(text, offset, length), data: { name } }
660
+ { ...span(idx, offset, length), data: { name } }
634
661
  ));
635
662
  };
636
663
  for (const m of body.matchAll(/%(\w+)%/gu)) undefinedAt(m[1] ?? "", m.index ?? 0, m[0].length);
@@ -638,19 +665,70 @@ function checkVariableReferences(text, known, out) {
638
665
  undefinedAt(m[1] ?? "", (m.index ?? 0) + m[0].indexOf(m[1] ?? ""), (m[1] ?? "").length);
639
666
  }
640
667
  }
641
- function detectCycle(current, defs, visited, rootPos, out) {
642
- const value = defs.get(current) ?? "";
643
- for (const m of value.matchAll(/%(\w+)%/gu)) {
644
- const ref = (m[1] ?? "").toLowerCase();
645
- if (ref === current) continue;
646
- if (visited.includes(ref)) {
647
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...visited, ref].join(" \u2192 ")}.`, rootPos));
648
- return;
668
+ function namesThatReachACycle(defs, refsOf) {
669
+ const GREY = 1;
670
+ const BLACK = 2;
671
+ const color = /* @__PURE__ */ new Map();
672
+ const reaches = /* @__PURE__ */ new Set();
673
+ for (const root of defs.keys()) {
674
+ if (color.has(root)) continue;
675
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
676
+ color.set(root, GREY);
677
+ while (stack.length > 0) {
678
+ const top = stack[stack.length - 1];
679
+ if (top.i < top.refs.length) {
680
+ const ref = top.refs[top.i];
681
+ top.i += 1;
682
+ if (ref === top.name || !defs.has(ref)) continue;
683
+ const c = color.get(ref);
684
+ if (c === GREY) reaches.add(top.name);
685
+ else if (c === BLACK) {
686
+ if (reaches.has(ref)) reaches.add(top.name);
687
+ } else {
688
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
689
+ color.set(ref, GREY);
690
+ }
691
+ } else {
692
+ stack.pop();
693
+ color.set(top.name, BLACK);
694
+ if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
695
+ }
696
+ }
697
+ }
698
+ return reaches;
699
+ }
700
+ function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
701
+ if (!reachesCycle.has(root)) return;
702
+ const path = [root];
703
+ const onPath = /* @__PURE__ */ new Set([root]);
704
+ const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
705
+ const leave = (frame) => {
706
+ stack.pop();
707
+ onPath.delete(frame.name);
708
+ path.pop();
709
+ };
710
+ while (stack.length > 0) {
711
+ const top = stack[stack.length - 1];
712
+ if (top.i >= top.refs.length) {
713
+ leave(top);
714
+ continue;
715
+ }
716
+ const ref = top.refs[top.i];
717
+ top.i += 1;
718
+ if (ref === top.name) continue;
719
+ if (onPath.has(ref)) {
720
+ out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
721
+ leave(top);
722
+ continue;
723
+ }
724
+ if (defs.has(ref) && reachesCycle.has(ref)) {
725
+ stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
726
+ onPath.add(ref);
727
+ path.push(ref);
649
728
  }
650
- if (defs.has(ref)) detectCycle(ref, defs, [...visited, ref], rootPos, out);
651
729
  }
652
730
  }
653
- function checkIncludeTargets(text, known, out) {
731
+ function checkIncludeTargets(text, idx, known, out) {
654
732
  const set = new Set(known);
655
733
  INCLUDE_RE.lastIndex = 0;
656
734
  for (const m of text.matchAll(INCLUDE_RE)) {
@@ -660,7 +738,7 @@ function checkIncludeTargets(text, known, out) {
660
738
  out.push(err(
661
739
  "include.unknown-target",
662
740
  `#include target '${ref}' does not match any known template.`,
663
- { ...span(text, refOffset, ref.length), data: { target: ref } }
741
+ { ...span(idx, refOffset, ref.length), data: { target: ref } }
664
742
  ));
665
743
  }
666
744
  }