diff --git a/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/UnitFileHighlighter.java b/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/UnitFileHighlighter.java index e7eb364c..b2eb33c2 100644 --- a/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/UnitFileHighlighter.java +++ b/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/UnitFileHighlighter.java @@ -29,6 +29,16 @@ public class UnitFileHighlighter extends SyntaxHighlighterBase { private static final TextAttributesKey BAD_CHARACTER = createTextAttributesKey("UNIT_FILE_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER); + // Grammar-driven value coloring (applied by an annotator, not the lexer) — one key per Role. + public static final TextAttributesKey GRAMMAR_ENUM + = createTextAttributesKey("UNIT_FILE_GRAMMAR_ENUM", DefaultLanguageHighlighterColors.CONSTANT); + public static final TextAttributesKey GRAMMAR_LITERAL + = createTextAttributesKey("UNIT_FILE_GRAMMAR_LITERAL", DefaultLanguageHighlighterColors.NUMBER); + public static final TextAttributesKey GRAMMAR_OPERATOR + = createTextAttributesKey("UNIT_FILE_GRAMMAR_OPERATOR", DefaultLanguageHighlighterColors.OPERATION_SIGN); + public static final TextAttributesKey GRAMMAR_IDENTIFIER + = createTextAttributesKey("UNIT_FILE_GRAMMAR_IDENTIFIER", DefaultLanguageHighlighterColors.IDENTIFIER); + private static final TextAttributesKey[] SECTION_KEYS = new TextAttributesKey[]{SECTION}; diff --git a/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/settings/UnitFileColorSettings.java b/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/settings/UnitFileColorSettings.java index e900eb4e..3b129312 100644 --- a/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/settings/UnitFileColorSettings.java +++ b/src/main/java/net/sjrx/intellij/plugins/systemdunitfiles/coloring/settings/UnitFileColorSettings.java @@ -11,6 +11,7 @@ import org.jetbrains.annotations.Nullable; import javax.swing.Icon; +import java.util.HashMap; import java.util.Map; public class UnitFileColorSettings implements ColorSettingsPage { @@ -21,6 +22,10 @@ public class UnitFileColorSettings implements ColorSettingsPage { new AttributesDescriptor("Key", UnitFileHighlighter.KEY), new AttributesDescriptor("Separator", UnitFileHighlighter.SEPARATOR), new AttributesDescriptor("Value", UnitFileHighlighter.VALUE), + new AttributesDescriptor("Value//Enum (grammar)", UnitFileHighlighter.GRAMMAR_ENUM), + new AttributesDescriptor("Value//Literal (grammar)", UnitFileHighlighter.GRAMMAR_LITERAL), + new AttributesDescriptor("Value//Operator (grammar)", UnitFileHighlighter.GRAMMAR_OPERATOR), + new AttributesDescriptor("Value//Identifier (grammar)", UnitFileHighlighter.GRAMMAR_IDENTIFIER), }; @Nullable @@ -58,6 +63,11 @@ public String getDemoText() { + "\n" + "[Service]\n" + "Type=oneshot\n" + + "RestrictAddressFamilies=~AF_INET AF_INET6\n" + + "SocketBindAllow=ipv4:tcp:8080\n" + + "SocketBindDeny=any\n" + + "IPAddressAllow=192.168.1.1 ::1\n" + + "RootImagePolicy=root=verity+signed\n" + "ExecStartPre=-/usr/bin/systemctl daemon-reload\n" + "; we have to retrigger initrd-fs.target after daemon-reload\n" + "ExecStart=-/usr/bin/systemctl --no-block start initrd-fs.target\n" @@ -67,7 +77,12 @@ public String getDemoText() { @Nullable @Override public Map getAdditionalHighlightingTagToDescriptorMap() { - return null; + Map tags = new HashMap<>(); + tags.put("gEnum", UnitFileHighlighter.GRAMMAR_ENUM); + tags.put("gLit", UnitFileHighlighter.GRAMMAR_LITERAL); + tags.put("gOp", UnitFileHighlighter.GRAMMAR_OPERATOR); + tags.put("gId", UnitFileHighlighter.GRAMMAR_IDENTIFIER); + return tags; } @NotNull diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/GrammarValueColorAnnotator.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/GrammarValueColorAnnotator.kt new file mode 100644 index 00000000..6ccc11cb --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/GrammarValueColorAnnotator.kt @@ -0,0 +1,56 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.annotators + +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.intellij.psi.util.PsiTreeUtil +import net.sjrx.intellij.plugins.systemdunitfiles.coloring.UnitFileHighlighter +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileProperty +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionType +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Role +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.colorize +import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings + +/** + * Grammar-based value coloring (#467 / #342), behind the experimental flag. + * + * For options validated by the new grammar engine, [colorize] is asked which spans of the value map + * to which [Role], and each span is painted with the matching text-attributes key. Does nothing when + * the flag is off, so normal users are unaffected. + */ +class GrammarValueColorAnnotator : Annotator { + + override fun annotate(element: PsiElement, holder: AnnotationHolder) { + if (element !is UnitFileProperty) return + if (!ExperimentalSettings.getInstance(element.project).state.useGrammarParseEngine) return + + val section = PsiTreeUtil.getParentOfType(element, UnitFileSectionType::class.java) ?: return + val value = element.valueText ?: return + val base = element.valueNode?.psi?.textRange?.startOffset ?: return + val fileClass = element.containingFile.fileClass() + val validator = SemanticDataRepository.instance.getOptionValidator(fileClass, section.sectionName, element.key) + if (validator !is GrammarOptionValue) return + + for (region in validator.combinator.colorize(value)) { + holder.newSilentAnnotation(HighlightSeverity.INFORMATION) + .range(TextRange(base + region.start, base + region.end)) + .textAttributes(attributesFor(region.role)) + .create() + } + } + + companion object { + fun attributesFor(role: Role): TextAttributesKey = when (role) { + Role.ENUM -> UnitFileHighlighter.GRAMMAR_ENUM + Role.LITERAL -> UnitFileHighlighter.GRAMMAR_LITERAL + Role.OPERATOR -> UnitFileHighlighter.GRAMMAR_OPERATOR + Role.IDENTIFIER -> UnitFileHighlighter.GRAMMAR_IDENTIFIER + } + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt index 87e80ac5..9c9cd5d7 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt @@ -59,5 +59,7 @@ fun Combinator.colorize(value: String): List { regions.add(Region(token.start, token.end, role)) } } - return regions.sortedBy { it.start } + // distinct(): nested Labeled (e.g. an IPv4 suffix inside an IPv6 address) can emit the same span + // twice; collapse exact duplicates. + return regions.distinct().sortedBy { it.start } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinators.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinators.kt index 825f0c28..a3d30f91 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinators.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinators.kt @@ -18,7 +18,8 @@ val TIME_VALUE = AlternativeCombinator( var IPV4_OCTET = IntegerTerminal(0, 256) val DOT = LiteralChoiceTerminal(".") -var IPV4_ADDR = SequenceCombinator(IPV4_OCTET, DOT, IPV4_OCTET, DOT, IPV4_OCTET, DOT, IPV4_OCTET) +// Labeled so an address colours as one literal span rather than per-octet/dot (transparent to matching). +var IPV4_ADDR = Labeled(Role.LITERAL, SequenceCombinator(IPV4_OCTET, DOT, IPV4_OCTET, DOT, IPV4_OCTET, DOT, IPV4_OCTET)) val CIDR_SEPARATOR = LiteralChoiceTerminal("/") @@ -51,7 +52,7 @@ val IPV6_IPV4_SUFFIX_FIVE_HEXTET_BEFORE_ZERO_COMP = SequenceCombinator(IPV6_HEX //val IPV6_ALL_ZEROS = DOUBLE_COLON -val IPV6_ADDR = AlternativeCombinator( +val IPV6_ADDR = Labeled(Role.LITERAL, AlternativeCombinator( IPV6_IPV4_SUFFIX_FULL, IPV6_IPV4_SUFFIX_ZERO_HEXTET_BEFORE_ZERO_COMP, IPV6_IPV4_SUFFIX_ONE_HEXTET_BEFORE_ZERO_COMP, @@ -72,7 +73,7 @@ val IPV6_ADDR = AlternativeCombinator( // I suspect maybe that this one is redundant //IPV6_ALL_ZEROS, -) +)) val IPV6_ADDR_AND_PREFIX_LENGTH = SequenceCombinator(IPV6_ADDR, CIDR_SEPARATOR, IntegerTerminal(64, 129)) val IPV6_ADDR_AND_OPTIONAL_PREFIX_LENGTH = SequenceCombinator(IPV6_ADDR, ZeroOrOne(SequenceCombinator(CIDR_SEPARATOR, IntegerTerminal(64, 129)))) diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 2fdc75e5..49b186b6 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -61,6 +61,7 @@ + , text: String) = + highlights.any { it.severity == HighlightSeverity.INFORMATION && it.text == text } + + @Test + fun testSocketBindValueIsColoredByRole() { + enableNewEngine() + setupFileInEditor("file.service", "[Service]\nSocketBindAllow=ipv4:tcp:8080") + + val highlights = myFixture.doHighlighting() + assertTrue(colored(highlights, "ipv4")) // ENUM + assertTrue(colored(highlights, ":")) // OPERATOR + assertTrue(colored(highlights, "8080")) // LITERAL + } + + @Test + fun testIpAddressIsColoredAsOneLiteralSpan() { + enableNewEngine() + setupFileInEditor("file.network", "[Network]\nGateway=192.168.1.1") + + // The whole address is one colored span (thanks to Labeled), not per-octet. + assertTrue(colored(myFixture.doHighlighting(), "192.168.1.1")) + } + + @Test + fun testNoValueColoringWhenFlagOff() { + setupFileInEditor("file.service", "[Service]\nSocketBindAllow=ipv4:tcp:8080") + + assertFalse(colored(myFixture.doHighlighting(), "8080")) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt index b800dde3..de899a76 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt @@ -36,9 +36,15 @@ class ColoringTest { @Test fun testLabeledPaintsACompositeSpanAsOneUnit() { - // Without Labeled an IPv4 address would colour per octet/dot; wrapping it makes it one LITERAL. - val grammar = SequenceCombinator(Labeled(Role.LITERAL, IPV4_ADDR), EOF()) - assertEquals(listOf(Region(0, 7, Role.LITERAL)), grammar.colorize("1.2.3.4")) + // Without Labeled these would colour as two ENUM tokens; wrapping makes the span one LITERAL. + val grammar = Labeled(Role.LITERAL, SequenceCombinator(LiteralChoiceTerminal("a"), LiteralChoiceTerminal("b"))) + assertEquals(listOf(Region(0, 2, Role.LITERAL)), grammar.colorize("ab")) + } + + @Test + fun testSharedIpCombinatorIsLabeledAsOneLiteral() { + // IPV4_ADDR is wrapped in Labeled in Combinators.kt, so an address colours as one literal. + assertEquals(listOf(Region(0, 7, Role.LITERAL)), SequenceCombinator(IPV4_ADDR, EOF()).colorize("1.2.3.4")) } @Test