@limaformat/lima 0.1.1 → 0.2.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,8 +12,22 @@ are both frozen regardless of what this file shows.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.2.0] — 2026-08-11
16
+
15
17
  ### Changed
16
18
 
19
+ - **Breaking:** `parse(input, options?)` now implements Lima References 2.0.
20
+ Document references use `$(key)` instead of `($key)`, and partial
21
+ references use `$(:key)` instead of `(%key)`. References 1.0 tokens are
22
+ literal text through the public 2.0 API.
23
+ - `parseReferences(input, options?)` is now a deprecated compatibility alias
24
+ for `parse` with identical References 2.0 results and diagnostics. It is not
25
+ a References 1.0 mode.
26
+ - `ParseOptions.mode` selects `"references"` (the default) or `"core"`.
27
+ Core mode uses the same reference-unaware path as `parseCore`; supplying
28
+ `partials` in Core mode is invalid. `parseCore` itself is unchanged.
29
+ - References 2.0 adds bounded transitive resolution (at most three reference
30
+ edges), partial mapping traversal, and `$(:namespace/name.path)` partials.
17
31
  - Error message prefix `LIMA:` → `Lima:` throughout (e.g. `LIMA: duplicate
18
32
  key "a" at line 3` → `Lima: duplicate key "a" at line 3`), plus internal
19
33
  code comments — naming-consistency fix, no functional change. `.code`
package/README.md CHANGED
@@ -35,30 +35,33 @@ tags:
35
35
  `parse` accepts the raw content **between** the frontmatter delimiters
36
36
  (`---`) — stripping them is the caller's job.
37
37
 
38
- Two functions are exported:
38
+ Three functions are exported:
39
39
 
40
40
  - `parseCore(input, options?)` — the base format only.
41
- - `parse(input, options?)` (alias for `parseReferences`) — adds
42
- `($key)` document references and `(%key)` external partials.
41
+ - `parse(input, options?)` — the primary References 2.0 parser; adds
42
+ `$(key)` document references and `$(:key)` external partials.
43
+ - `parseReferences(input, options?)` — deprecated compatibility alias for
44
+ `parse` with the same References 2.0 semantics.
43
45
 
44
46
  ```ts
45
47
  parse(frontmatter, { strict: true })
46
48
  parse(frontmatter, { partials: { author: 'Alice' } })
49
+ parse(frontmatter, { mode: 'core' }) // same reference-unaware path as parseCore
47
50
  ```
48
51
 
49
- Full syntax reference, the References extension, resource limits, strict
50
- mode, and the complete API:
51
- **[docs/guide.md](https://github.com/limaformat/lima/blob/main/docs/guide.md)**.
52
+ The **[guide](https://github.com/limaformat/lima/blob/main/docs/guide.md)**
53
+ covers Core and References 2.0 syntax, resource limits, strict mode, and the
54
+ public API. The normative rules are defined in the
55
+ **[References 2.0 specification](https://github.com/limaformat/lima/blob/main/docs/lima-references-2.0-spec.md)**.
52
56
 
53
57
  Why Lima exists, the case against YAML, and security rationale:
54
58
  **[repository README](https://github.com/limaformat/lima#readme)**.
55
59
 
56
60
  The [Lima Core 1.0](https://github.com/limaformat/lima/blob/main/docs/lima-core-1.0-spec.md)
57
- and [Lima References 1.0](https://github.com/limaformat/lima/blob/main/docs/lima-references-1.0-spec.md)
58
- specifications are the normative source of truth — this package implements
59
- them exactly, verified against a
60
- [250-case conformance corpus](https://github.com/limaformat/lima/tree/main/corpus)
61
- (count pinned by a test) shared with any other Lima implementation.
61
+ and [Lima References 2.0](https://github.com/limaformat/lima/blob/main/docs/lima-references-2.0-spec.md)
62
+ specifications are the source of truth for these public entry points. The
63
+ frozen References 1.0 implementation remains available internally to run its
64
+ independent conformance suite.
62
65
 
63
66
  Migrating existing YAML frontmatter:
64
67
  [docs/migrating-from-yaml.md](https://github.com/limaformat/lima/blob/main/docs/migrating-from-yaml.md).
package/dist/builder.d.ts CHANGED
@@ -25,11 +25,13 @@
25
25
  * therefore performance — is identical either way, verified by benchmark.
26
26
  */
27
27
  export interface ValueBuilder<V, M = Map<string, V>> {
28
+ /** Opt-in for References' annotated builder; native Core avoids source-map work. */
29
+ readonly tracksStringSourcePositions?: boolean;
28
30
  null(line: number): V;
29
31
  bool(value: boolean, line: number): V;
30
32
  int(value: number, line: number): V;
31
33
  float(value: number, line: number): V;
32
- string(value: string, line: number, quoted: boolean): V;
34
+ string(value: string, line: number, quoted: boolean, sourceSpans?: StringSourceSpan[]): V;
33
35
  instant(value: Date, line: number): V;
34
36
  array(items: V[], line: number): V;
35
37
  createMapping(): M;
@@ -39,3 +41,9 @@ export interface ValueBuilder<V, M = Map<string, V>> {
39
41
  mappingMaxDepth(entries: M, depthOf: (value: V) => number): number;
40
42
  mapping(entries: M, line: number): V;
41
43
  }
44
+ /** Maps a span in a decoded scalar back to its physical source line/column. */
45
+ export type StringSourceSpan = {
46
+ start: number;
47
+ line: number;
48
+ sourceOffset: number;
49
+ };
package/dist/core.js CHANGED
@@ -239,7 +239,9 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
239
239
  minIndent = Math.min(minIndent, key.length + 2);
240
240
  const trimAmt = minIndent > 1 && isFinite(minIndent) ? minIndent : 0;
241
241
  const mergedLines = [];
242
- for (const bodyLine of bodyLines) {
242
+ const mergedLineSpans = builder.tracksStringSourcePositions ? [] : undefined;
243
+ for (let bodyIndex = 0; bodyIndex < bodyLines.length; bodyIndex++) {
244
+ const bodyLine = bodyLines[bodyIndex];
243
245
  const lineLen = bodyLine.length;
244
246
  let start = trimAmt < lineLen ? trimAmt : lineLen;
245
247
  const isContinuation = bodyLine.charCodeAt(start) === 94 && bodyLine.charCodeAt(start + 1) === 94; // ^^
@@ -251,22 +253,41 @@ const parseCoreGeneric = (frontMatter, ctx, builder, computeDepth) => {
251
253
  const content = bodyLine.slice(start, end);
252
254
  if (isContinuation) {
253
255
  if (mergedLines.length > 0) {
254
- if (content)
256
+ if (content) {
257
+ const outputStart = mergedLines[mergedLines.length - 1].length + 1;
255
258
  mergedLines[mergedLines.length - 1] += ' ' + content;
259
+ mergedLineSpans?.[mergedLineSpans.length - 1].push({
260
+ start: outputStart, line: line + 1 + bodyIndex, sourceOffset: start,
261
+ });
262
+ }
256
263
  }
257
264
  else {
258
265
  mergedLines.push(content);
266
+ mergedLineSpans?.push(content ? [{ start: 0, line: line + 1 + bodyIndex, sourceOffset: start }] : []);
259
267
  }
260
268
  }
261
269
  else {
262
270
  mergedLines.push(content);
271
+ mergedLineSpans?.push(content ? [{ start: 0, line: line + 1 + bodyIndex, sourceOffset: start }] : []);
263
272
  }
264
273
  }
265
- while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1] === '')
274
+ while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1] === '') {
266
275
  mergedLines.pop();
276
+ mergedLineSpans?.pop();
277
+ }
267
278
  const joined = mergedLines.join('\n');
279
+ let sourceSpans;
280
+ if (mergedLineSpans) {
281
+ sourceSpans = [];
282
+ let outputStart = 0;
283
+ for (let i = 0; i < mergedLines.length; i++) {
284
+ for (const span of mergedLineSpans[i])
285
+ sourceSpans.push({ ...span, start: outputStart + span.start });
286
+ outputStart += mergedLines[i].length + 1;
287
+ }
288
+ }
268
289
  checkScalarLimit(LString(joined), line);
269
- builder.setMapping(root, key, builder.string(joined, line, false));
290
+ builder.setMapping(root, key, builder.string(joined, line + 1, false, sourceSpans));
270
291
  }
271
292
  };
272
293
  // A recognised top-level entry needs at least one UTF-16 unit of key text,
package/dist/index.d.ts CHANGED
@@ -4,12 +4,13 @@
4
4
  * Public entry point. The implementation is split per Appendix B's own
5
5
  * layering:
6
6
  * - `core.ts` — Lima Core 1.0, reference-unaware by construction.
7
- * - `references.ts` — the optional References 1.0 extension, layered on
8
- * top of Core's internal annotated value tree.
7
+ * - `references2.ts` — the References 2.0 extension layered on Core.
8
+ * - `references.ts` — the frozen internal References 1.0 implementation.
9
9
  * - `value.ts` — the shared Lima Value Model both build on.
10
10
  *
11
- * `parse` (the historical default export) resolves references — it is
12
- * `parseReferences` under a name kept for backward compatibility.
11
+ * `parse` is the primary References 2.0 entry point. `parseReferences` is
12
+ * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore, type CoreOptions, type Diagnostic } from './core.js';
15
- export { parseReferences, parse, type ReferencesOptions, type ParseOptions } from './references.js';
15
+ export { parseReferences, parse, type ParseMode, type ParseOptions } from './references2.js';
16
+ export type ReferencesOptions = import('./references2.js').ParseOptions;
package/dist/index.js CHANGED
@@ -4,12 +4,12 @@
4
4
  * Public entry point. The implementation is split per Appendix B's own
5
5
  * layering:
6
6
  * - `core.ts` — Lima Core 1.0, reference-unaware by construction.
7
- * - `references.ts` — the optional References 1.0 extension, layered on
8
- * top of Core's internal annotated value tree.
7
+ * - `references2.ts` — the References 2.0 extension layered on Core.
8
+ * - `references.ts` — the frozen internal References 1.0 implementation.
9
9
  * - `value.ts` — the shared Lima Value Model both build on.
10
10
  *
11
- * `parse` (the historical default export) resolves references — it is
12
- * `parseReferences` under a name kept for backward compatibility.
11
+ * `parse` is the primary References 2.0 entry point. `parseReferences` is
12
+ * retained as its deprecated compatibility alias.
13
13
  */
14
14
  export { parseCore } from './core.js';
15
- export { parseReferences, parse } from './references.js';
15
+ export { parseReferences, parse } from './references2.js';
@@ -0,0 +1,12 @@
1
+ /** Reference tokens recorded while the annotated Core tree is being built. */
2
+ import type { StringSourceSpan } from './builder.js';
3
+ export type ReferenceToken2 = {
4
+ token: string;
5
+ index: number;
6
+ offset: number;
7
+ line: number;
8
+ documentPath?: string;
9
+ partialPath?: string;
10
+ };
11
+ export declare const PURE_REFERENCE_2: RegExp;
12
+ export declare const scanReferenceTokens2: (value: string, firstLine: number, sourceSpans?: StringSourceSpan[]) => ReferenceToken2[];
@@ -0,0 +1,37 @@
1
+ const SEGMENT = '[a-zA-Z0-9_][a-zA-Z0-9_:-]*';
2
+ const DOC_PATH = `${SEGMENT}(?:\\.${SEGMENT})*`;
3
+ const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
4
+ const PARTIAL_PATH = `${PARTIAL_NAME}(?:\\.${SEGMENT})*`;
5
+ export const PURE_REFERENCE_2 = new RegExp(`^\\$\\((?:(${DOC_PATH})|:(${PARTIAL_PATH}))\\)$`);
6
+ const REFERENCE_2 = new RegExp(`\\$\\((?:(${DOC_PATH})|:(${PARTIAL_PATH}))\\)`, 'g');
7
+ const NO_TOKENS = [];
8
+ export const scanReferenceTokens2 = (value, firstLine, sourceSpans) => {
9
+ if (!value.includes('$('))
10
+ return NO_TOKENS;
11
+ REFERENCE_2.lastIndex = 0;
12
+ const result = [];
13
+ let line = firstLine;
14
+ let lineStart = 0;
15
+ let scanned = 0;
16
+ let spanIndex = 0;
17
+ for (const match of value.matchAll(REFERENCE_2)) {
18
+ const index = match.index ?? 0;
19
+ while (scanned < index) {
20
+ if (value.charCodeAt(scanned) === 10) {
21
+ line++;
22
+ lineStart = scanned + 1;
23
+ }
24
+ scanned++;
25
+ }
26
+ while (sourceSpans && spanIndex + 1 < sourceSpans.length && sourceSpans[spanIndex + 1].start <= index)
27
+ spanIndex++;
28
+ const source = sourceSpans?.[spanIndex];
29
+ result.push({
30
+ token: match[0], index,
31
+ line: source?.line ?? line,
32
+ offset: source ? source.sourceOffset + index - source.start : index - lineStart,
33
+ ...(match[1] !== undefined ? { documentPath: match[1] } : { partialPath: match[2] }),
34
+ });
35
+ }
36
+ return result;
37
+ };
@@ -20,8 +20,71 @@
20
20
  * There is no module-level mutable state: diagnostics are collected in a
21
21
  * `ResolutionContext` created fresh per call and threaded explicitly.
22
22
  */
23
- import { type Diagnostic } from './core.js';
23
+ import { type LimaValue } from './value.js';
24
+ import { type PositionedValue, type Diagnostic, type InsertedAt, type NativeValue } from './core.js';
24
25
  type Meta = Record<string, unknown>;
26
+ /**
27
+ * Structural deep copy for document-derived targets — preserves each leaf's
28
+ * own line/quoted (§4.2/R-104: still eligible for further resolution) and
29
+ * any existing `insertedAt` provenance from an earlier, nested resolution
30
+ * (R-112: a reference copied wholesale into a new position can itself
31
+ * already contain the result of an inner reference that resolved first —
32
+ * both insertion sites remain identifiable participants after the copy).
33
+ */
34
+ export declare const deepCopyPositioned: (v: PositionedValue) => PositionedValue;
35
+ /**
36
+ * Wraps a partial's ingested value into the annotated representation, with
37
+ * every string leaf marked permanently inert (§3.8: "no traversal into
38
+ * partial values" — the resolution phases must never rediscover a
39
+ * reference-like substring inside partial content). Called once per partial
40
+ * name at ingestion (see the call site), producing the one canonical tree
41
+ * every pure reference to that partial retrieves — deep-copying it on every
42
+ * such reference (§3.1) is the resolveTree call site's job, not this one.
43
+ */
44
+ export declare const partialToPositioned: (v: LimaValue, line: number) => PositionedValue;
45
+ /**
46
+ * References §5/R-112 (nesting depth), §6.2 (node count), and the public
47
+ * result shape (native conversion) computed together in one recursive pass
48
+ * over the final, post-substitution tree — instead of three independent
49
+ * full-tree walks (a depth-only pass, a count-only pass, then
50
+ * `toNativeFromPositioned`), each of which would otherwise revisit every
51
+ * node of what can be a large, reference-expanded result. Depth attribution
52
+ * needs more than a depth number: it needs to know which reference
53
+ * insertions lie on the actual deepest path, so the earliest of those (by
54
+ * line) can be blamed if the limit is exceeded — ties for "deepest child"
55
+ * all count, since a violation can be reached via more than one
56
+ * maximal-depth branch. `native` is still built unconditionally even
57
+ * though a depth/node-count violation elsewhere in the document means it
58
+ * will be discarded and never returned — cheaper than adding a second
59
+ * conditional branch to every call site, and the discarded case is the
60
+ * rare (error) path, not the one this pass exists to speed up.
61
+ *
62
+ * Each array/mapping branch tracks a running max depth and its participant
63
+ * list in a single loop over its children, instead of first materializing a
64
+ * full array of child results and then doing separate `.map()`/`Math.max`/
65
+ * `.filter().flatMap()` passes over it — for a wide sibling group (many
66
+ * children at the same depth, the common shape for flat documents with many
67
+ * top-level references) that would mean an extra full pass per level on top
68
+ * of the traversal itself. A running accumulator only ever copies-on-write:
69
+ * `.slice()` when a strictly greater depth replaces the running set, plain
70
+ * `.push()` onto that fresh copy for a tie at the current max — never a
71
+ * mutation of a child's own returned array. Reusing a child's array in
72
+ * place would currently be safe (each node is visited exactly once, no
73
+ * aliasing left after the pure-reference deep-copy fix), but relying on
74
+ * that invariant here has no upside worth the risk in a file that has
75
+ * already had one real aliasing bug.
76
+ */
77
+ export type FinalizedValue = {
78
+ native: NativeValue;
79
+ nodeCount: number;
80
+ depth: number;
81
+ deepestParticipants: InsertedAt[];
82
+ };
83
+ export declare const finalizePositioned: (v: PositionedValue) => FinalizedValue;
84
+ /** Earliest (lowest-line) participant, or null when none exist — R-113's "line 1" fallback applies then. */
85
+ export declare const earliestParticipant: (participants: InsertedAt[]) => InsertedAt | null;
86
+ /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
87
+ export declare const collectAllParticipants: (v: PositionedValue, acc: InsertedAt[]) => void;
25
88
  export type ReferencesOptions = {
26
89
  /** Named values available via `(%key)` references. */
27
90
  partials?: Meta;
@@ -33,4 +96,6 @@ export declare const parseReferences: <T extends Record<string, unknown> = Meta>
33
96
  /** Backward-compatible primary entry point — References layered on Core. */
34
97
  export declare const parse: typeof parseReferences;
35
98
  export type ParseOptions = ReferencesOptions;
99
+ /** Internal conformance entry point for the frozen References 1.0 suite. */
100
+ export declare const parseReferencesV1: typeof parseReferences;
36
101
  export {};
@@ -110,14 +110,14 @@ const isReferenceFreeP = (v) => {
110
110
  * already contain the result of an inner reference that resolved first —
111
111
  * both insertion sites remain identifiable participants after the copy).
112
112
  */
113
- const deepCopyPositioned = (v) => {
113
+ export const deepCopyPositioned = (v) => {
114
114
  switch (v.kind) {
115
- case 'array': return { kind: 'array', items: v.items.map(deepCopyPositioned), line: v.line, insertedAt: v.insertedAt };
115
+ case 'array': return { kind: 'array', items: v.items.map(deepCopyPositioned), line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt };
116
116
  case 'mapping': {
117
117
  const entries = new Map();
118
118
  for (const [k, c] of v.entries)
119
119
  entries.set(k, deepCopyPositioned(c));
120
- return { kind: 'mapping', entries, line: v.line, insertedAt: v.insertedAt };
120
+ return { kind: 'mapping', entries, line: v.line, references2Active: v.references2Active, insertedAt: v.insertedAt };
121
121
  }
122
122
  case 'instant': return { kind: 'instant', value: new Date(v.value.getTime()), line: v.line, insertedAt: v.insertedAt };
123
123
  default: return v;
@@ -132,7 +132,7 @@ const deepCopyPositioned = (v) => {
132
132
  * every pure reference to that partial retrieves — deep-copying it on every
133
133
  * such reference (§3.1) is the resolveTree call site's job, not this one.
134
134
  */
135
- const partialToPositioned = (v, line) => {
135
+ export const partialToPositioned = (v, line) => {
136
136
  switch (v.kind) {
137
137
  case 'null': return { kind: 'null', line };
138
138
  case 'bool': return { kind: 'bool', value: v.value, line };
@@ -282,7 +282,7 @@ const resolveTree = (node, lookup, partials, ctx) => {
282
282
  }
283
283
  return node; // null/bool/int/float/instant — nothing to resolve
284
284
  };
285
- const finalizePositioned = (v) => {
285
+ export const finalizePositioned = (v) => {
286
286
  const own = v.insertedAt ? [v.insertedAt] : [];
287
287
  if (v.kind === 'array') {
288
288
  if (v.items.length === 0)
@@ -334,9 +334,9 @@ const finalizePositioned = (v) => {
334
334
  return { native: toNativeFromPositioned(v), nodeCount: 1, depth: 0, deepestParticipants: own };
335
335
  };
336
336
  /** Earliest (lowest-line) participant, or null when none exist — R-113's "line 1" fallback applies then. */
337
- const earliestParticipant = (participants) => participants.length === 0 ? null : participants.reduce((a, b) => (b.line < a.line ? b : a));
337
+ export const earliestParticipant = (participants) => participants.length === 0 ? null : participants.reduce((a, b) => (b.line < a.line ? b : a));
338
338
  /** Node-count attribution for the RESOURCE_LIMIT error path: every reference insertion anywhere in the tree contributes to the total. */
339
- const collectAllParticipants = (v, acc) => {
339
+ export const collectAllParticipants = (v, acc) => {
340
340
  if (v.insertedAt)
341
341
  acc.push(v.insertedAt);
342
342
  if (v.kind === 'array')
@@ -520,3 +520,5 @@ export const parseReferences = (frontMatter, options) => {
520
520
  };
521
521
  /** Backward-compatible primary entry point — References layered on Core. */
522
522
  export const parse = parseReferences;
523
+ /** Internal conformance entry point for the frozen References 1.0 suite. */
524
+ export const parseReferencesV1 = parseReferences;
@@ -0,0 +1,13 @@
1
+ import { type Diagnostic } from './core.js';
2
+ type Meta = Record<string, unknown>;
3
+ export type ParseMode = 'references' | 'core';
4
+ export type ParseOptions = {
5
+ mode?: ParseMode;
6
+ partials?: Meta;
7
+ strict?: boolean;
8
+ onWarning?: (diagnostic: Diagnostic) => void;
9
+ };
10
+ export declare const parse: <T extends Record<string, unknown> = Meta>(frontMatter: string, options?: ParseOptions) => T;
11
+ /** @deprecated Use parse(). */
12
+ export declare const parseReferences: typeof parse;
13
+ export {};
@@ -0,0 +1,295 @@
1
+ /** Lima References 2.0 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';
3
+ import { NESTING_DEPTH_LIMIT, parseCore, parseCoreWithPositions, toPlainValue, } from './core.js';
4
+ import { hasActiveReferences2 } from './scalars.js';
5
+ import { LimaError } from './errors.js';
6
+ import { collectAllParticipants, deepCopyPositioned, earliestParticipant, finalizePositioned, partialToPositioned, } from './references.js';
7
+ const emptyMapping = () => Object.create(null);
8
+ const PARTIAL_NAME = '[a-zA-Z0-9_][a-zA-Z0-9_:/-]*';
9
+ const PARTIAL_NAME_RE = new RegExp(`^${PARTIAL_NAME}$`);
10
+ const MAX_EDGES = 3;
11
+ const addDiagnostic = (ctx, diagnostic, offset = 0) => {
12
+ ctx.diagnostics.push({ ...diagnostic, offset });
13
+ };
14
+ const tokenMatches = (node) => node.references2 ?? [];
15
+ const lookupPath = (root, segments) => {
16
+ let current = root;
17
+ for (const segment of segments) {
18
+ if (current?.kind !== 'mapping')
19
+ return undefined;
20
+ current = current.entries.get(segment);
21
+ }
22
+ return current;
23
+ };
24
+ const documentTarget = (root, path) => {
25
+ const segments = path.split('.');
26
+ const value = root.get(segments[0]);
27
+ return value === undefined ? undefined : { root: value, tail: segments.slice(1) };
28
+ };
29
+ const lookupPartial = (partials, path) => {
30
+ const dot = path.indexOf('.');
31
+ if (dot < 0)
32
+ return partials.get(path);
33
+ return lookupPath(partials.get(path.slice(0, dot)), path.slice(dot + 1).split('.'));
34
+ };
35
+ const isActiveString = (node) => node.kind === 'string' && !node.quoted;
36
+ const scalarText = (value, token, line, offset, ctx) => {
37
+ if (value.kind === 'mapping') {
38
+ addDiagnostic(ctx, {
39
+ code: 'INVALID_INTERPOLATION', line, token,
40
+ message: `Lima: invalid interpolation of "${token}" at line ${line}: mapping cannot be interpolated into a string`,
41
+ }, offset);
42
+ return null;
43
+ }
44
+ if (value.kind === 'array') {
45
+ if (value.items.some((item) => item.kind === 'array' || item.kind === 'mapping')) {
46
+ addDiagnostic(ctx, {
47
+ code: 'INVALID_INTERPOLATION', line, token,
48
+ message: `Lima: invalid interpolation of "${token}" at line ${line}: array contains a nested array or mapping`,
49
+ }, offset);
50
+ return null;
51
+ }
52
+ return value.items.map((item) => canonicalString(toPlainValue(item))).join(', ');
53
+ }
54
+ return canonicalString(toPlainValue(value));
55
+ };
56
+ const resolveNodeUncached = (node, document, partials, ctx, remainingEdges, stack) => {
57
+ if (node.kind === 'array') {
58
+ if (!hasActiveReferences2(node))
59
+ return { value: node, complete: true };
60
+ let complete = true;
61
+ const items = node.items.map((item) => {
62
+ const result = resolveNode(item, document, partials, ctx, remainingEdges, stack);
63
+ complete &&= result.complete;
64
+ if (isActiveString(item) && result.value.kind === 'array') {
65
+ const match = tokenMatches(item)[0];
66
+ const token = match?.token ?? item.value;
67
+ addDiagnostic(ctx, {
68
+ code: 'INVALID_REFERENCE_SHAPE', line: item.line, token,
69
+ message: `Lima: reference "${token}" resolves to an array, which cannot be inserted as a sequence item at line ${item.line}`,
70
+ });
71
+ return item;
72
+ }
73
+ return result.value;
74
+ });
75
+ return {
76
+ value: { ...node, items, references2Active: items.some(hasActiveReferences2) || undefined },
77
+ complete,
78
+ };
79
+ }
80
+ if (node.kind === 'mapping') {
81
+ if (!hasActiveReferences2(node))
82
+ return { value: node, complete: true };
83
+ let complete = true;
84
+ const entries = new Map();
85
+ for (const [key, child] of node.entries) {
86
+ const result = resolveNode(child, document, partials, ctx, remainingEdges, stack);
87
+ complete &&= result.complete;
88
+ entries.set(key, result.value);
89
+ }
90
+ let references2Active = false;
91
+ for (const value of entries.values()) {
92
+ if (hasActiveReferences2(value)) {
93
+ references2Active = true;
94
+ break;
95
+ }
96
+ }
97
+ return { value: { ...node, entries, references2Active: references2Active || undefined }, complete };
98
+ }
99
+ if (!isActiveString(node))
100
+ return { value: node, complete: true };
101
+ const recorded = tokenMatches(node);
102
+ const pure = recorded.length === 1 && recorded[0].index === 0 && recorded[0].token.length === node.value.length
103
+ ? recorded[0]
104
+ : undefined;
105
+ if (pure) {
106
+ if (remainingEdges === 0)
107
+ return { value: node, complete: false };
108
+ const documentLookup = pure.documentPath !== undefined ? documentTarget(document, pure.documentPath) : undefined;
109
+ const target = pure.documentPath !== undefined ? documentLookup?.root : lookupPartial(partials, pure.partialPath);
110
+ if (target === undefined || stack.has(target))
111
+ return { value: node, complete: false };
112
+ if (pure.partialPath !== undefined) {
113
+ return {
114
+ value: { ...deepCopyPositioned(target), insertedAt: { line: pure.line, token: pure.token } },
115
+ complete: true,
116
+ };
117
+ }
118
+ stack.add(target);
119
+ const result = resolveNode(target, document, partials, ctx, remainingEdges - 1, stack);
120
+ stack.delete(target);
121
+ if (!result.complete)
122
+ return { value: node, complete: false };
123
+ const selected = lookupPath(result.value, documentLookup?.tail ?? []);
124
+ if (selected === undefined)
125
+ return { value: node, complete: false };
126
+ return {
127
+ value: { ...deepCopyPositioned(selected), insertedAt: { line: pure.line, token: pure.token } },
128
+ complete: true,
129
+ };
130
+ }
131
+ const matches = recorded;
132
+ if (matches.length === 0)
133
+ return { value: node, complete: true };
134
+ let complete = true;
135
+ let cursor = 0;
136
+ let output = '';
137
+ const unresolvedTokens = [];
138
+ for (const match of matches) {
139
+ const offset = match.index;
140
+ output += node.value.slice(cursor, offset);
141
+ const documentLookup = match.documentPath !== undefined ? documentTarget(document, match.documentPath) : undefined;
142
+ const target = match.documentPath !== undefined ? documentLookup?.root : lookupPartial(partials, match.partialPath);
143
+ let replacement = null;
144
+ if (remainingEdges > 0 && target !== undefined && !stack.has(target)) {
145
+ if (match.partialPath !== undefined)
146
+ replacement = scalarText(target, match.token, match.line, match.offset, ctx);
147
+ else {
148
+ stack.add(target);
149
+ const result = resolveNode(target, document, partials, ctx, remainingEdges - 1, stack);
150
+ stack.delete(target);
151
+ const selected = result.complete ? lookupPath(result.value, documentLookup?.tail ?? []) : undefined;
152
+ if (selected !== undefined)
153
+ replacement = scalarText(selected, match.token, match.line, match.offset, ctx);
154
+ }
155
+ }
156
+ if (replacement === null) {
157
+ complete = false;
158
+ unresolvedTokens.push({ ...match, index: output.length });
159
+ output += match.token;
160
+ }
161
+ else
162
+ output += replacement;
163
+ cursor = offset + match.token.length;
164
+ }
165
+ output += node.value.slice(cursor);
166
+ if (codepointLength(output) > SCALAR_LENGTH_LIMIT) {
167
+ const first = matches[0];
168
+ addDiagnostic(ctx, {
169
+ code: 'RESOURCE_LIMIT', line: first.line, token: first.token,
170
+ message: `Lima: scalar exceeds maximum length of ${SCALAR_LENGTH_LIMIT} code points at line ${first.line}`,
171
+ }, first.offset);
172
+ }
173
+ return { value: { ...node, value: output, references2: unresolvedTokens }, complete };
174
+ };
175
+ /** Successful and failed dependency results are immutable and budget-specific. */
176
+ const resolveNode = (node, document, partials, ctx, remainingEdges, stack) => {
177
+ const cacheable = stack.size > 1 && (node.kind === 'array' || node.kind === 'mapping' ||
178
+ (isActiveString(node) && tokenMatches(node).length > 0));
179
+ if (!cacheable)
180
+ return resolveNodeUncached(node, document, partials, ctx, remainingEdges, stack);
181
+ const byBudget = ctx.cache.get(node);
182
+ const cached = byBudget?.get(remainingEdges);
183
+ if (cached !== undefined)
184
+ return cached;
185
+ const result = resolveNodeUncached(node, document, partials, ctx, remainingEdges, stack);
186
+ if (byBudget !== undefined)
187
+ byBudget.set(remainingEdges, result);
188
+ else
189
+ ctx.cache.set(node, new Map([[remainingEdges, result]]));
190
+ return result;
191
+ };
192
+ const validatePartials = (raw) => {
193
+ const names = Object.keys(raw);
194
+ if (names.length > PARTIAL_COUNT_LIMIT) {
195
+ throw new LimaError({ code: 'INVALID_PARTIAL', message: `Lima: too many partials (max ${PARTIAL_COUNT_LIMIT})` });
196
+ }
197
+ for (const name of names) {
198
+ if (!PARTIAL_NAME_RE.test(name)) {
199
+ throw new LimaError({ code: 'INVALID_PARTIAL', partial: name, message: `Lima: invalid partial name "${name}"` });
200
+ }
201
+ if (codepointLength(name) > PARTIAL_NAME_LENGTH_LIMIT) {
202
+ throw new LimaError({ code: 'INVALID_PARTIAL', partial: name, message: `Lima: invalid partial name "${name}": exceeds maximum length of ${PARTIAL_NAME_LENGTH_LIMIT} code points` });
203
+ }
204
+ }
205
+ const ingested = new Map();
206
+ for (const name of names)
207
+ ingested.set(name, ingestPartialValue(raw[name], name, name));
208
+ let nodes = 0;
209
+ for (const value of ingested.values())
210
+ nodes += countNodes(value);
211
+ if (nodes > PARTIAL_NODE_LIMIT) {
212
+ throw new LimaError({ code: 'INVALID_PARTIAL', message: `Lima: partials exceed the combined maximum of ${PARTIAL_NODE_LIMIT} value nodes` });
213
+ }
214
+ const result = new Map();
215
+ for (const [name, value] of ingested)
216
+ result.set(name, partialToPositioned(value, 0));
217
+ return result;
218
+ };
219
+ const scanUnresolved = (node, ctx) => {
220
+ if (isActiveString(node)) {
221
+ for (const match of tokenMatches(node)) {
222
+ addDiagnostic(ctx, {
223
+ code: 'UNRESOLVED_REFERENCE', line: match.line, token: match.token,
224
+ message: `Lima: unresolved reference "${match.token}" at line ${match.line}`,
225
+ }, match.offset);
226
+ }
227
+ return;
228
+ }
229
+ if (node.kind === 'array')
230
+ for (const item of node.items)
231
+ scanUnresolved(item, ctx);
232
+ if (node.kind === 'mapping')
233
+ for (const child of node.entries.values())
234
+ scanUnresolved(child, ctx);
235
+ };
236
+ export const parse = (frontMatter, options) => {
237
+ if (options?.mode !== undefined && options.mode !== 'core' && options.mode !== 'references') {
238
+ throw new TypeError(`Lima: invalid parse mode "${String(options.mode)}"`);
239
+ }
240
+ if (options?.mode === 'core') {
241
+ if (Object.prototype.hasOwnProperty.call(options, 'partials')) {
242
+ throw new TypeError('Lima: partials cannot be supplied in core mode');
243
+ }
244
+ // Core ignores the additional `mode` property. Passing the same object
245
+ // avoids allocating a projected options object on this hot dispatch path.
246
+ return parseCore(frontMatter, options);
247
+ }
248
+ const partials = validatePartials(options?.partials ?? {});
249
+ if (!frontMatter.includes('$(')) {
250
+ return parseCore(frontMatter, options);
251
+ }
252
+ const document = parseCoreWithPositions(frontMatter, { strict: options?.strict ?? false, onWarning: options?.onWarning });
253
+ const ctx = { diagnostics: [], cache: new WeakMap() };
254
+ const resolved = new Map();
255
+ for (const [key, value] of document) {
256
+ const stack = new Set([value]);
257
+ resolved.set(key, resolveNode(value, document, partials, ctx, MAX_EDGES, stack).value);
258
+ }
259
+ if (options?.strict)
260
+ for (const value of resolved.values())
261
+ scanUnresolved(value, ctx);
262
+ if (ctx.diagnostics.length > 0) {
263
+ ctx.diagnostics.sort((a, b) => a.line - b.line || a.offset - b.offset);
264
+ throw new LimaError(ctx.diagnostics[0]);
265
+ }
266
+ const finalized = [...resolved].map(([key, value]) => [key, finalizePositioned(value)]);
267
+ const depth = finalized.length === 0 ? 0 : Math.max(...finalized.map(([, value]) => value.depth));
268
+ if (depth > NESTING_DEPTH_LIMIT) {
269
+ const participants = finalized.filter(([, value]) => value.depth === depth).flatMap(([, value]) => value.deepestParticipants);
270
+ const winner = earliestParticipant(participants);
271
+ throw new LimaError({
272
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
273
+ message: `Lima: nesting depth exceeds maximum of ${NESTING_DEPTH_LIMIT} at line ${winner?.line ?? 1}`,
274
+ });
275
+ }
276
+ let nodeCount = 1;
277
+ for (const [, value] of finalized)
278
+ nodeCount += value.nodeCount;
279
+ if (nodeCount > RESULT_NODE_LIMIT) {
280
+ const participants = [];
281
+ for (const value of resolved.values())
282
+ collectAllParticipants(value, participants);
283
+ const winner = earliestParticipant(participants);
284
+ throw new LimaError({
285
+ code: 'RESOURCE_LIMIT', line: winner?.line ?? 1, token: winner?.token,
286
+ message: `Lima: result exceeds maximum size of ${RESULT_NODE_LIMIT} total nodes at line ${winner?.line ?? 1}`,
287
+ });
288
+ }
289
+ const out = emptyMapping();
290
+ for (const [key, value] of finalized)
291
+ out[key] = value.native;
292
+ return out;
293
+ };
294
+ /** @deprecated Use parse(). */
295
+ export const parseReferences = parse;
package/dist/scalars.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { type LimaValue } from './value.js';
8
8
  import { type ParseContext } from './normalize.js';
9
9
  import type { ValueBuilder } from './builder.js';
10
+ import { type ReferenceToken2 } from './reference-tokens2.js';
10
11
  /**
11
12
  * `insertedAt` is never set by Core — it's a References-only annotation
12
13
  * (see references.ts's `resolveTree`), stamped on the root of a value
@@ -45,6 +46,7 @@ export type PositionedValue = {
45
46
  value: string;
46
47
  line: number;
47
48
  quoted: boolean;
49
+ references2?: ReferenceToken2[];
48
50
  insertedAt?: InsertedAt;
49
51
  } | {
50
52
  kind: 'instant';
@@ -55,13 +57,16 @@ export type PositionedValue = {
55
57
  kind: 'array';
56
58
  items: PositionedValue[];
57
59
  line: number;
60
+ references2Active?: boolean;
58
61
  insertedAt?: InsertedAt;
59
62
  } | {
60
63
  kind: 'mapping';
61
64
  entries: Map<string, PositionedValue>;
62
65
  line: number;
66
+ references2Active?: boolean;
63
67
  insertedAt?: InsertedAt;
64
68
  };
69
+ export declare const hasActiveReferences2: (value: PositionedValue) => boolean;
65
70
  /** The `ValueBuilder<PositionedValue>` — reconstructs today's annotated tree exactly, for References. */
66
71
  export declare const positionedBuilder: ValueBuilder<PositionedValue>;
67
72
  /** Strips position/quoted-origin annotations, recursively — the public parseCore() projection. */
package/dist/scalars.js CHANGED
@@ -7,15 +7,29 @@
7
7
  import { LNull, LBool, LFloat, LInt, LString, LInstant } from './value.js';
8
8
  import { checkStringLimit } from './normalize.js';
9
9
  import { LimaError } from './errors.js';
10
+ import { scanReferenceTokens2 } from './reference-tokens2.js';
11
+ export const hasActiveReferences2 = (value) => value.kind === 'string' ? (value.references2?.length ?? 0) > 0
12
+ : value.kind === 'array' || value.kind === 'mapping' ? value.references2Active === true
13
+ : false;
10
14
  /** The `ValueBuilder<PositionedValue>` — reconstructs today's annotated tree exactly, for References. */
11
15
  export const positionedBuilder = {
16
+ tracksStringSourcePositions: true,
12
17
  null: (line) => ({ kind: 'null', line }),
13
18
  bool: (value, line) => ({ kind: 'bool', value, line }),
14
19
  int: (value, line) => ({ kind: 'int', value, line }),
15
20
  float: (value, line) => ({ kind: 'float', value, line }),
16
- string: (value, line, quoted) => ({ kind: 'string', value, line, quoted }),
21
+ string: (value, line, quoted, sourceSpans) => {
22
+ const references2 = quoted ? undefined : scanReferenceTokens2(value, line, sourceSpans);
23
+ return {
24
+ kind: 'string', value, line, quoted,
25
+ ...(references2 && references2.length > 0 ? { references2 } : {}),
26
+ };
27
+ },
17
28
  instant: (value, line) => ({ kind: 'instant', value, line }),
18
- array: (items, line) => ({ kind: 'array', items, line }),
29
+ array: (items, line) => ({
30
+ kind: 'array', items, line,
31
+ ...(items.some(hasActiveReferences2) ? { references2Active: true } : {}),
32
+ }),
19
33
  createMapping: () => new Map(),
20
34
  createMappingWith: (key, value) => new Map([[key, value]]),
21
35
  hasMappingKey: (entries, key) => entries.has(key),
@@ -28,7 +42,16 @@ export const positionedBuilder = {
28
42
  }
29
43
  return max;
30
44
  },
31
- mapping: (entries, line) => ({ kind: 'mapping', entries, line }),
45
+ mapping: (entries, line) => {
46
+ let references2Active = false;
47
+ for (const value of entries.values()) {
48
+ if (hasActiveReferences2(value)) {
49
+ references2Active = true;
50
+ break;
51
+ }
52
+ }
53
+ return { kind: 'mapping', entries, line, ...(references2Active ? { references2Active: true } : {}) };
54
+ },
32
55
  };
33
56
  /** Strips position/quoted-origin annotations, recursively — the public parseCore() projection. */
34
57
  export const toPlainValue = (v) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limaformat/lima",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -20,24 +20,25 @@
20
20
  }
21
21
  },
22
22
  "files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"],
23
- "engines": {
24
- "node": ">=18"
25
- },
26
- "scripts": {
27
- "build": "tsc -p tsconfig.build.json",
28
- "pretest": "bun run build",
29
- "test": "bun test",
30
- "typecheck": "tsc --noEmit",
31
- "prepublishOnly": "bun run typecheck && bun test",
32
- "bench": "bun run bench/index.ts",
33
- "bench:vs-yaml": "bun run bench/vs-yaml.ts",
34
- "bench:vs-yaml:node": "node bench/vs-yaml-node.mjs",
35
- "bench:differential": "bun run bench/differential.ts"
36
- },
37
- "devDependencies": {
38
- "@types/bun": "^1.3.14",
39
- "@types/js-yaml": "^4.0.9",
40
- "js-yaml": "^5.2.3",
41
- "typescript": "^7.0.2"
42
- }
23
+ "engines": { "node": ">=18" },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "pretest": "bun run build",
27
+ "test": "bun test",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepublishOnly": "bun run typecheck && bun test",
30
+ "bench": "bun run bench/index.ts",
31
+ "bench:vs-yaml": "bun run bench/vs-yaml.ts",
32
+ "bench:vs-yaml:node": "node bench/vs-yaml-node.mjs",
33
+ "bench:cross-language": "bun run bench/cross-language.ts",
34
+ "bench:differential": "bun run bench/differential.ts",
35
+ "wordcount": "bun run scripts/wordcount.ts"
36
+ },
37
+ "devDependencies": {
38
+ "@types/bun": "^1.3.14",
39
+ "@types/js-yaml": "^4.0.9",
40
+ "js-yaml": "^5.2.3",
41
+ "typescript": "^7.0.2",
42
+ "typescript-classic": "npm:typescript@^5.9.3"
43
+ }
43
44
  }