Skip to content

Commit dda42cf

Browse files
Steve Ramageclaude
andcommitted
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>
1 parent 6cd5561 commit dda42cf

7 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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.labeledRegions
20+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
21+
22+
/**
23+
* Suggests rewriting an IPv6 address to its RFC 5952 canonical form (#363), behind the experimental
24+
* flag. It walks the grammar's labeled value spans (e.g. a whole IP address) and offers a quick-fix
25+
* for any that aren't already canonical.
26+
*/
27+
class Ipv6CanonicalFormInspection : LocalInspectionTool() {
28+
29+
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
30+
val file = holder.file
31+
if (file !is UnitFile || !file.language.isKindOf(UnitFileLanguage.INSTANCE)) return PsiElementVisitor.EMPTY_VISITOR
32+
if (!ExperimentalSettings.getInstance(file.project).state.useGrammarParseEngine) return PsiElementVisitor.EMPTY_VISITOR
33+
return MyVisitor(holder)
34+
}
35+
36+
private class MyVisitor(private val holder: ProblemsHolder) : UnitFileVisitor() {
37+
override fun visitPropertyType(property: UnitFilePropertyType) {
38+
val section = PsiTreeUtil.getParentOfType(property, UnitFileSectionGroups::class.java) ?: return
39+
val value = property.valueText ?: return
40+
val validator = SemanticDataRepository.instance
41+
.getOptionValidator(section.containingFile.fileClass(), section.sectionName, property.key)
42+
if (validator !is GrammarOptionValue) return
43+
44+
for (region in validator.combinator.labeledRegions(value)) {
45+
val text = value.substring(region.start, region.end)
46+
val canonical = canonicalizeIpv6(text) ?: continue // null = not a (pure) IPv6 address
47+
if (canonical == text) continue
48+
holder.registerProblem(
49+
property.valueNode.psi,
50+
"IPv6 address is not in canonical form (RFC 5952); use '$canonical'",
51+
ProblemHighlightType.WEAK_WARNING,
52+
TextRange(region.start, region.end),
53+
CanonicalizeIpv6QuickFix(region.start, text, canonical),
54+
)
55+
}
56+
}
57+
}
58+
}
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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ private fun Array<out String>.allPunctuation(): Boolean =
5151
* region gets its terminal's [defaultRole]. Returns empty if no full parse exists — we don't colour
5252
* values that don't match the grammar.
5353
*/
54+
/**
55+
* The explicit [Labeled] spans in [value] (e.g. a whole IP address), from the first fully-valid
56+
* parse — i.e. structure the grammar marked, without the per-token coloring defaults. Used by
57+
* features that act on semantic spans, such as IPv6 canonicalization.
58+
*/
59+
fun Combinator.labeledRegions(value: String): List<Region> {
60+
val parse = parse(value, 0).filterIsInstance<Parse>()
61+
.firstOrNull { it.end == value.length && it.tokens.all { token -> token.valid } } ?: return emptyList()
62+
return parse.regions
63+
}
64+
5465
fun Combinator.colorize(value: String): List<Region> {
5566
val parse = parse(value, 0).filterIsInstance<Parse>().firstOrNull { it.end == value.length } ?: return emptyList()
5667

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/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+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Test
6+
7+
/** Unit tests for RFC 5952 IPv6 canonicalization. */
8+
class Ipv6Test {
9+
10+
@Test
11+
fun testCanonicalizes() {
12+
assertEquals("2001:db8::1", canonicalizeIpv6("2001:0db8:0000:0000:0000:0000:0000:0001"))
13+
assertEquals("2001:db8::1", canonicalizeIpv6("2001:DB8::1")) // lowercase
14+
assertEquals("fe80::1", canonicalizeIpv6("FE80:0:0:0:0:0:0:1")) // compress + lowercase
15+
assertEquals("::", canonicalizeIpv6("0:0:0:0:0:0:0:0")) // all zeros
16+
assertEquals("::1", canonicalizeIpv6("0:0:0:0:0:0:0:1"))
17+
assertEquals("1:2:3:4:5:6:7:8", canonicalizeIpv6("1:2:3:4:5:6:7:8")) // nothing to compress
18+
}
19+
20+
@Test
21+
fun testAlreadyCanonicalIsIdempotent() {
22+
for (a in listOf("2001:db8::1", "::1", "::", "fe80::1", "1:2:3:4:5:6:7:8")) {
23+
assertEquals(a, canonicalizeIpv6(a))
24+
}
25+
}
26+
27+
@Test
28+
fun testLongestRunIsCompressed() {
29+
// Two zero runs: compress the longer one (the second).
30+
assertEquals("1:0:0:1::1", canonicalizeIpv6("1:0:0:1:0:0:0:1"))
31+
// A single zero group must NOT be compressed.
32+
assertEquals("1:2:3:4:5:6:0:8", canonicalizeIpv6("1:2:3:4:5:6:0:8"))
33+
}
34+
35+
@Test
36+
fun testTieCompressesLeftmostRun() {
37+
assertEquals("1::1:0:0:1:1", canonicalizeIpv6("1:0:0:1:0:0:1:1"))
38+
}
39+
40+
@Test
41+
fun testNonIpv6ReturnsNull() {
42+
assertNull(canonicalizeIpv6("1.2.3.4")) // IPv4
43+
assertNull(canonicalizeIpv6("::ffff:1.2.3.4")) // embedded IPv4 (out of scope for now)
44+
assertNull(canonicalizeIpv6("8080")) // integer
45+
assertNull(canonicalizeIpv6("hello"))
46+
assertNull(canonicalizeIpv6(""))
47+
}
48+
}

0 commit comments

Comments
 (0)