From 9b6b9bf03b3e35114fc4b840dc4d742d00476448 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 12:22:47 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20grammar=20coloring=20engine=20layer?= =?UTF-8?q?=20=E2=80=94=20roles,=20defaults,=20optional=20Labeled=20(#467?= =?UTF-8?q?=20#342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the (pure, IntelliJ-free) substrate for grammar-based syntax coloring. Coloring is automatic and needs NO grammar changes; an optional wrapper handles composite spans. - Role { ENUM, LITERAL, OPERATOR, IDENTIFIER } + Region(start, end, role). - defaultRole(terminal): IntegerTerminal -> LITERAL; Literal/FlexibleLiteralChoice -> ENUM, or OPERATOR when all choices are punctuation (":", "+", "=", "~", "/", ...); RegexTerminal -> IDENTIFIER; whitespace/other -> uncoloured. - Labeled(role, inner): optional, transparent wrapper. Matching is delegated to inner unchanged (SyntacticMatch/SemanticMatch/parse), so it affects only colour — e.g. Labeled(LITERAL, IPV4_ADDR) paints 127.0.0.1 as one literal instead of per-octet. - Parse gains a defaulted `regions` field; the merging combinators (Seq, ZeroOrMore, OneOrMore, Repeat) thread it. colorize(value) returns labeled regions plus per-token defaults for anything not inside a labeled region. Role -> TextAttributes mapping is deliberately left to the IntelliJ layer (next MR: annotator + color settings, behind the experimental flag). Tests: default roles; automatic colouring of the unchanged RestrictAddressFamilies grammar (operator/enum, whitespace uncoloured); Labeled collapsing an IPv4 address to one LITERAL; and Labeled being transparent to validation. Refs #467 #342 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../optionvalues/grammar/Coloring.kt | 63 +++++++++++++++++++ .../optionvalues/grammar/Labeled.kt | 28 +++++++++ .../optionvalues/grammar/OneOrMore.kt | 2 +- .../optionvalues/grammar/Parse.kt | 7 ++- .../optionvalues/grammar/Repeat.kt | 2 +- .../grammar/SequenceCombinator.kt | 2 +- .../optionvalues/grammar/ZeroOrMore.kt | 2 +- .../optionvalues/grammar/ColoringTest.kt | 53 ++++++++++++++++ 8 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Labeled.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt 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 0000000..87e80ac --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt @@ -0,0 +1,63 @@ +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 + is RegexTerminal -> Role.IDENTIFIER + else -> null // WhitespaceTerminal, and any future terminal types: uncoloured by default +} + +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)) + } + } + return regions.sortedBy { it.start } +} 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 0000000..a08c148 --- /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 77c3850..6579a18 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 17f908a..2419342 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 c3621ea..4286723 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 c4c4ad1..73c3657 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 0739388..3dd4787 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/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 0000000..b800dde --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt @@ -0,0 +1,53 @@ +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(Role.IDENTIFIER, defaultRole(RegexTerminal("[a-z]+", "[a-z]+"))) + 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 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")) + } + + @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) + } +} From b043e793a8d1ff6421b2acb88f5ef46b0b69df85 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 12:40:40 -0700 Subject: [PATCH 2/5] feat: render grammar value coloring + color settings (#467 #342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendering half of grammar coloring — now visible in the editor behind the flag. - UnitFileHighlighter: four user-customizable TextAttributesKeys, one per Role (GRAMMAR_ENUM/LITERAL/OPERATOR/IDENTIFIER), defaulting to CONSTANT/NUMBER/ OPERATION_SIGN/IDENTIFIER colors. - GrammarValueColorAnnotator: flag-gated; for grammar-backed options it runs colorize() and paints each Region with the matching attributes key. No-op when the flag is off. - UnitFileColorSettings: adds the four roles under "Value" (customizable + themed) and extends the preview with grammar examples — RestrictAddressFamilies, SocketBindAllow/ Deny, IPAddressAllow, RootImagePolicy — via highlighting tags. - Combinators: IPV4_ADDR / IPV6_ADDR wrapped in Labeled(LITERAL) so an address colours as one literal span rather than per-octet/hextet (transparent to matching; full suite green confirms validation/completion unchanged). colorize() now dedupes identical regions (nested Labeled, e.g. IPv4 suffix inside IPv6). Tests: GrammarValueColorAnnotatorTest (SocketBindAllow coloured by role; an IP as one literal span; nothing when the flag is off) and the extended ColoringTest. Refs #467 #342 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../coloring/UnitFileHighlighter.java | 10 ++++ .../settings/UnitFileColorSettings.java | 17 +++++- .../annotators/GrammarValueColorAnnotator.kt | 56 +++++++++++++++++++ .../optionvalues/grammar/Coloring.kt | 4 +- .../optionvalues/grammar/Combinators.kt | 7 ++- src/main/resources/META-INF/plugin.xml | 1 + .../GrammarValueColorAnnotatorTest.kt | 56 +++++++++++++++++++ .../optionvalues/grammar/ColoringTest.kt | 12 +++- 8 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/GrammarValueColorAnnotator.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/GrammarValueColorAnnotatorTest.kt 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 e7eb364..b2eb33c 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 e900eb4..3b12931 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 0000000..6ccc11c --- /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 87e80ac..9c9cd5d 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 825f0c2..a3d30f9 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 4b77b51..7461b7f 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 b800dde..de899a7 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 From 53cf13fe6cf3d858e8b1effe12a9a2284ec92494 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 13:00:41 -0700 Subject: [PATCH 3/5] docs: show an identifier example (Description=) in the color settings preview (#467 #342) The color-settings preview had grammar examples for enum/literal/operator but none for the Identifier role, so clicking "Value//Identifier (grammar)" highlighted nothing. Tag the [Unit] Description= value (a config_parse_unit_string_printf identifier) so the role has a visible example. Refs #467 #342 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../coloring/settings/UnitFileColorSettings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3b12931..b1db238 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 @@ -53,7 +53,7 @@ public String getDemoText() { + "# (at your option) any later version.\n" + "\n" + "[Unit]\n" - + "Description=Reload Configuration from the Real Root\n" + + "Description=Reload Configuration from the Real Root\n" + "DefaultDependencies=no\n" + "Requires=initrd-root-fs.target\n" + "After=initrd-root-fs.target\n" From 275c02ed20dd79ea61f5ea58974fe2678e492b99 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 13:09:19 -0700 Subject: [PATCH 4/5] fix: leave free-form string values at the default color (#467 #342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default heuristic mapped RegexTerminal -> IDENTIFIER, so free-form values like Description= and interface names (Bond=) got recoloured, which felt wrong — those should keep the editor's normal value color. Make RegexTerminal uncoloured by default. - defaultRole: RegexTerminal now falls through to null (no coloring). - Drop the Identifier descriptor + tag + demo example from the color settings page, since nothing uses the IDENTIFIER role by default now. The Role and its key are kept for grammars that opt in explicitly via Labeled later (e.g. a single-token User=). Refs #467 #342 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../coloring/settings/UnitFileColorSettings.java | 4 +--- .../semanticdata/optionvalues/grammar/Coloring.kt | 6 ++++-- .../semanticdata/optionvalues/grammar/ColoringTest.kt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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 b1db238..96d39f3 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 @@ -25,7 +25,6 @@ public class UnitFileColorSettings implements ColorSettingsPage { 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 @@ -53,7 +52,7 @@ public String getDemoText() { + "# (at your option) any later version.\n" + "\n" + "[Unit]\n" - + "Description=Reload Configuration from the Real Root\n" + + "Description=Reload Configuration from the Real Root\n" + "DefaultDependencies=no\n" + "Requires=initrd-root-fs.target\n" + "After=initrd-root-fs.target\n" @@ -81,7 +80,6 @@ public Map getAdditionalHighlightingTagToDescriptorMa 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; } 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 9c9cd5d..f209343 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 @@ -37,8 +37,10 @@ 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 - is RegexTerminal -> Role.IDENTIFIER - else -> null // WhitespaceTerminal, and any future terminal types: uncoloured by default + // 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 = 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 de899a7..d21e199 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 @@ -14,7 +14,7 @@ class ColoringTest { assertEquals(Role.ENUM, defaultRole(FlexibleLiteralChoiceTerminal("AF_INET", "AF_INET6"))) assertEquals(Role.OPERATOR, defaultRole(LiteralChoiceTerminal(":"))) assertEquals(Role.OPERATOR, defaultRole(LiteralChoiceTerminal("~"))) - assertEquals(Role.IDENTIFIER, defaultRole(RegexTerminal("[a-z]+", "[a-z]+"))) + assertEquals(null, defaultRole(RegexTerminal("[a-z]+", "[a-z]+"))) // free-form: keeps default value color assertEquals(null, defaultRole(WhitespaceTerminal())) } From 6cd556175abba4d17909d32cd1bd0c5bf4c8ca19 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sat, 27 Jun 2026 08:39:58 -0700 Subject: [PATCH 5/5] test: filter INFORMATION highlights from GrammarParseEngineInspectionTest The value-color annotator adds INFORMATION-severity coloring highlights when the experimental flag is on, which inflates doHighlighting() counts. Count only the inspection problems (severity != INFORMATION), matching the value-coloring annotator. --- .../inspections/GrammarParseEngineInspectionTest.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 992a431..09fbf6a 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 }) } }