You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by jd...@apache.org on 2026/10/22 12:45:35 UTC
(grails-core) branch fix/16711-gsp-compile-environment updated (2e4bf2f1a9 -> 08f238a7b5)
This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a change to branch fix/16711-gsp-compile-environment
in repository https://gitbox.apache.org/repos/asf/grails-core.git
omit 2e4bf2f1a9 Compile views for a stated Grails environment, and key the build cache on it
new 08f238a7b5 Compile views for a stated Grails environment, and key the build cache on it
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (2e4bf2f1a9)
\
N -- N -- N refs/heads/fix/16711-gsp-compile-environment (08f238a7b5)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
grails-doc/src/en/guide/conf/environments.adoc | 4 +---
.../views/AbstractGroovyTemplateCompileTask.groovy | 2 +-
.../gradle/plugin/views/ViewCompileEnvironment.groovy | 9 ++++-----
.../plugin/views/gsp/GroovyPageForkCompileTask.groovy | 2 +-
.../AbstractViewCompileEnvironmentFunctionalSpec.groovy | 16 ++++++++--------
5 files changed, 15 insertions(+), 18 deletions(-)
(grails-core) 01/01: Compile views for a stated Grails environment, and key the build cache on it
Posted by jd...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
jdaugherty pushed a commit to branch fix/16711-gsp-compile-environment
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 08f238a7b51143764de9da31d471ed93c5276c9b
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Wed Oct 21 14:35:52 2026 -0400
Compile views for a stated Grails environment, and key the build cache on it
The forked compilers that compile GSP pages, JSON views and markup views ahead
of time read application.yml and application.groovy for the environment they
run in: CodeGenConfig merges that environment's block over the rest, and the
page parser writes the codecs it reads there into the compiled page as
constants. The environment was never stated to the compiler. It fell back to
GRAILS_ENV, inherited from the Gradle daemon and so from the shell, and to
development where that was not set; -Dgrails.env given to Gradle never reached
it. The compile tasks have been cacheable since 8a3cf63ae2 with no input for
the environment, so two clean builds that differed only in GRAILS_ENV shared a
cache entry, and a production build could be handed pages compiled with
development's codecs.
The tasks now carry the environment as grailsEnvironment and pass it to the
compiler as the grails.env system property, so nothing the compiler inherits
decides. It defaults to the environment the build runs with - a -Dgrails.env
the build already gave the compiler among the fork options, else the
grails.env system property given to Gradle, else GRAILS_ENV, each read for a
blank before the next is consulted, as the application's own resolution reads
them - and to development where none is set, which is what the compiler has
always fallen back to. The fork argument is read as the default and withheld
from the compiler, which gets the input: the environment's full name, read as
the application reads the name it is started with, which is also what the
cache key holds. A blank input fails the build before anything is compiled or
removed.
An environment change has to reach every page. The page compiler leaves a page
alone whose class in the destination is newer than its source, and leaves the
class of a page that is gone, so a production build after a development one
would keep development's pages and store them in the cache under production's
key. Each compile task now empties its destination, with Gradle's deleter,
before it compiles, at the cost of compiling every view each time it runs; the
destination holds exactly what the execution compiled, which is what the cache
keeps under its key, and so has to be a directory of the task's own within the
build directory and off the compiler's classpath, which a build that points it
elsewhere is told. The environment is read once, so that what is compiled is
what is keyed. The page compile task's source directory is internal
rather than an input directory, which has to exist where the directory need
not, and every page beneath it is an input in its place, filters
notwithstanding, since the compiler finds the pages by the directory and
compiles them all.
Fixes #16711
---
grails-doc/src/en/guide/conf/environments.adoc | 25 ++
.../views/AbstractGroovyTemplateCompileTask.groovy | 63 +++-
.../plugin/views/ViewCompileDestination.groovy | 83 +++++
.../plugin/views/ViewCompileEnvironment.groovy | 170 ++++++++++
.../views/gsp/GroovyPageForkCompileTask.groovy | 99 +++++-
.../grails/plugin/json/view/JsonViewCompiler.java | 37 +++
...ractViewCompileEnvironmentFunctionalSpec.groovy | 362 +++++++++++++++++++++
.../plugin/views/RecordingForkedCompiler.java | 82 +++++
...oovyPageCompileEnvironmentFunctionalSpec.groovy | 110 +++++++
...GsonViewCompileEnvironmentFunctionalSpec.groovy | 60 ++++
.../grails/web/pages/GroovyPageForkedCompiler.java | 37 +++
.../gson-compile-environment/build.gradle | 12 +
.../gson-compile-environment/settings.gradle | 1 +
.../gsp-compile-environment/build.gradle | 16 +
.../gsp-compile-environment/settings.gradle | 1 +
15 files changed, 1152 insertions(+), 6 deletions(-)
diff --git a/grails-doc/src/en/guide/conf/environments.adoc b/grails-doc/src/en/guide/conf/environments.adoc
index 472d809c1c..fa144606b5 100644
--- a/grails-doc/src/en/guide/conf/environments.adoc
+++ b/grails-doc/src/en/guide/conf/environments.adoc
@@ -139,6 +139,31 @@ grails -Dgrails.env=UAT run-app
./gradlew bootRun -Dgrails.env=UAT
----
+Views are compiled ahead of time by the build: GSP pages by the `compileGroovyPages` and `compileWebappGroovyPages` tasks, JSON views by `compileGsonViews` and markup views by `compileGmlViews`. The page compiler reads `application.yml` and `application.groovy`, and the view compilers `application.yml`, for one environment, so the settings a compiled view carries, such as the GSP codecs under `grails.views.gsp.codecs`, are those of that environment. The build compiles its views for the e [...]
+
+[source,bash]
+----
+./gradlew bootJar -Dgrails.env=UAT
+----
+
+A `-Dgrails.env` a build gives the compiler itself, among the fork options of a compile task, is kept, and outranks both.
+
+The build compiles each view once, for one environment, and the JSON and markup views it compiles serve the tests as well as the archive. A setting such a view carries therefore cannot differ between the tests and the archive: it belongs at the top level of the configuration rather than under `environments.test`, or the tests are run with the environment stated as above. Each compile task empties its output directory before it compiles, so the directory is the task's own: within the buil [...]
+
+A build can state the environment on the compile tasks instead, each of which has a `grailsEnvironment` property; the JSON and markup tasks, `compileGsonViews` and `compileGmlViews`, take it the same way where their plugins are applied:
+
+[source,groovy]
+----
+tasks.named('compileGroovyPages') {
+ grailsEnvironment = 'uat'
+}
+tasks.named('compileWebappGroovyPages') {
+ grailsEnvironment = 'uat'
+}
+----
+
+The environment is part of what the compiled views are, so the build cache keeps views compiled for different environments apart.
+
=== Programmatic Environment Detection
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy
index ff69e8a401..018b17845d 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/AbstractGroovyTemplateCompileTask.groovy
@@ -27,6 +27,7 @@ import groovy.transform.CompileStatic
import org.gradle.api.Action
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
@@ -35,6 +36,7 @@ import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.PathSensitive
@@ -46,9 +48,20 @@ import org.gradle.process.ExecResult
import org.gradle.process.JavaExecSpec
import org.gradle.work.InputChanges
+import grails.util.Environment
+
/**
* Abstract Gradle task for compiling templates, using GenericGroovyTemplateCompiler
*
+ * <p>Cacheable. What a view compiles to depends on the Groovy that compiles it and the classes it
+ * is compiled against, which {@code AbstractCompile} declares as the classpath input, on the Java it
+ * is compiled for, and on the Grails environment whose configuration it is compiled with, which is
+ * {@link #getGrailsEnvironment()}.</p>
+ *
+ * <p>Compiles every view, from an empty destination, each time it runs; see
+ * {@link ViewCompileDestination}. The compiler leaves the class of a view that is gone, which would
+ * otherwise be carried into the archive and the build cache.</p>
+ *
* @author Graeme Rocher
* @since 1.0
*/
@@ -83,6 +96,43 @@ abstract class AbstractGroovyTemplateCompileTask extends AbstractCompile {
@Input
final SetProperty<String> projectPackageNames
+ /**
+ * The Grails environment the views are compiled for, by name.
+ *
+ * <p>A view is compiled against the configuration of one environment: the forked compiler merges
+ * that environment's block of {@code application.yml} over the rest before it reads the settings
+ * of its view type. The environment is therefore part of what the views are, and part of the cache
+ * key, so views compiled for one environment are not restored into a build for another.</p>
+ *
+ * <p>Defaults as {@link ViewCompileEnvironment#defaultFor} says: to an environment the fork
+ * options of {@link #getCompileOptions()} already name, else to the one the build runs with, else
+ * to {@code development}. Read as {@link #getStatedEnvironment()}, which is the input.</p>
+ *
+ * @since 7.0.18
+ */
+ @Internal
+ final Property<String> grailsEnvironment
+
+ /**
+ * The environment stated to the forked compiler, as the {@code grails.env} system property in
+ * place of any the fork options name, so that nothing the compiler inherits decides:
+ * {@link #getGrailsEnvironment()} by the environment's full name, which is what the views are
+ * compiled for and so what the cache key holds. A blank {@code grailsEnvironment} fails the build
+ * here, before anything is compiled or removed.
+ *
+ * @since 7.0.18
+ */
+ @Input
+ String getStatedEnvironment() {
+ ViewCompileEnvironment.forCompiler(grailsEnvironment, path)
+ }
+
+ /** Gradle's file operations, which empty the destination; Gradle supplies them to the task it generates. */
+ @Inject
+ protected FileSystemOperations getFileSystemOperations() {
+ throw new UnsupportedOperationException()
+ }
+
@PathSensitive(PathSensitivity.RELATIVE)
@Override
FileTree getSource() {
@@ -91,15 +141,22 @@ abstract class AbstractGroovyTemplateCompileTask extends AbstractCompile {
private ExecOperations execOperations
+ /** The build directory, which the destination directory has to be within; see {@link ViewCompileDestination}. */
+ private final DirectoryProperty buildDirectory
+
@Inject
AbstractGroovyTemplateCompileTask(ExecOperations execOperations, ObjectFactory objectFactory, String extensionDefault, String scriptBaseNameDefault, String compilerNameDefault) {
this.execOperations = execOperations
+ buildDirectory = project.layout.buildDirectory
packageName = objectFactory.property(String).convention(project.name ?: project.projectDir.canonicalFile.name)
srcDir = objectFactory.directoryProperty()
compileOptions = new ViewCompileOptions(objectFactory)
fileExtension = objectFactory.property(String).convention(extensionDefault)
scriptBaseName = objectFactory.property(String).convention(scriptBaseNameDefault)
compilerName = objectFactory.property(String).convention(compilerNameDefault)
+ grailsEnvironment = objectFactory.property(String).convention(ViewCompileEnvironment.defaultFor(project.providers, compileOptions))
+ // the input is what the compiler gets: a change to it once the input is read would be compiled but not keyed
+ grailsEnvironment.finalizeValueOnRead()
grailsConfigurationPaths = objectFactory.fileCollection()
grailsConfigurationPaths.from(
//TODO: historically this only used .yml, should it explore all configuration paths?
@@ -122,6 +179,9 @@ abstract class AbstractGroovyTemplateCompileTask extends AbstractCompile {
@TaskAction
void execute(InputChanges inputs) {
+ String environment = statedEnvironment
+ logger.info('Compiling the {} views for the {} environment', fileExtension.get(), environment)
+ ViewCompileDestination.empty(fileSystemOperations, destinationDirectory.get().asFile, buildDirectory.get().asFile, classpath.files, path)
ExecResult result = execOperations.javaexec(
new Action<JavaExecSpec>() {
@Override
@@ -130,12 +190,13 @@ abstract class AbstractGroovyTemplateCompileTask extends AbstractCompile {
javaExecSpec.mainClass.set(compilerName)
javaExecSpec.classpath = classpath
- List<String> jvmArgs = compileOptions.forkOptions.jvmArgs
+ List<String> jvmArgs = ViewCompileEnvironment.withoutStated(compileOptions.forkOptions.jvmArgs)
if (jvmArgs) {
javaExecSpec.jvmArgs(jvmArgs)
}
javaExecSpec.maxHeapSize = compileOptions.forkOptions.memoryMaximumSize
javaExecSpec.minHeapSize = compileOptions.forkOptions.memoryInitialSize
+ javaExecSpec.systemProperty(Environment.KEY, environment)
String packageImports = projectPackageNames.get().join(',') ?: packageName.get()
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileDestination.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileDestination.groovy
new file mode 100644
index 0000000000..e461ef928b
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileDestination.groovy
@@ -0,0 +1,83 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views
+
+import java.nio.file.Path
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.InvalidUserDataException
+import org.gradle.api.file.DeleteSpec
+import org.gradle.api.file.FileSystemOperations
+
+/**
+ * The directory a view compile task compiles into.
+ *
+ * <p>A compiler leaves alone what it finds there - the page compiler a page whose class is newer than
+ * its source, both the class of a view that is gone - so a destination that kept what an earlier
+ * execution compiled would carry views of another environment, classpath or configuration, or views
+ * that no longer exist, into the archive and the build cache. It is emptied before each execution,
+ * and so holds exactly what that execution compiled, at the cost of compiling every view each time
+ * the task runs. It has therefore to be a directory of the task's own, within the build directory,
+ * and one the compiler's classpath does not hold: a build that points a task at the build directory
+ * itself, outside it, or at a directory of classes the views are compiled against is refused. A
+ * directory shared with the output of another task is emptied of that output too, which Gradle
+ * reports as overlapping outputs. A destination that is itself a symbolic link is not one Gradle
+ * keeps: it removes a link at a declared output path as a stale output before the task runs.</p>
+ *
+ * @since 7.0.18
+ */
+@CompileStatic
+final class ViewCompileDestination {
+
+ private ViewCompileDestination() {
+ }
+
+ /**
+ * Empties {@code destination}, with Gradle's deleter, which removes a symbolic link beneath it
+ * rather than what the link points at, and leaves the directory there to compile into.
+ *
+ * @param operations the build's file operations
+ * @param destination the directory
+ * @param buildDirectory the build directory, which the destination has to be a directory within
+ * @param classpath the compiler's classpath, which the destination may not hold or be held by
+ * @param task the path of the task, for the message
+ * @throws InvalidUserDataException where the destination is not a directory of the task's own
+ */
+ static void empty(FileSystemOperations operations, File destination, File buildDirectory, Collection<File> classpath, String task) {
+ Path target = destination.canonicalFile.toPath()
+ Path build = buildDirectory.canonicalFile.toPath()
+ if (target == build || !target.startsWith(build)) {
+ throw new InvalidUserDataException("The destination of ${task}, ${destination}, is not a directory within the build " +
+ "directory ${buildDirectory}. It is emptied before each compilation, so it has to be a directory of the " +
+ "task's own there")
+ }
+ File held = classpath.find { File entry ->
+ Path path = entry.canonicalFile.toPath()
+ path.startsWith(target) || target.startsWith(path)
+ }
+ if (held != null) {
+ throw new InvalidUserDataException("The destination of ${task}, ${destination}, holds or is held by ${held}, which " +
+ 'is on the classpath the views are compiled against. It is emptied before each compilation, so it has to ' +
+ "be a directory of the task's own")
+ }
+ operations.delete { DeleteSpec spec -> spec.delete(destination) }
+ destination.mkdirs()
+ }
+}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileEnvironment.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileEnvironment.groovy
new file mode 100644
index 0000000000..9f9141984f
--- /dev/null
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/ViewCompileEnvironment.groovy
@@ -0,0 +1,170 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views
+
+import java.util.concurrent.Callable
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.InvalidUserDataException
+import org.gradle.api.Transformer
+import org.gradle.api.provider.Provider
+import org.gradle.api.provider.ProviderFactory
+
+import grails.util.Environment
+
+/**
+ * The Grails environment a build compiles its views for.
+ *
+ * <p>Pages, JSON views and markup views are compiled ahead of time by a forked compiler that reads
+ * the application's configuration for the environment it runs in, so what a view compiles to - the
+ * codecs a page carries, for one - depends on that environment. The compile tasks state the
+ * environment as an input and hand it to the compiler, rather than leaving the compiler to whatever
+ * it inherits. This is where the input's default comes from, and how the input is read.</p>
+ *
+ * @since 7.0.18
+ */
+@CompileStatic
+final class ViewCompileEnvironment {
+
+ /** The JVM argument a build gave the compiler its environment by, before the task had an input for it. */
+ private static final String STATED_ARGUMENT = "-D${Environment.KEY}"
+
+ /** The environments Grails has configuration blocks of its own for, by whichever name it accepts. */
+ private static final List<Environment> KNOWN = [Environment.DEVELOPMENT, Environment.PRODUCTION, Environment.TEST].asImmutable()
+
+ private ViewCompileEnvironment() {
+ }
+
+ /**
+ * The default for a compile task's {@code grailsEnvironment}: an environment the build gave the
+ * compiler itself, as a {@code -Dgrails.env} JVM argument among the fork options of
+ * {@code options}, which is how a build chose the environment before the task had an input for
+ * it, else {@link #ofBuild the one the build runs with}.
+ *
+ * @param providers the build's providers
+ * @param options the task's compile options, whose fork options may name the environment
+ * @return the name of the environment, never absent
+ */
+ static Provider<String> defaultFor(ProviderFactory providers, ViewCompileOptions options) {
+ providers.provider({ -> statedIn(options.forkOptions.jvmArgs) } as Callable<String>)
+ .orElse(ofBuild(providers))
+ }
+
+ /**
+ * The environment the build runs with: the {@code grails.env} system property given to Gradle,
+ * else the {@code GRAILS_ENV} environment variable, and {@code development} where neither is
+ * set, which is what the compiler has always fallen back to, as the application does.
+ *
+ * @param providers the build's providers, which read the property and the variable
+ * @return the name of the environment, never absent
+ */
+ static Provider<String> ofBuild(ProviderFactory providers) {
+ resolve(providers.systemProperty(Environment.KEY), providers.environmentVariable(Environment.ENV_KEY))
+ }
+
+ /**
+ * The environment {@code stated} names, else the one {@code inherited} names, else
+ * {@code development}. Each is read as {@link #canonicalName} reads it, so a blank one counts as
+ * not set and leaves the choice to the next, as the application's own resolution does.
+ *
+ * @param stated the name the build was given, the {@code grails.env} system property
+ * @param inherited the name the build inherited, the {@code GRAILS_ENV} environment variable
+ * @return the name of the environment, never absent
+ */
+ static Provider<String> resolve(Provider<String> stated, Provider<String> inherited) {
+ Transformer<String, String> canonical = { String name -> canonicalName(name) } as Transformer<String, String>
+ stated.map(canonical)
+ .orElse(inherited.map(canonical))
+ .orElse(Environment.DEVELOPMENT.name)
+ }
+
+ /**
+ * The environment a compile task states to the compiler: its {@code grailsEnvironment}, by the
+ * environment's full name. A blank one fails the build: the compiler would read an environment
+ * from what it inherits instead, which the input would not record.
+ *
+ * @param stated the task's {@code grailsEnvironment}
+ * @param task the path of the task, for the message
+ * @return the environment's full name
+ */
+ static String forCompiler(Provider<String> stated, String task) {
+ String name = canonicalName(stated.getOrNull())
+ if (name == null) {
+ throw new InvalidUserDataException("The grailsEnvironment of ${task} is blank; it has to name the environment the views are compiled for")
+ }
+ name
+ }
+
+ /**
+ * The environment a build gave the compiler itself, as a {@code -Dgrails.env} JVM argument among
+ * the fork options of its compile task. The last one wins, as it does on a JVM command line.
+ *
+ * @param jvmArgs the JVM arguments of the compiler, possibly null
+ * @return the environment's full name, or null where none is given or the one given is blank
+ */
+ static String statedIn(List<String> jvmArgs) {
+ String argument = arguments(jvmArgs).reverse().find { String arg -> statesEnvironment(arg) }
+ argument == null ? null : canonicalName(argument.substring(Math.min(argument.length(), STATED_ARGUMENT.length() + 1)))
+ }
+
+ /**
+ * {@code jvmArgs} without any {@code -Dgrails.env} among them: the task states the environment to
+ * the compiler from its input, which that argument was read into, so that the input is what the
+ * compiler gets whatever the argument says.
+ *
+ * @param jvmArgs the JVM arguments of the compiler, possibly null
+ * @return the arguments to start the compiler with
+ */
+ static List<String> withoutStated(List<String> jvmArgs) {
+ arguments(jvmArgs).findAll { String arg -> !statesEnvironment(arg) }
+ }
+
+ /**
+ * The arguments as strings. A build script may well write one as a GString, which the list's
+ * type does not keep out and the compiler's command line takes as its text.
+ */
+ private static List<String> arguments(List<String> jvmArgs) {
+ jvmArgs == null ? [] : jvmArgs.collect { Object arg -> String.valueOf(arg) }
+ }
+
+ /** Whether {@code arg} states the environment, with a value or, as a bare {@code -Dgrails.env}, without. */
+ private static boolean statesEnvironment(String arg) {
+ arg == STATED_ARGUMENT || arg.startsWith(STATED_ARGUMENT + '=')
+ }
+
+ /**
+ * The full name of the environment {@code name} refers to, read as the application reads the
+ * name it is started with: {@code dev}, {@code prod} and {@code test} stand for the environments
+ * they are short for, the full names in any case for theirs, and any other name - {@code PROD}
+ * among them - is a custom environment, whose configuration block is named by it as given.
+ * {@code null} where {@code name} is blank, so that a blank setting counts as none.
+ *
+ * @param name the name as stated
+ * @return the environment's full name, or null for a blank name
+ */
+ static String canonicalName(String name) {
+ String trimmed = name?.trim()
+ if (!trimmed) {
+ return null
+ }
+ Environment environment = Environment.getEnvironment(trimmed)
+ environment in KNOWN ? environment.name : trimmed
+ }
+}
diff --git a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageForkCompileTask.groovy b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageForkCompileTask.groovy
index c9f181895d..f32ae0e5ce 100644
--- a/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageForkCompileTask.groovy
+++ b/grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageForkCompileTask.groovy
@@ -31,13 +31,15 @@ import org.gradle.api.Action
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.Directory
import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree
import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.IgnoreEmptyDirectories
import org.gradle.api.tasks.Input
-import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.LocalState
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
@@ -46,10 +48,14 @@ import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.compile.AbstractCompile
+import org.gradle.api.tasks.util.PatternFilterable
import org.gradle.process.ExecOperations
import org.gradle.process.ExecResult
import org.gradle.process.JavaExecSpec
+import grails.util.Environment
+import org.grails.gradle.plugin.views.ViewCompileDestination
+import org.grails.gradle.plugin.views.ViewCompileEnvironment
import org.grails.gradle.plugin.views.ViewCompileOptions
/**
@@ -57,6 +63,16 @@ import org.grails.gradle.plugin.views.ViewCompileOptions
* This Task is a Forked Java Task that is configurable with fork options provided
* by {@link ViewCompileOptions}
*
+ * <p>Cacheable. What a page compiles to depends on the Groovy that compiles it and the classes it
+ * is compiled against, which {@code AbstractCompile} declares as the classpath input, on the Java it
+ * is compiled for, and on the Grails environment whose configuration it is compiled with, which is
+ * {@link #getGrailsEnvironment()}.</p>
+ *
+ * <p>Compiles every page, from an empty destination, each time it runs; see
+ * {@link ViewCompileDestination}. The compiler's own economy, of leaving a page alone whose class is
+ * newer than its source, would otherwise carry pages of another environment, classpath or
+ * configuration, or pages that no longer exist, into the archive and the build cache.</p>
+ *
* @author David Estes
* @since 4.0
*/
@@ -72,8 +88,13 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
@PathSensitive(PathSensitivity.RELATIVE)
final ConfigurableFileCollection grailsConfigurationPaths
- @InputDirectory
- @PathSensitive(PathSensitivity.RELATIVE)
+ /**
+ * The directory of the pages, which the compiler names them under and finds them by. Internal
+ * rather than an input directory, which has to exist, where a project without
+ * {@code src/main/webapp} has none for its webapp pages; the pages beneath it are the input, as
+ * {@link #getAllPages()}.
+ */
+ @Internal
final DirectoryProperty srcDir
@Nested
@@ -86,8 +107,33 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
@Optional
final Property<String> serverpath
+ /**
+ * The Grails environment the pages are compiled for, by name.
+ *
+ * <p>A page is compiled against the configuration of one environment: the forked compiler merges
+ * that environment's block of {@code application.yml} and {@code application.groovy} over the rest
+ * before it reads the codecs and the other settings a page carries once compiled. The environment
+ * is therefore part of what the pages are, and part of the cache key, so pages compiled for one
+ * environment are not restored into a build for another.</p>
+ *
+ * <p>Defaults as {@link ViewCompileEnvironment#defaultFor} says: to an environment the fork
+ * options of {@link #getCompileOptions()} already name, else to the one the build runs with, else
+ * to {@code development}. Read as {@link #getStatedEnvironment()}, which is the input.</p>
+ *
+ * @since 7.0.18
+ */
+ @Internal
+ final Property<String> grailsEnvironment
+
private ExecOperations execOperations
+ /** The build directory, which {@link #getDestinationDirectory()} has to be within. */
+ private final DirectoryProperty buildDirectory
+
+ /**
+ * Where the pages are compiled to. Emptied before each compilation, so a directory of the task's
+ * own, within the build directory; see {@link ViewCompileDestination}.
+ */
@OutputDirectory
final DirectoryProperty destinationDirectory
@@ -98,12 +144,36 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
srcDir = objectFactory.directoryProperty()
compileOptions = objectFactory.newInstance(ViewCompileOptions)
serverpath = objectFactory.property(String)
+ grailsEnvironment = objectFactory.property(String).convention(ViewCompileEnvironment.defaultFor(project.providers, compileOptions))
+ // the input is what the compiler gets: a change to it once the input is read would be compiled but not keyed
+ grailsEnvironment.finalizeValueOnRead()
grailsConfigurationPaths = objectFactory.fileCollection()
grailsConfigurationPaths.from(
project.layout.projectDirectory.file('grails-app/conf/application.yml'),
project.layout.projectDirectory.file('grails-app/conf/application.groovy')
)
- destinationDirectory = objectFactory.directoryProperty().convention(project.layout.buildDirectory.dir('gsp-classes/main'))
+ buildDirectory = project.layout.buildDirectory
+ destinationDirectory = objectFactory.directoryProperty().convention(buildDirectory.dir('gsp-classes/main'))
+ }
+
+ /**
+ * The environment stated to the forked compiler, as the {@code grails.env} system property in
+ * place of any the fork options name, so that nothing the compiler inherits decides:
+ * {@link #getGrailsEnvironment()} by the environment's full name, which is what the pages are
+ * compiled for and so what the cache key holds. A blank {@code grailsEnvironment} fails the build
+ * here, before anything is compiled or removed.
+ *
+ * @since 7.0.18
+ */
+ @Input
+ String getStatedEnvironment() {
+ ViewCompileEnvironment.forCompiler(grailsEnvironment, path)
+ }
+
+ /** Gradle's file operations, which empty the destination; Gradle supplies them to the task it generates. */
+ @Inject
+ protected FileSystemOperations getFileSystemOperations() {
+ throw new UnsupportedOperationException()
}
@Override
@@ -112,6 +182,21 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
return super.getSource()
}
+ /**
+ * Every page beneath {@link #getSrcDir()}, as the compiler reads them: it finds the pages by the
+ * directory and compiles them all, so a page the task's own filters leave out of
+ * {@link #getSource()} is compiled all the same, and is an input all the same. Fingerprinted by
+ * path under the directory, so a checkout elsewhere reuses what this one compiled.
+ *
+ * @since 7.0.18
+ */
+ @InputFiles
+ @IgnoreEmptyDirectories
+ @PathSensitive(PathSensitivity.RELATIVE)
+ FileTree getAllPages() {
+ srcDir.asFileTree.matching { PatternFilterable pages -> pages.include('**/*.' + fileExtension) }
+ }
+
@Override
void setSource(Object source) {
if (Directory.isAssignableFrom(source.class)) {
@@ -138,6 +223,9 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
}
protected void compile() {
+ String environment = statedEnvironment
+ logger.info('Compiling the pages for the {} environment', environment)
+ ViewCompileDestination.empty(fileSystemOperations, destinationDirectory.get().asFile, buildDirectory.get().asFile, classpath.files, path)
ExecResult result = execOperations.javaexec(
new Action<JavaExecSpec>() {
@Override
@@ -146,12 +234,13 @@ abstract class GroovyPageForkCompileTask extends AbstractCompile {
javaExecSpec.mainClass.set(getCompilerName())
javaExecSpec.setClasspath(getClasspath())
- def jvmArgs = compileOptions.forkOptions.jvmArgs
+ List<String> jvmArgs = ViewCompileEnvironment.withoutStated(compileOptions.forkOptions.jvmArgs)
if (jvmArgs) {
javaExecSpec.jvmArgs(jvmArgs)
}
javaExecSpec.setMaxHeapSize(compileOptions.forkOptions.memoryMaximumSize)
javaExecSpec.setMinHeapSize(compileOptions.forkOptions.memoryInitialSize)
+ javaExecSpec.systemProperty(Environment.KEY, environment)
String configFiles = grailsConfigurationPaths.files.collect { it.canonicalPath }.join(',')
diff --git a/grails-gradle/plugins/src/test/groovy/grails/plugin/json/view/JsonViewCompiler.java b/grails-gradle/plugins/src/test/groovy/grails/plugin/json/view/JsonViewCompiler.java
new file mode 100644
index 0000000000..bf0c5e7fb3
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/grails/plugin/json/view/JsonViewCompiler.java
@@ -0,0 +1,37 @@
+/*
+ * 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
+ *
+ * https://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 grails.plugin.json.view;
+
+import java.io.IOException;
+
+import org.grails.gradle.plugin.views.RecordingForkedCompiler;
+
+/**
+ * Stands in for grails-views-gson's forked view compiler, by its name, which {@code GsonViewCompilerTask}
+ * starts; see {@link RecordingForkedCompiler}.
+ */
+public final class JsonViewCompiler {
+
+ private JsonViewCompiler() {
+ }
+
+ public static void main(String[] args) throws IOException {
+ RecordingForkedCompiler.record(args[0], args[1]);
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/AbstractViewCompileEnvironmentFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/AbstractViewCompileEnvironmentFunctionalSpec.groovy
new file mode 100644
index 0000000000..e0f73f1d70
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/AbstractViewCompileEnvironmentFunctionalSpec.groovy
@@ -0,0 +1,362 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views
+
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.GradleRunner
+import org.gradle.testkit.runner.TaskOutcome
+
+import org.grails.gradle.plugin.core.GradleSpecification
+
+/**
+ * Covers the Grails environment a view compile task compiles for, for each kind of view compiler.
+ *
+ * <p>The forked compiler reads the application's configuration for one environment, so the
+ * environment is part of what the views are. The task states it rather than leaving the compiler
+ * to whatever it inherits, compiles from an empty destination so that nothing of an earlier build
+ * is left in it, and the build cache keeps views compiled for one environment away from a build for
+ * another. The compiler is a stand-in that records what it was started with, and keeps an output
+ * it finds, as the real page compiler keeps a page whose class is newer than its source.</p>
+ *
+ * @since 7.0.18
+ */
+abstract class AbstractViewCompileEnvironmentFunctionalSpec extends GradleSpecification {
+
+ protected GradleRunner runner
+ protected File checkout
+
+ /** The test project under {@code src/test/resources/test-projects} that applies the plugin. */
+ abstract String getProjectName()
+
+ /** The task of the plugin that compiles the views. */
+ abstract String getTaskName()
+
+ /** Where the task writes, relative to the project. */
+ abstract String getDestination()
+
+ /** The class resource of the stand-in compiler, which the project puts on the compiler's classpath. */
+ abstract String getStandIn()
+
+ /**
+ * Writes a view named {@code name} for the task to compile.
+ *
+ * @return the view
+ */
+ abstract File view(File checkout, String name)
+
+ void cleanup() {
+ runner?.withEnvironment(null)
+ }
+
+ def "views are compiled for development unless the build is told otherwise"() {
+ given:
+ projectWithViews()
+
+ when:
+ compileViews()
+
+ then:
+ compiled().grailsEnvironment == 'development'
+ }
+
+ def "the environment variable of the build selects the environment"() {
+ given:
+ projectWithViews()
+
+ when:
+ compileViews([GRAILS_ENV: 'production'])
+
+ then:
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "the system property given to Gradle outranks the variable, and a short name is expanded"() {
+ given:
+ projectWithViews()
+
+ when:
+ compileViews([GRAILS_ENV: 'development'], ['-Dgrails.env=prod'])
+
+ then: 'the compiler is told, not left to what it inherits'
+ compiled().grailsEnvironment == 'production'
+ compiled().inheritedEnvironment == 'development'
+ }
+
+ def "a blank system property leaves the choice to the variable, as the application does"() {
+ given:
+ projectWithViews()
+
+ when:
+ compileViews([GRAILS_ENV: 'production'], ['-Dgrails.env='])
+
+ then:
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "a build can state the environment on the task, by any name Grails accepts for it"() {
+ given:
+ projectWithViews()
+ configureTask("grailsEnvironment = ' Production '")
+
+ when:
+ compileViews([GRAILS_ENV: 'development'])
+
+ then:
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "a blank environment stated on the task fails the build, before anything is removed"() {
+ given:
+ projectWithViews()
+ compileViews()
+ configureTask("grailsEnvironment = ' '")
+
+ when:
+ BuildResult result = failToCompileViews([GRAILS_ENV: 'production'])
+
+ then:
+ result.output.contains("The grailsEnvironment of :${taskName} is blank")
+ compiled().grailsEnvironment == 'development'
+ }
+
+ def "a name the application would take for a custom environment is kept as given"() {
+ given:
+ projectWithViews()
+ configureTask("grailsEnvironment = '${stated}'")
+
+ when:
+ compileViews()
+
+ then:
+ compiled().grailsEnvironment == compiledFor
+
+ where:
+ stated | compiledFor
+ 'PROD' | 'PROD'
+ 'custom' | 'custom'
+ 'DEVELOPMENT' | 'development'
+ }
+
+ def "the environment is read once, so that what is compiled is what is keyed"() {
+ given:
+ projectWithViews()
+ configureTask("doFirst { grailsEnvironment = 'uat' }")
+
+ when:
+ BuildResult result = failToCompileViews()
+
+ then:
+ result.output.contains('is final and cannot be changed any further')
+ }
+
+ def "an environment a build gave the compiler as a JVM argument is kept, the last where there are several"() {
+ given:
+ projectWithViews()
+ configureTask("compileOptions.forkOptions.jvmArgs = ['-Dgrails.env=dev', '-Dgrails.env=uat']")
+
+ when:
+ compileViews([GRAILS_ENV: 'development'], ['-Dgrails.env=prod'])
+
+ then:
+ compiled().grailsEnvironment == 'uat'
+ }
+
+ def "an environment stated on the task outranks the one its JVM arguments give the compiler"() {
+ given:
+ projectWithViews()
+ configureTask("compileOptions.forkOptions.jvmArgs = ['-Dgrails.env=uat']; grailsEnvironment = 'production'")
+
+ when:
+ compileViews()
+
+ then:
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "a JVM argument added as a GString, which the fork options' list does not keep out, is read the same"() {
+ given: 'added to the list, since Gradle refuses a GString given to the setter'
+ projectWithViews()
+ configureTask('''compileOptions.forkOptions.jvmArgs = []; compileOptions.forkOptions.jvmArgs.add("-Dgrails.env=${'uat'}")''')
+
+ when:
+ compileViews([GRAILS_ENV: 'development'])
+
+ then:
+ compiled().grailsEnvironment == 'uat'
+ }
+
+ def "a destination that is not a directory of the task's own within the build directory is refused rather than emptied"() {
+ given:
+ projectWithViews()
+ File kept = new File(checkout, 'elsewhere/kept.txt')
+ kept.parentFile.mkdirs()
+ kept.text = 'not the task\'s to remove'
+ configureTask("destinationDirectory = ${destination}")
+
+ when:
+ BuildResult result = failToCompileViews()
+
+ then:
+ result.output.contains("The destination of :${taskName}, ")
+ result.output.contains(refusedAs)
+ kept.exists()
+
+ where:
+ destination | refusedAs
+ "layout.projectDirectory.dir('elsewhere')" | 'is not a directory within the build directory'
+ 'layout.buildDirectory' | 'is not a directory within the build directory'
+ "layout.buildDirectory.dir('classes/groovy/main')" | 'is on the classpath the views are compiled against'
+ }
+
+ def "a JVM argument that names the property without a value does not reach the compiler either"() {
+ given:
+ projectWithViews()
+ configureTask("compileOptions.forkOptions.jvmArgs = ['-Dgrails.env']")
+
+ when: 'the build is told production, and the compiler would fall back to development if the argument blanked the property'
+ compileViews([GRAILS_ENV: 'development'], ['-Dgrails.env=prod'])
+
+ then:
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "an environment change compiles every view again, from an empty destination"() {
+ given:
+ projectWithViews()
+ cached()
+
+ when: 'the environment changes between two builds with no clean in between'
+ compileViews([GRAILS_ENV: 'development'], ['--build-cache'])
+ BuildResult production = compileViews([GRAILS_ENV: 'production'], ['--build-cache'])
+
+ then: 'the compiler finds nothing of the earlier build to keep'
+ production.task(":${taskName}").outcome == TaskOutcome.SUCCESS
+ lastRun().keptEarlier == 'false'
+ compiled().grailsEnvironment == 'production'
+
+ when: 'a clean build for production is restored from the cache'
+ executeTask('clean')
+ BuildResult restored = compileViews([GRAILS_ENV: 'production'], ['--build-cache'])
+
+ then: 'it gets what production compiled, not what was left of development'
+ restored.task(":${taskName}").outcome == TaskOutcome.FROM_CACHE
+ compiled().grailsEnvironment == 'production'
+ }
+
+ def "a change to the views alone compiles from an empty destination as well"() {
+ given:
+ projectWithViews()
+ File other = view(checkout, 'other')
+ compileViews()
+
+ when: 'a view is edited'
+ other << '\n'
+ BuildResult edited = compileViews()
+
+ then:
+ edited.task(":${taskName}").outcome == TaskOutcome.SUCCESS
+ lastRun().keptEarlier == 'false'
+
+ when: 'a view is removed'
+ other.delete()
+ BuildResult removed = compileViews()
+
+ then:
+ removed.task(":${taskName}").outcome == TaskOutcome.SUCCESS
+ lastRun().keptEarlier == 'false'
+ }
+
+ def "views compiled for one environment are not restored into a build for another"() {
+ given:
+ projectWithViews()
+ cached()
+
+ when:
+ BuildResult development = compileViews([GRAILS_ENV: 'development'], ['--build-cache'])
+ executeTask('clean')
+ BuildResult production = compileViews([GRAILS_ENV: 'production'], ['--build-cache'])
+ executeTask('clean')
+ BuildResult developmentAgain = compileViews([GRAILS_ENV: 'development'], ['--build-cache'])
+
+ then: 'each environment compiles its own views'
+ development.task(":${taskName}").outcome == TaskOutcome.SUCCESS
+ production.task(":${taskName}").outcome == TaskOutcome.SUCCESS
+
+ and: 'and gets them back'
+ developmentAgain.task(":${taskName}").outcome == TaskOutcome.FROM_CACHE
+ compiled().grailsEnvironment == 'development'
+ }
+
+ protected void projectWithViews() {
+ runner = setupTestResourceProject(projectName)
+ checkout = runner.projectDir
+ // the stand-in, under the real compiler's name, and the recorder it delegates to
+ [standIn, 'org/grails/gradle/plugin/views/RecordingForkedCompiler.class'].each { String resource ->
+ File compiler = new File(checkout, "compiler/${resource}")
+ compiler.parentFile.mkdirs()
+ compiler.bytes = getClass().classLoader.getResource(resource).bytes
+ }
+ view(checkout, 'index')
+ }
+
+ /** Configures the compile task in the build script, with {@code configuration} as its body. */
+ protected void configureTask(String configuration) {
+ new File(checkout, 'build.gradle') << "\ntasks.named('${taskName}') { ${configuration} }\n"
+ }
+
+ /** Keeps the project's build cache in a directory of its own, so that only this test's builds fill it. */
+ protected void cached() {
+ File cache = new File(checkout.parentFile, "${projectName}-build-cache")
+ new File(checkout, 'settings.gradle') << """
+ buildCache { local { directory = file('${cache.absolutePath.replace('\\', '/')}') } }
+ """
+ }
+
+ /** Compiles the views in a build whose environment carries {@code variables} and no {@code GRAILS_ENV} otherwise. */
+ protected BuildResult compileViews(Map<String, String> variables = [:], List<String> arguments = []) {
+ environment(variables)
+ executeTask(taskName, arguments)
+ }
+
+ protected BuildResult failToCompileViews(Map<String, String> variables = [:], List<String> arguments = []) {
+ environment(variables)
+ runner.withArguments([taskName, '--stacktrace'] + arguments).forwardOutput().buildAndFail()
+ }
+
+ protected void environment(Map<String, String> variables) {
+ Map<String, String> environment = new HashMap<>(System.getenv())
+ environment.remove('GRAILS_ENV')
+ environment.putAll(variables)
+ runner.withEnvironment(environment)
+ }
+
+ /** What the compiler recorded when it wrote the output: the environment it was told, and the one it inherited. */
+ protected Map<String, String> compiled() {
+ new File(checkout, "${destination}/compiler.txt").readLines().collectEntries { String line ->
+ line.split('=', 2) as List
+ }
+ }
+
+ /** What the compiler found on its last run: the environment it was told, and whether an earlier output was there to keep. */
+ protected Map<String, String> lastRun() {
+ new File(checkout, "${destination}/runs.txt").readLines().last().split(' ').collectEntries { String pair ->
+ pair.split('=', 2) as List
+ }
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/RecordingForkedCompiler.java b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/RecordingForkedCompiler.java
new file mode 100644
index 0000000000..d60e29a7fe
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/RecordingForkedCompiler.java
@@ -0,0 +1,82 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Stands in for the forked view compilers, which the plugin's tests cannot depend on, and records
+ * what a compile task starts it with - the directory of the views, the views it finds beneath it
+ * with their sizes, as the real compilers find them by the directory whatever the task's filters,
+ * the environment it is told to compile for and the one it would otherwise have inherited - to
+ * {@code compiler.txt} in the destination directory. Leaves that file alone where it is already
+ * there, as the real page compiler leaves a page alone whose class is newer than its source, and
+ * appends what each run found to {@code runs.txt}. Each compiler's class names its {@code main} at
+ * this.
+ */
+public final class RecordingForkedCompiler {
+
+ private RecordingForkedCompiler() {
+ }
+
+ /**
+ * Records the run.
+ *
+ * @param source the directory of the views, as the task passes it
+ * @param destination the destination directory, as the task passes it
+ */
+ public static void record(String source, String destination) throws IOException {
+ Path target = Paths.get(destination);
+ Files.createDirectories(target);
+ Path compiled = target.resolve("compiler.txt");
+ String environment = System.getProperty("grails.env");
+ boolean keptEarlier = Files.exists(compiled);
+ if (!keptEarlier) {
+ Files.write(compiled, List.of(
+ "source=" + source,
+ "pages=" + String.join(",", pagesBeneath(Paths.get(source))),
+ "grailsEnvironment=" + environment,
+ "inheritedEnvironment=" + System.getenv("GRAILS_ENV")), StandardCharsets.UTF_8);
+ }
+ Files.write(target.resolve("runs.txt"),
+ List.of("grailsEnvironment=" + environment + " keptEarlier=" + keptEarlier),
+ StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+ }
+
+ /** Each file beneath {@code source} by its path under it and its size, in order. */
+ private static List<String> pagesBeneath(Path source) throws IOException {
+ List<String> pages = new ArrayList<>();
+ if (Files.isDirectory(source)) {
+ try (Stream<Path> files = Files.walk(source)) {
+ files.filter(Files::isRegularFile).sorted().forEach(file ->
+ pages.add(source.relativize(file).toString().replace(File.separatorChar, '/') + ":" + file.toFile().length()));
+ }
+ }
+ return pages;
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageCompileEnvironmentFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageCompileEnvironmentFunctionalSpec.groovy
new file mode 100644
index 0000000000..adaaef0762
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/gsp/GroovyPageCompileEnvironmentFunctionalSpec.groovy
@@ -0,0 +1,110 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views.gsp
+
+import org.gradle.testkit.runner.BuildResult
+import org.gradle.testkit.runner.TaskOutcome
+
+import org.grails.gradle.plugin.views.AbstractViewCompileEnvironmentFunctionalSpec
+
+/**
+ * Covers the environment {@link GroovyPageForkCompileTask} compiles pages for, and that every page the
+ * compiler reads is an input of the task.
+ *
+ * @since 7.0.18
+ */
+class GroovyPageCompileEnvironmentFunctionalSpec extends AbstractViewCompileEnvironmentFunctionalSpec {
+
+ @Override
+ String getProjectName() {
+ 'gsp-compile-environment'
+ }
+
+ @Override
+ String getTaskName() {
+ 'compileGroovyPages'
+ }
+
+ @Override
+ String getDestination() {
+ 'build/gsp-classes/main'
+ }
+
+ // the page compiler comes from grails-web-gsp, which this build cannot depend on
+ @Override
+ String getStandIn() {
+ 'org/grails/web/pages/GroovyPageForkedCompiler.class'
+ }
+
+ @Override
+ File view(File checkout, String name) {
+ File page = new File(checkout, "grails-app/views/book/${name}.gsp")
+ page.parentFile.mkdirs()
+ page.text = '${book.title}'
+ page
+ }
+
+ def "the webapp pages task has no source in a project without src/main/webapp, rather than failing for want of it"() {
+ given:
+ projectWithViews()
+
+ when:
+ BuildResult result = compileViews()
+
+ then:
+ result.task(':compileWebappGroovyPages').outcome == TaskOutcome.NO_SOURCE
+ }
+
+ def "a project without pages of its own compiles none"() {
+ given:
+ projectWithViews()
+ new File(checkout, 'grails-app/views').deleteDir()
+
+ when:
+ BuildResult result = compileViews()
+
+ then:
+ result.task(':compileGroovyPages').outcome == TaskOutcome.NO_SOURCE
+ }
+
+ def "a page the task's own filters leave out is compiled all the same, and so is an input all the same"() {
+ given:
+ projectWithViews()
+ File other = view(checkout, 'other')
+ configureTask("exclude 'book/other.gsp'")
+ cached()
+ compileViews([:], ['--build-cache'])
+
+ when: 'the page outside the source is edited'
+ other << '\n'
+ BuildResult edited = compileViews([:], ['--build-cache'])
+
+ then: 'the task runs rather than standing up to date, and the compiler sees the edit'
+ edited.task(':compileGroovyPages').outcome == TaskOutcome.SUCCESS
+ compiled().pages.contains("book/other.gsp:${other.length()}")
+
+ when: 'a clean build is restored from the cache'
+ executeTask('clean')
+ BuildResult restored = compileViews([:], ['--build-cache'])
+
+ then: 'with what the edited page compiled to'
+ restored.task(':compileGroovyPages').outcome == TaskOutcome.FROM_CACHE
+ compiled().pages.contains("book/other.gsp:${other.length()}")
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/json/GsonViewCompileEnvironmentFunctionalSpec.groovy b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/json/GsonViewCompileEnvironmentFunctionalSpec.groovy
new file mode 100644
index 0000000000..90eaa424ef
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/gradle/plugin/views/json/GsonViewCompileEnvironmentFunctionalSpec.groovy
@@ -0,0 +1,60 @@
+/*
+ * 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
+ *
+ * https://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.grails.gradle.plugin.views.json
+
+import org.grails.gradle.plugin.views.AbstractViewCompileEnvironmentFunctionalSpec
+
+/**
+ * Covers the environment {@link GsonViewCompilerTask} compiles JSON views for: the view compiler
+ * reads the application's configuration for one environment too, from the same kind of forked task
+ * as the page compiler.
+ *
+ * @since 7.0.18
+ */
+class GsonViewCompileEnvironmentFunctionalSpec extends AbstractViewCompileEnvironmentFunctionalSpec {
+
+ @Override
+ String getProjectName() {
+ 'gson-compile-environment'
+ }
+
+ @Override
+ String getTaskName() {
+ 'compileGsonViews'
+ }
+
+ @Override
+ String getDestination() {
+ 'build/gson-classes/main'
+ }
+
+ // the view compiler comes from grails-views-gson, which this build cannot depend on
+ @Override
+ String getStandIn() {
+ 'grails/plugin/json/view/JsonViewCompiler.class'
+ }
+
+ @Override
+ File view(File checkout, String name) {
+ File view = new File(checkout, "grails-app/views/book/${name}.gson")
+ view.parentFile.mkdirs()
+ view.text = 'json { title book.title }'
+ view
+ }
+}
diff --git a/grails-gradle/plugins/src/test/groovy/org/grails/web/pages/GroovyPageForkedCompiler.java b/grails-gradle/plugins/src/test/groovy/org/grails/web/pages/GroovyPageForkedCompiler.java
new file mode 100644
index 0000000000..1f18ffe382
--- /dev/null
+++ b/grails-gradle/plugins/src/test/groovy/org/grails/web/pages/GroovyPageForkedCompiler.java
@@ -0,0 +1,37 @@
+/*
+ * 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
+ *
+ * https://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.grails.web.pages;
+
+import java.io.IOException;
+
+import org.grails.gradle.plugin.views.RecordingForkedCompiler;
+
+/**
+ * Stands in for grails-web-gsp's forked page compiler, by its name, which {@code GroovyPageForkCompileTask}
+ * starts; see {@link RecordingForkedCompiler}.
+ */
+public final class GroovyPageForkedCompiler {
+
+ private GroovyPageForkedCompiler() {
+ }
+
+ public static void main(String[] args) throws IOException {
+ RecordingForkedCompiler.record(args[0], args[1]);
+ }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/build.gradle
new file mode 100644
index 0000000000..1831da512d
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/build.gradle
@@ -0,0 +1,12 @@
+// A project whose JSON views are compiled by a stand-in for the view compiler, which the test puts
+// in the compiler directory, so that what the compile task starts the compiler with can be read back.
+// Spring Boot is applied because the views plugin wires its task to Boot's main class resolution.
+plugins {
+ id 'groovy'
+ id 'org.springframework.boot'
+ id 'org.apache.grails.gradle.grails-gson'
+}
+
+dependencies {
+ implementation files('compiler')
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/settings.gradle
new file mode 100644
index 0000000000..fb481ece2b
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/gson-compile-environment/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'gson-compile-environment'
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/build.gradle b/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/build.gradle
new file mode 100644
index 0000000000..303813c5d1
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/build.gradle
@@ -0,0 +1,16 @@
+// A project whose pages are compiled by a stand-in for the page compiler, which the test puts in
+// the compiler directory, so that what the compile task starts the compiler with can be read back.
+plugins {
+ id 'groovy'
+ id 'org.apache.grails.gradle.grails-gsp'
+}
+
+dependencies {
+ implementation files('compiler')
+}
+
+// the servlet API the plugin adds for the page compiler is of no use to the stand-in, and
+// resolving it would need a repository
+configurations.named('gspCompile') {
+ withDependencies { it.clear() }
+}
diff --git a/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/settings.gradle b/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/settings.gradle
new file mode 100644
index 0000000000..5b3aa19a51
--- /dev/null
+++ b/grails-gradle/plugins/src/test/resources/test-projects/gsp-compile-environment/settings.gradle
@@ -0,0 +1 @@
+rootProject.name = 'gsp-compile-environment'