@pieceful/ravel-core 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -198,6 +208,7 @@ const diagnostic = (code, message, source, related = []) => ({
198
208
  });
199
209
 
200
210
  const validComponent = (value) => value === null || (typeof value === "string" && componentPattern.test(value));
211
+ const validChunkPath = (value) => value === null || (typeof value === "string" && chunkPathPattern.test(value));
201
212
 
202
213
  export const formatChunkId = (identity) => {
203
214
  const prefix = identity.document === null ? "" : identity.document + "::";
@@ -223,7 +234,8 @@ export const parseChunkId = (input, { reference = false } = {}) => {
223
234
 
224
235
  let type = null;
225
236
  const typeIndex = remainder.lastIndexOf(".");
226
- if (typeIndex !== -1) {
237
+ const pathIndex = remainder.lastIndexOf("/");
238
+ if (typeIndex > pathIndex) {
227
239
  type = remainder.slice(typeIndex + 1);
228
240
  remainder = remainder.slice(0, typeIndex);
229
241
  if (!componentPattern.test(type)) return null;
@@ -236,11 +248,16 @@ export const parseChunkId = (input, { reference = false } = {}) => {
236
248
  chunk = remainder.slice(0, minorIndex);
237
249
  minor = remainder.slice(minorIndex + 1);
238
250
  if (!componentPattern.test(minor)) return null;
251
+ if (chunk.endsWith("/") && /^\.{1,2}(?:\/|$)/.test(chunk)) chunk = chunk.slice(0, -1);
239
252
  }
240
253
 
241
254
  if (chunk === "") chunk = null;
242
- if (!validComponent(chunk)) return null;
255
+ const relativePath = reference && typeof chunk === "string" && relativeChunkPathPattern.test(chunk)
256
+ ? chunk
257
+ : null;
258
+ if (relativePath === null && !validChunkPath(chunk)) return null;
243
259
  if (!validComponent(document) || !validComponent(minor) || !validComponent(type)) return null;
260
+ if (relativePath !== null && explicitDocument) return null;
244
261
 
245
262
  if (!reference && document === null && chunk === null) return null;
246
263
  if (!reference && document === null && chunk !== null && input.includes("::")) return null;
@@ -253,7 +270,14 @@ export const parseChunkId = (input, { reference = false } = {}) => {
253
270
  }
254
271
  if (reference && !explicitDocument && chunk === null && minor === null && type === null) return null;
255
272
 
256
- return { document, chunk, minor, type, explicitDocument };
273
+ return {
274
+ document,
275
+ chunk,
276
+ minor,
277
+ type,
278
+ explicitDocument,
279
+ ...(relativePath === null ? {} : { relativePath })
280
+ };
257
281
  };
258
282
 
259
283
  const validateIdentity = (identity) => {
@@ -267,7 +291,7 @@ const validateIdentity = (identity) => {
267
291
  type: identity.type,
268
292
  explicitDocument: identity.document !== null
269
293
  };
270
- if (!validComponent(result.document) || !validComponent(result.chunk) ||
294
+ if (!validComponent(result.document) || !validChunkPath(result.chunk) ||
271
295
  !validComponent(result.minor) || !validComponent(result.type)) {
272
296
  return null;
273
297
  }
@@ -483,6 +507,27 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
483
507
  };
484
508
  };
485
509
 
510
+ /**
511
+ * Parse a definition-time transform pipeline without executing it.
512
+ * Adapters use this entry point so every markup dialect shares one grammar.
513
+ */
514
+ export const parseDefinitionPipeline = (text, source) => {
515
+ const diagnostics = [];
516
+ if (typeof text !== "string" || !text.trim()) return { pipeline: [], diagnostics };
517
+ const expression = text.trim();
518
+ const pipeline = [];
519
+ for (const part of splitTopLevel(expression, "|")) {
520
+ const step = parsePipeStep(part, expression, source, 0, diagnostics);
521
+ if (!step) continue;
522
+ if (step.type !== "transform") {
523
+ diagnostics.push(diagnostic("RV120", "Definition pipelines accept transform calls only.", step.source ?? source));
524
+ continue;
525
+ }
526
+ pipeline.push({ name: step.name, arguments: step.arguments });
527
+ }
528
+ return { pipeline, diagnostics };
529
+ };
530
+
486
531
  // An embedded reference inherits the indentation of its containing source line
487
532
  // on continuation lines. The first line is already positioned by the literal
488
533
  // text before the reference, so it deliberately remains untouched.
@@ -513,18 +558,103 @@ const applyContinuationIndentMapped = (value, indentation, source, chunk) => {
513
558
  return concatMapped(...parts);
514
559
  };
515
560
 
516
- export const parseChunk = (body, source) => {
561
+ const firstUnescapedPipe = (value) => {
562
+ let escaped = false;
563
+ for (let index = 0; index < value.length; index += 1) {
564
+ if (escaped) escaped = false;
565
+ else if (value[index] === "\\") escaped = true;
566
+ else if (value[index] === "|") return index;
567
+ }
568
+ return -1;
569
+ };
570
+
571
+ const nowebExpression = (value, options) => {
572
+ const pipe = options.nowebPlus ? firstUnescapedPipe(value) : -1;
573
+ const authoredName = (pipe === -1 ? value : value.slice(0, pipe)).trim().replace(/\\\|/g, "|");
574
+ const canonicalName = options.referenceAliases?.[authoredName] ?? authoredName;
575
+ return pipe === -1
576
+ ? canonicalName
577
+ : canonicalName + " | " + value.slice(pipe + 1).trim();
578
+ };
579
+
580
+ export const parseChunk = (body, source, options = {}) => {
517
581
  const nodes = [];
518
582
  const diagnostics = [];
519
583
  let literalStart = 0;
520
584
  let index = 0;
521
585
 
522
586
  while (index < body.length) {
587
+ if (options.nowebReferences && body.startsWith("<<", index)) {
588
+ const end = body.indexOf(">>", index + 2);
589
+ if (end === -1) {
590
+ diagnostics.push(diagnostic("RV110", "Unterminated noweb chunk reference.", span(source, body, index, body.length)));
591
+ break;
592
+ }
593
+ if (literalStart < index) {
594
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
595
+ }
596
+ const authored = body.slice(index + 2, end);
597
+ const expression = nowebExpression(authored, options);
598
+ const parsed = parseExpression(expression, source, index + 2, diagnostics);
599
+ if (parsed) {
600
+ nodes.push({
601
+ ...parsed,
602
+ authoredReference: authored,
603
+ source: span(source, body, index + 2, end),
604
+ continuationIndent: continuationIndentAt(body, index)
605
+ });
606
+ }
607
+ index = end + 2;
608
+ literalStart = index;
609
+ continue;
610
+ }
611
+ const counted = options.underscoreReferences !== false && body[index] === "\\"
612
+ ? /^\\([1-9][0-9]*)_(["'`])/.exec(body.slice(index))
613
+ : null;
614
+ if (counted) {
615
+ const quote = counted[2];
616
+ const expressionStart = index + counted[0].length;
617
+ let end = expressionStart;
618
+ let escaped = false;
619
+ while (end < body.length) {
620
+ if (escaped) escaped = false;
621
+ else if (body[end] === "\\") escaped = true;
622
+ else if (body[end] === quote) break;
623
+ end += 1;
624
+ }
625
+ if (end >= body.length) {
626
+ diagnostics.push(diagnostic("RV110", "Unterminated counted chunk reference.", span(source, body, index, body.length)));
627
+ break;
628
+ }
629
+ if (literalStart < index) {
630
+ nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
631
+ }
632
+ const expression = body.slice(expressionStart, end);
633
+ const parsed = parseExpression(expression, source, expressionStart, diagnostics, { allowDelay: false });
634
+ if (parsed) {
635
+ nodes.push({
636
+ type: "delay",
637
+ value: {
638
+ kind: "ravel-command-argument",
639
+ command: { type: "chunk", expression, value: parsed, source: span(source, body, expressionStart, end) }
640
+ },
641
+ expression,
642
+ phase: Number(counted[1]),
643
+ safeSymbol: undefined,
644
+ source: span(source, body, index, end + 1),
645
+ continuationIndent: continuationIndentAt(body, index)
646
+ });
647
+ }
648
+ index = end + 1;
649
+ literalStart = index;
650
+ continue;
651
+ }
523
652
  if (body[index] === "\\" && body[index + 1] === "_" && ["\"", "'", "`"].includes(body[index + 2])) {
524
653
  index += 3;
525
654
  continue;
526
655
  }
527
- if (body[index] !== "_" || !["\"", "'", "`"].includes(body[index + 1])) {
656
+ if (options.underscoreReferences === false ||
657
+ body[index] !== "_" || !["\"", "'", "`"].includes(body[index + 1])) {
528
658
  index += 1;
529
659
  continue;
530
660
  }
@@ -565,8 +695,16 @@ export const parseChunk = (body, source) => {
565
695
  };
566
696
 
567
697
  const parseChunkFragments = (raw) => {
568
- const parsed = parseChunk(raw.body, raw.source);
569
- if (!Array.isArray(raw.fragments) || raw.fragments.length < 2 ||
698
+ const referenceSyntax = raw.metadata?.data?.ravel?.referenceSyntax;
699
+ const parsed = parseChunk(raw.body, raw.source, referenceSyntax && typeof referenceSyntax === "object"
700
+ ? {
701
+ nowebReferences: referenceSyntax.noweb === true,
702
+ nowebPlus: referenceSyntax.dialect === "noweb-plus",
703
+ underscoreReferences: referenceSyntax.underscore !== false,
704
+ referenceAliases: referenceSyntax.aliases
705
+ }
706
+ : {});
707
+ if (!Array.isArray(raw.fragments) || raw.fragments.length === 0 ||
570
708
  raw.fragments.some((fragment) => typeof fragment?.body !== "string" || !fragment.source) ||
571
709
  raw.fragments.map((fragment) => fragment.body).join("") !== raw.body) {
572
710
  return parsed;
@@ -583,16 +721,41 @@ const parseChunkFragments = (raw) => {
583
721
  start: source.range.start.offset - syntheticStart,
584
722
  end: source.range.end.offset - syntheticStart
585
723
  });
724
+ const fragmentSlice = (fragment, start, end) =>
725
+ fragment.precision === "coarse"
726
+ ? fragment.source
727
+ : span(
728
+ fragment.source,
729
+ fragment.body,
730
+ start - fragment.start,
731
+ end - fragment.start
732
+ );
586
733
  const remapLocation = (source) => {
587
734
  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
735
+ const overlapping = fragments.filter((entry) =>
736
+ range.start < entry.end && range.end > entry.start
737
+ );
738
+ if (!overlapping.length) return source;
739
+ const first = overlapping[0];
740
+ const last = overlapping.at(-1);
741
+ const firstSlice = fragmentSlice(
742
+ first,
743
+ Math.max(range.start, first.start),
744
+ Math.min(range.end, first.end)
745
+ );
746
+ const lastSlice = fragmentSlice(
747
+ last,
748
+ Math.max(range.start, last.start),
749
+ Math.min(range.end, last.end)
595
750
  );
751
+ if (firstSlice.uri !== lastSlice.uri) return firstSlice;
752
+ return {
753
+ uri: firstSlice.uri,
754
+ range: {
755
+ start: firstSlice.range.start,
756
+ end: lastSlice.range.end
757
+ }
758
+ };
596
759
  };
597
760
  const remapSources = (value) => {
598
761
  if (!value || typeof value !== "object") return value;
@@ -617,7 +780,8 @@ const parseChunkFragments = (raw) => {
617
780
  nodes.push({
618
781
  type: "literal",
619
782
  value: raw.body.slice(start, end),
620
- source: span(fragment.source, fragment.body, start - fragment.start, end - fragment.start)
783
+ source: fragmentSlice(fragment, start, end),
784
+ precision: fragment.precision ?? "exact"
621
785
  });
622
786
  }
623
787
  }
@@ -709,6 +873,30 @@ const applyTransform = (value, step, diagnostics, transforms, context = {}) => {
709
873
  if (step.name === "quote-reference") {
710
874
  return value.replace(/_(["'\x60])/g, "\\_$1");
711
875
  }
876
+ if (step.name === "jsontext") {
877
+ if (step.arguments.length > 1 ||
878
+ (step.arguments.length === 1 && typeof step.arguments[0] !== "string")) {
879
+ diagnostics.push(diagnostic("RV120", "jsontext accepts no arguments or one string key.", step.source));
880
+ return value;
881
+ }
882
+ let parsed;
883
+ try {
884
+ parsed = JSON.parse(value);
885
+ } catch {
886
+ diagnostics.push(diagnostic("RV120", "jsontext requires a live-result JSON value.", step.source));
887
+ return value;
888
+ }
889
+ if (step.arguments.length === 0) return JSON.stringify(parsed);
890
+
891
+ const key = step.arguments[0];
892
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed) ||
893
+ !Object.hasOwn(parsed, key)) {
894
+ diagnostics.push(diagnostic("RV120", "jsontext could not find object key " + JSON.stringify(key) + ".", step.source));
895
+ return value;
896
+ }
897
+ const selected = parsed[key];
898
+ return typeof selected === "string" ? selected : JSON.stringify(selected);
899
+ }
712
900
 
713
901
  const transform = customTransform(transforms, step.name);
714
902
  if (typeof transform === "function") {
@@ -797,9 +985,22 @@ const definitionFromComposeEmission = (owner, capture, emit) => {
797
985
  };
798
986
 
799
987
  const resolveTarget = (target, owner, definitions) => {
800
- const withOwnerChunk = target.chunk === null && !target.explicitDocument
801
- ? owner.identity.chunk
802
- : target.chunk;
988
+ let withOwnerChunk;
989
+ if (target.relativePath) {
990
+ const segments = (owner.identity.chunk ?? "").split("/").filter((segment, index, entries) =>
991
+ segment || (index > 0 && index < entries.length - 1)
992
+ );
993
+ for (const segment of target.relativePath.split("/")) {
994
+ if (segment === ".") continue;
995
+ if (segment === "..") segments.pop();
996
+ else segments.push(segment);
997
+ }
998
+ withOwnerChunk = segments.join("/");
999
+ } else {
1000
+ withOwnerChunk = target.chunk === null && !target.explicitDocument
1001
+ ? owner.identity.chunk
1002
+ : target.chunk;
1003
+ }
803
1004
  const parts = {
804
1005
  chunk: withOwnerChunk,
805
1006
  minor: target.minor,
@@ -829,6 +1030,11 @@ export const transformGraph = (pretransform, options = {}) => {
829
1030
  const delayTokenCorpus = JSON.stringify(pretransform);
830
1031
  const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
831
1032
  const orderedEntries = (value) => Object.entries(value).sort(([left], [right]) => compareText(left, right));
1033
+ const liveExecutions = options.liveResults?.executions ?? options.liveResults;
1034
+ const liveExecution = (id) => liveExecutions instanceof Map
1035
+ ? liveExecutions.get(id)
1036
+ : liveExecutions?.[id];
1037
+ const isExecutable = (definition) => definition?.metadata?.data?.ravel?.run === true;
832
1038
 
833
1039
  const normalizeDefinitionPipeline = (steps, source) => {
834
1040
  if (steps === undefined) return [];
@@ -1067,10 +1273,47 @@ export const transformGraph = (pretransform, options = {}) => {
1067
1273
  to: resolved,
1068
1274
  source: clone(node.source)
1069
1275
  };
1070
- let value = mappedValue(dependency.value, dependency.segments.map((segment) => ({
1071
- ...clone(segment),
1072
- via: [...(segment.via ?? []), reference]
1073
- })));
1276
+ const resolvedDefinition = definitions.get(resolved);
1277
+ const ownerIsExecutable = isExecutable(owner.definition);
1278
+ const shouldUseLiveResult = isExecutable(resolvedDefinition) && !ownerIsExecutable;
1279
+ if (shouldUseLiveResult && options.deferLiveResults && !liveExecutions) {
1280
+ return mappedValue();
1281
+ }
1282
+ let value;
1283
+ if (shouldUseLiveResult && liveExecutions) {
1284
+ const execution = liveExecution(resolved);
1285
+ if (execution?.status !== "succeeded") {
1286
+ diagnostics.push(diagnostic("RV140", "Live result is unavailable for reference " + node.reference + ".", node.source));
1287
+ return mappedValue();
1288
+ }
1289
+ const first = node.pipeline[0];
1290
+ const hasJsonTextBoundary = first?.type === "transform" && first.name === "jsontext";
1291
+ const serializeWholeValue = hasJsonTextBoundary && first.arguments.length === 0;
1292
+ if (typeof execution.value === "string" && !serializeWholeValue) {
1293
+ value = coarseMapped(execution.value, resolvedDefinition.source, resolved, "live-result", [reference]);
1294
+ } else if (hasJsonTextBoundary) {
1295
+ value = coarseMapped(
1296
+ execution.serialized ?? JSON.stringify(execution.value),
1297
+ resolvedDefinition.source,
1298
+ resolved,
1299
+ "live-result",
1300
+ [reference]
1301
+ );
1302
+ } else {
1303
+ diagnostics.push(diagnostic(
1304
+ "RV140",
1305
+ "Non-string live result " + resolved +
1306
+ " requires jsontext() or jsontext(\"key\") before ordinary Ravel processing.",
1307
+ node.source
1308
+ ));
1309
+ return mappedValue();
1310
+ }
1311
+ } else {
1312
+ value = mappedValue(dependency.value, dependency.segments.map((segment) => ({
1313
+ ...clone(segment),
1314
+ via: [...(segment.via ?? []), reference]
1315
+ })));
1316
+ }
1074
1317
  return evaluatePipeline(value, node.pipeline, owner);
1075
1318
  };
1076
1319
 
@@ -1136,7 +1379,13 @@ export const transformGraph = (pretransform, options = {}) => {
1136
1379
 
1137
1380
  const evaluateNode = (node, owner) => {
1138
1381
  if (node.type === "literal") {
1139
- return sourceSegment(node.value, node.source, owner.definition.id, "literal");
1382
+ return sourceSegment(
1383
+ node.value,
1384
+ node.source,
1385
+ owner.definition.id,
1386
+ "literal",
1387
+ node.precision ?? "exact"
1388
+ );
1140
1389
  }
1141
1390
  if (node.type === "delay") return evaluateDelay(node, owner);
1142
1391
  const value = evaluateExpression(node, owner);
@@ -1295,6 +1544,7 @@ export const transformGraph = (pretransform, options = {}) => {
1295
1544
  value: value.text,
1296
1545
  segments: value.segments,
1297
1546
  metadata: definition.metadata,
1547
+ source: definition.source,
1298
1548
  dependencies: [...owner.dependencies].sort(),
1299
1549
  // References retain authored source order, which is deterministic and is
1300
1550
  // more useful for explaining a chunk than lexical target order.
@@ -1327,7 +1577,26 @@ export const transformGraph = (pretransform, options = {}) => {
1327
1577
  diagnostics.push(diagnostic("RV130", "out requires a fully qualified existing source chunk ID.", directive.source));
1328
1578
  continue;
1329
1579
  }
1330
- const chunk = evaluate(id, directive.source);
1580
+ let chunk = evaluate(id, directive.source);
1581
+ const definition = definitions.get(id);
1582
+ if (isExecutable(definition) && liveExecutions) {
1583
+ const execution = liveExecution(id);
1584
+ if (execution?.status !== "succeeded") {
1585
+ diagnostics.push(diagnostic("RV140", "Live result is unavailable for output " + name + ".", directive.source));
1586
+ continue;
1587
+ }
1588
+ if (typeof execution.value !== "string") {
1589
+ diagnostics.push(diagnostic(
1590
+ "RV140",
1591
+ "Output " + name +
1592
+ " requires a live string; use jsontext() or jsontext(\"key\") in a derived chunk.",
1593
+ directive.source
1594
+ ));
1595
+ continue;
1596
+ }
1597
+ const mapped = coarseMapped(execution.value, definition.source, id, "live-result");
1598
+ chunk = { ...chunk, value: mapped.text, segments: mapped.segments };
1599
+ }
1331
1600
  if (deliverables[name]) {
1332
1601
  diagnostics.push(diagnostic("RV101", "Duplicate out deliverable: " + name, directive.source));
1333
1602
  continue;