reladraw 0.17.1 → 0.19.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 +2 -2
- package/SYNTAX.md +115 -12
- package/dist/checks.js +30 -9
- package/dist/cli.js +188 -1
- package/dist/format-settings.d.ts +60 -0
- package/dist/format-settings.js +250 -0
- package/dist/format.d.ts +14 -0
- package/dist/format.js +511 -0
- package/dist/icons.d.ts +13 -13
- package/dist/icons.js +61 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lexer.d.ts +8 -0
- package/dist/lexer.js +19 -6
- package/dist/lines.d.ts +8 -0
- package/dist/lines.js +50 -4
- package/dist/outline.d.ts +123 -0
- package/dist/outline.js +564 -0
- package/dist/parser.d.ts +25 -1
- package/dist/parser.js +95 -10
- package/dist/place.d.ts +5 -0
- package/dist/place.js +44 -14
- package/dist/render.js +43 -18
- package/dist/resolve.js +43 -3
- package/package.json +1 -1
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/icons.d.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* which caps the useful vocabulary at something that fits in a head. Add a name
|
|
19
19
|
* when a diagram asks for a distinction it cannot otherwise make.
|
|
20
20
|
*/
|
|
21
|
+
import { type ShapeLook } from './outline.js';
|
|
21
22
|
/**
|
|
22
23
|
* Icons carry three tones rather than colors. `ink` is the drawn line, `shade`
|
|
23
24
|
* the body it encloses, and `void` is the page showing through — the same
|
|
@@ -57,23 +58,21 @@ export declare const ICON_NAMES: string[];
|
|
|
57
58
|
* The outlines a node's body can take. `rectangle` is the plain one and needs no
|
|
58
59
|
* word, since it is what a node is when it says nothing.
|
|
59
60
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
61
|
+
* A shape geometry has a word for is named by that word, how it looks:
|
|
62
|
+
* `diamond`, never `decision`, since someone may just want a diamond, and
|
|
63
|
+
* reading `decision` as a diamond is a translation every reader has to make.
|
|
64
|
+
* Those take settings in a bracket for what the box doesn't decide. A
|
|
65
|
+
* flowchart shape geometry has no word for is named `flow-` and what it means,
|
|
66
|
+
* drawn the way the standard draws it, with no settings. No picture has two
|
|
67
|
+
* names. `document`, the folded corner, is older than the rule and keeps its
|
|
68
|
+
* name; the standard's wavy one is `flow-document`.
|
|
66
69
|
*
|
|
67
|
-
*
|
|
68
|
-
* meaning to name it after — it is a state, a step, a person, whatever the
|
|
69
|
-
* diagram's own convention makes it. `diamond` joins these when a diagram
|
|
70
|
-
* asks. The set being short is a fact about what has been drawn, not about the
|
|
71
|
-
* key.
|
|
70
|
+
* The geometry of each is in `outline.ts`.
|
|
72
71
|
*/
|
|
73
|
-
export declare const OUTLINES: readonly ["rectangle", "document", "circle"];
|
|
72
|
+
export declare const OUTLINES: readonly ["rectangle", "document", "circle", "diamond", "pill", "parallelogram", "hexagon", "trapezoid", "triangle", "cylinder", "flow-subprocess", "flow-document", "flow-off-page", "flow-delay", "flow-manual-input", "flow-stored-data", "flow-loop-start", "flow-loop-end", "flow-card", "flow-display", "flow-tape", "flow-punched-tape", "flow-collate"];
|
|
74
73
|
export type Outline = (typeof OUTLINES)[number];
|
|
75
74
|
/** Every value `shape:` accepts, `none` included. */
|
|
76
|
-
export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "none"];
|
|
75
|
+
export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "diamond", "pill", "parallelogram", "hexagon", "trapezoid", "triangle", "cylinder", "flow-subprocess", "flow-document", "flow-off-page", "flow-delay", "flow-manual-input", "flow-stored-data", "flow-loop-start", "flow-loop-end", "flow-card", "flow-display", "flow-tape", "flow-punched-tape", "flow-collate", "none"];
|
|
77
76
|
/**
|
|
78
77
|
* What a node is drawn as: an outline, a picture, or nothing at all.
|
|
79
78
|
*
|
|
@@ -88,6 +87,7 @@ export declare const SHAPE_WORDS: readonly ["rectangle", "document", "circle", "
|
|
|
88
87
|
export type Body = {
|
|
89
88
|
kind: 'shape';
|
|
90
89
|
outline: Outline;
|
|
90
|
+
look: ShapeLook;
|
|
91
91
|
} | {
|
|
92
92
|
kind: 'icon';
|
|
93
93
|
icon: Icon;
|
package/dist/icons.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* when a diagram asks for a distinction it cannot otherwise make.
|
|
20
20
|
*/
|
|
21
21
|
import { SourceError } from './errors.js';
|
|
22
|
+
import { readShapeLook } from './outline.js';
|
|
22
23
|
const GRID = 24;
|
|
23
24
|
/** Line width on the 24-unit grid, scaled with everything else. */
|
|
24
25
|
export const ICON_STROKE = 1.1;
|
|
@@ -119,23 +120,45 @@ export const ICON_NAMES = Object.keys(ICONS);
|
|
|
119
120
|
* The outlines a node's body can take. `rectangle` is the plain one and needs no
|
|
120
121
|
* word, since it is what a node is when it says nothing.
|
|
121
122
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
123
|
+
* A shape geometry has a word for is named by that word, how it looks:
|
|
124
|
+
* `diamond`, never `decision`, since someone may just want a diamond, and
|
|
125
|
+
* reading `decision` as a diamond is a translation every reader has to make.
|
|
126
|
+
* Those take settings in a bracket for what the box doesn't decide. A
|
|
127
|
+
* flowchart shape geometry has no word for is named `flow-` and what it means,
|
|
128
|
+
* drawn the way the standard draws it, with no settings. No picture has two
|
|
129
|
+
* names. `document`, the folded corner, is older than the rule and keeps its
|
|
130
|
+
* name; the standard's wavy one is `flow-document`.
|
|
128
131
|
*
|
|
129
|
-
*
|
|
130
|
-
* meaning to name it after — it is a state, a step, a person, whatever the
|
|
131
|
-
* diagram's own convention makes it. `diamond` joins these when a diagram
|
|
132
|
-
* asks. The set being short is a fact about what has been drawn, not about the
|
|
133
|
-
* key.
|
|
132
|
+
* The geometry of each is in `outline.ts`.
|
|
134
133
|
*/
|
|
135
|
-
export const OUTLINES = [
|
|
134
|
+
export const OUTLINES = [
|
|
135
|
+
'rectangle',
|
|
136
|
+
'document',
|
|
137
|
+
'circle',
|
|
138
|
+
'diamond',
|
|
139
|
+
'pill',
|
|
140
|
+
'parallelogram',
|
|
141
|
+
'hexagon',
|
|
142
|
+
'trapezoid',
|
|
143
|
+
'triangle',
|
|
144
|
+
'cylinder',
|
|
145
|
+
'flow-subprocess',
|
|
146
|
+
'flow-document',
|
|
147
|
+
'flow-off-page',
|
|
148
|
+
'flow-delay',
|
|
149
|
+
'flow-manual-input',
|
|
150
|
+
'flow-stored-data',
|
|
151
|
+
'flow-loop-start',
|
|
152
|
+
'flow-loop-end',
|
|
153
|
+
'flow-card',
|
|
154
|
+
'flow-display',
|
|
155
|
+
'flow-tape',
|
|
156
|
+
'flow-punched-tape',
|
|
157
|
+
'flow-collate',
|
|
158
|
+
];
|
|
136
159
|
/** Every value `shape:` accepts, `none` included. */
|
|
137
160
|
export const SHAPE_WORDS = [...OUTLINES, 'none'];
|
|
138
|
-
const PLAIN = { kind: 'shape', outline: 'rectangle' };
|
|
161
|
+
const PLAIN = { kind: 'shape', outline: 'rectangle', look: {} };
|
|
139
162
|
/**
|
|
140
163
|
* Which body a node has, from what it wrote and what its style carried.
|
|
141
164
|
*
|
|
@@ -158,14 +181,37 @@ export function bodyFor(attrs, appearance, line, declared = new Map()) {
|
|
|
158
181
|
throw new SourceError(`shape: ${shape} and icon: ${icon} both say what this node is drawn as, and it has one ` +
|
|
159
182
|
'body — `shape:` is the outline it is drawn with, `icon:` is the picture it is drawn as', line);
|
|
160
183
|
}
|
|
184
|
+
// The settings come from wherever the shape did: a node's own `shape:` takes
|
|
185
|
+
// its own bracket, never a style's.
|
|
186
|
+
const from = wroteShape ? attrs : appearance;
|
|
187
|
+
const settings = {};
|
|
188
|
+
for (const [key, value] of Object.entries(from))
|
|
189
|
+
if (key.startsWith('shape.'))
|
|
190
|
+
settings[key.slice('shape.'.length)] = value;
|
|
161
191
|
if (icon !== undefined)
|
|
162
192
|
return { kind: 'icon', icon: iconNamed(icon, line, declared) };
|
|
163
193
|
if (shape === undefined)
|
|
164
194
|
return PLAIN;
|
|
165
|
-
if (shape === 'none')
|
|
195
|
+
if (shape === 'none') {
|
|
196
|
+
if (Object.keys(settings).length > 0)
|
|
197
|
+
refuse(`\`shape: none\` draws no outline, so it takes no settings`, line);
|
|
166
198
|
return { kind: 'none' };
|
|
199
|
+
}
|
|
167
200
|
if (OUTLINES.includes(shape)) {
|
|
168
|
-
return { kind: 'shape', outline: shape };
|
|
201
|
+
return { kind: 'shape', outline: shape, look: readShapeLook(shape, settings, `shape: ${shape}`, line) };
|
|
202
|
+
}
|
|
203
|
+
if (shape === 'hourglass') {
|
|
204
|
+
return refuse('`shape: hourglass` is the flowchart\'s collate symbol, and it is `flow-collate`', line);
|
|
205
|
+
}
|
|
206
|
+
if (shape === 'pentagon' || shape === 'pointed' || shape === 'home-plate') {
|
|
207
|
+
return refuse(`there is no shape "${shape}" — the flowchart's off-page connector, a box with one end pointed, is \`flow-off-page\``, line);
|
|
208
|
+
}
|
|
209
|
+
if (shape === 'ellipse' || shape === 'oval') {
|
|
210
|
+
return refuse(`there is no shape "${shape}" — an ellipse is a circle given a width and a height: \`shape: circle width: 20 height: 8\``, line);
|
|
211
|
+
}
|
|
212
|
+
if (shape === 'decision' || shape === 'terminal' || shape === 'process') {
|
|
213
|
+
const look = { decision: 'diamond', terminal: 'pill', process: 'rectangle' }[shape];
|
|
214
|
+
return refuse(`a shape is named for how it looks, so a ${shape} is \`shape: ${look}\``, line);
|
|
169
215
|
}
|
|
170
216
|
// Until 0.3.0 `shape:` also took an icon name and drew the node as that
|
|
171
217
|
// picture. The two jobs are two parts and now have two keys, so an older file
|