UNPKG

@oselvar/c4

Version:

Test helpers for Cloudflare Workers

275 lines (270 loc) 8.94 kB
#!/usr/bin/env node // src/cli/index.ts import { existsSync } from "node:fs"; import { Command, InvalidArgumentError } from "commander"; import { z } from "zod"; // src/workflows/cloudflare/parseCloudflareWorkflowSource.ts import { Node, Project } from "ts-morph"; function parseCloudflareWorkflowSource(filePath) { const project = new Project(); const sourceFile = project.addSourceFileAtPath(filePath); return parse(sourceFile); } function parse(sourceFile) { const bodyNodes = []; function push(node) { bodyNodes.push(node); } function pop() { const top = bodyNodes.pop(); if (!top) { throw new Error("No body node to pop"); } return top; } push({ type: "root", body: [] }); descend(sourceFile, false); const rootNode = pop(); return rootNode; function descend(node, inStep) { const bodyNode = bodyNodes[bodyNodes.length - 1]; if (isStepCall(node)) { const stepNode = { type: "step", label: getStepLabel(node) }; bodyNode.body.push(stepNode); node.forEachChild((child) => descend(child, true)); } else if (Node.isIfStatement(node) && !inStep) { const condition = node.getExpression().getText(); const ifNode = { type: "if", condition }; bodyNode.body.push(ifNode); const thenStatement = node.getThenStatement(); if (thenStatement) { const thenNode = { type: "then", body: [] }; ifNode.thenBranch = thenNode; push(thenNode); descend(thenStatement, inStep); pop(); } const elseStatement = node.getElseStatement(); if (elseStatement) { const elseNode = { type: "else", body: [] }; ifNode.elseBranch = elseNode; push(elseNode); descend(elseStatement, inStep); pop(); } } else if (isPromiseAllCall(node) && !inStep) { const comment = node.getAncestors().map( (ancestor) => ancestor.getLeadingCommentRanges().flatMap((range) => range.getText()) ).flat()[0]; const label = comment ? comment.replace(/^\/\//, "").trim() : "parallel"; const parallelNode = { type: "parallel", label, body: [] }; bodyNode.body.push(parallelNode); push(parallelNode); descend(node.getArguments()[0], inStep); pop(); } else if (Node.isForStatement(node) && !inStep) { const forNode = { type: "loop", label: "FOR", body: [] }; bodyNode.body.push(forNode); push(forNode); node.forEachChild((child) => descend(child, inStep)); pop(); } else if (Node.isForOfStatement(node) && !inStep) { const comment = node.getLeadingCommentRanges().map((range) => range.getText())[0]; const label = comment ? comment.replace(/^\/\//, "").trim() : `foreach ${node.getExpression().getText()}`; const forNode = { type: "loop", label, body: [] }; bodyNode.body.push(forNode); push(forNode); node.forEachChild((child) => descend(child, inStep)); pop(); } else { node.forEachChild((child) => descend(child, inStep)); } } } function isStepCall(node) { if (!Node.isCallExpression(node)) { return false; } const expression = node.getExpression(); return Node.isPropertyAccessExpression(expression) && expression.getName() === "do" && expression.getExpression().getText() === "step"; } function isPromiseAllCall(node) { if (!Node.isCallExpression(node)) { return false; } const expression = node.getExpression(); return Node.isPropertyAccessExpression(expression) && expression.getName() === "all" && expression.getExpression().getText() === "Promise"; } function getStepLabel(node) { const args = node.getArguments(); if (args.length > 0 && Node.isStringLiteral(args[0])) { return args[0].getLiteralText(); } return `Step ${node.getText()}`; } // src/workflows/workflowToMermaid.ts function workflowToMermaid(rootNode) { let idCounter = 0; const getId = () => `node${idCounter++}`; const lines = ["flowchart TD"]; function visitBody(nodes, depth) { const indent = " ".repeat(depth); let result = null; for (const node of nodes) { const current = visit(node, depth); if (result === null) { result = current; } else { for (const exit of result.exits) { lines.push( `${indent}${exit.id} --> ${exit.type === "else" ? "|no| " : ""}${current.entry}` ); } result = { entry: result.entry, exits: current.exits }; } } return result || { entry: "", exits: [] }; } function visit(node, depth) { const indent = " ".repeat(depth); switch (node.type) { case "root": { return visitBody(node.body, depth); } case "step": { const id = getId(); lines.push(`${indent}${id}[${node.label}]`); return { entry: id, exits: [{ id, type: "step" }] }; } case "if": { const id = getId(); lines.push(`${indent}${id}{${node.condition}}`); const branchExits = []; if (node.thenBranch) { const thenRes = visit(node.thenBranch, depth); lines.push(`${indent}${id} --> |yes| ${thenRes.entry}`); branchExits.push(...thenRes.exits); } if (node.elseBranch) { const elseRes = visit(node.elseBranch, depth); if (elseRes.entry) { lines.push(`${indent}${id} --> |no| ${elseRes.entry}`); } branchExits.push(...elseRes.exits); } if (node.thenBranch && !node.elseBranch) { branchExits.push({ id, type: "else" }); } if (branchExits.length === 0) { branchExits.push({ id, type: "then" }); } return { entry: id, exits: branchExits }; } case "then": { return visitBody(node.body, depth); } case "else": { return visitBody(node.body, depth); } case "loop": { const sgId = getId(); lines.push(`${indent}subgraph ${sgId} [${node.label}]`); const res = visitBody(node.body, depth + 1); lines.push(`${indent}end`); lines.push(`${indent}class ${sgId} loop`); return res; } case "parallel": { const sgId = getId(); lines.push(`${indent}subgraph ${sgId} [${node.label}]`); const res = visitBody(node.body, depth + 1); lines.push(`${indent}end`); lines.push(`${indent}class ${sgId} parallel`); return res; } default: { return { entry: "", exits: [] }; } } } visit(rootNode, 1); lines.push(" classDef loop fill:#f9f"); lines.push(" classDef parallel fill:#9ff"); return lines.join("\n"); } // src/workflows/workflowToTimeline.ts function workflowToTimeline(rootNode) { const timelineSteps = []; function visit(node) { switch (node.type) { case "root": case "then": case "else": for (const child of node.body) { visit(child); } break; case "step": if (node.label.charAt(0) === node.label.charAt(0).toUpperCase()) { timelineSteps.push(node.label); } break; case "loop": if (node.label.charAt(0) === node.label.charAt(0).toUpperCase()) { timelineSteps.push(node.label); } break; case "parallel": if (node.label.charAt(0) === node.label.charAt(0).toUpperCase()) { timelineSteps.push(node.label); } break; case "if": if (node.thenBranch) { visit(node.thenBranch); } if (node.elseBranch) { visit(node.elseBranch); } break; default: break; } } visit(rootNode); return timelineSteps; } // src/cli/index.ts var program = new Command(); var pkg = await import("../package-XXHOOPYJ.js"); program.name("c4").description("CLI to generate C4 diagrams").version(pkg.version); var WorkflowType = z.enum(["mermaid", "timeline"]); program.command("workflow").description("Generate diagram or timeline from a workflow file").requiredOption("-f, --format <type>", "mermaid or timeline", (value) => { if (!WorkflowType.safeParse(value).success) { throw new InvalidArgumentError(`Invalid format: ${value}`); } return value; }).argument("<workflowPath>", "Path to the workflow file", (value) => { if (!existsSync(value)) { throw new InvalidArgumentError(`File does not exist: ${value}`); } return value; }).action( (workflowPath, { format }) => { const rootNode = parseCloudflareWorkflowSource(workflowPath); switch (format) { case "mermaid": { const mermaid = workflowToMermaid(rootNode); console.log(mermaid); break; } case "timeline": { const timeline = workflowToTimeline(rootNode); console.log(timeline); break; } } } ); program.parse(process.argv); //# sourceMappingURL=index.js.map