@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.
@@ -1,15 +1,38 @@
1
1
  // src/core/structure-txt.ts
2
2
 
3
- import type { StructureEntry, DirEntry, FileEntry } from '../schema';
4
- import { toPosixPath } from '../util/fs-utils';
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
- lineNo: number;
8
- indentSpaces: number;
9
- rawPath: string;
10
- stub?: string;
11
- include?: string[];
12
- exclude?: string[];
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
- const match = line.match(/^(\s*)(.+)$/);
24
- if (!match) return null;
25
-
26
- const indentSpaces = match[1].length;
27
- const rest = match[2].trim();
28
- if (!rest || rest.startsWith('#')) return null;
29
-
30
- const parts = rest.split(/\s+/);
31
- const pathToken = parts[0];
32
-
33
- let stub: string | undefined;
34
- const include: string[] = [];
35
- const exclude: string[] = [];
36
-
37
- for (const token of parts.slice(1)) {
38
- if (token.startsWith('@stub:')) {
39
- stub = token.slice('@stub:'.length);
40
- } else if (token.startsWith('@include:')) {
41
- const val = token.slice('@include:'.length);
42
- if (val) {
43
- include.push(
44
- ...val
45
- .split(',')
46
- .map((s) => s.trim())
47
- .filter(Boolean),
48
- );
49
- }
50
- } else if (token.startsWith('@exclude:')) {
51
- const val = token.slice('@exclude:'.length);
52
- if (val) {
53
- exclude.push(
54
- ...val
55
- .split(',')
56
- .map((s) => s.trim())
57
- .filter(Boolean),
58
- );
59
- }
60
- }
61
- }
62
-
63
- return {
64
- lineNo,
65
- indentSpaces,
66
- rawPath: pathToken,
67
- stub,
68
- include: include.length ? include : undefined,
69
- exclude: exclude.length ? exclude : undefined,
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 **2 spaces per level** (strict).
78
- * - Indent must be a multiple of 2.
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(text: string): StructureEntry[] {
85
- const lines = text.split(/\r?\n/);
86
- const parsed: ParsedLine[] = [];
87
-
88
- for (let i = 0; i < lines.length; i++) {
89
- const lineNo = i + 1;
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
- // If this line is indented (level > 0), parent must exist and must be dir
130
- if (level > 0) {
131
- const parent = stack[level - 1]; // parent level is (level - 1)
132
- if (!parent) {
133
- throw new Error(
134
- `structure.txt: Indented entry without a parent on line ${lineNo}.`,
135
- );
136
- }
137
- if (!parent.isDir) {
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
- `structure.txt: Cannot indent under a file on line ${lineNo}. ` +
140
- `Files cannot have children. Parent: "${parent.entry.path}".`,
158
+ `structure.txt: Invalid indent on line ${lineNo}. ` +
159
+ `Indent must be multiples of ${indentStep} spaces.`,
141
160
  );
142
- }
143
- }
144
-
145
- const isDir = p.rawPath.endsWith('/');
146
- const clean = p.rawPath.replace(/\/$/, '');
147
- const basePath = toPosixPath(clean);
148
-
149
- // Determine parent based on level
150
- // Pop stack until we are at the correct depth
151
- while (stack.length > level) {
152
- stack.pop();
153
- }
154
-
155
- const parent = stack[stack.length - 1]?.entry as DirEntry | undefined;
156
- const parentPath = parent ? parent.path.replace(/\/$/, '') : '';
157
-
158
- const fullPath = parentPath
159
- ? `${parentPath}/${basePath}${isDir ? '/' : ''}`
160
- : `${basePath}${isDir ? '/' : ''}`;
161
-
162
- if (isDir) {
163
- const dirEntry: DirEntry = {
164
- type: 'dir',
165
- path: fullPath,
166
- children: [],
167
- ...(p.stub ? { stub: p.stub } : {}),
168
- ...(p.include ? { include: p.include } : {}),
169
- ...(p.exclude ? { exclude: p.exclude } : {}),
170
- };
171
-
172
- if (parent && parent.type === 'dir') {
173
- parent.children = parent.children ?? [];
174
- parent.children.push(dirEntry);
175
- } else if (!parent) {
176
- rootEntries.push(dirEntry);
177
- }
178
-
179
- stack.push({ level, entry: dirEntry, isDir: true });
180
- } else {
181
- const fileEntry: FileEntry = {
182
- type: 'file',
183
- path: fullPath,
184
- ...(p.stub ? { stub: p.stub } : {}),
185
- ...(p.include ? { include: p.include } : {}),
186
- ...(p.exclude ? { exclude: p.exclude } : {}),
187
- };
188
-
189
- if (parent && parent.type === 'dir') {
190
- parent.children = parent.children ?? [];
191
- parent.children.push(fileEntry);
192
- } else if (!parent) {
193
- rootEntries.push(fileEntry);
194
- }
195
-
196
- // files are not added to the stack; they cannot have children
197
- stack.push({ level, entry: fileEntry, isDir: false });
198
- // but next lines at same or lower level will pop correctly
199
- }
200
- }
201
-
202
- return rootEntries;
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
@@ -1,5 +1,6 @@
1
1
  // src/index.ts
2
-
3
2
  export * from './schema';
4
3
  export * from './core/runner';
5
- export * from './core/scan-structure';
4
+ export * from './core/config-loader';
5
+ export * from './core/scan-structure';
6
+ export * from './core/structure-txt';
@@ -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
  */
@@ -1,4 +1,5 @@
1
1
  // src/schema/index.ts
2
+ export const SCAFFOLD_ROOT_DIR = '.scaffold';
2
3
  export * from './structure';
3
4
  export * from './hooks';
4
5
  export * from './config';
@@ -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
+ });