PlantUMLHeadless.java
package net.sourceforge.plantuml.teavm.js.headless;
import java.io.IOException;
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.McpResult;
import net.sourceforge.plantuml.mcp.SyntaxChecker;
import net.sourceforge.plantuml.teavm.DiagramRendererTeaVM;
import net.sourceforge.plantuml.teavm.js.browser.PendingResources;
import net.sourceforge.plantuml.teavm.js.browser.RenderQueue;
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.js.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 JsonJs} 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 what the diagram needs from JavaScript
* (the Viz.js instance for Graphviz layouts...) only arrives
* asynchronously; the diagram is rendered again once it is there (see
* {@link RenderQueue}, shared with
* {@link net.sourceforge.plantuml.teavm.js.browser.PlantUMLBrowser}). No thread
* and no TeaVM coroutine are involved.</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);
}
/** A {@link #renderSvg} request. */
private static final class RenderJob extends RenderQueue.Job {
private final String source;
private final ResultCallback callback;
private String json;
private RenderJob(String source, ResultCallback callback) {
this.source = source;
this.callback = callback;
}
@Override
protected void attempt() {
try {
json = renderSvgToJson(source);
} catch (Throwable e) {
json = errorJson("Could not render the diagram source: " + e);
}
}
@Override
protected void deliver() {
callback.call(json);
}
@Override
protected void giveUp(String message) {
callback.call(errorJson(message));
}
}
/**
* Parses and renders a single PlantUML diagram to a deterministic SVG.
*
* <p>
* Asynchronous: the result is delivered as a JSON string through
* {@code callback}, never before this call returns. Requests are answered
* one at a time, in the order of the calls, each exactly once. 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) {
RenderQueue.submit(new RenderJob(source, callback));
}
/**
* Synchronous core of {@link #renderSvg(String, ResultCallback)}: one
* rendering attempt. Returns the result JSON string.
*/
private static String renderSvgToJson(String source) {
final McpResult result;
try {
result = new DiagramRendererTeaVM() {
@Override
protected boolean isDiscarded() {
// The attempt missed a resource: it will be rendered again (RenderQueue)
return PendingResources.hasMissing();
}
}.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 = JsonJs.newArray();
JsonJs.pushObject(array,
explanationToJson(null, "Could not read the diagram source: " + e.getMessage(), -1));
return JsonJs.stringify(array);
}
final JSObject array = JsonJs.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;
JsonJs.pushObject(array, explanationToJson(explanation.getInput(), explanation.getExplain(), line));
}
return JsonJs.stringify(array);
}
private static JSObject explanationToJson(List<String> input, String explain, int line) {
final JSObject json = JsonJs.newObject();
JsonJs.putObject(json, "input", input == null ? JsonJs.newArray() : toJsArray(input));
JsonJs.putString(json, "explain", explain);
if (line > 0)
JsonJs.putInt(json, "lineNumber", line);
return json;
}
private static String okJson(McpResult result) {
final JSObject json = JsonJs.newObject();
JsonJs.putBoolean(json, "valid", true);
JsonJs.putString(json, "diagramType", result.getDiagramType());
JsonJs.putInt(json, "lineCount", result.getLineCount());
JsonJs.putObject(json, "warnings", toJsArray(result.getWarnings()));
return JsonJs.stringify(json);
}
private static String renderOkJson(McpResult result) {
final JSObject json = JsonJs.newObject();
JsonJs.putBoolean(json, "valid", true);
JsonJs.putString(json, "diagramType", result.getDiagramType());
JsonJs.putInt(json, "lineCount", result.getLineCount());
JsonJs.putObject(json, "warnings", toJsArray(result.getWarnings()));
JsonJs.addStringSafe(json, "svg", result.getSvg());
return JsonJs.stringify(json);
}
private static String errorJson(McpResult result) {
final JSObject json = JsonJs.newObject();
JsonJs.putBoolean(json, "valid", false);
JsonJs.putInt(json, "lineCount", result.getLineCount());
if (result.getErrorLineNumber() > 0)
JsonJs.putInt(json, "errorLineNumber", result.getErrorLineNumber());
JsonJs.addStringSafe(json, "errorMessage", result.getErrorMessage());
JsonJs.addStringSafe(json, "errorLine", result.getErrorLine());
JsonJs.addStringSafe(json, "errorContext", result.getErrorContext());
return JsonJs.stringify(json);
}
private static String errorJson(String errorMessage) {
final JSObject json = JsonJs.newObject();
JsonJs.putBoolean(json, "valid", false);
JsonJs.addStringSafe(json, "errorMessage", errorMessage);
return JsonJs.stringify(json);
}
private static JSObject toJsArray(List<String> values) {
final JSObject array = JsonJs.newArray();
for (int i = 0; i < values.size(); i++)
JsonJs.push(array, values.get(i));
return array;
}
}