Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions editors/code/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# fpp-diagram-render

Command-line rendering of FPP diagrams to SVG, using the same pipeline as the
VS Code extension's diagram panel:

1. `fpp-diagram --format sprotty` (the Rust `fpp_diagram` lowering the language
server uses) produces a sprotty `SGraph`.
2. Labels are sized from font metrics (the webview measures them in the DOM).
3. The extension's ELK layout (`src/diagram/layout.ts`,
`src/diagram/layout-config.ts`) places nodes, ports and labels.
4. The laid-out graph is written as standalone SVG with the webview's shapes
and stylesheet (`webview/css/diagram.css`, theme variables resolved).

## Build

```sh
cargo build --release -p fpp_diagram --bin fpp-diagram # repo root
cd editors/code && yarn install && yarn build # emits dist/fpp-diagram-render.js
```

## Use

```sh
# One component from the F Prime framework sources
node dist/fpp-diagram-render.js --name Svc.CommandDispatcher -o CommandDispatcher.svg \
$FPRIME/cmake/platform/unix/Platform/PlatformTypes.fpp \
$(find $FPRIME/Fpp $FPRIME/Fw $FPRIME/Svc $FPRIME/Drv $FPRIME/Os $FPRIME/Utils $FPRIME/default/config -name '*.fpp')

# A topology, pruning unconnected ports
node dist/fpp-diagram-render.js --kind topology --name Ref.Ref --hide-unused-ports -o Ref.svg <files...>

# PNG, if wanted (any SVG rasterizer works; headless Chrome shown)
google-chrome --headless=new --screenshot=CommandDispatcher.png --window-size=400,1100 CommandDispatcher.svg
```

Multiple `.fpp` files may be given; `include "..."` directives are resolved
relative to each file and inlined before analysis, so a whole source tree can be
passed. The `fpp-diagram` binary is found in `target/{release,debug}` of the
workspace or on `PATH`; override with `--fpp-diagram`.

`--kind` accepts `component` (default), `topology`, and `connectionGroup`
(`--name <topology>.<group>`). State machines are not rendered by this tool;
`fpp-diagram --format mermaid` already emits them as Mermaid text.
94 changes: 94 additions & 0 deletions editors/code/cli/src/fpp-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Front end of the CLI: turn a set of FPP files into a sprotty `SGraph` using
* the existing `fpp-diagram` binary (the same `fpp_diagram` lowering the
* language server runs for the VS Code diagram panel).
*
* `fpp-diagram` reads a single translation unit, so the input files are
* concatenated into one temporary file. `include "..."` directives are
* resolved relative to the file that contains them and inlined, which keeps
* the concatenated unit self-contained.
*/
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { spawnSync } from "child_process";
import { SGraph } from "sprotty-protocol";

const INCLUDE_RE = /^(\s*)include\s+"([^"]+)"\s*$/;

/** Inline `include` directives of a single FPP file, recursively. */
export function inlineIncludes(file: string, seen: Set<string> = new Set()): string {
const resolved = path.resolve(file);
if (seen.has(resolved)) {
throw new Error(`include cycle at ${resolved}`);
}
seen.add(resolved);
const dir = path.dirname(resolved);
const lines = fs.readFileSync(resolved, "utf8").split(/\r?\n/);
const out: string[] = [];
for (const line of lines) {
const m = INCLUDE_RE.exec(line);
if (m) {
out.push(inlineIncludes(path.join(dir, m[2]), seen));
} else {
out.push(line);
}
}
seen.delete(resolved);
return out.join("\n");
}

/** Concatenate FPP files into one translation unit with includes inlined. */
export function concatenateSources(files: string[]): string {
return files.map(f => inlineIncludes(f)).join("\n\n");
}

/** Candidate locations for `fpp-diagram` when none is given explicitly. */
export function findFppDiagram(explicit?: string): string {
if (explicit) {
return explicit;
}
// A workspace build next to this extension (editors/code -> repo root).
const repoRoot = path.resolve(__dirname, "..", "..", "..");
for (const profile of ["release", "debug"]) {
const candidate = path.join(repoRoot, "target", profile, "fpp-diagram");
if (fs.existsSync(candidate)) {
return candidate;
}
}
return "fpp-diagram"; // rely on PATH
}

export interface LowerOptions {
fppDiagram: string;
kind: string;
name: string;
hideUnusedPorts: boolean;
}

/** Run `fpp-diagram` on the FPP files and parse its sprotty JSON. */
export function lowerToSGraph(files: string[], opts: LowerOptions): SGraph {
const unit = concatenateSources(files);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fpp-diagram-render-"));
const tmpFile = path.join(tmpDir, "unit.fpp");
try {
fs.writeFileSync(tmpFile, unit);
const args = ["--kind", opts.kind, "--name", opts.name, "--format", "sprotty"];
if (opts.hideUnusedPorts) {
args.push("--hide-unused-ports");
}
args.push(tmpFile);
const result = spawnSync(opts.fppDiagram, args, { encoding: "utf8", maxBuffer: 1 << 28 });
if (result.error) {
throw new Error(`failed to run ${opts.fppDiagram}: ${result.error.message}`);
}
if (result.status !== 0) {
throw new Error(
`${opts.fppDiagram} exited with ${result.status}\n${result.stderr}${result.stdout}`
);
}
return JSON.parse(result.stdout) as SGraph;
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
135 changes: 135 additions & 0 deletions editors/code/cli/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* `fpp-diagram-render`: command-line rendering of FPP diagrams to SVG.
*
* Pipeline (same stages as the VS Code diagram panel, minus the webview):
* 1. `fpp-diagram --format sprotty` lowers the FPP model to a sprotty SGraph.
* 2. Labels get sizes (the webview measures them in the DOM; here they are
* estimated from font metrics).
* 3. `FppLayoutEngine` + `FppDiagramConfig` (the extension's ELK layout) place
* nodes, ports and labels.
* 4. The laid-out graph is written as SVG using the webview's shapes and CSS.
*/
import * as fs from "fs";
import ELK from "elkjs/lib/elk.bundled.js";
import { SGraph, SLabel, SModelElement } from "sprotty-protocol";
import { FppLayoutEngine } from "../../src/diagram/layout";
import { FppDiagramConfig, DiagramType } from "../../src/diagram/layout-config";
import { findFppDiagram, lowerToSGraph } from "./fpp-source";
import { textHeight, textWidth } from "./text-metrics";
import { Theme, toSvg } from "./svg";

interface CliArgs {
name?: string;
kind: DiagramType;
output?: string;
json?: string;
fppDiagram?: string;
hideUnusedPorts: boolean;
theme: Theme;
files: string[];
}

const USAGE = `usage: fpp-diagram-render --name <qualified-name> [options] <file.fpp>...

Render an FPP component, topology, or connection group diagram to SVG using the
same lowering (fpp-diagram) and layout (ELK) as the VS Code extension.

options:
--name <name> fully qualified name, e.g. Svc.CommandDispatcher
(connection group: <topology>.<group>)
--kind <kind> component | topology | connectionGroup [component]
-o, --output <file> SVG output path [stdout]
--json <file> read a sprotty SGraph JSON instead of running fpp-diagram
--fpp-diagram <path> fpp-diagram binary [target/{release,debug}/fpp-diagram, PATH]
--hide-unused-ports prune ports with no connections (topologies)
--theme <dark|light> color palette [dark]
-h, --help show this help

Multiple .fpp files may be given; \`include\` directives are resolved relative to
each file and inlined before analysis.`;

function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { kind: DiagramType.component, hideUnusedPorts: false, theme: "dark", files: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => {
if (i + 1 >= argv.length) throw new Error(`${a} requires a value`);
return argv[++i];
};
switch (a) {
case "--name": args.name = next(); break;
case "--kind": {
const k = next();
if (!Object.values(DiagramType).includes(k as DiagramType) || k === DiagramType.stateMachine) {
throw new Error(`unsupported kind '${k}' (component | topology | connectionGroup)`);
}
args.kind = k as DiagramType;
break;
}
case "-o": case "--output": args.output = next(); break;
case "--json": args.json = next(); break;
case "--fpp-diagram": args.fppDiagram = next(); break;
case "--hide-unused-ports": args.hideUnusedPorts = true; break;
case "--theme": {
const t = next();
if (t !== "dark" && t !== "light") throw new Error(`unknown theme '${t}'`);
args.theme = t;
break;
}
case "-h": case "--help": console.log(USAGE); process.exit(0);
default:
if (a.startsWith("-")) throw new Error(`unknown option '${a}'`);
args.files.push(a);
}
}
return args;
}

/** Give every label a size, as the webview's bounds round-trip would. */
function measureLabels(element: SModelElement): void {
if (element.type.startsWith("label")) {
const label = element as SLabel;
label.size = { width: textWidth(label.text), height: textHeight() };
}
for (const child of element.children ?? []) {
measureLabels(child);
}
}

async function layout(graph: SGraph, kind: DiagramType, hideUnusedPorts: boolean): Promise<SGraph> {
const config = new FppDiagramConfig();
config.currentDiagramType = kind;
config.hideUnusedPorts = hideUnusedPorts;
const engine = new FppLayoutEngine(() => new ELK(), undefined, config);
return engine.layout(graph);
}

export async function run(argv: string[]): Promise<void> {
const args = parseArgs(argv);
let graph: SGraph;
if (args.json) {
graph = JSON.parse(fs.readFileSync(args.json, "utf8")) as SGraph;
} else {
if (!args.name) throw new Error("--name is required");
if (args.files.length === 0) throw new Error("at least one .fpp file is required");
graph = lowerToSGraph(args.files, {
fppDiagram: findFppDiagram(args.fppDiagram),
kind: args.kind,
name: args.name,
hideUnusedPorts: args.hideUnusedPorts,
});
}
measureLabels(graph);
const laidOut = await layout(graph, args.kind, args.hideUnusedPorts);
const svg = toSvg(laidOut, args.theme);
if (args.output) {
fs.writeFileSync(args.output, svg);
} else {
process.stdout.write(svg);
}
}

run(process.argv.slice(2)).catch(e => {
console.error(`fpp-diagram-render: ${e instanceof Error ? e.message : e}`);
process.exit(1);
});
Loading
Loading