diff --git a/.gitignore b/.gitignore index 3c3629e..b0b3e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +graphing/temp.png diff --git a/README.md b/README.md index 07fda58..15de2e7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ Suggestions or PRs for how to improve this CLI are very welcome 🙇 ### Thank you +- [Matteo Abrate](https://observablehq.com/@nitaku/tangled-tree-visualization-ii) for the _tangled tree visualization_ +- [Mike Bostock](https://observablehq.com/@d3/arc-diagram) for the _arc diagram_ +- [GraphViz](https://graphviz.org/), [node-graphviz](https://github.com/glejeune/node-graphviz), and [d3-graphviz](https://github.com/magjac/d3-graphviz) for the simple graph +- [Mermaid-JS](https://github.com/mermaid-js/mermaid) for a way to create a graph - [Tutorial](https://convincedcoder.com/2019/01/19/Processing-TypeScript-using-TypeScript/) and code for processing TypeScript (AST) - [Tutorial](https://developer.okta.com/blog/2019/06/18/command-line-app-with-nodejs) for creating a *CLI* - [TS-Call-Graph](https://github.com/Deskbot/TS-Call-Graph) for inspiration diff --git a/arc.ts b/arc.ts new file mode 100644 index 0000000..17cc31d --- /dev/null +++ b/arc.ts @@ -0,0 +1,33 @@ +/** + * Convert the call map to format D3 wants + * @param calledFunctions + */ +export function convertForArc(allFunctions: string[], calledFunctions: Map) { + + const nodes = []; + const links = []; + + allFunctions.forEach((func: string) => { + nodes.push({ + id: func, + group: 1, // later make this tied to an integer representing the file it came from + }) + }); + + calledFunctions.forEach((childArr, key) => { + childArr.forEach((child) => { + links.push({ + source: key, + target: child, + value: 1, // indicates 'strength' of connection -- leave as 1 for now + }); + }); + }); + + const all = { + nodes: nodes, + links: links, + }; + + return all; +} diff --git a/bin/arc.js b/bin/arc.js new file mode 100644 index 0000000..2612f5c --- /dev/null +++ b/bin/arc.js @@ -0,0 +1,32 @@ +"use strict"; +exports.__esModule = true; +exports.convertForArc = void 0; +/** + * Convert the call map to format D3 wants + * @param calledFunctions + */ +function convertForArc(allFunctions, calledFunctions) { + var nodes = []; + var links = []; + allFunctions.forEach(function (func) { + nodes.push({ + id: func, + group: 1 + }); + }); + calledFunctions.forEach(function (childArr, key) { + childArr.forEach(function (child) { + links.push({ + source: key, + target: child, + value: 1 + }); + }); + }); + var all = { + nodes: nodes, + links: links + }; + return all; +} +exports.convertForArc = convertForArc; diff --git a/bin/cascade.js b/bin/cascade.js new file mode 100644 index 0000000..a834438 --- /dev/null +++ b/bin/cascade.js @@ -0,0 +1,75 @@ +"use strict"; +var __spreadArrays = (this && this.__spreadArrays) || function () { + for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length; + for (var r = Array(s), k = 0, i = 0; i < il; i++) + for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++) + r[k] = a[j]; + return r; +}; +exports.__esModule = true; +exports.convertForCascade = void 0; +// Some globals +var myMap; +var final = []; +var calledAlready = []; // to keep track of what has been called; prevent reculsion! +/** + * Take a parent function and return all children in proper format + * @param parent + */ +function generateFromOneParent(parent) { + var newElements = []; + if (myMap.has(parent)) { + var children = myMap.get(parent); + children.forEach(function (element) { + if (!calledAlready.includes(element)) { // PREVENT RECURSION ! + newElements.push({ id: element, parents: [parent] }); + } + }); + } + return newElements; +} +/** + * Recursive function to generate each subsequent level + * @param parents + * @param stackDepth - maximum depth of calls to trace + */ +function generateNextLevel(parents, stackDepth) { + calledAlready.push.apply(calledAlready, parents); + if (stackDepth === 0) { + return; + } + // Generate the NodeForGraphing[] for this 'level' + var thisLevel = []; + parents.forEach(function (parent) { + thisLevel.push.apply(thisLevel, generateFromOneParent(parent)); + }); + if (thisLevel.length) { + final.push(__spreadArrays(thisLevel)); + } + // start building the next `level` + var nextLevel = []; + parents.forEach(function (parent) { + if (myMap.has(parent)) { + nextLevel.push.apply(nextLevel, myMap.get(parent)); + } + }); + if (nextLevel.length) { + generateNextLevel(nextLevel, stackDepth - 1); + } +} +/** + * Convert the call map to format D3 wants + * @param calledFunctions + * @param startFunction -- string of the function name we want to use as start of call graph + */ +function convertForCascade(calledFunctions, startFunction) { + myMap = calledFunctions; + final = []; + calledAlready = []; + // 1st case -- handle manually + final.push([{ id: startFunction }]); + // all next cases generate automatically + generateNextLevel([startFunction], 10); + return final; +} +exports.convertForCascade = convertForCascade; diff --git a/bin/convert.js b/bin/convert.js new file mode 100644 index 0000000..cdc5ff9 --- /dev/null +++ b/bin/convert.js @@ -0,0 +1,79 @@ +"use strict"; +var __spreadArrays = (this && this.__spreadArrays) || function () { + for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length; + for (var r = Array(s), k = 0, i = 0; i < il; i++) + for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++) + r[k] = a[j]; + return r; +}; +exports.__esModule = true; +exports.convertForCascade = void 0; +// Some globals +var myMap; +var final = []; +var calledAlready = []; // to keep track of what has been called; prevent reculsion! +/** + * Take a parent function and return all children in proper format + * @param parent + */ +function generateFromOneParent(parent) { + var newElements = []; + if (myMap.has(parent)) { + var children = myMap.get(parent); + children.forEach(function (element) { + if (!calledAlready.includes(element)) { // PREVENT RECURSION ! + newElements.push({ id: element, parents: [parent] }); + } + }); + } + return newElements; +} +/** + * Recursive function to generate each subsequent level + * @param parents + * @param stackDepth - maximum depth of calls to trace + */ +function generateNextLevel(parents, stackDepth) { + calledAlready.push.apply(calledAlready, parents); + if (stackDepth === 0) { + return; + } + // Generate the NodeForGraphing[] for this 'level' + var thisLevel = []; + parents.forEach(function (parent) { + thisLevel.push.apply(thisLevel, generateFromOneParent(parent)); + }); + if (thisLevel.length) { + final.push(__spreadArrays(thisLevel)); + } + // start building the next `level` + var nextLevel = []; + parents.forEach(function (parent) { + if (myMap.has(parent)) { + nextLevel.push.apply(nextLevel, myMap.get(parent)); + } + }); + if (nextLevel.length) { + generateNextLevel(nextLevel, stackDepth - 1); + } +} +/** + * Convert the call map to format D3 wants + * @param calledFunctions + */ +function convertForCascade(calledFunctions) { + myMap = calledFunctions; + final = []; + calledAlready = []; + // 1st case -- handle manually + final.push([{ id: 'proceed' }]); + // all next cases generate automatically + generateNextLevel(['proceed'], 10); + console.log('======================================'); + console.log(final); + console.log('--------------------------------------'); + console.log(JSON.stringify(final)); + console.log(''); + return final; +} +exports.convertForCascade = convertForCascade; diff --git a/bin/extract.js b/bin/extract.js index 72bb09c..1f0f73d 100644 --- a/bin/extract.js +++ b/bin/extract.js @@ -3,6 +3,7 @@ exports.__esModule = true; exports.processFiles = void 0; var ts = require("typescript"); var fs = require('fs'); +var _a = require('kleur'), green = _a.green, red = _a.red; var functionsToIgnore = []; // optionally ['require', 'parseInt', 'exec', 'reject', 'resolve']; var allFunctions = []; var calledFunctions = new Map(); @@ -83,18 +84,29 @@ function processFiles(filenames) { // then do recursion for each filenames.forEach(function (filename) { var rootNodes = []; - var codeAsString = fs.readFileSync(filename).toString(); - var sourceFile = ts.createSourceFile(filename, codeAsString, ts.ScriptTarget.Latest); - sourceFile.forEachChild(function (child) { - rootNodes.push(child); - }); - rootNodes.forEach(function (node) { - currentFunction = undefined; - extractFunctionCalls(node, sourceFile, 1); - }); + var codeAsString; + var skipFile = false; + try { + codeAsString = fs.readFileSync(filename).toString(); + } + catch (err) { + console.log('File', green(filename), red('not found!'), ' - skipping'); + skipFile = true; + } + if (!skipFile) { + var sourceFile_1 = ts.createSourceFile(filename, codeAsString, ts.ScriptTarget.Latest); + sourceFile_1.forEachChild(function (child) { + rootNodes.push(child); + }); + rootNodes.forEach(function (node) { + currentFunction = undefined; + extractFunctionCalls(node, sourceFile_1, 1); + }); + } }); calledFunctions["delete"](undefined); // Output + console.log(''); console.log('======================================'); console.log(allFunctions); console.log('--------------------------------------'); @@ -108,7 +120,15 @@ function processFiles(filenames) { calledFunctions.set(key, value.filter(function (calledFunc) { return allFunctions.includes(calledFunc); })); + if (!calledFunctions.get(key).length) { + calledFunctions["delete"](key); + } }); console.log(calledFunctions); + var functions = { + all: allFunctions, + called: calledFunctions + }; + return functions; } exports.processFiles = processFiles; diff --git a/bin/graphviz.js b/bin/graphviz.js new file mode 100644 index 0000000..32cfd7d --- /dev/null +++ b/bin/graphviz.js @@ -0,0 +1,18 @@ +"use strict"; +exports.__esModule = true; +exports.convertForGraphViz = void 0; +var graphviz = require('graphviz'); +function convertForGraphViz(functionMap) { + var g = graphviz.digraph("G"); + g.set("rankdir", "LR"); + functionMap.forEach(function (value, key) { + g.addNode(key); + }); + functionMap.forEach(function (childArr, key) { + childArr.forEach(function (child) { + g.addEdge(key, child); + }); + }); + return g.to_dot(); +} +exports.convertForGraphViz = convertForGraphViz; diff --git a/bin/helper.js b/bin/helper.js new file mode 100644 index 0000000..1fe16ce --- /dev/null +++ b/bin/helper.js @@ -0,0 +1,35 @@ +"use strict"; +exports.__esModule = true; +exports.showServerRunning = exports.showHelpMessage = void 0; +var _a = require('kleur'), green = _a.green, bold = _a.bold; +/** + * Shown when user runs `tcg` without arguments + */ +function showHelpMessage() { + console.log(green('╭───────────────────────────╮')); + console.log(green('│ │')); + console.log(green('│ ') + bold('Typescript Node Graph') + green(' │')); + console.log(green('│ │')); + console.log(green('╰───────────────────────────╯')); + console.log('Please provide a list of input files and/or folders'); + console.log('e.g. `' + + green('myFile.ts') + '`, `' + + green('*') + '`, `' + + green('**/*') + '`, `' + + green('myFolder/*') + '`'); + console.log('or any combination of the above, like `' + green('myFile.ts myFolder/*') + '`'); +} +exports.showHelpMessage = showHelpMessage; +/** + * Console log that server is running + * @param filePath + */ +function showServerRunning(filePath) { + // Helpful message + console.log(green('╭───────────────────────────╮')); + console.log(green('│ ') + 'Graph visible @ ' + green(' │')); + console.log(green('│ ') + filePath + green(' │')); + console.log(green('│ ') + 'Ctrl + C to quit ' + green(' │')); + console.log(green('╰───────────────────────────╯')); +} +exports.showServerRunning = showServerRunning; diff --git a/bin/index.js b/bin/index.js index 83b988a..765fa04 100755 --- a/bin/index.js +++ b/bin/index.js @@ -1,8 +1,13 @@ #!/usr/bin/env node "use strict"; exports.__esModule = true; +var open = require("open"); var extract_1 = require("./extract"); -var _a = require('kleur'), green = _a.green, bold = _a.bold; +var arc_1 = require("./arc"); +var cascade_1 = require("./cascade"); +var graphviz_1 = require("./graphviz"); +var mermaid_1 = require("./mermaid"); +var helper_1 = require("./helper"); var myArgs = process.argv.slice(2); var onlyTypescript = myArgs.filter(function (file) { return file.endsWith('ts'); }); var withoutNodeModules = onlyTypescript.filter(function (file) { return !file.includes('node_modules'); }); @@ -23,25 +28,39 @@ if (withoutNodeModules.length) { }); } else { - showHelpMessage(); -} -function showHelpMessage() { - console.log(green('╭───────────────────────────╮')); - console.log(green('│ │')); - console.log(green('│ ') + bold('Typescript Node Graph') + green(' │')); - console.log(green('│ │')); - console.log(green('╰───────────────────────────╯')); - console.log('Please provide a list of input files and/or folders'); - console.log('e.g. `' - + green('myFile.ts') + '`, `' - + green('*') + '`, `' - + green('**/*') + '`, `' - + green('myFolder/*') + '`'); - console.log('or any combination of the above, like `' + green('myFile.ts myFolder/*') + '`'); + helper_1.showHelpMessage(); } /** * If user confirms the files they want to analyze, proceed */ function proceed() { - extract_1.processFiles(withoutNodeModules); + var functions = extract_1.processFiles(withoutNodeModules); + startServer(functions.all, functions.called); +} +/** + * Start Express server with static files and API endpoints + * @param functionMap + */ +function startServer(allFunctions, functionMap) { + var express = require('express'); + var app = express(); + var path = require('path'); + app.use(express.static(path.join(__dirname, '..', 'graphing'))); + app.use('/arc', express.static(path.join(__dirname, '..', 'graphing/arc'))); + app.use('/cascade', express.static(path.join(__dirname, '..', 'graphing/cascade'))); + app.use('/graphviz', express.static(path.join(__dirname, '..', 'graphing/graphviz'))); + app.use('/mermaid', express.static(path.join(__dirname, '..', 'graphing/mermaid'))); + app.use('/vendor', express.static(path.join(__dirname, '..', 'graphing/vendor'))); + // API endpoints + app.use('/all', function (req, res) { res.json(allFunctions); }); + app.get('/arcAPI', function (req, res) { res.json(arc_1.convertForArc(allFunctions, functionMap)); }); + app.get('/cascadeAPI/:startFunc', function (req, res) { + res.json(cascade_1.convertForCascade(functionMap, req.params.startFunc)); + }); + app.get('/graphvizAPI', function (req, res) { res.json(graphviz_1.convertForGraphViz(functionMap)); }); + app.get('/mermaidAPI', function (req, res) { res.json(mermaid_1.convertForMermaid(functionMap)); }); + app.listen(3000); + var filePath = 'http://localhost:3000'; + helper_1.showServerRunning(filePath); + open(filePath); } diff --git a/bin/mermaid.js b/bin/mermaid.js new file mode 100644 index 0000000..223deca --- /dev/null +++ b/bin/mermaid.js @@ -0,0 +1,13 @@ +"use strict"; +exports.__esModule = true; +exports.convertForMermaid = void 0; +function convertForMermaid(functionMap) { + var connections = []; + functionMap.forEach(function (childArr, key) { + childArr.forEach(function (child) { + connections.push(key + '(' + key + ')' + ' --> ' + child + '(' + child + ')'); + }); + }); + return connections; +} +exports.convertForMermaid = convertForMermaid; diff --git a/cascade.ts b/cascade.ts new file mode 100644 index 0000000..7c9b518 --- /dev/null +++ b/cascade.ts @@ -0,0 +1,85 @@ +interface NodeForGraphing { + id: string, + parents: string[], +} + +// Some globals +let myMap: Map; +let final = []; +let calledAlready = []; // to keep track of what has been called; prevent reculsion! + +/** + * Take a parent function and return all children in proper format + * @param parent + */ +function generateFromOneParent(parent: string): NodeForGraphing[] { + const newElements = []; + + if (myMap.has(parent)) { + const children = myMap.get(parent); + + children.forEach((element) => { + if (!calledAlready.includes(element)) { // PREVENT RECURSION ! + newElements.push({ id: element, parents: [parent] }); + } + }); + } + + return newElements; +} + +/** + * Recursive function to generate each subsequent level + * @param parents + * @param stackDepth - maximum depth of calls to trace + */ +function generateNextLevel(parents: string[], stackDepth: number): void { + + calledAlready.push(...parents); + + if (stackDepth === 0) { + return; + } + + // Generate the NodeForGraphing[] for this 'level' + let thisLevel = []; + + parents.forEach(parent => { + thisLevel.push(...generateFromOneParent(parent)); + }); + + if (thisLevel.length) { + final.push([...thisLevel]); + } + + // start building the next `level` + let nextLevel = []; + + parents.forEach(parent => { + if (myMap.has(parent)) { + nextLevel.push(...myMap.get(parent)); + } + }); + + if (nextLevel.length) { + generateNextLevel(nextLevel, stackDepth - 1); + } +} + +/** + * Convert the call map to format D3 wants + * @param calledFunctions + * @param startFunction -- string of the function name we want to use as start of call graph + */ +export function convertForCascade(calledFunctions: Map, startFunction: string) { + myMap = calledFunctions; + final = []; + calledAlready = []; + + // 1st case -- handle manually + final.push([{ id: startFunction }]); + // all next cases generate automatically + generateNextLevel([startFunction], 10); + + return final; +} diff --git a/extract.ts b/extract.ts index 9a4a05e..04d6415 100644 --- a/extract.ts +++ b/extract.ts @@ -1,6 +1,8 @@ import * as ts from "typescript"; const fs = require('fs'); +const { green, red } = require('kleur'); + const functionsToIgnore: string[] = []; // optionally ['require', 'parseInt', 'exec', 'reject', 'resolve']; const allFunctions: string[] = []; @@ -96,24 +98,36 @@ export function processFiles(filenames: string[]) { const rootNodes: ts.Node[] = []; - const codeAsString: string = fs.readFileSync(filename).toString(); + let codeAsString: string; - const sourceFile: ts.SourceFile = ts.createSourceFile(filename, codeAsString, ts.ScriptTarget.Latest); + let skipFile: boolean = false; - sourceFile.forEachChild((child: ts.Node) => { - rootNodes.push(child) - }); + try { + codeAsString = fs.readFileSync(filename).toString(); + } catch (err) { + console.log('File', green(filename), red('not found!'), ' - skipping'); + skipFile = true; + } - rootNodes.forEach((node: ts.Node) => { - currentFunction = undefined; - extractFunctionCalls(node, sourceFile, 1); - }); + if (!skipFile) { + const sourceFile: ts.SourceFile = ts.createSourceFile(filename, codeAsString, ts.ScriptTarget.Latest); + + sourceFile.forEachChild((child: ts.Node) => { + rootNodes.push(child) + }); + + rootNodes.forEach((node: ts.Node) => { + currentFunction = undefined; + extractFunctionCalls(node, sourceFile, 1); + }); + } }); calledFunctions.delete(undefined); // Output + console.log(''); console.log('======================================'); console.log(allFunctions); console.log('--------------------------------------'); @@ -128,8 +142,17 @@ export function processFiles(filenames: string[]) { calledFunctions.set(key, value.filter((calledFunc: string) => { return allFunctions.includes(calledFunc); })); + if (!calledFunctions.get(key).length) { + calledFunctions.delete(key); + } }); console.log(calledFunctions); + const functions = { + all: allFunctions, + called: calledFunctions, + } + + return functions; } diff --git a/graphing/arc/arc.js b/graphing/arc/arc.js new file mode 100644 index 0000000..50e5c89 --- /dev/null +++ b/graphing/arc/arc.js @@ -0,0 +1,209 @@ +// https://observablehq.com/@d3/arc-diagram@265 +export default function define(runtime, observer) { + const main = runtime.module(); + const fileAttachments = new Map([["arcData.json", new URL("http://localhost:3000/arcAPI", import.meta.url)]]); + main.builtin("FileAttachment", runtime.fileAttachments(name => fileAttachments.get(name))); + main.variable(observer()).define(["md"], function (md) { + return ( + md`## Arc Diagram` + ) + }); + main.variable(observer("viewof order")).define("viewof order", ["d3", "html"], function (d3, html) { + const options = [ + { name: "Order by name", value: (a, b) => d3.ascending(a.id, b.id) }, + { name: "Order by group", value: (a, b) => a.group - b.group || d3.ascending(a.id, b.id) }, + { name: "Order by degree", value: (a, b) => d3.sum(b.sourceLinks, l => l.value) + d3.sum(b.targetLinks, l => l.value) - d3.sum(a.sourceLinks, l => l.value) - d3.sum(a.targetLinks, l => l.value) || d3.ascending(a.id, b.id) } + ]; + const form = html`