@limaformat/lima 0.7.0 → 0.8.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,35 @@ carry their own.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.8.0] — 2026-11-05
16
+
17
+ ### Added
18
+
19
+ - `outline(input)` returns decoded key paths, key and separator spans, comments,
20
+ and parser diagnostics for editors. Positions use UTF-16 code units in the
21
+ BOM-free physical input, with tolerant recovery for unfinished documents.
22
+
23
+ ## [0.7.1] — 2026-10-31
24
+
25
+ ### Security
26
+
27
+ - A few KB of references could copy millions of nodes before the limit on the result stopped them, and interpolation
28
+ kept growing a value past its limit. Resolution now counts what it copies and stops (References §4.1), with the
29
+ error of an oversized result.
30
+
31
+ ### Fixed
32
+
33
+ - A value or sequence item followed by whitespace other than spaces kept it at the top level: `draft: false` with a no-break space
34
+ after it is now the boolean, as in a block (Core §4).
35
+ - `.0` and `-.0` are zero, not an underflow.
36
+ - A line with an empty key in a block is an unrecognised line.
37
+ - Spaces before a flow value in a sequence item threw an error without a Lima code.
38
+ - Messages no longer quote control or direction characters of the source, and shorten a long line.
39
+
40
+ ### Added
41
+
42
+ - `LimaError` is exported.
43
+
15
44
  ## [0.7.0] — 2026-10-30
16
45
 
17
46
  Lima Core 1.2 and References 2.1: an unquoted key may be written in any
package/README.md CHANGED
@@ -35,8 +35,9 @@ tags:
35
35
  `parse` accepts the raw content **between** the frontmatter delimiters
36
36
  (`---`) — stripping them is the caller's job.
37
37
 
38
- Three functions are exported:
38
+ Four functions are exported:
39
39
 
40
+ - `outline(input)` — source positions for editor keys, comments and diagnostics.
40
41
  - `parseCore(input, options?)` — the base format only.
41
42
  - `parse(input, options?)` — the primary References <!-- facts:language.references_version -->2.1<!-- /facts --> parser; adds
42
43
  `${key}` document references and `$(key)` external partials.
@@ -49,6 +50,29 @@ parse(frontmatter, { partials: { author: 'Alice' } })
49
50
  parse(frontmatter, { mode: 'core' }) // same reference-unaware path as parseCore
50
51
  ```
51
52
 
53
+ ## Editor outlines
54
+
55
+ ```ts
56
+ import { outline } from '@limaformat/lima'
57
+
58
+ const { keys, comments, diagnostics } = outline('author: {name: Ada} # byline')
59
+ // keys: paths ['author'] and ['author', 'name'], with key and separator spans
60
+ ```
61
+
62
+ `outline` tolerates unfinished content and returns keys in source order, including
63
+ all duplicate occurrences. Sequence paths include string indices, such as
64
+ `['authors', '0', 'name']`. Quoted keys are decoded; their spans include the quotes.
65
+ Comments run from `#` through the physical line's end, excluding its terminator.
66
+ Block scalar content contributes no keys or comments.
67
+
68
+ Spans are half-open. Lines and columns start at 1; columns and offsets count
69
+ UTF-16 code units (`offsetUnit: 'utf16-code-unit'`). Positions refer to the raw
70
+ input with a leading BOM removed; CRLF and tabs keep their original widths.
71
+ Diagnostics contain strict `parse`'s first error, followed by non-strict duplicate
72
+ warnings. A resource limit produces diagnostics and no spans. Structure remains
73
+ reference-inert: keys copied by reference substitution have no source occurrence
74
+ and are omitted; only the diagnostic pass evaluates references, without partials.
75
+
52
76
  The **[guide](https://github.com/limaformat/lima/blob/main/docs/guide.md)**
53
77
  covers Core <!-- facts:language.core_version -->1.2<!-- /facts --> and References <!-- facts:language.references_version -->2.1<!-- /facts --> syntax, resource limits, strict mode, and the
54
78
  public API. The normative rules are defined in the
package/dist/block.d.ts CHANGED
@@ -10,7 +10,10 @@
10
10
  */
11
11
  import { type ParseContext } from './normalize.js';
12
12
  import type { ValueBuilder } from './builder.js';
13
+ import { BlockCursor } from './block-cursor.js';
13
14
  export declare const findKeySep: (s: string) => number;
15
+ /** Shared block grammar consuming one mutable physical-line cursor. */
16
+ export declare const parseCursorBlock: <V, M>(cursor: BlockCursor, baseIndent: number, ctx: ParseContext, baseLine: number, builder: ValueBuilder<V, M>, compact?: boolean) => V | null;
14
17
  /** Complete block grammar over one UTF-16 source range. */
15
18
  export type BlockDepthRisk = {
16
19
  mayExceed: boolean;
package/dist/block.js CHANGED
@@ -13,7 +13,7 @@ import { stripKeyQuotes, stripComment, stripCommentKeepEscapes, parseQuotedOrTyp
13
13
  import { KEY_GRAMMAR_HINT, specificKeyHint } from './key-hints.js';
14
14
  import { parseFlowOrScalarValue, parseFlowSequence } from './flow.js';
15
15
  import { buildBlockScalar } from './block-scalar.js';
16
- import { LimaError } from './errors.js';
16
+ import { LimaError, printableLine } from './errors.js';
17
17
  import { isTrimWhitespace } from './chars.js';
18
18
  import { BlockCursor } from './block-cursor.js';
19
19
  const DASH_PREFIX_RE = /^-\s+/;
@@ -167,10 +167,10 @@ const cursorAfterDash = (cursor) => {
167
167
  /** Core 1.1 §10.1: a line in a block whose key the key grammar (§5.1) rejects, in strict mode. */
168
168
  const unrecognizedKeyLine = (line, trimmed) => new LimaError({
169
169
  code: 'UNRECOGNIZED_LINE', line,
170
- message: `Lima: unrecognised line at line ${line}: "${trimmed}"${specificKeyHint(trimmed) ?? KEY_GRAMMAR_HINT}`,
170
+ message: `Lima: unrecognised line at line ${line}: "${printableLine(trimmed)}"${specificKeyHint(trimmed) ?? KEY_GRAMMAR_HINT}`,
171
171
  });
172
172
  /** Shared block grammar consuming one mutable physical-line cursor. */
173
- const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact = false) => {
173
+ export const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact = false) => {
174
174
  const wantSource = builder.positions === true;
175
175
  let items = null;
176
176
  let entries = null;
@@ -208,7 +208,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
208
208
  const keyRaw = colonPos !== -1
209
209
  ? trimEndSlice(trimmed, 0, colonPos)
210
210
  : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
211
- if (keyRaw !== '' && !isValidKey(keyRaw)) {
211
+ if ((keyRaw !== '' || colonPos !== -1) && !isValidKey(keyRaw)) {
212
212
  // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
213
213
  // mode, as at the top level (Core 1.1 §10.1)
214
214
  if (ctx.strict)
@@ -268,9 +268,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
268
268
  if (items === null)
269
269
  items = [];
270
270
  const afterDashRaw = cursorAfterDash(cursor);
271
- let afterDash = afterDashRaw;
272
- if (afterDash.includes('#'))
273
- afterDash = stripComment(afterDash);
271
+ const afterDash = afterDashRaw.includes('#') ? stripComment(afterDashRaw) : afterDashRaw.trimEnd();
274
272
  const afterDashCol = wantSource ? cursor.indent + dashPrefixLen(cursor) : 0;
275
273
  const afterDashTab = wantSource && ctx.tabAdjust ? [ctx.tabAdjust[line - 1] ?? 0] : undefined;
276
274
  const dashSource = wantSource ? ({
@@ -331,7 +329,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
331
329
  // The key sits after `- `, two columns past the dash.
332
330
  const valueCol = wantSource ? afterDashCol + valueStart + leadingWsLen(afterDash.slice(valueStart)) : 0;
333
331
  pendingColumn = cursor.indent + dashPrefixLen(cursor);
334
- pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, trimSlice(afterDashRaw, valueStart, afterDashRaw.length) === '|', pendingColumn, line, valueCol, wantSource ? afterDashRaw.slice(valueStart) : raw, cursor, ctx, builder));
332
+ pendingItem = builder.createMappingWith(key, inlineOrBlockScalar(raw, trimSlice(afterDashRaw, valueStart, afterDashRaw.length) === '|', pendingColumn, line, valueCol, wantSource ? trimSlice(afterDashRaw, valueStart, afterDashRaw.length) : raw, cursor, ctx, builder));
335
333
  // the item's later keys at its key column; any other line is the block loop's (§7.2)
336
334
  while (cursor.valid && cursor.indent === pendingColumn) {
337
335
  const continuationLine = baseLine + cursor.lineIndex;
@@ -399,7 +397,7 @@ const parseCursorBlock = (cursor, baseIndent, ctx, baseLine, builder, compact =
399
397
  const keyRaw = colonPos !== -1
400
398
  ? trimEndSlice(trimmed, 0, colonPos)
401
399
  : bareKey ? trimEndSlice(trimmed, 0, trimmed.length - 1) : '';
402
- if (keyRaw !== '' && !isValidKey(keyRaw)) {
400
+ if ((keyRaw !== '' || colonPos !== -1) && !isValidKey(keyRaw)) {
403
401
  // §5.1: not a usable key — an unrecognised line, skipped in non-strict mode and an error in strict
404
402
  // mode, as at the top level (Core 1.1 §10.1)
405
403
  if (ctx.strict)
package/dist/core.js CHANGED
@@ -221,7 +221,7 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
221
221
  }
222
222
  const val = frontMatter.slice(rawStart, valueEnd);
223
223
  const hasHash = val.includes('#');
224
- const uncommented = hasHash ? stripComment(val) : val;
224
+ const uncommented = hasHash ? stripComment(val) : val.trimEnd();
225
225
  // The physical anchor keeps `\#` but drops a real comment, so a
226
226
  // `${…}`-shaped token inside a trailing comment is not scanned (§2.4).
227
227
  const source = wantSource ? {
package/dist/errors.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface LimaDiagnostic {
19
19
  partial?: string;
20
20
  path?: string;
21
21
  }
22
+ /** A line of the source for a message: shortened, and no control or direction character reaches a terminal. */
23
+ export declare const printableLine: (text: string) => string;
22
24
  export declare class LimaError extends Error {
23
25
  readonly code: LimaDiagnosticCode;
24
26
  readonly line?: number;
package/dist/errors.js CHANGED
@@ -8,6 +8,8 @@
8
8
  * module directly rather than duplicating it) inspect *why* a parse failed
9
9
  * without re-parsing the message string.
10
10
  */
11
+ /** A line of the source for a message: shortened, and no control or direction character reaches a terminal. */
12
+ export const printableLine = (text) => (text.length > 120 ? `${text.slice(0, 120)}…` : text).replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
11
13
  export class LimaError extends Error {
12
14
  code;
13
15
  line;
package/dist/flow.d.ts CHANGED
@@ -2,7 +2,25 @@
2
2
  import { type ParseContext } from './normalize.js';
3
3
  import type { ValueBuilder } from './builder.js';
4
4
  import type { ReferenceSource } from './reference-tokens2.js';
5
+ /** Stateful comma-item cursor over a flow container's original string. */
6
+ export declare class FlowCursor {
7
+ private readonly source;
8
+ private readonly end;
9
+ itemStart: number;
10
+ itemEnd: number;
11
+ private nextStart;
12
+ private done;
13
+ constructor(source: string, start: number, end: number);
14
+ next(): boolean;
15
+ get isLast(): boolean;
16
+ }
5
17
  export declare const parseFlowSequence: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V[] | null;
18
+ /**
19
+ * Index of the key/value `: ` separator within a flow-mapping item — the
20
+ * first `: ` that is not inside a quoted key (§5.1). Returns -1 when the
21
+ * item has no separator.
22
+ */
23
+ export declare const flowItemSeparator: (val: string, start: number, end: number) => number;
6
24
  export declare const parseFlowMapping: <V, M>(val: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V | null;
7
25
  /** Parses a value that may be a flow collection, without probing both flow parsers for ordinary scalars. */
8
26
  export declare const parseFlowOrScalarValue: <V, M>(raw: string, ctx: ParseContext, line: number, builder: ValueBuilder<V, M>, source?: ReferenceSource) => V;
package/dist/flow.js CHANGED
@@ -71,7 +71,7 @@ const trimEnd = (source, start, end) => {
71
71
  return end;
72
72
  };
73
73
  /** Stateful comma-item cursor over a flow container's original string. */
74
- class FlowCursor {
74
+ export class FlowCursor {
75
75
  source;
76
76
  end;
77
77
  itemStart = 0;
@@ -162,7 +162,7 @@ export const parseFlowSequence = (val, ctx, line, builder, source) => {
162
162
  * first `: ` that is not inside a quoted key (§5.1). Returns -1 when the
163
163
  * item has no separator.
164
164
  */
165
- const flowItemSeparator = (val, start, end) => {
165
+ export const flowItemSeparator = (val, start, end) => {
166
166
  let scan = start;
167
167
  if (val.charCodeAt(start) === 34 || val.charCodeAt(start) === 39) {
168
168
  // §5.2: a single-quoted key is literal, so its first `'` closes.
package/dist/index.d.ts CHANGED
@@ -12,5 +12,7 @@
12
12
  * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore, type CoreOptions, type Diagnostic } from './core.js';
15
+ export { LimaError, type LimaDiagnostic, type LimaDiagnosticCode } from './errors.js';
15
16
  export { parseReferences, parse, type ParseMode, type ParseOptions } from './references2.js';
16
17
  export type ReferencesOptions = import('./references2.js').ParseOptions;
18
+ export { outline, type LimaPoint, type LimaSpan, type LimaOutlineKey, type LimaOutline } from './outline.js';
package/dist/index.js CHANGED
@@ -12,4 +12,6 @@
12
12
  * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore } from './core.js';
15
+ export { LimaError } from './errors.js';
15
16
  export { parseReferences, parse } from './references2.js';
17
+ export { outline } from './outline.js';
@@ -0,0 +1,28 @@
1
+ import { type LimaDiagnostic } from './errors.js';
2
+ export interface LimaPoint {
3
+ /** One-based source line. */
4
+ readonly line: number;
5
+ /** One-based column in UTF-16 code units. */
6
+ readonly column: number;
7
+ /** UTF-16 code units from the start of the BOM-free input. */
8
+ readonly offset: number;
9
+ }
10
+ export interface LimaSpan {
11
+ readonly start: LimaPoint;
12
+ /** Exclusive end. */
13
+ readonly end: LimaPoint;
14
+ }
15
+ export interface LimaOutlineKey {
16
+ readonly key: string;
17
+ readonly path: readonly string[];
18
+ readonly span: LimaSpan;
19
+ readonly separator: LimaSpan;
20
+ }
21
+ export interface LimaOutline {
22
+ readonly offsetUnit: 'utf16-code-unit';
23
+ readonly keys: readonly LimaOutlineKey[];
24
+ readonly comments: readonly LimaSpan[];
25
+ readonly diagnostics: readonly LimaDiagnostic[];
26
+ }
27
+ /** Source structure for editors; references are only evaluated by the diagnostic oracle. */
28
+ export declare function outline(frontMatter: string): LimaOutline;
@@ -0,0 +1,339 @@
1
+ import { BlockCursor } from './block-cursor.js';
2
+ import { findKeySep, parseCursorBlock } from './block.js';
3
+ import { buildBlockScalar } from './block-scalar.js';
4
+ import { parseCore } from './core.js';
5
+ import { LimaError } from './errors.js';
6
+ import { FlowCursor, flowItemSeparator, parseFlowOrScalarValue } from './flow.js';
7
+ import { byteLength, DOCUMENT_SIZE_LIMIT, TOP_LEVEL_KEY_LIMIT, NESTING_DEPTH_LIMIT, checkKeyLength, checkDuplicateKey, } from './normalize.js';
8
+ import { parse } from './references2.js';
9
+ import { scanKeys } from './scanner.js';
10
+ import { isValidKey, stripComment, stripCommentKeepEscapes, stripKeyQuotes } from './scalars.js';
11
+ /** Core §3 normalization, with a boundary map back to the BOM-free physical input. */
12
+ function normalize(input) {
13
+ let source = '';
14
+ const offsets = [];
15
+ let leading = true;
16
+ for (let i = 0; i < input.length; i++) {
17
+ const c = input[i];
18
+ if (c === '\r' || c === '\n') {
19
+ while (source.endsWith(' ')) {
20
+ source = source.slice(0, -1);
21
+ offsets.pop();
22
+ }
23
+ offsets.push(i);
24
+ source += '\n';
25
+ if (c === '\r' && input[i + 1] === '\n')
26
+ i++;
27
+ leading = true;
28
+ }
29
+ else if (leading && c === '\t') {
30
+ source += ' ';
31
+ offsets.push(i, i);
32
+ }
33
+ else {
34
+ source += c;
35
+ offsets.push(i);
36
+ if (c !== ' ')
37
+ leading = false;
38
+ }
39
+ }
40
+ while (source.endsWith(' ')) {
41
+ source = source.slice(0, -1);
42
+ offsets.pop();
43
+ }
44
+ offsets.push(input.length);
45
+ return { source, offsets };
46
+ }
47
+ /** Editor positions count UTF-16 units, including CRLF's two units. */
48
+ function spanReader(input, offsets) {
49
+ const lines = [0];
50
+ for (let i = 0; i < input.length; i++) {
51
+ if (input[i] === '\r') {
52
+ if (input[i + 1] === '\n')
53
+ i++;
54
+ lines.push(i + 1);
55
+ }
56
+ else if (input[i] === '\n')
57
+ lines.push(i + 1);
58
+ }
59
+ const point = (position) => {
60
+ const offset = position;
61
+ let low = 0, high = lines.length;
62
+ while (low + 1 < high) {
63
+ const middle = (low + high) >>> 1;
64
+ if (lines[middle] <= offset)
65
+ low = middle;
66
+ else
67
+ high = middle;
68
+ }
69
+ return { line: low + 1, column: offset - lines[low] + 1, offset };
70
+ };
71
+ return (start, end) => ({ start: point(offsets[start]),
72
+ end: point(end > start ? offsets[end - 1] + 1 : offsets[end]) });
73
+ }
74
+ /** Physical flow sites use the parser's item and separator readers. */
75
+ function flowSites(source, start, end, line, sites) {
76
+ const value = source.slice(start, end).trimEnd();
77
+ const first = value[0], last = value.at(-1);
78
+ if (!((first === '{' && last === '}') || (first === '[' && last === ']')))
79
+ return;
80
+ const cursor = new FlowCursor(value, 1, value.length - 1);
81
+ while (cursor.next()) {
82
+ const a = cursor.itemStart, b = cursor.itemEnd;
83
+ if (first === '[') {
84
+ if (value[a] === '{')
85
+ flowSites(source, start + a, start + b, line, sites);
86
+ continue;
87
+ }
88
+ const colon = flowItemSeparator(value, a, b);
89
+ if (colon === -1)
90
+ continue;
91
+ const raw = value.slice(a, colon).trimEnd();
92
+ if (isValidKey(raw))
93
+ sites.push({ key: stripKeyQuotes(raw), start: start + a,
94
+ end: start + a + raw.length, colon: start + colon, line, sequence: false });
95
+ }
96
+ }
97
+ /**
98
+ * The real block/flow parsers build an occurrence tree. With warnings enabled,
99
+ * hasMappingKey records a key's line before its value is read. Sequence-item
100
+ * entries without duplicate checks are anchored by their value's first line.
101
+ * Physical sites are assigned in source order when walking the finished tree,
102
+ * so a flow key named like its enclosing key cannot steal the parent's site.
103
+ */
104
+ function outlineBuilder(currentLine) {
105
+ const leaf = (line) => ({ line });
106
+ return {
107
+ null: leaf, bool: (_, line) => leaf(line), int: (_, line) => leaf(line),
108
+ float: (_, line) => leaf(line), string: (_, line) => leaf(line), instant: (_, line) => leaf(line),
109
+ array: (items, line) => ({ line, items }), createMapping: () => [],
110
+ createMappingWith: (key, value) => [{ key, line: value.line, exact: false, sequence: true, value }],
111
+ hasMappingKey: (entries, key) => {
112
+ entries.push({ key, line: currentLine(), exact: true, sequence: false });
113
+ return false; // diagnostics come from separate public-parser passes
114
+ },
115
+ setMapping: (entries, key, value) => {
116
+ const entry = entries.findLast((e) => e.key === key && e.value === undefined);
117
+ if (entry)
118
+ entry.value = value;
119
+ else
120
+ entries.push({ key, line: value.line, exact: false, sequence: false, value });
121
+ },
122
+ mappingMaxDepth: () => 0, mapping: (entries, line) => ({ line, entries }),
123
+ };
124
+ }
125
+ /** Source structure for editors; references are only evaluated by the diagnostic oracle. */
126
+ export function outline(frontMatter) {
127
+ const diagnostics = [];
128
+ const keys = [];
129
+ const comments = [];
130
+ const result = { offsetUnit: 'utf16-code-unit', keys, comments, diagnostics };
131
+ const addWarning = (warning) => {
132
+ if (!diagnostics.some((d) => d.code === warning.code && d.line === warning.line && d.key === warning.key))
133
+ diagnostics.push(warning);
134
+ };
135
+ let limited = false;
136
+ try {
137
+ parse(frontMatter, { strict: true });
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof LimaError))
141
+ throw error;
142
+ diagnostics.push({ ...error, message: error.message });
143
+ limited = error.code === 'RESOURCE_LIMIT';
144
+ }
145
+ // Also check limits beyond strict mode's first error, and collect duplicate
146
+ // warnings without resolving references in the tolerant structural pass.
147
+ try {
148
+ parseCore(frontMatter, { onWarning: (warning) => addWarning(warning) });
149
+ }
150
+ catch (error) {
151
+ if (!(error instanceof LimaError))
152
+ throw error;
153
+ if (error.code === 'RESOURCE_LIMIT') {
154
+ limited = true;
155
+ if (!diagnostics.some((d) => d.code === error.code && d.line === error.line))
156
+ diagnostics.push({ ...error, message: error.message });
157
+ }
158
+ }
159
+ if (limited)
160
+ return result;
161
+ try {
162
+ if (byteLength(frontMatter) > DOCUMENT_SIZE_LIMIT)
163
+ throw new LimaError({ code: 'RESOURCE_LIMIT', line: 1,
164
+ message: `Lima: document exceeds maximum size of ${DOCUMENT_SIZE_LIMIT} bytes at line 1` });
165
+ const input = frontMatter.startsWith('\uFEFF') ? frontMatter.slice(1) : frontMatter;
166
+ const { source, offsets } = normalize(input);
167
+ const span = spanReader(input, offsets);
168
+ const matches = scanKeys(source);
169
+ if (matches.length > TOP_LEVEL_KEY_LIMIT)
170
+ throw new LimaError({ code: 'RESOURCE_LIMIT', line: 1,
171
+ message: `Lima: too many top-level key entries (max ${TOP_LEVEL_KEY_LIMIT}) at line 1` });
172
+ const scalarLines = new Set();
173
+ const ctx = { strict: false, onWarning: (warning) => addWarning(warning) };
174
+ const topKeys = new Set();
175
+ const emit = (site, path) => {
176
+ checkKeyLength(site.key, site.line);
177
+ const colon = span(site.colon, site.colon + 1);
178
+ const separator = input[colon.end.offset] === ' '
179
+ ? { start: colon.start, end: { ...colon.end, column: colon.end.column + 1, offset: colon.end.offset + 1 } }
180
+ : colon;
181
+ keys.push({ key: site.key, path: [...path, site.key], span: span(site.start, site.end), separator });
182
+ const lineEnd = source.indexOf('\n', site.colon);
183
+ const end = lineEnd === -1 ? source.length : lineEnd;
184
+ if (source.slice(site.colon + 1, end).trim() === '|') {
185
+ const body = source.slice(end + 1).split('\n');
186
+ if (body.at(-1) === '')
187
+ body.pop();
188
+ const column = site.start - (source.lastIndexOf('\n', site.start - 1) + 1);
189
+ const consumed = buildBlockScalar(body, column, site.line, ctx, outlineBuilder(() => site.line)).consumed;
190
+ for (let j = 1; j <= consumed; j++)
191
+ scalarLines.add(site.line + j);
192
+ }
193
+ };
194
+ const walk = (value, path, sites, position, depth = 0) => {
195
+ if (value.entries || value.items)
196
+ depth++;
197
+ if (depth > NESTING_DEPTH_LIMIT)
198
+ throw new LimaError({ code: 'RESOURCE_LIMIT', line: 1,
199
+ message: `Lima: nesting depth exceeds maximum of ${NESTING_DEPTH_LIMIT} at line 1` });
200
+ let keyColumn;
201
+ const seen = new Set();
202
+ for (const entry of value.entries ?? []) {
203
+ const site = sites.find((s) => s.key === entry.key && s.start > position.after &&
204
+ !scalarLines.has(s.line) && s.sequence === entry.sequence &&
205
+ (entry.exact ? s.line === entry.line : s.line <= entry.line) &&
206
+ (entry.exact || entry.sequence || keyColumn === undefined ||
207
+ s.start - (source.lastIndexOf('\n', s.start - 1) + 1) === keyColumn));
208
+ // Incomplete content can produce a tree entry without a readable
209
+ // physical key. Omit that entry and its children, then keep walking.
210
+ if (!site)
211
+ continue;
212
+ keyColumn ??= site.start - (source.lastIndexOf('\n', site.start - 1) + 1);
213
+ if (entry.exact)
214
+ checkDuplicateKey(seen.has(entry.key), entry.key, site.line, ctx);
215
+ seen.add(entry.key);
216
+ emit(site, path);
217
+ position.after = site.start;
218
+ if (entry.value)
219
+ walk(entry.value, [...path, entry.key], sites, position, depth);
220
+ }
221
+ value.items?.forEach((item, index) => walk(item, [...path, String(index)], sites, position, depth));
222
+ };
223
+ for (let i = 0; i < matches.length; i++) {
224
+ const match = matches[i];
225
+ const end = matches[i + 1]?.matchStart ?? source.length;
226
+ const key = match.unquoted ?? match.singleQuoted ?? stripKeyQuotes(source.slice(match.matchStart, match.sepStart - 1));
227
+ const site = { key, start: match.matchStart, end: match.sepStart - 1,
228
+ colon: match.sepStart - 1, line: match.line, sequence: false };
229
+ checkDuplicateKey(topKeys.has(key), key, site.line, ctx);
230
+ topKeys.add(key);
231
+ emit(site, []);
232
+ const sites = [];
233
+ const cursor = new BlockCursor(source, match.isBlock ? match.rawStart : match.matchStart, end);
234
+ while (cursor.next()) {
235
+ const line = (match.isBlock ? match.line + 1 : match.line) + cursor.lineIndex;
236
+ if (scalarLines.has(line) || cursor.empty || cursor.firstCode === 35)
237
+ continue;
238
+ let start = cursor.contentStart;
239
+ let sequence = false;
240
+ if (source[start] === '-' && (start + 1 === cursor.lineEnd || source[start + 1].trim() === '')) {
241
+ sequence = true;
242
+ start++;
243
+ while (start < cursor.lineEnd && source[start].trim() === '')
244
+ start++;
245
+ }
246
+ const content = source.slice(start, cursor.lineEnd).trimEnd();
247
+ let colon = findKeySep(content);
248
+ if (colon === -1 && content.endsWith(':'))
249
+ colon = content.length - 1;
250
+ const rawKey = colon === -1 ? '' : content.slice(0, colon).trimEnd();
251
+ let valueStart = start;
252
+ if (colon !== -1 && isValidKey(rawKey)) {
253
+ const candidate = { key: stripKeyQuotes(rawKey), start, end: start + rawKey.length,
254
+ colon: start + colon, line, sequence };
255
+ if (start !== match.matchStart)
256
+ sites.push(candidate);
257
+ valueStart = start + colon + 1;
258
+ while (valueStart < cursor.lineEnd && source[valueStart].trim() === '')
259
+ valueStart++;
260
+ }
261
+ const raw = stripCommentKeepEscapes(source.slice(valueStart, cursor.lineEnd));
262
+ flowSites(source, valueStart, valueStart + raw.length, line, sites);
263
+ }
264
+ if (match.isBlock) {
265
+ // Some malformed flows throw even in non-strict mode. Replace
266
+ // their value with a scalar (or skip an unreadable item) and retry.
267
+ let tolerant = source;
268
+ for (;;) {
269
+ const block = new BlockCursor(tolerant, match.rawStart, end);
270
+ while (block.next() && (block.empty || block.firstCode === 35)) { }
271
+ if (!block.valid || (block.asciiIndent === 0 && block.firstCode !== 45))
272
+ break;
273
+ const builder = outlineBuilder(() => match.line + 1 + block.lineIndex);
274
+ try {
275
+ const value = parseCursorBlock(block, block.asciiIndent, ctx, match.line + 1, builder, block.asciiIndent === 0);
276
+ if (value)
277
+ walk(value, [key], sites, { after: site.start });
278
+ break;
279
+ }
280
+ catch (error) {
281
+ if (!(error instanceof LimaError))
282
+ throw error;
283
+ if (error.code === 'RESOURCE_LIMIT')
284
+ throw error;
285
+ const line = match.line + 1 + block.lineIndex;
286
+ const keySite = sites.find((s) => s.line === line && (s.start === block.contentStart || s.sequence));
287
+ const a = keySite ? keySite.colon + 1 : block.lineStart, b = block.lineEnd;
288
+ const replacement = keySite ? ' 0'.padEnd(b - a, ' ') : ' '.repeat(b - a);
289
+ tolerant = tolerant.slice(0, a) + replacement + tolerant.slice(b);
290
+ }
291
+ }
292
+ }
293
+ else {
294
+ const raw = stripComment(source.slice(match.rawStart, match.inlineEnd ?? end)).trim();
295
+ if (raw !== '|') {
296
+ try {
297
+ walk(parseFlowOrScalarValue(raw, ctx, match.line, outlineBuilder(() => match.line)), [key], sites, { after: site.start });
298
+ }
299
+ catch (error) {
300
+ if (!(error instanceof LimaError) || error.code === 'RESOURCE_LIMIT')
301
+ throw error;
302
+ }
303
+ }
304
+ }
305
+ }
306
+ const lines = new BlockCursor(source, 0, source.length);
307
+ while (lines.next()) {
308
+ if (scalarLines.has(lines.lineIndex + 1))
309
+ continue;
310
+ let start = lines.contentStart;
311
+ // Quoted-key contents do not participate in value comment stripping.
312
+ const key = keys.find((k) => k.span.start.line === lines.lineIndex + 1);
313
+ if (key)
314
+ while (start < lines.lineEnd && offsets[start] < key.separator.end.offset)
315
+ start++;
316
+ const raw = source.slice(start, lines.lineEnd);
317
+ const kept = stripCommentKeepEscapes(raw);
318
+ if (kept.length !== raw.length) {
319
+ const hash = source.indexOf('#', start + kept.length);
320
+ const comment = span(hash, lines.lineEnd);
321
+ let end = comment.end.offset;
322
+ while (end < input.length && input[end] !== '\r' && input[end] !== '\n')
323
+ end++;
324
+ comments.push({ start: comment.start, end: { ...comment.end,
325
+ column: comment.end.column + end - comment.end.offset, offset: end } });
326
+ }
327
+ }
328
+ keys.sort((a, b) => a.span.start.offset - b.span.start.offset);
329
+ }
330
+ catch (error) {
331
+ if (!(error instanceof LimaError))
332
+ throw error;
333
+ keys.length = 0;
334
+ comments.length = 0;
335
+ if (!diagnostics.some((d) => d.code === error.code && d.line === error.line))
336
+ diagnostics.push({ ...error, message: error.message });
337
+ }
338
+ return result;
339
+ }
@@ -1,5 +1,5 @@
1
1
  /** Lima References 2.1 resolver. References 1.0 remains isolated in references.ts. */
2
- import { canonicalString, codepointLength, countNodes, ingestPartialValue, PARTIAL_COUNT_LIMIT, PARTIAL_NAME_LENGTH_LIMIT, PARTIAL_NODE_LIMIT, RESULT_NODE_LIMIT, SCALAR_LENGTH_LIMIT, } from './value.js';
2
+ import { canonicalString, codepointLength, countNodes, ingestPartialValue, PARTIAL_COUNT_LIMIT, PARTIAL_NAME_LENGTH_LIMIT, PARTIAL_NODE_LIMIT, RESOLUTION_COPY_LIMIT, RESULT_NODE_LIMIT, SCALAR_LENGTH_LIMIT, } from './value.js';
3
3
  import { isValidUnquotedKey } from './key-grammar.js';
4
4
  import { validateDocument } from './document.js';
5
5
  import { NESTING_DEPTH_LIMIT, parseCoreValidated, parseCoreWithPositions, toPlainValue, } from './core.js';
@@ -13,13 +13,44 @@ const TARGET_EDGE_BUDGET = MAX_EDGES - 1;
13
13
  const referenceResourceStatuses = new WeakMap();
14
14
  /** Copies a pure-reference target and stamps the new insertion without
15
15
  * discarding provenance already attached to the copied root (R-112). */
16
- const copyWithInsertion = (target, insertedAt) => {
16
+ const copyWithInsertion = (target, insertedAt, ctx) => {
17
+ const earliest = ctx.earliestInsertion;
18
+ ctx.earliestInsertion = earliest === null ? insertedAt : earliestParticipant([earliest, insertedAt]);
19
+ if (!ctx.overflowed) {
20
+ ctx.copied += countPositioned(target);
21
+ ctx.overflowed = ctx.copied > RESOLUTION_COPY_LIMIT;
22
+ }
23
+ // the result is lost by now: resolution only goes on to see the earliest insertion
24
+ if (ctx.overflowed)
25
+ return { kind: 'null', line: 0 };
17
26
  const copy = deepCopyPositioned(target);
18
27
  const priorInsertions = copy.insertedAt === undefined
19
28
  ? copy.priorInsertions
20
29
  : [...(copy.priorInsertions ?? []), copy.insertedAt];
21
30
  return { ...copy, insertedAt, priorInsertions };
22
31
  };
32
+ const countPositioned = (v) => {
33
+ let count = 1;
34
+ if (v.kind === 'array')
35
+ for (const item of v.items)
36
+ count += countPositioned(item);
37
+ if (v.kind === 'mapping')
38
+ for (const child of v.entries.values())
39
+ count += countPositioned(child);
40
+ return count;
41
+ };
42
+ const newContext = (cache) => ({
43
+ diagnostics: [], copied: 0, overflowed: false, earliestInsertion: null, ...(cache ? { cache } : {}),
44
+ });
45
+ /** The error of a document whose references would copy past RESOLUTION_COPY_LIMIT: that of an oversized result. */
46
+ const overflowError = (ctx) => {
47
+ const winner = ctx.earliestInsertion;
48
+ return new LimaError({
49
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
50
+ ...(winner?.offset !== undefined ? { column: winner.offset + 1 } : {}),
51
+ message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
52
+ });
53
+ };
23
54
  const addDiagnostic = (ctx, diagnostic, offset = 0) => {
24
55
  ctx.diagnostics.push({ ...diagnostic, offset });
25
56
  };
@@ -58,10 +89,10 @@ export const resolveDocumentReferenceTarget = (parsed, path) => {
58
89
  return { status: resourceStatus };
59
90
  if (resourceStatus === 'invalid')
60
91
  return { status: 'unresolved' };
61
- const ctx = { diagnostics: [], cache: new WeakMap() };
92
+ const ctx = newContext(new WeakMap());
62
93
  const stack = new Set([target.root]);
63
94
  const resolved = resolveNode(target.root, document, new Map(), ctx, TARGET_EDGE_BUDGET, stack);
64
- if (!resolved.complete || ctx.diagnostics.length > 0)
95
+ if (!resolved.complete || ctx.overflowed || ctx.diagnostics.length > 0)
65
96
  return { status: 'unresolved' };
66
97
  const selected = lookupPath(resolved.value, target.tail);
67
98
  return selected === undefined
@@ -116,9 +147,9 @@ const documentReferenceResourceStatus = (parsed) => {
116
147
  const cached = referenceResourceStatuses.get(document);
117
148
  if (cached !== undefined)
118
149
  return cached;
119
- const ctx = { diagnostics: [], cache: new WeakMap() };
150
+ const ctx = newContext(new WeakMap());
120
151
  const resolved = resolveDocument(document, new Map(), ctx);
121
- let valid = !ctx.diagnostics.some((diagnostic) => diagnostic.code === 'RESOURCE_LIMIT');
152
+ let valid = !ctx.overflowed && !ctx.diagnostics.some((diagnostic) => diagnostic.code === 'RESOURCE_LIMIT');
122
153
  if (valid) {
123
154
  try {
124
155
  assertResolvedDocumentResourceLimits(resolved);
@@ -220,7 +251,7 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
220
251
  // `partialToPositioned` never annotates partial targets: partials are
221
252
  // inert and have no source token before this document insertion.
222
253
  return {
223
- value: copyWithInsertion(target, { line: pure.line, offset: pure.offset, token: pure.token }),
254
+ value: copyWithInsertion(target, { line: pure.line, offset: pure.offset, token: pure.token }, ctx),
224
255
  complete: true,
225
256
  };
226
257
  }
@@ -235,7 +266,7 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
235
266
  return {
236
267
  // `selected` may itself be the root inserted by an earlier reference;
237
268
  // both it and this outer copy participate in final-structure errors.
238
- value: copyWithInsertion(selected, { line: pure.line, offset: pure.offset, token: pure.token }),
269
+ value: copyWithInsertion(selected, { line: pure.line, offset: pure.offset, token: pure.token }, ctx),
239
270
  complete: true,
240
271
  };
241
272
  }
@@ -280,6 +311,8 @@ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stac
280
311
  code: 'RESOURCE_LIMIT', line: first.line, token: first.token,
281
312
  message: `Lima: scalar exceeds maximum length of ${SCALAR_LENGTH_LIMIT} code points at line ${first.line}`,
282
313
  }, first.offset);
314
+ // the document has failed; a value that holds this one still exceeds the limit, and none grows further
315
+ output = output.slice(0, 2 * (SCALAR_LENGTH_LIMIT + 1));
283
316
  }
284
317
  return { value: { ...node, value: output, references2: unresolvedTokens }, complete };
285
318
  };
@@ -373,8 +406,10 @@ const parseInternal = (frontMatter, options, useResolveCache) => {
373
406
  return parseCoreValidated(frontMatter, options);
374
407
  }
375
408
  const document = parseCoreWithPositions(frontMatter, { strict: options?.strict ?? false, onWarning: options?.onWarning });
376
- const ctx = { diagnostics: [], ...(useResolveCache ? { cache: new WeakMap() } : {}) };
409
+ const ctx = newContext(useResolveCache ? new WeakMap() : undefined);
377
410
  const resolved = resolveDocument(document, partials, ctx);
411
+ if (ctx.overflowed)
412
+ throw overflowError(ctx);
378
413
  if (options?.strict)
379
414
  for (const value of resolved.values())
380
415
  scanUnresolved(value, ctx);
package/dist/scalars.js CHANGED
@@ -187,7 +187,7 @@ const parseDateUTC = (str, strict = false, line = 0) => {
187
187
  const NUMBER_RE = /^-?(?:(?:0|[1-9][0-9]*)(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
188
188
  const DATE_PRE_RE = /\d[\d\-:.\/a-zA-Z]{4,}/;
189
189
  const isFloatForm = (str) => str.includes('.') || str.includes('e') || str.includes('E');
190
- const isZeroLiteral = (str) => /^0+(\.0+)?$/.test(str.replace(/^-/, '').split(/[eE]/)[0]);
190
+ const isZeroLiteral = (str) => /^(?:0+(?:\.0+)?|\.0+)$/.test(str.replace(/^-/, '').split(/[eE]/)[0]);
191
191
  export const NO_SPAN_VALUE = Symbol('NO_SPAN_VALUE');
192
192
  /**
193
193
  * Parses allocation-free scalar forms whose complete grammar can be proven
package/dist/structure.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * these lines, as Core 1.0 did in both modes.
16
16
  */
17
17
  import { isTrimWhitespace } from './chars.js';
18
- import { LimaError } from './errors.js';
18
+ import { LimaError, printableLine } from './errors.js';
19
19
  import { isValidUnquotedKey } from './key-grammar.js';
20
20
  import { KEY_GRAMMAR_HINT, specificKeyHint } from './key-hints.js';
21
21
  /**
@@ -59,7 +59,7 @@ export function checkRangeLines(frontMatter, start, end, line, owner, valueStart
59
59
  const text = frontMatter.slice(pos, stop);
60
60
  throw new LimaError({
61
61
  code: 'UNRECOGNIZED_LINE', line: current,
62
- message: `Lima: unrecognised line at line ${current}: "${text}"${hint(text)}`,
62
+ message: `Lima: unrecognised line at line ${current}: "${printableLine(text)}"${hint(text)}`,
63
63
  });
64
64
  }
65
65
  if (owner === 'mapping' && base === -1)
package/dist/value.d.ts CHANGED
@@ -60,6 +60,12 @@ export declare const PARTIAL_COUNT_LIMIT = 128;
60
60
  export declare const PARTIAL_NAME_LENGTH_LIMIT = 128;
61
61
  export declare const PARTIAL_NODE_LIMIT = 4096;
62
62
  export declare const RESULT_NODE_LIMIT = 65536;
63
+ /**
64
+ * Nodes a resolution may copy (References §4.1: limits are validated during expansion). Each value is resolved
65
+ * for at most four edge budgets, so a document within RESULT_NODE_LIMIT stays far below this. Past it the result
66
+ * cannot fit, and copying on would only fill the memory: a few KB of references can reach millions of nodes.
67
+ */
68
+ export declare const RESOLUTION_COPY_LIMIT: number;
63
69
  export declare const SCALAR_LENGTH_LIMIT = 16384;
64
70
  /**
65
71
  * Counts Unicode code points (what every length-limit check in this
package/dist/value.js CHANGED
@@ -63,6 +63,12 @@ export const PARTIAL_COUNT_LIMIT = 128;
63
63
  export const PARTIAL_NAME_LENGTH_LIMIT = 128;
64
64
  export const PARTIAL_NODE_LIMIT = 4096;
65
65
  export const RESULT_NODE_LIMIT = 65536;
66
+ /**
67
+ * Nodes a resolution may copy (References §4.1: limits are validated during expansion). Each value is resolved
68
+ * for at most four edge budgets, so a document within RESULT_NODE_LIMIT stays far below this. Past it the result
69
+ * cannot fit, and copying on would only fill the memory: a few KB of references can reach millions of nodes.
70
+ */
71
+ export const RESOLUTION_COPY_LIMIT = 16 * RESULT_NODE_LIMIT;
66
72
  export const SCALAR_LENGTH_LIMIT = 16384;
67
73
  /**
68
74
  * Counts Unicode code points (what every length-limit check in this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limaformat/lima",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",