Skip to content

Commit

Permalink
add machine-to-create-machine codemod
Browse files Browse the repository at this point in the history
  • Loading branch information
with-heart committed Apr 29, 2023
1 parent b12a307 commit 5fa384e
Show file tree
Hide file tree
Showing 11 changed files with 571 additions and 8 deletions.
31 changes: 31 additions & 0 deletions packages/codemods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# `@xstate/codemods`

A library of automatic codebase refactors for [XState](https://github.com/statelyai/xstate).

## `v4-to-v5`

This codemod migrates a v4 codebase to v5.

### `machine-to-create-machine`

```diff
-import { Machine } from 'xstate';
+import { createMachine } from 'xstate';

-const machine = Machine({});
+const machine = createMachine({});
```

```diff
-import { Machine as SomethingElse } from 'xstate';
+import { createMachine as SomethingElse } from 'xstate';

const machine = SomethingElse({})
```

```diff
import xstate from 'xstate';

-const machine = xstate.Machine({});
+const machine = xstate.createMachine({});
```
3 changes: 2 additions & 1 deletion packages/codemods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"devDependencies": {
"@tsconfig/node16": "^1.0.3",
"@tsconfig/strictest": "^2.0.0"
"@tsconfig/strictest": "^2.0.0",
"outdent": "^0.8.0"
}
}
9 changes: 2 additions & 7 deletions packages/codemods/src/v4-to-v5/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
import ts from 'typescript';
import { machineToCreateMachine } from './transformers/machine-to-create-machine';

export const v4ToV5: ts.TransformerFactory<ts.SourceFile> =
(context) => (sourceFile) => {
const { factory } = context;

return sourceFile;
};
export const v4ToV5 = [machineToCreateMachine];
226 changes: 226 additions & 0 deletions packages/codemods/src/v4-to-v5/predicates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import outdent from 'outdent';
import ts from 'typescript';
import {
isDescendantOfXStateImport,
isMachineCallExpression,
isMachineNamedImportSpecifier,
isMachinePropertyAccessExpression,
isXStateImportClause,
isXStateImportDeclaration,
} from './predicates';
import { parse } from './test-utils';
import { findDescendant } from './utils';

describe('isXStateImportDeclaration', () => {
test.each<[code: string, expected: boolean]>([
[`import xstate from 'xstate'`, true],
[`import { Machine } from 'xstate'`, true],
[`import { Machine as M } from 'xstate'`, true],
[`import xstate from 'not-xstate'`, false],
[`import { Machine } from 'not-xstate'`, false],
[`import { Machine as M } from 'not-xstate'`, false],
[`console.log('not an ImportDeclaration')`, false],
])('%s (%s)', (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isXStateImportDeclaration);

if (expected) {
expect(node).not.toBeUndefined();
expect(node!.kind).toBe(ts.SyntaxKind.ImportDeclaration);
} else {
expect(node).toBeUndefined();
}
});
});

describe('isDescendantOfXStateImport', () => {
test.each<[code: string, expected: boolean]>([
[`import xstate from 'xstate'`, true],
[`import { Machine } from 'xstate'`, true],
[`import { Machine as M } from 'xstate'`, true],
[`import xstate from 'not-xstate'`, false],
[`import { Machine } from 'not-xstate'`, false],
[`import { Machine as M } from 'not-xstate'`, false],
])(`%s (%s)`, (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isDescendantOfXStateImport);

if (expected) {
expect(node).not.toBeUndefined();
} else {
expect(node).toBeUndefined();
}
});
});

describe('isXStateImportClause', () => {
test.each<[code: string, expected: boolean]>([
[`import xstate from 'xstate'`, true],
[`import { Machine } from 'xstate'`, false],
[`import { Machine as M } from 'xstate'`, false],
[`import xstate from 'not-xstate'`, false],
[`import { Machine } from 'not-xstate'`, false],
[`import { Machine as M } from 'not-xstate'`, false],
])('%s (%s)', (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isXStateImportClause);

if (expected) {
expect(node).not.toBeUndefined();
expect(node!.kind).toBe(ts.SyntaxKind.ImportClause);
} else {
expect(node).toBeUndefined();
}
});
});

describe('isMachineNamedImportSpecifier', () => {
test.each<[code: string, expected: boolean]>([
[`import { Machine } from 'xstate'`, true],
[`import { Machine as M } from 'xstate'`, true],
[`import { NotMachine } from 'xstate'`, false],
[`import { NotMachine as Machine } from 'xstate'`, false],
[`console.log('not an ImportSpecifier')`, false],
])('%s (%s)', (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isMachineNamedImportSpecifier);

if (expected) {
expect(node).not.toBeUndefined();
expect(node!.kind).toBe(ts.SyntaxKind.ImportSpecifier);
} else {
expect(node).toBeUndefined();
}
});
});

describe('isMachineCallExpression', () => {
test.each<[code: string, expected: boolean]>([
[
outdent`
import { Machine } from 'xstate'
const machine = Machine({})
`,
true,
],
[
outdent`
import { Machine as M } from 'xstate'
const machine = M({})
`,
false,
],
[
outdent`
import xstate from 'xstate'
const machine = xstate.Machine({})
`,
false,
],
[
outdent`
import { Machine } from 'not-xstate'
const machine = Machine({})
`,
false,
],
[
outdent`
function Machine() {}
const machine = Machine({})
`,
false,
],
[
outdent`
console.log('non-xstate CallExpression')
`,
false,
],
[
outdent`
const str = "not a CallExpression"
`,
false,
],
])('%s (%s)', (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isMachineCallExpression);

if (expected) {
expect(node).not.toBeUndefined();
expect(node!.kind).toBe(ts.SyntaxKind.CallExpression);
} else {
expect(node).toBeUndefined();
}
});
});

describe('isMachinePropertyAccessExpression', () => {
test.each<[input: string, expected: boolean]>([
[
outdent`
import xstate from 'xstate'
const machine = xstate.Machine({})
`,
true,
],
[
outdent`
import xstate from 'xstate'
const Machine = xstate.Machine
`,
true,
],
[
outdent`
import { Machine } from 'xstate'
const machine = Machine({})
`,
false,
],
[
outdent`
import { Machine as M } from 'xstate'
const machine = M({})
`,
false,
],
[
outdent`
import { Machine } from 'not-xstate'
const machine = Machine({})
`,
false,
],
[
outdent`
function Machine() {}
const machine = Machine({})
`,
false,
],
[
outdent`
console.log('non-xstate PropertyAccessExpression')
`,
false,
],
[
outdent`
const str = "not a CallExpression"
`,
false,
],
])('%s (%s)', (code, expected) => {
const sourceFile = parse(code);
const node = findDescendant(sourceFile, isMachinePropertyAccessExpression);

if (expected) {
expect(node).not.toBeUndefined();
expect(node!.kind).toBe(ts.SyntaxKind.PropertyAccessExpression);
} else {
expect(node).toBeUndefined();
}
});
});
85 changes: 85 additions & 0 deletions packages/codemods/src/v4-to-v5/predicates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import ts from 'typescript';
import { findDescendant } from './utils';

export function isXStateImportDeclaration(
node: ts.Node,
): node is ts.ImportDeclaration {
return (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === 'xstate'
);
}

export function isDescendantOfXStateImport(node: ts.Node): boolean {
return Boolean(ts.findAncestor(node, isXStateImportDeclaration));
}

export function isXStateImportClause(
node: ts.Node,
): node is ts.ImportClause & { name: ts.Identifier } {
return Boolean(
isDescendantOfXStateImport(node) &&
ts.isImportClause(node) &&
node.name &&
ts.isIdentifier(node.name),
);
}

export function isMachineNamedImportSpecifier(
node: ts.Node,
): node is ts.ImportSpecifier {
return (
isDescendantOfXStateImport(node) &&
ts.isImportSpecifier(node) &&
(node.propertyName
? node.propertyName.text === 'Machine'
: node.name.text === 'Machine')
);
}

export function isMachineCallExpression(
node: ts.Node,
): node is ts.CallExpression & { expression: ts.Identifier } {
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) {
return false;
}

const sourceFile = node.getSourceFile();
const namedImportSpecifier = findDescendant(
sourceFile,
isMachineNamedImportSpecifier,
);

if (!namedImportSpecifier) {
return false;
}

return (
node.expression.text ===
(namedImportSpecifier.propertyName ?? namedImportSpecifier.name).text
);
}

export function isMachinePropertyAccessExpression(
node: ts.Node,
): node is ts.PropertyAccessExpression {
if (
!ts.isPropertyAccessExpression(node) ||
!ts.isIdentifier(node.expression)
) {
return false;
}

const sourceFile = node.getSourceFile();
const xstateImportClause = findDescendant(sourceFile, isXStateImportClause);

if (!xstateImportClause) {
return false;
}

return (
node.expression.text === xstateImportClause.name.text &&
node.name.text === 'Machine'
);
}
13 changes: 13 additions & 0 deletions packages/codemods/src/v4-to-v5/test-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import ts from 'typescript';
import { isXStateImportDeclaration } from './predicates';
import { parse } from './test-utils';
import { findDescendant } from './utils';

test('parse', () => {
const sourceFile = parse(`import { Machine } from 'xstate'`);

expect(ts.isSourceFile(sourceFile)).toBe(true);
expect(
findDescendant(sourceFile, isXStateImportDeclaration),
).not.toBeUndefined();
});
8 changes: 8 additions & 0 deletions packages/codemods/src/v4-to-v5/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ts from 'typescript';

/**
* Parse a string of code into a TypeScript `SourceFile` node.
*/
export function parse(code: string, fileName: string = 'code.ts') {
return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true);
}
Loading

0 comments on commit 5fa384e

Please sign in to comment.