@zxsylph/dbml-formatter 1.0.4 → 1.0.8
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 +168 -1
- 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 });
|
|
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 });
|
|
92
|
+
console.log(formatted);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('Error formatting file:', error);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
28
97
|
}
|
|
@@ -3,6 +3,7 @@ import { Token, TokenType, tokenize } from './tokenizer';
|
|
|
3
3
|
export interface FormatterOptions {
|
|
4
4
|
indentSize?: number;
|
|
5
5
|
useTabs?: boolean;
|
|
6
|
+
orderField?: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function format(input: string, options: FormatterOptions = {}): string {
|
|
@@ -161,6 +162,91 @@ export function format(input: string, options: FormatterOptions = {}): string {
|
|
|
161
162
|
} else {
|
|
162
163
|
output += '\n\n';
|
|
163
164
|
}
|
|
165
|
+
} else {
|
|
166
|
+
// NEW: If no table note, add empty Note: ""
|
|
167
|
+
output += getIndent() + 'Note: ""\n\n';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// OPTIONAL: Sort Fields within groups
|
|
171
|
+
if (options.orderField) {
|
|
172
|
+
// 1. Normalize groups: Detach "Gap" (extra newlines) from content lines.
|
|
173
|
+
// If a line ends with > 1 newline, split it into [ContentLine] + [EmptyLine]s.
|
|
174
|
+
|
|
175
|
+
const normalized: Token[][] = [];
|
|
176
|
+
|
|
177
|
+
for (const line of otherLinesGroups) {
|
|
178
|
+
const last = line[line.length - 1];
|
|
179
|
+
let hasExtraNewline = false;
|
|
180
|
+
|
|
181
|
+
if (last && last.type === TokenType.Whitespace) {
|
|
182
|
+
const newlineCount = (last.value.match(/\n/g) || []).length;
|
|
183
|
+
if (newlineCount > 1) {
|
|
184
|
+
hasExtraNewline = true;
|
|
185
|
+
|
|
186
|
+
// Create stripped line (1 newline)
|
|
187
|
+
const newLineTokens = [...line];
|
|
188
|
+
newLineTokens[newLineTokens.length - 1] = {
|
|
189
|
+
...last,
|
|
190
|
+
value: last.value.replace(/\n+/g, '\n')
|
|
191
|
+
};
|
|
192
|
+
normalized.push(newLineTokens);
|
|
193
|
+
|
|
194
|
+
// Add spacer lines
|
|
195
|
+
for(let k=1; k < newlineCount; k++) {
|
|
196
|
+
normalized.push([{ type: TokenType.Whitespace, value: '\n', line: 0, column: 0 }]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!hasExtraNewline) {
|
|
202
|
+
normalized.push(line);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Replace otherLinesGroups with normalized version
|
|
207
|
+
otherLinesGroups.splice(0, otherLinesGroups.length, ...normalized);
|
|
208
|
+
|
|
209
|
+
// 2. Group lines by "is content" and Sort
|
|
210
|
+
|
|
211
|
+
// Helper to check if line is content
|
|
212
|
+
const isContentLine = (line: Token[]) => {
|
|
213
|
+
const m = line.filter(x => x.type !== TokenType.Whitespace && x.type !== TokenType.Comment);
|
|
214
|
+
return m.length > 0;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
let i = 0;
|
|
218
|
+
while(i < otherLinesGroups.length) {
|
|
219
|
+
if (isContentLine(otherLinesGroups[i])) {
|
|
220
|
+
// Start of a block
|
|
221
|
+
let j = i + 1;
|
|
222
|
+
while(j < otherLinesGroups.length && isContentLine(otherLinesGroups[j])) {
|
|
223
|
+
j++;
|
|
224
|
+
}
|
|
225
|
+
// Range [i, j) is a content block to sort
|
|
226
|
+
const block = otherLinesGroups.slice(i, j);
|
|
227
|
+
// Sort block
|
|
228
|
+
block.sort((a, b) => {
|
|
229
|
+
const getFirstWord = (toks: Token[]) => {
|
|
230
|
+
const t = toks.find(x => x.type === TokenType.Word || x.type === TokenType.String);
|
|
231
|
+
return t ? t.value.replace(/^"|"$/g, '').toLowerCase() : '';
|
|
232
|
+
};
|
|
233
|
+
const wa = getFirstWord(a);
|
|
234
|
+
const wb = getFirstWord(b);
|
|
235
|
+
if (wa < wb) return -1;
|
|
236
|
+
if (wa > wb) return 1;
|
|
237
|
+
return 0;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Put back
|
|
241
|
+
for(let k=0; k<block.length; k++) {
|
|
242
|
+
otherLinesGroups[i+k] = block[k];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
i = j;
|
|
246
|
+
} else {
|
|
247
|
+
i++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
164
250
|
}
|
|
165
251
|
|
|
166
252
|
// 5. Print other lines (Process Fields)
|
|
@@ -221,7 +307,88 @@ export function format(input: string, options: FormatterOptions = {}): string {
|
|
|
221
307
|
}
|
|
222
308
|
}
|
|
223
309
|
|
|
224
|
-
// 6. Apply "
|
|
310
|
+
// 6. Apply "Empty Field Note" logic
|
|
311
|
+
const meaningful = lineTokens.filter(t => t.type !== TokenType.Whitespace && t.type !== TokenType.Comment);
|
|
312
|
+
// Heuristic: Is this a field?
|
|
313
|
+
// It should have at least 2 tokens (Name, Type).
|
|
314
|
+
// It should NOT be 'indexes'.
|
|
315
|
+
// It should NOT contain '{' (which would imply a block start like indexes { )
|
|
316
|
+
|
|
317
|
+
let isField = false;
|
|
318
|
+
if (meaningful.length >= 2) {
|
|
319
|
+
const firstWord = meaningful[0].value.toLowerCase();
|
|
320
|
+
if (firstWord !== 'indexes' && firstWord !== 'note') {
|
|
321
|
+
// Check for braces in original lineTokens to avoid sub-blocks
|
|
322
|
+
const hasBrace = lineTokens.some(t => t.type === TokenType.Symbol && t.value === '{');
|
|
323
|
+
if (!hasBrace) {
|
|
324
|
+
isField = true;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
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
|
|
391
|
+
|
|
225
392
|
let wordCount = 0;
|
|
226
393
|
for (const t of lineTokens) {
|
|
227
394
|
// Only count words before `[`?
|
|
@@ -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
|
+
}
|