Skip to content

Commit 5fa384e

Browse files
committed
add machine-to-create-machine codemod
1 parent b12a307 commit 5fa384e

File tree

11 files changed

+571
-8
lines changed

11 files changed

+571
-8
lines changed

packages/codemods/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# `@xstate/codemods`
2+
3+
A library of automatic codebase refactors for [XState](https://github.com/statelyai/xstate).
4+
5+
## `v4-to-v5`
6+
7+
This codemod migrates a v4 codebase to v5.
8+
9+
### `machine-to-create-machine`
10+
11+
```diff
12+
-import { Machine } from 'xstate';
13+
+import { createMachine } from 'xstate';
14+
15+
-const machine = Machine({});
16+
+const machine = createMachine({});
17+
```
18+
19+
```diff
20+
-import { Machine as SomethingElse } from 'xstate';
21+
+import { createMachine as SomethingElse } from 'xstate';
22+
23+
const machine = SomethingElse({})
24+
```
25+
26+
```diff
27+
import xstate from 'xstate';
28+
29+
-const machine = xstate.Machine({});
30+
+const machine = xstate.createMachine({});
31+
```

packages/codemods/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"devDependencies": {
1313
"@tsconfig/node16": "^1.0.3",
14-
"@tsconfig/strictest": "^2.0.0"
14+
"@tsconfig/strictest": "^2.0.0",
15+
"outdent": "^0.8.0"
1516
}
1617
}
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
import ts from 'typescript';
1+
import { machineToCreateMachine } from './transformers/machine-to-create-machine';
22

3-
export const v4ToV5: ts.TransformerFactory<ts.SourceFile> =
4-
(context) => (sourceFile) => {
5-
const { factory } = context;
6-
7-
return sourceFile;
8-
};
3+
export const v4ToV5 = [machineToCreateMachine];
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import outdent from 'outdent';
2+
import ts from 'typescript';
3+
import {
4+
isDescendantOfXStateImport,
5+
isMachineCallExpression,
6+
isMachineNamedImportSpecifier,
7+
isMachinePropertyAccessExpression,
8+
isXStateImportClause,
9+
isXStateImportDeclaration,
10+
} from './predicates';
11+
import { parse } from './test-utils';
12+
import { findDescendant } from './utils';
13+
14+
describe('isXStateImportDeclaration', () => {
15+
test.each<[code: string, expected: boolean]>([
16+
[`import xstate from 'xstate'`, true],
17+
[`import { Machine } from 'xstate'`, true],
18+
[`import { Machine as M } from 'xstate'`, true],
19+
[`import xstate from 'not-xstate'`, false],
20+
[`import { Machine } from 'not-xstate'`, false],
21+
[`import { Machine as M } from 'not-xstate'`, false],
22+
[`console.log('not an ImportDeclaration')`, false],
23+
])('%s (%s)', (code, expected) => {
24+
const sourceFile = parse(code);
25+
const node = findDescendant(sourceFile, isXStateImportDeclaration);
26+
27+
if (expected) {
28+
expect(node).not.toBeUndefined();
29+
expect(node!.kind).toBe(ts.SyntaxKind.ImportDeclaration);
30+
} else {
31+
expect(node).toBeUndefined();
32+
}
33+
});
34+
});
35+
36+
describe('isDescendantOfXStateImport', () => {
37+
test.each<[code: string, expected: boolean]>([
38+
[`import xstate from 'xstate'`, true],
39+
[`import { Machine } from 'xstate'`, true],
40+
[`import { Machine as M } from 'xstate'`, true],
41+
[`import xstate from 'not-xstate'`, false],
42+
[`import { Machine } from 'not-xstate'`, false],
43+
[`import { Machine as M } from 'not-xstate'`, false],
44+
])(`%s (%s)`, (code, expected) => {
45+
const sourceFile = parse(code);
46+
const node = findDescendant(sourceFile, isDescendantOfXStateImport);
47+
48+
if (expected) {
49+
expect(node).not.toBeUndefined();
50+
} else {
51+
expect(node).toBeUndefined();
52+
}
53+
});
54+
});
55+
56+
describe('isXStateImportClause', () => {
57+
test.each<[code: string, expected: boolean]>([
58+
[`import xstate from 'xstate'`, true],
59+
[`import { Machine } from 'xstate'`, false],
60+
[`import { Machine as M } from 'xstate'`, false],
61+
[`import xstate from 'not-xstate'`, false],
62+
[`import { Machine } from 'not-xstate'`, false],
63+
[`import { Machine as M } from 'not-xstate'`, false],
64+
])('%s (%s)', (code, expected) => {
65+
const sourceFile = parse(code);
66+
const node = findDescendant(sourceFile, isXStateImportClause);
67+
68+
if (expected) {
69+
expect(node).not.toBeUndefined();
70+
expect(node!.kind).toBe(ts.SyntaxKind.ImportClause);
71+
} else {
72+
expect(node).toBeUndefined();
73+
}
74+
});
75+
});
76+
77+
describe('isMachineNamedImportSpecifier', () => {
78+
test.each<[code: string, expected: boolean]>([
79+
[`import { Machine } from 'xstate'`, true],
80+
[`import { Machine as M } from 'xstate'`, true],
81+
[`import { NotMachine } from 'xstate'`, false],
82+
[`import { NotMachine as Machine } from 'xstate'`, false],
83+
[`console.log('not an ImportSpecifier')`, false],
84+
])('%s (%s)', (code, expected) => {
85+
const sourceFile = parse(code);
86+
const node = findDescendant(sourceFile, isMachineNamedImportSpecifier);
87+
88+
if (expected) {
89+
expect(node).not.toBeUndefined();
90+
expect(node!.kind).toBe(ts.SyntaxKind.ImportSpecifier);
91+
} else {
92+
expect(node).toBeUndefined();
93+
}
94+
});
95+
});
96+
97+
describe('isMachineCallExpression', () => {
98+
test.each<[code: string, expected: boolean]>([
99+
[
100+
outdent`
101+
import { Machine } from 'xstate'
102+
const machine = Machine({})
103+
`,
104+
true,
105+
],
106+
[
107+
outdent`
108+
import { Machine as M } from 'xstate'
109+
const machine = M({})
110+
`,
111+
false,
112+
],
113+
[
114+
outdent`
115+
import xstate from 'xstate'
116+
const machine = xstate.Machine({})
117+
`,
118+
false,
119+
],
120+
[
121+
outdent`
122+
import { Machine } from 'not-xstate'
123+
const machine = Machine({})
124+
`,
125+
false,
126+
],
127+
[
128+
outdent`
129+
function Machine() {}
130+
const machine = Machine({})
131+
`,
132+
false,
133+
],
134+
[
135+
outdent`
136+
console.log('non-xstate CallExpression')
137+
`,
138+
false,
139+
],
140+
[
141+
outdent`
142+
const str = "not a CallExpression"
143+
`,
144+
false,
145+
],
146+
])('%s (%s)', (code, expected) => {
147+
const sourceFile = parse(code);
148+
const node = findDescendant(sourceFile, isMachineCallExpression);
149+
150+
if (expected) {
151+
expect(node).not.toBeUndefined();
152+
expect(node!.kind).toBe(ts.SyntaxKind.CallExpression);
153+
} else {
154+
expect(node).toBeUndefined();
155+
}
156+
});
157+
});
158+
159+
describe('isMachinePropertyAccessExpression', () => {
160+
test.each<[input: string, expected: boolean]>([
161+
[
162+
outdent`
163+
import xstate from 'xstate'
164+
const machine = xstate.Machine({})
165+
`,
166+
true,
167+
],
168+
[
169+
outdent`
170+
import xstate from 'xstate'
171+
const Machine = xstate.Machine
172+
`,
173+
true,
174+
],
175+
[
176+
outdent`
177+
import { Machine } from 'xstate'
178+
const machine = Machine({})
179+
`,
180+
false,
181+
],
182+
[
183+
outdent`
184+
import { Machine as M } from 'xstate'
185+
const machine = M({})
186+
`,
187+
false,
188+
],
189+
[
190+
outdent`
191+
import { Machine } from 'not-xstate'
192+
const machine = Machine({})
193+
`,
194+
false,
195+
],
196+
[
197+
outdent`
198+
function Machine() {}
199+
const machine = Machine({})
200+
`,
201+
false,
202+
],
203+
[
204+
outdent`
205+
console.log('non-xstate PropertyAccessExpression')
206+
`,
207+
false,
208+
],
209+
[
210+
outdent`
211+
const str = "not a CallExpression"
212+
`,
213+
false,
214+
],
215+
])('%s (%s)', (code, expected) => {
216+
const sourceFile = parse(code);
217+
const node = findDescendant(sourceFile, isMachinePropertyAccessExpression);
218+
219+
if (expected) {
220+
expect(node).not.toBeUndefined();
221+
expect(node!.kind).toBe(ts.SyntaxKind.PropertyAccessExpression);
222+
} else {
223+
expect(node).toBeUndefined();
224+
}
225+
});
226+
});
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import ts from 'typescript';
2+
import { findDescendant } from './utils';
3+
4+
export function isXStateImportDeclaration(
5+
node: ts.Node,
6+
): node is ts.ImportDeclaration {
7+
return (
8+
ts.isImportDeclaration(node) &&
9+
ts.isStringLiteral(node.moduleSpecifier) &&
10+
node.moduleSpecifier.text === 'xstate'
11+
);
12+
}
13+
14+
export function isDescendantOfXStateImport(node: ts.Node): boolean {
15+
return Boolean(ts.findAncestor(node, isXStateImportDeclaration));
16+
}
17+
18+
export function isXStateImportClause(
19+
node: ts.Node,
20+
): node is ts.ImportClause & { name: ts.Identifier } {
21+
return Boolean(
22+
isDescendantOfXStateImport(node) &&
23+
ts.isImportClause(node) &&
24+
node.name &&
25+
ts.isIdentifier(node.name),
26+
);
27+
}
28+
29+
export function isMachineNamedImportSpecifier(
30+
node: ts.Node,
31+
): node is ts.ImportSpecifier {
32+
return (
33+
isDescendantOfXStateImport(node) &&
34+
ts.isImportSpecifier(node) &&
35+
(node.propertyName
36+
? node.propertyName.text === 'Machine'
37+
: node.name.text === 'Machine')
38+
);
39+
}
40+
41+
export function isMachineCallExpression(
42+
node: ts.Node,
43+
): node is ts.CallExpression & { expression: ts.Identifier } {
44+
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) {
45+
return false;
46+
}
47+
48+
const sourceFile = node.getSourceFile();
49+
const namedImportSpecifier = findDescendant(
50+
sourceFile,
51+
isMachineNamedImportSpecifier,
52+
);
53+
54+
if (!namedImportSpecifier) {
55+
return false;
56+
}
57+
58+
return (
59+
node.expression.text ===
60+
(namedImportSpecifier.propertyName ?? namedImportSpecifier.name).text
61+
);
62+
}
63+
64+
export function isMachinePropertyAccessExpression(
65+
node: ts.Node,
66+
): node is ts.PropertyAccessExpression {
67+
if (
68+
!ts.isPropertyAccessExpression(node) ||
69+
!ts.isIdentifier(node.expression)
70+
) {
71+
return false;
72+
}
73+
74+
const sourceFile = node.getSourceFile();
75+
const xstateImportClause = findDescendant(sourceFile, isXStateImportClause);
76+
77+
if (!xstateImportClause) {
78+
return false;
79+
}
80+
81+
return (
82+
node.expression.text === xstateImportClause.name.text &&
83+
node.name.text === 'Machine'
84+
);
85+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import ts from 'typescript';
2+
import { isXStateImportDeclaration } from './predicates';
3+
import { parse } from './test-utils';
4+
import { findDescendant } from './utils';
5+
6+
test('parse', () => {
7+
const sourceFile = parse(`import { Machine } from 'xstate'`);
8+
9+
expect(ts.isSourceFile(sourceFile)).toBe(true);
10+
expect(
11+
findDescendant(sourceFile, isXStateImportDeclaration),
12+
).not.toBeUndefined();
13+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import ts from 'typescript';
2+
3+
/**
4+
* Parse a string of code into a TypeScript `SourceFile` node.
5+
*/
6+
export function parse(code: string, fileName: string = 'code.ts') {
7+
return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
8+
}

0 commit comments

Comments
 (0)