├── samples ├── Empty.ts ├── diagram.png ├── Color.ts ├── diagram-dependencies.png ├── Canvas.ts └── Shapes.ts ├── tsviz-cli ├── bin │ └── index.js ├── src │ ├── index.ts │ ├── collections-extensions.ts │ ├── tsviz-cli.ts │ └── uml-builder.ts └── package.json ├── tsviz ├── src │ ├── index.ts │ ├── collections-extensions.ts │ ├── tsviz.ts │ ├── ts-elements.ts │ └── ts-analyser.ts ├── tsconfig.json └── package.json ├── .gitignore ├── package.json ├── LICENSE ├── .vscode ├── launch.json └── tasks.json ├── README.md └── .github └── workflows └── build.yml /samples/Empty.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Doc on empty module. 3 | */ -------------------------------------------------------------------------------- /tsviz-cli/bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require("../dist/index"); -------------------------------------------------------------------------------- /tsviz/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./tsviz"; 2 | export * from "./ts-elements"; -------------------------------------------------------------------------------- /samples/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaompneves/tsviz/HEAD/samples/diagram.png -------------------------------------------------------------------------------- /tsviz-cli/src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import * as app from "./tsviz-cli"; 3 | app.run(); -------------------------------------------------------------------------------- /samples/Color.ts: -------------------------------------------------------------------------------- 1 | enum Color { 2 | RED, 3 | BLUE, 4 | GREEN 5 | } 6 | 7 | export = Color; 8 | -------------------------------------------------------------------------------- /samples/diagram-dependencies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joaompneves/tsviz/HEAD/samples/diagram-dependencies.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tsviz-cli/README.md 4 | tsviz-cli/samples/* 5 | tsviz/README.md 6 | tsviz/samples/* 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsviz-project", 3 | "workspaces": [ 4 | "tsviz", 5 | "tsviz-cli" 6 | ] 7 | } -------------------------------------------------------------------------------- /tsviz/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | } 8 | } -------------------------------------------------------------------------------- /samples/Canvas.ts: -------------------------------------------------------------------------------- 1 | import { Rectangle, Circle, Triangle, Shape } from "Shapes" 2 | import Color = require("./Color"); 3 | 4 | export class Canvas { 5 | public drawShapes(shapes: Shape[], color: Color) { } 6 | public clear() { } 7 | } -------------------------------------------------------------------------------- /tsviz/src/collections-extensions.ts: -------------------------------------------------------------------------------- 1 | 2 | export module Collections { 3 | export function firstOrDefault(collection: Iterable, predicate: (e: T) => boolean): T | null { 4 | for(const item of collection) { 5 | if (predicate(item)) { 6 | return item; 7 | } 8 | } 9 | return null; 10 | } 11 | } -------------------------------------------------------------------------------- /tsviz-cli/src/collections-extensions.ts: -------------------------------------------------------------------------------- 1 | 2 | export module Collections { 3 | export function distinct(collection: Iterable, getKey?: (item: T) => string): T[] { 4 | const hashset: Object = {}; 5 | const result = new Array(); 6 | getKey = getKey || ((item) => "" + item); 7 | for(const item of collection) { 8 | var key = getKey(item); 9 | if (!hashset.hasOwnProperty(key)) { 10 | result.push(item); 11 | (hashset as any)[key] = null; 12 | } 13 | } 14 | return result; 15 | } 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) ''2017'', ''João Neves'' 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE 14 | OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /samples/Shapes.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a 4 | * Shape. 5 | * @ignore 6 | * @test 7 | */ 8 | export class Shape { 9 | public draw() {} 10 | public resize() {} 11 | public rotate() { } 12 | } 13 | 14 | /** 15 | * Represents a Triangle. 16 | */ 17 | export class Triangle extends Shape { 18 | public draw() { } 19 | } 20 | 21 | /** 22 | * Represents a Rectangle. 23 | */ 24 | export class Rectangle extends Shape { 25 | public draw() { } 26 | public get width(): number { return 0; } 27 | public set width(w: number) { } 28 | public get height(): number { return 0; } 29 | public set height(h: number) { } 30 | } 31 | 32 | /** 33 | * Represents a Circle. 34 | */ 35 | export class Circle extends Shape { 36 | public draw() { } 37 | public get radius(): number { return 0; } 38 | public set radius(r: number) { } 39 | public static PI: number; 40 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "preLaunchTask": "${defaultBuildTask}", 15 | "program": "${workspaceFolder}/tsviz-cli/bin/index.js", 16 | "outFiles": [ 17 | "${workspaceFolder}/**/*.js" 18 | ], 19 | "sourceMaps": true, 20 | "cwd": "${workspaceFolder}", 21 | "args": [ 22 | "samples/", 23 | "temp/diagram.png" 24 | ] 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // Available variables which can be used inside of strings. 2 | // ${workspaceRoot}: the root folder of the team 3 | // ${file}: the current opened file 4 | // ${fileBasename}: the current opened file's basename 5 | // ${fileDirname}: the current opened file's dirname 6 | // ${fileExtname}: the current opened file's extension 7 | // ${cwd}: the current working directory of the spawned process 8 | 9 | { 10 | "version": "2.0.0", 11 | "tasks": [ 12 | { 13 | "label": "tsviz-cli: build", 14 | "path": "tsviz-cli", 15 | "type": "npm", 16 | "script": "build", 17 | "group": { 18 | "kind": "build", 19 | "isDefault": true 20 | }, 21 | "problemMatcher": [], 22 | "options": { 23 | "cwd": "${workspaceFolder}/tsviz-cli" 24 | }, 25 | "dependsOn": ["tsviz: build"] 26 | }, 27 | { 28 | "label": "tsviz: build", 29 | "path": "tsviz", 30 | "type": "npm", 31 | "script": "build", 32 | "group": "build", 33 | "problemMatcher": [], 34 | "options": { 35 | "cwd": "${workspaceFolder}/tsviz" 36 | } 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /tsviz/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.10", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "samples", 9 | "*.md" 10 | ], 11 | "engines": { 12 | "node": ">=14" 13 | }, 14 | "scripts": { 15 | "start": "tsdx watch", 16 | "build": "tsdx build", 17 | "test": "tsdx test", 18 | "lint": "tsdx lint", 19 | "prepare": "tsdx build", 20 | "size": "size-limit", 21 | "analyze": "size-limit --why", 22 | "prepack": "cpy ../README.md . && cpy ../samples samples" 23 | }, 24 | "peerDependencies": {}, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 120, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "tsviz", 37 | "author": "João Neves", 38 | "dependencies": { 39 | "typescript": "=4.4.3" 40 | }, 41 | "devDependencies": { 42 | "@types/node": "^16.11.12", 43 | "cpy-cli": "^3.1.1", 44 | "husky": "^7.0.2", 45 | "tsdx": "^0.14.1", 46 | "tslib": "^2.3.1" 47 | }, 48 | "bugs": { 49 | "url": "https://github.com/joaompneves/tsviz/issues" 50 | }, 51 | "homepage": "https://github.com/joaompneves/tsviz" 52 | } 53 | -------------------------------------------------------------------------------- /tsviz-cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.3", 3 | "license": "MIT", 4 | "main": "bin/index.js", 5 | "bin": "bin/index.js", 6 | "files": [ 7 | "bin", 8 | "dist", 9 | "samples", 10 | "*.md" 11 | ], 12 | "engines": { 13 | "node": ">=14" 14 | }, 15 | "scripts": { 16 | "start": "tsdx watch", 17 | "build": "tsdx build", 18 | "test": "tsdx test", 19 | "lint": "tsdx lint", 20 | "size": "size-limit", 21 | "analyze": "size-limit --why", 22 | "prepack": "cpy ../README.md . && cpy ../samples samples" 23 | }, 24 | "peerDependencies": {}, 25 | "husky": { 26 | "hooks": { 27 | "pre-commit": "tsdx lint" 28 | } 29 | }, 30 | "prettier": { 31 | "printWidth": 120, 32 | "semi": true, 33 | "singleQuote": true, 34 | "trailingComma": "es5" 35 | }, 36 | "name": "tsviz-cli", 37 | "author": "João Neves", 38 | "dependencies": { 39 | "graphviz": ">=0.0.8", 40 | "tsviz": ">=2.0.8" 41 | }, 42 | "devDependencies": { 43 | "@types/graphviz": "^0.0.34", 44 | "@types/node": "^16.10.2", 45 | "cpy-cli": "^3.1.1", 46 | "husky": "^7.0.2", 47 | "tsdx": "^0.14.1" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/joaompneves/tsviz/issues" 51 | }, 52 | "homepage": "https://github.com/joaompneves/tsviz" 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tsviz 2 | [![npm](https://img.shields.io/npm/v/tsviz?label=tsviz)](https://www.npmjs.com/package/tsviz) 3 | [![npm](https://img.shields.io/npm/v/tsviz-cli?label=tsviz-cli)](https://www.npmjs.com/package/tsviz-cli) 4 | 5 | This simple tool creates a UML diagram from typescript modules. 6 | 7 | ![diagram](https://github.com/joaompneves/tsviz/blob/master/samples/diagram.png) 8 | 9 | ## Installation 10 | 11 | ```bash 12 | npm install -g tsviz-cli 13 | ``` 14 | You also need to install [GraphViz](http://www.graphviz.org/download/), including correctly added it to your PATH. 15 | 16 | ## Usage 17 | 18 | ### Cli 19 | ``` 20 | tsviz-cli 21 | 22 | Available switches: 23 | -d, dependencies: produces the modules dependencies diagram 24 | -svg: output an svg file 25 | 26 | ``` 27 | 28 | In order to create a diagram for an entire project you simply type: 29 | 30 | ```bash 31 | tsviz-cli samples/ diagram.png 32 | ``` 33 | 34 | ### Library 35 | You may also consume tsviz npm library in your project to obtain a digest of modules, classes, methods, etc, of a given typescript project. 36 | 37 | ```bash 38 | npm install tsviz 39 | ``` 40 | 41 | ```typescript 42 | import { getModules, getModulesDependencies } from "tsviz"; 43 | 44 | const tsConfigDir = "path/where/your/tsconfig/lives"; 45 | 46 | const modules = getModules(tsConfigDir); 47 | ... 48 | 49 | const modulesDependencies = getModulesDependencies(tsConfigDir); 50 | ... 51 | ``` 52 | -------------------------------------------------------------------------------- /tsviz-cli/src/tsviz-cli.ts: -------------------------------------------------------------------------------- 1 | import { getModules } from "tsviz"; 2 | import { buildUml } from "./uml-builder"; 3 | 4 | function main(args: string[]) { 5 | const switches = args.filter(a => a.indexOf("-") === 0); 6 | const nonSwitches = args.filter(a => a.indexOf("-") !== 0); 7 | 8 | if (nonSwitches.length < 1) { 9 | console.error( 10 | "Invalid number of arguments. Usage:\n" + 11 | " \n" + 12 | "Available switches:\n" + 13 | " -d, dependencies: produces the modules' dependencies diagram\n" + 14 | " -svg: output an svg file"); 15 | return; 16 | } 17 | 18 | const targetPath = nonSwitches.length > 0 ? nonSwitches[0] : ""; 19 | const outputFilename = nonSwitches.length > 1 ? nonSwitches[1] : "diagram.png"; 20 | 21 | const dependenciesOnly = switches.indexOf("-d") >= 0 || switches.indexOf("-dependencies") >= 0; // dependencies or uml? 22 | const svgOutput = switches.indexOf("-svg") >= 0; 23 | 24 | try { 25 | createGraph(targetPath, outputFilename, dependenciesOnly, svgOutput); 26 | 27 | console.log("Done"); 28 | } catch (e) { 29 | console.error(e); 30 | } 31 | } 32 | 33 | function createGraph(targetPath: string, outputFilename: string, dependenciesOnly: boolean, svgOutput: boolean) { 34 | const modules = getModules(targetPath); 35 | buildUml(modules, outputFilename, dependenciesOnly, svgOutput); 36 | } 37 | 38 | export function run() { 39 | main(process.argv.slice(2)); 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | cache-dependency-path: '**/package-lock.json' 31 | 32 | - name: Npm Install 33 | working-directory: '.' 34 | run: npm ci 35 | 36 | - name: Build tsviz 37 | working-directory: 'tsviz' 38 | run: npm run build 39 | 40 | - name: Build tsviz-cli 41 | working-directory: 'tsviz-cli' 42 | run: npm run build 43 | 44 | - name: Publish tsviz 45 | if: github.ref == 'refs/heads/master' 46 | uses: JS-DevTools/npm-publish@v1 47 | with: 48 | package: 'tsviz/package.json' 49 | token: ${{ secrets.NPM_TOKEN }} 50 | 51 | - name: Publish tsviz-cli 52 | if: github.ref == 'refs/heads/master' 53 | uses: JS-DevTools/npm-publish@v1 54 | with: 55 | package: 'tsviz-cli/package.json' 56 | token: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /tsviz/src/tsviz.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from "path"; 2 | import * as ts from "typescript"; 3 | import { Module } from "./ts-elements"; 4 | import * as analyser from "./ts-analyser"; 5 | 6 | export interface OutputModule { 7 | name: string; 8 | dependencies: string[]; 9 | } 10 | 11 | function readConfigOptionsFrom(targetPath: string): ts.CompilerOptions { 12 | const configFileName = ts.findConfigFile(targetPath, ts.sys.fileExists); 13 | if (configFileName) { 14 | const configFile = ts.readConfigFile(configFileName, ts.sys.readFile); 15 | return ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configFileName)).options; 16 | } 17 | 18 | console.warn(`Tsconfig file not found in "${targetPath}". Using defaults.`); 19 | 20 | const defaultCompilerOptions: ts.CompilerOptions = { 21 | noEmitOnError: true, 22 | target: ts.ScriptTarget.ES5, 23 | module: ts.ModuleKind.AMD 24 | }; 25 | return defaultCompilerOptions; 26 | } 27 | 28 | export function getModules(targetPath: string): Module[] { 29 | targetPath = resolve(targetPath); 30 | 31 | const fileNames = ts.sys.readDirectory(targetPath); 32 | const options = readConfigOptionsFrom(targetPath); 33 | 34 | // analyse sources 35 | const dtsExtension = ".d.ts"; 36 | const compilerHost = ts.createCompilerHost(options, /*setParentNodes */ true); 37 | const program = ts.createProgram(fileNames, options, compilerHost); 38 | const modules = program.getSourceFiles() 39 | .filter(f => !f.fileName.endsWith(dtsExtension) && (f.fileName.endsWith(".ts") || f.fileName.endsWith(".tsx"))) 40 | .map(sourceFile => analyser.collectInformation(sourceFile, program, compilerHost)); 41 | 42 | return modules; 43 | } 44 | 45 | export function getModulesDependencies(targetPath: string): OutputModule[] { 46 | const modules = getModules(targetPath); 47 | const outputModules: OutputModule[] = []; 48 | modules.sort((a, b) => a.name.localeCompare(b.name)).forEach(module => { 49 | const uniqueDependencies = new Set(); 50 | module.dependencies.forEach(dependency => { 51 | uniqueDependencies.add(dependency.name); 52 | }); 53 | outputModules.push({ 54 | name: module.name, 55 | dependencies: Object.keys(uniqueDependencies).sort() 56 | }); 57 | }); 58 | return outputModules; 59 | } -------------------------------------------------------------------------------- /tsviz-cli/src/uml-builder.ts: -------------------------------------------------------------------------------- 1 | import * as graphviz from "graphviz"; 2 | import { Element, Module, Class, Method, Property, Visibility, Lifetime } from "tsviz"; 3 | import { Collections } from "./collections-extensions"; 4 | 5 | export function buildUml(modules: Module[], outputFilename: string, dependenciesOnly: boolean, svgOutput: boolean) { 6 | const g: graphviz.Graph = graphviz.digraph("G"); 7 | 8 | const fontSizeKey = "fontsize"; 9 | const fontSize = 12; 10 | const fontNameKey = "fontname"; 11 | const fontName = "Verdana"; 12 | 13 | // set diagram default styles 14 | g.set(fontSizeKey, fontSize); 15 | g.set(fontNameKey, fontName); 16 | g.setEdgeAttribut(fontSizeKey, fontSize); 17 | g.setEdgeAttribut(fontNameKey, fontName); 18 | g.setNodeAttribut(fontSizeKey, fontSize); 19 | g.setNodeAttribut(fontNameKey, fontName); 20 | g.setNodeAttribut("shape", "record"); 21 | 22 | modules.forEach(module => { 23 | buildModule(module, g, module.path, 0, dependenciesOnly); 24 | }); 25 | 26 | if (process.platform === "win32") { 27 | const pathVariable = process.env["PATH"] as string; 28 | if (pathVariable.indexOf("Graphviz") === -1) { 29 | console.warn("Could not find Graphviz in PATH."); 30 | } 31 | } 32 | 33 | // Generate a PNG/SVG output 34 | g.output(svgOutput ? "svg" : "png", outputFilename); 35 | } 36 | 37 | function buildModule(module: Module, g: graphviz.Graph, path: string, level: number, dependenciesOnly: boolean) { 38 | const ModulePrefix = "cluster_"; 39 | 40 | const moduleId = getGraphNodeId(path, module.name); 41 | const cluster = g.addCluster("\"" + ModulePrefix + moduleId + "\""); 42 | 43 | cluster.set("label", (module.visibility !== Visibility.Public ? visibilityToString(module.visibility) + " " : "") + module.name); 44 | cluster.set("style", "filled"); 45 | cluster.set("color", "gray" + Math.max(40, (95 - (level * 6)))); 46 | 47 | if (dependenciesOnly) { 48 | Collections.distinct(module.dependencies, d => d.name).forEach(d => { 49 | g.addEdge(module.name, getGraphNodeId("", d.name)); 50 | }); 51 | } else { 52 | const moduleMethods = combineSignatures(module.methods, getMethodSignature); 53 | if (moduleMethods) { 54 | cluster.addNode( 55 | getGraphNodeId(path, module.name), 56 | { 57 | "label": moduleMethods, 58 | "shape": "none" 59 | }); 60 | } 61 | 62 | module.modules.forEach(childModule => { 63 | buildModule(childModule, cluster, moduleId, level + 1, false); 64 | }); 65 | 66 | module.classes.forEach(childClass => { 67 | buildClass(childClass, cluster, moduleId); 68 | }); 69 | } 70 | } 71 | 72 | function buildClass(classDef: Class, g: graphviz.Graph, path: string) { 73 | const methodsSignatures = combineSignatures(classDef.methods, getMethodSignature); 74 | const propertiesSignatures = combineSignatures(classDef.properties, getPropertySignature); 75 | 76 | const classNode = g.addNode( 77 | getGraphNodeId(path, classDef.name), 78 | { 79 | "label": "{" + [ classDef.name, methodsSignatures, propertiesSignatures].filter(e => e.length > 0).join("|") + "}" 80 | }); 81 | 82 | if(classDef.extends) { 83 | // add inheritance arrow 84 | g.addEdge( 85 | classNode, 86 | classDef.extends.parts.reduce((path, name) => getGraphNodeId(path, name), ""), 87 | { "arrowhead": "onormal" }); 88 | } 89 | } 90 | 91 | function combineSignatures(elements: T[], map: (e: T) => string): string { 92 | return elements.filter(e => e.visibility == Visibility.Public) 93 | .map(e => map(e) + "\\l") 94 | .join(""); 95 | } 96 | 97 | function getMethodSignature(method: Method): string { 98 | return [ 99 | visibilityToString(method.visibility), 100 | lifetimeToString(method.lifetime), 101 | getName(method) + "()" 102 | ].join(" "); 103 | } 104 | 105 | function getPropertySignature(property: Property): string { 106 | return [ 107 | visibilityToString(property.visibility), 108 | lifetimeToString(property.lifetime), 109 | [ 110 | (property.hasGetter ? "get" : null), 111 | (property.hasSetter ? "set" : null) 112 | ].filter(v => v !== null).join("/"), 113 | getName(property) 114 | ].join(" "); 115 | } 116 | 117 | function visibilityToString(visibility: Visibility) { 118 | switch(visibility) { 119 | case Visibility.Public: 120 | return "+"; 121 | case Visibility.Protected: 122 | return "~"; 123 | case Visibility.Private: 124 | return "-"; 125 | } 126 | } 127 | 128 | function lifetimeToString(lifetime: Lifetime) { 129 | return lifetime === Lifetime.Static ? "\\" : ""; 130 | } 131 | 132 | function getName(element: Element) { 133 | return element.name; 134 | } 135 | 136 | function getGraphNodeId(path: string, name: string): string { 137 | const result = ((path ? path + "/" : "") + name).replace(/\//g, "|"); 138 | return result; 139 | } 140 | -------------------------------------------------------------------------------- /tsviz/src/ts-elements.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Visibility { 3 | Private, 4 | Public, 5 | Protected 6 | } 7 | 8 | export enum Lifetime { 9 | Static, 10 | Instance 11 | } 12 | 13 | export class QualifiedName { 14 | private nameParts: string[]; 15 | 16 | constructor(nameParts: string[]) { 17 | this.nameParts = nameParts; 18 | } 19 | 20 | public get parts(): string[] { 21 | return this.nameParts; 22 | } 23 | } 24 | 25 | export abstract class Element { 26 | private _documentations = new Array(); 27 | 28 | constructor( 29 | private _name: string, 30 | private _parent?: Element, 31 | private _visibility: Visibility = Visibility.Public, 32 | private _lifetime: Lifetime = Lifetime.Instance) { 33 | if (_parent) { 34 | _parent.addElement(this); 35 | } 36 | } 37 | 38 | public get name(): string { 39 | return this._name; 40 | } 41 | 42 | public get visibility() : Visibility { 43 | return this._visibility; 44 | } 45 | 46 | public get lifetime() : Lifetime { 47 | return this._lifetime; 48 | } 49 | 50 | public get documentations(): Documentation[] { 51 | return this._documentations; 52 | } 53 | 54 | public get parent() : Element | undefined { 55 | return this._parent; 56 | } 57 | 58 | public addElement(element: Element) { 59 | this.getTargetCollection(element).push(element); 60 | } 61 | 62 | protected getTargetCollection(element: Element): Element[] { 63 | if (element instanceof Documentation) { 64 | return this.documentations; 65 | } 66 | throw new Error(element.constructor.name + " not supported in " + this.constructor.name); 67 | } 68 | } 69 | 70 | export class Module extends Element { 71 | private _classes: Class[] = new Array(); 72 | private _modules: Module[] = new Array(); 73 | private _dependencies: ImportedModule[] = new Array(); 74 | private _methods = new Array(); 75 | private _path: string = ""; 76 | 77 | public get classes(): Class[] { 78 | return this._classes; 79 | } 80 | 81 | public get modules(): Module[] { 82 | return this._modules; 83 | } 84 | 85 | public get dependencies(): ImportedModule[] { 86 | return this._dependencies; 87 | } 88 | 89 | public get methods(): Method[] { 90 | return this._methods; 91 | } 92 | 93 | public get path(): string { 94 | return this._path; 95 | } 96 | 97 | public set path(value: string) { 98 | this._path = value; 99 | } 100 | 101 | protected getTargetCollection(element: Element): Element[] { 102 | if (element instanceof Class) { 103 | return this.classes; 104 | } else if (element instanceof Module) { 105 | return this.modules; 106 | } else if (element instanceof ImportedModule) { 107 | return this.dependencies; 108 | } else if (element instanceof Method) { 109 | return this.methods; 110 | } 111 | return super.getTargetCollection(element); 112 | } 113 | } 114 | 115 | export class Class extends Element { 116 | private _methods = new Array(); 117 | private _properties : { [name: string ] : Property } = {}; 118 | private _extends: QualifiedName | null = null; 119 | 120 | public get methods(): Method[] { 121 | return this._methods; 122 | } 123 | 124 | public get properties(): Property[] { 125 | var result = new Array(); 126 | for (const prop of Object.keys(this._properties)) { 127 | result.push(this._properties[prop]); 128 | } 129 | return result; 130 | } 131 | 132 | protected getTargetCollection(element: Element): Element[] { 133 | if (element instanceof Method) { 134 | return this.methods; 135 | } 136 | return super.getTargetCollection(element); 137 | } 138 | 139 | public addElement(element: Element) { 140 | if(element instanceof Property) { 141 | const property = element as Property; 142 | const existingProperty = this._properties[property.name]; 143 | if (existingProperty) { 144 | existingProperty.hasGetter = existingProperty.hasGetter || property.hasGetter; 145 | existingProperty.hasSetter = existingProperty.hasSetter || property.hasSetter; 146 | } else { 147 | this._properties[property.name] = property; 148 | } 149 | return; 150 | } 151 | super.addElement(element); 152 | } 153 | 154 | public get extends(): QualifiedName { 155 | return this._extends!; 156 | } 157 | 158 | public set extends(extendingClass: QualifiedName) { 159 | this._extends = extendingClass; 160 | } 161 | } 162 | 163 | export class Method extends Element { 164 | 165 | } 166 | 167 | export class ImportedModule extends Element { 168 | 169 | constructor(moduleName: string, private readonly _path: string, parent: Element) { 170 | super(moduleName, parent); 171 | } 172 | 173 | public get path(): string { 174 | return this._path; 175 | } 176 | } 177 | 178 | export class Property extends Element { 179 | private _hasGetter = false; 180 | private _hasSetter = false; 181 | 182 | public get hasGetter(): boolean { 183 | return this._hasGetter; 184 | } 185 | 186 | public set hasGetter(value: boolean) { 187 | this._hasGetter = value; 188 | } 189 | 190 | public get hasSetter(): boolean { 191 | return this._hasSetter; 192 | } 193 | 194 | public set hasSetter(value: boolean) { 195 | this._hasSetter = value; 196 | } 197 | } 198 | 199 | export class Documentation extends Element { 200 | constructor(parent: Element, private readonly _text: string, private readonly _tags: string[]) { 201 | super("", parent); 202 | } 203 | 204 | public get text(): string { 205 | return this._text; 206 | } 207 | 208 | public get tags(): string[] { 209 | return this._tags; 210 | } 211 | } -------------------------------------------------------------------------------- /tsviz/src/ts-analyser.ts: -------------------------------------------------------------------------------- 1 | import * as ts from "typescript"; 2 | import { basename } from "path"; 3 | import { Element, Module, Class, Method, ImportedModule, Property, Visibility, QualifiedName, Lifetime, Documentation } from "./ts-elements"; 4 | import { Collections } from "./collections-extensions"; 5 | 6 | interface IJsDocContainer { 7 | jsDoc: ts.JSDoc[]; 8 | } 9 | 10 | export function collectInformation(sourceFile: ts.SourceFile, program: ts.Program, host: ts.CompilerHost): Module { 11 | const typeChecker = program.getTypeChecker(); 12 | const compilerOptions = program.getCompilerOptions(); 13 | 14 | const filename = getFilenameWithoutExtension(sourceFile); 15 | const moduleName = basename(filename); // get module filename without directory 16 | 17 | const module = new Module(moduleName); 18 | module.path = sourceFile.fileName; 19 | 20 | analyseNode(sourceFile, module); 21 | 22 | function analyseNode(node: ts.Node, currentElement: Element) { 23 | let childElement: Element | null = null; 24 | let skipChildren = false; 25 | 26 | switch (node.kind) { 27 | case ts.SyntaxKind.ModuleDeclaration: 28 | const moduleDeclaration = node as ts.ModuleDeclaration; 29 | childElement = new Module(moduleDeclaration.name.text, currentElement, getVisibility(node)); 30 | break; 31 | 32 | case ts.SyntaxKind.ImportEqualsDeclaration: { 33 | const importEqualDeclaration = node as ts.ImportEqualsDeclaration; 34 | const moduleName = importEqualDeclaration.name.text; 35 | const moduleLocation = resolveModuleLocation(moduleName, sourceFile.fileName, compilerOptions, host); 36 | childElement = new ImportedModule(moduleName, moduleLocation, currentElement); 37 | break; 38 | } 39 | 40 | case ts.SyntaxKind.ImportDeclaration: { 41 | const importDeclaration = node as ts.ImportDeclaration; 42 | const moduleName = (importDeclaration.moduleSpecifier as ts.StringLiteral).text; 43 | const moduleLocation = resolveModuleLocation(moduleName, sourceFile.fileName, compilerOptions, host); 44 | childElement = new ImportedModule(moduleName, moduleLocation, currentElement); 45 | break; 46 | } 47 | 48 | case ts.SyntaxKind.ClassDeclaration: 49 | const classDeclaration = node as ts.ClassDeclaration; 50 | const classDef = new Class(classDeclaration.name!.text, currentElement, getVisibility(node)); 51 | if (classDeclaration.heritageClauses) { 52 | const extendsClause = Collections.firstOrDefault(classDeclaration.heritageClauses, c => c.token === ts.SyntaxKind.ExtendsKeyword); 53 | if (extendsClause && extendsClause.types.length > 0) { 54 | classDef.extends = getFullyQualifiedName(extendsClause.types[0], typeChecker); 55 | } 56 | } 57 | childElement = classDef; 58 | break; 59 | 60 | case ts.SyntaxKind.GetAccessor: 61 | case ts.SyntaxKind.SetAccessor: 62 | case ts.SyntaxKind.PropertyDeclaration: 63 | const propertyDeclaration = node as ts.PropertyDeclaration; 64 | const property = new Property((propertyDeclaration.name as ts.Identifier).text, currentElement, getVisibility(node), getLifetime(node)); 65 | switch (node.kind) { 66 | case ts.SyntaxKind.GetAccessor: 67 | property.hasGetter = true; 68 | break; 69 | case ts.SyntaxKind.SetAccessor: 70 | property.hasSetter = true; 71 | } 72 | childElement = property; 73 | skipChildren = true; 74 | break; 75 | 76 | case ts.SyntaxKind.MethodDeclaration: 77 | case ts.SyntaxKind.FunctionDeclaration: 78 | const functionDeclaration = node as ts.FunctionDeclaration | ts.MethodDeclaration; 79 | const nameText = (functionDeclaration.name as ts.Identifier)?.text ?? ""; 80 | childElement = new Method(nameText, currentElement, getVisibility(node), getLifetime(node)); 81 | skipChildren = true; 82 | break; 83 | 84 | case ts.SyntaxKind.SourceFile: 85 | break; 86 | 87 | default: 88 | // ignore stuff like interfaces, ... 89 | return; 90 | } 91 | 92 | if (childElement || currentElement) { 93 | const commentText = getDocsText(node); 94 | const tags = getDocsTags(node); 95 | if (commentText || tags.length > 0) { 96 | new Documentation(childElement || currentElement, commentText, tags); 97 | } 98 | } 99 | 100 | if (skipChildren) { 101 | return; // no need to inspect children 102 | } 103 | 104 | node.forEachChild((node) => analyseNode(node, childElement || currentElement)); 105 | } 106 | 107 | return module; 108 | } 109 | 110 | function getFullyQualifiedName(expression: ts.ExpressionWithTypeArguments, typeChecker: ts.TypeChecker) { 111 | const symbol = typeChecker.getSymbolAtLocation(expression.expression); 112 | if (symbol) { 113 | const nameParts = typeChecker.getFullyQualifiedName(symbol).split("."); 114 | if (symbol.declarations && symbol.declarations.length > 0 && symbol.declarations[0].kind === ts.SyntaxKind.ImportSpecifier) { 115 | // symbol comes from an imported module 116 | // get the module name from the import declaration 117 | const importSpecifier = symbol.declarations[0]; 118 | const moduleName = ((importSpecifier.parent.parent.parent as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text; 119 | nameParts.unshift(moduleName); 120 | } else { 121 | if (nameParts.length > 0 && nameParts[0].indexOf("\"") === 0) { 122 | // if first name part has " then it should be a module name 123 | const moduleName = nameParts[0].replace(/\"/g, ""); // remove " from module name 124 | nameParts[0] = moduleName; 125 | } 126 | } 127 | return new QualifiedName(nameParts); 128 | } 129 | console.warn("Unable to resolve type: '" + expression.getText() + "'"); 130 | return new QualifiedName(["unknown?"]); 131 | } 132 | 133 | function getVisibility(node: ts.Node) { 134 | if (node.modifiers) { 135 | const modifiers = node.modifiers.map(m => m.kind); 136 | if (modifiers.includes(ts.SyntaxKind.ProtectedKeyword)) { 137 | return Visibility.Protected; 138 | } else if (modifiers.includes(ts.SyntaxKind.PrivateKeyword)) { 139 | return Visibility.Private; 140 | } else if (modifiers.includes(ts.SyntaxKind.PublicKeyword)) { 141 | return Visibility.Public; 142 | } else if (modifiers.includes(ts.SyntaxKind.ExportKeyword)) { 143 | return Visibility.Public; 144 | } 145 | } 146 | switch (node.parent.kind) { 147 | case ts.SyntaxKind.ClassDeclaration: 148 | return Visibility.Public; 149 | case ts.SyntaxKind.ModuleDeclaration: 150 | return Visibility.Private; 151 | } 152 | return Visibility.Private; 153 | } 154 | 155 | function getLifetime(node: ts.Node) { 156 | if (node.modifiers) { 157 | const modifiers = node.modifiers.map(m => m.kind); 158 | if (modifiers.includes(ts.SyntaxKind.StaticKeyword)) { 159 | return Lifetime.Static; 160 | } 161 | } 162 | return Lifetime.Instance; 163 | } 164 | 165 | function getFilenameWithoutExtension(file: ts.SourceFile) { 166 | const filename = file.fileName; 167 | return filename.substr(0, filename.lastIndexOf(".")); // filename without extension 168 | } 169 | 170 | function getDocsText(node: ts.Node): string { 171 | const jsDoc = (node as unknown as IJsDocContainer).jsDoc; 172 | if (Array.isArray(jsDoc) && jsDoc.length > 0) { 173 | return jsDoc.map(d => d.comment instanceof Array ? d.comment.map(c => c.text) : d.comment).join("\n"); 174 | } 175 | return null; 176 | } 177 | 178 | function getDocsTags(node: ts.Node): string[] { 179 | const jsDoc = (node as unknown as IJsDocContainer).jsDoc; 180 | if (Array.isArray(jsDoc) && jsDoc.length > 0) { 181 | return jsDoc.flatMap(d => d.tags?.map(t => t.tagName.getText())); 182 | } 183 | return []; 184 | } 185 | 186 | function resolveModuleLocation(moduleName: string, sourceFilename: string, compilerOptions: ts.CompilerOptions, host: ts.CompilerHost) { 187 | const resolvedModuleName = ts.resolveModuleName(moduleName, sourceFilename, compilerOptions, host); 188 | return resolvedModuleName.resolvedModule?.resolvedFileName ?? ""; 189 | } 190 | --------------------------------------------------------------------------------