Bladeren bron

Add StringTemplate Gradle plugin (#97388)

This commit adds a StringTemplate Gradle plugin based on Antlr's StringTemplate.

With this plugin we can template Java source code and generate different variants of it, e.g. implementations that vary only by primitive type.
Chris Hegarty 2 jaren geleden
bovenliggende
commit
c508f6172e

+ 5 - 0
build-tools-internal/build.gradle

@@ -155,6 +155,10 @@ gradlePlugin {
       id = 'elasticsearch.standalone-test'
       implementationClass = 'org.elasticsearch.gradle.internal.test.StandaloneTestPlugin'
     }
+    stringTemplate {
+       id = 'elasticsearch.string-templates'
+       implementationClass = 'org.elasticsearch.gradle.internal.StringTemplatePlugin'
+     }
     testFixtures {
       id = 'elasticsearch.test.fixtures'
       implementationClass = 'org.elasticsearch.gradle.internal.testfixtures.TestFixturesPlugin'
@@ -271,6 +275,7 @@ dependencies {
   // needs to match the jackson minor version in use
   api buildLibs.json.schema.validator
   api buildLibs.jackson.dataformat.yaml
+  api buildLibs.antlrst4
   api buildLibs.asm
   api buildLibs.asm.tree
   api buildLibs.httpclient

+ 243 - 0
build-tools-internal/src/integTest/groovy/org/elasticsearch/gradle/internal/StringTemplatePluginTest.groovy

@@ -0,0 +1,243 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal;
+
+import org.elasticsearch.gradle.fixtures.AbstractGradleFuncTest;
+import org.gradle.testkit.runner.TaskOutcome;
+
+class StringTemplatePluginTest extends AbstractGradleFuncTest {
+
+    def "test substitution"() {
+        given:
+        internalBuild()
+        file('src/main/p/X-Box.java.st') << """
+          public class \$Type\$Box {
+            final \$type\$ value;
+            public \$Type\$Box(\$type\$ value) {
+              this.value = value;
+            }
+          }
+        """.stripIndent().stripTrailing()
+        file('src/main/generated-src/someFile.txt') << """
+          Just some random data
+        """
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            template {
+              it.properties = ["Type" : "Int", "type" : "int"]
+              it.inputFile = new File("${projectDir}/src/main/p/X-Box.java.st")
+              it.outputFile = "p/IntBox.java"
+            }
+            template {
+              it.properties = ["Type" : "Long", "type" : "long"]
+              it.inputFile = new File("${projectDir}/src/main/p/X-Box.java.st")
+              it.outputFile = "p/LongBox.java"
+            }
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/someFile.txt").exists() == false
+        file("src/main/generated-src/p/IntBox.java").exists()
+        file("src/main/generated-src/p/LongBox.java").exists()
+
+        //assert output
+        normalized(file("src/main/generated-src/p/IntBox.java").text) == """
+            public class IntBox {
+              final int value;
+              public IntBox(int value) {
+                this.value = value;
+              }
+            }
+          """.stripIndent().stripTrailing()
+        normalized(file("src/main/generated-src/p/LongBox.java").text) == """
+            public class LongBox {
+              final long value;
+              public LongBox(long value) {
+                this.value = value;
+              }
+            }
+          """.stripIndent().stripTrailing()
+    }
+
+    def "test basic conditional"() {
+        given:
+        internalBuild()
+        file('src/main/Color.txt.st') << """
+          \$if(Red)\$1 Red\$endif\$
+          \$if(Blue)\$2 Blue\$endif\$
+          \$if(Green)\$3 Green\$endif\$
+        """.stripIndent().stripTrailing()
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            template {
+              it.properties = ["Red" : "true", "Blue" : "", "Green" : "true"]
+              it.inputFile = new File("${projectDir}/src/main/Color.txt.st")
+              it.outputFile = "Color.txt"
+            }
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/Color.txt").exists()
+        //assert output
+        normalized(file("src/main/generated-src/Color.txt").text) == """
+            1 Red
+            3 Green
+          """.stripIndent().stripTrailing()
+    }
+
+    def "test if then else"() {
+        given:
+        internalBuild()
+        file('src/main/Token.txt.st') << """
+          \$if(Foo)\$1 Foo
+          \$elseif(Bar)\$2 Bar
+          \$else\$3 Baz
+          \$endif\$
+        """.stripIndent().stripTrailing()
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            template {
+              it.properties = [:] // no properties
+              it.inputFile = new File("${projectDir}/src/main/Token.txt.st")
+              it.outputFile = "Token.txt"
+            }
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/Token.txt").exists()
+        //assert output
+        normalized(file("src/main/generated-src/Token.txt").text) == """
+            3 Baz
+          """.stripIndent().stripTrailing()
+    }
+
+    def "output file already present and up to date"() {
+        given:
+        internalBuild()
+        file('src/main/UpToDate.txt.st') << """
+          Hello World!
+        """.stripIndent().stripTrailing()
+        // the output file is already created and up to date (content wise)
+        file('src/main/generated-src/UpToDate.txt') << """
+          Hello World!
+        """.stripIndent().stripTrailing()
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            template {
+              it.properties = [:] // no properties
+              it.inputFile = new File("${projectDir}/src/main/UpToDate.txt.st")
+              it.outputFile = "UpToDate.txt"
+            }
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/UpToDate.txt").exists()
+        //assert output - expect the original output
+        normalized(file("src/main/generated-src/UpToDate.txt").text) == """
+            Hello World!
+          """.stripIndent().stripTrailing()
+    }
+
+    def "output file already present but not up to date"() {
+        given:
+        internalBuild()
+        file('src/main/Message.txt.st') << """
+          Hello World!
+        """.stripIndent().stripTrailing()
+        // the output file is already created, but not up to date (content wise)
+        file('src/main/generated-src/Message.txt') << """
+          Hello Chris xx
+        """.stripIndent().stripTrailing()
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            template {
+              it.properties = [:] // no properties
+              it.inputFile = new File("${projectDir}/src/main/Message.txt.st")
+              it.outputFile = "Message.txt"
+            }
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/Message.txt").exists()
+        //assert output - expect the updated message output
+        normalized(file("src/main/generated-src/Message.txt").text) == """
+            Hello World!
+          """.stripIndent().stripTrailing()
+    }
+
+    def "cleanup delete files"() {
+        given:
+        internalBuild()
+        file('src/main/generated-src/someFile.txt') << """
+          Just some random data
+        """
+
+        buildFile << """
+          apply plugin: 'elasticsearch.build'
+          apply plugin: 'elasticsearch.string-templates'
+
+          tasks.named("stringTemplates").configure {
+            // no templates
+          }
+        """
+
+        when:
+        def result = gradleRunner("stringTemplates", '-g', gradleUserHome).build()
+
+        then:
+        result.task(":stringTemplates").outcome == TaskOutcome.SUCCESS
+        file("src/main/generated-src/someFile.txt").exists() == false
+        file("src/main/generated-src").exists() == true
+    }
+}

+ 34 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/StringTemplatePlugin.java

@@ -0,0 +1,34 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal;
+
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.api.plugins.JavaPluginExtension;
+import org.gradle.api.tasks.SourceSet;
+import org.gradle.api.tasks.SourceSetContainer;
+import org.gradle.api.tasks.TaskProvider;
+
+import java.io.File;
+
+public class StringTemplatePlugin implements Plugin<Project> {
+    @Override
+    public void apply(Project project) {
+        File outputDir = project.file("src/main/generated-src/");
+
+        TaskProvider<StringTemplateTask> generateSourceTask = project.getTasks().register("stringTemplates", StringTemplateTask.class);
+        generateSourceTask.configure(stringTemplateTask -> stringTemplateTask.getOutputFolder().set(outputDir));
+        project.getPlugins().withType(JavaPlugin.class, javaPlugin -> {
+            SourceSetContainer sourceSets = project.getExtensions().getByType(JavaPluginExtension.class).getSourceSets();
+            SourceSet mainSourceSet = sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
+            mainSourceSet.getJava().srcDir(generateSourceTask);
+        });
+    }
+}

+ 127 - 0
build-tools-internal/src/main/java/org/elasticsearch/gradle/internal/StringTemplateTask.java

@@ -0,0 +1,127 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+package org.elasticsearch.gradle.internal;
+
+import org.gradle.api.Action;
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.DirectoryProperty;
+import org.gradle.api.file.FileSystemOperations;
+import org.gradle.api.model.ObjectFactory;
+import org.gradle.api.provider.ListProperty;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFile;
+import org.gradle.api.tasks.Nested;
+import org.gradle.api.tasks.OutputDirectory;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.stringtemplate.v4.ST;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Map;
+
+import javax.inject.Inject;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+public abstract class StringTemplateTask extends DefaultTask {
+
+    private final ListProperty<TemplateSpec> templateSpecListProperty;
+    private final DirectoryProperty outputFolder;
+    private final FileSystemOperations fileSystemOperations;
+
+    @Inject
+    public StringTemplateTask(ObjectFactory objectFactory, FileSystemOperations fileSystemOperations) {
+        templateSpecListProperty = objectFactory.listProperty(TemplateSpec.class);
+        outputFolder = objectFactory.directoryProperty();
+        this.fileSystemOperations = fileSystemOperations;
+    }
+
+    public void template(Action<TemplateSpec> spec) {
+        TemplateSpec templateSpec = new TemplateSpec();
+        spec.execute(templateSpec);
+        templateSpecListProperty.add(templateSpec);
+    }
+
+    @Nested
+    public ListProperty<TemplateSpec> getTemplates() {
+        return templateSpecListProperty;
+    }
+
+    @OutputDirectory
+    public DirectoryProperty getOutputFolder() {
+        return outputFolder;
+    }
+
+    @TaskAction
+    public void generate() {
+        File outputRootFolder = getOutputFolder().getAsFile().get();
+        // clean the output directory to ensure no stale files persist
+        fileSystemOperations.delete(d -> d.delete(outputRootFolder));
+
+        for (TemplateSpec spec : getTemplates().get()) {
+            getLogger().info("StringTemplateTask generating {}, with properties {}", spec.inputFile, spec.properties);
+            try {
+                ST st = new ST(Files.readString(spec.inputFile.toPath(), UTF_8), '$', '$');
+                for (var entry : spec.properties.entrySet()) {
+                    if (entry.getValue().isEmpty()) {
+                        st.add(entry.getKey(), null);
+                    } else {
+                        st.add(entry.getKey(), entry.getValue());
+                    }
+                }
+                String output = st.render();
+                Files.createDirectories(outputRootFolder.toPath().resolve(spec.outputFile).getParent());
+                Files.writeString(new File(outputRootFolder, spec.outputFile).toPath(), output, UTF_8);
+                getLogger().info("StringTemplateTask generated {}", spec.outputFile);
+            } catch (IOException e) {
+                throw new GradleException("Cannot generate source from String template", e);
+            }
+        }
+    }
+
+    class TemplateSpec {
+        private File inputFile;
+
+        private String outputFile;
+
+        private Map<String, String> properties;
+
+        @InputFile
+        @PathSensitive(PathSensitivity.RELATIVE)
+        public File getInputFile() {
+            return inputFile;
+        }
+
+        public void setInputFile(File inputFile) {
+            this.inputFile = inputFile;
+        }
+
+        @Input
+        public String getOutputFile() {
+            return outputFile;
+        }
+
+        public void setOutputFile(String outputFile) {
+            this.outputFile = outputFile;
+        }
+
+        @Input
+        public Map<String, String> getProperties() {
+            return properties;
+        }
+
+        public void setProperties(Map<String, String> properties) {
+            this.properties = properties;
+        }
+    }
+}

+ 1 - 0
gradle/build.versions.toml

@@ -6,6 +6,7 @@ spock = "2.1-groovy-3.0"
 
 [libraries]
 ant = "org.apache.ant:ant:1.10.12"
+antlrst4 = "org.antlr:ST4:4.3.4"
 apache-compress = "org.apache.commons:commons-compress:1.21"
 apache-rat = "org.apache.rat:apache-rat:0.11"
 asm = { group = "org.ow2.asm", name="asm", version.ref="asm" }