nx/packages/workspace/src/utils/ast-utils.ts

748 lines
19 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT- style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
apply,
chain,
DirEntry,
forEach,
mergeWith,
noop,
Rule,
SchematicContext,
Source,
Tree,
} from '@angular-devkit/schematics';
import * as ts from 'typescript';
import { parseJson, serializeJson, FileData } from '@nrwl/devkit';
import { getWorkspacePath } from './cli-config-utils';
import { extname, join, normalize, Path } from '@angular-devkit/core';
import type { NxJsonConfiguration } from '@nrwl/devkit';
import { addInstallTask } from './rules/add-install-task';
import { findNodes } from 'nx/src/utils/typescript';
import { getSourceNodes } from '../utilities/typescript/get-source-nodes';
function nodesByPosition(first: ts.Node, second: ts.Node): number {
return first.getStart() - second.getStart();
}
function insertAfterLastOccurrence(
nodes: ts.Node[],
toInsert: string,
file: string,
fallbackPos: number,
syntaxKind?: ts.SyntaxKind
): Change {
// sort() has a side effect, so make a copy so that we won't overwrite the parent's object.
let lastItem = [...nodes].sort(nodesByPosition).pop();
if (!lastItem) {
throw new Error();
}
if (syntaxKind) {
lastItem = findNodes(lastItem, syntaxKind).sort(nodesByPosition).pop();
}
if (!lastItem && fallbackPos == undefined) {
throw new Error(
`tried to insert ${toInsert} as first occurrence with no fallback position`
);
}
const lastItemPosition: number = lastItem ? lastItem.getEnd() : fallbackPos;
return new InsertChange(file, lastItemPosition, toInsert);
}
export function sortObjectByKeys(obj: unknown) {
return Object.keys(obj)
.sort()
.reduce((result, key) => {
return {
...result,
[key]: obj[key],
};
}, {});
}
export { getSourceNodes } from '../utilities/typescript/get-source-nodes';
export interface Change {
apply(host: any): Promise<void>;
readonly type: string;
readonly path: string | null;
readonly order: number;
readonly description: string;
}
export class NoopChange implements Change {
type = 'noop';
description = 'No operation.';
order = Infinity;
path = null;
apply() {
return Promise.resolve();
}
}
export class InsertChange implements Change {
type = 'insert';
order: number;
description: string;
constructor(public path: string, public pos: number, public toAdd: string) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Inserted ${toAdd} into position ${pos} of ${path}`;
this.order = pos;
}
apply(host: any) {
return host.read(this.path).then((content) => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos);
return host.write(this.path, `${prefix}${this.toAdd}${suffix}`);
});
}
}
export class RemoveChange implements Change {
type = 'remove';
order: number;
description: string;
constructor(
public path: string,
public pos: number,
public toRemove: string
) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Removed ${toRemove} into position ${pos} of ${path}`;
this.order = pos;
}
apply(host: any): Promise<void> {
return host.read(this.path).then((content) => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos + this.toRemove.length);
return host.write(this.path, `${prefix}${suffix}`);
});
}
}
export class ReplaceChange implements Change {
type = 'replace';
order: number;
description: string;
constructor(
public path: string,
public pos: number,
public oldText: string,
public newText: string
) {
if (pos < 0) {
throw new Error('Negative positions are invalid');
}
this.description = `Replaced ${oldText} into position ${pos} of ${path} with ${newText}`;
this.order = pos;
}
apply(host: any): Promise<void> {
return host.read(this.path).then((content) => {
const prefix = content.substring(0, this.pos);
const suffix = content.substring(this.pos + this.oldText.length);
const text = content.substring(this.pos, this.pos + this.oldText.length);
if (text !== this.oldText) {
return Promise.reject(
new Error(`Invalid replace: "${text}" != "${this.oldText}".`)
);
}
return host.write(this.path, `${prefix}${this.newText}${suffix}`);
});
}
}
export function addParameterToConstructor(
source: ts.SourceFile,
modulePath: string,
opts: { className: string; param: string }
): Change[] {
const clazz = findClass(source, opts.className);
const constructor = clazz.members.filter(
(m) => m.kind === ts.SyntaxKind.Constructor
)[0];
if (constructor) {
throw new Error('Should be tested');
} else {
const methodHeader = `constructor(${opts.param})`;
return addMethod(source, modulePath, {
className: opts.className,
methodHeader,
body: null,
});
}
}
export function addMethod(
source: ts.SourceFile,
modulePath: string,
opts: { className: string; methodHeader: string; body: string }
): Change[] {
const clazz = findClass(source, opts.className);
const body = opts.body
? `
${opts.methodHeader} {
${offset(opts.body, 1, false)}
}
`
: `
${opts.methodHeader} {}
`;
return [new InsertChange(modulePath, clazz.end - 1, offset(body, 1, true))];
}
export function findClass(
source: ts.SourceFile,
className: string,
silent: boolean = false
): ts.ClassDeclaration {
const nodes = getSourceNodes(source);
const clazz = <any>(
nodes.filter(
(n) =>
n.kind === ts.SyntaxKind.ClassDeclaration &&
(<any>n).name.text === className
)[0]
);
if (!clazz && !silent) {
throw new Error(`Cannot find class '${className}'`);
}
return clazz;
}
export function offset(
text: string,
numberOfTabs: number,
wrap: boolean
): string {
const lines = text
.trim()
.split('\n')
.map((line) => {
let tabs = '';
for (let c = 0; c < numberOfTabs; ++c) {
tabs += ' ';
}
return `${tabs}${line}`;
})
.join('\n');
return wrap ? `\n${lines}\n` : lines;
}
export function addIncludeToTsConfig(
tsConfigPath: string,
source: ts.SourceFile,
include: string
): Change[] {
const includeKeywordPos = source.text.indexOf('"include":');
if (includeKeywordPos > -1) {
const includeArrayEndPos = source.text.indexOf(']', includeKeywordPos);
return [new InsertChange(tsConfigPath, includeArrayEndPos, include)];
} else {
return [];
}
}
export function getImport(
source: ts.SourceFile,
predicate: (a: any) => boolean
): { moduleSpec: string; bindings: string[] }[] {
const allImports = findNodes(source, ts.SyntaxKind.ImportDeclaration);
const matching = allImports.filter((i: ts.ImportDeclaration) =>
predicate(i.moduleSpecifier.getText())
);
return matching.map((i: ts.ImportDeclaration) => {
const moduleSpec = i.moduleSpecifier
.getText()
.substring(1, i.moduleSpecifier.getText().length - 1);
const t = i.importClause.namedBindings.getText();
const bindings = t
.replace('{', '')
.replace('}', '')
.split(',')
.map((q) => q.trim());
return { moduleSpec, bindings };
});
}
export function addGlobal(
source: ts.SourceFile,
modulePath: string,
statement: string
): Change[] {
const allImports = findNodes(source, ts.SyntaxKind.ImportDeclaration);
if (allImports.length > 0) {
const lastImport = allImports[allImports.length - 1];
return [
new InsertChange(modulePath, lastImport.end + 1, `\n${statement}\n`),
];
} else {
return [new InsertChange(modulePath, 0, `${statement}\n`)];
}
}
export function insert(host: Tree, modulePath: string, changes: Change[]) {
if (changes.length < 1) {
return;
}
// sort changes so that the highest pos goes first
const orderedChanges = changes.sort((a, b) => b.order - a.order) as any;
const recorder = host.beginUpdate(modulePath);
for (const change of orderedChanges) {
if (change.type == 'insert') {
recorder.insertLeft(change.pos, change.toAdd);
} else if (change.type == 'remove') {
recorder.remove(change.pos - 1, change.toRemove.length + 1);
} else if (change.type == 'replace') {
recorder.remove(change.pos, change.oldText.length);
recorder.insertLeft(change.pos, change.newText);
} else if (change.type === 'noop') {
// do nothing
} else {
throw new Error(`Unexpected Change '${change.constructor.name}'`);
}
}
host.commitUpdate(recorder);
}
/**
* This method is specifically for reading JSON files in a Tree
* @param host The host tree
* @param path The path to the JSON file
* @returns The JSON data in the file.
*/
export function readJsonInTree<T extends object = any>(
host: Tree,
path: string
): T {
if (!host.exists(path)) {
throw new Error(`Cannot find ${path}`);
}
try {
return parseJson(host.read(path)!.toString('utf-8'));
} catch (e) {
throw new Error(`Cannot parse ${path}: ${e.message}`);
}
}
// TODO(v13): remove this deprecated method
/**
* @deprecated This method is deprecated
*/
export function getFileDataInHost(host: Tree, path: Path): FileData {
return {
file: path,
ext: extname(normalize(path)),
hash: '',
};
}
export function allFilesInDirInHost(
host: Tree,
path: Path,
options: {
recursive: boolean;
} = { recursive: true }
): Path[] {
const dir = host.getDir(path);
const res: Path[] = [];
dir.subfiles.forEach((p) => {
res.push(join(path, p));
});
if (!options.recursive) {
return res;
}
dir.subdirs.forEach((p) => {
res.push(...allFilesInDirInHost(host, join(path, p)));
});
return res;
}
/**
* This method is specifically for updating JSON in a Tree
* @param path Path of JSON file in the Tree
* @param callback Manipulation of the JSON data
* @returns A rule which updates a JSON file file in a Tree
*/
export function updateJsonInTree<T extends object = any, O extends object = T>(
path: string,
callback: (json: T, context: SchematicContext) => O
): Rule {
return (host: Tree, context: SchematicContext): Tree => {
if (!host.exists(path)) {
host.create(path, serializeJson(callback({} as T, context)));
return host;
}
host.overwrite(
path,
serializeJson(callback(readJsonInTree(host, path), context))
);
return host;
};
}
export function updateWorkspaceInTree<
T extends object = any,
O extends object = T
>(callback: (json: T, context: SchematicContext, host: Tree) => O): Rule {
return (host: Tree, context: SchematicContext = undefined): Tree => {
const path = getWorkspacePath(host);
host.overwrite(
path,
serializeJson(callback(readJsonInTree(host, path), context, host))
);
return host;
};
}
export function readNxJsonInTree(host: Tree) {
return readJsonInTree<NxJsonConfiguration>(host, 'nx.json');
}
export function libsDir(host: Tree) {
const json = readJsonInTree<NxJsonConfiguration>(host, 'nx.json');
return json?.workspaceLayout?.libsDir ?? 'libs';
}
export function appsDir(host: Tree) {
const json = readJsonInTree<NxJsonConfiguration>(host, 'nx.json');
return json?.workspaceLayout?.appsDir ?? 'apps';
}
export function updateNxJsonInTree(
callback: (
json: NxJsonConfiguration,
context: SchematicContext
) => NxJsonConfiguration
): Rule {
return (host: Tree, context: SchematicContext): Tree => {
host.overwrite(
'nx.json',
serializeJson(callback(readJsonInTree(host, 'nx.json'), context))
);
return host;
};
}
export function readWorkspace(host: Tree): any {
const path = getWorkspacePath(host);
return readJsonInTree(host, path);
}
/**
* verifies whether the given packageJson dependencies require an update
* given the deps & devDeps passed in
*/
function requiresAddingOfPackages(packageJsonFile, deps, devDeps): boolean {
let needsDepsUpdate = false;
let needsDevDepsUpdate = false;
packageJsonFile.dependencies = packageJsonFile.dependencies || {};
packageJsonFile.devDependencies = packageJsonFile.devDependencies || {};
if (Object.keys(deps).length > 0) {
needsDepsUpdate = Object.keys(deps).some(
(entry) => !packageJsonFile.dependencies[entry]
);
}
if (Object.keys(devDeps).length > 0) {
needsDevDepsUpdate = Object.keys(devDeps).some(
(entry) => !packageJsonFile.devDependencies[entry]
);
}
return needsDepsUpdate || needsDevDepsUpdate;
}
/**
* Updates the package.json given the passed deps and/or devDeps. Only updates
* if the packages are not yet present
*
* @param deps the package.json dependencies to add
* @param devDeps the package.json devDependencies to add
* @param addInstall default `true`; set to false to avoid installs
*/
export function addDepsToPackageJson(
deps: any,
devDeps: any,
addInstall = true
): Rule {
return (host: Tree, context: SchematicContext) => {
const currentPackageJson = readJsonInTree(host, 'package.json');
if (requiresAddingOfPackages(currentPackageJson, deps, devDeps)) {
return chain([
updateJsonInTree('package.json', (json, context: SchematicContext) => {
json.dependencies = {
...(json.dependencies || {}),
...deps,
...(json.dependencies || {}),
};
json.devDependencies = {
...(json.devDependencies || {}),
...devDeps,
...(json.devDependencies || {}),
};
json.dependencies = sortObjectByKeys(json.dependencies);
json.devDependencies = sortObjectByKeys(json.devDependencies);
return json;
}),
addInstallTask({
skipInstall: !addInstall,
}),
]);
} else {
return noop();
}
};
}
export function updatePackageJsonDependencies(
deps: any,
devDeps: any,
addInstall = true
): Rule {
return chain([
updateJsonInTree('package.json', (json, context: SchematicContext) => {
json.dependencies = {
...(json.dependencies || {}),
...deps,
};
json.devDependencies = {
...(json.devDependencies || {}),
...devDeps,
};
json.dependencies = sortObjectByKeys(json.dependencies);
json.devDependencies = sortObjectByKeys(json.devDependencies);
return json;
}),
addInstallTask({
skipInstall: !addInstall,
}),
]);
}
export function getProjectConfig(host: Tree, name: string): any {
const workspaceJson = readJsonInTree(host, getWorkspacePath(host));
const projectConfig = workspaceJson.projects[name];
if (!projectConfig) {
throw new Error(`Cannot find project '${name}'`);
} else if (typeof projectConfig === 'string') {
return readJsonInTree(host, projectConfig);
} else {
return projectConfig;
}
}
export function createOrUpdate(host: Tree, path: string, content: string) {
if (host.exists(path)) {
host.overwrite(path, content);
} else {
host.create(path, content);
}
}
export function insertImport(
source: ts.SourceFile,
fileToEdit: string,
symbolName: string,
fileName: string,
isDefault = false
): Change {
const rootNode = source;
const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration);
// get nodes that map to import statements from the file fileName
const relevantImports = allImports.filter((node) => {
// StringLiteral of the ImportDeclaration is the import file (fileName in this case).
const importFiles = node
.getChildren()
.filter((child) => child.kind === ts.SyntaxKind.StringLiteral)
.map((n) => (n as ts.StringLiteral).text);
return importFiles.filter((file) => file === fileName).length === 1;
});
if (relevantImports.length > 0) {
let importsAsterisk = false;
// imports from import file
const imports: ts.Node[] = [];
relevantImports.forEach((n) => {
Array.prototype.push.apply(
imports,
findNodes(n, ts.SyntaxKind.Identifier)
);
if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) {
importsAsterisk = true;
}
});
// if imports * from fileName, don't add symbolName
if (importsAsterisk) {
return new NoopChange();
}
const importTextNodes = imports.filter(
(n) => (n as ts.Identifier).text === symbolName
);
// insert import if it's not there
if (importTextNodes.length === 0) {
const fallbackPos =
findNodes(
relevantImports[0],
ts.SyntaxKind.CloseBraceToken
)[0].getStart() ||
findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart();
return insertAfterLastOccurrence(
imports,
`, ${symbolName}`,
fileToEdit,
fallbackPos
);
}
return new NoopChange();
}
// no such import declaration exists
const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter(
(n: ts.StringLiteral) => n.text === 'use strict'
);
let fallbackPos = 0;
if (useStrict.length > 0) {
fallbackPos = useStrict[0].end;
}
const open = isDefault ? '' : '{ ';
const close = isDefault ? '' : ' }';
// if there are no imports or 'use strict' statement, insert import at beginning of file
const insertAtBeginning = allImports.length === 0 && useStrict.length === 0;
const separator = insertAtBeginning ? '' : ';\n';
const toInsert =
`${separator}import ${open}${symbolName}${close}` +
` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`;
return insertAfterLastOccurrence(
allImports,
toInsert,
fileToEdit,
fallbackPos,
ts.SyntaxKind.StringLiteral
);
}
export function replaceNodeValue(
host: Tree,
modulePath: string,
node: ts.Node,
content: string
) {
insert(host, modulePath, [
new ReplaceChange(
modulePath,
node.getStart(node.getSourceFile()),
node.getFullText(),
content
),
]);
}
export function renameSyncInTree(
tree: Tree,
from: string,
to: string,
cb: (err: string) => void
) {
if (!tree.exists(from)) {
cb(`Path: ${from} does not exist`);
} else if (tree.exists(to)) {
cb(`Path: ${to} already exists`);
} else {
renameFile(tree, from, to);
cb(null);
}
}
export function renameDirSyncInTree(
tree: Tree,
from: string,
to: string,
cb: (err: string) => void
) {
const dir = tree.getDir(from);
if (!dirExists(dir)) {
cb(`Path: ${from} does not exist`);
return;
}
dir.visit((path) => {
const destination = path.replace(from, to);
renameFile(tree, path, destination);
});
cb(null);
}
function dirExists(dir: DirEntry): boolean {
return dir.subdirs.length + dir.subfiles.length !== 0;
}
function renameFile(tree: Tree, from: string, to: string) {
const buffer = tree.read(from);
if (!buffer) {
return;
}
tree.create(to, buffer);
tree.delete(from);
}
/**
* Applies a template merge but skips for already existing entries
*/
export function applyWithSkipExisting(source: Source, rules: Rule[]): Rule {
return (tree: Tree, _context: SchematicContext) => {
const rule = mergeWith(
apply(source, [
...rules,
forEach((fileEntry) => {
if (tree.exists(fileEntry.path)) {
return null;
}
return fileEntry;
}),
])
);
return rule(tree, _context);
};
}