@onrails/codemod 0.2.1 → 0.2.3
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/boundary-spec.ts +153 -0
- package/src/constants.ts +4 -5
- package/src/index.ts +14 -0
- package/src/tersify-utils.ts +177 -0
- package/src/tersify.ts +14 -124
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/codemod",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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.
|
|
46
|
-
"@onrails/pattern": "0.
|
|
47
|
-
"@onrails/result": "0.
|
|
45
|
+
"@onrails/maybe": "0.2.0",
|
|
46
|
+
"@onrails/pattern": "0.2.0",
|
|
47
|
+
"@onrails/result": "0.2.0",
|
|
48
48
|
"typescript": "^6.0.3"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* boundary-spec.ts — the single owning module for the boundary idioms
|
|
3
|
+
* onrails enforces on consumers:
|
|
4
|
+
*
|
|
5
|
+
* 1. no `Promise<Result<…>>` in public signatures (`no-promise-result`)
|
|
6
|
+
* 2. no unsafe unwraps outside tests (`no-unsafe-unwrap`)
|
|
7
|
+
* 3. no deprecated neverthrow-era synonyms (`no-deprecated-synonyms`)
|
|
8
|
+
*
|
|
9
|
+
* The ESLint plugin (plain JS, `packages/eslint-plugin/src/index.js`) and the
|
|
10
|
+
* Biome plugin (GritQL, `packages/biome-plugin/rules/*.grit`) cannot import
|
|
11
|
+
* this module — cross-package imports are forbidden — so they hand-copy the
|
|
12
|
+
* names. `packages/codemod/test/boundary-conformance.spec.ts` reads those
|
|
13
|
+
* artifacts off disk and asserts they stay in sync with this spec.
|
|
14
|
+
*
|
|
15
|
+
* Deliberate per-engine divergences are declared in `ENGINE_DIVERGENCES`;
|
|
16
|
+
* anything not declared there is drift.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Unsafe unwraps
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* neverthrow-compat unsafe-unwrap methods → native named equivalents.
|
|
25
|
+
* The codemod rewrites `r._unsafeUnwrap()` to `unwrapOk(r)`; the natives are
|
|
26
|
+
* still unsafe (throw on the wrong branch) and stay lint-flagged.
|
|
27
|
+
*/
|
|
28
|
+
export const UNSAFE_UNWRAP_MEMBER_RENAMES: ReadonlyMap<string, string> = new Map([
|
|
29
|
+
["_unsafeUnwrap", "unwrapOk"],
|
|
30
|
+
["_unsafeUnwrapErr", "unwrapErr"],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** Free-function unwraps that throw on the unhappy branch. */
|
|
34
|
+
export const UNSAFE_UNWRAP_CALL_NAMES = ["unwrapOk", "unwrapErr", "unwrap"] as const;
|
|
35
|
+
|
|
36
|
+
/** Every identifier the `no-unsafe-unwrap` rule flags, across engines. */
|
|
37
|
+
export const UNSAFE_UNWRAP_NAMES: ReadonlySet<string> = new Set([
|
|
38
|
+
...UNSAFE_UNWRAP_MEMBER_RENAMES.keys(),
|
|
39
|
+
...UNSAFE_UNWRAP_CALL_NAMES,
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Matches compat unsafe-unwrap member calls (`._unsafeUnwrap(` /
|
|
44
|
+
* `._unsafeUnwrapErr(`) in raw source. Derived from the rename map so the
|
|
45
|
+
* codemod's compat-only detection cannot drift from the spec.
|
|
46
|
+
*/
|
|
47
|
+
export const UNSAFE_UNWRAP_MEMBER_CALL_RE = new RegExp(
|
|
48
|
+
String.raw`\.(?:${[...UNSAFE_UNWRAP_MEMBER_RENAMES.keys()].join("|")})\s*\(`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Deprecated synonyms
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export interface DeprecatedSynonym {
|
|
56
|
+
/** The deprecated identifier as it appears in consumer code. */
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** `method` — flagged as `x.name(…)`; `call` — flagged as `name(…)`. */
|
|
59
|
+
readonly form: "method" | "call";
|
|
60
|
+
/** Canonical replacement identifier. */
|
|
61
|
+
readonly canonical: string;
|
|
62
|
+
/** Extra guidance when the rename is not a plain identifier swap. */
|
|
63
|
+
readonly hint?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Deprecated neverthrow/fp-era synonyms → canonical onrails names.
|
|
68
|
+
* Matching is name-based in every engine — neither GritQL nor the ESLint
|
|
69
|
+
* rules scope by import, so same-named calls from other libraries (RxJS
|
|
70
|
+
* `of`, fp-ts `fold`) are flagged too.
|
|
71
|
+
*/
|
|
72
|
+
export const DEPRECATED_SYNONYMS: readonly DeprecatedSynonym[] = [
|
|
73
|
+
{ name: "chain", form: "method", canonical: "flatMap" },
|
|
74
|
+
{
|
|
75
|
+
name: "isOk",
|
|
76
|
+
form: "method",
|
|
77
|
+
canonical: "isOk",
|
|
78
|
+
hint: "await the ResultAsync, then narrow with the isOk() free function",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "isErr",
|
|
82
|
+
form: "method",
|
|
83
|
+
canonical: "isErr",
|
|
84
|
+
hint: "await the ResultAsync, then narrow with the isErr() free function",
|
|
85
|
+
},
|
|
86
|
+
{ name: "fold", form: "call", canonical: "match" },
|
|
87
|
+
{ name: "matchResult", form: "call", canonical: "match" },
|
|
88
|
+
{ name: "matchMaybe", form: "call", canonical: "match" },
|
|
89
|
+
{ name: "getOrElse", form: "call", canonical: "unwrapOr" },
|
|
90
|
+
{ name: "sequenceTupleAsync", form: "call", canonical: "ResultAsync.combineTuple" },
|
|
91
|
+
{ name: "collect", form: "call", canonical: "combine" },
|
|
92
|
+
{ name: "of", form: "call", canonical: "ok", hint: "or some() for Maybe" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Flat old-name → canonical-name view of {@link DEPRECATED_SYNONYMS}. */
|
|
96
|
+
export const DEPRECATED_SYNONYM_RENAMES: ReadonlyMap<string, string> = new Map(
|
|
97
|
+
DEPRECATED_SYNONYMS.map((s) => [s.name, s.canonical]),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Rule descriptors
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** `Promise<Result<…>>` in a type position → `ResultAsync<T, E>`. */
|
|
105
|
+
export const NO_PROMISE_RESULT_RULE = {
|
|
106
|
+
id: "no-promise-result",
|
|
107
|
+
replacement: "ResultAsync",
|
|
108
|
+
boundaryHelpers: ["fromAsync", "tryAsync"],
|
|
109
|
+
message:
|
|
110
|
+
"Promise<Result<…>> in a type position — use ResultAsync<T, E> at the boundary (fromAsync / tryAsync).",
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
/** Unsafe unwraps outside tests. Message is shared verbatim by both engines. */
|
|
114
|
+
export const NO_UNSAFE_UNWRAP_RULE = {
|
|
115
|
+
id: "no-unsafe-unwrap",
|
|
116
|
+
message:
|
|
117
|
+
"Avoid _unsafeUnwrap* and unwrap* outside tests — use match(), resolve(), or yieldResult() in tryGen.",
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
120
|
+
/** Deprecated synonym identifiers. Per-engine message wording differs. */
|
|
121
|
+
export const NO_DEPRECATED_SYNONYMS_RULE = {
|
|
122
|
+
id: "no-deprecated-synonyms",
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Declared divergences
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export interface EngineDivergence {
|
|
130
|
+
readonly rule: string;
|
|
131
|
+
/** Engine whose behaviour differs from the other(s). */
|
|
132
|
+
readonly engine: "eslint" | "biome" | "codemod";
|
|
133
|
+
/** Stable id the conformance test keys on. */
|
|
134
|
+
readonly divergence: string;
|
|
135
|
+
readonly detail: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Deliberate cross-engine behaviour differences. The conformance test treats
|
|
140
|
+
* these as declared (allowed); any other difference is accidental drift.
|
|
141
|
+
*/
|
|
142
|
+
export const ENGINE_DIVERGENCES: readonly EngineDivergence[] = [
|
|
143
|
+
{
|
|
144
|
+
rule: "no-unsafe-unwrap",
|
|
145
|
+
engine: "biome",
|
|
146
|
+
divergence: "no-test-file-exemption",
|
|
147
|
+
detail:
|
|
148
|
+
"The ESLint rule skips *.spec.* / *.test.* files where unsafe unwraps are " +
|
|
149
|
+
"acceptable; GritQL patterns cannot read the file path, so the Biome rule " +
|
|
150
|
+
"flags test code too. Suppress with a biome-ignore comment or a linter " +
|
|
151
|
+
"override for test globs.",
|
|
152
|
+
},
|
|
153
|
+
];
|
package/src/constants.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { UNSAFE_UNWRAP_MEMBER_CALL_RE, UNSAFE_UNWRAP_MEMBER_RENAMES } from "./boundary-spec.js";
|
|
2
|
+
|
|
1
3
|
export const SKIP = new Set([
|
|
2
4
|
"node_modules",
|
|
3
5
|
"dist",
|
|
@@ -16,7 +18,7 @@ export const DEP_KEYS = ["dependencies", "devDependencies", "peerDependencies"]
|
|
|
16
18
|
export const TYPE_ONLY_NATIVE = new Set(["Result", "Ok", "Err", "UnexpectedError"]);
|
|
17
19
|
export const COMPAT_ONLY_PATTERNS = [
|
|
18
20
|
{ pattern: /\bResult\.(combine|fromThrowable)\b/, label: "Result static helper" },
|
|
19
|
-
{ pattern:
|
|
21
|
+
{ pattern: UNSAFE_UNWRAP_MEMBER_CALL_RE, label: "unsafe compat unwrap" },
|
|
20
22
|
] as const;
|
|
21
23
|
export const RESULT_SPECIFIC_CHAIN_METHODS = new Set([
|
|
22
24
|
"andThen",
|
|
@@ -57,8 +59,5 @@ export const TEE_METHODS = new Map([
|
|
|
57
59
|
["orTee", "tapErr"],
|
|
58
60
|
]);
|
|
59
61
|
export const PREDICATE_METHODS = new Set(["isOk", "isErr"]);
|
|
60
|
-
export const UNSAFE_UNWRAP_METHODS = new Map(
|
|
61
|
-
["_unsafeUnwrap", "unwrapOk"],
|
|
62
|
-
["_unsafeUnwrapErr", "unwrapErr"],
|
|
63
|
-
]);
|
|
62
|
+
export const UNSAFE_UNWRAP_METHODS = new Map(UNSAFE_UNWRAP_MEMBER_RENAMES);
|
|
64
63
|
export const SAFE_BASE_PATTERNS = [/^(ok|err)\s*\(/, /^Result\./, /^[a-zA-Z_$][\w$]*$/] as const;
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { main } from "./cli.js";
|
|
3
3
|
|
|
4
|
+
export {
|
|
5
|
+
DEPRECATED_SYNONYM_RENAMES,
|
|
6
|
+
DEPRECATED_SYNONYMS,
|
|
7
|
+
type DeprecatedSynonym,
|
|
8
|
+
ENGINE_DIVERGENCES,
|
|
9
|
+
type EngineDivergence,
|
|
10
|
+
NO_DEPRECATED_SYNONYMS_RULE,
|
|
11
|
+
NO_PROMISE_RESULT_RULE,
|
|
12
|
+
NO_UNSAFE_UNWRAP_RULE,
|
|
13
|
+
UNSAFE_UNWRAP_CALL_NAMES,
|
|
14
|
+
UNSAFE_UNWRAP_MEMBER_CALL_RE,
|
|
15
|
+
UNSAFE_UNWRAP_MEMBER_RENAMES,
|
|
16
|
+
UNSAFE_UNWRAP_NAMES,
|
|
17
|
+
} from "./boundary-spec.js";
|
|
4
18
|
export { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
5
19
|
export { main } from "./cli.js";
|
|
6
20
|
export { computeFileChange } from "./file-change.js";
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
// Helper to check for single statement return
|
|
4
|
+
export function getSingleReturnExpression(statement: ts.Statement): ts.Expression | undefined {
|
|
5
|
+
if (ts.isReturnStatement(statement)) {
|
|
6
|
+
return statement.expression;
|
|
7
|
+
}
|
|
8
|
+
if (ts.isBlock(statement)) {
|
|
9
|
+
if (statement.statements.length === 1) {
|
|
10
|
+
const single = statement.statements[0];
|
|
11
|
+
if (single && ts.isReturnStatement(single)) {
|
|
12
|
+
return single.expression;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Helper to check if body uses 'this' or 'arguments'
|
|
20
|
+
export function usesThisOrArguments(body: ts.Node): boolean {
|
|
21
|
+
let found = false;
|
|
22
|
+
const visit = (n: ts.Node) => {
|
|
23
|
+
if (found) return;
|
|
24
|
+
if (n.kind === ts.SyntaxKind.ThisKeyword) {
|
|
25
|
+
found = true;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (ts.isIdentifier(n) && n.text === "arguments") {
|
|
29
|
+
found = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (
|
|
33
|
+
ts.isFunctionDeclaration(n) ||
|
|
34
|
+
ts.isFunctionExpression(n) ||
|
|
35
|
+
ts.isArrowFunction(n) ||
|
|
36
|
+
ts.isMethodDeclaration(n) ||
|
|
37
|
+
ts.isGetAccessorDeclaration(n) ||
|
|
38
|
+
ts.isSetAccessorDeclaration(n)
|
|
39
|
+
) {
|
|
40
|
+
return; // Stop recursion for nested scopes
|
|
41
|
+
}
|
|
42
|
+
ts.forEachChild(n, visit);
|
|
43
|
+
};
|
|
44
|
+
ts.forEachChild(body, visit);
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper to check if identifier referenced before pos
|
|
49
|
+
export function isIdentifierReferencedBefore(
|
|
50
|
+
name: string,
|
|
51
|
+
pos: number,
|
|
52
|
+
sf: ts.SourceFile,
|
|
53
|
+
): boolean {
|
|
54
|
+
let referenced = false;
|
|
55
|
+
const visit = (n: ts.Node) => {
|
|
56
|
+
if (referenced) return;
|
|
57
|
+
if (n.getStart(sf) >= pos) return;
|
|
58
|
+
if (ts.isIdentifier(n) && n.text === name) {
|
|
59
|
+
const parent = n.parent;
|
|
60
|
+
if (parent) {
|
|
61
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === n) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (ts.isPropertyAssignment(parent) && parent.name === n) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (ts.isMethodDeclaration(parent) && parent.name === n) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (ts.isPropertyDeclaration(parent) && parent.name === n) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (ts.isBindingElement(parent) && parent.propertyName === n) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
referenced = true;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ts.forEachChild(n, visit);
|
|
81
|
+
};
|
|
82
|
+
ts.forEachChild(sf, visit);
|
|
83
|
+
return referenced;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getTypeParametersText(
|
|
87
|
+
node: ts.FunctionDeclaration | ts.FunctionExpression,
|
|
88
|
+
sf: ts.SourceFile,
|
|
89
|
+
): string {
|
|
90
|
+
if (node.typeParameters && node.typeParameters.length > 0) {
|
|
91
|
+
const params = node.typeParameters.map((p) => p.getText(sf));
|
|
92
|
+
return params.length === 1 ? `<${params[0]},>` : `<${params.join(", ")}>`;
|
|
93
|
+
}
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const isSimpleExpression = (expr: ts.Expression): boolean =>
|
|
98
|
+
ts.isIdentifier(expr) ||
|
|
99
|
+
ts.isPropertyAccessExpression(expr) ||
|
|
100
|
+
ts.isElementAccessExpression(expr) ||
|
|
101
|
+
ts.isCallExpression(expr);
|
|
102
|
+
|
|
103
|
+
export function containsConditional(node: ts.Node): boolean {
|
|
104
|
+
if (ts.isConditionalExpression(node)) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
let found = false;
|
|
108
|
+
ts.forEachChild(node, (child) => {
|
|
109
|
+
if (found) return;
|
|
110
|
+
if (containsConditional(child)) {
|
|
111
|
+
found = true;
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A concise arrow body that is — or unwraps through `as` / `satisfies` to — an
|
|
118
|
+
// object literal must be parenthesized, else `{ … }` parses as a block body.
|
|
119
|
+
export function startsWithObjectLiteral(expr: ts.Expression): boolean {
|
|
120
|
+
let e: ts.Expression = expr;
|
|
121
|
+
while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
|
|
122
|
+
e = e.expression;
|
|
123
|
+
}
|
|
124
|
+
return ts.isObjectLiteralExpression(e);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getTernaryBranchText(node: ts.Expression, sf: ts.SourceFile): string {
|
|
128
|
+
let current = node;
|
|
129
|
+
let hasParens = false;
|
|
130
|
+
while (ts.isParenthesizedExpression(current)) {
|
|
131
|
+
current = current.expression;
|
|
132
|
+
hasParens = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let unwrapped: ts.Expression | undefined;
|
|
136
|
+
if (ts.isJsxFragment(current)) {
|
|
137
|
+
const children = current.children;
|
|
138
|
+
const nonTriviaChildren = children.filter((child) => {
|
|
139
|
+
if (ts.isJsxText(child)) {
|
|
140
|
+
return !child.containsOnlyTriviaWhiteSpaces;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
});
|
|
144
|
+
if (nonTriviaChildren.length === 1) {
|
|
145
|
+
const soleChild = nonTriviaChildren[0];
|
|
146
|
+
if (soleChild && ts.isJsxExpression(soleChild) && soleChild.expression) {
|
|
147
|
+
unwrapped = soleChild.expression;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (ts.isJsxElement(current)) {
|
|
151
|
+
const tagName = current.openingElement.tagName.getText(sf);
|
|
152
|
+
if (tagName === "Fragment" || tagName === "React.Fragment" || tagName.endsWith(".Fragment")) {
|
|
153
|
+
if (current.openingElement.attributes.properties.length === 0) {
|
|
154
|
+
const children = current.children;
|
|
155
|
+
const nonTriviaChildren = children.filter((child) => {
|
|
156
|
+
if (ts.isJsxText(child)) {
|
|
157
|
+
return !child.containsOnlyTriviaWhiteSpaces;
|
|
158
|
+
}
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
if (nonTriviaChildren.length === 1) {
|
|
162
|
+
const soleChild = nonTriviaChildren[0];
|
|
163
|
+
if (soleChild && ts.isJsxExpression(soleChild) && soleChild.expression) {
|
|
164
|
+
unwrapped = soleChild.expression;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (unwrapped) {
|
|
172
|
+
const unwrappedText = getTernaryBranchText(unwrapped, sf);
|
|
173
|
+
return hasParens ? `(${unwrappedText})` : unwrappedText;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return node.getText(sf);
|
|
177
|
+
}
|
package/src/tersify.ts
CHANGED
|
@@ -3,118 +3,18 @@ import ts from "typescript";
|
|
|
3
3
|
import { edit, spanEdit, walkSource } from "./ast.js";
|
|
4
4
|
import { applyEditStep, byStartDesc } from "./chains.js";
|
|
5
5
|
import { scanIfToSwitchSequences } from "./switch.js";
|
|
6
|
+
import {
|
|
7
|
+
containsConditional,
|
|
8
|
+
getSingleReturnExpression,
|
|
9
|
+
getTernaryBranchText,
|
|
10
|
+
getTypeParametersText,
|
|
11
|
+
isIdentifierReferencedBefore,
|
|
12
|
+
isSimpleExpression,
|
|
13
|
+
startsWithObjectLiteral,
|
|
14
|
+
usesThisOrArguments,
|
|
15
|
+
} from "./tersify-utils.js";
|
|
6
16
|
import type { Edit } from "./types.js";
|
|
7
17
|
|
|
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
18
|
function tryTersifyIfElse(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
119
19
|
if (ts.isIfStatement(node) && node.elseStatement) {
|
|
120
20
|
const x = getSingleReturnExpression(node.thenStatement);
|
|
@@ -138,24 +38,14 @@ function tryTersifyIfElse(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
|
138
38
|
return some(spanEdit(node, sf, edit(text)));
|
|
139
39
|
}
|
|
140
40
|
|
|
141
|
-
const xText = x
|
|
142
|
-
const yText = y
|
|
41
|
+
const xText = getTernaryBranchText(x, sf);
|
|
42
|
+
const yText = getTernaryBranchText(y, sf);
|
|
143
43
|
return some(spanEdit(node, sf, edit(`return ${condText} ? ${xText} : ${yText};`)));
|
|
144
44
|
}
|
|
145
45
|
}
|
|
146
46
|
return none();
|
|
147
47
|
}
|
|
148
48
|
|
|
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
49
|
function tryTersifyArrowBlock(node: ts.Node, sf: ts.SourceFile): Maybe<Edit> {
|
|
160
50
|
if (ts.isArrowFunction(node) && ts.isBlock(node.body)) {
|
|
161
51
|
const expr = getSingleReturnExpression(node.body);
|
|
@@ -353,8 +243,8 @@ function tryTersifyTernaryOrBooleanSequence(
|
|
|
353
243
|
const needsParens = !isSimpleExpression(stmt.expression);
|
|
354
244
|
text = needsParens ? `return !(${condText});` : `return !${condText};`;
|
|
355
245
|
} else {
|
|
356
|
-
const xText = x
|
|
357
|
-
const yText = y
|
|
246
|
+
const xText = getTernaryBranchText(x, sf);
|
|
247
|
+
const yText = getTernaryBranchText(y, sf);
|
|
358
248
|
text = `return ${condText} ? ${xText} : ${yText};`;
|
|
359
249
|
}
|
|
360
250
|
|