diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/annotation/TwigUxToolkitAnnotator.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/annotation/TwigUxToolkitAnnotator.java new file mode 100644 index 000000000..55ceeab92 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/annotation/TwigUxToolkitAnnotator.java @@ -0,0 +1,150 @@ +package fr.adrienbrault.idea.symfony2plugin.templating.annotation; + +import com.intellij.lang.annotation.AnnotationHolder; +import com.intellij.lang.annotation.Annotator; +import com.intellij.lang.annotation.HighlightSeverity; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.jetbrains.php.lang.highlighter.PhpHighlightingData; +import com.jetbrains.twig.TwigTokenTypes; +import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; +import org.jetbrains.annotations.NotNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Provides syntax highlighting for Symfony UX Toolkit Twig comment annotations. + * + * Supports: + * - {# @prop name type Description #} + * - {# @block name Description #} + * + * Uses PHP's PHPDoc highlighting colors for consistency with `@property` annotations. + * + * @see Symfony UX Toolkit + */ +public class TwigUxToolkitAnnotator implements Annotator { + /** + * Pattern for @prop annotations. + * Format: @prop name type Description + * Example: @prop open boolean Whether the item is open by default. + * + * Supports complex types: + * - Simple: string, boolean, int + * - Nullable: ?string, string|null + * - Union: string|int|null + * - Generic: array, Collection + * - Literal strings: 'vertical'|'horizontal' + * - FQCN: App\Entity\Item, \DateTime + * - Arrays: string[], Item[] + * + * @see Regex101 + */ + private static final Pattern PROP_PATTERN = Pattern.compile( + "(@prop)\\s+(\\w+)\\s+(\\S+)\\s+(.+?)\\s*$", + Pattern.DOTALL + ); + + /** + * Pattern for @block annotations. + * Format: @block name Description + * Example: @block content The item content. + * + * @see Regex101 + */ + private static final Pattern BLOCK_PATTERN = Pattern.compile( + "(@block)\\s+(\\w+)\\s+(.+?)\\s*$", + Pattern.DOTALL + ); + + @Override + public void annotate(@NotNull PsiElement element, @NotNull AnnotationHolder holder) { + if (!Symfony2ProjectComponent.isEnabled(element.getProject())) { + return; + } + + // Only process Twig comment text tokens + if (element.getNode().getElementType() != TwigTokenTypes.COMMENT_TEXT) { + return; + } + + String text = element.getText(); + int startOffset = element.getTextRange().getStartOffset(); + + // Try to match @prop pattern + Matcher propMatcher = PROP_PATTERN.matcher(text); + if (propMatcher.find()) { + annotateProp(holder, startOffset, propMatcher); + return; + } + + // Try to match @block pattern + Matcher blockMatcher = BLOCK_PATTERN.matcher(text); + if (blockMatcher.find()) { + annotateBlock(holder, startOffset, blockMatcher); + } + } + + /** + * Annotates a @prop comment with syntax highlighting. + * Highlights: @prop keyword, property name, and type. + * + * Uses PHP PHPDoc colors: + * - DOC_TAG for @prop keyword (like @property in PHPDoc) + * - DOC_PROPERTY_IDENTIFIER for property name (like $foo in @property string $foo) + * - DOC_IDENTIFIER for type (like string in @property string $foo) + */ + private void annotateProp(@NotNull AnnotationHolder holder, int startOffset, @NotNull Matcher matcher) { + // Highlight @prop keyword (group 1) - like @property + highlightRange(holder, startOffset, matcher, 1, PhpHighlightingData.DOC_TAG); + + // Highlight property name (group 2) - like $foo in @property string $foo + highlightRange(holder, startOffset, matcher, 2, PhpHighlightingData.DOC_PROPERTY_IDENTIFIER); + + // Highlight type (group 3) - like string in @property string $foo + highlightRange(holder, startOffset, matcher, 3, PhpHighlightingData.DOC_IDENTIFIER); + } + + /** + * Annotates a @block comment with syntax highlighting. + * Highlights: @block keyword and block name. + * + * Uses PHP PHPDoc colors: + * - DOC_TAG for @block keyword + * - DOC_PROPERTY_IDENTIFIER for block name + */ + private void annotateBlock(@NotNull AnnotationHolder holder, int startOffset, @NotNull Matcher matcher) { + // Highlight @block keyword (group 1) + highlightRange(holder, startOffset, matcher, 1, PhpHighlightingData.DOC_TAG); + + // Highlight block name (group 2) + highlightRange(holder, startOffset, matcher, 2, PhpHighlightingData.DOC_PROPERTY_IDENTIFIER); + } + + /** + * Creates a silent annotation with the specified text attributes for a regex group match. + */ + private void highlightRange( + @NotNull AnnotationHolder holder, + int baseOffset, + @NotNull Matcher matcher, + int group, + @NotNull TextAttributesKey textAttributesKey + ) { + if (matcher.group(group) == null) { + return; + } + + TextRange range = new TextRange( + baseOffset + matcher.start(group), + baseOffset + matcher.end(group) + ); + + holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(range) + .textAttributes(textAttributesKey) + .create(); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 22a1c5e4a..635a665ef 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -230,6 +230,8 @@ + + diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/annotation/TwigUxToolkitAnnotatorTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/annotation/TwigUxToolkitAnnotatorTest.java new file mode 100644 index 000000000..a469298b4 --- /dev/null +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/annotation/TwigUxToolkitAnnotatorTest.java @@ -0,0 +1,186 @@ +package fr.adrienbrault.idea.symfony2plugin.tests.templating.annotation; + +import com.intellij.codeInsight.daemon.impl.HighlightInfo; +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import fr.adrienbrault.idea.symfony2plugin.templating.annotation.TwigUxToolkitAnnotator; +import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase; + +import java.util.List; + +/** + * @author Daniel Espendiller + * @see TwigUxToolkitAnnotator + */ +public class TwigUxToolkitAnnotatorTest extends SymfonyLightCodeInsightFixtureTestCase { + + public void testPropAnnotationIsHighlighted() { + myFixture.configureByText( + "test.html.twig", + "{# @prop open boolean Whether the item is open by default. #}" + ); + + List highlighting = myFixture.doHighlighting(); + + // Verify that highlighting is applied (INFORMATION level annotations from our annotator) + assertTrue( + "Expected highlighting for @prop annotation", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG) + ) + ); + } + + public void testPropAnnotationHighlightsPropertyName() { + myFixture.configureByText( + "test.html.twig", + "{# @prop open boolean Whether the item is open by default. #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for property name", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG_VALUE) + ) + ); + } + + public void testPropAnnotationHighlightsType() { + myFixture.configureByText( + "test.html.twig", + "{# @prop open boolean Whether the item is open by default. #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for type", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.CLASS_REFERENCE) + ) + ); + } + + public void testBlockAnnotationIsHighlighted() { + myFixture.configureByText( + "test.html.twig", + "{# @block content The item content. #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for @block annotation", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG) + ) + ); + } + + public void testBlockAnnotationHighlightsBlockName() { + myFixture.configureByText( + "test.html.twig", + "{# @block content The item content. #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for block name", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG_VALUE) + ) + ); + } + + public void testPropWithComplexType() { + myFixture.configureByText( + "test.html.twig", + "{# @prop items App\\Entity\\Item[] List of items #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for complex type", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.CLASS_REFERENCE) + ) + ); + } + + public void testPropWithUnionType() { + myFixture.configureByText( + "test.html.twig", + "{# @prop value string|int|null The value #}" + ); + + List highlighting = myFixture.doHighlighting(); + + assertTrue( + "Expected highlighting for union type", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.CLASS_REFERENCE) + ) + ); + } + + public void testRegularCommentNotHighlighted() { + myFixture.configureByText( + "test.html.twig", + "{# This is a regular comment #}" + ); + + List highlighting = myFixture.doHighlighting(); + + // Regular comments should not have our specific highlighting + assertFalse( + "Regular comments should not have DOC_COMMENT_TAG highlighting", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG) + ) + ); + } + + public void testVarCommentNotAffected() { + // @var comments are handled by a different mechanism, ensure we don't interfere + myFixture.configureByText( + "test.html.twig", + "{# @var foo \\App\\Entity\\Foo #}" + ); + + List highlighting = myFixture.doHighlighting(); + + // @var should not be highlighted by our annotator (it's not @prop or @block) + assertFalse( + "@var comments should not be highlighted by TwigUxToolkitAnnotator", + highlighting.stream().anyMatch(info -> + info.getSeverity().getName().equals("INFORMATION") && + hasTextAttributesKey(info, DefaultLanguageHighlighterColors.DOC_COMMENT_TAG) && + info.getText() != null && info.getText().contains("@var") + ) + ); + } + + /** + * Helper method to check if a HighlightInfo has a specific TextAttributesKey. + */ + private boolean hasTextAttributesKey(HighlightInfo info, TextAttributesKey expectedKey) { + if (info.forcedTextAttributesKey != null) { + return info.forcedTextAttributesKey.equals(expectedKey) || + info.forcedTextAttributesKey.getFallbackAttributeKey() != null && + info.forcedTextAttributesKey.getFallbackAttributeKey().equals(expectedKey); + } + return false; + } +}