@zgeoff/format-codemod 0.0.4 → 0.0.6
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/README.md +89 -76
- package/dist/cli/load-format-ignore.d.ts +8 -0
- package/dist/cli.js +93 -52
- package/dist/index.js +68 -45
- package/dist/transform/collect-head-chain.d.ts +9 -0
- package/dist/transform/needs-blank-line.d.ts +8 -6
- package/dist/transform/plan-gap-edit.d.ts +16 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,106 +1,119 @@
|
|
|
1
1
|
# @zgeoff/format-codemod
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
Machine-owned vertical spacing for TypeScript. Every gap between adjacent statements is normalized
|
|
4
|
+
to exactly one blank line where a rule below requires one, and zero where none does — a superset of
|
|
5
|
+
ESLint's `padding-line-between-statements` and `@stylistic/lines-between-class-members`, run as a
|
|
6
|
+
text-splice codemod. Each file is parsed once with [oxc-parser](https://oxc.rs) (native) and edited
|
|
7
|
+
by positional whitespace splices — no type-aware analysis — so a pass is fast enough for a
|
|
8
|
+
pre-commit hook or a `format` pipeline. Runs are idempotent: a pass over compliant code is a no-op.
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
Three kinds of gap are left alone:
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
- A gap holding any comment is never collapsed, only grown — prose between statements marks
|
|
13
|
+
intentional grouping the rules can't see.
|
|
14
|
+
- The interior of an import block is never touched — it belongs to the import sorter. The boundary
|
|
15
|
+
after the last import is still padded.
|
|
16
|
+
- Two statements sharing a physical line (a leading-semicolon `;(expr)` guard) are skipped — a
|
|
17
|
+
blank-only edit can't separate them without orphaning a token.
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
## Rules
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
**Padded on both sides** — the statement is its own paragraph:
|
|
22
|
+
|
|
23
|
+
- a control-flow block: `if`, `for`, `for…in`, `for…of`, `while`, `switch`, `try`
|
|
24
|
+
- any statement that spans multiple lines
|
|
25
|
+
- a type alias or interface declaration — runs of them are padded apart, not glued. This is the one
|
|
26
|
+
rule that looks through `export`: `export type X = …` pads like `type X = …`.
|
|
27
|
+
|
|
28
|
+
**Padded on one side:**
|
|
29
|
+
|
|
30
|
+
- before every `return`
|
|
19
31
|
- after a `function` or `class` declaration
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
- after a directive prologue (`'use strict'`, `'use client'`); runs of directives stay glued
|
|
33
|
+
- after the last import of a block
|
|
34
|
+
|
|
35
|
+
**Kind boundaries** — statements classify into kinds; a run of one kind sits flush, and the boundary
|
|
36
|
+
between two kinds takes a blank line:
|
|
37
|
+
|
|
38
|
+
- `const`/`let`/`var` declarations
|
|
39
|
+
- `using`/`await using` declarations
|
|
40
|
+
- bare calls and method calls — two distinct kinds, split by the first call in reading order:
|
|
41
|
+
`expect(x).toBe(y)` opens with a bare function, `fs.writeFileSync(…)` opens with a member, and a
|
|
42
|
+
member call chained onto a bare call's result is still a bare call
|
|
43
|
+
- assignments and increments
|
|
44
|
+
|
|
45
|
+
Two axes overlay the kinds and pad their own boundaries:
|
|
46
|
+
|
|
47
|
+
- instantiation — a statement headed by `new` (`new X(…)`, `new X(…).method()`) is separated from
|
|
48
|
+
any non-instantiation statement; `new` in argument position doesn't count, and runs of
|
|
49
|
+
instantiations stay glued
|
|
50
|
+
- awaitedness — an awaited statement (`await using`, or an `await` in the head of the statement's
|
|
51
|
+
value chain: `await f()`, `const x = await f()`) is separated from non-awaited ones; an `await` in
|
|
52
|
+
argument position (`use(await f())`) or under a non-chain head (`flag ? await f() : g()`) doesn't
|
|
53
|
+
count, and runs of awaited statements stay glued
|
|
54
|
+
|
|
55
|
+
**Class members** — one blank line between members.
|
|
56
|
+
|
|
57
|
+
Only bare declarations match the declaration rules: `export const`, `export function`, and
|
|
58
|
+
`export class` are not declarations to the classifier (type aliases and interfaces are the one
|
|
59
|
+
exception, above).
|
|
60
|
+
|
|
61
|
+
Comment positions come from the parser, not lexical scanning, so comment-lookalike text can't
|
|
62
|
+
mislead the classification. A trailing same-line comment stays attached to the statement before it —
|
|
63
|
+
the blank line goes after the comment; a comment on its own line attaches to the statement after it
|
|
64
|
+
— the blank line goes before. The next statement's indentation is preserved.
|
|
47
65
|
|
|
48
66
|
## CLI
|
|
49
67
|
|
|
50
68
|
```bash
|
|
51
69
|
format-codemod [options] <file|dir|glob> ...
|
|
52
70
|
|
|
53
|
-
--check
|
|
54
|
-
--dry
|
|
55
|
-
--bench
|
|
56
|
-
--quiet
|
|
71
|
+
--check exit 1 if any file would change; do not write
|
|
72
|
+
--dry print unified diff to stdout; do not write
|
|
73
|
+
--bench print parse stats as JSON to stderr
|
|
74
|
+
--quiet only print files that would change
|
|
75
|
+
--ignore <glob> skip files matching the glob (repeatable)
|
|
57
76
|
--version
|
|
58
77
|
--help
|
|
59
78
|
```
|
|
60
79
|
|
|
61
|
-
Flags are strict: an unknown flag is a usage error
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
Flags are strict: an unknown flag is a usage error, never a silent no-op — the default mode writes
|
|
81
|
+
files, so a typo'd `--check` must not fall through to a rewrite. Exit codes: `0` clean, `1` files
|
|
82
|
+
would change (`--check`), `2` usage error or one or more files failed (parse error, unreadable,
|
|
83
|
+
unwritable).
|
|
65
84
|
|
|
66
85
|
```bash
|
|
67
86
|
format-codemod 'src/**/*.{ts,tsx}' # rewrite in place
|
|
68
87
|
format-codemod --check 'src/**/*.{ts,tsx}' # CI gate
|
|
69
|
-
format-codemod --dry path/to/file.ts # preview diff
|
|
88
|
+
format-codemod --dry path/to/file.ts # preview diff; applies cleanly with patch(1)
|
|
70
89
|
format-codemod . # whole repo, in place
|
|
71
90
|
```
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
heuristic runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are handled correctly
|
|
75
|
-
rather than read as glob character classes. A directory argument expands to `**/*.{ts,tsx}` beneath
|
|
76
|
-
it; `node_modules` and `.git` are never descended into, and overlapping patterns are deduped so a
|
|
77
|
-
file is processed once.
|
|
78
|
-
|
|
79
|
-
`--dry` prints a real unified diff — `@@` hunk headers with three lines of context — that applies
|
|
80
|
-
cleanly with `patch(1)`.
|
|
92
|
+
### Inputs
|
|
81
93
|
|
|
82
|
-
|
|
94
|
+
Globs use Node's built-in `fs.glob`. A literal path is resolved as a file before the glob heuristic
|
|
95
|
+
runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are read as files, not glob
|
|
96
|
+
character classes. A directory expands to `**/*.{ts,tsx}` beneath it; `node_modules` and `.git` are
|
|
97
|
+
never descended into, and overlapping patterns are deduped so each file is processed once. `.d.ts`
|
|
98
|
+
files are skipped. The extension picks the parse dialect: `.tsx` enables JSX, everything else parses
|
|
99
|
+
as plain TypeScript — so `.ts`-only syntax (`<string>value` assertions, un-comma'd generic arrows
|
|
100
|
+
`<T>(a: T) => a`) parses correctly instead of being misread as JSX.
|
|
83
101
|
|
|
84
|
-
|
|
85
|
-
TypeScript. That means `.ts`-only syntax — old-style type assertions (`<string>value`), un-comma'd
|
|
86
|
-
generic arrows (`<T>(a: T) => a`) — parses correctly instead of being misread as JSX.
|
|
102
|
+
### Ignoring files
|
|
87
103
|
|
|
88
|
-
|
|
104
|
+
A `.formatignore` file in the working directory supplies ignore globs — one per line, blank lines
|
|
105
|
+
and `#`-prefixed comment lines skipped:
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
- Indentation of the next statement is preserved.
|
|
96
|
-
- Two statements that share a physical line are left untouched — for example a leading-semicolon
|
|
97
|
-
`;(expr)` whose `;` terminates the previous statement. A blank-only edit cannot separate them
|
|
98
|
-
without orphaning a token, so the codemod skips the pair.
|
|
99
|
-
|
|
100
|
-
## Idempotent
|
|
107
|
+
```gitignore
|
|
108
|
+
# committed codegen
|
|
109
|
+
src/generated/**
|
|
110
|
+
dist/**
|
|
111
|
+
```
|
|
101
112
|
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
Each line uses the same semantics as `--ignore` (Node's `path.matchesGlob`, tried against both the
|
|
114
|
+
expanded path and the path relative to the working directory). It is not a gitignore dialect: `!`
|
|
115
|
+
negation and directory-anchoring rules are not supported. `--ignore` flags merge additively with the
|
|
116
|
+
file's globs.
|
|
104
117
|
|
|
105
118
|
## Programmatic use
|
|
106
119
|
|
|
@@ -112,5 +125,5 @@ const { output, edits, parseError } = transform(source, { filename: 'component.t
|
|
|
112
125
|
// parseError: error message if the source could not be parsed; source returned untouched
|
|
113
126
|
```
|
|
114
127
|
|
|
115
|
-
`filename` is optional and only
|
|
116
|
-
|
|
128
|
+
`filename` is optional and only picks the dialect (default `'source.ts'` — plain TypeScript, no
|
|
129
|
+
JSX); nothing is read from disk.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads `.formatignore` from the given directory: one glob per line, blank
|
|
3
|
+
* lines and `#`-prefixed comment lines skipped. Lines share --ignore's glob
|
|
4
|
+
* semantics — this is not a gitignore dialect, so `!` negation and directory
|
|
5
|
+
* anchoring don't apply. A missing file yields an empty list, so callers
|
|
6
|
+
* merge unconditionally.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadFormatIgnore(dir: string): readonly string[];
|
package/dist/cli.js
CHANGED
|
@@ -49,6 +49,18 @@ function isIgnored(file, patterns) {
|
|
|
49
49
|
return patterns.some((pattern) => path.matchesGlob(file, pattern) || path.matchesGlob(relative, pattern));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// src/cli/load-format-ignore.ts
|
|
53
|
+
import fs2 from "node:fs";
|
|
54
|
+
import path2 from "node:path";
|
|
55
|
+
function loadFormatIgnore(dir) {
|
|
56
|
+
const file = path2.join(dir, ".formatignore");
|
|
57
|
+
if (!fs2.existsSync(file)) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return fs2.readFileSync(file, "utf8").split(`
|
|
61
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
// src/cli/parse-cli-args.ts
|
|
53
65
|
import { parseArgs } from "node:util";
|
|
54
66
|
function parseCLIArgs(argv) {
|
|
@@ -103,6 +115,11 @@ options:
|
|
|
103
115
|
--version
|
|
104
116
|
--help
|
|
105
117
|
|
|
118
|
+
files:
|
|
119
|
+
.formatignore read from the working directory when present: one glob per
|
|
120
|
+
line, blank lines and # comments skipped, merged with
|
|
121
|
+
--ignore
|
|
122
|
+
|
|
106
123
|
exit codes:
|
|
107
124
|
0 clean
|
|
108
125
|
1 files would change (--check)
|
|
@@ -133,9 +150,9 @@ function printReport(file, report, quiet) {
|
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
// src/cli/read-package-version.ts
|
|
136
|
-
import
|
|
153
|
+
import fs3 from "node:fs";
|
|
137
154
|
function readPackageVersion(pkgPath) {
|
|
138
|
-
const parsed = JSON.parse(
|
|
155
|
+
const parsed = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
|
|
139
156
|
if (!isPackageJSON(parsed)) {
|
|
140
157
|
throw new TypeError("Invalid package.json");
|
|
141
158
|
}
|
|
@@ -146,7 +163,7 @@ function isPackageJSON(value) {
|
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
// src/cli/check-file.ts
|
|
149
|
-
import
|
|
166
|
+
import fs5 from "node:fs";
|
|
150
167
|
|
|
151
168
|
// src/transform/apply-edits.ts
|
|
152
169
|
function applyEdits(src, edits) {
|
|
@@ -190,6 +207,27 @@ function collectChildNodes(node) {
|
|
|
190
207
|
return children;
|
|
191
208
|
}
|
|
192
209
|
|
|
210
|
+
// src/transform/collect-head-chain.ts
|
|
211
|
+
var HEAD_PROPERTY = {
|
|
212
|
+
CallExpression: "callee",
|
|
213
|
+
MemberExpression: "object",
|
|
214
|
+
ChainExpression: "expression",
|
|
215
|
+
AwaitExpression: "argument",
|
|
216
|
+
TSNonNullExpression: "expression",
|
|
217
|
+
ParenthesizedExpression: "expression"
|
|
218
|
+
};
|
|
219
|
+
function collectHeadChain(expression) {
|
|
220
|
+
const chain = [];
|
|
221
|
+
let current = expression;
|
|
222
|
+
while (current !== undefined) {
|
|
223
|
+
chain.push(current);
|
|
224
|
+
const property = HEAD_PROPERTY[current.type];
|
|
225
|
+
const inner = property === undefined ? undefined : current[property];
|
|
226
|
+
current = isASTNode(inner) ? inner : undefined;
|
|
227
|
+
}
|
|
228
|
+
return chain;
|
|
229
|
+
}
|
|
230
|
+
|
|
193
231
|
// src/transform/needs-blank-line.ts
|
|
194
232
|
var CONTROL_FLOW_TYPES = new Set([
|
|
195
233
|
"IfStatement",
|
|
@@ -204,19 +242,30 @@ function needsBlankLine(container, prev, next) {
|
|
|
204
242
|
if (container.type === "ClassBody") {
|
|
205
243
|
return true;
|
|
206
244
|
}
|
|
207
|
-
if (
|
|
245
|
+
if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
|
|
208
246
|
return true;
|
|
209
247
|
}
|
|
210
|
-
if (
|
|
248
|
+
if (isTypeDecl(prev) || isTypeDecl(next)) {
|
|
211
249
|
return true;
|
|
212
250
|
}
|
|
213
|
-
|
|
251
|
+
return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
|
|
252
|
+
}
|
|
253
|
+
function isRunEnd(prev, next) {
|
|
254
|
+
return isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next) || isDirective(prev) && !isDirective(next) || prev.type === "ImportDeclaration" && next.type !== "ImportDeclaration";
|
|
255
|
+
}
|
|
256
|
+
function isDirective(node) {
|
|
257
|
+
return typeof node["directive"] === "string";
|
|
258
|
+
}
|
|
259
|
+
var TYPE_DECL_TYPES = new Set(["TSTypeAliasDeclaration", "TSInterfaceDeclaration"]);
|
|
260
|
+
function isTypeDecl(node) {
|
|
261
|
+
if (TYPE_DECL_TYPES.has(node.type)) {
|
|
214
262
|
return true;
|
|
215
263
|
}
|
|
216
|
-
|
|
264
|
+
const declaration = node.declaration;
|
|
265
|
+
return node.type === "ExportNamedDeclaration" && isASTNode(declaration) && TYPE_DECL_TYPES.has(declaration.type);
|
|
217
266
|
}
|
|
218
267
|
function isNewHeaded(node) {
|
|
219
|
-
return getStatementValues(node).some((value) =>
|
|
268
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
|
|
220
269
|
}
|
|
221
270
|
function getStatementValues(node) {
|
|
222
271
|
if (node.type === "VariableDeclaration") {
|
|
@@ -232,44 +281,11 @@ function getStatementValues(node) {
|
|
|
232
281
|
}
|
|
233
282
|
return [expression];
|
|
234
283
|
}
|
|
235
|
-
var HEAD_PROPERTY = {
|
|
236
|
-
CallExpression: "callee",
|
|
237
|
-
MemberExpression: "object",
|
|
238
|
-
ChainExpression: "expression",
|
|
239
|
-
AwaitExpression: "argument",
|
|
240
|
-
TSNonNullExpression: "expression",
|
|
241
|
-
ParenthesizedExpression: "expression"
|
|
242
|
-
};
|
|
243
|
-
function getExpressionHead(node) {
|
|
244
|
-
let current = node;
|
|
245
|
-
let property = HEAD_PROPERTY[current.type];
|
|
246
|
-
while (property !== undefined) {
|
|
247
|
-
const inner = current[property];
|
|
248
|
-
if (!isASTNode(inner)) {
|
|
249
|
-
break;
|
|
250
|
-
}
|
|
251
|
-
current = inner;
|
|
252
|
-
property = HEAD_PROPERTY[current.type];
|
|
253
|
-
}
|
|
254
|
-
return current;
|
|
255
|
-
}
|
|
256
284
|
function isAwaitHeaded(node) {
|
|
257
285
|
if (node.type === "VariableDeclaration" && node.kind === "await using") {
|
|
258
286
|
return true;
|
|
259
287
|
}
|
|
260
|
-
return getStatementValues(node).some((value) =>
|
|
261
|
-
}
|
|
262
|
-
function hasAwaitInHeadChain(node) {
|
|
263
|
-
let current = node;
|
|
264
|
-
while (current !== undefined) {
|
|
265
|
-
if (current.type === "AwaitExpression") {
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
const property = HEAD_PROPERTY[current.type];
|
|
269
|
-
const inner = property === undefined ? undefined : current[property];
|
|
270
|
-
current = isASTNode(inner) ? inner : undefined;
|
|
271
|
-
}
|
|
272
|
-
return false;
|
|
288
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
|
|
273
289
|
}
|
|
274
290
|
var CALL_TYPES = ["CallExpression", "AwaitExpression"];
|
|
275
291
|
var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
|
|
@@ -286,10 +302,20 @@ function getStatementKind(node) {
|
|
|
286
302
|
return "declaration";
|
|
287
303
|
}
|
|
288
304
|
if (isExpressionStatementOf(node, CALL_TYPES)) {
|
|
289
|
-
return
|
|
305
|
+
return pickCallKind(node);
|
|
290
306
|
}
|
|
291
307
|
return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
|
|
292
308
|
}
|
|
309
|
+
function pickCallKind(node) {
|
|
310
|
+
const expression = node["expression"];
|
|
311
|
+
const callee = isASTNode(expression) ? findDeepestCallee(expression) : null;
|
|
312
|
+
return callee?.type === "MemberExpression" ? "method-call" : "bare-call";
|
|
313
|
+
}
|
|
314
|
+
function findDeepestCallee(expression) {
|
|
315
|
+
const deepestCall = collectHeadChain(expression).findLast((link) => link.type === "CallExpression");
|
|
316
|
+
const callee = deepestCall?.["callee"];
|
|
317
|
+
return isASTNode(callee) ? callee : null;
|
|
318
|
+
}
|
|
293
319
|
function isExpressionStatementOf(node, types) {
|
|
294
320
|
const expression = node["expression"];
|
|
295
321
|
return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
|
|
@@ -313,12 +339,12 @@ function isFnOrClassDecl(node) {
|
|
|
313
339
|
}
|
|
314
340
|
|
|
315
341
|
// src/transform/plan-gap-edit.ts
|
|
316
|
-
function planGapEdit(
|
|
317
|
-
const gap = buildGap(file, prev, next);
|
|
342
|
+
function planGapEdit(input) {
|
|
343
|
+
const gap = buildGap(input.file, input.prev, input.next);
|
|
318
344
|
if (!isSafeToResize(gap)) {
|
|
319
345
|
return null;
|
|
320
346
|
}
|
|
321
|
-
return planBlankLineEdit(gap);
|
|
347
|
+
return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
|
|
322
348
|
}
|
|
323
349
|
function buildGap(file, prev, next) {
|
|
324
350
|
const start = getNodeEnd(prev);
|
|
@@ -400,6 +426,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
|
|
|
400
426
|
function countNewlines(s) {
|
|
401
427
|
return (s.match(/\n/gu) ?? []).length;
|
|
402
428
|
}
|
|
429
|
+
function planCollapseEdit(gap) {
|
|
430
|
+
if (gap.comments.length > 0) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const text = gap.src.slice(gap.start, gap.end);
|
|
434
|
+
if (countNewlines(text) < MIN_NEWLINES) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const indent = text.slice(text.lastIndexOf(`
|
|
438
|
+
`) + 1);
|
|
439
|
+
return { start: gap.start, end: gap.end, replacement: `
|
|
440
|
+
${indent}` };
|
|
441
|
+
}
|
|
403
442
|
|
|
404
443
|
// src/transform/build-edits-from-ast.ts
|
|
405
444
|
function buildEditsFromAST(src, parsed) {
|
|
@@ -432,8 +471,9 @@ function buildPairEdits(file, container, body) {
|
|
|
432
471
|
for (let i = 0;i < body.length - 1; i++) {
|
|
433
472
|
const prev = body[i];
|
|
434
473
|
const next = body[i + 1];
|
|
435
|
-
if (prev !== undefined && next !== undefined && !isImportPair(prev, next)
|
|
436
|
-
const
|
|
474
|
+
if (prev !== undefined && next !== undefined && !isImportPair(prev, next)) {
|
|
475
|
+
const pad = needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next);
|
|
476
|
+
const edit = planGapEdit({ file, prev, next, pad });
|
|
437
477
|
if (edit !== null) {
|
|
438
478
|
edits.push(edit);
|
|
439
479
|
}
|
|
@@ -478,7 +518,7 @@ function transform(src, options) {
|
|
|
478
518
|
}
|
|
479
519
|
|
|
480
520
|
// src/cli/apply-diff-mode.ts
|
|
481
|
-
import
|
|
521
|
+
import fs4 from "node:fs";
|
|
482
522
|
|
|
483
523
|
// src/cli/build-unified-diff.ts
|
|
484
524
|
function buildUnifiedDiff(before, after, label) {
|
|
@@ -642,13 +682,13 @@ function applyDiffMode(edit, mode) {
|
|
|
642
682
|
if (mode === "dry") {
|
|
643
683
|
return { message: null, stdout: buildUnifiedDiff(edit.src, edit.result.output, edit.file) };
|
|
644
684
|
}
|
|
645
|
-
|
|
685
|
+
fs4.writeFileSync(edit.file, edit.result.output);
|
|
646
686
|
return { message: `WROTE ${edit.file} ${edit.result.edits} edit(s)`, stdout: null };
|
|
647
687
|
}
|
|
648
688
|
|
|
649
689
|
// src/cli/check-file.ts
|
|
650
690
|
function checkFile(file, mode) {
|
|
651
|
-
const src =
|
|
691
|
+
const src = fs5.readFileSync(file, "utf8");
|
|
652
692
|
const bytes = Buffer.byteLength(src);
|
|
653
693
|
const result = transform(src, { filename: file });
|
|
654
694
|
if (result.parseError !== null) {
|
|
@@ -701,7 +741,8 @@ if (parsedArgs.version) {
|
|
|
701
741
|
process.exit(0);
|
|
702
742
|
}
|
|
703
743
|
var t0 = Date.now();
|
|
704
|
-
var
|
|
744
|
+
var ignore = [...loadFormatIgnore(process.cwd()), ...parsedArgs.ignore];
|
|
745
|
+
var files = await expandInputs(inputs, ignore);
|
|
705
746
|
if (files.length === 0) {
|
|
706
747
|
console.error("no files matched");
|
|
707
748
|
process.exit(2);
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,27 @@ function collectChildNodes(node) {
|
|
|
40
40
|
return children;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// src/transform/collect-head-chain.ts
|
|
44
|
+
var HEAD_PROPERTY = {
|
|
45
|
+
CallExpression: "callee",
|
|
46
|
+
MemberExpression: "object",
|
|
47
|
+
ChainExpression: "expression",
|
|
48
|
+
AwaitExpression: "argument",
|
|
49
|
+
TSNonNullExpression: "expression",
|
|
50
|
+
ParenthesizedExpression: "expression"
|
|
51
|
+
};
|
|
52
|
+
function collectHeadChain(expression) {
|
|
53
|
+
const chain = [];
|
|
54
|
+
let current = expression;
|
|
55
|
+
while (current !== undefined) {
|
|
56
|
+
chain.push(current);
|
|
57
|
+
const property = HEAD_PROPERTY[current.type];
|
|
58
|
+
const inner = property === undefined ? undefined : current[property];
|
|
59
|
+
current = isASTNode(inner) ? inner : undefined;
|
|
60
|
+
}
|
|
61
|
+
return chain;
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
// src/transform/needs-blank-line.ts
|
|
44
65
|
var CONTROL_FLOW_TYPES = new Set([
|
|
45
66
|
"IfStatement",
|
|
@@ -54,19 +75,30 @@ function needsBlankLine(container, prev, next) {
|
|
|
54
75
|
if (container.type === "ClassBody") {
|
|
55
76
|
return true;
|
|
56
77
|
}
|
|
57
|
-
if (
|
|
78
|
+
if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
|
|
58
79
|
return true;
|
|
59
80
|
}
|
|
60
|
-
if (
|
|
81
|
+
if (isTypeDecl(prev) || isTypeDecl(next)) {
|
|
61
82
|
return true;
|
|
62
83
|
}
|
|
63
|
-
|
|
84
|
+
return isNewHeaded(prev) !== isNewHeaded(next) || isAwaitHeaded(prev) !== isAwaitHeaded(next) || isKindBoundary(prev, next) || CONTROL_FLOW_TYPES.has(next.type) || CONTROL_FLOW_TYPES.has(prev.type);
|
|
85
|
+
}
|
|
86
|
+
function isRunEnd(prev, next) {
|
|
87
|
+
return isVarDecl(prev) && !isVarDecl(next) || isUsingDecl(prev) && !isUsingDecl(next) || isDirective(prev) && !isDirective(next) || prev.type === "ImportDeclaration" && next.type !== "ImportDeclaration";
|
|
88
|
+
}
|
|
89
|
+
function isDirective(node) {
|
|
90
|
+
return typeof node["directive"] === "string";
|
|
91
|
+
}
|
|
92
|
+
var TYPE_DECL_TYPES = new Set(["TSTypeAliasDeclaration", "TSInterfaceDeclaration"]);
|
|
93
|
+
function isTypeDecl(node) {
|
|
94
|
+
if (TYPE_DECL_TYPES.has(node.type)) {
|
|
64
95
|
return true;
|
|
65
96
|
}
|
|
66
|
-
|
|
97
|
+
const declaration = node.declaration;
|
|
98
|
+
return node.type === "ExportNamedDeclaration" && isASTNode(declaration) && TYPE_DECL_TYPES.has(declaration.type);
|
|
67
99
|
}
|
|
68
100
|
function isNewHeaded(node) {
|
|
69
|
-
return getStatementValues(node).some((value) =>
|
|
101
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
|
|
70
102
|
}
|
|
71
103
|
function getStatementValues(node) {
|
|
72
104
|
if (node.type === "VariableDeclaration") {
|
|
@@ -82,44 +114,11 @@ function getStatementValues(node) {
|
|
|
82
114
|
}
|
|
83
115
|
return [expression];
|
|
84
116
|
}
|
|
85
|
-
var HEAD_PROPERTY = {
|
|
86
|
-
CallExpression: "callee",
|
|
87
|
-
MemberExpression: "object",
|
|
88
|
-
ChainExpression: "expression",
|
|
89
|
-
AwaitExpression: "argument",
|
|
90
|
-
TSNonNullExpression: "expression",
|
|
91
|
-
ParenthesizedExpression: "expression"
|
|
92
|
-
};
|
|
93
|
-
function getExpressionHead(node) {
|
|
94
|
-
let current = node;
|
|
95
|
-
let property = HEAD_PROPERTY[current.type];
|
|
96
|
-
while (property !== undefined) {
|
|
97
|
-
const inner = current[property];
|
|
98
|
-
if (!isASTNode(inner)) {
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
current = inner;
|
|
102
|
-
property = HEAD_PROPERTY[current.type];
|
|
103
|
-
}
|
|
104
|
-
return current;
|
|
105
|
-
}
|
|
106
117
|
function isAwaitHeaded(node) {
|
|
107
118
|
if (node.type === "VariableDeclaration" && node.kind === "await using") {
|
|
108
119
|
return true;
|
|
109
120
|
}
|
|
110
|
-
return getStatementValues(node).some((value) =>
|
|
111
|
-
}
|
|
112
|
-
function hasAwaitInHeadChain(node) {
|
|
113
|
-
let current = node;
|
|
114
|
-
while (current !== undefined) {
|
|
115
|
-
if (current.type === "AwaitExpression") {
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
const property = HEAD_PROPERTY[current.type];
|
|
119
|
-
const inner = property === undefined ? undefined : current[property];
|
|
120
|
-
current = isASTNode(inner) ? inner : undefined;
|
|
121
|
-
}
|
|
122
|
-
return false;
|
|
121
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
|
|
123
122
|
}
|
|
124
123
|
var CALL_TYPES = ["CallExpression", "AwaitExpression"];
|
|
125
124
|
var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
|
|
@@ -136,10 +135,20 @@ function getStatementKind(node) {
|
|
|
136
135
|
return "declaration";
|
|
137
136
|
}
|
|
138
137
|
if (isExpressionStatementOf(node, CALL_TYPES)) {
|
|
139
|
-
return
|
|
138
|
+
return pickCallKind(node);
|
|
140
139
|
}
|
|
141
140
|
return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
|
|
142
141
|
}
|
|
142
|
+
function pickCallKind(node) {
|
|
143
|
+
const expression = node["expression"];
|
|
144
|
+
const callee = isASTNode(expression) ? findDeepestCallee(expression) : null;
|
|
145
|
+
return callee?.type === "MemberExpression" ? "method-call" : "bare-call";
|
|
146
|
+
}
|
|
147
|
+
function findDeepestCallee(expression) {
|
|
148
|
+
const deepestCall = collectHeadChain(expression).findLast((link) => link.type === "CallExpression");
|
|
149
|
+
const callee = deepestCall?.["callee"];
|
|
150
|
+
return isASTNode(callee) ? callee : null;
|
|
151
|
+
}
|
|
143
152
|
function isExpressionStatementOf(node, types) {
|
|
144
153
|
const expression = node["expression"];
|
|
145
154
|
return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
|
|
@@ -163,12 +172,12 @@ function isFnOrClassDecl(node) {
|
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
// src/transform/plan-gap-edit.ts
|
|
166
|
-
function planGapEdit(
|
|
167
|
-
const gap = buildGap(file, prev, next);
|
|
175
|
+
function planGapEdit(input) {
|
|
176
|
+
const gap = buildGap(input.file, input.prev, input.next);
|
|
168
177
|
if (!isSafeToResize(gap)) {
|
|
169
178
|
return null;
|
|
170
179
|
}
|
|
171
|
-
return planBlankLineEdit(gap);
|
|
180
|
+
return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
|
|
172
181
|
}
|
|
173
182
|
function buildGap(file, prev, next) {
|
|
174
183
|
const start = getNodeEnd(prev);
|
|
@@ -250,6 +259,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
|
|
|
250
259
|
function countNewlines(s) {
|
|
251
260
|
return (s.match(/\n/gu) ?? []).length;
|
|
252
261
|
}
|
|
262
|
+
function planCollapseEdit(gap) {
|
|
263
|
+
if (gap.comments.length > 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const text = gap.src.slice(gap.start, gap.end);
|
|
267
|
+
if (countNewlines(text) < MIN_NEWLINES) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const indent = text.slice(text.lastIndexOf(`
|
|
271
|
+
`) + 1);
|
|
272
|
+
return { start: gap.start, end: gap.end, replacement: `
|
|
273
|
+
${indent}` };
|
|
274
|
+
}
|
|
253
275
|
|
|
254
276
|
// src/transform/build-edits-from-ast.ts
|
|
255
277
|
function buildEditsFromAST(src, parsed) {
|
|
@@ -282,8 +304,9 @@ function buildPairEdits(file, container, body) {
|
|
|
282
304
|
for (let i = 0;i < body.length - 1; i++) {
|
|
283
305
|
const prev = body[i];
|
|
284
306
|
const next = body[i + 1];
|
|
285
|
-
if (prev !== undefined && next !== undefined && !isImportPair(prev, next)
|
|
286
|
-
const
|
|
307
|
+
if (prev !== undefined && next !== undefined && !isImportPair(prev, next)) {
|
|
308
|
+
const pad = needsBlankLine(container, prev, next) || isMultiline(file.src, prev) || isMultiline(file.src, next);
|
|
309
|
+
const edit = planGapEdit({ file, prev, next, pad });
|
|
287
310
|
if (edit !== null) {
|
|
288
311
|
edits.push(edit);
|
|
289
312
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ASTNode } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The nodes on the walk from an expression to its head, outermost first and
|
|
4
|
+
* ending at the head itself — `(await f()).prop` yields the member access,
|
|
5
|
+
* the parenthesis, the await, the call, and finally `f`. Nodes outside the
|
|
6
|
+
* walk (call arguments, ternary branches) never appear, so a scan of the
|
|
7
|
+
* chain sees only what a reader meets before the expression's first token.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectHeadChain(expression: ASTNode): ASTNode[];
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ASTNode } from '../types.ts';
|
|
2
2
|
/**
|
|
3
3
|
* The "always" rules: a blank line between class members, after a var or
|
|
4
|
-
* using block before a statement of any other kind,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* using block before a statement of any other kind, after a directive
|
|
5
|
+
* prologue, after the last import of a block, before a return, after a
|
|
6
|
+
* function/class declaration, on both sides of a type alias or interface
|
|
7
|
+
* declaration, on both sides of a control-flow block — its closing brace
|
|
8
|
+
* ends a visual unit just like its opening keyword starts one —
|
|
9
|
+
* and at the boundary between statement kinds: bare call vs method call vs
|
|
10
|
+
* mutation, instantiation vs anything else, and awaited vs non-awaited. Any
|
|
11
|
+
* match means exactly one blank line; a pair matching no rule sits flush.
|
|
10
12
|
*/
|
|
11
13
|
export declare function needsBlankLine(container: ASTNode, prev: ASTNode, next: ASTNode): boolean;
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import type { ASTNode, Edit, SourceFile } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A pair of adjacent statements in a file and the gap shape they take: one
|
|
4
|
+
* blank line between them when `pad` is set, none otherwise.
|
|
5
|
+
*/
|
|
6
|
+
export interface GapEditInput {
|
|
7
|
+
readonly file: SourceFile;
|
|
8
|
+
readonly prev: ASTNode;
|
|
9
|
+
readonly next: ASTNode;
|
|
10
|
+
readonly pad: boolean;
|
|
11
|
+
}
|
|
2
12
|
/**
|
|
3
13
|
* Plans the single whitespace splice that gives the gap between two statements
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
14
|
+
* its target shape, or null when the gap is already compliant or unsafe to
|
|
15
|
+
* touch. Collapsing never crosses a comment: prose between statements marks
|
|
16
|
+
* grouping the padding rules can't see, so a comment-bearing gap only ever
|
|
17
|
+
* grows. Comment positions come from the parser rather than lexical scanning,
|
|
18
|
+
* so comment-lookalike text can't mislead the classification.
|
|
7
19
|
*/
|
|
8
|
-
export declare function planGapEdit(
|
|
20
|
+
export declare function planGapEdit(input: GapEditInput): Edit | null;
|