@timeax/scaffold 0.0.2 → 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 +59 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +68 -1
- package/dist/index.d.ts +68 -1
- package/dist/index.mjs +57 -13
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -3
- package/readme.md +285 -81
- package/src/ast/format.ts +261 -0
- package/src/ast/index.ts +2 -0
- package/src/ast/parser.ts +593 -0
- package/src/core/config-loader.ts +4 -3
- package/src/core/init-scaffold.ts +8 -3
- package/src/core/structure-txt.ts +221 -174
- package/src/index.ts +3 -2
- package/src/schema/config.ts +10 -1
- package/src/schema/index.ts +1 -0
- 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 -1141
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.mjs +0 -1130
- package/dist/cli.mjs.map +0 -1
|
@@ -1,15 +1,38 @@
|
|
|
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[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Strip inline comments from a content segment.
|
|
18
|
+
*
|
|
19
|
+
* Supports:
|
|
20
|
+
* - "index.ts # comment"
|
|
21
|
+
* - "index.ts // comment"
|
|
22
|
+
*
|
|
23
|
+
* Rules:
|
|
24
|
+
* - We assume leading indentation has already been removed.
|
|
25
|
+
* - Leading '#' or '//' (full-line comments) are handled BEFORE this function.
|
|
26
|
+
* - A comment starts at the first '#' or '//' that is
|
|
27
|
+
* preceded by whitespace (space or tab).
|
|
28
|
+
*/
|
|
29
|
+
function stripInlineComment(content: string): string {
|
|
30
|
+
const cutIndex = mapThrough(content);
|
|
31
|
+
if (cutIndex === -1) {
|
|
32
|
+
return content.trimEnd();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return content.slice(0, cutIndex).trimEnd();
|
|
13
36
|
}
|
|
14
37
|
|
|
15
38
|
/**
|
|
@@ -20,184 +43,208 @@ interface ParsedLine {
|
|
|
20
43
|
* - @exclude:pattern,pattern2
|
|
21
44
|
*/
|
|
22
45
|
function parseLine(line: string, lineNo: number): ParsedLine | null {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
+
};
|
|
71
117
|
}
|
|
72
118
|
|
|
73
119
|
/**
|
|
74
120
|
* Convert a structure.txt content into a nested StructureEntry[].
|
|
75
121
|
*
|
|
76
122
|
* Rules:
|
|
77
|
-
* - Indentation is **
|
|
78
|
-
* - Indent must be a multiple of
|
|
123
|
+
* - Indentation is **indentStep** spaces per level (default: 2).
|
|
124
|
+
* - Indent must be a multiple of indentStep.
|
|
79
125
|
* - You cannot "skip" levels (no jumping from level 0 to 2 directly).
|
|
80
126
|
* - **Only directories can have children**:
|
|
81
127
|
* - If you indent under a file, an error is thrown.
|
|
82
128
|
* - Folders must end with "/" in the txt; paths are normalized to POSIX.
|
|
83
129
|
*/
|
|
84
|
-
export function parseStructureText(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const p = parseLine(lines[i], lineNo);
|
|
91
|
-
if (p) parsed.push(p);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const rootEntries: StructureEntry[] = [];
|
|
95
|
-
|
|
96
|
-
type StackItem = {
|
|
97
|
-
level: number;
|
|
98
|
-
entry: DirEntry | FileEntry;
|
|
99
|
-
isDir: boolean;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const stack: StackItem[] = [];
|
|
103
|
-
const INDENT_STEP = 2;
|
|
104
|
-
|
|
105
|
-
for (const p of parsed) {
|
|
106
|
-
const { indentSpaces, lineNo } = p;
|
|
107
|
-
|
|
108
|
-
if (indentSpaces % INDENT_STEP !== 0) {
|
|
109
|
-
throw new Error(
|
|
110
|
-
`structure.txt: Invalid indent on line ${lineNo}. ` +
|
|
111
|
-
`Indent must be multiples of ${INDENT_STEP} spaces.`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const level = indentSpaces / INDENT_STEP;
|
|
116
|
-
|
|
117
|
-
// Determine parent level and enforce no skipping
|
|
118
|
-
if (level > stack.length) {
|
|
119
|
-
// e.g. current stack depth 1, but line level=2+ is invalid
|
|
120
|
-
if (level !== stack.length + 1) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`structure.txt: Invalid indentation on line ${lineNo}. ` +
|
|
123
|
-
`You cannot jump more than one level at a time. ` +
|
|
124
|
-
`Previous depth: ${stack.length}, this line depth: ${level}.`,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
130
|
+
export function parseStructureText(
|
|
131
|
+
text: string,
|
|
132
|
+
indentStep = 2,
|
|
133
|
+
): StructureEntry[] {
|
|
134
|
+
const lines = text.split(/\r?\n/);
|
|
135
|
+
const parsed: ParsedLine[] = [];
|
|
128
136
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
142
|
+
|
|
143
|
+
const rootEntries: StructureEntry[] = [];
|
|
144
|
+
|
|
145
|
+
type StackItem = {
|
|
146
|
+
level: number;
|
|
147
|
+
entry: DirEntry | FileEntry;
|
|
148
|
+
isDir: boolean;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const stack: StackItem[] = [];
|
|
152
|
+
|
|
153
|
+
for (const p of parsed) {
|
|
154
|
+
const {indentSpaces, lineNo} = p;
|
|
155
|
+
|
|
156
|
+
if (indentSpaces % indentStep !== 0) {
|
|
138
157
|
throw new Error(
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
`structure.txt: Invalid indent on line ${lineNo}. ` +
|
|
159
|
+
`Indent must be multiples of ${indentStep} spaces.`,
|
|
141
160
|
);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
250
|
}
|
package/src/index.ts
CHANGED
package/src/schema/config.ts
CHANGED
|
@@ -143,9 +143,18 @@ export interface ScaffoldConfig {
|
|
|
143
143
|
* runner / CLI.
|
|
144
144
|
*/
|
|
145
145
|
watch?: boolean;
|
|
146
|
-
}
|
|
147
146
|
|
|
148
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Number of spaces per indent level in structure files.
|
|
150
|
+
* Default: 2.
|
|
151
|
+
*
|
|
152
|
+
* Examples:
|
|
153
|
+
* - 2 → "··entry"
|
|
154
|
+
* - 4 → "····entry"
|
|
155
|
+
*/
|
|
156
|
+
indentStep?: number;
|
|
157
|
+
}
|
|
149
158
|
/**
|
|
150
159
|
* Options when scanning an existing directory into a structure.txt tree.
|
|
151
160
|
*/
|
package/src/schema/index.ts
CHANGED
|
@@ -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
|
+
});
|