This is an automated email from the ASF dual-hosted git repository. Cole-Greer pushed a commit to branch docsBuild-3.8 in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit 47114fa4a7a6894e23de0cf4d6508b4ceb3b1f7a Author: Cole Greer <[email protected]> AuthorDate: Sat May 2 22:20:13 2026 -0700 Replace AWK/shell docs preprocessing with AsciidoctorJ extension Introduce the gremlin-docs module, a custom AsciidoctorJ TreeProcessor extension that replaces the entire AWK/shell preprocessing pipeline (10 AWK scripts, 5 shell scripts) used to build TinkerPop documentation. The new system executes [gremlin-groovy] code blocks in an embedded GremlinGroovyScriptEngine during Asciidoctor rendering, eliminating the need for a running Gremlin Server, Hadoop daemons, or the Gremlin Console distribution at build time. Key features: - Embedded execution of gremlin code blocks with live query results - Auto-generated language variant tabs (Java, Python, JavaScript, C#, Go) using the ANTLR-based GremlinTranslator infrastructure - Standalone tab group support for manually-authored [source,LANG,tab] blocks - Hadoop/Spark example support via local-mode Spark with sandboxed HDFS - Console utility functions (describeGraph) loaded from gremlin-console - GremlinPlugin SPI loading for hadoop-gremlin/spark-gremlin imports and bindings - Syntax highlighting via highlight.js 11.9.0 (replaces CodeRay) - Console command detection (:remote, :>, :submit) for static rendering - Multi-line statement joining with bracket balancing - Callout marker preservation through execution New files: - gremlin-docs/ - Maven module (not in reactor, built separately) - GremlinDocsExtension.java - SPI entry point for AsciidoctorJ - GremlinTreeProcessor.java - Main TreeProcessor that walks the AST - GremlinExecutor.java - Embedded script engine wrapper - VariantTranslator.java - GremlinTranslator wrapper for all GLVs - GremlinExecutorTest.java - Unit tests - bin/process-docs-new.sh - New build entry point Root pom.xml changes: - Added gremlin-docs as asciidoctor-maven-plugin dependency - Switched source-highlighter from coderay to highlightjs 11.9.0 - Added highlightjs-languages for groovy support - Added tabs-1 CSS rule for single-tab blocks Usage: bin/process-docs-new.sh # full build bin/process-docs-new.sh --dry-run # skip gremlin execution Assisted-by: Kiro:claude-opus-4.6 --- bin/process-docs-new.sh | 82 ++++ docs/stylesheets/tinkerpop.css | 2 +- gremlin-docs/pom.xml | 98 +++++ .../gremlin/docs/GremlinDocsExtension.java | 34 ++ .../tinkerpop/gremlin/docs/GremlinExecutor.java | 445 +++++++++++++++++++++ .../gremlin/docs/GremlinTreeProcessor.java | 380 ++++++++++++++++++ .../tinkerpop/gremlin/docs/VariantTranslator.java | 132 ++++++ ...ciidoctor.jruby.extension.spi.ExtensionRegistry | 1 + .../gremlin/docs/GremlinExecutorTest.java | 141 +++++++ pom.xml | 69 +++- 10 files changed, 1368 insertions(+), 16 deletions(-) diff --git a/bin/process-docs-new.sh b/bin/process-docs-new.sh new file mode 100755 index 0000000000..576accf67a --- /dev/null +++ b/bin/process-docs-new.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# Builds TinkerPop documentation using the gremlin-docs AsciidoctorJ extension. +# This bypasses the old AWK preprocessing pipeline and processes [gremlin-*] blocks +# directly during Asciidoctor rendering. +# +# Usage: +# bin/process-docs-new.sh # full build with live gremlin execution +# bin/process-docs-new.sh --dry-run # skip gremlin execution (fast, for layout checks) + +set -e + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "${PROJECT_ROOT}" + +TP_VERSION=$(cat pom.xml | grep -A1 '<artifactId>tinkerpop</artifactId>' | grep '<version>' | sed -e 's/.*<version>//' -e 's/<\/version>.*//') + +if [ -z "${TP_VERSION}" ]; then + echo "ERROR: Could not determine TinkerPop version from pom.xml" + exit 1 +fi + +ASCIIDOC_ATTRS="" +if [ "$1" = "--dry-run" ]; then + ASCIIDOC_ATTRS="-Dasciidoctor.attributes.gremlin-docs-dryrun=true" + echo "Dry-run mode: gremlin blocks will not be executed" +fi + +echo "Building docs for TinkerPop ${TP_VERSION}..." +echo "Source: docs/src/" +echo "Output: target/docs/htmlsingle/" + +# build and install the gremlin-docs extension (not part of the main reactor) +echo "Installing gremlin-docs extension..." +mvn install -f gremlin-docs/pom.xml -DskipTests -Denforcer.skip=true -q + +# copy static assets that live outside docs/src/ into the staging area +# (Maven's copy-docs-to-work-area handles docs/src/ itself) +mkdir -p target/doc-source +cp -r docs/static target/doc-source/ 2>/dev/null || true +cp -r docs/stylesheets target/doc-source/ 2>/dev/null || true + +# set up conf/hadoop so GraphFactory.open('conf/hadoop/...') resolves during build +mkdir -p conf/hadoop +cp hadoop-gremlin/conf/* conf/hadoop/ 2>/dev/null || true + +# run asciidoctor with the gremlin-docs extension, pointing at raw sources +mvn process-resources \ + -Dasciidoc \ + -Dasciidoc.source.dir="${PROJECT_ROOT}/docs/src" \ + -Drat.skip=true \ + ${ASCIIDOC_ATTRS} + +# clean up +rm -rf conf/hadoop +rmdir conf 2>/dev/null || true + +# post-process: replace version placeholder +echo "Post-processing: replacing x.y.z with ${TP_VERSION}..." +find target/docs/htmlsingle -name '*.html' | while IFS= read -r f; do + sed "s/x\.y\.z/${TP_VERSION}/g" "$f" > "$f.tmp" && mv "$f.tmp" "$f" +done + +echo "Done. Output in target/docs/htmlsingle/" diff --git a/docs/stylesheets/tinkerpop.css b/docs/stylesheets/tinkerpop.css index 71cc47eeec..b763310c03 100644 --- a/docs/stylesheets/tinkerpop.css +++ b/docs/stylesheets/tinkerpop.css @@ -692,4 +692,4 @@ table.tableblock.grid-all th.tableblock, table.tableblock.grid-all td.tableblock #footer { background-color: #465158; padding: 2em; } #footer-text { color: #eee; font-size: 0.8em; text-align: center; } -.tabs{position:relative;margin:40px auto;width:1024px;max-width:100%;overflow:hidden;padding-top:10px;margin-bottom:60px}.tabs input{position:absolute;z-index:1000;height:50px;left:0;top:0;opacity:0;-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";filter:alpha(opacity=0);cursor:pointer;margin:0}.tabs input:hover+label{background:#e08f24}.tabs label{background:#e9ffe9;color:#1a1a1a;font-size:15px;line-height:50px;height:60px;position:relative;top:0;padding:0 20px;float:left [...] +.tabs{position:relative;margin:40px auto;width:1024px;max-width:100%;overflow:hidden;padding-top:10px;margin-bottom:60px}.tabs input{position:absolute;z-index:1000;height:50px;left:0;top:0;opacity:0;-ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=0)";filter:alpha(opacity=0);cursor:pointer;margin:0}.tabs input:hover+label{background:#e08f24}.tabs label{background:#e9ffe9;color:#1a1a1a;font-size:15px;line-height:50px;height:60px;position:relative;top:0;padding:0 20px;float:left [...] diff --git a/gremlin-docs/pom.xml b/gremlin-docs/pom.xml new file mode 100644 index 0000000000..6840e9e964 --- /dev/null +++ b/gremlin-docs/pom.xml @@ -0,0 +1,98 @@ +<!-- +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>tinkerpop</artifactId> + <version>3.8.2-SNAPSHOT</version> + </parent> + <artifactId>gremlin-docs</artifactId> + <name>Apache TinkerPop :: Gremlin Docs</name> + <description>AsciidoctorJ extension for processing Gremlin code blocks in TinkerPop documentation</description> + <dependencies> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>gremlin-core</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>gremlin-groovy</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>tinkergraph-gremlin</artifactId> + <version>${project.version}</version> + </dependency> + <!-- optional: for [gremlin-groovy,GRAPH,hadoop] blocks that need OLAP/Spark --> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>hadoop-gremlin</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>spark-gremlin</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>gremlin-console</artifactId> + <version>${project.version}</version> + </dependency> + <dependency> + <groupId>org.asciidoctor</groupId> + <artifactId>asciidoctorj</artifactId> + <version>2.5.8</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.asciidoctor</groupId> + <artifactId>asciidoctorj-api</artifactId> + <version>2.5.8</version> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + <!-- test --> + <dependency> + <groupId>junit</groupId> + <artifactId>junit</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.hamcrest</groupId> + <artifactId>hamcrest</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + <build> + <directory>${basedir}/target</directory> + <finalName>${project.artifactId}-${project.version}</finalName> + </build> +</project> diff --git a/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinDocsExtension.java b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinDocsExtension.java new file mode 100644 index 0000000000..5261b07700 --- /dev/null +++ b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinDocsExtension.java @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.tinkerpop.gremlin.docs; + +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.jruby.extension.spi.ExtensionRegistry; + +/** + * SPI entry point that registers the {@link GremlinTreeProcessor} with AsciidoctorJ. + * Discovered automatically via {@code META-INF/services/org.asciidoctor.jruby.extension.spi.ExtensionRegistry}. + */ +public class GremlinDocsExtension implements ExtensionRegistry { + + @Override + public void register(final Asciidoctor asciidoctor) { + asciidoctor.javaExtensionRegistry().treeprocessor(GremlinTreeProcessor.class); + } +} diff --git a/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutor.java b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutor.java new file mode 100644 index 0000000000..9bd228f857 --- /dev/null +++ b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutor.java @@ -0,0 +1,445 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.tinkerpop.gremlin.docs; + +import org.apache.tinkerpop.gremlin.groovy.jsr223.GremlinGroovyScriptEngine; +import org.apache.tinkerpop.gremlin.jsr223.BindingsCustomizer; +import org.apache.tinkerpop.gremlin.jsr223.Customizer; +import org.apache.tinkerpop.gremlin.jsr223.GremlinPlugin; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.script.Bindings; +import javax.script.ScriptException; +import java.io.Closeable; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.ServiceLoader; +import java.util.stream.Stream; + +/** + * Wraps a {@link GremlinGroovyScriptEngine} to execute Gremlin code blocks and capture console-style output. + * Maintains state across evaluations within a single document, matching the behavior of the old AWK pipeline + * which piped the entire document through one Gremlin Console session. + */ +public class GremlinExecutor implements Closeable { + + private static final Logger log = LoggerFactory.getLogger(GremlinExecutor.class); + + /** + * Maximum number of results to display per traversal, matching the Gremlin Console's + * {@code :set max-iteration 100} default used by the old docs preprocessor. + * Can be changed per-block via {@code :set max-iteration N}. + */ + private int maxIteration = 100; + + private final GremlinGroovyScriptEngine engine; + private boolean hadoopInitialized; + + public GremlinExecutor() { + // Load all GremlinPlugin customizers (hadoop, spark, etc.) so their imports + // and bindings (hdfs, fs, spark, SparkGraphComputer, etc.) are available + final List<Customizer> customizers = new ArrayList<>(); + final List<BindingsCustomizer> bindingsCustomizers = new ArrayList<>(); + for (final GremlinPlugin plugin : ServiceLoader.load(GremlinPlugin.class)) { + plugin.getCustomizers("gremlin-groovy").ifPresent(c -> { + for (final Customizer customizer : c) { + if (customizer instanceof BindingsCustomizer) { + bindingsCustomizers.add((BindingsCustomizer) customizer); + } else { + customizers.add(customizer); + } + } + }); + } + this.engine = new GremlinGroovyScriptEngine(customizers.toArray(new Customizer[0])); + + // Set Hadoop's default filesystem to an isolated temp directory so that + // hdfs.ls(), hdfs.copyFromLocal() etc. operate in a clean sandbox instead + // of the user's home directory. + java.io.File hadoopTmp = null; + try { + hadoopTmp = java.nio.file.Files.createTempDirectory("tinkerpop-docs-hdfs").toFile(); + hadoopTmp.deleteOnExit(); + } catch (final Exception e) { + log.debug("Could not set up isolated HDFS directory", e); + } + + // BindingsCustomizer is not handled by the engine constructor — apply manually. + for (final BindingsCustomizer bc : bindingsCustomizers) { + final Bindings bindings = bc.getBindings(); + bindings.forEach((k, v) -> engine.put(k, v)); + } + + // Override hdfs/fs bindings with FileSystemStorage rooted at the temp directory. + // FileSystemStorage.ls() with no args lists fs.getHomeDirectory(), so we need a + // filesystem whose home directory is our temp dir. + if (hadoopTmp != null) { + try { + final String tmpPath = hadoopTmp.getAbsolutePath(); + engine.put("__docsHdfsRoot", tmpPath); + // Use a RawLocalFileSystem subclass that overrides getHomeDirectory. + // We define it in Groovy so it's available to the script engine. + engine.eval( + "class DocsLocalFileSystem extends org.apache.hadoop.fs.RawLocalFileSystem {\n" + + " private org.apache.hadoop.fs.Path home\n" + + " DocsLocalFileSystem(String homeDir) {\n" + + " super()\n" + + " this.home = new org.apache.hadoop.fs.Path(homeDir)\n" + + " initialize(java.net.URI.create('file:///'), new org.apache.hadoop.conf.Configuration())\n" + + " setWorkingDirectory(home)\n" + + " }\n" + + " org.apache.hadoop.fs.Path getHomeDirectory() { home }\n" + + "}\n" + + "hdfs = org.apache.tinkerpop.gremlin.hadoop.structure.io.FileSystemStorage.open(new DocsLocalFileSystem(__docsHdfsRoot))\n" + + "fs = hdfs\n"); + } catch (final Exception e) { + log.debug("Could not override hdfs binding", e); + } + } + + this.hadoopInitialized = false; + + // Load console utility functions (describeGraph, etc.) from gremlin-console + try (final java.io.InputStream is = Thread.currentThread().getContextClassLoader() + .getResourceAsStream("org/apache/tinkerpop/gremlin/console/jsr223/UtilitiesGremlinPluginScript.groovy")) { + if (is != null) { + engine.eval(new String(is.readAllBytes())); + } + } catch (final Exception e) { + log.debug("Could not load console utility functions", e); + } + } + + /** + * Initializes the graph environment for a code block. The graph parameter corresponds to the second + * attribute in {@code [gremlin-groovy,modern]} — e.g. "modern", "classic", "crew", "sink", "grateful", + * or empty for a bare TinkerGraph. "existing" means reuse the current graph state. + * <p> + * Replicates the initialization from the old {@code init-code-blocks.awk}: + * <ul> + * <li>Creates the graph via {@code TinkerFactory} or opens an empty {@code TinkerGraph}</li> + * <li>Creates a traversal source {@code g}</li> + * <li>Pre-binds {@code marko} vertex (if present in the graph) for convenience</li> + * <li>Cleans up {@code /tmp/tinkergraph.kryo} temp files</li> + * </ul> + */ + public void initGraph(final String graph) throws ScriptException { + initGraph(graph, false); + } + + /** + * Initializes the graph environment with optional Hadoop/Spark support. + * + * @param graph the graph name (modern, classic, etc.) or null for empty TinkerGraph + * @param hadoop if true, configures a HadoopGraph with Spark in local mode + */ + public void initGraph(final String graph, final boolean hadoop) throws ScriptException { + if ("existing".equals(graph)) return; + + if (hadoop) { + initHadoopGraph(graph); + return; + } + + // close previous graph if one exists + try { engine.eval("if (graph != null && graph instanceof AutoCloseable) graph.close()"); } + catch (final Exception ignored) { } + + if (graph != null && !graph.isEmpty()) { + engine.eval("graph = org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerFactory.create" + + capitalize(graph) + "()"); + } else { + engine.eval("graph = org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph.open()"); + } + engine.eval("g = graph.traversal()"); + + // pre-bind convenience variables matching init-code-blocks.awk + engine.eval("marko = g.V().has('name', 'marko').tryNext().orElse(null)"); + engine.eval("f = new File('/tmp/tinkergraph.kryo'); if (f.exists()) f.deleteDir()"); + + } + + /** + * Initializes a HadoopGraph with Spark running in local mode. This enables execution of + * OLAP examples that use {@code SparkGraphComputer} without requiring external Hadoop/Spark + * infrastructure. The graph data is written to a temp file in Gryo format and read by + * HadoopGraph via the local filesystem. + */ + private void initHadoopGraph(final String graph) throws ScriptException { + // close previous graph if one exists + try { engine.eval("if (graph != null && graph instanceof AutoCloseable) graph.close()"); } + catch (final Exception ignored) { } + + if (!hadoopInitialized) { + // one-time setup: import hadoop/spark classes + engine.eval("import org.apache.tinkerpop.gremlin.hadoop.structure.HadoopGraph\n" + + "import org.apache.tinkerpop.gremlin.hadoop.Constants\n" + + "import org.apache.tinkerpop.gremlin.hadoop.structure.io.gryo.GryoInputFormat\n" + + "import org.apache.tinkerpop.gremlin.hadoop.structure.io.gryo.GryoOutputFormat\n" + + "import org.apache.tinkerpop.gremlin.spark.process.computer.SparkGraphComputer\n" + + "import org.apache.tinkerpop.gremlin.spark.structure.io.gryo.GryoRegistrator\n" + + "import org.apache.tinkerpop.gremlin.hadoop.structure.io.FileSystemStorage\n" + + "import org.apache.commons.configuration2.BaseConfiguration\n" + + "import org.apache.tinkerpop.gremlin.structure.io.gryo.GryoIo\n"); + hadoopInitialized = true; + } + + // write the TinkerFactory graph to a temp gryo file for HadoopGraph to read + final String factoryMethod = (graph != null && !graph.isEmpty()) + ? "TinkerFactory.create" + capitalize(graph) + "()" + : "TinkerGraph.open()"; + + engine.eval( + "tmpGraph = " + factoryMethod + "\n" + + "tmpFile = File.createTempFile('tinkerpop-docs-', '.kryo')\n" + + "tmpFile.deleteOnExit()\n" + + "tmpGraph.io(GryoIo.build()).writeGraph(tmpFile.absolutePath)\n" + + "tmpGraph.close()\n" + + "\n" + + "hadoopConf = new BaseConfiguration()\n" + + "hadoopConf.setProperty('gremlin.graph', 'org.apache.tinkerpop.gremlin.hadoop.structure.HadoopGraph')\n" + + "hadoopConf.setProperty('gremlin.hadoop.graphReader', 'org.apache.tinkerpop.gremlin.hadoop.structure.io.gryo.GryoInputFormat')\n" + + "hadoopConf.setProperty('gremlin.hadoop.graphWriter', 'org.apache.tinkerpop.gremlin.hadoop.structure.io.gryo.GryoOutputFormat')\n" + + "hadoopConf.setProperty('gremlin.hadoop.inputLocation', tmpFile.absolutePath)\n" + + "hadoopConf.setProperty('gremlin.hadoop.outputLocation', 'output-' + System.currentTimeMillis())\n" + + "hadoopConf.setProperty('gremlin.hadoop.jarsInDistributedCache', false)\n" + + "hadoopConf.setProperty('gremlin.hadoop.defaultGraphComputer', 'org.apache.tinkerpop.gremlin.spark.process.computer.SparkGraphComputer')\n" + + "hadoopConf.setProperty('spark.master', 'local[4]')\n" + + "hadoopConf.setProperty('spark.serializer', 'org.apache.spark.serializer.KryoSerializer')\n" + + "hadoopConf.setProperty('spark.kryo.registrator', 'org.apache.tinkerpop.gremlin.spark.structure.io.gryo.GryoRegistrator')\n" + + "\n" + + "graph = HadoopGraph.open(hadoopConf)\n" + + "g = traversal().with(graph).withComputer(SparkGraphComputer)\n"); + + } + + /** + * Executes a block of Gremlin code lines and returns console-style formatted output. + * Multi-line statements (lines ending with {@code .} for method chaining) are joined before + * evaluation. Results are formatted as {@code gremlin> line} followed by {@code ==>result} + * lines, matching the Gremlin Console output format. + * <p> + * When a block contains {@code import} statements, the entire block is evaluated as a single + * script since imports don't persist across separate {@code eval()} calls. + */ + public String execute(final List<String> lines) throws ScriptException { + // reset per-block settings + maxIteration = 100; + // check if block contains import statements — if so, evaluate as a single script + final boolean hasImports = lines.stream().anyMatch(l -> l.trim().startsWith("import ")); + if (hasImports) { + return executeAsScript(lines); + } + return executeLineByLine(lines); + } + + /** + * Evaluates the entire block as a single Groovy script. Used for blocks containing + * {@code import} statements or complex Groovy constructs that don't work with + * line-by-line evaluation. + */ + private String executeAsScript(final List<String> lines) throws ScriptException { + final StringBuilder output = new StringBuilder(); + final StringBuilder script = new StringBuilder(); + + for (final String rawLine : lines) { + final String trimmed = rawLine.replaceAll("(\\s*<\\d+>)+\\s*$", "").trim(); + if (trimmed.isEmpty()) continue; + if (trimmed.startsWith(":")) continue; + if (trimmed.startsWith("//")) continue; + + output.append("gremlin> ").append(trimmed).append("\n"); + script.append(trimmed).append("\n"); + } + + try { + final Object result = engine.eval(script.toString()); + if (result != null) { + formatResult(result, output); + } + } catch (final ScriptException e) { + log.warn("Error evaluating gremlin script block", e); + output.append("ERROR: ").append(e.getMessage()).append("\n"); + } + + return output.toString(); + } + + private String executeLineByLine(final List<String> lines) throws ScriptException { + final StringBuilder output = new StringBuilder(); + final StringBuilder currentStatement = new StringBuilder(); + + for (final String rawLine : lines) { + // strip trailing AsciiDoc callout markers like <1>, <2>, or multiple <5> <6> + final String trimmed = rawLine.replaceAll("(\\s*<\\d+>)+\\s*$", "").trim(); + if (trimmed.isEmpty()) continue; + + // handle :set max-iteration console command + if (trimmed.startsWith(":set max-iteration")) { + try { + maxIteration = Integer.parseInt(trimmed.split("\\s+")[2]); + } catch (final Exception ignored) { } + continue; + } + + // skip other console commands like :plugin, etc. + if (trimmed.startsWith(":")) continue; + + // skip comment lines + if (trimmed.startsWith("//")) continue; + + // accumulate multi-line statements (lines ending with . are continuations) + if (currentStatement.length() == 0) { + output.append("gremlin> ").append(trimmed).append("\n"); + } else { + output.append(" ").append(trimmed).append("\n"); + } + currentStatement.append(trimmed).append("\n"); + + // if line ends with a continuation character, keep accumulating + if (isContinuationLine(trimmed, currentStatement.toString())) { + continue; + } + + // evaluate the complete statement + final String stmt = currentStatement.toString(); + currentStatement.setLength(0); + + try { + final Object result = engine.eval(stmt); + if (result != null) { + formatResult(result, output); + } + } catch (final ScriptException e) { + log.warn("Error evaluating gremlin: {}", stmt, e); + output.append("ERROR: ").append(e.getMessage()).append("\n"); + } + } + + // evaluate any remaining accumulated statement + if (currentStatement.length() > 0) { + try { + final Object result = engine.eval(currentStatement.toString()); + if (result != null) { + formatResult(result, output); + } + } catch (final ScriptException e) { + log.warn("Error evaluating gremlin: {}", currentStatement, e); + } + } + + return output.toString(); + } + + /** + * Returns the raw Gremlin lines suitable for translation — strips comments, callout markers, + * and multi-line continuations into single statements. + */ + public static List<String> extractTranslatableLines(final List<String> lines) { + final List<String> result = new ArrayList<>(); + final StringBuilder current = new StringBuilder(); + + for (String line : lines) { + // strip trailing callout markers like <1>, <2>, or multiple <5> <6> + line = line.replaceAll("(\\s*<\\d+>)+\\s*$", "").trim(); + if (line.isEmpty() || line.startsWith("//") || line.startsWith(":")) continue; + + current.append(line).append("\n"); + + if (!isContinuationLine(line, current.toString())) { + result.add(current.toString().trim()); + current.setLength(0); + } + } + + if (current.length() > 0) { + result.add(current.toString().trim()); + } + + return result; + } + + /** + * Determines if the current accumulated statement is incomplete and needs more lines. + * Used by both {@link #executeLineByLine} and {@link #extractTranslatableLines}. + * <p> + * Note: counts brackets naively without respecting string literals. + * Sufficient for typical Gremlin doc examples. + */ + static boolean isContinuationLine(final String trimmedLine, final String accumulated) { + if (trimmedLine.endsWith(".") || trimmedLine.endsWith("{") || trimmedLine.endsWith(",") || + trimmedLine.endsWith("(") || trimmedLine.endsWith("\\")) { + return true; + } + return countChar(accumulated, '(') > countChar(accumulated, ')') || + countChar(accumulated, '[') > countChar(accumulated, ']') || + countChar(accumulated, '{') > countChar(accumulated, '}'); + } + + @Override + public void close() { + // GremlinGroovyScriptEngine does not implement Closeable/AutoCloseable. + // Clean up graph and Hadoop/Spark resources if they were initialized. + try { engine.eval("if (graph != null && graph instanceof AutoCloseable) graph.close()"); } + catch (final Exception ignored) { } + if (hadoopInitialized) { + try { + engine.eval("org.apache.tinkerpop.gremlin.spark.process.computer.SparkGraphComputer.close()"); + } catch (final Exception ignored) { } + } + } + + private void formatResult(final Object result, final StringBuilder output) { + if (result instanceof Iterator) { + final Iterator<?> iter = (Iterator<?>) result; + int count = 0; + while (iter.hasNext() && count < maxIteration) { + output.append("==>").append(iter.next()).append("\n"); + count++; + } + } else if (result instanceof Iterable) { + int count = 0; + for (final Object item : (Iterable<?>) result) { + if (count >= maxIteration) break; + output.append("==>").append(item).append("\n"); + count++; + } + } else if (result instanceof Stream) { + ((Stream<?>) result).limit(maxIteration) + .forEach(item -> output.append("==>").append(item).append("\n")); + } else { + output.append("==>").append(result).append("\n"); + } + } + + private static String capitalize(final String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + private static int countChar(final String s, final char c) { + int count = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == c) count++; + } + return count; + } +} diff --git a/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeProcessor.java b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeProcessor.java new file mode 100644 index 0000000000..5562b3bb0a --- /dev/null +++ b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/GremlinTreeProcessor.java @@ -0,0 +1,380 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.tinkerpop.gremlin.docs; + +import org.asciidoctor.ast.Block; +import org.asciidoctor.ast.Document; +import org.asciidoctor.ast.StructuralNode; +import org.asciidoctor.extension.Treeprocessor; +import org.apache.tinkerpop.gremlin.language.translator.Translator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AsciidoctorJ {@link Treeprocessor} that processes {@code [gremlin-groovy,modern]} code blocks + * in TinkerPop documentation. For each such block, it: + * <ol> + * <li>Executes the Gremlin code in an embedded {@link GremlinExecutor} and captures console output</li> + * <li>Translates the canonical Gremlin to all language variants via {@link VariantTranslator}</li> + * <li>Wraps the console output and translations in a tabbed UI with proper AST listing blocks + * so Asciidoctor applies syntax highlighting via CodeRay</li> + * </ol> + */ +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"); + + try (final GremlinExecutor executor = new GremlinExecutor()) { + processNode(document, executor, dryRun); + } + + return document; + } + + private void processNode(final StructuralNode node, final GremlinExecutor executor, final boolean dryRun) { + final List<StructuralNode> blocks = node.getBlocks(); + if (blocks == null || blocks.isEmpty()) return; + + for (int i = 0; i < blocks.size(); i++) { + final StructuralNode child = blocks.get(i); + + if (child instanceof Block && isGremlinBlock((Block) child)) { + i = processGremlinBlock(node, i, (Block) child, executor, dryRun); + } else if (child instanceof Block && isTabStartBlock((Block) child)) { + i = processStandaloneTabGroup(node, i); + } else { + processNode(child, executor, dryRun); + } + } + } + + /** + * Replaces a gremlin block with a sequence of AST nodes that form a tabbed view: + * passthrough HTML for the tab structure interleaved with real listing blocks that + * Asciidoctor will syntax-highlight. + */ + private int processGremlinBlock(final StructuralNode parent, final int index, + final Block block, final GremlinExecutor executor, + final boolean dryRun) { + final Matcher m = GREMLIN_STYLE.matcher(block.getStyle()); + if (!m.matches()) return index; + + final String lang = m.group(1); + final String graph = getGraphAttribute(block); + final boolean hadoop = isHadoopBlock(block); + final List<String> lines = block.getLines(); + + log.info("Processing [gremlin-{},{}{}] block ({} lines)", lang, + graph != null ? graph : "", hadoop ? ",hadoop" : "", lines.size()); + + // execute the gremlin code + String consoleOutput; + if (dryRun || isConsoleCommandBlock(lines)) { + consoleOutput = formatDryRun(lines); + } else { + try { + executor.initGraph(graph, hadoop); + consoleOutput = executor.execute(lines); + } catch (final Exception e) { + log.error("Failed to execute gremlin block", e); + consoleOutput = formatDryRun(lines); + } + } + + // collect tab entries: label + language + code content + final List<TabEntry> tabs = new ArrayList<>(); + tabs.add(new TabEntry("console", "groovy", consoleOutput)); + + // translate to language variants (available on 4.0+ with ANTLR-based translator) + final List<String> translatableLines = GremlinExecutor.extractTranslatableLines(lines); + if (!translatableLines.isEmpty()) { + final Map<Translator, String> translations = VariantTranslator.translateBlock(translatableLines); + for (final Map.Entry<Translator, String> entry : translations.entrySet()) { + tabs.add(new TabEntry( + VariantTranslator.getDisplayName(entry.getKey()), + VariantTranslator.getSourceLanguage(entry.getKey()), + entry.getValue())); + } + } + + // consume any following [source,LANG,tab] blocks + final List<StructuralNode> siblings = parent.getBlocks(); + int nextIndex = index + 1; + while (nextIndex < siblings.size()) { + final StructuralNode next = siblings.get(nextIndex); + if (next instanceof Block && isManualTabBlock((Block) next)) { + final Block tabBlock = (Block) next; + final String tabLang = getSourceLanguage(tabBlock); + tabs.add(new TabEntry( + tabLang != null ? tabLang : "code", + tabLang, + String.join("\n", tabBlock.getLines()))); + nextIndex++; + } else { + break; + } + } + + // build the replacement AST nodes + final List<StructuralNode> replacements = buildTabbedBlocks(parent, tabs); + + // replace original block and consumed tab blocks with the new sequence + // remove consumed blocks first (backwards to preserve indices) + for (int j = nextIndex - 1; j > index; j--) { + siblings.remove(j); + } + // remove the original gremlin block + siblings.remove(index); + // insert replacements at the same position + siblings.addAll(index, replacements); + + // return last index of inserted blocks so the loop continues after them + return index + replacements.size() - 1; + } + + /** + * Builds a sequence of AST blocks: passthrough HTML for tab structure interleaved + * with real listing blocks for syntax-highlighted code. + */ + private List<StructuralNode> buildTabbedBlocks(final StructuralNode parent, final List<TabEntry> tabs) { + final List<StructuralNode> nodes = new ArrayList<>(); + + final long id = counter.incrementAndGet(); + final int numTabs = tabs.size(); + + // opening HTML: section + radio buttons + labels + first tab content div open + final StringBuilder openHtml = new StringBuilder(); + openHtml.append("<section class=\"tabs tabs-").append(numTabs).append("\">\n"); + for (int i = 0; i < numTabs; i++) { + final int tabNum = i + 1; + final String checked = (i == 0) ? " checked=\"checked\"" : ""; + openHtml.append(" <input id=\"tab-").append(id).append("-").append(tabNum) + .append("\" type=\"radio\" name=\"radio-set-").append(id) + .append("\" class=\"tab-selector-").append(tabNum).append("\"") + .append(checked).append(" />\n"); + openHtml.append(" <label for=\"tab-").append(id).append("-").append(tabNum) + .append("\" class=\"tab-label-").append(tabNum).append("\">") + .append(tabs.get(i).label).append("</label>\n"); + } + openHtml.append(" <div class=\"tabcontent\">\n <div class=\"tabcontent-1\">\n"); + nodes.add(createBlock(parent, "pass", openHtml.toString())); + + // first tab content (listing block) + nodes.add(createListingBlock(parent, tabs.get(0).language, tabs.get(0).content)); + + // remaining tabs: close previous div, open next div, listing block + for (int i = 1; i < numTabs; i++) { + final int tabNum = i + 1; + final String divHtml = " </div>\n </div>\n" + + " <div class=\"tabcontent\">\n <div class=\"tabcontent-" + tabNum + "\">\n"; + nodes.add(createBlock(parent, "pass", divHtml)); + nodes.add(createListingBlock(parent, tabs.get(i).language, tabs.get(i).content)); + } + + // closing HTML + nodes.add(createBlock(parent, "pass", " </div>\n </div>\n</section>")); + + return nodes; + } + + /** + * Creates a proper Asciidoctor source listing block by parsing AsciiDoc markup. + * This ensures CodeRay syntax highlighting is applied, since the block goes through + * Asciidoctor's normal parsing pipeline. + */ + private Block createListingBlock(final StructuralNode parent, final String language, final String content) { + final List<String> lines = new ArrayList<>(); + lines.add("[source," + language + "]"); + lines.add("----"); + for (final String line : content.split("\n", -1)) { + lines.add(line); + } + lines.add("----"); + final int sizeBefore = parent.getBlocks().size(); + parseContent(parent, lines); + final List<StructuralNode> blocks = parent.getBlocks(); + if (blocks.size() > sizeBefore) { + return (Block) blocks.remove(blocks.size() - 1); + } + // fallback if parseContent produced nothing + return (Block) createBlock(parent, "listing", content); + } + + private boolean isGremlinBlock(final Block block) { + final String style = block.getStyle(); + return style != null && GREMLIN_STYLE.matcher(style).matches(); + } + + /** + * Checks if a block starts a standalone tab group: {@code [source,LANG,tab]}. + */ + private boolean isTabStartBlock(final Block block) { + if (!"source".equals(block.getStyle())) return false; + final Map<String, Object> attrs = block.getAttributes(); + // "tab" can appear as attribute "2" or "3" depending on how asciidoctor parses positions + return "tab".equals(attrs.get("2")) || "tab".equals(attrs.get("3")); + } + + /** + * Checks if a block is a continuation of a tab group: a {@code [source,LANG]} block + * whose language hasn't already been seen in the group. + */ + private boolean isTabContinuationBlock(final Block block, final java.util.Set<String> seenLanguages) { + if (!"source".equals(block.getStyle())) return false; + final String lang = getSourceLanguage(block); + return lang != null && !seenLanguages.contains(lang); + } + + private boolean isManualTabBlock(final Block block) { + if (!"source".equals(block.getStyle())) return false; + final Map<String, Object> attrs = block.getAttributes(); + return "tab".equals(attrs.get("2")) || "tab".equals(attrs.get("3")); + } + + /** + * Processes a standalone tab group starting with {@code [source,LANG,tab]} and collecting + * all consecutive {@code [source,LANG]} blocks into a tabbed view. + */ + private int processStandaloneTabGroup(final StructuralNode parent, final int index) { + final List<StructuralNode> siblings = parent.getBlocks(); + final List<TabEntry> tabs = new ArrayList<>(); + + // collect the first block and all consecutive source blocks + final Set<String> seenLanguages = new HashSet<>(); + int nextIndex = index; + while (nextIndex < siblings.size()) { + final StructuralNode node = siblings.get(nextIndex); + if (!(node instanceof Block)) break; + final Block block = (Block) node; + + if (nextIndex == index) { + // first block must be a tab-start block + if (!isTabStartBlock(block)) break; + } else { + // subsequent blocks must be source blocks with a unique language + if (!isTabContinuationBlock(block, seenLanguages)) break; + } + + final String lang = getSourceLanguage(block); + final String label = lang != null ? lang : "code"; + if (lang != null) seenLanguages.add(lang); + tabs.add(new TabEntry(label, lang, String.join("\n", block.getLines()))); + nextIndex++; + } + + if (tabs.size() <= 1) return index; // not enough blocks for tabs + + log.info("Processing standalone tab group ({} tabs)", tabs.size()); + + final List<StructuralNode> replacements = buildTabbedBlocks(parent, tabs); + + // remove original blocks (backwards) + for (int j = nextIndex - 1; j >= index; j--) { + siblings.remove(j); + } + siblings.addAll(index, replacements); + + return index + replacements.size() - 1; + } + + private String getGraphAttribute(final Block block) { + final Map<String, Object> attrs = block.getAttributes(); + Object attr = attrs.get("2"); + if (attr == null) attr = attrs.get(2); + if (attr == null || "false".equals(attr.toString()) || attr.toString().isEmpty()) return null; + return attr.toString(); + } + + /** + * Checks if a gremlin block has the "hadoop" attribute, e.g. {@code [gremlin-groovy,modern,hadoop]}. + * The "hadoop" flag appears as the third positional attribute. + */ + private boolean isHadoopBlock(final Block block) { + final Map<String, Object> attrs = block.getAttributes(); + Object attr = attrs.get("3"); + if (attr == null) attr = attrs.get(3); + return "hadoop".equals(attr != null ? attr.toString() : null); + } + + private String getSourceLanguage(final Block block) { + // For [source,LANG], asciidoctor may store the language in "language" attr, + // attribute "1" (which may contain "source"), or attribute "2". + final Object langAttr = block.getAttribute("language"); + if (langAttr != null) return langAttr.toString(); + final Map<String, Object> attrs = block.getAttributes(); + // attribute "1" is often the style name itself; "2" has the language + final Object attr2 = attrs.get("2"); + if (attr2 != null && !"tab".equals(attr2.toString()) && !"false".equals(attr2.toString())) { + return attr2.toString(); + } + final Object attr1 = attrs.get("1"); + if (attr1 != null && !"source".equals(attr1.toString())) return attr1.toString(); + return null; + } + + /** + * Detects blocks that contain console commands ({@code :remote}, {@code :>}, + * {@code :submit}) which cannot be executed in an embedded engine. These are rendered + * as static code blocks with {@code gremlin>} prompts. + */ + private static boolean isConsoleCommandBlock(final List<String> lines) { + for (final String line : lines) { + final String trimmed = line.trim(); + if (trimmed.startsWith(":remote") || trimmed.startsWith(":>") || trimmed.startsWith(":submit")) { + return true; + } + } + return false; + } + + private static String formatDryRun(final List<String> lines) { + final StringBuilder sb = new StringBuilder(); + for (final String line : lines) { + sb.append("gremlin> ").append(line).append("\n"); + } + return sb.toString(); + } + + private static class TabEntry { + final String label; + final String language; + final String content; + + TabEntry(final String label, final String language, final String content) { + this.label = label; + this.language = language; + this.content = content; + } + } +} diff --git a/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/VariantTranslator.java b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/VariantTranslator.java new file mode 100644 index 0000000000..ef5bd8b64a --- /dev/null +++ b/gremlin-docs/src/main/java/org/apache/tinkerpop/gremlin/docs/VariantTranslator.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.tinkerpop.gremlin.docs; + +import org.apache.tinkerpop.gremlin.language.translator.GremlinTranslator; +import org.apache.tinkerpop.gremlin.language.translator.Translation; +import org.apache.tinkerpop.gremlin.language.translator.Translator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Translates canonical Gremlin into all supported language variants using {@link GremlinTranslator}. + */ +public class VariantTranslator { + + private static final Logger log = LoggerFactory.getLogger(VariantTranslator.class); + + /** + * The language variants to generate, in display order. Excludes CANONICAL, ANONYMIZED, GROOVY + * (which is essentially the same as the console output), and LANGUAGE (deprecated). + */ + static final List<Translator> VARIANT_LANGUAGES = Collections.unmodifiableList(Arrays.asList( + Translator.JAVA, + Translator.PYTHON, + Translator.JAVASCRIPT, + Translator.DOTNET, + Translator.GO + )); + + /** + * Display names for tab labels. + */ + private static final Map<Translator, String> DISPLAY_NAMES = new LinkedHashMap<>(); + static { + DISPLAY_NAMES.put(Translator.JAVA, "java"); + DISPLAY_NAMES.put(Translator.PYTHON, "python"); + DISPLAY_NAMES.put(Translator.JAVASCRIPT, "javascript"); + DISPLAY_NAMES.put(Translator.DOTNET, "c#"); + DISPLAY_NAMES.put(Translator.GO, "go"); + } + + /** + * Asciidoc source language identifiers for syntax highlighting. + */ + private static final Map<Translator, String> SOURCE_LANGUAGES = new LinkedHashMap<>(); + static { + SOURCE_LANGUAGES.put(Translator.JAVA, "java"); + SOURCE_LANGUAGES.put(Translator.PYTHON, "python"); + SOURCE_LANGUAGES.put(Translator.JAVASCRIPT, "javascript"); + SOURCE_LANGUAGES.put(Translator.DOTNET, "csharp"); + SOURCE_LANGUAGES.put(Translator.GO, "go"); + } + + public static String getDisplayName(final Translator translator) { + return DISPLAY_NAMES.getOrDefault(translator, translator.getName().toLowerCase()); + } + + public static String getSourceLanguage(final Translator translator) { + return SOURCE_LANGUAGES.getOrDefault(translator, translator.getName().toLowerCase()); + } + + /** + * Translates a single Gremlin statement to all variant languages. Returns a map from + * {@link Translator} to the translated code string. Statements that fail to parse + * (e.g. those containing lambdas or non-standard Groovy) are skipped with a warning. + */ + public static Map<Translator, String> translateStatement(final String gremlin) { + final Map<Translator, String> results = new LinkedHashMap<>(); + for (final Translator lang : VARIANT_LANGUAGES) { + try { + final Translation t = GremlinTranslator.translate(gremlin, "g", lang); + results.put(lang, t.getTranslated()); + } catch (final Exception e) { + log.debug("Cannot translate to {}: {} — {}", lang.getName(), gremlin, e.getMessage()); + } + } + return results; + } + + /** + * Translates multiple Gremlin statements and joins them with newlines per language. + * If any statement fails to translate for a given language, that language is omitted entirely. + */ + public static Map<Translator, String> translateBlock(final List<String> statements) { + final Map<Translator, String> results = new LinkedHashMap<>(); + + for (final Translator lang : VARIANT_LANGUAGES) { + final StringBuilder sb = new StringBuilder(); + boolean allTranslated = true; + + for (final String stmt : statements) { + try { + final Translation t = GremlinTranslator.translate(stmt, "g", lang); + if (sb.length() > 0) sb.append("\n"); + sb.append(t.getTranslated()); + } catch (final Exception e) { + log.debug("Cannot translate to {}: {} — {}", lang.getName(), stmt, e.getMessage()); + allTranslated = false; + break; + } + } + + if (allTranslated) { + results.put(lang, sb.toString()); + } + } + + return results; + } +} diff --git a/gremlin-docs/src/main/resources/META-INF/services/org.asciidoctor.jruby.extension.spi.ExtensionRegistry b/gremlin-docs/src/main/resources/META-INF/services/org.asciidoctor.jruby.extension.spi.ExtensionRegistry new file mode 100644 index 0000000000..6a81ac1f60 --- /dev/null +++ b/gremlin-docs/src/main/resources/META-INF/services/org.asciidoctor.jruby.extension.spi.ExtensionRegistry @@ -0,0 +1 @@ +org.apache.tinkerpop.gremlin.docs.GremlinDocsExtension diff --git a/gremlin-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutorTest.java b/gremlin-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutorTest.java new file mode 100644 index 0000000000..c03f6d2d45 --- /dev/null +++ b/gremlin-docs/src/test/java/org/apache/tinkerpop/gremlin/docs/GremlinExecutorTest.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.tinkerpop.gremlin.docs; + +import org.apache.tinkerpop.gremlin.language.translator.Translator; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class GremlinExecutorTest { + + @Test + public void shouldExecuteSimpleTraversal() throws Exception { + try (final GremlinExecutor executor = new GremlinExecutor()) { + executor.initGraph("modern"); + final String output = executor.execute(Arrays.asList("g.V().count()")); + assertTrue(output.contains("gremlin> g.V().count()")); + assertTrue(output.contains("==>6")); + } + } + + @Test + public void shouldMaintainStateBetweenExecutions() throws Exception { + try (final GremlinExecutor executor = new GremlinExecutor()) { + executor.initGraph("modern"); + executor.execute(Arrays.asList("x = g.V().has('name','marko').next()")); + + // "existing" should reuse the graph and bindings + executor.initGraph("existing"); + final String output = executor.execute(Arrays.asList("x.value('name')")); + assertTrue(output.contains("==>marko")); + } + } + + @Test + public void shouldExecuteMultipleLines() throws Exception { + try (final GremlinExecutor executor = new GremlinExecutor()) { + executor.initGraph("modern"); + final String output = executor.execute(Arrays.asList( + "g.V().has('name','marko').values('name')", + "g.V().has('name','marko').out('knows').values('name')" + )); + assertTrue(output.contains("==>marko")); + assertTrue(output.contains("==>josh")); + assertTrue(output.contains("==>vadas")); + } + } + + @Test + public void shouldExtractTranslatableLines() { + final List<String> lines = Arrays.asList( + "g.V().has('name','marko'). <1>", + " out('knows').values('name') <2>", + "// this is a comment", + "g.V().count()" + ); + final List<String> result = GremlinExecutor.extractTranslatableLines(lines); + assertEquals(2, result.size()); + assertEquals("g.V().has('name','marko').\nout('knows').values('name')", result.get(0)); + assertEquals("g.V().count()", result.get(1)); + } + + @Test + public void shouldTranslateToVariants() { + final Map<Translator, String> translations = VariantTranslator.translateStatement( + "g.V().has('name','marko').out('knows').values('name')"); + + assertFalse(translations.isEmpty()); + assertTrue(translations.containsKey(Translator.PYTHON)); + assertTrue(translations.containsKey(Translator.JAVA)); + assertTrue(translations.containsKey(Translator.JAVASCRIPT)); + assertTrue(translations.containsKey(Translator.DOTNET)); + assertTrue(translations.containsKey(Translator.GO)); + + // python should use snake_case + assertTrue(translations.get(Translator.PYTHON).contains("has(")); + assertTrue(translations.get(Translator.PYTHON).contains("out(")); + } + + @Test + public void shouldTranslateBlock() { + final List<String> statements = Arrays.asList( + "g.V().has('name','marko').out('knows').values('name')", + "g.V().count()" + ); + final Map<Translator, String> translations = VariantTranslator.translateBlock(statements); + + assertFalse(translations.isEmpty()); + // each translation should contain both statements + for (final String code : translations.values()) { + assertTrue(code.contains("\n")); + } + } + + @Test + public void shouldInitEmptyGraph() throws Exception { + try (final GremlinExecutor executor = new GremlinExecutor()) { + executor.initGraph(null); + final String output = executor.execute(Arrays.asList("g.V().count()")); + assertTrue(output.contains("==>0")); + } + } + + @Test + public void shouldInitEmptyStringGraph() throws Exception { + try (final GremlinExecutor executor = new GremlinExecutor()) { + executor.initGraph(""); + final String output = executor.execute(Arrays.asList("g.V().count()")); + assertTrue(output.contains("==>0")); + } + } + + @Test + public void shouldSkipUntranslatableStatements() { + // lambdas can't be translated + final Map<Translator, String> translations = VariantTranslator.translateStatement( + "g.V().filter{it.get().label() == 'person'}"); + // should either be empty or have partial results — not throw + assertNotNull(translations); + } +} diff --git a/pom.xml b/pom.xml index aaef1b2fde..03d4878173 100644 --- a/pom.xml +++ b/pom.xml @@ -956,9 +956,9 @@ limitations under the License. </activation> <properties> - <!-- the source asciidocs are copied from /docs to asciidoc.source.dir by bin/process-docs.sh - where they can the scripts embedded in the text are then executed and their results shoved - back into the doc --> + <!-- When using the gremlin-docs AsciidoctorJ extension, point directly at docs/src + by passing -Dasciidoc.source.dir=docs/src on the command line. The old preprocessor + pipeline writes to target/postprocess-asciidoc which remains the default. --> <asciidoc.source.dir>${project.basedir}/target/postprocess-asciidoc</asciidoc.source.dir> <!-- once code processing is over, the documents are basically ready for the formatter and are @@ -1035,6 +1035,21 @@ limitations under the License. <groupId>org.asciidoctor</groupId> <artifactId>asciidoctor-maven-plugin</artifactId> <inherited>false</inherited> + <dependencies> + <dependency> + <groupId>org.apache.tinkerpop</groupId> + <artifactId>gremlin-docs</artifactId> + <version>${project.version}</version> + </dependency> + <!-- Force commons-text version to match what gremlin-core needs. + The asciidoctor plugin bundles commons-text:1.3 which conflicts + with commons-configuration2's requirement for 1.15.0. --> + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-text</artifactId> + <version>1.15.0</version> + </dependency> + </dependencies> <executions> <execution> <id>home</id> @@ -1065,7 +1080,9 @@ limitations under the License. <encoding>UTF-8</encoding> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1092,7 +1109,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1119,7 +1138,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1146,7 +1167,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1173,7 +1196,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1200,7 +1225,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1227,7 +1254,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1254,7 +1283,9 @@ limitations under the License. <toc-position>left</toc-position> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1279,7 +1310,9 @@ limitations under the License. <encoding>UTF-8</encoding> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1305,7 +1338,9 @@ limitations under the License. <encoding>UTF-8</encoding> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1331,7 +1366,9 @@ limitations under the License. <encoding>UTF-8</encoding> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir> @@ -1356,7 +1393,9 @@ limitations under the License. <encoding>UTF-8</encoding> <stylesdir>${asciidoctor.style.dir}</stylesdir> <stylesheet>tinkerpop.css</stylesheet> - <source-highlighter>coderay</source-highlighter> + <source-highlighter>highlightjs</source-highlighter> + <highlightjsdir>https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0</highlightjsdir> + <highlightjs-languages>groovy</highlightjs-languages> <basedir>${project.basedir}</basedir> <docinfo>shared</docinfo> <docinfodir>${project.basedir}/docs/src</docinfodir>
