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)
+ }
+}