Skip to content

Commit a0ca73b

Browse files
committed
Decouple Twig form field resolution from PSI elements
1 parent e022f52 commit a0ca73b

13 files changed

Lines changed: 373 additions & 63 deletions

File tree

src/main/java/fr/adrienbrault/idea/symfony2plugin/form/util/FormUtil.java

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,21 +251,40 @@ public static Method resolveFormGetterCallMethod(MethodReference methodReference
251251
*/
252252
@Nullable
253253
public static PhpClass getFormTypeClassOnParameter(@NotNull PsiElement psiElement) {
254-
255-
if (psiElement instanceof StringLiteralExpression) {
256-
return getFormTypeToClass(psiElement.getProject(), ((StringLiteralExpression) psiElement).getContents());
254+
switch (psiElement) {
255+
case StringLiteralExpression stringLiteralExpression -> {
256+
return getFormTypeToClass(psiElement.getProject(), stringLiteralExpression.getContents());
257+
}
258+
case ClassConstantReference classConstantReference -> {
259+
return PhpElementsUtil.getClassConstantPhpClass(classConstantReference);
260+
}
261+
case PhpTypedElement phpTypedElement -> {
262+
String typeName = phpTypedElement.getType().toString();
263+
return getFormTypeToClass(psiElement.getProject(), typeName);
264+
}
265+
default -> {
266+
}
257267
}
258268

259-
if (psiElement instanceof ClassConstantReference) {
260-
return PhpElementsUtil.getClassConstantPhpClass((ClassConstantReference) psiElement);
261-
}
269+
return null;
270+
}
262271

263-
if (psiElement instanceof PhpTypedElement) {
264-
String typeName = ((PhpTypedElement) psiElement).getType().toString();
265-
return getFormTypeToClass(psiElement.getProject(), typeName);
272+
/**
273+
* Resolves a Symfony form type parameter to a normalized FQN string without exposing the resolved {@link PhpClass}.
274+
*
275+
* <p>Supported inputs match {@link #getFormTypeClassOnParameter(PsiElement)}: string literals, class constants,
276+
* and typed expressions such as {@code new FooType()}.</p>
277+
*
278+
* @return normalized class FQN with a leading backslash, or {@code null} when the parameter cannot be resolved
279+
*/
280+
@Nullable
281+
public static String getFormTypeFqnOnParameter(@NotNull PsiElement psiElement) {
282+
PhpClass phpClass = getFormTypeClassOnParameter(psiElement);
283+
if (phpClass == null) {
284+
return null;
266285
}
267286

268-
return null;
287+
return phpClass.getFQN();
269288
}
270289

271290
@NotNull

src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigLineMarkerProvider.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,11 @@ private LineMarkerInfo<?> attachFormType(@NotNull PsiElement psiElement) {
333333

334334
for (TwigTypeContainer twigTypeContainer : twigTypeContainers) {
335335
Object dataHolder = twigTypeContainer.getDataHolder();
336-
if (dataHolder instanceof FormDataHolder && PhpElementsUtil.isInstanceOf(((FormDataHolder) dataHolder).getFormType(), "\\Symfony\\Component\\Form\\FormTypeInterface")) {
337-
phpClasses.add(((FormDataHolder) dataHolder).getFormType());
336+
if (dataHolder instanceof FormDataHolder formDataHolder) {
337+
PhpClass phpClass = PhpElementsUtil.getClassInterface(psiElement.getProject(), formDataHolder.ownerFormTypeFqn());
338+
if (phpClass != null && PhpElementsUtil.isInstanceOf(phpClass, "\\Symfony\\Component\\Form\\FormTypeInterface")) {
339+
phpClasses.add(phpClass);
340+
}
338341
}
339342
}
340343

src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateCompletionContributor.java

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
import org.apache.commons.lang3.StringUtils;
5858
import org.intellij.lang.annotations.RegExp;
5959
import org.jetbrains.annotations.NotNull;
60+
import org.jetbrains.annotations.Nullable;
6061

6162
import java.util.*;
6263
import java.util.function.Function;
@@ -755,11 +756,8 @@ protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull
755756

756757
// form
757758
Object dataHolder = twigTypeContainer.getDataHolder();
758-
if (dataHolder instanceof FormDataHolder) {
759-
lookupElement = lookupElement.withIcon(Symfony2Icons.FORM_TYPE);
760-
761-
lookupElement = lookupElement.withTypeText(((FormDataHolder) dataHolder).getPhpClass().getName());
762-
lookupElement = lookupElement.withTailText("(" + ((FormDataHolder) dataHolder).getFormType().getName() + ")", true);
759+
if (dataHolder instanceof FormDataHolder formDataHolder) {
760+
lookupElement = decorateFormFieldLookupElement(lookupElement, formDataHolder);
763761
}
764762

765763
resultSet.addElement(lookupElement);
@@ -976,7 +974,7 @@ public boolean accepts(@NotNull String s, ProcessingContext processingContext) {
976974
String typeText = null;
977975

978976
if (twigTypeContainers.getDataHolder() instanceof FormDataHolder formDataHolder) {
979-
typeText = formDataHolder.getPhpClass().getName();
977+
typeText = getFormTypeShortName(formDataHolder.fieldTypeFqn());
980978
}
981979

982980
for (String s : new String[]{"form_row", "form_widget", "form_label", "form_errors", "form_help"}) {
@@ -992,6 +990,28 @@ public boolean accepts(@NotNull String s, ProcessingContext processingContext) {
992990
}
993991
}
994992

993+
@Nullable
994+
private static String getFormTypeShortName(@Nullable String fqn) {
995+
if (fqn == null || fqn.isBlank()) {
996+
return null;
997+
}
998+
999+
int index = fqn.lastIndexOf('\\');
1000+
return index >= 0 ? fqn.substring(index + 1) : fqn;
1001+
}
1002+
1003+
@NotNull
1004+
public static LookupElementBuilder decorateFormFieldLookupElement(@NotNull LookupElementBuilder lookupElement, @NotNull FormDataHolder formDataHolder) {
1005+
lookupElement = lookupElement.withIcon(Symfony2Icons.FORM_TYPE);
1006+
1007+
String fieldTypeShortName = getFormTypeShortName(formDataHolder.fieldTypeFqn());
1008+
if (fieldTypeShortName != null) {
1009+
lookupElement = lookupElement.withTypeText(fieldTypeShortName);
1010+
}
1011+
1012+
return lookupElement.withTailText("(" + getFormTypeShortName(formDataHolder.ownerFormTypeFqn()) + ")", true);
1013+
}
1014+
9951015
private class IncompleteFormPrintBlockCompletionProvider extends CompletionProvider<CompletionParameters> {
9961016
@Override
9971017
protected void addCompletions(@NotNull CompletionParameters completionParameters, @NotNull ProcessingContext processingContext, @NotNull CompletionResultSet resultSet) {
@@ -1028,9 +1048,9 @@ public boolean accepts(@NotNull String s, ProcessingContext processingContext) {
10281048
}
10291049

10301050
String typeText = null;
1031-
Collection<PhpClass> formTypeFromFormFactory = FormFieldResolver.getFormTypeFromFormFactory(element);
1032-
if (!formTypeFromFormFactory.isEmpty()) {
1033-
typeText = StringUtils.stripStart(formTypeFromFormFactory.iterator().next().getFQN(), "\\");
1051+
Set<String> formTypeFqnsFromFormFactory = FormFieldResolver.getFormTypeFqnsFromFormFactory(element);
1052+
if (!formTypeFqnsFromFormFactory.isEmpty()) {
1053+
typeText = StringUtils.stripStart(formTypeFqnsFromFormFactory.iterator().next(), "\\");
10341054
}
10351055

10361056
for (String s : new String[]{"form_start", "form_rest", "form_end", "form_errors"}) {
@@ -1082,9 +1102,9 @@ public boolean accepts(@NotNull String s, ProcessingContext processingContext) {
10821102
}
10831103

10841104
String typeText = null;
1085-
Collection<PhpClass> formTypeFromFormFactory = FormFieldResolver.getFormTypeFromFormFactory(element);
1086-
if (!formTypeFromFormFactory.isEmpty()) {
1087-
typeText = StringUtils.stripStart(formTypeFromFormFactory.iterator().next().getFQN(), "\\");
1105+
Set<String> formTypeFqnsFromFormFactory = FormFieldResolver.getFormTypeFqnsFromFormFactory(element);
1106+
if (!formTypeFqnsFromFormFactory.isEmpty()) {
1107+
typeText = StringUtils.stripStart(formTypeFqnsFromFormFactory.iterator().next(), "\\");
10881108
}
10891109

10901110
if (orderedList == null) {

src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateGoToDeclarationHandler.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,12 @@ public static Collection<PsiElement> getTypeGoto(@NotNull PsiElement psiElement)
539539
// @TODO: provide extension
540540
if (text.equals(twigTypeContainer.getStringElement())) {
541541
Object dataHolder = twigTypeContainer.getDataHolder();
542-
if (dataHolder instanceof FormDataHolder) {
542+
if (dataHolder instanceof FormDataHolder formDataHolder) {
543543
// @TODO: resolve the to field itself
544-
targetPsiElements.add(((FormDataHolder) dataHolder).getFormType());
544+
PhpClass phpClass = PhpElementsUtil.getClassInterface(psiElement.getProject(), formDataHolder.ownerFormTypeFqn());
545+
if (phpClass != null) {
546+
targetPsiElements.add(phpClass);
547+
}
545548
}
546549
}
547550
}

src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/variable/resolver/FormFieldResolver.java

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ public static Collection<PhpClass> getFormTypeFromFormFactory(@NotNull PsiElemen
125125
return phpClasses;
126126
}
127127

128+
/**
129+
* Resolves form type FQNs for a form reference such as {@code $form->createView()}.
130+
*
131+
* <p>This is the primitive counterpart of {@link #getFormTypeFromFormFactory(PsiElement)}. It may still use PSI
132+
* internally, but it returns only normalized FQN strings with a leading backslash.</p>
133+
*/
134+
@NotNull
135+
public static Set<String> getFormTypeFqnsFromFormFactory(@NotNull PsiElement formReference) {
136+
Set<String> formTypeFqns = new LinkedHashSet<>();
137+
138+
for (PhpClass phpClass : getFormTypeFromFormFactory(formReference)) {
139+
formTypeFqns.add(phpClass.getFQN());
140+
}
141+
142+
return formTypeFqns;
143+
}
144+
128145
@Nullable
129146
private static PhpClass resolveCall(@NotNull MethodReference methodReference) {
130147
int index = -1;
@@ -157,49 +174,55 @@ private static PhpClass resolveCall(@NotNull MethodReference methodReference) {
157174
}
158175

159176
@NotNull
160-
private static List<TwigTypeContainer> getTwigTypeContainer(@NotNull Method method, @NotNull PhpClass formTypClass) {
161-
List<TwigTypeContainer> twigTypeContainers = new ArrayList<>();
177+
private static List<TwigFormField> getTwigFormFields(@NotNull Method method, @NotNull PhpClass formTypeClass) {
178+
List<TwigFormField> twigFormFields = new ArrayList<>();
162179

163180
for(MethodReference methodReference: FormUtil.getFormBuilderTypes(method)) {
164181

165182
String fieldName = PsiElementUtils.getMethodParameterAt(methodReference, 0);
183+
if (fieldName == null) {
184+
continue;
185+
}
186+
166187
PsiElement psiElement = PsiElementUtils.getMethodParameterPsiElementAt(methodReference, 1);
167-
TwigTypeContainer twigTypeContainer = new TwigTypeContainer(fieldName);
188+
String fieldTypeFqn = null;
168189

169190
// find form field type
170191
if(psiElement != null) {
171-
PhpClass fieldType = FormUtil.getFormTypeClassOnParameter(psiElement);
172-
if(fieldType != null) {
173-
twigTypeContainer.withDataHolder(new FormDataHolder(fieldType, formTypClass));
174-
}
192+
fieldTypeFqn = FormUtil.getFormTypeFqnOnParameter(psiElement);
175193
}
176194

177-
twigTypeContainers.add(twigTypeContainer);
195+
twigFormFields.add(new TwigFormField(fieldName, fieldTypeFqn, formTypeClass.getFQN()));
178196
}
179197

180-
return twigTypeContainers;
198+
return twigFormFields;
181199
}
182200

183201
/**
184202
* Search and resolve: "$form->createView()" to its PhpClass which is a form type
185203
*/
186204
public static void visitFormReferencesFields(@NotNull PsiElement formReference, @NotNull Consumer<TwigTypeContainer> consumer) {
187-
visitFormReferencesFields(formReference.getProject(), getFormTypeFromFormFactory(formReference), consumer);
205+
visitFormFields(formReference.getProject(), getFormTypeFqnsFromFormFactory(formReference), field -> consumer.accept(toTwigTypeContainer(field)));
188206
}
189207

190208
/**
191209
* Visit all form fields in given PhpClass which are already a form type
192210
*/
193211
public static void visitFormReferencesFields(@NotNull PhpClass phpClass, @NotNull Consumer<TwigTypeContainer> consumer) {
194-
visitFormReferencesFields(phpClass.getProject(), Collections.singleton(phpClass), consumer);
212+
visitFormFields(phpClass.getProject(), Collections.singleton(phpClass.getFQN()), field -> consumer.accept(toTwigTypeContainer(field)));
195213
}
196214

197-
private static void visitFormReferencesFields(@NotNull Project project, @NotNull Collection<PhpClass> phpClasses, @NotNull Consumer<TwigTypeContainer> consumer) {
215+
public static void visitFormFields(@NotNull Project project, @NotNull Collection<String> formTypeFqns, @NotNull Consumer<TwigFormField> consumer) {
198216
FormUtil.FormTypeCollector collector = null;
199217

200218
Collection<Method> methods = new HashSet<>();
201219

202-
for (PhpClass phpClass : phpClasses) {
220+
for (String formTypeFqn : formTypeFqns) {
221+
PhpClass phpClass = PhpElementsUtil.getClassInterface(project, formTypeFqn);
222+
if (phpClass == null) {
223+
continue;
224+
}
225+
203226
Method method = phpClass.findMethodByName("buildForm");
204227
if (method != null) {
205228
methods.add(method);
@@ -225,14 +248,19 @@ private static void visitFormReferencesFields(@NotNull Project project, @NotNull
225248
}
226249
}
227250

228-
private static void consumeFieldType(@NotNull PhpClass phpClass, @NotNull Consumer<TwigTypeContainer> consumer) {
251+
@NotNull
252+
private static TwigTypeContainer toTwigTypeContainer(@NotNull TwigFormField field) {
253+
return new TwigTypeContainer(field.name()).withDataHolder(new FormDataHolder(field.fieldTypeFqn(), field.ownerFormTypeFqn()));
254+
}
255+
256+
private static void consumeFieldType(@NotNull PhpClass phpClass, @NotNull Consumer<TwigFormField> consumer) {
229257
Method method = phpClass.findMethodByName("buildForm");
230258
if (method == null) {
231259
return;
232260
}
233261

234-
for (TwigTypeContainer twigTypeContainer : getTwigTypeContainer(method, phpClass)) {
235-
consumer.accept(twigTypeContainer);
262+
for (TwigFormField twigFormField : getTwigFormFields(method, phpClass)) {
263+
consumer.accept(twigFormField);
236264
}
237265
}
238266
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package fr.adrienbrault.idea.symfony2plugin.templating.variable.resolver;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
import org.jetbrains.annotations.Nullable;
5+
6+
/**
7+
* Primitive metadata for a Symfony form field found in a form type.
8+
*
9+
* @param name field name passed to the form builder
10+
* @param fieldTypeFqn explicit field form type FQN, or {@code null} when the builder call has no type parameter
11+
* @param ownerFormTypeFqn FQN of the form type whose {@code buildForm()} contributed this field
12+
*
13+
* @author Daniel Espendiller <daniel@espendiller.net>
14+
*/
15+
public record TwigFormField(
16+
@NotNull String name,
17+
@Nullable String fieldTypeFqn,
18+
@NotNull String ownerFormTypeFqn
19+
) {
20+
}
Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,27 @@
11
package fr.adrienbrault.idea.symfony2plugin.templating.variable.resolver.holder;
22

3-
import com.jetbrains.php.lang.psi.elements.PhpClass;
43
import org.jetbrains.annotations.NotNull;
4+
import org.jetbrains.annotations.Nullable;
55

66
/**
7+
* UI metadata for a Symfony form field without holding PSI objects.
8+
*
9+
* @param fieldTypeFqn explicit normalized field form type FQN, or {@code null} when the builder call has no type parameter
10+
* @param ownerFormTypeFqn normalized FQN of the form type whose {@code buildForm()} contributed this field
11+
*
712
* @author Daniel Espendiller <daniel@espendiller.net>
813
*/
9-
public class FormDataHolder {
10-
@NotNull
11-
private final PhpClass phpClass;
14+
public record FormDataHolder(
15+
@Nullable String fieldTypeFqn,
16+
@NotNull String ownerFormTypeFqn
17+
) {
18+
public FormDataHolder {
19+
if (fieldTypeFqn != null && !fieldTypeFqn.startsWith("\\")) {
20+
throw new IllegalArgumentException("fieldTypeFqn must be normalized with a leading backslash");
21+
}
1222

13-
@NotNull
14-
private final PhpClass formType;
15-
16-
public FormDataHolder(@NotNull PhpClass phpClass, @NotNull PhpClass formType) {
17-
this.phpClass = phpClass;
18-
this.formType = formType;
19-
}
20-
21-
@NotNull
22-
public PhpClass getPhpClass() {
23-
return phpClass;
24-
}
25-
26-
@NotNull
27-
public PhpClass getFormType() {
28-
return formType;
23+
if (!ownerFormTypeFqn.startsWith("\\")) {
24+
throw new IllegalArgumentException("ownerFormTypeFqn must be normalized with a leading backslash");
25+
}
2926
}
3027
}

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/SymfonyLightCodeInsightFixtureTestCase.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ private void assertNavigationIsEmpty() {
267267
}
268268
}
269269

270-
private void assertNavigationMatch(ElementPattern<?> pattern) {
270+
public void assertNavigationMatch(ElementPattern<?> pattern) {
271271

272272
PsiElement psiElement = myFixture.getFile().findElementAt(myFixture.getCaretOffset());
273273

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/form/util/FormUtilTest.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ public void testGetFormTypeClassOnParameter() {
4949
).getFQN());
5050
}
5151

52+
public void testGetFormTypeFqnOnParameter() {
53+
assertEquals("\\Form\\FormType\\Foo", FormUtil.getFormTypeFqnOnParameter(
54+
PhpPsiElementFactory.createPhpPsiFromText(getProject(), PhpTypedElementImpl.class, "<?php new \\Form\\FormType\\Foo();")
55+
));
56+
57+
assertEquals("\\Form\\FormType\\Foo", FormUtil.getFormTypeFqnOnParameter(
58+
PhpPsiElementFactory.createPhpPsiFromText(getProject(), StringLiteralExpressionImpl.class, "<?php '\\Form\\FormType\\Foo'")
59+
));
60+
61+
assertEquals("\\Form\\FormType\\Foo", FormUtil.getFormTypeFqnOnParameter(
62+
PhpPsiElementFactory.createPhpPsiFromText(getProject(), StringLiteralExpressionImpl.class, "<?php 'Form\\FormType\\Foo'")
63+
));
64+
65+
assertEquals("\\Form\\FormType\\Foo", FormUtil.getFormTypeFqnOnParameter(
66+
PhpPsiElementFactory.createPhpPsiFromText(getProject(), ClassConstantReferenceImpl.class, "<?php Form\\FormType\\Foo::class")
67+
));
68+
}
69+
5270
@SuppressWarnings({"ConstantConditions"})
5371
public void testGetFormTypeClasses() {
5472
Map<String, FormTypeClass> formTypeClasses = FormUtil.getFormTypeClasses(getProject());

0 commit comments

Comments
 (0)