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


The following commit(s) were added to refs/heads/main by this push:
     new a67b655  Reconcile asset-pipeline JS support: cross-block injection 
via extension point
a67b655 is described below

commit a67b6551d79aa9365a90ca1313f4edd5fcba809f
Author: James Daugherty <[email protected]>
AuthorDate: Mon Jul 20 15:10:10 2026 -0500

    Reconcile asset-pipeline JS support: cross-block injection via extension 
point
    
    Asset-pipeline highlighting already existed in core (AssetScriptInjector,
    references.kt), but treated each <asset:script> block as an isolated JS
    fragment, so symbols defined in one block were invisible to another.
    
    Port the parked branch's gspJsInjectionTag extension point into core:
    recognizing asset:script as a JS-injection tag routes it through the core
    JS injector (GrailsJavaScriptInjector) and getJsTags(), granting language
    injection AND cross-tag symbol visibility. The EP keeps third-party taglib
    knowledge extensible; asset:script ships as a core contribution.
    
    - Add GspJsInjectionTagBean + EP-driven 
JavaScriptIntegrationUtil.isJsInjectionTag
    - Invalidate GspFileImpl JS-tag cache on EP mod-count change
    - Guard AssetScriptInjector to skip JS-injection tags (avoid double 
injection)
    - Register asset:script in plugin.xml
    - Add test coverage (injection, cross-block completion, EP, path references)
---
 resources/META-INF/plugin.xml                      |   4 +
 .../grails/addins/js/GspJsInjectionTagBean.java    |  44 +++++++
 .../addins/js/JavaScriptIntegrationUtil.java       |  23 +++-
 .../grails/lang/gsp/psi/gsp/impl/GspFileImpl.java  |   7 +-
 .../assetPipeline/AssetScriptInjector.kt           |   5 +
 .../gsp/AssetPipelineScriptInjectionTest.java      |  77 ++++++++++++
 .../groovy/grails/gsp/GspJsInjectionTagEpTest.java |  55 +++++++++
 .../grails/tagSupport/AssetPipelineSrcTest.java    | 129 +++++++++++++++++++++
 8 files changed, 342 insertions(+), 2 deletions(-)

diff --git a/resources/META-INF/plugin.xml b/resources/META-INF/plugin.xml
index 4fdceef..b7f13e3 100644
--- a/resources/META-INF/plugin.xml
+++ b/resources/META-INF/plugin.xml
@@ -77,6 +77,7 @@
     <extensionPoint name="commandProvider" dynamic="true" 
interface="org.jetbrains.plugins.grails.commands.GrailsCommandProvider"/>
     <extensionPoint name="artefactHandler" dynamic="true" 
interface="org.jetbrains.plugins.grails.artefact.api.GrailsArtefactHandler"/>
     <extensionPoint name="viewNodeProvider" dynamic="true" 
interface="org.jetbrains.plugins.grails.projectView.api.GrailsViewNodeProvider"/>
+    <extensionPoint name="gspJsInjectionTag" dynamic="true" 
beanClass="org.jetbrains.plugins.grails.addins.js.GspJsInjectionTagBean"/>
   </extensionPoints>
 
   <extensions defaultExtensionNs="org.jetbrains.plugins.gradle">
@@ -97,6 +98,9 @@
     <viewNodeProvider 
implementation="org.jetbrains.plugins.grails.projectView.impl.GrailsAppNodeProvider"/>
     <viewNodeProvider 
implementation="org.jetbrains.plugins.grails.projectView.impl.OldGrailsNodeProvider"/>
     <viewNodeProvider 
implementation="org.jetbrains.plugins.grails.projectView.impl.Grails3NodeProvider"/>
+
+    <!-- asset-pipeline: <asset:script> bodies are inline JavaScript (deferred 
scripts). -->
+    <gspJsInjectionTag name="asset:script"/>
   </extensions>
 
   <extensions defaultExtensionNs="org.intellij.groovy">
diff --git 
a/src/org/jetbrains/plugins/grails/addins/js/GspJsInjectionTagBean.java 
b/src/org/jetbrains/plugins/grails/addins/js/GspJsInjectionTagBean.java
new file mode 100644
index 0000000..c2df2fa
--- /dev/null
+++ b/src/org/jetbrains/plugins/grails/addins/js/GspJsInjectionTagBean.java
@@ -0,0 +1,44 @@
+/*
+ * 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.jetbrains.plugins.grails.addins.js;
+
+import com.intellij.openapi.extensions.ExtensionPointName;
+import com.intellij.openapi.extensions.RequiredElement;
+import com.intellij.util.xmlb.annotations.Attribute;
+
+/**
+ * Extension bean contributing the qualified name of a GSP tag (e.g. {@code 
asset:script}) whose
+ * body should be treated as inline JavaScript — for language injection and 
cross-tag symbol
+ * visibility. Core recognizes {@code g:javascript} and {@code r:script} 
directly; optional plugin
+ * modules contribute additional tags through this extension point so 
third-party taglib knowledge
+ * stays out of core.
+ *
+ * @see JavaScriptIntegrationUtil#isJsInjectionTag(String)
+ */
+public final class GspJsInjectionTagBean {
+
+  public static final ExtensionPointName<GspJsInjectionTagBean> EP_NAME =
+    ExtensionPointName.create("org.intellij.grails.gspJsInjectionTag");
+
+  /** Qualified GSP tag name, including the namespace prefix, e.g. {@code 
asset:script}. */
+  @Attribute("name")
+  @RequiredElement
+  public String name;
+}
diff --git 
a/src/org/jetbrains/plugins/grails/addins/js/JavaScriptIntegrationUtil.java 
b/src/org/jetbrains/plugins/grails/addins/js/JavaScriptIntegrationUtil.java
index d611a99..8041f89 100644
--- a/src/org/jetbrains/plugins/grails/addins/js/JavaScriptIntegrationUtil.java
+++ b/src/org/jetbrains/plugins/grails/addins/js/JavaScriptIntegrationUtil.java
@@ -25,14 +25,29 @@ import 
org.jetbrains.plugins.grails.addins.GrailsIntegrationUtil;
 import 
org.jetbrains.plugins.grails.lang.gsp.psi.groovy.api.GspOuterHtmlElement;
 import org.jetbrains.plugins.grails.lang.gsp.psi.gsp.api.gtag.GspGrailsTag;
 
+import java.util.concurrent.atomic.AtomicLong;
+
 public final class JavaScriptIntegrationUtil {
 
   private static final String JS_GRAILS_TAG_NAME1 = "g:javascript";
   private static final String JS_GRAILS_TAG_NAME2 = "r:script";
 
+  // Bumped whenever the set of JS-injection-tag contributions changes 
(dynamic plugin/module
+  // load-unload). Consumers that cache tag lists (e.g. GspFileImpl) compare 
against this to
+  // invalidate; see GspFileImpl.getJsTags().
+  private static final AtomicLong ourInjectionTagEpModCount = new AtomicLong();
+
+  static {
+    
GspJsInjectionTagBean.EP_NAME.addChangeListener(ourInjectionTagEpModCount::incrementAndGet,
 null);
+  }
+
   private JavaScriptIntegrationUtil() {
   }
 
+  public static long getInjectionTagModificationCount() {
+    return ourInjectionTagEpModCount.get();
+  }
+
   public static boolean isJSEmbeddedContent(final PsiElement element) {
     return element instanceof JSEmbeddedContent;
   }
@@ -50,7 +65,13 @@ public final class JavaScriptIntegrationUtil {
   }
 
   public static boolean isJsInjectionTag(String tagName) {
-    return JS_GRAILS_TAG_NAME1.equals(tagName) || 
JS_GRAILS_TAG_NAME2.equals(tagName);
+    if (JS_GRAILS_TAG_NAME1.equals(tagName) || 
JS_GRAILS_TAG_NAME2.equals(tagName)) {
+      return true;
+    }
+    for (GspJsInjectionTagBean bean : 
GspJsInjectionTagBean.EP_NAME.getExtensionList()) {
+      if (tagName.equals(bean.name)) return true;
+    }
+    return false;
   }
 
   public static boolean isJavaScriptInjection(PsiElement element) {
diff --git 
a/src/org/jetbrains/plugins/grails/lang/gsp/psi/gsp/impl/GspFileImpl.java 
b/src/org/jetbrains/plugins/grails/lang/gsp/psi/gsp/impl/GspFileImpl.java
index 833bc32..fbde185 100644
--- a/src/org/jetbrains/plugins/grails/lang/gsp/psi/gsp/impl/GspFileImpl.java
+++ b/src/org/jetbrains/plugins/grails/lang/gsp/psi/gsp/impl/GspFileImpl.java
@@ -79,6 +79,7 @@ public class GspFileImpl extends PsiFileImpl implements 
GspFile {
   private Map<GspDirectiveKind, List<GspDirective>> myDirectives;
 
   private volatile List<XmlTag> myJsTagCache;
+  private volatile long myJsTagCacheModCount = -1;
 
   public GspFileImpl(FileViewProvider viewProvider) {
     super(GspParserDefinition.GSP_FILE, GspParserDefinition.GSP_FILE, 
viewProvider);
@@ -268,8 +269,11 @@ public class GspFileImpl extends PsiFileImpl implements 
GspFile {
   }
 
   private List<XmlTag> getJsTags() {
+    // The set of JS-injection tags is extensible (see 
JavaScriptIntegrationUtil.isJsInjectionTag);
+    // drop the cache when contributions change so a dynamic plugin/module 
load-unload is reflected.
+    long modCount = 
JavaScriptIntegrationUtil.getInjectionTagModificationCount();
     List<XmlTag> res = myJsTagCache;
-    if (res == null) {
+    if (res == null || myJsTagCacheModCount != modCount) {
       res = new ArrayList<>();
 
       XmlDocument document = (XmlDocument)getFirstChild();
@@ -289,6 +293,7 @@ public class GspFileImpl extends PsiFileImpl implements 
GspFile {
       });
 
       myJsTagCache = res;
+      myJsTagCacheModCount = modCount;
     }
 
     return res;
diff --git 
a/src/org/jetbrains/plugins/grails/pluginSupport/assetPipeline/AssetScriptInjector.kt
 
b/src/org/jetbrains/plugins/grails/pluginSupport/assetPipeline/AssetScriptInjector.kt
index 0841475..eb3321e 100644
--- 
a/src/org/jetbrains/plugins/grails/pluginSupport/assetPipeline/AssetScriptInjector.kt
+++ 
b/src/org/jetbrains/plugins/grails/pluginSupport/assetPipeline/AssetScriptInjector.kt
@@ -22,6 +22,7 @@ import com.intellij.lang.injection.MultiHostRegistrar
 import com.intellij.openapi.util.TextRange
 import com.intellij.psi.PsiElement
 import com.intellij.psi.impl.source.html.HtmlScriptLanguageInjector
+import org.jetbrains.plugins.grails.addins.js.JavaScriptIntegrationUtil
 import org.jetbrains.plugins.grails.lang.gsp.psi.groovy.api.GspOuterHtmlElement
 import org.jetbrains.plugins.grails.lang.gsp.psi.gsp.api.gtag.GspGrailsTag
 
@@ -30,6 +31,10 @@ class AssetScriptInjector : MultiHostInjector {
   override fun getLanguagesToInject(registrar: MultiHostRegistrar, context: 
PsiElement) {
     context as GspGrailsTag
     if (context.namespacePrefix != "asset") return
+    // JavaScript-body tags (e.g. asset:script) are owned by the core JS 
injector via
+    // JavaScriptIntegrationUtil.isJsInjectionTag, which also grants cross-tag 
symbol visibility;
+    // injecting here too would double-inject the same host.
+    if (JavaScriptIntegrationUtil.isJsInjectionTag(context.name)) return
     val languageToInject = 
HtmlScriptLanguageInjector.getScriptLanguageToInject(context) ?: return
     if (!LanguageUtil.isInjectableLanguage(languageToInject)) return
     var started = false
diff --git 
a/test/org/jetbrains/plugins/groovy/grails/gsp/AssetPipelineScriptInjectionTest.java
 
b/test/org/jetbrains/plugins/groovy/grails/gsp/AssetPipelineScriptInjectionTest.java
new file mode 100644
index 0000000..0b18f1b
--- /dev/null
+++ 
b/test/org/jetbrains/plugins/groovy/grails/gsp/AssetPipelineScriptInjectionTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.jetbrains.plugins.groovy.grails.gsp;
+
+import com.intellij.psi.PsiFile;
+import org.jetbrains.plugins.groovy.grails.GrailsTestCase;
+
+/**
+ * Verifies that {@code <asset:script>} bodies (contributed via the 
asset-pipeline module through the
+ * {@code gspJsInjectionTag} extension point) receive the same JavaScript 
treatment as
+ * {@code <g:javascript>}: language injection and cross-tag symbol visibility.
+ */
+public class AssetPipelineScriptInjectionTest extends GrailsTestCase {
+
+  private void addAssetsTagLib() {
+    addTaglib("class AssetsTagLib { static namespace = \"asset\" }");
+  }
+
+  public void testCompletionAcrossAssetScriptBlocks() {
+    addAssetsTagLib();
+
+    PsiFile file = addView("a.gsp", """
+      <html>
+      <script>
+        var xxx1 = 1;
+      </script>
+      <asset:script>
+        var xxx2 = 2
+      </asset:script>
+
+      <asset:script>
+        xxx<caret>
+      </asset:script>
+      </html>
+      """);
+
+    checkCompletionVariants(file, "xxx1", "xxx2");
+  }
+
+  public void testFunctionCompletionAcrossAssetScriptBlocks() {
+    addAssetsTagLib();
+
+    PsiFile file = addView("a.gsp", """
+      <html>
+      <script>
+        function xxx1() {}
+      </script>
+      <asset:script>
+        function xxx2() {}
+      </asset:script>
+
+      <asset:script>
+        xxx<caret>
+      </asset:script>
+      </html>
+      """);
+
+    checkCompletionVariants(file, "xxx1", "xxx2");
+  }
+}
diff --git 
a/test/org/jetbrains/plugins/groovy/grails/gsp/GspJsInjectionTagEpTest.java 
b/test/org/jetbrains/plugins/groovy/grails/gsp/GspJsInjectionTagEpTest.java
new file mode 100644
index 0000000..8f15db4
--- /dev/null
+++ b/test/org/jetbrains/plugins/groovy/grails/gsp/GspJsInjectionTagEpTest.java
@@ -0,0 +1,55 @@
+/*
+ * 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.jetbrains.plugins.groovy.grails.gsp;
+
+import com.intellij.testFramework.fixtures.BasePlatformTestCase;
+import org.jetbrains.plugins.grails.addins.js.GspJsInjectionTagBean;
+import org.jetbrains.plugins.grails.addins.js.JavaScriptIntegrationUtil;
+
+/**
+ * Verifies the {@code org.intellij.grails.gspJsInjectionTag} extension point: 
contributed tags are
+ * recognized by {@link JavaScriptIntegrationUtil#isJsInjectionTag(String)}, 
and the modification
+ * count that drives cache invalidation bumps when contributions change.
+ */
+public class GspJsInjectionTagEpTest extends BasePlatformTestCase {
+
+  public void testCoreDefaultsAlwaysRecognized() {
+    assertTrue(JavaScriptIntegrationUtil.isJsInjectionTag("g:javascript"));
+    assertTrue(JavaScriptIntegrationUtil.isJsInjectionTag("r:script"));
+    // asset:script ships as a core gspJsInjectionTag contribution (see 
plugin.xml).
+    assertTrue(JavaScriptIntegrationUtil.isJsInjectionTag("asset:script"));
+  }
+
+  public void testContributedTagRecognizedAndModCountBumps() {
+    // A tag not shipped by core: recognized only once contributed through the 
EP.
+    String tag = "myplugin:script";
+    assertFalse(JavaScriptIntegrationUtil.isJsInjectionTag(tag));
+
+    long before = JavaScriptIntegrationUtil.getInjectionTagModificationCount();
+
+    GspJsInjectionTagBean bean = new GspJsInjectionTagBean();
+    bean.name = tag;
+    GspJsInjectionTagBean.EP_NAME.getPoint().registerExtension(bean, 
getTestRootDisposable());
+
+    assertTrue(JavaScriptIntegrationUtil.isJsInjectionTag(tag));
+    assertTrue("mod count must bump on EP change",
+               JavaScriptIntegrationUtil.getInjectionTagModificationCount() > 
before);
+  }
+}
diff --git 
a/test/org/jetbrains/plugins/groovy/grails/tagSupport/AssetPipelineSrcTest.java 
b/test/org/jetbrains/plugins/groovy/grails/tagSupport/AssetPipelineSrcTest.java
new file mode 100644
index 0000000..0458542
--- /dev/null
+++ 
b/test/org/jetbrains/plugins/groovy/grails/tagSupport/AssetPipelineSrcTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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.jetbrains.plugins.groovy.grails.tagSupport;
+
+import com.intellij.psi.PsiElement;
+import com.intellij.psi.PsiReference;
+import org.jetbrains.plugins.groovy.grails.GrailsTestCase;
+import org.junit.Assert;
+
+import java.util.List;
+
+/**
+ * File-reference support for asset-pipeline path attributes, contributed by 
the asset-pipeline
+ * module through the {@code gspTagAttributeReferenceProvider} extension 
point. Resolution is rooted
+ * at {@code grails-app/assets/<type>}.
+ */
+public class AssetPipelineSrcTest extends GrailsTestCase {
+
+  private void addAssetsTagLib() {
+    addTaglib("class AssetsTagLib { static namespace = \"asset\" }");
+  }
+
+  private void assertResolvesTo(String expectedFileName) {
+    PsiElement target = myFixture.getElementAtCaret();
+    Assert.assertNotNull(target);
+    Assert.assertEquals(expectedFileName, 
target.getContainingFile().getName());
+  }
+
+  public void testJavascriptSrcResolves() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/javascripts/site/forms.js", 
"");
+
+    configureByView("a.gsp", "<asset:javascript 
src=\"site/forms.js<caret>\"/>");
+
+    assertResolvesTo("forms.js");
+  }
+
+  public void testStylesheetSrcResolves() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/stylesheets/main.css", "");
+
+    configureByView("a.gsp", "<asset:stylesheet src=\"main.css<caret>\"/>");
+
+    assertResolvesTo("main.css");
+  }
+
+  public void testStylesheetHrefResolves() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/stylesheets/main.css", "");
+
+    configureByView("a.gsp", "<asset:stylesheet href=\"main.css<caret>\"/>");
+
+    assertResolvesTo("main.css");
+  }
+
+  public void testImageSrcResolves() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/images/logo.png", "");
+
+    configureByView("a.gsp", "<asset:image src=\"logo.png<caret>\"/>");
+
+    assertResolvesTo("logo.png");
+  }
+
+  public void testMissingAssetDoesNotResolve() {
+    addAssetsTagLib();
+    // a sibling file makes grails-app/assets/javascripts exist, so the 
provider attaches a
+    // reference; the referenced file itself is absent and must not resolve.
+    myFixture.addFileToProject("grails-app/assets/javascripts/present.js", "");
+
+    configureByView("a.gsp", "<asset:javascript src=\"missing.js<caret>\"/>");
+
+    PsiReference reference = myFixture.getReferenceAtCaretPosition();
+    Assert.assertNotNull("expected a file reference on the src attribute", 
reference);
+    Assert.assertNull("a missing asset must not resolve", reference.resolve());
+  }
+
+  public void testResolutionSkippedWithoutAssetRoot() {
+    // Vanilla project without grails-app/assets: the reference set is soft 
(no default contexts),
+    // so a reference may be attached but must never resolve and must not be 
flagged as an error.
+    addAssetsTagLib();
+
+    configureByView("a.gsp", "<asset:javascript src=\"anything.js<caret>\"/>");
+
+    PsiReference reference = myFixture.getReferenceAtCaretPosition();
+    if (reference != null) {
+      Assert.assertNull("a missing asset root must not resolve", 
reference.resolve());
+    }
+  }
+
+  public void testCompletion() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/javascripts/aaa.js", "");
+    myFixture.addFileToProject("grails-app/assets/javascripts/bbb.js", "");
+
+    configureByView("a.gsp", "<asset:javascript src=\"<caret>\"/>");
+
+    myFixture.completeBasic();
+    
Assert.assertTrue(myFixture.getLookupElementStrings().containsAll(List.of("aaa.js",
 "bbb.js")));
+  }
+
+  public void testRename() {
+    addAssetsTagLib();
+    myFixture.addFileToProject("grails-app/assets/javascripts/old.js", "");
+
+    configureByView("a.gsp", "<asset:javascript src=\"old.js<caret>\"/>");
+
+    myFixture.renameElementAtCaret("new.js");
+
+    Assert.assertEquals("<asset:javascript src=\"new.js\"/>", 
myFixture.getFile().getText());
+  }
+}

Reply via email to