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/07/26 20:33:44 UTC

(grails-intellij-plugin) 24/35: Convert the structure package from Kotlin to Java

This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/grails-intellij-plugin.git

commit 83356c517c644379c998352d3bd28f69f3d01dc2
Author: James Daugherty <jd...@jdresources.net>
AuthorDate: Sun Jul 26 13:55:54 2026 -0400

    Convert the structure package from Kotlin to Java
    
    14 Kotlin files become 15 Java files, leaving no Kotlin under structure/.
    987 tests / 0 failures, RAT clean.
    
    This package had to be converted as one unit rather than file by file, which is a
    constraint the earlier batches did not hit: Kotlin's `override val project` cannot
    override a Java getter. Converting GrailsApplication alone produced
    "'project' overrides nothing" from every Kotlin subclass, because Kotlin
    synthesises property syntax for *reading* a Java getter but requires
    `override fun getProject()` to implement one. So a class hierarchy crossing the
    language boundary is not partially convertible -- interface and implementors move
    together.
    
    Two smaller translation traps, both caught by the compiler:
    
    * Grails2Application was Kotlin `internal`, which is public in bytecode, so the
      runner package could reach it across packages. Package-private in Java could not;
      it is public with @ApiStatus.Internal, the same fix the projectView batch needed
      for its plugin.xml-instantiated classes.
    * GrailsSettings.properties is a public FIELD, not a getter. Kotlin's
      `settings.properties[key]` reads fields and getters identically, so the literal
      translation to .getProperties() does not compile.
    
    Naming: structure/util.kt held a single @JvmField COMPARATOR, so Java already
    referenced it as UtilKt.COMPARATOR; it becomes GrailsApplications.COMPARATOR, still
    a static field, with the one import updated in GrailsApplicationCombobox.
    impl/util.kt's nullable-receiver extension becomes GrailsProperties.getPropertyValue.
    
    `by lazy` / `lazyUnsafe` fields become volatile lazily-computed fields, as in the
    earlier batches. Each is derived from immutable external-system data or the VFS, so
    an idempotent recompute under a race is harmless and only publication needs care.
    
    62 files left in the conversion target set.
---
 .../runner/ui/GrailsApplicationCombobox.java       |   2 +-
 ...ails3Application.kt => Grails3Application.java} |  10 +-
 .../grails/structure/GrailsApplication.java        |  58 +++++++++
 .../plugins/grails/structure/GrailsApplication.kt  |  54 --------
 .../grails/structure/GrailsApplicationBase.java    |  86 +++++++++++++
 .../grails/structure/GrailsApplicationBase.kt      |  58 ---------
 .../grails/structure/GrailsApplications.java       |  47 +++++++
 .../grails/structure/GrailsCommonClassNames.java   | 143 +++++++++++++++++++++
 .../grails/structure/GrailsCommonClassNames.kt     |  84 ------------
 ...ilsApplication.kt => OldGrailsApplication.java} |  15 ++-
 .../grails/structure/impl/Grails2Application.java  |  73 +++++++++++
 .../grails/structure/impl/Grails2Application.kt    |  46 -------
 .../structure/impl/Grails3ApplicationBase.java     |  91 +++++++++++++
 .../structure/impl/Grails3ApplicationBase.kt       |  55 --------
 .../structure/impl/Grails3ApplicationProvider.java |  64 +++++++++
 .../structure/impl/Grails3ApplicationProvider.kt   |  54 --------
 .../impl/Grails3MultiModuleApplication.java        |  93 ++++++++++++++
 .../impl/Grails3MultiModuleApplication.kt          |  55 --------
 .../impl/Grails3SingleModuleApplication.java       |  57 ++++++++
 .../impl/Grails3SingleModuleApplication.kt         |  51 --------
 .../impl/GrailsModuleBasedApplication.java         |  77 +++++++++++
 .../structure/impl/GrailsModuleBasedApplication.kt |  60 ---------
 .../grails/structure/impl/GrailsProperties.java    |  34 +++++
 .../impl/OldGrailsModuleBasedApplication.java      |  81 ++++++++++++
 .../impl/OldGrailsModuleBasedApplication.kt        |  69 ----------
 .../plugins/grails/structure/impl/util.kt          |  21 ---
 .../org/jetbrains/plugins/grails/structure/util.kt |  27 ----
 27 files changed, 918 insertions(+), 647 deletions(-)

diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/runner/ui/GrailsApplicationCombobox.java b/plugin/src/main/java/org/jetbrains/plugins/grails/runner/ui/GrailsApplicationCombobox.java
index 6b81b2b..e210a5a 100644
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/runner/ui/GrailsApplicationCombobox.java
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/runner/ui/GrailsApplicationCombobox.java
@@ -29,7 +29,7 @@ import org.jetbrains.plugins.grails.structure.GrailsApplication;
 
 import java.util.Collection;
 
-import static org.jetbrains.plugins.grails.structure.UtilKt.COMPARATOR;
+import static org.jetbrains.plugins.grails.structure.GrailsApplications.COMPARATOR;
 
 public class GrailsApplicationCombobox extends ComboBox<GrailsApplication> {
 
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.java
similarity index 70%
rename from plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.kt
rename to plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.java
index efc17f4..1f24d56 100644
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.kt
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/Grails3Application.java
@@ -13,12 +13,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.jetbrains.plugins.grails.structure;
 
-package org.jetbrains.plugins.grails.structure
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.plugins.grails.gradle.GrailsModuleData;
 
-import org.jetbrains.plugins.grails.gradle.GrailsModuleData
+public interface Grails3Application extends GrailsApplication {
 
-interface Grails3Application : GrailsApplication {
-
-  val gradleData: GrailsModuleData
+  @NotNull GrailsModuleData getGradleData();
 }
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.java
new file mode 100644
index 0000000..2b3af1c
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure;
+
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.NlsSafe;
+import com.intellij.openapi.util.UserDataHolderEx;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.search.GlobalSearchScope;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.grails.util.version.Version;
+
+import javax.swing.Icon;
+
+public interface GrailsApplication extends UserDataHolderEx {
+
+  @NlsSafe @NotNull String getName();
+
+  default @Nullable String getAppVersion() {
+    return null;
+  }
+
+  @NotNull Icon getIcon();
+
+  @NotNull Project getProject();
+
+  /**
+   * @return folder that contains grails-app, application.properties, etc.
+   */
+  @NotNull VirtualFile getRoot();
+
+  /**
+   * @return {@code grails-app} folder
+   */
+  @NotNull VirtualFile getAppRoot();
+
+  @NotNull Version getGrailsVersion();
+
+  boolean isValid();
+
+  void invalidate();
+
+  @NotNull GlobalSearchScope getScope(boolean includeDependencies, boolean testsOnly);
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.kt
deleted file mode 100644
index 82308d4..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplication.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure
-
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.util.NlsSafe
-import com.intellij.openapi.util.UserDataHolderEx
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.psi.search.GlobalSearchScope
-import org.jetbrains.plugins.grails.util.version.Version
-import javax.swing.Icon
-
-interface GrailsApplication : UserDataHolderEx {
-
-  val name: @NlsSafe String
-
-  val appVersion: String? get() = null
-
-  val icon: Icon
-
-  val project: Project
-
-  /**
-   * @return folder that contains grails-app, application.properties, etc.
-   */
-  val root: VirtualFile
-
-  /**
-   * @return `grails-app` folder
-   */
-  val appRoot: VirtualFile
-
-  val grailsVersion: Version
-
-  val isValid: Boolean
-
-  fun invalidate()
-
-  fun getScope(includeDependencies: Boolean, testsOnly: Boolean): GlobalSearchScope
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.java
new file mode 100644
index 0000000..c6d3456
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure;
+
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.util.UserDataHolderBase;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.plugins.grails.GroovyMvcIcons;
+import org.jetbrains.plugins.grails.config.GrailsConstants;
+
+import javax.swing.Icon;
+import java.util.Objects;
+
+public abstract class GrailsApplicationBase extends UserDataHolderBase implements GrailsApplication {
+
+  private final Project myProject;
+  private final VirtualFile myRoot;
+  private boolean myValid = true;
+
+  protected GrailsApplicationBase(@NotNull Project project, @NotNull VirtualFile root) {
+    myProject = project;
+    myRoot = root;
+  }
+
+  @Override
+  public @NotNull Project getProject() {
+    return myProject;
+  }
+
+  @Override
+  public @NotNull VirtualFile getRoot() {
+    return myRoot;
+  }
+
+  @Override
+  public @NotNull VirtualFile getAppRoot() {
+    return Objects.requireNonNull(myRoot.findChild(GrailsConstants.APP_DIRECTORY));
+  }
+
+  @Override
+  public @NotNull Icon getIcon() {
+    return GroovyMvcIcons.Grails_app;
+  }
+
+  @Override
+  public boolean isValid() {
+    return myValid && myRoot.findChild(GrailsConstants.APP_DIRECTORY) != null && !myProject.isDisposed();
+  }
+
+  @Override
+  public void invalidate() {
+    myValid = false;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) return true;
+    if (other == null || other.getClass() != getClass()) return false;
+    GrailsApplicationBase that = (GrailsApplicationBase)other;
+    return myProject.equals(that.myProject) && myRoot.equals(that.myRoot);
+  }
+
+  @Override
+  public int hashCode() {
+    return 31 * myProject.hashCode() + myRoot.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{name: " + getName() + ", root: " + myRoot + ", version: " + getGrailsVersion() + "}";
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.kt
deleted file mode 100644
index 09cefe5..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplicationBase.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure
-
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.util.UserDataHolderBase
-import com.intellij.openapi.vfs.VirtualFile
-import org.jetbrains.plugins.grails.GroovyMvcIcons
-import org.jetbrains.plugins.grails.config.GrailsConstants
-import javax.swing.Icon
-
-abstract class GrailsApplicationBase(
-  override val project: Project,
-  override val root: VirtualFile
-) : UserDataHolderBase(), GrailsApplication {
-
-  override val appRoot: VirtualFile get() = root.findChild(GrailsConstants.APP_DIRECTORY)!!
-
-  override val icon: Icon = GroovyMvcIcons.Grails_app
-
-  private var myValid = true
-
-  override val isValid: Boolean get() = myValid && root.findChild(GrailsConstants.APP_DIRECTORY) != null && !project.isDisposed
-
-  override fun invalidate() {
-    myValid = false
-  }
-
-  override fun equals(other: Any?): Boolean {
-    if (this === other) return true
-    if (other?.javaClass != javaClass) return false
-
-    other as GrailsApplicationBase
-
-    if (project != other.project) return false
-    if (root != other.root) return false
-
-    return true
-  }
-
-  override fun hashCode(): Int = 31 * project.hashCode() + root.hashCode()
-
-  override fun toString(): String = "${javaClass.simpleName}{name: $name, root: $root, version: $grailsVersion}"
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplications.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplications.java
new file mode 100644
index 0000000..344ed61
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsApplications.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Comparator;
+
+public final class GrailsApplications {
+
+  /**
+   * Orders applications by name, case-insensitively, with nulls first.
+   *
+   * <p>Was a top-level {@code @JvmField val} in structure/util.kt, so Java already referenced it
+   * as {@code UtilKt.COMPARATOR}; it keeps the same shape as a static field here.
+   */
+  public static final Comparator<GrailsApplication> COMPARATOR = new Comparator<>() {
+    @Override
+    public int compare(@Nullable GrailsApplication app1, @Nullable GrailsApplication app2) {
+      if (app1 == null && app2 == null) return 0;
+      if (app1 == null) return -1;
+      if (app2 == null) return 1;
+      return app1.getName().compareToIgnoreCase(app2.getName());
+    }
+  };
+
+  private GrailsApplications() {
+  }
+
+  static @NotNull Comparator<GrailsApplication> comparator() {
+    return COMPARATOR;
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.java
new file mode 100644
index 0000000..ee384b1
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure;
+
+import com.intellij.psi.PsiElement;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.plugins.grails.util.version.Version;
+
+/**
+ * Fully qualified names of Grails/GORM classes whose packages moved between major versions.
+ * The prefix and the constraint sub-package differ per era, so callers resolve an instance for
+ * the context's application rather than hardcoding names.
+ */
+public final class GrailsCommonClassNames {
+
+  private static final String HIBERNATE_MAPPING_BUILDER = "orm.hibernate.cfg.HibernateMappingBuilder";
+  private static final String CREDIT_CARD_CONSTRAINT = "CreditCardConstraint";
+  private static final String MAX_SIZE_CONSTRAINT = "MaxSizeConstraint";
+  private static final String EMAIL_CONSTRAINT = "EmailConstraint";
+  private static final String BLANK_CONSTRAINT = "BlankConstraint";
+  private static final String RANGE_CONSTRAINT = "RangeConstraint";
+  private static final String URL_CONSTRAINT = "UrlConstraint";
+  private static final String SIZE_CONSTRAINT = "SizeConstraint";
+  private static final String IN_LIST_CONSTRAINT = "InListConstraint";
+  private static final String MATCHES_CONSTRAINT = "MatchesConstraint";
+  private static final String MIN_CONSTRAINT = "MinConstraint";
+  private static final String MAX_CONSTRAINT = "MaxConstraint";
+  private static final String MIN_SIZE_CONSTRAINT = "MinSizeConstraint";
+  private static final String SCALE_CONSTRAINT = "ScaleConstraint";
+  private static final String NOT_EQUAL_CONSTRAINT = "NotEqualConstraint";
+  private static final String NULLABLE_CONSTRAINT = "NullableConstraint";
+  private static final String VALIDATOR_CONSTRAINT = "ValidatorConstraint";
+  private static final String UNIQUE_CONSTRAINT = "UniqueConstraint";
+  private static final String URL_MAPPING_BUILDER = "web.mapping.DefaultUrlMappingEvaluator.UrlMappingBuilder";
+
+  private static final GrailsCommonClassNames AFTER_40 =
+    new GrailsCommonClassNames("org.grails.", "datastore.gorm.validation.constraints.");
+  private static final GrailsCommonClassNames AFTER_30 =
+    new GrailsCommonClassNames("org.grails.", "validation.");
+  private static final GrailsCommonClassNames BEFORE_30 =
+    new GrailsCommonClassNames("org.codehaus.groovy.grails.", "validation.");
+
+  private final String hibernateMappingBuilder;
+  private final String creditCardConstraint;
+  private final String emailConstraint;
+  private final String blankConstraint;
+  private final String rangeConstraint;
+  private final String inListConstraint;
+  private final String urlConstraint;
+  private final String sizeConstraint;
+  private final String matchesConstraint;
+  private final String minConstraint;
+  private final String maxConstraint;
+  private final String minSizeConstraint;
+  private final String maxSizeConstraint;
+  private final String scaleConstraint;
+  private final String notEqualConstraint;
+  private final String nullableConstraint;
+  private final String validatorConstraint;
+  private final String uniqueConstraint;
+  private final String urlMappingBuilder;
+
+  private GrailsCommonClassNames(@NotNull String globalPrefix, @NotNull String constraintPath) {
+    hibernateMappingBuilder = globalPrefix + HIBERNATE_MAPPING_BUILDER;
+    creditCardConstraint = globalPrefix + constraintPath + CREDIT_CARD_CONSTRAINT;
+    emailConstraint = globalPrefix + constraintPath + EMAIL_CONSTRAINT;
+    blankConstraint = globalPrefix + constraintPath + BLANK_CONSTRAINT;
+    rangeConstraint = globalPrefix + constraintPath + RANGE_CONSTRAINT;
+    inListConstraint = globalPrefix + constraintPath + IN_LIST_CONSTRAINT;
+    urlConstraint = globalPrefix + constraintPath + URL_CONSTRAINT;
+    sizeConstraint = globalPrefix + constraintPath + SIZE_CONSTRAINT;
+    matchesConstraint = globalPrefix + constraintPath + MATCHES_CONSTRAINT;
+    minConstraint = globalPrefix + constraintPath + MIN_CONSTRAINT;
+    maxConstraint = globalPrefix + constraintPath + MAX_CONSTRAINT;
+    minSizeConstraint = globalPrefix + constraintPath + MIN_SIZE_CONSTRAINT;
+    maxSizeConstraint = globalPrefix + constraintPath + MAX_SIZE_CONSTRAINT;
+    scaleConstraint = globalPrefix + constraintPath + SCALE_CONSTRAINT;
+    notEqualConstraint = globalPrefix + constraintPath + NOT_EQUAL_CONSTRAINT;
+    nullableConstraint = globalPrefix + constraintPath + NULLABLE_CONSTRAINT;
+    validatorConstraint = globalPrefix + constraintPath + VALIDATOR_CONSTRAINT;
+    uniqueConstraint = globalPrefix + constraintPath + UNIQUE_CONSTRAINT;
+    urlMappingBuilder = globalPrefix + URL_MAPPING_BUILDER;
+  }
+
+  public static @NotNull GrailsCommonClassNames getInstance(@NotNull PsiElement context) {
+    GrailsApplication app = GrailsApplicationManager.findApplication(context);
+    if (app == null) return BEFORE_30;
+    if (Version.AT_LEAST_4.contains(app.getGrailsVersion())) return AFTER_40;
+    if (Version.AT_LEAST_3.contains(app.getGrailsVersion())) return AFTER_30;
+    return BEFORE_30;
+  }
+
+  public @NotNull String getHibernateMappingBuilder() { return hibernateMappingBuilder; }
+
+  public @NotNull String getCreditCardConstraint() { return creditCardConstraint; }
+
+  public @NotNull String getEmailConstraint() { return emailConstraint; }
+
+  public @NotNull String getBlankConstraint() { return blankConstraint; }
+
+  public @NotNull String getRangeConstraint() { return rangeConstraint; }
+
+  public @NotNull String getInListConstraint() { return inListConstraint; }
+
+  public @NotNull String getUrlConstraint() { return urlConstraint; }
+
+  public @NotNull String getSizeConstraint() { return sizeConstraint; }
+
+  public @NotNull String getMatchesConstraint() { return matchesConstraint; }
+
+  public @NotNull String getMinConstraint() { return minConstraint; }
+
+  public @NotNull String getMaxConstraint() { return maxConstraint; }
+
+  public @NotNull String getMinSizeConstraint() { return minSizeConstraint; }
+
+  public @NotNull String getMaxSizeConstraint() { return maxSizeConstraint; }
+
+  public @NotNull String getScaleConstraint() { return scaleConstraint; }
+
+  public @NotNull String getNotEqualConstraint() { return notEqualConstraint; }
+
+  public @NotNull String getNullableConstraint() { return nullableConstraint; }
+
+  public @NotNull String getValidatorConstraint() { return validatorConstraint; }
+
+  public @NotNull String getUniqueConstraint() { return uniqueConstraint; }
+
+  public @NotNull String getUrlMappingBuilder() { return urlMappingBuilder; }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.kt
deleted file mode 100644
index 9bcfda7..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/GrailsCommonClassNames.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure
-
-import com.intellij.psi.PsiElement
-import org.jetbrains.plugins.grails.util.version.Version
-
-class GrailsCommonClassNames private constructor(globalPrefix: String, constraintPath: String) {
-
-  companion object {
-    private val after40: GrailsCommonClassNames = GrailsCommonClassNames("org.grails.", "datastore.gorm.validation.constraints.")
-    private val after30: GrailsCommonClassNames = GrailsCommonClassNames("org.grails.", "validation.")
-    private val before30: GrailsCommonClassNames = GrailsCommonClassNames("org.codehaus.groovy.grails.", "validation.")
-
-    @JvmStatic
-    fun getInstance(context: PsiElement): GrailsCommonClassNames {
-      val app: GrailsApplication = GrailsApplicationManager.findApplication(context) ?: return before30
-      if (Version.AT_LEAST_4.contains(app.grailsVersion)) {
-        return after40
-      }
-      else if (Version.AT_LEAST_3.contains(app.grailsVersion)) {
-        return after30
-      }
-      else {
-        return before30
-      }
-    }
-
-    private const val HIBERNATE_MAPPING_BUILDER = "orm.hibernate.cfg.HibernateMappingBuilder"
-    private const val CREDIT_CARD_CONSTRAINT    = "CreditCardConstraint"
-    private const val MAX_SIZE_CONSTRAINT       = "MaxSizeConstraint"
-    private const val EMAIL_CONSTRAINT          = "EmailConstraint"
-    private const val BLANK_CONSTRAINT          = "BlankConstraint"
-    private const val RANGE_CONSTRAINT          = "RangeConstraint"
-    private const val URL_CONSTRAINT            = "UrlConstraint"
-    private const val SIZE_CONSTRAINT           = "SizeConstraint"
-    private const val IN_LIST_CONSTRAINT        = "InListConstraint"
-    private const val MATCHES_CONSTRAINT        = "MatchesConstraint"
-    private const val MIN_CONSTRAINT            = "MinConstraint"
-    private const val MAX_CONSTRAINT            = "MaxConstraint"
-    private const val MIN_SIZE_CONSTRAINT       = "MinSizeConstraint"
-    private const val SCALE_CONSTRAINT          = "ScaleConstraint"
-    private const val NOT_EQUAL_CONSTRAINT      = "NotEqualConstraint"
-    private const val NULLABLE_CONSTRAINT       = "NullableConstraint"
-    private const val VALIDATOR_CONSTRAINT      = "ValidatorConstraint"
-    private const val UNIQUE_CONSTRAINT         = "UniqueConstraint"
-    private const val URL_MAPPING_BUILDER       = "web.mapping.DefaultUrlMappingEvaluator.UrlMappingBuilder"
-  }
-
-  val hibernateMappingBuilder: String = globalPrefix + HIBERNATE_MAPPING_BUILDER
-  val creditCardConstraint: String    = globalPrefix + constraintPath + CREDIT_CARD_CONSTRAINT
-  val emailConstraint: String         = globalPrefix + constraintPath + EMAIL_CONSTRAINT
-  val blankConstraint: String         = globalPrefix + constraintPath + BLANK_CONSTRAINT
-  val rangeConstraint: String         = globalPrefix + constraintPath + RANGE_CONSTRAINT
-  val inListConstraint: String        = globalPrefix + constraintPath + IN_LIST_CONSTRAINT
-  val urlConstraint: String           = globalPrefix + constraintPath + URL_CONSTRAINT
-  val sizeConstraint: String          = globalPrefix + constraintPath + SIZE_CONSTRAINT
-  val matchesConstraint: String       = globalPrefix + constraintPath + MATCHES_CONSTRAINT
-  val minConstraint: String           = globalPrefix + constraintPath + MIN_CONSTRAINT
-  val maxConstraint: String           = globalPrefix + constraintPath + MAX_CONSTRAINT
-  val minSizeConstraint: String       = globalPrefix + constraintPath + MIN_SIZE_CONSTRAINT
-  val maxSizeConstraint: String       = globalPrefix + constraintPath + MAX_SIZE_CONSTRAINT
-  val scaleConstraint: String         = globalPrefix + constraintPath + SCALE_CONSTRAINT
-  val notEqualConstraint: String      = globalPrefix + constraintPath + NOT_EQUAL_CONSTRAINT
-  val nullableConstraint: String      = globalPrefix + constraintPath + NULLABLE_CONSTRAINT
-  val validatorConstraint: String     = globalPrefix + constraintPath + VALIDATOR_CONSTRAINT
-  val uniqueConstraint: String        = globalPrefix + constraintPath + UNIQUE_CONSTRAINT
-  val urlMappingBuilder: String       = globalPrefix + URL_MAPPING_BUILDER
-
-}
\ No newline at end of file
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.java
similarity index 62%
rename from plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.kt
rename to plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.java
index 11fced1..170857b 100644
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.kt
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/OldGrailsApplication.java
@@ -13,15 +13,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package org.jetbrains.plugins.grails.structure;
 
-package org.jetbrains.plugins.grails.structure
+import com.intellij.lang.properties.psi.PropertiesFile;
+import com.intellij.openapi.module.Module;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
-import com.intellij.lang.properties.psi.PropertiesFile
-import com.intellij.openapi.module.Module
+public interface OldGrailsApplication extends GrailsApplication {
 
-interface OldGrailsApplication : GrailsApplication {
+  @NotNull Module getModule();
 
-  val module: Module
-
-  val applicationProperties: PropertiesFile?
+  @Nullable PropertiesFile getApplicationProperties();
 }
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.java
new file mode 100644
index 0000000..1e70256
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.application.ReadAction;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.grails.config.GrailsModuleStructureUtil;
+import org.jetbrains.plugins.grails.config.GrailsSettingsService;
+import org.jetbrains.plugins.grails.config.PrintGrailsSettingsConstants;
+import org.jetbrains.plugins.grails.util.version.Version;
+import org.jetbrains.plugins.grails.util.version.VersionImpl;
+
+// Public because it is referenced from the runner package. It was Kotlin `internal`, which is
+// public in bytecode, so cross-package use compiled; package-private here would not.
+@ApiStatus.Internal
+public final class Grails2Application extends OldGrailsModuleBasedApplication {
+
+  public Grails2Application(@NotNull VirtualFile root, @NotNull Module module) {
+    super(module, root);
+  }
+
+  @Override
+  public @NotNull String getName() {
+    String name = getApplicationPropertiesValue("app.name");
+    return name != null ? name : getModule().getName();
+  }
+
+  @Override
+  public @Nullable String getAppVersion() {
+    return getApplicationPropertiesValue("app.version");
+  }
+
+  public @Nullable Version getApplicationPropertiesVersion() {
+    String version = getApplicationPropertiesValue(GrailsModuleStructureUtil.GRAILS_VERSION_KEY);
+    return version == null || version.isBlank() ? null : new VersionImpl(version);
+  }
+
+  public boolean isRunForked() {
+    return getBooleanSetting(PrintGrailsSettingsConstants.DEBUG_RUN_FORK);
+  }
+
+  public boolean isTestForked() {
+    return getBooleanSetting(PrintGrailsSettingsConstants.DEBUG_TEST_FORK);
+  }
+
+  private @Nullable String getApplicationPropertiesValue(@NotNull String key) {
+    return ReadAction.compute(() -> GrailsProperties.getPropertyValue(getApplicationProperties(), key));
+  }
+
+  private boolean getBooleanSetting(@NotNull String key) {
+    return ReadAction.compute(() -> {
+      String value = GrailsSettingsService.getGrailsSettings(getModule()).properties.get(key);
+      return value != null && Boolean.parseBoolean(value);
+    });
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.kt
deleted file mode 100644
index 3aec612..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails2Application.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.application.runReadAction
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.vfs.VirtualFile
-import org.jetbrains.plugins.grails.config.GrailsModuleStructureUtil
-import org.jetbrains.plugins.grails.config.GrailsSettingsService.getGrailsSettings
-import org.jetbrains.plugins.grails.config.PrintGrailsSettingsConstants
-import org.jetbrains.plugins.grails.util.version.Version
-import org.jetbrains.plugins.grails.util.version.VersionImpl
-
-internal class Grails2Application(root: VirtualFile, module: Module) : OldGrailsModuleBasedApplication(module, root) {
-
-  override val name: String get() = getApplicationPropertiesValue("app.name") ?: module.name
-
-  override val appVersion: String? get() = getApplicationPropertiesValue("app.version")
-
-  val applicationPropertiesVersion: Version? get() {
-    val version = getApplicationPropertiesValue(GrailsModuleStructureUtil.GRAILS_VERSION_KEY)
-    return if (version.isNullOrBlank()) null else VersionImpl(version)
-  }
-
-  val isRunForked: Boolean get() = getBooleanSetting(PrintGrailsSettingsConstants.DEBUG_RUN_FORK)
-
-  val isTestForked: Boolean get() = getBooleanSetting(PrintGrailsSettingsConstants.DEBUG_TEST_FORK)
-
-  private fun getApplicationPropertiesValue(key: String) = runReadAction { applicationProperties?.getPropertyValue(key) }
-
-  private fun getBooleanSetting(key: String) = runReadAction { getGrailsSettings(module).properties[key]?.toBoolean() ?: false }
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.java
new file mode 100644
index 0000000..5f8ec44
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.externalSystem.model.DataNode;
+import com.intellij.openapi.externalSystem.model.project.ModuleData;
+import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.grails.gradle.GrailsModuleData;
+import org.jetbrains.plugins.grails.structure.Grails3Application;
+import org.jetbrains.plugins.grails.structure.GrailsApplicationBase;
+import org.jetbrains.plugins.grails.util.version.Version;
+import org.jetbrains.plugins.grails.util.version.VersionImpl;
+
+import java.util.Objects;
+
+abstract class Grails3ApplicationBase extends GrailsApplicationBase implements Grails3Application {
+
+  private final DataNode<ModuleData> myModuleDataNode;
+
+  // Both derived from the external-system model, which does not change for a given node, so they
+  // are computed once. Volatile publication only; the computation is idempotent.
+  private volatile GrailsModuleData myGradleData;
+  private volatile Version myGrailsVersion;
+
+  protected Grails3ApplicationBase(@NotNull Project project,
+                                   @NotNull VirtualFile root,
+                                   @NotNull DataNode<ModuleData> moduleDataNode) {
+    super(project, root);
+    myModuleDataNode = moduleDataNode;
+  }
+
+  @Override
+  public @NotNull String getName() {
+    return myModuleDataNode.getData().getExternalName();
+  }
+
+  @Override
+  public @Nullable String getAppVersion() {
+    return myModuleDataNode.getData().getVersion();
+  }
+
+  @Override
+  public @NotNull GrailsModuleData getGradleData() {
+    GrailsModuleData result = myGradleData;
+    if (result == null) {
+      result = Objects.requireNonNull(ExternalSystemApiUtil.find(myModuleDataNode, GrailsModuleData.KEY)).getData();
+      myGradleData = result;
+    }
+    return result;
+  }
+
+  @Override
+  public @NotNull Version getGrailsVersion() {
+    Version result = myGrailsVersion;
+    if (result == null) {
+      result = new VersionImpl(getGradleData().getGrailsVersion());
+      myGrailsVersion = result;
+    }
+    return result;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) return true;
+    if (other == null || other.getClass() != getClass()) return false;
+    if (!super.equals(other)) return false;
+    return myModuleDataNode.equals(((Grails3ApplicationBase)other).myModuleDataNode);
+  }
+
+  @Override
+  public int hashCode() {
+    return 31 * super.hashCode() + myModuleDataNode.hashCode();
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.kt
deleted file mode 100644
index 2507f13..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationBase.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.externalSystem.model.DataNode
-import com.intellij.openapi.externalSystem.model.project.ModuleData
-import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.util.lazyUnsafe
-import org.jetbrains.plugins.grails.gradle.GrailsModuleData
-import org.jetbrains.plugins.grails.structure.Grails3Application
-import org.jetbrains.plugins.grails.structure.GrailsApplicationBase
-import org.jetbrains.plugins.grails.util.version.VersionImpl
-
-internal abstract class Grails3ApplicationBase(
-    project: Project,
-    root: VirtualFile,
-    private val moduleDataNode: DataNode<ModuleData>
-) : GrailsApplicationBase(project, root), Grails3Application {
-
-  override val name: String get() = moduleDataNode.data.externalName
-
-  override val appVersion: String? get() = moduleDataNode.data.version
-
-  override val grailsVersion by lazyUnsafe { VersionImpl(gradleData.grailsVersion) }
-
-  override val gradleData by lazyUnsafe { ExternalSystemApiUtil.find(moduleDataNode, GrailsModuleData.KEY)!!.data }
-
-  override fun equals(other: Any?): Boolean {
-    if (this === other) return true
-    if (other?.javaClass != javaClass) return false
-    if (!super.equals(other)) return false
-
-    other as Grails3ApplicationBase
-
-    return moduleDataNode == other.moduleDataNode
-  }
-
-  override fun hashCode() = 31 * super.hashCode() + moduleDataNode.hashCode()
-}
\ No newline at end of file
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.java
new file mode 100644
index 0000000..5264479
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.externalSystem.model.DataNode;
+import com.intellij.openapi.externalSystem.model.project.ModuleData;
+import com.intellij.openapi.externalSystem.model.project.ProjectData;
+import com.intellij.openapi.externalSystem.service.project.ProjectDataManager;
+import com.intellij.openapi.externalSystem.settings.ExternalProjectSettings;
+import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil;
+import com.intellij.openapi.externalSystem.model.ExternalProjectInfo;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.roots.ProjectRootManager;
+import com.intellij.openapi.vfs.VirtualFile;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.gradle.service.project.GradleProjectResolverUtil;
+import org.jetbrains.plugins.gradle.settings.GradleProjectSettings;
+import org.jetbrains.plugins.gradle.util.GradleConstants;
+import org.jetbrains.plugins.grails.gradle.GrailsModuleData;
+import org.jetbrains.plugins.grails.structure.GrailsApplication;
+import org.jetbrains.plugins.grails.structure.GrailsApplicationProvider;
+
+public final class Grails3ApplicationProvider extends GrailsApplicationProvider {
+
+  @Override
+  public @Nullable GrailsApplication createApplication(@NotNull Project project, @NotNull VirtualFile root) {
+    String path = root.getPath();
+
+    ExternalProjectSettings projectSettings =
+      ExternalSystemApiUtil.getSettings(project, GradleConstants.SYSTEM_ID).getLinkedProjectSettings(path);
+    if (!(projectSettings instanceof GradleProjectSettings linkedProjectSettings)) return null;
+
+    ExternalProjectInfo gradleProjectInfo = ProjectDataManager.getInstance()
+      .getExternalProjectData(project, GradleConstants.SYSTEM_ID, linkedProjectSettings.getExternalProjectPath());
+    if (gradleProjectInfo == null) return null;
+
+    DataNode<ProjectData> structure = gradleProjectInfo.getExternalProjectStructure();
+    DataNode<ModuleData> moduleData = GradleProjectResolverUtil.findModule(structure, path);
+    if (moduleData == null) return null;
+    if (ExternalSystemApiUtil.find(moduleData, GrailsModuleData.KEY) == null) return null;
+
+    if (linkedProjectSettings.isResolveModulePerSourceSet()) {
+      return new Grails3MultiModuleApplication(project, root, moduleData);
+    }
+
+    Module module = ProjectRootManager.getInstance(project).getFileIndex().getModuleForFile(root);
+    return module != null ? new Grails3SingleModuleApplication(module, root, moduleData) : null;
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.kt
deleted file mode 100644
index 7a490d5..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3ApplicationProvider.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.externalSystem.service.project.ProjectDataManager
-import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.roots.ProjectRootManager
-import com.intellij.openapi.vfs.VirtualFile
-import org.jetbrains.plugins.gradle.service.project.GradleProjectResolverUtil.findModule
-import org.jetbrains.plugins.gradle.settings.GradleProjectSettings
-import org.jetbrains.plugins.gradle.util.GradleConstants.SYSTEM_ID
-import org.jetbrains.plugins.grails.gradle.GrailsModuleData
-import org.jetbrains.plugins.grails.structure.GrailsApplication
-import org.jetbrains.plugins.grails.structure.GrailsApplicationProvider
-
-class Grails3ApplicationProvider : GrailsApplicationProvider() {
-
-  override fun createApplication(project: Project, root: VirtualFile): GrailsApplication? {
-    val path = root.path
-
-    val settings = ExternalSystemApiUtil.getSettings(project, SYSTEM_ID)
-    val linkedProjectSettings = settings.getLinkedProjectSettings(path) as? GradleProjectSettings ?: return null
-    val gradleProjectInfo = ProjectDataManager.getInstance().getExternalProjectData(
-        project, SYSTEM_ID, linkedProjectSettings.externalProjectPath
-    ) ?: return null
-
-    val moduleData = findModule(gradleProjectInfo.externalProjectStructure, path) ?: return null
-    if (ExternalSystemApiUtil.find(moduleData, GrailsModuleData.KEY) == null) return null
-
-    return if (linkedProjectSettings.isResolveModulePerSourceSet) {
-      Grails3MultiModuleApplication(project, root, moduleData)
-    }
-    else {
-      ProjectRootManager.getInstance(project).fileIndex.getModuleForFile(root)?.let { module ->
-        Grails3SingleModuleApplication(module, root, moduleData)
-      }
-    }
-  }
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.java
new file mode 100644
index 0000000..0cd1d63
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.externalSystem.model.DataNode;
+import com.intellij.openapi.externalSystem.model.project.ModuleData;
+import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.module.ModuleManager;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.search.GlobalSearchScope;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.gradle.model.data.GradleSourceSetData;
+
+import java.util.ArrayList;
+import java.util.List;
+
+final class Grails3MultiModuleApplication extends Grails3ApplicationBase {
+
+  private final DataNode<ModuleData> myModuleDataNode;
+
+  // The source sets come from the external-system model and do not change for a given node.
+  private volatile List<GradleSourceSetData> mySourceSets;
+
+  Grails3MultiModuleApplication(@NotNull Project project,
+                                @NotNull VirtualFile root,
+                                @NotNull DataNode<ModuleData> moduleDataNode) {
+    super(project, root, moduleDataNode);
+    myModuleDataNode = moduleDataNode;
+  }
+
+  @Override
+  public @NotNull GlobalSearchScope getScope(boolean includeDependencies, boolean testsOnly) {
+    if (testsOnly) {
+      return sourceSetScope("test", includeDependencies, true)
+        .uniteWith(sourceSetScope("integrationTest", includeDependencies, true));
+    }
+    return sourceSetScope("main", includeDependencies, false);
+  }
+
+  private @NotNull GlobalSearchScope sourceSetScope(@NotNull String sourceSetName,
+                                                    boolean includeDependencies,
+                                                    boolean testsOnly) {
+    Module module = findModule(sourceSetName);
+    if (module == null) return GlobalSearchScope.EMPTY_SCOPE;
+    return includeDependencies ? module.getModuleRuntimeScope(testsOnly) : module.getModuleScope(testsOnly);
+  }
+
+  private @NotNull List<GradleSourceSetData> getSourceSets() {
+    List<GradleSourceSetData> result = mySourceSets;
+    if (result == null) {
+      result = new ArrayList<>();
+      for (DataNode<GradleSourceSetData> node : ExternalSystemApiUtil.findAll(myModuleDataNode, GradleSourceSetData.KEY)) {
+        result.add(node.getData());
+      }
+      mySourceSets = result;
+    }
+    return result;
+  }
+
+  private @Nullable Module findModule(@NotNull String sourceSetName) {
+    GradleSourceSetData sourceSet = null;
+    for (GradleSourceSetData candidate : getSourceSets()) {
+      if (candidate.getId().endsWith(":" + sourceSetName)) {
+        sourceSet = candidate;
+        break;
+      }
+    }
+    if (sourceSet == null) return null;
+
+    for (Module module : ModuleManager.getInstance(getProject()).getModules()) {
+      if (sourceSet.getId().equals(ExternalSystemApiUtil.getExternalProjectId(module))) {
+        return module;
+      }
+    }
+    return null;
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.kt
deleted file mode 100644
index e41654b..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3MultiModuleApplication.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.externalSystem.model.DataNode
-import com.intellij.openapi.externalSystem.model.project.ModuleData
-import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.module.ModuleManager
-import com.intellij.openapi.project.Project
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.psi.search.GlobalSearchScope
-import com.intellij.psi.search.GlobalSearchScope.EMPTY_SCOPE
-import org.jetbrains.plugins.gradle.model.data.GradleSourceSetData
-
-internal class Grails3MultiModuleApplication(
-    project: Project,
-    root: VirtualFile,
-    moduleDataNode: DataNode<ModuleData>
-) : Grails3ApplicationBase(project, root, moduleDataNode) {
-
-  override fun getScope(includeDependencies: Boolean, testsOnly: Boolean): GlobalSearchScope {
-    fun getScope(sourceSetName: String) = findModule(sourceSetName)?.let {
-      if (includeDependencies) it.getModuleRuntimeScope(testsOnly) else it.getModuleScope(testsOnly)
-    } ?: EMPTY_SCOPE
-
-    return when {
-      testsOnly -> getScope("test").uniteWith(getScope("integrationTest"))
-      else -> getScope("main")
-    }
-  }
-
-  private val mySourceSets by lazy {
-    ExternalSystemApiUtil.findAll(moduleDataNode, GradleSourceSetData.KEY).map { it.data }
-  }
-
-  private fun findModule(sourceSetName: String): Module? {
-    val sourceSet = mySourceSets.find { it.id.endsWith(":$sourceSetName") } ?: return null
-    return ModuleManager.getInstance(project).modules.find { ExternalSystemApiUtil.getExternalProjectId(it) == sourceSet.id }
-  }
-}
\ No newline at end of file
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.java
new file mode 100644
index 0000000..fea9fe3
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.externalSystem.model.DataNode;
+import com.intellij.openapi.externalSystem.model.project.ModuleData;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.search.GlobalSearchScope;
+import org.jetbrains.annotations.NotNull;
+
+final class Grails3SingleModuleApplication extends Grails3ApplicationBase {
+
+  private final Module myModule;
+
+  Grails3SingleModuleApplication(@NotNull Module module,
+                                 @NotNull VirtualFile root,
+                                 @NotNull DataNode<ModuleData> moduleDataNode) {
+    super(module.getProject(), root, moduleDataNode);
+    myModule = module;
+  }
+
+  public @NotNull Module getModule() {
+    return myModule;
+  }
+
+  @Override
+  public @NotNull GlobalSearchScope getScope(boolean includeDependencies, boolean testsOnly) {
+    return includeDependencies ? myModule.getModuleRuntimeScope(testsOnly) : myModule.getModuleScope(testsOnly);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) return true;
+    if (other == null || other.getClass() != getClass()) return false;
+    if (!super.equals(other)) return false;
+    return myModule.equals(((Grails3SingleModuleApplication)other).myModule);
+  }
+
+  @Override
+  public int hashCode() {
+    return 31 * super.hashCode() + myModule.hashCode();
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.kt
deleted file mode 100644
index cac4fc9..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/Grails3SingleModuleApplication.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.externalSystem.model.DataNode
-import com.intellij.openapi.externalSystem.model.project.ModuleData
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.psi.search.GlobalSearchScope
-
-internal class Grails3SingleModuleApplication(
-    val module: Module,
-    root: VirtualFile,
-    moduleDataNode: DataNode<ModuleData>
-) : Grails3ApplicationBase(module.project, root, moduleDataNode) {
-
-  override fun getScope(includeDependencies: Boolean, testsOnly: Boolean): GlobalSearchScope {
-    return if (includeDependencies) {
-      module.getModuleRuntimeScope(testsOnly)
-    }
-    else {
-      module.getModuleScope(testsOnly)
-    }
-  }
-
-  override fun equals(other: Any?): Boolean {
-    if (this === other) return true
-    if (other?.javaClass != javaClass) return false
-    if (!super.equals(other)) return false
-
-    other as Grails3SingleModuleApplication
-
-    return module == other.module
-  }
-
-  override fun hashCode() = 31 * super.hashCode() + module.hashCode()
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.java
new file mode 100644
index 0000000..a39f0f4
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.roots.ProjectRootManager;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.search.GlobalSearchScope;
+import com.intellij.psi.util.CachedValueProvider.Result;
+import com.intellij.psi.util.CachedValuesManager;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.plugins.grails.structure.GrailsApplicationBase;
+
+public abstract class GrailsModuleBasedApplication extends GrailsApplicationBase {
+
+  private final Module myModule;
+
+  protected GrailsModuleBasedApplication(@NotNull Module module, @NotNull VirtualFile root) {
+    super(module.getProject(), root);
+    myModule = module;
+  }
+
+  public @NotNull Module getModule() {
+    return myModule;
+  }
+
+  @Override
+  public boolean isValid() {
+    return super.isValid() && !myModule.isDisposed();
+  }
+
+  @Override
+  public @NotNull GlobalSearchScope getScope(boolean includeDependencies, boolean testsOnly) {
+    if (testsOnly && includeDependencies) {
+      return myModule.getModuleTestsWithDependentsScope();
+    }
+    if (testsOnly) {
+      // tests only, without dependencies: the test sources minus the production sources
+      return CachedValuesManager.getManager(getProject()).getCachedValue(this, () -> {
+        GlobalSearchScope moduleScope = myModule.getModuleScope(true);
+        GlobalSearchScope moduleWithoutTestsScope = myModule.getModuleScope(false);
+        GlobalSearchScope result = moduleScope.intersectWith(GlobalSearchScope.notScope(moduleWithoutTestsScope));
+        return Result.create(result, ProjectRootManager.getInstance(getProject()));
+      });
+    }
+    if (includeDependencies) {
+      return myModule.getModuleRuntimeScope(false);
+    }
+    return myModule.getModuleScope(false);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) return true;
+    if (other == null || other.getClass() != getClass()) return false;
+    if (!super.equals(other)) return false;
+    return myModule.equals(((GrailsModuleBasedApplication)other).myModule);
+  }
+
+  @Override
+  public int hashCode() {
+    return 31 * super.hashCode() + myModule.hashCode();
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.kt
deleted file mode 100644
index 4913bde..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsModuleBasedApplication.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.roots.ProjectRootManager
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.psi.search.GlobalSearchScope
-import com.intellij.psi.util.CachedValueProvider.Result
-import com.intellij.psi.util.CachedValuesManager
-import org.jetbrains.plugins.grails.structure.GrailsApplicationBase
-
-abstract class GrailsModuleBasedApplication(
-  val module: Module,
-  root: VirtualFile
-) : GrailsApplicationBase(module.project, root) {
-
-  override val isValid: Boolean get() = super.isValid && !module.isDisposed
-
-  override fun getScope(includeDependencies: Boolean, testsOnly: Boolean): GlobalSearchScope {
-    return when {
-      testsOnly && includeDependencies -> module.moduleTestsWithDependentsScope
-      testsOnly && !includeDependencies -> CachedValuesManager.getManager(project).getCachedValue(this) {
-        val moduleScope = module.getModuleScope(true)
-        val moduleWithoutTestsScope = module.getModuleScope(false)
-        val result = moduleScope.intersectWith(GlobalSearchScope.notScope(moduleWithoutTestsScope))
-        Result.create(result, ProjectRootManager.getInstance(project))
-      }
-      !testsOnly && includeDependencies -> module.getModuleRuntimeScope(false)
-      !testsOnly && !includeDependencies -> module.getModuleScope(false)
-      else -> throw RuntimeException("never happens")
-    }
-  }
-
-  override fun equals(other: Any?): Boolean {
-    if (this === other) return true
-    if (other?.javaClass != javaClass) return false
-    if (!super.equals(other)) return false
-
-    other as GrailsModuleBasedApplication
-
-    return module == other.module
-  }
-
-  override fun hashCode(): Int = 31 * super.hashCode() + module.hashCode()
-}
\ No newline at end of file
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsProperties.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsProperties.java
new file mode 100644
index 0000000..2cfe544
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/GrailsProperties.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.lang.properties.psi.PropertiesFile;
+import com.intellij.lang.properties.IProperty;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+final class GrailsProperties {
+
+  private GrailsProperties() {
+  }
+
+  /** Null-tolerant property lookup; was a nullable-receiver extension in impl/util.kt. */
+  static @Nullable String getPropertyValue(@Nullable PropertiesFile file, @NotNull String key) {
+    if (file == null) return null;
+    IProperty property = file.findPropertyByKey(key);
+    return property != null ? property.getValue() : null;
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.java b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.java
new file mode 100644
index 0000000..72c0800
--- /dev/null
+++ b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.java
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2000-2026 JetBrains s.r.o. and contributors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jetbrains.plugins.grails.structure.impl;
+
+import com.intellij.lang.properties.psi.PropertiesFile;
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiManager;
+import com.intellij.psi.search.GlobalSearchScope;
+import com.intellij.psi.search.GlobalSearchScopesCore;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.plugins.grails.config.GrailsConfigUtils;
+import org.jetbrains.plugins.grails.structure.OldGrailsApplication;
+import org.jetbrains.plugins.grails.util.version.Version;
+import org.jetbrains.plugins.grails.util.version.VersionImpl;
+
+public abstract class OldGrailsModuleBasedApplication extends GrailsModuleBasedApplication implements OldGrailsApplication {
+
+  private static final String[] TEST_FOLDERS = {"test/unit", "test/integration", "test/functional"};
+
+  // Deferred and cached: computing it walks the VFS, and it is only needed for tests-only scopes.
+  private volatile GlobalSearchScope myTestFoldersScope;
+
+  protected OldGrailsModuleBasedApplication(@NotNull Module module, @NotNull VirtualFile root) {
+    super(module, root);
+  }
+
+  private @NotNull GlobalSearchScope getTestFoldersScope() {
+    GlobalSearchScope result = myTestFoldersScope;
+    if (result == null) {
+      result = GlobalSearchScope.EMPTY_SCOPE;
+      for (String path : TEST_FOLDERS) {
+        VirtualFile directory = getRoot().findFileByRelativePath(path);
+        if (directory != null && directory.isDirectory()) {
+          result = result.union(GlobalSearchScopesCore.directoryScope(getProject(), directory, true));
+        }
+      }
+      myTestFoldersScope = result;
+    }
+    return result;
+  }
+
+  @Override
+  public @NotNull Version getGrailsVersion() {
+    String version = GrailsConfigUtils.getGrailsVersion(getModule());
+    return version != null ? new VersionImpl(version) : Version.LATEST_2x;
+  }
+
+  @Override
+  public @NotNull GlobalSearchScope getScope(boolean includeDependencies, boolean testsOnly) {
+    GlobalSearchScope scope = super.getScope(includeDependencies, testsOnly);
+    return testsOnly ? scope.union(getTestFoldersScope()) : scope;
+  }
+
+  @Override
+  public @Nullable PropertiesFile getApplicationProperties() {
+    VirtualFile root = getRoot();
+    if (!root.isValid()) return null;
+
+    VirtualFile applicationProperties = root.findChild("application.properties");
+    if (applicationProperties == null) return null;
+
+    PsiFile psiFile = PsiManager.getInstance(getProject()).findFile(applicationProperties);
+    return psiFile instanceof PropertiesFile propertiesFile ? propertiesFile : null;
+  }
+}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.kt
deleted file mode 100644
index 6ad9602..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/OldGrailsModuleBasedApplication.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.lang.properties.psi.PropertiesFile
-import com.intellij.openapi.module.Module
-import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.psi.PsiManager
-import com.intellij.psi.search.GlobalSearchScope
-import com.intellij.psi.search.GlobalSearchScopesCore
-import org.jetbrains.plugins.grails.config.GrailsConfigUtils
-import org.jetbrains.plugins.grails.structure.OldGrailsApplication
-import org.jetbrains.plugins.grails.util.version.Version
-import org.jetbrains.plugins.grails.util.version.Version.LATEST_2x
-import org.jetbrains.plugins.grails.util.version.VersionImpl
-
-abstract class OldGrailsModuleBasedApplication(
-    module: Module,
-    root: VirtualFile
-) : GrailsModuleBasedApplication(module, root), OldGrailsApplication {
-
-  private val myTestFoldersScope by lazy {
-    arrayOf("test/unit", "test/integration", "test/functional").fold(GlobalSearchScope.EMPTY_SCOPE) { scope, path ->
-      val directory = root.findFileByRelativePath(path)
-      if (directory != null && directory.isDirectory) {
-        scope.union(GlobalSearchScopesCore.directoryScope(project, directory, true))
-      }
-      else {
-        scope
-      }
-    }
-  }
-
-  override val grailsVersion: Version get() = GrailsConfigUtils.getGrailsVersion(module)?.let { VersionImpl(it) } ?: LATEST_2x
-
-  override fun getScope(includeDependencies: Boolean, testsOnly: Boolean): GlobalSearchScope {
-    val scope = super.getScope(includeDependencies, testsOnly)
-    if (testsOnly) {
-      return scope.union(myTestFoldersScope)
-    }
-    else {
-      return scope
-    }
-  }
-
-  override val applicationProperties: PropertiesFile? get() {
-    val root = root
-    if (!root.isValid) return null
-
-    val applicationProperties = root.findChild("application.properties") ?: return null
-
-    val psiFile = PsiManager.getInstance(project).findFile(applicationProperties)
-    return if (psiFile is PropertiesFile) psiFile else null
-  }
-}
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/util.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/util.kt
deleted file mode 100644
index 186f511..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/impl/util.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure.impl
-
-import com.intellij.lang.properties.psi.PropertiesFile
-
-internal fun PropertiesFile?.getPropertyValue(key: String) = this?.findPropertyByKey(key)?.value
diff --git a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/util.kt b/plugin/src/main/java/org/jetbrains/plugins/grails/structure/util.kt
deleted file mode 100644
index fe5de44..0000000
--- a/plugin/src/main/java/org/jetbrains/plugins/grails/structure/util.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Copyright 2000-2026 JetBrains s.r.o. and contributors.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.jetbrains.plugins.grails.structure
-
-@JvmField
-val COMPARATOR: Comparator<GrailsApplication?> = Comparator { app1, app2 ->
-  when {
-    app1 == null && app2 == null -> 0
-    app1 == null -> -1
-    app2 == null -> 1
-    else -> app1.name.compareTo(app2.name, ignoreCase = true)
-  }
-}
\ No newline at end of file