@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 +1 -1
- package/src/cli.ts +83 -14
- package/src/formatter/formatter.ts +404 -62
- package/src/formatter/tokenizer.ts +1 -0
- package/src/repro_field_issue.ts +34 -0
- package/src/repro_issue.ts +18 -0
package/package.json
CHANGED
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-
|
|
9
|
+
console.error('Usage: dbml-formatter <file> | --folder <path> [--dry-run]');
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
const
|
|
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 (
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
//
|
|
437
|
-
|
|
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
|
-
//
|
|
766
|
+
// Spacing/Indent Logic
|
|
471
767
|
if (localOutput.length === 0 || localOutput.endsWith('\n')) {
|
|
472
768
|
if (token.value !== '}') {
|
|
473
|
-
localOutput +=
|
|
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
|
-
|
|
790
|
+
indentStack.push(getCurrentIndent() + oneIndent);
|
|
495
791
|
} else if (token.value === '}') {
|
|
496
792
|
if (!localOutput.endsWith('\n')) localOutput += '\n';
|
|
497
|
-
|
|
498
|
-
localOutput +=
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
553
|
-
|
|
890
|
+
localOutput += token.value;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (token.padRight) {
|
|
895
|
+
localOutput += ' '.repeat(token.padRight);
|
|
554
896
|
}
|
|
555
897
|
}
|
|
556
898
|
|
|
@@ -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
|
+
}
|