Skip to content

Commit c4a6a7d

Browse files
committed
Support resolving TwigComponent from compiled container XML
1 parent 565689c commit c4a6a7d

7 files changed

Lines changed: 602 additions & 32 deletions

File tree

src/main/java/fr/adrienbrault/idea/symfony2plugin/util/UxUtil.java

Lines changed: 126 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.UxTemplateStubIndex;
3535
import fr.adrienbrault.idea.symfony2plugin.stubs.util.IndexUtil;
3636
import fr.adrienbrault.idea.symfony2plugin.templating.path.TwigPath;
37+
import fr.adrienbrault.idea.symfony2plugin.templating.path.UxComponentFactoryParser;
3738
import fr.adrienbrault.idea.symfony2plugin.templating.path.UxComponentTemplateFinderParser;
3839
import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigTypeResolveUtil;
3940
import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigUtil;
41+
import fr.adrienbrault.idea.symfony2plugin.util.dict.CompiledTwigComponent;
4042
import fr.adrienbrault.idea.symfony2plugin.util.service.ServiceXmlParserFactory;
4143
import fr.adrienbrault.idea.symfony2plugin.util.dict.TwigComponentNamespace;
4244
import kotlin.Pair;
@@ -140,8 +142,38 @@ public static void visitComponentsForIndex(@NotNull PhpFile phpFile, @NotNull Co
140142
}
141143
}
142144

145+
/**
146+
* Returns declared template names for a component class.
147+
*
148+
* Examples:
149+
* - \App\Twig\Components\Alert -> components/Alert.html.twig
150+
* - \App\Twig\Components\ShopCard -> components/shop/Card.html.twig from the compiled container
151+
*/
143152
public static Collection<String> getComponentTemplatesForPhpClass(@NotNull PhpClass phpClass) {
144153
Set<String> templates = new HashSet<>();
154+
String fqn = phpClass.getFQN();
155+
156+
UxComponentFactoryParser compiledComponentParser = getCompiledTwigComponentFactoryParser(phpClass.getProject());
157+
Map<String, CompiledTwigComponent> compiledComponents = getCompiledTwigComponents(compiledComponentParser);
158+
for (CompiledTwigComponent component : compiledComponents.values()) {
159+
if (fqn.equals(component.phpClass()) && component.template() != null) {
160+
templates.add(component.template());
161+
}
162+
}
163+
164+
if (templates.isEmpty()) {
165+
String componentName = compiledComponentParser != null ? compiledComponentParser.getClassMap().get(fqn) : null;
166+
if (componentName != null) {
167+
CompiledTwigComponent component = compiledComponents.get(componentName);
168+
if (component != null && component.template() != null) {
169+
templates.add(component.template());
170+
}
171+
}
172+
}
173+
174+
if (!templates.isEmpty()) {
175+
return templates;
176+
}
145177

146178
Set<String> names = new HashSet<>();
147179

@@ -159,7 +191,6 @@ public static Collection<String> getComponentTemplatesForPhpClass(@NotNull PhpCl
159191
return templates;
160192
}
161193

162-
String fqn = phpClass.getFQN();
163194
for (TwigComponentNamespace twigComponentNamespace : getNamespaces(phpClass.getProject())) {
164195
String componentNamespace = "\\" + StringUtils.strip(twigComponentNamespace.namespace(), "\\") + "\\";
165196
if (!fqn.startsWith(componentNamespace)) {
@@ -262,7 +293,7 @@ public static Collection<TwigComponent> getAllComponentNames(@NotNull Project pr
262293
: addNamePrefix(value.phpClass().substring(namespace1.length()).replace("\\", ":"), namespace.namePrefix());
263294

264295
if (!name.isBlank()) {
265-
names.put(name, new TwigComponent(name, value.phpClass(), namespace));
296+
names.put(name, new TwigComponent(name, value.phpClass(), namespace, value.template(), null));
266297
}
267298

268299
break;
@@ -271,6 +302,10 @@ public static Collection<TwigComponent> getAllComponentNames(@NotNull Project pr
271302
}
272303
}
273304

305+
for (CompiledTwigComponent component : getCompiledTwigComponents(project).values()) {
306+
mergeCompiledComponent(names, component);
307+
}
308+
274309
for (String valueValue : getAnonymousTemplateDirectories(project)) {
275310
String namespace = null;
276311
if (valueValue.startsWith("@")) {
@@ -367,7 +402,7 @@ public boolean visitFile(@NotNull VirtualFile file) {
367402
String componentName = getAnonymousComponentNameFromRelativePath(relativePath);
368403
if (componentName != null) {
369404
String name = StringUtils.isBlank(namePrefix) ? componentName : namePrefix + ":" + componentName;
370-
names.putIfAbsent(name, new TwigComponent(name, null, null));
405+
names.putIfAbsent(name, new TwigComponent(name, null, null, null, null));
371406
}
372407

373408
return super.visitFile(file);
@@ -393,6 +428,13 @@ public static Set<PhpClass> getTwigComponentPhpClasses(@NotNull Project project,
393428
return phpClasses;
394429
}
395430

431+
/**
432+
* Resolves a component name to concrete template files.
433+
*
434+
* Examples:
435+
* - Alert -> templates/components/Alert.html.twig
436+
* - Shop:Card -> templates/components/Shop/Card.html.twig
437+
*/
396438
public static Collection<PsiFile> getComponentTemplates(@NotNull Project project, @NotNull String component) {
397439
Collection<VirtualFile> virtualFiles = new LinkedHashSet<>();
398440

@@ -401,6 +443,14 @@ public static Collection<PsiFile> getComponentTemplates(@NotNull Project project
401443
continue;
402444
}
403445

446+
if (entry.template() != null && addTemplateFilesWithFallback(project, entry.template(), virtualFiles)) {
447+
continue;
448+
}
449+
450+
if (entry.templateFromMethod() != null) {
451+
continue;
452+
}
453+
404454
if (entry.twigComponentNamespace != null) {
405455
String componentTemplateName = removeNamePrefix(component, entry.twigComponentNamespace.namePrefix());
406456
if (componentTemplateName != null) {
@@ -528,7 +578,15 @@ public static boolean hasComponentUsages(@NotNull TwigFile twigFile) {
528578
return false;
529579
}
530580

581+
/**
582+
* Adds matching template files for a Symfony template name.
583+
*
584+
* Examples:
585+
* - components/Alert.html.twig resolved by Twig namespaces
586+
* - templates/components/Alert.html.twig via project fallback
587+
*/
531588
private static boolean addTemplateFilesWithFallback(@NotNull Project project, @NotNull String templateName, @NotNull Collection<VirtualFile> virtualFiles) {
589+
// Use Twig's resolver first, including configured namespaces like "@Admin/components/Card.html.twig".
532590
Collection<VirtualFile> files = TwigUtil.getTemplateFiles(project, templateName);
533591
if (!files.isEmpty()) {
534592
virtualFiles.addAll(files);
@@ -540,6 +598,7 @@ private static boolean addTemplateFilesWithFallback(@NotNull Project project, @N
540598
return false;
541599
}
542600

601+
// Compiled UX metadata stores names relative to templates/, for example "components/Alert.html.twig".
543602
VirtualFile templatesDir = VfsUtil.findRelativeFile(baseDir, "templates");
544603
if (templatesDir != null) {
545604
VirtualFile virtualFile = VfsUtil.findRelativeFile(templatesDir, templateName.split("/"));
@@ -549,15 +608,6 @@ private static boolean addTemplateFilesWithFallback(@NotNull Project project, @N
549608
}
550609
}
551610

552-
VirtualFile appViewsDir = VfsUtil.findRelativeFile(baseDir, "app", "Resources", "views");
553-
if (appViewsDir != null) {
554-
VirtualFile virtualFile = VfsUtil.findRelativeFile(appViewsDir, templateName.split("/"));
555-
if (virtualFile != null) {
556-
virtualFiles.add(virtualFile);
557-
return true;
558-
}
559-
}
560-
561611
return false;
562612
}
563613

@@ -570,7 +620,9 @@ public static Collection<PhpClass> getComponentClassesForTemplateFile(@NotNull P
570620
Collections.unmodifiableCollection(getComponentClassFqnsForTemplateFileInner(project, psiFile)),
571621
psiFile,
572622
FileIndexCaches.getModificationTrackerForIndexId(project, UxTemplateStubIndex.KEY),
573-
FileIndexCaches.getModificationTrackerForIndexId(project, ConfigStubIndex.KEY)
623+
FileIndexCaches.getModificationTrackerForIndexId(project, ConfigStubIndex.KEY),
624+
SymfonyVarDirectoryWatcherKt.getSymfonyVarDirectoryWatcher(project)
625+
.getModificationTracker(SymfonyVarDirectoryWatcher.Scope.CONTAINER)
574626
)
575627
);
576628

@@ -598,6 +650,12 @@ private static Collection<String> getComponentClassFqnsForTemplateFileInner(@Not
598650
}
599651
}
600652

653+
for (CompiledTwigComponent component : getCompiledTwigComponents(project).values()) {
654+
if (component.phpClass() != null && template.equals(component.template())) {
655+
phpClassFqnsTemplateMatch.add(component.phpClass());
656+
}
657+
}
658+
601659
if (!phpClassFqnsTemplateMatch.isEmpty()) {
602660
phpClassFqns.addAll(phpClassFqnsTemplateMatch);
603661
break;
@@ -620,6 +678,54 @@ private static Collection<String> getComponentClassFqnsForTemplateFileInner(@Not
620678
return phpClassFqns;
621679
}
622680

681+
@Nullable
682+
private static UxComponentFactoryParser getCompiledTwigComponentFactoryParser(@NotNull Project project) {
683+
return ServiceXmlParserFactory.getInstance(project, UxComponentFactoryParser.class);
684+
}
685+
686+
@NotNull
687+
private static Map<String, CompiledTwigComponent> getCompiledTwigComponents(@Nullable UxComponentFactoryParser parser) {
688+
return parser != null ? parser.getComponents() : Collections.emptyMap();
689+
}
690+
691+
@NotNull
692+
private static Map<String, CompiledTwigComponent> getCompiledTwigComponents(@NotNull Project project) {
693+
return getCompiledTwigComponents(getCompiledTwigComponentFactoryParser(project));
694+
}
695+
696+
private static void mergeCompiledComponent(
697+
@NotNull Map<String, TwigComponent> components,
698+
@NotNull CompiledTwigComponent compiledComponent
699+
) {
700+
TwigComponent existing = components.get(compiledComponent.name());
701+
702+
if (existing == null) {
703+
components.put(compiledComponent.name(), new TwigComponent(
704+
compiledComponent.name(),
705+
compiledComponent.phpClass(),
706+
null,
707+
compiledComponent.template(),
708+
compiledComponent.templateFromMethod()
709+
));
710+
return;
711+
}
712+
713+
components.put(compiledComponent.name(), new TwigComponent(
714+
compiledComponent.name(),
715+
compiledComponent.phpClass() != null ? compiledComponent.phpClass() : existing.phpClass(),
716+
existing.twigComponentNamespace(),
717+
compiledComponent.template() != null ? compiledComponent.template() : existing.template(),
718+
compiledComponent.templateFromMethod() != null ? compiledComponent.templateFromMethod() : existing.templateFromMethod()
719+
));
720+
}
721+
722+
private static Collection<UxComponent> getComponentsWithTemplates(@NotNull Project project) {
723+
return IndexUtil.getAllKeysForProject(UxTemplateStubIndex.KEY, project)
724+
.stream().flatMap(key -> FileBasedIndex.getInstance().getValues(UxTemplateStubIndex.KEY, key, GlobalSearchScope.allScope(project)).stream())
725+
.filter(value -> value.template() != null)
726+
.collect(Collectors.toList());
727+
}
728+
623729
public static Set<PhpClass> getTwigComponentAllTargets(@NotNull Project project) {
624730
Set<PhpClass> phpClasses = new HashSet<>();
625731

@@ -762,19 +868,18 @@ private static Collection<String> getExposeName(@NotNull PhpAttributesOwner phpA
762868
return names;
763869
}
764870

765-
private static Collection<UxComponent> getComponentsWithTemplates(@NotNull Project project) {
766-
return IndexUtil.getAllKeysForProject(UxTemplateStubIndex.KEY, project)
767-
.stream().flatMap(key -> FileBasedIndex.getInstance().getValues(UxTemplateStubIndex.KEY, key, GlobalSearchScope.allScope(project)).stream())
768-
.filter(value -> value.template() != null)
769-
.collect(Collectors.toList());
770-
}
771-
772871
public enum TwigComponentType {
773872
LIVE_COMPONENT,
774873
TWIG_COMPONENT,
775874
}
776875

777876
public record TwigComponentIndex(@Nullable String name, @NotNull PhpClass phpClass, @Nullable String template, @NotNull TwigComponentType type) {}
778877

779-
public record TwigComponent(@NotNull String name, @Nullable String phpClass, @Nullable TwigComponentNamespace twigComponentNamespace) {}
878+
public record TwigComponent(
879+
@NotNull String name,
880+
@Nullable String phpClass,
881+
@Nullable TwigComponentNamespace twigComponentNamespace,
882+
@Nullable String template,
883+
@Nullable String templateFromMethod
884+
) {}
780885
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package fr.adrienbrault.idea.symfony2plugin.util.dict;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
import org.jetbrains.annotations.Nullable;
5+
6+
/**
7+
* Symfony UX TwigComponent metadata extracted from compiled container XML.
8+
*/
9+
public record CompiledTwigComponent(
10+
@NotNull String name,
11+
@Nullable String phpClass,
12+
@Nullable String template,
13+
@Nullable String templateFromMethod
14+
) {
15+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package fr.adrienbrault.idea.symfony2plugin.templating.path
2+
3+
import com.intellij.openapi.project.Project
4+
import com.intellij.openapi.vfs.VirtualFile
5+
import fr.adrienbrault.idea.symfony2plugin.util.dict.CompiledTwigComponent
6+
import fr.adrienbrault.idea.symfony2plugin.util.service.AbstractServiceParser
7+
import org.w3c.dom.Element
8+
import org.w3c.dom.Node
9+
import java.io.InputStream
10+
11+
/**
12+
* Parses Symfony UX TwigComponent runtime metadata from the compiled container.
13+
*
14+
* Symfony writes the final component configuration to the service
15+
* "ux.twig_component.component_factory":
16+
*
17+
* - top-level argument 4: component metadata keyed by component name
18+
* - top-level argument 5: class FQN to component name map
19+
*/
20+
class UxComponentFactoryParser : AbstractServiceParser() {
21+
22+
val components: MutableMap<String, CompiledTwigComponent> = linkedMapOf()
23+
val classMap: MutableMap<String, String> = linkedMapOf()
24+
25+
override fun getXPathFilter(): String =
26+
"/container/services/service[@id='ux.twig_component.component_factory']"
27+
28+
@Synchronized
29+
override fun parser(file: InputStream, sourceFile: VirtualFile, project: Project) {
30+
val services = parserer(file) ?: return
31+
32+
for (i in 0 until services.length) {
33+
val service = services.item(i) as? Element ?: continue
34+
val arguments = directArgumentChildren(service)
35+
val componentsArgument = arguments.getOrNull(4)
36+
if (componentsArgument != null) {
37+
parseComponentsArgument(componentsArgument)
38+
}
39+
40+
val classMapArgument = arguments.getOrNull(5)
41+
if (classMapArgument != null) {
42+
parseClassMapArgument(classMapArgument)
43+
}
44+
}
45+
}
46+
47+
private fun parseComponentsArgument(argument: Element) {
48+
for (componentArgument in directArgumentChildren(argument)) {
49+
val outerName = componentArgument.getAttribute("key").trim().takeIf { it.isNotBlank() }
50+
val values = keyedDirectArgumentValues(componentArgument)
51+
52+
val name = values["key"]?.takeIf { it.isNotBlank() } ?: outerName ?: continue
53+
val phpClass = normalizePhpClass(values["class"])
54+
val template = values["template"]?.takeIf { it.isNotBlank() }
55+
val templateFromMethod = values["template_from_method"]?.takeIf { it.isNotBlank() }
56+
57+
components[name] = CompiledTwigComponent(name, phpClass, template, templateFromMethod)
58+
}
59+
}
60+
61+
private fun parseClassMapArgument(argument: Element) {
62+
for (mapArgument in directArgumentChildren(argument)) {
63+
val phpClass = normalizePhpClass(mapArgument.getAttribute("key")) ?: continue
64+
val componentName = mapArgument.textContent.trim()
65+
if (componentName.isNotBlank()) {
66+
classMap[phpClass] = componentName
67+
}
68+
}
69+
}
70+
71+
private fun keyedDirectArgumentValues(argument: Element): Map<String, String> {
72+
val values = linkedMapOf<String, String>()
73+
for (child in directArgumentChildren(argument)) {
74+
val key = child.getAttribute("key").trim()
75+
if (key.isNotBlank()) {
76+
values[key] = child.textContent.trim()
77+
}
78+
}
79+
80+
return values
81+
}
82+
83+
private fun directArgumentChildren(element: Element): List<Element> {
84+
val arguments = mutableListOf<Element>()
85+
val childNodes = element.childNodes
86+
for (i in 0 until childNodes.length) {
87+
val child = childNodes.item(i)
88+
if (child.nodeType == Node.ELEMENT_NODE && child.nodeName == "argument") {
89+
arguments.add(child as Element)
90+
}
91+
}
92+
93+
return arguments
94+
}
95+
96+
private fun normalizePhpClass(value: String?): String? {
97+
val className = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
98+
return "\\" + className.trimStart('\\')
99+
}
100+
}

0 commit comments

Comments
 (0)