Skip to content
This repository has been archived by the owner on Mar 5, 2023. It is now read-only.

Rewrite to ts morph #78

Open
wants to merge 28 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bae5ac4
Rewrite for type-level extractor using ts-morph
Andarist Sep 14, 2020
1805f5b
Merge branch 'master' into rewrite-to-ts-morph
Andarist Sep 14, 2020
9b45dfc
Implement array extraction
Andarist Sep 14, 2020
560fe30
Fixed minor issues
Andarist Sep 15, 2020
748d65e
Implemented options extractor
Andarist Sep 15, 2020
2564bd3
Added support for extracting delayed transitions
Andarist Sep 15, 2020
88f143d
Merge branch 'master' into rewrite-to-ts-morph
Andarist Sep 15, 2020
cc61a46
Add support for extracting `always`
Andarist Sep 16, 2020
0cf8405
Use entry/exit properties over onEntry/onExit in the complex machine …
Andarist Sep 16, 2020
c534077
Added support for action objects and fixed issue with inline function…
Andarist Sep 16, 2020
4b4f3f4
Fixed the Action extractor when there is no type for it and add funct…
Andarist Sep 16, 2020
3d3696c
Allow for inline actions in the Actions extractor
Andarist Sep 16, 2020
218b5a4
Rewritten to extract based on the inline AST nodes and only fallback …
Andarist Sep 19, 2020
1fa58b1
Merge branch 'master' into rewrite-to-ts-morph
Andarist Sep 20, 2020
61783fa
Rewrite extraction to work purely~ on types
Andarist Feb 25, 2021
06d6dd6
Remove undefined initial from one of the parallel examples
Andarist Apr 9, 2021
df7f672
Fixed an issue in the Action extractor
Andarist Apr 9, 2021
683b6ec
Merge branch 'master' into rewrite-to-ts-morph
Andarist Apr 9, 2021
cbade56
Switch to using createMachine exclusively and fix some tests which we…
Andarist Apr 9, 2021
9a7c2d1
Add delays extractor to the options extractor
Andarist Apr 9, 2021
ea38207
Added fail cases
mattpocock Apr 13, 2021
d98aac0
Fixed extracting string actions from onDone
Andarist Apr 17, 2021
44aec32
Add snapshots releases workflow
Andarist Apr 29, 2021
0b74547
Add changeset
Andarist Apr 29, 2021
b4f1697
Update Changesets
Andarist Apr 29, 2021
0305fc1
Tweak snapshot release workflow
Andarist Apr 29, 2021
a140c23
Empty commit
Andarist Apr 29, 2021
d352d52
Remove publish-related scripts from the xstate-compiled directory
Andarist Apr 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Rewrite for type-level extractor using ts-morph
Andarist committed Sep 14, 2020
commit bae5ac4b094148bb6515bfe694176446e623830d
4 changes: 3 additions & 1 deletion packages/xstate-compiled/examples/fetchMachine.machine.ts
Original file line number Diff line number Diff line change
@@ -28,7 +28,9 @@ const machine = Machine<Context, Event, 'fetchMachine'>({
},
},
success: {
entry: ['celebrate'],
// TODO: need to implement arrays support properly
// entry: ['celebrate'],
entry: 'celebrate',
},
},
});
7 changes: 1 addition & 6 deletions packages/xstate-compiled/package.json
Original file line number Diff line number Diff line change
@@ -6,21 +6,16 @@
"license": "MIT",
"repository": "https://github.com/mattpocock/xstate-codegen",
"dependencies": {
"@babel/core": "^7.10.4",
"@babel/helper-split-export-declaration": "^7.11.0",
"@babel/parser": "^7.10.4",
"@babel/plugin-proposal-optional-chaining": "^7.10.4",
"@babel/plugin-transform-typescript": "^7.10.4",
"@rollup/plugin-babel": "^5.2.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"babel-plugin-macros": "^2.8.0",
"colors": "^1.4.0",
"gaze": "^1.1.3",
"glob": "^7.1.6",
"handlebars": "^4.7.6",
"handlebars-helpers": "^0.10.0",
"pkg-up": "^3.1.0",
"rollup": "^2.26.3",
"ts-morph": "^8.1.0",
"xstate": "^4.12.0"
},
"devDependencies": {
223 changes: 223 additions & 0 deletions packages/xstate-compiled/src/configExtractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { Type, ts } from 'ts-morph';

const indexer = Symbol('schema.extractor.indexer');

// TODO: implement support for inline functions - we just need to skip them
// but probably would be good to declare that in a schema somehow?

type TypeExtractor = {
extract: (
type: Type | undefined,
) => [true, undefined] | [false, any, boolean?];
};

const lazy = (getter: () => TypeExtractor): TypeExtractor => ({
extract: (type: Type | undefined) => getter().extract(type),
});
const object = (
shape: Record<string | symbol, TypeExtractor>,
): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type || !type.isObject()) {
return [true, undefined];
}

const objectType = type as Type<ts.ObjectType>;

if (objectType.getStringIndexType() || objectType.getNumberIndexType()) {
// we don't allow indexer types, we need to resolve to literal keys
return [true, undefined];
}

const extracted: any = {};

for (const key of Object.keys(shape)) {
const valueDeclar = objectType
.getProperty(key)
?.getValueDeclarationOrThrow() as any; /* PropertyAssignment */
const propType = valueDeclar?.getInitializerOrThrow().getType();

const [err, value, hasValue] = shape[key].extract(propType);
if (err) {
return [err, undefined];
}
if (hasValue) {
extracted[key] = value;
}
}

if (shape[indexer as any]) {
const indexerExtractor: TypeExtractor = shape[indexer as any];
for (const prop of objectType.getProperties()) {
const name = prop.getName();
if (name in shape) {
continue;
}
const valueDeclar = prop?.getValueDeclarationOrThrow() as any; /* PropertyAssignment */
const propType = valueDeclar?.getInitializerOrThrow().getType();

const [err, value, hasValue] = indexerExtractor.extract(propType);
if (err) {
return [err, undefined];
}
if (hasValue) {
extracted[name] = value;
}
}
}

return [false, extracted, true];
},
});
const optional = (t: TypeExtractor): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type) {
return [false, undefined, false];
}
return t.extract(type);
},
});
const array = (type: TypeExtractor): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type || !type.isArray()) {
return [true, undefined];
}
throw new Error('Extracting arrays is not implemented yet.');
},
});
const match = (candidates: TypeExtractor[]): TypeExtractor => ({
extract(type: Type | undefined) {
for (const candidate of candidates) {
const [, value, hasValue] = candidate.extract(type);
if (hasValue) {
return [false, value, true];
}
}

return [true, undefined];
},
});
const undef = (): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type || !type.isUndefined()) {
return [true, undefined];
}
return [false, undefined, true];
},
});
const bool = (literal?: boolean): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type || !type.isBooleanLiteral()) {
return [true, undefined];
}
return [false, (type.compilerType as any).intrinsicName === 'true', true];
},
});
const string = (literals?: string[]): TypeExtractor => ({
extract(type: Type | undefined) {
if (!type || !type.isStringLiteral()) {
return [true, undefined];
}

const literal = (type.compilerType as any).value;
if (!literals || literals.includes(literal)) {
return [false, literal, true];
}
return [true, undefined];
},
});

const SingleOrArray = (type: TypeExtractor): TypeExtractor =>
match([type, array(type)]);

const Actions = SingleOrArray(string());

const Target = match([undef(), SingleOrArray(string())]);

const Transition = match([
Target,
object({
target: Target,
cond: optional(string()),
actions: optional(Actions),
internal: optional(bool()),
}),
]);

const TransitionsMap = object({
[indexer]: SingleOrArray(Transition),
});

const Invoke = SingleOrArray(
object({
// TODO: this can be an object with .type
src: string(),
id: optional(string()),
onDone: optional(SingleOrArray(Transition)),
onError: optional(SingleOrArray(Transition)),
autoForward: optional(bool()),
// TODO:
// data:
}),
);

const AtomicState = object({
type: optional(string(['atomic'])),
id: optional(string()),
entry: optional(Actions),
exit: optional(Actions),
invoke: optional(Invoke),
on: optional(TransitionsMap),
});
const CompoundState = object({
type: optional(string(['compound'])),
id: optional(string()),
initial: string(),
entry: optional(Actions),
exit: optional(Actions),
invoke: optional(Invoke),
states: lazy(() => States),
on: optional(TransitionsMap),
});
const ParallelState = object({
type: string(['parallel']),
id: optional(string()),
entry: optional(Actions),
exit: optional(Actions),
invoke: optional(Invoke),
states: lazy(() => States),
on: optional(TransitionsMap),
});
const FinalState = object({
type: string(['final']),
id: optional(string()),
entry: optional(Actions),
exit: optional(Actions),
// TODO: implement it
// data: ?
});
const HistoryState = object({
type: string(['history']),
history: match([string(['shallow', 'deep']), bool(true)]),
// XState seems to allow undefined here, that's weird? what would it mean?
// it also only allows StateValue, need to recheck how the whole thing behaves
// let's keep this defined as a simple string for now
target: string(),
});

// order matters here - compound and atomic have to come last, in that order
const State = match([
FinalState,
ParallelState,
HistoryState,
CompoundState,
AtomicState,
]);

const States = object({
[indexer]: State,
});

const extractConfig = (configType: Type) => State.extract(configType);

export default extractConfig;
411 changes: 90 additions & 321 deletions packages/xstate-compiled/src/extractMachines.ts
Original file line number Diff line number Diff line change
@@ -1,345 +1,114 @@
import * as babelCore from '@babel/core';
import { Scope } from '@babel/traverse';
// @ts-ignore
import splitExportDeclaration from '@babel/helper-split-export-declaration';
import path from 'path';
import babelPluginMacros, { createMacro } from 'babel-plugin-macros';
import { StateMachine } from 'xstate';
import { rollup } from 'rollup';
import babelPlugin from '@rollup/plugin-babel';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import Module from 'module';
import { StateMachine, Machine } from 'xstate';
import { Project, ts, Node } from 'ts-morph';
import extractConfig from './configExtractor';

const generateRandomId = (): string =>
Math.random()
.toString(36)
.substring(2);

const generateUniqueId = (map: Record<string, any>): string => {
const id = generateRandomId();
return Object.prototype.hasOwnProperty.call(map, id)
? generateUniqueId(map)
: id;
};

const compiledOutputs: Record<string, string> = Object.create(null);
(Module as any)._extensions['.xstate.js'] = (module: any, filename: string) => {
const [_match, id] = filename.match(/-(\w+)\.xstate\.js$/)!;
module._compile(compiledOutputs[id], filename);
};

type UsedImport = {
localName: string;
importedName: string;
};

type ReferencePathsByImportName = Record<
string,
Array<babelCore.NodePath<babelCore.types.Node>>
>;

const cwd = process.cwd();
const extensions = ['.tsx', '.ts', '.jsx', '.js'];

const getImports = (
{ types: t }: typeof babelCore,
path: babelCore.NodePath<babelCore.types.ImportDeclaration>,
): UsedImport[] => {
return path.node.specifiers.map((specifier) => {
if (t.isImportNamespaceSpecifier(specifier)) {
throw new Error(
'Using a namespace import for `@xstate/import` is not supported.',
);
}
return {
localName: specifier.local.name,
importedName:
specifier.type === 'ImportDefaultSpecifier'
? 'default'
: specifier.local.name,
};
});
type ExtractedMachine = {
id: string;
machine: StateMachine<any, any, any>;
};

const getReferencePathsByImportName = (
scope: Scope,
imports: UsedImport[],
): ReferencePathsByImportName | undefined => {
let shouldExit = false;
let hasReferences = false;
const referencePathsByImportName = imports.reduce(
(byName, { importedName, localName }) => {
let binding = scope.getBinding(localName);
if (!binding) {
shouldExit = true;
return byName;
}
byName[importedName] = binding.referencePaths;
hasReferences = hasReferences || Boolean(byName[importedName].length);
return byName;
},
{} as ReferencePathsByImportName,
);

if (!hasReferences || shouldExit) {
return;
}

return referencePathsByImportName;
};
let projectCache = new Map<string, Project>();

const getMachineId = (
importName: string,
{ types: t }: typeof babelCore,
callExpression: babelCore.types.CallExpression,
) => {
const { typeParameters } = callExpression;
export const extractMachines = async (
filePath: string,
): Promise<ExtractedMachine[]> => {
const resolvedFilePath = path.resolve(process.cwd(), filePath);

if (
!typeParameters ||
!typeParameters.params[2] ||
!t.isTSLiteralType(typeParameters.params[2]) ||
!t.isStringLiteral(typeParameters.params[2].literal)
) {
console.log('You must pass three type arguments to your machine.');
console.log();
console.log('For instance:');
console.log(
`const machine = ${importName}<Context, Event, 'aUniqueIdForYourMachine'>({})`,
);
console.log();
throw new Error('You must pass three type arguments to your machine.');
let configFileName = ts.findConfigFile(resolvedFilePath, ts.sys.fileExists);
if (!configFileName) {
throw new Error('No tsconfig.json file could be found');
}
return typeParameters.params[2].literal.value;
};

const insertExtractingExport = (
{ types: t }: typeof babelCore,
statementPath: babelCore.NodePath<babelCore.types.Statement>,
{
importName,
index,
machineId,
machineIdentifier,
}: {
importName: string;
index: number;
machineId: string;
machineIdentifier: string;
},
) => {
statementPath.insertAfter(
t.exportNamedDeclaration(
t.variableDeclaration('var', [
t.variableDeclarator(
t.identifier(`__xstate_${importName}_${index}`),
t.objectExpression([
t.objectProperty(t.identifier('id'), t.stringLiteral(machineId)),
t.objectProperty(
t.identifier('machine'),
t.identifier(machineIdentifier),
),
]),
),
]),
),
);
};

const handleMachineFactoryCalls = (
importName: string,
{ references, babel }: babelPluginMacros.MacroParams,
) => {
if (!references[importName]) {
return;
let isFreshProject = false;
if (!projectCache.has(configFileName)) {
isFreshProject = true;
const cachedProject = new Project({
tsConfigFilePath: configFileName,
addFilesFromTsConfig: false,
});
projectCache.set(configFileName, cachedProject);
}

const { types: t } = babel;

references[importName].forEach((referencePath, index) => {
const callExpressionPath = referencePath.parentPath;

if (!t.isCallExpression(callExpressionPath.node)) {
throw new Error(`\`${importName}\` can only be called.`);
let project = projectCache.get(configFileName)!;
project.addSourceFileAtPath(resolvedFilePath);
project.resolveSourceFileDependencies();

if (!isFreshProject) {
let sourceFiles = project.getSourceFiles();
for (let sourceFile of sourceFiles) {
sourceFile.refreshFromFileSystemSync();
}
const machineId = getMachineId(importName, babel, callExpressionPath.node);

const callExpressionParentPath = callExpressionPath.parentPath;
const callExpressionParentNode = callExpressionParentPath.node;

switch (callExpressionParentNode.type) {
case 'VariableDeclarator': {
if (!t.isIdentifier(callExpressionParentNode.id)) {
throw new Error(
`Result of the \`${importName}\` call can only appear in the variable declaration.`,
);
}
const statementPath = callExpressionParentPath.getStatementParent();
if (!statementPath.parentPath.isProgram()) {
throw new Error(
`\`${importName}\` calls can only appear in top-level statements.`,
);
}

insertExtractingExport(babel, statementPath, {
importName,
index,
machineId,
machineIdentifier: callExpressionParentNode.id.name,
});
}

break;
let sourceFile = project.getSourceFileOrThrow(resolvedFilePath);

const machineReferences = sourceFile
.getImportDeclarations()
.filter(
(importDeclar) =>
importDeclar.getModuleSpecifierValue() === '@xstate/compiled',
)
.flatMap((importDeclar) => {
if (importDeclar.getNamespaceImport()) {
throw new Error('Namespace imports are not supported yet.');
}
case 'ExportDefaultDeclaration': {
splitExportDeclaration(callExpressionParentPath);

insertExtractingExport(
babel,
callExpressionParentPath.getStatementParent(),
{
importName,
index,
machineId,
machineIdentifier: ((callExpressionParentPath as babelCore.NodePath<
babelCore.types.VariableDeclaration
>).node.declarations[0].id as babelCore.types.Identifier).name,
},
);
break;
}
default: {
throw new Error(
`\`${importName}\` calls can only appear in the variable declaration or as a default export.`,
);
}
return importDeclar.getNamedImports();
})
.filter(
(namedImport) =>
namedImport.getNameNode().compilerNode.text === 'Machine',
)
.flatMap((namedImport) =>
(
namedImport.getAliasNode() || namedImport.getNameNode()
).findReferences(),
)
.flatMap((references) => references.getReferences())
.map((reference) => reference.getNode())
.filter((referenceNode) => {
const statement = referenceNode.getFirstAncestor(Node.isStatement);
return !statement || !Node.isImportDeclaration(statement);
});

return machineReferences.map((machineReference) => {
const machineCall = machineReference.getParent()!;

if (
!Node.isCallExpression(machineCall) ||
machineCall.getExpression() !== machineReference
) {
throw new Error(
"`Machine` can only be called - you can't pass it around or do anything else with it.",
);
}
});
};

const macro = createMacro((params) => {
handleMachineFactoryCalls('createMachine', params);
handleMachineFactoryCalls('Machine', params);
});
const configType = machineCall.getArguments()[0].getType();
const [error, config] = extractConfig(configType);

type ExtractedMachine = {
id: string;
machine: StateMachine<any, any, any>;
};

const getCreatedExports = (
importName: string,
exportsObj: Record<string, any>,
): ExtractedMachine[] => {
const extracted: ExtractedMachine[] = [];
let counter = 0;
while (true) {
const currentCandidate = exportsObj[`__xstate_${importName}_${counter++}`];
if (!currentCandidate) {
return extracted;
if (error) {
throw new Error('Could not extract config.');
}
extracted.push(currentCandidate);
}
};

export const extractMachines = async (
filePath: string,
): Promise<ExtractedMachine[]> => {
const resolvedFilePath = path.resolve(cwd, filePath);
const secondTypeArg = machineCall.getTypeArguments()[2];

const build = await rollup({
input: resolvedFilePath,
external: (id) => !id.startsWith('.'),
plugins: [
nodeResolvePlugin({
extensions,
}),
babelPlugin({
babelHelpers: 'bundled',
extensions,
plugins: [
'@babel/plugin-transform-typescript',
'@babel/plugin-proposal-optional-chaining',
(babel: typeof babelCore) => {
return {
name: 'xstate-codegen-machines-extractor',
visitor: {
ImportDeclaration(
path: babelCore.NodePath<babelCore.types.ImportDeclaration>,
state: babelCore.PluginPass,
) {
if (
state.filename !== resolvedFilePath ||
path.node.source.value !== '@xstate/compiled'
) {
return;
}

const imports = getImports(babel, path);
const referencePathsByImportName = getReferencePathsByImportName(
path.scope,
imports,
);
if (!Node.isLiteralTypeNode(secondTypeArg)) {
throw new Error(
'Second type argument passed to `Machine` has to be a string literal.',
);
}

if (!referencePathsByImportName) {
return;
}
const literal = secondTypeArg.getLiteral();

/**
* Other plugins that run before babel-plugin-macros might use path.replace, where a path is
* put into its own replacement. Apparently babel does not update the scope after such
* an operation. As a remedy, the whole scope is traversed again with an empty "Identifier"
* visitor - this makes the problem go away.
*
* See: https://github.com/kentcdodds/import-all.macro/issues/7
*/
state.file.scope.path.traverse({
Identifier() {},
});
if (!Node.isStringLiteral(literal)) {
throw new Error(
'Second type argument passed to `Machine` has to be a string literal.',
);
}

macro({
path,
references: referencePathsByImportName,
state,
babel,
// hack to make this call accepted by babel-plugin-macros
isBabelMacrosCall: true,
});
},
},
};
},
],
}),
],
});
const output = await build.generate({
format: 'cjs',
exports: 'named',
return {
id: literal.getLiteralValue(),
machine: Machine(config as any),
};
});
const chunk = output.output[0];
const { code } = chunk;

// dance with those unique ids is not really needed, at least right now
// loading CJS modules is synchronous
// once we start to support loading ESM this won't hold true anymore
let uniqueId = generateUniqueId(compiledOutputs);

try {
compiledOutputs[uniqueId] = code;
const fakeFileName = path.join(
path.dirname(resolvedFilePath),
`${path
.basename(resolvedFilePath)
.replace(/\./g, '-')}-${uniqueId}.xstate.js`,
);
const module = new Module(fakeFileName);
(module as any).load(fakeFileName);

return [
...getCreatedExports('createMachine', module.exports),
...getCreatedExports('Machine', module.exports),
];
} finally {
delete compiledOutputs[uniqueId];
}
};
17 changes: 8 additions & 9 deletions packages/xstate-compiled/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
{
"compilerOptions": {
"types": [
"node",
"jest"
],
"types": ["node", "jest"],
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
@@ -13,9 +10,11 @@
"resolveJsonModule": true,
"strictNullChecks": true,
"outDir": "bin",
"baseUrl": "src"
"baseUrl": "src",
"noEmitOnError": false,
"target": "ES2017",
"module": "CommonJS",
"lib": ["ES2020"]
},
"include": [
"src",
]
}
"include": ["src"]
}
3,371 changes: 3,292 additions & 79 deletions yarn.lock

Large diffs are not rendered by default.