Created
April 28, 2022 10:05
-
-
Save adrienjoly/fc117b187f87cca3417abc4a8433e3a2 to your computer and use it in GitHub Desktop.
Generate the tree of callers of a TypeScript function.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// This script generates the call tree (a.k.a. call hierarchy, or dependency graph) of a function. | |
// | |
// Usage: $ npx node-ts find-function-calls.ts <target-file.ts> <target-function-name> | |
import util from "util" | |
import assert from "assert" | |
import * as ts from "typescript" | |
import * as tsmorph from "ts-morph" | |
import type { ReferenceEntry, Node, ReferencedSymbol } from "ts-morph" | |
import type { StandardizedFilePath } from "@ts-morph/common" | |
const params = { | |
tsConfigFilePath: "./tsconfig.json", | |
targetFile: process.argv[2], | |
targetFunction: process.argv[3], | |
} | |
type FunctionWithCalls = { | |
filePath: StandardizedFilePath | |
function: Node<ts.Node> | undefined | |
functionCalls: Node<ts.Node>[] | |
exportedCallers: Node<ts.Node>[] | |
externalCallers: FunctionWithCalls[] | |
} | |
/** Returns `true` if `node` (e.g. of kind: `VariableStatement`) is exported. */ | |
const isExported = (node: Node<ts.Node>) => | |
(node.getCombinedModifierFlags() & ts.ModifierFlags.Export) !== 0 | |
/** Walk up the list of parents, to find a call to the `functionIdentifierNode` function and return it. */ | |
const findFunctionCall = (functionIdentifierNode: Node<ts.Node>) => { | |
let node: Node<ts.Node> | undefined = functionIdentifierNode | |
while (node && node.getKind() !== ts.SyntaxKind.CallExpression) { | |
node = node.getParent() | |
} | |
return node | |
} | |
type Callable = | |
| tsmorph.FunctionDeclaration | |
| tsmorph.MethodDeclaration | |
| tsmorph.ClassDeclaration | |
| tsmorph.VariableDeclaration // only if it contains an ArrowFunction | |
const isNamedFunction = (decl: Node<ts.Node>): decl is Callable => | |
decl instanceof tsmorph.FunctionDeclaration || | |
decl instanceof tsmorph.MethodDeclaration || | |
decl instanceof tsmorph.ClassDeclaration || | |
(decl instanceof tsmorph.VariableDeclaration && | |
decl.getChildrenOfKind(tsmorph.ts.SyntaxKind.ArrowFunction).length === | |
1) | |
/** Walk up the list of parents, to find a function that calls `functionIdentifierNode` and return it. */ | |
const findFunctionCaller = ( | |
functionIdentifierNode: Node<ts.Node> | |
): Node<ts.Node> | undefined => { | |
let node = findFunctionCall(functionIdentifierNode) | |
// Opportunity of improvement: for function call that happens in a class method, return the method instead of its class | |
while (node && !isNamedFunction(node)) { | |
node = node.getParent() // example of sequence of parents' SyntaxKind: Identifier <- CallExpression <- ExpressionStatement <- Block <- ArrowFunction <- VariableDeclaration <- VariableDeclarationList <- VariableStatement | |
} | |
return node | |
} | |
/** Walk up the list of parents, to find a function that calls `functionIdentifierNode` and return it, if it is exported. */ | |
const findExportedCaller = (functionIdentifierNode: Node<ts.Node>) => { | |
let node: Node<ts.Node> | undefined = findFunctionCaller( | |
functionIdentifierNode | |
) | |
while (node && !isExported(node)) { | |
node = node.getParent() | |
} | |
return node | |
} | |
/** Return a list of (in)direct calls to a given function, recursively. */ | |
const findFunctionCalls = ( | |
importedFunction: ReferencedSymbol | |
): FunctionWithCalls => { | |
const functionCalls = importedFunction | |
.getReferences() | |
.map((ref: ReferenceEntry) => findFunctionCall(ref.getNode())) | |
.filter((fctCall): fctCall is Node<ts.Node> => fctCall !== undefined) | |
const exportedCallers = functionCalls | |
.map((callRef) => findExportedCaller(callRef)) | |
.filter((callNode): callNode is Node<ts.Node> => callNode !== undefined) | |
const functionCallers = functionCalls | |
.map(findFunctionCaller) | |
.filter((node): node is Callable => !!node && isNamedFunction(node)) | |
const definition = importedFunction.getDefinition() | |
return { | |
filePath: definition.getSourceFile().getFilePath(), | |
function: definition.getDeclarationNode(), | |
functionCalls, | |
exportedCallers, | |
externalCallers: functionCallers.flatMap( | |
(extCaller: Node<ts.Node>): FunctionWithCalls[] => { | |
const refs = extCaller | |
.getSymbol() | |
?.getDeclarations() | |
?.find(isNamedFunction) | |
?.findReferences() | |
return (refs || []).map(findFunctionCalls) | |
} | |
), | |
} | |
} | |
/** Find all calls to a given function, located in the scope of a TypeScript project. */ | |
const findFunctionCallsInTypeScriptProject = ({ | |
tsConfigFilePath, | |
targetFile, | |
targetFunction, | |
}: { | |
tsConfigFilePath: string | |
targetFile: string | |
targetFunction: string | |
}): FunctionWithCalls[] => { | |
const project = new tsmorph.Project({ tsConfigFilePath }) | |
const declaration = project | |
.getSourceFile(targetFile) | |
?.getExportedDeclarations() | |
?.get(targetFunction) | |
?.find(tsmorph.Node.isReferenceFindable) | |
assert(declaration, `no declaration was found for: ${targetFunction}`) | |
return (declaration?.findReferences() || []).map(findFunctionCalls) // => one array item per source code file | |
} | |
const exportedRefs = findFunctionCallsInTypeScriptProject(params) | |
const nbCalls = exportedRefs.reduce( | |
(acc, ref) => acc + ref.functionCalls.length, | |
0 | |
) | |
const getLine = (node: Node<ts.Node>) => node.getText().split("\n")[0] | |
const getFunctionName = (node?: Node<ts.Node>) => node?.getSymbol()?.getName() | |
const formatExportedRef = (ref: FunctionWithCalls): unknown => ({ | |
...ref, | |
function: getFunctionName(ref.function), | |
functionCalls: ref.functionCalls.map(getLine), | |
exportedCallers: ref.exportedCallers.map(getFunctionName), | |
externalCallers: ref.externalCallers.map(formatExportedRef), | |
}) | |
console.log( | |
util.inspect(exportedRefs.map(formatExportedRef), { | |
depth: null, | |
colors: true, | |
}) | |
) | |
console.warn( | |
`Found ${nbCalls} calls to ${params.targetFunction}, in ${exportedRefs.length} files.` | |
) |
Note: cs-au-dk/jelly: JavaScript/TypeScript static analyzer for call graph construction, library usage pattern matching, and vulnerability exposure analysis could possibly be used as an alternative of ts-morph
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See openwhyd/openwhyd#580 for an updated version of this script.