@limaformat/lima 0.3.1 → 0.4.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/core.js CHANGED
@@ -18,11 +18,11 @@
18
18
  * `flow.ts` (`[...]`/`{...}`), and `block.ts` (indentation-based sequences
19
19
  * and mappings) — this file is the orchestrator that ties them together.
20
20
  */
21
- import { LString } from './value.js';
22
- import { checkKeyLength, checkScalarLimit, byteLength, DOCUMENT_SIZE_LIMIT, TOP_LEVEL_KEY_LIMIT, NESTING_DEPTH_LIMIT, } from './normalize.js';
23
- import { unescapeDQ, stripComment, positionedBuilder, NO_SPAN_VALUE, parseSimpleScalarSpan, } from './scalars.js';
21
+ import { checkKeyLength, byteLength, DOCUMENT_SIZE_LIMIT, TOP_LEVEL_KEY_LIMIT, NESTING_DEPTH_LIMIT, } from './normalize.js';
22
+ import { unescapeDQ, stripComment, stripCommentKeepEscapes, positionedBuilder, NO_SPAN_VALUE, parseSimpleScalarSpan, } from './scalars.js';
24
23
  import { parseFlowOrScalarValue } from './flow.js';
25
24
  import { parseBlockRange } from './block.js';
25
+ import { buildBlockScalar } from './block-scalar.js';
26
26
  import { LimaError } from './errors.js';
27
27
  import { KeyCursor, scanKeys } from './scanner.js';
28
28
  export { NESTING_DEPTH_LIMIT, SCALAR_LENGTH_LIMIT } from './normalize.js';
@@ -68,19 +68,6 @@ const depthOfNative = (v) => {
68
68
  return 0;
69
69
  };
70
70
  const SPACE_BEFORE_COLON_RE = /^(?:'[^']*'|"(?:[^"\\]|\\.)*")[ \t]+:/;
71
- const leadingSpaces = (line) => {
72
- let i = 0;
73
- while (i < line.length && line.charCodeAt(i) === 32)
74
- i++;
75
- return i;
76
- };
77
- /** Index right after the last non-space character — the `trimEnd()` boundary, without allocating. */
78
- const trailingSpaceEnd = (line) => {
79
- let i = line.length;
80
- while (i > 0 && line.charCodeAt(i - 1) === 32)
81
- i--;
82
- return i;
83
- };
84
71
  const lineAt = (s, pos) => {
85
72
  let n = 1;
86
73
  for (let i = 0; i < pos; i++)
@@ -136,7 +123,14 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
136
123
  if (frontMatter.includes('\r'))
137
124
  frontMatter = frontMatter.replace(/\r\n|\r/g, '\n');
138
125
  if (frontMatter.includes('\t')) {
139
- frontMatter = frontMatter.replace(/^([ \t]*)/gm, (leading) => (leading.includes('\t') ? leading.replace(/\t/g, ' ') : leading));
126
+ // Record how many columns each line's leading whitespace grew, so a
127
+ // References 2.0 token can be reported at its *original* column (§2.4).
128
+ ctx.tabAdjust = [];
129
+ frontMatter = frontMatter.replace(/^([ \t]*)/gm, (leading) => {
130
+ const expanded = leading.includes('\t') ? leading.replace(/\t/g, ' ') : leading;
131
+ ctx.tabAdjust.push(expanded.length - leading.length);
132
+ return expanded;
133
+ });
140
134
  }
141
135
  if (frontMatter.includes(' \n') || frontMatter.endsWith(' ')) {
142
136
  // Capture-and-reinsert instead of a lookahead: `( +)(\n|$)` replaced
@@ -163,7 +157,7 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
163
157
  }
164
158
  }
165
159
  const parseEntry = (key, line, rawStart, isBlock, inlineEnd, nextStart) => {
166
- checkKeyLength(key, () => line);
160
+ checkKeyLength(key, line);
167
161
  if ((strict || ctx.onWarning !== undefined) && builder.hasMappingKey(root, key)) {
168
162
  const diagnostic = {
169
163
  code: 'DUPLICATE_KEY', line, key,
@@ -178,6 +172,14 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
178
172
  }
179
173
  const firstNewline = inlineEnd ?? frontMatter.indexOf('\n', rawStart);
180
174
  const newlineInRange = firstNewline < nextStart;
175
+ // Physical codepoint column of the value's first character in its
176
+ // source line — the anchor for References 2.0 token positions (§2.4).
177
+ // References 2.0 token positions (§2.4): only build the source anchor
178
+ // for the annotated builder; `parseCore` (native) discards it.
179
+ const wantSource = builder === positionedBuilder;
180
+ const valueLineStart = wantSource ? frontMatter.lastIndexOf('\n', rawStart - 1) + 1 : 0;
181
+ const valueCol = wantSource ? [...frontMatter.slice(valueLineStart, rawStart)].length : 0;
182
+ const lineTabAdjust = wantSource && ctx.tabAdjust ? [ctx.tabAdjust[line - 1] ?? 0] : undefined;
181
183
  // Typical frontmatter uses one inline scalar per key. Avoid building
182
184
  // and filling a temporary lines array for that overwhelmingly common
183
185
  // case; the scanner-delimited raw slice contains at most its terminal
@@ -190,8 +192,15 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
190
192
  return;
191
193
  }
192
194
  const val = frontMatter.slice(rawStart, valueEnd);
193
- const uncommented = val.includes('#') ? stripComment(val) : val;
194
- builder.setMapping(root, key, parseFlowOrScalarValue(uncommented, ctx, line, builder));
195
+ const hasHash = val.includes('#');
196
+ const uncommented = hasHash ? stripComment(val) : val;
197
+ // The physical anchor keeps `\#` but drops a real comment, so a
198
+ // `${…}`-shaped token inside a trailing comment is not scanned (§2.4).
199
+ const source = wantSource ? {
200
+ raw: hasHash ? stripCommentKeepEscapes(val) : val,
201
+ line, col: valueCol, tabAdjust: lineTabAdjust,
202
+ } : undefined;
203
+ builder.setMapping(root, key, parseFlowOrScalarValue(uncommented, ctx, line, builder, source));
195
204
  return;
196
205
  }
197
206
  if (isBlock) {
@@ -217,77 +226,22 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
217
226
  const line0Trimmed = lines[0].trim();
218
227
  if (lines.length === 1 || line0Trimmed !== '|') {
219
228
  const line0 = lines[0];
220
- const val = line0.includes('#') ? stripComment(line0) : line0;
221
- builder.setMapping(root, key, parseFlowOrScalarValue(val, ctx, line, builder));
229
+ const hasHash = line0.includes('#');
230
+ const val = hasHash ? stripComment(line0) : line0;
231
+ const source = wantSource ? {
232
+ raw: hasHash ? stripCommentKeepEscapes(line0) : line0,
233
+ line, col: valueCol, tabAdjust: lineTabAdjust,
234
+ } : undefined;
235
+ builder.setMapping(root, key, parseFlowOrScalarValue(val, ctx, line, builder, source));
222
236
  return;
223
237
  }
224
- // Multi-line string (`|` literal block scalar).
238
+ // Multi-line string (`|` literal block scalar). The introducing key
239
+ // is at column 0 here; `buildBlockScalar` finds the scalar's own
240
+ // extent within the range (which runs to the next top-level key),
241
+ // so a dedented comment or dedented freetext before that key ends
242
+ // the scalar rather than being absorbed into it.
225
243
  const bodyLines = raw.slice(raw.indexOf('\n') + 1).split('\n');
226
- // Avoids trim()/trimStart()/trimEnd() — each allocates a whole new
227
- // string just to measure or strip whitespace. leadingSpaces/
228
- // trailingSpaceEnd compute the same boundaries by scanning char
229
- // codes, and dedent+continuation-strip+trailing-trim collapse
230
- // into a single final slice() per line instead of up to three.
231
- let minIndent = Infinity;
232
- for (const bodyLine of bodyLines) {
233
- const indent = leadingSpaces(bodyLine);
234
- if (indent === bodyLine.length)
235
- continue; // blank (all spaces, or empty)
236
- if (indent < minIndent)
237
- minIndent = indent;
238
- }
239
- minIndent = Math.min(minIndent, key.length + 2);
240
- const trimAmt = minIndent > 1 && isFinite(minIndent) ? minIndent : 0;
241
- const mergedLines = [];
242
- const mergedLineSpans = builder.tracksStringSourcePositions ? [] : undefined;
243
- for (let bodyIndex = 0; bodyIndex < bodyLines.length; bodyIndex++) {
244
- const bodyLine = bodyLines[bodyIndex];
245
- const lineLen = bodyLine.length;
246
- let start = trimAmt < lineLen ? trimAmt : lineLen;
247
- const isContinuation = bodyLine.charCodeAt(start) === 94 && bodyLine.charCodeAt(start + 1) === 94; // ^^
248
- if (isContinuation)
249
- start += 2;
250
- let end = trailingSpaceEnd(bodyLine);
251
- if (end < start)
252
- end = start;
253
- const content = bodyLine.slice(start, end);
254
- if (isContinuation) {
255
- if (mergedLines.length > 0) {
256
- if (content) {
257
- const outputStart = mergedLines[mergedLines.length - 1].length + 1;
258
- mergedLines[mergedLines.length - 1] += ' ' + content;
259
- mergedLineSpans?.[mergedLineSpans.length - 1].push({
260
- start: outputStart, line: line + 1 + bodyIndex, sourceOffset: start,
261
- });
262
- }
263
- }
264
- else {
265
- mergedLines.push(content);
266
- mergedLineSpans?.push(content ? [{ start: 0, line: line + 1 + bodyIndex, sourceOffset: start }] : []);
267
- }
268
- }
269
- else {
270
- mergedLines.push(content);
271
- mergedLineSpans?.push(content ? [{ start: 0, line: line + 1 + bodyIndex, sourceOffset: start }] : []);
272
- }
273
- }
274
- while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1] === '') {
275
- mergedLines.pop();
276
- mergedLineSpans?.pop();
277
- }
278
- const joined = mergedLines.join('\n');
279
- let sourceSpans;
280
- if (mergedLineSpans) {
281
- sourceSpans = [];
282
- let outputStart = 0;
283
- for (let i = 0; i < mergedLines.length; i++) {
284
- for (const span of mergedLineSpans[i])
285
- sourceSpans.push({ ...span, start: outputStart + span.start });
286
- outputStart += mergedLines[i].length + 1;
287
- }
288
- }
289
- checkScalarLimit(LString(joined), line);
290
- builder.setMapping(root, key, builder.string(joined, line + 1, false, sourceSpans));
244
+ builder.setMapping(root, key, buildBlockScalar(bodyLines, 0, line, ctx, builder).value);
291
245
  }
292
246
  };
293
247
  // A recognised top-level entry needs at least one UTF-16 unit of key text,
@@ -336,7 +290,7 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
336
290
  for (let i = 0; i < keyCount; i++) {
337
291
  const m = matches[i];
338
292
  const rawDQ = m.doubleQuotedRaw;
339
- const key = m.unquoted ?? m.singleQuoted ?? (rawDQ !== undefined ? unescapeDQ(rawDQ) : undefined);
293
+ const key = m.unquoted ?? m.singleQuoted ?? (rawDQ !== undefined ? unescapeDQ(rawDQ, strict, m.line) : undefined);
340
294
  if (key !== undefined)
341
295
  parseEntry(key, m.line, m.rawStart, m.isBlock, m.inlineEnd, i + 1 < keyCount ? matches[i + 1].matchStart : frontMatter.length);
342
296
  }
package/dist/flow.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /** Core §15.8 flow collections: `[...]` sequences and `{...}` mappings. */
2
2
  import { type ParseContext } from './normalize.js';
3
3
  import type { ValueBuilder } from './builder.js';
4
- export declare const parseFlowSequence: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>) => V[] | null;
5
- export declare const parseFlowMapping: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>) => V | null;
4
+ import type { ReferenceSource } from './reference-tokens2.js';
5
+ export declare const parseFlowSequence: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V[] | null;
6
+ export declare const parseFlowMapping: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V | null;
6
7
  /** Parses a value that may be a flow collection, without probing both flow parsers for ordinary scalars. */
7
- export declare const parseFlowOrScalarValue: <V, M>(raw: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>) => V;
8
+ export declare const parseFlowOrScalarValue: <V, M>(raw: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V;
package/dist/flow.js CHANGED
@@ -1,8 +1,65 @@
1
1
  /** Core §15.8 flow collections: `[...]` sequences and `{...}` mappings. */
2
2
  import { checkKeyLength, checkDuplicateKey } from './normalize.js';
3
- import { parseQuotedOrTyped, parseScalarValue, stripKeyQuotes } from './scalars.js';
3
+ import { parseQuotedOrTyped, parseScalarValue, stripKeyQuotes, closingQuoteIndex, isValidKey } from './scalars.js';
4
4
  import { LimaError } from './errors.js';
5
5
  import { isTrimWhitespace } from './chars.js';
6
+ /**
7
+ * Maps a UTF-16 offset in the decoded container `val` to the corresponding
8
+ * offset in the physical container `raw`. The two differ only by `\#` → `#`
9
+ * collapses outside quoted strings (FR-5 keeps `\#` inside quotes), so every
10
+ * `#` in `val` outside a quote is one such collapse — `raw` has `\#` there.
11
+ * Brackets, commas, quotes and quoted content are identical in both.
12
+ */
13
+ const rawOffsetOf = (raw, val, valOffset) => {
14
+ let ri = 0;
15
+ let vi = 0;
16
+ let quote = 0;
17
+ while (vi < valOffset) {
18
+ const vc = val.charCodeAt(vi);
19
+ if (quote) {
20
+ if (vc === 92) {
21
+ vi += 2;
22
+ ri += 2;
23
+ continue;
24
+ } // escaped pair, identical in both
25
+ if (vc === quote)
26
+ quote = 0;
27
+ }
28
+ else if (vc === 34 || vc === 39) {
29
+ quote = vc;
30
+ }
31
+ else if (vc === 35) {
32
+ vi += 1;
33
+ ri += 2;
34
+ continue; // '#' in the decoded container ⇐ '\#' in raw
35
+ }
36
+ vi += 1;
37
+ ri += 1;
38
+ }
39
+ return ri;
40
+ };
41
+ /**
42
+ * The `ReferenceSource` for a flow element spanning UTF-16 `[start, end)` in
43
+ * the decoded container `val`. The element's physical column is the
44
+ * container's column plus the *codepoint* distance to the element's start in
45
+ * the physical text, and its `raw` is the physical element slice — so a
46
+ * token's reported position (References §2.4) and §5 left-to-right ordering
47
+ * are correct even when an earlier `\#` widened the source. `source.raw` is
48
+ * the physical container (equal to `val` when the value carried no `\#`).
49
+ */
50
+ const elementSource = (source, val, start, end) => {
51
+ if (source === undefined)
52
+ return undefined;
53
+ const raw = source.raw ?? val;
54
+ const rs = rawOffsetOf(raw, val, start);
55
+ const re = rawOffsetOf(raw, val, end);
56
+ return {
57
+ line: source.line,
58
+ col: source.col + [...raw.slice(0, rs)].length,
59
+ raw: raw.slice(rs, re),
60
+ tabAdjust: source.tabAdjust,
61
+ };
62
+ };
6
63
  const trimStart = (source, start, end) => {
7
64
  while (start < end && isTrimWhitespace(source.charCodeAt(start)))
8
65
  start++;
@@ -61,7 +118,7 @@ class FlowCursor {
61
118
  }
62
119
  const isNestedFlowConstruct = (item) => (item.charCodeAt(0) === 91 && item.charCodeAt(item.length - 1) === 93) ||
63
120
  (item.charCodeAt(0) === 123 && item.charCodeAt(item.length - 1) === 125);
64
- export const parseFlowSequence = (val, ctx, line, builder) => {
121
+ export const parseFlowSequence = (val, ctx, line, builder, source) => {
65
122
  if (val.charCodeAt(0) !== 91 || val.charCodeAt(val.length - 1) !== 93)
66
123
  return null;
67
124
  const innerStart = trimStart(val, 1, val.length - 1);
@@ -90,17 +147,34 @@ export const parseFlowSequence = (val, ctx, line, builder) => {
90
147
  });
91
148
  }
92
149
  if (item.charCodeAt(0) === 123 && item.charCodeAt(item.length - 1) === 125) {
93
- const nested = parseFlowMapping(item, ctx, line, builder);
150
+ const nested = parseFlowMapping(item, ctx, line, builder, elementSource(source, val, start, end));
94
151
  if (nested !== null) {
95
152
  items.push(nested);
96
153
  continue;
97
154
  }
98
155
  }
99
- items.push(parseQuotedOrTyped(item, ctx, line, false, builder));
156
+ items.push(parseQuotedOrTyped(item, ctx, line, builder, elementSource(source, val, start, end)));
100
157
  }
101
158
  return items;
102
159
  };
103
- export const parseFlowMapping = (val, ctx, line, builder) => {
160
+ /**
161
+ * Index of the key/value `: ` separator within a flow-mapping item — the
162
+ * first `: ` that is not inside a quoted key (§5.1). Returns -1 when the
163
+ * item has no separator.
164
+ */
165
+ const flowItemSeparator = (val, start, end) => {
166
+ let scan = start;
167
+ if (val.charCodeAt(start) === 34 || val.charCodeAt(start) === 39) {
168
+ // §5.2: a single-quoted key is literal, so its first `'` closes.
169
+ const close = closingQuoteIndex(val.slice(start, end), false);
170
+ if (close === -1)
171
+ return -1;
172
+ scan = start + close + 1;
173
+ }
174
+ const sep = val.indexOf(': ', scan);
175
+ return sep === -1 || sep >= end ? -1 : sep;
176
+ };
177
+ export const parseFlowMapping = (val, ctx, line, builder, source) => {
104
178
  if (val.charCodeAt(0) !== 123 || val.charCodeAt(val.length - 1) !== 125)
105
179
  return null;
106
180
  const innerStart = trimStart(val, 1, val.length - 1);
@@ -117,8 +191,8 @@ export const parseFlowMapping = (val, ctx, line, builder) => {
117
191
  throw new LimaError({ code: 'INVALID_FLOW_SYNTAX', line, message: `Lima: empty element in flow mapping at line ${line}` });
118
192
  continue;
119
193
  }
120
- const colonPos = val.indexOf(': ', itemStart);
121
- if (colonPos === -1 || colonPos >= itemEnd) {
194
+ const colonPos = flowItemSeparator(val, itemStart, itemEnd);
195
+ if (colonPos === -1) {
122
196
  if (ctx.strict)
123
197
  throw new LimaError({
124
198
  code: 'INVALID_FLOW_SYNTAX', line,
@@ -128,8 +202,15 @@ export const parseFlowMapping = (val, ctx, line, builder) => {
128
202
  }
129
203
  const keyStart = trimStart(val, itemStart, colonPos);
130
204
  const keyEnd = trimEnd(val, keyStart, colonPos);
131
- const key = stripKeyQuotes(val.slice(keyStart, keyEnd));
132
- checkKeyLength(key, () => line);
205
+ const keyRaw = val.slice(keyStart, keyEnd);
206
+ if (!isValidKey(keyRaw)) {
207
+ // §5.1: an unquoted key with a space/dot, or a malformed quoted
208
+ // key, is not a key. Skip the item in both modes (§10's strict
209
+ // list is closed and does not cover this — same as the top level).
210
+ continue;
211
+ }
212
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
213
+ checkKeyLength(key, line);
133
214
  if (ctx.strict || ctx.onWarning !== undefined) {
134
215
  checkDuplicateKey(builder.hasMappingKey(entries, key), key, line, ctx);
135
216
  }
@@ -139,24 +220,24 @@ export const parseFlowMapping = (val, ctx, line, builder) => {
139
220
  if (isNestedFlowConstruct(rawVal)) {
140
221
  throw new LimaError({ code: 'INVALID_FLOW_SYNTAX', line, message: `Lima: invalid flow nesting at line ${line}: "${rawVal}"` });
141
222
  }
142
- builder.setMapping(entries, key, parseQuotedOrTyped(rawVal, ctx, line, false, builder));
223
+ builder.setMapping(entries, key, parseQuotedOrTyped(rawVal, ctx, line, builder, elementSource(source, val, valueStart, valueEnd)));
143
224
  }
144
225
  return builder.mapping(entries, line);
145
226
  };
146
227
  /** Parses a value that may be a flow collection, without probing both flow parsers for ordinary scalars. */
147
- export const parseFlowOrScalarValue = (raw, ctx, line, builder) => {
228
+ export const parseFlowOrScalarValue = (raw, ctx, line, builder, source) => {
148
229
  const first = raw.charCodeAt(0);
149
230
  if (first === 91) {
150
- const sequence = parseFlowSequence(raw, ctx, line, builder);
231
+ const sequence = parseFlowSequence(raw, ctx, line, builder, source);
151
232
  if (sequence !== null)
152
233
  return builder.array(sequence, line);
153
- return parseScalarValue(raw, ctx, line, builder);
234
+ return parseScalarValue(raw, ctx, line, builder, source);
154
235
  }
155
236
  else if (first === 123) {
156
- const mapping = parseFlowMapping(raw, ctx, line, builder);
237
+ const mapping = parseFlowMapping(raw, ctx, line, builder, source);
157
238
  if (mapping !== null)
158
239
  return mapping;
159
- return parseScalarValue(raw, ctx, line, builder);
240
+ return parseScalarValue(raw, ctx, line, builder, source);
160
241
  }
161
- return parseQuotedOrTyped(raw, ctx, line, true, builder);
242
+ return parseQuotedOrTyped(raw, ctx, line, builder, source);
162
243
  };
@@ -20,6 +20,13 @@ export type Diagnostic = {
20
20
  export type ParseContext = {
21
21
  strict: boolean;
22
22
  onWarning?: (diagnostic: Diagnostic) => void;
23
+ /**
24
+ * Per-line (0-based) count of columns added by Core §3 leading-tab
25
+ * expansion — subtracted from a References 2.0 token's column so it
26
+ * reports its position in the *original* source (§2.4), not the
27
+ * tab-expanded one. Absent when the document had no tabs.
28
+ */
29
+ tabAdjust?: number[];
23
30
  };
24
31
  export declare const DOCUMENT_SIZE_LIMIT = 65536;
25
32
  export declare const KEY_LENGTH_LIMIT = 128;
@@ -28,14 +35,6 @@ export declare const NESTING_DEPTH_LIMIT = 16;
28
35
  export declare const byteLength: (s: string) => number;
29
36
  export declare const checkStringLimit: (value: string, line: number) => void;
30
37
  export declare const checkScalarLimit: (v: LimaValue, line: number) => void;
31
- /**
32
- * `line` is a thunk, not a plain number: computing a top-level key's line
33
- * can trigger an O(document length) scan (see `keyLine` in core.ts) the very
34
- * first time it's called, and this check runs for every key in the
35
- * document. Evaluating it eagerly would pay that cost on every parse, even
36
- * though the overwhelming majority of keys never violate the limit — the
37
- * thunk defers it to the one branch that actually needs a line number.
38
- */
39
- export declare const checkKeyLength: (key: string, line: () => number) => void;
38
+ export declare const checkKeyLength: (key: string, line: number) => void;
40
39
  export declare const checkDuplicateKey: (exists: boolean, key: string, line: number, ctx: ParseContext) => void;
41
40
  export declare const checkDuplicateKeyMap: (entries: Map<string, unknown>, key: string, line: number, ctx: ParseContext) => void;
package/dist/normalize.js CHANGED
@@ -28,20 +28,11 @@ export const checkScalarLimit = (v, line) => {
28
28
  if (v.kind === 'string')
29
29
  checkStringLimit(v.value, line);
30
30
  };
31
- /**
32
- * `line` is a thunk, not a plain number: computing a top-level key's line
33
- * can trigger an O(document length) scan (see `keyLine` in core.ts) the very
34
- * first time it's called, and this check runs for every key in the
35
- * document. Evaluating it eagerly would pay that cost on every parse, even
36
- * though the overwhelming majority of keys never violate the limit — the
37
- * thunk defers it to the one branch that actually needs a line number.
38
- */
39
31
  export const checkKeyLength = (key, line) => {
40
32
  if (key.length > KEY_LENGTH_LIMIT && codepointLength(key) > KEY_LENGTH_LIMIT) {
41
- const l = line();
42
33
  throw new LimaError({
43
- code: 'RESOURCE_LIMIT', line: l,
44
- message: `Lima: key "${key}" exceeds maximum length of ${KEY_LENGTH_LIMIT} code points at line ${l}`,
34
+ code: 'RESOURCE_LIMIT', line,
35
+ message: `Lima: key "${key}" exceeds maximum length of ${KEY_LENGTH_LIMIT} code points at line ${line}`,
45
36
  });
46
37
  }
47
38
  };
@@ -0,0 +1,73 @@
1
+ /** Position-annotated tree helpers shared by frozen References 1.0 and active References 2.0; contains no reference grammar or resolution logic. */
2
+ import { type InsertedAt, type NativeValue, type PositionedValue } from './core.js';
3
+ import { type LimaValue } from './value.js';
4
+ type Meta = Record<string, unknown>;
5
+ /** Every Lima mapping result must be a prototype-free object. */
6
+ export declare const emptyMapping: () => Meta;
7
+ /**
8
+ * Structural deep copy for document-derived targets — preserves each leaf's
9
+ * own line/quoted (§4.2/R-104: still eligible for further resolution) and
10
+ * any existing `insertedAt` provenance from an earlier, nested resolution
11
+ * (R-112: a reference copied wholesale into a new position can itself
12
+ * already contain the result of an inner reference that resolved first —
13
+ * both insertion sites remain identifiable participants after the copy).
14
+ */
15
+ export declare const deepCopyPositioned: (v: PositionedValue) => PositionedValue;
16
+ /**
17
+ * Wraps a partial's ingested value into the annotated representation, with
18
+ * every string leaf marked permanently inert (§3.8: "no traversal into
19
+ * partial values" — the resolution phases must never rediscover a
20
+ * reference-like substring inside partial content). Called once per partial
21
+ * name at ingestion (see the call site), producing the one canonical tree
22
+ * every pure reference to that partial retrieves — deep-copying it on every
23
+ * such reference (§3.1) is the resolveTree call site's job, not this one.
24
+ */
25
+ export declare const partialToPositioned: (v: LimaValue, line: number) => PositionedValue;
26
+ /**
27
+ * References §5/R-112 (nesting depth), §6.2 (node count), and the public
28
+ * result shape (native conversion) computed together in one recursive pass
29
+ * over the final, post-substitution tree — instead of three independent
30
+ * full-tree walks (a depth-only pass, a count-only pass, then
31
+ * `toNativeFromPositioned`), each of which would otherwise revisit every
32
+ * node of what can be a large, reference-expanded result. Depth attribution
33
+ * needs more than a depth number: it needs to know which reference
34
+ * insertions lie on the actual deepest path, so the earliest of those (by
35
+ * line) can be blamed if the limit is exceeded — ties for "deepest child"
36
+ * all count, since a violation can be reached via more than one
37
+ * maximal-depth branch. `native` is still built unconditionally even
38
+ * though a depth/node-count violation elsewhere in the document means it
39
+ * will be discarded and never returned — cheaper than adding a second
40
+ * conditional branch to every call site, and the discarded case is the
41
+ * rare (error) path, not the one this pass exists to speed up.
42
+ *
43
+ * Each array/mapping branch tracks a running max depth and its participant
44
+ * list in a single loop over its children, instead of first materializing a
45
+ * full array of child results and then doing separate `.map()`/`Math.max`/
46
+ * `.filter().flatMap()` passes over it — for a wide sibling group (many
47
+ * children at the same depth, the common shape for flat documents with many
48
+ * top-level references) that would mean an extra full pass per level on top
49
+ * of the traversal itself. A running accumulator only ever copies-on-write:
50
+ * `.slice()` when a strictly greater depth replaces the running set, plain
51
+ * `.push()` onto that fresh copy for a tie at the current max — never a
52
+ * mutation of a child's own returned array. Reusing a child's array in
53
+ * place would currently be safe (each node is visited exactly once, no
54
+ * aliasing left after the pure-reference deep-copy fix), but relying on
55
+ * that invariant here has no upside worth the risk in a file that has
56
+ * already had one real aliasing bug.
57
+ */
58
+ export type FinalizedValue = {
59
+ native: NativeValue;
60
+ nodeCount: number;
61
+ depth: number;
62
+ deepestParticipants: InsertedAt[];
63
+ };
64
+ export declare const finalizePositioned: (v: PositionedValue) => FinalizedValue;
65
+ /**
66
+ * Earliest participant by source position — lowest line, then lowest
67
+ * character offset (References §5) — or null when none exist (R-113's
68
+ * "line 1" fallback applies then).
69
+ */
70
+ export declare const earliestParticipant: (participants: InsertedAt[]) => InsertedAt | null;
71
+ /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
72
+ export declare const collectAllParticipants: (v: PositionedValue, acc: InsertedAt[]) => void;
73
+ export {};
@@ -0,0 +1,123 @@
1
+ /** Position-annotated tree helpers shared by frozen References 1.0 and active References 2.0; contains no reference grammar or resolution logic. */
2
+ import { toNativeFromPositioned, } from './core.js';
3
+ /** Every Lima mapping result must be a prototype-free object. */
4
+ export const emptyMapping = () => Object.create(null);
5
+ /**
6
+ * Structural deep copy for document-derived targets — preserves each leaf's
7
+ * own line/quoted (§4.2/R-104: still eligible for further resolution) and
8
+ * any existing `insertedAt` provenance from an earlier, nested resolution
9
+ * (R-112: a reference copied wholesale into a new position can itself
10
+ * already contain the result of an inner reference that resolved first —
11
+ * both insertion sites remain identifiable participants after the copy).
12
+ */
13
+ export const deepCopyPositioned = (v) => {
14
+ switch (v.kind) {
15
+ case 'array': return { kind: 'array', items: v.items.map(deepCopyPositioned), line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt, priorInsertions: v.priorInsertions };
16
+ case 'mapping': {
17
+ const entries = new Map();
18
+ for (const [k, c] of v.entries)
19
+ entries.set(k, deepCopyPositioned(c));
20
+ return { kind: 'mapping', entries, line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt, priorInsertions: v.priorInsertions };
21
+ }
22
+ case 'instant': return { kind: 'instant', value: new Date(v.value.getTime()), line: v.line, insertedAt: v.insertedAt, priorInsertions: v.priorInsertions };
23
+ default: return v;
24
+ }
25
+ };
26
+ /**
27
+ * Wraps a partial's ingested value into the annotated representation, with
28
+ * every string leaf marked permanently inert (§3.8: "no traversal into
29
+ * partial values" — the resolution phases must never rediscover a
30
+ * reference-like substring inside partial content). Called once per partial
31
+ * name at ingestion (see the call site), producing the one canonical tree
32
+ * every pure reference to that partial retrieves — deep-copying it on every
33
+ * such reference (§3.1) is the resolveTree call site's job, not this one.
34
+ */
35
+ export const partialToPositioned = (v, line) => {
36
+ switch (v.kind) {
37
+ case 'null': return { kind: 'null', line };
38
+ case 'bool': return { kind: 'bool', value: v.value, line };
39
+ case 'int': return { kind: 'int', value: v.value, line };
40
+ case 'float': return { kind: 'float', value: v.value, line };
41
+ case 'string': return { kind: 'string', value: v.value, line, quoted: true };
42
+ case 'instant': return { kind: 'instant', value: v.value, line };
43
+ case 'array': return { kind: 'array', items: v.items.map((i) => partialToPositioned(i, line)), line };
44
+ case 'mapping': {
45
+ const entries = new Map();
46
+ for (const [k, c] of v.entries)
47
+ entries.set(k, partialToPositioned(c, line));
48
+ return { kind: 'mapping', entries, line };
49
+ }
50
+ }
51
+ };
52
+ export const finalizePositioned = (v) => {
53
+ const own = v.insertedAt ? [...(v.priorInsertions ?? []), v.insertedAt] : (v.priorInsertions ?? []);
54
+ if (v.kind === 'array') {
55
+ if (v.items.length === 0)
56
+ return { native: [], nodeCount: 1, depth: 1, deepestParticipants: own };
57
+ const native = new Array(v.items.length);
58
+ let nodeCount = 1;
59
+ let maxDepth = -1;
60
+ let childParticipants = [];
61
+ for (let i = 0; i < v.items.length; i++) {
62
+ const r = finalizePositioned(v.items[i]);
63
+ native[i] = r.native;
64
+ nodeCount += r.nodeCount;
65
+ if (r.depth > maxDepth) {
66
+ maxDepth = r.depth;
67
+ childParticipants = r.deepestParticipants.slice();
68
+ }
69
+ else if (r.depth === maxDepth) {
70
+ for (const p of r.deepestParticipants)
71
+ childParticipants.push(p);
72
+ }
73
+ }
74
+ return { native, nodeCount, depth: 1 + maxDepth, deepestParticipants: own.length ? own.concat(childParticipants) : childParticipants };
75
+ }
76
+ if (v.kind === 'mapping') {
77
+ if (v.entries.size === 0)
78
+ return { native: emptyMapping(), nodeCount: 1, depth: 1, deepestParticipants: own };
79
+ const native = emptyMapping();
80
+ let nodeCount = 1;
81
+ let maxDepth = -1;
82
+ let childParticipants = [];
83
+ for (const [k, c] of v.entries) {
84
+ const r = finalizePositioned(c);
85
+ native[k] = r.native;
86
+ nodeCount += r.nodeCount;
87
+ if (r.depth > maxDepth) {
88
+ maxDepth = r.depth;
89
+ childParticipants = r.deepestParticipants.slice();
90
+ }
91
+ else if (r.depth === maxDepth) {
92
+ for (const p of r.deepestParticipants)
93
+ childParticipants.push(p);
94
+ }
95
+ }
96
+ return { native: native, nodeCount, depth: 1 + maxDepth, deepestParticipants: own.length ? own.concat(childParticipants) : childParticipants };
97
+ }
98
+ // Scalar leaf: null/bool/int/float/string/instant. No further recursion,
99
+ // so plain toNativeFromPositioned is exactly the single-node conversion
100
+ // needed here — reused rather than duplicated.
101
+ return { native: toNativeFromPositioned(v), nodeCount: 1, depth: 0, deepestParticipants: own };
102
+ };
103
+ /**
104
+ * Earliest participant by source position — lowest line, then lowest
105
+ * character offset (References §5) — or null when none exist (R-113's
106
+ * "line 1" fallback applies then).
107
+ */
108
+ export const earliestParticipant = (participants) => participants.length === 0
109
+ ? null
110
+ : participants.reduce((a, b) => b.line < a.line || (b.line === a.line && (b.offset ?? 0) < (a.offset ?? 0)) ? b : a);
111
+ /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
112
+ export const collectAllParticipants = (v, acc) => {
113
+ if (v.priorInsertions)
114
+ acc.push(...v.priorInsertions);
115
+ if (v.insertedAt)
116
+ acc.push(v.insertedAt);
117
+ if (v.kind === 'array')
118
+ for (const item of v.items)
119
+ collectAllParticipants(item, acc);
120
+ if (v.kind === 'mapping')
121
+ for (const c of v.entries.values())
122
+ collectAllParticipants(c, acc);
123
+ };