toon-parser 1.0.0 → 1.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/index.cjs +874 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +868 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -12
- package/index.js +0 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
2
|
+
maxDepth: 64,
|
|
3
|
+
maxArrayLength: 50000,
|
|
4
|
+
maxTotalNodes: 250000,
|
|
5
|
+
disallowedKeys: ['__proto__', 'constructor', 'prototype']
|
|
6
|
+
});
|
|
7
|
+
const DEFAULT_DELIMITER = ',';
|
|
8
|
+
const NUMERIC_RE = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
9
|
+
const NUMERIC_LIKE_RE = /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i;
|
|
10
|
+
const LEADING_ZERO_RE = /^0\d+$/;
|
|
11
|
+
const SAFE_KEY_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/;
|
|
12
|
+
export class ToonError extends Error {
|
|
13
|
+
constructor(message, line) {
|
|
14
|
+
super(line ? `Line ${line}: ${message}` : message);
|
|
15
|
+
this.name = 'ToonError';
|
|
16
|
+
this.line = line;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function jsonToToon(value, options = {}) {
|
|
20
|
+
const indentSize = options.indent ?? 2;
|
|
21
|
+
if (!Number.isInteger(indentSize) || indentSize <= 0) {
|
|
22
|
+
throw new ToonError('Indent must be a positive integer.');
|
|
23
|
+
}
|
|
24
|
+
const delimiter = options.delimiter ?? DEFAULT_DELIMITER;
|
|
25
|
+
const limits = applyLimits(options);
|
|
26
|
+
const state = { nodes: 0 };
|
|
27
|
+
const lines = [];
|
|
28
|
+
const indentUnit = ' '.repeat(indentSize);
|
|
29
|
+
const encodeValue = (input, depth, key, activeDelimiter) => {
|
|
30
|
+
enforceLimits(depth, limits, state);
|
|
31
|
+
if (isPrimitive(input)) {
|
|
32
|
+
const line = primitiveLine(key, input, indentUnit.repeat(depth), activeDelimiter, limits);
|
|
33
|
+
lines.push(line);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(input)) {
|
|
37
|
+
encodeArray(key, input, depth, activeDelimiter);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (isPlainObject(input)) {
|
|
41
|
+
encodeObject(key, input, depth, activeDelimiter);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw new ToonError(`Unsupported value type: ${typeof input}`);
|
|
45
|
+
};
|
|
46
|
+
const encodeObject = (key, obj, depth, activeDelimiter) => {
|
|
47
|
+
enforceLimits(depth, limits, state);
|
|
48
|
+
const entries = Object.entries(obj);
|
|
49
|
+
const sortedEntries = options.sortKeys ? [...entries].sort(([a], [b]) => a.localeCompare(b)) : entries;
|
|
50
|
+
const prefix = indentUnit.repeat(depth);
|
|
51
|
+
if (key !== null) {
|
|
52
|
+
validateKeySafety(key, limits);
|
|
53
|
+
if (sortedEntries.length === 0) {
|
|
54
|
+
lines.push(`${prefix}${encodeKey(key, activeDelimiter)}:`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
lines.push(`${prefix}${encodeKey(key, activeDelimiter)}:`);
|
|
58
|
+
}
|
|
59
|
+
else if (depth > 0 && sortedEntries.length === 0) {
|
|
60
|
+
// empty anonymous object
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const [childKey, childValue] of sortedEntries) {
|
|
64
|
+
const nextDepth = key === null ? depth : depth + 1;
|
|
65
|
+
enforceLimits(nextDepth, limits, state);
|
|
66
|
+
encodeValue(childValue, nextDepth, childKey, activeDelimiter);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const encodeArray = (key, arr, depth, activeDelimiter) => {
|
|
70
|
+
enforceLimits(depth, limits, state);
|
|
71
|
+
if (key !== null) {
|
|
72
|
+
validateKeySafety(key, limits);
|
|
73
|
+
}
|
|
74
|
+
if (arr.length > limits.maxArrayLength) {
|
|
75
|
+
throw new ToonError(`Array length ${arr.length} exceeds limit ${limits.maxArrayLength}.`);
|
|
76
|
+
}
|
|
77
|
+
const prefix = indentUnit.repeat(depth);
|
|
78
|
+
const headerKey = key === null ? '' : encodeKey(key, activeDelimiter);
|
|
79
|
+
if (arr.every(isPrimitive)) {
|
|
80
|
+
const encoded = arr.map(v => encodePrimitive(v, activeDelimiter, activeDelimiter)).join(activeDelimiter);
|
|
81
|
+
const spacing = arr.length > 0 ? ' ' : '';
|
|
82
|
+
lines.push(`${prefix}${headerKey}[${arr.length}]:${spacing}${encoded}`);
|
|
83
|
+
bumpNodes(state, limits, arr.length);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const tabular = detectTabular(arr);
|
|
87
|
+
if (tabular) {
|
|
88
|
+
const { fields, rows } = tabular;
|
|
89
|
+
const encodedFields = fields.map(f => encodeKey(f, activeDelimiter)).join(activeDelimiter);
|
|
90
|
+
lines.push(`${prefix}${headerKey}[${arr.length}]{${encodedFields}}:`);
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
const rowValues = fields
|
|
93
|
+
.map(f => encodePrimitive(row[f], activeDelimiter, activeDelimiter))
|
|
94
|
+
.join(activeDelimiter);
|
|
95
|
+
lines.push(`${indentUnit.repeat(depth + 1)}${rowValues}`);
|
|
96
|
+
}
|
|
97
|
+
bumpNodes(state, limits, arr.length * fields.length);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// expanded list
|
|
101
|
+
lines.push(`${prefix}${headerKey}[${arr.length}]:`);
|
|
102
|
+
const itemIndent = depth + 1;
|
|
103
|
+
const itemPrefix = indentUnit.repeat(itemIndent);
|
|
104
|
+
for (const item of arr) {
|
|
105
|
+
enforceLimits(itemIndent, limits, state);
|
|
106
|
+
if (isPrimitive(item)) {
|
|
107
|
+
lines.push(`${itemPrefix}- ${encodePrimitive(item, activeDelimiter, activeDelimiter)}`);
|
|
108
|
+
bumpNodes(state, limits, 1);
|
|
109
|
+
}
|
|
110
|
+
else if (Array.isArray(item)) {
|
|
111
|
+
const inline = item.every(isPrimitive);
|
|
112
|
+
if (inline) {
|
|
113
|
+
const encoded = item.map(v => encodePrimitive(v, activeDelimiter, activeDelimiter)).join(activeDelimiter);
|
|
114
|
+
const spacing = item.length > 0 ? ' ' : '';
|
|
115
|
+
lines.push(`${itemPrefix}- [${item.length}]:${spacing}${encoded}`);
|
|
116
|
+
bumpNodes(state, limits, item.length);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(`${itemPrefix}-`);
|
|
120
|
+
encodeArray(null, item, itemIndent + 1, activeDelimiter);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (isPlainObject(item)) {
|
|
124
|
+
const objEntries = Object.entries(item);
|
|
125
|
+
if (objEntries.length === 0) {
|
|
126
|
+
lines.push(`${itemPrefix}-`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
lines.push(`${itemPrefix}-`);
|
|
130
|
+
for (const [childKey, childValue] of objEntries) {
|
|
131
|
+
encodeValue(childValue, itemIndent + 1, childKey, activeDelimiter);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
throw new ToonError(`Unsupported array item type: ${typeof item}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
encodeValue(value, 0, null, delimiter);
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
export function toonToJson(text, options = {}) {
|
|
143
|
+
const limits = applyLimits(options);
|
|
144
|
+
const strict = options.strict ?? true;
|
|
145
|
+
const delimiterFallback = DEFAULT_DELIMITER;
|
|
146
|
+
const lines = text.split(/\r?\n/);
|
|
147
|
+
const contexts = [];
|
|
148
|
+
let rootContainer = null;
|
|
149
|
+
let indentStep = null;
|
|
150
|
+
let root = null;
|
|
151
|
+
const state = { nodes: 0 };
|
|
152
|
+
const finalizeContainer = (container, lineNo) => {
|
|
153
|
+
if (container.type === 'tabular') {
|
|
154
|
+
if (strict && container.value.length !== container.expectedLength) {
|
|
155
|
+
throw new ToonError(`Tabular array length mismatch: expected ${container.expectedLength}, got ${container.value.length}.`, lineNo);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (container.type === 'list') {
|
|
159
|
+
if (strict && container.expectedLength !== null && container.value.length !== container.expectedLength) {
|
|
160
|
+
throw new ToonError(`List length mismatch: expected ${container.expectedLength}, got ${container.value.length}.`, lineNo);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else if (container.type === 'placeholder') {
|
|
164
|
+
// If a placeholder was not filled, treat it as an empty object value.
|
|
165
|
+
// This matches how `jsonToToon` emits empty objects as a `-` list item with no body.
|
|
166
|
+
if (!container.filled) {
|
|
167
|
+
container.assign(createSafeObject());
|
|
168
|
+
container.filled = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const attachValue = (value, parent, key, lineNo) => {
|
|
173
|
+
const ensureRootObject = () => {
|
|
174
|
+
if (rootContainer) {
|
|
175
|
+
return rootContainer;
|
|
176
|
+
}
|
|
177
|
+
const obj = { type: 'object', value: createSafeObject(), indent: 0 };
|
|
178
|
+
rootContainer = obj;
|
|
179
|
+
root = obj.value;
|
|
180
|
+
contexts.push(obj);
|
|
181
|
+
return obj;
|
|
182
|
+
};
|
|
183
|
+
if (!parent) {
|
|
184
|
+
if (key !== null) {
|
|
185
|
+
const target = ensureRootObject();
|
|
186
|
+
target.value[key] = value;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (root !== null) {
|
|
190
|
+
throw new ToonError('Multiple root values detected.', lineNo);
|
|
191
|
+
}
|
|
192
|
+
root = value;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (parent.type === 'object') {
|
|
196
|
+
if (key === null) {
|
|
197
|
+
throw new ToonError('Missing key for object assignment.');
|
|
198
|
+
}
|
|
199
|
+
parent.value[key] = value;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (parent.type === 'list') {
|
|
203
|
+
parent.value.push(value);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (parent.type === 'placeholder') {
|
|
207
|
+
if (parent.filled) {
|
|
208
|
+
throw new ToonError('List item already filled.', lineNo);
|
|
209
|
+
}
|
|
210
|
+
parent.assign(value);
|
|
211
|
+
parent.filled = true;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
throw new ToonError('Invalid parent container.');
|
|
215
|
+
};
|
|
216
|
+
const parseArrayHeader = (token, lineNo) => {
|
|
217
|
+
const match = token.match(/^\[(\d+)([,\|\t])?\](\{(.+)\})?$/);
|
|
218
|
+
if (!match) {
|
|
219
|
+
throw new ToonError(`Invalid array header "${token}".`, lineNo);
|
|
220
|
+
}
|
|
221
|
+
const length = parseInt(match[1], 10);
|
|
222
|
+
if (!Number.isFinite(length)) {
|
|
223
|
+
throw new ToonError('Invalid array length.', lineNo);
|
|
224
|
+
}
|
|
225
|
+
const delimiter = match[2] ?? delimiterFallback;
|
|
226
|
+
const fieldsRaw = match[4];
|
|
227
|
+
if (fieldsRaw === undefined) {
|
|
228
|
+
return { length, delimiter };
|
|
229
|
+
}
|
|
230
|
+
const fields = splitDelimited(fieldsRaw, delimiter, lineNo).map(f => decodeKey(f, delimiter, lineNo));
|
|
231
|
+
if (fields.length === 0 && strict) {
|
|
232
|
+
throw new ToonError('Tabular arrays require at least one field.', lineNo);
|
|
233
|
+
}
|
|
234
|
+
return { length, delimiter, fields };
|
|
235
|
+
};
|
|
236
|
+
const processKeyValueLine = (indentLevel, keyToken, valueToken, lineNo, parent) => {
|
|
237
|
+
const { rawKey, header } = splitKeyHeader(keyToken);
|
|
238
|
+
const key = rawKey === '' ? null : decodeKey(rawKey, delimiterFallback, lineNo);
|
|
239
|
+
if (key !== null) {
|
|
240
|
+
validateKeySafety(key, limits, lineNo);
|
|
241
|
+
}
|
|
242
|
+
if (header) {
|
|
243
|
+
const { length, delimiter, fields } = parseArrayHeader(header, lineNo);
|
|
244
|
+
if (length > limits.maxArrayLength) {
|
|
245
|
+
throw new ToonError(`Array length ${length} exceeds limit ${limits.maxArrayLength}.`, lineNo);
|
|
246
|
+
}
|
|
247
|
+
if (fields) {
|
|
248
|
+
if (valueToken !== '') {
|
|
249
|
+
throw new ToonError('Tabular array header must not have inline values.', lineNo);
|
|
250
|
+
}
|
|
251
|
+
const arr = [];
|
|
252
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
253
|
+
attachValue(arr, parent, key, lineNo);
|
|
254
|
+
contexts.push({
|
|
255
|
+
type: 'tabular',
|
|
256
|
+
value: arr,
|
|
257
|
+
indent: indentLevel + 1,
|
|
258
|
+
expectedLength: length,
|
|
259
|
+
delimiter,
|
|
260
|
+
fields
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (valueToken === '') {
|
|
265
|
+
const arr = [];
|
|
266
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
267
|
+
attachValue(arr, parent, key, lineNo);
|
|
268
|
+
contexts.push({
|
|
269
|
+
type: 'list',
|
|
270
|
+
value: arr,
|
|
271
|
+
indent: indentLevel + 1,
|
|
272
|
+
expectedLength: length,
|
|
273
|
+
delimiter
|
|
274
|
+
});
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const values = splitDelimited(valueToken, delimiter, lineNo).map(t => parsePrimitiveToken(t, delimiter, lineNo, strict));
|
|
278
|
+
if (strict && values.length !== length) {
|
|
279
|
+
throw new ToonError(`Inline array length mismatch: expected ${length}, got ${values.length}.`, lineNo);
|
|
280
|
+
}
|
|
281
|
+
attachValue(values, parent, key, lineNo);
|
|
282
|
+
bumpNodes(state, limits, values.length, lineNo);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (valueToken === '') {
|
|
286
|
+
const obj = createSafeObject();
|
|
287
|
+
attachValue(obj, parent, key, lineNo);
|
|
288
|
+
contexts.push({ type: 'object', value: obj, indent: indentLevel + 1 });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const value = parsePrimitiveToken(valueToken, delimiterFallback, lineNo, strict);
|
|
292
|
+
attachValue(value, parent, key, lineNo);
|
|
293
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
294
|
+
};
|
|
295
|
+
lines.forEach((rawLine, index) => {
|
|
296
|
+
const lineNo = index + 1;
|
|
297
|
+
const trimmedEnd = rawLine.replace(/[ \t]+$/, '');
|
|
298
|
+
if (trimmedEnd.trim() === '') {
|
|
299
|
+
return; // skip blank lines
|
|
300
|
+
}
|
|
301
|
+
const indentSpaces = countLeadingSpaces(trimmedEnd, lineNo);
|
|
302
|
+
if (indentStep === null) {
|
|
303
|
+
indentStep = indentSpaces === 0 ? 2 : indentSpaces;
|
|
304
|
+
}
|
|
305
|
+
if (indentSpaces % indentStep !== 0) {
|
|
306
|
+
throw new ToonError(`Inconsistent indentation: expected multiples of ${indentStep} spaces.`, lineNo);
|
|
307
|
+
}
|
|
308
|
+
const indentLevel = indentSpaces / indentStep;
|
|
309
|
+
if (indentLevel > limits.maxDepth) {
|
|
310
|
+
throw new ToonError(`Maximum depth ${limits.maxDepth} exceeded.`, lineNo);
|
|
311
|
+
}
|
|
312
|
+
const line = trimmedEnd.slice(indentSpaces);
|
|
313
|
+
while (true) {
|
|
314
|
+
const top = contexts[contexts.length - 1];
|
|
315
|
+
if (!top || indentLevel >= top.indent) {
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
contexts.pop();
|
|
319
|
+
finalizeContainer(top, lineNo);
|
|
320
|
+
}
|
|
321
|
+
// tabular row disambiguation
|
|
322
|
+
let handled = false;
|
|
323
|
+
let consumed = false;
|
|
324
|
+
while (!handled) {
|
|
325
|
+
const top = contexts[contexts.length - 1];
|
|
326
|
+
if (top && top.type === 'tabular' && indentLevel === top.indent) {
|
|
327
|
+
const classification = classifyTabularLine(line, top.delimiter);
|
|
328
|
+
if (classification === 'row') {
|
|
329
|
+
const cells = splitDelimited(line, top.delimiter, lineNo);
|
|
330
|
+
if (strict && cells.length !== top.fields.length) {
|
|
331
|
+
throw new ToonError(`Tabular row width mismatch: expected ${top.fields.length}, got ${cells.length}.`, lineNo);
|
|
332
|
+
}
|
|
333
|
+
const obj = createSafeObject();
|
|
334
|
+
top.fields.forEach((field, idx) => {
|
|
335
|
+
validateKeySafety(field, limits, lineNo);
|
|
336
|
+
const token = cells[idx] ?? '';
|
|
337
|
+
obj[field] = parsePrimitiveToken(token, top.delimiter, lineNo, strict);
|
|
338
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
339
|
+
});
|
|
340
|
+
top.value.push(obj);
|
|
341
|
+
if (top.value.length > limits.maxArrayLength) {
|
|
342
|
+
throw new ToonError(`Tabular array length exceeds limit ${limits.maxArrayLength}.`, lineNo);
|
|
343
|
+
}
|
|
344
|
+
handled = true;
|
|
345
|
+
consumed = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
finalizeContainer(top, lineNo);
|
|
349
|
+
contexts.pop();
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
handled = true;
|
|
353
|
+
}
|
|
354
|
+
if (consumed) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const parent = contexts[contexts.length - 1];
|
|
358
|
+
if (parent && parent.type === 'list') {
|
|
359
|
+
if (indentLevel !== parent.indent) {
|
|
360
|
+
throw new ToonError('List items must align under their header.', lineNo);
|
|
361
|
+
}
|
|
362
|
+
parseListItem(line, parent, indentLevel, lineNo, processKeyValueLine, contexts, state, limits, strict);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (parent && parent.type === 'placeholder') {
|
|
366
|
+
if (indentLevel !== parent.indent) {
|
|
367
|
+
throw new ToonError('List item body must indent one level below "-".', lineNo);
|
|
368
|
+
}
|
|
369
|
+
// allow body to be parsed as if parent were absent; attachValue will route through placeholder.
|
|
370
|
+
}
|
|
371
|
+
if (indentLevel !== (parent ? parent.indent : 0)) {
|
|
372
|
+
throw new ToonError('Unexpected indentation level.', lineNo);
|
|
373
|
+
}
|
|
374
|
+
const colonIndex = findUnquotedColon(line);
|
|
375
|
+
if (colonIndex === -1) {
|
|
376
|
+
// possible root primitive
|
|
377
|
+
if (root === null && !parent) {
|
|
378
|
+
const value = parsePrimitiveToken(line.trim(), delimiterFallback, lineNo, strict);
|
|
379
|
+
root = value;
|
|
380
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
throw new ToonError('Expected key-value pair.', lineNo);
|
|
384
|
+
}
|
|
385
|
+
const keyToken = line.slice(0, colonIndex).trim();
|
|
386
|
+
const valueToken = line.slice(colonIndex + 1).trim();
|
|
387
|
+
// If we're in a placeholder parent (a `-` with no inline content), and the body
|
|
388
|
+
// line contains a key:value, we should attach to the current object context.
|
|
389
|
+
// If this is the first body line, create and track the object on placeholder.current.
|
|
390
|
+
// Subsequent body lines will reuse the same object.
|
|
391
|
+
if (parent && parent.type === 'placeholder') {
|
|
392
|
+
// Don't automatically create an object for array headers (e.g., `[2]{...}:`)
|
|
393
|
+
// — handle those via the normal `processKeyValueLine` flow so headers attach
|
|
394
|
+
// to the placeholder correctly as arrays/tabular types.
|
|
395
|
+
if (!keyToken.startsWith('[')) {
|
|
396
|
+
// Reuse existing current object or create new one on first body line
|
|
397
|
+
if (!parent.current) {
|
|
398
|
+
const obj = createSafeObject();
|
|
399
|
+
// Call assign BEFORE setting current, so the skip logic can distinguish
|
|
400
|
+
// first assignment from finalizeContainer's empty object push
|
|
401
|
+
parent.assign(obj);
|
|
402
|
+
parent.current = { value: obj, indent: indentLevel + 1 };
|
|
403
|
+
parent.filled = true;
|
|
404
|
+
}
|
|
405
|
+
const objContext = { type: 'object', value: parent.current.value, indent: indentLevel + 1 };
|
|
406
|
+
processKeyValueLine(indentLevel + 1, keyToken, valueToken, lineNo, objContext);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
processKeyValueLine(indentLevel, keyToken, valueToken, lineNo, parent);
|
|
411
|
+
});
|
|
412
|
+
while (contexts.length > 0) {
|
|
413
|
+
const container = contexts.pop();
|
|
414
|
+
finalizeContainer(container, lines.length);
|
|
415
|
+
}
|
|
416
|
+
if (root === null) {
|
|
417
|
+
return createSafeObject();
|
|
418
|
+
}
|
|
419
|
+
return root;
|
|
420
|
+
}
|
|
421
|
+
function classifyTabularLine(line, delimiter) {
|
|
422
|
+
let inQuote = false;
|
|
423
|
+
let escape = false;
|
|
424
|
+
let firstColon = -1;
|
|
425
|
+
let firstDelim = -1;
|
|
426
|
+
for (let i = 0; i < line.length; i++) {
|
|
427
|
+
const ch = line[i];
|
|
428
|
+
if (escape) {
|
|
429
|
+
escape = false;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (ch === '\\') {
|
|
433
|
+
escape = true;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (ch === '"') {
|
|
437
|
+
inQuote = !inQuote;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (inQuote)
|
|
441
|
+
continue;
|
|
442
|
+
if (ch === ':' && firstColon === -1)
|
|
443
|
+
firstColon = i;
|
|
444
|
+
if (ch === delimiter && firstDelim === -1)
|
|
445
|
+
firstDelim = i;
|
|
446
|
+
}
|
|
447
|
+
if (firstColon === -1)
|
|
448
|
+
return 'row';
|
|
449
|
+
if (firstDelim === -1)
|
|
450
|
+
return 'field';
|
|
451
|
+
return firstDelim < firstColon ? 'row' : 'field';
|
|
452
|
+
}
|
|
453
|
+
function parseListItem(line, list, indentLevel, lineNo, processKeyValueLine, contexts, state, limits, strict) {
|
|
454
|
+
const trimmed = line.trim();
|
|
455
|
+
if (!trimmed.startsWith('-')) {
|
|
456
|
+
throw new ToonError('List items must start with "-".', lineNo);
|
|
457
|
+
}
|
|
458
|
+
const content = trimmed.slice(1).trim();
|
|
459
|
+
if (content === '') {
|
|
460
|
+
const placeholder = {
|
|
461
|
+
type: 'placeholder',
|
|
462
|
+
indent: indentLevel + 1,
|
|
463
|
+
filled: false,
|
|
464
|
+
current: undefined,
|
|
465
|
+
assign: function (value) {
|
|
466
|
+
// Push value to list; the current field tracks multi-line object context
|
|
467
|
+
// Skip only if: current already exists (meaning we've already assigned) AND
|
|
468
|
+
// the value being assigned is an empty object (from finalizeContainer).
|
|
469
|
+
if (this.current && typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0) {
|
|
470
|
+
// Empty object from finalizeContainer; skip pushing again
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
list.value.push(value);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
contexts.push(placeholder);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Array list item (header on the same line)
|
|
480
|
+
if (content.startsWith('[')) {
|
|
481
|
+
const colonIndex = findUnquotedColon(content);
|
|
482
|
+
const headerToken = colonIndex === -1 ? content : content.slice(0, colonIndex).trim();
|
|
483
|
+
const valueToken = colonIndex === -1 ? '' : content.slice(colonIndex + 1).trim();
|
|
484
|
+
const { length, delimiter, fields } = parseArrayHeaderFromList(headerToken, lineNo);
|
|
485
|
+
if (length > limits.maxArrayLength) {
|
|
486
|
+
throw new ToonError(`Array length ${length} exceeds limit ${limits.maxArrayLength}.`, lineNo);
|
|
487
|
+
}
|
|
488
|
+
if (fields) {
|
|
489
|
+
if (valueToken !== '') {
|
|
490
|
+
throw new ToonError('Tabular header in list item cannot have inline values.', lineNo);
|
|
491
|
+
}
|
|
492
|
+
const arr = [];
|
|
493
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
494
|
+
list.value.push(arr);
|
|
495
|
+
contexts.push({
|
|
496
|
+
type: 'tabular',
|
|
497
|
+
value: arr,
|
|
498
|
+
indent: indentLevel + 1,
|
|
499
|
+
expectedLength: length,
|
|
500
|
+
delimiter,
|
|
501
|
+
fields
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (valueToken === '') {
|
|
506
|
+
const arr = [];
|
|
507
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
508
|
+
list.value.push(arr);
|
|
509
|
+
contexts.push({
|
|
510
|
+
type: 'list',
|
|
511
|
+
value: arr,
|
|
512
|
+
indent: indentLevel + 1,
|
|
513
|
+
expectedLength: length,
|
|
514
|
+
delimiter
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const values = splitDelimited(valueToken, delimiter, lineNo).map(t => parsePrimitiveToken(t, delimiter, lineNo, strict));
|
|
519
|
+
if (strict && values.length !== length) {
|
|
520
|
+
throw new ToonError(`Inline array length mismatch: expected ${length}, got ${values.length}.`, lineNo);
|
|
521
|
+
}
|
|
522
|
+
bumpNodes(state, limits, values.length, lineNo);
|
|
523
|
+
list.value.push(values);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// Inline object field or array header
|
|
527
|
+
const colonIndex = findUnquotedColon(content);
|
|
528
|
+
if (colonIndex !== -1) {
|
|
529
|
+
const keyToken = content.slice(0, colonIndex).trim();
|
|
530
|
+
const valueToken = content.slice(colonIndex + 1).trim();
|
|
531
|
+
const obj = createSafeObject();
|
|
532
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
533
|
+
list.value.push(obj);
|
|
534
|
+
const objContext = { type: 'object', value: obj, indent: indentLevel + 1 };
|
|
535
|
+
contexts.push(objContext);
|
|
536
|
+
processKeyValueLine(indentLevel + 1, keyToken, valueToken, lineNo, objContext);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Primitive value
|
|
540
|
+
const value = parsePrimitiveToken(content, list.delimiter, lineNo, strict);
|
|
541
|
+
bumpNodes(state, limits, 1, lineNo);
|
|
542
|
+
list.value.push(value);
|
|
543
|
+
}
|
|
544
|
+
function splitKeyHeader(token) {
|
|
545
|
+
let inQuote = false;
|
|
546
|
+
let escape = false;
|
|
547
|
+
for (let i = 0; i < token.length; i++) {
|
|
548
|
+
const ch = token[i];
|
|
549
|
+
if (escape) {
|
|
550
|
+
escape = false;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (ch === '\\') {
|
|
554
|
+
escape = true;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (ch === '"') {
|
|
558
|
+
inQuote = !inQuote;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!inQuote && ch === '[') {
|
|
562
|
+
return {
|
|
563
|
+
rawKey: token.slice(0, i).trim(),
|
|
564
|
+
header: token.slice(i).trim()
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return { rawKey: token.trim() };
|
|
569
|
+
}
|
|
570
|
+
function parseArrayHeaderFromList(token, lineNo) {
|
|
571
|
+
const match = token.match(/^\[(\d+)([,\|\t])?\](\{(.+)\})?$/);
|
|
572
|
+
if (!match) {
|
|
573
|
+
throw new ToonError(`Invalid array header "${token}".`, lineNo);
|
|
574
|
+
}
|
|
575
|
+
const length = parseInt(match[1], 10);
|
|
576
|
+
const delimiter = match[2] ?? DEFAULT_DELIMITER;
|
|
577
|
+
const fieldsRaw = match[4];
|
|
578
|
+
const fields = fieldsRaw ? splitDelimited(fieldsRaw, delimiter, lineNo).map(f => decodeKey(f, delimiter, lineNo)) : undefined;
|
|
579
|
+
return { length, delimiter, fields };
|
|
580
|
+
}
|
|
581
|
+
function findUnquotedColon(text) {
|
|
582
|
+
let inQuote = false;
|
|
583
|
+
let escape = false;
|
|
584
|
+
for (let i = 0; i < text.length; i++) {
|
|
585
|
+
const ch = text[i];
|
|
586
|
+
if (escape) {
|
|
587
|
+
escape = false;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (ch === '\\') {
|
|
591
|
+
escape = true;
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (ch === '"') {
|
|
595
|
+
inQuote = !inQuote;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (!inQuote && ch === ':') {
|
|
599
|
+
return i;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return -1;
|
|
603
|
+
}
|
|
604
|
+
function countLeadingSpaces(text, lineNo) {
|
|
605
|
+
let count = 0;
|
|
606
|
+
for (const ch of text) {
|
|
607
|
+
if (ch === ' ') {
|
|
608
|
+
count++;
|
|
609
|
+
}
|
|
610
|
+
else if (ch === '\t') {
|
|
611
|
+
throw new ToonError('Tabs are not allowed for indentation.', lineNo);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return count;
|
|
618
|
+
}
|
|
619
|
+
function parsePrimitiveToken(token, delimiter, lineNo, strict) {
|
|
620
|
+
if (token === '') {
|
|
621
|
+
return '';
|
|
622
|
+
}
|
|
623
|
+
const trimmed = token.trim();
|
|
624
|
+
if (trimmed !== token && strict) {
|
|
625
|
+
throw new ToonError('Unquoted values may not contain leading or trailing whitespace.', lineNo);
|
|
626
|
+
}
|
|
627
|
+
if (trimmed.startsWith('"')) {
|
|
628
|
+
return decodeQuotedString(trimmed, lineNo);
|
|
629
|
+
}
|
|
630
|
+
if (trimmed === 'true')
|
|
631
|
+
return true;
|
|
632
|
+
if (trimmed === 'false')
|
|
633
|
+
return false;
|
|
634
|
+
if (trimmed === 'null')
|
|
635
|
+
return null;
|
|
636
|
+
// If token is a plain integer with leading zeros (e.g., 007) this is disallowed
|
|
637
|
+
// and should be quoted - reject early.
|
|
638
|
+
if (/^-?\d+$/.test(trimmed) && /^-?0\d+$/.test(trimmed)) {
|
|
639
|
+
throw new ToonError('Numbers with leading zeros must be quoted.', lineNo);
|
|
640
|
+
}
|
|
641
|
+
if (NUMERIC_RE.test(trimmed)) {
|
|
642
|
+
if (/^-?0\d+/.test(trimmed)) {
|
|
643
|
+
throw new ToonError('Numbers with leading zeros must be quoted.', lineNo);
|
|
644
|
+
}
|
|
645
|
+
const num = Number(trimmed);
|
|
646
|
+
if (!Number.isFinite(num)) {
|
|
647
|
+
throw new ToonError('Invalid numeric value.', lineNo);
|
|
648
|
+
}
|
|
649
|
+
return num;
|
|
650
|
+
}
|
|
651
|
+
if (strict && trimmed.includes(delimiter)) {
|
|
652
|
+
throw new ToonError('Unquoted value contains the active delimiter.', lineNo);
|
|
653
|
+
}
|
|
654
|
+
return trimmed;
|
|
655
|
+
}
|
|
656
|
+
function decodeQuotedString(token, lineNo) {
|
|
657
|
+
if (!token.endsWith('"')) {
|
|
658
|
+
throw new ToonError('Unterminated string.', lineNo);
|
|
659
|
+
}
|
|
660
|
+
let result = '';
|
|
661
|
+
let escape = false;
|
|
662
|
+
for (let i = 1; i < token.length - 1; i++) {
|
|
663
|
+
const ch = token[i];
|
|
664
|
+
if (escape) {
|
|
665
|
+
if (ch === '"' || ch === '\\') {
|
|
666
|
+
result += ch;
|
|
667
|
+
}
|
|
668
|
+
else if (ch === 'n') {
|
|
669
|
+
result += '\n';
|
|
670
|
+
}
|
|
671
|
+
else if (ch === 'r') {
|
|
672
|
+
result += '\r';
|
|
673
|
+
}
|
|
674
|
+
else if (ch === 't') {
|
|
675
|
+
result += '\t';
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
throw new ToonError(`Invalid escape sequence \\${ch}.`, lineNo);
|
|
679
|
+
}
|
|
680
|
+
escape = false;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (ch === '\\') {
|
|
684
|
+
escape = true;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
result += ch;
|
|
688
|
+
}
|
|
689
|
+
if (escape) {
|
|
690
|
+
throw new ToonError('Unterminated escape sequence.', lineNo);
|
|
691
|
+
}
|
|
692
|
+
return result;
|
|
693
|
+
}
|
|
694
|
+
function splitDelimited(text, delimiter, lineNo) {
|
|
695
|
+
const tokens = [];
|
|
696
|
+
let current = '';
|
|
697
|
+
let inQuote = false;
|
|
698
|
+
let escape = false;
|
|
699
|
+
for (let i = 0; i < text.length; i++) {
|
|
700
|
+
const ch = text[i];
|
|
701
|
+
if (escape) {
|
|
702
|
+
current += ch;
|
|
703
|
+
escape = false;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (ch === '\\') {
|
|
707
|
+
current += ch;
|
|
708
|
+
escape = true;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (ch === '"') {
|
|
712
|
+
current += ch;
|
|
713
|
+
inQuote = !inQuote;
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (!inQuote && ch === delimiter) {
|
|
717
|
+
tokens.push(current);
|
|
718
|
+
current = '';
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
current += ch;
|
|
722
|
+
}
|
|
723
|
+
if (inQuote) {
|
|
724
|
+
throw new ToonError('Unterminated quoted value.', lineNo);
|
|
725
|
+
}
|
|
726
|
+
tokens.push(current);
|
|
727
|
+
return tokens;
|
|
728
|
+
}
|
|
729
|
+
function encodePrimitive(value, activeDelimiter, documentDelimiter) {
|
|
730
|
+
if (value === null)
|
|
731
|
+
return 'null';
|
|
732
|
+
if (typeof value === 'boolean')
|
|
733
|
+
return value ? 'true' : 'false';
|
|
734
|
+
if (typeof value === 'number') {
|
|
735
|
+
if (!Number.isFinite(value)) {
|
|
736
|
+
throw new ToonError('Numeric values must be finite.');
|
|
737
|
+
}
|
|
738
|
+
if (Object.is(value, -0))
|
|
739
|
+
return '-0';
|
|
740
|
+
return String(value);
|
|
741
|
+
}
|
|
742
|
+
return encodeString(value, activeDelimiter, documentDelimiter);
|
|
743
|
+
}
|
|
744
|
+
function encodeString(value, activeDelimiter, documentDelimiter) {
|
|
745
|
+
const needsQuote = value.length === 0 ||
|
|
746
|
+
/^\s|\s$/.test(value) ||
|
|
747
|
+
value === 'true' ||
|
|
748
|
+
value === 'false' ||
|
|
749
|
+
value === 'null' ||
|
|
750
|
+
NUMERIC_LIKE_RE.test(value) ||
|
|
751
|
+
LEADING_ZERO_RE.test(value) ||
|
|
752
|
+
value.includes(':') ||
|
|
753
|
+
value.includes('"') ||
|
|
754
|
+
value.includes('\\') ||
|
|
755
|
+
/[\[\]{}]/.test(value) ||
|
|
756
|
+
/[\n\r\t]/.test(value) ||
|
|
757
|
+
value.includes(activeDelimiter) ||
|
|
758
|
+
value.includes(documentDelimiter) ||
|
|
759
|
+
value === '-' ||
|
|
760
|
+
value.startsWith('-');
|
|
761
|
+
if (!needsQuote) {
|
|
762
|
+
return value;
|
|
763
|
+
}
|
|
764
|
+
return `"${escapeString(value)}"`;
|
|
765
|
+
}
|
|
766
|
+
function escapeString(value) {
|
|
767
|
+
return value
|
|
768
|
+
.replace(/\\/g, '\\\\')
|
|
769
|
+
.replace(/"/g, '\\"')
|
|
770
|
+
.replace(/\n/g, '\\n')
|
|
771
|
+
.replace(/\r/g, '\\r')
|
|
772
|
+
.replace(/\t/g, '\\t');
|
|
773
|
+
}
|
|
774
|
+
function primitiveLine(key, value, indent, activeDelimiter, limits = DEFAULT_LIMITS) {
|
|
775
|
+
const encodedValue = encodePrimitive(value, activeDelimiter, activeDelimiter);
|
|
776
|
+
if (key === null) {
|
|
777
|
+
return `${indent}${encodedValue}`;
|
|
778
|
+
}
|
|
779
|
+
validateKeySafety(key, limits);
|
|
780
|
+
return `${indent}${encodeKey(key, activeDelimiter)}: ${encodedValue}`;
|
|
781
|
+
}
|
|
782
|
+
function encodeKey(key, activeDelimiter) {
|
|
783
|
+
if (SAFE_KEY_RE.test(key) && !key.includes(activeDelimiter)) {
|
|
784
|
+
return key;
|
|
785
|
+
}
|
|
786
|
+
return `"${escapeString(key)}"`;
|
|
787
|
+
}
|
|
788
|
+
function decodeKey(token, delimiter, lineNo) {
|
|
789
|
+
const trimmed = token.trim();
|
|
790
|
+
if (trimmed.startsWith('"')) {
|
|
791
|
+
return decodeQuotedString(trimmed, lineNo);
|
|
792
|
+
}
|
|
793
|
+
if (!SAFE_KEY_RE.test(trimmed)) {
|
|
794
|
+
throw new ToonError('Invalid key token.', lineNo);
|
|
795
|
+
}
|
|
796
|
+
if (trimmed.includes(delimiter)) {
|
|
797
|
+
throw new ToonError('Key contains active delimiter and must be quoted.', lineNo);
|
|
798
|
+
}
|
|
799
|
+
return trimmed;
|
|
800
|
+
}
|
|
801
|
+
function bumpNodes(state, limits, count, lineNo) {
|
|
802
|
+
state.nodes += count;
|
|
803
|
+
if (state.nodes > limits.maxTotalNodes) {
|
|
804
|
+
throw new ToonError(`Node count ${state.nodes} exceeds limit ${limits.maxTotalNodes}.`, lineNo);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function enforceLimits(depth, limits, state) {
|
|
808
|
+
if (depth > limits.maxDepth) {
|
|
809
|
+
throw new ToonError(`Maximum depth ${limits.maxDepth} exceeded.`);
|
|
810
|
+
}
|
|
811
|
+
bumpNodes(state, limits, 1);
|
|
812
|
+
}
|
|
813
|
+
function applyLimits(options) {
|
|
814
|
+
return {
|
|
815
|
+
maxDepth: options.maxDepth ?? DEFAULT_LIMITS.maxDepth,
|
|
816
|
+
maxArrayLength: options.maxArrayLength ?? DEFAULT_LIMITS.maxArrayLength,
|
|
817
|
+
maxTotalNodes: options.maxTotalNodes ?? DEFAULT_LIMITS.maxTotalNodes,
|
|
818
|
+
disallowedKeys: options.disallowedKeys ?? DEFAULT_LIMITS.disallowedKeys
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function isPrimitive(value) {
|
|
822
|
+
return (value === null ||
|
|
823
|
+
typeof value === 'string' ||
|
|
824
|
+
typeof value === 'number' ||
|
|
825
|
+
typeof value === 'boolean');
|
|
826
|
+
}
|
|
827
|
+
function isPlainObject(value) {
|
|
828
|
+
if (value === null || typeof value !== 'object')
|
|
829
|
+
return false;
|
|
830
|
+
const proto = Object.getPrototypeOf(value);
|
|
831
|
+
return proto === Object.prototype || proto === null;
|
|
832
|
+
}
|
|
833
|
+
function detectTabular(arr) {
|
|
834
|
+
if (arr.length === 0)
|
|
835
|
+
return null;
|
|
836
|
+
if (!arr.every(item => isPlainObject(item))) {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const first = arr[0];
|
|
840
|
+
const fields = Object.keys(first);
|
|
841
|
+
if (fields.length === 0) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const rows = [];
|
|
845
|
+
for (const item of arr) {
|
|
846
|
+
const obj = item;
|
|
847
|
+
const objKeys = Object.keys(obj);
|
|
848
|
+
if (objKeys.length !== fields.length)
|
|
849
|
+
return null;
|
|
850
|
+
for (const field of fields) {
|
|
851
|
+
if (!Object.prototype.hasOwnProperty.call(obj, field))
|
|
852
|
+
return null;
|
|
853
|
+
if (!isPrimitive(obj[field]))
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
rows.push(obj);
|
|
857
|
+
}
|
|
858
|
+
return { fields, rows };
|
|
859
|
+
}
|
|
860
|
+
function validateKeySafety(key, limits = DEFAULT_LIMITS, lineNo) {
|
|
861
|
+
if (limits.disallowedKeys.includes(key)) {
|
|
862
|
+
throw new ToonError(`Disallowed key "${key}" to prevent prototype pollution.`, lineNo);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
function createSafeObject() {
|
|
866
|
+
return Object.create(null);
|
|
867
|
+
}
|
|
868
|
+
//# sourceMappingURL=index.js.map
|