@onrails/codemod 0.2.8 → 0.3.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/README.md +21 -0
- package/package.json +4 -4
- package/src/boundary-spec.ts +35 -0
- package/src/cli.ts +2 -1
- package/src/file-change.ts +8 -0
- package/src/index.ts +4 -0
- package/src/literals.ts +251 -0
- package/src/types.ts +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,9 @@ bunx @onrails/codemod /path/to/target-repo \
|
|
|
46
46
|
# stage 2: compat shim -> native imports
|
|
47
47
|
bunx @onrails/codemod /path/to/target-repo --to-native --dry
|
|
48
48
|
bunx @onrails/codemod /path/to/target-repo --to-native
|
|
49
|
+
|
|
50
|
+
# optional: build carriers with constructors instead of object literals
|
|
51
|
+
bunx @onrails/codemod /path/to/target-repo --literals --dry
|
|
49
52
|
```
|
|
50
53
|
|
|
51
54
|
After applying:
|
|
@@ -58,6 +61,24 @@ bun typecheck
|
|
|
58
61
|
bun test
|
|
59
62
|
```
|
|
60
63
|
|
|
64
|
+
## Literal rewrite
|
|
65
|
+
|
|
66
|
+
`--literals` rewrites hand-built carrier literals to constructor calls, so the
|
|
67
|
+
code keeps working if carriers ever stop being plain objects:
|
|
68
|
+
|
|
69
|
+
| Before | After |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `{ _tag: "Ok", value: x }` | `ok(x)` |
|
|
72
|
+
| `{ _tag: "Err", error: e }` | `err(e)` |
|
|
73
|
+
| `{ _tag: "Some", value: x }` | `some(x)` (`@onrails/maybe`) |
|
|
74
|
+
| `{ _tag: "None" }` | `none()` (`@onrails/maybe`) |
|
|
75
|
+
|
|
76
|
+
Only exact shapes are touched. Literals with extra keys, spreads, computed keys
|
|
77
|
+
or a non-literal `_tag` are left alone, as are files that already bind `ok`,
|
|
78
|
+
`err`, `some` or `none` to something else. A trailing `as const` is dropped
|
|
79
|
+
with the literal. Imports are added or merged. It works against the current
|
|
80
|
+
API, so it can run before any breaking release.
|
|
81
|
+
|
|
61
82
|
## Native migration
|
|
62
83
|
|
|
63
84
|
`--to-native` is intentionally conservative. It rewrites imports that have a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onrails/codemod",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.2.
|
|
46
|
-
"@onrails/pattern": "^0.3.
|
|
47
|
-
"@onrails/result": "^0.4.
|
|
45
|
+
"@onrails/maybe": "^0.2.5",
|
|
46
|
+
"@onrails/pattern": "^0.3.5",
|
|
47
|
+
"@onrails/result": "^0.4.2",
|
|
48
48
|
"typescript": "^7.0.2",
|
|
49
49
|
"typescript6": "npm:typescript@^6.0.3"
|
|
50
50
|
},
|
package/src/boundary-spec.ts
CHANGED
|
@@ -147,6 +147,28 @@ export const FLUENT_STAYS_LOCAL_RULE = {
|
|
|
147
147
|
"FluentResult/FluentMaybe must stay local — it was flagged as a return type, an exported binding, a stored field, or an argument to a serialize/postMessage/cache call. Call toResult()/toMaybe()/toString() before it leaves the function.",
|
|
148
148
|
} as const;
|
|
149
149
|
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Carrier sinks (prototype carriers)
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/** Carrier types whose methods live on a prototype that plain-data routes drop. */
|
|
155
|
+
export const CARRIER_TYPES = ["Result", "Maybe"] as const;
|
|
156
|
+
|
|
157
|
+
/** Deserialization sinks whose output is plain data, whatever the cast claims. */
|
|
158
|
+
export const CARRIER_SINK_NAMES = ["JSON.parse", "structuredClone", ".json()"] as const;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Plain data from a deserialization sink typed as `Result` / `Maybe` without
|
|
162
|
+
* `Result.from` / `Maybe.from`: the type claims methods the value lacks.
|
|
163
|
+
*/
|
|
164
|
+
export const CARRIER_SINKS_NEED_FROM_RULE = {
|
|
165
|
+
id: "carrier-sinks-need-from",
|
|
166
|
+
carriers: CARRIER_TYPES,
|
|
167
|
+
sinks: CARRIER_SINK_NAMES,
|
|
168
|
+
message:
|
|
169
|
+
"Data from JSON.parse/structuredClone/.json() is typed as Result/Maybe but carries no methods — lift it with Result.from / Maybe.from.",
|
|
170
|
+
} as const;
|
|
171
|
+
|
|
150
172
|
// ---------------------------------------------------------------------------
|
|
151
173
|
// Declared divergences
|
|
152
174
|
// ---------------------------------------------------------------------------
|
|
@@ -200,4 +222,17 @@ export const ENGINE_DIVERGENCES: readonly EngineDivergence[] = [
|
|
|
200
222
|
"cannot express the serialize/postMessage/cache argument heuristic at " +
|
|
201
223
|
"all, since that needs expression-shape traversal, not a type pattern.",
|
|
202
224
|
},
|
|
225
|
+
{
|
|
226
|
+
rule: CARRIER_SINKS_NEED_FROM_RULE.id,
|
|
227
|
+
engine: "biome",
|
|
228
|
+
divergence: "cast-form-only",
|
|
229
|
+
detail:
|
|
230
|
+
"The ESLint rule flags `JSON.parse(x) as Result<T, E>` and also the " +
|
|
231
|
+
"flow forms: `const r: Result<T, E> = JSON.parse(x)`, `return JSON.parse(x)` " +
|
|
232
|
+
"from a function annotated to return a carrier (or Promise of one), a " +
|
|
233
|
+
"carrier-typed parameter default, a `const` holding a sink result that is " +
|
|
234
|
+
"later cast, annotated or returned, and `res.json<Result<T, E>>()`. GritQL " +
|
|
235
|
+
"cannot relate a declarator, return type or binding to its use, so the " +
|
|
236
|
+
"Biome rule only matches the direct `as` cast form.",
|
|
237
|
+
},
|
|
203
238
|
];
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ const applyArg = (s: ArgState, a: string): ArgState =>
|
|
|
17
17
|
.withEither("--dry", "-n", () => ({ ...s, dry: true }))
|
|
18
18
|
.with("--to-native", () => ({ ...s, mode: "native" }))
|
|
19
19
|
.with("--tersify", () => ({ ...s, mode: "tersify" }))
|
|
20
|
+
.with("--literals", () => ({ ...s, mode: "literals" }))
|
|
20
21
|
.with(
|
|
21
22
|
(x) => x.startsWith(ONRAILS_FLAG),
|
|
22
23
|
(x) => ({ ...s, onrails: resolve(x.slice(ONRAILS_FLAG.length)) }),
|
|
@@ -28,7 +29,7 @@ const applyArg = (s: ArgState, a: string): ArgState =>
|
|
|
28
29
|
.otherwise(() => s);
|
|
29
30
|
|
|
30
31
|
const USAGE =
|
|
31
|
-
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--tersify] [--onrails=<abs-path>]";
|
|
32
|
+
"usage: onrails-codemod-neverthrow <target-dir> [--dry] [--to-native] [--tersify] [--literals] [--onrails=<abs-path>]";
|
|
32
33
|
|
|
33
34
|
export function parseArgs(argv: string[]): Result<Args, string> {
|
|
34
35
|
const initial: ArgState = {
|
package/src/file-change.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { countOccurrences } from "./ast.js";
|
|
|
3
3
|
import { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
4
4
|
import { COMPAT_SPEC, IMPORT_RE, NATIVE_SPEC } from "./constants.js";
|
|
5
5
|
import { rewriteCompatImportsToNative } from "./imports.js";
|
|
6
|
+
import { rewriteCarrierLiterals } from "./literals.js";
|
|
6
7
|
import { tersify } from "./tersify.js";
|
|
7
8
|
import type { ComputedChange, FileChange, Mode, ModeStrategy, Warning } from "./types.js";
|
|
8
9
|
import {
|
|
@@ -43,6 +44,13 @@ const MODES: Record<Mode, ModeStrategy> = {
|
|
|
43
44
|
warnings: () => [],
|
|
44
45
|
countAfter: () => 0,
|
|
45
46
|
},
|
|
47
|
+
literals: {
|
|
48
|
+
countBefore: (src) => countOccurrences(src, "_tag"),
|
|
49
|
+
earlyExit: (_src, before) => before === 0,
|
|
50
|
+
transform: rewriteCarrierLiterals,
|
|
51
|
+
warnings: () => [],
|
|
52
|
+
countAfter: () => 0,
|
|
53
|
+
},
|
|
46
54
|
};
|
|
47
55
|
|
|
48
56
|
export const computeFileChange = (src: string, mode: Mode, jsx = false): Maybe<ComputedChange> => {
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
import { main } from "./cli.js";
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
|
+
CARRIER_SINK_NAMES,
|
|
6
|
+
CARRIER_SINKS_NEED_FROM_RULE,
|
|
7
|
+
CARRIER_TYPES,
|
|
5
8
|
DEPRECATED_SYNONYM_RENAMES,
|
|
6
9
|
DEPRECATED_SYNONYMS,
|
|
7
10
|
type DeprecatedSynonym,
|
|
@@ -19,6 +22,7 @@ export { rewriteCompatMethodChainsToNative } from "./chains.js";
|
|
|
19
22
|
export { main } from "./cli.js";
|
|
20
23
|
export { computeFileChange } from "./file-change.js";
|
|
21
24
|
export { rewriteCompatImportsToNative } from "./imports.js";
|
|
25
|
+
export { rewriteCarrierLiterals } from "./literals.js";
|
|
22
26
|
export { computePkgRewrite } from "./pkg.js";
|
|
23
27
|
export { tersify } from "./tersify.js";
|
|
24
28
|
export { collectNativeMigrationWarnings } from "./warnings.js";
|
package/src/literals.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import ts from "typescript6";
|
|
2
|
+
import { walkSource } from "./ast.js";
|
|
3
|
+
import { NATIVE_SPEC } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
const MAYBE_SPEC = "@onrails/maybe";
|
|
6
|
+
|
|
7
|
+
type Shape = { readonly fn: string; readonly spec: string; readonly field: string | null };
|
|
8
|
+
|
|
9
|
+
const SHAPES: Record<string, Shape> = {
|
|
10
|
+
Ok: { fn: "ok", spec: NATIVE_SPEC, field: "value" },
|
|
11
|
+
Err: { fn: "err", spec: NATIVE_SPEC, field: "error" },
|
|
12
|
+
Some: { fn: "some", spec: MAYBE_SPEC, field: "value" },
|
|
13
|
+
None: { fn: "none", spec: MAYBE_SPEC, field: null },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Prop = { readonly key: string; readonly text: string };
|
|
17
|
+
type LiteralEdit = { start: number; end: number; text: string; name: string; spec: string };
|
|
18
|
+
|
|
19
|
+
const keyOf = (name: ts.PropertyName): string | null =>
|
|
20
|
+
ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : null;
|
|
21
|
+
|
|
22
|
+
/** `key: value` / `key` shorthand pairs; null when a spread, method, accessor or computed key is present. */
|
|
23
|
+
const readProps = (lit: ts.ObjectLiteralExpression): readonly Prop[] | null => {
|
|
24
|
+
const props: Prop[] = [];
|
|
25
|
+
for (const p of lit.properties) {
|
|
26
|
+
if (ts.isPropertyAssignment(p)) {
|
|
27
|
+
const key = keyOf(p.name);
|
|
28
|
+
if (key === null) return null;
|
|
29
|
+
props.push({ key, text: p.initializer.getText() });
|
|
30
|
+
} else if (ts.isShorthandPropertyAssignment(p)) {
|
|
31
|
+
props.push({ key: p.name.text, text: p.name.text });
|
|
32
|
+
} else return null;
|
|
33
|
+
}
|
|
34
|
+
return props;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const tagOf = (props: readonly Prop[]): string | undefined => {
|
|
38
|
+
const raw = props.find((p) => p.key === "_tag")?.text;
|
|
39
|
+
return raw?.match(/^(["'])(\w+)\1$/)?.[2];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const bindingNames = (name: ts.BindingName): readonly string[] =>
|
|
43
|
+
ts.isIdentifier(name)
|
|
44
|
+
? [name.text]
|
|
45
|
+
: name.elements.flatMap((el) => (ts.isOmittedExpression(el) ? [] : bindingNames(el.name)));
|
|
46
|
+
|
|
47
|
+
const declaredIn = (statements: readonly ts.Statement[]): readonly string[] =>
|
|
48
|
+
statements.flatMap((st) => {
|
|
49
|
+
if ((ts.isFunctionDeclaration(st) || ts.isClassDeclaration(st)) && st.name)
|
|
50
|
+
return [st.name.text];
|
|
51
|
+
if (ts.isVariableStatement(st)) {
|
|
52
|
+
return st.declarationList.declarations.flatMap((d) => bindingNames(d.name));
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** The genuine value binding of an onrails constructor: `import { ok }` with no alias, not type-only. */
|
|
58
|
+
const isPlainValueSpecifier = (el: ts.ImportSpecifier, clauseTypeOnly: boolean): boolean =>
|
|
59
|
+
!clauseTypeOnly && !el.isTypeOnly && el.propertyName === undefined;
|
|
60
|
+
|
|
61
|
+
/** Locals the file's imports bind to something other than the onrails constructor of the same name. */
|
|
62
|
+
const importedLocals = (sf: ts.SourceFile): readonly string[] =>
|
|
63
|
+
sf.statements.flatMap((st) => {
|
|
64
|
+
if (!ts.isImportDeclaration(st)) return [];
|
|
65
|
+
const spec = ts.isStringLiteral(st.moduleSpecifier) ? st.moduleSpecifier.text : "";
|
|
66
|
+
const onrails = spec === NATIVE_SPEC || spec === MAYBE_SPEC;
|
|
67
|
+
const clause = st.importClause;
|
|
68
|
+
const nb = clause?.namedBindings;
|
|
69
|
+
const named =
|
|
70
|
+
nb && ts.isNamedImports(nb)
|
|
71
|
+
? nb.elements.filter(
|
|
72
|
+
(el) => !onrails || !isPlainValueSpecifier(el, clause?.isTypeOnly ?? false),
|
|
73
|
+
)
|
|
74
|
+
: [];
|
|
75
|
+
return [
|
|
76
|
+
...(clause?.name ? [clause.name.text] : []),
|
|
77
|
+
...(nb && ts.isNamespaceImport(nb) ? [nb.name.text] : []),
|
|
78
|
+
...named.map((el) => el.name.text),
|
|
79
|
+
];
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** `var` names declared anywhere under `root`, stopping at nested functions (they hoist to those). */
|
|
83
|
+
const hoistedVars = (root: ts.Node): readonly string[] => {
|
|
84
|
+
const out: string[] = [];
|
|
85
|
+
const visit = (n: ts.Node): void => {
|
|
86
|
+
if (ts.isFunctionLike(n) || ts.isClassLike(n)) return;
|
|
87
|
+
if (ts.isVariableDeclarationList(n) && !(n.flags & ts.NodeFlags.BlockScoped)) {
|
|
88
|
+
for (const d of n.declarations) out.push(...bindingNames(d.name));
|
|
89
|
+
}
|
|
90
|
+
ts.forEachChild(n, visit);
|
|
91
|
+
};
|
|
92
|
+
ts.forEachChild(root, (child) => {
|
|
93
|
+
// the function's own parameters and name are not `var`s; its body is
|
|
94
|
+
if (!ts.isFunctionLike(root) || child === (root as ts.FunctionLikeDeclaration).body)
|
|
95
|
+
visit(child);
|
|
96
|
+
});
|
|
97
|
+
return out;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Names a single scope-creating node binds for the code inside it. */
|
|
101
|
+
const boundBy = (node: ts.Node): readonly string[] => {
|
|
102
|
+
if (ts.isSourceFile(node)) {
|
|
103
|
+
return [...importedLocals(node), ...declaredIn(node.statements), ...hoistedVars(node)];
|
|
104
|
+
}
|
|
105
|
+
if (ts.isBlock(node) || ts.isModuleBlock(node)) return declaredIn(node.statements);
|
|
106
|
+
if (ts.isCaseBlock(node)) return declaredIn(node.clauses.flatMap((c) => [...c.statements]));
|
|
107
|
+
if (ts.isCatchClause(node)) {
|
|
108
|
+
return node.variableDeclaration ? bindingNames(node.variableDeclaration.name) : [];
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
(ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
112
|
+
node.initializer &&
|
|
113
|
+
ts.isVariableDeclarationList(node.initializer)
|
|
114
|
+
) {
|
|
115
|
+
return node.initializer.declarations.flatMap((d) => bindingNames(d.name));
|
|
116
|
+
}
|
|
117
|
+
if (ts.isFunctionLike(node)) {
|
|
118
|
+
const own =
|
|
119
|
+
(ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name
|
|
120
|
+
? [node.name.text]
|
|
121
|
+
: [];
|
|
122
|
+
return [...own, ...node.parameters.flatMap((p) => bindingNames(p.name)), ...hoistedVars(node)];
|
|
123
|
+
}
|
|
124
|
+
return [];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** True when `name` resolves to a non-onrails binding at `node` (walks every enclosing scope). */
|
|
128
|
+
const isShadowed = (node: ts.Node, name: string): boolean => {
|
|
129
|
+
for (let cur: ts.Node | undefined = node.parent; cur; cur = cur.parent) {
|
|
130
|
+
if (boundBy(cur).includes(name)) return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const PATTERN_CALLS = new Set(["with", "withEither", "when", "isMatching"]);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* True when the literal is (part of) a pattern argument of a ts-pattern style call. A pattern is
|
|
139
|
+
* not a value: `ok(P.number)` would be a carrier where a matcher is expected. Stops at function
|
|
140
|
+
* boundaries so literals inside a handler body are still rewritten.
|
|
141
|
+
*/
|
|
142
|
+
const isPatternPosition = (node: ts.Node): boolean => {
|
|
143
|
+
for (let cur: ts.Node | undefined = node.parent; cur; cur = cur.parent) {
|
|
144
|
+
if (ts.isFunctionLike(cur)) return false;
|
|
145
|
+
if (!ts.isCallExpression(cur)) continue;
|
|
146
|
+
const callee = cur.expression;
|
|
147
|
+
const name = ts.isPropertyAccessExpression(callee)
|
|
148
|
+
? callee.name.text
|
|
149
|
+
: ts.isIdentifier(callee)
|
|
150
|
+
? callee.text
|
|
151
|
+
: "";
|
|
152
|
+
if (PATTERN_CALLS.has(name)) return true;
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The node to replace: the literal, widened over a trailing `as const` that the call makes redundant. */
|
|
158
|
+
const spanNode = (lit: ts.ObjectLiteralExpression): ts.Node =>
|
|
159
|
+
ts.isAsExpression(lit.parent) &&
|
|
160
|
+
ts.isTypeReferenceNode(lit.parent.type) &&
|
|
161
|
+
lit.parent.type.typeName.getText() === "const"
|
|
162
|
+
? lit.parent
|
|
163
|
+
: lit;
|
|
164
|
+
|
|
165
|
+
const collectEdits = (src: string, jsx: boolean): readonly LiteralEdit[] => {
|
|
166
|
+
const edits: LiteralEdit[] = [];
|
|
167
|
+
walkSource(
|
|
168
|
+
src,
|
|
169
|
+
(node, sf) => {
|
|
170
|
+
if (!ts.isObjectLiteralExpression(node)) return false;
|
|
171
|
+
const props = readProps(node);
|
|
172
|
+
const tag = props && tagOf(props);
|
|
173
|
+
const shape = tag ? SHAPES[tag] : undefined;
|
|
174
|
+
if (!props || !shape || isShadowed(node, shape.fn) || isPatternPosition(node)) return false;
|
|
175
|
+
const wanted = shape.field === null ? ["_tag"] : ["_tag", shape.field];
|
|
176
|
+
const keys = props.map((p) => p.key);
|
|
177
|
+
if (keys.length !== wanted.length || !wanted.every((k) => keys.includes(k))) return false;
|
|
178
|
+
const arg =
|
|
179
|
+
shape.field === null ? "" : (props.find((p) => p.key === shape.field)?.text ?? "");
|
|
180
|
+
const span = spanNode(node);
|
|
181
|
+
edits.push({
|
|
182
|
+
start: span.getStart(sf),
|
|
183
|
+
end: span.getEnd(),
|
|
184
|
+
text: `${shape.fn}(${arg})`,
|
|
185
|
+
name: shape.fn,
|
|
186
|
+
spec: shape.spec,
|
|
187
|
+
});
|
|
188
|
+
// outermost only; nested literals are picked up on the next pass
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
jsx,
|
|
192
|
+
);
|
|
193
|
+
return edits;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const matchingImports = (sf: ts.SourceFile, spec: string): readonly ts.ImportDeclaration[] =>
|
|
197
|
+
sf.statements.filter(
|
|
198
|
+
(st): st is ts.ImportDeclaration =>
|
|
199
|
+
ts.isImportDeclaration(st) &&
|
|
200
|
+
ts.isStringLiteral(st.moduleSpecifier) &&
|
|
201
|
+
st.moduleSpecifier.text === spec &&
|
|
202
|
+
!st.importClause?.isTypeOnly &&
|
|
203
|
+
st.importClause?.namedBindings !== undefined &&
|
|
204
|
+
ts.isNamedImports(st.importClause.namedBindings),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Merges `names` into a value import of `spec` (found through the parser), or inserts one after any
|
|
209
|
+
* shebang. A name counts as present only when a plain, unaliased, non-type specifier binds it locally.
|
|
210
|
+
*/
|
|
211
|
+
const addImports = (src: string, spec: string, names: readonly string[]): string => {
|
|
212
|
+
const wanted = [...new Set(names)].sort();
|
|
213
|
+
if (wanted.length === 0) return src;
|
|
214
|
+
const sf = ts.createSourceFile("codemod.ts", src, ts.ScriptTarget.Latest, true);
|
|
215
|
+
const decls = matchingImports(sf, spec);
|
|
216
|
+
const bound = new Set(
|
|
217
|
+
decls.flatMap((d) => {
|
|
218
|
+
const nb = d.importClause?.namedBindings;
|
|
219
|
+
return nb && ts.isNamedImports(nb)
|
|
220
|
+
? nb.elements.filter((el) => isPlainValueSpecifier(el, false)).map((el) => el.name.text)
|
|
221
|
+
: [];
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const extra = wanted.filter((n) => !bound.has(n));
|
|
225
|
+
if (extra.length === 0) return src;
|
|
226
|
+
const target = decls[0]?.importClause?.namedBindings;
|
|
227
|
+
if (target && ts.isNamedImports(target)) {
|
|
228
|
+
// keep existing specifiers verbatim (`type X`, aliases) and append what is missing
|
|
229
|
+
const texts = [...target.elements.map((el) => el.getText(sf)), ...extra];
|
|
230
|
+
return `${src.slice(0, target.getStart(sf))}{ ${texts.join(", ")} }${src.slice(target.getEnd())}`;
|
|
231
|
+
}
|
|
232
|
+
const line = `import { ${extra.join(", ")} } from "${spec}";\n`;
|
|
233
|
+
const shebang = /^#![^\n]*\n/.exec(src);
|
|
234
|
+
return shebang ? `${shebang[0]}${line}${src.slice(shebang[0].length)}` : `${line}${src}`;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Rewrites `{ _tag: "Ok", value }` style literals to `ok(value)` etc. so carriers are built by constructors. */
|
|
238
|
+
export function rewriteCarrierLiterals(src: string, jsx = false): string {
|
|
239
|
+
let cur = src;
|
|
240
|
+
const used = new Map<string, Set<string>>();
|
|
241
|
+
// each pass rewrites the outermost literals and strictly shrinks the literal count, so this terminates
|
|
242
|
+
for (;;) {
|
|
243
|
+
const edits = [...collectEdits(cur, jsx)].sort((a, b) => b.start - a.start);
|
|
244
|
+
if (edits.length === 0) break;
|
|
245
|
+
for (const e of edits) {
|
|
246
|
+
cur = `${cur.slice(0, e.start)}${e.text}${cur.slice(e.end)}`;
|
|
247
|
+
used.set(e.spec, (used.get(e.spec) ?? new Set()).add(e.name));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...used].reduce((acc, [spec, names]) => addImports(acc, spec, [...names]), cur);
|
|
251
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Mode = "compat" | "native" | "tersify";
|
|
1
|
+
export type Mode = "compat" | "native" | "tersify" | "literals";
|
|
2
2
|
export type Args = { target: string; dry: boolean; onrails: string; mode: Mode };
|
|
3
3
|
|
|
4
4
|
export type Warning = { line: number; label: string; text: string };
|