Skip to content

Commit 8202a40

Browse files
SJrXSteve Ramageclaude
authored
feat: IPv6 canonicalization to RFC 5952 behind the experimental flag (#499)
* feat: IPv6 canonicalization to RFC 5952 (#363) Offers a quick-fix to rewrite a non-canonical IPv6 address to its recommended form. Behind the experimental flag. - canonicalizeIpv6 (pure, dependency-free): parses to 8 groups and reformats per RFC 5952 §4 — lowercase hex, drop leading zeros, compress the longest zero run to "::" (leftmost on ties, only for runs of 2+, never a single zero group). Returns null for non-IPv6 input and, for now, for embedded-IPv4 (§5 mixed notation) addresses. - Combinator.labeledRegions(value): the grammar's explicit Labeled spans (e.g. a whole IP address) from the first fully-valid parse — lets features act on semantic spans. - Ipv6CanonicalFormInspection: flag-gated; for grammar-backed options it scans labeled spans and registers a WEAK_WARNING + CanonicalizeIpv6QuickFix on any IPv6 that isn't already canonical. Reuses the IPV4_ADDR/IPV6_ADDR Labeled(LITERAL) spans we added for coloring, so no IPv6-specific engine markers were needed. Tests: canonicalizer cases incl. zero-run/tie/single-zero/idempotence/non-IPv6; e2e warning + quick-fix rewriting 2001:DB8::1 -> 2001:db8::1, and nothing when canonical or the flag is off. Closes #363. Refs #467 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: attach colorize()'s KDoc to colorize(), not labeledRegions() labeledRegions() was inserted between colorize()'s doc comment and its body; reorder so each function sits under its own KDoc. * refactor: match IPv6 spans by a grammar tag, not by re-sniffing text The IPv6 canonicalization inspection walked every Labeled value span and ran canonicalizeIpv6() on each, treating "the string happens to parse as IPv6" as "this is an IPv6 address". That was only correct by luck: the sole Labeled spans today are IPV4_ADDR and IPV6_ADDR, and IPv4 is excluded because canonicalizeIpv6() bails on a dot. The invariant ("no other Labeled span ever matches an 8-hextet shape") lived nowhere in the code, and its violation wouldn't just false-positive — the quick-fix would rewrite the span, corrupting whatever it really was. Give Labeled an optional SemanticTag threaded onto Region, tag IPV6_ADDR as SemanticTag.IPV6, and have the inspection act only on tagged spans. The grammar now declares "this span is IPv6" and the inspection trusts it; canonicalizeIpv6() is demoted from detector to pure formatter (it still returns null for out-of-scope IPv4-tail forms). Role stays colour-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Steve Ramage <gitcommits@sjrx.net> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a1daaf9 commit 8202a40

11 files changed

Lines changed: 340 additions & 7 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.inspections
2+
3+
import com.intellij.codeInspection.LocalInspectionTool
4+
import com.intellij.codeInspection.ProblemHighlightType
5+
import com.intellij.codeInspection.ProblemsHolder
6+
import com.intellij.openapi.util.TextRange
7+
import com.intellij.psi.PsiElementVisitor
8+
import com.intellij.psi.util.PsiTreeUtil
9+
import net.sjrx.intellij.plugins.systemdunitfiles.UnitFileLanguage
10+
import net.sjrx.intellij.plugins.systemdunitfiles.intentions.CanonicalizeIpv6QuickFix
11+
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFile
12+
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType
13+
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups
14+
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileVisitor
15+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository
16+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass
17+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue
18+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.canonicalizeIpv6
19+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.SemanticTag
20+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.labeledRegions
21+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
22+
23+
/**
24+
* Suggests rewriting an IPv6 address to its RFC 5952 canonical form (#363), behind the experimental
25+
* flag. It walks the grammar's labeled value spans and, for those the grammar tagged
26+
* [SemanticTag.IPV6], offers a quick-fix when the address isn't already canonical. Keying off the tag
27+
* (rather than re-sniffing every literal span) means it only ever touches spans the grammar declared
28+
* to be IPv6 addresses.
29+
*/
30+
class Ipv6CanonicalFormInspection : LocalInspectionTool() {
31+
32+
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
33+
val file = holder.file
34+
if (file !is UnitFile || !file.language.isKindOf(UnitFileLanguage.INSTANCE)) return PsiElementVisitor.EMPTY_VISITOR
35+
if (!ExperimentalSettings.getInstance(file.project).state.useGrammarParseEngine) return PsiElementVisitor.EMPTY_VISITOR
36+
return MyVisitor(holder)
37+
}
38+
39+
private class MyVisitor(private val holder: ProblemsHolder) : UnitFileVisitor() {
40+
override fun visitPropertyType(property: UnitFilePropertyType) {
41+
val section = PsiTreeUtil.getParentOfType(property, UnitFileSectionGroups::class.java) ?: return
42+
val value = property.valueText ?: return
43+
val validator = SemanticDataRepository.instance
44+
.getOptionValidator(section.containingFile.fileClass(), section.sectionName, property.key)
45+
if (validator !is GrammarOptionValue) return
46+
47+
for (region in validator.combinator.labeledRegions(value)) {
48+
if (region.tag != SemanticTag.IPV6) continue // act only on spans the grammar declared IPv6
49+
val text = value.substring(region.start, region.end)
50+
val canonical = canonicalizeIpv6(text) ?: continue // e.g. an IPv4-tail form, out of scope
51+
if (canonical == text) continue
52+
holder.registerProblem(
53+
property.valueNode.psi,
54+
"IPv6 address is not in canonical form (RFC 5952); use '$canonical'",
55+
ProblemHighlightType.WEAK_WARNING,
56+
TextRange(region.start, region.end),
57+
CanonicalizeIpv6QuickFix(region.start, text, canonical),
58+
)
59+
}
60+
}
61+
}
62+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.intentions
2+
3+
import com.intellij.codeInspection.LocalQuickFix
4+
import com.intellij.codeInspection.ProblemDescriptor
5+
import com.intellij.openapi.project.Project
6+
import com.intellij.psi.util.PsiTreeUtil
7+
import net.sjrx.intellij.plugins.systemdunitfiles.psi.impl.UnitFilePropertyImpl
8+
9+
/**
10+
* Replaces the IPv6 address at [offset] (within the option value) with its RFC 5952 [canonical] form.
11+
*/
12+
class CanonicalizeIpv6QuickFix(private val offset: Int, private val original: String, private val canonical: String) : LocalQuickFix {
13+
14+
override fun getName(): String = "Convert to canonical IPv6 '$canonical'"
15+
16+
override fun getFamilyName(): String = "Convert to canonical IPv6 (RFC 5952)"
17+
18+
override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
19+
val fullPropertyValue = descriptor.psiElement.text
20+
val newText = fullPropertyValue.substring(0, offset) + canonical + fullPropertyValue.substring(offset + original.length)
21+
val property = PsiTreeUtil.getParentOfType(descriptor.psiElement, UnitFilePropertyImpl::class.java) ?: return
22+
val newElement = UnitElementFactory.createProperty(project, property.key, newText)
23+
property.replace(newElement)
24+
}
25+
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Coloring.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,12 @@ enum class Role {
2323
OPERATOR,
2424
}
2525

26-
/** A coloured span `[start, end)` and its [role]. */
27-
data class Region(val start: Int, val end: Int, val role: Role)
26+
/**
27+
* A coloured span `[start, end)` with its [role] and an optional [tag]. [tag] is the grammar's
28+
* declared identity for the span (e.g. [SemanticTag.IPV6]); features that act on spans by meaning
29+
* rather than colour filter on it. `null` for plain per-token coloring and untagged [Labeled] spans.
30+
*/
31+
data class Region(val start: Int, val end: Int, val role: Role, val tag: SemanticTag? = null)
2832

2933
/**
3034
* The role a terminal should get when it is NOT wrapped in [Labeled]. `null` means "do not colour"
@@ -42,6 +46,17 @@ fun defaultRole(terminal: TerminalCombinator): Role? = when (terminal) {
4246
private fun Array<out String>.allPunctuation(): Boolean =
4347
isNotEmpty() && all { choice -> choice.isNotEmpty() && choice.none(Char::isLetterOrDigit) }
4448

49+
/**
50+
* The explicit [Labeled] spans in [value] (e.g. a whole IP address), from the first fully-valid
51+
* parse — i.e. structure the grammar marked, without the per-token coloring defaults. Used by
52+
* features that act on semantic spans, such as IPv6 canonicalization.
53+
*/
54+
fun Combinator.labeledRegions(value: String): List<Region> {
55+
val parse = parse(value, 0).filterIsInstance<Parse>()
56+
.firstOrNull { it.end == value.length && it.tokens.all { token -> token.valid } } ?: return emptyList()
57+
return parse.regions
58+
}
59+
4560
/**
4661
* The coloured regions for [value]. Explicit [Labeled] regions win; any token not inside a labeled
4762
* region gets its terminal's [defaultRole]. Returns empty if no full parse exists — we don't colour

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Combinators.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ val IPV6_IPV4_SUFFIX_FIVE_HEXTET_BEFORE_ZERO_COMP = SequenceCombinator(IPV6_HEX
5252

5353
//val IPV6_ALL_ZEROS = DOUBLE_COLON
5454

55-
val IPV6_ADDR = Labeled(Role.LITERAL, AlternativeCombinator(
55+
val IPV6_ADDR = Labeled(Role.LITERAL, tag = SemanticTag.IPV6, inner = AlternativeCombinator(
5656
IPV6_IPV4_SUFFIX_FULL,
5757
IPV6_IPV4_SUFFIX_ZERO_HEXTET_BEFORE_ZERO_COMP,
5858
IPV6_IPV4_SUFFIX_ONE_HEXTET_BEFORE_ZERO_COMP,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
/*
4+
* IPv6 canonicalization to RFC 5952 §4 (#363):
5+
* - lowercase hex,
6+
* - drop leading zeros in each 16-bit group,
7+
* - compress the longest run of all-zero groups to "::" (leftmost on a tie, only if the run is 2+),
8+
* - never compress a single zero group.
9+
*
10+
* Hand-rolled and dependency-free. The address is parsed to eight groups, then re-formatted. The
11+
* mixed IPv4-tail notation (RFC 5952 §5, e.g. ::ffff:1.2.3.4) is intentionally out of scope for now —
12+
* [canonicalizeIpv6] returns null for addresses containing a dotted IPv4 part, so they're left alone.
13+
*/
14+
fun canonicalizeIpv6(address: String): String? {
15+
if (address.isEmpty() || '.' in address) return null
16+
val groups = parseGroups(address) ?: return null
17+
return format(groups)
18+
}
19+
20+
private fun parseGroups(address: String): IntArray? {
21+
val doubleColon = address.indexOf("::")
22+
val groups: List<Int>
23+
if (doubleColon >= 0) {
24+
if (address.indexOf("::", doubleColon + 1) >= 0) return null // at most one "::"
25+
val head = address.substring(0, doubleColon).split(":").filter { it.isNotEmpty() }
26+
val tail = address.substring(doubleColon + 2).split(":").filter { it.isNotEmpty() }
27+
val missing = 8 - head.size - tail.size
28+
if (missing < 1) return null // "::" must stand for at least one zero group
29+
groups = (head + List(missing) { "0" } + tail).map { parseHextet(it) ?: return null }
30+
} else {
31+
val parts = address.split(":")
32+
if (parts.size != 8) return null
33+
groups = parts.map { parseHextet(it) ?: return null }
34+
}
35+
return groups.toIntArray()
36+
}
37+
38+
private fun parseHextet(s: String): Int? {
39+
if (s.isEmpty() || s.length > 4) return null
40+
val value = s.toIntOrNull(16) ?: return null
41+
return if (value in 0..0xFFFF) value else null
42+
}
43+
44+
private fun format(groups: IntArray): String {
45+
// Longest run of consecutive zero groups (leftmost on ties); only compressible if length >= 2.
46+
var runStart = -1
47+
var runLen = 0
48+
var i = 0
49+
while (i < groups.size) {
50+
if (groups[i] == 0) {
51+
var j = i
52+
while (j < groups.size && groups[j] == 0) j++
53+
if (j - i > runLen) {
54+
runLen = j - i
55+
runStart = i
56+
}
57+
i = j
58+
} else {
59+
i++
60+
}
61+
}
62+
if (runLen < 2) runStart = -1
63+
64+
val sb = StringBuilder()
65+
i = 0
66+
while (i < groups.size) {
67+
if (i == runStart) {
68+
sb.append("::")
69+
i += runLen
70+
continue
71+
}
72+
if (sb.isNotEmpty() && !sb.endsWith("::")) sb.append(":")
73+
sb.append(Integer.toHexString(groups[i]))
74+
i++
75+
}
76+
return sb.toString()
77+
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Labeled.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
22

33
/**
4-
* Wraps [inner] and tags the whole matched span with a coloring [role] (#467 / #342).
4+
* Wraps [inner] and marks the whole matched span with a coloring [role] and, optionally, a
5+
* [SemanticTag] (#467 / #342).
56
*
67
* It is OPTIONAL and TRANSPARENT: matching (SyntacticMatch / SemanticMatch / parse) is delegated to
7-
* [inner] unchanged, so wrapping a sub-grammar affects only coloring, never validation or
8+
* [inner] unchanged, so wrapping a sub-grammar affects only coloring/tagging, never validation or
89
* completion. Use it where a composite value should read as one unit — e.g.
910
* `Labeled(Role.LITERAL, IPV4_ADDR)` colors `127.0.0.1` as a single literal instead of per-octet.
11+
* Pass [tag] when a feature needs to recognise the span by what the grammar declared it to be rather
12+
* than by re-sniffing its text — e.g. `Labeled(Role.LITERAL, ..., SemanticTag.IPV6)`.
1013
*/
11-
class Labeled(private val role: Role, private val inner: Combinator) : Combinator {
14+
class Labeled(private val role: Role, private val inner: Combinator, private val tag: SemanticTag? = null) : Combinator {
1215

1316
override fun SyntacticMatch(value: String, offset: Int): MatchResult = inner.SyntacticMatch(value, offset)
1417

@@ -18,7 +21,7 @@ class Labeled(private val role: Role, private val inner: Combinator) : Combinato
1821
inner.parse(value, offset).map { step ->
1922
when (step) {
2023
is Parse ->
21-
if (step.end > offset) Parse(step.end, step.tokens, step.regions + Region(offset, step.end, role))
24+
if (step.end > offset) Parse(step.end, step.tokens, step.regions + Region(offset, step.end, role, tag))
2225
else step // matched nothing; no region to add
2326
is Stuck -> step
2427
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
/**
4+
* A semantic identity a grammar can attach to a [Labeled] span, independent of its coloring [Role].
5+
*
6+
* Where [Role] answers "what colour?", a tag answers "what *is* this span?". It lets a feature act on
7+
* a span because the grammar *declared* what it is, rather than re-sniffing the raw text and hoping no
8+
* other labeled span happens to look the same. Currently only IPv6 canonicalization keys off it (see
9+
* Ipv6CanonicalFormInspection); add members as other features need structural identity.
10+
*/
11+
enum class SemanticTag {
12+
/** A whole IPv6 address (possibly with an IPv4 tail), as matched by `IPV6_ADDR`. */
13+
IPV6,
14+
}

src/main/resources/META-INF/plugin.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@
8282
groupPath="Unit files (systemd)" language="Unit File (systemd)"
8383
shortName="MissingRequiredKey" displayName="Missing required key"
8484
groupName="Validity" enabledByDefault="true" level="ERROR"/>
85+
<localInspection implementationClass="net.sjrx.intellij.plugins.systemdunitfiles.inspections.Ipv6CanonicalFormInspection"
86+
groupPath="Unit files (systemd)" language="Unit File (systemd)"
87+
shortName="Ipv6CanonicalForm" displayName="IPv6 address not in canonical form (RFC 5952)"
88+
groupName="Style" enabledByDefault="true" level="WEAK WARNING"/>
8589
<localInspection implementationClass="net.sjrx.intellij.plugins.systemdunitfiles.inspections.IPAddressAllowOnlyInspection"
8690
groupPath="Unit files (systemd)" language="Unit File (systemd)"
8791
shortName="IPAddressAllowOnly" displayName="IPAddressAllow without IPAddressDeny"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.inspections
2+
3+
import com.intellij.lang.annotation.HighlightSeverity
4+
import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest
5+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
6+
import org.junit.Test
7+
8+
/** End-to-end: a non-canonical IPv6 address is flagged and the quick-fix rewrites it (flag-gated). */
9+
class Ipv6CanonicalFormInspectionTest : AbstractUnitFileTest() {
10+
11+
override fun tearDown() {
12+
try {
13+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
14+
} finally {
15+
super.tearDown()
16+
}
17+
}
18+
19+
private fun enableNewEngine() {
20+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = true
21+
}
22+
23+
private fun hasCanonicalWarning() = myFixture.doHighlighting().any {
24+
it.severity == HighlightSeverity.WEAK_WARNING && it.description?.contains("canonical form") == true
25+
}
26+
27+
@Test
28+
fun testNonCanonicalIsFlagged() {
29+
enableNewEngine()
30+
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:DB8::1")
31+
enableInspection(Ipv6CanonicalFormInspection::class.java)
32+
assertTrue(hasCanonicalWarning())
33+
}
34+
35+
@Test
36+
fun testAlreadyCanonicalIsNotFlagged() {
37+
enableNewEngine()
38+
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:db8::1")
39+
enableInspection(Ipv6CanonicalFormInspection::class.java)
40+
assertFalse(hasCanonicalWarning())
41+
}
42+
43+
@Test
44+
fun testNotFlaggedWhenFlagOff() {
45+
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:DB8::1")
46+
enableInspection(Ipv6CanonicalFormInspection::class.java)
47+
assertFalse(hasCanonicalWarning())
48+
}
49+
50+
@Test
51+
fun testQuickFixRewritesToCanonical() {
52+
enableNewEngine()
53+
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:D${COMPLETION_POSITION}B8::1")
54+
enableInspection(Ipv6CanonicalFormInspection::class.java)
55+
myFixture.doHighlighting()
56+
57+
val fix = myFixture.findSingleIntention("Convert to canonical IPv6")
58+
myFixture.launchAction(fix)
59+
60+
myFixture.checkResult("[Service]\nIPAddressAllow=2001:db8::1")
61+
}
62+
}

src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/ColoringTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ class ColoringTest {
4747
assertEquals(listOf(Region(0, 7, Role.LITERAL)), SequenceCombinator(IPV4_ADDR, EOF()).colorize("1.2.3.4"))
4848
}
4949

50+
@Test
51+
fun testLabeledCarriesSemanticTag() {
52+
// The optional tag rides on the Region so a feature can recognise a span by what the grammar
53+
// declared it to be, instead of re-sniffing the text. Untagged Labeled spans stay tag == null.
54+
val tagged = Labeled(Role.LITERAL, LiteralChoiceTerminal("ab"), SemanticTag.IPV6)
55+
assertEquals(listOf(Region(0, 2, Role.LITERAL, SemanticTag.IPV6)), tagged.labeledRegions("ab"))
56+
assertEquals(listOf(Region(0, 2, Role.LITERAL, null)), Labeled(Role.LITERAL, LiteralChoiceTerminal("ab")).labeledRegions("ab"))
57+
}
58+
59+
@Test
60+
fun testIpCombinatorsDeclareTheirIdentityStructurally() {
61+
// IPV6_ADDR declares itself IPv6; IPV4_ADDR is untagged. The IPv6 inspection keys off this tag,
62+
// so it never has to guess whether a literal span "looks like" an IPv6 address.
63+
assertEquals(
64+
listOf(Region(0, 3, Role.LITERAL, SemanticTag.IPV6)),
65+
SequenceCombinator(IPV6_ADDR, EOF()).labeledRegions("::1"),
66+
)
67+
assertEquals(
68+
listOf(Region(0, 7, Role.LITERAL, null)),
69+
SequenceCombinator(IPV4_ADDR, EOF()).labeledRegions("1.2.3.4"),
70+
)
71+
}
72+
5073
@Test
5174
fun testLabeledIsTransparentToValidation() {
5275
// Wrapping changes only colour: validation behaves exactly as the bare grammar.

0 commit comments

Comments
 (0)