@onrails/codemod 0.1.3 → 0.2.0
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/package.json +4 -4
- package/src/chains.ts +39 -21
- package/src/cli.ts +10 -10
- package/src/file-change.ts +11 -2
- package/src/index.ts +1 -0
- package/src/switch.ts +209 -0
- package/src/tersify.ts +470 -0
- package/src/types.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/codemod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Codemods for migrating onto @onrails/result (neverthrow → compat shim, and beyond)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alan R. Soares",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
".": "./src/index.ts"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@onrails/maybe": "0.1.
|
|
46
|
-
"@onrails/pattern": "0.1.
|
|
47
|
-
"@onrails/result": "0.1.
|
|
45
|
+
"@onrails/maybe": "0.1.4",
|
|
46
|
+
"@onrails/pattern": "0.1.4",
|
|
47
|
+
"@onrails/result": "0.1.4",
|
|
48
48
|
"typescript": "^6.0.3"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
package/src/chains.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { compactMap, isSome, type Maybe, map, none, some } from "@onrails/maybe";
|
|
2
|
+
import { match } from "@onrails/pattern";
|
|
2
3
|
import ts from "typescript";
|
|
3
4
|
import { argsToText, edit, lookupMap, spanEdit, walkSource } from "./ast.js";
|
|
4
5
|
import {
|
|
@@ -22,6 +23,21 @@ export function isSupportedChainCall(node: ts.Node): node is ts.CallExpression {
|
|
|
22
23
|
return CHAIN_METHODS.has(method) || TERMINAL_METHODS.has(method);
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
const HELPERS = [
|
|
27
|
+
"sequenceTupleAsync",
|
|
28
|
+
"getOrElse",
|
|
29
|
+
"collect",
|
|
30
|
+
"matchResult",
|
|
31
|
+
"matchMaybe",
|
|
32
|
+
"fold",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
type HelperName = (typeof HELPERS)[number];
|
|
36
|
+
|
|
37
|
+
function isHelperName(x: string): x is HelperName {
|
|
38
|
+
return (HELPERS as readonly string[]).includes(x);
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
function identifierCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
26
42
|
if (!ts.isIdentifier(node.expression)) return none();
|
|
27
43
|
const name = node.expression.text;
|
|
@@ -30,24 +46,25 @@ function identifierCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
|
30
46
|
if (isSome(zero)) return zero;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
|
-
if (name
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
if (name === "getOrElse") {
|
|
37
|
-
return some(edit(`unwrapOr(${argsToText(node.arguments)})`, ["unwrapOr"]));
|
|
38
|
-
}
|
|
39
|
-
if (name === "collect") {
|
|
40
|
-
return some(edit(`combine(${argsToText(node.arguments)})`, ["combine"]));
|
|
41
|
-
}
|
|
42
|
-
if (name === "matchResult" || name === "matchMaybe") {
|
|
43
|
-
return some(edit(`match(${argsToText(node.arguments)})`, ["match"]));
|
|
44
|
-
}
|
|
45
|
-
if (name === "fold") {
|
|
46
|
-
return map(foldHandlerTexts(node.arguments[0]), ({ okText, errText }) =>
|
|
47
|
-
edit(`match(${okText}, ${errText})`, ["match"]),
|
|
48
|
-
);
|
|
49
|
+
if (!isHelperName(name)) {
|
|
50
|
+
return none();
|
|
49
51
|
}
|
|
50
|
-
|
|
52
|
+
|
|
53
|
+
return match(name)
|
|
54
|
+
.with("sequenceTupleAsync", () =>
|
|
55
|
+
some(edit(`ResultAsync.combineTuple(${argsToText(node.arguments)})`, ["ResultAsync"])),
|
|
56
|
+
)
|
|
57
|
+
.with("getOrElse", () => some(edit(`unwrapOr(${argsToText(node.arguments)})`, ["unwrapOr"])))
|
|
58
|
+
.with("collect", () => some(edit(`combine(${argsToText(node.arguments)})`, ["combine"])))
|
|
59
|
+
.withOneOf(["matchResult", "matchMaybe"], () =>
|
|
60
|
+
some(edit(`match(${argsToText(node.arguments)})`, ["match"])),
|
|
61
|
+
)
|
|
62
|
+
.with("fold", () =>
|
|
63
|
+
map(foldHandlerTexts(node.arguments[0]), ({ okText, errText }) =>
|
|
64
|
+
edit(`match(${okText}, ${errText})`, ["match"]),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
.exhaustive();
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
type FoldHandlerTexts = { okText: string; errText: string };
|
|
@@ -85,10 +102,11 @@ function curriedFoldToNative(node: ts.CallExpression): Maybe<Edit> {
|
|
|
85
102
|
return none();
|
|
86
103
|
}
|
|
87
104
|
const receiver = node.arguments[0];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
return !receiver
|
|
106
|
+
? none()
|
|
107
|
+
: map(foldHandlerTexts(node.expression.arguments[0]), ({ okText, errText }) =>
|
|
108
|
+
edit(`match(${okText}, ${errText})(${receiver.getText()})`, ["match"]),
|
|
109
|
+
);
|
|
92
110
|
}
|
|
93
111
|
|
|
94
112
|
export function helperCallToNative(node: ts.CallExpression): Maybe<Edit> {
|
package/src/cli.ts
CHANGED
|
@@ -16,6 +16,7 @@ const applyArg = (s: ArgState, a: string): ArgState =>
|
|
|
16
16
|
.returnType<ArgState>()
|
|
17
17
|
.withEither("--dry", "-n", () => ({ ...s, dry: true }))
|
|
18
18
|
.with("--to-native", () => ({ ...s, mode: "native" }))
|
|
19
|
+
.with("--tersify", () => ({ ...s, mode: "tersify" }))
|
|
19
20
|
.with(
|
|
20
21
|
(x) => x.startsWith(ONRAILS_FLAG),
|
|
21
22
|
(x) => ({ ...s, onrails: resolve(x.slice(ONRAILS_FLAG.length)) }),
|
|
@@ -27,7 +28,7 @@ const applyArg = (s: ArgState, a: string): ArgState =>
|
|
|
27
28
|
.otherwise(() => s);
|
|
28
29
|
|
|
29
30
|
const USAGE =
|
|
30
|
-
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--onrails=<abs-path>]";
|
|
31
|
+
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--tersify] [--onrails=<abs-path>]";
|
|
31
32
|
|
|
32
33
|
export function parseArgs(argv: string[]): Result<Args, string> {
|
|
33
34
|
const initial: ArgState = {
|
|
@@ -37,15 +38,14 @@ export function parseArgs(argv: string[]): Result<Args, string> {
|
|
|
37
38
|
onrails: resolve(import.meta.dir, "../../..", "packages/result"),
|
|
38
39
|
};
|
|
39
40
|
const { positional, dry, mode, onrails } = argv.reduce(applyArg, initial);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
41
|
+
return positional.length !== 1
|
|
42
|
+
? err(USAGE)
|
|
43
|
+
: ok({
|
|
44
|
+
target: resolve(positional[0] ?? "."),
|
|
45
|
+
dry,
|
|
46
|
+
onrails,
|
|
47
|
+
mode,
|
|
48
|
+
});
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
const shouldSkip = (path: string) => path.split("/").some((seg) => SKIP.has(seg));
|
package/src/file-change.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { concatCollectors, countOccurrences } from "./ast.js";
|
|
|
4
4
|
import { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
5
5
|
import { COMPAT_SPEC, IMPORT_RE, NATIVE_SPEC } from "./constants.js";
|
|
6
6
|
import { rewriteCompatImportsToNative } from "./imports.js";
|
|
7
|
+
import { tersify } from "./tersify.js";
|
|
7
8
|
import type { ComputedChange, FileChange, Mode, ModeStrategy } from "./types.js";
|
|
8
9
|
import {
|
|
9
10
|
collectNativeMigrationWarnings,
|
|
@@ -35,6 +36,13 @@ const MODES: Record<Mode, ModeStrategy> = {
|
|
|
35
36
|
warnings: collectAllNativeWarnings,
|
|
36
37
|
countAfter: (next) => countOccurrences(next, NATIVE_SPEC),
|
|
37
38
|
},
|
|
39
|
+
tersify: {
|
|
40
|
+
countBefore: () => 0,
|
|
41
|
+
earlyExit: () => false,
|
|
42
|
+
transform: tersify,
|
|
43
|
+
warnings: () => [],
|
|
44
|
+
countAfter: () => 0,
|
|
45
|
+
},
|
|
38
46
|
};
|
|
39
47
|
|
|
40
48
|
export const computeFileChange = (src: string, mode: Mode): Maybe<ComputedChange> => {
|
|
@@ -44,8 +52,9 @@ export const computeFileChange = (src: string, mode: Mode): Maybe<ComputedChange
|
|
|
44
52
|
const next = strat.transform(src);
|
|
45
53
|
const changed = next !== src;
|
|
46
54
|
const warnings = strat.warnings(next);
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
return !changed && warnings.length === 0
|
|
56
|
+
? none()
|
|
57
|
+
: some({ next, before, after: strat.countAfter(next), changed, warnings });
|
|
49
58
|
};
|
|
50
59
|
|
|
51
60
|
export const toFileChange = (path: string, c: ComputedChange): FileChange => ({
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { main } from "./cli.js";
|
|
|
6
6
|
export { computeFileChange } from "./file-change.js";
|
|
7
7
|
export { rewriteCompatImportsToNative } from "./imports.js";
|
|
8
8
|
export { computePkgRewrite } from "./pkg.js";
|
|
9
|
+
export { tersify } from "./tersify.js";
|
|
9
10
|
export { collectNativeMigrationWarnings } from "./warnings.js";
|
|
10
11
|
|
|
11
12
|
if (import.meta.main) await main();
|
package/src/switch.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Edit } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface Comparison {
|
|
5
|
+
target: ts.Expression;
|
|
6
|
+
literal: ts.Expression;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getComparison(node: ts.Expression): Comparison | undefined {
|
|
10
|
+
if (
|
|
11
|
+
ts.isBinaryExpression(node) &&
|
|
12
|
+
node.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken
|
|
13
|
+
) {
|
|
14
|
+
const left = node.left;
|
|
15
|
+
const right = node.right;
|
|
16
|
+
const isLit = (n: ts.Expression) =>
|
|
17
|
+
ts.isLiteralExpression(n) ||
|
|
18
|
+
n.kind === ts.SyntaxKind.TrueKeyword ||
|
|
19
|
+
n.kind === ts.SyntaxKind.FalseKeyword ||
|
|
20
|
+
n.kind === ts.SyntaxKind.NullKeyword ||
|
|
21
|
+
(ts.isIdentifier(n) && n.text === "undefined");
|
|
22
|
+
|
|
23
|
+
if (isLit(left)) {
|
|
24
|
+
return { target: right, literal: left };
|
|
25
|
+
}
|
|
26
|
+
if (isLit(right)) {
|
|
27
|
+
return { target: left, literal: right };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getComparisons(node: ts.Expression, list: Comparison[] = []): Comparison[] | undefined {
|
|
34
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
|
|
35
|
+
const left = getComparisons(node.left, list);
|
|
36
|
+
if (!left) return undefined;
|
|
37
|
+
const right = getComparisons(node.right, list);
|
|
38
|
+
return !right ? undefined : list;
|
|
39
|
+
}
|
|
40
|
+
const comp = getComparison(node);
|
|
41
|
+
if (comp) {
|
|
42
|
+
list.push(comp);
|
|
43
|
+
return list;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isSimpleTarget(node: ts.Expression): boolean {
|
|
49
|
+
if (ts.isIdentifier(node)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
53
|
+
return isSimpleTarget(node.expression);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function endsWithReturn(statement: ts.Statement): boolean {
|
|
59
|
+
if (ts.isReturnStatement(statement)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (ts.isBlock(statement)) {
|
|
63
|
+
const last = statement.statements[statement.statements.length - 1];
|
|
64
|
+
return last ? endsWithReturn(last) : false;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getLinePrefix(pos: number, sf: ts.SourceFile): string {
|
|
70
|
+
const lineStarts = sf.getLineStarts();
|
|
71
|
+
const { line } = ts.getLineAndCharacterOfPosition(sf, pos);
|
|
72
|
+
const lineStart = lineStarts[line];
|
|
73
|
+
if (lineStart === undefined) return "";
|
|
74
|
+
const lineText = sf.text.slice(lineStart, pos);
|
|
75
|
+
const match = /^[ \t]+$/.exec(lineText);
|
|
76
|
+
return match ? lineText : "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SwitchSequence {
|
|
80
|
+
seq: ts.IfStatement[];
|
|
81
|
+
targetText: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findSwitchSequence(
|
|
85
|
+
statements: readonly ts.Statement[],
|
|
86
|
+
startIndex: number,
|
|
87
|
+
sf: ts.SourceFile,
|
|
88
|
+
): SwitchSequence | undefined {
|
|
89
|
+
const seq: ts.IfStatement[] = [];
|
|
90
|
+
let targetText: string | undefined;
|
|
91
|
+
|
|
92
|
+
let j = startIndex;
|
|
93
|
+
while (j < statements.length) {
|
|
94
|
+
const s = statements[j];
|
|
95
|
+
if (!s || !ts.isIfStatement(s) || s.elseStatement) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const comps = getComparisons(s.expression, []);
|
|
99
|
+
if (!comps || comps.length === 0) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const target = comps[0]?.target;
|
|
103
|
+
if (!target || !isSimpleTarget(target)) {
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
const tText = target.getText(sf);
|
|
107
|
+
if (targetText === undefined) {
|
|
108
|
+
targetText = tText;
|
|
109
|
+
} else if (targetText !== tText) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
const allSame = comps.every((c) => c.target.getText(sf) === targetText);
|
|
113
|
+
if (!allSame) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
seq.push(s);
|
|
118
|
+
j++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return seq.length >= 3 && targetText !== undefined ? { seq, targetText } : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildSwitchStatement(
|
|
125
|
+
seq: readonly ts.IfStatement[],
|
|
126
|
+
targetText: string,
|
|
127
|
+
prefix: string,
|
|
128
|
+
sf: ts.SourceFile,
|
|
129
|
+
): string {
|
|
130
|
+
let switchText = `switch (${targetText}) {\n`;
|
|
131
|
+
for (const s of seq) {
|
|
132
|
+
const comps = getComparisons(s.expression, []);
|
|
133
|
+
if (comps) {
|
|
134
|
+
for (const c of comps) {
|
|
135
|
+
switchText += `${prefix} case ${c.literal.getText(sf)}:\n`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let bodyText = "";
|
|
139
|
+
if (ts.isBlock(s.thenStatement)) {
|
|
140
|
+
bodyText = s.thenStatement.statements
|
|
141
|
+
.map((stmt) => {
|
|
142
|
+
const stmtPrefix = getLinePrefix(stmt.getStart(sf), sf);
|
|
143
|
+
return stmt
|
|
144
|
+
.getText(sf)
|
|
145
|
+
.split("\n")
|
|
146
|
+
.map((line) =>
|
|
147
|
+
line.startsWith(stmtPrefix)
|
|
148
|
+
? `${prefix} ${line.slice(stmtPrefix.length)}`
|
|
149
|
+
: `${prefix} ${line.trimStart()}`,
|
|
150
|
+
)
|
|
151
|
+
.join("\n");
|
|
152
|
+
})
|
|
153
|
+
.join("\n");
|
|
154
|
+
} else {
|
|
155
|
+
const stmtPrefix = getLinePrefix(s.thenStatement.getStart(sf), sf);
|
|
156
|
+
bodyText = s.thenStatement
|
|
157
|
+
.getText(sf)
|
|
158
|
+
.split("\n")
|
|
159
|
+
.map((line) =>
|
|
160
|
+
line.startsWith(stmtPrefix)
|
|
161
|
+
? `${prefix} ${line.slice(stmtPrefix.length)}`
|
|
162
|
+
: `${prefix} ${line.trimStart()}`,
|
|
163
|
+
)
|
|
164
|
+
.join("\n");
|
|
165
|
+
}
|
|
166
|
+
switchText += `${bodyText}\n`;
|
|
167
|
+
if (!endsWithReturn(s.thenStatement)) {
|
|
168
|
+
switchText += `${prefix} break;\n`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
switchText += `${prefix}}`;
|
|
172
|
+
return switchText;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function scanIfToSwitchSequences(
|
|
176
|
+
node: ts.Node,
|
|
177
|
+
sf: ts.SourceFile,
|
|
178
|
+
skippedNodes: Set<ts.Node>,
|
|
179
|
+
edits: Edit[],
|
|
180
|
+
): void {
|
|
181
|
+
if (ts.isBlock(node) || ts.isSourceFile(node)) {
|
|
182
|
+
const statements = node.statements;
|
|
183
|
+
let i = 0;
|
|
184
|
+
while (i < statements.length) {
|
|
185
|
+
const result = findSwitchSequence(statements, i, sf);
|
|
186
|
+
if (result) {
|
|
187
|
+
const { seq, targetText } = result;
|
|
188
|
+
const firstSeq = seq[0];
|
|
189
|
+
const lastSeq = seq[seq.length - 1];
|
|
190
|
+
if (firstSeq && lastSeq) {
|
|
191
|
+
const prefix = getLinePrefix(firstSeq.getStart(sf), sf);
|
|
192
|
+
const switchText = buildSwitchStatement(seq, targetText, prefix, sf);
|
|
193
|
+
|
|
194
|
+
for (const s of seq) {
|
|
195
|
+
skippedNodes.add(s);
|
|
196
|
+
}
|
|
197
|
+
edits.push({
|
|
198
|
+
start: firstSeq.getStart(sf),
|
|
199
|
+
end: lastSeq.getEnd(),
|
|
200
|
+
text: switchText,
|
|
201
|
+
imports: [],
|
|
202
|
+
});
|
|
203
|
+
i += seq.length - 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/tersify.ts
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { type Maybe, none, some } from "@onrails/maybe";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { edit, spanEdit, walkSource } from "./ast.js";
|
|
4
|
+
import { applyEditStep, byStartDesc } from "./chains.js";
|
|
5
|
+
import { scanIfToSwitchSequences } from "./switch.js";
|
|
6
|
+
import type { Edit } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// Helper to check for single statement return
|
|
9
|
+
function getSingleReturnExpression(statement: ts.Statement): ts.Expression | undefined {
|
|
10
|
+
if (ts.isReturnStatement(statement)) {
|
|
11
|
+
return statement.expression;
|
|
12
|
+
}
|
|
13
|
+
if (ts.isBlock(statement)) {
|
|
14
|
+
if (statement.statements.length === 1) {
|
|
15
|
+
const single = statement.statements[0];
|
|
16
|
+
if (single && ts.isReturnStatement(single)) {
|
|
17
|
+
return single.expression;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to check if body uses 'this' or 'arguments'
|
|
25
|
+
function usesThisOrArguments(body: ts.Node): boolean {
|
|
26
|
+
let found = false;
|
|
27
|
+
const visit = (n: ts.Node) => {
|
|
28
|
+
if (found) return;
|
|
29
|
+
if (n.kind === ts.SyntaxKind.ThisKeyword) {
|
|
30
|
+
found = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (ts.isIdentifier(n) && n.text === "arguments") {
|
|
34
|
+
found = true;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
ts.isFunctionDeclaration(n) ||
|
|
39
|
+
ts.isFunctionExpression(n) ||
|
|
40
|
+
ts.isArrowFunction(n) ||
|
|
41
|
+
ts.isMethodDeclaration(n) ||
|
|
42
|
+
ts.isGetAccessorDeclaration(n) ||
|
|
43
|
+
ts.isSetAccessorDeclaration(n)
|
|
44
|
+
) {
|
|
45
|
+
return; // Stop recursion for nested scopes
|
|
46
|
+
}
|
|
47
|
+
ts.forEachChild(n, visit);
|
|
48
|
+
};
|
|
49
|
+
ts.forEachChild(body, visit);
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Helper to check if identifier referenced before pos
|
|
54
|
+
function isIdentifierReferencedBefore(name: string, pos: number, sf: ts.SourceFile): boolean {
|
|
55
|
+
let referenced = false;
|
|
56
|
+
const visit = (n: ts.Node) => {
|
|
57
|
+
if (referenced) return;
|
|
58
|
+
if (n.getStart(sf) >= pos) return;
|
|
59
|
+
if (ts.isIdentifier(n) && n.text === name) {
|
|
60
|
+
const parent = n.parent;
|
|
61
|
+
if (parent) {
|
|
62
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === n) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (ts.isPropertyAssignment(parent) && parent.name === n) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (ts.isMethodDeclaration(parent) && parent.name === n) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (ts.isPropertyDeclaration(parent) && parent.name === n) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (ts.isBindingElement(parent) && parent.propertyName === n) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
referenced = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
ts.forEachChild(n, visit);
|
|
82
|
+
};
|
|
83
|
+
ts.forEachChild(sf, visit);
|
|
84
|
+
return referenced;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getTypeParametersText(
|
|
88
|
+
node: ts.FunctionDeclaration | ts.FunctionExpression,
|
|
89
|
+
sf: ts.SourceFile,
|
|
90
|
+
): string {
|
|
91
|
+
if (node.typeParameters && node.typeParameters.length > 0) {
|
|
92
|
+
const params = node.typeParameters.map((p) => p.getText(sf));
|
|
93
|
+
return params.length === 1 ? `<${params[0]},>` : `<${params.join(", ")}>`;
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const isSimpleExpression = (expr: ts.Expression): boolean =>
|
|
99
|
+
ts.isIdentifier(expr) ||
|
|
100
|
+
ts.isPropertyAccessExpression(expr) ||
|
|
101
|
+
ts.isElementAccessExpression(expr) ||
|
|
102
|
+
ts.isCallExpression(expr);
|
|
103
|
+
|
|
104
|
+
function containsConditional(node: ts.Node): boolean {
|
|
105
|
+
if (ts.isConditionalExpression(node)) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
let found = false;
|
|
109
|
+
ts.forEachChild(node, (child) => {
|
|
110
|
+
if (found) return;
|
|
111
|
+
if (containsConditional(child)) {
|
|
112
|
+
found = true;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tryTersifyIfElse(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
119
|
+
if (ts.isIfStatement(node) && node.elseStatement) {
|
|
120
|
+
const x = getSingleReturnExpression(node.thenStatement);
|
|
121
|
+
const y = getSingleReturnExpression(node.elseStatement);
|
|
122
|
+
if (x && y) {
|
|
123
|
+
if (containsConditional(x) || containsConditional(y)) {
|
|
124
|
+
return none();
|
|
125
|
+
}
|
|
126
|
+
const condText = node.expression.getText(sf);
|
|
127
|
+
const xIsTrue = x.kind === ts.SyntaxKind.TrueKeyword;
|
|
128
|
+
const xIsFalse = x.kind === ts.SyntaxKind.FalseKeyword;
|
|
129
|
+
const yIsTrue = y.kind === ts.SyntaxKind.TrueKeyword;
|
|
130
|
+
const yIsFalse = y.kind === ts.SyntaxKind.FalseKeyword;
|
|
131
|
+
|
|
132
|
+
if (xIsTrue && yIsFalse) {
|
|
133
|
+
return some(spanEdit(node, sf, edit(`return ${condText};`)));
|
|
134
|
+
}
|
|
135
|
+
if (xIsFalse && yIsTrue) {
|
|
136
|
+
const needsParens = !isSimpleExpression(node.expression);
|
|
137
|
+
const text = needsParens ? `return !(${condText});` : `return !${condText};`;
|
|
138
|
+
return some(spanEdit(node, sf, edit(text)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const xText = x.getText(sf);
|
|
142
|
+
const yText = y.getText(sf);
|
|
143
|
+
return some(spanEdit(node, sf, edit(`return ${condText} ? ${xText} : ${yText};`)));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return none();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A concise arrow body that is — or unwraps through `as` / `satisfies` to — an
|
|
150
|
+
// object literal must be parenthesized, else `{ … }` parses as a block body.
|
|
151
|
+
function startsWithObjectLiteral(expr: ts.Expression): boolean {
|
|
152
|
+
let e: ts.Expression = expr;
|
|
153
|
+
while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
|
|
154
|
+
e = e.expression;
|
|
155
|
+
}
|
|
156
|
+
return ts.isObjectLiteralExpression(e);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function tryTersifyArrowBlock(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
160
|
+
if (ts.isArrowFunction(node) && ts.isBlock(node.body)) {
|
|
161
|
+
const expr = getSingleReturnExpression(node.body);
|
|
162
|
+
if (expr) {
|
|
163
|
+
const exprText = expr.getText(sf);
|
|
164
|
+
const text = startsWithObjectLiteral(expr) ? `(${exprText})` : exprText;
|
|
165
|
+
return some(spanEdit(node.body, sf, edit(text)));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return none();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function tryTersifyFunctionExpression(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
172
|
+
if (ts.isFunctionExpression(node) && !node.name && !node.asteriskToken) {
|
|
173
|
+
const expr = getSingleReturnExpression(node.body);
|
|
174
|
+
if (expr && !usesThisOrArguments(node.body)) {
|
|
175
|
+
const asyncKeyword = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
176
|
+
? "async "
|
|
177
|
+
: "";
|
|
178
|
+
const typeParamsText = getTypeParametersText(node, sf);
|
|
179
|
+
const paramsText = node.parameters.map((p) => p.getText(sf)).join(", ");
|
|
180
|
+
const returnType = node.type ? `: ${node.type.getText(sf)}` : "";
|
|
181
|
+
const exprText = expr.getText(sf);
|
|
182
|
+
const bodyText = startsWithObjectLiteral(expr) ? `(${exprText})` : exprText;
|
|
183
|
+
const text = `${asyncKeyword}${typeParamsText}(${paramsText})${returnType} => ${bodyText}`;
|
|
184
|
+
return some(spanEdit(node, sf, edit(text)));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return none();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function tryTersifyFunctionDeclaration(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
191
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.body && !node.asteriskToken) {
|
|
192
|
+
const isDefault = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
193
|
+
if (!isDefault) {
|
|
194
|
+
const expr = getSingleReturnExpression(node.body);
|
|
195
|
+
if (expr && !usesThisOrArguments(node.body)) {
|
|
196
|
+
const nameText = node.name.getText(sf);
|
|
197
|
+
if (!isIdentifierReferencedBefore(nameText, node.getStart(sf), sf)) {
|
|
198
|
+
const isExported = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
199
|
+
const asyncKeyword = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
200
|
+
? "async "
|
|
201
|
+
: "";
|
|
202
|
+
const typeParamsText = getTypeParametersText(node, sf);
|
|
203
|
+
const paramsText = node.parameters.map((p) => p.getText(sf)).join(", ");
|
|
204
|
+
const returnType = node.type ? `: ${node.type.getText(sf)}` : "";
|
|
205
|
+
const exprText = expr.getText(sf);
|
|
206
|
+
const bodyText = startsWithObjectLiteral(expr) ? `(${exprText})` : exprText;
|
|
207
|
+
const exportPrefix = isExported ? "export " : "";
|
|
208
|
+
const text = `${exportPrefix}const ${nameText} = ${asyncKeyword}${typeParamsText}(${paramsText})${returnType} => ${bodyText};`;
|
|
209
|
+
return some(spanEdit(node, sf, edit(text)));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return none();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function tryTersifyPropertyAssignment(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
218
|
+
if (ts.isPropertyAssignment(node)) {
|
|
219
|
+
if (
|
|
220
|
+
ts.isIdentifier(node.name) &&
|
|
221
|
+
ts.isIdentifier(node.initializer) &&
|
|
222
|
+
node.name.text === node.initializer.text
|
|
223
|
+
) {
|
|
224
|
+
return some(spanEdit(node, sf, edit(node.name.text)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return none();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function tryTersifyNoSubstitutionTemplateLiteral(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
231
|
+
if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
232
|
+
if (node.parent && ts.isTaggedTemplateExpression(node.parent)) {
|
|
233
|
+
return none();
|
|
234
|
+
}
|
|
235
|
+
const rawText = node.getText(sf);
|
|
236
|
+
const content = rawText.slice(1, -1);
|
|
237
|
+
if (!content.includes("\n") && !content.includes("\r") && !content.includes('"')) {
|
|
238
|
+
return some(spanEdit(node, sf, edit(`"${content}"`)));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return none();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function tryTersifyIdentityFilter(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
245
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
246
|
+
if (node.parameters.length === 1) {
|
|
247
|
+
const param = node.parameters[0];
|
|
248
|
+
if (param && ts.isIdentifier(param.name)) {
|
|
249
|
+
const paramName = param.name.text;
|
|
250
|
+
const body = node.body;
|
|
251
|
+
const expr = ts.isBlock(body) ? getSingleReturnExpression(body) : body;
|
|
252
|
+
|
|
253
|
+
if (expr) {
|
|
254
|
+
const isDirectIdentity = ts.isIdentifier(expr) && expr.text === paramName;
|
|
255
|
+
let isDoubleNegatedIdentity = false;
|
|
256
|
+
if (
|
|
257
|
+
ts.isPrefixUnaryExpression(expr) &&
|
|
258
|
+
expr.operator === ts.SyntaxKind.ExclamationToken &&
|
|
259
|
+
ts.isPrefixUnaryExpression(expr.operand) &&
|
|
260
|
+
expr.operand.operator === ts.SyntaxKind.ExclamationToken &&
|
|
261
|
+
ts.isIdentifier(expr.operand.operand) &&
|
|
262
|
+
expr.operand.operand.text === paramName
|
|
263
|
+
) {
|
|
264
|
+
isDoubleNegatedIdentity = true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (isDirectIdentity || isDoubleNegatedIdentity) {
|
|
268
|
+
const parent = node.parent;
|
|
269
|
+
if (
|
|
270
|
+
parent &&
|
|
271
|
+
ts.isCallExpression(parent) &&
|
|
272
|
+
parent.arguments.includes(node as ts.Expression)
|
|
273
|
+
) {
|
|
274
|
+
if (ts.isPropertyAccessExpression(parent.expression)) {
|
|
275
|
+
const method = parent.expression.name.text;
|
|
276
|
+
if (
|
|
277
|
+
method === "filter" ||
|
|
278
|
+
method === "some" ||
|
|
279
|
+
method === "every" ||
|
|
280
|
+
method === "find"
|
|
281
|
+
) {
|
|
282
|
+
return some(spanEdit(node, sf, edit("Boolean")));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return none();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function tryTersifyOptionalChainGuard(
|
|
295
|
+
stmt: ts.IfStatement,
|
|
296
|
+
nextStmt: ts.ReturnStatement,
|
|
297
|
+
sf: ts.SourceFile,
|
|
298
|
+
): Maybe<Edit> {
|
|
299
|
+
const firstStmt = ts.isBlock(stmt.thenStatement) ? stmt.thenStatement.statements[0] : undefined;
|
|
300
|
+
const isThenEmptyReturn =
|
|
301
|
+
(ts.isReturnStatement(stmt.thenStatement) && !stmt.thenStatement.expression) ||
|
|
302
|
+
(ts.isBlock(stmt.thenStatement) &&
|
|
303
|
+
stmt.thenStatement.statements.length === 1 &&
|
|
304
|
+
firstStmt &&
|
|
305
|
+
ts.isReturnStatement(firstStmt) &&
|
|
306
|
+
!firstStmt.expression);
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
isThenEmptyReturn &&
|
|
310
|
+
ts.isPrefixUnaryExpression(stmt.expression) &&
|
|
311
|
+
stmt.expression.operator === ts.SyntaxKind.ExclamationToken &&
|
|
312
|
+
nextStmt.expression &&
|
|
313
|
+
ts.isPropertyAccessExpression(nextStmt.expression)
|
|
314
|
+
) {
|
|
315
|
+
const target = stmt.expression.operand;
|
|
316
|
+
const accessExpr = nextStmt.expression;
|
|
317
|
+
if (accessExpr.expression.getText(sf) === target.getText(sf)) {
|
|
318
|
+
const targetText = target.getText(sf);
|
|
319
|
+
const propName = accessExpr.name.getText(sf);
|
|
320
|
+
const text = `return ${targetText}?.${propName};`;
|
|
321
|
+
return some({
|
|
322
|
+
start: stmt.getStart(sf),
|
|
323
|
+
end: nextStmt.getEnd(),
|
|
324
|
+
text,
|
|
325
|
+
imports: [],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return none();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function tryTersifyTernaryOrBooleanSequence(
|
|
333
|
+
stmt: ts.IfStatement,
|
|
334
|
+
nextStmt: ts.ReturnStatement,
|
|
335
|
+
sf: ts.SourceFile,
|
|
336
|
+
): Maybe<Edit> {
|
|
337
|
+
const x = getSingleReturnExpression(stmt.thenStatement);
|
|
338
|
+
const y = nextStmt.expression;
|
|
339
|
+
if (x && y) {
|
|
340
|
+
if (containsConditional(x) || containsConditional(y)) {
|
|
341
|
+
return none();
|
|
342
|
+
}
|
|
343
|
+
const condText = stmt.expression.getText(sf);
|
|
344
|
+
const xIsTrue = x.kind === ts.SyntaxKind.TrueKeyword;
|
|
345
|
+
const xIsFalse = x.kind === ts.SyntaxKind.FalseKeyword;
|
|
346
|
+
const yIsTrue = y.kind === ts.SyntaxKind.TrueKeyword;
|
|
347
|
+
const yIsFalse = y.kind === ts.SyntaxKind.FalseKeyword;
|
|
348
|
+
|
|
349
|
+
let text = "";
|
|
350
|
+
if (xIsTrue && yIsFalse) {
|
|
351
|
+
text = `return ${condText};`;
|
|
352
|
+
} else if (xIsFalse && yIsTrue) {
|
|
353
|
+
const needsParens = !isSimpleExpression(stmt.expression);
|
|
354
|
+
text = needsParens ? `return !(${condText});` : `return !${condText};`;
|
|
355
|
+
} else {
|
|
356
|
+
const xText = x.getText(sf);
|
|
357
|
+
const yText = y.getText(sf);
|
|
358
|
+
text = `return ${condText} ? ${xText} : ${yText};`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return some({
|
|
362
|
+
start: stmt.getStart(sf),
|
|
363
|
+
end: nextStmt.getEnd(),
|
|
364
|
+
text,
|
|
365
|
+
imports: [],
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
return none();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function scanIfReturnSequences(
|
|
372
|
+
node: ts.Node,
|
|
373
|
+
sf: ts.SourceFile,
|
|
374
|
+
skippedNodes: Set<ts.Node>,
|
|
375
|
+
edits: Edit[],
|
|
376
|
+
): void {
|
|
377
|
+
if (ts.isBlock(node) || ts.isSourceFile(node)) {
|
|
378
|
+
const statements = node.statements;
|
|
379
|
+
for (let i = 0; i < statements.length - 1; i++) {
|
|
380
|
+
const stmt = statements[i];
|
|
381
|
+
const nextStmt = statements[i + 1];
|
|
382
|
+
if (
|
|
383
|
+
stmt &&
|
|
384
|
+
nextStmt &&
|
|
385
|
+
ts.isIfStatement(stmt) &&
|
|
386
|
+
!stmt.elseStatement &&
|
|
387
|
+
ts.isReturnStatement(nextStmt)
|
|
388
|
+
) {
|
|
389
|
+
// If this IfStatement is part of a sequence of IfStatements, do not convert it.
|
|
390
|
+
// We detect this by checking if the previous statement is also an IfStatement.
|
|
391
|
+
const prev = statements[i - 1];
|
|
392
|
+
if (prev && ts.isIfStatement(prev)) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const optChainEdit = tryTersifyOptionalChainGuard(stmt, nextStmt, sf);
|
|
397
|
+
if (optChainEdit._tag === "Some") {
|
|
398
|
+
skippedNodes.add(stmt);
|
|
399
|
+
skippedNodes.add(nextStmt);
|
|
400
|
+
edits.push(optChainEdit.value);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const seqEdit = tryTersifyTernaryOrBooleanSequence(stmt, nextStmt, sf);
|
|
405
|
+
if (seqEdit._tag === "Some") {
|
|
406
|
+
skippedNodes.add(stmt);
|
|
407
|
+
skippedNodes.add(nextStmt);
|
|
408
|
+
edits.push(seqEdit.value);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const TRANSFORMS: ReadonlyArray<(node: ts.Node, sf: ts.SourceFile) => Maybe<Edit>> = [
|
|
416
|
+
tryTersifyIfElse,
|
|
417
|
+
tryTersifyArrowBlock,
|
|
418
|
+
tryTersifyFunctionExpression,
|
|
419
|
+
tryTersifyFunctionDeclaration,
|
|
420
|
+
tryTersifyPropertyAssignment,
|
|
421
|
+
tryTersifyNoSubstitutionTemplateLiteral,
|
|
422
|
+
tryTersifyIdentityFilter,
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
function tersifyOnce(src: string): string {
|
|
426
|
+
const edits: Edit[] = [];
|
|
427
|
+
const skippedNodes = new Set<ts.Node>();
|
|
428
|
+
|
|
429
|
+
walkSource(src, (node, sf) => {
|
|
430
|
+
if (skippedNodes.has(node)) {
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
scanIfReturnSequences(node, sf, skippedNodes, edits);
|
|
435
|
+
scanIfToSwitchSequences(node, sf, skippedNodes, edits);
|
|
436
|
+
|
|
437
|
+
for (const transform of TRANSFORMS) {
|
|
438
|
+
const editResult = transform(node, sf);
|
|
439
|
+
if (editResult._tag === "Some") {
|
|
440
|
+
edits.push(editResult.value);
|
|
441
|
+
skippedNodes.add(node);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return false;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
if (edits.length === 0) return src;
|
|
450
|
+
|
|
451
|
+
const { src: next } = edits
|
|
452
|
+
.sort(byStartDesc)
|
|
453
|
+
.reduce(applyEditStep, { src, imports: new Set<string>() });
|
|
454
|
+
|
|
455
|
+
return next;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function tersify(src: string): string {
|
|
459
|
+
let current = src;
|
|
460
|
+
let iterations = 0;
|
|
461
|
+
while (iterations < 10) {
|
|
462
|
+
const next = tersifyOnce(current);
|
|
463
|
+
if (next === current) {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
current = next;
|
|
467
|
+
iterations++;
|
|
468
|
+
}
|
|
469
|
+
return current;
|
|
470
|
+
}
|
package/src/types.ts
CHANGED