PlantUMLHeadless.java
package net.sourceforge.plantuml.teavm.headless;
import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import org.teavm.jso.JSExport;
import org.teavm.jso.JSFunctor;
import org.teavm.jso.JSObject;
import net.sourceforge.plantuml.explain.DiagramExplainer;
import net.sourceforge.plantuml.explain.Explanation;
import net.sourceforge.plantuml.mcp.DiagramRendererTeaVM;
import net.sourceforge.plantuml.mcp.McpResult;
import net.sourceforge.plantuml.mcp.SyntaxChecker;
import net.sourceforge.plantuml.utils.LineLocation;
import net.sourceforge.plantuml.version.Version;
/**
* Headless TeaVM entry point for the PlantUML MCP server (Node/stdio).
*
* <p>
* Unlike {@link net.sourceforge.plantuml.teavm.browser.PlantUMLBrowser}, this
* class exposes no DOM- or Graphviz-dependent code: just plain text-returning
* functions usable from a plain Node process, with no browser and no Java
* runtime.
*
* <p>
* The actual work is delegated to the shared MCP classes ({@link SyntaxChecker}
* / {@link McpResult}), so the logic is not duplicated between the Java server
* and this JS build. This class only adapts their result to a JSON string,
* built as a native JS object via {@link TeaVmJson} and serialized with
* {@code JSON.stringify} (escaping handled by the platform). The Node wrapper
* simply {@code JSON.parse}s the result.
*
* <p>
* Tools so far:
* <ul>
* <li>{@link #version()} — the embedded PlantUML version (validates the
* full chain: TeaVM build -> JS artifact -> Node wrapper -> MCP stdio
* transport -> tool discovery by the client).</li>
* <li>{@link #checkSyntax(String)} — parses a single diagram and reports
* a structured diagnostic, without rendering.</li>
* <li>{@link #renderSvg(String, ResultCallback)} — parses and renders a
* single diagram to a deterministic SVG. Asynchronous: the result JSON is
* delivered through a callback, because diagrams that need Graphviz call the
* Viz.js engine, whose {@code @Async} bridge can only suspend from a TeaVM
* worker thread (see {@link net.sourceforge.plantuml.teavm.browser.PlantUMLBrowser}
* for the same constraint).</li>
* </ul>
*/
public class PlantUMLHeadless {
// ::remove file when __MIT__ __EPL__ __BSD__ __ASL__ __LGPL__ __GPLV2__
// ::remove file when JAVA8
/**
* Returns the full description of the embedded PlantUML version.
*
* @return the version string, as produced by {@link Version#fullDescription()}
*/
@JSExport
public static String version() {
return Version.fullDescription();
}
/**
* Checks the syntax of a single PlantUML diagram, without rendering it.
*
* <p>
* The result is a JSON object with the following shape:
*
* <pre>
* {
* "valid": true,
* "diagramType": "SequenceDiagram",
* "lineCount": 4,
* "warnings": ["..."]
* }
* </pre>
*
* or, on failure:
*
* <pre>
* {
* "valid": false,
* "lineCount": 4,
* "warnings": [],
* "errorLine": 2,
* "errorMessage": "...",
* "errorContext": "the offending source line"
* }
* </pre>
*
* @param source the raw PlantUML source (a single {@code @start.../@end...}
* diagram)
* @return a JSON string describing the diagnostic; never {@code null}
*/
@JSExport
public static String checkSyntax(String source) {
final McpResult result;
try {
result = new SyntaxChecker().check(source);
} catch (IOException e) {
return errorJson("Could not read the diagram source: " + e.getMessage());
}
if (result.isOk())
// return okJson(result.getDiagramType(), result.getLineCount(),
// result.getWarnings());
return okJson(result);
return errorJson(result);
// return errorJson(result.getErrorLineNumber(), result.getErrorMessage(), result.getErrorLine(),
// result.getLineCount());
}
/**
* Callback used to deliver the render result JSON back to JavaScript.
*/
@JSFunctor
public interface ResultCallback extends JSObject {
void call(String json);
}
// =========================================================================
// Render worker thread
//
// Rendering must run on a TeaVM thread so that the Viz.js @Async bridge
// (GraphVizjsTeaVMEngine) can suspend. An @JSExport called directly from
// JavaScript runs in a native JS context, where suspension is illegal. So
// renderSvg() only queues a request and wakes the worker; the worker does the
// actual rendering in a coroutine context and invokes the callback.
// =========================================================================
private static final Object LOCK = new Object();
private static volatile boolean workerStarted = false;
private static final class RenderRequest {
final String source;
final ResultCallback callback;
RenderRequest(String source, ResultCallback callback) {
this.source = source;
this.callback = callback;
}
}
// A real queue (not a single slot like PlantUMLBrowser): an MCP server may
// receive several independent render_diagram calls, and none must be dropped.
private static final Deque<RenderRequest> QUEUE = new ArrayDeque<>();
private static void ensureWorkerStarted() {
if (workerStarted == false) {
synchronized (LOCK) {
if (workerStarted == false) {
new Thread(PlantUMLHeadless::workerLoop, "plantuml-headless-render").start();
workerStarted = true;
}
}
}
}
private static void workerLoop() {
while (true) {
final RenderRequest request;
synchronized (LOCK) {
while (QUEUE.isEmpty()) {
try {
LOCK.wait();
} catch (InterruptedException e) {
// Not expected; just retry.
}
}
request = QUEUE.removeFirst();
}
// Render OUTSIDE the lock so new requests can be queued meanwhile.
String json;
try {
json = renderSvgToJson(request.source);
} catch (Throwable e) {
json = errorJson("Could not render the diagram source: " + e);
}
request.callback.call(json);
}
}
/**
* Parses and renders a single PlantUML diagram to a deterministic SVG.
*
* <p>
* Asynchronous: the result is delivered as a JSON string through
* {@code callback}. On success the JSON has the shape:
*
* <pre>
* {
* "valid": true,
* "diagramType": "SequenceDiagram",
* "lineCount": 4,
* "warnings": ["..."],
* "svg": "<svg ...>...</svg>"
* }
* </pre>
*
* On failure, it has the same error shape as {@link #checkSyntax(String)} (no
* {@code svg} field).
*
* @param source the raw PlantUML source (a single {@code @start.../@end...}
* diagram)
* @param callback invoked once with the result JSON
*/
@JSExport
public static void renderSvg(String source, ResultCallback callback) {
ensureWorkerStarted();
synchronized (LOCK) {
QUEUE.addLast(new RenderRequest(source, callback));
LOCK.notify();
}
}
/**
* Synchronous core of {@link #renderSvg(String, ResultCallback)}, run on the
* worker thread. Returns the result JSON string.
*/
private static String renderSvgToJson(String source) {
final McpResult result;
try {
result = new DiagramRendererTeaVM().render(source);
} catch (IOException e) {
return errorJson("Could not render the diagram source: " + e.getMessage());
}
if (result.isOk())
return renderOkJson(result);
return errorJson(result);
}
/**
* Explains how a single PlantUML diagram is parsed, line by line.
*
* <p>
* The result is a JSON array of objects, each with the shape:
*
* <pre>
* {
* "input": ["Alice -> Bob"],
* "explain": "Message from 'Alice' to 'Bob'",
* "line": 2
* }
* </pre>
*
* where {@code input} is the source line(s) that produced the explanation,
* {@code explain} is the human-readable text, and {@code line} is the 1-based
* line number (omitted when no location applies).
*
* @param source the raw PlantUML source (a single {@code @start.../@end...}
* diagram)
* @return a JSON array string describing the explanations; never {@code null}
*/
@JSExport
public static String explain(String source) {
final List<Explanation> explanations;
try {
explanations = new DiagramExplainer().explain(source);
} catch (IOException e) {
final JSObject array = TeaVmJson.newArray();
TeaVmJson.pushObject(array,
explanationToJson(null, "Could not read the diagram source: " + e.getMessage(), -1));
return TeaVmJson.stringify(array);
}
final JSObject array = TeaVmJson.newArray();
for (int i = 0; i < explanations.size(); i++) {
final Explanation explanation = explanations.get(i);
final LineLocation location = explanation.getLocation();
// getPosition() is 0-based; the public contract exposes a 1-based line.
final int line = location == null ? -1 : location.getPosition() + 1;
TeaVmJson.pushObject(array, explanationToJson(explanation.getInput(), explanation.getExplain(), line));
}
return TeaVmJson.stringify(array);
}
private static JSObject explanationToJson(List<String> input, String explain, int line) {
final JSObject json = TeaVmJson.newObject();
TeaVmJson.putObject(json, "input", input == null ? TeaVmJson.newArray() : toJsArray(input));
TeaVmJson.putString(json, "explain", explain);
if (line > 0)
TeaVmJson.putInt(json, "lineNumber", line);
return json;
}
private static String okJson(McpResult result) {
final JSObject json = TeaVmJson.newObject();
TeaVmJson.putBoolean(json, "valid", true);
TeaVmJson.putString(json, "diagramType", result.getDiagramType());
TeaVmJson.putInt(json, "lineCount", result.getLineCount());
TeaVmJson.putObject(json, "warnings", toJsArray(result.getWarnings()));
return TeaVmJson.stringify(json);
}
private static String renderOkJson(McpResult result) {
final JSObject json = TeaVmJson.newObject();
TeaVmJson.putBoolean(json, "valid", true);
TeaVmJson.putString(json, "diagramType", result.getDiagramType());
TeaVmJson.putInt(json, "lineCount", result.getLineCount());
TeaVmJson.putObject(json, "warnings", toJsArray(result.getWarnings()));
TeaVmJson.addStringSafe(json, "svg", result.getSvg());
return TeaVmJson.stringify(json);
}
private static String errorJson(McpResult result) {
final JSObject json = TeaVmJson.newObject();
TeaVmJson.putBoolean(json, "valid", false);
TeaVmJson.putInt(json, "lineCount", result.getLineCount());
if (result.getErrorLineNumber() > 0)
TeaVmJson.putInt(json, "errorLineNumber", result.getErrorLineNumber());
TeaVmJson.addStringSafe(json, "errorMessage", result.getErrorMessage());
TeaVmJson.addStringSafe(json, "errorLine", result.getErrorLine());
TeaVmJson.addStringSafe(json, "errorContext", result.getErrorContext());
return TeaVmJson.stringify(json);
}
private static String errorJson(String errorMessage) {
final JSObject json = TeaVmJson.newObject();
TeaVmJson.putBoolean(json, "valid", false);
TeaVmJson.addStringSafe(json, "errorMessage", errorMessage);
return TeaVmJson.stringify(json);
}
private static JSObject toJsArray(List<String> values) {
final JSObject array = TeaVmJson.newArray();
for (int i = 0; i < values.size(); i++)
TeaVmJson.push(array, values.get(i));
return array;
}
}