Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <a href="https://github.com/symfony/ux-toolkit">Symfony UX Toolkit</a>
*/
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<string>, Collection<int, Item>
* - Literal strings: 'vertical'|'horizontal'
* - FQCN: App\Entity\Item, \DateTime
* - Arrays: string[], Item[]
*
* @see <a href="https://regex101.com/r/3JXNX7/1">Regex101</a>
*/
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 <a href="https://regex101.com/r/jYjXpq/1">Regex101</a>
*/
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();
}
}
2 changes: 2 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@
<lang.foldingBuilder language="PHP" implementationClass="fr.adrienbrault.idea.symfony2plugin.navigation.PhpFoldingBuilder"/>
<lang.foldingBuilder language="Twig" implementationClass="fr.adrienbrault.idea.symfony2plugin.navigation.TwigFoldingBuilder"/>

<annotator language="Twig" implementationClass="fr.adrienbrault.idea.symfony2plugin.templating.annotation.TwigUxToolkitAnnotator"/>

<completion.contributor language="any" implementationClass="fr.adrienbrault.idea.symfony2plugin.codeInsight.completion.CompletionContributor"/>
<gotoDeclarationHandler implementation="fr.adrienbrault.idea.symfony2plugin.codeInsight.navigation.GotoHandler"/>

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <daniel@espendiller.net>
* @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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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<HighlightInfo> 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;
}
}
Loading