@pieceful/ravel-core 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pieceful/ravel-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Ravel chunk syntax, resolution, graph evaluation, and diagnostics.",
6
6
  "license": "MIT",
@@ -5,5 +5,5 @@ export function append(reference: string, source: SourceLocation): unknown;
5
5
  export function newline(count: number, source: SourceLocation): unknown;
6
6
  export function pipe(steps: unknown[], source: SourceLocation): unknown;
7
7
  export function pass(steps: unknown[], source: SourceLocation): unknown;
8
- export function createDirective(name: string, value: unknown, source: SourceLocation): unknown;
9
- export function aliasDirective(name: string, reference: string, source: SourceLocation): unknown;
8
+ export function createDirective(name: string, value: unknown[] | string, source: SourceLocation, document?: string): { kind: "create"; document?: string; name: string; compose?: unknown[]; body?: string; source: SourceLocation };
9
+ export function aliasDirective(name: string, reference: string, source: SourceLocation, document?: string): { kind: "alias"; document?: string; name: string; reference: string; source: SourceLocation };
package/src/directives.js CHANGED
@@ -12,5 +12,18 @@ export const pass = (steps, source) => ({ kind: "pass", steps, source });
12
12
  * document's canonical chunk identity. Alias is intentionally the only
13
13
  * directive that may introduce an alternate graph name for an existing node.
14
14
  */
15
- export const createDirective = (name, value, source) => ({ kind: "create", name, value, source });
16
- export const aliasDirective = (name, reference, source) => ({ kind: "alias", name, reference, source });
15
+ export const createDirective = (name, value, source, document) => ({
16
+ kind: "create",
17
+ ...(document === undefined ? {} : { document }),
18
+ name,
19
+ // An array is a compose step list; a string is a chunk body.
20
+ ...(Array.isArray(value) ? { compose: value } : { body: String(value ?? "") }),
21
+ source
22
+ });
23
+ export const aliasDirective = (name, reference, source, document) => ({
24
+ kind: "alias",
25
+ ...(document === undefined ? {} : { document }),
26
+ name,
27
+ reference,
28
+ source
29
+ });
package/src/index.d.ts CHANGED
@@ -104,6 +104,7 @@ export interface ChunkReferenceSyntaxOptions {
104
104
  referenceAliases?: Record<string, string>;
105
105
  }
106
106
  export function parseChunk(body: string, source: SourceLocation, options?: ChunkReferenceSyntaxOptions): { nodes: unknown[]; diagnostics: Diagnostic[] };
107
+ export function nowebReferenceAt(body: string, index: number): { authored: string; end: number } | null;
107
108
  export function parseDefinitionPipeline(text: string, source: SourceLocation): { pipeline: TransformCall[]; diagnostics: Diagnostic[] };
108
109
  export function combineMaps(maps: RavelMap[]): PretransformGraph;
109
110
  export function transformGraph(graph: PretransformGraph, options?: {
@@ -137,8 +138,8 @@ export function append(reference: string, source: SourceLocation): unknown;
137
138
  export function newline(count: number, source: SourceLocation): unknown;
138
139
  export function pipe(steps: unknown[], source: SourceLocation): unknown;
139
140
  export function pass(steps: unknown[], source: SourceLocation): unknown;
140
- export function createDirective(name: string, value: unknown, source: SourceLocation): unknown;
141
- export function aliasDirective(name: string, reference: string, source: SourceLocation): unknown;
141
+ export function createDirective(name: string, value: unknown[] | string, source: SourceLocation, document?: string): { kind: "create"; document?: string; name: string; compose?: unknown[]; body?: string; source: SourceLocation };
142
+ export function aliasDirective(name: string, reference: string, source: SourceLocation, document?: string): { kind: "alias"; document?: string; name: string; reference: string; source: SourceLocation };
142
143
  export function parseRavelDirectiveBlock(text: string, options: {
143
144
  document: string;
144
145
  sourceAt(start: number, end: number): SourceLocation;
package/src/index.js CHANGED
@@ -177,18 +177,32 @@ const replaceMappedOnce = (value, search, replacement) => {
177
177
  );
178
178
  };
179
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
+
180
194
  const advance = (start, text, index) => {
181
- let line = start.line;
182
- let column = start.column;
183
- for (let i = 0; i < index; i += 1) {
184
- if (text[i] === "\n") {
185
- line += 1;
186
- column = 0;
187
- } else {
188
- column += 1;
189
- }
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;
190
202
  }
191
- 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 };
192
206
  };
193
207
 
194
208
  const span = (source, text, start, end) => ({
@@ -382,9 +396,10 @@ const parseEmitSuffix = (value) => {
382
396
  return { minor, type, inheritMinor: false };
383
397
  };
384
398
 
385
- 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) => {
386
401
  const match = /^([a-z][a-z0-9-]*)\s*(?:\((.*)\))?$/s.exec(part.value);
387
- const location = span(source, expression, expressionOffset + part.start, expressionOffset + part.end);
402
+ const location = span(source, text, expressionOffset + part.start, expressionOffset + part.end);
388
403
  if (!match) {
389
404
  diagnostics.push(diagnostic("RV110", "Malformed pipeline step: " + part.value, location));
390
405
  return null;
@@ -430,7 +445,7 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
430
445
  diagnostics.push(diagnostic("RV121", "ch requires one quoted chunk expression.", location));
431
446
  return null;
432
447
  }
433
- 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 });
434
449
  if (!parsed) return null;
435
450
  return { type: "chunk", expression: argumentsValue[0], value: parsed, source: location };
436
451
  }
@@ -438,7 +453,7 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
438
453
  const argumentsParsed = rawArguments.map((argument) => {
439
454
  const value = parseValue(argument.value);
440
455
  if (typeof value !== "undefined") return value;
441
- 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);
442
457
  return nested?.type === "text" || nested?.type === "chunk"
443
458
  ? { kind: "ravel-command-argument", command: nested }
444
459
  : undefined;
@@ -450,12 +465,14 @@ const parsePipeStep = (part, expression, source, expressionOffset, diagnostics)
450
465
  return { type: name === "delay" ? "delay" : "transform", name, arguments: argumentsParsed, source: location };
451
466
  };
452
467
 
453
- 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 } = {}) => {
454
471
  const parts = splitTopLevel(expression, "|");
455
472
  const reference = parts.shift();
456
473
  const target = parseChunkId(reference?.value, { reference: true });
457
474
  const pipeline = parts
458
- .map((part) => parsePipeStep(part, expression, source, expressionOffset, diagnostics))
475
+ .map((part) => parsePipeStep(part, text, source, expressionOffset, diagnostics))
459
476
  .filter(Boolean);
460
477
  if (!reference || !target) {
461
478
  const delay = pipeline.length === 1 && pipeline[0].type === "delay" ? pipeline[0] : null;
@@ -474,7 +491,7 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
474
491
  expression: typeof value === "string" ? value : command.expression ?? command.value,
475
492
  phase,
476
493
  safeSymbol,
477
- source: span(source, expression, expressionOffset, expressionOffset + expression.length)
494
+ source: span(source, text, expressionOffset, expressionOffset + expression.length)
478
495
  };
479
496
  }
480
497
  if (reference?.value === "" && pipeline.length && pipeline[0].type !== "delay" &&
@@ -483,13 +500,13 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
483
500
  diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
484
501
  return null;
485
502
  }
486
- 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) };
487
504
  }
488
505
  if (pipeline.some((step) => step.type === "delay")) {
489
506
  diagnostics.push(diagnostic("RV121", "delay may only appear as the sole command in _\"|delay(...)\".", pipeline.find((step) => step.type === "delay").source));
490
507
  return null;
491
508
  }
492
- 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)));
493
510
  return null;
494
511
  }
495
512
 
@@ -503,7 +520,7 @@ const parseExpression = (expression, source, expressionOffset, diagnostics, { al
503
520
  reference: reference.value,
504
521
  target,
505
522
  pipeline,
506
- source: span(source, expression, expressionOffset, expressionOffset + expression.length)
523
+ source: span(source, text, expressionOffset, expressionOffset + expression.length)
507
524
  };
508
525
  };
509
526
 
@@ -532,7 +549,10 @@ export const parseDefinitionPipeline = (text, source) => {
532
549
  // on continuation lines. The first line is already positioned by the literal
533
550
  // text before the reference, so it deliberately remains untouched.
534
551
  const continuationIndentAt = (body, index) => {
535
- 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;
536
556
  return /^[\t ]*/.exec(body.slice(lineStart, index))[0];
537
557
  };
538
558
 
@@ -577,6 +597,26 @@ const nowebExpression = (value, options) => {
577
597
  : canonicalName + " | " + value.slice(pipe + 1).trim();
578
598
  };
579
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
+
580
620
  export const parseChunk = (body, source, options = {}) => {
581
621
  const nodes = [];
582
622
  const diagnostics = [];
@@ -584,18 +624,23 @@ export const parseChunk = (body, source, options = {}) => {
584
624
  let index = 0;
585
625
 
586
626
  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;
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) });
592
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;
593
639
  if (literalStart < index) {
594
640
  nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
595
641
  }
596
- const authored = body.slice(index + 2, end);
597
642
  const expression = nowebExpression(authored, options);
598
- const parsed = parseExpression(expression, source, index + 2, diagnostics);
643
+ const parsed = parseExpression(expression, source, index + 2, diagnostics, { text: body });
599
644
  if (parsed) {
600
645
  nodes.push({
601
646
  ...parsed,
@@ -608,9 +653,11 @@ export const parseChunk = (body, source, options = {}) => {
608
653
  literalStart = index;
609
654
  continue;
610
655
  }
611
- const counted = options.underscoreReferences !== false && body[index] === "\\"
612
- ? /^\\([1-9][0-9]*)_(["'`])/.exec(body.slice(index))
613
- : null;
656
+ let counted = null;
657
+ if (options.underscoreReferences !== false && body[index] === "\\") {
658
+ countedReferencePattern.lastIndex = index;
659
+ counted = countedReferencePattern.exec(body);
660
+ }
614
661
  if (counted) {
615
662
  const quote = counted[2];
616
663
  const expressionStart = index + counted[0].length;
@@ -630,7 +677,7 @@ export const parseChunk = (body, source, options = {}) => {
630
677
  nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
631
678
  }
632
679
  const expression = body.slice(expressionStart, end);
633
- const parsed = parseExpression(expression, source, expressionStart, diagnostics, { allowDelay: false });
680
+ const parsed = parseExpression(expression, source, expressionStart, diagnostics, { allowDelay: false, text: body });
634
681
  if (parsed) {
635
682
  nodes.push({
636
683
  type: "delay",
@@ -682,7 +729,7 @@ export const parseChunk = (body, source, options = {}) => {
682
729
  nodes.push({ type: "literal", value: body.slice(literalStart, index), source: span(source, body, literalStart, index) });
683
730
  }
684
731
  const expression = body.slice(index + 2, end);
685
- const parsed = parseExpression(expression, source, index + 2, diagnostics);
732
+ const parsed = parseExpression(expression, source, index + 2, diagnostics, { text: body });
686
733
  if (parsed) nodes.push({ ...parsed, continuationIndent: continuationIndentAt(body, index) });
687
734
  index = end + 1;
688
735
  literalStart = index;
@@ -901,9 +948,12 @@ const applyTransform = (value, step, diagnostics, transforms, context = {}) => {
901
948
  const transform = customTransform(transforms, step.name);
902
949
  if (typeof transform === "function") {
903
950
  try {
904
- 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 });
905
954
  if (typeof result === "string") return result;
906
- 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));
907
957
  } catch (error) {
908
958
  diagnostics.push(diagnostic("RV121", "Transform " + step.name + " failed: " + (error?.message ?? String(error)), step.source));
909
959
  }
@@ -1235,14 +1285,52 @@ export const transformGraph = (pretransform, options = {}) => {
1235
1285
  return (hash >>> 0).toString(36).toUpperCase();
1236
1286
  };
1237
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
+
1238
1322
  const evaluateTransformMapped = (input, step, definition, context = {}) => {
1323
+ const { argumentValues, ...transformContext } = context;
1239
1324
  const output = applyTransform(
1240
1325
  input.text,
1241
1326
  step,
1242
1327
  diagnostics,
1243
1328
  options.transforms,
1244
- { chunk: definition, ...context }
1329
+ { chunk: definition, ...transformContext }
1245
1330
  );
1331
+ if (typeof output !== "string") {
1332
+ return mappedTransformParts(input, output.parts, step, definition, argumentValues) ?? input;
1333
+ }
1246
1334
  return mapBuiltinTransform(input, output, step, definition.id) ?? coarseDerivedMapped(
1247
1335
  output,
1248
1336
  step.source,
@@ -1358,8 +1446,16 @@ export const transformGraph = (pretransform, options = {}) => {
1358
1446
  } else if (step.type === "chunk") {
1359
1447
  value = evaluateExpression(step.value, owner);
1360
1448
  } else if (step.type === "transform") {
1361
- const argumentsValue = step.arguments.map((argument) => evaluateArgument(argument, owner));
1362
- 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 });
1363
1459
  }
1364
1460
  }
1365
1461
  return value;