Modifying Files with a Generator

Modifying existing files is an order of magnitude harder than creating new files, so care should be taken when trying to automate this process. When the situation merits it, automating a process can lead to tremendous benefits across the organization. Here are some approaches listed from simplest to most complex.

Compose Existing Generators

If you can compose together existing generators to modify the files you need, you should take that approach. See Composing Generators for more information.

Modify JSON Files

JSON files are fairly simple to modify, given their predictable structure.

The following example adds a package.json script that issues a friendly greeting.

1import { updateJson } from '@nrwl/devkit';
2
3export default async function (tree: Tree, schema: any) {
4  updateJson(tree, 'package.json', (pkgJson) => {
5    // if scripts is undefined, set it to an empty object
6    pkgJson.scripts = pkgJson.scripts ?? {};
7    // add greet script
8    pkgJson.scripts.greet = 'echo "Hello!"';
9    // return modified JSON object
10    return pkgJson;
11  });
12}
13

String Replace

For files that are not as predictable as JSON files (like .ts, .md or .css files), modifying the contents can get tricky. One approach is to do a find and replace on the string contents of the file.

Let's say we want to replace any instance of thomasEdison with nikolaTesla in the index.ts file.

1export default async function (tree: Tree, schema: any) {
2  const filePath = `path/to/index.ts`;
3  const contents = tree.read(filePath);
4  contents.replace('thomasEdison', 'nikolaTesla');
5  tree.write(filePath, contents);
6}
7

This works, but only replaces the first instance of thomasEdison. To replace them all, you need to use regular expressions. (Regular expressions also give you a lot more flexibility in how you search for a string.)

1export default async function (tree: Tree, schema: any) {
2  const filePath = `path/to/index.ts`;
3  const contents = tree.read(filePath);
4  contents.replace(/thomasEdison/g, 'nikolaTesla');
5  tree.write(filePath, contents);
6}
7

AST Manipulation

ASTs (Abstract Syntax Trees) allow you to understand exactly the code you're modifying. Replacing a string value can accidentally modify text found in a comment rather than changing the name of a variable.

We'll write a generator that replaces all instances of the type Array<something> with something[]. To help accomplish this, we'll use the @phenomnomnominal/tsquery npm package and the TSQuery Playground site. TSQuery allows you to query and modify ASTs with a syntax similar to CSS selectors. The AST Explorer tool allows you to easily examine the AST for a given snippet of code.

First, go to TSQuery Playground and paste in a snippet of code that contains the input and desired output of our generator.

1// input
2const arr: Array<string> = [];
3
4// desired output
5const arr: string[] = [];
6

When you place the cursor on the Array text, the right hand panel highlights the corresponding node of the AST. The AST node we're looking for looks like this:

1{ // TypeReference
2  typeName: { // Identifier
3    escapedText: "Array"
4  },
5  typeArguments: [/* this is where the generic type parameter is specified */]
6}
7

Second, we need to choose a selector to target this node. Just like with CSS selectors, there is an art to choosing a selector that is specific enough to target the correct nodes, but not overly tied to a certain structure. For our simple example, we can use TypeReference to select the parent node and check to see if it has a typeName of Array before we perform the replacement. We'll then use the typeArguments to get the text inside the <> characters.

The finished code looks like this:

1import { readProjectConfiguration, Tree } from '@nrwl/devkit';
2import { tsquery } from '@phenomnomnominal/tsquery';
3import { TypeReferenceNode } from 'typescript';
4
5/**
6 * Run the callback on all files inside the specified path
7 */
8function visitAllFiles(
9  tree: Tree,
10  path: string,
11  callback: (filePath: string) => void
12) {
13  tree.children(path).forEach((fileName) => {
14    const filePath = `${path}/${fileName}`;
15    if (!tree.isFile(filePath)) {
16      visitAllFiles(tree, filePath, callback);
17    } else {
18      callback(filePath);
19    }
20  });
21}
22
23export default function (tree: Tree, schema: any) {
24  const sourceRoot = readProjectConfiguration(tree, schema.name).sourceRoot;
25  visitAllFiles(tree, sourceRoot, (filePath) => {
26    const fileEntry = tree.read(filePath);
27    const contents = fileEntry.toString();
28
29    // Check each `TypeReference` node to see if we need to replace it
30    const newContents = tsquery.replace(contents, 'TypeReference', (node) => {
31      const trNode = node as TypeReferenceNode;
32      if (trNode.typeName.getText() === 'Array') {
33        const typeArgument = trNode.typeArguments[0];
34        return `${typeArgument.getText()}[]`;
35      }
36      // return undefined does not replace anything
37    });
38
39    // only write the file if something has changed
40    if (newContents !== contents) {
41      tree.write(filePath, newContents);
42    }
43  });
44}
45

Concepts

Recipes

Reference