reladraw 0.9.0 → 0.11.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/README.md CHANGED
@@ -66,7 +66,7 @@ That gets you a copy of the skill at the time you run it, so you'll need to re-r
66
66
 
67
67
  ## Status
68
68
 
69
- Version 0.9.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG.
69
+ Version 0.11.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG.
70
70
 
71
71
  The language isn't stable yet, so expect the syntax to change.
72
72
 
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.9.0
1
+ # Syntax reference — 0.11.0
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -6,9 +6,19 @@ The worked example is `examples/arch.reladraw`, transcribed from the reference r
6
6
 
7
7
  ## Shape of the file
8
8
 
9
- One statement per line. No multi-line statements, no line continuations, no blocks.
9
+ A statement starts at the beginning of a line. A long one may continue onto the lines after it by indenting them:
10
10
 
11
- Blank lines and `//` comments are ignored. A comment runs to the end of the line and may trail a statement, so `node a "Docker" // the one that matters` is fine. Indentation is ignored entirely — a formatter may add it for readability, and stale indentation cannot change what a file means.
11
+ ```
12
+ node desktop1 "Desktop 1 / Linux"
13
+ above-left of hub gap: tight
14
+ badge: desktop style: synced
15
+ ```
16
+
17
+ A line that starts with whitespace continues the statement above it, and a blank line or the next unindented line ends it. How much whitespace makes no difference — one space and a tab mean the same — so nothing can depend on indentation you cannot see. A bracket may stay open across lines, as in `line: (color: red,` with `pattern: dashed)` on the next. There are no blocks: indentation continues a statement, and never puts one thing inside another.
18
+
19
+ An indented line with no statement to continue — at the top of the file, or after a blank line or an unindented comment — is an error rather than a statement of its own, and so is an indented line starting with a statement keyword such as `node`. Both are almost always a stray indent, and both say to remove it.
20
+
21
+ Blank lines and `//` comments are ignored. A comment runs to the end of the line and may trail a statement, so `node a "Docker" // the one that matters` is fine. An indented comment line sits inside the statement it follows; an unindented one ends it.
12
22
 
13
23
  A lone `/` is an ordinary character rather than the start of a comment, and `#` is ordinary too — it opens a hex color. Comments were spelled `#` in an earlier version and moved to `//` so that `fill: #14532d` could be written the way every other tool writes a color.
14
24
 
@@ -421,13 +431,15 @@ An edge with no text asks for nothing, since every gap is wide enough for an arr
421
431
  edge desktop1.files <-> hub from: right to: top
422
432
  ```
423
433
 
424
- That line leaves the right side of `desktop1.files` heading right, and arrives at the top of `hub` heading down. Naming a side is a statement about how the line should leave or arrive, so the edge is drawn as a curve that actually does. An edge naming neither side stays the straight center-to-center line it has always been. Either end may be named on its own; the unnamed one aims at wherever its partner ended up.
434
+ That line leaves the right side of `desktop1.files` heading right, and arrives at the top of `hub` heading down. Naming a side is a statement about how the line should leave or arrive, so the edge is drawn as a curve that actually does. An edge naming neither side is a straight line from center to center, unless a box is in the way (see "Going round boxes"). Either end may be named on its own; the unnamed one aims at wherever its partner ended up.
425
435
 
426
436
  You name a side and never a point on it. Alone on a side, an edge lands at its center. Sharing a side with other edges, the attachments space themselves apart, and which one goes where is derived from where the far ends actually sit — of two edges arriving at one top side, the one coming from further left arrives further left. Move a node and the order follows it. This is the same rule as nodes not overlapping: the tool separates things by default and reads the direction off the solved layout rather than asking you.
427
437
 
428
438
  The space it leaves is deliberately small, and shrinks further if the side is too short to hold the whole group. On a side short enough, it shrinks to nothing and the attachments coincide. Their texts still come apart, because the lines bow in the middle to make up what the side could not give them, but the arrowheads themselves land on one point and nothing warns you — so a small node with several edges arriving on one side is worth a look.
429
439
 
430
- #### Ends that face away from each other
440
+ #### Going round boxes
441
+
442
+ A line never passes through a box. It takes the shortest way between its ends that goes through none, leaving a named side heading straight out and arriving at one heading straight in, with as few turns as that length allows. When the direct line is clear, that is the line you get. When it is not, the line goes round whatever is in the way. A line you have steered with `between` or `below <node>` keeps to what you said instead.
431
443
 
432
444
  ```
433
445
  node parser "Parser"
@@ -436,17 +448,17 @@ node renderer "Renderer" right of resolver
436
448
  edge parser -> renderer "test edge" from: left to: right
437
449
  ```
438
450
 
439
- That line leaves Parser heading left and has to arrive at Renderer from the right, so it must turn back on itself somewhere. It turns back in the gap between its two nodes when there is one wide enough to hold the line and its text, and otherwise it goes over the top: out of its side, along the top of the row clear of the tallest node between its ends, and down into the far side. The text rides on that top stretch, so it never sits on a node. The same goes for `from: top to: bottom` in a column, which goes round the right, and for an edge from a node back to itself.
451
+ That line leaves Parser heading left and has to arrive at Renderer from the right, so it must turn back on itself somewhere. The shortest way is out of Parser's left, over the top of the row, and down into Renderer's right. Under the row would be exactly as long, and when two ways round tie, the line goes over the top, or round the right in a column. A node stepped down far enough leaves a gap between the two, and the line turns back through the gap, because that is shorter.
440
452
 
441
- Several such lines over the same row take a lane each, the shorter inside the longer, so they nest instead of drawing on top of one another.
453
+ The same rule covers every other case: sides at right angles where one faces away from the other end, two ends naming the same side with one node behind the other, a node back to itself, and a line with no side named that has a box between its ends. Each goes the shortest way round. An end with no side named leaves or arrives by whichever of its box's sides makes the way shortest.
442
454
 
443
- Sides at right angles can face away too. `from: left to: top` with the far node to the right leaves heading away from it, so the line steps out of the left, goes up past both nodes, runs along above them and comes straight down into the top. The side named at the far end says which way round: `to: bottom` takes it underneath instead.
455
+ Several lines along the same stretch take a lane each, side by side, instead of drawing on top of one another. The text rides on the line's longest straight piece that it does not land on a box from, and never changes which way the line goes.
444
456
 
445
- When the two nodes are stacked instead — `from: bottom to: left` into a node directly below — going out past both and coming straight in would pass through the lower node. There the line is one curve, bending round the corner into the side named. If even the curve would cross a node, the line goes round the node on the side it names, as `left of` that node would send it.
457
+ A curved line and a square one take the same way; the curved one sweeps its turns. At an arrowhead, the line always finishes straight, so the head points square into the side.
446
458
 
447
- Both ends can name the same side. `from: left to: left` with the far node to the right leaves heading away from it, and has to get round the node behind to reach the far side. It goes over the top, as the lines above do, and down into the gap; in a column, `from: bottom to: bottom` goes round the right.
459
+ When there is no way at all — the side you named is flush against another box — the file is refused with the reason: `edge a -> b: cannot leave a's right side, b is against it`.
448
460
 
449
- Over the top is the rule, not a guess the tool makes each time — it does not look for the shorter way round. To send the line underneath or round the left, say which side of a node it passes: `below resolver`. See "Passing on one side of a node".
461
+ To send a line round the other way, say which side of a node it passes: `below resolver`. See "Passing on one side of a node".
450
462
 
451
463
  #### Several edges between the same two sides
452
464
 
@@ -602,7 +614,7 @@ edge web -> api line: (color: #d2904e, thickness: thick, crossing: arc)
602
614
  | `color` | a color | as `line:` alone |
603
615
  | `path` | `curved` (the default), `square`, `straight` | how the line joins the points it has to meet |
604
616
  | `corners` | `sharp` (the default), `rounded` | how a square or straight line turns |
605
- | `crossing` | `none` (the default), `arc`, `gap`, `square` | how the line is drawn where it crosses an earlier one |
617
+ | `crossing` | `gap` (the default), `none`, `arc`, `square` | how the line is drawn where it crosses an earlier one |
606
618
  | `pattern` | `solid` (the default), `dashed`, `dotted`, `dash-dot` | |
607
619
  | `thickness` | `thin`, `normal` (the default), `thick`, or a number of pixels | |
608
620
 
@@ -614,7 +626,7 @@ edge web -> api line: (color: #d2904e, thickness: thick, crossing: arc)
614
626
 
615
627
  **Corners** apply wherever a line has them: every bend in a square line, and the bends a clause puts in a straight one. A curved line has none, so `corners: rounded` written on a curved edge is refused. From a style or a default it rounds the edges that have corners and leaves curved ones alone, as a style's `fill:` lands only on the things that have an inside.
616
628
 
617
- **A crossing is drawn on the later line**: the edge written further down the file jumps the one written above it. `arc` is a small half-circle hop, `square` a three-sided jump, and `gap` a short break in the later line, as though it passed underneath. A line never jumps within reach of its own ends, where two lines meeting at a box are not passing each other.
629
+ **A crossing is drawn on the later line**: the edge written further down the file jumps the one written above it. `gap`, the default, is a short break in the later line, as though it passed underneath; `arc` is a small half-circle hop, `square` a three-sided jump, and `none` draws the two lines straight across each other. A line never jumps within reach of its own ends, where two lines meeting at a box are not passing each other.
618
630
 
619
631
  **A pattern is measured in thicknesses**, so dashes keep their proportions on a thick line.
620
632
 
@@ -999,7 +1011,7 @@ Deliberate omissions. What they protect is that the renderer never *chooses* an
999
1011
  - **Guessing an axis nobody constrained.** When two placements bind one axis and nothing binds the other, the tool refuses rather than picking a target to center on. Choosing there would decide which row a node shares, not how far it sits from something.
1000
1012
  - **Placements that run in a circle.** A loop where each placement demands more room than the last cannot be satisfied and is an error naming the placements involved. A target does *not* have to be positioned before the node naming it — the whole system is solved at once — so ordinary mutual references are fine.
1001
1013
  - **Edge waypoints.** A point a line must pass through is a coordinate wearing a hat. Saying a line goes between two named things is not one — it names things the diagram already contains, and it survives those things moving.
1002
- - **Choosing a route.** The tool will not find its own way around an obstacle. A line that crosses something it should not is a line you have not yet said enough about, and `between` and `below <node>` are how you say it.
1014
+ - **Choosing where a line goes when you said.** The tool finds its own way round a box in a line's way, but only as the shortest way through the open space, so where a line goes follows from where the boxes are. When you want a particular way instead, `between` and `below <node>` say it, and the tool keeps to what they say.
1003
1015
  - **Set-level placement.** Four siblings around a hub are four statements today. Whether a durable group that reflows when a member is added is worth the same-axis conflict it introduces is undecided.
1004
1016
 
1005
1017
  Note what is *not* on this list: saying more about where something goes. A statement that lets you be more precise is not a step toward auto-layout, and the first version was short enough of them to render the benchmark wrong.
@@ -1050,12 +1062,27 @@ That one was found by testing the lexer, not by rendering — and it could not h
1050
1062
 
1051
1063
  ~~An edge text ignored the line break.~~ Fixed. ` / ` split a node's text and was never applied to an edge's, so the marker came out as a literal slash on an arrow and the benchmark's two-line captions had to be flattened to one. The measurer had always returned the split lines; the renderer was handing it the raw string and drawing that instead. The block now centers on the point the text already occupied, so a one-line text sits exactly where it did.
1052
1064
 
1053
- ~~An edge whose ends faced away from each other was drawn through its own nodes.~~ Fixed. `from: left to: right` with the far node further right was one curve bending out at both ends, and with the two nodes in a row it flattened into a straight line through both of them and anything between, its text landing on whatever was in the middle. It was not a matter of exact alignment: a node stepped down a little drew a tilted version of the same line. Such an edge now turns back in the gap between its nodes if there is room, and goes over the top of the row if not. See "Ends that face away from each other".
1065
+ ~~An edge whose ends faced away from each other was drawn through its own nodes.~~ Fixed. `from: left to: right` with the far node further right was one curve bending out at both ends, and with the two nodes in a row it flattened into a straight line through both of them and anything between, its text landing on whatever was in the middle. It was not a matter of exact alignment: a node stepped down a little drew a tilted version of the same line. Such an edge now turns back in the gap between its nodes if there is room, and goes over the top of the row if not. See "Going round boxes".
1054
1066
 
1055
1067
  ## Changelog
1056
1068
 
1057
1069
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
1058
1070
 
1071
+ **0.11.0**
1072
+
1073
+ - A line never passes through a box. Every edge takes the shortest way between its ends that goes through none, and goes round whatever is in its way, including edges that name no side. This replaces the separate rules for lines going over a row, turning back and going round a node behind, which kept missing cases; a fan of three from one side into a column was the latest. See "Going round boxes".
1074
+ - Of two ways round that are equally short, the line goes over the top; otherwise the shorter way wins. It used to go over the top always.
1075
+ - A curve finishes straight at an arrowhead, so the head points square into its side. Most diagrams with a named side look slightly different.
1076
+ - Where two lines cross, the later one now breaks for a short gap, as though it passed underneath. It used to draw straight across; `crossing: none` keeps that. See "How the line is drawn".
1077
+ - Lines that turn down the same gap, such as two into one side of a box, take a lane each in the order that keeps them from crossing, instead of curving across each other.
1078
+ - A line's text no longer lands on another line's text: where two would overlap, the later one slides along its own line to the nearest clear spot.
1079
+ - **Breaking:** a line whose named side is flush against another box, with no way out, is refused with the reason. It used to be drawn through the box.
1080
+
1081
+ **0.10.0**
1082
+
1083
+ - A statement may continue onto the lines after it by indenting them. See "Shape of the file". An error in such a statement names the line the mistake is on.
1084
+ - **Breaking:** indentation used to be ignored, so an indented `node` line was a statement of its own. It now continues the statement above and is refused, with a message saying to remove the indentation.
1085
+
1059
1086
  **0.9.0**
1060
1087
 
1061
1088
  - Theme colors: `theme-page`, `theme-text`, `theme-muted`, `theme-fill`, `theme-border` and `theme-line` name the theme's own color for each part, and `theme-primary` and `theme-secondary`, with `-subtle` and `theme-on-` forms, name its two accents. Any attribute that takes a color takes them, and they follow the theme when it changes, so a diagram colored with them reads in light and dark themes alike. See "Theme colors".
@@ -1121,7 +1148,7 @@ Pre-1.0, so the minor number is where a breaking change goes. Every removal belo
1121
1148
 
1122
1149
  Open questions the benchmark raised, recorded so a later session does not rediscover them.
1123
1150
 
1124
- - One edge passing some nodes above or below and others left or right — an L-shaped line, across under one node and then down past another — is refused today. Often the picture leaves only one order for the two legs; where two orders both fit, the tool must not pick one, and whether it asks for another clause or follows a standing rule is not decided.
1151
+ - ~~One edge passing some nodes above or below and others left or right — an L-shaped line, across under one node and then down past another — is refused today.~~ Answered in 0.6.0: such an edge turns between the two kinds as few times as it can, and where two orders both fit, a standing rule picks — a side named at an end says which way the line goes first, and with none named it goes across first. See "Passing on one side of a node" above.
1125
1152
  - Named gaps are the first step toward numbers, but making them minimums took most of the pressure off: they now set how much a diagram breathes, never whether something fits. Whether four names is the right number is still open.
1126
1153
  - Four machines each holding a `files` child with the same text means writing the same line four times. This is the strongest case for a set-level declaration, for terseness rather than for placement.
1127
1154
  - The 2×2 arrangement around a hub is four independent statements, so a fifth machine has no slot to reflow into. There are only eight directions.
package/dist/ast.d.ts CHANGED
@@ -255,7 +255,7 @@ export declare const LINE_KEYS: readonly ["color", "path", "corners", "crossing"
255
255
  export declare const LINE_VALUES: {
256
256
  readonly path: readonly ["curved", "square", "straight"];
257
257
  readonly corners: readonly ["sharp", "rounded"];
258
- readonly crossing: readonly ["none", "arc", "gap", "square"];
258
+ readonly crossing: readonly ["gap", "none", "arc", "square"];
259
259
  readonly pattern: readonly ["solid", "dashed", "dotted", "dash-dot"];
260
260
  readonly thickness: readonly ["normal", "thin", "thick"];
261
261
  };
package/dist/ast.js CHANGED
@@ -236,7 +236,7 @@ export const LINE_KEYS = ['color', 'path', 'corners', 'crossing', 'pattern', 'th
236
236
  export const LINE_VALUES = {
237
237
  path: ['curved', 'square', 'straight'],
238
238
  corners: ['sharp', 'rounded'],
239
- crossing: ['none', 'arc', 'gap', 'square'],
239
+ crossing: ['gap', 'none', 'arc', 'square'],
240
240
  pattern: ['solid', 'dashed', 'dotted', 'dash-dot'],
241
241
  thickness: ['normal', 'thin', 'thick'],
242
242
  };
package/dist/grammar.d.ts CHANGED
@@ -19,9 +19,12 @@
19
19
  * The scanner is the JavaScript consumer of the same spec, and the playground is
20
20
  * its only caller.
21
21
  *
22
- * Nothing in the language spans lines: `//` runs to the end of one, a string
23
- * closes on one, and there are no blocks. That is the property those editor
24
- * formats need and the reason a `.reladraw` grammar is small in all of them.
22
+ * No token spans lines: `//` runs to the end of one, a string closes on one,
23
+ * and there are no blocks. A statement may continue onto indented lines, but
24
+ * each of those is colored by the same rules, and a word that is a keyword at
25
+ * the start of one is refused by the parser anyway. That is the property those
26
+ * editor formats need and the reason a `.reladraw` grammar is small in all of
27
+ * them.
25
28
  */
26
29
  /** What a span of source is, for coloring. */
27
30
  export type TokenKind =
package/dist/grammar.js CHANGED
@@ -19,9 +19,12 @@
19
19
  * The scanner is the JavaScript consumer of the same spec, and the playground is
20
20
  * its only caller.
21
21
  *
22
- * Nothing in the language spans lines: `//` runs to the end of one, a string
23
- * closes on one, and there are no blocks. That is the property those editor
24
- * formats need and the reason a `.reladraw` grammar is small in all of them.
22
+ * No token spans lines: `//` runs to the end of one, a string closes on one,
23
+ * and there are no blocks. A statement may continue onto indented lines, but
24
+ * each of those is colored by the same rules, and a word that is a keyword at
25
+ * the start of one is refused by the parser anyway. That is the property those
26
+ * editor formats need and the reason a `.reladraw` grammar is small in all of
27
+ * them.
25
28
  */
26
29
  import { DIRECTIONS, POSITIONS, SIDES, PASSAGE_AXES } from './ast.js';
27
30
  /**
package/dist/index.d.ts CHANGED
@@ -10,5 +10,9 @@ export { render, type RenderOptions } from './render.js';
10
10
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES, type Theme } from './themes.js';
11
11
  import { type RenderOptions } from './render.js';
12
12
  import { type ResolveOptions } from './resolve.js';
13
- /** Source text in, SVG out. The whole pipeline in one call. */
13
+ /**
14
+ * Source text in, SVG out. The whole pipeline in one call. An error in a
15
+ * statement written over several lines names the line at fault, not the one
16
+ * the statement starts on; see `blame`.
17
+ */
14
18
  export declare function compile(source: string, options?: ResolveOptions & RenderOptions): string;
package/dist/index.js CHANGED
@@ -8,10 +8,21 @@ export { parse } from './parser.js';
8
8
  export { resolve } from './resolve.js';
9
9
  export { render } from './render.js';
10
10
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES } from './themes.js';
11
- import { parse } from './parser.js';
11
+ import { SourceError } from './errors.js';
12
+ import { blame, parse } from './parser.js';
12
13
  import { render } from './render.js';
13
14
  import { resolve } from './resolve.js';
14
- /** Source text in, SVG out. The whole pipeline in one call. */
15
+ /**
16
+ * Source text in, SVG out. The whole pipeline in one call. An error in a
17
+ * statement written over several lines names the line at fault, not the one
18
+ * the statement starts on; see `blame`.
19
+ */
15
20
  export function compile(source, options = {}) {
16
- return render(resolve(parse(source), options), options);
21
+ const run = (text) => render(resolve(parse(text), options), options);
22
+ try {
23
+ return run(source);
24
+ }
25
+ catch (error) {
26
+ throw error instanceof SourceError ? blame(source, error, run) : error;
27
+ }
17
28
  }
package/dist/lexer.d.ts CHANGED
@@ -18,8 +18,11 @@ export interface Token {
18
18
  * opening bracket counts only where a token starts, and a closing one only
19
19
  * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
20
20
  *
21
+ * `depth` is how many brackets are still open from the lines before, when a
22
+ * statement continues onto this one.
23
+ *
21
24
  * Returns an empty array for a blank or comment-only line.
22
25
  */
23
- export declare function tokenizeLine(line: string, lineNumber: number): Token[];
26
+ export declare function tokenizeLine(line: string, lineNumber: number, depth?: number): Token[];
24
27
  /** A bare token ending in `:` opens the attribute section of a statement. */
25
28
  export declare function isAttrKey(token: Token): boolean;
package/dist/lexer.js CHANGED
@@ -14,12 +14,14 @@ import { SourceError } from './errors.js';
14
14
  * opening bracket counts only where a token starts, and a closing one only
15
15
  * while a group is open, so an unquoted `rgb(20,20,20)` stays a single token.
16
16
  *
17
+ * `depth` is how many brackets are still open from the lines before, when a
18
+ * statement continues onto this one.
19
+ *
17
20
  * Returns an empty array for a blank or comment-only line.
18
21
  */
19
- export function tokenizeLine(line, lineNumber) {
22
+ export function tokenizeLine(line, lineNumber, depth = 0) {
20
23
  const tokens = [];
21
24
  let i = 0;
22
- let depth = 0;
23
25
  while (i < line.length) {
24
26
  const ch = line[i];
25
27
  if (ch === ' ' || ch === '\t') {
package/dist/parser.d.ts CHANGED
@@ -1,3 +1,28 @@
1
1
  import type { Document } from './ast.js';
2
- /** Parse a whole source file. One statement per line; blanks and comments drop out. */
2
+ import { SourceError } from './errors.js';
3
+ /**
4
+ * Parse a whole source file. A statement starts on an unindented line and runs
5
+ * on through any line that starts with whitespace; a blank line or the next
6
+ * unindented one ends it. How much whitespace never matters, only whether
7
+ * there is any, so two lines that look alike cannot mean different things.
8
+ *
9
+ * An indented comment stays inside the statement and an unindented one ends
10
+ * it. An indented line with nothing open above it is refused rather than read
11
+ * as a statement of its own: it is a stray indent or a deleted first line, and
12
+ * both are worth hearing about.
13
+ */
3
14
  export declare function parse(source: string): Document;
15
+ /**
16
+ * Find the line at fault for an error reported on the first line of a
17
+ * continued statement. Everything after the parser reports a statement by the
18
+ * line it starts on, which for a continued one is often not where the mistake
19
+ * is, so this asks which line brings the error in: `run` is tried on the
20
+ * source with the statement cut after each of its lines in turn, and the first
21
+ * cut that fails with the same message names the line. A cut that fails
22
+ * differently — a bracket not yet closed, a placement not yet written — is not
23
+ * the culprit and is passed over. Lines keep their numbers, because what is cut
24
+ * is blanked rather than removed.
25
+ *
26
+ * This costs a few extra runs, and only when there is an error to report.
27
+ */
28
+ export declare function blame(source: string, error: SourceError, run: (source: string) => unknown): SourceError;
package/dist/parser.js CHANGED
@@ -1,19 +1,108 @@
1
1
  import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
+ import { STATEMENT_KEYWORDS } from './grammar.js';
3
4
  import { isAttrKey, tokenizeLine } from './lexer.js';
4
5
  import { THEME_COLORS, THEME_NAMES, THEMES } from './themes.js';
5
- /** Parse a whole source file. One statement per line; blanks and comments drop out. */
6
+ /**
7
+ * Parse a whole source file. A statement starts on an unindented line and runs
8
+ * on through any line that starts with whitespace; a blank line or the next
9
+ * unindented one ends it. How much whitespace never matters, only whether
10
+ * there is any, so two lines that look alike cannot mean different things.
11
+ *
12
+ * An indented comment stays inside the statement and an unindented one ends
13
+ * it. An indented line with nothing open above it is refused rather than read
14
+ * as a statement of its own: it is a stray indent or a deleted first line, and
15
+ * both are worth hearing about.
16
+ */
6
17
  export function parse(source) {
7
18
  const statements = [];
19
+ /** The statement being gathered, and the line it starts on. */
20
+ let open = null;
21
+ // Brackets may close on a later line, and the lexer only reads `)` as a
22
+ // bracket while one is open, so the depth is carried from line to line.
23
+ let depth = 0;
24
+ const close = () => {
25
+ if (open)
26
+ statements.push(parseStatement(open.tokens, open.line));
27
+ open = null;
28
+ depth = 0;
29
+ };
8
30
  source.split(/\r?\n/).forEach((line, index) => {
9
31
  const lineNumber = index + 1;
10
- const tokens = tokenizeLine(line, lineNumber);
32
+ if (line.trim() === '') {
33
+ close();
34
+ return;
35
+ }
36
+ const indented = line[0] === ' ' || line[0] === '\t';
37
+ if (!indented)
38
+ close();
39
+ const tokens = tokenizeLine(line, lineNumber, depth);
11
40
  if (tokens.length === 0)
12
41
  return;
13
- statements.push(parseStatement(tokens, lineNumber));
42
+ if (!indented) {
43
+ open = { tokens, line: lineNumber };
44
+ }
45
+ else {
46
+ const first = tokens[0];
47
+ if (!open) {
48
+ throw new SourceError('this line is indented, which continues the statement above, but there is none to ' +
49
+ 'continue — remove the indentation to start a new statement', lineNumber);
50
+ }
51
+ if (!first.quoted && STATEMENT_KEYWORDS.includes(first.text)) {
52
+ throw new SourceError(`\`${first.text}\` cannot continue the statement above — an indented line continues ` +
53
+ `the one before it, so remove the indentation to start a new statement`, lineNumber);
54
+ }
55
+ open.tokens.push(...tokens);
56
+ }
57
+ for (const token of tokens) {
58
+ if (token.quoted)
59
+ continue;
60
+ if (token.text === '(')
61
+ depth += 1;
62
+ else if (token.text === ')')
63
+ depth -= 1;
64
+ }
14
65
  });
66
+ close();
15
67
  return { statements };
16
68
  }
69
+ /**
70
+ * Find the line at fault for an error reported on the first line of a
71
+ * continued statement. Everything after the parser reports a statement by the
72
+ * line it starts on, which for a continued one is often not where the mistake
73
+ * is, so this asks which line brings the error in: `run` is tried on the
74
+ * source with the statement cut after each of its lines in turn, and the first
75
+ * cut that fails with the same message names the line. A cut that fails
76
+ * differently — a bracket not yet closed, a placement not yet written — is not
77
+ * the culprit and is passed over. Lines keep their numbers, because what is cut
78
+ * is blanked rather than removed.
79
+ *
80
+ * This costs a few extra runs, and only when there is an error to report.
81
+ */
82
+ export function blame(source, error, run) {
83
+ const lines = source.split(/\r?\n/);
84
+ const start = error.line - 1;
85
+ const first = lines[start];
86
+ if (first === undefined || first.trim() === '' || first[0] === ' ' || first[0] === '\t')
87
+ return error;
88
+ let end = start;
89
+ while (end + 1 < lines.length && lines[end + 1].trim() !== '' && /^[ \t]/.test(lines[end + 1]))
90
+ end += 1;
91
+ if (end === start)
92
+ return error;
93
+ for (let last = start; last < end; last++) {
94
+ const cut = lines.map((line, index) => (index > last && index <= end ? '' : line)).join('\n');
95
+ try {
96
+ run(cut);
97
+ }
98
+ catch (partial) {
99
+ if (partial instanceof SourceError && partial.message === error.message) {
100
+ return last === start ? error : new SourceError(error.message, last + 1);
101
+ }
102
+ }
103
+ }
104
+ return new SourceError(error.message, end + 1);
105
+ }
17
106
  function parseStatement(tokens, line) {
18
107
  const keyword = tokens[0];
19
108
  if (!keyword || keyword.quoted) {