reladraw 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/README.md +30 -22
- package/SYNTAX.md +435 -213
- package/dist/ast.d.ts +206 -55
- package/dist/ast.js +190 -51
- package/dist/constants.d.ts +60 -45
- package/dist/constants.js +76 -63
- package/dist/grammar.d.ts +17 -4
- package/dist/grammar.js +53 -7
- package/dist/icons.d.ts +53 -35
- package/dist/icons.js +81 -43
- package/dist/lexer.js +6 -5
- package/dist/measure.d.ts +3 -3
- package/dist/measure.js +3 -3
- package/dist/model.d.ts +86 -16
- package/dist/parser.js +522 -213
- package/dist/render.d.ts +1 -1
- package/dist/render.js +347 -282
- package/dist/resolve.js +1185 -265
- package/dist/text.d.ts +48 -0
- package/dist/text.js +196 -0
- package/package.json +2 -2
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { COLOR_KEYS, DIAGRAM_KEYS,
|
|
1
|
+
import { COLOR_KEYS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
|
|
2
2
|
import { SourceError } from './errors.js';
|
|
3
3
|
import { isAttrKey, tokenizeLine } from './lexer.js';
|
|
4
4
|
/** Parse a whole source file. One statement per line; blanks and comments drop out. */
|
|
@@ -14,122 +14,273 @@ export function parse(source) {
|
|
|
14
14
|
return { statements };
|
|
15
15
|
}
|
|
16
16
|
function parseStatement(tokens, line) {
|
|
17
|
-
const
|
|
18
|
-
const head = split === -1 ? tokens : tokens.slice(0, split);
|
|
19
|
-
const attrs = split === -1 ? {} : parseAttrs(tokens.slice(split), line);
|
|
20
|
-
const keyword = head[0];
|
|
17
|
+
const keyword = tokens[0];
|
|
21
18
|
if (!keyword || keyword.quoted) {
|
|
22
19
|
throw new SourceError('a statement must begin with a keyword', line);
|
|
23
20
|
}
|
|
24
21
|
switch (keyword.text) {
|
|
25
|
-
case '
|
|
26
|
-
return
|
|
27
|
-
case '
|
|
28
|
-
return
|
|
29
|
-
case 'link':
|
|
30
|
-
return parseLink(head, attrs, line);
|
|
22
|
+
case 'node':
|
|
23
|
+
return parseNode(tokens, line);
|
|
24
|
+
case 'edge':
|
|
25
|
+
return parseEdge(tokens, line);
|
|
31
26
|
case 'deck':
|
|
32
|
-
return parseDeck(
|
|
27
|
+
return parseDeck(tokens, line);
|
|
33
28
|
case 'style':
|
|
34
|
-
return parseStyle(
|
|
29
|
+
return parseStyle(tokens, line);
|
|
35
30
|
case 'diagram':
|
|
36
|
-
return parseDiagram(
|
|
31
|
+
return parseDiagram(tokens, line);
|
|
37
32
|
default:
|
|
38
|
-
throw new SourceError(
|
|
33
|
+
throw new SourceError(substitution(keyword.text, tokens), line);
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
36
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
37
|
+
* The statement keywords that are not words in this language, and the word to
|
|
38
|
+
* write instead. `box` and `link` were the keywords until 0.3.0; `rect` and
|
|
39
|
+
* `arrow` never were, and are here because they are what somebody arriving from
|
|
40
|
+
* another format types first.
|
|
41
|
+
*
|
|
42
|
+
* Refused by name with the substitution quoted, the same treatment `stroke:`
|
|
43
|
+
* and `width:` get. A synonym was the other candidate and is refused for the
|
|
44
|
+
* reasons in the design record: an alias is a variant every reader has to
|
|
45
|
+
* learn, and the statement keyword would become the one place a misspelling
|
|
46
|
+
* silently succeeds.
|
|
45
47
|
*/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
48
|
+
const SUBSTITUTIONS = {
|
|
49
|
+
box: 'node',
|
|
50
|
+
rect: 'node',
|
|
51
|
+
link: 'edge',
|
|
52
|
+
arrow: 'edge',
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* What to say about a word that opens no statement. A word this language once
|
|
56
|
+
* used, or one another format uses, gets the replacement quoted back in the
|
|
57
|
+
* author's own name for the thing; anything else has no remedy but its
|
|
58
|
+
* spelling.
|
|
59
|
+
*/
|
|
60
|
+
function substitution(word, head) {
|
|
61
|
+
// A note is not a kind of statement any more, and the reason is worth the
|
|
62
|
+
// longer message: a keyword names a picture, and "note" names a use. Free
|
|
63
|
+
// text is a brace caption, a title over a diagram or an aside, so the
|
|
64
|
+
// picture it names is a node with no body — which is what to write.
|
|
65
|
+
if (word === 'note') {
|
|
66
|
+
return `reladraw has no \`note\` statement — a note is a node with no body, so try ` +
|
|
67
|
+
`\`node ${rewrite(head)} shape: none\``;
|
|
68
|
+
}
|
|
69
|
+
const replacement = SUBSTITUTIONS[word];
|
|
70
|
+
if (replacement === undefined)
|
|
71
|
+
return `unknown statement "${word}"`;
|
|
72
|
+
const plural = replacement === 'node' ? 'nodes' : 'edges';
|
|
73
|
+
// Quote the fix in the line the author actually wrote. `node parser` and
|
|
74
|
+
// `edge a -> b` both say more than a placeholder does, and the whole head is
|
|
75
|
+
// what makes the second of those readable.
|
|
76
|
+
const rest = rewrite(head);
|
|
77
|
+
const example = rest === '' ? '' : ` \u2014 try \`${replacement} ${rest}\``;
|
|
78
|
+
return `reladraw calls these ${plural}, so there is no \`${word}\` statement${example}`;
|
|
79
|
+
}
|
|
80
|
+
/** Everything after the keyword, written back the way the author would type it. */
|
|
81
|
+
function rewrite(head) {
|
|
82
|
+
return head
|
|
83
|
+
.slice(1)
|
|
84
|
+
.map((token) => (token.quoted ? quoteOf(token.text) : token.text))
|
|
85
|
+
.join(' ');
|
|
59
86
|
}
|
|
60
|
-
/** The value as the author would have to write it back into a
|
|
87
|
+
/** The value as the author would have to write it back into a text. */
|
|
61
88
|
function quoteOf(text) {
|
|
62
89
|
return `"${text.replace(/"/g, '\\"')}"`;
|
|
63
90
|
}
|
|
64
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Everything after a statement's positional head: its attributes and, on a
|
|
93
|
+
* node, its placements, in whatever order they were written.
|
|
94
|
+
*
|
|
95
|
+
* The ordering rule that used to stand here — placements first, attributes
|
|
96
|
+
* after — existed because a bare `gap:` written between two placements could
|
|
97
|
+
* not be told from the node-wide default. Gaps went into brackets on their own
|
|
98
|
+
* placement, so that ambiguity is gone and with it the reason for the rule. A
|
|
99
|
+
* `key:` token can never open a placement and a placement never opens with one,
|
|
100
|
+
* so the two interleave with nothing to resolve.
|
|
101
|
+
*/
|
|
102
|
+
function parseTail(tokens, start, line, subject, other) {
|
|
65
103
|
const attrs = {};
|
|
66
|
-
|
|
104
|
+
const placements = [];
|
|
105
|
+
let i = start;
|
|
67
106
|
while (i < tokens.length) {
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// the parser expected describes its own state; say what to move instead.
|
|
73
|
-
if (startsPlacement(keyToken)) {
|
|
74
|
-
throw new SourceError(`"${keyToken.text}" starts a placement, and placements come before the attributes — move it in front of the first "key: value"`, line);
|
|
75
|
-
}
|
|
76
|
-
throw new SourceError(`expected an attribute like "key: value", found "${keyToken.text}"`, line);
|
|
107
|
+
const token = tokens[i];
|
|
108
|
+
if (isAttrKey(token)) {
|
|
109
|
+
i = readAttr(tokens, i, attrs, line, subject);
|
|
110
|
+
continue;
|
|
77
111
|
}
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (isAttrKey(valueToken)) {
|
|
83
|
-
throw new SourceError(`attribute "${key}" has no value`, line);
|
|
112
|
+
const taken = other?.(tokens, i);
|
|
113
|
+
if (taken !== undefined) {
|
|
114
|
+
i = taken;
|
|
115
|
+
continue;
|
|
84
116
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
const read = readPlacement(tokens, i, line, subject);
|
|
118
|
+
placements.push(read.placement);
|
|
119
|
+
i = read.next;
|
|
120
|
+
}
|
|
121
|
+
return { attrs, placements };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The attribute keys whose value is a bracket rather than a word, and what may
|
|
125
|
+
* be written inside it. `text:` is a style's way of saying what a node says in
|
|
126
|
+
* the brackets after its own string; `contents:` names a part whose two
|
|
127
|
+
* properties are independent and sit one level below the node.
|
|
128
|
+
*/
|
|
129
|
+
const BRACKET_KEYS = {
|
|
130
|
+
text: TEXT_KEYS,
|
|
131
|
+
contents: CONTENTS_KEYS,
|
|
132
|
+
};
|
|
133
|
+
/** How each bracketed key's error quotes itself back, and what it is about. */
|
|
134
|
+
const BRACKET_ABOUT = {
|
|
135
|
+
text: { kind: 'a text', example: 'color: muted' },
|
|
136
|
+
contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* The top-level keys that moved into the text's bracket in 0.3.0, and the
|
|
140
|
+
* substitution each one gets. They are properties of a node's *text* and never
|
|
141
|
+
* of the node, and leaving them at the top level is what let `size:` sit beside
|
|
142
|
+
* `fill:` as though the two were the same sort of statement.
|
|
143
|
+
*/
|
|
144
|
+
const MOVED_INTO_BRACKET = ['size', 'wrap', 'align'];
|
|
145
|
+
/** Read one `key: value` pair, and refuse the words that used to be keys. */
|
|
146
|
+
function readAttr(tokens, at, attrs, line, subject) {
|
|
147
|
+
const keyToken = tokens[at];
|
|
148
|
+
const key = keyToken.text.slice(0, -1);
|
|
149
|
+
const bracketKeys = BRACKET_KEYS[key];
|
|
150
|
+
if (bracketKeys !== undefined && follows(tokens, at + 1, '(')) {
|
|
151
|
+
// `text: (color: muted)` — the whole bracket belongs to one part, and it is
|
|
152
|
+
// stored under dotted keys so that a style merges into a node exactly the
|
|
153
|
+
// way every other attribute does.
|
|
154
|
+
const read = readBracket(tokens, at + 1, bracketKeys, {
|
|
155
|
+
subject,
|
|
156
|
+
what: `\`${key}:\``,
|
|
157
|
+
kind: BRACKET_ABOUT[key].kind,
|
|
158
|
+
example: BRACKET_ABOUT[key].example,
|
|
159
|
+
line,
|
|
160
|
+
});
|
|
161
|
+
if (Object.keys(read.values).length === 0) {
|
|
162
|
+
throw new SourceError(`${subject}: \`${key}:\` opens empty brackets`, line);
|
|
92
163
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
164
|
+
for (const [inner, value] of Object.entries(read.values))
|
|
165
|
+
attrs[`${key}.${inner}`] = value;
|
|
166
|
+
return read.next;
|
|
167
|
+
}
|
|
168
|
+
if (key === 'url') {
|
|
169
|
+
// `url: https://example.com` loses everything from the `//` onwards, because
|
|
170
|
+
// `//` opens a comment — so the value is either missing entirely or is the
|
|
171
|
+
// bare scheme, which reads as another attribute key. Neither report says
|
|
172
|
+
// what is wrong, and the remedy is punctuation rather than a missing word.
|
|
173
|
+
const value = tokens[at + 1];
|
|
174
|
+
if (!value || !value.quoted) {
|
|
175
|
+
throw new SourceError(`${subject}: a url is written in quotes — \`url: "https://example.com"\`. Without them ` +
|
|
176
|
+
'everything from the `//` onwards is read as a comment', line);
|
|
100
177
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
178
|
+
attrs[key] = value.text;
|
|
179
|
+
return at + 2;
|
|
180
|
+
}
|
|
181
|
+
if (bracketKeys !== undefined && key !== 'text') {
|
|
182
|
+
// `contents: match` names the part and then says one of its two properties
|
|
183
|
+
// without saying which. The brackets are what make the level shift visible,
|
|
184
|
+
// so there is no unbracketed spelling to fall back to.
|
|
185
|
+
const given = tokens[at + 1];
|
|
186
|
+
throw new SourceError(`${subject}: \`${key}:\` takes its properties in brackets — write \`${key}: (${BRACKET_ABOUT[key].example})\`` +
|
|
187
|
+
(given && !isAttrKey(given) ? `, not \`${key}: ${given.text}\`` : ''), line);
|
|
188
|
+
}
|
|
189
|
+
const valueToken = tokens[at + 1];
|
|
190
|
+
if (!valueToken || isAttrKey(valueToken) || valueToken.text === ')') {
|
|
191
|
+
throw new SourceError(`attribute "${key}" has no value`, line);
|
|
192
|
+
}
|
|
193
|
+
if (key === 'stroke') {
|
|
194
|
+
// Removed 2026-09-09. It meant a different part on every kind — the
|
|
195
|
+
// border of a box, the text of a note or a glyph body, the line of a
|
|
196
|
+
// edge — so it could never be wrong, and a node's text had no word at all.
|
|
197
|
+
// Refused by name rather than ignored: an older file must be told what
|
|
198
|
+
// to write, not silently drawn without its colors.
|
|
199
|
+
throw new SourceError('`stroke:` has been replaced by the part it colors — `border:` on a node, `text: (color: …)` on the text of anything, `line:` on an edge. A style shared between nodes and edges writes both, as in `border: #d2904e line: #d2904e`', line);
|
|
200
|
+
}
|
|
201
|
+
if (key === 'width') {
|
|
202
|
+
// Renamed 2026-09-09, and moved into the text's bracket in 0.3.0.
|
|
203
|
+
throw new SourceError('`width:` is now `wrap:` and belongs to the text — it folds the text every n characters and says nothing about how wide anything is, so write it as `"…" (wrap: 30)`', line);
|
|
204
|
+
}
|
|
205
|
+
if (key === 'subtext') {
|
|
206
|
+
// Removed in 0.3.0. It colored "every line after the first", which is a
|
|
207
|
+
// positional slice: the rule lived in a style elsewhere in the file and was
|
|
208
|
+
// applied by counting, so a reader of the text could not see it. Markup
|
|
209
|
+
// says what is quiet where it is quiet, and reaches a word in the middle of
|
|
210
|
+
// a line, which the slice never could.
|
|
211
|
+
throw new SourceError('`subtext:` has been replaced by markup in the text — write `style dim text: (color: muted)` ' +
|
|
212
|
+
'and mark the quiet words as `"Dropbox / [dim]synced[/dim]"`', line);
|
|
213
|
+
}
|
|
214
|
+
if (key === 'text') {
|
|
215
|
+
// `text:` is the text's bracket now, so a bare word after it is either the
|
|
216
|
+
// old color key or an attempt to set the words themselves. The quotes tell
|
|
217
|
+
// the two apart, and they want different remedies.
|
|
218
|
+
throw new SourceError(valueToken.quoted
|
|
219
|
+
? `\`text:\` is how a style says something about text, not how anything sets it — write the words in quotes after the name, as in \`node name ${quoteOf(valueToken.text)}\``
|
|
220
|
+
: `\`text:\` takes the text's properties in brackets — write \`text: (color: ${valueToken.text})\` in a style, and \`(color: ${valueToken.text})\` in the brackets after a node's or an edge's own text`, line);
|
|
221
|
+
}
|
|
222
|
+
if (key === 'align' && valueToken.text === 'widths') {
|
|
223
|
+
// Removed in 0.3.0. It was a size operation wearing an alignment's name,
|
|
224
|
+
// and its value set had one member — a flag in a property's clothes. Its
|
|
225
|
+
// job is `contents: (widths: match)`, and with it gone `align` means one
|
|
226
|
+
// thing everywhere.
|
|
227
|
+
throw new SourceError('`align: widths` is now `contents: (widths: match)` — it is a size, not an alignment, and ' +
|
|
228
|
+
'the same brackets take `align: center` for where the contents sit when the title is wider', line);
|
|
229
|
+
}
|
|
230
|
+
if (MOVED_INTO_BRACKET.includes(key)) {
|
|
231
|
+
throw new SourceError(`\`${key}:\` belongs to the text rather than to the node — write it in the brackets after ` +
|
|
232
|
+
`the text, as in \`"…" (${key}: ${valueToken.text})\`, or as \`text: (${key}: ${valueToken.text})\` in a style`, line);
|
|
233
|
+
}
|
|
234
|
+
if (valueToken.quoted && COLOR_KEYS.includes(key)) {
|
|
235
|
+
// A quoted value is the author saying "this is text", and every one of
|
|
236
|
+
// these keys takes a color. Without this the string is passed through as
|
|
237
|
+
// a color, turns out not to be one, and nothing is drawn and nothing is
|
|
238
|
+
// said.
|
|
239
|
+
if (valueToken.text.startsWith('#')) {
|
|
240
|
+
// A hex color that was merely quoted. The author wrote a color and the
|
|
241
|
+
// remedy is punctuation, so say that rather than that it is not one.
|
|
242
|
+
throw new SourceError(`a color is written without quotes — "${key}: ${valueToken.text}"`, line);
|
|
121
243
|
}
|
|
122
|
-
|
|
123
|
-
|
|
244
|
+
throw new SourceError(`"${key}" takes a color and a quoted value is text — drop the quotes if ${valueToken.text} is a color`, line);
|
|
245
|
+
}
|
|
246
|
+
attrs[key] = valueToken.text;
|
|
247
|
+
return at + 2;
|
|
248
|
+
}
|
|
249
|
+
/** Attributes only, for the statements that take no placements. */
|
|
250
|
+
function attrsOnly(tokens, start, line, subject) {
|
|
251
|
+
const attrs = {};
|
|
252
|
+
let i = start;
|
|
253
|
+
while (i < tokens.length) {
|
|
254
|
+
const token = tokens[i];
|
|
255
|
+
if (!isAttrKey(token)) {
|
|
256
|
+
throw new SourceError(`${subject}: expected an attribute like "key: value", found "${token.text}"`, line);
|
|
257
|
+
}
|
|
258
|
+
i = readAttr(tokens, i, attrs, line, subject);
|
|
124
259
|
}
|
|
125
260
|
return attrs;
|
|
126
261
|
}
|
|
262
|
+
/** Everything a text takes, less the one word that needs a box to sit in. */
|
|
263
|
+
const EDGE_TEXT_KEYS = TEXT_KEYS.filter((key) => key !== 'at');
|
|
264
|
+
/**
|
|
265
|
+
* `text:` is how a *style* says something about the text of whatever wears it,
|
|
266
|
+
* because a style has no string of its own. A node and an edge do, so they say
|
|
267
|
+
* it in the brackets after that string, and there is one spelling per place.
|
|
268
|
+
*/
|
|
269
|
+
function refuseTextKey(attrs, subject, where, line) {
|
|
270
|
+
for (const key of Object.keys(attrs)) {
|
|
271
|
+
if (!key.startsWith('text.'))
|
|
272
|
+
continue;
|
|
273
|
+
const inner = key.slice('text.'.length);
|
|
274
|
+
throw new SourceError(`${subject}: \`text: (…)\` is how a style says it, having no text of its own. This has one, ` +
|
|
275
|
+
`so write \`(${inner}: ${attrs[key]})\` in the brackets ${where}`, line);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
127
278
|
/**
|
|
128
|
-
* `
|
|
279
|
+
* `node <name> ["<text>"] [(<text modifiers>)] [<placement> ...]`
|
|
129
280
|
*
|
|
130
|
-
* The text is optional and the name stands in for it, because a bare `
|
|
281
|
+
* The text is optional and the name stands in for it, because a bare `node a`
|
|
131
282
|
* asking for an empty rectangle is a default nobody wants: the first lines
|
|
132
|
-
* anybody types are `
|
|
283
|
+
* anybody types are `node a` and `node b right of a`, and they mean the two
|
|
133
284
|
* boxes to say "a" and "b". `""` is how a box says it is deliberately blank —
|
|
134
285
|
* an invisible container, a glyph body, a node that is nothing but its icon —
|
|
135
286
|
* and every such box already writes it, so nothing that predates this changed
|
|
@@ -140,130 +291,155 @@ function parseAttrs(tokens, line) {
|
|
|
140
291
|
* `server.docker` reading "docker" says everything the whole path would.
|
|
141
292
|
*
|
|
142
293
|
* A name now has two jobs, so renaming a node can change the picture. That is
|
|
143
|
-
* the price, and it is honest: a file that states no
|
|
144
|
-
* is the
|
|
294
|
+
* the price, and it is honest: a file that states no text is saying the name
|
|
295
|
+
* is the text.
|
|
145
296
|
*/
|
|
146
|
-
function
|
|
147
|
-
const name = requireName(head[1], '
|
|
297
|
+
function parseNode(head, line) {
|
|
298
|
+
const name = requireName(head[1], 'node', line);
|
|
148
299
|
const written = head[2];
|
|
149
300
|
const textToken = written?.quoted ? written : undefined;
|
|
150
|
-
// A bare word here is a
|
|
301
|
+
// A bare word here is a text somebody forgot to quote far more often than
|
|
151
302
|
// it is anything else, and `"Parser" is not a direction` would send them
|
|
152
303
|
// looking in the wrong place.
|
|
153
304
|
if (written && !textToken && !isAttrKey(written) && !startsPlacement(written) && written.text !== '(') {
|
|
154
|
-
throw new SourceError(`
|
|
305
|
+
throw new SourceError(`node "${name}": a text is quoted — write "${written.text}" rather than ${written.text}`, line);
|
|
155
306
|
}
|
|
156
307
|
const text = textToken ? textToken.text : name.slice(name.lastIndexOf('.') + 1);
|
|
157
|
-
const subject = `
|
|
158
|
-
const
|
|
308
|
+
const subject = `node "${name}"`;
|
|
309
|
+
const bracket = readBracket(head, textToken ? 3 : 2, TEXT_KEYS, {
|
|
159
310
|
subject,
|
|
160
|
-
what: 'the
|
|
161
|
-
kind: 'a
|
|
311
|
+
what: 'the text',
|
|
312
|
+
kind: 'a text',
|
|
162
313
|
example: 'at: bottom',
|
|
163
314
|
line,
|
|
164
315
|
});
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (follows(head, 3, '(')) {
|
|
178
|
-
throw new SourceError(`note "${name}" carries label modifiers. A note is bare text, so there is no box for its label to sit anywhere in`, line);
|
|
179
|
-
}
|
|
180
|
-
const placements = parsePlacements(head.slice(3), line, `note "${name}"`);
|
|
181
|
-
return { kind: 'note', name, text: textToken.text, placements, attrs, line };
|
|
316
|
+
const tail = parseTail(head, bracket.next, line, subject);
|
|
317
|
+
refuseTextKey(tail.attrs, subject, 'after the name', line);
|
|
318
|
+
return {
|
|
319
|
+
kind: 'node',
|
|
320
|
+
name,
|
|
321
|
+
text,
|
|
322
|
+
statedText: textToken !== undefined,
|
|
323
|
+
textAttrs: bracket.values,
|
|
324
|
+
placements: tail.placements,
|
|
325
|
+
attrs: tail.attrs,
|
|
326
|
+
line,
|
|
327
|
+
};
|
|
182
328
|
}
|
|
183
329
|
/**
|
|
184
|
-
* `
|
|
330
|
+
* `edge <from> -> <to> ["<text>"] [between <a> and <b>]`, with `<->` for a
|
|
185
331
|
* two-headed arrow and `<-` for one pointing the other way.
|
|
186
332
|
*
|
|
187
333
|
* `a <- b` is exactly `b -> a` and carries no meaning of its own downstream.
|
|
188
334
|
* What it buys is the ordering: the name written first is the one the line is
|
|
189
|
-
* about, and plenty of
|
|
335
|
+
* about, and plenty of edges have the target as their subject.
|
|
190
336
|
*/
|
|
191
337
|
const ARROWS = ['->', '<->', '<-'];
|
|
192
|
-
function
|
|
193
|
-
const left = requireName(head[1], '
|
|
338
|
+
function parseEdge(head, line) {
|
|
339
|
+
const left = requireName(head[1], 'edge', line);
|
|
194
340
|
const arrow = head[2];
|
|
195
341
|
if (!arrow || arrow.quoted || !ARROWS.includes(arrow.text)) {
|
|
196
|
-
throw new SourceError('
|
|
342
|
+
throw new SourceError('an edge needs "->", "<-" or "<->" between its endpoints', line);
|
|
197
343
|
}
|
|
198
344
|
const rightToken = head[3];
|
|
199
345
|
if (!rightToken || rightToken.quoted) {
|
|
200
|
-
throw new SourceError('
|
|
346
|
+
throw new SourceError('an edge needs a node on the right of the arrow', line);
|
|
201
347
|
}
|
|
202
348
|
const back = arrow.text === '<-';
|
|
203
349
|
let at = 4;
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
350
|
+
const textToken = head[at]?.quoted ? head[at] : undefined;
|
|
351
|
+
if (textToken)
|
|
206
352
|
at += 1;
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
//
|
|
353
|
+
const subject = `edge ${left} ${arrow.text} ${rightToken.text}`;
|
|
354
|
+
// An edge's text takes the same bracket a node's does, less `at:`: a node's
|
|
355
|
+
// text sits somewhere in a box and an edge's rides at the middle of its line,
|
|
356
|
+
// so there is no position to name until a diagram asks for one.
|
|
357
|
+
const bracket = readBracket(head, at, EDGE_TEXT_KEYS, {
|
|
358
|
+
subject,
|
|
359
|
+
what: 'the text',
|
|
360
|
+
kind: "an edge's text",
|
|
361
|
+
example: 'color: muted',
|
|
362
|
+
line,
|
|
363
|
+
});
|
|
364
|
+
at = bracket.next;
|
|
210
365
|
let between;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
366
|
+
// An edge is not placed, so its tail holds attributes and the one clause that
|
|
367
|
+
// is neither: `between`, which says which gap the line travels down.
|
|
368
|
+
const tail = parseTail(head, at, line, subject, (tokens, index) => {
|
|
369
|
+
const word = tokens[index];
|
|
370
|
+
if (word.quoted || word.text !== 'between')
|
|
371
|
+
return undefined;
|
|
372
|
+
if (between)
|
|
373
|
+
throw new SourceError(`${subject}: "between" is written twice`, line);
|
|
374
|
+
// A gap has two sides, so `between` takes exactly two targets rather than
|
|
375
|
+
// the open list a placement takes. `right of a and b` means "clear of
|
|
376
|
+
// both", and there is no matching reading of "pass between three things".
|
|
377
|
+
const read = readTargets(tokens, index + 1, subject, 'between', line);
|
|
217
378
|
if (read.targets.length !== 2) {
|
|
218
379
|
throw new SourceError(`"between" takes two nodes, one for each side of the gap — found ${read.targets.length}`, line);
|
|
219
380
|
}
|
|
220
|
-
|
|
381
|
+
let next = read.next;
|
|
221
382
|
// Two targets sitting diagonally have two gaps between them, and this is
|
|
222
383
|
// the only way to say which. It is optional because most pairs have one.
|
|
223
|
-
const trailing =
|
|
384
|
+
const trailing = tokens[next];
|
|
224
385
|
const axis = trailing && !trailing.quoted ? PASSAGE_AXES[trailing.text] : undefined;
|
|
225
386
|
if (axis !== undefined)
|
|
226
|
-
|
|
387
|
+
next += 1;
|
|
227
388
|
between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
|
|
389
|
+
return next;
|
|
390
|
+
});
|
|
391
|
+
if (tail.placements.length > 0) {
|
|
392
|
+
throw new SourceError(`${subject}: "${describePlacement(tail.placements[0])}" places a node, and an edge is not ` +
|
|
393
|
+
'placed — it joins two things that are', line);
|
|
228
394
|
}
|
|
229
|
-
|
|
230
|
-
throw new SourceError(`unexpected "${head[at].text}" after the link`, line);
|
|
231
|
-
}
|
|
395
|
+
refuseTextKey(tail.attrs, subject, 'after the arrow', line);
|
|
232
396
|
return {
|
|
233
|
-
kind: '
|
|
397
|
+
kind: 'edge',
|
|
398
|
+
textAttrs: bracket.values,
|
|
234
399
|
from: back ? rightToken.text : left,
|
|
235
400
|
to: back ? left : rightToken.text,
|
|
236
401
|
both: arrow.text === '<->',
|
|
237
|
-
...(
|
|
402
|
+
...(textToken ? { text: textToken.text } : {}),
|
|
238
403
|
...(between ? { between } : {}),
|
|
239
|
-
attrs,
|
|
404
|
+
attrs: tail.attrs,
|
|
240
405
|
line,
|
|
241
406
|
};
|
|
242
407
|
}
|
|
243
|
-
/** `deck <name> "<
|
|
408
|
+
/** `deck <name> "<text>" ["<text>" ...]` */
|
|
244
409
|
function parseDeck(head, line) {
|
|
245
410
|
const name = requireName(head[1], 'deck', line);
|
|
246
|
-
const
|
|
411
|
+
const texts = [];
|
|
247
412
|
for (const token of head.slice(2)) {
|
|
248
413
|
if (!token.quoted) {
|
|
249
|
-
|
|
414
|
+
// Until 0.3.0 the head/attributes split cut these off and threw them
|
|
415
|
+
// away, so `deck d "one" fill: red` drew an uncolored deck in silence.
|
|
416
|
+
if (isAttrKey(token)) {
|
|
417
|
+
throw new SourceError(`deck "${name}" has ${token.text} — a deck says how many copies a node has and what ` +
|
|
418
|
+
`each one reads, so write attributes on \`node ${name}\` itself`, line);
|
|
419
|
+
}
|
|
420
|
+
throw new SourceError(`deck "${name}" takes quoted texts only`, line);
|
|
250
421
|
}
|
|
251
|
-
|
|
422
|
+
texts.push(token.text);
|
|
252
423
|
}
|
|
253
|
-
if (
|
|
254
|
-
throw new SourceError(`deck "${name}" needs at least one
|
|
424
|
+
if (texts.length === 0) {
|
|
425
|
+
throw new SourceError(`deck "${name}" needs at least one text`, line);
|
|
255
426
|
}
|
|
256
|
-
return { kind: 'deck', name,
|
|
427
|
+
return { kind: 'deck', name, texts, line };
|
|
257
428
|
}
|
|
258
429
|
/** `style <name> <attributes>` */
|
|
259
|
-
function parseStyle(head,
|
|
430
|
+
function parseStyle(head, line) {
|
|
260
431
|
const name = requireName(head[1], 'style', line);
|
|
261
|
-
|
|
262
|
-
throw new SourceError(`unexpected "${head[2].text}" after style name`, line);
|
|
263
|
-
}
|
|
432
|
+
const attrs = attrsOnly(head, 2, line, `style "${name}"`);
|
|
264
433
|
if (Object.keys(attrs).length === 0) {
|
|
265
434
|
throw new SourceError(`style "${name}" sets nothing`, line);
|
|
266
435
|
}
|
|
436
|
+
if (attrs['url'] !== undefined) {
|
|
437
|
+
// A destination is content, not appearance. A style is a bundle worn by
|
|
438
|
+
// many things, so a `url:` in one would point every node wearing it at the
|
|
439
|
+
// same place — which is never what anybody means, and would be silent.
|
|
440
|
+
throw new SourceError(`style "${name}" has a url. A destination is part of what a node says rather than how it ` +
|
|
441
|
+
'looks, so it is written on the node or the edge itself', line);
|
|
442
|
+
}
|
|
267
443
|
return { kind: 'style', name, attrs, line };
|
|
268
444
|
}
|
|
269
445
|
/**
|
|
@@ -271,10 +447,8 @@ function parseStyle(head, attrs, line) {
|
|
|
271
447
|
* keys are refused rather than ignored: a misspelt diagram-wide setting that
|
|
272
448
|
* silently does nothing is the kind of thing an author stares at for a while.
|
|
273
449
|
*/
|
|
274
|
-
function parseDiagram(head,
|
|
275
|
-
|
|
276
|
-
throw new SourceError(`unexpected "${head[1].text}" after diagram`, line);
|
|
277
|
-
}
|
|
450
|
+
function parseDiagram(head, line) {
|
|
451
|
+
const attrs = attrsOnly(head, 1, line, 'diagram');
|
|
278
452
|
if (Object.keys(attrs).length === 0) {
|
|
279
453
|
throw new SourceError('diagram sets nothing', line);
|
|
280
454
|
}
|
|
@@ -292,71 +466,158 @@ function requireName(token, keyword, line) {
|
|
|
292
466
|
return token.text;
|
|
293
467
|
}
|
|
294
468
|
/**
|
|
295
|
-
* Read
|
|
296
|
-
*
|
|
297
|
-
*
|
|
469
|
+
* Read one placement. A direction and a target (`right of docker`, `below
|
|
470
|
+
* deploy`), an alignment (`level with docker`), or an overlay (`on hub at
|
|
471
|
+
* top-right`).
|
|
298
472
|
*
|
|
299
473
|
* `of` is optional after every direction. "left of X" and "below X" are both
|
|
300
474
|
* good English and "below of X" is not, so the word is accepted wherever it
|
|
301
|
-
* helps and never demanded. Shorthands added later
|
|
302
|
-
*
|
|
475
|
+
* helps and never demanded. Shorthands added later extend this without
|
|
476
|
+
* disturbing what it already reads.
|
|
303
477
|
*/
|
|
304
|
-
function
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
478
|
+
function readPlacement(tokens, at, line, subject) {
|
|
479
|
+
const word = tokens[at];
|
|
480
|
+
if (word.quoted) {
|
|
481
|
+
throw new SourceError(`${subject}: unexpected text "${word.text}"`, line);
|
|
482
|
+
}
|
|
483
|
+
if (word.text === 'on')
|
|
484
|
+
return readOn(tokens, at, line, subject);
|
|
485
|
+
if (word.text === 'inside' || word.text === 'outside') {
|
|
486
|
+
return readTucked(tokens, at, line, subject, word.text);
|
|
487
|
+
}
|
|
488
|
+
// `top level with media` names a side rather than the center line. `left`
|
|
489
|
+
// and `right` are sides as well as directions, so it is the word after them
|
|
490
|
+
// that says which was meant — "left of drive" against "left level with drive".
|
|
491
|
+
const side = isSideWord(word.text) && follows(tokens, at + 1, 'level') ? word.text : undefined;
|
|
492
|
+
const head = side ? tokens[at + 1] : word;
|
|
493
|
+
if (head.text === 'level' && !head.quoted) {
|
|
494
|
+
const from = side ? at + 1 : at;
|
|
495
|
+
const written = side ? `${side} level with` : 'level with';
|
|
496
|
+
if (!follows(tokens, from + 1, 'with')) {
|
|
497
|
+
throw new SourceError(`${subject}: an alignment reads "${written} <node>"`, line);
|
|
311
498
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const read = readTargets(tokens, at + 2, subject, written, line);
|
|
324
|
-
const modifiers = readModifiers(tokens, read.next, subject, written, line);
|
|
325
|
-
// An alignment shares a line outright, so there is no distance in it for
|
|
326
|
-
// a gap to set. Refusing rather than dropping it, for the reason unknown
|
|
327
|
-
// modifier names are refused: a word that quietly does nothing reads as a
|
|
328
|
-
// fault in the tool.
|
|
329
|
-
if (modifiers.gap !== undefined) {
|
|
330
|
-
throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" shares a line rather than leaving a space, so it takes no gap`, line);
|
|
331
|
-
}
|
|
332
|
-
placements.push({
|
|
499
|
+
const read = readTargets(tokens, from + 2, subject, written, line);
|
|
500
|
+
const modifiers = readModifiers(tokens, read.next, subject, written, line);
|
|
501
|
+
// An alignment shares a line outright, so there is no distance in it for
|
|
502
|
+
// a gap to set. Refusing rather than dropping it, for the reason unknown
|
|
503
|
+
// modifier names are refused: a word that quietly does nothing reads as a
|
|
504
|
+
// fault in the tool.
|
|
505
|
+
if (modifiers.gap !== undefined) {
|
|
506
|
+
throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" shares a line rather than leaving a space, so it takes no gap`, line);
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
placement: {
|
|
333
510
|
kind: 'align',
|
|
334
|
-
axis:
|
|
335
|
-
|
|
511
|
+
axis: SIDE_AXIS[side ?? 'center'],
|
|
512
|
+
side: side ?? 'center',
|
|
336
513
|
targets: read.targets,
|
|
337
514
|
line,
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
515
|
+
},
|
|
516
|
+
next: modifiers.next,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (!isDirection(word.text)) {
|
|
520
|
+
// A position word where a direction belongs is the one confusable pair, and
|
|
521
|
+
// it is worth naming rather than only refusing: the two vocabularies reach
|
|
522
|
+
// the same corner with different words and only one of them takes `of`.
|
|
523
|
+
if (isPosition(word.text)) {
|
|
524
|
+
throw new SourceError(`${subject}: "${word.text}" is a position on a box rather than a direction from one — ` +
|
|
525
|
+
`write \`inside <node> ${word.text}\` to put this in that corner, \`on <node> ` +
|
|
526
|
+
`${word.text}\` to straddle it, or a direction like ${DIRECTIONS.join(', ')} to put ` +
|
|
527
|
+
'it outside', line);
|
|
344
528
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
529
|
+
throw new SourceError(`${subject}: "${word.text}" is not a direction`, line);
|
|
530
|
+
}
|
|
531
|
+
let next = at + 1;
|
|
532
|
+
if (follows(tokens, next, 'of'))
|
|
533
|
+
next += 1;
|
|
534
|
+
const read = readTargets(tokens, next, subject, word.text, line);
|
|
535
|
+
const modifiers = readModifiers(tokens, read.next, subject, word.text, line);
|
|
536
|
+
return {
|
|
537
|
+
placement: {
|
|
351
538
|
kind: 'offset',
|
|
352
539
|
direction: word.text,
|
|
353
540
|
targets: read.targets,
|
|
354
541
|
...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
|
|
355
542
|
line,
|
|
356
|
-
}
|
|
357
|
-
|
|
543
|
+
},
|
|
544
|
+
next: modifiers.next,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* `on hub top-right` — the node's center at the part's center, straddling it.
|
|
549
|
+
*
|
|
550
|
+
* One target, and the `and` list the other placements take is refused by name.
|
|
551
|
+
* A direction against several targets means "clear of the box that bounds them
|
|
552
|
+
* all", which is a floor and decomposes into one constraint per target; this
|
|
553
|
+
* names an exact point of one box, and the box bounding two things is not a
|
|
554
|
+
* box anybody drew.
|
|
555
|
+
*/
|
|
556
|
+
function readOn(tokens, at, line, subject) {
|
|
557
|
+
const read = readTargets(tokens, at + 1, subject, 'on', line, true);
|
|
558
|
+
const target = read.targets[0];
|
|
559
|
+
if (read.targets.length > 1) {
|
|
560
|
+
throw new SourceError(`${subject}: "on ${listTargets(read.targets)}" names ${read.targets.length} nodes, and a ` +
|
|
561
|
+
'stamp sits on one box — name the one it is stamped on', line);
|
|
562
|
+
}
|
|
563
|
+
// `on X at <position>` shipped in 0.3.0 and never reached a release. Every
|
|
564
|
+
// picture it drew is still drawable, in words that had to exist anyway, so
|
|
565
|
+
// it is refused by name rather than left as a second spelling.
|
|
566
|
+
if (follows(tokens, read.next, 'at')) {
|
|
567
|
+
const wordToken = tokens[read.next + 1];
|
|
568
|
+
const word = wordToken && !wordToken.quoted ? wordToken.text : '<position>';
|
|
569
|
+
throw new SourceError(`${subject}: "on ${target.name} at ${word}" is no longer how a node is put on a box — ` +
|
|
570
|
+
`write \`inside ${target.name} ${word}\` to tuck it inside that corner, or ` +
|
|
571
|
+
`\`on ${target.name} ${word}\` to straddle it`, line);
|
|
358
572
|
}
|
|
359
|
-
|
|
573
|
+
const modifiers = readModifiers(tokens, read.next, subject, `on ${nameTarget(target)}`, line);
|
|
574
|
+
if (modifiers.gap !== undefined) {
|
|
575
|
+
throw new SourceError(`${subject}: "on ${nameTarget(target)}" puts this node's center on that point rather than ` +
|
|
576
|
+
'leaving a space, so it takes no gap', line);
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
placement: { kind: 'on', targets: read.targets, line },
|
|
580
|
+
next: modifiers.next,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* `inside server right`, `outside board top-left` — a direction read off the
|
|
585
|
+
* part rather than written.
|
|
586
|
+
*
|
|
587
|
+
* Both are shorthands, and their expansion is *derived* rather than listed:
|
|
588
|
+
* inside is the direction from the named part toward the box's center, outside
|
|
589
|
+
* is away from it. One rule covers every part — `inside right` is `left of`,
|
|
590
|
+
* `inside top-right` is `below-left of` — so nobody writes a table and the
|
|
591
|
+
* long form can be printed back.
|
|
592
|
+
*/
|
|
593
|
+
function readTucked(tokens, at, line, subject, written) {
|
|
594
|
+
const read = readTargets(tokens, at + 1, subject, written, line, true);
|
|
595
|
+
const target = read.targets[0];
|
|
596
|
+
if (read.targets.length > 1) {
|
|
597
|
+
throw new SourceError(`${subject}: "${written} ${listTargets(read.targets)}" names ${read.targets.length} nodes, ` +
|
|
598
|
+
`and "${written}" reads its direction off one part of one box`, line);
|
|
599
|
+
}
|
|
600
|
+
const inward = target.part === undefined ? undefined : INWARD[target.part];
|
|
601
|
+
if (inward === undefined) {
|
|
602
|
+
const named = target.part === undefined
|
|
603
|
+
? `"${written} ${target.name}" names no part of "${target.name}"`
|
|
604
|
+
: `"${written} ${nameTarget(target)}" reads no direction from "${target.part}", ` +
|
|
605
|
+
'which is not on the boundary';
|
|
606
|
+
throw new SourceError(`${subject}: ${named} — "${written}" takes a side or a point of the box: ` +
|
|
607
|
+
`${BOUNDARY_PARTS.join(', ')}`, line);
|
|
608
|
+
}
|
|
609
|
+
const modifiers = readModifiers(tokens, read.next, subject, `${written} ${nameTarget(target)}`, line);
|
|
610
|
+
return {
|
|
611
|
+
placement: {
|
|
612
|
+
kind: 'offset',
|
|
613
|
+
direction: written === 'inside' ? inward : OPPOSITE[inward],
|
|
614
|
+
written,
|
|
615
|
+
targets: read.targets,
|
|
616
|
+
...(modifiers.gap !== undefined ? { gap: modifiers.gap } : {}),
|
|
617
|
+
line,
|
|
618
|
+
},
|
|
619
|
+
next: modifiers.next,
|
|
620
|
+
};
|
|
360
621
|
}
|
|
361
622
|
/**
|
|
362
623
|
* The bracketed modifiers on one placement — `left of hub (gap: wide)`.
|
|
@@ -379,7 +640,7 @@ function readModifiers(tokens, start, subject, placement, line) {
|
|
|
379
640
|
}
|
|
380
641
|
/**
|
|
381
642
|
* A bracketed `key: value` list, shared by a placement's modifiers and a
|
|
382
|
-
*
|
|
643
|
+
* text's. Both exist for the same reason — a modifier belongs to the clause it
|
|
383
644
|
* modifies, and the brackets say which clause that is rather than leaving it to
|
|
384
645
|
* be inferred from what happens to precede it.
|
|
385
646
|
*
|
|
@@ -421,23 +682,36 @@ function follows(tokens, at, word) {
|
|
|
421
682
|
const token = tokens[at];
|
|
422
683
|
return token !== undefined && !token.quoted && token.text === word;
|
|
423
684
|
}
|
|
424
|
-
/**
|
|
685
|
+
/**
|
|
686
|
+
* Could this token open a placement? `top` and `left` open the side alignments,
|
|
687
|
+
* `on` opens an overlay. Used only to tell a forgotten pair of quotes after a
|
|
688
|
+
* node's name from a placement, so a word that is nearly one counts.
|
|
689
|
+
*/
|
|
425
690
|
function startsPlacement(token) {
|
|
426
691
|
if (token.quoted)
|
|
427
692
|
return false;
|
|
428
693
|
return (isDirection(token.text) ||
|
|
429
694
|
token.text === 'level' ||
|
|
430
|
-
|
|
695
|
+
token.text === 'on' ||
|
|
696
|
+
token.text === 'inside' ||
|
|
697
|
+
token.text === 'outside' ||
|
|
698
|
+
SIDES.includes(token.text));
|
|
431
699
|
}
|
|
432
|
-
function
|
|
433
|
-
return word !== 'center' &&
|
|
700
|
+
function isSideWord(word) {
|
|
701
|
+
return word !== 'center' && SIDES.includes(word);
|
|
434
702
|
}
|
|
435
703
|
/**
|
|
436
704
|
* One target, or several joined by `and` — `right of borg and bare`, or
|
|
437
705
|
* `level with borg, bare and media`. A trailing comma separates just as `and`
|
|
438
706
|
* does, so both the way people write lists come out the same.
|
|
707
|
+
*
|
|
708
|
+
* Each name may be followed by a *part* of that node, spaced: `right of hub
|
|
709
|
+
* text`, `inside server right`. See `partAfter` for the two words that are
|
|
710
|
+
* parts everywhere else in the language too, and how they are told apart.
|
|
711
|
+
* `partFirst` is for the placements where a side word after the name can only
|
|
712
|
+
* be the part; see there.
|
|
439
713
|
*/
|
|
440
|
-
function readTargets(tokens, start, subject, placement, line) {
|
|
714
|
+
function readTargets(tokens, start, subject, placement, line, partFirst = false) {
|
|
441
715
|
const targets = [];
|
|
442
716
|
let i = start;
|
|
443
717
|
for (;;) {
|
|
@@ -446,8 +720,14 @@ function readTargets(tokens, start, subject, placement, line) {
|
|
|
446
720
|
throw new SourceError(`${subject}: "${placement}" names no node`, line);
|
|
447
721
|
}
|
|
448
722
|
const listed = token.text.endsWith(',') && token.text.length > 1;
|
|
449
|
-
|
|
723
|
+
const name = listed ? token.text.slice(0, -1) : token.text;
|
|
450
724
|
i += 1;
|
|
725
|
+
// A part can only follow a name the author did not already close with a
|
|
726
|
+
// comma — `a, b` is two targets and the comma says so.
|
|
727
|
+
const part = listed ? undefined : partAfter(tokens, i, partFirst);
|
|
728
|
+
if (part)
|
|
729
|
+
i += 1;
|
|
730
|
+
targets.push(part === undefined ? { name } : { name, part });
|
|
451
731
|
if (follows(tokens, i, 'and')) {
|
|
452
732
|
i += 1;
|
|
453
733
|
continue;
|
|
@@ -457,3 +737,32 @@ function readTargets(tokens, start, subject, placement, line) {
|
|
|
457
737
|
return { targets, next: i };
|
|
458
738
|
}
|
|
459
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* The part word after a target's name, if there is one.
|
|
742
|
+
*
|
|
743
|
+
* Two of the part words are also the openings of something else, and both are
|
|
744
|
+
* settled by the word that follows rather than by a reservation:
|
|
745
|
+
*
|
|
746
|
+
* - `right of hub right of mirror` — a side followed by `of` is the *direction*
|
|
747
|
+
* opening the next placement, which is how `left` and `right` have always
|
|
748
|
+
* been told apart.
|
|
749
|
+
* - `right of hub top level with mirror` — a side followed by `level` is the
|
|
750
|
+
* alignment opening the next placement, the same lookahead `readPlacement`
|
|
751
|
+
* makes for `top level with`.
|
|
752
|
+
*
|
|
753
|
+
* The second does not hold after `inside`, `outside` or `on`, so `partFirst`
|
|
754
|
+
* lifts it there: `inside server right level with server.db` is the right side
|
|
755
|
+
* and then an alignment. `inside` and `outside` need a part, so a partless
|
|
756
|
+
* reading is an error anyway; and `on` already fixes both axes, so an edge
|
|
757
|
+
* alignment after a partless `on hub` would contradict it.
|
|
758
|
+
*/
|
|
759
|
+
function partAfter(tokens, at, partFirst = false) {
|
|
760
|
+
const token = tokens[at];
|
|
761
|
+
if (!token || token.quoted || !isPart(token.text))
|
|
762
|
+
return undefined;
|
|
763
|
+
if (follows(tokens, at + 1, 'of'))
|
|
764
|
+
return undefined;
|
|
765
|
+
if (!partFirst && follows(tokens, at + 1, 'level'))
|
|
766
|
+
return undefined;
|
|
767
|
+
return token.text;
|
|
768
|
+
}
|