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..96d39f36 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,9 @@ 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), }; @Nullable @@ -58,6 +62,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 +76,11 @@ 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); + 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 new file mode 100644 index 00000000..f209343e --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt @@ -0,0 +1,67 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +/* + * Grammar-based syntax coloring (#467 / #342). + * + * Coloring is OPTIONAL and mostly automatic: [colorize] assigns each matched token a [Role] from + * its terminal's [defaultRole], so existing grammars are coloured with no changes. Where a composite + * span should read as one unit (e.g. an IP address rather than per-octet), wrap it in [Labeled], + * which paints the whole span with one role and is otherwise transparent to matching. + * + * Roles are abstract; the role -> TextAttributes mapping lives in the IntelliJ layer. + */ + +/** Semantic role of a coloured span. */ +enum class Role { + /** A value chosen from a fixed set of words (e.g. `none`, `verity`, `AF_INET`). */ + ENUM, + + /** A literal value: a number, or a composite value span wrapped in [Labeled] (e.g. an IP). */ + LITERAL, + + /** A punctuation separator/operator (e.g. `:`, `+`, `=`, `~`, `/`). */ + OPERATOR, + + /** A free-form identifier (e.g. a regex-matched name). */ + IDENTIFIER, +} + +/** A coloured span `[start, end)` and its [role]. */ +data class Region(val start: Int, val end: Int, val role: Role) + +/** + * The role a terminal should get when it is NOT wrapped in [Labeled]. `null` means "do not colour" + * (whitespace, and anything we don't recognise). + */ +fun defaultRole(terminal: TerminalCombinator): Role? = when (terminal) { + is IntegerTerminal -> Role.LITERAL + is LiteralChoiceTerminal -> if (terminal.choices.allPunctuation()) Role.OPERATOR else Role.ENUM + is FlexibleLiteralChoiceTerminal -> if (terminal.choices.allPunctuation()) Role.OPERATOR else Role.ENUM + // RegexTerminal (free-form names/strings: Description=, interface names, ...) and whitespace stay + // uncoloured by default — they keep the editor's normal value colour. The IDENTIFIER role is still + // available for grammars that opt in explicitly via Labeled (e.g. a single-token field like User=). + else -> null +} + +private fun Array.allPunctuation(): Boolean = + isNotEmpty() && all { choice -> choice.isNotEmpty() && choice.none(Char::isLetterOrDigit) } + +/** + * The coloured regions for [value]. Explicit [Labeled] regions win; any token not inside a labeled + * region gets its terminal's [defaultRole]. Returns empty if no full parse exists — we don't colour + * values that don't match the grammar. + */ +fun Combinator.colorize(value: String): List { + val parse = parse(value, 0).filterIsInstance().firstOrNull { it.end == value.length } ?: return emptyList() + + val regions = parse.regions.toMutableList() + for (token in parse.tokens) { + val role = defaultRole(token.terminal) ?: continue + if (regions.none { token.start >= it.start && token.end <= it.end }) { + regions.add(Region(token.start, token.end, role)) + } + } + // 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/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Labeled.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Labeled.kt new file mode 100644 index 00000000..a08c148e --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Labeled.kt @@ -0,0 +1,28 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +/** + * Wraps [inner] and tags the whole matched span with a coloring [role] (#467 / #342). + * + * It is OPTIONAL and TRANSPARENT: matching (SyntacticMatch / SemanticMatch / parse) is delegated to + * [inner] unchanged, so wrapping a sub-grammar affects only coloring, never validation or + * completion. Use it where a composite value should read as one unit — e.g. + * `Labeled(Role.LITERAL, IPV4_ADDR)` colors `127.0.0.1` as a single literal instead of per-octet. + */ +class Labeled(private val role: Role, private val inner: Combinator) : Combinator { + + override fun SyntacticMatch(value: String, offset: Int): MatchResult = inner.SyntacticMatch(value, offset) + + override fun SemanticMatch(value: String, offset: Int): MatchResult = inner.SemanticMatch(value, offset) + + override fun parse(value: String, offset: Int): Sequence = + inner.parse(value, offset).map { step -> + when (step) { + is Parse -> + if (step.end > offset) Parse(step.end, step.tokens, step.regions + Region(offset, step.end, role)) + else step // matched nothing; no region to add + is Stuck -> step + } + } + + override fun toStringIndented(indent: Int): String = inner.toStringIndented(indent) +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/OneOrMore.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/OneOrMore.kt index 77c3850c..6579a18b 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/OneOrMore.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/OneOrMore.kt @@ -46,7 +46,7 @@ class OneOrMore(val combinator : Combinator) : Combinator { yield(from) for (step in combinator.parse(value, from.end)) { when (step) { - is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens))) + is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens, from.regions + step.regions))) is Stuck -> yield(step) } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt index 17f908ac..2419342e 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt @@ -41,8 +41,11 @@ data class ParsedToken( /** One step a matcher can take from an offset: either it consumed input ([Parse]) or it got [Stuck]. */ sealed interface ParseStep -/** A successful match: consumed input up to [end], producing [tokens] (each with its `valid` flag). */ -data class Parse(val end: Int, val tokens: List) : ParseStep +/** + * A successful match: consumed input up to [end], producing [tokens] (each with its `valid` flag). + * [regions] carries any coloring spans contributed by [Labeled] wrappers (empty for most parses). + */ +data class Parse(val end: Int, val tokens: List, val regions: List = emptyList()) : ParseStep /** * A dead end: matching could not proceed at [offset], where [expected] is the set of matchers the diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Repeat.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Repeat.kt index c3621ea2..4286723d 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Repeat.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Repeat.kt @@ -71,7 +71,7 @@ class Repeat(val combinator : Combinator, val minInclusive: Int, val maxExclusiv if (count < maxExclusive) { for (step in combinator.parse(value, from.end)) { when (step) { - is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens), count + 1)) + is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens, from.regions + step.regions), count + 1)) is Stuck -> yield(step) } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SequenceCombinator.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SequenceCombinator.kt index c4c4ad12..73c36579 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SequenceCombinator.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/SequenceCombinator.kt @@ -65,7 +65,7 @@ open class SequenceCombinator(vararg val tokens: Combinator) : Combinator { is Stuck -> sequenceOf(acc) // path already dead-ended; carry it forward is Parse -> token.parse(value, acc.end).map { step -> when (step) { - is Parse -> Parse(step.end, acc.tokens + step.tokens) + is Parse -> Parse(step.end, acc.tokens + step.tokens, acc.regions + step.regions) is Stuck -> step // this part got stuck after acc; propagate the dead end } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ZeroOrMore.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ZeroOrMore.kt index 07393886..3dd47872 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ZeroOrMore.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ZeroOrMore.kt @@ -50,7 +50,7 @@ class ZeroOrMore(val combinator : Combinator) : Combinator { yield(from) // stop repeating here... for (step in combinator.parse(value, from.end)) { when (step) { - is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens))) + is Parse -> if (step.end > from.end) yieldAll(extend(Parse(step.end, from.tokens + step.tokens, from.regions + step.regions))) is Stuck -> yield(step) // couldn't take another repetition; remember where/why } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 4b77b510..7461b7f7 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/inspections/GrammarParseEngineInspectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt index 992a4313..09fbf6a6 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/GrammarParseEngineInspectionTest.kt @@ -1,5 +1,6 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections +import com.intellij.lang.annotation.HighlightSeverity import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings import org.junit.Test @@ -42,7 +43,7 @@ class GrammarParseEngineInspectionTest : AbstractUnitFileTest() { setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) - assertSize(0, myFixture.doHighlighting()) + assertSize(0, myFixture.doHighlighting().filter { it.severity != HighlightSeverity.INFORMATION }) } @Test @@ -62,7 +63,7 @@ class GrammarParseEngineInspectionTest : AbstractUnitFileTest() { setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) - assertSize(3, myFixture.doHighlighting()) + assertSize(3, myFixture.doHighlighting().filter { it.severity != HighlightSeverity.INFORMATION }) } @Test @@ -77,6 +78,6 @@ class GrammarParseEngineInspectionTest : AbstractUnitFileTest() { setupFileInEditor("file.service", file) enableInspection(InvalidValueInspection::class.java) - assertSize(0, myFixture.doHighlighting()) + assertSize(0, myFixture.doHighlighting().filter { it.severity != HighlightSeverity.INFORMATION }) } } 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 new file mode 100644 index 00000000..d21e199a --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt @@ -0,0 +1,59 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar + +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseAddressFamiliesOptionValue +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Unit tests for grammar coloring: default roles, automatic coloring, and the optional Labeled wrapper. */ +class ColoringTest { + + @Test + fun testDefaultRoles() { + assertEquals(Role.LITERAL, defaultRole(IntegerTerminal(0, 10))) + assertEquals(Role.ENUM, defaultRole(LiteralChoiceTerminal("none"))) + assertEquals(Role.ENUM, defaultRole(FlexibleLiteralChoiceTerminal("AF_INET", "AF_INET6"))) + assertEquals(Role.OPERATOR, defaultRole(LiteralChoiceTerminal(":"))) + assertEquals(Role.OPERATOR, defaultRole(LiteralChoiceTerminal("~"))) + assertEquals(null, defaultRole(RegexTerminal("[a-z]+", "[a-z]+"))) // free-form: keeps default value color + assertEquals(null, defaultRole(WhitespaceTerminal())) + } + + @Test + fun testAutomaticColoringNeedsNoGrammarChanges() { + // The real RestrictAddressFamilies grammar, unchanged: "~" is an operator, families are enums, + // whitespace is uncoloured. + val grammar = ConfigParseAddressFamiliesOptionValue().combinator + val regions = grammar.colorize("~AF_INET AF_INET6") + assertEquals( + listOf( + Region(0, 1, Role.OPERATOR), // ~ + Region(1, 8, Role.ENUM), // AF_INET + Region(9, 17, Role.ENUM), // AF_INET6 (the space at 8..9 is uncoloured) + ), + regions, + ) + } + + @Test + fun testLabeledPaintsACompositeSpanAsOneUnit() { + // 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 + fun testLabeledIsTransparentToValidation() { + // Wrapping changes only colour: validation behaves exactly as the bare grammar. + val bare = SequenceCombinator(IPV4_ADDR, EOF()) + val labeled = SequenceCombinator(Labeled(Role.LITERAL, IPV4_ADDR), EOF()) + assertEquals(bare.validate("1.2.3.4"), labeled.validate("1.2.3.4")) + assertEquals(ParseOutcome.Valid, labeled.validate("1.2.3.4")) + assertEquals(bare.validate("999.0.0.1")::class, labeled.validate("999.0.0.1")::class) + } +}