Skip to content

Instantly share code, notes, and snippets.

@adrienjoly
Created April 28, 2022 10:05
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save adrienjoly/fc117b187f87cca3417abc4a8433e3a2 to your computer and use it in GitHub Desktop.
Save adrienjoly/fc117b187f87cca3417abc4a8433e3a2 to your computer and use it in GitHub Desktop.
Generate the tree of callers of a TypeScript function.
// 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.`
)
@adrienjoly
Copy link
Author

See openwhyd/openwhyd#580 for an updated version of this script.

@adrienjoly
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment