Search code examples
typescriptclassmethodspropertiesabstract-syntax-tree

Typescript: harvest class, property and method details at Compile Time


I wish to harvest class, properties and method information using the Typescript compiler.

I am using nodejs and wish to use the information to construct client side forms etc based on my server side class definitions.

I have made good progress using stack overflow as a start eg: Correct way of getting type for a variable declaration in a typescript AST? but would like to extend further to get Method parameter information which is currently missing from the classes.json file as shown below. Any suggestions would be appreciated. My code:

import ts from 'typescript';
import * as fs from "fs";

interface DocEntry {
    name?: string;
    fileName?: string;
    documentation?: string;
    type?: string;
    constructors?: DocEntry[];
    parameters?: DocEntry[];
    returnType?: string;
}

/** Generate documentation for all classes in a set of .ts files */
function generateDocumentation(
    fileNames: string[],
    options: ts.CompilerOptions
): void {
    // Build a program using the set of root file names in fileNames
    let program = ts.createProgram(fileNames, options);

    // Get the checker, we will use it to find more about classes
    let checker = program.getTypeChecker();
    let output = {
        component: [],
        fields: [],
        methods: []
    };

    // Visit every sourceFile in the program
    for (const sourceFile of program.getSourceFiles()) {
        if (!sourceFile.isDeclarationFile) {
            // Walk the tree to search for classes
            ts.forEachChild(sourceFile, visit);
        }
    }

    // print out the definitions
    fs.writeFileSync("classes.json", JSON.stringify(output, undefined, 4));

    return;

    /** visit nodes */
    function visit(node: ts.Node) {

        if (ts.isClassDeclaration(node) && node.name) {
            // This is a top level class, get its symbol
            let symbol = checker.getSymbolAtLocation(node.name);
            if (symbol) {
                const details = serializeClass(symbol);
                output.component.push(details);
            }
            ts.forEachChild(node, visit);

        }
        else if (ts.isPropertyDeclaration(node)) {
            const x = 0;
            let symbol = checker.getSymbolAtLocation(node.name);
            if (symbol) {
                output.fields.push(serializeClass(symbol));
            }
        } else if (ts.isMethodDeclaration(node)) {
            const x = 0;
            let symbol = checker.getSymbolAtLocation(node.name);
            if (symbol) {
                output.methods.push(serializeClass(symbol));
            }
        }
    }

    /** Serialize a symbol into a json object */
    function serializeSymbol(symbol: ts.Symbol): DocEntry {
        return {
            name: symbol.getName(),
            documentation: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
            type: checker.typeToString(
                checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!)
            )
        };
    }

    /** Serialize a class symbol information */
    function serializeClass(symbol: ts.Symbol) {
        let details = serializeSymbol(symbol);

        // Get the construct signatures
        let constructorType = checker.getTypeOfSymbolAtLocation(
            symbol,
            symbol.valueDeclaration!
        );
        details.constructors = constructorType
            .getConstructSignatures()
            .map(serializeSignature);
        return details;
    }

    /** Serialize a signature (call or construct) */
    function serializeSignature(signature: ts.Signature) {
        return {
            parameters: signature.parameters.map(serializeSymbol),
            returnType: checker.typeToString(signature.getReturnType()),
            documentation: ts.displayPartsToString(signature.getDocumentationComment(checker))
        };
    }
}

generateDocumentation(["source1.ts"], {
    target: ts.ScriptTarget.ES5,
    module: ts.ModuleKind.CommonJS
});

target source file source1.ts:

 * Documentation for C
 */
class C {
    /**bg2 is very cool*/
    bg2: number = 2;
    bg4: number = 4;
    bgA: string = "A";

    /**
     * constructor documentation
     * @param a my parameter documentation
     * @param b another parameter documentation
     */
    constructor(a: string, b: C) {
    }

    /** MethodA is an A type Method*/
    methodA(myarga1: string): number {
        return 22;
    }

    /** definitely a B grade Method
     * @param myargb1 is very argumentative*/
    methodB(myargb1: string): string {
        return "abc";
    }
}

resulting JSON file classes.json:

{
    "component": [
        {
            "name": "C",
            "documentation": "Documentation for C",
            "type": "typeof C",
            "constructors": [
                {
                    "parameters": [
                        {
                            "name": "a",
                            "documentation": "my parameter documentation",
                            "type": "string"
                        },
                        {
                            "name": "b",
                            "documentation": "another parameter documentation",
                            "type": "C"
                        }
                    ],
                    "returnType": "C",
                    "documentation": "constructor documentation"
                }
            ]
        }
    ],
    "fields": [
        {
            "name": "bg2",
            "documentation": "bg2 is very cool",
            "type": "number",
            "constructors": []
        },
        {
            "name": "bg4",
            "documentation": "",
            "type": "number",
            "constructors": []
        },
        {
            "name": "bgA",
            "documentation": "",
            "type": "string",
            "constructors": []
        }
    ],
    "methods": [
        {
            "name": "methodA",
            "documentation": "MethodA is an A type Method",
            "type": "(myarga1: string) => number",
            "constructors": []
        },
        {
            "name": "methodB",
            "documentation": "definitely a B grade Method",
            "type": "(myargb1: string) => string",
            "constructors": []
        }
    ]
}

Solution

  • Added a check for ts.isMethodDeclaration(node) in the visit function to caputure Method details. Also added support for multiple files and for documentation tags (eg like @DummyTag written in the documentation comments like:

    /** @DummyTag Mary had a little lamb  */
    
    

    So new file works well:

    // @ts-ignore
    import ts from 'typescript';
    import * as fs from "fs";
    
    interface DocEntry {
        name?: string;
        fileName?: string;
        documentation?: string;
        type?: string;
        constructors?: DocEntry[];
        parameters?: DocEntry[];
        returnType?: string;
        tags?: Record<string, string>;
    }
    
    /** Generate documentation for all classes in a set of .ts files */
    function generateDocumentation(
        fileNames: string[],
        options: ts.CompilerOptions
    ): void {
        // Build a program using the set of root file names in fileNames
        let program = ts.createProgram(fileNames, options);
        console.log("ROOT FILES:",program.getRootFileNames());
        // Get the checker, we will use it to find more about classes
        let checker = program.getTypeChecker();
    
        let allOutput = [];
        let output = null;
    let exportStatementFound = false;
    
        let currentMethod = null;
        let fileIndex = 0;
        // Visit the sourceFile for each "source file" in the program
        //ie don't use program.getSourceFiles() as it gets all the imports as well
    
        for (let i=0; i<fileNames.length; i++) {
            const fileName = fileNames[i];
            const sourceFile = program.getSourceFile(fileName);
            // console.log("sourceFile.kind:", sourceFile.kind);
            if (sourceFile.kind === ts.SyntaxKind.ImportDeclaration){
                console.log("IMPORT");
            }
            exportStatementFound = false;
            if (!sourceFile.isDeclarationFile) {
                // Walk the tree to search for classes
                output = {
                    fileName: fileName,
                    component: [],
                    fields: [],
                    methods: []
                };
    
                ts.forEachChild(sourceFile, visit);
                if (output) {
                    allOutput.push(output);
                }
                if (!exportStatementFound){
                    console.log("WARNING: no export statement found in:", fileName);
                }
            }
        }
        // print out the definitions
        fs.writeFileSync("classes.json", JSON.stringify(allOutput, undefined, 4));
    
        return;
    
        /** visit nodes */
        function visit(node: ts.Node) {
            if (!output){
                return;
            }
            if (node.kind === ts.SyntaxKind.ImportDeclaration){
                console.log("IMPORT");
                //output = null;
                return;
            }
            if (node.kind === ts.SyntaxKind.DefaultKeyword){
                console.log("DEFAULT");
                return;
            }
            if (node.kind === ts.SyntaxKind.ExportKeyword){
                exportStatementFound = true;
                console.log("EXPORT");
                return;
            }
    
            if (ts.isClassDeclaration(node) && node.name) {
                // This is a top level class, get its symbol
                let symbol = checker.getSymbolAtLocation(node.name);
                if (symbol) {
                    //need localSymbol for the name, if there is one because otherwise exported as "default"
                    symbol = (symbol.valueDeclaration?.localSymbol)?symbol.valueDeclaration?.localSymbol: symbol;
                    const details = serializeClass(symbol);
                    output.component.push(details);
                }
                ts.forEachChild(node, visit);
            }
            else if (ts.isPropertyDeclaration(node)) {
                let symbol = checker.getSymbolAtLocation(node.name);
                if (symbol) {
                    output.fields.push(serializeField(symbol));
                }
            } else if (ts.isMethodDeclaration(node)) {
                let symbol = checker.getSymbolAtLocation(node.name);
                if (symbol) {
                    currentMethod = serializeMethod(symbol);
                    output.methods.push(currentMethod);
                }
                ts.forEachChild(node, visit);
            }
    
    
        }
    
        /** Serialize a symbol into a json object */
        function serializeSymbol(symbol: ts.Symbol): DocEntry {
            const tags = symbol.getJsDocTags();
            let tagMap = null;
            if (tags?.length){
                console.log("TAGS:", tags);
                for (let i=0; i<tags.length; i++){
                    const tag = tags[i];
                    if (tag.name !== "param"){
                        tagMap = tagMap?tagMap:{};
                        tagMap[tag.name] = tag.text;
                    }
                }
            }
            return {
                name: symbol.getName(),
                documentation: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
                type: checker.typeToString(
                    checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!)
                ),
                tags: tagMap
            };
        }
    
        /** Serialize a class symbol information */
        function serializeClass(symbol: ts.Symbol) {
            let details = serializeSymbol(symbol);
    
            // Get the construct signatures
            let constructorType = checker.getTypeOfSymbolAtLocation(
                symbol,
                symbol.valueDeclaration!
            );
            details.constructors = constructorType
                .getConstructSignatures()
                .map(serializeSignature);
            return details;
        }
    
        function serializeField(symbol: ts.Symbol) {
            return serializeSymbol(symbol);
        }
    
        function serializeMethod(symbol: ts.Symbol) {
            let details = serializeSymbol(symbol);
    
            // Get the construct signatures
            let methodType = checker.getTypeOfSymbolAtLocation(
                symbol,
                symbol.valueDeclaration!
            );
            let callingDetails = methodType.getCallSignatures()
                .map(serializeSignature)["0"];
            details = {...details, ...callingDetails};
            return details;
        }
    
        /** Serialize a signature (call or construct) */
        function serializeSignature(signature: ts.Signature) {
            return {
                parameters: signature.parameters.map(serializeSymbol),
                returnType: checker.typeToString(signature.getReturnType()),
                documentation: ts.displayPartsToString(signature.getDocumentationComment(checker))
            };
        }
    }
    
    generateDocumentation(["source1.ts", "source2.ts"], {
        target: ts.ScriptTarget.ES5,
        module: ts.ModuleKind.CommonJS
    });