@timeax/scaffold 0.0.3 → 0.0.4
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/.vscode/settings.json +12 -0
- package/dist/ast.cjs +438 -0
- package/dist/ast.cjs.map +1 -0
- package/dist/ast.d.cts +152 -0
- package/dist/ast.d.ts +152 -0
- package/dist/ast.mjs +433 -0
- package/dist/ast.mjs.map +1 -0
- package/dist/index.cjs +11 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +11 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -4
- package/readme.md +215 -110
- package/src/ast/format.ts +261 -0
- package/src/ast/index.ts +2 -0
- package/src/ast/parser.ts +593 -0
- package/src/core/structure-txt.ts +196 -222
- package/test/format-roundtrip.spec.ts +20 -0
- package/test/format.spec.ts +104 -0
- package/test/parser-diagnostics.spec.ts +86 -0
- package/test/parser-tree.spec.ts +102 -0
- package/tsup.config.ts +61 -43
- package/vitest.config.ts +9 -0
- package/dist/cli.cjs +0 -1182
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.mjs +0 -1171
- package/dist/cli.mjs.map +0 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// src/core/structure-txt.ts
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
3
|
+
import type {StructureEntry, DirEntry, FileEntry} from '../schema';
|
|
4
|
+
import {toPosixPath} from '../util/fs-utils';
|
|
5
|
+
import {mapThrough} from "../ast";
|
|
5
6
|
|
|
6
7
|
interface ParsedLine {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
lineNo: number;
|
|
9
|
+
indentSpaces: number;
|
|
10
|
+
rawPath: string;
|
|
11
|
+
stub?: string;
|
|
12
|
+
include?: string[];
|
|
13
|
+
exclude?: string[];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -26,39 +27,12 @@ interface ParsedLine {
|
|
|
26
27
|
* preceded by whitespace (space or tab).
|
|
27
28
|
*/
|
|
28
29
|
function stripInlineComment(content: string): string {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// Inline "# ..."
|
|
37
|
-
if (ch === '#') {
|
|
38
|
-
if (i === 0) continue; // full-line handled earlier
|
|
39
|
-
if (prev === ' ' || prev === '\t') {
|
|
40
|
-
cutIndex = i;
|
|
41
|
-
break;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Inline "// ..."
|
|
46
|
-
if (
|
|
47
|
-
ch === '/' &&
|
|
48
|
-
i + 1 < len &&
|
|
49
|
-
content[i + 1] === '/' &&
|
|
50
|
-
(prev === ' ' || prev === '\t')
|
|
51
|
-
) {
|
|
52
|
-
cutIndex = i;
|
|
53
|
-
break;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (cutIndex === -1) {
|
|
58
|
-
return content.trimEnd();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return content.slice(0, cutIndex).trimEnd();
|
|
30
|
+
const cutIndex = mapThrough(content);
|
|
31
|
+
if (cutIndex === -1) {
|
|
32
|
+
return content.trimEnd();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return content.slice(0, cutIndex).trimEnd();
|
|
62
36
|
}
|
|
63
37
|
|
|
64
38
|
/**
|
|
@@ -69,77 +43,77 @@ function stripInlineComment(content: string): string {
|
|
|
69
43
|
* - @exclude:pattern,pattern2
|
|
70
44
|
*/
|
|
71
45
|
function parseLine(line: string, lineNo: number): ParsedLine | null {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
46
|
+
const match = line.match(/^(\s*)(.*)$/);
|
|
47
|
+
if (!match) return null;
|
|
48
|
+
|
|
49
|
+
const indentSpaces = match[1].length;
|
|
50
|
+
let rest = match[2];
|
|
51
|
+
|
|
52
|
+
// If line (after indent) is empty, skip
|
|
53
|
+
if (!rest.trim()) return null;
|
|
54
|
+
|
|
55
|
+
// Full-line comments after indent
|
|
56
|
+
const trimmedRest = rest.trimStart();
|
|
57
|
+
if (trimmedRest.startsWith('#') || trimmedRest.startsWith('//')) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strip inline comments (# or //) before parsing tokens
|
|
62
|
+
const stripped = stripInlineComment(rest);
|
|
63
|
+
const trimmed = stripped.trim();
|
|
64
|
+
if (!trimmed) return null;
|
|
65
|
+
|
|
66
|
+
const parts = trimmed.split(/\s+/);
|
|
67
|
+
if (!parts.length) return null;
|
|
68
|
+
|
|
69
|
+
const pathToken = parts[0];
|
|
70
|
+
|
|
71
|
+
// 🚫 Reserve ":" for annotations only – paths may not contain it.
|
|
72
|
+
if (pathToken.includes(':')) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`structure.txt: ":" is reserved for annotations (@stub:, @include:, etc). ` +
|
|
75
|
+
`Invalid path "${pathToken}" on line ${lineNo}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let stub: string | undefined;
|
|
80
|
+
const include: string[] = [];
|
|
81
|
+
const exclude: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const token of parts.slice(1)) {
|
|
84
|
+
if (token.startsWith('@stub:')) {
|
|
85
|
+
stub = token.slice('@stub:'.length);
|
|
86
|
+
} else if (token.startsWith('@include:')) {
|
|
87
|
+
const val = token.slice('@include:'.length);
|
|
88
|
+
if (val) {
|
|
89
|
+
include.push(
|
|
90
|
+
...val
|
|
91
|
+
.split(',')
|
|
92
|
+
.map((s) => s.trim())
|
|
93
|
+
.filter(Boolean),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
} else if (token.startsWith('@exclude:')) {
|
|
97
|
+
const val = token.slice('@exclude:'.length);
|
|
98
|
+
if (val) {
|
|
99
|
+
exclude.push(
|
|
100
|
+
...val
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((s) => s.trim())
|
|
103
|
+
.filter(Boolean),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
lineNo,
|
|
111
|
+
indentSpaces,
|
|
112
|
+
rawPath: pathToken,
|
|
113
|
+
stub,
|
|
114
|
+
include: include.length ? include : undefined,
|
|
115
|
+
exclude: exclude.length ? exclude : undefined,
|
|
116
|
+
};
|
|
143
117
|
}
|
|
144
118
|
|
|
145
119
|
/**
|
|
@@ -154,123 +128,123 @@ function parseLine(line: string, lineNo: number): ParsedLine | null {
|
|
|
154
128
|
* - Folders must end with "/" in the txt; paths are normalized to POSIX.
|
|
155
129
|
*/
|
|
156
130
|
export function parseStructureText(
|
|
157
|
-
|
|
158
|
-
|
|
131
|
+
text: string,
|
|
132
|
+
indentStep = 2,
|
|
159
133
|
): StructureEntry[] {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
for (let i = 0; i < lines.length; i++) {
|
|
164
|
-
const lineNo = i + 1;
|
|
165
|
-
const p = parseLine(lines[i], lineNo);
|
|
166
|
-
if (p) parsed.push(p);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const rootEntries: StructureEntry[] = [];
|
|
134
|
+
const lines = text.split(/\r?\n/);
|
|
135
|
+
const parsed: ParsedLine[] = [];
|
|
170
136
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
137
|
+
for (let i = 0; i < lines.length; i++) {
|
|
138
|
+
const lineNo = i + 1;
|
|
139
|
+
const p = parseLine(lines[i], lineNo);
|
|
140
|
+
if (p) parsed.push(p);
|
|
141
|
+
}
|
|
176
142
|
|
|
177
|
-
|
|
143
|
+
const rootEntries: StructureEntry[] = [];
|
|
178
144
|
|
|
179
|
-
|
|
180
|
-
|
|
145
|
+
type StackItem = {
|
|
146
|
+
level: number;
|
|
147
|
+
entry: DirEntry | FileEntry;
|
|
148
|
+
isDir: boolean;
|
|
149
|
+
};
|
|
181
150
|
|
|
182
|
-
|
|
183
|
-
throw new Error(
|
|
184
|
-
`structure.txt: Invalid indent on line ${lineNo}. ` +
|
|
185
|
-
`Indent must be multiples of ${indentStep} spaces.`,
|
|
186
|
-
);
|
|
187
|
-
}
|
|
151
|
+
const stack: StackItem[] = [];
|
|
188
152
|
|
|
189
|
-
|
|
153
|
+
for (const p of parsed) {
|
|
154
|
+
const {indentSpaces, lineNo} = p;
|
|
190
155
|
|
|
191
|
-
|
|
192
|
-
if (level > stack.length) {
|
|
193
|
-
// e.g. current stack depth 1, but line level=3 is invalid
|
|
194
|
-
if (level !== stack.length + 1) {
|
|
195
|
-
throw new Error(
|
|
196
|
-
`structure.txt: Invalid indentation on line ${lineNo}. ` +
|
|
197
|
-
`You cannot jump more than one level at a time. ` +
|
|
198
|
-
`Previous depth: ${stack.length}, this line depth: ${level}.`,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// If this line is indented (level > 0), parent must exist and must be dir
|
|
204
|
-
if (level > 0) {
|
|
205
|
-
const parent = stack[level - 1]; // parent level is (level - 1)
|
|
206
|
-
if (!parent) {
|
|
207
|
-
throw new Error(
|
|
208
|
-
`structure.txt: Indented entry without a parent on line ${lineNo}.`,
|
|
209
|
-
);
|
|
210
|
-
}
|
|
211
|
-
if (!parent.isDir) {
|
|
156
|
+
if (indentSpaces % indentStep !== 0) {
|
|
212
157
|
throw new Error(
|
|
213
|
-
|
|
214
|
-
|
|
158
|
+
`structure.txt: Invalid indent on line ${lineNo}. ` +
|
|
159
|
+
`Indent must be multiples of ${indentStep} spaces.`,
|
|
215
160
|
);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const level = indentSpaces / indentStep;
|
|
164
|
+
|
|
165
|
+
// Determine parent level and enforce no skipping
|
|
166
|
+
if (level > stack.length) {
|
|
167
|
+
// e.g. current stack depth 1, but line level=3 is invalid
|
|
168
|
+
if (level !== stack.length + 1) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`structure.txt: Invalid indentation on line ${lineNo}. ` +
|
|
171
|
+
`You cannot jump more than one level at a time. ` +
|
|
172
|
+
`Previous depth: ${stack.length}, this line depth: ${level}.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// If this line is indented (level > 0), parent must exist and must be dir
|
|
178
|
+
if (level > 0) {
|
|
179
|
+
const parent = stack[level - 1]; // parent level is (level - 1)
|
|
180
|
+
if (!parent) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`structure.txt: Indented entry without a parent on line ${lineNo}.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!parent.isDir) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`structure.txt: Cannot indent under a file on line ${lineNo}. ` +
|
|
188
|
+
`Files cannot have children. Parent: "${parent.entry.path}".`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const isDir = p.rawPath.endsWith('/');
|
|
194
|
+
const clean = p.rawPath.replace(/\/$/, '');
|
|
195
|
+
const basePath = toPosixPath(clean);
|
|
196
|
+
|
|
197
|
+
// Pop stack until we are at the correct depth
|
|
198
|
+
while (stack.length > level) {
|
|
199
|
+
stack.pop();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const parent = stack[stack.length - 1]?.entry as DirEntry | undefined;
|
|
203
|
+
const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
|
|
204
|
+
|
|
205
|
+
const fullPath = parentPath
|
|
206
|
+
? `${parentPath}/${basePath}${isDir ? '/' : ''}`
|
|
207
|
+
: `${basePath}${isDir ? '/' : ''}`;
|
|
208
|
+
|
|
209
|
+
if (isDir) {
|
|
210
|
+
const dirEntry: DirEntry = {
|
|
211
|
+
type: 'dir',
|
|
212
|
+
path: fullPath,
|
|
213
|
+
children: [],
|
|
214
|
+
...(p.stub ? {stub: p.stub} : {}),
|
|
215
|
+
...(p.include ? {include: p.include} : {}),
|
|
216
|
+
...(p.exclude ? {exclude: p.exclude} : {}),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
if (parent && parent.type === 'dir') {
|
|
220
|
+
parent.children = parent.children ?? [];
|
|
221
|
+
parent.children.push(dirEntry);
|
|
222
|
+
} else if (!parent) {
|
|
223
|
+
rootEntries.push(dirEntry);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
stack.push({level, entry: dirEntry, isDir: true});
|
|
227
|
+
} else {
|
|
228
|
+
const fileEntry: FileEntry = {
|
|
229
|
+
type: 'file',
|
|
230
|
+
path: fullPath,
|
|
231
|
+
...(p.stub ? {stub: p.stub} : {}),
|
|
232
|
+
...(p.include ? {include: p.include} : {}),
|
|
233
|
+
...(p.exclude ? {exclude: p.exclude} : {}),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (parent && parent.type === 'dir') {
|
|
237
|
+
parent.children = parent.children ?? [];
|
|
238
|
+
parent.children.push(fileEntry);
|
|
239
|
+
} else if (!parent) {
|
|
240
|
+
rootEntries.push(fileEntry);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// We still push files into the stack at this level so that
|
|
244
|
+
// bad indentation under them can be detected and rejected.
|
|
245
|
+
stack.push({level, entry: fileEntry, isDir: false});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return rootEntries;
|
|
276
250
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// test/format-roundtrip.spec.ts
|
|
2
|
+
import {describe, it, expect} from 'vitest';
|
|
3
|
+
import {formatStructureText} from '../src/ast';
|
|
4
|
+
|
|
5
|
+
describe('formatStructureText roundtrip', () => {
|
|
6
|
+
it('is idempotent for a valid tree', () => {
|
|
7
|
+
const input = [
|
|
8
|
+
'src/',
|
|
9
|
+
' index.ts # entry',
|
|
10
|
+
'',
|
|
11
|
+
' schema/',
|
|
12
|
+
' index.ts',
|
|
13
|
+
].join('\n');
|
|
14
|
+
|
|
15
|
+
const first = formatStructureText(input, {indentStep: 2});
|
|
16
|
+
const second = formatStructureText(first.text, {indentStep: 2});
|
|
17
|
+
|
|
18
|
+
expect(second.text).toBe(first.text);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// test/format.spec.ts
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect} from 'vitest';
|
|
4
|
+
import {formatStructureText} from '../src/ast';
|
|
5
|
+
|
|
6
|
+
describe('formatStructureText', () => {
|
|
7
|
+
it('formats a simple tree with canonical indentation', () => {
|
|
8
|
+
const input = [
|
|
9
|
+
'src/',
|
|
10
|
+
' index.ts',
|
|
11
|
+
'',
|
|
12
|
+
' schema/',
|
|
13
|
+
' index.ts',
|
|
14
|
+
' field.ts',
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
const {text} = formatStructureText(input, {indentStep: 2});
|
|
18
|
+
|
|
19
|
+
expect(text).toBe(
|
|
20
|
+
[
|
|
21
|
+
'src/',
|
|
22
|
+
' index.ts',
|
|
23
|
+
'',
|
|
24
|
+
' schema/',
|
|
25
|
+
' index.ts',
|
|
26
|
+
' field.ts',
|
|
27
|
+
].join('\n'),
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('preserves blank lines and comments', () => {
|
|
32
|
+
const input = [
|
|
33
|
+
'# root comment',
|
|
34
|
+
'src/ # src dir',
|
|
35
|
+
'',
|
|
36
|
+
' index.ts // main entry',
|
|
37
|
+
'',
|
|
38
|
+
' schema/ # schema section',
|
|
39
|
+
' index.ts',
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
const {text} = formatStructureText(input, {indentStep: 2});
|
|
43
|
+
|
|
44
|
+
expect(text).toBe(
|
|
45
|
+
[
|
|
46
|
+
'# root comment',
|
|
47
|
+
'src/ # src dir',
|
|
48
|
+
'',
|
|
49
|
+
' index.ts // main entry',
|
|
50
|
+
'',
|
|
51
|
+
' schema/ # schema section',
|
|
52
|
+
' index.ts',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('fixes over-indented children in loose mode', () => {
|
|
58
|
+
const input = [
|
|
59
|
+
'src/',
|
|
60
|
+
' schema/', // 8 spaces (depth 4 if step=2), but no intermediate levels
|
|
61
|
+
' index.ts',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
const {text, ast} = formatStructureText(input, {
|
|
65
|
+
indentStep: 2,
|
|
66
|
+
mode: 'loose',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// In loose mode, this should become src/ (depth 0) and schema/ (depth 1)
|
|
70
|
+
expect(text).toBe(
|
|
71
|
+
[
|
|
72
|
+
'src/',
|
|
73
|
+
' schema/',
|
|
74
|
+
' index.ts',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// And there should be at least one warning about the indent jump.
|
|
79
|
+
const hasIndentWarning = ast.diagnostics.some(
|
|
80
|
+
(d) => d.code && d.code.includes('indent-skip-level'),
|
|
81
|
+
);
|
|
82
|
+
expect(hasIndentWarning).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps inline comments attached to their entries', () => {
|
|
86
|
+
const input = [
|
|
87
|
+
'src/',
|
|
88
|
+
' index.ts # comment one',
|
|
89
|
+
' schema/ @stub:schema // comment two',
|
|
90
|
+
' index.ts',
|
|
91
|
+
].join('\n');
|
|
92
|
+
|
|
93
|
+
const {text} = formatStructureText(input, {indentStep: 2});
|
|
94
|
+
|
|
95
|
+
expect(text).toBe(
|
|
96
|
+
[
|
|
97
|
+
'src/',
|
|
98
|
+
' index.ts # comment one',
|
|
99
|
+
' schema/ @stub:schema // comment two',
|
|
100
|
+
' index.ts',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// test/parser-diagnostics.spec.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { parseStructureAst } from '../src/ast';
|
|
5
|
+
|
|
6
|
+
describe('parseStructureAst diagnostics', () => {
|
|
7
|
+
it('reports indent-skip-level when jumping multiple levels', () => {
|
|
8
|
+
const text = [
|
|
9
|
+
'src/',
|
|
10
|
+
' index.ts', // 8 spaces, with indentStep=2 this is a big jump
|
|
11
|
+
].join('\n');
|
|
12
|
+
|
|
13
|
+
const ast = parseStructureAst(text, { indentStep: 2, mode: 'loose' });
|
|
14
|
+
|
|
15
|
+
const codes = ast.diagnostics.map((d) => d.code);
|
|
16
|
+
expect(codes).toContain('indent-skip-level');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('reports indent-misaligned when decreasing indent by a non-multiple', () => {
|
|
20
|
+
// Here the *third* line has LESS indent than the second, and not a clean multiple.
|
|
21
|
+
// 0 spaces -> 4 spaces -> 3 spaces
|
|
22
|
+
const text = [
|
|
23
|
+
'src/',
|
|
24
|
+
' schema/',
|
|
25
|
+
' index.ts', // 3 spaces: decrease from 4 by 1 → not multiple of 2
|
|
26
|
+
].join('\n');
|
|
27
|
+
|
|
28
|
+
const ast = parseStructureAst(text, { indentStep: 2, mode: 'loose' });
|
|
29
|
+
|
|
30
|
+
const diag = ast.diagnostics.find((d) => d.code === 'indent-misaligned');
|
|
31
|
+
expect(diag).toBeDefined();
|
|
32
|
+
expect(diag?.line).toBe(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reports path-colon when a path token contains ":"', () => {
|
|
36
|
+
const text = [
|
|
37
|
+
'src/',
|
|
38
|
+
' api:v1/', // invalid: colon in path token
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
const ast = parseStructureAst(text, { indentStep: 2, mode: 'loose' });
|
|
42
|
+
|
|
43
|
+
const diag = ast.diagnostics.find((d) => d.code === 'path-colon');
|
|
44
|
+
expect(diag).toBeDefined();
|
|
45
|
+
expect(diag?.line).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reports child-of-file-loose when an entry is indented under a file', () => {
|
|
49
|
+
const text = [
|
|
50
|
+
'index.ts',
|
|
51
|
+
' child.ts',
|
|
52
|
+
].join('\n');
|
|
53
|
+
|
|
54
|
+
const ast = parseStructureAst(text, { indentStep: 2, mode: 'loose' });
|
|
55
|
+
|
|
56
|
+
const diag = ast.diagnostics.find(
|
|
57
|
+
(d) => d.code === 'child-of-file-loose' || d.code === 'child-of-file',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(diag).toBeDefined();
|
|
61
|
+
expect(diag?.line).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does NOT treat comments or blanks as entries (no diagnostics for them)', () => {
|
|
65
|
+
const text = [
|
|
66
|
+
'# comment',
|
|
67
|
+
'',
|
|
68
|
+
' // another comment',
|
|
69
|
+
'src/',
|
|
70
|
+
].join('\n');
|
|
71
|
+
|
|
72
|
+
const ast = parseStructureAst(text, { indentStep: 2, mode: 'loose' });
|
|
73
|
+
|
|
74
|
+
// Should parse fine, with no indent-related diagnostics on comment lines
|
|
75
|
+
const indentDiags = ast.diagnostics.filter(
|
|
76
|
+
(d) =>
|
|
77
|
+
d.code === 'indent-skip-level' ||
|
|
78
|
+
d.code === 'indent-misaligned' ||
|
|
79
|
+
d.code === 'indent-tabs',
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(indentDiags.length).toBe(0);
|
|
83
|
+
expect(ast.rootNodes.length).toBe(1);
|
|
84
|
+
expect(ast.rootNodes[0].name).toBe('src/');
|
|
85
|
+
});
|
|
86
|
+
});
|