Browse Source

ESQL: Generate railroad diagrams for operators (#103143)

This enables the generation of railroad diagrams for unary minus and a
bunch of binary operators like `+`, `-`, `%`, and `>=`.

Relates to #100558
Nik Everett 1 year ago
parent
commit
6e0c031342

+ 9 - 1
docs/reference/esql/functions/binary.asciidoc

@@ -9,4 +9,12 @@ These binary comparison operators are supported:
 * less than: `<`
 * less than or equal: `<=`
 * larger than: `>`
-* larger than or equal: `>=`
+* larger than or equal: `>=`
+
+And these mathematical operators are supported:
+
+[.text-center]
+image::esql/functions/signature/add.svg[Embedded,opts=inline]
+
+[.text-center]
+image::esql/functions/signature/sub.svg[Embedded,opts=inline]

+ 2 - 0
docs/reference/esql/functions/operators.asciidoc

@@ -9,6 +9,7 @@ Boolean operators for comparing against one or multiple expressions.
 
 // tag::op_list[]
 * <<esql-binary-operators>>
+* <<esql-unary-operators>>
 * <<esql-logical-operators>>
 * <<esql-predicates>>
 * <<esql-cidr_match>>
@@ -23,6 +24,7 @@ Boolean operators for comparing against one or multiple expressions.
 // end::op_list[]
 
 include::binary.asciidoc[]
+include::unary.asciidoc[]
 include::logical.asciidoc[]
 include::predicates.asciidoc[]
 include::cidr_match.asciidoc[]

+ 1 - 0
docs/reference/esql/functions/signature/add.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">+</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/div.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">/</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/equals.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="186" height="46" viewbox="0 0 186 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m44 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="44" height="36" rx="7"/><text class="syn" x="81" y="31">==</text><rect class="l" x="125" y="5" width="56" height="36" rx="7"/><text class="j" x="135" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/greater_than.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">&gt;</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/less_than.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">&lt;</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/mod.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">%</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/mul.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">*</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/neg.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="84" height="46" viewbox="0 0 84 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m32 0h10m32 0h5"/><rect class="l" x="5" y="5" width="32" height="36" rx="7"/><text class="syn" x="15" y="31">-</text><rect class="l" x="47" y="5" width="32" height="36" rx="7"/><text class="j" x="57" y="31">v</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/not_equals.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="186" height="46" viewbox="0 0 186 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m44 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="44" height="36" rx="7"/><text class="syn" x="81" y="31">!=</text><rect class="l" x="125" y="5" width="56" height="36" rx="7"/><text class="j" x="135" y="31">rhs</text></svg>

+ 1 - 0
docs/reference/esql/functions/signature/sub.svg

@@ -0,0 +1 @@
+<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="174" height="46" viewbox="0 0 174 46"><defs><style type="text/css">#guide .c{fill:none;stroke:#222222;}#guide .j{fill:#000000;font-family:Roboto Mono,Sans-serif;font-size:20px;}#guide .l{fill:#e4f4ff;stroke:#222222;}#guide .syn{fill:#8D8D8D;font-family:Roboto Mono,Sans-serif;font-size:20px;}</style></defs><path class="c" d="M0 31h5m56 0h10m32 0h10m56 0h5"/><rect class="l" x="5" y="5" width="56" height="36" rx="7"/><text class="j" x="15" y="31">lhs</text><rect class="l" x="71" y="5" width="32" height="36" rx="7"/><text class="syn" x="81" y="31">-</text><rect class="l" x="113" y="5" width="56" height="36" rx="7"/><text class="j" x="123" y="31">rhs</text></svg>

+ 8 - 0
docs/reference/esql/functions/unary.asciidoc

@@ -0,0 +1,8 @@
+[discrete]
+[[esql-unary-operators]]
+=== Unary operators
+
+These unary mathematical operators are supported:
+
+[.text-center]
+image::esql/functions/signature/neg.svg[Embedded,opts=inline]

+ 89 - 40
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/AbstractFunctionTestCase.java

@@ -36,6 +36,7 @@ import org.elasticsearch.xpack.esql.evaluator.EvalMapper;
 import org.elasticsearch.xpack.esql.expression.function.scalar.conditional.Greatest;
 import org.elasticsearch.xpack.esql.expression.function.scalar.nulls.Coalesce;
 import org.elasticsearch.xpack.esql.optimizer.FoldNull;
+import org.elasticsearch.xpack.esql.parser.ExpressionBuilder;
 import org.elasticsearch.xpack.esql.planner.Layout;
 import org.elasticsearch.xpack.esql.planner.PlannerUtils;
 import org.elasticsearch.xpack.esql.type.EsqlDataTypes;
@@ -158,8 +159,9 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
     /**
      * Build the expression being tested, for the given source and list of arguments.  Test classes need to implement this
      * to have something to test.
+     *
      * @param source the source
-     * @param args arg list from the test case, should match the length expected
+     * @param args   arg list from the test case, should match the length expected
      * @return an expression for evaluating the function being tested on the given arguments
      */
     protected abstract Expression build(Source source, List<Expression> args);
@@ -256,8 +258,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
     /**
      * Evaluates a {@link Block} of values, all copied from the input pattern, read directly from the page.
      * <p>
-     *     Note that this'll sometimes be a {@link Vector} of values if the
-     *     input pattern contained only a single value.
+     * Note that this'll sometimes be a {@link Vector} of values if the
+     * input pattern contained only a single value.
      * </p>
      */
     public final void testEvaluateBlockWithoutNulls() {
@@ -267,8 +269,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
     /**
      * Evaluates a {@link Block} of values, all copied from the input pattern, read from an intermediate operator.
      * <p>
-     *     Note that this'll sometimes be a {@link Vector} of values if the
-     *     input pattern contained only a single value.
+     * Note that this'll sometimes be a {@link Vector} of values if the
+     * input pattern contained only a single value.
      * </p>
      */
     public final void testEvaluateBlockWithoutNullsFloating() {
@@ -296,8 +298,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
      * read directly from the {@link Page}, using the
      * {@link CrankyCircuitBreakerService} which fails randomly.
      * <p>
-     *     Note that this'll sometimes be a {@link Vector} of values if the
-     *     input pattern contained only a single value.
+     * Note that this'll sometimes be a {@link Vector} of values if the
+     * input pattern contained only a single value.
      * </p>
      */
     public final void testCrankyEvaluateBlockWithoutNulls() {
@@ -314,8 +316,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
      * read from an intermediate operator, using the
      * {@link CrankyCircuitBreakerService} which fails randomly.
      * <p>
-     *     Note that this'll sometimes be a {@link Vector} of values if the
-     *     input pattern contained only a single value.
+     * Note that this'll sometimes be a {@link Vector} of values if the
+     * input pattern contained only a single value.
      * </p>
      */
     public final void testCrankyEvaluateBlockWithoutNullsFloating() {
@@ -544,7 +546,7 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
             LogManager.getLogger(getTestClass()).info("Skipping function info checks because we're running a portion of the tests");
             return;
         }
-        FunctionDefinition definition = definition();
+        FunctionDefinition definition = definition(functionName());
         if (definition == null) {
             LogManager.getLogger(getTestClass()).info("Skipping function info checks because the function isn't registered");
             return;
@@ -588,14 +590,15 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
     /**
      * Adds cases with {@code null} and asserts that the result is {@code null}.
      * <p>
-     *     Note: This won't add more than a single null to any existing test case,
-     *     just to keep the number of test cases from exploding totally.
+     * Note: This won't add more than a single null to any existing test case,
+     * just to keep the number of test cases from exploding totally.
      * </p>
-     * @param  entirelyNullPreservesType should a test case that only contains parameters
-     *                                   with the {@code null} type keep it's expected type?
-     *                                   This is <strong>mostly</strong> going to be {@code true}
-     *                                   except for functions that base their type entirely
-     *                                   on input types like {@link Greatest} or {@link Coalesce}.
+     *
+     * @param entirelyNullPreservesType should a test case that only contains parameters
+     *                                  with the {@code null} type keep it's expected type?
+     *                                  This is <strong>mostly</strong> going to be {@code true}
+     *                                  except for functions that base their type entirely
+     *                                  on input types like {@link Greatest} or {@link Coalesce}.
      */
     protected static List<TestCaseSupplier> anyNullIsNull(boolean entirelyNullPreservesType, List<TestCaseSupplier> testCaseSuppliers) {
         typesRequired(testCaseSuppliers);
@@ -691,8 +694,7 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
              * the full combinatorial explosions of all nulls - just a single null.
              * Hopefully <null>, <null> cases will function the same as <null>, <valid>
              * cases.
-             */
-            .filter(types -> types.stream().filter(t -> t == DataTypes.NULL).count() <= 1)
+             */.filter(types -> types.stream().filter(t -> t == DataTypes.NULL).count() <= 1)
             .map(types -> typeErrorSupplier(validPerPosition.size() != 1, validPerPosition, types))
             .forEach(suppliers::add);
         return suppliers;
@@ -911,30 +913,44 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
             LogManager.getLogger(getTestClass()).info("Skipping rendering signature because we're running a portion of the tests");
             return;
         }
-        FunctionDefinition definition = definition();
-        if (definition == null) {
+        String rendered = buildSignatureSvg(functionName());
+        if (rendered == null) {
             LogManager.getLogger(getTestClass()).info("Skipping rendering signature because the function isn't registered");
-            return;
+        } else {
+            LogManager.getLogger(getTestClass()).info("Writing function signature");
+            writeToTempDir("signature", rendered, "svg");
         }
+    }
 
-        String rendered = RailRoadDiagram.functionSignature(definition);
-        LogManager.getLogger(getTestClass()).info("Writing function signature");
-        writeToTempDir("signature", rendered, "svg");
+    private static String buildSignatureSvg(String name) throws IOException {
+        String binaryOperator = binaryOperator(name);
+        if (binaryOperator != null) {
+            return RailRoadDiagram.binaryOperator(binaryOperator);
+        }
+        String unaryOperator = unaryOperator(name);
+        if (unaryOperator != null) {
+            return RailRoadDiagram.unaryOperator(unaryOperator);
+        }
+        FunctionDefinition definition = definition(name);
+        if (definition != null) {
+            return RailRoadDiagram.functionSignature(definition);
+        }
+        return null;
     }
 
     /**
      * Unique signatures encountered by this test.
      * <p>
-     *     We clear this at the beginning of the test class with
-     *     {@link #clearSignatures} out of paranoia. It <strong>is</strong>
-     *     shared by many tests, after all.
+     * We clear this at the beginning of the test class with
+     * {@link #clearSignatures} out of paranoia. It <strong>is</strong>
+     * shared by many tests, after all.
      * </p>
      * <p>
-     *     After each test method we add the signature it operated on via
-     *     {@link #trackSignature}. Once the test class is done we render
-     *     all the unique signatures to a temp file with {@link #renderTypesTable}.
-     *     We use a temp file because that's all we're allowed to write to.
-     *     Gradle will move the files into the docs after this is done.
+     * After each test method we add the signature it operated on via
+     * {@link #trackSignature}. Once the test class is done we render
+     * all the unique signatures to a temp file with {@link #renderTypesTable}.
+     * We use a temp file because that's all we're allowed to write to.
+     * Gradle will move the files into the docs after this is done.
      * </p>
      */
     private static final Map<List<DataType>, DataType> signatures = new HashMap<>();
@@ -960,7 +976,8 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
         if (System.getProperty("generateDocs") == null) {
             return;
         }
-        FunctionDefinition definition = definition();
+        String name = functionName(); // TODO types table for operators
+        FunctionDefinition definition = definition(name);
         if (definition == null) {
             LogManager.getLogger(getTestClass()).info("Skipping rendering types because the function isn't registered");
             return;
@@ -995,8 +1012,11 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
         writeToTempDir("types", rendered, "asciidoc");
     }
 
-    private static FunctionDefinition definition() {
-        String name = functionName();
+    private static String functionName() {
+        return StringUtils.camelCaseToUnderscore(getTestClass().getSimpleName().replace("Tests", "")).toLowerCase(Locale.ROOT);
+    }
+
+    private static FunctionDefinition definition(String name) {
         EsqlFunctionRegistry registry = new EsqlFunctionRegistry();
         if (registry.functionExists(name)) {
             return registry.resolveFunction(name);
@@ -1004,15 +1024,44 @@ public abstract class AbstractFunctionTestCase extends ESTestCase {
         return null;
     }
 
-    private static String functionName() {
-        return StringUtils.camelCaseToUnderscore(getTestClass().getSimpleName().replace("Tests", "")).toLowerCase(Locale.ROOT);
+    /**
+     * If this test is a for a binary operator return its symbol, otherwise return {@code null}.
+     * This is functionally the reverse of the combination of
+     * {@link ExpressionBuilder#visitArithmeticBinary} and {@link ExpressionBuilder#visitComparison}.
+     */
+    private static String binaryOperator(String name) {
+        return switch (name) {
+            case "add" -> "+";
+            case "div" -> "/";
+            case "equals" -> "==";
+            case "greater_than" -> ">";
+            case "greater_than_or_equal_to" -> ">=";
+            case "less_than" -> "<";
+            case "less_than_or_equal_to" -> "<=";
+            case "mod" -> "%";
+            case "mul" -> "*";
+            case "not_equals" -> "!=";
+            case "sub" -> "-";
+            default -> null;
+        };
+    }
+
+    /**
+     * If this tests is for a unary operator return its symbol, otherwise return {@code null}.
+     * This is functionally the reverse of {@link ExpressionBuilder#visitArithmeticUnary}.
+     */
+    private static String unaryOperator(String name) {
+        return switch (name) {
+            case "neg" -> "-";
+            default -> null;
+        };
     }
 
     /**
      * Write some text to a tempdir so we can copy it to the docs later.
      * <p>
-     *     We need to write to a tempdir instead of the docs because the tests
-     *     don't have write permission to the docs.
+     * We need to write to a tempdir instead of the docs because the tests
+     * don't have write permission to the docs.
      * </p>
      */
     private static void writeToTempDir(String subdir, String str, String extension) throws IOException {

+ 68 - 17
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/RailRoadDiagram.java

@@ -42,6 +42,10 @@ public class RailRoadDiagram {
      */
     private static final LazyInitializable<Font, IOException> FONT = new LazyInitializable<>(() -> loadFont().deriveFont(20.0F));
 
+    /**
+     * Generate a railroad diagram for a function. The output would look like
+     * {@code FOO(a, b, c)}.
+     */
     static String functionSignature(FunctionDefinition definition) throws IOException {
         List<Expression> expressions = new ArrayList<>();
         expressions.add(new SpecialSequence(definition.name().toUpperCase(Locale.ROOT)));
@@ -61,10 +65,34 @@ public class RailRoadDiagram {
             }
         }
         expressions.add(new Syntax(")"));
-        net.nextencia.rrdiagram.grammar.model.Expression rr = new Sequence(
-            expressions.toArray(net.nextencia.rrdiagram.grammar.model.Expression[]::new)
-        );
-        RRDiagram rrDiagram = new GrammarToRRDiagram().convert(new Rule("test", rr));
+        return toSvg(new Sequence(expressions.toArray(Expression[]::new)));
+    }
+
+    /**
+     * Generate a railroad diagram for binary operator. The output would look like
+     * {@code lhs + rhs}.
+     */
+    static String binaryOperator(String operator) throws IOException {
+        List<Expression> expressions = new ArrayList<>();
+        expressions.add(new Literal("lhs"));
+        expressions.add(new Syntax(operator));
+        expressions.add(new Literal("rhs"));
+        return toSvg(new Sequence(expressions.toArray(Expression[]::new)));
+    }
+
+    /**
+     * Generate a railroad diagram for unary operator. The output would look like
+     * {@code -v}.
+     */
+    static String unaryOperator(String operator) throws IOException {
+        List<Expression> expressions = new ArrayList<>();
+        expressions.add(new Syntax(operator));
+        expressions.add(new Literal("v"));
+        return toSvg(new Sequence(expressions.toArray(Expression[]::new)));
+    }
+
+    private static String toSvg(Expression exp) throws IOException {
+        RRDiagram rrDiagram = new GrammarToRRDiagram().convert(new Rule("", exp));
 
         RRDiagramToSVG toSvg = new RRDiagramToSVG();
         toSvg.setSpecialSequenceShape(RRDiagramToSVG.BoxShape.RECTANGLE);
@@ -74,20 +102,29 @@ public class RailRoadDiagram {
         toSvg.setLiteralFont(FONT.getOrCompute());
 
         toSvg.setRuleFont(FONT.getOrCompute());
-        /*
-         * "Tighten" the styles in the SVG so they beat the styles sitting in the
-         * main page. We need this because we're embedding the SVG into the page.
-         * We need to embed the SVG into the page so it can get fonts loaded in the
-         * primary stylesheet. We need to load a font so they images are consistent
-         * on all clients.
-         */
-        return toSvg.convert(rrDiagram).replace(".c", "#guide .c").replace(".k", "#guide .k").replace(".s", "#guide .s");
+        return tightenStyles(toSvg.convert(rrDiagram));
+    }
+
+    /**
+     * "Tighten" the styles in the SVG so they beat the styles sitting in the
+     * main page. We need this because we're embedding the SVG into the page.
+     * We need to embed the SVG into the page so it can get fonts loaded in the
+     * primary stylesheet. We need to load a font so they images are consistent
+     * on all clients.
+     */
+    private static String tightenStyles(String svg) {
+        for (String c : new String[] { "c", "k", "s", "j", "l" }) {
+            svg = svg.replace("." + c, "#guide ." + c);
+        }
+        return svg;
     }
 
     /**
      * Like a literal but with light grey text for a more muted appearance for syntax.
      */
     private static class Syntax extends Literal {
+        private static final String LITERAL_CLASS = "l";
+        private static final String SYNTAX_CLASS = "lsyn";
         private static final String LITERAL_TEXT_CLASS = "j";
         private static final String SYNTAX_TEXT_CLASS = "syn";
         private static final String SYNTAX_GREY = "8D8D8D";
@@ -101,13 +138,20 @@ public class RailRoadDiagram {
 
         @Override
         protected RRElement toRRElement(GrammarToRRDiagram grammarToRRDiagram) {
-            // This performs a monumentally rude hack to replace the text color of this element.
+            /*
+             * This performs a monumentally rude hack to replace the text color of this element.
+             * It renders a "literal" element but intercepts the layer that defines it's css class
+             * and replaces it with our own.
+             */
             return new RRText(RRText.Type.LITERAL, text, null) {
                 @Override
                 protected void toSVG(RRDiagramToSVG rrDiagramToSVG, int xOffset, int yOffset, RRDiagram.SvgContent svgContent) {
                     super.toSVG(rrDiagramToSVG, xOffset, yOffset, new RRDiagram.SvgContent() {
                         @Override
                         public String getDefinedCSSClass(String style) {
+                            if (style.equals(LITERAL_CLASS)) {
+                                return svgContent.getDefinedCSSClass(SYNTAX_CLASS);
+                            }
                             if (style.equals(LITERAL_TEXT_CLASS)) {
                                 return svgContent.getDefinedCSSClass(SYNTAX_TEXT_CLASS);
                             }
@@ -116,11 +160,18 @@ public class RailRoadDiagram {
 
                         @Override
                         public String setCSSClass(String cssClass, String definition) {
-                            if (false == cssClass.equals(LITERAL_TEXT_CLASS)) {
-                                return svgContent.setCSSClass(cssClass, definition);
+                            if (cssClass.equals(LITERAL_CLASS)) {
+                                svgContent.setCSSClass(cssClass, definition);
+                                return svgContent.setCSSClass(SYNTAX_CLASS, definition);
+                            }
+                            if (cssClass.equals(LITERAL_TEXT_CLASS)) {
+                                svgContent.setCSSClass(cssClass, definition);
+                                return svgContent.setCSSClass(
+                                    SYNTAX_TEXT_CLASS,
+                                    definition.replace("fill:#000000", "fill:#" + SYNTAX_GREY)
+                                );
                             }
-                            svgContent.setCSSClass(cssClass, definition);
-                            return svgContent.setCSSClass(SYNTAX_TEXT_CLASS, definition.replace("fill:#000000", "fill:#" + SYNTAX_GREY));
+                            return svgContent.setCSSClass(cssClass, definition);
                         }
 
                         @Override