@spintax/core 0.6.0 → 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/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes to `@spintax/core` are documented here. This project adheres to
4
4
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## 0.6.1 — 2026-08-19
7
+
8
+ **The engine no longer throws on deeply nested content** (issue #68). `parse()`, `render()` and
9
+ `analyze()` raised `RangeError: Maximum call stack size exceeded` at about 2000 levels of nesting —
10
+ a **3.9 KB** template — while §9.2 promises a bad construct comes back as text with fullwidth
11
+ braces, never as an exception. No other engine in the family threw.
12
+
13
+ ### Fixed
14
+
15
+ Three walks recursed once per level of nesting, and each is a frame stack now:
16
+
17
+ - `parseSequence` — a construct returns a *plan* (its child texts, and how to assemble the node)
18
+ and a stack drives it. The shape mirrors the Python port's `_plan_*` functions, written that way
19
+ from the start for this reason.
20
+ - `renderNodes` — the same. **The RNG order is contract and did not move**: an enumeration picks
21
+ before descending, so an unpicked branch never consumes RNG; a permutation renders every element
22
+ first and consumes its own picks after. Seeded renders are reproducible within the engine (§3.2)
23
+ and corpus fixtures pin exact picks with `rng: { sequence }`.
24
+ - `walk` (`ast.ts`) — pre-order, children in source order, because `analyze`'s `constructs` counts
25
+ and `refs` order come out of it.
26
+
27
+ Fixing only the parser would have moved the wall rather than removed it: with parsing iterative,
28
+ `render` and `analyze` still threw at ~5000. All three now return at 2 000, 9 000 and 50 000 levels.
29
+
30
+ `validate()` and `extract()` never threw, which made this worse than it looked — a caller could not
31
+ pre-screen for it by validating first.
32
+
33
+ ### Notes
34
+
35
+ **Output is byte-identical.** 400 generated documents covering every construct, dumped across
36
+ parse / extract / validate×2 / analyze / render×2 / neutralize before and after — no difference.
37
+ The harness itself was corrected first: of three deliberate control mutations it caught two and
38
+ missed one, because the generator never emitted the per-element `[a < or > | b]` separator — which
39
+ is precisely the code this release moved. A green that cannot see the changed line is not evidence.
40
+
41
+ **Deep nesting is still super-linear**, in this engine and in the family: 9 000 levels cost seconds
42
+ here where the PHP engine costs 382 ms, because `findMatchingClose`, the inner slice and
43
+ `splitTopLevel` each rescan a construct's content once per level. Removing that means threading
44
+ spans through five helpers whose separator rules deliberately differ from one another, so it is
45
+ recorded rather than guessed at — the same call as #71. Bounding input remains a host job (§9.3);
46
+ the reference Worker caps a source at 8192 characters.
47
+
48
+ `AST_VERSION` is unchanged — the node shape did not move.
49
+
6
50
  ## 0.6.0 — 2026-08-18
7
51
 
8
52
  **`validate()` now emits ONE `variable.circular-reference` per NAME that takes part in, or leads
package/dist/index.cjs CHANGED
@@ -3,19 +3,32 @@
3
3
  // src/internal/ast.ts
4
4
  var AST_VERSION = 2;
5
5
  function walk(nodes, visit) {
6
- for (const n of nodes) {
6
+ const stack = [{ list: nodes, i: 0 }];
7
+ while (stack.length > 0) {
8
+ const top = stack[stack.length - 1];
9
+ if (top.i >= top.list.length) {
10
+ stack.pop();
11
+ continue;
12
+ }
13
+ const n = top.list[top.i];
14
+ top.i += 1;
7
15
  visit(n);
16
+ let children;
8
17
  switch (n.type) {
9
18
  case "enumeration":
10
- for (const opt of n.options) walk(opt, visit);
19
+ children = n.options;
11
20
  break;
12
21
  case "conditional":
13
- walk(n.then, visit);
14
- walk(n.else, visit);
22
+ children = [n.then, n.else];
15
23
  break;
16
24
  case "permutation":
17
- for (const opt of n.options) walk(opt.nodes, visit);
25
+ children = n.options.map((opt) => opt.nodes);
18
26
  break;
27
+ default:
28
+ continue;
29
+ }
30
+ for (let k = children.length - 1; k >= 0; k -= 1) {
31
+ stack.push({ list: children[k], i: 0 });
19
32
  }
20
33
  }
21
34
  }
@@ -92,65 +105,102 @@ function stripComments(text) {
92
105
  return text.replace(/\/#[\s\S]*?#\//g, "");
93
106
  }
94
107
  function parseSequence(text) {
95
- const nodes = [];
96
- let literal = "";
97
- let i = 0;
98
- const flushLiteral = () => {
99
- if (literal !== "") {
100
- nodes.push({ type: "literal", value: literal });
101
- literal = "";
102
- }
103
- };
104
- while (i < text.length) {
105
- const ch = text.charAt(i);
106
- if (ch === "{") {
107
- const end = findMatchingClose(text, i, "{", "}");
108
- if (end === -1) {
109
- literal += ch;
110
- i += 1;
108
+ const frame = (t) => ({ text: t, i: 0, literal: "", nodes: [], plan: null, parts: [] });
109
+ const stack = [frame(text)];
110
+ while (stack.length > 0) {
111
+ const f = stack[stack.length - 1];
112
+ if (f.plan !== null) {
113
+ if (f.parts.length < f.plan.texts.length) {
114
+ stack.push(frame(f.plan.texts[f.parts.length]));
111
115
  continue;
112
116
  }
113
- const content = text.slice(i + 1, end);
114
- flushLiteral();
115
- nodes.push(parseBraceConstruct(content));
116
- i = end + 1;
117
+ f.nodes.push(f.plan.build(f.parts));
118
+ f.plan = null;
119
+ f.parts = [];
117
120
  continue;
118
121
  }
119
- if (ch === "[") {
120
- const end = findMatchingClose(text, i, "[", "]");
121
- if (end === -1) {
122
- literal += ch;
123
- i += 1;
124
- continue;
122
+ const flushLiteral = () => {
123
+ if (f.literal !== "") {
124
+ f.nodes.push({ type: "literal", value: f.literal });
125
+ f.literal = "";
125
126
  }
126
- flushLiteral();
127
- nodes.push(parsePermutation(text.slice(i + 1, end)));
128
- i = end + 1;
129
- continue;
130
- }
131
- if (ch === "%") {
132
- const name = VARIABLE_RE.exec(text.slice(i))?.[1];
133
- if (name !== void 0) {
127
+ };
128
+ let planned = null;
129
+ while (f.i < f.text.length && planned === null) {
130
+ const ch = f.text.charAt(f.i);
131
+ if (ch === "{" || ch === "[") {
132
+ const close = ch === "{" ? "}" : "]";
133
+ const end = findMatchingClose(f.text, f.i, ch, close);
134
+ if (end === -1) {
135
+ f.literal += ch;
136
+ f.i += 1;
137
+ continue;
138
+ }
139
+ const inner = f.text.slice(f.i + 1, end);
134
140
  flushLiteral();
135
- nodes.push({ type: "variable", name });
136
- i += name.length + 2;
141
+ planned = ch === "{" ? planBraceConstruct(inner) : planPermutation(inner);
142
+ f.i = end + 1;
137
143
  continue;
138
144
  }
145
+ if (ch === "%") {
146
+ const name = VARIABLE_RE.exec(f.text.slice(f.i))?.[1];
147
+ if (name !== void 0) {
148
+ flushLiteral();
149
+ f.nodes.push({ type: "variable", name });
150
+ f.i += name.length + 2;
151
+ continue;
152
+ }
153
+ }
154
+ f.literal += ch;
155
+ f.i += 1;
139
156
  }
140
- literal += ch;
141
- i += 1;
157
+ if (planned !== null) {
158
+ if ("node" in planned) f.nodes.push(planned.node);
159
+ else f.plan = planned;
160
+ continue;
161
+ }
162
+ flushLiteral();
163
+ stack.pop();
164
+ const parent = stack[stack.length - 1];
165
+ if (parent !== void 0) parent.parts.push(f.nodes);
166
+ else return f.nodes;
142
167
  }
143
- flushLiteral();
144
- return nodes;
168
+ return [];
145
169
  }
146
- function parseBraceConstruct(content) {
170
+ function planBraceConstruct(content) {
147
171
  if (content.charAt(0) === "?") {
148
- const cond = tryParseConditional(content);
149
- if (cond) return cond;
172
+ const parts = splitConditional(content);
173
+ if (parts !== null) {
174
+ return {
175
+ texts: [parts.thenRaw, parts.elseRaw],
176
+ build: (children) => ({
177
+ type: "conditional",
178
+ name: parts.name,
179
+ inverted: parts.inverted,
180
+ then: children[0] ?? [],
181
+ else: children[1] ?? []
182
+ })
183
+ };
184
+ }
150
185
  } else if (content.startsWith(PLURAL_PREFIX) && content.slice(PLURAL_PREFIX.length).includes(":")) {
151
- return parsePlural(content.slice(PLURAL_PREFIX.length));
186
+ return { node: parsePlural(content.slice(PLURAL_PREFIX.length)) };
152
187
  }
153
- return { type: "enumeration", options: splitTopLevel(content).map((o) => parseSequence(o)) };
188
+ return {
189
+ texts: splitTopLevel(content),
190
+ build: (children) => ({ type: "enumeration", options: children })
191
+ };
192
+ }
193
+ function planPermutation(rawInner) {
194
+ const { config, content } = extractPermutationConfig(rawInner);
195
+ const { texts, separators } = permutationElements(splitTopLevel(content));
196
+ return {
197
+ texts,
198
+ build: (children) => ({
199
+ type: "permutation",
200
+ config,
201
+ options: children.map((nodes, i) => ({ nodes, separator: separators[i] ?? null }))
202
+ })
203
+ };
154
204
  }
155
205
  function recognizeConditional(text, contentStart, contentEnd) {
156
206
  let p = contentStart + 1;
@@ -179,17 +229,6 @@ function splitConditional(content) {
179
229
  elseRaw: sep < 0 ? "" : body.slice(sep + 1)
180
230
  };
181
231
  }
182
- function tryParseConditional(content) {
183
- const parts = splitConditional(content);
184
- if (parts === null) return null;
185
- return {
186
- type: "conditional",
187
- name: parts.name,
188
- inverted: parts.inverted,
189
- then: parseSequence(parts.thenRaw),
190
- else: parseSequence(parts.elseRaw)
191
- };
192
- }
193
232
  function parsePlural(afterPrefix) {
194
233
  const colon = afterPrefix.indexOf(":");
195
234
  return { type: "plural", countRaw: afterPrefix.slice(0, colon), formsRaw: afterPrefix.slice(colon + 1) };
@@ -204,11 +243,6 @@ var PER_ELEM_HTML_RE = /^[a-zA-Z][a-zA-Z0-9]*\s/;
204
243
  function defaultPermConfig() {
205
244
  return { minsize: null, maxsize: null, sep: " ", lastsep: null };
206
245
  }
207
- function parsePermutation(rawInner) {
208
- const { config, content } = extractPermutationConfig(rawInner);
209
- const options = extractPerElementSeparators(splitTopLevel(content));
210
- return { type: "permutation", config, options };
211
- }
212
246
  function extractPermutationConfig(content) {
213
247
  const trimmed = phpLtrim(content);
214
248
  if (trimmed === "" || trimmed.charAt(0) !== "<") {
@@ -252,8 +286,9 @@ function looksLikeHtmlStartTag(tagText, remaining) {
252
286
  const tagName = (m[1] ?? "").toLowerCase();
253
287
  return new RegExp(`</${escapeRegExp(tagName)}\\s*>`, "iu").test(remaining);
254
288
  }
255
- function extractPerElementSeparators(rawParts) {
256
- const options = [];
289
+ function permutationElements(rawParts) {
290
+ const texts = [];
291
+ const separators = [];
257
292
  let pendingSep = null;
258
293
  rawParts.forEach((part, i) => {
259
294
  let text = part;
@@ -267,11 +302,12 @@ function extractPerElementSeparators(rawParts) {
267
302
  }
268
303
  const trimmed = phpTrim(text);
269
304
  if (trimmed !== "") {
270
- options.push({ nodes: parseSequence(trimmed), separator: pendingSep });
305
+ texts.push(trimmed);
306
+ separators.push(pendingSep);
271
307
  }
272
308
  pendingSep = trailingSep;
273
309
  });
274
- return options;
310
+ return { texts, separators };
275
311
  }
276
312
  function extractTrailingSep(part) {
277
313
  const trimmed = phpRtrim(part);
@@ -1016,9 +1052,34 @@ function directReferences(text) {
1016
1052
  return [...text.matchAll(/%(\w+)%/gu)].map((match) => (match[1] ?? "").toLowerCase());
1017
1053
  }
1018
1054
  function renderNodes(nodes, opts) {
1019
- let out = "";
1020
- for (const node of nodes) out += renderNode(node, opts);
1021
- return out;
1055
+ const frame = (list) => ({ nodes: list, i: 0, out: [], pending: null });
1056
+ const stack = [frame(nodes)];
1057
+ while (stack.length > 0) {
1058
+ const f = stack[stack.length - 1];
1059
+ if (f.pending !== null) {
1060
+ if (f.pending.done.length < f.pending.lists.length) {
1061
+ stack.push(frame(f.pending.lists[f.pending.done.length]));
1062
+ continue;
1063
+ }
1064
+ f.out.push(f.pending.assemble(f.pending.done));
1065
+ f.pending = null;
1066
+ continue;
1067
+ }
1068
+ if (f.i >= f.nodes.length) {
1069
+ const text = f.out.join("");
1070
+ stack.pop();
1071
+ const parent = stack[stack.length - 1];
1072
+ if (parent === void 0) return text;
1073
+ parent.pending.done.push(text);
1074
+ continue;
1075
+ }
1076
+ const node = f.nodes[f.i];
1077
+ f.i += 1;
1078
+ const step = renderNode(node, opts);
1079
+ if (typeof step === "string") f.out.push(step);
1080
+ else f.pending = step;
1081
+ }
1082
+ return "";
1022
1083
  }
1023
1084
  function renderNode(node, opts) {
1024
1085
  switch (node.type) {
@@ -1070,7 +1131,7 @@ function conditionalTakesThen(name, inverted, opts) {
1070
1131
  }
1071
1132
  function renderConditional(node, opts) {
1072
1133
  const truthy = conditionalTakesThen(node.name, node.inverted, opts);
1073
- return renderNodes(truthy ? node.then : node.else, opts);
1134
+ return { lists: [truthy ? node.then : node.else], done: [], assemble: (parts) => parts[0] ?? "" };
1074
1135
  }
1075
1136
  function resolveCountConditionals(text, opts) {
1076
1137
  if (!text.includes("{?")) return text;
@@ -1155,7 +1216,7 @@ function renderPlural(node, opts) {
1155
1216
  return fullwidthVerbatim(countRaw, formsRaw);
1156
1217
  }
1157
1218
  const picked = pluralFor(base, Number.parseInt(count, 10), forms);
1158
- return renderNodes(parseSequence(picked), opts);
1219
+ return { lists: [parseSequence(picked)], done: [], assemble: (parts) => parts[0] ?? "" };
1159
1220
  }
1160
1221
  function rawConstruct(countRaw, formsRaw) {
1161
1222
  return `{plural ${countRaw}:${formsRaw}}`;
@@ -1166,13 +1227,19 @@ function fullwidthVerbatim(countRaw, formsRaw) {
1166
1227
  function renderEnumeration(options, opts) {
1167
1228
  if (options.length === 0) return "";
1168
1229
  const picked = options[randomInt(opts.rng, 0, options.length - 1)];
1169
- return picked ? renderNodes(picked, opts) : "";
1230
+ if (!picked) return "";
1231
+ return { lists: [picked], done: [], assemble: (parts) => parts[0] ?? "" };
1170
1232
  }
1171
1233
  function renderPermutation(node, opts) {
1172
- const elements = node.options.map((o) => ({
1173
- text: renderNodes(o.nodes, opts),
1174
- sep: o.separator
1175
- }));
1234
+ if (node.options.length === 0) return "";
1235
+ return {
1236
+ lists: node.options.map((o) => o.nodes),
1237
+ done: [],
1238
+ assemble: (parts) => assemblePermutation(node, parts, opts)
1239
+ };
1240
+ }
1241
+ function assemblePermutation(node, rendered, opts) {
1242
+ const elements = node.options.map((o, i) => ({ text: rendered[i] ?? "", sep: o.separator }));
1176
1243
  const total = elements.length;
1177
1244
  if (total === 0) return "";
1178
1245
  const { config } = node;