+ * The orchestration layer implements this to close the current console, toggle/rename plugin
+ * directories, and start a new console instance.
+ */
+@FunctionalInterface
+public interface ConsoleRestartHandler {
+
+ /**
+ * Called when the document attribute {@code :gremlin-docs-plugins-exclude:} is detected
+ * with a changed value. The handler should close the current console, exclude the specified
+ * plugin directories, and start a new console.
+ *
+ * @param excludedPlugins list of plugin directory names to exclude (e.g., "neo4j-gremlin")
+ * @throws IOException if the restart operation fails
+ */
+ void onRestart(List
+ * Uses prompt-based boundary detection: reads stdout until the {@code gremlin>} prompt appears.
+ * Automatically dismisses {@code Display stack trace?} error prompts on stderr.
+ */
+public class GremlinConsole implements Closeable {
+
+ static final String PROMPT = "gremlin>";
+ static final String ERROR_PROMPT = "Display stack trace?";
+ private static final long DEFAULT_TIMEOUT_MS = 90_000;
+ private static final int EOF = -1;
+
+ private final Process process;
+ private final Writer stdin;
+ private final BufferedReader stdout;
+ private final BufferedReader stderr;
+ private final Object stderrStdinLock = new Object();
+ private final Thread errorDismisser;
+ private final Thread stdoutReaderThread;
+ private final BlockingQueue
+ * The console's {@code bin/gremlin.sh} composes its classpath from {@code lib/*.jar} plus
+ * {@code ext/
+ * The generated structure uses radio inputs for tab switching without JavaScript:
+ *
+ * <section class="tabs tabs-N">
+ * <input type="radio" name="tab-group-ID" id="tab-ID-1" class="tab-selector-1" checked>
+ * <label for="tab-ID-1" class="tab-label-1">Label</label>
+ * ...
+ * <div class="tabcontent">
+ * <div class="tabcontent-1">...</div>
+ * ...
+ * </div>
+ * </section>
+ *
+ */
+public class TabbedHtmlBuilder {
+
+ private static final Pattern CALLOUT_PATTERN = Pattern.compile("<(\\d+)>");
+
+ private int groupCounter = 0;
+
+ /**
+ * A single tab entry with a label, language, and source code content.
+ */
+ static class Tab {
+ private final String label;
+ private final String language;
+ private final String content;
+ private final boolean preHighlighted;
+
+ Tab(final String label, final String language, final String content) {
+ this(label, language, content, false);
+ }
+
+ Tab(final String label, final String language, final String content, final boolean preHighlighted) {
+ this.label = label;
+ this.language = language;
+ this.content = content;
+ this.preHighlighted = preHighlighted;
+ }
+
+ String getLabel() {
+ return label;
+ }
+
+ String getLanguage() {
+ return language;
+ }
+
+ String getContent() {
+ return content;
+ }
+
+ boolean isPreHighlighted() {
+ return preHighlighted;
+ }
+ }
+
+ /**
+ * Returns the current group counter value (for testing).
+ */
+ int getGroupCounter() {
+ return groupCounter;
+ }
+
+ /**
+ * Resets the group counter (for testing or reprocessing).
+ */
+ void resetCounter() {
+ groupCounter = 0;
+ }
+
+ /**
+ * Builds tabbed HTML for a list of tabs.
+ *
+ * @param tabs the tabs to render
+ * @return the complete HTML string for the tab group
+ */
+ String build(final List
\n");
+ html.append("")
+ .append(tab.isPreHighlighted() ? tab.getContent() : renderContent(tab.getContent()))
+ .append("> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ final String input = "= Test\n:gremlin-docs-plugins-exclude: neo4j-gremlin,spark-gremlin\n\n" +
+ "[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input, Options.builder().build());
+ assertThat(restartCalls.size(), is(1));
+ assertThat(restartCalls.get(0).contains("neo4j-gremlin"), is(true));
+ assertThat(restartCalls.get(0).contains("spark-gremlin"), is(true));
+ }
+ }
+
+ @Test
+ public void shouldNotInvokeRestartHandlerWhenNoExcludeAttribute() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ final String input = "= Test\n\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input, Options.builder().build());
+ assertThat(restartCalls.isEmpty(), is(true));
+ }
+ }
+
+ @Test
+ public void shouldNotInvokeRestartHandlerWhenExcludeListUnchanged() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ // Process same document twice - handler should only be called once
+ final String input = "= Test\n:gremlin-docs-plugins-exclude: neo4j-gremlin\n\n" +
+ "[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input, Options.builder().build());
+ asciidoctor.convert(input, Options.builder().build());
+ assertThat(restartCalls.size(), is(1));
+ }
+ }
+
+ @Test
+ public void shouldInvokeRestartHandlerWhenExcludeListChanges() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ final String input1 = "= Test\n:gremlin-docs-plugins-exclude: neo4j-gremlin\n\n" +
+ "[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input1, Options.builder().build());
+
+ final String input2 = "= Test\n:gremlin-docs-plugins-exclude: spark-gremlin\n\n" +
+ "[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input2, Options.builder().build());
+ assertThat(restartCalls.size(), is(2));
+ assertThat(restartCalls.get(0).contains("neo4j-gremlin"), is(true));
+ assertThat(restartCalls.get(1).contains("spark-gremlin"), is(true));
+ }
+ }
+
+ @Test
+ public void shouldInvokeRestartHandlerForSectionLevelExcludeWithinOneDocument() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ // Single document with per-section exclusions changing mid-document, as in the reference book.
+ final String input = "= Book\n\n"
+ + "== Neo4j\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n\n"
+ + "[gremlin-docs-plugins-exclude=\"neo4j-gremlin\"]\n"
+ + "== Spark\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input, Options.builder().build());
+ assertThat(restartCalls.size(), is(1));
+ assertThat(restartCalls.get(0).contains("neo4j-gremlin"), is(true));
+ }
+ }
+
+ @Test
+ public void shouldLatchSectionExclusionUntilChanged() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ // Two consecutive excluding sections sharing the same set => one restart;
+ // a later section with no attribute inherits (no extra restart).
+ final String input = "= Book\n\n"
+ + "[gremlin-docs-plugins-exclude=\"neo4j-gremlin\"]\n"
+ + "== Hadoop\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n\n"
+ + "[gremlin-docs-plugins-exclude=\"neo4j-gremlin\"]\n"
+ + "== Spark\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n\n"
+ + "== Compilers\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input, Options.builder().build());
+ assertThat(restartCalls.size(), is(1));
+ }
+ }
+
+ @Test
+ public void shouldParseExcludeListWithWhitespace() {
+ final List
> restartCalls = new ArrayList<>();
+ final ConsoleRestartHandler handler = restartCalls::add;
+ final RecordingExecutor executor = new RecordingExecutor("==>v[1]");
+ final GremlinTreeprocessor processor = new GremlinTreeprocessor(executor, handler);
+
+ try (final Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
+ asciidoctor.unregisterAllExtensions();
+ asciidoctor.javaExtensionRegistry().treeprocessor(processor);
+ // First doc has exclusions
+ final String input1 = "= Test\n:gremlin-docs-plugins-exclude: neo4j-gremlin\n\n" +
+ "[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input1, Options.builder().build());
+
+ // Second doc has no exclusions
+ final String input2 = "= Test\n\n[gremlin-groovy,modern]\n----\ng.V(1)\n----\n";
+ asciidoctor.convert(input2, Options.builder().build());
+ assertThat(restartCalls.size(), is(2));
+ assertThat(restartCalls.get(1).isEmpty(), is(true));
+ }
+ }
+
+ /**
+ * A recording executor that captures all executed statements and returns canned responses.
+ */
+ private static class RecordingExecutor implements GremlinTreeprocessor.StatementExecutor {
+ final List
"));
+ }
+
+ @Test
+ public void shouldEscapeHtmlInContent() {
+ final List"));
+ assertThat(html, containsString("