Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package net.sjrx.intellij.plugins.systemdunitfiles.inspections

import com.intellij.codeInspection.LocalInspectionTool
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElementVisitor
import com.intellij.psi.util.PsiTreeUtil
import net.sjrx.intellij.plugins.systemdunitfiles.UnitFileLanguage
import net.sjrx.intellij.plugins.systemdunitfiles.intentions.CanonicalizeIpv6QuickFix
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFile
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileVisitor
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.canonicalizeIpv6
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.labeledRegions
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings

/**
* Suggests rewriting an IPv6 address to its RFC 5952 canonical form (#363), behind the experimental
* flag. It walks the grammar's labeled value spans (e.g. a whole IP address) and offers a quick-fix
* for any that aren't already canonical.
*/
class Ipv6CanonicalFormInspection : LocalInspectionTool() {

override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean): PsiElementVisitor {
val file = holder.file
if (file !is UnitFile || !file.language.isKindOf(UnitFileLanguage.INSTANCE)) return PsiElementVisitor.EMPTY_VISITOR
if (!ExperimentalSettings.getInstance(file.project).state.useGrammarParseEngine) return PsiElementVisitor.EMPTY_VISITOR
return MyVisitor(holder)
}

private class MyVisitor(private val holder: ProblemsHolder) : UnitFileVisitor() {
override fun visitPropertyType(property: UnitFilePropertyType) {
val section = PsiTreeUtil.getParentOfType(property, UnitFileSectionGroups::class.java) ?: return
val value = property.valueText ?: return
val validator = SemanticDataRepository.instance
.getOptionValidator(section.containingFile.fileClass(), section.sectionName, property.key)
if (validator !is GrammarOptionValue) return

for (region in validator.combinator.labeledRegions(value)) {
val text = value.substring(region.start, region.end)
val canonical = canonicalizeIpv6(text) ?: continue // null = not a (pure) IPv6 address
if (canonical == text) continue
holder.registerProblem(
property.valueNode.psi,
"IPv6 address is not in canonical form (RFC 5952); use '$canonical'",
ProblemHighlightType.WEAK_WARNING,
TextRange(region.start, region.end),
CanonicalizeIpv6QuickFix(region.start, text, canonical),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.sjrx.intellij.plugins.systemdunitfiles.intentions

import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemDescriptor
import com.intellij.openapi.project.Project
import com.intellij.psi.util.PsiTreeUtil
import net.sjrx.intellij.plugins.systemdunitfiles.psi.impl.UnitFilePropertyImpl

/**
* Replaces the IPv6 address at [offset] (within the option value) with its RFC 5952 [canonical] form.
*/
class CanonicalizeIpv6QuickFix(private val offset: Int, private val original: String, private val canonical: String) : LocalQuickFix {

override fun getName(): String = "Convert to canonical IPv6 '$canonical'"

override fun getFamilyName(): String = "Convert to canonical IPv6 (RFC 5952)"

override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
val fullPropertyValue = descriptor.psiElement.text
val newText = fullPropertyValue.substring(0, offset) + canonical + fullPropertyValue.substring(offset + original.length)
val property = PsiTreeUtil.getParentOfType(descriptor.psiElement, UnitFilePropertyImpl::class.java) ?: return
val newElement = UnitElementFactory.createProperty(project, property.key, newText)
property.replace(newElement)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ private fun Array<out String>.allPunctuation(): Boolean =
* region gets its terminal's [defaultRole]. Returns empty if no full parse exists — we don't colour
* values that don't match the grammar.
*/
/**
* The explicit [Labeled] spans in [value] (e.g. a whole IP address), from the first fully-valid
* parse — i.e. structure the grammar marked, without the per-token coloring defaults. Used by
* features that act on semantic spans, such as IPv6 canonicalization.
*/
fun Combinator.labeledRegions(value: String): List<Region> {
val parse = parse(value, 0).filterIsInstance<Parse>()
.firstOrNull { it.end == value.length && it.tokens.all { token -> token.valid } } ?: return emptyList()
return parse.regions
}

fun Combinator.colorize(value: String): List<Region> {
val parse = parse(value, 0).filterIsInstance<Parse>().firstOrNull { it.end == value.length } ?: return emptyList()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar

/*
* IPv6 canonicalization to RFC 5952 §4 (#363):
* - lowercase hex,
* - drop leading zeros in each 16-bit group,
* - compress the longest run of all-zero groups to "::" (leftmost on a tie, only if the run is 2+),
* - never compress a single zero group.
*
* Hand-rolled and dependency-free. The address is parsed to eight groups, then re-formatted. The
* mixed IPv4-tail notation (RFC 5952 §5, e.g. ::ffff:1.2.3.4) is intentionally out of scope for now —
* [canonicalizeIpv6] returns null for addresses containing a dotted IPv4 part, so they're left alone.
*/
fun canonicalizeIpv6(address: String): String? {
if (address.isEmpty() || '.' in address) return null
val groups = parseGroups(address) ?: return null
return format(groups)
}

private fun parseGroups(address: String): IntArray? {
val doubleColon = address.indexOf("::")
val groups: List<Int>
if (doubleColon >= 0) {
if (address.indexOf("::", doubleColon + 1) >= 0) return null // at most one "::"
val head = address.substring(0, doubleColon).split(":").filter { it.isNotEmpty() }
val tail = address.substring(doubleColon + 2).split(":").filter { it.isNotEmpty() }
val missing = 8 - head.size - tail.size
if (missing < 1) return null // "::" must stand for at least one zero group
groups = (head + List(missing) { "0" } + tail).map { parseHextet(it) ?: return null }
} else {
val parts = address.split(":")
if (parts.size != 8) return null
groups = parts.map { parseHextet(it) ?: return null }
}
return groups.toIntArray()
}

private fun parseHextet(s: String): Int? {
if (s.isEmpty() || s.length > 4) return null
val value = s.toIntOrNull(16) ?: return null
return if (value in 0..0xFFFF) value else null
}

private fun format(groups: IntArray): String {
// Longest run of consecutive zero groups (leftmost on ties); only compressible if length >= 2.
var runStart = -1
var runLen = 0
var i = 0
while (i < groups.size) {
if (groups[i] == 0) {
var j = i
while (j < groups.size && groups[j] == 0) j++
if (j - i > runLen) {
runLen = j - i
runStart = i
}
i = j
} else {
i++
}
}
if (runLen < 2) runStart = -1

val sb = StringBuilder()
i = 0
while (i < groups.size) {
if (i == runStart) {
sb.append("::")
i += runLen
continue
}
if (sb.isNotEmpty() && !sb.endsWith("::")) sb.append(":")
sb.append(Integer.toHexString(groups[i]))
i++
}
return sb.toString()
}
4 changes: 4 additions & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@
groupPath="Unit files (systemd)" language="Unit File (systemd)"
shortName="MissingRequiredKey" displayName="Missing required key"
groupName="Validity" enabledByDefault="true" level="ERROR"/>
<localInspection implementationClass="net.sjrx.intellij.plugins.systemdunitfiles.inspections.Ipv6CanonicalFormInspection"
groupPath="Unit files (systemd)" language="Unit File (systemd)"
shortName="Ipv6CanonicalForm" displayName="IPv6 address not in canonical form (RFC 5952)"
groupName="Style" enabledByDefault="true" level="WEAK WARNING"/>
<localInspection implementationClass="net.sjrx.intellij.plugins.systemdunitfiles.inspections.IPAddressAllowOnlyInspection"
groupPath="Unit files (systemd)" language="Unit File (systemd)"
shortName="IPAddressAllowOnly" displayName="IPAddressAllow without IPAddressDeny"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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

/** End-to-end: a non-canonical IPv6 address is flagged and the quick-fix rewrites it (flag-gated). */
class Ipv6CanonicalFormInspectionTest : AbstractUnitFileTest() {

override fun tearDown() {
try {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
} finally {
super.tearDown()
}
}

private fun enableNewEngine() {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = true
}

private fun hasCanonicalWarning() = myFixture.doHighlighting().any {
it.severity == HighlightSeverity.WEAK_WARNING && it.description?.contains("canonical form") == true
}

@Test
fun testNonCanonicalIsFlagged() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:DB8::1")
enableInspection(Ipv6CanonicalFormInspection::class.java)
assertTrue(hasCanonicalWarning())
}

@Test
fun testAlreadyCanonicalIsNotFlagged() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:db8::1")
enableInspection(Ipv6CanonicalFormInspection::class.java)
assertFalse(hasCanonicalWarning())
}

@Test
fun testNotFlaggedWhenFlagOff() {
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:DB8::1")
enableInspection(Ipv6CanonicalFormInspection::class.java)
assertFalse(hasCanonicalWarning())
}

@Test
fun testQuickFixRewritesToCanonical() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nIPAddressAllow=2001:D${COMPLETION_POSITION}B8::1")
enableInspection(Ipv6CanonicalFormInspection::class.java)
myFixture.doHighlighting()

val fix = myFixture.findSingleIntention("Convert to canonical IPv6")
myFixture.launchAction(fix)

myFixture.checkResult("[Service]\nIPAddressAllow=2001:db8::1")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test

/** Unit tests for RFC 5952 IPv6 canonicalization. */
class Ipv6Test {

@Test
fun testCanonicalizes() {
assertEquals("2001:db8::1", canonicalizeIpv6("2001:0db8:0000:0000:0000:0000:0000:0001"))
assertEquals("2001:db8::1", canonicalizeIpv6("2001:DB8::1")) // lowercase
assertEquals("fe80::1", canonicalizeIpv6("FE80:0:0:0:0:0:0:1")) // compress + lowercase
assertEquals("::", canonicalizeIpv6("0:0:0:0:0:0:0:0")) // all zeros
assertEquals("::1", canonicalizeIpv6("0:0:0:0:0:0:0:1"))
assertEquals("1:2:3:4:5:6:7:8", canonicalizeIpv6("1:2:3:4:5:6:7:8")) // nothing to compress
}

@Test
fun testAlreadyCanonicalIsIdempotent() {
for (a in listOf("2001:db8::1", "::1", "::", "fe80::1", "1:2:3:4:5:6:7:8")) {
assertEquals(a, canonicalizeIpv6(a))
}
}

@Test
fun testLongestRunIsCompressed() {
// Two zero runs: compress the longer one (the second).
assertEquals("1:0:0:1::1", canonicalizeIpv6("1:0:0:1:0:0:0:1"))
// A single zero group must NOT be compressed.
assertEquals("1:2:3:4:5:6:0:8", canonicalizeIpv6("1:2:3:4:5:6:0:8"))
}

@Test
fun testTieCompressesLeftmostRun() {
assertEquals("1::1:0:0:1:1", canonicalizeIpv6("1:0:0:1:0:0:1:1"))
}

@Test
fun testNonIpv6ReturnsNull() {
assertNull(canonicalizeIpv6("1.2.3.4")) // IPv4
assertNull(canonicalizeIpv6("::ffff:1.2.3.4")) // embedded IPv4 (out of scope for now)
assertNull(canonicalizeIpv6("8080")) // integer
assertNull(canonicalizeIpv6("hello"))
assertNull(canonicalizeIpv6(""))
}
}
Loading