reladraw 0.18.0 → 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/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/parser.d.ts CHANGED
@@ -1,5 +1,27 @@
1
1
  import type { Document } from './ast.js';
2
2
  import { SourceError } from './errors.js';
3
+ import { type Token } from './lexer.js';
4
+ /**
5
+ * One statement as `parse` gathered it, before reading: the line it starts on,
6
+ * and each of its lines with that line's tokens. A comment-only line inside it
7
+ * has no tokens and is not listed.
8
+ */
9
+ export interface Gathered {
10
+ line: number;
11
+ lines: {
12
+ line: number;
13
+ tokens: Token[];
14
+ }[];
15
+ }
16
+ /**
17
+ * Parse a source for the formatter: each statement as gathered, and the first
18
+ * token of each clause read. Throws as `parse` does, so a file that does not
19
+ * parse is never rewritten.
20
+ */
21
+ export declare function clausesOf(source: string): {
22
+ statements: Gathered[];
23
+ starts: Set<Token>;
24
+ };
3
25
  /**
4
26
  * Parse a whole source file. A statement starts on an unindented line and runs
5
27
  * on through any line that starts with whitespace; a blank line or the next
@@ -15,7 +37,7 @@ import { SourceError } from './errors.js';
15
37
  * are its own, blank or not, and a line opening with one continues the
16
38
  * statement above without being indented.
17
39
  */
18
- export declare function parse(source: string): Document;
40
+ export declare function parse(source: string, seen?: (statement: Gathered) => void): Document;
19
41
  /**
20
42
  * Find the line at fault for an error reported on the first line of a
21
43
  * continued statement. Everything after the parser reports a statement by the
@@ -30,3 +52,5 @@ export declare function parse(source: string): Document;
30
52
  * This costs a few extra runs, and only when there is an error to report.
31
53
  */
32
54
  export declare function blame(source: string, error: SourceError, run: (source: string) => unknown): SourceError;
55
+ /** Whether a token is an arrow, or begins one: a bracketed mark, or a run of dashes with marks at its ends. */
56
+ export declare function looksLikeArrow(token: Token): boolean;