@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/CHANGELOG.md CHANGED
@@ -12,6 +12,76 @@ are both frozen regardless of what this file shows.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.4.0] — 2026-09-08
16
+
17
+ First independent whole-repo review of Lima, then a second review of the
18
+ consolidated result. Nine rounds of Core 1.0 errata (`1.0.1`–`1.0.9`) and
19
+ a cross-implementation rework of References 2.0 error positions, all
20
+ fixing behaviour the specifications already required. The npm package
21
+ version advances to `0.4.0` to align with the Rust crate and Go module.
22
+ The Lima Core 1.0 and References 2.0 specification texts gain only
23
+ non-normative clarifications (§5, §5.2, §6.1.3, error-api); no rule
24
+ changed.
25
+
26
+ The shared conformance corpus grows to 211 Core and 136 References 2.0
27
+ cases (from 149 / 119). The frozen 1.0.0 baseline of 149 Core cases is
28
+ byte-identical; every added case carries a `since` revision marker.
29
+
30
+ ### Changed
31
+
32
+ - An unquoted key must now match the Core §5.1 grammar
33
+ (`[A-Za-z0-9_][A-Za-z0-9_:-]*`) in **every** mapping context — top level,
34
+ nested, and flow. Keys such as `a.b`, `($x)`, `${x}`, or a key with a
35
+ leading non-ASCII space are no longer recognised; the line or item is
36
+ skipped in both strict and non-strict mode. Previously some of these
37
+ were accepted as literal keys in nested or flow mappings. (errata 1.0.5)
38
+ - `parseCore` no longer builds reference-position metadata — that work is
39
+ now confined to the `parse` / positioned path — restoring the Core hot
40
+ path to its pre-errata baseline.
41
+
42
+ ### Fixed
43
+
44
+ - `|` literal block scalars: recognised under a nested key, not only at the
45
+ top level; the scalar now ends at the first line indented to the key's
46
+ column or less, so a dedented comment or freetext is no longer absorbed;
47
+ indentation trimming removes exactly the smallest common indent (no
48
+ `key.length + 2` cap, a one-space indent is handled). (errata 1.0.1)
49
+ - Quoted-string lexing is escape-aware in every value position: `"a\""` and
50
+ `'a\''` are unterminated in flow and block contexts too; `"a: b": v` and
51
+ `"say \"hi\"": v` parse as a single key; an unquoted key with interior
52
+ whitespace makes its line or item unrecognised in nested and flow
53
+ mappings, matching the top level. (errata 1.0.2)
54
+ - A comment line between a bare key and its nested block no longer drops
55
+ the block. (errata 1.0.3)
56
+ - A block-sequence item's first bare key now aligns following sibling keys
57
+ at the key's own column, not the dash column. (errata 1.0.6)
58
+ - A raw U+000A inside a quoted key is rejected (Core §15.6), the `\n`
59
+ escape is unaffected, and — corrected in 1.0.9 — U+2028 / U+2029 are
60
+ ordinary quoted-key characters (§15.6 excludes only U+000A), matching
61
+ the Rust and Go implementations. (errata 1.0.8 / 1.0.9)
62
+ - A block-scalar line whose only content is a non-ASCII space (U+00A0,
63
+ U+2028, …) is no longer treated as blank in the Go implementation; it
64
+ ends the scalar, matching TypeScript and Rust. (errata 1.0.9)
65
+ - `\#` is collapsed to `#` only in unquoted values (Core §6.1.4). Inside
66
+ `"..."` it is now an unknown escape — non-strict keeps the backslash,
67
+ strict throws `INVALID_ESCAPE` — and inside `'...'` a literal backslash.
68
+ (errata 1.0.9)
69
+ - The whitespace between a value and a trailing `#` comment is stripped
70
+ with the full project whitespace set in all three implementations, so a
71
+ U+00A0 or U+FEFF before a comment no longer stays attached to the value
72
+ (Go and Rust previously used narrower sets). U+0085 is not in the set
73
+ and remains literal content. (errata 1.0.9)
74
+ - References 2.0 error positions: `line` and `column` are the physical
75
+ position in the original source text, counted in Unicode code points and
76
+ identical across the TypeScript, Rust, and Go implementations.
77
+ Leading-tab expansion and `\#` escapes no longer shift a later token's
78
+ reported column — including inside a flow `[...]` / `{...}`.
79
+ `LimaError.column` is now populated on reference errors.
80
+ - References 2.0 diagnostic ordering: an unresolved-reference or structural
81
+ error is attributed to the earliest participating source token,
82
+ including across flow-collection elements on one line and across a second
83
+ pure-reference copy.
84
+
15
85
  ## [0.3.1] — 2026-08-12
16
86
 
17
87
  ### Fixed
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Core §6.1.5 literal block scalars (`|`). Shared by the top-level key path
3
+ * (`core.ts`) and the nested block path (`block.ts`): §6.1.5 defines block
4
+ * scalar extent generically ("indentation strictly greater than the
5
+ * indentation of the key that introduced the scalar"), with no top-level
6
+ * restriction, so both call sites must agree byte for byte.
7
+ *
8
+ * Generic over the output representation via `ValueBuilder` (see
9
+ * `builder.ts`), like the rest of the Core grammar.
10
+ */
11
+ import { type ParseContext } from './normalize.js';
12
+ import type { ValueBuilder } from './builder.js';
13
+ /** Leading U+0020 count. "Indentation" in §6.1.5 is spaces, measured from column 0. */
14
+ export declare const leadingSpaces: (line: string) => number;
15
+ /** Index right after the last non-space character — the `trimEnd()` boundary, without allocating. */
16
+ export declare const trailingSpaceEnd: (line: string) => number;
17
+ /**
18
+ * Builds a `|` block scalar value.
19
+ *
20
+ * `bodyLines` are the physical source lines *after* the `|` line, each in
21
+ * its original column-0 form (trailing whitespace is stripped here).
22
+ * `keyIndent` is the indentation of the key that introduced the scalar
23
+ * (0 at the top level). `pipeLine` is the 1-based line number of the `|`
24
+ * line, so `bodyLines[i]` is at `pipeLine + 1 + i`.
25
+ *
26
+ * Returns the joined value plus how many of `bodyLines` the scalar
27
+ * consumed — a caller iterating physical lines resumes past that; a caller
28
+ * that already sliced the range to the scalar can ignore it.
29
+ */
30
+ export declare const buildBlockScalar: <V, M>(bodyLines: string[], keyIndent: number, pipeLine: number, ctx: ParseContext, builder: ValueBuilder<V, M>) => {
31
+ value: V;
32
+ consumed: number;
33
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Core §6.1.5 literal block scalars (`|`). Shared by the top-level key path
3
+ * (`core.ts`) and the nested block path (`block.ts`): §6.1.5 defines block
4
+ * scalar extent generically ("indentation strictly greater than the
5
+ * indentation of the key that introduced the scalar"), with no top-level
6
+ * restriction, so both call sites must agree byte for byte.
7
+ *
8
+ * Generic over the output representation via `ValueBuilder` (see
9
+ * `builder.ts`), like the rest of the Core grammar.
10
+ */
11
+ import { LString } from './value.js';
12
+ import { checkScalarLimit } from './normalize.js';
13
+ /** Leading U+0020 count. "Indentation" in §6.1.5 is spaces, measured from column 0. */
14
+ export const leadingSpaces = (line) => {
15
+ let i = 0;
16
+ while (i < line.length && line.charCodeAt(i) === 32)
17
+ i++;
18
+ return i;
19
+ };
20
+ /** Index right after the last non-space character — the `trimEnd()` boundary, without allocating. */
21
+ export const trailingSpaceEnd = (line) => {
22
+ let i = line.length;
23
+ while (i > 0 && line.charCodeAt(i - 1) === 32)
24
+ i--;
25
+ return i;
26
+ };
27
+ /**
28
+ * Builds a `|` block scalar value.
29
+ *
30
+ * `bodyLines` are the physical source lines *after* the `|` line, each in
31
+ * its original column-0 form (trailing whitespace is stripped here).
32
+ * `keyIndent` is the indentation of the key that introduced the scalar
33
+ * (0 at the top level). `pipeLine` is the 1-based line number of the `|`
34
+ * line, so `bodyLines[i]` is at `pipeLine + 1 + i`.
35
+ *
36
+ * Returns the joined value plus how many of `bodyLines` the scalar
37
+ * consumed — a caller iterating physical lines resumes past that; a caller
38
+ * that already sliced the range to the scalar can ignore it.
39
+ */
40
+ export const buildBlockScalar = (bodyLines, keyIndent, pipeLine, ctx, builder) => {
41
+ // Extent (§6.1.5): a line belongs to the scalar iff its indentation is
42
+ // strictly greater than the introducing key's. Empty lines between
43
+ // content lines belong regardless of indentation; the first non-empty
44
+ // line dedented to the key's column or less ends the scalar, `#` lines
45
+ // included.
46
+ let consumed = bodyLines.length;
47
+ for (let i = 0; i < bodyLines.length; i++) {
48
+ const bl = bodyLines[i];
49
+ const ind = leadingSpaces(bl);
50
+ if (ind === bl.length)
51
+ continue; // empty (blank or whitespace-only)
52
+ if (ind <= keyIndent) {
53
+ consumed = i;
54
+ break;
55
+ }
56
+ }
57
+ const lines = consumed === bodyLines.length ? bodyLines : bodyLines.slice(0, consumed);
58
+ // Content indentation (§6.1.5): the smallest number of leading spaces
59
+ // among all non-empty content lines, measured from column 0, removed
60
+ // uniformly from every line.
61
+ let minIndent = Infinity;
62
+ for (const l of lines) {
63
+ const ind = leadingSpaces(l);
64
+ if (ind === l.length)
65
+ continue; // empty lines do not participate
66
+ if (ind < minIndent)
67
+ minIndent = ind;
68
+ }
69
+ const trimAmt = isFinite(minIndent) ? minIndent : 0;
70
+ // dedent + `^^` continuation strip + trailing-space trim collapse into a
71
+ // single slice() per line. leadingSpaces/trailingSpaceEnd avoid the
72
+ // temporary strings trim()/trimStart()/trimEnd() would allocate.
73
+ const merged = [];
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const l = lines[i];
76
+ const lineLen = l.length;
77
+ let start = trimAmt < lineLen ? trimAmt : lineLen;
78
+ const isContinuation = l.charCodeAt(start) === 94 && l.charCodeAt(start + 1) === 94; // ^^
79
+ if (isContinuation)
80
+ start += 2;
81
+ let end = trailingSpaceEnd(l);
82
+ if (end < start)
83
+ end = start;
84
+ const content = l.slice(start, end);
85
+ if (isContinuation && merged.length > 0) {
86
+ if (content)
87
+ merged[merged.length - 1] += ' ' + content;
88
+ }
89
+ else {
90
+ merged.push(content);
91
+ }
92
+ }
93
+ // Trailing content (§6.1.5): all trailing empty lines are stripped.
94
+ while (merged.length > 0 && merged[merged.length - 1] === '')
95
+ merged.pop();
96
+ const joined = merged.join('\n');
97
+ checkScalarLimit(LString(joined), pipeLine);
98
+ // A token's physical `(line, offset)` is read from the original body
99
+ // lines (column-0 form), not reconstructed from the dedented/`^^`-merged
100
+ // `joined` string — References 2.0 §2.4. `lines` are at physical lines
101
+ // `pipeLine + 1 .. pipeLine + consumed` (0-based indices `pipeLine ..`).
102
+ return {
103
+ value: builder.string(joined, pipeLine + 1, false, {
104
+ raw: lines.join('\n'), line: pipeLine + 1, col: 0,
105
+ tabAdjust: ctx.tabAdjust?.slice(pipeLine, pipeLine + consumed),
106
+ }),
107
+ consumed,
108
+ };
109
+ };
package/dist/block.js CHANGED
@@ -8,22 +8,72 @@
8
8
  * reference-shape check — that error class cannot occur here since Core
9
9
  * never resolves a reference in the first place).
10
10
  */
11
- import { LString } from './value.js';
12
- import { checkKeyLength, checkDuplicateKey, checkScalarLimit, NESTING_DEPTH_LIMIT } from './normalize.js';
13
- import { stripKeyQuotes, unescapeDQ, stripComment, parseQuotedOrTyped, } from './scalars.js';
11
+ import { checkKeyLength, checkDuplicateKey, NESTING_DEPTH_LIMIT } from './normalize.js';
12
+ import { stripKeyQuotes, stripComment, stripCommentKeepEscapes, parseQuotedOrTyped, closingQuoteIndex, isValidKey, positionedBuilder, } from './scalars.js';
14
13
  import { parseFlowMapping, parseFlowOrScalarValue } from './flow.js';
14
+ import { buildBlockScalar } from './block-scalar.js';
15
15
  import { LimaError } from './errors.js';
16
16
  import { isTrimWhitespace } from './chars.js';
17
17
  import { BlockCursor } from './block-cursor.js';
18
18
  const DASH_PREFIX_RE = /^-\s+/;
19
+ /**
20
+ * A key's inline value text. If it is exactly `|`, consume the following
21
+ * physical lines belonging to the Core §6.1.5 block scalar introduced by a
22
+ * key at `keyIndent` and return the scalar; the cursor is left on the first
23
+ * line past the scalar. Otherwise parse `raw` as an ordinary inline value
24
+ * and advance one line. §6.1.5 places no top-level restriction on block
25
+ * scalars, so this is the same primitive `core.ts` uses at the top level.
26
+ */
27
+ /** Length of the `- ` (dash + whitespace) prefix on the cursor's current line. */
28
+ const dashPrefixLen = (cursor) => {
29
+ const start = cursor.contentStart, end = cursor.lineEnd;
30
+ if (start + 1 === end)
31
+ return 1;
32
+ let content = start + 1;
33
+ while (content < end && isTrimWhitespace(cursor.source.charCodeAt(content)))
34
+ content++;
35
+ return content - start;
36
+ };
37
+ /** Codepoints of leading whitespace on `s` — for a value's column within its line. */
38
+ const leadingWsLen = (s) => s.length - s.trimStart().length;
39
+ /** A value's physical `raw` for References 2.0 token positions: a trailing
40
+ * `#` comment removed (its `${…}`-shaped text is not part of the value, so
41
+ * must not be scanned), but `\#` kept as written (§2.4 — a `\#` before a
42
+ * token still occupies two source columns). */
43
+ const physicalRaw = (s) => (s.includes('#') ? stripCommentKeepEscapes(s) : s);
44
+ /** The `ReferenceSource` for an inline value on line `keyLine` at column
45
+ * `valueCol`; `rawUndecoded` is the value text before comment stripping /
46
+ * `\#` collapse (so a `\#` before a token doesn't shift its column). */
47
+ const inlineSource = (wantSource, ctx, keyLine, valueCol, rawUndecoded) => wantSource ? ({
48
+ raw: physicalRaw(rawUndecoded), line: keyLine, col: valueCol,
49
+ tabAdjust: ctx.tabAdjust ? [ctx.tabAdjust[keyLine - 1] ?? 0] : undefined,
50
+ }) : undefined;
51
+ const inlineOrBlockScalar = (raw, keyIndent, keyLine, valueCol, rawUndecoded, cursor, ctx, builder) => {
52
+ const wantSource = builder === positionedBuilder;
53
+ if (raw !== '|') {
54
+ const value = parseFlowOrScalarValue(raw, ctx, keyLine, builder, inlineSource(wantSource, ctx, keyLine, valueCol, rawUndecoded));
55
+ cursor.next();
56
+ return value;
57
+ }
58
+ cursor.next(); // past the `key: |` line
59
+ const bodyLines = [];
60
+ while (cursor.valid) {
61
+ if (!cursor.empty && cursor.asciiIndent <= keyIndent)
62
+ break;
63
+ bodyLines.push(cursor.source.slice(cursor.lineStart, cursor.lineEnd));
64
+ cursor.next();
65
+ }
66
+ return buildBlockScalar(bodyLines, keyIndent, keyLine, ctx, builder).value;
67
+ };
19
68
  export const findKeySep = (s) => {
20
69
  const first = s.charCodeAt(0);
21
70
  if (first === 39 || first === 34) {
22
- let i = 1;
23
- while (i < s.length && s.charCodeAt(i) !== first)
24
- i++;
25
- if (s.charCodeAt(i + 1) === 58 && s.charCodeAt(i + 2) === 32)
26
- return i + 1;
71
+ // §5.1: the `: ` separator must sit outside the quoted key, so scan
72
+ // past its closing quote first. Double-quoted keys honour `\"`; a
73
+ // single-quoted key is literal (§5.2), so its first `'` closes.
74
+ const close = closingQuoteIndex(s, false);
75
+ if (close !== -1 && s.charCodeAt(close + 1) === 58 && s.charCodeAt(close + 2) === 32)
76
+ return close + 1;
27
77
  return -1;
28
78
  }
29
79
  return s.indexOf(': ');
@@ -52,6 +102,19 @@ const trimSlice = (source, start, end) => {
52
102
  return source.slice(start, end);
53
103
  };
54
104
  const cursorContent = (cursor) => cursor.source.slice(cursor.contentStart, cursor.lineEnd);
105
+ /**
106
+ * Core §4 rule 7 / §6.1.3: comment lines (first non-whitespace character
107
+ * `#`) do not affect base indentation and are skipped — including in the
108
+ * lookahead that decides whether a bare key (`key:` with no inline value)
109
+ * has any nested block at all. Advancing past a comment here, not only a
110
+ * blank line, keeps that pre-check consistent with the same skip already
111
+ * applied once inside an established block (`parseCursorBlock`'s own
112
+ * `cursor.empty || cursor.firstCode === 35` check at the top of its loop).
113
+ */
114
+ const skipEmptyAndCommentLines = (cursor) => {
115
+ while (cursor.valid && (cursor.empty || cursor.firstCode === 35))
116
+ cursor.next();
117
+ };
55
118
  const cursorAfterDash = (cursor) => {
56
119
  const start = cursor.contentStart;
57
120
  const end = cursor.lineEnd;
@@ -66,6 +129,7 @@ const cursorAfterDash = (cursor) => {
66
129
  };
67
130
  /** Shared block grammar consuming one mutable physical-line cursor. */
68
131
  const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
132
+ const wantSource = builder === positionedBuilder;
69
133
  let items = null;
70
134
  let entries = null;
71
135
  let pendingItem = null;
@@ -82,22 +146,30 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
82
146
  if (indent > baseIndent) {
83
147
  const trimmed = cursorContent(cursor);
84
148
  if (items !== null && pendingItem !== null) {
85
- const colonPos = findKeySep(trimmed);
86
- if (colonPos !== -1) {
87
- const key = stripKeyQuotes(trimSlice(trimmed, 0, colonPos));
88
- checkKeyLength(key, () => line);
89
- let raw = trimSlice(trimmed, colonPos + 2, trimmed.length);
90
- if (raw.includes('#'))
91
- raw = stripComment(raw);
92
- builder.setMapping(pendingItem, key, parseFlowOrScalarValue(raw, ctx, line, builder));
149
+ const bareKey = trimmed.charCodeAt(trimmed.length - 1) === 58;
150
+ const colonPos = bareKey && trimmed.indexOf(' ') === -1 ? -1 : findKeySep(trimmed);
151
+ const keyRaw = colonPos !== -1
152
+ ? trimSlice(trimmed, 0, colonPos)
153
+ : bareKey ? trimSlice(trimmed, 0, trimmed.length - 1) : '';
154
+ if (keyRaw !== '' && !isValidKey(keyRaw)) {
155
+ // §5.1: not a usable key — skipped in both modes, as at the top level.
93
156
  cursor.next();
94
157
  }
95
- else if (trimmed.endsWith(':')) {
96
- const key = stripKeyQuotes(trimSlice(trimmed, 0, trimmed.length - 1));
97
- checkKeyLength(key, () => line);
158
+ else if (colonPos !== -1) {
159
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
160
+ checkKeyLength(key, line);
161
+ const rawUndecoded = trimSlice(trimmed, colonPos + 2, trimmed.length);
162
+ let raw = rawUndecoded;
163
+ if (raw !== '|' && raw.includes('#'))
164
+ raw = stripComment(raw);
165
+ const valueCol = wantSource ? cursor.indent + colonPos + 2 + leadingWsLen(trimmed.slice(colonPos + 2)) : 0;
166
+ builder.setMapping(pendingItem, key, inlineOrBlockScalar(raw, indent, line, valueCol, rawUndecoded, cursor, ctx, builder));
167
+ }
168
+ else if (bareKey) {
169
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
170
+ checkKeyLength(key, line);
98
171
  cursor.next();
99
- while (cursor.valid && cursor.empty)
100
- cursor.next();
172
+ skipEmptyAndCommentLines(cursor);
101
173
  if (cursor.valid && cursor.indent > indent) {
102
174
  const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
103
175
  builder.setMapping(pendingItem, key, nested ?? builder.null(line));
@@ -134,17 +206,23 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
134
206
  cursor.next();
135
207
  continue;
136
208
  }
137
- let afterDash = cursorAfterDash(cursor);
209
+ const afterDashRaw = cursorAfterDash(cursor);
210
+ let afterDash = afterDashRaw;
138
211
  if (afterDash.includes('#'))
139
212
  afterDash = stripComment(afterDash);
213
+ const afterDashCol = wantSource ? cursor.indent + dashPrefixLen(cursor) : 0;
214
+ const afterDashTab = wantSource && ctx.tabAdjust ? [ctx.tabAdjust[line - 1] ?? 0] : undefined;
215
+ const dashSource = wantSource ? ({
216
+ raw: physicalRaw(afterDashRaw), line, col: afterDashCol, tabAdjust: afterDashTab,
217
+ }) : undefined;
140
218
  const first = afterDash.charCodeAt(0);
141
219
  if (first !== 34 && first !== 39 && first !== 45 && first !== 123 &&
142
220
  afterDash.indexOf(': ') === -1 && !afterDash.endsWith(':')) {
143
- items.push(parseQuotedOrTyped(afterDash, ctx, line, false, builder));
221
+ items.push(parseQuotedOrTyped(afterDash, ctx, line, builder, dashSource));
144
222
  cursor.next();
145
223
  continue;
146
224
  }
147
- const flowMap = parseFlowMapping(afterDash, ctx, line, builder);
225
+ const flowMap = parseFlowMapping(afterDash, ctx, line, builder, dashSource);
148
226
  const colonPos = findKeySep(afterDash);
149
227
  if (flowMap !== null) {
150
228
  items.push(flowMap);
@@ -166,53 +244,60 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
166
244
  cursor.next();
167
245
  }
168
246
  }
169
- else if (colonPos !== -1) {
170
- const keyFirst = afterDash.charCodeAt(0);
171
- const keyLast = afterDash.charCodeAt(colonPos - 1);
172
- const keyRaw = keyFirst > 0x20 && keyFirst < 0x7f && keyLast > 0x20 && keyLast < 0x7f
173
- ? afterDash.slice(0, colonPos) : trimSlice(afterDash, 0, colonPos);
174
- const key = keyFirst === 34 || keyFirst === 39 ? stripKeyQuotes(keyRaw) : keyRaw;
175
- checkKeyLength(key, () => line);
247
+ else if (colonPos !== -1 && isValidKey(trimSlice(afterDash, 0, colonPos))) {
248
+ const keyRaw = trimSlice(afterDash, 0, colonPos);
249
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
250
+ checkKeyLength(key, line);
176
251
  const valueStart = colonPos + 2;
177
252
  const valueFirst = afterDash.charCodeAt(valueStart);
178
253
  const valueLast = afterDash.charCodeAt(afterDash.length - 1);
179
254
  const raw = valueFirst > 0x20 && valueFirst < 0x7f && valueLast > 0x20 && valueLast < 0x7f
180
255
  ? afterDash.slice(valueStart) : trimSlice(afterDash, valueStart, afterDash.length);
181
- pendingItem = builder.createMappingWith(key, parseFlowOrScalarValue(raw, ctx, line, builder));
182
- cursor.next();
256
+ // The key sits after `- `, two columns past the dash.
257
+ const valueCol = wantSource ? afterDashCol + valueStart + leadingWsLen(afterDash.slice(valueStart)) : 0;
258
+ pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, baseIndent + 2, line, valueCol, wantSource ? afterDashRaw.slice(valueStart) : raw, cursor, ctx, builder));
183
259
  while (cursor.valid && cursor.indent > baseIndent) {
184
260
  const continuationLine = baseLine + cursor.lineIndex;
185
- const start = cursor.contentStart, end = cursor.lineEnd;
186
- const cfirst = cursor.source.charCodeAt(start);
187
- if (cfirst === 34 || cfirst === 39 || cfirst === 35)
261
+ const ckeyIndent = cursor.asciiIndent;
262
+ const cfirst = cursor.source.charCodeAt(cursor.contentStart);
263
+ if (cfirst === 35)
264
+ break;
265
+ const contLine = trimSlice(cursor.source, cursor.contentStart, cursor.lineEnd);
266
+ const csep = findKeySep(contLine);
267
+ if (csep === -1)
188
268
  break;
189
- const sep = cursor.source.indexOf(': ', start);
190
- if (sep === -1 || sep >= end)
269
+ const ckeyRaw = trimSlice(contLine, 0, csep);
270
+ if (!isValidKey(ckeyRaw))
191
271
  break;
192
- const keyLast = cursor.source.charCodeAt(sep - 1);
193
- const ckey = cfirst > 0x20 && cfirst < 0x7f && keyLast > 0x20 && keyLast < 0x7f
194
- ? cursor.source.slice(start, sep) : trimSlice(cursor.source, start, sep);
272
+ const ckey = stripKeyQuotes(ckeyRaw, ctx.strict, continuationLine);
195
273
  if (!ckey)
196
274
  break;
197
- checkKeyLength(ckey, () => continuationLine);
198
- const valueStart = sep + 2;
199
- const valueFirst = cursor.source.charCodeAt(valueStart);
200
- const valueLast = cursor.source.charCodeAt(end - 1);
201
- let value = valueFirst > 0x20 && valueFirst < 0x7f && valueLast > 0x20 && valueLast < 0x7f
202
- ? cursor.source.slice(valueStart, end) : trimSlice(cursor.source, valueStart, end);
203
- if (value.includes('#'))
275
+ checkKeyLength(ckey, continuationLine);
276
+ const valueUndecoded = trimSlice(contLine, csep + 2, contLine.length);
277
+ let value = valueUndecoded;
278
+ if (value !== '|' && value.includes('#'))
204
279
  value = stripComment(value);
205
- builder.setMapping(pendingItem, ckey, parseFlowOrScalarValue(value, ctx, continuationLine, builder));
206
- cursor.next();
280
+ // contLine starts exactly at contentStart (no leading trim), so
281
+ // the value's column is the indent plus its offset in contLine.
282
+ const cValueCol = wantSource ? cursor.indent + csep + 2 + leadingWsLen(contLine.slice(csep + 2)) : 0;
283
+ builder.setMapping(pendingItem, ckey, inlineOrBlockScalar(value, ckeyIndent, continuationLine, cValueCol, valueUndecoded, cursor, ctx, builder));
207
284
  }
208
285
  }
209
- else if (afterDash.endsWith(':')) {
210
- const key = stripKeyQuotes(trimSlice(afterDash, 0, afterDash.length - 1));
211
- checkKeyLength(key, () => line);
286
+ else if (afterDash.endsWith(':') && isValidKey(trimSlice(afterDash, 0, afterDash.length - 1))) {
287
+ const key = stripKeyQuotes(trimSlice(afterDash, 0, afterDash.length - 1), ctx.strict, line);
288
+ checkKeyLength(key, line);
289
+ // §7.1 rule 3 / §7.2: sibling keys of an object item align at
290
+ // the column of the first key *after* `- `; a nested block
291
+ // must be indented deeper than that column, not merely deeper
292
+ // than the item's dash. A line at the key's own column is the
293
+ // next sibling key, so the bare key is `null`.
294
+ let keyColumn = cursor.contentStart + 1;
295
+ while (keyColumn < cursor.lineEnd && isTrimWhitespace(cursor.source.charCodeAt(keyColumn)))
296
+ keyColumn++;
297
+ keyColumn -= cursor.lineStart;
212
298
  cursor.next();
213
- while (cursor.valid && cursor.empty)
214
- cursor.next();
215
- if (cursor.valid && cursor.indent > baseIndent) {
299
+ skipEmptyAndCommentLines(cursor);
300
+ if (cursor.valid && cursor.indent > keyColumn) {
216
301
  const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
217
302
  pendingItem = builder.createMappingWith(key, nested ?? builder.null(line));
218
303
  }
@@ -220,15 +305,9 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
220
305
  pendingItem = builder.createMappingWith(key, builder.null(line));
221
306
  }
222
307
  else {
223
- const q = afterDash.charCodeAt(0);
224
- if ((q === 34 || q === 39) && afterDash.charCodeAt(afterDash.length - 1) === q) {
225
- const inner = afterDash.slice(1, -1);
226
- const value = q === 34 ? unescapeDQ(inner, ctx.strict, line) : inner.replace(/\\'/g, "'");
227
- checkScalarLimit(LString(value), line);
228
- items.push(builder.string(value, line, true));
229
- }
230
- else
231
- items.push(parseQuotedOrTyped(afterDash, ctx, line, false, builder));
308
+ // A quoted-or-typed scalar item — parseQuotedOrTyped enforces
309
+ // §10.1's unterminated / trailing-content strict checks.
310
+ items.push(parseQuotedOrTyped(afterDash, ctx, line, builder, dashSource));
232
311
  cursor.next();
233
312
  }
234
313
  }
@@ -241,30 +320,39 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
241
320
  cursor.next();
242
321
  continue;
243
322
  }
244
- const colonPos = findKeySep(trimmed);
245
- if (colonPos !== -1) {
323
+ const bareKey = trimmed.charCodeAt(trimmed.length - 1) === 58;
324
+ const colonPos = bareKey && trimmed.indexOf(' ') === -1 ? -1 : findKeySep(trimmed);
325
+ const keyRaw = colonPos !== -1
326
+ ? trimSlice(trimmed, 0, colonPos)
327
+ : bareKey ? trimSlice(trimmed, 0, trimmed.length - 1) : '';
328
+ if (keyRaw !== '' && !isValidKey(keyRaw)) {
329
+ // §5.1: not a usable key — unrecognised line, skipped in both
330
+ // modes (§10's strict list is closed and does not cover this).
331
+ cursor.next();
332
+ }
333
+ else if (colonPos !== -1) {
246
334
  if (entries === null)
247
335
  entries = builder.createMapping();
248
- const key = stripKeyQuotes(trimSlice(trimmed, 0, colonPos));
249
- checkKeyLength(key, () => line);
336
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
337
+ checkKeyLength(key, line);
250
338
  if (ctx.strict || ctx.onWarning !== undefined)
251
339
  checkDuplicateKey(builder.hasMappingKey(entries, key), key, line, ctx);
252
- let raw = trimSlice(trimmed, colonPos + 2, trimmed.length);
253
- if (raw.includes('#'))
340
+ const rawUndecoded = trimSlice(trimmed, colonPos + 2, trimmed.length);
341
+ let raw = rawUndecoded;
342
+ if (raw !== '|' && raw.includes('#'))
254
343
  raw = stripComment(raw);
255
- builder.setMapping(entries, key, parseFlowOrScalarValue(raw, ctx, line, builder));
256
- cursor.next();
344
+ const valueCol = wantSource ? cursor.indent + colonPos + 2 + leadingWsLen(trimmed.slice(colonPos + 2)) : 0;
345
+ builder.setMapping(entries, key, inlineOrBlockScalar(raw, baseIndent, line, valueCol, rawUndecoded, cursor, ctx, builder));
257
346
  }
258
- else if (trimmed.endsWith(':')) {
347
+ else if (bareKey) {
259
348
  if (entries === null)
260
349
  entries = builder.createMapping();
261
- const key = stripKeyQuotes(trimSlice(trimmed, 0, trimmed.length - 1));
262
- checkKeyLength(key, () => line);
350
+ const key = stripKeyQuotes(keyRaw, ctx.strict, line);
351
+ checkKeyLength(key, line);
263
352
  if (ctx.strict || ctx.onWarning !== undefined)
264
353
  checkDuplicateKey(builder.hasMappingKey(entries, key), key, line, ctx);
265
354
  cursor.next();
266
- while (cursor.valid && cursor.empty)
267
- cursor.next();
355
+ skipEmptyAndCommentLines(cursor);
268
356
  if (cursor.valid && cursor.indent > baseIndent) {
269
357
  const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
270
358
  builder.setMapping(entries, key, nested ?? builder.null(line));
@@ -289,8 +377,7 @@ export const parseBlockRange = (source, start, end, ctx, baseLine, builder, dept
289
377
  const cursor = new BlockCursor(source, start, end);
290
378
  if (!cursor.next())
291
379
  return null;
292
- while (cursor.valid && cursor.empty)
293
- cursor.next();
380
+ skipEmptyAndCommentLines(cursor);
294
381
  if (!cursor.valid)
295
382
  return null;
296
383
  const baseIndent = cursor.asciiIndent;
package/dist/builder.d.ts CHANGED
@@ -24,14 +24,19 @@
24
24
  * is compile-time-only: generics are erased, so the emitted JS — and
25
25
  * therefore performance — is identical either way, verified by benchmark.
26
26
  */
27
+ import type { ReferenceSource } from './reference-tokens2.js';
27
28
  export interface ValueBuilder<V, M = Map<string, V>> {
28
- /** Opt-in for References' annotated builder; native Core avoids source-map work. */
29
- readonly tracksStringSourcePositions?: boolean;
30
29
  null(line: number): V;
31
30
  bool(value: boolean, line: number): V;
32
31
  int(value: number, line: number): V;
33
32
  float(value: number, line: number): V;
34
- string(value: string, line: number, quoted: boolean, sourceSpans?: StringSourceSpan[]): V;
33
+ /**
34
+ * `source` (when given, for unquoted strings that may hold References 2.0
35
+ * tokens) carries the physical position needed to attribute those tokens
36
+ * to a real `(line, offset)` in the source text (§2.4). `nativeBuilder`
37
+ * ignores it.
38
+ */
39
+ string(value: string, line: number, quoted: boolean, source?: ReferenceSource): V;
35
40
  instant(value: Date, line: number): V;
36
41
  array(items: V[], line: number): V;
37
42
  createMapping(): M;
@@ -41,9 +46,3 @@ export interface ValueBuilder<V, M = Map<string, V>> {
41
46
  mappingMaxDepth(entries: M, depthOf: (value: V) => number): number;
42
47
  mapping(entries: M, line: number): V;
43
48
  }
44
- /** Maps a span in a decoded scalar back to its physical source line/column. */
45
- export type StringSourceSpan = {
46
- start: number;
47
- line: number;
48
- sourceOffset: number;
49
- };