@zgeoff/format-codemod 0.0.5 → 0.0.7
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 +78 -84
- package/dist/cli.js +70 -47
- 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,49 +1,67 @@
|
|
|
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
|
|
|
@@ -59,67 +77,43 @@ format-codemod [options] <file|dir|glob> ...
|
|
|
59
77
|
--help
|
|
60
78
|
```
|
|
61
79
|
|
|
62
|
-
Flags are strict: an unknown flag is a usage error
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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).
|
|
66
84
|
|
|
67
85
|
```bash
|
|
68
86
|
format-codemod 'src/**/*.{ts,tsx}' # rewrite in place
|
|
69
87
|
format-codemod --check 'src/**/*.{ts,tsx}' # CI gate
|
|
70
|
-
format-codemod --dry path/to/file.ts # preview diff
|
|
88
|
+
format-codemod --dry path/to/file.ts # preview diff; applies cleanly with patch(1)
|
|
71
89
|
format-codemod . # whole repo, in place
|
|
72
90
|
```
|
|
73
91
|
|
|
74
|
-
|
|
75
|
-
heuristic runs, so bracketed names (Next.js routes like `[slug]/page.tsx`) are handled correctly
|
|
76
|
-
rather than read as glob character classes. A directory argument expands to `**/*.{ts,tsx}` beneath
|
|
77
|
-
it; `node_modules` and `.git` are never descended into, and overlapping patterns are deduped so a
|
|
78
|
-
file is processed once.
|
|
92
|
+
### Inputs
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
82
101
|
|
|
83
102
|
### Ignoring files
|
|
84
103
|
|
|
85
|
-
A `.formatignore` file in the working directory supplies ignore globs
|
|
86
|
-
|
|
104
|
+
A `.formatignore` file in the working directory supplies ignore globs — one per line, blank lines
|
|
105
|
+
and `#`-prefixed comment lines skipped:
|
|
87
106
|
|
|
88
107
|
```gitignore
|
|
89
108
|
# committed codegen
|
|
90
109
|
src/generated/**
|
|
91
|
-
|
|
92
|
-
# build output
|
|
93
110
|
dist/**
|
|
94
111
|
```
|
|
95
112
|
|
|
96
|
-
Each line uses the same
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
## Dialect handling
|
|
102
|
-
|
|
103
|
-
The filename picks the parse dialect: `.tsx` enables JSX, everything else parses as plain
|
|
104
|
-
TypeScript. That means `.ts`-only syntax — old-style type assertions (`<string>value`), un-comma'd
|
|
105
|
-
generic arrows (`<T>(a: T) => a`) — parses correctly instead of being misread as JSX.
|
|
106
|
-
|
|
107
|
-
## Trivia handling
|
|
108
|
-
|
|
109
|
-
Comment positions come from the parser, not lexical scanning:
|
|
110
|
-
|
|
111
|
-
- Trailing same-line comments stay attached to the previous statement: `const X = 1; // note` keeps
|
|
112
|
-
the blank line _after_ the comment (several same-line comments all stay together).
|
|
113
|
-
- Leading comments stay attached to the next statement: the blank line goes _before_ the comment.
|
|
114
|
-
- Indentation of the next statement is preserved.
|
|
115
|
-
- Two statements that share a physical line are left untouched — for example a leading-semicolon
|
|
116
|
-
`;(expr)` whose `;` terminates the previous statement. A blank-only edit cannot separate them
|
|
117
|
-
without orphaning a token, so the codemod skips the pair.
|
|
118
|
-
|
|
119
|
-
## Idempotent
|
|
120
|
-
|
|
121
|
-
Running the codemod twice produces the same output as running it once. A run on already-compliant
|
|
122
|
-
code is a no-op.
|
|
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.
|
|
123
117
|
|
|
124
118
|
## Programmatic use
|
|
125
119
|
|
|
@@ -131,5 +125,5 @@ const { output, edits, parseError } = transform(source, { filename: 'component.t
|
|
|
131
125
|
// parseError: error message if the source could not be parsed; source returned untouched
|
|
132
126
|
```
|
|
133
127
|
|
|
134
|
-
`filename` is optional and only
|
|
135
|
-
|
|
128
|
+
`filename` is optional and only picks the dialect (default `'source.ts'` — plain TypeScript, no
|
|
129
|
+
JSX); nothing is read from disk.
|
package/dist/cli.js
CHANGED
|
@@ -207,6 +207,27 @@ function collectChildNodes(node) {
|
|
|
207
207
|
return children;
|
|
208
208
|
}
|
|
209
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
|
+
|
|
210
231
|
// src/transform/needs-blank-line.ts
|
|
211
232
|
var CONTROL_FLOW_TYPES = new Set([
|
|
212
233
|
"IfStatement",
|
|
@@ -221,19 +242,30 @@ function needsBlankLine(container, prev, next) {
|
|
|
221
242
|
if (container.type === "ClassBody") {
|
|
222
243
|
return true;
|
|
223
244
|
}
|
|
224
|
-
if (
|
|
245
|
+
if (isRunEnd(prev, next) || next.type === "ReturnStatement" || isFnOrClassDecl(prev)) {
|
|
225
246
|
return true;
|
|
226
247
|
}
|
|
227
|
-
if (
|
|
248
|
+
if (isTypeDecl(prev) || isTypeDecl(next)) {
|
|
228
249
|
return true;
|
|
229
250
|
}
|
|
230
|
-
|
|
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)) {
|
|
231
262
|
return true;
|
|
232
263
|
}
|
|
233
|
-
|
|
264
|
+
const declaration = node.declaration;
|
|
265
|
+
return node.type === "ExportNamedDeclaration" && isASTNode(declaration) && TYPE_DECL_TYPES.has(declaration.type);
|
|
234
266
|
}
|
|
235
267
|
function isNewHeaded(node) {
|
|
236
|
-
return getStatementValues(node).some((value) =>
|
|
268
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).at(-1)?.type === "NewExpression");
|
|
237
269
|
}
|
|
238
270
|
function getStatementValues(node) {
|
|
239
271
|
if (node.type === "VariableDeclaration") {
|
|
@@ -249,44 +281,11 @@ function getStatementValues(node) {
|
|
|
249
281
|
}
|
|
250
282
|
return [expression];
|
|
251
283
|
}
|
|
252
|
-
var HEAD_PROPERTY = {
|
|
253
|
-
CallExpression: "callee",
|
|
254
|
-
MemberExpression: "object",
|
|
255
|
-
ChainExpression: "expression",
|
|
256
|
-
AwaitExpression: "argument",
|
|
257
|
-
TSNonNullExpression: "expression",
|
|
258
|
-
ParenthesizedExpression: "expression"
|
|
259
|
-
};
|
|
260
|
-
function getExpressionHead(node) {
|
|
261
|
-
let current = node;
|
|
262
|
-
let property = HEAD_PROPERTY[current.type];
|
|
263
|
-
while (property !== undefined) {
|
|
264
|
-
const inner = current[property];
|
|
265
|
-
if (!isASTNode(inner)) {
|
|
266
|
-
break;
|
|
267
|
-
}
|
|
268
|
-
current = inner;
|
|
269
|
-
property = HEAD_PROPERTY[current.type];
|
|
270
|
-
}
|
|
271
|
-
return current;
|
|
272
|
-
}
|
|
273
284
|
function isAwaitHeaded(node) {
|
|
274
285
|
if (node.type === "VariableDeclaration" && node.kind === "await using") {
|
|
275
286
|
return true;
|
|
276
287
|
}
|
|
277
|
-
return getStatementValues(node).some((value) =>
|
|
278
|
-
}
|
|
279
|
-
function hasAwaitInHeadChain(node) {
|
|
280
|
-
let current = node;
|
|
281
|
-
while (current !== undefined) {
|
|
282
|
-
if (current.type === "AwaitExpression") {
|
|
283
|
-
return true;
|
|
284
|
-
}
|
|
285
|
-
const property = HEAD_PROPERTY[current.type];
|
|
286
|
-
const inner = property === undefined ? undefined : current[property];
|
|
287
|
-
current = isASTNode(inner) ? inner : undefined;
|
|
288
|
-
}
|
|
289
|
-
return false;
|
|
288
|
+
return getStatementValues(node).some((value) => collectHeadChain(value).some((link) => link.type === "AwaitExpression"));
|
|
290
289
|
}
|
|
291
290
|
var CALL_TYPES = ["CallExpression", "AwaitExpression"];
|
|
292
291
|
var MUTATION_TYPES = ["AssignmentExpression", "UpdateExpression"];
|
|
@@ -303,10 +302,20 @@ function getStatementKind(node) {
|
|
|
303
302
|
return "declaration";
|
|
304
303
|
}
|
|
305
304
|
if (isExpressionStatementOf(node, CALL_TYPES)) {
|
|
306
|
-
return
|
|
305
|
+
return pickCallKind(node);
|
|
307
306
|
}
|
|
308
307
|
return isExpressionStatementOf(node, MUTATION_TYPES) ? "mutation" : null;
|
|
309
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
|
+
}
|
|
310
319
|
function isExpressionStatementOf(node, types) {
|
|
311
320
|
const expression = node["expression"];
|
|
312
321
|
return node.type === "ExpressionStatement" && isASTNode(expression) && types.includes(expression.type);
|
|
@@ -330,12 +339,12 @@ function isFnOrClassDecl(node) {
|
|
|
330
339
|
}
|
|
331
340
|
|
|
332
341
|
// src/transform/plan-gap-edit.ts
|
|
333
|
-
function planGapEdit(
|
|
334
|
-
const gap = buildGap(file, prev, next);
|
|
342
|
+
function planGapEdit(input) {
|
|
343
|
+
const gap = buildGap(input.file, input.prev, input.next);
|
|
335
344
|
if (!isSafeToResize(gap)) {
|
|
336
345
|
return null;
|
|
337
346
|
}
|
|
338
|
-
return planBlankLineEdit(gap);
|
|
347
|
+
return input.pad ? planBlankLineEdit(gap) : planCollapseEdit(gap);
|
|
339
348
|
}
|
|
340
349
|
function buildGap(file, prev, next) {
|
|
341
350
|
const start = getNodeEnd(prev);
|
|
@@ -417,6 +426,19 @@ function planWhitespaceGap(gap, gapStart, gapEnd) {
|
|
|
417
426
|
function countNewlines(s) {
|
|
418
427
|
return (s.match(/\n/gu) ?? []).length;
|
|
419
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
|
+
}
|
|
420
442
|
|
|
421
443
|
// src/transform/build-edits-from-ast.ts
|
|
422
444
|
function buildEditsFromAST(src, parsed) {
|
|
@@ -449,8 +471,9 @@ function buildPairEdits(file, container, body) {
|
|
|
449
471
|
for (let i = 0;i < body.length - 1; i++) {
|
|
450
472
|
const prev = body[i];
|
|
451
473
|
const next = body[i + 1];
|
|
452
|
-
if (prev !== undefined && next !== undefined && !isImportPair(prev, next)
|
|
453
|
-
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 });
|
|
454
477
|
if (edit !== null) {
|
|
455
478
|
edits.push(edit);
|
|
456
479
|
}
|
|
@@ -527,10 +550,10 @@ class MyersDiff {
|
|
|
527
550
|
this.b = b;
|
|
528
551
|
}
|
|
529
552
|
buildOps() {
|
|
530
|
-
this.
|
|
553
|
+
this.updateTrace();
|
|
531
554
|
return this.backtrack();
|
|
532
555
|
}
|
|
533
|
-
|
|
556
|
+
updateTrace() {
|
|
534
557
|
for (let d = 0;d <= this.a.length + this.b.length; d++) {
|
|
535
558
|
this.trace.push(new Map(this.v));
|
|
536
559
|
if (this.stepRound(d)) {
|
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;
|