@zxsylph/dbml-formatter 1.0.8 → 1.0.11

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 ADDED
@@ -0,0 +1,94 @@
1
+ # @zxsylph/dbml-formatter
2
+
3
+ A powerful and flexible formatter for [DBML (Database Markup Language)](https://dbml.dbdiagram.io/home/) files. This tool helps keep your DBML schemas clean, consistent, and readable by applying standard formatting rules, alignment, and optional enhancements.
4
+
5
+ ## Features
6
+
7
+ - **Standard Formatting**: Applies consistent spacing and indentation to tables, fields, indexes, and relationships.
8
+ - **Alignment**:
9
+ - Automatically aligns field data types for better readability.
10
+ - Aligns field settings (e.g., `[pk, increment]`) for a clean columnar look.
11
+ - **Field Sorting** (Optional): Sorts fields within groups alphabetically.
12
+ - **Note Management** (Optional): Automatically adds empty notes to fields and tables if they are missing.
13
+ - **Batch Processing**: Supports formatting individual files or entire directories recursively.
14
+ - **Dry Run**: Preview changes without modifying files.
15
+
16
+ ## Installation
17
+
18
+ You can use the formatter directly via `npx` without installation:
19
+
20
+ ```bash
21
+ npx @zxsylph/dbml-formatter <file-or-options>
22
+ ```
23
+
24
+ Or install it globally:
25
+
26
+ ```bash
27
+ npm install -g @zxsylph/dbml-formatter
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ### Format a Single File
33
+
34
+ Prints the formatted result to `stdout`.
35
+
36
+ ```bash
37
+ npx @zxsylph/dbml-formatter schema.dbml
38
+ ```
39
+
40
+ ### Format a Directory
41
+
42
+ Formats all `.dbml` files in the specified directory (and subdirectories) **in place**.
43
+
44
+ ```bash
45
+ npx @zxsylph/dbml-formatter --folder ./database
46
+ ```
47
+
48
+ ### Options & Flags
49
+
50
+ | Flag | Description |
51
+ | ----------------- | ---------------------------------------------------------------------------------------------- |
52
+ | `--folder <path>` | Specifies a directory to recursively find and format `.dbml` files. Updates files in place. |
53
+ | `--dry-run` | Use with `--folder` to preview formatting changes in the console instead of overwriting files. |
54
+ | `--order-field` | Sorts fields within their logical groups (separated by blank lines) in ascending order. |
55
+ | `--add-note` | Automatically adds `Note: ''` to tables and fields that don't have one. |
56
+
57
+ ### Examples
58
+
59
+ **1. Basic file formatting (output to console):**
60
+
61
+ ```bash
62
+ npx @zxsylph/dbml-formatter schema.dbml > formatted_schema.dbml
63
+ ```
64
+
65
+ **2. Format an entire project directory:**
66
+
67
+ ```bash
68
+ npx @zxsylph/dbml-formatter --folder ./src/db
69
+ ```
70
+
71
+ **3. Format with field sorting and extra notes:**
72
+
73
+ ```bash
74
+ npx @zxsylph/dbml-formatter --folder ./src/db --order-field --add-note
75
+ ```
76
+
77
+ **4. Preview changes for a folder:**
78
+
79
+ ```bash
80
+ npx @zxsylph/dbml-formatter --folder ./src/db --dry-run
81
+ ```
82
+
83
+ ## Formatting Rules
84
+
85
+ The formatter applies the following styles:
86
+
87
+ - **Indentation**: 2 spaces.
88
+ - **Spacing**: Removes extra empty lines inside table bodies.
89
+ - **Alignment**: Vertically aligns data types and column settings `[...]`.
90
+ - **Hoisting**: Moves table-level notes to the top of the table definition.
91
+
92
+ ## License
93
+
94
+ ISC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxsylph/dbml-formatter",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "description": "",
5
5
  "files": [
6
6
  "bin",
package/src/cli.ts CHANGED
@@ -55,7 +55,7 @@ if (folderIndex !== -1) {
55
55
  files.forEach(file => {
56
56
  try {
57
57
  const content = fs.readFileSync(file, 'utf-8');
58
- const formatted = format(content, { orderField });
58
+ const formatted = format(content, { orderField, addNote: args.includes('--add-note') });
59
59
 
60
60
  if (isDryRun) {
61
61
  console.log(`\n--- Dry Run: ${file} ---`);
@@ -88,7 +88,7 @@ if (folderIndex !== -1) {
88
88
 
89
89
  try {
90
90
  const content = fs.readFileSync(absPath, 'utf-8');
91
- const formatted = format(content, { orderField });
91
+ const formatted = format(content, { orderField, addNote: args.includes('--add-note') });
92
92
  console.log(formatted);
93
93
  } catch (error) {
94
94
  console.error('Error formatting file:', error);
@@ -4,6 +4,7 @@ export interface FormatterOptions {
4
4
  indentSize?: number;
5
5
  useTabs?: boolean;
6
6
  orderField?: boolean;
7
+ addNote?: boolean;
7
8
  }
8
9
 
9
10
  export function format(input: string, options: FormatterOptions = {}): string {
@@ -144,6 +145,21 @@ export function format(input: string, options: FormatterOptions = {}): string {
144
145
  otherLinesGroups.push(currentGroup);
145
146
  }
146
147
  }
148
+
149
+ // NEW: Trim leading and trailing empty lines (whitespace-only groups) from otherLinesGroups
150
+ // Note: we do not touch tableNoteTokens here.
151
+
152
+ const isWhitespaceGroup = (g: Token[]) => g.every(t => t.type === TokenType.Whitespace);
153
+
154
+ // Trim start
155
+ while (otherLinesGroups.length > 0 && isWhitespaceGroup(otherLinesGroups[0])) {
156
+ otherLinesGroups.shift();
157
+ }
158
+
159
+ // Trim end
160
+ while (otherLinesGroups.length > 0 && isWhitespaceGroup(otherLinesGroups[otherLinesGroups.length - 1])) {
161
+ otherLinesGroups.pop();
162
+ }
147
163
 
148
164
  // 4. Print Table Note first (if exists)
149
165
  if (tableNoteTokens.length > 0) {
@@ -164,7 +180,9 @@ export function format(input: string, options: FormatterOptions = {}): string {
164
180
  }
165
181
  } else {
166
182
  // NEW: If no table note, add empty Note: ""
167
- output += getIndent() + 'Note: ""\n\n';
183
+ if (options.addNote) {
184
+ output += getIndent() + 'Note: ""\n\n';
185
+ }
168
186
  }
169
187
 
170
188
  // OPTIONAL: Sort Fields within groups
@@ -249,7 +267,9 @@ export function format(input: string, options: FormatterOptions = {}): string {
249
267
  }
250
268
  }
251
269
 
252
- // 5. Print other lines (Process Fields)
270
+ // 5. Process Fields (Transform -> Align -> Print)
271
+
272
+ // 5a. Transformation Pass
253
273
  for (let lgIdx = 0; lgIdx < otherLinesGroups.length; lgIdx++) {
254
274
  const lineTokens = otherLinesGroups[lgIdx];
255
275
  // Check for Field Settings `[...]` reordering
@@ -307,7 +327,7 @@ export function format(input: string, options: FormatterOptions = {}): string {
307
327
  }
308
328
  }
309
329
 
310
- // 6. Apply "Empty Field Note" logic
330
+ // Apply "Empty Field Note" logic
311
331
  const meaningful = lineTokens.filter(t => t.type !== TokenType.Whitespace && t.type !== TokenType.Comment);
312
332
  // Heuristic: Is this a field?
313
333
  // It should have at least 2 tokens (Name, Type).
@@ -326,69 +346,66 @@ export function format(input: string, options: FormatterOptions = {}): string {
326
346
  }
327
347
  }
328
348
 
329
- if (isField) {
330
- // Find settings block
331
- let openBracketIdx = -1;
332
- let closeBracketIdx = -1;
333
- for(let idx=0; idx<lineTokens.length; idx++) {
334
- if (lineTokens[idx].type === TokenType.Symbol && lineTokens[idx].value === '[') openBracketIdx = idx;
335
- if (lineTokens[idx].type === TokenType.Symbol && lineTokens[idx].value === ']') closeBracketIdx = idx;
336
- }
337
-
338
- if (openBracketIdx !== -1 && closeBracketIdx !== -1 && closeBracketIdx > openBracketIdx) {
339
- // Settings exist. Check if 'note' is present.
340
- const inside = lineTokens.slice(openBracketIdx + 1, closeBracketIdx);
341
- let hasNote = false;
342
-
343
- // Simple token scan for 'note' word
344
- // Ideally we should parse comma groups, but 'note' keyword is reserved in settings.
345
- for (const t of inside) {
346
- if (t.type === TokenType.Word && t.value.toLowerCase() === 'note') {
347
- hasNote = true;
348
- break;
349
- }
350
- }
351
-
352
- if (!hasNote) {
353
- // Insert `note: ""` at the beginning of settings
354
- // We need to insert: "note", ":", "\"\"", ","
355
- // REMOVED explicit spaces
356
- const newTokens: Token[] = [
357
- { type: TokenType.Word, value: 'note', line: 0, column: 0 },
358
- { type: TokenType.Symbol, value: ':', line: 0, column: 0 },
359
- { type: TokenType.String, value: '""', line: 0, column: 0 },
360
- { type: TokenType.Symbol, value: ',', line: 0, column: 0 }
361
- ];
362
- lineTokens.splice(openBracketIdx + 1, 0, ...newTokens);
363
- }
364
- } else {
365
- // No settings exist. Append ` [note: ""]`.
366
- // REMOVED explicit spaces
367
-
368
- // Find last meaningful token index
369
- let lastMeaningfulIdx = -1;
370
- for (let idx = lineTokens.length - 1; idx >= 0; idx--) {
371
- if (lineTokens[idx].type !== TokenType.Whitespace && lineTokens[idx].type !== TokenType.Comment) {
372
- lastMeaningfulIdx = idx;
373
- break;
374
- }
375
- }
376
-
377
- if (lastMeaningfulIdx !== -1) {
378
- const appendTokens: Token[] = [
379
- { type: TokenType.Symbol, value: '[', line: 0, column: 0 },
380
- { type: TokenType.Word, value: 'note', line: 0, column: 0 },
381
- { type: TokenType.Symbol, value: ':', line: 0, column: 0 },
382
- { type: TokenType.String, value: '""', line: 0, column: 0 },
383
- { type: TokenType.Symbol, value: ']', line: 0, column: 0 }
384
- ];
385
- lineTokens.splice(lastMeaningfulIdx + 1, 0, ...appendTokens);
386
- }
387
- }
388
- }
389
-
390
- // 7. Apply "Quote Data Types" logic
349
+ if (isField && options.addNote) {
350
+ // Find settings block
351
+ let openBracketIdx = -1;
352
+ let closeBracketIdx = -1;
353
+ for(let idx=0; idx<lineTokens.length; idx++) {
354
+ if (lineTokens[idx].type === TokenType.Symbol && lineTokens[idx].value === '[') openBracketIdx = idx;
355
+ if (lineTokens[idx].type === TokenType.Symbol && lineTokens[idx].value === ']') closeBracketIdx = idx;
356
+ }
357
+
358
+ if (openBracketIdx !== -1 && closeBracketIdx !== -1 && closeBracketIdx > openBracketIdx) {
359
+ // Settings exist. Check if 'note' is present.
360
+ const inside = lineTokens.slice(openBracketIdx + 1, closeBracketIdx);
361
+ let hasNote = false;
362
+
363
+ // Simple token scan for 'note' word
364
+ // Ideally we should parse comma groups, but 'note' keyword is reserved in settings.
365
+ for (const t of inside) {
366
+ if (t.type === TokenType.Word && t.value.toLowerCase() === 'note') {
367
+ hasNote = true;
368
+ break;
369
+ }
370
+ }
371
+
372
+ if (!hasNote) {
373
+ // Insert `note: ""` at the beginning of settings
374
+ // We need to insert: "note", ":", "\"\"", ","
375
+ const newTokens: Token[] = [
376
+ { type: TokenType.Word, value: 'note', line: 0, column: 0 },
377
+ { type: TokenType.Symbol, value: ':', line: 0, column: 0 },
378
+ { type: TokenType.String, value: '""', line: 0, column: 0 },
379
+ { type: TokenType.Symbol, value: ',', line: 0, column: 0 }
380
+ ];
381
+ lineTokens.splice(openBracketIdx + 1, 0, ...newTokens);
382
+ }
383
+ } else {
384
+ // No settings exist. Append ` [note: ""]`.
385
+
386
+ // Find last meaningful token index
387
+ let lastMeaningfulIdx = -1;
388
+ for (let idx = lineTokens.length - 1; idx >= 0; idx--) {
389
+ if (lineTokens[idx].type !== TokenType.Whitespace && lineTokens[idx].type !== TokenType.Comment) {
390
+ lastMeaningfulIdx = idx;
391
+ break;
392
+ }
393
+ }
394
+
395
+ if (lastMeaningfulIdx !== -1) {
396
+ const appendTokens: Token[] = [
397
+ { type: TokenType.Symbol, value: '[', line: 0, column: 0 },
398
+ { type: TokenType.Word, value: 'note', line: 0, column: 0 },
399
+ { type: TokenType.Symbol, value: ':', line: 0, column: 0 },
400
+ { type: TokenType.String, value: '""', line: 0, column: 0 },
401
+ { type: TokenType.Symbol, value: ']', line: 0, column: 0 }
402
+ ];
403
+ lineTokens.splice(lastMeaningfulIdx + 1, 0, ...appendTokens);
404
+ }
405
+ }
406
+ }
391
407
 
408
+ // Apply "Quote Data Types" logic
392
409
  let wordCount = 0;
393
410
  for (const t of lineTokens) {
394
411
  // Only count words before `[`?
@@ -398,39 +415,148 @@ export function format(input: string, options: FormatterOptions = {}): string {
398
415
  if (wordCount === 2) {
399
416
  // Quote this token!
400
417
  t.value = `"${t.value}"`;
401
- // Note: we are modifying the token object directly in the buffer.
402
418
  }
403
419
  }
404
420
  if (t.type === TokenType.String && wordCount < 2) {
405
- // Strings count as words/tokens for position?
406
- // Example `name "varchar"` -> "varchar" IS the string.
407
421
  wordCount++;
408
422
  }
409
423
  }
424
+ }
410
425
 
411
- // Ensure previous line enforced newline if missing?
412
- // processTokens appends tokens. If tokens lack newline, it might merge?
413
- // `lineTokens` usually comes from `currentGroup` which ended with newline token (except last one).
414
- // If last group lacks newline, and we print next group...
415
-
416
- // Check if output buffer needs separation?
417
- // processTokens logic respects local newlines inside `lineTokens`.
418
- // But if `lineTokens` (last group) didn't have newline, we append.
419
-
420
- output += processTokens(lineTokens, indentLevel, indentChar, indentSize, true);
421
-
422
- // Heuristic: If we just printed a line group, and it didn't generate a newline at end,
423
- // AND there is another group coming, insert newline?
424
- // But `processTokens` output might end with proper indent? No.
426
+ // 5b. Alignment Pass
427
+ const isFieldLine = (tokens: Token[]) => {
428
+ const m = tokens.filter(t => t.type !== TokenType.Whitespace && t.type !== TokenType.Comment);
429
+ if (m.length < 2) return false;
430
+ const first = m[0].value.toLowerCase();
431
+ if (first === 'indexes' || first === 'note') return false;
432
+ if (tokens.some(t => t.type === TokenType.Symbol && t.value === '{')) return false;
433
+ return true;
434
+ };
435
+
436
+ const alignFieldBlock = (blockLines: Token[][]) => {
437
+ // 1. Collect info suitable for alignment
438
+ interface RowInfo {
439
+ lineTokens: Token[];
440
+ nameTokenIdx: number;
441
+ typeStartIdx: number;
442
+ typeEndIdx: number; // exclusive
443
+ settingsStartIdx: number;
444
+
445
+ nameWidth: number;
446
+ typeWidth: number;
447
+ }
448
+
449
+ const rows: RowInfo[] = [];
450
+
451
+ for (const line of blockLines) {
452
+ // Find Name Token (First meaningful)
453
+ let nameIdx = -1;
454
+ for(let k=0; k<line.length; k++) {
455
+ if (line[k].type !== TokenType.Whitespace && line[k].type !== TokenType.Comment) {
456
+ nameIdx = k;
457
+ break;
458
+ }
459
+ }
460
+ if (nameIdx === -1) continue;
461
+
462
+ // Find Settings Start `[`
463
+ let settingsIdx = -1;
464
+ for(let k=0; k<line.length; k++) {
465
+ if (line[k].type === TokenType.Symbol && line[k].value === '[') {
466
+ settingsIdx = k;
467
+ break;
468
+ }
469
+ }
470
+
471
+ // Determine Type Range
472
+ // Type is between Name and Settings (or End of Line)
473
+ let typeStart = nameIdx + 1;
474
+ while(typeStart < line.length && (line[typeStart].type === TokenType.Whitespace || line[typeStart].type === TokenType.Comment)) {
475
+ typeStart++;
476
+ }
477
+
478
+ let typeEnd = settingsIdx;
479
+ if (typeEnd === -1) {
480
+ // No settings, type ends at the last meaningful token of the line
481
+ let lastMeaningful = -1;
482
+ for(let k=line.length-1; k >= 0; k--) {
483
+ if (line[k].type !== TokenType.Whitespace && line[k].type !== TokenType.Comment) {
484
+ lastMeaningful = k;
485
+ break;
486
+ }
487
+ }
488
+
489
+ if (lastMeaningful > nameIdx) {
490
+ typeEnd = lastMeaningful + 1; // exclusive
491
+ } else {
492
+ continue;
493
+ }
494
+ }
495
+
496
+ if (typeStart >= typeEnd) continue;
497
+
498
+ // Calculate Widths
499
+ const nameWidth = line[nameIdx].value.length;
500
+
501
+ // Dry run type width
502
+ const typeTokens = line.slice(typeStart, typeEnd);
503
+ const typeStr = processTokens(typeTokens, 0, ' ', 2, false);
504
+ const typeWidth = typeStr.length;
505
+
506
+ rows.push({
507
+ lineTokens: line,
508
+ nameTokenIdx: nameIdx,
509
+ typeStartIdx: typeStart,
510
+ typeEndIdx: typeEnd,
511
+ settingsStartIdx: settingsIdx,
512
+ nameWidth,
513
+ typeWidth
514
+ });
515
+ }
516
+
517
+ if (rows.length === 0) return;
518
+
519
+ // 2. Calc Max Widths
520
+ const maxNameWidth = Math.max(...rows.map(r => r.nameWidth));
521
+ const maxTypeWidth = Math.max(...rows.map(r => r.typeWidth));
522
+
523
+ // 3. Apply Padding
524
+ for (const row of rows) {
525
+ // Pad Name
526
+ const namePad = (maxNameWidth - row.nameWidth) + 1; // +1 for minimum space
527
+ const nameTok = row.lineTokens[row.nameTokenIdx];
528
+ nameTok.padRight = namePad;
529
+
530
+ // Pad Type (Last token of type sequence)
531
+ // Only needed if there is something after (settings)
532
+ if (row.settingsStartIdx !== -1) {
533
+ const typePad = (maxTypeWidth - row.typeWidth) + 1;
534
+ // Find the last meaningful token of Type sequence
535
+ let lastTypeTokIdx = row.typeEndIdx - 1;
536
+ while(lastTypeTokIdx >= row.typeStartIdx && (row.lineTokens[lastTypeTokIdx].type === TokenType.Whitespace || row.lineTokens[lastTypeTokIdx].type === TokenType.Comment)) {
537
+ lastTypeTokIdx--;
538
+ }
539
+
540
+ if (lastTypeTokIdx >= row.typeStartIdx) {
541
+ row.lineTokens[lastTypeTokIdx].padRight = typePad;
542
+ }
543
+ }
544
+ }
545
+ };
546
+
547
+ // Align globally across all lines in the table (ignoring inter-field grouping)
548
+ alignFieldBlock(otherLinesGroups);
549
+
550
+ // 5c. Print Pass
551
+ for (let lgIdx = 0; lgIdx < otherLinesGroups.length; lgIdx++) {
552
+ const lineTokens = otherLinesGroups[lgIdx];
553
+ output += processTokens(lineTokens, indentLevel, indentChar, indentSize, true);
425
554
 
426
- // Let's check `output`.
427
- if (lgIdx < otherLinesGroups.length - 1) {
428
- if (!output.endsWith('\n')) {
429
- // This implies the group didn't end with newline token.
430
- // Force it.
431
- output += '\n';
432
- }
433
- }
555
+ if (lgIdx < otherLinesGroups.length - 1) {
556
+ if (!output.endsWith('\n')) {
557
+ output += '\n';
558
+ }
559
+ }
434
560
  }
435
561
 
436
562
  // End block
@@ -596,12 +722,15 @@ function processTokens(
596
722
  ): string {
597
723
 
598
724
  let localOutput = '';
599
- let currentIndentLevel = baseIndentLevel;
600
725
  const oneIndent = indentChar.repeat(indentSize);
601
- const getLocalIndent = () => oneIndent.repeat(Math.max(0, currentIndentLevel));
602
726
 
603
- // ... multiline stack and checkArrayMultiline ...
604
- const checkArrayMultiline = (startIdx: number): boolean => {
727
+ // Stack of indentation strings. Start with base indent.
728
+ const indentStack: string[] = [oneIndent.repeat(Math.max(0, baseIndentLevel))];
729
+
730
+ // Helper to get current indentation
731
+ const getCurrentIndent = () => indentStack[indentStack.length - 1];
732
+
733
+ const checkArrayMultiline = (startIdx: number): boolean => {
605
734
  let depth = 1;
606
735
  let hasComma = false;
607
736
  for (let k = startIdx + 1; k < tokens.length; k++) {
@@ -634,10 +763,10 @@ function processTokens(
634
763
  continue;
635
764
  }
636
765
 
637
- // Corrected Spacing/Indent Logic for processTokens:
766
+ // Spacing/Indent Logic
638
767
  if (localOutput.length === 0 || localOutput.endsWith('\n')) {
639
768
  if (token.value !== '}') {
640
- localOutput += getLocalIndent();
769
+ localOutput += getCurrentIndent();
641
770
  }
642
771
  } else {
643
772
  // Not start of line
@@ -658,27 +787,69 @@ function processTokens(
658
787
  case TokenType.Symbol:
659
788
  if (token.value === '{') {
660
789
  localOutput += '{\n';
661
- currentIndentLevel++;
790
+ indentStack.push(getCurrentIndent() + oneIndent);
662
791
  } else if (token.value === '}') {
663
792
  if (!localOutput.endsWith('\n')) localOutput += '\n';
664
- currentIndentLevel--;
665
- localOutput += getLocalIndent() + '}';
793
+ if (indentStack.length > 1) indentStack.pop();
794
+ localOutput += getCurrentIndent() + '}';
666
795
  } else if (token.value === '[') {
667
796
  const isMultiline = checkArrayMultiline(i);
668
797
  multilineArrayStack.push(isMultiline);
798
+
799
+ const preBracketLength = localOutput.length;
800
+ const lastNewline = localOutput.lastIndexOf('\n');
801
+
669
802
  localOutput += '[';
803
+
670
804
  if (isMultiline) {
671
- localOutput += '\n';
672
- currentIndentLevel++;
805
+ // Calculate visual column for Anchor
806
+ let visualColOfBracket = 0;
807
+ for(let c=lastNewline + 1; c < preBracketLength; c++) {
808
+ if (localOutput[c] === '\t') visualColOfBracket += (indentSize - (visualColOfBracket % indentSize));
809
+ else visualColOfBracket += 1;
810
+ }
811
+
812
+ const anchorIndent = ' '.repeat(visualColOfBracket);
813
+ // Push Anchor first (for the closing bracket)
814
+ indentStack.push(anchorIndent);
815
+ // Push Anchor + Indent (for the content)
816
+ indentStack.push(anchorIndent + oneIndent);
817
+
818
+ localOutput += '\n';
673
819
  }
674
820
  } else if (token.value === ']') {
675
- const isMultiline = multilineArrayStack.pop();
676
- if (isMultiline) {
677
- if (!localOutput.endsWith('\n')) localOutput += '\n';
678
- currentIndentLevel--;
679
- if (localOutput.endsWith('\n')) localOutput += getLocalIndent();
680
- }
681
- localOutput += ']';
821
+ const isMultiline = multilineArrayStack.pop();
822
+ if (isMultiline) {
823
+ // We are currently at Content Indent level (top of stack).
824
+ // But for the closing bracket, we want to go back to Anchor Indent level.
825
+
826
+ // First, remove Content Indent from stack
827
+ indentStack.pop();
828
+
829
+ // Now top of stack is Anchor Indent.
830
+ const anchorIndent = getCurrentIndent();
831
+
832
+ // Check what's currently on the last line.
833
+ // The main loop might have already added indentation (Content Indent).
834
+ const lastNewline = localOutput.lastIndexOf('\n');
835
+ const lastLineFormatted = localOutput.substring(lastNewline + 1);
836
+
837
+ if (lastLineFormatted.trim().length === 0) {
838
+ // usage-case: The line is empty or just contains the indentation added by the loop.
839
+ // We should replace that indentation with our Anchor Indent.
840
+ localOutput = localOutput.substring(0, lastNewline + 1) + anchorIndent;
841
+ } else {
842
+ // There is content on this line. We need a new line.
843
+ localOutput += '\n' + anchorIndent;
844
+ }
845
+
846
+ localOutput += ']';
847
+
848
+ // Finally, pop the Anchor Indent so we return to previous base indent
849
+ indentStack.pop();
850
+ } else {
851
+ localOutput += ']';
852
+ }
682
853
  } else if (token.value === ',') {
683
854
  localOutput += ',';
684
855
  const currentMultiline = multilineArrayStack.length > 0 && multilineArrayStack[multilineArrayStack.length - 1];
@@ -689,21 +860,21 @@ function processTokens(
689
860
  break;
690
861
 
691
862
  case TokenType.Word:
692
- // Handle keyword PascalCase in buffer
693
- if (token.value.toLowerCase() === 'table') token.value = 'Table';
694
- if (token.value.toLowerCase() === 'ref') token.value = 'Ref';
695
- if (token.value.toLowerCase() === 'note') {
696
- // Peek locally inside tokens list
697
- let nextIdx = i + 1;
698
- while(nextIdx < tokens.length && (tokens[nextIdx].type === TokenType.Whitespace || tokens[nextIdx].type === TokenType.Comment)) {
699
- nextIdx++;
700
- }
701
- if (nextIdx < tokens.length && tokens[nextIdx].type === TokenType.Symbol && tokens[nextIdx].value === ':') {
702
- token.value = 'Note';
703
- }
704
- }
705
- localOutput += token.value;
706
- break;
863
+ // Handle keyword PascalCase
864
+ if (token.value.toLowerCase() === 'table') token.value = 'Table';
865
+ if (token.value.toLowerCase() === 'ref') token.value = 'Ref';
866
+ if (token.value.toLowerCase() === 'note') {
867
+ // Peek locally inside tokens list
868
+ let nextIdx = i + 1;
869
+ while(nextIdx < tokens.length && (tokens[nextIdx].type === TokenType.Whitespace || tokens[nextIdx].type === TokenType.Comment)) {
870
+ nextIdx++;
871
+ }
872
+ if (nextIdx < tokens.length && tokens[nextIdx].type === TokenType.Symbol && tokens[nextIdx].value === ':') {
873
+ token.value = 'Note';
874
+ }
875
+ }
876
+ localOutput += token.value;
877
+ break;
707
878
 
708
879
  case TokenType.String:
709
880
  let val = token.value;
@@ -716,8 +887,12 @@ function processTokens(
716
887
  break;
717
888
 
718
889
  default:
719
- localOutput += token.value;
720
- break;
890
+ localOutput += token.value;
891
+ break;
892
+ }
893
+
894
+ if (token.padRight) {
895
+ localOutput += ' '.repeat(token.padRight);
721
896
  }
722
897
  }
723
898
 
@@ -12,6 +12,7 @@ export interface Token {
12
12
  value: string;
13
13
  line: number;
14
14
  column: number;
15
+ padRight?: number;
15
16
  }
16
17
 
17
18
  export function tokenize(input: string): Token[] {