@limaformat/lima 0.5.0 → 0.6.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
@@ -4,13 +4,99 @@ All notable changes to `@limaformat/lima` are documented in this file.
4
4
 
5
5
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  This package's version number tracks its own release history and is
7
- independent of the Lima specification version — [Lima Core
8
- 1.0](https://github.com/limaformat/lima/blob/main/docs/lima-core-1.0-spec.md)
7
+ independent of the Lima specification versions — [Lima Core
8
+ 1.1](https://github.com/limaformat/lima/blob/main/docs/lima-core-1.0-spec.md)
9
9
  and [Lima References
10
- 1.0](https://github.com/limaformat/lima/blob/main/docs/lima-references-1.0-spec.md)
11
- are both frozen regardless of what this file shows.
10
+ 2.0](https://github.com/limaformat/lima/blob/main/docs/lima-references-2.0-spec.md)
11
+ carry their own.
12
12
 
13
- ## [Unreleased]
13
+ ## [0.6.0] — 2026-10-28
14
+
15
+ Lima Core 1.1: strict mode rejects a content line that belongs to no
16
+ structure, which Core 1.0 skipped in both modes. Core 1.1 also settles
17
+ cases Core 1.0 left open, where the three implementations differed; some
18
+ non-strict results change with them (below).
19
+
20
+ ### Changed
21
+
22
+ - Strict mode throws `INVALID_INDENTATION` for a line indented after a
23
+ top-level key with an inline value — the lines after a YAML `>`, `|-` or
24
+ `|+`, a key indented by a tab, freetext after a value — as it already did
25
+ one level down; the message names the YAML marker and what Lima writes
26
+ instead.
27
+ - Strict mode throws the new `UNRECOGNIZED_LINE` for an unindented line that
28
+ matches no key pattern: a key without its `: `, a dotted or otherwise
29
+ invalid key, a YAML `---` / `...` marker, a quoted key broken by a newline.
30
+ - A line of a block mapping whose key the key grammar rejects (`a.b: value`,
31
+ `bad key: value`), skipped in strict mode until now, throws `UNRECOGNIZED_LINE`
32
+ too; an indented line below a block mapping's base indentation, or after a
33
+ `|` scalar a column-0 comment ended, throws `INVALID_INDENTATION`.
34
+ - `LimaDiagnosticCode` gains `'UNRECOGNIZED_LINE'`.
35
+ - A bare key's sequence may start at the key's own column, as YAML writes
36
+ it (`tags:` / `- a` / `- b`), at any level (Core 1.1 §7.2). At the top
37
+ level this was read so by accident; one level down the sequence and the
38
+ keys after it were lost.
39
+ - An item whose value is a flow sequence (`- [a, b]`, `- []`) is a nested
40
+ sequence (Core 1.1 §7.2): `null` in non-strict mode, `INVALID_FLOW_SYNTAX`
41
+ in strict mode.
42
+ - Indentation is spaces only (Core 1.1 §7.1): a line whose spaces are followed
43
+ by other whitespace, such as U+00A0, before its content is unrecognised —
44
+ skipped in non-strict mode, an error in strict mode. Whitespace is defined
45
+ once (§1), as the set every check already used.
46
+ TypeScript counted such whitespace as indentation.
47
+ - An object item's later keys stand at its first key's column (§7.2): a line
48
+ of the item deeper than that is skipped in non-strict mode, and one less
49
+ indented ends the item; strict mode throws `INVALID_INDENTATION` for
50
+ either. Any deeper line was taken as a key of the item.
51
+
52
+ ### Fixed
53
+
54
+ - A `|` scalar as an object item's first key measured its body from the dash
55
+ plus two columns, so after wider whitespace (`- a: |`) it took the item's
56
+ next key as text; it measures from the key's column (§6.1.5, §7.2).
57
+ - In strict mode, a line deeper than a nested `|` scalar's key, after a line
58
+ of other whitespace that ended the scalar, was dropped without an error; it
59
+ throws `INVALID_INDENTATION`.
60
+ - A top-level `key:` at the very end of the input, without a line
61
+ terminator, was no key at all and vanished; it is `null`, as at any nested
62
+ level and in the Rust and Go implementations, and as Core 1.1 §4 now says.
63
+ This changes a non-strict result too.
64
+ - A `|` with no content lines (`a: |` at the end of a block, or of the
65
+ input) was the string `"|"`; it is `""` (Core 1.1 §6.1.5).
66
+ - A block of mapping entries with a sequence item in it, or the reverse, was
67
+ an empty sequence in non-strict mode, all its entries lost; the first kind
68
+ wins and a line of the other kind is skipped (Core 1.1 §7.1).
69
+ - An item value that begins with `[` but is no flow sequence (`- [x] done`),
70
+ or with `{` and does not close (`- {a: 1`), throws `INVALID_FLOW_SYNTAX`
71
+ in strict mode, as an inline value does.
72
+ - `key: | # c` below the top level, in a block mapping or an object item,
73
+ was a block scalar; it is the string `"|"`, as at the top level — the
74
+ marker stands alone (§6.1.5).
75
+
76
+ The shared conformance corpus is at 336 Core cases: the 218 Core 1.0 cases
77
+ without the 8 that Core 1.1 supersedes, plus 126 Core 1.1 cases
78
+ (`corpus/core-1.1/`).
79
+
80
+ ## [0.5.1] — 2026-10-28
81
+
82
+ Core errata `1.0.10`: aligned top-level values are trimmed, as Core 1.0
83
+ always required.
84
+
85
+ ### Fixed
86
+
87
+ - A top-level inline value kept every space after `: ` but the first:
88
+ `title: Hello` read `' Hello'`, and `n: 42` the string `' 42'`
89
+ instead of the integer 42 — against §4 step 3 and the §4 and §5.1
90
+ examples. Nested keys were not affected. The top-level entry now skips the
91
+ whitespace after the separator before reading the value, as the block
92
+ mapping does; the References 2.0 columns it reports were already physical
93
+ and are unchanged.
94
+
95
+ The shared conformance corpus grows to 218 Core cases (7 new, `since:
96
+ "1.0.10"`); TypeScript, Rust, and Go pass all of them.
97
+
98
+ A unit test in `src/block.test.ts` had asserted the defect (`key: value`
99
+ → `' value'`); it now asserts the specified `'value'`.
14
100
 
15
101
  ## [0.5.0] — 2026-09-09
16
102
 
package/README.md CHANGED
@@ -57,7 +57,7 @@ public API. The normative rules are defined in the
57
57
  Why Lima exists, the case against YAML, and security rationale:
58
58
  **[repository README](https://github.com/limaformat/lima#readme)**.
59
59
 
60
- The [Lima Core 1.0](https://github.com/limaformat/lima/blob/main/docs/lima-core-1.0-spec.md)
60
+ The [Lima Core 1.1](https://github.com/limaformat/lima/blob/main/docs/lima-core-1.0-spec.md)
61
61
  and [Lima References 2.0](https://github.com/limaformat/lima/blob/main/docs/lima-references-2.0-spec.md)
62
62
  specifications are the source of truth for these public entry points. The
63
63
  frozen References 1.0 implementation remains available internally to run its
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Allocation-free physical-line cursor over one top-level block extent.
3
- * Positions are UTF-16 code-unit offsets. Structural boundaries are ASCII;
4
- * indentation additionally follows the complete whitespace predicate used by
5
- * `trimStart()`, matching the existing block grammar.
3
+ * Positions are UTF-16 code-unit offsets. Indentation is the run of spaces
4
+ * (Core 1.1 §7.1); other whitespace after it, such as U+00A0, starts the
5
+ * content of a line that has any, and only a blank or comment line looks
6
+ * past it (the complete whitespace predicate used by `trimStart()`).
6
7
  */
7
8
  export declare class BlockCursor {
8
9
  readonly source: string;
@@ -1,9 +1,10 @@
1
1
  import { isTrimWhitespace } from './chars.js';
2
2
  /**
3
3
  * Allocation-free physical-line cursor over one top-level block extent.
4
- * Positions are UTF-16 code-unit offsets. Structural boundaries are ASCII;
5
- * indentation additionally follows the complete whitespace predicate used by
6
- * `trimStart()`, matching the existing block grammar.
4
+ * Positions are UTF-16 code-unit offsets. Indentation is the run of spaces
5
+ * (Core 1.1 §7.1); other whitespace after it, such as U+00A0, starts the
6
+ * content of a line that has any, and only a blank or comment line looks
7
+ * past it (the complete whitespace predicate used by `trimStart()`).
7
8
  */
8
9
  export class BlockCursor {
9
10
  source;
@@ -50,6 +51,9 @@ export class BlockCursor {
50
51
  } while (content < end);
51
52
  }
52
53
  }
54
+ // other whitespace before content is no indentation: the content starts at it (Core 1.1 §7.1)
55
+ if (content > asciiEnd && content < end && this.source.charCodeAt(content) !== 35)
56
+ content = asciiEnd;
53
57
  this.lineStart = start;
54
58
  this.lineEnd = end;
55
59
  this.contentStart = content;
package/dist/block.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * never resolves a reference in the first place).
10
10
  */
11
11
  import { checkKeyLength, checkDuplicateKey, NESTING_DEPTH_LIMIT } from './normalize.js';
12
- import { stripKeyQuotes, stripComment, stripCommentKeepEscapes, parseQuotedOrTyped, closingQuoteIndex, isValidKey, positionedBuilder, } from './scalars.js';
13
- import { parseFlowMapping, parseFlowOrScalarValue } from './flow.js';
12
+ import { stripKeyQuotes, stripComment, stripCommentKeepEscapes, parseQuotedOrTyped, closingQuoteIndex, isValidKey, } from './scalars.js';
13
+ import { parseFlowOrScalarValue, parseFlowSequence } from './flow.js';
14
14
  import { buildBlockScalar } from './block-scalar.js';
15
15
  import { LimaError } from './errors.js';
16
16
  import { isTrimWhitespace } from './chars.js';
@@ -48,9 +48,10 @@ const inlineSource = (wantSource, ctx, keyLine, valueCol, rawUndecoded) => wantS
48
48
  raw: physicalRaw(rawUndecoded), line: keyLine, col: valueCol,
49
49
  tabAdjust: ctx.tabAdjust ? [ctx.tabAdjust[keyLine - 1] ?? 0] : undefined,
50
50
  }) : undefined;
51
- const inlineOrBlockScalar = (raw, keyIndent, keyLine, valueCol, rawUndecoded, cursor, ctx, builder) => {
52
- const wantSource = builder === positionedBuilder;
53
- if (raw !== '|') {
51
+ /** An inline value, or a `|` block scalar when `marker`: the value is `|` alone (§6.1.5; `| # c` is the string `|`). */
52
+ const inlineOrBlockScalar = (raw, marker, keyIndent, keyLine, valueCol, rawUndecoded, cursor, ctx, builder) => {
53
+ const wantSource = builder.positions === true;
54
+ if (!marker) {
54
55
  const value = parseFlowOrScalarValue(raw, ctx, keyLine, builder, inlineSource(wantSource, ctx, keyLine, valueCol, rawUndecoded));
55
56
  cursor.next();
56
57
  return value;
@@ -58,7 +59,9 @@ const inlineOrBlockScalar = (raw, keyIndent, keyLine, valueCol, rawUndecoded, cu
58
59
  cursor.next(); // past the `key: |` line
59
60
  const bodyLines = [];
60
61
  while (cursor.valid) {
61
- if (!cursor.empty && cursor.asciiIndent <= keyIndent)
62
+ // the scalar's own extent (§6.1.5, `buildBlockScalar`): a line of spaces only is empty, any other ends it at the
63
+ // key's column or less — a line of other whitespace too, which is content here
64
+ if (cursor.lineEnd - cursor.lineStart > cursor.asciiIndent && cursor.asciiIndent <= keyIndent)
62
65
  break;
63
66
  bodyLines.push(cursor.source.slice(cursor.lineStart, cursor.lineEnd));
64
67
  cursor.next();
@@ -83,6 +86,15 @@ export const findKeySep = (s) => {
83
86
  * the original source. Strings are materialized only when grammar or scalar
84
87
  * parsing needs their content.
85
88
  */
89
+ /**
90
+ * `source.slice(start, end).trimEnd()`: a key, or a line's content, keeps the whitespace it starts with, which makes it
91
+ * no key (Core 1.1 §7.1: other whitespace before content is no indentation).
92
+ */
93
+ const trimEndSlice = (source, start, end) => {
94
+ while (end > start && isTrimWhitespace(source.charCodeAt(end - 1)))
95
+ end--;
96
+ return source.slice(start, end);
97
+ };
86
98
  /** `source.slice(start, end).trim()` with no temporary untrimmed substring. */
87
99
  const trimSlice = (source, start, end) => {
88
100
  if (start < end) {
@@ -115,6 +127,30 @@ const skipEmptyAndCommentLines = (cursor) => {
115
127
  while (cursor.valid && (cursor.empty || cursor.firstCode === 35))
116
128
  cursor.next();
117
129
  };
130
+ /**
131
+ * §7.2: a sequence item is a line of `-` alone or `-` followed by whitespace; `---` or `-x` is none (Core 1.1). The
132
+ * cursor is on a line with content.
133
+ */
134
+ const isSequenceItem = (cursor) => {
135
+ if (cursor.firstCode !== 45)
136
+ return false;
137
+ const next = cursor.contentStart + 1;
138
+ return next === cursor.lineEnd || isTrimWhitespace(cursor.source.charCodeAt(next));
139
+ };
140
+ /**
141
+ * Core 1.1 §7.2: the block of a bare key at `keyColumn` — deeper than the key (§7.1 rule 3), or a sequence that
142
+ * starts at the key's own column, which `compact` parsing ends at that column's first line that is no item. Null
143
+ * without either. The cursor is on the first content line after the key.
144
+ */
145
+ const blockOfBareKey = (cursor, keyColumn, ctx, baseLine, builder) => {
146
+ if (!cursor.valid)
147
+ return null;
148
+ if (cursor.indent > keyColumn)
149
+ return parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
150
+ if (cursor.indent === keyColumn && isSequenceItem(cursor))
151
+ return parseCursorBlock(cursor, keyColumn, ctx, baseLine, builder, true);
152
+ return null;
153
+ };
118
154
  const cursorAfterDash = (cursor) => {
119
155
  const start = cursor.contentStart;
120
156
  const end = cursor.lineEnd;
@@ -127,12 +163,19 @@ const cursorAfterDash = (cursor) => {
127
163
  content++;
128
164
  return cursor.source.slice(content, end);
129
165
  };
166
+ /** Core 1.1 §10.1: a line in a block whose key the key grammar (§5.1) rejects, in strict mode. */
167
+ const unrecognizedKeyLine = (line, trimmed) => new LimaError({
168
+ code: 'UNRECOGNIZED_LINE', line,
169
+ message: `Lima: unrecognised line at line ${line}: "${trimmed}" (an unquoted key has letters, digits, "_", "-" and ":" only; quote any other)`,
170
+ });
130
171
  /** Shared block grammar consuming one mutable physical-line cursor. */
131
- const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
132
- const wantSource = builder === positionedBuilder;
172
+ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact = false) => {
173
+ const wantSource = builder.positions === true;
133
174
  let items = null;
134
175
  let entries = null;
135
176
  let pendingItem = null;
177
+ // the column of the pending object item's keys: its first key's, after `- ` (§7.2)
178
+ let pendingColumn = 0;
136
179
  const startLine = cursor.lineIndex;
137
180
  while (cursor.valid) {
138
181
  const line = baseLine + cursor.lineIndex;
@@ -145,14 +188,30 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
145
188
  break;
146
189
  if (indent > baseIndent) {
147
190
  const trimmed = cursorContent(cursor);
191
+ // §7.2: an object item's keys stand at its first key's column. A line less indented ends the item (§7.1
192
+ // rule 4); a deeper one, which no nested block took, is inconsistently indented (rule 5) and the item goes on
193
+ if (pendingItem !== null && indent < pendingColumn) {
194
+ items.push(builder.mapping(pendingItem, line));
195
+ pendingItem = null;
196
+ }
197
+ if (pendingItem !== null && indent > pendingColumn) {
198
+ if (ctx.strict)
199
+ throw new LimaError({ code: 'INVALID_INDENTATION', line,
200
+ message: `Lima: unexpected indentation at line ${line}: "${trimmed}"` });
201
+ cursor.next();
202
+ continue;
203
+ }
148
204
  if (items !== null && pendingItem !== null) {
149
205
  const bareKey = trimmed.charCodeAt(trimmed.length - 1) === 58;
150
206
  const colonPos = bareKey && trimmed.indexOf(' ') === -1 ? -1 : findKeySep(trimmed);
151
207
  const keyRaw = colonPos !== -1
152
- ? trimSlice(trimmed, 0, colonPos)
153
- : bareKey ? trimSlice(trimmed, 0, trimmed.length - 1) : '';
208
+ ? trimEndSlice(trimmed, 0, colonPos)
209
+ : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
154
210
  if (keyRaw !== '' && !isValidKey(keyRaw)) {
155
- // §5.1: not a usable key — skipped in both modes, as at the top level.
211
+ // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
212
+ // mode, as at the top level (Core 1.1 §10.1)
213
+ if (ctx.strict)
214
+ throw unrecognizedKeyLine(line, trimmed);
156
215
  cursor.next();
157
216
  }
158
217
  else if (colonPos !== -1) {
@@ -163,19 +222,15 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
163
222
  if (raw !== '|' && raw.includes('#'))
164
223
  raw = stripComment(raw);
165
224
  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));
225
+ builder.setMapping(pendingItem, key, inlineOrBlockScalar(raw, rawUndecoded === '|', indent, line, valueCol, rawUndecoded, cursor, ctx, builder));
167
226
  }
168
227
  else if (bareKey) {
169
228
  const key = stripKeyQuotes(keyRaw, ctx.strict, line);
170
229
  checkKeyLength(key, line);
171
230
  cursor.next();
172
231
  skipEmptyAndCommentLines(cursor);
173
- if (cursor.valid && cursor.indent > indent) {
174
- const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
175
- builder.setMapping(pendingItem, key, nested ?? builder.null(line));
176
- }
177
- else
178
- builder.setMapping(pendingItem, key, builder.null(line));
232
+ const nested = blockOfBareKey(cursor, indent, ctx, baseLine, builder);
233
+ builder.setMapping(pendingItem, key, nested ?? builder.null(line));
179
234
  }
180
235
  else {
181
236
  if (ctx.strict)
@@ -192,13 +247,16 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
192
247
  }
193
248
  continue;
194
249
  }
195
- if (cursor.firstCode === 45) {
250
+ // a sequence at its key's column ends at that column's first line that is no item (Core 1.1 §7.2)
251
+ if (compact && !isSequenceItem(cursor))
252
+ break;
253
+ if (isSequenceItem(cursor)) {
196
254
  if (pendingItem !== null) {
197
255
  items.push(builder.mapping(pendingItem, line));
198
256
  pendingItem = null;
199
257
  }
200
- if (items === null)
201
- items = [];
258
+ // a block is a mapping or a sequence, as its first line decides; in non-strict mode a line of the other kind
259
+ // is skipped, and what the block holds stays (Core 1.1 §7.1)
202
260
  if (entries !== null) {
203
261
  if (ctx.strict)
204
262
  throw new LimaError({ code: 'INVALID_INDENTATION', line,
@@ -206,6 +264,8 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
206
264
  cursor.next();
207
265
  continue;
208
266
  }
267
+ if (items === null)
268
+ items = [];
209
269
  const afterDashRaw = cursorAfterDash(cursor);
210
270
  let afterDash = afterDashRaw;
211
271
  if (afterDash.includes('#'))
@@ -216,19 +276,33 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
216
276
  raw: physicalRaw(afterDashRaw), line, col: afterDashCol, tabAdjust: afterDashTab,
217
277
  }) : undefined;
218
278
  const first = afterDash.charCodeAt(0);
219
- if (first !== 34 && first !== 39 && first !== 45 && first !== 123 &&
279
+ // an item's value is an inline value (§15.3): a flow mapping, or what an unclosed one falls back to (§7.5); a
280
+ // flow sequence there is a sequence in a sequence (§7.2)
281
+ if (first === 123) {
282
+ items.push(parseFlowOrScalarValue(afterDash, ctx, line, builder, dashSource));
283
+ cursor.next();
284
+ continue;
285
+ }
286
+ if (first === 91) {
287
+ const sequence = parseFlowSequence(afterDash, ctx, line, builder, dashSource);
288
+ if (sequence === null)
289
+ items.push(parseFlowOrScalarValue(afterDash, ctx, line, builder, dashSource));
290
+ else if (ctx.strict)
291
+ throw new LimaError({ code: 'INVALID_FLOW_SYNTAX', line,
292
+ message: `Lima: nested sequence at line ${line}: "${cursorContent(cursor)}"` });
293
+ else
294
+ items.push(builder.null(line));
295
+ cursor.next();
296
+ continue;
297
+ }
298
+ if (first !== 34 && first !== 39 && first !== 45 &&
220
299
  afterDash.indexOf(': ') === -1 && !afterDash.endsWith(':')) {
221
300
  items.push(parseQuotedOrTyped(afterDash, ctx, line, builder, dashSource));
222
301
  cursor.next();
223
302
  continue;
224
303
  }
225
- const flowMap = parseFlowMapping(afterDash, ctx, line, builder, dashSource);
226
304
  const colonPos = findKeySep(afterDash);
227
- if (flowMap !== null) {
228
- items.push(flowMap);
229
- cursor.next();
230
- }
231
- else if (afterDash === '-' || DASH_PREFIX_RE.test(afterDash)) {
305
+ if (afterDash === '-' || DASH_PREFIX_RE.test(afterDash)) {
232
306
  if (ctx.strict)
233
307
  throw new LimaError({ code: 'INVALID_INDENTATION', line,
234
308
  message: `Lima: nested block sequence at line ${line}: "${cursorContent(cursor)}"` });
@@ -255,18 +329,20 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
255
329
  ? afterDash.slice(valueStart) : trimSlice(afterDash, valueStart, afterDash.length);
256
330
  // The key sits after `- `, two columns past the dash.
257
331
  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));
259
- while (cursor.valid && cursor.indent > baseIndent) {
332
+ pendingColumn = cursor.indent + dashPrefixLen(cursor);
333
+ pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, trimSlice(afterDashRaw, valueStart, afterDashRaw.length) === '|', pendingColumn, line, valueCol, wantSource ? afterDashRaw.slice(valueStart) : raw, cursor, ctx, builder));
334
+ // the item's later keys at its key column; any other line is the block loop's (§7.2)
335
+ while (cursor.valid && cursor.indent === pendingColumn) {
260
336
  const continuationLine = baseLine + cursor.lineIndex;
261
337
  const ckeyIndent = cursor.asciiIndent;
262
338
  const cfirst = cursor.source.charCodeAt(cursor.contentStart);
263
339
  if (cfirst === 35)
264
340
  break;
265
- const contLine = trimSlice(cursor.source, cursor.contentStart, cursor.lineEnd);
341
+ const contLine = trimEndSlice(cursor.source, cursor.contentStart, cursor.lineEnd);
266
342
  const csep = findKeySep(contLine);
267
343
  if (csep === -1)
268
344
  break;
269
- const ckeyRaw = trimSlice(contLine, 0, csep);
345
+ const ckeyRaw = trimEndSlice(contLine, 0, csep);
270
346
  if (!isValidKey(ckeyRaw))
271
347
  break;
272
348
  const ckey = stripKeyQuotes(ckeyRaw, ctx.strict, continuationLine);
@@ -280,7 +356,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
280
356
  // contLine starts exactly at contentStart (no leading trim), so
281
357
  // the value's column is the indent plus its offset in contLine.
282
358
  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));
359
+ builder.setMapping(pendingItem, ckey, inlineOrBlockScalar(value, valueUndecoded === '|', ckeyIndent, continuationLine, cValueCol, valueUndecoded, cursor, ctx, builder));
284
360
  }
285
361
  }
286
362
  else if (afterDash.endsWith(':') && isValidKey(trimSlice(afterDash, 0, afterDash.length - 1))) {
@@ -297,12 +373,9 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
297
373
  keyColumn -= cursor.lineStart;
298
374
  cursor.next();
299
375
  skipEmptyAndCommentLines(cursor);
300
- if (cursor.valid && cursor.indent > keyColumn) {
301
- const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
302
- pendingItem = builder.createMappingWith(key, nested ?? builder.null(line));
303
- }
304
- else
305
- pendingItem = builder.createMappingWith(key, builder.null(line));
376
+ const nested = blockOfBareKey(cursor, keyColumn, ctx, baseLine, builder);
377
+ pendingColumn = keyColumn;
378
+ pendingItem = builder.createMappingWith(key, nested ?? builder.null(line));
306
379
  }
307
380
  else {
308
381
  // A quoted-or-typed scalar item — parseQuotedOrTyped enforces
@@ -323,11 +396,13 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
323
396
  const bareKey = trimmed.charCodeAt(trimmed.length - 1) === 58;
324
397
  const colonPos = bareKey && trimmed.indexOf(' ') === -1 ? -1 : findKeySep(trimmed);
325
398
  const keyRaw = colonPos !== -1
326
- ? trimSlice(trimmed, 0, colonPos)
327
- : bareKey ? trimSlice(trimmed, 0, trimmed.length - 1) : '';
399
+ ? trimEndSlice(trimmed, 0, colonPos)
400
+ : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
328
401
  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).
402
+ // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
403
+ // mode, as at the top level (Core 1.1 §10.1)
404
+ if (ctx.strict)
405
+ throw unrecognizedKeyLine(line, trimmed);
331
406
  cursor.next();
332
407
  }
333
408
  else if (colonPos !== -1) {
@@ -342,7 +417,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
342
417
  if (raw !== '|' && raw.includes('#'))
343
418
  raw = stripComment(raw);
344
419
  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));
420
+ builder.setMapping(entries, key, inlineOrBlockScalar(raw, rawUndecoded === '|', baseIndent, line, valueCol, rawUndecoded, cursor, ctx, builder));
346
421
  }
347
422
  else if (bareKey) {
348
423
  if (entries === null)
@@ -353,12 +428,8 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder) => {
353
428
  checkDuplicateKey(builder.hasMappingKey(entries, key), key, line, ctx);
354
429
  cursor.next();
355
430
  skipEmptyAndCommentLines(cursor);
356
- if (cursor.valid && cursor.indent > baseIndent) {
357
- const nested = parseCursorBlock(cursor, cursor.indent, ctx, baseLine, builder);
358
- builder.setMapping(entries, key, nested ?? builder.null(line));
359
- }
360
- else
361
- builder.setMapping(entries, key, builder.null(line));
431
+ const nested = blockOfBareKey(cursor, baseIndent, ctx, baseLine, builder);
432
+ builder.setMapping(entries, key, nested ?? builder.null(line));
362
433
  }
363
434
  else {
364
435
  if (ctx.strict)
@@ -381,7 +452,10 @@ export const parseBlockRange = (source, start, end, ctx, baseLine, builder, dept
381
452
  if (!cursor.valid)
382
453
  return null;
383
454
  const baseIndent = cursor.asciiIndent;
384
- const value = parseCursorBlock(cursor, baseIndent, ctx, baseLine, builder);
455
+ // Core 1.1 §7.2: at the top level's own column 0 only a sequence is the key's block; any other first line is not
456
+ if (baseIndent === 0 && !isSequenceItem(cursor))
457
+ return null;
458
+ const value = parseCursorBlock(cursor, baseIndent, ctx, baseLine, builder, baseIndent === 0);
385
459
  // Recursive block containers require strictly increasing integer
386
460
  // indentation. At one indentation level a sequence-item mapping can add
387
461
  // one container and Core flow syntax at most two more, so delta + 4 is a
package/dist/builder.d.ts CHANGED
@@ -26,6 +26,8 @@
26
26
  */
27
27
  import type { ReferenceSource } from './reference-tokens2.js';
28
28
  export interface ValueBuilder<V, M = Map<string, V>> {
29
+ /** Whether scalar builders need physical source anchors for editor positions. */
30
+ readonly positions?: boolean;
29
31
  null(line: number): V;
30
32
  bool(value: boolean, line: number): V;
31
33
  int(value: number, line: number): V;
package/dist/core.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lima Core 1.0 parser — reference-unaware by construction (Appendix B:
2
+ * Lima Core 1.1 parser — reference-unaware by construction (Appendix B:
3
3
  * reference resolution is exclusively the concern of the optional
4
4
  * References extension). `($key)`/`(%key)`-shaped text is never
5
5
  * recognised or coerced here; it simply falls through as an ordinary
@@ -22,6 +22,7 @@ import { type LimaValue } from './value.js';
22
22
  import { type ParseContext, type Diagnostic } from './normalize.js';
23
23
  import { type PositionedValue } from './scalars.js';
24
24
  import type { ValueBuilder } from './builder.js';
25
+ import type { ReferenceToken2 } from './reference-tokens2.js';
25
26
  export type { Diagnostic, ParseContext } from './normalize.js';
26
27
  export { NESTING_DEPTH_LIMIT, SCALAR_LENGTH_LIMIT } from './normalize.js';
27
28
  export type { PositionedValue, InsertedAt } from './scalars.js';
@@ -33,13 +34,23 @@ export type CoreOptions = {
33
34
  onWarning?: (diagnostic: Diagnostic) => void;
34
35
  };
35
36
  /**
36
- * Parses Lima Core 1.0 syntax into the internal annotated value tree —
37
+ * Parses Lima Core 1.1 syntax into the internal annotated value tree —
37
38
  * every node carrying its source line, string leaves additionally carrying
38
39
  * whether they came from quoted syntax. `($key)`/`(%key)` text is left
39
40
  * exactly as written; nothing here ever inspects or resolves it. The
40
41
  * primitive the References layer (`references.ts`) builds on.
41
42
  */
42
43
  export declare const parseCoreWithPositions: (frontMatter: string, ctx: ParseContext) => Map<string, PositionedValue>;
44
+ export type PositionedReferenceParse = {
45
+ document: Map<string, PositionedValue>;
46
+ references: ReferenceToken2[];
47
+ };
48
+ /**
49
+ * Position-aware Core parse plus every active References 2.0 token seen while
50
+ * values are built. Recording at construction time preserves tokens from
51
+ * earlier duplicate-key values even though the result map is last-value-wins.
52
+ */
53
+ export declare const parseCoreWithPositionedReferences: (frontMatter: string, ctx: ParseContext) => PositionedReferenceParse;
43
54
  /** The public result shape (Core §11.1): every value `toNative*` can produce. */
44
55
  export type NativeValue = null | boolean | number | string | Date | NativeValue[] | {
45
56
  [key: string]: NativeValue;
@@ -64,7 +75,7 @@ export declare const toNative: (v: LimaValue) => NativeValue;
64
75
  */
65
76
  export declare const toNativeFromPositioned: (v: PositionedValue) => NativeValue;
66
77
  /**
67
- * Public Core 1.0 entry point. Never resolves `($key)`/`(%key)` text — see
78
+ * Public Core 1.1 entry point. Never resolves `($key)`/`(%key)` text — see
68
79
  * the module doc comment. Equivalent in observable behavior to calling
69
80
  * `parseReferences()` with no partials on a document that happens to
70
81
  * contain no references, except that here reference-shaped text is
package/dist/core.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Lima Core 1.0 parser — reference-unaware by construction (Appendix B:
2
+ * Lima Core 1.1 parser — reference-unaware by construction (Appendix B:
3
3
  * reference resolution is exclusively the concern of the optional
4
4
  * References extension). `($key)`/`(%key)`-shaped text is never
5
5
  * recognised or coerced here; it simply falls through as an ordinary
@@ -20,11 +20,13 @@
20
20
  */
21
21
  import { checkKeyLength, byteLength, DOCUMENT_SIZE_LIMIT, TOP_LEVEL_KEY_LIMIT, NESTING_DEPTH_LIMIT, } from './normalize.js';
22
22
  import { unescapeDQ, stripComment, stripCommentKeepEscapes, positionedBuilder, NO_SPAN_VALUE, parseSimpleScalarSpan, } from './scalars.js';
23
+ import { isTrimWhitespace } from './chars.js';
23
24
  import { parseFlowOrScalarValue } from './flow.js';
24
25
  import { parseBlockRange } from './block.js';
25
26
  import { buildBlockScalar } from './block-scalar.js';
26
27
  import { LimaError } from './errors.js';
27
28
  import { KeyCursor, scanKeys } from './scanner.js';
29
+ import { checkRangeLines, isBlockScalarMarker } from './structure.js';
28
30
  export { NESTING_DEPTH_LIMIT, SCALAR_LENGTH_LIMIT } from './normalize.js';
29
31
  export { toPlainValue } from './scalars.js';
30
32
  /** Every Lima mapping result must be a prototype-free object (Core §11.1). */
@@ -76,7 +78,7 @@ const lineAt = (s, pos) => {
76
78
  return n;
77
79
  };
78
80
  /**
79
- * Parses Lima Core 1.0 syntax, generic over the output representation
81
+ * Parses Lima Core 1.1 syntax, generic over the output representation
80
82
  * (`builder` — see `builder.ts`): References needs the annotated
81
83
  * `PositionedValue` tree (`parseCoreWithPositions` below fixes `V` to
82
84
  * that), while `parseCore` fixes `V` to the public native shape directly,
@@ -156,8 +158,21 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
156
158
  searchFrom = lineEnd + 1;
157
159
  }
158
160
  }
159
- const parseEntry = (key, line, rawStart, isBlock, inlineEnd, nextStart) => {
161
+ const parseEntry = (key, line, separatorEnd, isBlock, inlineEnd, nextStart) => {
160
162
  checkKeyLength(key, line);
163
+ // §4 step 3: an inline value begins after the spaces that follow `: `, however many there are — as in a
164
+ // block mapping (block.ts trimSlice), so that the scalar paths below and the source column see the value
165
+ // itself. The newline that ends the line is not skipped.
166
+ let rawStart = separatorEnd;
167
+ if (!isBlock) {
168
+ while (rawStart < nextStart) {
169
+ const c = frontMatter.charCodeAt(rawStart);
170
+ // printable ASCII other than SPACE is never whitespace: the common case ends here at once
171
+ if ((c > 0x20 && c < 0x7f) || c === 10 || !isTrimWhitespace(c))
172
+ break;
173
+ rawStart++;
174
+ }
175
+ }
161
176
  if ((strict || ctx.onWarning !== undefined) && builder.hasMappingKey(root, key)) {
162
177
  const diagnostic = {
163
178
  code: 'DUPLICATE_KEY', line, key,
@@ -172,11 +187,23 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
172
187
  }
173
188
  const firstNewline = inlineEnd ?? frontMatter.indexOf('\n', rawStart);
174
189
  const newlineInRange = firstNewline < nextStart;
190
+ // §6.1.5: `|` alone is a literal block scalar marker, with a body or without one (then its value is empty).
191
+ // The introducing key is at column 0; `buildBlockScalar` finds the scalar's own extent within the range (which
192
+ // runs to the next top-level key), so a dedented comment or dedented freetext before that key ends the scalar
193
+ // rather than being absorbed into it.
194
+ if (!isBlock && frontMatter.charCodeAt(rawStart) === 124) {
195
+ const markerEnd = newlineInRange && firstNewline >= 0 ? firstNewline : nextStart;
196
+ if (frontMatter.slice(rawStart, markerEnd).trim() === '|') {
197
+ const bodyLines = markerEnd < nextStart ? frontMatter.slice(markerEnd + 1, nextStart).split('\n') : [];
198
+ builder.setMapping(root, key, buildBlockScalar(bodyLines, 0, line, ctx, builder).value);
199
+ return;
200
+ }
201
+ }
175
202
  // Physical codepoint column of the value's first character in its
176
203
  // source line — the anchor for References 2.0 token positions (§2.4).
177
204
  // References 2.0 token positions (§2.4): only build the source anchor
178
205
  // for the annotated builder; `parseCore` (native) discards it.
179
- const wantSource = builder === positionedBuilder;
206
+ const wantSource = builder.positions === true;
180
207
  const valueLineStart = wantSource ? frontMatter.lastIndexOf('\n', rawStart - 1) + 1 : 0;
181
208
  const valueCol = wantSource ? [...frontMatter.slice(valueLineStart, rawStart)].length : 0;
182
209
  const lineTabAdjust = wantSource && ctx.tabAdjust ? [ctx.tabAdjust[line - 1] ?? 0] : undefined;
@@ -218,31 +245,18 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
218
245
  lineStart = j + 1;
219
246
  }
220
247
  }
221
- {
222
- if (lines.length === 0) {
223
- builder.setMapping(root, key, builder.null(line));
224
- return;
225
- }
226
- const line0Trimmed = lines[0].trim();
227
- if (lines.length === 1 || line0Trimmed !== '|') {
228
- const line0 = lines[0];
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));
236
- return;
237
- }
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.
243
- const bodyLines = raw.slice(raw.indexOf('\n') + 1).split('\n');
244
- builder.setMapping(root, key, buildBlockScalar(bodyLines, 0, line, ctx, builder).value);
248
+ if (lines.length === 0) {
249
+ builder.setMapping(root, key, builder.null(line));
250
+ return;
245
251
  }
252
+ const line0 = lines[0];
253
+ const hasHash = line0.includes('#');
254
+ const val = hasHash ? stripComment(line0) : line0;
255
+ const source = wantSource ? {
256
+ raw: hasHash ? stripCommentKeepEscapes(line0) : line0,
257
+ line, col: valueCol, tabAdjust: lineTabAdjust,
258
+ } : undefined;
259
+ builder.setMapping(root, key, parseFlowOrScalarValue(val, ctx, line, builder, source));
246
260
  };
247
261
  // A recognised top-level entry needs at least one UTF-16 unit of key text,
248
262
  // `:`, and its terminating newline: three units. Consequently 129 entries
@@ -279,6 +293,9 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
279
293
  else {
280
294
  const matches = scanKeys(frontMatter);
281
295
  const keyCount = matches.length;
296
+ // Core 1.1: before the first key no line belongs to any structure (structure.ts)
297
+ if (strict)
298
+ checkRangeLines(frontMatter, 0, keyCount === 0 ? frontMatter.length : matches[0].matchStart, 1, 'none');
282
299
  if (keyCount === 0)
283
300
  return root;
284
301
  if (keyCount > TOP_LEVEL_KEY_LIMIT) {
@@ -291,8 +308,19 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
291
308
  const m = matches[i];
292
309
  const rawDQ = m.doubleQuotedRaw;
293
310
  const key = m.unquoted ?? m.singleQuoted ?? (rawDQ !== undefined ? unescapeDQ(rawDQ, strict, m.line) : undefined);
311
+ const nextStart = i + 1 < keyCount ? matches[i + 1].matchStart : frontMatter.length;
294
312
  if (key !== undefined)
295
- parseEntry(key, m.line, m.rawStart, m.isBlock, m.inlineEnd, i + 1 < keyCount ? matches[i + 1].matchStart : frontMatter.length);
313
+ parseEntry(key, m.line, m.rawStart, m.isBlock, m.inlineEnd, nextStart);
314
+ // Core 1.1: the lines after the key's own line, up to the next key, belong to its block or to nothing
315
+ if (strict) {
316
+ const lineEnd = m.isBlock ? m.rawStart - 1 : (m.inlineEnd ?? frontMatter.indexOf('\n', m.rawStart));
317
+ // no line after the key's own one: nothing to check, the usual case
318
+ if (lineEnd !== -1 && lineEnd + 1 < nextStart) {
319
+ const owner = m.isBlock ? 'mapping'
320
+ : isBlockScalarMarker(frontMatter, m.rawStart, lineEnd) ? 'scalar' : 'inline';
321
+ checkRangeLines(frontMatter, lineEnd + 1, nextStart, m.line + 1, owner, m.rawStart, lineEnd);
322
+ }
323
+ }
296
324
  }
297
325
  }
298
326
  // Core §9 nesting depth, over Core's own reference-inert tree. When
@@ -308,13 +336,35 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
308
336
  return root;
309
337
  };
310
338
  /**
311
- * Parses Lima Core 1.0 syntax into the internal annotated value tree —
339
+ * Parses Lima Core 1.1 syntax into the internal annotated value tree —
312
340
  * every node carrying its source line, string leaves additionally carrying
313
341
  * whether they came from quoted syntax. `($key)`/`(%key)` text is left
314
342
  * exactly as written; nothing here ever inspects or resolves it. The
315
343
  * primitive the References layer (`references.ts`) builds on.
316
344
  */
317
345
  export const parseCoreWithPositions = (frontMatter, ctx) => parseCoreGeneric(frontMatter, ctx, positionedBuilder, depthOfPositioned);
346
+ /**
347
+ * Position-aware Core parse plus every active References 2.0 token seen while
348
+ * values are built. Recording at construction time preserves tokens from
349
+ * earlier duplicate-key values even though the result map is last-value-wins.
350
+ */
351
+ export const parseCoreWithPositionedReferences = (frontMatter, ctx) => {
352
+ const references = [];
353
+ const recordingBuilder = {
354
+ ...positionedBuilder,
355
+ string: (value, line, quoted, source) => {
356
+ const positioned = positionedBuilder.string(value, line, quoted, source);
357
+ if (positioned.kind === 'string' && positioned.references2) {
358
+ references.push(...positioned.references2);
359
+ }
360
+ return positioned;
361
+ },
362
+ };
363
+ return {
364
+ document: parseCoreGeneric(frontMatter, ctx, recordingBuilder, depthOfPositioned),
365
+ references,
366
+ };
367
+ };
318
368
  /**
319
369
  * The `ValueBuilder<NativeValue>` — `parseCore`'s fast path. Every scalar
320
370
  * builder is the identity function: unlike `positionedBuilder`, there is no
@@ -402,7 +452,7 @@ export const toNativeFromPositioned = (v) => {
402
452
  }
403
453
  };
404
454
  /**
405
- * Public Core 1.0 entry point. Never resolves `($key)`/`(%key)` text — see
455
+ * Public Core 1.1 entry point. Never resolves `($key)`/`(%key)` text — see
406
456
  * the module doc comment. Equivalent in observable behavior to calling
407
457
  * `parseReferences()` with no partials on a document that happens to
408
458
  * contain no references, except that here reference-shaped text is
package/dist/errors.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * module directly rather than duplicating it) inspect *why* a parse failed
9
9
  * without re-parsing the message string.
10
10
  */
11
- export type LimaDiagnosticCode = 'INVALID_ESCAPE' | 'INVALID_QUOTE' | 'INVALID_DATE' | 'INVALID_NUMBER' | 'INVALID_REFERENCE_SHAPE' | 'INVALID_INDENTATION' | 'INVALID_FLOW_SYNTAX' | 'DUPLICATE_KEY' | 'RESOURCE_LIMIT' | 'UNRESOLVED_REFERENCE' | 'INVALID_INTERPOLATION' | 'INVALID_PARTIAL';
11
+ export type LimaDiagnosticCode = 'INVALID_ESCAPE' | 'INVALID_QUOTE' | 'INVALID_DATE' | 'INVALID_NUMBER' | 'INVALID_REFERENCE_SHAPE' | 'INVALID_INDENTATION' | 'UNRECOGNIZED_LINE' | 'INVALID_FLOW_SYNTAX' | 'DUPLICATE_KEY' | 'RESOURCE_LIMIT' | 'UNRESOLVED_REFERENCE' | 'INVALID_INTERPOLATION' | 'INVALID_PARTIAL';
12
12
  export interface LimaDiagnostic {
13
13
  code: LimaDiagnosticCode;
14
14
  message: string;
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public entry point. The implementation is split per Appendix B's own
5
5
  * layering:
6
- * - `core.ts` — Lima Core 1.0, reference-unaware by construction.
6
+ * - `core.ts` — Lima Core 1.1, reference-unaware by construction.
7
7
  * - `references2.ts` — the References 2.0 extension layered on Core.
8
8
  * - `references.ts` — the frozen internal References 1.0 implementation.
9
9
  * - `value.ts` — the shared Lima Value Model both build on.
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public entry point. The implementation is split per Appendix B's own
5
5
  * layering:
6
- * - `core.ts` — Lima Core 1.0, reference-unaware by construction.
6
+ * - `core.ts` — Lima Core 1.1, reference-unaware by construction.
7
7
  * - `references2.ts` — the References 2.0 extension layered on Core.
8
8
  * - `references.ts` — the frozen internal References 1.0 implementation.
9
9
  * - `value.ts` — the shared Lima Value Model both build on.
@@ -39,5 +39,13 @@ export type ReferenceSource = {
39
39
  col: number;
40
40
  tabAdjust?: number[];
41
41
  };
42
+ export declare const SEGMENT = "[a-zA-Z0-9_][a-zA-Z0-9_:-]*";
43
+ export declare const DOC_PATH = "[a-zA-Z0-9_][a-zA-Z0-9_:-]*(?:\\.[a-zA-Z0-9_][a-zA-Z0-9_:-]*)*";
44
+ export declare const PARTIAL_NAME = "[a-zA-Z0-9_][a-zA-Z0-9_:/-]*";
45
+ export declare const PARTIAL_PATH = "[a-zA-Z0-9_][a-zA-Z0-9_:/-]*(?:\\.[a-zA-Z0-9_][a-zA-Z0-9_:-]*)*";
42
46
  export declare const PURE_REFERENCE_2: RegExp;
47
+ /** Converts a 0-based codepoint offset to JavaScript/LSP UTF-16 units. */
48
+ export declare const codepointOffsetToUtf16: (text: string, codepointOffset: number) => number;
49
+ /** Converts a 0-based JavaScript/LSP UTF-16 offset to a codepoint offset. */
50
+ export declare const utf16OffsetToCodepoint: (text: string, utf16Offset: number) => number;
43
51
  export declare const scanReferenceTokens2: (value: string, source: ReferenceSource | undefined) => ReferenceToken2[];
@@ -1,20 +1,42 @@
1
1
  /** Reference tokens recorded while the annotated Core tree is being built. */
2
- const SEGMENT = '[a-zA-Z0-9_][a-zA-Z0-9_:-]*';
3
- const DOC_PATH = `${SEGMENT}(?:\\.${SEGMENT})*`;
4
- const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
5
- const PARTIAL_PATH = `${PARTIAL_NAME}(?:\\.${SEGMENT})*`;
2
+ export const SEGMENT = '[a-zA-Z0-9_][a-zA-Z0-9_:-]*';
3
+ export const DOC_PATH = `${SEGMENT}(?:\\.${SEGMENT})*`;
4
+ export const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
5
+ export const PARTIAL_PATH = `${PARTIAL_NAME}(?:\\.${SEGMENT})*`;
6
6
  export const PURE_REFERENCE_2 = new RegExp(`^(?:\\$\\{(${DOC_PATH})\\}|\\$\\((${PARTIAL_PATH})\\))$`);
7
7
  const REFERENCE_2 = new RegExp(`\\$\\{(${DOC_PATH})\\}|\\$\\((${PARTIAL_PATH})\\)`, 'g');
8
8
  const NO_TOKENS = [];
9
+ const utf16WidthAt = (text, offset) => text.codePointAt(offset) > 0xffff ? 2 : 1;
9
10
  /** Codepoint length of the UTF-16 slice `[a, b)` of `s`. */
10
11
  const codepointLen = (s, a, b) => {
11
12
  let n = 0;
12
13
  for (let i = a; i < b;) {
13
- i += s.codePointAt(i) > 0xffff ? 2 : 1;
14
+ i += utf16WidthAt(s, i);
14
15
  n++;
15
16
  }
16
17
  return n;
17
18
  };
19
+ /** Converts a 0-based codepoint offset to JavaScript/LSP UTF-16 units. */
20
+ export const codepointOffsetToUtf16 = (text, codepointOffset) => {
21
+ let utf16Offset = 0;
22
+ let codepoints = 0;
23
+ while (utf16Offset < text.length && codepoints < codepointOffset) {
24
+ utf16Offset += utf16WidthAt(text, utf16Offset);
25
+ codepoints++;
26
+ }
27
+ return utf16Offset;
28
+ };
29
+ /** Converts a 0-based JavaScript/LSP UTF-16 offset to a codepoint offset. */
30
+ export const utf16OffsetToCodepoint = (text, utf16Offset) => {
31
+ let currentUtf16 = 0;
32
+ let codepoints = 0;
33
+ const limit = Math.min(Math.max(utf16Offset, 0), text.length);
34
+ while (currentUtf16 < limit) {
35
+ currentUtf16 += utf16WidthAt(text, currentUtf16);
36
+ codepoints++;
37
+ }
38
+ return codepoints;
39
+ };
18
40
  /** Splice points (decoded value order): token text, UTF-16 index, path. */
19
41
  const scanDecoded = (value) => {
20
42
  REFERENCE_2.lastIndex = 0;
@@ -1,5 +1,19 @@
1
- import { type Diagnostic } from './core.js';
1
+ import { type Diagnostic, type PositionedReferenceParse, type PositionedValue } from './core.js';
2
2
  type Meta = Record<string, unknown>;
3
+ /**
4
+ * Resolve one document-reference path with the same traversal, cycle checks,
5
+ * and edge budget used by the public References 2.0 parser. The returned
6
+ * positioned value retains the definition site's source line.
7
+ */
8
+ export type DocumentReferenceTargetResolution = {
9
+ status: 'resolved';
10
+ target: PositionedValue;
11
+ } | {
12
+ status: 'unverifiable-partials';
13
+ } | {
14
+ status: 'unresolved';
15
+ };
16
+ export declare const resolveDocumentReferenceTarget: (parsed: PositionedReferenceParse, path: string) => DocumentReferenceTargetResolution;
3
17
  export type ParseMode = 'references' | 'core';
4
18
  export type ParseOptions = {
5
19
  mode?: ParseMode;
@@ -7,6 +7,10 @@ import { collectAllParticipants, deepCopyPositioned, earliestParticipant, emptyM
7
7
  const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
8
8
  const PARTIAL_NAME_RE = new RegExp(`^${PARTIAL_NAME}$`);
9
9
  const MAX_EDGES = 3;
10
+ // Looking up the root path is itself the first reference edge; recursion into
11
+ // that root therefore gets the public resolver's remaining two-edge budget.
12
+ const TARGET_EDGE_BUDGET = MAX_EDGES - 1;
13
+ const referenceResourceStatuses = new WeakMap();
10
14
  /** Copies a pure-reference target and stamps the new insertion without
11
15
  * discarding provenance already attached to the copied root (R-112). */
12
16
  const copyWithInsertion = (target, insertedAt) => {
@@ -44,6 +48,91 @@ const documentTarget = (root, path) => {
44
48
  }
45
49
  return { root: value, tail: segments.slice(index) };
46
50
  };
51
+ export const resolveDocumentReferenceTarget = (parsed, path) => {
52
+ const { document } = parsed;
53
+ const target = documentTarget(document, path);
54
+ if (target === undefined)
55
+ return { status: 'unresolved' };
56
+ const resourceStatus = documentReferenceResourceStatus(parsed);
57
+ if (resourceStatus === 'unverifiable-partials')
58
+ return { status: resourceStatus };
59
+ if (resourceStatus === 'invalid')
60
+ return { status: 'unresolved' };
61
+ const ctx = { diagnostics: [], cache: new WeakMap() };
62
+ const stack = new Set([target.root]);
63
+ const resolved = resolveNode(target.root, document, new Map(), ctx, TARGET_EDGE_BUDGET, stack);
64
+ if (!resolved.complete || ctx.diagnostics.length > 0)
65
+ return { status: 'unresolved' };
66
+ const selected = lookupPath(resolved.value, target.tail);
67
+ return selected === undefined
68
+ ? { status: 'unresolved' }
69
+ : { status: 'resolved', target: selected };
70
+ };
71
+ const resolveDocument = (document, partials, ctx) => {
72
+ const resolved = new Map();
73
+ for (const [key, value] of document) {
74
+ const stack = new Set([value]);
75
+ resolved.set(key, resolveNode(value, document, partials, ctx, MAX_EDGES, stack).value);
76
+ }
77
+ return resolved;
78
+ };
79
+ const assertResolvedDocumentResourceLimits = (resolved) => {
80
+ const finalized = [...resolved].map(([key, value]) => [key, finalizePositioned(value)]);
81
+ const depth = finalized.length === 0 ? 0 : Math.max(...finalized.map(([, value]) => value.depth));
82
+ if (depth > NESTING_DEPTH_LIMIT) {
83
+ const participants = finalized
84
+ .filter(([, value]) => value.depth === depth)
85
+ .flatMap(([, value]) => value.deepestParticipants);
86
+ const winner = earliestParticipant(participants);
87
+ throw new LimaError({
88
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
89
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
90
+ message: `Lima: nesting depth exceeds maximum of ${NESTING_DEPTH_LIMIT} at line ${winner?.line ?? 1}`,
91
+ });
92
+ }
93
+ let nodeCount = 1;
94
+ for (const [, value] of finalized)
95
+ nodeCount += value.nodeCount;
96
+ if (nodeCount > RESULT_NODE_LIMIT) {
97
+ const participants = [];
98
+ for (const value of resolved.values())
99
+ collectAllParticipants(value, participants);
100
+ const winner = earliestParticipant(participants);
101
+ throw new LimaError({
102
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
103
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
104
+ message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
105
+ });
106
+ }
107
+ return finalized;
108
+ };
109
+ const documentReferenceResourceStatus = (parsed) => {
110
+ // Partial values are supplied by caller code and can change whole-document
111
+ // depth/node limits. With no file convention for them, editor tooling cannot
112
+ // prove that even an unrelated document reference is safe to resolve.
113
+ if (parsed.references.some((reference) => reference.partialPath !== undefined))
114
+ return 'unverifiable-partials';
115
+ const { document } = parsed;
116
+ const cached = referenceResourceStatuses.get(document);
117
+ if (cached !== undefined)
118
+ return cached;
119
+ const ctx = { diagnostics: [], cache: new WeakMap() };
120
+ const resolved = resolveDocument(document, new Map(), ctx);
121
+ let valid = !ctx.diagnostics.some((diagnostic) => diagnostic.code === 'RESOURCE_LIMIT');
122
+ if (valid) {
123
+ try {
124
+ assertResolvedDocumentResourceLimits(resolved);
125
+ }
126
+ catch (error) {
127
+ if (!(error instanceof LimaError) || error.code !== 'RESOURCE_LIMIT')
128
+ throw error;
129
+ valid = false;
130
+ }
131
+ }
132
+ const status = valid ? 'valid' : 'invalid';
133
+ referenceResourceStatuses.set(document, status);
134
+ return status;
135
+ };
47
136
  const lookupPartial = (partials, path) => {
48
137
  const dot = path.indexOf('.');
49
138
  if (dot < 0)
@@ -284,11 +373,7 @@ const parseInternal = (frontMatter, options, useResolveCache) => {
284
373
  }
285
374
  const document = parseCoreWithPositions(frontMatter, { strict: options?.strict ?? false, onWarning: options?.onWarning });
286
375
  const ctx = { diagnostics: [], ...(useResolveCache ? { cache: new WeakMap() } : {}) };
287
- const resolved = new Map();
288
- for (const [key, value] of document) {
289
- const stack = new Set([value]);
290
- resolved.set(key, resolveNode(value, document, partials, ctx, MAX_EDGES, stack).value);
291
- }
376
+ const resolved = resolveDocument(document, partials, ctx);
292
377
  if (options?.strict)
293
378
  for (const value of resolved.values())
294
379
  scanUnresolved(value, ctx);
@@ -299,31 +384,7 @@ const parseInternal = (frontMatter, options, useResolveCache) => {
299
384
  // already do; `offset` was internal-only here.
300
385
  throw new LimaError({ ...winner, column: winner.offset + 1 });
301
386
  }
302
- const finalized = [...resolved].map(([key, value]) => [key, finalizePositioned(value)]);
303
- const depth = finalized.length === 0 ? 0 : Math.max(...finalized.map(([, value]) => value.depth));
304
- if (depth > NESTING_DEPTH_LIMIT) {
305
- const participants = finalized.filter(([, value]) => value.depth === depth).flatMap(([, value]) => value.deepestParticipants);
306
- const winner = earliestParticipant(participants);
307
- throw new LimaError({
308
- code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
309
- ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
310
- message: `Lima: nesting depth exceeds maximum of ${NESTING_DEPTH_LIMIT} at line ${winner?.line ?? 1}`,
311
- });
312
- }
313
- let nodeCount = 1;
314
- for (const [, value] of finalized)
315
- nodeCount += value.nodeCount;
316
- if (nodeCount > RESULT_NODE_LIMIT) {
317
- const participants = [];
318
- for (const value of resolved.values())
319
- collectAllParticipants(value, participants);
320
- const winner = earliestParticipant(participants);
321
- throw new LimaError({
322
- code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
323
- ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
324
- message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
325
- });
326
- }
387
+ const finalized = assertResolvedDocumentResourceLimits(resolved);
327
388
  const out = emptyMapping();
328
389
  for (const [key, value] of finalized)
329
390
  out[key] = value.native;
package/dist/scalars.js CHANGED
@@ -13,6 +13,7 @@ export const hasActiveReferences2 = (value) => value.kind === 'string' ? (value.
13
13
  : false;
14
14
  /** The `ValueBuilder<PositionedValue>` — reconstructs today's annotated tree exactly, for References. */
15
15
  export const positionedBuilder = {
16
+ positions: true,
16
17
  null: (line) => ({ kind: 'null', line }),
17
18
  bool: (value, line) => ({ kind: 'bool', value, line }),
18
19
  int: (value, line) => ({ kind: 'int', value, line }),
package/dist/scanner.js CHANGED
@@ -59,6 +59,10 @@ const matchSeparator = (s, pos) => {
59
59
  i++; // consume spaces
60
60
  if (i < s.length && s.charCodeAt(i) === 10)
61
61
  return i + 1;
62
+ // Core 1.1: the end of the input ends the key's line as a newline does, so a last `key:` is `key: null`, as it
63
+ // is at any nested level (it was no key at all at the top level before)
64
+ if (i === s.length)
65
+ return i;
62
66
  // Backtracking a pure-space run can never reveal a `\n` the maximal
63
67
  // scan didn't already see (every intermediate position is followed by
64
68
  // either another space or the same non-`\n` stop character) — so
@@ -0,0 +1,13 @@
1
+ /**
2
+ * What the lines after a key belong to: nothing (`none`, before the first key; `inline`, after an inline value), a
3
+ * block mapping or sequence (`mapping`, after a bare key) or a `|` scalar (`scalar`).
4
+ */
5
+ export type RangeOwner = 'none' | 'inline' | 'mapping' | 'scalar';
6
+ /**
7
+ * Checks the lines of `frontMatter` from `start` (a line start) to `end` that follow a key owned as `owner`; `line`
8
+ * is the 1-based line at `start`; `valueStart` to `valueEnd` is the inline value of that key, read only for the hint a
9
+ * YAML marker earns in the error. Throws for the first line that belongs to no structure.
10
+ */
11
+ export declare function checkRangeLines(frontMatter: string, start: number, end: number, line: number, owner: RangeOwner, valueStart?: number, valueEnd?: number): void;
12
+ /** Whether the inline value that starts at `rawStart` and ends its line at `lineEnd` is a `|` block scalar marker. */
13
+ export declare const isBlockScalarMarker: (frontMatter: string, rawStart: number, lineEnd: number) => boolean;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Core 1.1 §4, §7.1 rule 5 and §10.1: in strict mode every content line belongs to the structure. Between two
3
+ * top-level keys (and before the first), a line that is neither blank nor a comment is
4
+ *
5
+ * - unrecognised (`UNRECOGNIZED_LINE`) when it starts at column 0: it matches no key pattern, or the scanner would
6
+ * have ended the range at it;
7
+ * A sequence that starts at column 0 under a bare key is that key's (Core 1.1 §7.2), up to the first line at
8
+ * column 0 that is no item.
9
+ * - inconsistently indented (`INVALID_INDENTATION`) when it is indented and no block owns it: before the first key;
10
+ * after a key with an inline value other than `|`, which opens no block; under a block mapping, but less indented
11
+ * than its first line (its base indentation, §7.1 rule 1), where the block parser stops; or after a `|` scalar
12
+ * that a line at column 0 ended (§6.1.5).
13
+ *
14
+ * The lines a block mapping or a `|` scalar owns are checked where they are parsed. Non-strict mode skips all of
15
+ * these lines, as Core 1.0 did in both modes.
16
+ */
17
+ import { isTrimWhitespace } from './chars.js';
18
+ import { LimaError } from './errors.js';
19
+ /**
20
+ * Checks the lines of `frontMatter` from `start` (a line start) to `end` that follow a key owned as `owner`; `line`
21
+ * is the 1-based line at `start`; `valueStart` to `valueEnd` is the inline value of that key, read only for the hint a
22
+ * YAML marker earns in the error. Throws for the first line that belongs to no structure.
23
+ */
24
+ export function checkRangeLines(frontMatter, start, end, line, owner, valueStart = 0, valueEnd = 0) {
25
+ let pos = start;
26
+ let current = line;
27
+ let base = -1; // a block mapping's base indentation, once its first content line set it
28
+ let scalarOpen = owner === 'scalar';
29
+ while (pos < end) {
30
+ const lineEnd = frontMatter.indexOf('\n', pos);
31
+ const stop = lineEnd === -1 || lineEnd > end ? end : lineEnd;
32
+ // positions, not substrings: a line is read out of `frontMatter` only for an error message
33
+ let spaces = pos;
34
+ while (spaces < stop && frontMatter.charCodeAt(spaces) === 32)
35
+ spaces++;
36
+ const indent = spaces - pos;
37
+ // §6.1.5: a `|` scalar holds every line up to the first non-empty one at its key's column (0), `#` included
38
+ if (scalarOpen && (spaces === stop || indent > 0)) {
39
+ pos = lineEnd + 1;
40
+ current++;
41
+ if (lineEnd === -1 || lineEnd >= end)
42
+ break;
43
+ continue;
44
+ }
45
+ scalarOpen = false;
46
+ // blank and comment lines by Lima's own whitespace; indentation by ASCII space only (§3 expanded the tabs)
47
+ let first = pos;
48
+ while (first < stop && isTrimWhitespace(frontMatter.charCodeAt(first)))
49
+ first++;
50
+ if (first < stop && frontMatter.charCodeAt(first) !== 35) {
51
+ // §7.2: a bare key's sequence may start at its own column, 0 here; it ends at the first line there that is no
52
+ // item
53
+ const item = indent === 0 && isItemAt(frontMatter, pos, stop);
54
+ if (owner === 'mapping' && base === -1 && item)
55
+ base = 0;
56
+ if (indent === 0 && !(base === 0 && item)) {
57
+ const text = frontMatter.slice(pos, stop);
58
+ throw new LimaError({
59
+ code: 'UNRECOGNIZED_LINE', line: current,
60
+ message: `Lima: unrecognised line at line ${current}: "${text}"${hint(text)}`,
61
+ });
62
+ }
63
+ if (owner === 'mapping' && base === -1)
64
+ base = indent;
65
+ if (indent > 0 && !(owner === 'mapping' && indent >= base)) {
66
+ throw new LimaError({
67
+ code: 'INVALID_INDENTATION', line: current,
68
+ message: `Lima: unexpected indentation at line ${current}: "${frontMatter.slice(first, stop)}"${owner === 'inline' ? yamlHint(frontMatter.slice(valueStart, valueEnd).trim()) : ''}`,
69
+ });
70
+ }
71
+ }
72
+ if (lineEnd === -1 || lineEnd >= end)
73
+ break;
74
+ pos = lineEnd + 1;
75
+ current++;
76
+ }
77
+ }
78
+ /** Whether the inline value that starts at `rawStart` and ends its line at `lineEnd` is a `|` block scalar marker. */
79
+ export const isBlockScalarMarker = (frontMatter, rawStart, lineEnd) => {
80
+ let i = rawStart, j = lineEnd;
81
+ while (i < j && isTrimWhitespace(frontMatter.charCodeAt(i)))
82
+ i++;
83
+ while (j > i && isTrimWhitespace(frontMatter.charCodeAt(j - 1)))
84
+ j--;
85
+ return j - i === 1 && frontMatter.charCodeAt(i) === 124;
86
+ };
87
+ /** What a reader likely meant by an unrecognised line, as a hint after the message; none for anything else. */
88
+ const hint = (text) => {
89
+ if (text === '---' || text === '...')
90
+ return ' (a YAML document marker; Lima has one document)';
91
+ if (isItem(text))
92
+ return ' (a sequence item belongs to a key with no value of its own)';
93
+ if (text.startsWith('"') || text.startsWith("'"))
94
+ return ' (a quoted key closes on its own line)';
95
+ // `author Odile`, `key:value`: key characters, but no ": " after them
96
+ if (!text.includes(':') || /^[a-zA-Z0-9_][a-zA-Z0-9_:-]*$/.test(text.split(' ')[0]))
97
+ return ' (a key needs ": " after it)';
98
+ return ' (an unquoted key has letters, digits, "_", "-" and ":" only; quote any other)';
99
+ };
100
+ /** The hint a YAML block scalar marker in place of a value earns; none for any other value. */
101
+ const yamlHint = (value) => {
102
+ if (value === '>' || value === '>-' || value === '>+')
103
+ return ` ("${value}" is YAML's folded scalar; Lima writes "|" and continues a line with "^^")`;
104
+ if (value === '|-' || value === '|+')
105
+ return ` ("${value}" is a YAML chomping indicator; Lima writes "|")`;
106
+ return '';
107
+ };
108
+ /** §7.2: `-` alone or `-` followed by whitespace begins a sequence item (Core 1.1); `---` or `-x` does not. */
109
+ const isItemAt = (s, start, end) => s.charCodeAt(start) === 45 && (end - start === 1 || isTrimWhitespace(s.charCodeAt(start + 1)));
110
+ const isItem = (text) => isItemAt(text, 0, text.length);
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@limaformat/lima",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Lima frontmatter parser — LIMA Is Metadata Annotation",
5
5
  "keywords": ["frontmatter", "parser", "yaml", "markdown", "metadata"],
6
6
  "homepage": "https://limaformat.dev",
7
7
  "author": "Michael Kortstiege",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/limaformat/lima",
10
+ "url": "git+https://github.com/limaformat/lima.git",
11
11
  "directory": "js"
12
12
  },
13
13
  "bugs": "https://github.com/limaformat/lima/issues",