Table of Contents

This blog post is part of an article series.


In the two prior installments of this series, I demonstrated how Schematics can be used to generate custom code through the Angular CLI and how it can refresh an NgModule to declare the newly generated components. That last task turned out to be relatively straightforward, since the CLI already performs the same operation, which means ready-made helper functions exist for our use.

However, it's not always the case that convenient helpers are available. When they aren't, we have to handle the complex work ourselves, which is precisely the focus here: directly editing existing source code in a safe, reliable manner.

A closer look at the helper utilities from the previous article reveals they rely on the TypeScript Compiler API, which, among other things, provides a syntax tree representation for TypeScript files. By walking this tree and examining its nodes, we can inspect existing code and determine exactly where a change should be applied.

With this technique, the current post expands upon the schematic from the last article by injecting the generated Service into the AppComponent so it can be configured there:

[...] import { SideMenuService } from './core/side-menu/side-menu.service'; @Component({ [...] }) export class AppComponent { constructor( private sideMenuService: SideMenuService) { // sideMenuService.show = true; } }

In my view, delivering this kind of boilerplate for library configuration can make initial adoption noticeably smoother. Still, keep in mind that this basic example stands in for many scenarios where altering existing code adds significant convenience.

The source code for the examples discussed here is available in this GitHub repository.

Schematics is currently an Angular Labs project. Its public API is experimental and can change in future.
Angular Labs

To become comfortable with the TypeScript Compiler API, we'll begin with a straightforward NodeJS example that showcases its core usage. TypeScript itself is the only prerequisite. Since this will run inside a basic NodeJS app, I'll also install the corresponding type definitions. In a fresh directory, these commands will get us going:

npm init
npm install typescript --save
npm install @types/node --save-dev

Beyond that, a tsconfig.json with suitable compiler options is required:

{ "compilerOptions": { "target": "es6", "module": "commonjs", "lib": ["dom", "es2017"], "moduleResolution": "node" } }

With that foundation in place, we can proceed to our first experiment with the Compiler CLI. Let's create an index.ts file:

import * as ts from 'typescript'; import * as fs from 'fs'; function showTree(node: ts.Node, indent: string = ' '): void { console.log(indent + ts.SyntaxKind[node.kind]); if (node.getChildCount() === 0) { console.log(indent + ' Text: ' + node.getText()); } for(let child of node.getChildren()) { showTree(child, indent + ' '); } } let buffer = fs.readFileSync('demo.ts'); let content = buffer.toString('utf-8'); let node = ts.createSourceFile('demo.ts', content, ts.ScriptTarget.Latest, true); showTree(node);

The showTree function performs a recursive traversal of the syntax tree, starting from the given node. It logs each node's kind property to the console, which reveals whether the node represents, say, a class name, a constructor, or a parameter list. When a node has no children, the program also prints its text content, such as the class name it denotes. The process then repeats for every child node, increasing the indentation level.

At the end, the program loads a TypeScript file and builds a SourceFile object from its contents. Because SourceFile itself is a node, it can be passed to showTree.

We also need the demo.ts file that the application reads. For simplicity, let's use the following minimal class:

class Demo { constructor(otherDemo: Demo) {} }

To compile and execute the application, use these commands:

tsc index.ts
node index.js

Obviously, setting up a npm script for this would be sensible.

Once running, the application should present the syntax tree as follows:

SourceFile
    SyntaxList
        ClassDeclaration
            ClassKeyword
                Text: class
            Identifier
                Text: Demo
            FirstPunctuation
                Text: {
            SyntaxList
                Constructor
                    ConstructorKeyword
                        Text: constructor
                    OpenParenToken
                        Text: (
                    SyntaxList
                        Parameter
                            Identifier
                                Text: otherDemo
                            ColonToken
                                Text: :
                            TypeReference
                                Identifier
                                    Text: Demo
                    CloseParenToken
                        Text: )
                    Block
                        FirstPunctuation
                            Text: {
                        SyntaxList
                            Text: 
                        CloseBraceToken
                            Text: }
            CloseBraceToken
                Text: }
    EndOfFileToken
        Text: 

Spend a moment examining this tree. Every component of our demo.ts is represented by a corresponding node. For instance, there's a ClassDeclaration node for our class, which contains a ClassKeyword and an Identifier bearing the text Demo. You'll also notice a Constructor node with children that represent its various parts, including a SyntaxList holding a subtree for the constructor argument otherDemo.

Combining the insights gained from this example with what we already understand about Schematics from earlier posts, we have the full toolkit to implement the goal described at the outset. The following sections detail the steps involved.

Supplying Essential Data

When crafting a Schematics rule, a sensible initial move is to enumerate all the data it requires and encapsulate that in a class. Here's what that class looks like for our case:

export interface AddInjectionContext { appComponentFileName: string; // e. g. /src/app/app.component.ts relativeServiceFileName: string; // e. g. ./core/side-menu/side-menu.service serviceName: string; // e. g. SideMenuService }

To acquire this data, we'll create a function named createAddInjectionContext:

function createAddInjectionContext(options: ModuleOptions): AddInjectionContext { let appComponentFileName = findFileByName('app.component.ts', options.path || '/', host); let destinationPath = constructDestinationPath(options); let serviceName = classify(<span class="hljs-subst">${options.name}</span>Service); let serviceFileName = join(normalize(destinationPath), <span class="hljs-subst">${dasherize(options.name)}</span>.service); let relativeServiceFileName = buildRelativePath(appComponentFileName, serviceFileName); return { appComponentFileName, relativeServiceFileName, serviceName } } function findFileByName(file: string, path: string, host: Tree): string { let dir: DirEntry | null = host.getDir(path); while(dir) { let appComponentFileName = dir.path + '/' + file; if (host.exists(appComponentFileName)) { return appComponentFileName; } dir = dir.parent; } throw new SchematicsException(File <span class="hljs-subst">${file}</span> not found in <span class="hljs-subst">${path}</span> or one of its anchestors); }

As shown, createAddInjectionContext accepts an instance of the ModuleOptions class, which is part of the utils that Schematics provides and represents the parameters passing through the CLI. The three necessary fields are derived from that instance. To determine where the generated files should reside, it employs the custom helper constructDestinationPath:

export function constructDestinationPath(options: ModuleOptions): string { return '/' + (options.sourceDir? options.sourceDir + '/' : '') + (options.path || '') + (options.flat ? '' : '/' + dasherize(options.name)); }

Additionally, it makes use of several helper functions that ship with Schematics:

  • classify: Generates a class name, for instance converting side-menu into SideMenu.
  • normalize: Standardizes a path to account for platform-specific characters like \ on Windows.
  • dasherize: Transforms a string into Kebab case, e.g., turning SideMenu into side-menu.
  • join: Concatenates two paths together.
  • buildRelativePath: Creates a relative path pointing from the first absolute path argument to the second one.

It's worth mentioning that some of the helpers used here aren't exposed through the public API. To avoid issues from potential breaking changes, I've duplicated the relevant files. Further discussion of this quirk appears in my earlier post on the subject.

Generating a New Constructor

If the AppComponent lacks a constructor entirely, we need to fabricate one. The Schematics approach is to produce a Change object that describes this alteration. For this job, I've written a function called createConstructorForInjection. Even though it's somewhat lengthy, largely due to the necessary null/undefined checks, the logic itself is straightforward:

function createConstructorForInjection(context: AddInjectionContext, nodes: ts.Node[], options: ModuleOptions): Change { let classNode = nodes.find(n => n.kind === ts.SyntaxKind.ClassKeyword); if (!classNode) { throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span>); } if (!classNode.parent) { throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parent node); } let siblings = classNode.parent.getChildren(); let classIndex = siblings.indexOf(classNode); siblings = siblings.slice(classIndex); let classIdentifierNode = siblings.find(n => n.kind === ts.SyntaxKind.Identifier); if (!classIdentifierNode) { throw new SchematicsException(expected class in <span class="hljs-subst">${context.appComponentFileName}</span> to have an identifier); } if (classIdentifierNode.getText() !== 'AppComponent') { throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have the name AppComponent); } // Find opening cury braces (FirstPunctuation means '{' here). let curlyNodeIndex = siblings.findIndex(n => n.kind === ts.SyntaxKind.FirstPunctuation); siblings = siblings.slice(curlyNodeIndex); let listNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList); if (!listNode) { throw new SchematicsException(expected first class in <span class="hljs-subst">${context.appComponentFileName}</span> to have a body); } let toAdd = ` constructor(private ${camelize(context.serviceName)}: ${classify(context.serviceName)}) { // ${camelize(context.serviceName)}.show = true; } `; return new InsertChange(context.appComponentFileName, listNode.pos+1, toAdd); }

The nodes parameter holds a flattened array of all nodes within the syntax tree. This format is also utilized by some of Schematics' built-in rules and simplifies searching through the tree with array methods. The function scans for the first node of type ClassKeyword, which corresponds to the class keyword itself. Cross-reference this with the syntax tree shown in the initial example above.

Following that, it obtains an array of the ClassKeyword's siblings (i.e., its parent's children) and scans from left to right to pinpoint a suitable location for the new constructor. To move left to right, it repeatedly truncates everything to the left of the current position using slice. Honestly, this isn't optimal from a performance standpoint, but it should be sufficiently fast, and I believe it enhances code readability.

Using this method, the function proceeds rightward until it encounters a SyntaxList (representing the class body) that follows a FirstPunctuation node (in this case, the { character), which itself comes after an Identifier (the class name). It then leverages the SyntaxList's position to craft an InsertChange object indicating where the constructor should be placed.

One could also examine the class body to find a more refined spot for the constructor—say, between property declarations and method declarations—but I've set that aside for the sake of simplicity and clarity in this demo.

Adding a Constructor Parameter

When a constructor already exists, we must append another argument to accommodate our service. The following function handles this task. Along with other parameters, it accepts the node representing the constructor, which you can also match against the syntax tree from the first example above.

function addConstructorArgument(context: AddInjectionContext, ctorNode: ts.Node, options: ModuleOptions): Change { let siblings = ctorNode.getChildren(); let parameterListNode = siblings.find(n => n.kind === ts.SyntaxKind.SyntaxList); if (!parameterListNode) { throw new SchematicsException(expected constructor in <span class="hljs-subst">${context.appComponentFileName}</span> to have a parameter list); } let parameterNodes = parameterListNode.getChildren(); let paramNode = parameterNodes.find(p => { let typeNode = findSuccessor(p, [ts.SyntaxKind.TypeReference, ts.SyntaxKind.Identifier]); if (!typeNode) return false; return typeNode.getText() === context.serviceName; }); // There is already a respective constructor argument --> nothing to do for us here ... if (paramNode) return new NoopChange(); // Is the new argument the first one? if (!paramNode && parameterNodes.length == 0) { let toAdd = private <span class="hljs-subst">${camelize(context.serviceName)}</span>: <span class="hljs-subst">${classify(context.serviceName)}</span>; return new InsertChange(context.appComponentFileName, parameterListNode.pos, toAdd); } else if (!paramNode && parameterNodes.length > 0) { let toAdd = `, private ${camelize(context.serviceName)}: ${classify(context.serviceName)}`; let lastParameter = parameterNodes[parameterNodes.length-1]; return new InsertChange(context.appComponentFileName, lastParameter.end, toAdd); } return new NoopChange(); }

This function fetches all child nodes of the constructor and seeks out a SyntaxList (the parameter list) that contains a TypeReference child, which in turn has an Identifier child. For this, it relies on the findSuccessor helper shown below. The resulting identifier holds the type of the argument in question. If an argument referencing our service's type already exists, no action is needed. Otherwise, the function checks whether we're inserting the first argument or a subsequent one, locates the correct position for the new argument in either case, and returns a corresponding InsertChange object for the required modification.

function findSuccessor(node: ts.Node, searchPath: ts.SyntaxKind[] ) { let children = node.getChildren(); let next: ts.Node | undefined = undefined; for(let syntaxKind of searchPath) { next = children.find(n => n.kind == syntaxKind); if (!next) return null; children = next.getChildren(); } return next; }

Choosing Between Creation and Modification

Here's the encouraging part: the tough work is behind us. What remains is a function to determine which of the two alternatives—adding a constructor or modifying one—is appropriate:

function buildInjectionChanges(context: AddInjectionContext, host: Tree, options: ModuleOptions): Change[] { let text = host.read(context.appComponentFileName); if (!text) throw new SchematicsException(File <span class="hljs-subst">${options.module}</span> does not exist.); let sourceText = text.toString('utf-8'); let sourceFile = ts.createSourceFile(context.appComponentFileName, sourceText, ts.ScriptTarget.Latest, true); let nodes = getSourceNodes(sourceFile); let ctorNode = nodes.find(n => n.kind == ts.SyntaxKind.Constructor); let constructorChange: Change; if (!ctorNode) { // No constructor found constructorChange = createConstructorForInjection(context, nodes, options); } else { constructorChange = addConstructorArgument(context, ctorNode, options); } return [ constructorChange, insertImport(sourceFile, context.appComponentFileName, context.serviceName, context.relativeServiceFileName) ]; }

Just like the first example in this post, it uses the TypeScript Compiler API to generate a SourceFile object for the file containing the AppComponent. It then relies on Schematics' getSourceNodes function to traverse the tree and produce a flat array of all nodes. Those nodes are searched to locate a constructor. If none is found, createConstructorForInjection is invoked to build a Change object; otherwise, addConstructorArgument takes over. In the end, the function returns this Change alongside another one generated by insertImport, also provided by Schematics, which inserts the required import statement at the top of the TypeScript file.

Be aware that the sequence of these two changes is critical, as they add lines to the source file, which alters the position information stored in the node objects.

Bringing Everything Together

Now, the only missing piece is a factory function for a rule that calls buildInjectionChanges and applies the returned changes:

export function injectServiceIntoAppComponent(options: ModuleOptions): Rule { return (host: Tree) => { let context = createAddInjectionContext(options); let changes = buildInjectionChanges(context, host, options); const declarationRecorder = host.beginUpdate(context.appComponentFileName); for (let change of changes) { if (change instanceof InsertChange) { declarationRecorder.insertLeft(change.pos, change.toAdd); } } host.commitUpdate(declarationRecorder); return host; }; };

This function accepts the ModuleOptions containing the parameters passed from the CLI and returns a Rule function. It constructs the context object with the key data and hands off to buildInjectionChanges. The received rules are then iterated over and executed.

Incorporating the Rule into the Schematic

To ensure our new injectServiceIntoAppComponent rule gets invoked, we need to call it within its index.ts:

[...] export default function (options: MenuOptions): Rule { return (host: Tree, context: SchematicContext) => { [...] const rule = chain([ branchAndMerge(chain([ mergeWith(templateSource), addDeclarationToNgModule(options, options.export), injectServiceIntoAppComponent(options) ])) ]); return rule(host, context); } }

Validating the Enhanced Schematic

Once the modifications are in place, build the Schematic and transfer all its files into the node_modules directory of your sample project. Following the pattern from the previous article, the destination is node_modules/nav. Be careful to omit the Schematic Collection's own node_modules folder from the copy, avoiding a nested structure like node_modules/nav/node_modules.

From the root of the example application, execute the Schematic:

ng g nav:menu side-menu --menu-service --export

The output includes the generated SideMenu component and also registers its corresponding service within the AppComponent:

import { Component } from '@angular/core'; import { OnChanges, OnInit } from '@angular/core'; import { SideMenuService } from './core/side-menu/side-menu.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { constructor(private sideMenuService: SideMenuService) { // sideMenuService.show = true; } title = 'app'; }