@pieceful/ravel-core 0.1.1 → 0.3.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 CHANGED
@@ -1,6 +1,16 @@
1
1
  const componentPattern = /^[a-z][a-z0-9-]*$/;
2
+ const chunkPathPattern = /^[a-z][a-z0-9-]*(?:\/(?:[a-z][a-z0-9-]*)?)*$/;
3
+ const relativeChunkPathPattern = /^(?:\.\.?)(?:\/(?:\.\.?|[a-z][a-z0-9-]*))*$/;
2
4
 
3
5
  export { directiveKinds, compose, append, newline, pipe, pass, createDirective, aliasDirective } from "./directives.js";
6
+ export { parseRavelDirectiveBlock } from "./directive-syntax.js";
7
+ export {
8
+ cloneRavelValue,
9
+ executeLiveProgram,
10
+ planLiveExecutions,
11
+ ravelValueIssue,
12
+ serializeRavelValue
13
+ } from "./live.js";
4
14
 
5
15
  const clone = (value) => JSON.parse(JSON.stringify(value));
6
16
 
@@ -167,18 +177,32 @@ const replaceMappedOnce = (value, search, replacement) => {
167
177
  );
168
178
  };
169
179
 
180
+ // Parsing asks for many positions in the same chunk body, so keep the newline
181
+ // offsets of the most recent text and binary-search them instead of rescanning.
182
+ const lineIndexCache = new Map();
183
+ const lineStartsOf = (text) => {
184
+ let starts = lineIndexCache.get(text);
185
+ if (starts) return starts;
186
+ starts = [];
187
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) starts.push(i + 1);
188
+ // A handful of entries covers a chunk body and the expressions inside it.
189
+ if (lineIndexCache.size >= 16) lineIndexCache.delete(lineIndexCache.keys().next().value);
190
+ lineIndexCache.set(text, starts);
191
+ return starts;
192
+ };
193
+
170
194
  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
- }
195
+ const starts = lineStartsOf(text);
196
+ let low = 0;
197
+ let high = starts.length;
198
+ while (low < high) {
199
+ const middle = (low + high) >> 1;
200
+ if (starts[middle] <= index) low = middle + 1;
201
+ else high = middle;
180
202
  }
181
- return { line, column, offset: start.offset + index };
203
+ return low === 0
204
+ ? { line: start.line, column: start.column + index, offset: start.offset + index }
205
+ : { line: start.line + low, column: index - starts[low - 1], offset: start.offset + index };
182
206
  };
183
207
 
184
208
  const span = (source, text, start, end) => ({
@@ -198,6 +222,7 @@ const diagnostic = (code, message, source, related = []) => ({
198
222
  });
199
223
 
200
224
  const validComponent = (value) => value === null || (typeof value === "string" && componentPattern.test(value));
225
+ const validChunkPath = (value) => value === null || (typeof value === "string" && chunkPathPattern.test(value));
201
226
 
202
227
  export const formatChunkId = (identity) => {
203
228
  const prefix = identity.document === null ? "" : identity.document + "::";
@@ -223,7 +248,8 @@ export const parseChunkId = (input, { reference = false } = {}) => {
223
248
 
224
249
  let type = null;
225
250
  const typeIndex = remainder.lastIndexOf(".");
226
- if (typeIndex !== -1) {
251
+ const pathIndex = remainder.lastIndexOf("/");
252
+ if (typeIndex > pathIndex) {
227
253
  type = remainder.slice(typeIndex + 1);
228
254
  remainder = remainder.slice(0, typeIndex);
229
255
  if (!componentPattern.test(type)) return null;
@@ -236,11 +262,16 @@ export const parseChunkId = (input, { reference = false } = {}) => {
236
262
  chunk = remainder.slice(0, minorIndex);
237
263
  minor = remainder.slice(minorIndex + 1);
238
264
  if (!componentPattern.test(minor)) return null;
265
+ if (chunk.endsWith("/") && /^\.{1,2}(?:\/|$)/.test(chunk)) chunk = chunk.slice(0, -1);
239
266
  }
240
267
 
241
268
  if (chunk === "") chunk = null;
242
- if (!validComponent(chunk)) return null;
269
+ const relativePath = reference && typeof chunk === "string" && relativeChunkPathPattern.test(chunk)
270
+ ? chunk
271
+ : null;
272
+ if (relativePath === null && !validChunkPath(chunk)) return null;
243
273
  if (!validComponent(document) || !validComponent(minor) || !validComponent(type)) return null;
274
+ if (relativePath !== null && explicitDocument) return null;
244
275
 
245
276
  if (!reference && document === null && chunk === null) return null;
246
277
  if (!reference && document === null && chunk !== null && input.includes("::")) return null;
@@ -253,7 +284,14 @@ export const parseChunkId = (input, { reference = false } = {}) => {
253
284
  }
254
285
  if (reference && !explicitDocument && chunk === null && minor === null && type === null) return null;
255
286
 
256
- return { document, chunk, minor, type, explicitDocument };
287
+ return {
288
+ document,
289
+ chunk,
290
+ minor,
291
+ type,
292
+ explicitDocument,
293
+ ...(relativePath === null ? {} : { relativePath })
294
+ };
257
295
  };
258
296
 
259
297
  const validateIdentity = (identity) => {
@@ -267,7 +305,7 @@ const validateIdentity = (identity) => {
267
305
  type: identity.type,
268
306
  explicitDocument: identity.document !== null
269
307
  };
270
- if (!validComponent(result.document) || !validComponent(result.chunk) ||
308
+ if (!validComponent(result.document) || !validChunkPath(result.chunk) ||
271
309
  !validComponent(result.minor) || !validComponent(result.type)) {
272
310
  return null;
273
311
  }
@@ -358,9 +396,10 @@ const parseEmitSuffix = (value) => {
358
396
  return { minor, type, inheritMinor: false };
359
397
  };
360
398
 
361
- const parsePipeStep = (part, expression, source, expressionOffset, diagnostics) => {
399
+ // `text` is the string that `expressionOffset` and `part` offsets index into.
400
+ const parsePipeStep = (part, text, source, expressionOffset, diagnostics) => {
362
401
  const match = /^([a-z][a-z0-9-]*)\s*(?:\((.*)\))?$/s.exec(part.value);
363
- const location = span(source, expression, expressionOffset + part.start, expressionOffset + part.end);
402
+ const location = span(source, text, expressionOffset + part.start, expressionOffset + part.end);
364
403
  if (!match) {
365
404
  diagnostics.push(diagnostic("RV110", "Malformed pipeline step: " + part.value, location));
366
405
  return null;
@@ -406,7 +445,7 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
406
445
  diagnostics.push(diagnostic("RV121", "ch requires one quoted chunk expression.", location));
407
446
  return null;
408
447
  }
409
- const parsed = parseExpression(argumentsValue[0], source, expressionOffset + part.start, diagnostics, { allowDelay: false });
448
+ const parsed = parseExpression(argumentsValue[0], source, expressionOffset + part.start, diagnostics, { allowDelay: false, text });
410
449
  if (!parsed) return null;
411
450
  return { type: "chunk", expression: argumentsValue[0], value: parsed, source: location };
412
451
  }
@@ -414,7 +453,7 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
414
453
  const argumentsParsed = rawArguments.map((argument) => {
415
454
  const value = parseValue(argument.value);
416
455
  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);
456
+ const nested = parsePipeStep({ value: argument.value, start: part.start + argument.start, end: part.start + argument.end }, text, source, expressionOffset, diagnostics);
418
457
  return nested?.type === "text" || nested?.type === "chunk"
419
458
  ? { kind: "ravel-command-argument", command: nested }
420
459
  : undefined;
@@ -426,12 +465,14 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
426
465
  return { type: name === "delay" ? "delay" : "transform", name, arguments: argumentsParsed, source: location };
427
466
  };
428
467
 
429
- const parseExpression = (expression, source, expressionOffset, diagnostics, { allowDelay = true } = {}) => {
468
+ // `text` is the chunk body that `expressionOffset` indexes into; positions
469
+ // must be computed against it, not against the expression alone.
470
+ const parseExpression = (expression, source, expressionOffset, diagnostics, { allowDelay = true, text = expression } = {}) => {
430
471
  const parts = splitTopLevel(expression, "|");
431
472
  const reference = parts.shift();
432
473
  const target = parseChunkId(reference?.value, { reference: true });
433
474
  const pipeline = parts
434
- .map((part) => parsePipeStep(part, expression, source, expressionOffset, diagnostics))
475
+ .map((part) => parsePipeStep(part, text, source, expressionOffset, diagnostics))
435
476
  .filter(Boolean);
436
477
  if (!reference || !target) {
437
478
  const delay = pipeline.length === 1 && pipeline[0].type === "delay" ? pipeline[0] : null;
@@ -450,7 +491,7 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
450
491
  expression: typeof value === "string" ? value : command.expression ?? command.value,
451
492
  phase,
452
493
  safeSymbol,
453
- source: span(source, expression, expressionOffset, expressionOffset + expression.length)
494
+ source: span(source, text, expressionOffset, expressionOffset + expression.length)
454
495
  };
455
496
  }
456
497
  if (reference?.value === "" && pipeline.length && pipeline[0].type !== "delay" &&
@@ -459,13 +500,13 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
459
500
  diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
460
501
  return null;
461
502
  }
462
- return { type: "pipeline", pipeline, source: span(source, expression, expressionOffset, expressionOffset + expression.length) };
503
+ return { type: "pipeline", pipeline, source: span(source, text, expressionOffset, expressionOffset + expression.length) };
463
504
  }
464
505
  if (pipeline.some((step) => step.type === "delay")) {
465
506
  diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
466
507
  return null;
467
508
  }
468
- diagnostics.push(diagnostic("RV110", "Malformed chunk reference: " + (reference?.value ?? ""), span(source, expression, expressionOffset, expressionOffset + expression.length)));
509
+ diagnostics.push(diagnostic("RV110", "Malformed chunk reference: " + (reference?.value ?? ""), span(source, text, expressionOffset, expressionOffset + expression.length)));
469
510
  return null;
470
511
  }
471
512
 
@@ -479,15 +520,39 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
479
520
  reference: reference.value,
480
521
  target,
481
522
  pipeline,
482
- source: span(source, expression, expressionOffset, expressionOffset + expression.length)
523
+ source: span(source, text, expressionOffset, expressionOffset + expression.length)
483
524
  };
484
525
  };
485
526
 
527
+ /**
528
+ * Parse a definition-time transform pipeline without executing it.
529
+ * Adapters use this entry point so every markup dialect shares one grammar.
530
+ */
531
+ export const parseDefinitionPipeline = (text, source) => {
532
+ const diagnostics = [];
533
+ if (typeof text !== "string" || !text.trim()) return { pipeline: [], diagnostics };
534
+ const expression = text.trim();
535
+ const pipeline = [];
536
+ for (const part of splitTopLevel(expression, "|")) {
537
+ const step = parsePipeStep(part, expression, source, 0, diagnostics);
538
+ if (!step) continue;
539
+ if (step.type !== "transform") {
540
+ diagnostics.push(diagnostic("RV120", "Definition pipelines accept transform calls only.", step.source ?? source));
541
+ continue;
542
+ }
543
+ pipeline.push({ name: step.name, arguments: step.arguments });
544
+ }
545
+ return { pipeline, diagnostics };
546
+ };
547
+
486
548
  // An embedded reference inherits the indentation of its containing source line
487
549
  // on continuation lines. The first line is already positioned by the literal
488
550
  // text before the reference, so it deliberately remains untouched.
489
551
  const continuationIndentAt = (body, index) => {
490
- const lineStart = Math.max(body.lastIndexOf("\n", index - 1), body.lastIndexOf("\r", index - 1)) + 1;
552
+ // Walk back only to the start of this line; lastIndexOf for an absent "\r"
553
+ // would rescan the whole body for every reference.
554
+ let lineStart = index;
555
+ while (lineStart > 0 && body[lineStart - 1] !== "\n" && body[lineStart - 1] !== "\r") lineStart -= 1;
491
556
  return /^[\t ]*/.exec(body.slice(lineStart, index))[0];
492
557
  };
493
558
 
@@ -513,18 +578,130 @@ const applyContinuationIndentMapped = (value, indentation, source, chunk) => {
513
578
  return concatMapped(...parts);
514
579
  };
515
580
 
516
- export const parseChunk = (body, source) => {
581
+ const firstUnescapedPipe = (value) => {
582
+ let escaped = false;
583
+ for (let index = 0; index < value.length; index += 1) {
584
+ if (escaped) escaped = false;
585
+ else if (value[index] === "\\") escaped = true;
586
+ else if (value[index] === "|") return index;
587
+ }
588
+ return -1;
589
+ };
590
+
591
+ const nowebExpression = (value, options) => {
592
+ const pipe = options.nowebPlus ? firstUnescapedPipe(value) : -1;
593
+ const authoredName = (pipe === -1 ? value : value.slice(0, pipe)).trim().replace(/\\\|/g, "|");
594
+ const canonicalName = options.referenceAliases?.[authoredName] ?? authoredName;
595
+ return pipe === -1
596
+ ? canonicalName
597
+ : canonicalName + " | " + value.slice(pipe + 1).trim();
598
+ };
599
+
600
+ /**
601
+ * Recognize a noweb-style `<<name>>` reference starting at `index`.
602
+ *
603
+ * A reference must close on the same line and its name must be non-empty
604
+ * without leading or trailing whitespace, so shift operators such as
605
+ * `std::cout << x` or `a << 2; b >> 1` remain ordinary code. Returns the
606
+ * authored text and the offset of the closing `>>`, or null.
607
+ */
608
+ const countedReferencePattern = /\\([1-9][0-9]*)_(["'`])/y;
609
+
610
+ export const nowebReferenceAt = (body, index) => {
611
+ if (!body.startsWith("<<", index)) return null;
612
+ const lineEnd = body.indexOf("\n", index + 2);
613
+ const end = body.indexOf(">>", index + 2);
614
+ if (end === -1 || (lineEnd !== -1 && end > lineEnd)) return null;
615
+ const authored = body.slice(index + 2, end);
616
+ if (!authored || authored !== authored.trim() || authored.startsWith("<")) return null;
617
+ return { authored, end };
618
+ };
619
+
620
+ export const parseChunk = (body, source, options = {}) => {
517
621
  const nodes = [];
518
622
  const diagnostics = [];
519
623
  let literalStart = 0;
520
624
  let index = 0;
521
625
 
522
626
  while (index < body.length) {
627
+ if (options.nowebReferences && body.startsWith("@<<", index)) {
628
+ // `@<<` is noweb's escape for a literal `<<`; drop the `@`.
629
+ if (literalStart < index) {
630
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
631
+ }
632
+ literalStart = index + 1;
633
+ index += 3;
634
+ continue;
635
+ }
636
+ const noweb = options.nowebReferences ? nowebReferenceAt(body, index) : null;
637
+ if (noweb) {
638
+ const { end, authored } = noweb;
639
+ if (literalStart < index) {
640
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
641
+ }
642
+ const expression = nowebExpression(authored, options);
643
+ const parsed = parseExpression(expression, source, index + 2, diagnostics, { text: body });
644
+ if (parsed) {
645
+ nodes.push({
646
+ ...parsed,
647
+ authoredReference: authored,
648
+ source: span(source, body, index + 2, end),
649
+ continuationIndent: continuationIndentAt(body, index)
650
+ });
651
+ }
652
+ index = end + 2;
653
+ literalStart = index;
654
+ continue;
655
+ }
656
+ let counted = null;
657
+ if (options.underscoreReferences !== false && body[index] === "\\") {
658
+ countedReferencePattern.lastIndex = index;
659
+ counted = countedReferencePattern.exec(body);
660
+ }
661
+ if (counted) {
662
+ const quote = counted[2];
663
+ const expressionStart = index + counted[0].length;
664
+ let end = expressionStart;
665
+ let escaped = false;
666
+ while (end < body.length) {
667
+ if (escaped) escaped = false;
668
+ else if (body[end] === "\\") escaped = true;
669
+ else if (body[end] === quote) break;
670
+ end += 1;
671
+ }
672
+ if (end >= body.length) {
673
+ diagnostics.push(diagnostic("RV110", "Unterminated counted chunk reference.", span(source, body, index, body.length)));
674
+ break;
675
+ }
676
+ if (literalStart < index) {
677
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
678
+ }
679
+ const expression = body.slice(expressionStart, end);
680
+ const parsed = parseExpression(expression, source, expressionStart, diagnostics, { allowDelay: false, text: body });
681
+ if (parsed) {
682
+ nodes.push({
683
+ type: "delay",
684
+ value: {
685
+ kind: "ravel-command-argument",
686
+ command: { type: "chunk", expression, value: parsed, source: span(source, body, expressionStart, end) }
687
+ },
688
+ expression,
689
+ phase: Number(counted[1]),
690
+ safeSymbol: undefined,
691
+ source: span(source, body, index, end + 1),
692
+ continuationIndent: continuationIndentAt(body, index)
693
+ });
694
+ }
695
+ index = end + 1;
696
+ literalStart = index;
697
+ continue;
698
+ }
523
699
  if (body[index] === "\\" && body[index + 1] === "_" && ["\"", "'", "`"].includes(body[index + 2])) {
524
700
  index += 3;
525
701
  continue;
526
702
  }
527
- if (body[index] !== "_" || !["\"", "'", "`"].includes(body[index + 1])) {
703
+ if (options.underscoreReferences === false ||
704
+ body[index] !== "_" || !["\"", "'", "`"].includes(body[index + 1])) {
528
705
  index += 1;
529
706
  continue;
530
707
  }
@@ -552,7 +729,7 @@ export const parseChunk = (body, source) => {
552
729
  nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
553
730
  }
554
731
  const expression = body.slice(index + 2, end);
555
- const parsed = parseExpression(expression, source, index + 2, diagnostics);
732
+ const parsed = parseExpression(expression, source, index + 2, diagnostics, { text: body });
556
733
  if (parsed) nodes.push({ ...parsed, continuationIndent: continuationIndentAt(body, index) });
557
734
  index = end + 1;
558
735
  literalStart = index;
@@ -565,8 +742,16 @@ export const parseChunk = (body, source) => {
565
742
  };
566
743
 
567
744
  const parseChunkFragments = (raw) => {
568
- const parsed = parseChunk(raw.body, raw.source);
569
- if (!Array.isArray(raw.fragments) || raw.fragments.length < 2 ||
745
+ const referenceSyntax = raw.metadata?.data?.ravel?.referenceSyntax;
746
+ const parsed = parseChunk(raw.body, raw.source, referenceSyntax && typeof referenceSyntax === "object"
747
+ ? {
748
+ nowebReferences: referenceSyntax.noweb === true,
749
+ nowebPlus: referenceSyntax.dialect === "noweb-plus",
750
+ underscoreReferences: referenceSyntax.underscore !== false,
751
+ referenceAliases: referenceSyntax.aliases
752
+ }
753
+ : {});
754
+ if (!Array.isArray(raw.fragments) || raw.fragments.length === 0 ||
570
755
  raw.fragments.some((fragment) => typeof fragment?.body !== "string" || !fragment.source) ||
571
756
  raw.fragments.map((fragment) => fragment.body).join("") !== raw.body) {
572
757
  return parsed;
@@ -583,16 +768,41 @@ const parseChunkFragments = (raw) => {
583
768
  start: source.range.start.offset - syntheticStart,
584
769
  end: source.range.end.offset - syntheticStart
585
770
  });
771
+ const fragmentSlice = (fragment, start, end) =>
772
+ fragment.precision === "coarse"
773
+ ? fragment.source
774
+ : span(
775
+ fragment.source,
776
+ fragment.body,
777
+ start - fragment.start,
778
+ end - fragment.start
779
+ );
586
780
  const remapLocation = (source) => {
587
781
  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
782
+ const overlapping = fragments.filter((entry) =>
783
+ range.start < entry.end && range.end > entry.start
595
784
  );
785
+ if (!overlapping.length) return source;
786
+ const first = overlapping[0];
787
+ const last = overlapping.at(-1);
788
+ const firstSlice = fragmentSlice(
789
+ first,
790
+ Math.max(range.start, first.start),
791
+ Math.min(range.end, first.end)
792
+ );
793
+ const lastSlice = fragmentSlice(
794
+ last,
795
+ Math.max(range.start, last.start),
796
+ Math.min(range.end, last.end)
797
+ );
798
+ if (firstSlice.uri !== lastSlice.uri) return firstSlice;
799
+ return {
800
+ uri: firstSlice.uri,
801
+ range: {
802
+ start: firstSlice.range.start,
803
+ end: lastSlice.range.end
804
+ }
805
+ };
596
806
  };
597
807
  const remapSources = (value) => {
598
808
  if (!value || typeof value !== "object") return value;
@@ -617,7 +827,8 @@ const parseChunkFragments = (raw) => {
617
827
  nodes.push({
618
828
  type: "literal",
619
829
  value: raw.body.slice(start, end),
620
- source: span(fragment.source, fragment.body, start - fragment.start, end - fragment.start)
830
+ source: fragmentSlice(fragment, start, end),
831
+ precision: fragment.precision ?? "exact"
621
832
  });
622
833
  }
623
834
  }
@@ -709,13 +920,40 @@ const applyTransform = (value, step, diagnostics, transforms, context = {}) => {
709
920
  if (step.name === "quote-reference") {
710
921
  return value.replace(/_(["'\x60])/g, "\\_$1");
711
922
  }
923
+ if (step.name === "jsontext") {
924
+ if (step.arguments.length > 1 ||
925
+ (step.arguments.length === 1 && typeof step.arguments[0] !== "string")) {
926
+ diagnostics.push(diagnostic("RV120", "jsontext accepts no arguments or one string key.", step.source));
927
+ return value;
928
+ }
929
+ let parsed;
930
+ try {
931
+ parsed = JSON.parse(value);
932
+ } catch {
933
+ diagnostics.push(diagnostic("RV120", "jsontext requires a live-result JSON value.", step.source));
934
+ return value;
935
+ }
936
+ if (step.arguments.length === 0) return JSON.stringify(parsed);
937
+
938
+ const key = step.arguments[0];
939
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ||
940
+ !Object.hasOwn(parsed, key)) {
941
+ diagnostics.push(diagnostic("RV120", "jsontext could not find object key " + JSON.stringify(key) + ".", step.source));
942
+ return value;
943
+ }
944
+ const selected = parsed[key];
945
+ return typeof selected === "string" ? selected : JSON.stringify(selected);
946
+ }
712
947
 
713
948
  const transform = customTransform(transforms, step.name);
714
949
  if (typeof transform === "function") {
715
950
  try {
716
- const result = transform(value, { ...context, arguments: clone(step.arguments), transform: step.name });
951
+ // `parts: true` invites a transform to describe which output came from
952
+ // its input or arguments; a plain string result is always accepted.
953
+ const result = transform(value, { ...context, arguments: clone(step.arguments), transform: step.name, parts: true });
717
954
  if (typeof result === "string") return result;
718
- diagnostics.push(diagnostic("RV121", "Transform " + step.name + " must return a string.", step.source));
955
+ if (Array.isArray(result?.parts)) return result;
956
+ diagnostics.push(diagnostic("RV121", "Transform " + step.name + " must return a string or { parts }.", step.source));
719
957
  } catch (error) {
720
958
  diagnostics.push(diagnostic("RV121", "Transform " + step.name + " failed: " + (error?.message ?? String(error)), step.source));
721
959
  }
@@ -797,9 +1035,22 @@ const definitionFromComposeEmission = (owner, capture, emit) => {
797
1035
  };
798
1036
 
799
1037
  const resolveTarget = (target, owner, definitions) => {
800
- const withOwnerChunk = target.chunk === null && !target.explicitDocument
801
- ? owner.identity.chunk
802
- : target.chunk;
1038
+ let withOwnerChunk;
1039
+ if (target.relativePath) {
1040
+ const segments = (owner.identity.chunk ?? "").split("/").filter((segment, index, entries) =>
1041
+ segment || (index > 0 && index < entries.length - 1)
1042
+ );
1043
+ for (const segment of target.relativePath.split("/")) {
1044
+ if (segment === ".") continue;
1045
+ if (segment === "..") segments.pop();
1046
+ else segments.push(segment);
1047
+ }
1048
+ withOwnerChunk = segments.join("/");
1049
+ } else {
1050
+ withOwnerChunk = target.chunk === null && !target.explicitDocument
1051
+ ? owner.identity.chunk
1052
+ : target.chunk;
1053
+ }
803
1054
  const parts = {
804
1055
  chunk: withOwnerChunk,
805
1056
  minor: target.minor,
@@ -829,6 +1080,11 @@ export const transformGraph = (pretransform, options = {}) => {
829
1080
  const delayTokenCorpus = JSON.stringify(pretransform);
830
1081
  const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
831
1082
  const orderedEntries = (value) => Object.entries(value).sort(([left], [right]) => compareText(left, right));
1083
+ const liveExecutions = options.liveResults?.executions ?? options.liveResults;
1084
+ const liveExecution = (id) => liveExecutions instanceof Map
1085
+ ? liveExecutions.get(id)
1086
+ : liveExecutions?.[id];
1087
+ const isExecutable = (definition) => definition?.metadata?.data?.ravel?.run === true;
832
1088
 
833
1089
  const normalizeDefinitionPipeline = (steps, source) => {
834
1090
  if (steps === undefined) return [];
@@ -1029,14 +1285,52 @@ export const transformGraph = (pretransform, options = {}) => {
1029
1285
  return (hash >>> 0).toString(36).toUpperCase();
1030
1286
  };
1031
1287
 
1288
+ // Assemble a `{ parts }` transform result: input and argument ranges keep
1289
+ // their provenance; literal parts are attributed to the transform step.
1290
+ const mappedTransformParts = (input, parts, step, definition, argumentValues = []) => {
1291
+ const derivation = { kind: "transform", name: step.name, source: clone(step.source) };
1292
+ const argumentMapped = (index) => argumentValues[index] ??
1293
+ (typeof step.arguments[index] === "string" ? coarseMapped(step.arguments[index], step.source, definition.id, "transform", [derivation]) : null);
1294
+ const validRange = (text, start, end) => Number.isInteger(start) && Number.isInteger(end) && start >= 0 && start <= end && end <= text.length;
1295
+ let value = mappedValue();
1296
+ for (const part of parts) {
1297
+ let piece = null;
1298
+ if (typeof part === "string") {
1299
+ piece = coarseMapped(part, step.source, definition.id, "transform", [derivation]);
1300
+ } else if (Array.isArray(part?.input) && validRange(input.text, part.input[0], part.input[1])) {
1301
+ piece = withMappedDerivation(sliceMapped(input, part.input[0], part.input[1]), derivation);
1302
+ } else if (Number.isInteger(part?.argument) && typeof part.text === "string") {
1303
+ const source = argumentMapped(part.argument);
1304
+ piece = source
1305
+ ? coarseDerivedMapped(part.text, step.source, definition.id, "transform", [derivation], source)
1306
+ : coarseMapped(part.text, step.source, definition.id, "transform", [derivation]);
1307
+ } else if (Number.isInteger(part?.argument)) {
1308
+ const source = argumentMapped(part.argument);
1309
+ if (source && validRange(source.text, part.start, part.end)) {
1310
+ piece = withMappedDerivation(sliceMapped(source, part.start, part.end), derivation);
1311
+ }
1312
+ }
1313
+ if (!piece) {
1314
+ diagnostics.push(diagnostic("RV121", "Transform " + step.name + " returned an invalid part.", step.source));
1315
+ return null;
1316
+ }
1317
+ value = concatMapped(value, piece);
1318
+ }
1319
+ return value;
1320
+ };
1321
+
1032
1322
  const evaluateTransformMapped = (input, step, definition, context = {}) => {
1323
+ const { argumentValues, ...transformContext } = context;
1033
1324
  const output = applyTransform(
1034
1325
  input.text,
1035
1326
  step,
1036
1327
  diagnostics,
1037
1328
  options.transforms,
1038
- { chunk: definition, ...context }
1329
+ { chunk: definition, ...transformContext }
1039
1330
  );
1331
+ if (typeof output !== "string") {
1332
+ return mappedTransformParts(input, output.parts, step, definition, argumentValues) ?? input;
1333
+ }
1040
1334
  return mapBuiltinTransform(input, output, step, definition.id) ?? coarseDerivedMapped(
1041
1335
  output,
1042
1336
  step.source,
@@ -1067,10 +1361,47 @@ export const transformGraph = (pretransform, options = {}) => {
1067
1361
  to: resolved,
1068
1362
  source: clone(node.source)
1069
1363
  };
1070
- let value = mappedValue(dependency.value, dependency.segments.map((segment) => ({
1071
- ...clone(segment),
1072
- via: [...(segment.via ?? []), reference]
1073
- })));
1364
+ const resolvedDefinition = definitions.get(resolved);
1365
+ const ownerIsExecutable = isExecutable(owner.definition);
1366
+ const shouldUseLiveResult = isExecutable(resolvedDefinition) && !ownerIsExecutable;
1367
+ if (shouldUseLiveResult && options.deferLiveResults && !liveExecutions) {
1368
+ return mappedValue();
1369
+ }
1370
+ let value;
1371
+ if (shouldUseLiveResult && liveExecutions) {
1372
+ const execution = liveExecution(resolved);
1373
+ if (execution?.status !== "succeeded") {
1374
+ diagnostics.push(diagnostic("RV140", "Live result is unavailable for reference " + node.reference + ".", node.source));
1375
+ return mappedValue();
1376
+ }
1377
+ const first = node.pipeline[0];
1378
+ const hasJsonTextBoundary = first?.type === "transform" && first.name === "jsontext";
1379
+ const serializeWholeValue = hasJsonTextBoundary && first.arguments.length === 0;
1380
+ if (typeof execution.value === "string" && !serializeWholeValue) {
1381
+ value = coarseMapped(execution.value, resolvedDefinition.source, resolved, "live-result", [reference]);
1382
+ } else if (hasJsonTextBoundary) {
1383
+ value = coarseMapped(
1384
+ execution.serialized ?? JSON.stringify(execution.value),
1385
+ resolvedDefinition.source,
1386
+ resolved,
1387
+ "live-result",
1388
+ [reference]
1389
+ );
1390
+ } else {
1391
+ diagnostics.push(diagnostic(
1392
+ "RV140",
1393
+ "Non-string live result " + resolved +
1394
+ " requires jsontext() or jsontext(\"key\") before ordinary Ravel processing.",
1395
+ node.source
1396
+ ));
1397
+ return mappedValue();
1398
+ }
1399
+ } else {
1400
+ value = mappedValue(dependency.value, dependency.segments.map((segment) => ({
1401
+ ...clone(segment),
1402
+ via: [...(segment.via ?? []), reference]
1403
+ })));
1404
+ }
1074
1405
  return evaluatePipeline(value, node.pipeline, owner);
1075
1406
  };
1076
1407
 
@@ -1115,8 +1446,16 @@ export const transformGraph = (pretransform, options = {}) => {
1115
1446
  } else if (step.type === "chunk") {
1116
1447
  value = evaluateExpression(step.value, owner);
1117
1448
  } else if (step.type === "transform") {
1118
- const argumentsValue = step.arguments.map((argument) => evaluateArgument(argument, owner));
1119
- value = evaluateTransformMapped(value, { ...step, arguments: argumentsValue }, owner.definition);
1449
+ // Evaluate each ch()/text() argument once, keeping its mapped value so
1450
+ // a parts-aware transform can carry its provenance through.
1451
+ const argumentValues = step.arguments.map((argument) => {
1452
+ const command = argument?.kind === "ravel-command-argument" ? argument.command : null;
1453
+ if (command?.type === "chunk") return evaluateExpression(command.value, owner);
1454
+ if (command?.type === "text") return coarseMapped(command.value, command.source ?? step.source, owner.definition.id, "text");
1455
+ return null;
1456
+ });
1457
+ const argumentsValue = step.arguments.map((argument, index) => argumentValues[index]?.text ?? evaluateArgument(argument, owner));
1458
+ value = evaluateTransformMapped(value, { ...step, arguments: argumentsValue }, owner.definition, { argumentValues });
1120
1459
  }
1121
1460
  }
1122
1461
  return value;
@@ -1136,7 +1475,13 @@ export const transformGraph = (pretransform, options = {}) => {
1136
1475
 
1137
1476
  const evaluateNode = (node, owner) => {
1138
1477
  if (node.type === "literal") {
1139
- return sourceSegment(node.value, node.source, owner.definition.id, "literal");
1478
+ return sourceSegment(
1479
+ node.value,
1480
+ node.source,
1481
+ owner.definition.id,
1482
+ "literal",
1483
+ node.precision ?? "exact"
1484
+ );
1140
1485
  }
1141
1486
  if (node.type === "delay") return evaluateDelay(node, owner);
1142
1487
  const value = evaluateExpression(node, owner);
@@ -1295,6 +1640,7 @@ export const transformGraph = (pretransform, options = {}) => {
1295
1640
  value: value.text,
1296
1641
  segments: value.segments,
1297
1642
  metadata: definition.metadata,
1643
+ source: definition.source,
1298
1644
  dependencies: [...owner.dependencies].sort(),
1299
1645
  // References retain authored source order, which is deterministic and is
1300
1646
  // more useful for explaining a chunk than lexical target order.
@@ -1327,7 +1673,26 @@ export const transformGraph = (pretransform, options = {}) => {
1327
1673
  diagnostics.push(diagnostic("RV130", "out requires a fully qualified existing source chunk ID.", directive.source));
1328
1674
  continue;
1329
1675
  }
1330
- const chunk = evaluate(id, directive.source);
1676
+ let chunk = evaluate(id, directive.source);
1677
+ const definition = definitions.get(id);
1678
+ if (isExecutable(definition) && liveExecutions) {
1679
+ const execution = liveExecution(id);
1680
+ if (execution?.status !== "succeeded") {
1681
+ diagnostics.push(diagnostic("RV140", "Live result is unavailable for output " + name + ".", directive.source));
1682
+ continue;
1683
+ }
1684
+ if (typeof execution.value !== "string") {
1685
+ diagnostics.push(diagnostic(
1686
+ "RV140",
1687
+ "Output " + name +
1688
+ " requires a live string; use jsontext() or jsontext(\"key\") in a derived chunk.",
1689
+ directive.source
1690
+ ));
1691
+ continue;
1692
+ }
1693
+ const mapped = coarseMapped(execution.value, definition.source, id, "live-result");
1694
+ chunk = { ...chunk, value: mapped.text, segments: mapped.segments };
1695
+ }
1331
1696
  if (deliverables[name]) {
1332
1697
  diagnostics.push(diagnostic("RV101", "Duplicate out deliverable: " + name, directive.source));
1333
1698
  continue;