@pieceful/ravel-core 0.1.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/src/index.js ADDED
@@ -0,0 +1,1526 @@
1
+ const componentPattern = /^[a-z][a-z0-9-]*$/;
2
+
3
+ export { directiveKinds, compose, append, newline, pipe, pass, createDirective, aliasDirective } from "./directives.js";
4
+
5
+ const clone = (value) => JSON.parse(JSON.stringify(value));
6
+
7
+ const mappedValue = (text = "", segments = []) => ({ text, segments });
8
+
9
+ const sourceSegment = (text, source, chunk, kind, precision = "exact", via = []) => mappedValue(
10
+ text,
11
+ text.length === 0 ? [] : [{
12
+ generated: { start: 0, end: text.length },
13
+ source: source ? clone(source) : null,
14
+ chunk,
15
+ kind,
16
+ precision,
17
+ via: clone(via)
18
+ }]
19
+ );
20
+
21
+ const concatMapped = (...values) => {
22
+ let text = "";
23
+ const segments = [];
24
+ for (const value of values) {
25
+ const offset = text.length;
26
+ text += value.text;
27
+ for (const segment of value.segments) {
28
+ segments.push({
29
+ ...segment,
30
+ generated: {
31
+ start: segment.generated.start + offset,
32
+ end: segment.generated.end + offset
33
+ }
34
+ });
35
+ }
36
+ }
37
+ return mappedValue(text, segments);
38
+ };
39
+
40
+ const sliceMapped = (value, start, end = value.text.length) => {
41
+ const text = value.text.slice(start, end);
42
+ const segments = [];
43
+ for (const segment of value.segments) {
44
+ const overlapStart = Math.max(start, segment.generated.start);
45
+ const overlapEnd = Math.min(end, segment.generated.end);
46
+ if (overlapStart >= overlapEnd) continue;
47
+ let source = segment.source;
48
+ const segmentLength = segment.generated.end - segment.generated.start;
49
+ const sourceStart = segment.source?.range?.start;
50
+ const sourceEnd = segment.source?.range?.end;
51
+ if (segment.precision === "exact" && sourceStart && sourceEnd &&
52
+ sourceEnd.offset - sourceStart.offset === segmentLength) {
53
+ const segmentText = value.text.slice(segment.generated.start, segment.generated.end);
54
+ source = {
55
+ uri: segment.source.uri,
56
+ range: {
57
+ start: advance(sourceStart, segmentText, overlapStart - segment.generated.start),
58
+ end: advance(sourceStart, segmentText, overlapEnd - segment.generated.start)
59
+ }
60
+ };
61
+ }
62
+ segments.push({
63
+ ...segment,
64
+ source,
65
+ generated: {
66
+ start: overlapStart - start,
67
+ end: overlapEnd - start
68
+ }
69
+ });
70
+ }
71
+ return mappedValue(text, segments);
72
+ };
73
+
74
+ const coarseMapped = (text, source, chunk, kind, via = []) =>
75
+ sourceSegment(text, source, chunk, kind, "coarse", via);
76
+
77
+ const segmentOrigins = (value) => {
78
+ const origins = [];
79
+ const seen = new Set();
80
+ for (const segment of value.segments) {
81
+ const candidates = segment.origins ?? [{
82
+ source: segment.source,
83
+ chunk: segment.chunk,
84
+ kind: segment.kind,
85
+ precision: segment.precision,
86
+ via: segment.via ?? []
87
+ }];
88
+ for (const candidate of candidates) {
89
+ const key = JSON.stringify(candidate);
90
+ if (seen.has(key)) continue;
91
+ seen.add(key);
92
+ origins.push(clone(candidate));
93
+ }
94
+ }
95
+ return origins;
96
+ };
97
+
98
+ const coarseDerivedMapped = (text, source, chunk, kind, via, input) => {
99
+ const result = coarseMapped(text, source, chunk, kind, via);
100
+ if (result.segments.length) result.segments[0].origins = segmentOrigins(input);
101
+ return result;
102
+ };
103
+
104
+ const withMappedDerivation = (value, derivation) => mappedValue(
105
+ value.text,
106
+ value.segments.map((segment) => ({
107
+ ...segment,
108
+ via: [...(segment.via ?? []), clone(derivation)]
109
+ }))
110
+ );
111
+
112
+ const mapBuiltinTransform = (input, output, step, chunk) => {
113
+ const derivation = { kind: "transform", name: step.name, source: clone(step.source) };
114
+ if (step.name === "concat") return withMappedDerivation(input, derivation);
115
+ if (step.name === "trim") {
116
+ const start = input.text.length - input.text.trimStart().length;
117
+ const end = input.text.trimEnd().length;
118
+ return withMappedDerivation(sliceMapped(input, start, Math.max(start, end)), derivation);
119
+ }
120
+ if (step.name === "indent" && Number.isInteger(step.arguments[0]) && step.arguments[0] >= 0) {
121
+ const padding = " ".repeat(step.arguments[0]);
122
+ const parts = [];
123
+ const lines = input.text.split("\n");
124
+ let cursor = 0;
125
+ for (const line of lines) {
126
+ if (line.length) {
127
+ parts.push(coarseMapped(padding, step.source, chunk, "transform-insert", [derivation]));
128
+ parts.push(withMappedDerivation(sliceMapped(input, cursor, cursor + line.length), derivation));
129
+ }
130
+ cursor += line.length;
131
+ if (cursor < input.text.length) {
132
+ parts.push(withMappedDerivation(sliceMapped(input, cursor, cursor + 1), derivation));
133
+ cursor += 1;
134
+ }
135
+ }
136
+ const result = concatMapped(...parts);
137
+ return result.text === output ? result : null;
138
+ }
139
+ if (step.name === "dedent") {
140
+ const lines = input.text.split("\n");
141
+ const indents = lines.filter((line) => /\S/.test(line)).map((line) => /^\s*/.exec(line)[0].length);
142
+ const amount = indents.length ? Math.min(...indents) : 0;
143
+ const parts = [];
144
+ let cursor = 0;
145
+ for (const line of lines) {
146
+ const end = cursor + line.length;
147
+ parts.push(withMappedDerivation(sliceMapped(input, Math.min(end, cursor + amount), end), derivation));
148
+ cursor = end;
149
+ if (cursor < input.text.length) {
150
+ parts.push(withMappedDerivation(sliceMapped(input, cursor, cursor + 1), derivation));
151
+ cursor += 1;
152
+ }
153
+ }
154
+ const result = concatMapped(...parts);
155
+ return result.text === output ? result : null;
156
+ }
157
+ return null;
158
+ };
159
+
160
+ const replaceMappedOnce = (value, search, replacement) => {
161
+ const index = value.text.indexOf(search);
162
+ if (index === -1 || value.text.indexOf(search, index + search.length) !== -1) return null;
163
+ return concatMapped(
164
+ sliceMapped(value, 0, index),
165
+ replacement,
166
+ sliceMapped(value, index + search.length)
167
+ );
168
+ };
169
+
170
+ const advance = (start, text, index) => {
171
+ let line = start.line;
172
+ let column = start.column;
173
+ for (let i = 0; i < index; i += 1) {
174
+ if (text[i] === "\n") {
175
+ line += 1;
176
+ column = 0;
177
+ } else {
178
+ column += 1;
179
+ }
180
+ }
181
+ return { line, column, offset: start.offset + index };
182
+ };
183
+
184
+ const span = (source, text, start, end) => ({
185
+ uri: source.uri,
186
+ range: {
187
+ start: advance(source.range.start, text, start),
188
+ end: advance(source.range.start, text, end)
189
+ }
190
+ });
191
+
192
+ const diagnostic = (code, message, source, related = []) => ({
193
+ code,
194
+ severity: "error",
195
+ message,
196
+ source,
197
+ related
198
+ });
199
+
200
+ const validComponent = (value) => value === null || (typeof value === "string" && componentPattern.test(value));
201
+
202
+ export const formatChunkId = (identity) => {
203
+ const prefix = identity.document === null ? "" : identity.document + "::";
204
+ return prefix +
205
+ (identity.chunk ?? "") +
206
+ (identity.minor === null ? "" : ":" + identity.minor) +
207
+ (identity.type === null ? "" : "." + identity.type);
208
+ };
209
+
210
+ export const parseChunkId = (input, { reference = false } = {}) => {
211
+ if (typeof input !== "string" || input.length === 0) return null;
212
+
213
+ const delimiter = input.indexOf("::");
214
+ const explicitDocument = delimiter !== -1;
215
+ let document = null;
216
+ let remainder = input;
217
+
218
+ if (explicitDocument) {
219
+ document = input.slice(0, delimiter);
220
+ remainder = input.slice(delimiter + 2);
221
+ if (!componentPattern.test(document)) return null;
222
+ }
223
+
224
+ let type = null;
225
+ const typeIndex = remainder.lastIndexOf(".");
226
+ if (typeIndex !== -1) {
227
+ type = remainder.slice(typeIndex + 1);
228
+ remainder = remainder.slice(0, typeIndex);
229
+ if (!componentPattern.test(type)) return null;
230
+ }
231
+
232
+ let chunk = remainder;
233
+ let minor = null;
234
+ const minorIndex = remainder.indexOf(":");
235
+ if (minorIndex !== -1) {
236
+ chunk = remainder.slice(0, minorIndex);
237
+ minor = remainder.slice(minorIndex + 1);
238
+ if (!componentPattern.test(minor)) return null;
239
+ }
240
+
241
+ if (chunk === "") chunk = null;
242
+ if (!validComponent(chunk)) return null;
243
+ if (!validComponent(document) || !validComponent(minor) || !validComponent(type)) return null;
244
+
245
+ if (!reference && document === null && chunk === null) return null;
246
+ if (!reference && document === null && chunk !== null && input.includes("::")) return null;
247
+ if (!reference && document === null && chunk !== null) return null;
248
+ if (!reference && document !== null && chunk === null && (minor !== null || type !== null)) {
249
+ return { document, chunk, minor, type, explicitDocument };
250
+ }
251
+ if (!reference && document !== null && chunk === null) {
252
+ return { document, chunk, minor, type, explicitDocument };
253
+ }
254
+ if (reference && !explicitDocument && chunk === null && minor === null && type === null) return null;
255
+
256
+ return { document, chunk, minor, type, explicitDocument };
257
+ };
258
+
259
+ const validateIdentity = (identity) => {
260
+ if (!identity || typeof identity !== "object") return null;
261
+ const required = ["document", "chunk", "minor", "type"];
262
+ if (!required.every((key) => Object.hasOwn(identity, key))) return null;
263
+ const result = {
264
+ document: identity.document,
265
+ chunk: identity.chunk,
266
+ minor: identity.minor,
267
+ type: identity.type,
268
+ explicitDocument: identity.document !== null
269
+ };
270
+ if (!validComponent(result.document) || !validComponent(result.chunk) ||
271
+ !validComponent(result.minor) || !validComponent(result.type)) {
272
+ return null;
273
+ }
274
+ if (result.document === null && result.chunk === null) return null;
275
+ return result;
276
+ };
277
+
278
+ const splitTopLevel = (text, separator) => {
279
+ const parts = [];
280
+ let start = 0;
281
+ let quote = "";
282
+ let escaped = false;
283
+ let depth = 0;
284
+
285
+ for (let index = 0; index < text.length; index += 1) {
286
+ const char = text[index];
287
+ if (quote) {
288
+ if (escaped) {
289
+ escaped = false;
290
+ } else if (char === "\\") {
291
+ escaped = true;
292
+ } else if (char === quote) {
293
+ quote = "";
294
+ }
295
+ continue;
296
+ }
297
+ if (char === "'" || char === "\"") {
298
+ quote = char;
299
+ } else if (char === "(" || char === "[" || char === "{") {
300
+ depth += 1;
301
+ } else if (char === ")" || char === "]" || char === "}") {
302
+ depth -= 1;
303
+ } else if (char === separator && depth === 0) {
304
+ parts.push({ value: text.slice(start, index).trim(), start, end: index });
305
+ start = index + 1;
306
+ }
307
+ }
308
+ parts.push({ value: text.slice(start).trim(), start, end: text.length });
309
+ return parts;
310
+ };
311
+
312
+ const parseString = (value) => {
313
+ if (value.startsWith("\"")) {
314
+ try {
315
+ return JSON.parse(value);
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ }
320
+ if (value.startsWith("'") && value.endsWith("'")) {
321
+ return value.slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\");
322
+ }
323
+ return undefined;
324
+ };
325
+
326
+ const parseValue = (value) => {
327
+ const trimmed = value.trim();
328
+ const string = parseString(trimmed);
329
+ if (typeof string === "string") return string;
330
+ if (trimmed === "true") return true;
331
+ if (trimmed === "false") return false;
332
+ if (trimmed === "null") return null;
333
+ if (/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(trimmed)) return Number(trimmed);
334
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
335
+ try {
336
+ return JSON.parse(trimmed);
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ }
341
+ return undefined;
342
+ };
343
+
344
+ const parseEmitSuffix = (value) => {
345
+ if (typeof value !== "string" || value.length === 0 || value.includes(":")) return null;
346
+
347
+ if (value.startsWith(".")) {
348
+ const type = value.slice(1);
349
+ return componentPattern.test(type)
350
+ ? { minor: null, type, inheritMinor: true }
351
+ : null;
352
+ }
353
+
354
+ const typeIndex = value.lastIndexOf(".");
355
+ const minor = typeIndex === -1 ? value : value.slice(0, typeIndex);
356
+ const type = typeIndex === -1 ? null : value.slice(typeIndex + 1);
357
+ if (!componentPattern.test(minor) || !validComponent(type)) return null;
358
+ return { minor, type, inheritMinor: false };
359
+ };
360
+
361
+ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics) => {
362
+ const match = /^([a-z][a-z0-9-]*)\s*(?:\((.*)\))?$/s.exec(part.value);
363
+ const location = span(source, expression, expressionOffset + part.start, expressionOffset + part.end);
364
+ if (!match) {
365
+ diagnostics.push(diagnostic("RV110", "Malformed pipeline step: " + part.value, location));
366
+ return null;
367
+ }
368
+
369
+ const name = match[1];
370
+ const rawArguments = typeof match[2] === "undefined" || match[2].trim() === ""
371
+ ? []
372
+ : splitTopLevel(match[2], ",");
373
+ const argumentsValue = rawArguments.map((argument) => parseValue(argument.value));
374
+
375
+ if (name === "emit") {
376
+ if (argumentsValue.some((argument) => typeof argument === "undefined")) {
377
+ diagnostics.push(diagnostic("RV120", "emit arguments must be JSON-like literals.", location));
378
+ return null;
379
+ }
380
+
381
+ const rawSuffix = argumentsValue[0];
382
+ const metadata = argumentsValue.length > 1 ? argumentsValue[1] : {};
383
+ const suffix = parseEmitSuffix(rawSuffix);
384
+ if (!suffix) {
385
+ diagnostics.push(diagnostic("RV131", "emit requires a local minor/type suffix such as 'cool', 'cool.js', or '.js'.", location));
386
+ return null;
387
+ }
388
+ if (!metadata || Array.isArray(metadata) || typeof metadata !== "object") {
389
+ diagnostics.push(diagnostic("RV131", "emit metadata must be an object.", location));
390
+ return null;
391
+ }
392
+ return { type: "emit", suffix, metadata, source: location };
393
+ }
394
+
395
+ const textArgument = () => {
396
+ if (argumentsValue.length > 1 || argumentsValue.some((argument) => typeof argument !== "string")) {
397
+ diagnostics.push(diagnostic("RV121", "text accepts zero or one string argument.", location));
398
+ return null;
399
+ }
400
+ return { type: "text", value: argumentsValue[0] ?? "", source: location };
401
+ };
402
+ if (name === "text") return textArgument();
403
+
404
+ if (name === "ch") {
405
+ if (argumentsValue.length !== 1 || typeof argumentsValue[0] !== "string") {
406
+ diagnostics.push(diagnostic("RV121", "ch requires one quoted chunk expression.", location));
407
+ return null;
408
+ }
409
+ const parsed = parseExpression(argumentsValue[0], source, expressionOffset + part.start, diagnostics, { allowDelay: false });
410
+ if (!parsed) return null;
411
+ return { type: "chunk", expression: argumentsValue[0], value: parsed, source: location };
412
+ }
413
+
414
+ const argumentsParsed = rawArguments.map((argument) => {
415
+ const value = parseValue(argument.value);
416
+ if (typeof value !== "undefined") return value;
417
+ const nested = parsePipeStep({ value: argument.value, start: part.start + argument.start, end: part.start + argument.end }, expression, source, expressionOffset, diagnostics);
418
+ return nested?.type === "text" || nested?.type === "chunk"
419
+ ? { kind: "ravel-command-argument", command: nested }
420
+ : undefined;
421
+ });
422
+ if (argumentsParsed.some((argument) => typeof argument === "undefined")) {
423
+ diagnostics.push(diagnostic("RV120", "Pipeline arguments must be JSON-like literals, text(...), or ch(...).", location));
424
+ return null;
425
+ }
426
+ return { type: name === "delay" ? "delay" : "transform", name, arguments: argumentsParsed, source: location };
427
+ };
428
+
429
+ const parseExpression = (expression, source, expressionOffset, diagnostics, { allowDelay = true } = {}) => {
430
+ const parts = splitTopLevel(expression, "|");
431
+ const reference = parts.shift();
432
+ const target = parseChunkId(reference?.value, { reference: true });
433
+ const pipeline = parts
434
+ .map((part) => parsePipeStep(part, expression, source, expressionOffset, diagnostics))
435
+ .filter(Boolean);
436
+ if (!reference || !target) {
437
+ const delay = pipeline.length === 1 && pipeline[0].type === "delay" ? pipeline[0] : null;
438
+ if (reference?.value === "" && delay && allowDelay) {
439
+ const [value, phase = 1, safeSymbol] = delay.arguments;
440
+ const command = value?.kind === "ravel-command-argument" ? value.command : null;
441
+ if ((typeof value !== "string" && command?.type !== "text" && command?.type !== "chunk") ||
442
+ !Number.isInteger(phase) || phase < 1 ||
443
+ (safeSymbol !== undefined && (typeof safeSymbol !== "string" || !/^[A-Za-z0-9]+$/.test(safeSymbol)))) {
444
+ diagnostics.push(diagnostic("RV121", "delay requires text(...), ch(...), or a string, then an optional positive phase and safe symbol.", delay.source));
445
+ return null;
446
+ }
447
+ return {
448
+ type: "delay",
449
+ value,
450
+ expression: typeof value === "string" ? value : command.expression ?? command.value,
451
+ phase,
452
+ safeSymbol,
453
+ source: span(source, expression, expressionOffset, expressionOffset + expression.length)
454
+ };
455
+ }
456
+ if (reference?.value === "" && pipeline.length && pipeline[0].type !== "delay" &&
457
+ (pipeline[0].type === "text" || pipeline[0].type === "chunk")) {
458
+ if (pipeline.some((step) => step.type === "delay")) {
459
+ diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
460
+ return null;
461
+ }
462
+ return { type: "pipeline", pipeline, source: span(source, expression, expressionOffset, expressionOffset + expression.length) };
463
+ }
464
+ if (pipeline.some((step) => step.type === "delay")) {
465
+ diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
466
+ return null;
467
+ }
468
+ diagnostics.push(diagnostic("RV110", "Malformed chunk reference: " + (reference?.value ?? ""), span(source, expression, expressionOffset, expressionOffset + expression.length)));
469
+ return null;
470
+ }
471
+
472
+ if (pipeline.some((step) => step.type === "delay")) {
473
+ diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
474
+ return null;
475
+ }
476
+
477
+ return {
478
+ type: "reference",
479
+ reference: reference.value,
480
+ target,
481
+ pipeline,
482
+ source: span(source, expression, expressionOffset, expressionOffset + expression.length)
483
+ };
484
+ };
485
+
486
+ // An embedded reference inherits the indentation of its containing source line
487
+ // on continuation lines. The first line is already positioned by the literal
488
+ // text before the reference, so it deliberately remains untouched.
489
+ const continuationIndentAt = (body, index) => {
490
+ const lineStart = Math.max(body.lastIndexOf("\n", index - 1), body.lastIndexOf("\r", index - 1)) + 1;
491
+ return /^[\t ]*/.exec(body.slice(lineStart, index))[0];
492
+ };
493
+
494
+ const applyContinuationIndentMapped = (value, indentation, source, chunk) => {
495
+ if (!indentation || !/[\r\n]/.test(value.text)) return value;
496
+ const parts = [];
497
+ const pattern = /(\r\n|\n|\r)([^\r\n]*)/g;
498
+ let cursor = 0;
499
+ for (const match of value.text.matchAll(pattern)) {
500
+ if (!/\S/.test(match[2])) continue;
501
+ const insertion = match.index + match[1].length;
502
+ parts.push(sliceMapped(value, cursor, insertion));
503
+ parts.push(coarseMapped(
504
+ indentation,
505
+ source,
506
+ chunk,
507
+ "continuation-indent",
508
+ [{ kind: "indent", value: indentation, source: clone(source) }]
509
+ ));
510
+ cursor = insertion;
511
+ }
512
+ parts.push(sliceMapped(value, cursor));
513
+ return concatMapped(...parts);
514
+ };
515
+
516
+ export const parseChunk = (body, source) => {
517
+ const nodes = [];
518
+ const diagnostics = [];
519
+ let literalStart = 0;
520
+ let index = 0;
521
+
522
+ while (index < body.length) {
523
+ if (body[index] === "\\" && body[index + 1] === "_" && ["\"", "'", "`"].includes(body[index + 2])) {
524
+ index += 3;
525
+ continue;
526
+ }
527
+ if (body[index] !== "_" || !["\"", "'", "`"].includes(body[index + 1])) {
528
+ index += 1;
529
+ continue;
530
+ }
531
+
532
+ const quote = body[index + 1];
533
+ let end = index + 2;
534
+ let escaped = false;
535
+ while (end < body.length) {
536
+ if (escaped) {
537
+ escaped = false;
538
+ } else if (body[end] === "\\") {
539
+ escaped = true;
540
+ } else if (body[end] === quote) {
541
+ break;
542
+ }
543
+ end += 1;
544
+ }
545
+
546
+ if (end >= body.length) {
547
+ diagnostics.push(diagnostic("RV110", "Unterminated quoted chunk reference.", span(source, body, index, body.length)));
548
+ break;
549
+ }
550
+
551
+ if (literalStart < index) {
552
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
553
+ }
554
+ const expression = body.slice(index + 2, end);
555
+ const parsed = parseExpression(expression, source, index + 2, diagnostics);
556
+ if (parsed) nodes.push({ ...parsed, continuationIndent: continuationIndentAt(body, index) });
557
+ index = end + 1;
558
+ literalStart = index;
559
+ }
560
+
561
+ if (literalStart < body.length) {
562
+ nodes.push({ type: "literal", value: body.slice(literalStart), source: span(source, body, literalStart, body.length) });
563
+ }
564
+ return { nodes, diagnostics };
565
+ };
566
+
567
+ const parseChunkFragments = (raw) => {
568
+ const parsed = parseChunk(raw.body, raw.source);
569
+ if (!Array.isArray(raw.fragments) || raw.fragments.length < 2 ||
570
+ raw.fragments.some((fragment) => typeof fragment?.body !== "string" || !fragment.source) ||
571
+ raw.fragments.map((fragment) => fragment.body).join("") !== raw.body) {
572
+ return parsed;
573
+ }
574
+
575
+ let bodyOffset = 0;
576
+ const fragments = raw.fragments.map((fragment) => {
577
+ const start = bodyOffset;
578
+ bodyOffset += fragment.body.length;
579
+ return { ...fragment, start, end: bodyOffset };
580
+ });
581
+ const syntheticStart = raw.source.range.start.offset;
582
+ const relativeRange = (source) => ({
583
+ start: source.range.start.offset - syntheticStart,
584
+ end: source.range.end.offset - syntheticStart
585
+ });
586
+ const remapLocation = (source) => {
587
+ const range = relativeRange(source);
588
+ const fragment = fragments.find((entry) => range.start >= entry.start && range.end <= entry.end);
589
+ if (!fragment) return source;
590
+ return span(
591
+ fragment.source,
592
+ fragment.body,
593
+ range.start - fragment.start,
594
+ range.end - fragment.start
595
+ );
596
+ };
597
+ const remapSources = (value) => {
598
+ if (!value || typeof value !== "object") return value;
599
+ if (typeof value.uri === "string" && value.range?.start && value.range?.end) {
600
+ return remapLocation(value);
601
+ }
602
+ if (Array.isArray(value)) return value.map(remapSources);
603
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, remapSources(child)]));
604
+ };
605
+
606
+ const nodes = [];
607
+ for (const node of parsed.nodes) {
608
+ if (node.type !== "literal") {
609
+ nodes.push(remapSources(node));
610
+ continue;
611
+ }
612
+ const range = relativeRange(node.source);
613
+ for (const fragment of fragments) {
614
+ const start = Math.max(range.start, fragment.start);
615
+ const end = Math.min(range.end, fragment.end);
616
+ if (start >= end) continue;
617
+ nodes.push({
618
+ type: "literal",
619
+ value: raw.body.slice(start, end),
620
+ source: span(fragment.source, fragment.body, start - fragment.start, end - fragment.start)
621
+ });
622
+ }
623
+ }
624
+ return { nodes, diagnostics: remapSources(parsed.diagnostics) };
625
+ };
626
+
627
+ const normalizeChunk = (raw, document, diagnostics) => {
628
+ const identity = validateIdentity(raw.identity);
629
+ if (!identity) {
630
+ diagnostics.push(diagnostic("RV100", "Chunks require explicit document, chunk, minor, and type identity fields.", raw.source));
631
+ return null;
632
+ }
633
+ if (identity.document !== null && identity.document !== document.id) {
634
+ diagnostics.push(diagnostic("RV100", "Chunk identity document must match its source map document.", raw.source));
635
+ return null;
636
+ }
637
+ const id = formatChunkId(identity);
638
+ if (raw.id !== id) {
639
+ diagnostics.push(diagnostic("RV100", "Chunk id must equal its canonical identity form: " + id, raw.source));
640
+ return null;
641
+ }
642
+ return { ...clone(raw), id, identity };
643
+ };
644
+
645
+ export const combineMaps = (maps) => {
646
+ const diagnostics = [];
647
+ const chunks = [];
648
+ const directives = [];
649
+ const documents = [];
650
+ const ids = new Set();
651
+ const documentIds = new Set();
652
+
653
+ for (const map of maps) {
654
+ if (documentIds.has(map.document.id)) {
655
+ diagnostics.push(diagnostic("RV102", "Duplicate document ID: " + map.document.id, {
656
+ uri: map.document.uri,
657
+ range: { start: { line: 0, column: 0, offset: 0 }, end: { line: 0, column: 0, offset: 0 } }
658
+ }));
659
+ continue;
660
+ }
661
+ documentIds.add(map.document.id);
662
+ documents.push(clone(map.document));
663
+ for (const raw of map.chunks ?? []) {
664
+ const chunk = normalizeChunk(raw, map.document, diagnostics);
665
+ if (!chunk) continue;
666
+ if (ids.has(chunk.id)) {
667
+ diagnostics.push(diagnostic("RV101", "Duplicate chunk ID: " + chunk.id, chunk.source));
668
+ } else {
669
+ ids.add(chunk.id);
670
+ chunks.push(chunk);
671
+ }
672
+ }
673
+ for (const directive of map.directives ?? []) directives.push(clone(directive));
674
+ }
675
+
676
+ return { version: 1, documents, chunks, directives, diagnostics };
677
+ };
678
+
679
+ const customTransform = (transforms, name) => transforms instanceof Map ? transforms.get(name) : transforms?.[name];
680
+
681
+ const applyTransform = (value, step, diagnostics, transforms, context = {}) => {
682
+ if (step.name === "concat") return value;
683
+ if (step.name === "trim") return value.trim();
684
+ if (step.name === "normalize-eol") return value.replace(/\r\n?/g, "\n");
685
+ if (step.name === "indent") {
686
+ const count = step.arguments[0];
687
+ if (!Number.isInteger(count) || count < 0) {
688
+ diagnostics.push(diagnostic("RV120", "indent requires a non-negative integer.", step.source));
689
+ return value;
690
+ }
691
+ const padding = " ".repeat(count);
692
+ return value.split("\n").map((line) => line ? padding + line : line).join("\n");
693
+ }
694
+ if (step.name === "dedent") {
695
+ const lines = value.split("\n");
696
+ const indents = lines.filter((line) => /\S/.test(line)).map((line) => /^\s*/.exec(line)[0].length);
697
+ const amount = indents.length ? Math.min(...indents) : 0;
698
+ return lines.map((line) => line.slice(amount)).join("\n");
699
+ }
700
+ if (step.name === "replace") {
701
+ const search = step.arguments[0];
702
+ const replacement = step.arguments[1];
703
+ if (typeof search !== "string" || typeof replacement !== "string") {
704
+ diagnostics.push(diagnostic("RV120", "replace requires string search and replacement arguments.", step.source));
705
+ return value;
706
+ }
707
+ return value.split(search).join(replacement);
708
+ }
709
+ if (step.name === "quote-reference") {
710
+ return value.replace(/_(["'\x60])/g, "\\_$1");
711
+ }
712
+
713
+ const transform = customTransform(transforms, step.name);
714
+ if (typeof transform === "function") {
715
+ try {
716
+ const result = transform(value, { ...context, arguments: clone(step.arguments), transform: step.name });
717
+ if (typeof result === "string") return result;
718
+ diagnostics.push(diagnostic("RV121", "Transform " + step.name + " must return a string.", step.source));
719
+ } catch (error) {
720
+ diagnostics.push(diagnostic("RV121", "Transform " + step.name + " failed: " + (error?.message ?? String(error)), step.source));
721
+ }
722
+ return value;
723
+ }
724
+
725
+ diagnostics.push(diagnostic("RV120", "Unknown transform: " + step.name, step.source));
726
+ return value;
727
+ };
728
+
729
+ const definitionFromEmission = (owner, reference, prefix, emit) => {
730
+ const identity = {
731
+ document: owner.identity.document,
732
+ chunk: owner.identity.chunk,
733
+ minor: emit.suffix.inheritMinor ? owner.identity.minor : emit.suffix.minor,
734
+ type: emit.suffix.type,
735
+ explicitDocument: true
736
+ };
737
+ const id = formatChunkId(identity);
738
+ return {
739
+ id,
740
+ identity,
741
+ name: typeof emit.metadata.name === "string" ? emit.metadata.name : id,
742
+ metadata: {
743
+ language: emit.metadata.language,
744
+ tags: Array.isArray(emit.metadata.tags) ? emit.metadata.tags : [],
745
+ data: emit.metadata.data && typeof emit.metadata.data === "object" ? emit.metadata.data : {}
746
+ },
747
+ source: emit.source,
748
+ definitionPipeline: [],
749
+ generated: true,
750
+ origin: {
751
+ kind: "emit",
752
+ owner: owner.id,
753
+ source: emit.source,
754
+ reference: reference.reference,
755
+ pipeline: clone(prefix)
756
+ },
757
+ ast: [{
758
+ type: "reference",
759
+ reference: reference.reference,
760
+ target: clone(reference.target),
761
+ pipeline: clone(prefix),
762
+ continuationIndent: "",
763
+ source: reference.source
764
+ }]
765
+ };
766
+ };
767
+
768
+ const definitionFromComposeEmission = (owner, capture, emit) => {
769
+ const identity = {
770
+ document: owner.identity.document,
771
+ chunk: owner.identity.chunk,
772
+ minor: emit.suffix.inheritMinor ? owner.identity.minor : emit.suffix.minor,
773
+ type: emit.suffix.type,
774
+ explicitDocument: true
775
+ };
776
+ const id = formatChunkId(identity);
777
+ return {
778
+ id,
779
+ identity,
780
+ name: typeof emit.metadata.name === "string" ? emit.metadata.name : id,
781
+ metadata: {
782
+ language: emit.metadata.language,
783
+ tags: Array.isArray(emit.metadata.tags) ? emit.metadata.tags : [],
784
+ data: emit.metadata.data && typeof emit.metadata.data === "object" ? emit.metadata.data : {}
785
+ },
786
+ source: emit.source,
787
+ definitionPipeline: [],
788
+ generated: true,
789
+ origin: {
790
+ kind: "emit",
791
+ owner: owner.id,
792
+ source: emit.source,
793
+ compose: clone(capture)
794
+ },
795
+ composeCapture: { owner: owner.id, ...clone(capture) }
796
+ };
797
+ };
798
+
799
+ const resolveTarget = (target, owner, definitions) => {
800
+ const withOwnerChunk = target.chunk === null && !target.explicitDocument
801
+ ? owner.identity.chunk
802
+ : target.chunk;
803
+ const parts = {
804
+ chunk: withOwnerChunk,
805
+ minor: target.minor,
806
+ type: target.type
807
+ };
808
+
809
+ if (target.explicitDocument) {
810
+ const id = formatChunkId({ document: target.document, ...parts });
811
+ return definitions.has(id) ? id : null;
812
+ }
813
+
814
+ if (owner.identity.document !== null) {
815
+ const local = formatChunkId({ document: owner.identity.document, ...parts });
816
+ if (definitions.has(local)) return local;
817
+ }
818
+
819
+ const global = formatChunkId({ document: null, ...parts });
820
+ return definitions.has(global) ? global : null;
821
+ };
822
+
823
+ export const transformGraph = (pretransform, options = {}) => {
824
+ const diagnostics = [...(pretransform.diagnostics ?? [])];
825
+ const definitions = new Map();
826
+ // An automatically generated delay marker must not vary between equivalent
827
+ // builds. Keep the authored input as a collision corpus so a marker cannot
828
+ // accidentally replace a literal already present in the project.
829
+ const delayTokenCorpus = JSON.stringify(pretransform);
830
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
831
+ const orderedEntries = (value) => Object.entries(value).sort(([left], [right]) => compareText(left, right));
832
+
833
+ const normalizeDefinitionPipeline = (steps, source) => {
834
+ if (steps === undefined) return [];
835
+ if (!Array.isArray(steps)) {
836
+ diagnostics.push(diagnostic("RV121", "definitionPipeline must be an ordered array of transform calls.", source));
837
+ return [];
838
+ }
839
+ const normalized = [];
840
+ for (const step of steps) {
841
+ if (step?.type !== undefined && step.type !== "transform" || typeof step?.name !== "string" || !Array.isArray(step?.arguments ?? [])) {
842
+ diagnostics.push(diagnostic("RV121", "definitionPipeline accepts transform calls only.", step?.source ?? source));
843
+ continue;
844
+ }
845
+ normalized.push({ type: "transform", name: step.name, arguments: clone(step.arguments ?? []), source: step.source ?? source });
846
+ }
847
+ return normalized;
848
+ };
849
+
850
+ for (const raw of pretransform.chunks ?? []) {
851
+ const parsed = parseChunkFragments(raw);
852
+ diagnostics.push(...parsed.diagnostics);
853
+ definitions.set(raw.id, {
854
+ id: raw.id,
855
+ identity: raw.identity,
856
+ name: raw.name ?? raw.id,
857
+ metadata: raw.metadata ?? {},
858
+ source: raw.source,
859
+ definitionPipeline: normalizeDefinitionPipeline(raw.definitionPipeline, raw.source),
860
+ generated: false,
861
+ origin: { kind: "source", source: raw.source },
862
+ ast: parsed.nodes
863
+ });
864
+ }
865
+
866
+ const directiveIdentity = (directive) => {
867
+ const document = directive.document;
868
+ if (typeof document !== "string" || typeof directive.name !== "string" || directive.name.includes("::")) return null;
869
+ const parsed = parseChunkId(document + "::" + directive.name, { reference: true });
870
+ return parsed?.chunk === null ? null : parsed;
871
+ };
872
+
873
+ const normalizeComposePipeline = (steps, source) => {
874
+ if (!Array.isArray(steps)) return null;
875
+ const normalized = [];
876
+ for (const step of steps) {
877
+ if (step?.type === "transform" && typeof step.name === "string" && Array.isArray(step.arguments)) {
878
+ normalized.push({ type: "transform", name: step.name, arguments: clone(step.arguments), source: step.source ?? source });
879
+ } else if (step?.type === "emit" && typeof step.metadata === "object" && step.metadata !== null) {
880
+ const suffix = typeof step.suffix === "string" ? parseEmitSuffix(step.suffix) : step.suffix;
881
+ if (!suffix || !validComponent(suffix.minor) || !validComponent(suffix.type) ||
882
+ typeof suffix.inheritMinor !== "boolean") {
883
+ diagnostics.push(diagnostic("RV131", "emit requires a local minor/type suffix such as 'cool', 'cool.js', or '.js'.", step.source ?? source));
884
+ return null;
885
+ }
886
+ normalized.push({ type: "emit", suffix: clone(suffix), metadata: clone(step.metadata), source: step.source ?? source });
887
+ } else {
888
+ diagnostics.push(diagnostic("RV130", "pipe and pass require transform or emit pipeline steps.", step?.source ?? source));
889
+ return null;
890
+ }
891
+ }
892
+ return normalized;
893
+ };
894
+
895
+ const parseCompose = (steps, source) => {
896
+ if (!Array.isArray(steps)) {
897
+ diagnostics.push(diagnostic("RV130", "create compose requires an ordered step list.", source));
898
+ return null;
899
+ }
900
+ const parsed = [];
901
+ for (const step of steps) {
902
+ if (step?.kind === "newline") {
903
+ if (!Number.isInteger(step.count) || step.count < 0) {
904
+ diagnostics.push(diagnostic("RV130", "newline requires a non-negative integer.", step.source ?? source));
905
+ return null;
906
+ }
907
+ parsed.push({ kind: "newline", count: step.count, source: step.source ?? source });
908
+ } else if (step?.kind === "append" && typeof step.reference === "string") {
909
+ const reference = parseExpression(step.reference, step.source ?? source, 0, diagnostics);
910
+ if (!reference) return null;
911
+ parsed.push({ kind: "append", reference, source: step.source ?? source });
912
+ } else if (step?.kind === "pipe" || step?.kind === "pass") {
913
+ const pipeline = normalizeComposePipeline(step.steps, step.source ?? source);
914
+ if (!pipeline) return null;
915
+ parsed.push({ kind: step.kind, pipeline, source: step.source ?? source });
916
+ } else {
917
+ diagnostics.push(diagnostic("RV130", "compose accepts append, newline, pipe, and pass steps.", step?.source ?? source));
918
+ return null;
919
+ }
920
+ }
921
+ return parsed;
922
+ };
923
+
924
+ for (const directive of pretransform.directives ?? []) {
925
+ if (directive.kind !== "create" && directive.kind !== "alias") continue;
926
+ const identity = directiveIdentity(directive);
927
+ if (!identity) {
928
+ diagnostics.push(diagnostic("RV130", directive.kind + " requires a current document and local chunk:minor.type name.", directive.source));
929
+ continue;
930
+ }
931
+ const id = formatChunkId(identity);
932
+ if (definitions.has(id)) {
933
+ diagnostics.push(diagnostic("RV101", "Duplicate directive chunk ID: " + id, directive.source));
934
+ continue;
935
+ }
936
+ const source = directive.source;
937
+ if (directive.kind === "create") {
938
+ const compose = directive.compose ? parseCompose(directive.compose, source) : null;
939
+ if (directive.compose && !compose) continue;
940
+ const parsed = compose ? { nodes: [], diagnostics: [] } : parseChunk(typeof directive.body === "string" ? directive.body : "", source);
941
+ diagnostics.push(...parsed.diagnostics);
942
+ definitions.set(id, {
943
+ id, identity, name: directive.name, metadata: directive.metadata ?? {}, source,
944
+ definitionPipeline: [], generated: true, origin: { kind: "create", source }, ast: parsed.nodes, compose
945
+ });
946
+ } else {
947
+ const reference = parseExpression(directive.reference, source, 0, diagnostics);
948
+ if (!reference) continue;
949
+ definitions.set(id, {
950
+ id, identity, name: directive.name, metadata: directive.metadata ?? {}, source,
951
+ definitionPipeline: [], generated: true, origin: { kind: "alias", source, target: directive.reference }, ast: [reference]
952
+ });
953
+ }
954
+ }
955
+
956
+ const addEmission = (emitted, source) => {
957
+ if (definitions.has(emitted.id)) {
958
+ diagnostics.push(diagnostic("RV101", "emit creates duplicate chunk ID: " + emitted.id, source));
959
+ } else {
960
+ definitions.set(emitted.id, emitted);
961
+ }
962
+ };
963
+
964
+ for (const definition of [...definitions.values()]) {
965
+ for (const node of definition.ast) {
966
+ if (node.type !== "reference") continue;
967
+ const prefix = [];
968
+ const retained = [];
969
+ for (const step of node.pipeline) {
970
+ if (step.type === "emit") {
971
+ if (definition.identity.document === null) {
972
+ diagnostics.push(diagnostic("RV131", "emit is unavailable from a document-less global chunk.", step.source));
973
+ continue;
974
+ }
975
+ const emitted = definitionFromEmission(definition, node, prefix, step);
976
+ addEmission(emitted, step.source);
977
+ } else {
978
+ prefix.push(step);
979
+ retained.push(step);
980
+ }
981
+ }
982
+ node.pipeline = retained;
983
+ }
984
+ for (const [stepIndex, step] of (definition.compose ?? []).entries()) {
985
+ if (step.kind === "append") {
986
+ const prefix = [];
987
+ const retained = [];
988
+ for (const pipelineStep of step.reference.pipeline) {
989
+ if (pipelineStep.type === "emit") {
990
+ if (definition.identity.document === null) {
991
+ diagnostics.push(diagnostic("RV131", "emit is unavailable from a document-less global chunk.", pipelineStep.source));
992
+ } else {
993
+ addEmission(definitionFromEmission(definition, step.reference, prefix, pipelineStep), pipelineStep.source);
994
+ }
995
+ } else {
996
+ prefix.push(pipelineStep);
997
+ retained.push(pipelineStep);
998
+ }
999
+ }
1000
+ step.reference.pipeline = retained;
1001
+ }
1002
+ if (step.kind !== "pipe" && step.kind !== "pass") continue;
1003
+ for (const [pipelineIndex, pipelineStep] of step.pipeline.entries()) {
1004
+ if (pipelineStep.type !== "emit") continue;
1005
+ if (definition.identity.document === null) {
1006
+ diagnostics.push(diagnostic("RV131", "emit is unavailable from a document-less global chunk.", pipelineStep.source));
1007
+ continue;
1008
+ }
1009
+ addEmission(definitionFromComposeEmission(definition, {
1010
+ stepIndex,
1011
+ pipelineIndex,
1012
+ stepKind: step.kind
1013
+ }, pipelineStep), pipelineStep.source);
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ const values = new Map();
1019
+ const evaluating = [];
1020
+ const resultChunks = {};
1021
+ const traceChunks = {};
1022
+
1023
+ const stableTokenHash = (value) => {
1024
+ let hash = 2166136261;
1025
+ for (let index = 0; index < value.length; index += 1) {
1026
+ hash ^= value.charCodeAt(index);
1027
+ hash = Math.imul(hash, 16777619);
1028
+ }
1029
+ return (hash >>> 0).toString(36).toUpperCase();
1030
+ };
1031
+
1032
+ const evaluateTransformMapped = (input, step, definition, context = {}) => {
1033
+ const output = applyTransform(
1034
+ input.text,
1035
+ step,
1036
+ diagnostics,
1037
+ options.transforms,
1038
+ { chunk: definition, ...context }
1039
+ );
1040
+ return mapBuiltinTransform(input, output, step, definition.id) ?? coarseDerivedMapped(
1041
+ output,
1042
+ step.source,
1043
+ definition.id,
1044
+ "transform",
1045
+ [{
1046
+ kind: "transform",
1047
+ name: step.name,
1048
+ ...(context.phase ? { phase: context.phase } : {}),
1049
+ source: clone(step.source)
1050
+ }],
1051
+ input
1052
+ );
1053
+ };
1054
+
1055
+ const evaluateReference = (node, owner) => {
1056
+ const resolved = resolveTarget(node.target, owner.definition, definitions);
1057
+ if (!resolved) {
1058
+ diagnostics.push(diagnostic("RV111", "Unknown chunk reference: " + node.reference, node.source));
1059
+ return mappedValue();
1060
+ }
1061
+ const dependency = evaluate(resolved, node.source);
1062
+ owner.dependencies.add(resolved);
1063
+ owner.references.push({ chunk: resolved, requested: node.reference, source: node.source });
1064
+ const reference = {
1065
+ kind: "reference",
1066
+ from: owner.definition.id,
1067
+ to: resolved,
1068
+ source: clone(node.source)
1069
+ };
1070
+ let value = mappedValue(dependency.value, dependency.segments.map((segment) => ({
1071
+ ...clone(segment),
1072
+ via: [...(segment.via ?? []), reference]
1073
+ })));
1074
+ return evaluatePipeline(value, node.pipeline, owner);
1075
+ };
1076
+
1077
+ const delayToken = (node, owner) => {
1078
+ const safeSymbol = node.safeSymbol;
1079
+ if (safeSymbol) return safeSymbol;
1080
+ const source = node.source ?? {};
1081
+ const start = source.range?.start ?? {};
1082
+ const identity = owner.definition.id + "\u0000" + (source.uri ?? "") + "\u0000" +
1083
+ (start.offset ?? "") + "\u0000" + (node.expression ?? "");
1084
+ let attempt = 0;
1085
+ while (true) {
1086
+ const token = "RAVELDELAY" + stableTokenHash(identity + "\u0000" + attempt) +
1087
+ (attempt === 0 ? "" : "X" + attempt.toString(36).toUpperCase());
1088
+ if (!delayTokenCorpus.includes(token) && !owner.delays.some((delay) => delay.token === token)) return token;
1089
+ attempt += 1;
1090
+ }
1091
+ };
1092
+
1093
+ const evaluateDelay = (node, owner) => {
1094
+ const token = delayToken(node, owner);
1095
+ if (owner.delays.some((delay) => delay.token === token)) {
1096
+ diagnostics.push(diagnostic("RV121", "Each delay safe symbol must be unique within a chunk.", node.source));
1097
+ return mappedValue();
1098
+ }
1099
+ owner.delays.push({ ...node, token });
1100
+ return coarseMapped(token, node.source, owner.definition.id, "delay-placeholder");
1101
+ };
1102
+
1103
+ const evaluateArgument = (argument, owner) => {
1104
+ const command = argument?.kind === "ravel-command-argument" ? argument.command : null;
1105
+ if (command?.type === "text") return command.value;
1106
+ if (command?.type === "chunk") return evaluateExpression(command.value, owner).text;
1107
+ return argument;
1108
+ };
1109
+
1110
+ const evaluatePipeline = (initial, pipeline, owner) => {
1111
+ let value = initial;
1112
+ for (const step of pipeline) {
1113
+ if (step.type === "text") {
1114
+ value = coarseMapped(step.value, step.source, owner.definition.id, "text");
1115
+ } else if (step.type === "chunk") {
1116
+ value = evaluateExpression(step.value, owner);
1117
+ } else if (step.type === "transform") {
1118
+ const argumentsValue = step.arguments.map((argument) => evaluateArgument(argument, owner));
1119
+ value = evaluateTransformMapped(value, { ...step, arguments: argumentsValue }, owner.definition);
1120
+ }
1121
+ }
1122
+ return value;
1123
+ };
1124
+
1125
+ const evaluateExpression = (node, owner) => node.type === "pipeline"
1126
+ ? evaluatePipeline(mappedValue(), node.pipeline, owner)
1127
+ : evaluateReference(node, owner);
1128
+
1129
+ const evaluateDelayValue = (value, owner, source) => {
1130
+ const command = value?.kind === "ravel-command-argument" ? value.command : null;
1131
+ if (command?.type === "text") return coarseMapped(command.value, command.source ?? source, owner.definition.id, "text");
1132
+ if (command?.type === "chunk") return evaluateExpression(command.value, owner);
1133
+ const text = typeof value === "string" ? value : String(value ?? "");
1134
+ return sourceSegment(text, source, owner.definition.id, "delay-value");
1135
+ };
1136
+
1137
+ const evaluateNode = (node, owner) => {
1138
+ if (node.type === "literal") {
1139
+ return sourceSegment(node.value, node.source, owner.definition.id, "literal");
1140
+ }
1141
+ if (node.type === "delay") return evaluateDelay(node, owner);
1142
+ const value = evaluateExpression(node, owner);
1143
+ if (!node.continuationIndent) return value;
1144
+ return applyContinuationIndentMapped(
1145
+ value,
1146
+ node.continuationIndent,
1147
+ node.source,
1148
+ owner.definition.id
1149
+ );
1150
+ };
1151
+
1152
+ const evaluateCompose = (definition, owner, capture = null) => {
1153
+ let value = mappedValue();
1154
+ let pendingNewlines = 1;
1155
+ let pendingNewlineSource = definition.source;
1156
+ let hasAppend = false;
1157
+ const composeOwner = { ...owner, definition };
1158
+
1159
+ for (const [stepIndex, step] of definition.compose.entries()) {
1160
+ if (step.kind === "newline") {
1161
+ pendingNewlines = step.count;
1162
+ pendingNewlineSource = step.source;
1163
+ continue;
1164
+ }
1165
+ if (step.kind === "append") {
1166
+ if (hasAppend) {
1167
+ value = concatMapped(value, coarseMapped(
1168
+ "\n".repeat(pendingNewlines),
1169
+ pendingNewlineSource,
1170
+ definition.id,
1171
+ "compose-newline"
1172
+ ));
1173
+ }
1174
+ value = concatMapped(value, evaluateNode(step.reference, composeOwner));
1175
+ hasAppend = true;
1176
+ pendingNewlines = 1;
1177
+ pendingNewlineSource = step.source;
1178
+ continue;
1179
+ }
1180
+
1181
+ const input = value;
1182
+ let transformed = value;
1183
+ for (const [pipelineIndex, pipelineStep] of step.pipeline.entries()) {
1184
+ if (pipelineStep.type === "transform") {
1185
+ transformed = evaluateTransformMapped(transformed, pipelineStep, definition);
1186
+ }
1187
+ if (capture && capture.stepIndex === stepIndex && capture.pipelineIndex === pipelineIndex) {
1188
+ return transformed;
1189
+ }
1190
+ }
1191
+ value = step.kind === "pipe" ? transformed : input;
1192
+ }
1193
+ return value;
1194
+ };
1195
+
1196
+ const evaluate = (id, requestedFrom) => {
1197
+ if (values.has(id)) return values.get(id);
1198
+ const definition = definitions.get(id);
1199
+ if (!definition) {
1200
+ diagnostics.push(diagnostic("RV111", "Unknown chunk reference: " + id, requestedFrom));
1201
+ return { value: "", segments: [], dependencies: [], provenance: [] };
1202
+ }
1203
+
1204
+ const cycleIndex = evaluating.indexOf(id);
1205
+ if (cycleIndex !== -1) {
1206
+ const cycle = [...evaluating.slice(cycleIndex), id];
1207
+ diagnostics.push(diagnostic("RV112", "Chunk reference cycle: " + cycle.join(" → "), requestedFrom));
1208
+ return { value: "", segments: [], dependencies: [], provenance: [] };
1209
+ }
1210
+
1211
+ evaluating.push(id);
1212
+ const owner = { definition, dependencies: new Set(), references: [], delays: [], trace: [] };
1213
+ let value = mappedValue();
1214
+ if (definition.composeCapture) {
1215
+ const sourceDefinition = definitions.get(definition.composeCapture.owner);
1216
+ if (!sourceDefinition?.compose) {
1217
+ diagnostics.push(diagnostic("RV130", "emit capture has no compose source.", definition.source));
1218
+ } else {
1219
+ value = evaluateCompose(sourceDefinition, owner, definition.composeCapture);
1220
+ }
1221
+ } else if (definition.compose) {
1222
+ value = evaluateCompose(definition, owner);
1223
+ } else {
1224
+ for (const node of definition.ast) {
1225
+ value = concatMapped(value, evaluateNode(node, owner));
1226
+ }
1227
+ }
1228
+
1229
+ const phaseCount = Math.max(1, definition.definitionPipeline.length);
1230
+ for (let phase = 1; phase <= phaseCount; phase += 1) {
1231
+ owner.trace.push({ phase, stage: "protected-input", value: value.text });
1232
+ const step = definition.definitionPipeline[phase - 1];
1233
+ if (step) {
1234
+ value = evaluateTransformMapped(value, step, definition, { phase });
1235
+ owner.trace.push({
1236
+ phase,
1237
+ stage: "transform-output",
1238
+ transform: { name: step.name, arguments: clone(step.arguments) },
1239
+ value: value.text
1240
+ });
1241
+ }
1242
+ const due = owner.delays.filter((delay) => delay.phase === phase);
1243
+ if (due.length) {
1244
+ for (const delay of due) {
1245
+ const occurrences = value.text.split(delay.token).length - 1;
1246
+ if (occurrences !== 1) {
1247
+ diagnostics.push(diagnostic("RV123", "Delay safe symbol was " + (occurrences ? "duplicated" : "removed") + " by a transform: " + delay.token, delay.source));
1248
+ }
1249
+ const replacement = evaluateDelayValue(delay.value, owner, delay.source);
1250
+ const exactReplacement = occurrences === 1
1251
+ ? replaceMappedOnce(value, delay.token, replacement)
1252
+ : null;
1253
+ value = exactReplacement ?? coarseMapped(
1254
+ value.text.split(delay.token).join(replacement.text),
1255
+ delay.source,
1256
+ definition.id,
1257
+ "delay-fulfillment"
1258
+ );
1259
+ }
1260
+ owner.trace.push({
1261
+ phase,
1262
+ stage: "fulfilled-output",
1263
+ delays: due.map((delay) => ({ expression: delay.expression, safeSymbol: delay.token, source: delay.source })),
1264
+ value: value.text
1265
+ });
1266
+ }
1267
+ }
1268
+ for (const delay of owner.delays.filter((entry) => entry.phase > phaseCount)) {
1269
+ diagnostics.push(diagnostic("RV122", "delay requests phase " + delay.phase + ", but this chunk has only " + definition.definitionPipeline.length + " definition transform phases.", delay.source));
1270
+ value = coarseMapped(
1271
+ value.text.split(delay.token).join(""),
1272
+ delay.source,
1273
+ definition.id,
1274
+ "delay-removal"
1275
+ );
1276
+ }
1277
+ if (definition.generated) {
1278
+ const derivation = {
1279
+ kind: definition.origin.kind,
1280
+ source: clone(definition.origin.source),
1281
+ ...(definition.origin.owner ? { owner: definition.origin.owner } : {}),
1282
+ ...(definition.origin.target ? { target: definition.origin.target } : {})
1283
+ };
1284
+ value = mappedValue(value.text, value.segments.map((segment) => ({
1285
+ ...segment,
1286
+ via: [...(segment.via ?? []), derivation]
1287
+ })));
1288
+ }
1289
+ evaluating.pop();
1290
+
1291
+ const completed = {
1292
+ id,
1293
+ identity: definition.identity,
1294
+ name: definition.name,
1295
+ value: value.text,
1296
+ segments: value.segments,
1297
+ metadata: definition.metadata,
1298
+ dependencies: [...owner.dependencies].sort(),
1299
+ // References retain authored source order, which is deterministic and is
1300
+ // more useful for explaining a chunk than lexical target order.
1301
+ references: owner.references,
1302
+ trace: owner.trace,
1303
+ provenance: [definition.origin],
1304
+ generated: definition.generated
1305
+ };
1306
+ values.set(id, completed);
1307
+ resultChunks[id] = completed;
1308
+ if (owner.trace.length) traceChunks[id] = owner.trace;
1309
+ return completed;
1310
+ };
1311
+
1312
+ for (const id of definitions.keys()) evaluate(id, definitions.get(id).source);
1313
+
1314
+ const deliverables = {};
1315
+ for (const directive of pretransform.directives ?? []) {
1316
+ if (directive.kind !== "out") continue;
1317
+ const name = directive.name ?? directive.target;
1318
+ if (typeof name !== "string" || !name) {
1319
+ diagnostics.push(diagnostic("RV130", "out requires a file-like name.", directive.source));
1320
+ continue;
1321
+ }
1322
+ const target = parseChunkId(directive.from);
1323
+ const id = target && target.explicitDocument
1324
+ ? formatChunkId(target)
1325
+ : null;
1326
+ if (!id || !definitions.has(id)) {
1327
+ diagnostics.push(diagnostic("RV130", "out requires a fully qualified existing source chunk ID.", directive.source));
1328
+ continue;
1329
+ }
1330
+ const chunk = evaluate(id, directive.source);
1331
+ if (deliverables[name]) {
1332
+ diagnostics.push(diagnostic("RV101", "Duplicate out deliverable: " + name, directive.source));
1333
+ continue;
1334
+ }
1335
+ deliverables[name] = {
1336
+ name,
1337
+ from: id,
1338
+ value: chunk.value,
1339
+ segments: chunk.segments,
1340
+ dependencies: chunk.dependencies,
1341
+ provenance: chunk.provenance,
1342
+ source: directive.source
1343
+ };
1344
+ }
1345
+
1346
+ return {
1347
+ version: 1,
1348
+ documents: (pretransform.documents ?? []).slice().sort((left, right) =>
1349
+ compareText(left.id ?? "", right.id ?? "") || compareText(left.uri ?? "", right.uri ?? "")
1350
+ ),
1351
+ chunks: Object.fromEntries(orderedEntries(resultChunks)),
1352
+ trace: { chunks: Object.fromEntries(orderedEntries(traceChunks)) },
1353
+ deliverables: Object.fromEntries(orderedEntries(deliverables)),
1354
+ // Diagnostics retain parse/evaluation order so related failures read in
1355
+ // the order their authored constructs are encountered.
1356
+ diagnostics
1357
+ };
1358
+ };
1359
+
1360
+ export const provenanceMapVersion = 1;
1361
+
1362
+ /**
1363
+ * Create the portable sidecar representation for one generated deliverable.
1364
+ * Offsets use JavaScript/JSON's native UTF-16 code-unit indexing.
1365
+ */
1366
+ export const createDeliverableProvenanceMap = (deliverable) => ({
1367
+ version: provenanceMapVersion,
1368
+ kind: "ravel-provenance-map",
1369
+ generated: {
1370
+ uri: deliverable.name,
1371
+ length: deliverable.value.length,
1372
+ offsetEncoding: "utf-16"
1373
+ },
1374
+ from: deliverable.from,
1375
+ segments: clone(deliverable.segments ?? [])
1376
+ });
1377
+
1378
+ /** Create a deterministic aggregate containing every deliverable sidecar map. */
1379
+ export const createBuildProvenanceMap = (program) => ({
1380
+ version: provenanceMapVersion,
1381
+ kind: "ravel-provenance-bundle",
1382
+ maps: Object.values(program.deliverables ?? {})
1383
+ .slice()
1384
+ .sort((left, right) => left.name.localeCompare(right.name))
1385
+ .map(createDeliverableProvenanceMap)
1386
+ });
1387
+
1388
+ /** Resolve a generated offset to its source segment and exact source offset when possible. */
1389
+ export const sourceAtGeneratedOffset = (map, offset) => {
1390
+ if (!Number.isInteger(offset) || offset < 0) return null;
1391
+ const segment = map?.segments?.find((entry) =>
1392
+ offset >= entry.generated.start && offset < entry.generated.end
1393
+ );
1394
+ if (!segment) return null;
1395
+ const result = clone(segment);
1396
+ const sourceStart = segment.source?.range?.start?.offset;
1397
+ const sourceEnd = segment.source?.range?.end?.offset;
1398
+ const generatedLength = segment.generated.end - segment.generated.start;
1399
+ if (segment.precision === "exact" && Number.isInteger(sourceStart) &&
1400
+ sourceEnd - sourceStart === generatedLength) {
1401
+ result.sourceOffset = sourceStart + offset - segment.generated.start;
1402
+ }
1403
+ return result;
1404
+ };
1405
+
1406
+ const sourceOffsetValue = (position) => Number.isInteger(position)
1407
+ ? position
1408
+ : position?.offset;
1409
+
1410
+ const sourceCandidates = (segment) => [
1411
+ {
1412
+ source: segment.source,
1413
+ chunk: segment.chunk,
1414
+ kind: segment.kind,
1415
+ precision: segment.precision,
1416
+ via: segment.via ?? [],
1417
+ through: "segment"
1418
+ },
1419
+ ...(segment.origins ?? []).map((origin) => ({ ...origin, through: "origin" }))
1420
+ ];
1421
+
1422
+ /**
1423
+ * Resolve a source offset to generated ranges. Exact segments return a single
1424
+ * corresponding offset; coarse segments return the containing generated range.
1425
+ */
1426
+ export const generatedRangesForSource = (map, uri, offset) => {
1427
+ if (typeof uri !== "string" || !Number.isInteger(offset) || offset < 0) return [];
1428
+ const matches = [];
1429
+ const seen = new Set();
1430
+ for (const segment of map?.segments ?? []) {
1431
+ for (const candidate of sourceCandidates(segment)) {
1432
+ const start = candidate.source?.range?.start?.offset;
1433
+ const end = candidate.source?.range?.end?.offset;
1434
+ if (candidate.source?.uri !== uri || !Number.isInteger(start) || offset < start || offset >= end) continue;
1435
+ const generatedLength = segment.generated.end - segment.generated.start;
1436
+ const exact = candidate.through === "segment" && candidate.precision === "exact" &&
1437
+ end - start === generatedLength;
1438
+ const match = {
1439
+ generated: clone(segment.generated),
1440
+ ...(exact ? { generatedOffset: segment.generated.start + offset - start } : {}),
1441
+ precision: exact ? "exact" : "coarse",
1442
+ chunk: candidate.chunk,
1443
+ kind: candidate.kind,
1444
+ via: clone([...(candidate.via ?? []), ...(candidate.through === "origin" ? segment.via ?? [] : [])]),
1445
+ ...(candidate.through === "origin" ? { through: "transform-origin" } : {})
1446
+ };
1447
+ const key = JSON.stringify(match);
1448
+ if (seen.has(key)) continue;
1449
+ seen.add(key);
1450
+ matches.push(match);
1451
+ }
1452
+ }
1453
+ return matches;
1454
+ };
1455
+
1456
+ /**
1457
+ * Resolve a half-open source range to generated ranges. The range may use
1458
+ * integer offsets or SourcePosition-like objects with an `offset` field.
1459
+ */
1460
+ export const generatedRangesForSourceRange = (map, uri, range) => {
1461
+ const queryStart = sourceOffsetValue(range?.start);
1462
+ const queryEnd = sourceOffsetValue(range?.end);
1463
+ if (typeof uri !== "string" || !Number.isInteger(queryStart) ||
1464
+ !Number.isInteger(queryEnd) || queryStart < 0 || queryEnd <= queryStart) {
1465
+ return [];
1466
+ }
1467
+ const matches = [];
1468
+ const seen = new Set();
1469
+ for (const segment of map?.segments ?? []) {
1470
+ for (const candidate of sourceCandidates(segment)) {
1471
+ const sourceStart = candidate.source?.range?.start?.offset;
1472
+ const sourceEnd = candidate.source?.range?.end?.offset;
1473
+ const overlapStart = Math.max(queryStart, sourceStart ?? Number.POSITIVE_INFINITY);
1474
+ const overlapEnd = Math.min(queryEnd, sourceEnd ?? Number.NEGATIVE_INFINITY);
1475
+ if (candidate.source?.uri !== uri || overlapStart >= overlapEnd) continue;
1476
+ const generatedLength = segment.generated.end - segment.generated.start;
1477
+ const exact = candidate.through === "segment" && candidate.precision === "exact" &&
1478
+ sourceEnd - sourceStart === generatedLength;
1479
+ const generated = exact ? {
1480
+ start: segment.generated.start + overlapStart - sourceStart,
1481
+ end: segment.generated.start + overlapEnd - sourceStart
1482
+ } : clone(segment.generated);
1483
+ const match = {
1484
+ generated,
1485
+ source: { start: overlapStart, end: overlapEnd },
1486
+ precision: exact ? "exact" : "coarse",
1487
+ chunk: candidate.chunk,
1488
+ kind: candidate.kind,
1489
+ via: clone([...(candidate.via ?? []), ...(candidate.through === "origin" ? segment.via ?? [] : [])]),
1490
+ ...(candidate.through === "origin" ? { through: "transform-origin" } : {})
1491
+ };
1492
+ const key = JSON.stringify(match);
1493
+ if (seen.has(key)) continue;
1494
+ seen.add(key);
1495
+ matches.push(match);
1496
+ }
1497
+ }
1498
+ return matches;
1499
+ };
1500
+
1501
+ /** Explain one generated offset using the evaluated program and its graph. */
1502
+ export const explainGeneratedOffset = (program, deliverableName, offset) => {
1503
+ const deliverable = program?.deliverables?.[deliverableName];
1504
+ if (!deliverable) return null;
1505
+ const segment = sourceAtGeneratedOffset(createDeliverableProvenanceMap(deliverable), offset);
1506
+ if (!segment) return null;
1507
+ const references = (segment.via ?? []).filter((step) => step.kind === "reference");
1508
+ const dependencyPath = [deliverable.from];
1509
+ for (const reference of references.slice().reverse()) {
1510
+ if (reference.from === dependencyPath[dependencyPath.length - 1]) dependencyPath.push(reference.to);
1511
+ }
1512
+ const definition = program.chunks?.[segment.chunk];
1513
+ return {
1514
+ deliverable: { name: deliverable.name, from: deliverable.from },
1515
+ generatedOffset: offset,
1516
+ segment,
1517
+ definition: definition ? {
1518
+ id: definition.id,
1519
+ identity: clone(definition.identity),
1520
+ metadata: clone(definition.metadata),
1521
+ generated: definition.generated
1522
+ } : null,
1523
+ references: clone(references),
1524
+ dependencyPath
1525
+ };
1526
+ };