@sprucelabs/spruce-cli
Version:
Command line interface for building Spruce skills.
328 lines (277 loc) • 10.3 kB
text/typescript
import { dirname } from 'path'
import { diskUtil } from '@sprucelabs/spruce-skill-utils'
import _ from 'lodash'
import * as tsutils from 'tsutils'
import * as ts from 'typescript'
const serializeSymbol = (options: {
checker: ts.TypeChecker
symbol: ts.Symbol
}): DocEntry => {
const { checker, symbol } = options
const doc: DocEntry = {
name: symbol.getName(),
documentation: ts.displayPartsToString(
symbol.getDocumentationComment(checker)
),
}
if (symbol.valueDeclaration) {
doc.type = checker.typeToString(
checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration)
)
}
return doc
}
const serializeSignature = (options: {
checker: ts.TypeChecker
signature: ts.Signature
}) => {
const { checker, signature } = options
return {
parameters: signature.parameters.map((p) =>
serializeSymbol({ symbol: p, checker })
),
returnType: checker.typeToString(signature.getReturnType()),
documentation: ts.displayPartsToString(
signature.getDocumentationComment(checker)
),
}
}
const introspectionUtil = {
introspect(tsFiles: string[]): Introspection[] {
const filePaths = tsFiles
const program = ts.createProgram(filePaths, {})
const checker = program.getTypeChecker()
// for building results
const introspects: Introspection[] = []
for (const tsFile of filePaths) {
const sourceFile = program.getSourceFile(tsFile)
const results: Introspection = { classes: [], interfaces: [] }
if (sourceFile && _.includes(filePaths, sourceFile.fileName)) {
if (!this.hasClassDefinition(sourceFile)) {
const exports = this.getExports(sourceFile)
const firstExport = exports[0]
if (firstExport) {
const declaration =
this.getClassDeclarationFromImportedFile(
firstExport,
dirname(tsFile),
program
)
if (declaration) {
const { classes, interfaces } =
getDeclarationsFromNode(
declaration,
checker,
sourceFile
)
results.classes.push(...classes)
results.interfaces.push(...interfaces)
} else {
// must have imported from somewhere else (another node module)
const className = //@ts-ignore
firstExport.exportClause?.elements?.[0]
?.propertyName?.text
if (className) {
results.classes.push({
className,
classPath: tsFile,
isAbstract: false,
optionsInterfaceName: undefined,
parentClassName: undefined,
parentClassPath: undefined,
staticProperties: {},
})
}
}
}
} else {
ts.forEachChild(sourceFile, (node) => {
const { classes, interfaces } = getDeclarationsFromNode(
node,
checker,
sourceFile
)
results.classes.push(...classes)
results.interfaces.push(...interfaces)
})
}
}
introspects.push(results)
}
return introspects
},
getExports(sourceFile: ts.SourceFile): ts.Node[] {
const exports: ts.Node[] = []
const traverse = (node: ts.Node) => {
if (ts.isExportDeclaration(node)) {
exports.push(node)
}
ts.forEachChild(node, traverse)
}
traverse(sourceFile)
return exports
},
getClassDeclarationFromImportedFile(
exportDeclaration: ts.Node,
dirName: string,
program: ts.Program
): ts.ClassDeclaration | undefined {
if (!ts.isExportDeclaration(exportDeclaration)) {
return undefined
}
const exportClause = exportDeclaration.exportClause
if (!exportClause || !ts.isNamedExports(exportClause)) {
return undefined
}
for (const element of exportClause.elements) {
if (element.propertyName) {
const propertyName = element.propertyName.text
const moduleSpecifier = (
exportDeclaration.moduleSpecifier as ts.StringLiteral
).text
const sourceFile = diskUtil.resolveFile(
dirName,
moduleSpecifier.replace(/^\.\//, '')
)
if (!sourceFile) {
return undefined
}
// Load the source file containing the class declaration
const declarationSourceFile = program.getSourceFile(sourceFile)
if (!declarationSourceFile) {
return undefined
}
const traverse = (
node: ts.Node
): ts.ClassDeclaration | undefined => {
if (
ts.isClassDeclaration(node) &&
node.name &&
node.name.text === propertyName
) {
return node
}
for (const child of node.getChildren(
declarationSourceFile
)) {
const result = traverse(child)
if (result) {
return result
}
}
return undefined
}
return traverse(declarationSourceFile)
}
}
return undefined
},
hasClassDefinition(sourceFile: ts.SourceFile): boolean {
let hasClass = false
const traverse = (node: ts.Node) => {
if (ts.isClassDeclaration(node)) {
hasClass = true
}
if (!hasClass) {
ts.forEachChild(node, traverse)
}
}
traverse(sourceFile)
return hasClass
},
}
export default introspectionUtil
function getDeclarationsFromNode(
node: ts.Node,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
) {
const classes: IntrospectionClass[] = []
const interfaces: IntrospectionInterface[] = []
// if this is a class declaration
if (ts.isClassDeclaration(node) && node.name) {
const symbol = checker.getSymbolAtLocation(node.name)
if (symbol?.valueDeclaration) {
const details = serializeSymbol({ checker, symbol })
// Get the construct signatures
const constructorType = checker.getTypeOfSymbolAtLocation(
symbol,
symbol.valueDeclaration
)
let parentClassSymbol: ts.Symbol | undefined
if (node.heritageClauses && node.heritageClauses[0]) {
parentClassSymbol = checker
.getTypeAtLocation(node.heritageClauses[0].types[0])
.getSymbol()
}
const parentClassName =
// @ts-ignore
parentClassSymbol?.valueDeclaration?.name?.text
// @ts-ignore
const parentClassPath = parentClassSymbol?.parent
?.getName()
.replace('"', '')
const isAbstractClass = tsutils.isModifierFlagSet(
node,
ts.ModifierFlags.Abstract
)
details.constructors = constructorType
.getConstructSignatures()
.map((s) => serializeSignature({ signature: s, checker }))
classes.push({
className: node.name.text,
classPath: sourceFile.fileName,
parentClassName,
parentClassPath,
staticProperties: pluckStaticProperties(node),
optionsInterfaceName:
details.constructors?.[0].parameters?.[0]?.type,
isAbstract: isAbstractClass,
})
}
} else if (ts.isInterfaceDeclaration(node)) {
interfaces.push({
interfaceName: node.name.text,
})
}
return { classes, interfaces }
}
function pluckStaticProperties(node: ts.ClassDeclaration): StaticProperties {
const staticProps: StaticProperties = {}
for (const member of node.members) {
//@ts-ignore
const name = member.name?.escapedText
//@ts-ignore
const value = member.initializer?.text
if (name && value) {
staticProps[name] = value
}
}
return staticProps
}
export interface IntrospectionClass {
className: string
classPath: string
parentClassName: string | undefined
parentClassPath: string | undefined
optionsInterfaceName: string | undefined
isAbstract: boolean
staticProperties: StaticProperties
}
type StaticProperties = Record<string, any>
interface IntrospectionInterface {
interfaceName: string
}
export interface Introspection {
classes: IntrospectionClass[]
interfaces: IntrospectionInterface[]
}
interface DocEntry {
name?: string
fileName?: string
documentation?: string
type?: string
constructors?: DocEntry[]
parameters?: DocEntry[]
returnType?: string
}