reladraw 0.18.0 → 0.19.1

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/dist/format.js ADDED
@@ -0,0 +1,511 @@
1
+ import { SourceError } from './errors.js';
2
+ import { DEFAULT_FORMAT } from './format-settings.js';
3
+ import { isAttrKey } from './lexer.js';
4
+ import { clausesOf, looksLikeArrow, parse } from './parser.js';
5
+ /**
6
+ * Lay a source file out again: spacing between clauses, columns shared by
7
+ * statements that belong together, comments and blank lines, as `settings`
8
+ * say. Only whitespace changes, and the order of clauses when `clause-order`
9
+ * asks. Every token is copied out of the line it was
10
+ * written on, so no word is respelled, and the result is parsed again and
11
+ * compared with the original, so a mistake here can never change what a file
12
+ * draws: it throws instead.
13
+ *
14
+ * Throws the parser's error for a file that does not parse, so a broken file
15
+ * is never rewritten.
16
+ */
17
+ export function format(source, settings = DEFAULT_FORMAT) {
18
+ const lines = source.split(/\r?\n/);
19
+ const endsWithBreak = lines.length > 1 && lines[lines.length - 1] === '';
20
+ if (endsWithBreak)
21
+ lines.pop();
22
+ const { statements, starts } = clausesOf(source);
23
+ const items = gatherItems(lines, statements, starts, settings);
24
+ const out = layOutItems(items, settings);
25
+ // A file keeps the line breaks it came with, so formatting on Windows
26
+ // doesn't turn every line into a change. The first break decides.
27
+ const lineBreak = /\r\n/.test(source.match(/\r?\n/)?.[0] ?? '') ? '\r\n' : '\n';
28
+ const result = out.join(lineBreak) + (endsWithBreak ? lineBreak : '');
29
+ if (!sameMeaning(source, result)) {
30
+ throw new Error('the formatter would change what this file draws, so it was left alone — please report this as a bug');
31
+ }
32
+ return result;
33
+ }
34
+ /**
35
+ * Does `a` say the same as `b`? Both parse to the same statements, line
36
+ * numbers aside. The keys of a parsed object are compared in any order, since
37
+ * `clause-order` may write `from:` after `via:`; lists keep theirs.
38
+ */
39
+ function sameMeaning(a, b) {
40
+ const strip = (source) => JSON.stringify(parse(source), (key, value) => {
41
+ if (key === 'line')
42
+ return undefined;
43
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
44
+ return value;
45
+ return Object.fromEntries(Object.entries(value).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0)));
46
+ });
47
+ return strip(a) === strip(b);
48
+ }
49
+ const OPENING = new Set(['keyword', 'head']);
50
+ /** The kind of clause a run of tokens is. */
51
+ function kindOf(run) {
52
+ const first = run[0].token;
53
+ if (first.quoted)
54
+ return 'text';
55
+ if (isAttrKey(first))
56
+ return first.text.slice(0, -1);
57
+ return 'placement';
58
+ }
59
+ /** Everything after `//` outside quotes on a line, or nothing. */
60
+ function trailingCommentAfter(line, from) {
61
+ const at = line.indexOf('//', from);
62
+ return at === -1 ? undefined : line.slice(at).trimEnd();
63
+ }
64
+ function gatherItems(lines, statements, starts, settings) {
65
+ const byLine = new Map(statements.map((statement) => [statement.line, statement]));
66
+ const items = [];
67
+ for (let index = 0; index < lines.length; index++) {
68
+ const lineNumber = index + 1;
69
+ const line = lines[index];
70
+ const statement = byLine.get(lineNumber);
71
+ if (statement === undefined) {
72
+ if (line.trim() === '')
73
+ items.push({ kind: 'blank' });
74
+ else
75
+ items.push({ kind: 'comment', text: line.trimEnd() });
76
+ continue;
77
+ }
78
+ // The statement's own lines run to its last line of tokens, and on over any
79
+ // indented comment lines after it: `parse` reads those as inside it too.
80
+ const tokenLines = statement.lines;
81
+ const lastEntry = tokenLines[tokenLines.length - 1];
82
+ let last = lastEntry.line - 1 + newlinesIn(lastEntry.tokens);
83
+ while (last + 1 < lines.length && /^[ \t]+\/\//.test(lines[last + 1]))
84
+ last += 1;
85
+ const span = lines.slice(index, last + 1);
86
+ index = last;
87
+ if (tokenLines.some((entry) => entry.tokens.some((token) => token.triple))) {
88
+ items.push({ kind: 'verbatim', lines: span });
89
+ continue;
90
+ }
91
+ const all = tokenLines.flatMap((entry) => entry.tokens.map((token) => ({ token, line: lines[entry.line - 1] })));
92
+ const keywordCount = all[0].token.text === 'default' || all[0].token.text === 'guide' ? 2 : 1;
93
+ const keyword = all.slice(0, keywordCount).map(({ token, line: text }) => text.slice(token.start, token.end)).join(' ');
94
+ // Comments among the statement's lines: whole comment lines, and comments
95
+ // trailing any line but the last.
96
+ const inside = [];
97
+ let trailing;
98
+ for (let at = 0; at < span.length; at++) {
99
+ const physical = lineNumber + at;
100
+ const entry = tokenLines.find((one) => one.line === physical);
101
+ const text = span[at];
102
+ if (entry === undefined) {
103
+ if (text.trim() !== '')
104
+ inside.push({ line: physical, text: text.trim() });
105
+ continue;
106
+ }
107
+ const comment = trailingCommentAfter(text, entry.tokens[entry.tokens.length - 1].end ?? 0);
108
+ if (comment === undefined)
109
+ continue;
110
+ if (entry === lastEntry)
111
+ trailing = comment;
112
+ else
113
+ inside.push({ line: physical, text: comment });
114
+ }
115
+ // A trailing comment on the last line is inside too when whole comment lines follow it.
116
+ if (trailing !== undefined && inside.some((one) => one.line > lastEntry.line)) {
117
+ inside.push({ line: lastEntry.line, text: trailing });
118
+ inside.sort((a, b) => a.line - b.line);
119
+ trailing = undefined;
120
+ }
121
+ const cellsOf = (entries) => statementCells(entries, keywordCount, starts, settings);
122
+ if (inside.length > 0 && settings['comments-inside'] === 'refuse') {
123
+ throw new SourceError('a comment among the lines of a statement has nowhere to go when the statement is joined onto one line — ' +
124
+ 'move it above the statement, or set comments-inside: keep-lines', inside[0].line);
125
+ }
126
+ if (inside.length > 0 && settings['comments-inside'] === 'keep-lines') {
127
+ const kept = [];
128
+ for (let at = 0; at < span.length; at++) {
129
+ const physical = lineNumber + at;
130
+ const entry = tokenLines.find((one) => one.line === physical);
131
+ const text = span[at];
132
+ if (entry === undefined) {
133
+ if (text.trim() !== '')
134
+ kept.push({ cells: [], comment: text.trim() });
135
+ continue;
136
+ }
137
+ const own = all.filter(({ token }) => entry.tokens.includes(token));
138
+ const comment = trailingCommentAfter(text, entry.tokens[entry.tokens.length - 1].end ?? 0);
139
+ // The first line keeps its keyword as a cell of its own.
140
+ const lineCells = at === 0 ? cellsOf(own) : continuedCells(own, starts);
141
+ kept.push({ cells: lineCells, ...(comment !== undefined ? { comment } : {}) });
142
+ }
143
+ items.push({ kind: 'kept', keyword, lines: kept, first: cellsOf(all) });
144
+ continue;
145
+ }
146
+ for (const comment of inside)
147
+ items.push({ kind: 'comment', text: comment.text });
148
+ const cells = orderCells(cellsOf(all), settings);
149
+ items.push({ kind: 'statement', keyword, cells, ...(trailing !== undefined ? { comment: trailing } : {}) });
150
+ }
151
+ return items;
152
+ }
153
+ /** How many physical lines a `"""` string on this line runs on past it. */
154
+ function newlinesIn(tokens) {
155
+ return tokens.reduce((count, token) => count + (token.triple ? token.text.split('\n').length - 1 : 0), 0);
156
+ }
157
+ /**
158
+ * Split a statement's tokens into its cells: the keyword, what follows it up
159
+ * to the first clause (a name, or an edge's name and ends), then one cell per
160
+ * clause. A clause starts where the parser began reading one, outside
161
+ * brackets; a quoted text starts one too, unless it is a key's value.
162
+ */
163
+ function statementCells(all, keywordCount, starts, settings) {
164
+ const result = [];
165
+ const keyword = all.slice(0, keywordCount);
166
+ result.push({ text: joinTokens(keyword), kind: 'keyword' });
167
+ const runs = clauseRuns(all.slice(keywordCount), starts);
168
+ const head = runs[0] && !isAttrKey(runs[0][0].token) && !starts.has(runs[0][0].token) ? runs.shift() : undefined;
169
+ if (head) {
170
+ if (all[0].token.text === 'edge' && settings['edge-name-and-ends'] === 'own-columns')
171
+ result.push(...edgeHeadCells(head));
172
+ else
173
+ result.push({ text: joinTokens(head), kind: 'head' });
174
+ }
175
+ for (const run of runs)
176
+ result.push({ text: joinTokens(run), kind: kindOf(run), ...(run[0].token.quoted ? { quoted: true } : {}) });
177
+ return result;
178
+ }
179
+ /** The cells of a continued line in a kept statement: no keyword, no head. */
180
+ function continuedCells(own, starts) {
181
+ return clauseRuns(own, starts).map((run) => ({ text: joinTokens(run), kind: kindOf(run) }));
182
+ }
183
+ /**
184
+ * A statement's clauses in the order `clause-order` lists their kinds, the
185
+ * kinds it leaves out after them as written; clauses of one kind keep their
186
+ * order. Quoted texts never move: the language takes them only straight after
187
+ * the name. A clause only moves where moving it can't change what the
188
+ * statement says, so one that would read differently reordered keeps its
189
+ * written order.
190
+ */
191
+ function orderCells(cells, settings) {
192
+ const order = settings['clause-order'];
193
+ if (order === 'as-written')
194
+ return cells;
195
+ const stays = (cell) => OPENING.has(cell.kind) || cell.quoted === true;
196
+ const opening = cells.filter(stays);
197
+ const rank = (cell) => (order.includes(cell.kind) ? order.indexOf(cell.kind) : order.length);
198
+ const rest = cells
199
+ .filter((cell) => !stays(cell))
200
+ .map((cell, at) => ({ cell, at }))
201
+ .sort((a, b) => rank(a.cell) - rank(b.cell) || a.at - b.at)
202
+ .map(({ cell }) => cell);
203
+ const ordered = [...opening, ...rest];
204
+ const line = (some) => some.map((cell) => cell.text).filter((text) => text !== '').join(' ');
205
+ try {
206
+ return sameMeaning(line(cells), line(ordered)) ? ordered : cells;
207
+ }
208
+ catch {
209
+ return cells;
210
+ }
211
+ }
212
+ /** Tokens grouped into clauses, by where clauses start outside brackets. */
213
+ function clauseRuns(tokens, starts) {
214
+ const runs = [];
215
+ let depth = 0;
216
+ let inKey = false;
217
+ for (const entry of tokens) {
218
+ const { token } = entry;
219
+ const opens = depth === 0 &&
220
+ token.text !== '(' &&
221
+ (starts.has(token) || (token.quoted && !inKey) || runs.length === 0);
222
+ if (opens) {
223
+ runs.push([entry]);
224
+ inKey = isAttrKey(token);
225
+ }
226
+ else {
227
+ runs[runs.length - 1].push(entry);
228
+ }
229
+ if (!token.quoted && token.text === '(')
230
+ depth += 1;
231
+ else if (!token.quoted && token.text === ')')
232
+ depth -= 1;
233
+ }
234
+ return runs;
235
+ }
236
+ /**
237
+ * An edge's name and ends as a cell each: the name if it has one, the first
238
+ * end, the arrow, the second end. An edge with no name gets an empty name cell,
239
+ * so its ends still line up with a named one's.
240
+ */
241
+ function edgeHeadCells(head) {
242
+ const arrowAt = head.findIndex(({ token }) => token.text !== '(' && looksLikeArrow(token));
243
+ if (arrowAt === -1)
244
+ return [{ text: joinTokens(head), kind: 'head' }];
245
+ const before = head.slice(0, arrowAt);
246
+ const named = before.length > 1 && !before[0].token.text.endsWith(',') && before[0].token.text !== 'each';
247
+ return [
248
+ { text: named ? joinTokens(before.slice(0, 1)) : '', kind: 'head' },
249
+ { text: joinTokens(named ? before.slice(1) : before), kind: 'head' },
250
+ { text: joinTokens(head.slice(arrowAt, arrowAt + 1)), kind: 'head' },
251
+ { text: joinTokens(head.slice(arrowAt + 1)), kind: 'head' },
252
+ ];
253
+ }
254
+ /**
255
+ * Tokens written back as one stretch of text, spaced the way the docs spell a
256
+ * clause however it was written: one space between words and after a key's
257
+ * colon, none just inside a bracket, and a lone comma against the word before
258
+ * it. `line:( corners:rounded , path:square )` comes out as
259
+ * `line: (corners: rounded, path: square)`.
260
+ */
261
+ function joinTokens(tokens) {
262
+ let text = '';
263
+ tokens.forEach(({ token, line }, index) => {
264
+ const previous = tokens[index - 1];
265
+ if (previous) {
266
+ const opens = !previous.token.quoted && previous.token.text === '(';
267
+ const closes = !token.quoted && (token.text === ')' || token.text === ',');
268
+ text += opens || closes ? '' : ' ';
269
+ }
270
+ const written = line.slice(token.start, token.end);
271
+ // A comma the lexer joined on from further along the line.
272
+ text += !token.quoted && token.text.endsWith(',') && !written.endsWith(',') ? `${written},` : written;
273
+ });
274
+ return text;
275
+ }
276
+ /** The width of a stretch of text in characters, counting each code point once. */
277
+ function textWidth(text) {
278
+ return [...text].length;
279
+ }
280
+ /** How wide a tab counts as, where a width has to be known. */
281
+ const TAB_WIDTH = 4;
282
+ /**
283
+ * Whether a statement goes past `wrap-at` or `clause-per-line-at`, and if so
284
+ * where its first line ends. Its length is measured on its own, unaligned, so
285
+ * whether one statement breaks never depends on its neighbors. The first line
286
+ * holds the keyword and name, and the texts after them if `first-line` says
287
+ * so, and shares columns with its neighbors like a one-line statement; the
288
+ * clauses after it are laid out by `continuedLines`.
289
+ */
290
+ function breakStatement(item, settings, gap) {
291
+ const texts = item.cells.map((cell) => cell.text);
292
+ const length = textWidth(texts.filter((text, at) => text !== '' || at === 0).join(gap));
293
+ const perLine = settings['clause-per-line-at'];
294
+ const wrap = settings['wrap-at'];
295
+ const oneEach = perLine === 'always' || (typeof perLine === 'number' && length > perLine);
296
+ if (!oneEach && !(typeof wrap === 'number' && length > wrap))
297
+ return item;
298
+ let firstEnd = item.cells.findIndex((cell) => !OPENING.has(cell.kind));
299
+ if (firstEnd === -1)
300
+ return item;
301
+ if (settings['first-line'] === 'text')
302
+ while (item.cells[firstEnd]?.quoted)
303
+ firstEnd += 1;
304
+ const rest = item.cells.slice(firstEnd).map((cell) => cell.text);
305
+ if (rest.length === 0)
306
+ return item;
307
+ return {
308
+ kind: 'broken',
309
+ keyword: item.keyword,
310
+ first: item.cells.slice(0, firstEnd),
311
+ rest,
312
+ oneEach,
313
+ ...(item.comment !== undefined ? { comment: item.comment } : {}),
314
+ };
315
+ }
316
+ /**
317
+ * A broken statement's lines after its first: one clause to a line, or as
318
+ * many as fit under `wrap-at`. A clause is never split, so one longer than
319
+ * the width stands alone on its line.
320
+ */
321
+ function continuedLines(item, indent, settings, gap, gapWidth) {
322
+ if (item.oneEach)
323
+ return item.rest.map((text) => indent + text);
324
+ const wrap = settings['wrap-at'];
325
+ const indentWidth = indent === '\t' ? TAB_WIDTH : indent.length;
326
+ const lines = [];
327
+ let line = '';
328
+ for (const text of item.rest) {
329
+ if (line !== '' && indentWidth + textWidth(line) + gapWidth + textWidth(text) > wrap) {
330
+ lines.push(indent + line);
331
+ line = '';
332
+ }
333
+ line = line === '' ? text : line + gap + text;
334
+ }
335
+ lines.push(indent + line);
336
+ return lines;
337
+ }
338
+ function layOutItems(written, settings) {
339
+ const gap = settings['clause-gap'] === 'tab' ? '\t' : ' '.repeat(settings['clause-gap']);
340
+ const gapWidth = settings['clause-gap'] === 'tab' ? 1 : settings['clause-gap'];
341
+ const items = written.map((item) => (item.kind === 'statement' ? breakStatement(item, settings, gap) : item));
342
+ // What lines up in columns: a one-line statement whole, a broken one's first line.
343
+ const aligned = (item) => item.kind === 'statement' ? item.cells : item.kind === 'broken' ? item.first : undefined;
344
+ // Which statements share columns: each is given a group number. A comment
345
+ // line is passed over, as if it weren't there: it labels what follows
346
+ // rather than separating it.
347
+ const group = new Map();
348
+ let current = -1;
349
+ let previous;
350
+ let lastStatement;
351
+ items.forEach((item, index) => {
352
+ if (item.kind === 'comment')
353
+ return;
354
+ if (item.kind !== 'statement' && item.kind !== 'broken') {
355
+ if (item.kind !== 'blank' || settings['columns-shared-by'] !== 'same-keyword') {
356
+ if (settings['columns-shared-by'] !== 'file')
357
+ lastStatement = undefined;
358
+ }
359
+ previous = item;
360
+ return;
361
+ }
362
+ const by = settings['columns-shared-by'];
363
+ const joins = lastStatement !== undefined &&
364
+ (by === 'file' ||
365
+ (by === 'neighbors' && previous === lastStatement) ||
366
+ (by === 'same-keyword-neighbors' && previous === lastStatement && lastStatement.keyword === item.keyword) ||
367
+ (by === 'same-keyword' && lastStatement.keyword === item.keyword));
368
+ if (!joins)
369
+ current += 1;
370
+ group.set(index, current);
371
+ lastStatement = item;
372
+ previous = item;
373
+ });
374
+ // Each group's column widths, and the column its trailing comments start at.
375
+ const widths = new Map();
376
+ const commentAt = new Map();
377
+ items.forEach((item, index) => {
378
+ const cells = aligned(item);
379
+ if (cells === undefined)
380
+ return;
381
+ const id = group.get(index);
382
+ const own = widths.get(id) ?? [];
383
+ cells.forEach((cell, at) => (own[at] = Math.max(own[at] ?? 0, textWidth(cell.text))));
384
+ widths.set(id, own);
385
+ });
386
+ // Each cell's text, and where on the line it starts.
387
+ const placed = (cells, id) => {
388
+ const own = widths.get(id);
389
+ const result = [];
390
+ let at = 0;
391
+ cells.forEach((cell, index) => {
392
+ // A column empty in every statement of the set, such as the name column
393
+ // of unnamed edges, takes no room; with columns off, neither does an
394
+ // empty cell.
395
+ if (settings.columns === 'off' ? cell.text === '' && index > 0 : own[index] === 0)
396
+ return;
397
+ result.push({ text: cell.text, at });
398
+ at += (settings.columns === 'off' ? textWidth(cell.text) : own[index]) + gapWidth;
399
+ });
400
+ return result;
401
+ };
402
+ const lineOf = (cells, id) => {
403
+ if (settings.columns === 'off')
404
+ return placed(cells, id).map((one) => one.text).join(gap);
405
+ let line = '';
406
+ for (const one of placed(cells, id))
407
+ line += ' '.repeat(one.at - textWidth(line)) + one.text;
408
+ return line;
409
+ };
410
+ items.forEach((item, index) => {
411
+ if (item.kind !== 'statement')
412
+ return;
413
+ const id = group.get(index);
414
+ commentAt.set(id, Math.max(commentAt.get(id) ?? 0, textWidth(lineOf(item.cells, id))));
415
+ });
416
+ // Where a broken statement's later lines start, measured on its first line
417
+ // as aligned, so `under-text` sits under the text where it was drawn.
418
+ const brokenIndent = (item, id) => {
419
+ const indent = settings['continued-lines-indent'];
420
+ if (indent === 'tab' || typeof indent === 'number')
421
+ return continuedIndent(item.first, settings, gapWidth);
422
+ const cells = placed(item.first, id);
423
+ const name = cells[1]?.at ?? textWidth(item.first[0].text) + gapWidth;
424
+ if (indent === 'under-name')
425
+ return ' '.repeat(name);
426
+ if (indent === 'under-text') {
427
+ const at = item.first.findIndex((cell) => cell.quoted);
428
+ const text = cells.find((one) => one.text === item.first[at]?.text);
429
+ return ' '.repeat(text?.at ?? name);
430
+ }
431
+ return ' '.repeat(name + indent.underName);
432
+ };
433
+ const out = [];
434
+ let blankRun = 0;
435
+ const flushBlanks = () => {
436
+ const blanks = settings['blank-lines'];
437
+ const count = blanks === 'as-written' ? blankRun : 'exactly' in blanks ? (blankRun > 0 ? blanks.exactly : 0) : Math.min(blankRun, blanks.atMost);
438
+ for (let i = 0; i < count; i++)
439
+ out.push('');
440
+ blankRun = 0;
441
+ };
442
+ items.forEach((item, index) => {
443
+ if (item.kind === 'blank') {
444
+ blankRun += 1;
445
+ return;
446
+ }
447
+ flushBlanks();
448
+ switch (item.kind) {
449
+ case 'comment':
450
+ out.push(item.text);
451
+ return;
452
+ case 'verbatim':
453
+ out.push(...item.lines);
454
+ return;
455
+ case 'broken': {
456
+ const id = group.get(index);
457
+ const lines = [lineOf(item.first, id), ...continuedLines(item, brokenIndent(item, id), settings, gap, gapWidth)];
458
+ lines.forEach((line, at) => {
459
+ const last = at === lines.length - 1;
460
+ out.push(last && item.comment !== undefined ? `${line}${gap}${item.comment}` : line);
461
+ });
462
+ return;
463
+ }
464
+ case 'kept': {
465
+ const indent = continuedIndent(item.first, settings, gapWidth);
466
+ item.lines.forEach((line, at) => {
467
+ const text = line.cells.map((cell) => cell.text).join(gap);
468
+ const body = line.comment === undefined ? text : text === '' ? line.comment : `${text}${gap}${line.comment}`;
469
+ out.push(at === 0 ? body : indent + body);
470
+ });
471
+ return;
472
+ }
473
+ case 'statement': {
474
+ const id = group.get(index);
475
+ const text = lineOf(item.cells, id);
476
+ if (item.comment === undefined)
477
+ out.push(text);
478
+ else if (settings['trailing-comments'] === 'shared-column') {
479
+ out.push(text + ' '.repeat(commentAt.get(id) - textWidth(text) + gapWidth) + item.comment);
480
+ }
481
+ else
482
+ out.push(text + gap + item.comment);
483
+ }
484
+ }
485
+ });
486
+ flushBlanks();
487
+ return out;
488
+ }
489
+ /**
490
+ * Where a statement's continued lines start, as `continued-lines-indent` says,
491
+ * measured on its first line's cells. `under-text` falls back to under the
492
+ * name for a statement with no text.
493
+ */
494
+ function continuedIndent(first, settings, gapWidth) {
495
+ const indent = settings['continued-lines-indent'];
496
+ if (indent === 'tab')
497
+ return '\t';
498
+ if (typeof indent === 'number')
499
+ return ' '.repeat(indent);
500
+ const name = textWidth(first[0].text) + gapWidth;
501
+ if (indent === 'under-name')
502
+ return ' '.repeat(name);
503
+ if (indent === 'under-text') {
504
+ const at = first.findIndex((cell) => cell.quoted);
505
+ if (at === -1)
506
+ return ' '.repeat(name);
507
+ const before = first.slice(0, at).filter((cell, index) => cell.text !== '' || index === 0);
508
+ return ' '.repeat(before.reduce((width, cell) => width + textWidth(cell.text) + gapWidth, 0));
509
+ }
510
+ return ' '.repeat(name + indent.underName);
511
+ }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ export * from './grammar.js';
6
6
  export * from './measure.js';
7
7
  export * from './model.js';
8
8
  export { parse } from './parser.js';
9
+ export { format } from './format.js';
10
+ export { DEFAULT_FORMAT, readFormatSettings, SETTINGS, SETTINGS_FILE, settingsFileText, type FormatSettings, } from './format-settings.js';
9
11
  export { resolve, type ResolveOptions } from './resolve.js';
10
12
  export { render, type RenderOptions } from './render.js';
11
13
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES, type Theme } from './themes.js';
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ export * from './grammar.js';
6
6
  export * from './measure.js';
7
7
  export * from './model.js';
8
8
  export { parse } from './parser.js';
9
+ export { format } from './format.js';
10
+ export { DEFAULT_FORMAT, readFormatSettings, SETTINGS, SETTINGS_FILE, settingsFileText, } from './format-settings.js';
9
11
  export { resolve } from './resolve.js';
10
12
  export { render } from './render.js';
11
13
  export { DARK_THEME, DEFAULT_THEME, THEMES, THEME_NAMES } from './themes.js';
package/dist/lexer.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface Token {
11
11
  * about the word can quote what the author typed.
12
12
  */
13
13
  joined?: string;
14
+ /**
15
+ * Where the token was written: from `start` up to `end` in the line given to
16
+ * `tokenizeLine`, quotes included. The formatter copies each token back out
17
+ * of its line by these, so nothing it writes is respelled. Absent on a token
18
+ * made up by the parser rather than read from a line.
19
+ */
20
+ start?: number;
21
+ end?: number;
14
22
  }
15
23
  /**
16
24
  * A `"""` string still open at the end of the text given. `parse` catches this
package/dist/lexer.js CHANGED
@@ -48,13 +48,13 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
48
48
  break;
49
49
  if (ch === '(') {
50
50
  depth += 1;
51
- tokens.push({ text: '(', quoted: false });
51
+ tokens.push({ text: '(', quoted: false, start: i, end: i + 1 });
52
52
  i += 1;
53
53
  continue;
54
54
  }
55
55
  if (ch === ')' && depth > 0) {
56
56
  depth -= 1;
57
- tokens.push({ text: ')', quoted: false });
57
+ tokens.push({ text: ')', quoted: false, start: i, end: i + 1 });
58
58
  i += 1;
59
59
  continue;
60
60
  }
@@ -65,11 +65,12 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
65
65
  const end = line.indexOf('"""', i + 3);
66
66
  if (end === -1)
67
67
  throw new OpenString('this """ string is never closed — end it with """', lineNumber);
68
- tokens.push({ text: line.slice(i + 3, end), quoted: true, triple: true });
68
+ tokens.push({ text: line.slice(i + 3, end), quoted: true, triple: true, start: i, end: end + 3 });
69
69
  i = end + 3;
70
70
  continue;
71
71
  }
72
72
  if (ch === '"') {
73
+ const start = i;
73
74
  let text = '';
74
75
  i += 1;
75
76
  let closed = false;
@@ -96,7 +97,7 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
96
97
  }
97
98
  if (!closed)
98
99
  throw new SourceError('unterminated string', lineNumber);
99
- tokens.push({ text, quoted: true });
100
+ tokens.push({ text, quoted: true, start, end: i });
100
101
  continue;
101
102
  }
102
103
  const start = i;
@@ -111,6 +112,11 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
111
112
  break;
112
113
  text += c;
113
114
  i += 1;
115
+ // A comma ends a word, so `rounded,path: square` reads as
116
+ // `rounded, path: square`. Not inside a word's own bracket, which keeps
117
+ // `rgb(20,20,20)` whole.
118
+ if (c === ',' && !text.includes('('))
119
+ break;
114
120
  }
115
121
  // `gap:tight` is `gap: tight` with no space, since how much whitespace there
116
122
  // is never matters. A word straight after a key is its value and is left
@@ -120,11 +126,18 @@ export function tokenizeLine(line, lineNumber, depth = 0) {
120
126
  const previous = tokens[tokens.length - 1];
121
127
  const joined = previous && isAttrKey(previous) ? null : JOINED_KEY.exec(text);
122
128
  if (joined) {
123
- tokens.push({ text: joined[1], quoted: false, joined: text });
129
+ tokens.push({ text: joined[1], quoted: false, joined: text, start, end: start + joined[1].length });
124
130
  i = start + joined[1].length;
125
131
  continue;
126
132
  }
127
- tokens.push({ text, quoted: false });
133
+ // A comma written apart from its word, `start , above`, is the comma of
134
+ // `start, above`. A bracket or a quoted text keeps its comma separate, so
135
+ // neither is changed by what follows it.
136
+ if (text === ',' && previous && !previous.quoted && !isAttrKey(previous) && !/^[()]$/.test(previous.text) && !previous.text.endsWith(',')) {
137
+ previous.text += ',';
138
+ continue;
139
+ }
140
+ tokens.push({ text, quoted: false, start, end: i });
128
141
  }
129
142
  return tokens;
130
143
  }
package/dist/lines.d.ts CHANGED
@@ -101,4 +101,5 @@ export declare function crossingPoints(a: Point[], b: Point[], clear: number): {
101
101
  along: Point;
102
102
  other: Point;
103
103
  }[];
104
+ /** An edge as written, its name first where it has one: `e2 b.1 -- b.2`, so two lines between the same boxes can be told apart. */
104
105
  export declare function describeEdge(edge: LayoutEdge): string;
package/dist/lines.js CHANGED
@@ -145,16 +145,20 @@ export function planSides(layout) {
145
145
  /**
146
146
  * Copies are lines with the same two boxes on the same sides, in either
147
147
  * direction, with nothing stated about their path (6.5): no waypoints, no
148
- * landing point, no place among a side's ends. Found by filing each line under
149
- * its boxes and sides, in one pass.
148
+ * landing point, no place among a side's ends, and no `step-out:` or `turn:`
149
+ * written on the line itself. Found by filing each line under its boxes and
150
+ * sides, in one pass.
150
151
  */
151
152
  function copySets(edges, ends) {
152
153
  const filed = new Map();
153
- const plain = (end) => end.spec.kind === 'side' && end.spec.after === undefined && !end.onLine && end.edge.onMark?.[end.which] !== 'point';
154
+ const listed = (end) => end.edge.name !== undefined && (end.node.attrs[`${end.spec.side}-edge-order`]?.split(', ').includes(end.edge.name) ?? false);
155
+ const plain = (end) => end.spec.kind === 'side' && end.spec.after === undefined && !listed(end) && !end.onLine && end.edge.onMark?.[end.which] !== 'point';
154
156
  const keyOf = (end) => `${end.node.name}\n${end.spec.side}`;
155
157
  for (const edge of edges) {
156
158
  if (edge.via !== undefined || edge.straight !== undefined)
157
159
  continue;
160
+ if (edge.look.stepOutWritten !== undefined || edge.look.turnWritten !== undefined)
161
+ continue;
158
162
  const [start, end] = ends.get(edge);
159
163
  if (!plain(start) || !plain(end))
160
164
  continue;
@@ -2007,8 +2011,10 @@ function intersect(a, b, c, d) {
2007
2011
  return undefined;
2008
2012
  return { x: a.x + r.x * t, y: a.y + r.y * t };
2009
2013
  }
2014
+ /** An edge as written, its name first where it has one: `e2 b.1 -- b.2`, so two lines between the same boxes can be told apart. */
2010
2015
  export function describeEdge(edge) {
2011
2016
  if (edge.bundle)
2012
2017
  return `bundle ${edge.name}`;
2013
- return `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
2018
+ const ends = `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
2019
+ return edge.name === undefined ? ends : `${edge.name} ${ends}`;
2014
2020
  }
package/dist/model.d.ts CHANGED
@@ -287,6 +287,8 @@ export interface LineLook {
287
287
  turnWritten: string | undefined;
288
288
  /** The short first and last leg of a square or curved line, in pixels. */
289
289
  stepOut: number;
290
+ /** `step-out:` as written on the edge itself; from a style or a default it is undefined. */
291
+ stepOutWritten: string | undefined;
290
292
  }
291
293
  export interface Layout {
292
294
  /** Every node, containers and children alike, in declaration order. */