From cab658ee4d4d9c390da02fdacf6201dd22b3018f Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 13:38:50 -0700 Subject: [PATCH] feat: IPv6 canonicalization to RFC 5952 (#363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Ipv6CanonicalFormInspection.kt | 58 ++++++++++++++ .../intentions/CanonicalizeIpv6QuickFix.kt | 25 ++++++ .../optionvalues/grammar/Coloring.kt | 11 +++ .../semanticdata/optionvalues/grammar/Ipv6.kt | 77 +++++++++++++++++++ src/main/resources/META-INF/plugin.xml | 4 + .../Ipv6CanonicalFormInspectionTest.kt | 62 +++++++++++++++ .../optionvalues/grammar/Ipv6Test.kt | 48 ++++++++++++ 7 files changed, 285 insertions(+) create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Ipv6CanonicalFormInspection.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/CanonicalizeIpv6QuickFix.kt create mode 100644 src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Ipv6.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Ipv6CanonicalFormInspectionTest.kt create mode 100644 src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Ipv6Test.kt diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Ipv6CanonicalFormInspection.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Ipv6CanonicalFormInspection.kt new file mode 100644 index 0000000..366f9f5 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/Ipv6CanonicalFormInspection.kt @@ -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), + ) + } + } + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/CanonicalizeIpv6QuickFix.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/CanonicalizeIpv6QuickFix.kt new file mode 100644 index 0000000..ad04663 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/CanonicalizeIpv6QuickFix.kt @@ -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) + } +} 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 f209343..64b1e8d 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 @@ -51,6 +51,17 @@ private fun Array.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 { + val parse = parse(value, 0).filterIsInstance() + .firstOrNull { it.end == value.length && it.tokens.all { token -> token.valid } } ?: return emptyList() + return parse.regions +} + fun Combinator.colorize(value: String): List { val parse = parse(value, 0).filterIsInstance().firstOrNull { it.end == value.length } ?: return emptyList() diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Ipv6.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Ipv6.kt new file mode 100644 index 0000000..8dc57c0 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Ipv6.kt @@ -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 + 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() +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 7dd61f1..e99f4da 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -83,6 +83,10 @@ groupPath="Unit files (systemd)" language="Unit File (systemd)" shortName="MissingRequiredKey" displayName="Missing required key" groupName="Validity" enabledByDefault="true" level="ERROR"/> +