-
Notifications
You must be signed in to change notification settings - Fork 4
TypeScript AST Mutations
This document outlines the ways a TypeScript
file can be mutated using API that exists in the cli-core
repo. The logic is divided into a factory that can create new TypeScript
nodes, an AST transformer which mutates existing nodes in the file, and a formatting service which applies formatting to the final code based on FormatSettings
.
It can currently create the following types of nodes:
- object literal expressions
- array literal expressions
- import declarations
- dynamic imports
- call expressions
- property assignments with an arrow function as value that has a call expression in its body
When initializing the TypeScriptNodeFactory
, you can provide FormatSettings
which will be used to style the newly-created nodes.
const formatSettings: FormatSettings = { singleQuotes: true };
const factory = new TypeScriptNodeFactory(formatSettings);
The TypeScriptNodeFactory
contains methods that can be used to create new nodes. They are wrappers around methods of the same/similar names in the ts.factory
and are exposed for ease of use.
Method | Description |
---|---|
createObjectLiteralExpression |
Creates a ts.ObjectLiteralExpression with a set of key-value pair properties. It has an optional transform delegate that can be used to mutate the object's properties' values to a ts.LiteralExpression , by default it will transform them to a ts.StringLiteral . The newly-created object literal can be on single or multiple lines. |
createArrayLiteralExpression |
Creates a ts.ArrayLiteralExpression with the provided elements. It supports both primitive and complex elements. The newly-created array literal can be on single or multiple lines. |
createCallExpression |
Creates a ts.CallExpression for a given identifier that calls a method. |
createArrowFunctionWithCallExpression |
Creates a property assignment with a zero arity arrow function as the value, which has a call expression in its body. Takes the form memberName: () => callExpressionName(callExpressionArgs) . |
createDynamicImport |
Creates an arrow function with no arity that returns a dynamic import. Takes the form () => import(path).then(m => m.prop) . |
createImportDeclaration |
Creates a node for a ts.ImportDeclaration that can be a side effects import, a default import or an import with named bindings. |
The types of imports that the createImportDeclaration
method currently supports are:
- Side effects Import -
import "my-module;"
- Default Import -
import X from "my-module";
- Imports With Named Bindings -
import { X, Y... } from "my-module";
An example of createCallExpression
could be:
const typeArg = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
const arg = ts.factory.createNumericLiteral("5");
const callExpression = factory.createCallExpression("x", "myGenericFunction", [typeArg], [arg]);
The callExpression
constant will contain a ts.CallExpression
node which if printed will look something like this:
x.myGenericFunction<number>(5);
An example of createArrayLiteralExpression
could be:
const newArrayLiteral = factory.createArrayLiteralExpression([
{
name: 'key1',
value: ts.factory.createStringLiteral('new-value'),
},
{
name: 'key2',
value: ts.factory.createNumericLiteral('5'),
},
]);
// If printed the above node will look like this - [{ key1: 'new-value' }, { key2: 5 }]
An example of createObjectLiteralExpression
could be:
const newObjectLiteral = factory.createObjectLiteralExpression(
[{ name: 'key1', value: ts.factory.createStringLiteral('new-value') }]
);
// If printed, the above node will look like this - { key1: 'new-value }
These two methods can be used together:
const newObjectLiteral = factory.createObjectLiteralExpression(
[{ name: 'key1', value: ts.factory.createStringLiteral('new-value') }]
);
const newArrayLiteral = factory.createArrayLiteralExpression([newObjectLiteral]);
// If printed the `newArrayLiteral` node will look like this - [{ key1: 'new-value' }]
Important
Creating new nodes with the TypeScriptNodeFactory
or the ts.factory
and inserting them in the AST with a transformation should be handled with great care. The reason being that the AST is only built during the parsing of the source code and any nodes added afterwards can cause unwanted behavior when some of their members are accessed - for example members such as node.getText
or node.parent
can cause throws or undefined
returns. This is because node.getText
relies on the position of the node in the AST to get its source code representation, with a dynamically added node, its position is not defined and the method will throw. For node.parent
, the newly-created node is not aware of its parent and accessing that will return undefined
. The parent is also only resolved during parsing of the source code and is otherwise readonly. However, a parent will be aware of its children, even if they are dynamically added nodes.
The TypeScriptAstTransformer
utility is designed to be able to manipulate a TypeScript
AST structure and apply different changes to it. It is platform agnostic and is only concerned with the modification of a ts.SourceFile
. As such, it expects a source file as input and exposes multiple utility methods that stage changes for the source file. The changes are later applied consecutively during finalization.
It can modify different TypeScript
nodes as follows:
- modify existing members of object literals
- add new members to object literals
- prepend/append members to array literals
- it supports an optional anchor element that it can prepend/append elements around
- add identifiers to existing import declarations
- will detect if the newly-added import declaration's identifier(s) already exist
- detect collisions between existing import declarations
- create a call expression of the form
x.call<T>(args)
- where the type argument and the method arguments are optional
- modify arguments in a method call expression
- look up a node's ancestor and check it against a condition
- look up a
ts.PropertyAssignment
in an object literal - look up an identifier/element in an array literal
To initialize the AST Transformer you need to provide a ts.SourceFile
. Optional parameters are ts.PrinterOptions
, ts.CompilerOptions
and FormatSettings
.
const fileName = './mySourceFile.ts';
const fileContent = 'const a = 5;'
const sourceFile: ts.SourceFile = ts.createSourceFile(fileName, fileContent, ts.ScriptTarget.Latest);
const astTransformer = new TypeScriptAstTransformer(sourceFile);
- The
ts.PrinterOptions
are used by the transformer'sts.Printer
and allow for the resulting text to be modified by some parameters. For example, it can remove any comments, set the type of the new line character, etc. - The
ts.CompilerOptions
are used while applying the stored transformations and host a variety of options that can be used to apply to the resulting source. - The
FormatSettings
are used in theTypeScriptFormattingService
to apply different styles to the resulting code, like the number of spaces/tabs, if strings should be formatted with single or double quotes, etc.
A visitorCondition
function is a predicate that is used by some of the methods in the AST transformer to narrow down to a specific node that is to be modified.
If we have the following code:
const myOtherArr = [];
const myArr = [1];
And we want to add a new member to the array literal initializer of the const myArr
, we can do it like so:
const visitorCondition = (node: ts.ArrayLiteralExpression) =>
node.elements.some((e) => ts.isNumericLiteral(node) && node.text === "1");
astTransformer.requestNewMembersInArrayLiteral(visitorCondition, [newObjectLiteral]);
In this simplified example, the visitorCondition
will resolve to true
for the array that contains an element that is the numeric literal of 1
.
For the method requestNewMembersInArrayLiteral
, the provided argument in the visitorCondition
will always be a ts.ArrayLiteralExpression
, as this is the type the method is interested in modifying.
Note
All methods that request specific changes will at least narrow down to a node of the respective concrete type and provide it as a typed argument in the visitorCondition
.
As shown in the Visitor Conditions section, the transformer contains methods that can be used to request changes in the AST. All of them require a predicate that is used when drilling down to the appropriate node.
Method | Description |
---|---|
requestNewMemberInObjectLiteral |
Creates a request for an update in the AST that will add a new member (ts.PropertyAssignment ) in an object literal expression. |
requestJsxMemberInObjectLiteral |
Similar to requestNewMemberInObjectLiteral with the only difference being that the value of the created member will be a ts.JsxSelfClosingElement . |
requestUpdateForObjectLiteralMember |
Creates a request for an update in the AST that will change the value of a member in an object literal. |
requestNewMembersInArrayLiteral |
Creates a request for an update in the AST that will add n members in a particular ts.ArrayLiteralExpression . It supports an optional anchorElement that can be used to prepend/append the new nodes around a given target. |
requestNewImportDeclaration |
Creates a request for an update in the AST that will add a new import declaration of the forms outlined in createImportDeclaration . |
requestNewArgumentInMethodCallExpression |
Creates a request which will add a new argument to a method call expression. |
An example usage of createObjectLiteralExpression
alongside requestNewMembersInArrayLiteral
could be:
const newObjectLiteral = factory.createObjectLiteralExpression([
{ name: "path", value: ts.factory.createStringLiteral("some-new-path") },
{ name: "component", value: ts.factory.createIdentifier("MyComponent") },
]);
// the condition that will be used when traversing the AST to narrow down to the node that we want to modify
const condition = (node: ts.ArrayLiteralExpression) =>
node.elements.some(
(e) =>
ts.isObjectLiteralExpression(e) &&
e.properties.some(
(p) =>
ts.isPropertyAssignment(p) &&
ts.isIdentifier(p.name) &&
p.name.text === "path"
)
);
astTransformer.requestNewMembersInArrayLiteral(condition, [newObjectLiteral]);
This will create a new object ({ path: "some-new-path", component: MyComponent }
) and add it to an array literal that has an object literal member with property with a name path
. In this example the multiline
parameter is not provided to either factory.createObjectLiteralExpression
nor astTransformer.requestNewMembersInArrayLiteral
, so the result will be an object literal on a single line, added at the end of the targeted array literal, again on the same line.
So this:
const routes: Route[] = [{ path: "some-path", component: SomeComponent }];
Will become this:
const routes: Route[] = [{ path: "some-path", component: SomeComponent }, { path: "some-new-path", component: MyComponent }];
Note
Keep in mind that this is a very crude and simplified example as in reality additional checks will have to be done to make sure that the node that is being added goes precisely where it is supposed to.
The requestNewMembersInArrayLiteral
also has an anchorElement
parameter that can be used to put the element before or after a specific element in the array literal. So modifying the above example to include an anchor and also to apply multiline
for a more beautiful result can be done like this:
const newObjectLiteral = factory.createObjectLiteralExpression(
[
{ name: "path", value: ts.factory.createStringLiteral("some-new-path") },
{ name: "component", value: ts.factory.createIdentifier("MyComponent") }
],
true // multiline
);
const anchorElement: PropertyAssignment = {
name: 'path',
value: ts.factory.createStringLiteral('anchor')
};
astTransformer.requestNewMembersInArrayLiteral(
condition,
[newObjectLiteral],
true, // prepend
anchorElement,
true // multiline
);
So, if we have an array literal like this:
const routes: Route[] = [
{ path: "anchor", component: SomeComponent }
];
After the transformation, it will become like this:
const routes: Route[] = [
{
path: 'some-new-path',
value: MyComponent
},
{ path: 'anchor', component: SomeComponent }
];
One thing to mention here is that our initial array is formatted on multiple lines as well. This is the reason why the newly-created object literals are added in C# style, with the opening brace on a new line. If the array was instead on a single line, like in the initial example:
const routes: Route[] = [{ path: "some-path", component: SomeComponent }];
Then multiline
will only be applied for the new nodes, meaning the resulting code will look like this:
const routes: Route[] = [{
path: 'some-new-path',
value: MyComponent
}, { path: 'anchor', component: SomeComponent }
];
Note
Also, keep in mind that formatting will be applied only if FormatSettings
have been provided during the transformer's instantiation. And that the FormattingService will attempt to access the FS to read editor and formatting configs and to write the final output.
The TypeScriptAstTransformer
will only store the requested changes and will not apply any of them until either finalize
or applyChanges
is called.
Method | Description |
---|---|
applyChanges |
Applies the aggregated changes to the ts.SourceFile and returns the resulting AST, does not modify the original one. |
finalize |
Calls applyChanges internally and then prints the resulting source code, a formatter can be used to make the code prettier. |
Note
Calling either of these will clear the transformer's cache of requested changes.
Regarding any of the exposed utilities, the transformer will not attempt to fix potentially broken code as it is only concerned with the modification of the AST and not whether or not the resulting code is actually runnable.
The TypeScriptFormattingService
is a utility used by the transformer after applying the changes to the AST and finalizing the source. This service will read a project's .editorconfig
and attempt to format the source code by using the ts.LanguageService
's formatting capabilities. Then it will save the file onto the file system by utilizing the App.container utility, which depending on the context can communicate directly with the physical file system or a virtual one.
Method | Description |
---|---|
applyFormatting |
Apply formatting to a source file. |
As P1
, it will also support a formatting utility such as biome
, prettier
, etc.