@oselvar/c4
Version:
Test helpers for Cloudflare Workers
275 lines (270 loc) • 8.94 kB
JavaScript
// 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