@spintax/core 0.5.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,19 +1,32 @@
1
1
  // src/internal/ast.ts
2
2
  var AST_VERSION = 2;
3
3
  function walk(nodes, visit) {
4
- for (const n of nodes) {
4
+ const stack = [{ list: nodes, i: 0 }];
5
+ while (stack.length > 0) {
6
+ const top = stack[stack.length - 1];
7
+ if (top.i >= top.list.length) {
8
+ stack.pop();
9
+ continue;
10
+ }
11
+ const n = top.list[top.i];
12
+ top.i += 1;
5
13
  visit(n);
14
+ let children;
6
15
  switch (n.type) {
7
16
  case "enumeration":
8
- for (const opt of n.options) walk(opt, visit);
17
+ children = n.options;
9
18
  break;
10
19
  case "conditional":
11
- walk(n.then, visit);
12
- walk(n.else, visit);
20
+ children = [n.then, n.else];
13
21
  break;
14
22
  case "permutation":
15
- for (const opt of n.options) walk(opt.nodes, visit);
23
+ children = n.options.map((opt) => opt.nodes);
16
24
  break;
25
+ default:
26
+ continue;
27
+ }
28
+ for (let k = children.length - 1; k >= 0; k -= 1) {
29
+ stack.push({ list: children[k], i: 0 });
17
30
  }
18
31
  }
19
32
  }
@@ -90,65 +103,102 @@ function stripComments(text) {
90
103
  return text.replace(/\/#[\s\S]*?#\//g, "");
91
104
  }
92
105
  function parseSequence(text) {
93
- const nodes = [];
94
- let literal = "";
95
- let i = 0;
96
- const flushLiteral = () => {
97
- if (literal !== "") {
98
- nodes.push({ type: "literal", value: literal });
99
- literal = "";
100
- }
101
- };
102
- while (i < text.length) {
103
- const ch = text.charAt(i);
104
- if (ch === "{") {
105
- const end = findMatchingClose(text, i, "{", "}");
106
- if (end === -1) {
107
- literal += ch;
108
- i += 1;
106
+ const frame = (t) => ({ text: t, i: 0, literal: "", nodes: [], plan: null, parts: [] });
107
+ const stack = [frame(text)];
108
+ while (stack.length > 0) {
109
+ const f = stack[stack.length - 1];
110
+ if (f.plan !== null) {
111
+ if (f.parts.length < f.plan.texts.length) {
112
+ stack.push(frame(f.plan.texts[f.parts.length]));
109
113
  continue;
110
114
  }
111
- const content = text.slice(i + 1, end);
112
- flushLiteral();
113
- nodes.push(parseBraceConstruct(content));
114
- i = end + 1;
115
+ f.nodes.push(f.plan.build(f.parts));
116
+ f.plan = null;
117
+ f.parts = [];
115
118
  continue;
116
119
  }
117
- if (ch === "[") {
118
- const end = findMatchingClose(text, i, "[", "]");
119
- if (end === -1) {
120
- literal += ch;
121
- i += 1;
122
- continue;
120
+ const flushLiteral = () => {
121
+ if (f.literal !== "") {
122
+ f.nodes.push({ type: "literal", value: f.literal });
123
+ f.literal = "";
123
124
  }
124
- flushLiteral();
125
- nodes.push(parsePermutation(text.slice(i + 1, end)));
126
- i = end + 1;
127
- continue;
128
- }
129
- if (ch === "%") {
130
- const name = VARIABLE_RE.exec(text.slice(i))?.[1];
131
- if (name !== void 0) {
125
+ };
126
+ let planned = null;
127
+ while (f.i < f.text.length && planned === null) {
128
+ const ch = f.text.charAt(f.i);
129
+ if (ch === "{" || ch === "[") {
130
+ const close = ch === "{" ? "}" : "]";
131
+ const end = findMatchingClose(f.text, f.i, ch, close);
132
+ if (end === -1) {
133
+ f.literal += ch;
134
+ f.i += 1;
135
+ continue;
136
+ }
137
+ const inner = f.text.slice(f.i + 1, end);
132
138
  flushLiteral();
133
- nodes.push({ type: "variable", name });
134
- i += name.length + 2;
139
+ planned = ch === "{" ? planBraceConstruct(inner) : planPermutation(inner);
140
+ f.i = end + 1;
135
141
  continue;
136
142
  }
143
+ if (ch === "%") {
144
+ const name = VARIABLE_RE.exec(f.text.slice(f.i))?.[1];
145
+ if (name !== void 0) {
146
+ flushLiteral();
147
+ f.nodes.push({ type: "variable", name });
148
+ f.i += name.length + 2;
149
+ continue;
150
+ }
151
+ }
152
+ f.literal += ch;
153
+ f.i += 1;
154
+ }
155
+ if (planned !== null) {
156
+ if ("node" in planned) f.nodes.push(planned.node);
157
+ else f.plan = planned;
158
+ continue;
137
159
  }
138
- literal += ch;
139
- i += 1;
160
+ flushLiteral();
161
+ stack.pop();
162
+ const parent = stack[stack.length - 1];
163
+ if (parent !== void 0) parent.parts.push(f.nodes);
164
+ else return f.nodes;
140
165
  }
141
- flushLiteral();
142
- return nodes;
166
+ return [];
143
167
  }
144
- function parseBraceConstruct(content) {
168
+ function planBraceConstruct(content) {
145
169
  if (content.charAt(0) === "?") {
146
- const cond = tryParseConditional(content);
147
- if (cond) return cond;
170
+ const parts = splitConditional(content);
171
+ if (parts !== null) {
172
+ return {
173
+ texts: [parts.thenRaw, parts.elseRaw],
174
+ build: (children) => ({
175
+ type: "conditional",
176
+ name: parts.name,
177
+ inverted: parts.inverted,
178
+ then: children[0] ?? [],
179
+ else: children[1] ?? []
180
+ })
181
+ };
182
+ }
148
183
  } else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
149
- return parsePlural(content.slice(PLURAL_PREFIX.length));
184
+ return { node: parsePlural(content.slice(PLURAL_PREFIX.length)) };
150
185
  }
151
- return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
186
+ return {
187
+ texts: splitTopLevel(content),
188
+ build: (children) => ({ type: "enumeration", options: children })
189
+ };
190
+ }
191
+ function planPermutation(rawInner) {
192
+ const { config, content } = extractPermutationConfig(rawInner);
193
+ const { texts, separators } = permutationElements(splitTopLevel(content));
194
+ return {
195
+ texts,
196
+ build: (children) => ({
197
+ type: "permutation",
198
+ config,
199
+ options: children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }))
200
+ })
201
+ };
152
202
  }
153
203
  function recognizeConditional(text, contentStart, contentEnd) {
154
204
  let p = contentStart + 1;
@@ -177,17 +227,6 @@ function splitConditional(content) {
177
227
  elseRaw: sep < 0 ? "" : body.slice(sep + 1)
178
228
  };
179
229
  }
180
- function tryParseConditional(content) {
181
- const parts = splitConditional(content);
182
- if (parts === null) return null;
183
- return {
184
- type: "conditional",
185
- name: parts.name,
186
- inverted: parts.inverted,
187
- then: parseSequence(parts.thenRaw),
188
- else: parseSequence(parts.elseRaw)
189
- };
190
- }
191
230
  function parsePlural(afterPrefix) {
192
231
  const colon = afterPrefix.indexOf(":");
193
232
  return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
@@ -202,11 +241,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
202
241
  function defaultPermConfig() {
203
242
  return { minsize: null, maxsize: null, sep: " ", lastsep: null };
204
243
  }
205
- function parsePermutation(rawInner) {
206
- const { config, content } = extractPermutationConfig(rawInner);
207
- const options = extractPerElementSeparators(splitTopLevel(content));
208
- return { type: "permutation", config, options };
209
- }
210
244
  function extractPermutationConfig(content) {
211
245
  const trimmed = phpLtrim(content);
212
246
  if (trimmed === "" || trimmed.charAt(0) !== "<") {
@@ -250,8 +284,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
250
284
  const tagName = (m[1] ?? "").toLowerCase();
251
285
  return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
252
286
  }
253
- function extractPerElementSeparators(rawParts) {
254
- const options = [];
287
+ function permutationElements(rawParts) {
288
+ const texts = [];
289
+ const separators = [];
255
290
  let pendingSep = null;
256
291
  rawParts.forEach((part, i) => {
257
292
  let text = part;
@@ -265,11 +300,12 @@ function extractPerElementSeparators(rawParts) {
265
300
  }
266
301
  const trimmed = phpTrim(text);
267
302
  if (trimmed !== "") {
268
- options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
303
+ texts.push(trimmed);
304
+ separators.push(pendingSep);
269
305
  }
270
306
  pendingSep = trailingSep;
271
307
  });
272
- return options;
308
+ return { texts, separators };
273
309
  }
274
310
  function extractTrailingSep(part) {
275
311
  const trimmed = phpRtrim(part);
@@ -748,9 +784,14 @@ function checkVariableReferences(text, idx, known, out) {
748
784
  for (const m of value.matchAll(/%(\w+)%/gu)) refs.push((m[1] ?? "").toLowerCase());
749
785
  refsOf.set(name, refs);
750
786
  }
751
- const reachesCycle = namesThatReachACycle(defs, refsOf);
787
+ const { reaches: reachesCycle, via } = namesThatReachACycle(defs, refsOf);
752
788
  for (const name of defs.keys()) {
753
- detectCycle(name, defs, refsOf, reachesCycle, defPos.get(name) ?? somewhere, out);
789
+ if (!reachesCycle.has(name)) continue;
790
+ out.push(err(
791
+ "variable.circular-reference",
792
+ `Circular variable reference: ${cyclePath(name, via)}.`,
793
+ { ...defPos.get(name) ?? somewhere, data: { name } }
794
+ ));
754
795
  }
755
796
  const body = text.replace(/^[ \t]*#(?:set|def)[ \t]+%\w+%[ \t]*=[ \t]*.*?$/gmu, (m) => m.replace(/[^\n]/g, " "));
756
797
  const seen = /* @__PURE__ */ new Set();
@@ -774,6 +815,12 @@ function namesThatReachACycle(defs, refsOf) {
774
815
  const BLACK = 2;
775
816
  const color = /* @__PURE__ */ new Map();
776
817
  const reaches = /* @__PURE__ */ new Set();
818
+ const via = /* @__PURE__ */ new Map();
819
+ const mark = (name, witness) => {
820
+ if (reaches.has(name)) return;
821
+ reaches.add(name);
822
+ via.set(name, witness);
823
+ };
777
824
  for (const root of defs.keys()) {
778
825
  if (color.has(root)) continue;
779
826
  const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
@@ -785,9 +832,9 @@ function namesThatReachACycle(defs, refsOf) {
785
832
  top.i += 1;
786
833
  if (ref === top.name || !defs.has(ref)) continue;
787
834
  const c = color.get(ref);
788
- if (c === GREY) reaches.add(top.name);
835
+ if (c === GREY) mark(top.name, ref);
789
836
  else if (c === BLACK) {
790
- if (reaches.has(ref)) reaches.add(top.name);
837
+ if (reaches.has(ref)) mark(top.name, ref);
791
838
  } else {
792
839
  stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
793
840
  color.set(ref, GREY);
@@ -795,42 +842,41 @@ function namesThatReachACycle(defs, refsOf) {
795
842
  } else {
796
843
  stack.pop();
797
844
  color.set(top.name, BLACK);
798
- if (stack.length > 0 && reaches.has(top.name)) reaches.add(stack[stack.length - 1].name);
845
+ if (stack.length > 0 && reaches.has(top.name)) mark(stack[stack.length - 1].name, top.name);
799
846
  }
800
847
  }
801
848
  }
802
- return reaches;
803
- }
804
- function detectCycle(root, defs, refsOf, reachesCycle, rootPos, out) {
805
- if (!reachesCycle.has(root)) return;
806
- const path = [root];
807
- const onPath = /* @__PURE__ */ new Set([root]);
808
- const stack = [{ name: root, refs: refsOf.get(root) ?? [], i: 0 }];
809
- const leave = (frame) => {
810
- stack.pop();
811
- onPath.delete(frame.name);
812
- path.pop();
813
- };
814
- while (stack.length > 0) {
815
- const top = stack[stack.length - 1];
816
- if (top.i >= top.refs.length) {
817
- leave(top);
818
- continue;
849
+ return { reaches, via };
850
+ }
851
+ var CYCLE_PATH_LIMIT = 8;
852
+ function cyclePath(name, via) {
853
+ const seen = /* @__PURE__ */ new Set([name]);
854
+ const shown = [name];
855
+ let current = name;
856
+ for (; ; ) {
857
+ const next = via.get(current);
858
+ if (next === void 0) break;
859
+ if (seen.has(next)) {
860
+ shown.push(next);
861
+ return shown.join(" \u2192 ");
819
862
  }
820
- const ref = top.refs[top.i];
821
- top.i += 1;
822
- if (ref === top.name) continue;
823
- if (onPath.has(ref)) {
824
- out.push(err("variable.circular-reference", `Circular variable reference: ${[...path, ref].join(" \u2192 ")}.`, rootPos));
825
- leave(top);
826
- continue;
827
- }
828
- if (defs.has(ref) && reachesCycle.has(ref)) {
829
- stack.push({ name: ref, refs: refsOf.get(ref) ?? [], i: 0 });
830
- onPath.add(ref);
831
- path.push(ref);
863
+ if (shown.length >= CYCLE_PATH_LIMIT) {
864
+ let more = 0;
865
+ let walk2 = next;
866
+ while (!seen.has(walk2)) {
867
+ seen.add(walk2);
868
+ more += 1;
869
+ const step = via.get(walk2);
870
+ if (step === void 0) break;
871
+ walk2 = step;
872
+ }
873
+ return `${shown.join(" \u2192 ")} \u2192 \u2026 (${more} more)`;
832
874
  }
875
+ seen.add(next);
876
+ shown.push(next);
877
+ current = next;
833
878
  }
879
+ return shown.join(" \u2192 ");
834
880
  }
835
881
  function checkIncludeTargets(text, idx, known, out) {
836
882
  const set = new Set(known);
@@ -1004,9 +1050,34 @@ function directReferences(text) {
1004
1050
  return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
1005
1051
  }
1006
1052
  function renderNodes(nodes, opts) {
1007
- let out = "";
1008
- for (const node of nodes) out += renderNode(node, opts);
1009
- return out;
1053
+ const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
1054
+ const stack = [frame(nodes)];
1055
+ while (stack.length > 0) {
1056
+ const f = stack[stack.length - 1];
1057
+ if (f.pending !== null) {
1058
+ if (f.pending.done.length < f.pending.lists.length) {
1059
+ stack.push(frame(f.pending.lists[f.pending.done.length]));
1060
+ continue;
1061
+ }
1062
+ f.out.push(f.pending.assemble(f.pending.done));
1063
+ f.pending = null;
1064
+ continue;
1065
+ }
1066
+ if (f.i >= f.nodes.length) {
1067
+ const text = f.out.join("");
1068
+ stack.pop();
1069
+ const parent = stack[stack.length - 1];
1070
+ if (parent === void 0) return text;
1071
+ parent.pending.done.push(text);
1072
+ continue;
1073
+ }
1074
+ const node = f.nodes[f.i];
1075
+ f.i += 1;
1076
+ const step = renderNode(node, opts);
1077
+ if (typeof step === "string") f.out.push(step);
1078
+ else f.pending = step;
1079
+ }
1080
+ return "";
1010
1081
  }
1011
1082
  function renderNode(node, opts) {
1012
1083
  switch (node.type) {
@@ -1058,7 +1129,7 @@ function conditionalTakesThen(name, inverted, opts) {
1058
1129
  }
1059
1130
  function renderConditional(node, opts) {
1060
1131
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1061
- return renderNodes(truthy ? node.then : node.else, opts);
1132
+ return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1062
1133
  }
1063
1134
  function resolveCountConditionals(text, opts) {
1064
1135
  if (!text.includes("{?")) return text;
@@ -1143,7 +1214,7 @@ function renderPlural(node, opts) {
1143
1214
  return fullwidthVerbatim(countRaw, formsRaw);
1144
1215
  }
1145
1216
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1146
- return renderNodes(parseSequence(picked), opts);
1217
+ return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1147
1218
  }
1148
1219
  function rawConstruct(countRaw, formsRaw) {
1149
1220
  return `{plural ${countRaw}:${formsRaw}}`;
@@ -1154,13 +1225,19 @@ function fullwidthVerbatim(countRaw, formsRaw) {
1154
1225
  function renderEnumeration(options, opts) {
1155
1226
  if (options.length === 0) return "";
1156
1227
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1157
- return picked ? renderNodes(picked, opts) : "";
1228
+ if (!picked) return "";
1229
+ return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
1158
1230
  }
1159
1231
  function renderPermutation(node, opts) {
1160
- const elements = node.options.map((o) => ({
1161
- text: renderNodes(o.nodes, opts),
1162
- sep: o.separator
1163
- }));
1232
+ if (node.options.length === 0) return "";
1233
+ return {
1234
+ lists: node.options.map((o) => o.nodes),
1235
+ done: [],
1236
+ assemble: (parts) => assemblePermutation(node, parts, opts)
1237
+ };
1238
+ }
1239
+ function assemblePermutation(node, rendered, opts) {
1240
+ const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
1164
1241
  const total = elements.length;
1165
1242
  if (total === 0) return "";
1166
1243
  const { config } = node;