Skip to content

Commit 386482d

Browse files
authored
Merge pull request #308 from shopware/twig-block-gutter-markers
Restore inheritance gutter markers on Twig blocks
2 parents 23df66f + c86cf44 commit 386482d

11 files changed

Lines changed: 414 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
## Unreleased
66

7+
- Restored the inheritance gutter markers on Twig blocks that were lost with the reworked Twig template handling in `0.1.0`: a block now shows an "overrides" icon when it overrides a block of the `sw_extends` chain and an "overridden" icon when extending templates override it, both navigating to the related blocks. The markers are provided by the plugin itself and no longer require the Symfony plugin. Fixes #307
8+
79
## 0.1.0 - 2026-07-30
810

911
- Replaced the Symfony plugin integration for `sw_extends` / `sw_include` with own navigation and autocompletion: navigating a template reference now offers all templates of that view path (the referenced bundle first, then all plugin overrides), and completion suggests templates of every bundle including plugins in `custom/plugins`
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package de.shyim.shopware6.index
2+
3+
import com.intellij.util.indexing.DataIndexer
4+
import com.intellij.util.indexing.DefaultFileTypeSpecificInputFilter
5+
import com.intellij.util.indexing.FileBasedIndex
6+
import com.intellij.util.indexing.FileBasedIndexExtension
7+
import com.intellij.util.indexing.FileContent
8+
import com.intellij.util.indexing.ID
9+
import com.intellij.util.io.EnumeratorStringDescriptor
10+
import com.intellij.util.io.KeyDescriptor
11+
import com.intellij.util.io.VoidDataExternalizer
12+
import com.jetbrains.twig.TwigFileType
13+
import de.shyim.shopware6.util.TwigUtil
14+
15+
/**
16+
* Reverse of [ShopwareTemplateIndex]: indexes every extending template by the view path its
17+
* sw_extends tag points to. This makes the templates extending a given view path a single index
18+
* lookup instead of a scan over every known template.
19+
*/
20+
class ShopwareTemplateExtendsIndex : FileBasedIndexExtension<String, Void>() {
21+
override fun getName(): ID<String, Void> {
22+
return key
23+
}
24+
25+
override fun getIndexer(): DataIndexer<String, Void, FileContent> {
26+
return DataIndexer { inputData ->
27+
if (!inputData.file.path.contains("Resources/views/")) {
28+
return@DataIndexer mapOf()
29+
}
30+
31+
val target = TwigUtil.findExtendsTargetReference(inputData.contentAsText)
32+
?: return@DataIndexer mapOf()
33+
34+
// overrides reference the template by bundle ("@Storefront/..."), but at runtime they
35+
// extend every template of that view path, so only the view path is indexed
36+
val viewPath = target.substringAfter("/", "")
37+
38+
if (viewPath.isEmpty()) {
39+
return@DataIndexer mapOf()
40+
}
41+
42+
mapOf(viewPath to null)
43+
}
44+
}
45+
46+
override fun getKeyDescriptor(): KeyDescriptor<String> {
47+
return EnumeratorStringDescriptor.INSTANCE
48+
}
49+
50+
override fun getValueExternalizer(): VoidDataExternalizer {
51+
return VoidDataExternalizer.INSTANCE
52+
}
53+
54+
override fun getVersion(): Int {
55+
return 1
56+
}
57+
58+
override fun getInputFilter(): FileBasedIndex.InputFilter {
59+
return object : DefaultFileTypeSpecificInputFilter(TwigFileType.INSTANCE) {
60+
}
61+
}
62+
63+
override fun dependsOnFileContent(): Boolean {
64+
return true
65+
}
66+
67+
companion object {
68+
val key = ID.create<String, Void>("de.shyim.shopware6.frontend.twig_template_extends")
69+
}
70+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package de.shyim.shopware6.marker.twig
2+
3+
import com.intellij.codeInsight.daemon.RelatedItemLineMarkerInfo
4+
import com.intellij.codeInsight.daemon.RelatedItemLineMarkerProvider
5+
import com.intellij.codeInsight.navigation.NavigationGutterIconBuilder
6+
import com.intellij.openapi.util.NotNullLazyValue
7+
import com.intellij.psi.PsiElement
8+
import com.intellij.psi.util.elementType
9+
import com.jetbrains.php.PhpIcons
10+
import com.jetbrains.twig.TwigTokenTypes
11+
import com.jetbrains.twig.elements.TwigBlockTag
12+
import de.shyim.shopware6.util.TwigUtil
13+
14+
/**
15+
* Gutter icons on {% block name %} pointing to the block it overrides and to the blocks
16+
* overriding it, the same way PhpStorm marks overridden methods.
17+
*/
18+
class TwigBlockMarker : RelatedItemLineMarkerProvider() {
19+
override fun collectNavigationMarkers(
20+
element: PsiElement,
21+
result: MutableCollection<in RelatedItemLineMarkerInfo<*>>
22+
) {
23+
// the identifier leaf carries the marker, so the icon sits on the block's line
24+
if (element.elementType != TwigTokenTypes.IDENTIFIER) {
25+
return
26+
}
27+
28+
val blockTag = element.parent as? TwigBlockTag ?: return
29+
val blockName = blockTag.name ?: return
30+
31+
if (blockName != element.text) {
32+
return
33+
}
34+
35+
val file = element.containingFile.originalFile
36+
37+
// a template that does not extend cannot override a block. Without this check the
38+
// relative path fallback of getUpstreamBlocks would report the overrides of the very
39+
// same view path as upstream
40+
if (TwigUtil.isExtendingTemplate(file) && TwigUtil.getUpstreamBlocks(file, blockName).isNotEmpty()) {
41+
result.add(
42+
NavigationGutterIconBuilder.create(PhpIcons.OVERRIDES)
43+
.setTargets(NotNullLazyValue.lazy { upstreamTargets(element, blockName) })
44+
.setTooltipText("Overrides block")
45+
.createLineMarkerInfo(element)
46+
)
47+
}
48+
49+
// the paths come from the indexes, the PSI of the extending templates is only loaded
50+
// once the marker is clicked
51+
if (TwigUtil.getDownstreamBlockPaths(file, blockName).isNotEmpty()) {
52+
result.add(
53+
NavigationGutterIconBuilder.create(PhpIcons.IMPLEMENTED)
54+
.setTargets(NotNullLazyValue.lazy { TwigUtil.getDownstreamBlocks(file, blockName) })
55+
.setTooltipText("Overridden in extending templates")
56+
.createLineMarkerInfo(element)
57+
)
58+
}
59+
}
60+
61+
private fun upstreamTargets(element: PsiElement, blockName: String): Collection<PsiElement> {
62+
val file = element.containingFile.originalFile
63+
64+
return TwigUtil.getUpstreamBlocks(file, blockName)
65+
.flatMap { TwigUtil.findBlockTagsInFile(element.project, it.absolutePath, blockName) }
66+
}
67+
}

src/main/kotlin/de/shyim/shopware6/navigation/TwigBlockGoToDeclareHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class TwigBlockGoToDeclareHandler : GotoDeclarationHandler {
2525

2626
// navigate to the upstream block this one overrides, nearest parent first
2727
val targets = TwigUtil.getUpstreamBlocks(element.containingFile.originalFile, blockName)
28-
.mapNotNull { TwigUtil.findBlockTagInFile(element.project, it.absolutePath, blockName) }
28+
.mapNotNull { TwigUtil.findBlockTagsInFile(element.project, it.absolutePath, blockName).firstOrNull() }
2929

3030
return if (targets.isEmpty()) null else targets.toTypedArray()
3131
}

src/main/kotlin/de/shyim/shopware6/util/ShopwareTemplateUtil.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.intellij.psi.search.GlobalSearchScope
99
import com.intellij.psi.util.CachedValueProvider
1010
import com.intellij.psi.util.CachedValuesManager
1111
import com.intellij.util.indexing.FileBasedIndex
12+
import de.shyim.shopware6.index.ShopwareTemplateExtendsIndex
1213
import de.shyim.shopware6.index.ShopwareTemplateIndex
1314
import icons.ShopwareToolBoxIcons
1415

@@ -70,6 +71,89 @@ object ShopwareTemplateUtil {
7071
(candidates - bundleMatches.toSet()).sortedWith(templateOrder())
7172
}
7273

74+
/**
75+
* Every template extending the given one, nearest child first. Templates extending a child
76+
* are collected transitively.
77+
*/
78+
fun getTemplatesExtendingTemplate(project: Project, file: VirtualFile): List<VirtualFile> {
79+
val children = LinkedHashSet<VirtualFile>()
80+
81+
// several extensions typically override the same view path, so every one of them
82+
// references it and looks like a child of the others. The templates the file extends
83+
// itself are upstream, never children
84+
val visited = HashSet(getExtendsChainPaths(project, file))
85+
visited.add(file.path)
86+
87+
var current = listOf(file)
88+
var depth = 0
89+
90+
// a cycle is already broken by "visited", the depth limit only guards against
91+
// pathologically long chains
92+
while (current.isNotEmpty() && depth++ < 8) {
93+
val next = ArrayList<VirtualFile>()
94+
95+
current.forEach { template ->
96+
getDirectChildren(project, template).forEach { child ->
97+
if (visited.add(child.path)) {
98+
children.add(child)
99+
next.add(child)
100+
}
101+
}
102+
}
103+
104+
current = next
105+
}
106+
107+
return children.toList()
108+
}
109+
110+
/**
111+
* Paths of the templates the given one extends, nearest parent first
112+
*/
113+
fun getExtendsChainPaths(project: Project, file: VirtualFile): List<String> {
114+
return followExtendsChain(project, file.path, getExtendsTarget(project, file))
115+
}
116+
117+
/**
118+
* Walks up the sw_extends chain, starting at the given target reference, nearest parent
119+
* first. The targets of the parents come from the index, so no further files are loaded.
120+
*/
121+
fun followExtendsChain(project: Project, startPath: String?, startTarget: String?): List<String> {
122+
val paths = ArrayList<String>()
123+
val visited = HashSet<String>()
124+
startPath?.let { visited.add(it) }
125+
126+
var target = startTarget
127+
128+
while (target != null && paths.size < 10) {
129+
val bundleName = target.substringBefore("/").removePrefix("@")
130+
val templatePath = target.substringAfter("/", "")
131+
132+
if (templatePath.isEmpty()) {
133+
break
134+
}
135+
136+
val parent = findTemplateInBundle(project, bundleName, templatePath) ?: break
137+
138+
if (!visited.add(parent.path)) {
139+
break
140+
}
141+
142+
paths.add(parent.path)
143+
target = getExtendsTarget(project, parent)
144+
}
145+
146+
return paths
147+
}
148+
149+
private fun getDirectChildren(project: Project, file: VirtualFile): List<VirtualFile> {
150+
return FileBasedIndex.getInstance().getContainingFiles(
151+
ShopwareTemplateExtendsIndex.key,
152+
TwigUtil.getRelativePath(file.path),
153+
GlobalSearchScope.allScope(project)
154+
).filter { it.path != file.path }.sortedWith(templateOrder())
155+
}
156+
73157
fun getTemplateLookupElements(project: Project): List<LookupElement> {
74158
// the template set only changes when files are created, moved or deleted
75159
return CachedValuesManager.getManager(project).getCachedValue(project) {

src/main/kotlin/de/shyim/shopware6/util/TwigUtil.kt

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import com.intellij.psi.PsiFileFactory
1010
import com.intellij.psi.PsiManager
1111
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
1212
import com.intellij.psi.search.GlobalSearchScope
13+
import com.intellij.psi.util.CachedValueProvider
14+
import com.intellij.psi.util.CachedValuesManager
15+
import com.intellij.psi.util.PsiModificationTracker
1316
import com.intellij.util.indexing.FileBasedIndex
1417
import com.jetbrains.php.composer.actions.update.ComposerInstalledPackagesService
1518
import com.jetbrains.twig.TwigFileType
@@ -74,56 +77,86 @@ object TwigUtil {
7477
}
7578

7679
fun getExtendsChainPaths(file: PsiFile): List<String> {
77-
val project = file.project
78-
val paths = ArrayList<String>()
79-
val visited = HashSet<String>()
80-
file.originalFile.virtualFile?.path?.let { visited.add(it) }
81-
82-
var target = findExtendsTargetReference(file.text)
83-
84-
while (target != null && paths.size < 10) {
85-
val bundleName = target.substring(1).substringBefore("/")
86-
val templatePath = target.substringAfter("/", "")
87-
88-
if (templatePath.isEmpty()) {
89-
break
90-
}
91-
92-
val parent = ShopwareTemplateUtil.findTemplateInBundle(project, bundleName, templatePath) ?: break
93-
94-
if (!visited.add(parent.path)) {
95-
break
96-
}
97-
98-
paths.add(parent.path)
99-
100-
// the parents' extends targets come from the index, so no further files need to be loaded
101-
target = ShopwareTemplateUtil.getExtendsTarget(project, parent)
80+
// cached per file, as every block of a template walks the same chain
81+
return CachedValuesManager.getCachedValue(file) {
82+
CachedValueProvider.Result.create(
83+
// the first target is read from the PSI so that unsaved edits are respected
84+
ShopwareTemplateUtil.followExtendsChain(
85+
file.project,
86+
file.originalFile.virtualFile?.path,
87+
findExtendsTargetReference(file.text)
88+
),
89+
PsiModificationTracker.MODIFICATION_COUNT
90+
)
10291
}
103-
104-
return paths
10592
}
10693

107-
fun findBlockTagInFile(project: Project, path: String, blockName: String): PsiElement? {
108-
val virtualFile = ShopwareTemplateUtil.findTemplateByPath(project, path) ?: return null
109-
110-
val psiFile = PsiManager.getInstance(project).findFile(virtualFile) ?: return null
94+
fun findBlockTagsInFile(project: Project, path: String, blockName: String): List<PsiElement> {
95+
val virtualFile = ShopwareTemplateUtil.findTemplateByPath(project, path) ?: return emptyList()
96+
val psiFile = PsiManager.getInstance(project).findFile(virtualFile) ?: return emptyList()
11197

112-
var blockTag: PsiElement? = null
98+
val blockTags = ArrayList<PsiElement>()
11399

114100
psiFile.acceptChildren(object : PsiRecursiveElementWalkingVisitor() {
115101
override fun visitElement(element: PsiElement) {
116102
if (element is TwigBlockTag && element.name == blockName) {
117-
blockTag = element
118-
stopWalking()
103+
blockTags.add(element)
119104
return
120105
}
121106

122107
super.visitElement(element)
123108
}
124109
})
125110

126-
return blockTag
111+
return blockTags
112+
}
113+
114+
/**
115+
* Paths of the templates extending the given file, nearest child first. Cached per file, as
116+
* every block of a template resolves the same set.
117+
*/
118+
fun getExtendingTemplatePaths(file: PsiFile): List<String> {
119+
return CachedValuesManager.getCachedValue(file) {
120+
val virtualFile = file.originalFile.virtualFile
121+
122+
CachedValueProvider.Result.create(
123+
if (virtualFile == null) {
124+
emptyList()
125+
} else {
126+
ShopwareTemplateUtil.getTemplatesExtendingTemplate(file.project, virtualFile)
127+
.map { it.path }
128+
},
129+
PsiModificationTracker.MODIFICATION_COUNT
130+
)
131+
}
132+
}
133+
134+
/**
135+
* Paths of the templates extending the given file which define a block of the given name,
136+
* nearest child first. Resolved from the indexes only, so it is cheap enough to decide
137+
* whether a gutter marker is shown.
138+
*/
139+
fun getDownstreamBlockPaths(file: PsiFile, blockName: String): List<String> {
140+
val extending = getExtendingTemplatePaths(file)
141+
142+
if (extending.isEmpty()) {
143+
return emptyList()
144+
}
145+
146+
val paths = FileBasedIndex.getInstance()
147+
.getValues(TwigBlockHashIndex.key, blockName, GlobalSearchScope.allScope(file.project))
148+
.mapTo(HashSet()) { it.absolutePath }
149+
150+
return extending.filter { paths.contains(it) }
151+
}
152+
153+
/**
154+
* Every block of the given name in templates extending the given file, nearest child first
155+
*/
156+
fun getDownstreamBlocks(file: PsiFile, blockName: String): List<PsiElement> {
157+
val project = file.project
158+
159+
return getDownstreamBlockPaths(file, blockName).flatMap { findBlockTagsInFile(project, it, blockName) }
127160
}
128161

129162
fun getUpstreamBlocks(file: PsiFile, blockName: String): List<TwigBlockHash> {

0 commit comments

Comments
 (0)