@zxsylph/dbml-formatter 1.0.4 → 1.0.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxsylph/dbml-formatter",
3
- "version": "1.0.4",
3
+ "version": "1.0.10",
4
4
  "description": "",
5
5
  "files": [
6
6
  "bin",
package/src/cli.ts CHANGED
@@ -6,23 +6,92 @@ import { format } from './formatter/formatter';
6
6
  const args = process.argv.slice(2);
7
7
 
8
8
  if (args.length === 0) {
9
- console.error('Usage: dbml-fmt <file>');
9
+ console.error('Usage: dbml-formatter <file> | --folder <path> [--dry-run]');
10
10
  process.exit(1);
11
11
  }
12
12
 
13
- const filePath = args[0];
14
- const absPath = path.resolve(process.cwd(), filePath);
13
+ const folderIndex = args.indexOf('--folder');
14
+ const dryRunIndex = args.indexOf('--dry-run');
15
+ const orderFieldIndex = args.indexOf('--order-field');
16
+ const isDryRun = dryRunIndex !== -1;
17
+ const orderField = orderFieldIndex !== -1;
15
18
 
16
- if (!fs.existsSync(absPath)) {
17
- console.error(`File not found: ${absPath}`);
18
- process.exit(1);
19
- }
19
+ if (folderIndex !== -1) {
20
+ // Folder mode
21
+ const folderPath = args[folderIndex + 1];
22
+ if (!folderPath || folderPath.startsWith('--')) {
23
+ console.error('Error: --folder requires a path argument');
24
+ process.exit(1);
25
+ }
20
26
 
21
- try {
22
- const content = fs.readFileSync(absPath, 'utf-8');
23
- const formatted = format(content);
24
- console.log(formatted);
25
- } catch (error) {
26
- console.error('Error formatting file:', error);
27
- process.exit(1);
27
+ const absFolderPath = path.resolve(process.cwd(), folderPath);
28
+ if (!fs.existsSync(absFolderPath) || !fs.statSync(absFolderPath).isDirectory()) {
29
+ console.error(`Directory not found: ${absFolderPath}`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const getDbmlFiles = (dir: string): string[] => {
34
+ let results: string[] = [];
35
+ const list = fs.readdirSync(dir);
36
+ list.forEach(file => {
37
+ const filePath = path.join(dir, file);
38
+ const stat = fs.statSync(filePath);
39
+ if (stat && stat.isDirectory()) {
40
+ results = results.concat(getDbmlFiles(filePath));
41
+ } else {
42
+ if (file.endsWith('.dbml')) {
43
+ results.push(filePath);
44
+ }
45
+ }
46
+ });
47
+ return results;
48
+ };
49
+
50
+ const files = getDbmlFiles(absFolderPath);
51
+
52
+ if (files.length === 0) {
53
+ console.log(`No .dbml files found in ${absFolderPath}`);
54
+ } else {
55
+ files.forEach(file => {
56
+ try {
57
+ const content = fs.readFileSync(file, 'utf-8');
58
+ const formatted = format(content, { orderField, addNote: args.includes('--add-note') });
59
+
60
+ if (isDryRun) {
61
+ console.log(`\n--- Dry Run: ${file} ---`);
62
+ console.log(formatted);
63
+ } else {
64
+ fs.writeFileSync(file, formatted, 'utf-8');
65
+ console.log(`Formatted: ${file}`);
66
+ }
67
+ } catch (err) {
68
+ console.error(`Error formatting ${file}:`, err);
69
+ }
70
+ });
71
+ }
72
+
73
+ } else {
74
+ // Single file mode
75
+ const filePath = args[0];
76
+
77
+ if (filePath.startsWith('--')) {
78
+ console.error('Usage: dbml-formatter <file> | --folder <path> [--dry-run]');
79
+ process.exit(1);
80
+ }
81
+
82
+ const absPath = path.resolve(process.cwd(), filePath);
83
+
84
+ if (!fs.existsSync(absPath)) {
85
+ console.error(`File not found: ${absPath}`);
86
+ process.exit(1);
87
+ }
88
+
89
+ try {
90
+ const content = fs.readFileSync(absPath, 'utf-8');
91
+ const formatted = format(content, { orderField, addNote: args.includes('--add-note') });
92
+ console.log(formatted);
93
+ } catch (error) {
94
+ console.error('Error formatting file:', error);
95
+ process.exit(1);
96
+ }
28
97
  }
@@ -3,6 +3,8 @@ import { Token, TokenType, tokenize } from './tokenizer';
3
3
  export interface FormatterOptions {
4
4
  indentSize?: number;
5
5
  useTabs?: boolean;
6
+ orderField?: boolean;
7
+ addNote?: boolean;
6
8
  }
7
9
 
8
10
  export function format(input: string, options: FormatterOptions = {}): string {
@@ -143,6 +145,21 @@ export function format(input: string, options: FormatterOptions = {}): string {
143
145
  otherLinesGroups.push(currentGroup);
144
146
  }
145
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
+ }
146
163
 
147
164
  // 4. Print Table Note first (if exists)
148
165
  if (tableNoteTokens.length > 0) {
@@ -161,9 +178,98 @@ export function format(input: string, options: FormatterOptions = {}): string {
161
178
  } else {
162
179
  output += '\n\n';
163
180
  }
181
+ } else {
182
+ // NEW: If no table note, add empty Note: ""
183
+ if (options.addNote) {
184
+ output += getIndent() + 'Note: ""\n\n';
185
+ }
186
+ }
187
+
188
+ // OPTIONAL: Sort Fields within groups
189
+ if (options.orderField) {
190
+ // 1. Normalize groups: Detach "Gap" (extra newlines) from content lines.
191
+ // If a line ends with > 1 newline, split it into [ContentLine] + [EmptyLine]s.
192
+
193
+ const normalized: Token[][] = [];
194
+
195
+ for (const line of otherLinesGroups) {
196
+ const last = line[line.length - 1];
197
+ let hasExtraNewline = false;
198
+
199
+ if (last && last.type === TokenType.Whitespace) {
200
+ const newlineCount = (last.value.match(/\n/g) || []).length;
201
+ if (newlineCount > 1) {
202
+ hasExtraNewline = true;
203
+
204
+ // Create stripped line (1 newline)
205
+ const newLineTokens = [...line];
206
+ newLineTokens[newLineTokens.length - 1] = {
207
+ ...last,
208
+ value: last.value.replace(/\n+/g, '\n')
209
+ };
210
+ normalized.push(newLineTokens);
211
+
212
+ // Add spacer lines
213
+ for(let k=1; k < newlineCount; k++) {
214
+ normalized.push([{ type: TokenType.Whitespace, value: '\n', line: 0, column: 0 }]);
215
+ }
216
+ }
217
+ }
218
+
219
+ if (!hasExtraNewline) {
220
+ normalized.push(line);
221
+ }
222
+ }
223
+
224
+ // Replace otherLinesGroups with normalized version
225
+ otherLinesGroups.splice(0, otherLinesGroups.length, ...normalized);
226
+
227
+ // 2. Group lines by "is content" and Sort
228
+
229
+ // Helper to check if line is content
230
+ const isContentLine = (line: Token[]) => {
231
+ const m = line.filter(x => x.type !== TokenType.Whitespace && x.type !== TokenType.Comment);
232
+ return m.length > 0;
233
+ };
234
+
235
+ let i = 0;
236
+ while(i < otherLinesGroups.length) {
237
+ if (isContentLine(otherLinesGroups[i])) {
238
+ // Start of a block
239
+ let j = i + 1;
240
+ while(j < otherLinesGroups.length && isContentLine(otherLinesGroups[j])) {
241
+ j++;
242
+ }
243
+ // Range [i, j) is a content block to sort
244
+ const block = otherLinesGroups.slice(i, j);
245
+ // Sort block
246
+ block.sort((a, b) => {
247
+ const getFirstWord = (toks: Token[]) => {
248
+ const t = toks.find(x => x.type === TokenType.Word || x.type === TokenType.String);
249
+ return t ? t.value.replace(/^"|"$/g, '').toLowerCase() : '';
250
+ };
251
+ const wa = getFirstWord(a);
252
+ const wb = getFirstWord(b);
253
+ if (wa < wb) return -1;
254
+ if (wa > wb) return 1;
255
+ return 0;
256
+ });
257
+
258
+ // Put back
259
+ for(let k=0; k<block.length; k++) {
260
+ otherLinesGroups[i+k] = block[k];
261
+ }
262
+
263
+ i = j;
264
+ } else {
265
+ i++;
266
+ }
267
+ }
164
268
  }
165
269
 
166
- // 5. Print other lines (Process Fields)
270
+ // 5. Process Fields (Transform -> Align -> Print)
271
+
272
+ // 5a. Transformation Pass
167
273
  for (let lgIdx = 0; lgIdx < otherLinesGroups.length; lgIdx++) {
168
274
  const lineTokens = otherLinesGroups[lgIdx];
169
275
  // Check for Field Settings `[...]` reordering
@@ -221,7 +327,85 @@ export function format(input: string, options: FormatterOptions = {}): string {
221
327
  }
222
328
  }
223
329
 
224
- // 6. Apply "Quote Data Types" logic
330
+ // Apply "Empty Field Note" logic
331
+ const meaningful = lineTokens.filter(t => t.type !== TokenType.Whitespace && t.type !== TokenType.Comment);
332
+ // Heuristic: Is this a field?
333
+ // It should have at least 2 tokens (Name, Type).
334
+ // It should NOT be 'indexes'.
335
+ // It should NOT contain '{' (which would imply a block start like indexes { )
336
+
337
+ let isField = false;
338
+ if (meaningful.length >= 2) {
339
+ const firstWord = meaningful[0].value.toLowerCase();
340
+ if (firstWord !== 'indexes' && firstWord !== 'note') {
341
+ // Check for braces in original lineTokens to avoid sub-blocks
342
+ const hasBrace = lineTokens.some(t => t.type === TokenType.Symbol && t.value === '{');
343
+ if (!hasBrace) {
344
+ isField = true;
345
+ }
346
+ }
347
+ }
348
+
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
+ }
407
+
408
+ // Apply "Quote Data Types" logic
225
409
  let wordCount = 0;
226
410
  for (const t of lineTokens) {
227
411
  // Only count words before `[`?
@@ -231,39 +415,148 @@ export function format(input: string, options: FormatterOptions = {}): string {
231
415
  if (wordCount === 2) {
232
416
  // Quote this token!
233
417
  t.value = `"${t.value}"`;
234
- // Note: we are modifying the token object directly in the buffer.
235
418
  }
236
419
  }
237
420
  if (t.type === TokenType.String && wordCount < 2) {
238
- // Strings count as words/tokens for position?
239
- // Example `name "varchar"` -> "varchar" IS the string.
240
421
  wordCount++;
241
422
  }
242
423
  }
424
+ }
243
425
 
244
- // Ensure previous line enforced newline if missing?
245
- // processTokens appends tokens. If tokens lack newline, it might merge?
246
- // `lineTokens` usually comes from `currentGroup` which ended with newline token (except last one).
247
- // If last group lacks newline, and we print next group...
248
-
249
- // Check if output buffer needs separation?
250
- // processTokens logic respects local newlines inside `lineTokens`.
251
- // But if `lineTokens` (last group) didn't have newline, we append.
252
-
253
- output += processTokens(lineTokens, indentLevel, indentChar, indentSize, true);
254
-
255
- // Heuristic: If we just printed a line group, and it didn't generate a newline at end,
256
- // AND there is another group coming, insert newline?
257
- // 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);
258
554
 
259
- // Let's check `output`.
260
- if (lgIdx < otherLinesGroups.length - 1) {
261
- if (!output.endsWith('\n')) {
262
- // This implies the group didn't end with newline token.
263
- // Force it.
264
- output += '\n';
265
- }
266
- }
555
+ if (lgIdx < otherLinesGroups.length - 1) {
556
+ if (!output.endsWith('\n')) {
557
+ output += '\n';
558
+ }
559
+ }
267
560
  }
268
561
 
269
562
  // End block
@@ -429,12 +722,15 @@ function processTokens(
429
722
  ): string {
430
723
 
431
724
  let localOutput = '';
432
- let currentIndentLevel = baseIndentLevel;
433
725
  const oneIndent = indentChar.repeat(indentSize);
434
- const getLocalIndent = () => oneIndent.repeat(Math.max(0, currentIndentLevel));
435
726
 
436
- // ... multiline stack and checkArrayMultiline ...
437
- 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 => {
438
734
  let depth = 1;
439
735
  let hasComma = false;
440
736
  for (let k = startIdx + 1; k < tokens.length; k++) {
@@ -467,10 +763,10 @@ function processTokens(
467
763
  continue;
468
764
  }
469
765
 
470
- // Corrected Spacing/Indent Logic for processTokens:
766
+ // Spacing/Indent Logic
471
767
  if (localOutput.length === 0 || localOutput.endsWith('\n')) {
472
768
  if (token.value !== '}') {
473
- localOutput += getLocalIndent();
769
+ localOutput += getCurrentIndent();
474
770
  }
475
771
  } else {
476
772
  // Not start of line
@@ -491,27 +787,69 @@ function processTokens(
491
787
  case TokenType.Symbol:
492
788
  if (token.value === '{') {
493
789
  localOutput += '{\n';
494
- currentIndentLevel++;
790
+ indentStack.push(getCurrentIndent() + oneIndent);
495
791
  } else if (token.value === '}') {
496
792
  if (!localOutput.endsWith('\n')) localOutput += '\n';
497
- currentIndentLevel--;
498
- localOutput += getLocalIndent() + '}';
793
+ if (indentStack.length > 1) indentStack.pop();
794
+ localOutput += getCurrentIndent() + '}';
499
795
  } else if (token.value === '[') {
500
796
  const isMultiline = checkArrayMultiline(i);
501
797
  multilineArrayStack.push(isMultiline);
798
+
799
+ const preBracketLength = localOutput.length;
800
+ const lastNewline = localOutput.lastIndexOf('\n');
801
+
502
802
  localOutput += '[';
803
+
503
804
  if (isMultiline) {
504
- localOutput += '\n';
505
- 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';
506
819
  }
507
820
  } else if (token.value === ']') {
508
- const isMultiline = multilineArrayStack.pop();
509
- if (isMultiline) {
510
- if (!localOutput.endsWith('\n')) localOutput += '\n';
511
- currentIndentLevel--;
512
- if (localOutput.endsWith('\n')) localOutput += getLocalIndent();
513
- }
514
- 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
+ }
515
853
  } else if (token.value === ',') {
516
854
  localOutput += ',';
517
855
  const currentMultiline = multilineArrayStack.length > 0 && multilineArrayStack[multilineArrayStack.length - 1];
@@ -522,21 +860,21 @@ function processTokens(
522
860
  break;
523
861
 
524
862
  case TokenType.Word:
525
- // Handle keyword PascalCase in buffer
526
- if (token.value.toLowerCase() === 'table') token.value = 'Table';
527
- if (token.value.toLowerCase() === 'ref') token.value = 'Ref';
528
- if (token.value.toLowerCase() === 'note') {
529
- // Peek locally inside tokens list
530
- let nextIdx = i + 1;
531
- while(nextIdx < tokens.length && (tokens[nextIdx].type === TokenType.Whitespace || tokens[nextIdx].type === TokenType.Comment)) {
532
- nextIdx++;
533
- }
534
- if (nextIdx < tokens.length && tokens[nextIdx].type === TokenType.Symbol && tokens[nextIdx].value === ':') {
535
- token.value = 'Note';
536
- }
537
- }
538
- localOutput += token.value;
539
- 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;
540
878
 
541
879
  case TokenType.String:
542
880
  let val = token.value;
@@ -549,8 +887,12 @@ function processTokens(
549
887
  break;
550
888
 
551
889
  default:
552
- localOutput += token.value;
553
- break;
890
+ localOutput += token.value;
891
+ break;
892
+ }
893
+
894
+ if (token.padRight) {
895
+ localOutput += ' '.repeat(token.padRight);
554
896
  }
555
897
  }
556
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[] {
@@ -0,0 +1,34 @@
1
+ import { format } from './formatter/formatter';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+
5
+ const filePath = path.join(__dirname, '../test_folder/repro_empty_note_field.dbml');
6
+ const content = fs.readFileSync(filePath, 'utf-8');
7
+
8
+ const formatted = format(content);
9
+
10
+ console.log('--- Formatted Output ---');
11
+ console.log(formatted);
12
+
13
+ // Robust check:
14
+ // We just check if the output contains `Note: ""` or `note: ""` for each field name.
15
+ // Since the output might be multiline, we can't just filter lines.
16
+ // We scan the text.
17
+
18
+ const requiredFields = ['id', 'username', 'email'];
19
+ let allPresent = true;
20
+
21
+ // Just check globally if we have 3 empty notes + the table note?
22
+ // Table note is also `Note: ""`
23
+ // So we expect 4 `Note: ""` or `note: ""`
24
+ const matches = formatted.match(/Note:\s*""/gi);
25
+ const count = matches ? matches.length : 0;
26
+
27
+ console.log(`Found ${count} empty notes.`);
28
+
29
+ if (count >= 4) { // 1 for table + 3 for fields
30
+ console.log('SUCCESS: Empty notes added to fields.');
31
+ } else {
32
+ console.error('FAILURE: Empty notes NOT added to fields (or not enough).');
33
+ process.exit(1);
34
+ }
@@ -0,0 +1,18 @@
1
+ import { format } from './formatter/formatter';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+
5
+ const filePath = path.join(__dirname, '../test_folder/repro_empty_note.dbml');
6
+ const content = fs.readFileSync(filePath, 'utf-8');
7
+
8
+ const formatted = format(content);
9
+
10
+ console.log('--- Formatted Output ---');
11
+ console.log(formatted);
12
+
13
+ if (formatted.includes('Note: ""')) {
14
+ console.log('SUCCESS: Empty note added.');
15
+ } else {
16
+ console.error('FAILURE: Empty note NOT added.');
17
+ process.exit(1);
18
+ }