@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
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
// src/ast/parser.ts
|
|
2
|
+
|
|
3
|
+
import {toPosixPath} from '../util/fs-utils';
|
|
4
|
+
|
|
5
|
+
export type AstMode = 'strict' | 'loose';
|
|
6
|
+
|
|
7
|
+
export type DiagnosticSeverity = 'info' | 'warning' | 'error';
|
|
8
|
+
|
|
9
|
+
export interface Diagnostic {
|
|
10
|
+
line: number; // 1-based
|
|
11
|
+
column?: number; // 1-based (optional)
|
|
12
|
+
message: string;
|
|
13
|
+
severity: DiagnosticSeverity;
|
|
14
|
+
code?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How a physical line in the text was classified.
|
|
19
|
+
*/
|
|
20
|
+
export type LineKind = 'blank' | 'comment' | 'entry';
|
|
21
|
+
|
|
22
|
+
export interface StructureAstLine {
|
|
23
|
+
index: number; // 0-based
|
|
24
|
+
lineNo: number; // 1-based
|
|
25
|
+
raw: string;
|
|
26
|
+
kind: LineKind;
|
|
27
|
+
indentSpaces: number;
|
|
28
|
+
content: string; // after leading whitespace (includes path+annotations+inline comment)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* AST node base for structure entries.
|
|
33
|
+
*/
|
|
34
|
+
interface AstNodeBase {
|
|
35
|
+
type: 'dir' | 'file';
|
|
36
|
+
/** The last segment name, e.g. "schema/" or "index.ts". */
|
|
37
|
+
name: string;
|
|
38
|
+
/** Depth level (0 = root, 1 = child of root, etc.). */
|
|
39
|
+
depth: number;
|
|
40
|
+
/** 1-based source line number. */
|
|
41
|
+
line: number;
|
|
42
|
+
/** Normalized POSIX path from root, e.g. "src/schema/index.ts" or "src/schema/". */
|
|
43
|
+
path: string;
|
|
44
|
+
/** Stub annotation, if any. */
|
|
45
|
+
stub?: string;
|
|
46
|
+
/** Include glob patterns, if any. */
|
|
47
|
+
include?: string[];
|
|
48
|
+
/** Exclude glob patterns, if any. */
|
|
49
|
+
exclude?: string[];
|
|
50
|
+
/** Parent node; null for roots. */
|
|
51
|
+
parent: DirNode | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DirNode extends AstNodeBase {
|
|
55
|
+
type: 'dir';
|
|
56
|
+
children: AstNode[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FileNode extends AstNodeBase {
|
|
60
|
+
type: 'file';
|
|
61
|
+
children?: undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AstNode = DirNode | FileNode;
|
|
65
|
+
|
|
66
|
+
export interface AstOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Spaces per indent level.
|
|
69
|
+
* Default: 2.
|
|
70
|
+
*/
|
|
71
|
+
indentStep?: number;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parser mode:
|
|
75
|
+
* - "strict": mismatched indentation / impossible structures are errors.
|
|
76
|
+
* - "loose" : tries to recover from bad indentation, demotes some issues to warnings.
|
|
77
|
+
*
|
|
78
|
+
* Default: "loose".
|
|
79
|
+
*/
|
|
80
|
+
mode?: AstMode;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Full AST result: nodes + per-line meta + diagnostics.
|
|
85
|
+
*/
|
|
86
|
+
export interface StructureAst {
|
|
87
|
+
/** Root-level nodes (depth 0). */
|
|
88
|
+
rootNodes: AstNode[];
|
|
89
|
+
/** All lines as seen in the source file. */
|
|
90
|
+
lines: StructureAstLine[];
|
|
91
|
+
/** Collected diagnostics (errors + warnings + infos). */
|
|
92
|
+
diagnostics: Diagnostic[];
|
|
93
|
+
/** Resolved options used by the parser. */
|
|
94
|
+
options: Required<AstOptions>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Main entry: parse a structure text into an AST tree with diagnostics.
|
|
99
|
+
*
|
|
100
|
+
* - Does NOT throw on parse errors.
|
|
101
|
+
* - Always returns something (even if diagnostics contain errors).
|
|
102
|
+
* - In "loose" mode, attempts to repair:
|
|
103
|
+
* - odd/misaligned indentation → snapped via relative depth rules with warnings.
|
|
104
|
+
* - large indent jumps → treated as "one level deeper" with warnings.
|
|
105
|
+
* - children under files → attached to nearest viable ancestor with warnings.
|
|
106
|
+
*/
|
|
107
|
+
export function parseStructureAst(
|
|
108
|
+
text: string,
|
|
109
|
+
opts: AstOptions = {},
|
|
110
|
+
): StructureAst {
|
|
111
|
+
const indentStep = opts.indentStep ?? 2;
|
|
112
|
+
const mode: AstMode = opts.mode ?? 'loose';
|
|
113
|
+
|
|
114
|
+
const diagnostics: Diagnostic[] = [];
|
|
115
|
+
const lines: StructureAstLine[] = [];
|
|
116
|
+
|
|
117
|
+
const rawLines = text.split(/\r?\n/);
|
|
118
|
+
|
|
119
|
+
// First pass: classify + measure indentation.
|
|
120
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
121
|
+
const raw = rawLines[i];
|
|
122
|
+
const lineNo = i + 1;
|
|
123
|
+
|
|
124
|
+
const m = raw.match(/^(\s*)(.*)$/);
|
|
125
|
+
const indentRaw = m ? m[1] : '';
|
|
126
|
+
const content = m ? m[2] : '';
|
|
127
|
+
|
|
128
|
+
const {indentSpaces, hasTabs} = measureIndent(indentRaw, indentStep);
|
|
129
|
+
|
|
130
|
+
if (hasTabs) {
|
|
131
|
+
diagnostics.push({
|
|
132
|
+
line: lineNo,
|
|
133
|
+
message:
|
|
134
|
+
'Tabs detected in indentation. Consider using spaces only for consistent levels.',
|
|
135
|
+
severity: mode === 'strict' ? 'warning' : 'info',
|
|
136
|
+
code: 'indent-tabs',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const trimmed = content.trim();
|
|
141
|
+
let kind: LineKind;
|
|
142
|
+
if (!trimmed) {
|
|
143
|
+
kind = 'blank';
|
|
144
|
+
} else if (trimmed.startsWith('#') || trimmed.startsWith('//')) {
|
|
145
|
+
kind = 'comment';
|
|
146
|
+
} else {
|
|
147
|
+
kind = 'entry';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push({
|
|
151
|
+
index: i,
|
|
152
|
+
lineNo,
|
|
153
|
+
raw,
|
|
154
|
+
kind,
|
|
155
|
+
indentSpaces,
|
|
156
|
+
content,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const rootNodes: AstNode[] = [];
|
|
161
|
+
const stack: AstNode[] = []; // nodes by depth index (0 = level 0, 1 = level 1, ...)
|
|
162
|
+
|
|
163
|
+
const depthCtx: DepthContext = {
|
|
164
|
+
lastIndentSpaces: null,
|
|
165
|
+
lastDepth: null,
|
|
166
|
+
lastWasFile: false,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (line.kind !== 'entry') continue;
|
|
171
|
+
|
|
172
|
+
const {entry, depth, diags} = parseEntryLine(
|
|
173
|
+
line,
|
|
174
|
+
indentStep,
|
|
175
|
+
mode,
|
|
176
|
+
depthCtx,
|
|
177
|
+
);
|
|
178
|
+
diagnostics.push(...diags);
|
|
179
|
+
|
|
180
|
+
if (!entry) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
attachNode(entry, depth, line, rootNodes, stack, diagnostics, mode);
|
|
185
|
+
depthCtx.lastWasFile = !entry.isDir;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
rootNodes,
|
|
190
|
+
lines,
|
|
191
|
+
diagnostics,
|
|
192
|
+
options: {
|
|
193
|
+
indentStep,
|
|
194
|
+
mode,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Internal: indentation measurement & depth fixing (relative model)
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
function measureIndent(rawIndent: string, indentStep: number): {
|
|
204
|
+
indentSpaces: number;
|
|
205
|
+
hasTabs: boolean;
|
|
206
|
+
} {
|
|
207
|
+
let spaces = 0;
|
|
208
|
+
let hasTabs = false;
|
|
209
|
+
|
|
210
|
+
for (const ch of rawIndent) {
|
|
211
|
+
if (ch === ' ') {
|
|
212
|
+
spaces += 1;
|
|
213
|
+
} else if (ch === '\t') {
|
|
214
|
+
hasTabs = true;
|
|
215
|
+
// Treat tab as one level to avoid chaos. This is arbitrary but stable-ish.
|
|
216
|
+
spaces += indentStep;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {indentSpaces: spaces, hasTabs};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface DepthContext {
|
|
224
|
+
lastIndentSpaces: number | null;
|
|
225
|
+
lastDepth: number | null;
|
|
226
|
+
lastWasFile: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compute logical depth using a relative algorithm:
|
|
231
|
+
*
|
|
232
|
+
* First entry line:
|
|
233
|
+
* - depth = 0
|
|
234
|
+
*
|
|
235
|
+
* For each subsequent entry line:
|
|
236
|
+
* Let prevSpaces = lastIndentSpaces, prevDepth = lastDepth.
|
|
237
|
+
*
|
|
238
|
+
* - if spaces > prevSpaces:
|
|
239
|
+
* - if spaces > prevSpaces + indentStep → warn about a "skip"
|
|
240
|
+
* - depth = prevDepth + 1
|
|
241
|
+
*
|
|
242
|
+
* - else if spaces === prevSpaces:
|
|
243
|
+
* - depth = prevDepth
|
|
244
|
+
*
|
|
245
|
+
* - else (spaces < prevSpaces):
|
|
246
|
+
* - diff = prevSpaces - spaces
|
|
247
|
+
* - steps = round(diff / indentStep)
|
|
248
|
+
* - if diff is not a clean multiple → warn about misalignment
|
|
249
|
+
* - depth = max(prevDepth - steps, 0)
|
|
250
|
+
*/
|
|
251
|
+
function computeDepth(
|
|
252
|
+
line: StructureAstLine,
|
|
253
|
+
indentStep: number,
|
|
254
|
+
mode: AstMode,
|
|
255
|
+
ctx: DepthContext,
|
|
256
|
+
diagnostics: Diagnostic[],
|
|
257
|
+
): number {
|
|
258
|
+
let spaces = line.indentSpaces;
|
|
259
|
+
if (spaces < 0) spaces = 0;
|
|
260
|
+
|
|
261
|
+
let depth: number;
|
|
262
|
+
|
|
263
|
+
if (ctx.lastIndentSpaces == null || ctx.lastDepth == null) {
|
|
264
|
+
// First entry line: treat as root.
|
|
265
|
+
depth = 0;
|
|
266
|
+
} else {
|
|
267
|
+
const prevSpaces = ctx.lastIndentSpaces;
|
|
268
|
+
const prevDepth = ctx.lastDepth;
|
|
269
|
+
|
|
270
|
+
if (spaces > prevSpaces) {
|
|
271
|
+
const diff = spaces - prevSpaces;
|
|
272
|
+
|
|
273
|
+
// NEW: indenting under a file → child-of-file-loose
|
|
274
|
+
if (ctx.lastWasFile) {
|
|
275
|
+
diagnostics.push({
|
|
276
|
+
line: line.lineNo,
|
|
277
|
+
message:
|
|
278
|
+
'Entry appears indented under a file; treating it as a sibling of the file instead of a child.',
|
|
279
|
+
severity: mode === 'strict' ? 'error' : 'warning',
|
|
280
|
+
code: 'child-of-file-loose',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Treat as sibling of the file, not a child:
|
|
284
|
+
depth = prevDepth;
|
|
285
|
+
} else {
|
|
286
|
+
if (diff > indentStep) {
|
|
287
|
+
diagnostics.push({
|
|
288
|
+
line: line.lineNo,
|
|
289
|
+
message: `Indentation jumps from ${prevSpaces} to ${spaces} spaces; treating as one level deeper.`,
|
|
290
|
+
severity: mode === 'strict' ? 'error' : 'warning',
|
|
291
|
+
code: 'indent-skip-level',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
depth = prevDepth + 1;
|
|
295
|
+
}
|
|
296
|
+
} else if (spaces === prevSpaces) {
|
|
297
|
+
depth = prevDepth;
|
|
298
|
+
} else {
|
|
299
|
+
const diff = prevSpaces - spaces;
|
|
300
|
+
const steps = Math.round(diff / indentStep);
|
|
301
|
+
|
|
302
|
+
if (diff % indentStep !== 0) {
|
|
303
|
+
diagnostics.push({
|
|
304
|
+
line: line.lineNo,
|
|
305
|
+
message: `Indentation decreases from ${prevSpaces} to ${spaces} spaces, which is not a multiple of indent step (${indentStep}).`,
|
|
306
|
+
severity: mode === 'strict' ? 'error' : 'warning',
|
|
307
|
+
code: 'indent-misaligned',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
depth = Math.max(prevDepth - steps, 0);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
ctx.lastIndentSpaces = spaces;
|
|
316
|
+
ctx.lastDepth = depth;
|
|
317
|
+
|
|
318
|
+
return depth;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Internal: entry line parsing (path + annotations)
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
interface ParsedEntry {
|
|
326
|
+
segmentName: string;
|
|
327
|
+
isDir: boolean;
|
|
328
|
+
stub?: string;
|
|
329
|
+
include?: string[];
|
|
330
|
+
exclude?: string[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Parse a single entry line into a ParsedEntry + depth.
|
|
335
|
+
*/
|
|
336
|
+
function parseEntryLine(
|
|
337
|
+
line: StructureAstLine,
|
|
338
|
+
indentStep: number,
|
|
339
|
+
mode: AstMode,
|
|
340
|
+
ctx: DepthContext,
|
|
341
|
+
): {
|
|
342
|
+
entry: ParsedEntry | null;
|
|
343
|
+
depth: number;
|
|
344
|
+
diags: Diagnostic[];
|
|
345
|
+
} {
|
|
346
|
+
const diags: Diagnostic[] = [];
|
|
347
|
+
const depth = computeDepth(line, indentStep, mode, ctx, diags);
|
|
348
|
+
|
|
349
|
+
// Extract before inline comment
|
|
350
|
+
const {contentWithoutComment} = extractInlineCommentParts(line.content);
|
|
351
|
+
const trimmed = contentWithoutComment.trim();
|
|
352
|
+
if (!trimmed) {
|
|
353
|
+
// Structural line that became empty after stripping inline comment; treat as no-op.
|
|
354
|
+
return {entry: null, depth, diags};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const parts = trimmed.split(/\s+/);
|
|
358
|
+
const pathToken = parts[0];
|
|
359
|
+
const annotationTokens = parts.slice(1);
|
|
360
|
+
|
|
361
|
+
// Path sanity checks
|
|
362
|
+
if (pathToken.includes(':')) {
|
|
363
|
+
diags.push({
|
|
364
|
+
line: line.lineNo,
|
|
365
|
+
message:
|
|
366
|
+
'Path token contains ":" which is reserved for annotations. This is likely a mistake.',
|
|
367
|
+
severity: mode === 'strict' ? 'error' : 'warning',
|
|
368
|
+
code: 'path-colon',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const isDir = pathToken.endsWith('/');
|
|
373
|
+
const segmentName = pathToken;
|
|
374
|
+
|
|
375
|
+
let stub: string | undefined;
|
|
376
|
+
const include: string[] = [];
|
|
377
|
+
const exclude: string[] = [];
|
|
378
|
+
|
|
379
|
+
for (const token of annotationTokens) {
|
|
380
|
+
if (token.startsWith('@stub:')) {
|
|
381
|
+
stub = token.slice('@stub:'.length);
|
|
382
|
+
} else if (token.startsWith('@include:')) {
|
|
383
|
+
const val = token.slice('@include:'.length);
|
|
384
|
+
if (val) {
|
|
385
|
+
include.push(
|
|
386
|
+
...val
|
|
387
|
+
.split(',')
|
|
388
|
+
.map((s) => s.trim())
|
|
389
|
+
.filter(Boolean),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
} else if (token.startsWith('@exclude:')) {
|
|
393
|
+
const val = token.slice('@exclude:'.length);
|
|
394
|
+
if (val) {
|
|
395
|
+
exclude.push(
|
|
396
|
+
...val
|
|
397
|
+
.split(',')
|
|
398
|
+
.map((s) => s.trim())
|
|
399
|
+
.filter(Boolean),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
} else if (token.startsWith('@')) {
|
|
403
|
+
diags.push({
|
|
404
|
+
line: line.lineNo,
|
|
405
|
+
message: `Unknown annotation token "${token}".`,
|
|
406
|
+
severity: 'info',
|
|
407
|
+
code: 'unknown-annotation',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const entry: ParsedEntry = {
|
|
413
|
+
segmentName,
|
|
414
|
+
isDir,
|
|
415
|
+
stub,
|
|
416
|
+
include: include.length ? include : undefined,
|
|
417
|
+
exclude: exclude.length ? exclude : undefined,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return {entry, depth, diags};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function mapThrough(content: string) {
|
|
424
|
+
let cutIndex = -1;
|
|
425
|
+
const len = content.length;
|
|
426
|
+
|
|
427
|
+
for (let i = 0; i < len; i++) {
|
|
428
|
+
const ch = content[i];
|
|
429
|
+
const prev = i > 0 ? content[i - 1] : '';
|
|
430
|
+
|
|
431
|
+
// Inline "# ..."
|
|
432
|
+
if (ch === '#') {
|
|
433
|
+
if (i === 0) {
|
|
434
|
+
// full-line comment; not our case (we only call this for "entry" lines)
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (prev === ' ' || prev === '\t') {
|
|
438
|
+
cutIndex = i;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Inline "// ..."
|
|
444
|
+
if (
|
|
445
|
+
ch === '/' &&
|
|
446
|
+
i + 1 < len &&
|
|
447
|
+
content[i + 1] === '/' &&
|
|
448
|
+
(prev === ' ' || prev === '\t')
|
|
449
|
+
) {
|
|
450
|
+
cutIndex = i;
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return cutIndex;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Extracts the inline comment portion (if any) from the content area (no leading indent).
|
|
460
|
+
*/
|
|
461
|
+
export function extractInlineCommentParts(content: string): {
|
|
462
|
+
contentWithoutComment: string;
|
|
463
|
+
inlineComment: string | null;
|
|
464
|
+
} {
|
|
465
|
+
const cutIndex = mapThrough(content);
|
|
466
|
+
|
|
467
|
+
if (cutIndex === -1) {
|
|
468
|
+
return {
|
|
469
|
+
contentWithoutComment: content,
|
|
470
|
+
inlineComment: null,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
contentWithoutComment: content.slice(0, cutIndex),
|
|
476
|
+
inlineComment: content.slice(cutIndex),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// Internal: tree construction
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
function attachNode(
|
|
485
|
+
entry: ParsedEntry,
|
|
486
|
+
depth: number,
|
|
487
|
+
line: StructureAstLine,
|
|
488
|
+
rootNodes: AstNode[],
|
|
489
|
+
stack: AstNode[],
|
|
490
|
+
diagnostics: Diagnostic[],
|
|
491
|
+
mode: AstMode,
|
|
492
|
+
): void {
|
|
493
|
+
const lineNo = line.lineNo;
|
|
494
|
+
|
|
495
|
+
// Pop stack until we’re at or above the desired depth.
|
|
496
|
+
while (stack.length > depth) {
|
|
497
|
+
stack.pop();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let parent: DirNode | null = null;
|
|
501
|
+
if (depth > 0) {
|
|
502
|
+
const candidate = stack[depth - 1];
|
|
503
|
+
if (!candidate) {
|
|
504
|
+
// Indented but no parent; in strict mode error, in loose mode, treat as root.
|
|
505
|
+
diagnostics.push({
|
|
506
|
+
line: lineNo,
|
|
507
|
+
message: `Entry has indent depth ${depth} but no parent at depth ${
|
|
508
|
+
depth - 1
|
|
509
|
+
}. Treating as root.`,
|
|
510
|
+
severity: mode === 'strict' ? 'error' : 'warning',
|
|
511
|
+
code: 'missing-parent',
|
|
512
|
+
});
|
|
513
|
+
} else if (candidate.type === 'file') {
|
|
514
|
+
// Child under file, impossible by design.
|
|
515
|
+
if (mode === 'strict') {
|
|
516
|
+
diagnostics.push({
|
|
517
|
+
line: lineNo,
|
|
518
|
+
message: `Cannot attach child under file "${candidate.path}".`,
|
|
519
|
+
severity: 'error',
|
|
520
|
+
code: 'child-of-file',
|
|
521
|
+
});
|
|
522
|
+
// Force it to root to at least keep the node.
|
|
523
|
+
} else {
|
|
524
|
+
diagnostics.push({
|
|
525
|
+
line: lineNo,
|
|
526
|
+
message: `Entry appears under file "${candidate.path}". Attaching as sibling at depth ${
|
|
527
|
+
candidate.depth
|
|
528
|
+
}.`,
|
|
529
|
+
severity: 'warning',
|
|
530
|
+
code: 'child-of-file-loose',
|
|
531
|
+
});
|
|
532
|
+
// Treat as sibling at candidate's depth.
|
|
533
|
+
while (stack.length > candidate.depth) {
|
|
534
|
+
stack.pop();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
parent = candidate as DirNode;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
|
|
543
|
+
const normalizedSegment = toPosixPath(entry.segmentName.replace(/\/+$/, ''));
|
|
544
|
+
const fullPath = parentPath
|
|
545
|
+
? `${parentPath}/${normalizedSegment}${entry.isDir ? '/' : ''}`
|
|
546
|
+
: `${normalizedSegment}${entry.isDir ? '/' : ''}`;
|
|
547
|
+
|
|
548
|
+
const baseNode: AstNodeBase = {
|
|
549
|
+
type: entry.isDir ? 'dir' : 'file',
|
|
550
|
+
name: entry.segmentName,
|
|
551
|
+
depth,
|
|
552
|
+
line: lineNo,
|
|
553
|
+
path: fullPath,
|
|
554
|
+
parent,
|
|
555
|
+
...(entry.stub ? {stub: entry.stub} : {}),
|
|
556
|
+
...(entry.include ? {include: entry.include} : {}),
|
|
557
|
+
...(entry.exclude ? {exclude: entry.exclude} : {}),
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
if (entry.isDir) {
|
|
561
|
+
const dirNode: DirNode = {
|
|
562
|
+
...baseNode,
|
|
563
|
+
type: 'dir',
|
|
564
|
+
children: [],
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
if (parent) {
|
|
568
|
+
parent.children.push(dirNode);
|
|
569
|
+
} else {
|
|
570
|
+
rootNodes.push(dirNode);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Ensure stack[depth] is this dir.
|
|
574
|
+
while (stack.length > depth) {
|
|
575
|
+
stack.pop();
|
|
576
|
+
}
|
|
577
|
+
stack[depth] = dirNode;
|
|
578
|
+
} else {
|
|
579
|
+
const fileNode: FileNode = {
|
|
580
|
+
...baseNode,
|
|
581
|
+
type: 'file',
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
if (parent) {
|
|
585
|
+
parent.children.push(fileNode);
|
|
586
|
+
} else {
|
|
587
|
+
rootNodes.push(fileNode);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Files themselves are NOT placed on the stack to prevent children,
|
|
591
|
+
// but attachNode will repair children-under-file in loose mode.
|
|
592
|
+
}
|
|
593
|
+
}
|