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());
+ }
+}