forma-dsl 0.3.8 → 0.3.9

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  A declarative DSL for 3D modeling and scene composition, with reusable components and live
7
7
  previews.
8
8
 
9
- Models are written as text — blocks, attributes and expressions in an HCL-like syntax —
9
+ Models are written as text - blocks, attributes and expressions in an HCL-like syntax -
10
10
  parsed and solved by the [Manifold](https://github.com/elalish/manifold) CSG kernel compiled
11
11
  to WebAssembly. It runs unchanged in Node and in the browser.
12
12
 
@@ -97,7 +97,7 @@ Hand-written TypeScript declarations ship with it, so the API is typed from eith
97
97
 
98
98
  ## Documentation
99
99
 
100
- **[The full reference](https://kalpak44.github.io/forma-dsl/docs/)** — every declaration,
100
+ **[The full reference](https://kalpak44.github.io/forma-dsl/docs/)** - every declaration,
101
101
  block, function and error, each with usage and troubleshooting. The
102
102
  **[editor](https://kalpak44.github.io/forma-dsl/editor/)** runs the whole thing in the browser:
103
103
  type, and the solid updates.
@@ -113,7 +113,7 @@ source ──▶ lexer ──▶ parser ──▶ evaluator ──▶ geometry n
113
113
  The evaluator does not call the kernel. It produces a tree of immutable geometry nodes, each
114
114
  carrying a 128-bit digest of its own content and its children's digests. Identity is digest
115
115
  identity, so a component used twenty times is one node, evaluated once. Nodes are the only
116
- thing that reaches the kernel, and every result is memoized by digest — which is what makes
116
+ thing that reaches the kernel, and every result is memoized by digest - which is what makes
117
117
  an edit cheap, since only the subtrees whose content actually changed are re-evaluated.
118
118
 
119
119
  WebAssembly objects are not garbage-collected, so an `EvaluationContext` owns everything it
@@ -124,4 +124,4 @@ One runtime dependency, `manifold-3d`.
124
124
 
125
125
  ## License
126
126
 
127
- MIT — see [LICENSE](LICENSE).
127
+ MIT - see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-dsl",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "A declarative DSL for 3D modeling and scene composition, reusable components, and live previews.",
5
5
  "keywords": [
6
6
  "cad",
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Two things earn this its own module. The cache is keyed on content rather than on object
6
6
  * identity, so an edit that rebuilds the tree from scratch still hits every subtree it did
7
- * not change — which is what makes re-solving on a keystroke affordable. And the kernel's
7
+ * not change - which is what makes re-solving on a keystroke affordable. And the kernel's
8
8
  * objects are WASM allocations that no garbage collector will free, so something has to own
9
9
  * them and let them go; that is what `dispose` and `collect` are for, and why the caller is
10
10
  * told in `render`'s contract that the context is theirs.
@@ -18,8 +18,8 @@ import { loadKernel } from './kernel.js';
18
18
  /**
19
19
  * How many unreachable entries `collect` keeps.
20
20
  *
21
- * Editing a model churns the tree — a keystroke changes one subtree and leaves its siblings
22
- * untouched — so the entries that just fell out of the tree are the ones most likely to come
21
+ * Editing a model churns the tree - a keystroke changes one subtree and leaves its siblings
22
+ * untouched - so the entries that just fell out of the tree are the ones most likely to come
23
23
  * straight back, on an undo or on the next character of a number being typed.
24
24
  */
25
25
  const DEFAULT_RETAINED = 512;
@@ -113,7 +113,7 @@ export class EvaluationContext {
113
113
  /**
114
114
  * Resolves to the kernel object for a node.
115
115
  *
116
- * Concurrent requests for the same node share one evaluation — without the in-flight map,
116
+ * Concurrent requests for the same node share one evaluation - without the in-flight map,
117
117
  * a shape used twice in a tree that is walked concurrently would be built twice and one
118
118
  * copy leaked.
119
119
  *
@@ -335,6 +335,6 @@ function free(value) {
335
335
  try {
336
336
  value?.delete();
337
337
  } catch {
338
- // Already freed by the kernel, or never owned one — either way there is nothing to do.
338
+ // Already freed by the kernel, or never owned one - either way there is nothing to do.
339
339
  }
340
340
  }
@@ -23,8 +23,8 @@ function rotl(x, r) {
23
23
  /**
24
24
  * Wraps a number to a signed 32-bit integer, the way the algorithm's additions are defined.
25
25
  *
26
- * `Math.imul(x, 1)` rather than `x | 0`: the two are identical — both coerce through
27
- * ToInt32 — but this one says *wrap*, which is what MurmurHash means here. `Math.trunc` is
26
+ * `Math.imul(x, 1)` rather than `x | 0`: the two are identical - both coerce through
27
+ * ToInt32 - but this one says *wrap*, which is what MurmurHash means here. `Math.trunc` is
28
28
  * emphatically not a substitute: it drops the fraction without wrapping, so `2**31 + 5`
29
29
  * stays positive instead of becoming negative, and every digest downstream changes.
30
30
  *
@@ -68,7 +68,7 @@ function fmix(h) {
68
68
  /**
69
69
  * Packs the bytes after the last whole block into four words, little-endian.
70
70
  *
71
- * Byte `i` of the tail belongs to word `i >> 2` at shift `(i & 3) * 8` — which is what
71
+ * Byte `i` of the tail belongs to word `i >> 2` at shift `(i & 3) * 8` - which is what
72
72
  * MurmurHash3's fallthrough spells out one case at a time. The arithmetic is identical;
73
73
  * only the shape differs, and the digests it produces are asserted to be unchanged.
74
74
  *
@@ -2,7 +2,7 @@
2
2
  * Loading the Manifold WASM module, and the global knobs for curve resolution.
3
3
  *
4
4
  * One instance per page or process. Memoized here rather than left to each caller, because
5
- * two models evaluating concurrently at startup must not each start an instantiation — and
5
+ * two models evaluating concurrently at startup must not each start an instantiation - and
6
6
  * because the resolution controls below are global to the module, so there is nowhere else
7
7
  * they could honestly live.
8
8
  */
@@ -38,7 +38,7 @@ export function loadKernel(options) {
38
38
  return wasm;
39
39
  }, (error) => {
40
40
  // A rejected promise would be memoized along with everything else, so a transient
41
- // failure — a WASM fetch that lost the network — would be permanent for the page.
41
+ // failure - a WASM fetch that lost the network - would be permanent for the page.
42
42
  modulePromise = null;
43
43
  throw error;
44
44
  });
package/src/core/node.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * The geometry tree: a description of geometry that has not been built yet.
3
3
  *
4
4
  * A node names an operation and its children, and carries a digest of the whole subtree
5
- * beneath it. Nothing here touches the kernel — that separation is the point, and the
5
+ * beneath it. Nothing here touches the kernel - that separation is the point, and the
6
6
  * evaluation context is the other half of it.
7
7
  *
8
8
  * The digest is what the evaluation cache is keyed on, so everything feeding it has to be
@@ -19,7 +19,7 @@ import { Transform } from '../values/transform.js';
19
19
  /**
20
20
  * Orders strings by UTF-16 code unit, which is what a bare `sort()` does.
21
21
  *
22
- * Written out rather than left implicit — and deliberately *not* `localeCompare`, which is
22
+ * Written out rather than left implicit - and deliberately *not* `localeCompare`, which is
23
23
  * what a linter will suggest. Collation is locale-dependent, and this ordering feeds the
24
24
  * digest: two machines with different locales would have to agree, or the cache would miss
25
25
  * across them and a model would re-solve from scratch on someone else's laptop.
package/src/export/stl.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Binary STL, for handing a solved part to a slicer.
3
3
  *
4
- * STL is the least a mesh format can carry — triangles and normals, no units, no colour and
5
- * no notion of a part — and it is what every slicer reads. Anything richer belongs in its
4
+ * STL is the least a mesh format can carry - triangles and normals, no units, no colour and
5
+ * no notion of a part - and it is what every slicer reads. Anything richer belongs in its
6
6
  * own exporter rather than in a dialect of this one.
7
7
  */
8
8
 
package/src/index.d.ts CHANGED
@@ -47,6 +47,17 @@ export interface FormaDocument {
47
47
 
48
48
  export declare function parse(source: string): FormaDocument;
49
49
 
50
+ /**
51
+ * Renders the lines around a position with a caret under its column.
52
+ *
53
+ * Both the CLI and the MCP server print this beside an error message; a position on its own
54
+ * still makes the reader count lines.
55
+ */
56
+ export declare function excerpt(
57
+ source: string,
58
+ loc?: Pick<SourceLocation, 'line' | 'column'> | null,
59
+ ): string | undefined;
60
+
50
61
  // --- the kernel -------------------------------------------------------------------------
51
62
 
52
63
  // Re-exported from the dependency rather than restated here. A hand-written approximation
@@ -66,7 +77,7 @@ export type Kernel = ManifoldToplevel;
66
77
  * A live 3D solid owned by an EvaluationContext.
67
78
  *
68
79
  * WebAssembly objects are not garbage-collected. Anything obtained from a context belongs
69
- * to that context — do not call `delete()` on it yourself.
80
+ * to that context - do not call `delete()` on it yourself.
70
81
  */
71
82
  export type Solid = Manifold;
72
83
 
@@ -146,7 +157,7 @@ export declare class Transform {
146
157
  readonly m: readonly number[];
147
158
  readonly order: number;
148
159
  at(row: number, col: number): number;
149
- /** `this ∘ other` — `other` runs first. */
160
+ /** `this ∘ other` - `other` runs first. */
150
161
  concat(other: Transform): Transform;
151
162
  toArray(): number[];
152
163
  }
@@ -204,7 +215,7 @@ export interface ContextStats {
204
215
  /**
205
216
  * Evaluates a geometry tree against the Manifold kernel, memoized by node digest.
206
217
  *
207
- * A context is meant to outlive a single render — reusing one is what makes a re-render
218
+ * A context is meant to outlive a single render - reusing one is what makes a re-render
208
219
  * after an edit skip the subtrees that did not change. It owns every WASM object it holds,
209
220
  * so it must eventually be `dispose()`d, and `collect()` bounds what it holds meanwhile.
210
221
  */
@@ -357,6 +368,12 @@ export interface FunctionDefinition {
357
368
  export declare const FUNCTIONS: Record<string, FunctionDefinition>;
358
369
  export declare const CONSTANTS: Record<string, number>;
359
370
 
371
+ /**
372
+ * The type names a `param` block may declare, each of which is also an ordinary identifier
373
+ * bound to itself in the root scope - which is why `type = number` needs no quoting.
374
+ */
375
+ export declare const TYPE_NAMES: readonly ParameterType[];
376
+
360
377
  export interface RenderMesh {
361
378
  /** Three vertices per triangle, expanded so hard edges stay hard. */
362
379
  positions: Float32Array;
package/src/index.js CHANGED
@@ -13,7 +13,8 @@
13
13
  export { Program, Evaluator } from './lang/evaluate.js';
14
14
  export { parse } from './lang/parser.js';
15
15
  export { tokenize, FormaError } from './lang/lexer.js';
16
- export { BLOCKS, FUNCTIONS, CONSTANTS } from './lang/builtins.js';
16
+ export { excerpt } from './lang/excerpt.js';
17
+ export { BLOCKS, FUNCTIONS, CONSTANTS, TYPE_NAMES } from './lang/builtins.js';
17
18
  export { GeometryNode } from './core/node.js';
18
19
  export { EvaluationContext } from './core/context.js';
19
20
  export { loadKernel, setQuality, resetQuality } from './core/kernel.js';
@@ -32,7 +33,7 @@ import { toRenderMesh } from './export/mesh.js';
32
33
  *
33
34
  * The caller owns the returned `context` and must dispose it: it holds the WASM objects for
34
35
  * every node in the tree, and those are not garbage-collected. Passing the same context back
35
- * on the next call is the intended use — that is what lets the digest cache skip the subtrees
36
+ * on the next call is the intended use - that is what lets the digest cache skip the subtrees
36
37
  * an edit did not change. Call `context.collect(parts.map(p => p.node))` afterwards to bound
37
38
  * how much a reused context holds.
38
39
  *
@@ -66,7 +67,7 @@ export async function render(source, options = {}) {
66
67
  // Checked before solving rather than after: a 2D part is going to be refused either
67
68
  // way, and there is no reason to spend the kernel call first.
68
69
  if (part.node.dim !== 3) {
69
- throw new Error(`part "${part.name}" is 2D — extrude or revolve it before rendering`);
70
+ throw new Error(`part "${part.name}" is 2D - extrude or revolve it before rendering`);
70
71
  }
71
72
  // The check above is what makes this a solid rather than a cross-section.
72
73
  const concrete = /** @type {Solid} */ (await owned.evaluate(part.node));
@@ -110,7 +111,7 @@ export function describeParameters(source) {
110
111
  try {
111
112
  return evaluator.expression(expression, evaluator.root);
112
113
  } catch {
113
- // One unreadable attribute — a min that refers to a param with no value yet — must
114
+ // One unreadable attribute - a min that refers to a param with no value yet - must
114
115
  // not cost the caller every other descriptor in the document.
115
116
  return undefined;
116
117
  }
package/src/lang/ast.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * one carries, could only be recovered by reading the parser and the evaluator side by side
6
6
  * and hoping they agreed.
7
7
  *
8
- * Declarations rather than JSDoc typedefs, which is what this always was — there is no
8
+ * Declarations rather than JSDoc typedefs, which is what this always was - there is no
9
9
  * runtime content here, so there is nothing for a `.js` file to carry and nothing to build.
10
10
  * Written as types, a discriminant is `kind: 'unary'` rather than a `@property` whose
11
11
  * description the lint obliged someone to invent.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * This is the one description of what the language contains. The editor's highlighting, the
6
6
  * landing page's and the docs' code samples, and the MCP server's catalogue all read
7
- * `BLOCKS` and `FUNCTIONS` from here rather than restating them — so a block added here is
7
+ * `BLOCKS` and `FUNCTIONS` from here rather than restating them - so a block added here is
8
8
  * one they all learn about, and a block they claim without it existing fails their tests.
9
9
  */
10
10
 
@@ -195,8 +195,8 @@ export class Args {
195
195
  * leaves it guessing at spellings. What it read is tracked already, so the list costs
196
196
  * nothing to produce.
197
197
  *
198
- * A block that branches on which of two spellings it was given — `radius` against
199
- * `diameter` — only reads one of them, so the list is what this call would accept and not
198
+ * A block that branches on which of two spellings it was given - `radius` against
199
+ * `diameter` - only reads one of them, so the list is what this call would accept and not
200
200
  * always the whole set.
201
201
  *
202
202
  * @returns {void}
@@ -207,7 +207,7 @@ export class Args {
207
207
  if (extra.length) {
208
208
  const plural = extra.length > 1 ? 's' : '';
209
209
  const reads = [...this.used].sort((a, z) => a.localeCompare(z));
210
- const instead = reads.length ? ` — ${this.type} reads ${quoteAll(reads)}` : '';
210
+ const instead = reads.length ? ` - ${this.type} reads ${quoteAll(reads)}` : '';
211
211
  throw new FormaError(
212
212
  `${this.type}: unknown attribute${plural} ${quoteAll(extra)}${instead}`,
213
213
  this.loc,
@@ -579,6 +579,6 @@ export const CONSTANTS = { pi: Math.PI, e: Math.E };
579
579
 
580
580
  /**
581
581
  * Type names are ordinary identifiers that stand for themselves, so `type = number` in a
582
- * param block needs no keyword and no quoting — the same trade HCL makes.
582
+ * param block needs no keyword and no quoting - the same trade HCL makes.
583
583
  */
584
584
  export const TYPE_NAMES = ['number', 'string', 'bool', 'vector', 'list', 'angle'];
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Nothing here calls the kernel. Evaluating a model produces {@link GeometryNode}s, which
5
5
  * describe geometry without building any, and solving those is {@link EvaluationContext}'s
6
- * job. Keeping the two apart is what makes a live preview affordable — a description is
6
+ * job. Keeping the two apart is what makes a live preview affordable - a description is
7
7
  * cheap to rebuild on every keystroke, and a solid is not.
8
8
  *
9
9
  * The three things in this file fail in three different ways, which is why they are three
@@ -30,7 +30,7 @@ const SPECIAL_FORMS = ['align', 'for', 'if', 'part'];
30
30
  /**
31
31
  * What a body produces: geometry to hand upwards, and any scene parts declared inside it.
32
32
  *
33
- * Parts travel separately from nodes because a part is not an operand — it is a coloured
33
+ * Parts travel separately from nodes because a part is not an operand - it is a colored
34
34
  * piece of the finished scene, and an enclosing `translate` must not absorb it.
35
35
  *
36
36
  * @typedef {object} Produced
@@ -150,8 +150,8 @@ export class Program {
150
150
  /**
151
151
  * The declared parameters, in source order, as the editor needs them to build controls.
152
152
  *
153
- * A param may legitimately have no default — the document then requires a value from the
154
- * caller — so the descriptor has to stand on its own. `type` is taken from the declaration
153
+ * A param may legitimately have no default - the document then requires a value from the
154
+ * caller - so the descriptor has to stand on its own. `type` is taken from the declaration
155
155
  * when it is given and inferred from the default otherwise, and falls back to `number`,
156
156
  * which is what a param without either almost always is.
157
157
  *
@@ -205,8 +205,8 @@ export class Evaluator {
205
205
  /** @type {Program} The program being evaluated. */
206
206
  this.program = program;
207
207
  /**
208
- * @type {EvaluationContext | null} Needed only by blocks that measure their child —
209
- * `align` has to know where the geometry actually is — so a program without one can
208
+ * @type {EvaluationContext | null} Needed only by blocks that measure their child -
209
+ * `align` has to know where the geometry actually is - so a program without one can
210
210
  * still be built into a node tree without loading the kernel.
211
211
  */
212
212
  this.context = context;
@@ -217,13 +217,13 @@ export class Evaluator {
217
217
  * How many blocks may be built before evaluation gives up, or null for no limit.
218
218
  *
219
219
  * {@link MAX_DEPTH} stops a component that calls itself, and `range` caps how many values
220
- * a single loop produces — but neither bounds their product. Two loops of ten thousand
220
+ * a single loop produces - but neither bounds their product. Two loops of ten thousand
221
221
  * nested in each other is a hundred million blocks, each one a kernel allocation, and no
222
222
  * amount of waiting finishes it.
223
223
  *
224
224
  * Off by default, because the embedder that controls its own input does not need it and
225
- * should not have a ceiling imposed. A host that builds documents it did not write — an
226
- * MCP server, a shared editor — should pass one.
225
+ * should not have a ceiling imposed. A host that builds documents it did not write - an
226
+ * MCP server, a shared editor - should pass one.
227
227
  *
228
228
  * @type {number | null}
229
229
  */
@@ -265,7 +265,7 @@ export class Evaluator {
265
265
 
266
266
  /**
267
267
  * Settles one declared param: what the caller gave, or what the declaration defaults to,
268
- * or nothing at all — which is the caller's problem to report, since a document param and
268
+ * or nothing at all - which is the caller's problem to report, since a document param and
269
269
  * a component param say different things about it.
270
270
  *
271
271
  * @param {unknown} supplied What was passed in, or undefined.
@@ -452,6 +452,12 @@ export class Evaluator {
452
452
  /** @type {Produced} */
453
453
  const produced = { nodes: [], parts: [] };
454
454
 
455
+ // One block at a time, deliberately. `block` checks the signal and the node budget on
456
+ // the way in, so building these concurrently would enter every one of them before any
457
+ // could trip either - which turns `maxNodes` from a bound on how much gets built into a
458
+ // bound on how much gets built after everything has already started. There is also
459
+ // nothing to overlap: the evaluator is pure, and the one block that reaches the kernel
460
+ // is memoized by the context anyway.
455
461
  for (const block of body.blocks) {
456
462
  const result = await this.block(block, inner);
457
463
  produced.nodes.push(...result.nodes);
@@ -478,14 +484,14 @@ export class Evaluator {
478
484
 
479
485
  if (this.maxNodes !== null && ++this.built > this.maxNodes) {
480
486
  throw new FormaError(
481
- `more than ${this.maxNodes} blocks built — the document asks for more geometry than `
487
+ `more than ${this.maxNodes} blocks built - the document asks for more geometry than `
482
488
  + 'this budget allows; narrow a loop, or raise maxNodes if it is genuinely this large',
483
489
  block.loc,
484
490
  );
485
491
  }
486
492
 
487
493
  if (this.depth > MAX_DEPTH) {
488
- throw new FormaError(`nesting deeper than ${MAX_DEPTH} blocks — is a component using itself?`, block.loc);
494
+ throw new FormaError(`nesting deeper than ${MAX_DEPTH} blocks - is a component using itself?`, block.loc);
489
495
  }
490
496
 
491
497
  if (block.kind === 'for') return this.forBlock(block, scope);
@@ -505,7 +511,7 @@ export class Evaluator {
505
511
  if (!definition) {
506
512
  // The whole vocabulary, because the reader is often a program with no copy of the
507
513
  // manual: a misspelling it cannot correct from the error costs a round trip it has no
508
- // way to end. Declared components come first — they are the names this document itself
514
+ // way to end. Declared components come first - they are the names this document itself
509
515
  // introduced, and the likeliest thing a near miss was reaching for.
510
516
  const known = [
511
517
  ...[...this.program.components.keys()].sort((a, z) => a.localeCompare(z)),
@@ -513,7 +519,7 @@ export class Evaluator {
513
519
  ...Object.keys(BLOCKS).sort((a, z) => a.localeCompare(z)),
514
520
  ];
515
521
  throw new FormaError(
516
- `unknown block "${block.type}" — the blocks are ${quoteAll(known)}`,
522
+ `unknown block "${block.type}" - the blocks are ${quoteAll(known)}`,
517
523
  block.loc,
518
524
  );
519
525
  }
@@ -611,6 +617,9 @@ export class Evaluator {
611
617
  /** @type {Produced} */
612
618
  const produced = { nodes: [], parts: [] };
613
619
 
620
+ // Sequential for the same reason `body` is, and more so: this is the loop a runaway
621
+ // document runs away in, and the budget can only stop it if the iterations are entered
622
+ // one at a time.
614
623
  for (const [index, item] of sequence.entries()) {
615
624
  const iteration = scope.child();
616
625
  // One name binds the value; two bind the index and then the value.
@@ -665,7 +674,7 @@ export class Evaluator {
665
674
  */
666
675
  async alignBlock(block, scope) {
667
676
  if (!this.context) {
668
- throw new FormaError('align needs a kernel context — render the model rather than building it', block.loc);
677
+ throw new FormaError('align needs a kernel context - render the model rather than building it', block.loc);
669
678
  }
670
679
  const args = new Args('align', this.attributes(block.body, scope), block.loc);
671
680
  const { nodes: children } = await this.body(block.body, scope.child());
@@ -903,7 +912,7 @@ function subtract(left, right, loc) {
903
912
  }
904
913
 
905
914
  /**
906
- * `*`: a vector times a scalar in either order — the common case in a model — componentwise
915
+ * `*`: a vector times a scalar in either order - the common case in a model - componentwise
907
916
  * on two lists, and ordinary multiplication otherwise.
908
917
  *
909
918
  * @param {unknown} left The left operand.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Rendering the lines around an error, with a caret under the column it points at.
3
+ *
4
+ * Every error the language raises carries a position, and a position on its own still makes
5
+ * the reader count lines. This turns one into something that can be printed next to the
6
+ * message - which is what the CLI writes to stderr and what the MCP server hands back to a
7
+ * model that has just written a document that does not parse.
8
+ *
9
+ * It lives in the library rather than in either consumer because both want the same answer,
10
+ * and two copies of it would be two things to keep agreeing about what a column means.
11
+ */
12
+ /** @import { SourceLocation } from '../index.js' */
13
+
14
+ /** How many lines of source to show either side of the one in question. */
15
+ const CONTEXT_LINES = 2;
16
+
17
+ /**
18
+ * Renders a source excerpt with a caret under the column an error points at.
19
+ *
20
+ * @param {string} source The document.
21
+ * @param {Pick<SourceLocation, 'line' | 'column'> | null} [loc] Where the error was raised.
22
+ * @returns {string | undefined} The excerpt, or undefined when there is no position to show.
23
+ */
24
+ export function excerpt(source, loc = null) {
25
+ if (!loc || !Number.isInteger(loc.line)) return undefined;
26
+
27
+ const lines = source.split('\n');
28
+ const first = Math.max(1, loc.line - CONTEXT_LINES);
29
+ const last = Math.min(lines.length, loc.line + CONTEXT_LINES);
30
+ const width = String(last).length;
31
+
32
+ /** @type {string[]} */
33
+ const out = [];
34
+ for (let n = first; n <= last; n++) {
35
+ out.push(`${String(n).padStart(width)} | ${lines[n - 1]}`);
36
+ if (n === loc.line) {
37
+ out.push(`${' '.repeat(width)} | ${' '.repeat(Math.max(0, loc.column - 1))}^`);
38
+ }
39
+ }
40
+ return out.join('\n');
41
+ }
package/src/lang/lexer.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Strings are the one thing it does not hand on whole. A `"..."` carrying `${...}` is split
5
5
  * here into literal runs and spliced source, because working out where a splice ends means
6
- * counting braces — which is a job for something reading characters, not for a parser reading
6
+ * counting braces - which is a job for something reading characters, not for a parser reading
7
7
  * tokens.
8
8
  *
9
9
  * {@link FormaError} lives here rather than next to the evaluator because this is the first
@@ -62,15 +62,15 @@ const isDigit = (c) => c >= '0' && c <= '9';
62
62
  /**
63
63
  * @param {string} c A single character.
64
64
  * @returns {boolean} Whether it is whitespace that carries no meaning. A newline does carry
65
- * meaning — it ends an attribute — so it is deliberately not here.
65
+ * meaning - it ends an attribute - so it is deliberately not here.
66
66
  */
67
67
  const isSpace = (c) => c === ' ' || c === '\t' || c === '\r';
68
68
 
69
69
  /**
70
70
  * Walks the source once, keeping the cursor and the accumulated tokens together.
71
71
  *
72
- * A class rather than one long function because the four scanners below — comments,
73
- * strings, numbers, names — all need the same mutable cursor, and threading it through
72
+ * A class rather than one long function because the four scanners below - comments,
73
+ * strings, numbers, names - all need the same mutable cursor, and threading it through
74
74
  * arguments made each of them harder to read than the grammar it implements.
75
75
  */
76
76
  class Lexer {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Recursive descent with a precedence table for the infix operators. The grammar is small
5
5
  * enough that a generator would cost more than it saved, and writing it by hand is what lets
6
- * every node carry the source position that produced it — which is the whole of how errors
6
+ * every node carry the source position that produced it - which is the whole of how errors
7
7
  * point at something later on.
8
8
  *
9
9
  * It checks shape and nothing else. Whether `cylinder` is a real block, and whether its
@@ -321,7 +321,7 @@ class Parser {
321
321
  }
322
322
 
323
323
  /**
324
- * @returns {boolean} Whether the current token starts a new line outside any bracket —
324
+ * @returns {boolean} Whether the current token starts a new line outside any bracket -
325
325
  * the signal that the expression is finished and the next attribute has begun.
326
326
  */
327
327
  breaksLine() {
@@ -361,7 +361,7 @@ class Parser {
361
361
  }
362
362
 
363
363
  /**
364
- * Reads the suffixes — member access, indexing and calls — that bind tightest.
364
+ * Reads the suffixes - member access, indexing and calls - that bind tightest.
365
365
  *
366
366
  * @returns {Expression} The expression.
367
367
  */
@@ -40,7 +40,7 @@ export class Angle {
40
40
  static turns(value) { return new Angle(Number(value) * 2 * Math.PI); }
41
41
 
42
42
  /**
43
- * Accepts an Angle or a bare number, which is read as degrees — the unit a person writing
43
+ * Accepts an Angle or a bare number, which is read as degrees - the unit a person writing
44
44
  * a model thinks in.
45
45
  *
46
46
  * @param {Angle | number} value An angle, or a number of degrees.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A value type: every operation returns a new transform and none mutates the receiver, so a
5
5
  * transform captured by a scope cannot be changed underneath it by a sibling block. That
6
- * matters more than it sounds — nesting is how the language composes, and a mutable
6
+ * matters more than it sounds - nesting is how the language composes, and a mutable
7
7
  * transform would make a block's meaning depend on what ran before it.
8
8
  */
9
9
 
@@ -116,7 +116,7 @@ export class Transform {
116
116
  }
117
117
 
118
118
  /**
119
- * Rotates about x, then y, then z — the order OpenSCAD and Cadova both use, so a model
119
+ * Rotates about x, then y, then z - the order OpenSCAD and Cadova both use, so a model
120
120
  * ported from either lands in the same orientation.
121
121
  *
122
122
  * @param {Vector | ReadonlyArray<number>} angles Degrees about x, y and z.