@timeax/scaffold 0.0.3 → 0.0.5

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.
@@ -0,0 +1,262 @@
1
+ // src/ast/format.ts
2
+
3
+ import {
4
+ parseStructureAst,
5
+ type AstMode,
6
+ type StructureAst,
7
+ type AstNode, extractInlineCommentParts,
8
+ } from './parser';
9
+ import {FormatConfig} from "../schema";
10
+
11
+ export interface FormatOptions extends FormatConfig {
12
+ /**
13
+ * Spaces per indent level for re-printing entries.
14
+ * Defaults to 2.
15
+ */
16
+ indentStep?: number;
17
+
18
+ /**
19
+ * Parser mode to use for the AST.
20
+ * - "loose": attempt to repair mis-indents / bad parents (default).
21
+ * - "strict": report issues as errors, less repair.
22
+ */
23
+ mode?: AstMode;
24
+
25
+ /**
26
+ * Normalize newlines to the dominant style in the original text (LF vs. CRLF).
27
+ * Defaults to true.
28
+ */
29
+ normalizeNewlines?: boolean;
30
+
31
+ /**
32
+ * Trim trailing whitespace on non-entry lines (comments / blanks).
33
+ * Defaults to true.
34
+ */
35
+ trimTrailingWhitespace?: boolean;
36
+
37
+ /**
38
+ * Whether to normalize annotation ordering and spacing:
39
+ * name @stub:... @include:... @exclude:...
40
+ * Defaults to true.
41
+ */
42
+ normalizeAnnotations?: boolean;
43
+ }
44
+
45
+ export interface FormatResult {
46
+ /** Formatted text. */
47
+ text: string;
48
+ /** Underlying AST that was used. */
49
+ ast: StructureAst;
50
+ }
51
+
52
+ /**
53
+ * Smart formatter for scaffold structure files.
54
+ *
55
+ * - Uses the loose AST parser (parseStructureAst) to understand structure.
56
+ * - Auto-fixes indentation based on tree depth (indentStep).
57
+ * - Keeps **all** blank lines and full-line comments in place.
58
+ * - Preserves inline comments (# / //) on entry lines.
59
+ * - Canonicalizes annotation order (stub → include → exclude) if enabled.
60
+ *
61
+ * It does **not** throw on invalid input:
62
+ * - parseStructureAst always returns an AST + diagnostics.
63
+ * - If something is catastrophically off (entry/node counts mismatch),
64
+ * it falls back to a minimal normalization pass.
65
+ */
66
+ export function formatStructureText(
67
+ text: string,
68
+ options: FormatOptions = {},
69
+ ): FormatResult {
70
+ const indentStep = options.indentStep ?? 2;
71
+ const mode: AstMode = options.mode ?? 'loose';
72
+ const normalizeNewlines =
73
+ options.normalizeNewlines === undefined ? true : options.normalizeNewlines;
74
+ const trimTrailingWhitespace =
75
+ options.trimTrailingWhitespace === undefined
76
+ ? true
77
+ : options.trimTrailingWhitespace;
78
+ const normalizeAnnotations =
79
+ options.normalizeAnnotations === undefined
80
+ ? true
81
+ : options.normalizeAnnotations;
82
+
83
+ // 1. Parse to our "smart" AST (non-throwing).
84
+ const ast = parseStructureAst(text, {
85
+ indentStep,
86
+ mode,
87
+ });
88
+
89
+ const rawLines = text.split(/\r?\n/);
90
+ const lineCount = rawLines.length;
91
+
92
+ // Sanity check: AST lines length should match raw lines length.
93
+ if (ast.lines.length !== lineCount) {
94
+ return {
95
+ text: basicNormalize(text, {normalizeNewlines, trimTrailingWhitespace}),
96
+ ast,
97
+ };
98
+ }
99
+
100
+ // 2. Collect entry line indices and inline comments from the original text.
101
+ const entryLineIndexes: number[] = [];
102
+ const inlineComments: (string | null)[] = [];
103
+
104
+ for (let i = 0; i < lineCount; i++) {
105
+ const lineMeta = ast.lines[i];
106
+ if (lineMeta.kind === 'entry') {
107
+ entryLineIndexes.push(i);
108
+ const {inlineComment} = extractInlineCommentParts(lineMeta.content);
109
+ inlineComments.push(inlineComment);
110
+ }
111
+ }
112
+
113
+ // 3. Flatten AST nodes in depth-first order to get an ordered node list.
114
+ const flattened: { node: AstNode; level: number }[] = [];
115
+ flattenAstNodes(ast.rootNodes, 0, flattened);
116
+
117
+ if (flattened.length !== entryLineIndexes.length) {
118
+ // If counts don't match, something is inconsistent – do not risk corruption.
119
+ return {
120
+ text: basicNormalize(text, {normalizeNewlines, trimTrailingWhitespace}),
121
+ ast,
122
+ };
123
+ }
124
+
125
+ // 4. Build canonical entry lines from AST nodes.
126
+ const canonicalEntryLines: string[] = flattened.map(({node, level}) =>
127
+ formatAstNodeLine(node, level, indentStep, normalizeAnnotations),
128
+ );
129
+
130
+ // 5. Merge canonical entry lines + inline comments back into original structure.
131
+ const resultLines: string[] = [];
132
+ let entryIdx = 0;
133
+
134
+ for (let i = 0; i < lineCount; i++) {
135
+ const lineMeta = ast.lines[i];
136
+ const originalLine = rawLines[i];
137
+
138
+ if (lineMeta.kind === 'entry') {
139
+ const base = canonicalEntryLines[entryIdx].replace(/[ \t]+$/g, '');
140
+ const inline = inlineComments[entryIdx];
141
+ entryIdx++;
142
+
143
+ if (inline) {
144
+ // Always ensure a single space before the inline comment marker.
145
+ resultLines.push(base + ' ' + inline);
146
+ } else {
147
+ resultLines.push(base);
148
+ }
149
+ } else {
150
+ let out = originalLine;
151
+ if (trimTrailingWhitespace) {
152
+ out = out.replace(/[ \t]+$/g, '');
153
+ }
154
+ resultLines.push(out);
155
+ }
156
+ }
157
+
158
+ const eol = normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
159
+ return {
160
+ text: resultLines.join(eol),
161
+ ast,
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Internal helpers
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Fallback: basic normalization when we can't safely map AST ↔ text.
171
+ */
172
+ function basicNormalize(
173
+ text: string,
174
+ opts: { normalizeNewlines: boolean; trimTrailingWhitespace: boolean },
175
+ ): string {
176
+ const lines = text.split(/\r?\n/);
177
+ const normalizedLines = opts.trimTrailingWhitespace
178
+ ? lines.map((line) => line.replace(/[ \t]+$/g, ''))
179
+ : lines;
180
+
181
+ const eol = opts.normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
182
+ return normalizedLines.join(eol);
183
+ }
184
+
185
+ /**
186
+ * Detect whether the file is more likely LF or CRLF and reuse that.
187
+ * If mixed or no clear signal, default to "\n".
188
+ */
189
+ function detectPreferredEol(text: string): string {
190
+ const crlfCount = (text.match(/\r\n/g) || []).length;
191
+ const lfCount = (text.match(/(?<!\r)\n/g) || []).length;
192
+
193
+ if (crlfCount === 0 && lfCount === 0) {
194
+ return '\n';
195
+ }
196
+
197
+ if (crlfCount > lfCount) {
198
+ return '\r\n';
199
+ }
200
+
201
+ return '\n';
202
+ }
203
+
204
+ /**
205
+ * If you really want the raw style, detect only CRLF vs. LF.
206
+ */
207
+ function getRawEol(text: string): string {
208
+ return text.includes('\r\n') ? '\r\n' : '\n';
209
+ }
210
+
211
+ /**
212
+ * Flatten AST nodes into a depth-first list while tracking indent level.
213
+ */
214
+ function flattenAstNodes(
215
+ nodes: AstNode[],
216
+ level: number,
217
+ out: { node: AstNode; level: number }[],
218
+ ): void {
219
+ for (const node of nodes) {
220
+ out.push({node, level});
221
+ if (node.type === 'dir' && node.children && node.children.length) {
222
+ flattenAstNodes(node.children, level + 1, out);
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Format a single AST node into one canonical line.
229
+ *
230
+ * - Uses `level * indentStep` spaces as indentation.
231
+ * - Uses the node's `name` as provided by the parser (e.g. "src/" or "index.ts").
232
+ * - Annotations are printed in a stable order if normalizeAnnotations is true:
233
+ * @stub:..., @include:..., @exclude:...
234
+ */
235
+ function formatAstNodeLine(
236
+ node: AstNode,
237
+ level: number,
238
+ indentStep: number,
239
+ normalizeAnnotations: boolean,
240
+ ): string {
241
+ const indent = ' '.repeat(indentStep * level);
242
+ const baseName = node.name;
243
+
244
+ if (!normalizeAnnotations) {
245
+ return indent + baseName;
246
+ }
247
+
248
+ const tokens: string[] = [];
249
+
250
+ if (node.stub) {
251
+ tokens.push(`@stub:${node.stub}`);
252
+ }
253
+ if (node.include && node.include.length > 0) {
254
+ tokens.push(`@include:${node.include.join(',')}`);
255
+ }
256
+ if (node.exclude && node.exclude.length > 0) {
257
+ tokens.push(`@exclude:${node.exclude.join(',')}`);
258
+ }
259
+
260
+ const annotations = tokens.length ? ' ' + tokens.join(' ') : '';
261
+ return indent + baseName + annotations;
262
+ }
@@ -0,0 +1,2 @@
1
+ export * from './parser';
2
+ export * from './format';