+ * The console process is started once and reused across all code blocks in a document, + * maintaining session state (variables, graph bindings) between blocks. + *
+ * Protocol: Each statement is sent individually, followed by a sentinel marker. + * The sentinel is sent twice to handle the console's "Display stack trace? [yN]" error + * prompt, which reads the next stdin line as an answer. If a statement errors, the first + * sentinel is consumed as the "N" answer and the second sentinel produces the expected + * output. This per-statement approach prevents cascading failures where one error could + * consume subsequent code lines or sentinels. + */ +public class ConsoleExecutor implements Closeable { + + private static final Logger log = LoggerFactory.getLogger(ConsoleExecutor.class); + + /** + * Sentinel echoed after each statement to mark the end of output. Chosen to be + * unlikely to appear in normal Gremlin output. + */ + private static final String SENTINEL = "__GREMLIN_DOCS_BLOCK_END__"; + + /** Pattern to strip ANSI escape codes from console output. */ + private static final Pattern ANSI_PATTERN = Pattern.compile("\u001B\\[[0-9;]*[a-zA-Z]"); + + /** Pattern matching the gremlin prompt (with possible ANSI codes already stripped). */ + private static final Pattern PROMPT_PATTERN = Pattern.compile("^gremlin>\\s?"); + + /** Pattern matching continuation prompts like {@code ......1> }. */ + private static final Pattern CONTINUATION_PATTERN = Pattern.compile("^\\.+\\d+>\\s?"); + + private final Process process; + private final BufferedWriter stdin; + private final BufferedReader stdout; + private final Thread stderrDrainer; + + /** + * Creates a new ConsoleExecutor that launches {@code bin/gremlin.sh} from the given + * console home directory. + * + * @param consoleHome path to the unpacked Gremlin Console distribution + */ + public ConsoleExecutor(final String consoleHome) { + this(consoleHome, null); + } + + /** + * Creates a new ConsoleExecutor with an optional {@code HADOOP_GREMLIN_LIBS} setting. + * + * @param consoleHome path to the unpacked Gremlin Console distribution + * @param hadoopGremlinLibs value for the HADOOP_GREMLIN_LIBS environment variable, or null + */ + public ConsoleExecutor(final String consoleHome, final String hadoopGremlinLibs) { + final Path consoleBin = Paths.get(consoleHome, "bin", "gremlin.sh"); + log.info("Starting Gremlin Console from {}", consoleBin); + + try { + final ProcessBuilder pb = new ProcessBuilder(consoleBin.toString()); + pb.directory(Paths.get(consoleHome).toFile()); + pb.environment().put("TERM", "dumb"); + if (hadoopGremlinLibs != null) { + pb.environment().put("HADOOP_GREMLIN_LIBS", hadoopGremlinLibs); + } + + this.process = pb.start(); + this.stdin = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + this.stdout = new BufferedReader(new InputStreamReader(process.getInputStream())); + final BufferedReader stderr = new BufferedReader(new InputStreamReader(process.getErrorStream())); + + // drain stderr in background to prevent blocking + this.stderrDrainer = new Thread(() -> { + try { + String line; + while ((line = stderr.readLine()) != null) { + log.debug("console stderr: {}", line); + } + } catch (final Exception ignored) { } + }, "console-stderr-drainer"); + stderrDrainer.setDaemon(true); + stderrDrainer.start(); + + // wait for the console to be ready by consuming the startup banner + sendSentinel(); + consumeUntilSentinel(); + + log.info("Gremlin Console started successfully"); + } catch (final Exception e) { + throw new RuntimeException("Failed to start Gremlin Console from " + consoleHome, e); + } + } + + /** + * Initializes the graph environment for a code block. + * + * @param graph the graph name (modern, classic, crew, sink, grateful) or null/empty for bare TinkerGraph. + * "existing" means reuse the current graph state. + */ + public void initGraph(final String graph) { + if ("existing".equals(graph)) return; + + executeQuietly("if (graph != null && graph instanceof AutoCloseable) graph.close()"); + + if (graph != null && !graph.isEmpty()) { + executeQuietly("graph = TinkerFactory.create" + capitalize(graph) + "()"); + } else { + executeQuietly("graph = TinkerGraph.open()"); + } + executeQuietly("g = graph.traversal()"); + executeQuietly("marko = g.V().has('name', 'marko').tryNext().orElse(null)"); + executeQuietly("f = new File('/tmp/tinkergraph.kryo'); if (f.exists()) f.deleteDir()"); + executeQuietly(":set max-iteration 100"); + } + + /** + * Executes a block of Gremlin code lines and returns the console-formatted output. + * Each statement is sent individually with its own sentinel boundary, so errors + * on one statement cannot consume subsequent statements. + *
+ * Multi-line statements (lines ending with {@code .}, open brackets, etc.) are
+ * accumulated and sent as a single unit.
+ */
+ public String execute(final List
+ *
+ */
+public class GremlinTreeProcessor extends Treeprocessor {
+
+ private static final Logger log = LoggerFactory.getLogger(GremlinTreeProcessor.class);
+ private static final Pattern GREMLIN_STYLE = Pattern.compile("gremlin-(\\w+)");
+ private static final AtomicLong counter = new AtomicLong(System.currentTimeMillis());
+
+ @Override
+ public Document process(final Document document) {
+ final boolean dryRun = document.hasAttribute("gremlin-docs-dryrun");
+ final String consoleHome = document.hasAttribute("gremlin-docs-console-home")
+ ? document.getAttribute("gremlin-docs-console-home").toString()
+ : null;
+ final String hadoopLibs = document.hasAttribute("gremlin-docs-hadoop-libs")
+ ? document.getAttribute("gremlin-docs-hadoop-libs").toString()
+ : null;
+
+ if (dryRun || consoleHome == null) {
+ processNode(document, null, true);
+ } else {
+ try (final ConsoleExecutor executor = new ConsoleExecutor(consoleHome, hadoopLibs)) {
+ processNode(document, executor, false);
+ }
+ }
+
+ return document;
+ }
+
+ private void processNode(final StructuralNode node, final ConsoleExecutor executor, final boolean dryRun) {
+ final List