Browse Source

ESQL: Fail build docs aren't up to date (#131739)

Adds a new behavior to the esql build we enable in CI that, instead of
generating the docs, asserts that they are already up to date.
Nik Everett 2 months ago
parent
commit
f3d74f36cc
19 changed files with 253 additions and 124 deletions
  1. 1 1
      docs/reference/query-languages/esql/_snippets/functions/description/md5.md
  2. 2 8
      docs/reference/query-languages/esql/_snippets/functions/examples/knn.md
  3. 2 2
      docs/reference/query-languages/esql/_snippets/functions/functionNamedParams/categorize.md
  4. 0 3
      docs/reference/query-languages/esql/_snippets/functions/functionNamedParams/knn.md
  5. 3 0
      docs/reference/query-languages/esql/_snippets/functions/parameters/knn.md
  6. 0 6
      docs/reference/query-languages/esql/kibana/definition/commands/rrf.json
  7. 1 1
      docs/reference/query-languages/esql/kibana/definition/functions/md5.json
  8. 1 1
      docs/reference/query-languages/esql/kibana/docs/functions/md5.md
  9. 58 32
      x-pack/plugin/esql/build.gradle
  10. 5 1
      x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupJoinTypesIT.java
  11. 8 7
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CommandDocsTests.java
  12. 122 41
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3Support.java
  13. 14 15
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3SupportTests.java
  14. 8 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLikeListTests.java
  15. 8 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLikeTests.java
  16. 8 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLikeTests.java
  17. 7 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/predicate/operator/CastOperatorTests.java
  18. 3 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InTests.java
  19. 2 1
      x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/logical/CommandLicenseTests.java

+ 1 - 1
docs/reference/query-languages/esql/_snippets/functions/description/md5.md

@@ -2,5 +2,5 @@
 
 **Description**
 
-Computes the MD5 hash of the input.
+Computes the MD5 hash of the input (if the MD5 hash is available on the JVM).
 

+ 2 - 8
docs/reference/query-languages/esql/_snippets/functions/examples/knn.md

@@ -1,10 +1,10 @@
 % This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
 
-**Examples**
+**Example**
 
 ```esql
 from colors metadata _score
-| where knn(rgb_vector, [0, 120, 0])
+| where knn(rgb_vector, [0, 120, 0], 10)
 | sort _score desc, color asc
 ```
 
@@ -21,10 +21,4 @@ from colors metadata _score
 | gray | [128.0, 128.0, 128.0] |
 | chartreuse | [127.0, 255.0, 0.0] |
 
-```esql
-from colors metadata _score
-| where knn(rgb_vector, [0,255,255], {"k": 4})
-| sort _score desc, color asc
-```
-
 

+ 2 - 2
docs/reference/query-languages/esql/_snippets/functions/functionNamedParams/categorize.md

@@ -3,10 +3,10 @@
 **Supported function named parameters**
 
 `output_format`
-:   (boolean) The output format of the categories. Defaults to regex.
+:   (keyword) The output format of the categories. Defaults to regex.
 
 `similarity_threshold`
-:   (boolean) The minimum percentage of token weight that must match for text to be added to the category bucket. Must be between 1 and 100. The larger the value the narrower the categories. Larger values will increase memory usage and create narrower categories. Defaults to 70.
+:   (integer) The minimum percentage of token weight that must match for text to be added to the category bucket. Must be between 1 and 100. The larger the value the narrower the categories. Larger values will increase memory usage and create narrower categories. Defaults to 70.
 
 `analyzer`
 :   (keyword) Analyzer used to convert the field into tokens for text categorization.

+ 0 - 3
docs/reference/query-languages/esql/_snippets/functions/functionNamedParams/knn.md

@@ -8,9 +8,6 @@
 `boost`
 :   (float) Floating point number used to decrease or increase the relevance scores of the query.Defaults to 1.0.
 
-`k`
-:   (integer) The number of nearest neighbors to return from each shard. Elasticsearch collects k results from each shard, then merges them to find the global top results. This value must be less than or equal to num_candidates. Defaults to 10.
-
 `rescore_oversample`
 :   (double) Applies the specified oversampling for rescoring quantized vectors. See [oversampling and rescoring quantized vectors](docs-content://solutions/search/vector/knn.md#dense-vector-knn-search-rescoring) for details.
 

+ 3 - 0
docs/reference/query-languages/esql/_snippets/functions/parameters/knn.md

@@ -8,6 +8,9 @@
 `query`
 :   Vector value to find top nearest neighbours for.
 
+`k`
+:   The number of nearest neighbors to return from each shard. Elasticsearch collects k results from each shard, then merges them to find the global top results. This value must be less than or equal to num_candidates.
+
 `options`
 :   (Optional) kNN additional options as [function named parameters](/reference/query-languages/esql/esql-syntax.md#esql-function-named-params). See [knn query](/reference/query-languages/query-dsl/query-dsl-match-query.md#query-dsl-knn-query) for more information.
 

+ 0 - 6
docs/reference/query-languages/esql/kibana/definition/commands/rrf.json

@@ -1,6 +0,0 @@
-{
-  "comment" : "This is generated by ESQL’s DocsV3Support. Do not edit it. See ../README.md for how to regenerate it.",
-  "type" : "command",
-  "name" : "rrf",
-  "license" : "ENTERPRISE"
-}

+ 1 - 1
docs/reference/query-languages/esql/kibana/definition/functions/md5.json

@@ -2,7 +2,7 @@
   "comment" : "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.",
   "type" : "scalar",
   "name" : "md5",
-  "description" : "Computes the MD5 hash of the input.",
+  "description" : "Computes the MD5 hash of the input (if the MD5 hash is available on the JVM).",
   "signatures" : [
     {
       "params" : [

+ 1 - 1
docs/reference/query-languages/esql/kibana/docs/functions/md5.md

@@ -1,7 +1,7 @@
 % This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.
 
 ### MD5
-Computes the MD5 hash of the input.
+Computes the MD5 hash of the input (if the MD5 hash is available on the JVM).
 
 ```esql
 FROM sample_data

+ 58 - 32
x-pack/plugin/esql/build.gradle

@@ -94,37 +94,55 @@ interface Injected {
 }
 
 tasks.named("test").configure {
-  if (buildParams.ci == false) {
-    systemProperty 'generateDocs', true
-    def injected = project.objects.newInstance(Injected)
-    // Define the folder to delete and recreate
-    def tempDir = file("build/testrun/test/temp/esql")
-    def commandsExamplesFile = new File(tempDir, "commands.examples")
-    // Find all matching .md files for commands examples
-    def mdFiles = fileTree("${rootDir}/docs/reference/query-languages/esql/_snippets/commands/examples/") {
-      include("**/*.csv-spec/*.md")
+  def injected = project.objects.newInstance(Injected)
+  // Define the folder to delete and recreate
+  def tempDir = file("build/testrun/test/temp/esql")
+  def commandsExamplesFile = new File(tempDir, "commands.examples")
+  // Find all matching .md files for commands examples
+  def mdFiles = fileTree("${rootDir}/docs/reference/query-languages/esql/_snippets/commands/examples/") {
+    include("**/*.csv-spec/*.md")
+  }
+  def esqlDocFolder = file("${rootDir}/docs/reference/query-languages/esql").toPath()
+  def imagesDocFolder = file("${esqlDocFolder}/images")
+  def snippetsDocFolder = file("${esqlDocFolder}/_snippets")
+  def kibanaDocFolder = file("${esqlDocFolder}/kibana")
+  File imagesFolder = file("build/testrun/test/temp/esql/images")
+  File snippetsFolder = file("build/testrun/test/temp/esql/_snippets")
+  File kibanaFolder = file("build/testrun/test/temp/esql/kibana")
+
+  doFirst {
+    injected.fs.delete {
+      it.delete(tempDir)
     }
-    doFirst {
-      injected.fs.delete {
-        it.delete(tempDir)
-      }
-      // Re-create this folder so we can save a table of generated examples to extract from csv-spec tests
-      tempDir.mkdirs() // Recreate the folder
+    // Re-create this folder so we can save a table of generated examples to extract from csv-spec tests
+    tempDir.mkdirs() // Recreate the folder
 
-      // Write directory name and filename of each .md file to the output file
-      commandsExamplesFile.withWriter { writer ->
-        mdFiles.each { file ->
-          writer.writeLine("${file.parentFile.name}/${file.name}")
-        }
+    // Write directory name and filename of each .md file to the output file
+    commandsExamplesFile.withWriter { writer ->
+      mdFiles.each { file ->
+        writer.writeLine("${file.parentFile.name}/${file.name}")
       }
-      println "File 'commands.examples' created with ${mdFiles.size()} example specifications from csv-spec files."
     }
-    File imagesFolder = file("build/testrun/test/temp/esql/images")
-    File snippetsFolder = file("build/testrun/test/temp/esql/_snippets")
-    File kibanaFolder = file("build/testrun/test/temp/esql/kibana")
-    def imagesDocFolder = file("${rootDir}/docs/reference/query-languages/esql/images")
-    def snippetsDocFolder = file("${rootDir}/docs/reference/query-languages/esql/_snippets")
-    def kibanaDocFolder = file("${rootDir}/docs/reference/query-languages/esql/kibana")
+    println "File 'commands.examples' created with ${mdFiles.size()} example specifications from csv-spec files."
+    if (buildParams.ci) {
+      injected.fs.sync {
+        from snippetsDocFolder
+        into snippetsFolder
+      }
+      injected.fs.sync {
+        from imagesDocFolder
+        into imagesFolder
+      }
+      injected.fs.sync {
+        from kibanaDocFolder
+        into kibanaFolder
+      }
+    }
+  }
+  if (buildParams.ci) {
+    systemProperty 'generateDocs', 'assert'
+  } else {
+    systemProperty 'generateDocs', 'write'
     def snippetsTree = fileTree(snippetsFolder).matching {
       include "**/types/*.md"  // Recursively include all types/*.md files (effectively counting functions and operators)
     }
@@ -229,9 +247,19 @@ tasks.named("test").configure {
 // This is similar to the test task above, but needed for the LookupJoinTypesIT which runs in the internalClusterTest task
 // and generates a types table for the LOOKUP JOIN command. It is possible in future we might have move tests that do this.
 tasks.named("internalClusterTest").configure {
-  if (buildParams.ci == false) {
-    systemProperty 'generateDocs', true
-    def injected = project.objects.newInstance(Injected)
+  def injected = project.objects.newInstance(Injected)
+  File snippetsFolder = file("build/testrun/internalClusterTest/temp/esql/_snippets")
+  def snippetsDocFolder = file("${rootDir}/docs/reference/query-languages/esql/_snippets")
+  if (buildParams.ci) {
+    systemProperty 'generateDocs', 'assert'
+    doFirst {
+      injected.fs.sync {
+        from snippetsDocFolder
+        into snippetsFolder
+      }
+    }
+  } else {
+    systemProperty 'generateDocs', 'write'
     // Define the folder to delete and recreate
     def tempDir = file("build/testrun/internalClusterTest/temp/esql")
     doFirst {
@@ -241,8 +269,6 @@ tasks.named("internalClusterTest").configure {
       // Re-create this folder so we can save a table of generated examples to extract from csv-spec tests
       tempDir.mkdirs() // Recreate the folder
     }
-    File snippetsFolder = file("build/testrun/internalClusterTest/temp/esql/_snippets")
-    def snippetsDocFolder = file("${rootDir}/docs/reference/query-languages/esql/_snippets")
     def snippetsTree = fileTree(snippetsFolder).matching {
       include "**/types/*.md"  // Recursively include all types/*.md files (effectively counting functions and operators)
     }

+ 5 - 1
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/action/LookupJoinTypesIT.java

@@ -768,6 +768,9 @@ public class LookupJoinTypesIT extends ESIntegTestCase {
     }
 
     private static void saveJoinTypes(Supplier<Map<List<DataType>, DataType>> signatures) throws Exception {
+        if (System.getProperty("generateDocs") == null) {
+            return;
+        }
         ArrayList<EsqlFunctionRegistry.ArgSignature> args = new ArrayList<>();
         args.add(new EsqlFunctionRegistry.ArgSignature("field from the left index", null, null, false, false));
         args.add(new EsqlFunctionRegistry.ArgSignature("field from the lookup index", null, null, false, false));
@@ -776,7 +779,8 @@ public class LookupJoinTypesIT extends ESIntegTestCase {
             LookupJoinTypesIT.class,
             null,
             args,
-            signatures
+            signatures,
+            DocsV3Support.callbacksFromSystemProperty()
         );
         docs.renderDocs();
     }

+ 8 - 7
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/CommandDocsTests.java

@@ -8,6 +8,8 @@
 package org.elasticsearch.xpack.esql;
 
 import org.elasticsearch.core.PathUtils;
+import org.elasticsearch.logging.LogManager;
+import org.elasticsearch.logging.Logger;
 import org.elasticsearch.test.ESTestCase;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.DocsV3Support;
@@ -50,8 +52,10 @@ public class CommandDocsTests extends ESTestCase {
      * in the documentation.
      */
     public static class TestDocsV3Support extends DocsV3Support {
+        private static final Logger logger = LogManager.getLogger(TestDocsV3Support.class);
+
         public TestDocsV3Support() {
-            super("commands", "commands", CommandDocsTests.class, null);
+            super("commands", "commands", CommandDocsTests.class, null, callbacksFromSystemProperty());
         }
 
         @Override
@@ -108,11 +112,11 @@ public class CommandDocsTests extends ESTestCase {
             }
             String rendered = builder.toString();
             logger.info("Writing example for [{}]:\n{}", name, rendered);
-            writeExampleFile(csvFile, tagFile, rendered);
+            writeExampleFile(csvFile, tag, rendered);
             return true;
         }
 
-        protected void writeExampleFile(String csvFile, String tagFile, String str) throws IOException {
+        protected void writeExampleFile(String csvFile, String tag, String str) throws IOException {
             // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
             Path dir = PathUtils.get(System.getProperty("java.io.tmpdir"))
                 .resolve("esql")
@@ -120,10 +124,7 @@ public class CommandDocsTests extends ESTestCase {
                 .resolve(category)
                 .resolve("examples")
                 .resolve(csvFile);
-            Files.createDirectories(dir);
-            Path file = dir.resolve(tagFile);
-            Files.writeString(file, str);
-            logger.info("Wrote to file: {}", file);
+            callbacks.write(dir, tag, "md", str, false);
         }
 
         @SuppressWarnings("SameParameterValue")

+ 122 - 41
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3Support.java

@@ -72,6 +72,7 @@ import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import static java.util.Map.entry;
+import static org.elasticsearch.test.ESTestCase.fail;
 import static org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase.constructorWithFunctionInfo;
 import static org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase.definition;
 import static org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase.functionRegistered;
@@ -79,6 +80,8 @@ import static org.elasticsearch.xpack.esql.expression.function.AbstractFunctionT
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.mapParam;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.param;
 import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegistry.paramWithoutAnnotation;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 
 /**
  * This class exists to support the new Docs V3 system.
@@ -96,31 +99,32 @@ import static org.elasticsearch.xpack.esql.expression.function.EsqlFunctionRegis
  * and partially re-written to satisfy the above requirements.
  */
 public abstract class DocsV3Support {
+    private static final Logger logger = LogManager.getLogger(DocsV3Support.class);
 
     private static final String DOCS_WARNING_JSON =
         "This is generated by ESQL's AbstractFunctionTestCase. Do not edit it. See ../README.md for how to regenerate it.";
 
     protected static final String DOCS_WARNING = "% " + DOCS_WARNING_JSON + "\n\n";
 
-    static FunctionDocsSupport forFunctions(String name, Class<?> testClass) {
-        return new FunctionDocsSupport(name, testClass);
+    static FunctionDocsSupport forFunctions(String name, Class<?> testClass, Callbacks callbacks) {
+        return new FunctionDocsSupport(name, testClass, callbacks);
     }
 
-    static OperatorsDocsSupport forOperators(String name, Class<?> testClass) {
-        return new OperatorsDocsSupport(name, testClass);
+    static OperatorsDocsSupport forOperators(String name, Class<?> testClass, Callbacks callbacks) {
+        return new OperatorsDocsSupport(name, testClass, callbacks);
     }
 
     static void renderDocs(String name, Class<?> testClass) throws Exception {
         if (OPERATORS.containsKey(name)) {
-            var docs = DocsV3Support.forOperators(name, testClass);
+            var docs = DocsV3Support.forOperators(name, testClass, callbacksFromSystemProperty());
             docs.renderSignature();
             docs.renderDocs();
         } else if (functionRegistered(name)) {
-            var docs = DocsV3Support.forFunctions(name, testClass);
+            var docs = DocsV3Support.forFunctions(name, testClass, callbacksFromSystemProperty());
             docs.renderSignature();
             docs.renderDocs();
         } else {
-            LogManager.getLogger(testClass).info("Skipping rendering docs because the function '" + name + "' isn't registered");
+            logger.info("Skipping rendering docs because the function '" + name + "' isn't registered");
         }
     }
 
@@ -128,9 +132,10 @@ public abstract class DocsV3Support {
         @NotNull Constructor<?> ctor,
         String name,
         Function<String, String> description,
-        Class<?> testClass
+        Class<?> testClass,
+        Callbacks callbacks
     ) throws Exception {
-        var docs = forOperators("not " + name.toLowerCase(Locale.ROOT), testClass);
+        var docs = forOperators("not " + name.toLowerCase(Locale.ROOT), testClass, callbacks);
         docs.renderDocsForNegatedOperators(ctor, description);
     }
 
@@ -260,19 +265,75 @@ public abstract class DocsV3Support {
         return entry(name, new OperatorConfig(name, symbol, clazz, category));
     }
 
-    @FunctionalInterface
-    interface TempFileWriter {
-        void writeToTempDir(Path dir, String extension, String str) throws IOException;
+    public interface Callbacks {
+        void write(Path dir, String name, String extension, String str, boolean kibana) throws IOException;
+
+        boolean supportsRendering();
     }
 
-    private class DocsFileWriter implements TempFileWriter {
+    public static class WriteCallbacks implements Callbacks {
         @Override
-        public void writeToTempDir(Path dir, String extension, String str) throws IOException {
+        public void write(Path dir, String name, String extension, String str, boolean kibana) throws IOException {
             Files.createDirectories(dir);
             Path file = dir.resolve(name + "." + extension);
             Files.writeString(file, str);
             logger.info("Wrote to file: {}", file);
         }
+
+        @Override
+        public boolean supportsRendering() {
+            return true;
+        }
+    }
+
+    public static class AssertCallbacks implements Callbacks {
+        @Override
+        public void write(Path dir, String name, String extension, String str, boolean kibana) throws IOException {
+            /*
+             * If you've arrived in this method because a CI build is failing and
+             * rerunning the tests doesn't fix it then there is some bug with the
+             * docs generation assertion logic. You can run your REPRODUCE WITH
+             * line prefixed with `BUILDKITE_BUILD_URL=true`.
+             */
+            Path file = dir.resolve(name + "." + extension);
+            assertTrue("rerun test for " + name + ". " + file + " is missing", Files.exists(file));
+            List<String> found = Files.readAllLines(file);
+            List<String> renderedLines = str.lines().toList();
+            int length = Math.min(found.size(), renderedLines.size());
+            for (int i = 0; i < length; i++) {
+                String r = renderedLines.get(i);
+                if (kibana) {
+                    r = r.replaceAll("]\\(/reference/([^)\\s]+)\\.md(#\\S+)?\\)", "](https://www.elastic.co/docs/reference/$1$2)");
+                }
+                String f = found.get(i);
+                if (r.equals(f) == false) {
+                    assertEquals(errorMessage(name, file, i) + ": mismatch", r, f);
+                }
+            }
+            if (renderedLines.size() > found.size()) {
+                fail(errorMessage(name, file, found.size()) + ": rendered extra line: " + renderedLines.get(found.size()));
+            }
+            if (renderedLines.size() < found.size()) {
+                fail(errorMessage(name, file, found.size()) + ": found extra line: " + found.get(renderedLines.size()));
+            }
+        }
+
+        private String errorMessage(String name, Path file, int i) {
+            return "rerun tests for " + name + ". Generated docs out of date in " + file + ":" + (i + 1);
+        }
+
+        @Override
+        public boolean supportsRendering() {
+            /*
+             * Our svg rendering stuff needs the OS it's running on to have
+             * some gui support. Why?! Svg is just xml. And we should be fine
+             * making that. And we are! But we also need to get the sizing
+             * of some fonts and *that* has to render the word. And *that*
+             * needs the gui. Which is wild. And we shouldn't need it. Svg
+             * is a vector format. But that is a problem for another time.
+             */
+            return false;
+        }
     }
 
     /**
@@ -311,13 +372,18 @@ public abstract class DocsV3Support {
     protected final String category;
     protected final String name;
     protected final FunctionDefinition definition;
-    protected final Logger logger;
     protected final Supplier<Map<List<DataType>, DataType>> signatures;
-    private TempFileWriter tempFileWriter;
+    protected final Callbacks callbacks;
     private final LicenseRequirementChecker licenseChecker;
 
-    protected DocsV3Support(String category, String name, Class<?> testClass, Supplier<Map<List<DataType>, DataType>> signatures) {
-        this(category, name, null, testClass, signatures);
+    protected DocsV3Support(
+        String category,
+        String name,
+        Class<?> testClass,
+        Supplier<Map<List<DataType>, DataType>> signatures,
+        Callbacks callbacks
+    ) {
+        this(category, name, null, testClass, signatures, callbacks);
     }
 
     private DocsV3Support(
@@ -325,22 +391,17 @@ public abstract class DocsV3Support {
         String name,
         FunctionDefinition definition,
         Class<?> testClass,
-        Supplier<Map<List<DataType>, DataType>> signatures
+        Supplier<Map<List<DataType>, DataType>> signatures,
+        Callbacks callbacks
     ) {
         this.category = category;
         this.name = name;
         this.definition = definition == null ? definition(name) : definition;
-        this.logger = LogManager.getLogger(testClass);
         this.signatures = signatures;
-        this.tempFileWriter = new DocsFileWriter();
+        this.callbacks = callbacks;
         this.licenseChecker = new LicenseRequirementChecker(testClass);
     }
 
-    /** Used in tests to capture output for asserting on the content */
-    void setTempFileWriter(TempFileWriter tempFileWriter) {
-        this.tempFileWriter = tempFileWriter;
-    }
-
     String replaceLinks(String text) {
         return replaceAsciidocLinks(replaceMacros(text));
     }
@@ -475,9 +536,10 @@ public abstract class DocsV3Support {
     }
 
     void writeToTempImageDir(String str) throws IOException {
+        assert callbacks.supportsRendering();
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("images").resolve(category);
-        tempFileWriter.writeToTempDir(dir, "svg", str);
+        callbacks.write(dir, name, "svg", str, false);
     }
 
     void writeToTempSnippetsDir(String subdir, String str) throws IOException {
@@ -487,13 +549,13 @@ public abstract class DocsV3Support {
             .resolve("_snippets")
             .resolve(category)
             .resolve(subdir);
-        tempFileWriter.writeToTempDir(dir, "md", str);
+        callbacks.write(dir, name, "md", str, false);
     }
 
     void writeToTempKibanaDir(String subdir, String extension, String str) throws IOException {
         // We have to write to a tempdir because it’s all test are allowed to write to. Gradle can move them.
         Path dir = PathUtils.get(System.getProperty("java.io.tmpdir")).resolve("esql").resolve("kibana").resolve(subdir).resolve(category);
-        tempFileWriter.writeToTempDir(dir, extension, str);
+        callbacks.write(dir, name, extension, str, true);
     }
 
     protected abstract void renderSignature() throws IOException;
@@ -501,21 +563,25 @@ public abstract class DocsV3Support {
     protected abstract void renderDocs() throws Exception;
 
     static class FunctionDocsSupport extends DocsV3Support {
-        private FunctionDocsSupport(String name, Class<?> testClass) {
-            super("functions", name, testClass, () -> AbstractFunctionTestCase.signatures(testClass));
+        private FunctionDocsSupport(String name, Class<?> testClass, Callbacks callbacks) {
+            super("functions", name, testClass, () -> AbstractFunctionTestCase.signatures(testClass), callbacks);
         }
 
         FunctionDocsSupport(
             String name,
             Class<?> testClass,
             FunctionDefinition definition,
-            Supplier<Map<List<DataType>, DataType>> signatures
+            Supplier<Map<List<DataType>, DataType>> signatures,
+            Callbacks callbacks
         ) {
-            super("functions", name, definition, testClass, signatures);
+            super("functions", name, definition, testClass, signatures, callbacks);
         }
 
         @Override
         protected void renderSignature() throws IOException {
+            if (callbacks.supportsRendering() == false) {
+                return;
+            }
             String rendered = buildFunctionSignatureSvg();
             if (rendered == null) {
                 logger.info("Skipping rendering signature because the function '{}' isn't registered", name);
@@ -681,22 +747,26 @@ public abstract class DocsV3Support {
     public static class OperatorsDocsSupport extends DocsV3Support {
         private final OperatorConfig op;
 
-        private OperatorsDocsSupport(String name, Class<?> testClass) {
-            this(name, testClass, OPERATORS.get(name), () -> AbstractFunctionTestCase.signatures(testClass));
+        private OperatorsDocsSupport(String name, Class<?> testClass, Callbacks callbacks) {
+            this(name, testClass, OPERATORS.get(name), () -> AbstractFunctionTestCase.signatures(testClass), callbacks);
         }
 
         public OperatorsDocsSupport(
             String name,
             Class<?> testClass,
             OperatorConfig op,
-            Supplier<Map<List<DataType>, DataType>> signatures
+            Supplier<Map<List<DataType>, DataType>> signatures,
+            Callbacks callbacks
         ) {
-            super("operators", name, testClass, signatures);
+            super("operators", name, testClass, signatures, callbacks);
             this.op = op;
         }
 
         @Override
         public void renderSignature() throws IOException {
+            if (callbacks.supportsRendering() == false) {
+                return;
+            }
             String rendered = (switch (op.category()) {
                 case BINARY -> RailRoadDiagram.infixOperator("lhs", op.symbol(), "rhs");
                 case UNARY -> RailRoadDiagram.prefixOperator(op.symbol(), "v");
@@ -871,9 +941,10 @@ public abstract class DocsV3Support {
             Class<?> testClass,
             LogicalPlan command,
             XPackLicenseState licenseState,
-            ObservabilityTier observabilityTier
+            ObservabilityTier observabilityTier,
+            Callbacks callbacks
         ) {
-            super("commands", name, testClass, Map::of);
+            super("commands", name, testClass, Map::of, callbacks);
             this.command = command;
             this.licenseState = licenseState;
             this.observabilityTier = observabilityTier;
@@ -885,9 +956,10 @@ public abstract class DocsV3Support {
             Class<?> testClass,
             LogicalPlan command,
             List<EsqlFunctionRegistry.ArgSignature> args,
-            Supplier<Map<List<DataType>, DataType>> signatures
+            Supplier<Map<List<DataType>, DataType>> signatures,
+            Callbacks callbacks
         ) {
-            super("commands", name, testClass, signatures);
+            super("commands", name, testClass, signatures, callbacks);
             this.command = command;
             this.args = args;
             this.licenseState = null;
@@ -1422,4 +1494,13 @@ public abstract class DocsV3Support {
         }
         return sb.append(" |\n").toString();
     }
+
+    public static Callbacks callbacksFromSystemProperty() {
+        String prop = System.getProperty("generateDocs");
+        return switch (prop) {
+            case "write" -> new WriteCallbacks();
+            case "assert" -> new AssertCallbacks();
+            default -> throw new IllegalArgumentException("unsupported value for generateDocs [" + prop + "]");
+        };
+    }
 }

+ 14 - 15
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/DocsV3SupportTests.java

@@ -25,7 +25,7 @@ import java.util.Map;
 import static org.hamcrest.Matchers.equalTo;
 
 public class DocsV3SupportTests extends ESTestCase {
-    private static DocsV3Support docs = DocsV3Support.forFunctions("test", DocsV3SupportTests.class);
+    private static DocsV3Support docs = DocsV3Support.forFunctions("test", DocsV3SupportTests.class, null);
     private static final String ESQL = "/reference/query-languages/esql";
 
     public void testFunctionLink() {
@@ -301,7 +301,7 @@ public class DocsV3SupportTests extends ESTestCase {
             | --- | --- |
             | 1 | 0 |
             """;
-        TestDocsFileWriter tempFileWriter = renderTestClassDocs();
+        TestCallbacks tempFileWriter = renderTestClassDocs();
         String rendered = tempFileWriter.rendered.get("examples/count.md");
         assertThat(rendered.trim(), equalTo(expected.trim()));
     }
@@ -335,36 +335,35 @@ public class DocsV3SupportTests extends ESTestCase {
             :::{include} ../examples/count.md
             :::
             """;
-        TestDocsFileWriter tempFileWriter = renderTestClassDocs();
+        TestCallbacks tempFileWriter = renderTestClassDocs();
         String rendered = tempFileWriter.rendered.get("layout/count.md");
         assertThat(rendered.trim(), equalTo(expected.trim()));
     }
 
-    private TestDocsFileWriter renderTestClassDocs() throws Exception {
+    private TestCallbacks renderTestClassDocs() throws Exception {
         FunctionInfo info = functionInfo(TestClass.class);
         assert info != null;
         FunctionDefinition definition = EsqlFunctionRegistry.def(TestClass.class, TestClass::new, "count");
-        var docs = new DocsV3Support.FunctionDocsSupport("count", TestClass.class, definition, TestClass::signatures);
-        TestDocsFileWriter tempFileWriter = new TestDocsFileWriter("count");
-        docs.setTempFileWriter(tempFileWriter);
+        TestCallbacks callbacks = new TestCallbacks();
+        var docs = new DocsV3Support.FunctionDocsSupport("count", TestClass.class, definition, TestClass::signatures, callbacks);
         docs.renderDocs();
-        return tempFileWriter;
+        return callbacks;
     }
 
-    private class TestDocsFileWriter implements DocsV3Support.TempFileWriter {
-        private final String name;
+    private class TestCallbacks implements DocsV3Support.Callbacks {
         private final Map<String, String> rendered = new HashMap<>();
 
-        TestDocsFileWriter(String name) {
-            this.name = name;
-        }
-
         @Override
-        public void writeToTempDir(Path dir, String extension, String str) throws IOException {
+        public void write(Path dir, String name, String extension, String str, boolean kibana) {
             String file = dir.getFileName() + "/" + name + "." + extension;
             rendered.put(file, str);
             logger.info("Wrote to file: {}", file);
         }
+
+        @Override
+        public boolean supportsRendering() {
+            return true;
+        }
     }
 
     private static FunctionInfo functionInfo(Class<?> clazz) {

+ 8 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLikeListTests.java

@@ -20,6 +20,7 @@ import org.elasticsearch.xpack.esql.core.expression.predicate.regex.RLikePattern
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.DocsV3Support;
 import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.regex.RLike;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.regex.RLikeList;
@@ -201,6 +202,12 @@ public class RLikeListTests extends AbstractScalarFunctionTestCase {
 
     @AfterClass
     public static void renderNotRLike() throws Exception {
-        renderNegatedOperator(constructorWithFunctionInfo(RLike.class), "RLIKE", d -> d, getTestClass());
+        renderNegatedOperator(
+            constructorWithFunctionInfo(RLike.class),
+            "RLIKE",
+            d -> d,
+            getTestClass(),
+            DocsV3Support.callbacksFromSystemProperty()
+        );
     }
 }

+ 8 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/RLikeTests.java

@@ -19,6 +19,7 @@ import org.elasticsearch.xpack.esql.core.expression.predicate.regex.RLikePattern
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.DocsV3Support;
 import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.regex.RLike;
 import org.junit.AfterClass;
@@ -199,6 +200,12 @@ public class RLikeTests extends AbstractScalarFunctionTestCase {
 
     @AfterClass
     public static void renderNotRLike() throws Exception {
-        renderNegatedOperator(constructorWithFunctionInfo(RLike.class), "RLIKE", d -> d, getTestClass());
+        renderNegatedOperator(
+            constructorWithFunctionInfo(RLike.class),
+            "RLIKE",
+            d -> d,
+            getTestClass(),
+            DocsV3Support.callbacksFromSystemProperty()
+        );
     }
 }

+ 8 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/scalar/string/WildcardLikeTests.java

@@ -18,6 +18,7 @@ import org.elasticsearch.xpack.esql.core.expression.predicate.regex.WildcardPatt
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.AbstractScalarFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.DocsV3Support;
 import org.elasticsearch.xpack.esql.expression.function.FunctionName;
 import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
 import org.elasticsearch.xpack.esql.expression.function.scalar.string.regex.WildcardLike;
@@ -99,6 +100,12 @@ public class WildcardLikeTests extends AbstractScalarFunctionTestCase {
 
     @AfterClass
     public static void renderNotLike() throws Exception {
-        renderNegatedOperator(constructorWithFunctionInfo(WildcardLike.class), "LIKE", d -> d, getTestClass());
+        renderNegatedOperator(
+            constructorWithFunctionInfo(WildcardLike.class),
+            "LIKE",
+            d -> d,
+            getTestClass(),
+            DocsV3Support.callbacksFromSystemProperty()
+        );
     }
 }

+ 7 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/predicate/operator/CastOperatorTests.java

@@ -35,7 +35,13 @@ public class CastOperatorTests extends ESTestCase {
             TestCastOperator.class,
             DocsV3Support.OperatorCategory.CAST
         );
-        var docs = new DocsV3Support.OperatorsDocsSupport("cast", CastOperatorTests.class, op, CastOperatorTests::signatures);
+        var docs = new DocsV3Support.OperatorsDocsSupport(
+            "cast",
+            CastOperatorTests.class,
+            op,
+            CastOperatorTests::signatures,
+            DocsV3Support.callbacksFromSystemProperty()
+        );
         docs.renderSignature();
         docs.renderDocs();
     }

+ 3 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/predicate/operator/comparison/InTests.java

@@ -16,6 +16,7 @@ import org.elasticsearch.xpack.esql.core.expression.Expression;
 import org.elasticsearch.xpack.esql.core.tree.Source;
 import org.elasticsearch.xpack.esql.core.type.DataType;
 import org.elasticsearch.xpack.esql.expression.function.AbstractFunctionTestCase;
+import org.elasticsearch.xpack.esql.expression.function.DocsV3Support;
 import org.elasticsearch.xpack.esql.expression.function.TestCaseSupplier;
 import org.junit.AfterClass;
 
@@ -268,7 +269,8 @@ public class InTests extends AbstractFunctionTestCase {
             "IN",
             d -> "The `NOT IN` operator allows testing whether a field or expression does *not* equal any element "
                 + "in a list of literals, fields or expressions.",
-            getTestClass()
+            getTestClass(),
+            DocsV3Support.callbacksFromSystemProperty()
         );
     }
 }

+ 2 - 1
x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/plan/logical/CommandLicenseTests.java

@@ -101,7 +101,8 @@ public class CommandLicenseTests extends ESTestCase {
             CommandLicenseTests.class,
             command,
             licenseState,
-            observabilityTier
+            observabilityTier,
+            DocsV3Support.callbacksFromSystemProperty()
         );
         docs.renderDocs();
     }