diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ddb4c39b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +IntelliJ plugin providing language support for systemd unit files (.service, .mount, .timer, .socket, .path, .automount, .swap, .slice, .device, .target, .nspawn). Written in Kotlin and Java, using Grammar-Kit for parsing. + +## Build Commands + +```bash +./gradlew buildPlugin # Build plugin distribution zip +./gradlew test # Run all tests +./gradlew test --tests "net.sjrx.intellij.plugins.systemdunitfiles.lexer.UnitFileLexerTest" # Single test class +./gradlew runIde # Launch dev IDE with plugin loaded +./gradlew checkstyleMain # Run checkstyle (excludes generated code) +./gradlew generateLexerTask # Regenerate lexer from .flex grammar +./gradlew generateParserTask # Regenerate parser from .bnf grammar +./gradlew generateDataFromManPages # Parse systemd XML man pages → JSON semantic data +``` + +The metadata generation pipeline uses Docker: `docker compose --project-directory ./systemd-build up --build` + +## Architecture + +**Grammar & Parsing:** The lexer is defined in `src/main/resources/.../lexer/SystemdUnitFile.flex` (JFlex) and the parser grammar in `src/main/resources/.../grammar/SystemdUnitFile.bnf`. Generated code goes to `src/main/gen/`. + +**Semantic Data Pipeline:** Docker builds systemd from source, extracts man pages (XML) and gperf files. A custom Groovy task in `buildSrc/` (`GenerateDataFromManPages.groovy`) transforms XML via XSLT into JSON data and HTML documentation stored under `src/main/resources/.../semanticdata/`. + +**Plugin Feature Modules** (all under `src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/`): +- `semanticdata/` — Core data models, validators, option value parsers, documentation repository. This is the foundational layer other features depend on. +- `completion/` — Auto-completion contributors for keys, values, and sections +- `inspections/` — Code inspections (InvalidValue, UnknownKey, Deprecated, MissingRequiredKey, ShellSyntax, IPAddress) +- `annotators/` — Real-time inline annotations (invalid sections, deprecated options, whitespace) +- `documentation/` — Inline documentation provider +- `filetypes/` — File type definitions for each supported extension +- `lexer/` — Lexical analysis adapter + +**Java source** (`src/main/java/`) contains syntax highlighting (`coloring/`), parser definition (`parser/`), and generated PSI elements (`psi/`). + +## Testing + +Tests use JUnit 4 with IntelliJ Platform Test Framework. Test sources are in `src/test/kotlin/` with fixtures in `src/test/resources/`. Tests mirror the main source structure. + +## Conventions + +- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) format +- Java 21 for compilation, targets IntelliJ 2024.2 (platform version 242) +- Plugin version range: 242.0–270.0 +- Generated code in `src/main/gen/` — do not edit manually; regenerate with grammar-kit tasks diff --git a/build.gradle.kts b/build.gradle.kts index fdaac58d..c1ab9ec3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -219,6 +219,97 @@ tasks.register("generateOptionValidator") { into("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/") } +tasks.register("mergePodmanDocumentation") { + description = "Merge podman quadlet documentation JSON into the generated sectionToKeywordMapFromDoc.json" + group = "generation" + + val semanticDataDir = file("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata") + val podmanJsonFile = file("./src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-sectionToKeywordMapFromDoc.json") + val targetJsonFile = file("${semanticDataDir}/sectionToKeywordMapFromDoc.json") + + inputs.file(podmanJsonFile) + + dependsOn("generateDataFromManPages") + mustRunAfter("processResources", "generateOptionValidator", "generateUnitAutoCompleteData") + + doLast { + val slurper = groovy.json.JsonSlurper() + val sharedSections = listOf("Unit", "Install", "Service") + + // Merge documented keywords + @Suppress("UNCHECKED_CAST") + val mainData = slurper.parse(targetJsonFile) as MutableMap + @Suppress("UNCHECKED_CAST") + val podmanData = slurper.parse(podmanJsonFile) as MutableMap + + @Suppress("UNCHECKED_CAST") + val unitFileClassData = mainData["unit"] as? Map + @Suppress("UNCHECKED_CAST") + val podmanNetworkData = podmanData.getOrDefault("podman_network", mutableMapOf()) as MutableMap + if (unitFileClassData != null) { + for (section in sharedSections) { + val sectionData = unitFileClassData[section] + if (sectionData != null) { + podmanNetworkData[section] = sectionData + } + } + } + podmanData["podman_network"] = podmanNetworkData + mainData.putAll(podmanData) + + val output = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(mainData)) + targetJsonFile.writeText(output) + + // Merge undocumented keywords (deprecated/moved options) + val undocumentedJsonFile = file("${semanticDataDir}/undocumentedSectionToKeywordMap.json") + @Suppress("UNCHECKED_CAST") + val undocData = slurper.parse(undocumentedJsonFile) as MutableMap + @Suppress("UNCHECKED_CAST") + val unitUndocData = undocData["unit"] as? Map + if (unitUndocData != null) { + val podmanUndocData = mutableMapOf() + for (section in sharedSections) { + val sectionData = unitUndocData[section] + if (sectionData != null) { + podmanUndocData[section] = sectionData + } + } + undocData["podman_network"] = podmanUndocData + } + + val undocOutput = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(undocData)) + undocumentedJsonFile.writeText(undocOutput) + } +} + +tasks.register("generatePodmanNetworkGperf") { + description = "Generate podman-network-gperf.gperf by merging systemd unit sections with podman quadlet network keys" + group = "generation" + + val loadFragmentFile = file("./systemd-build/build/load-fragment-gperf.gperf") + val podmanNetworkFile = file("./src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-network.gperf") + val outputDir = file("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/") + val outputFile = file("${outputDir}/podman-network-gperf.gperf") + + inputs.file(loadFragmentFile) + inputs.file(podmanNetworkFile) + outputs.file(outputFile) + + dependsOn("generateOptionValidator") + + doLast { + val unitSections = setOf("Unit", "Install", "Service") + val unitLines = loadFragmentFile.readLines().filter { line -> + val trimmed = line.trim() + trimmed.isNotEmpty() && unitSections.any { section -> trimmed.startsWith("$section.") } + } + val podmanLines = podmanNetworkFile.readLines() + + outputFile.parentFile.mkdirs() + outputFile.writeText((unitLines + podmanLines).joinToString("\n") + "\n") + } +} + tasks { runIde { @@ -231,6 +322,7 @@ tasks { tasks { classes { dependsOn("generateOptionValidator") + dependsOn("generatePodmanNetworkGperf") } } @@ -268,7 +360,9 @@ if (!(project.file("./systemd-build/build/ubuntu-units.txt").exists())) { tasks { jar { dependsOn("generateDataFromManPages") + dependsOn("mergePodmanDocumentation") dependsOn("generateOptionValidator") + dependsOn("generatePodmanNetworkGperf") dependsOn("generateUnitAutoCompleteData") } @@ -279,17 +373,21 @@ tasks { instrumentedJar { dependsOn("generateDataFromManPages") + dependsOn("mergePodmanDocumentation") + dependsOn("generatePodmanNetworkGperf") dependsOn("generateUnitAutoCompleteData") } compileTestKotlin { dependsOn("generateUnitAutoCompleteData") dependsOn("generateDataFromManPages") + dependsOn("mergePodmanDocumentation") } compileTestJava { dependsOn("generateUnitAutoCompleteData") dependsOn("generateDataFromManPages") + dependsOn("mergePodmanDocumentation") } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/InvalidSectionHeaderNameAnnotator.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/InvalidSectionHeaderNameAnnotator.kt index b9362c42..753ddaa5 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/InvalidSectionHeaderNameAnnotator.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/InvalidSectionHeaderNameAnnotator.kt @@ -4,8 +4,10 @@ import com.intellij.lang.annotation.AnnotationHolder import com.intellij.lang.annotation.Annotator import com.intellij.lang.annotation.HighlightSeverity import com.intellij.psi.PsiElement +import net.sjrx.intellij.plugins.systemdunitfiles.intentions.EnablePodmanQuadletSupportQuickFix import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionType import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository +import net.sjrx.intellij.plugins.systemdunitfiles.settings.shouldSuggestPodmanSupport import java.util.regex.Pattern class InvalidSectionHeaderNameAnnotator : Annotator { @@ -29,7 +31,7 @@ class InvalidSectionHeaderNameAnnotator : Annotator { if (validSection) { val sectionName = text.substring(1, text.length - 1) - val allowedSections = SemanticDataRepository.instance.getAllowedSectionsInFile(element.containingFile.name) + val allowedSections = SemanticDataRepository.instance.getAllowedSectionsInFile(element.containingFile) val unitType = SemanticDataRepository.instance.getUnitType(element.containingFile.name) @@ -38,7 +40,13 @@ class InvalidSectionHeaderNameAnnotator : Annotator { // Also if we don't have any sections then we can ignore the warning (this is a hack, to prevent templates in the plugin from having errors). if ((sectionName !in allowedSections) && !sectionName.startsWith("X-") && !allowedSections.isEmpty()) { val errorString = SECTION_IN_WRONG_FILE.format(sectionName, unitType, allowedSections) - holder.newAnnotation(HighlightSeverity.ERROR, errorString).range(element.getFirstChild()).create() + val annotation = holder.newAnnotation(HighlightSeverity.ERROR, errorString).range(element.getFirstChild()) + + if (shouldSuggestPodmanSupport(element.containingFile)) { + annotation.withFix(EnablePodmanQuadletSupportQuickFix()) + } + + annotation.create() } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileSectionCompletionContributor.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileSectionCompletionContributor.kt index 045344d1..191ce5f5 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileSectionCompletionContributor.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileSectionCompletionContributor.kt @@ -22,7 +22,7 @@ class UnitFileSectionCompletionContributor() : CompletionContributor() { resultSet: CompletionResultSet) { parameters.position.containingFile.name.substringAfterLast(".", "") - val completeSections = SemanticDataRepository.instance.getAllowedSectionsInFile(parameters.position.containingFile.name) + val completeSections = SemanticDataRepository.instance.getAllowedSectionsInFile(parameters.position.containingFile) resultSet.addAllElements( completeSections.map { diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/documentation/UnitFileDocumentationProvider.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/documentation/UnitFileDocumentationProvider.kt index abb260b9..e1f4b0ef 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/documentation/UnitFileDocumentationProvider.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/documentation/UnitFileDocumentationProvider.kt @@ -37,7 +37,8 @@ class UnitFileDocumentationProvider : AbstractDocumentationProvider() { val section = PsiTreeUtil.getParentOfType(element, UnitFileSectionType::class.java) ?: return null val sectionName = section.sectionName val sdr: SemanticDataRepository = SemanticDataRepository.instance - val sectionComment = sdr.getDocumentationContentForSection(sectionName) + val fileClass = section.containingFile.fileClass() + val sectionComment = sdr.getDocumentationContentForSection(fileClass, sectionName) if (sectionComment != null) { return DocumentationMarkup.DEFINITION_START + sectionName + DocumentationMarkup.DEFINITION_END + DocumentationMarkup.CONTENT_START + sectionComment + DocumentationMarkup.CONTENT_END } @@ -77,7 +78,8 @@ class UnitFileDocumentationProvider : AbstractDocumentationProvider() { val section = PsiTreeUtil.getParentOfType(element, UnitFileSectionType::class.java) ?: return null val sectionName = section.sectionName val sdr: SemanticDataRepository = SemanticDataRepository.instance - val sectionUrl = sdr.getUrlForSectionName(sectionName) + val fileClass = section.containingFile.fileClass() + val sectionUrl = sdr.getUrlForSectionName(fileClass, sectionName) if (sectionUrl != null) { return listOf(sectionUrl) } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/UnknownKeyInSectionInspection.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/UnknownKeyInSectionInspection.kt index f55bc9e0..9e0ce8af 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/UnknownKeyInSectionInspection.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/UnknownKeyInSectionInspection.kt @@ -1,17 +1,20 @@ package net.sjrx.intellij.plugins.systemdunitfiles.inspections import com.intellij.codeInspection.LocalInspectionTool +import com.intellij.codeInspection.LocalQuickFix import com.intellij.codeInspection.ProblemHighlightType import com.intellij.codeInspection.ProblemsHolder 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.EnablePodmanQuadletSupportQuickFix 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.settings.shouldSuggestPodmanSupport /** * The purpose of this inspection is to catch any warnings that may be generated by systemd when processing a unit file. @@ -48,8 +51,13 @@ class UnknownKeyInSectionInspection : LocalInspectionTool() { val fileClass = section.containingFile.fileClass() if (!SemanticDataRepository.instance.getAllowedKeywordsInSectionFromValidators(fileClass, sectionName).contains(key)) { - // TODO Figure out what highlight to use - holder.registerProblem(property.keyNode.psi, INSPECTION_TOOL_TIP_TEXT, ProblemHighlightType.GENERIC_ERROR_OR_WARNING) + val fixes = mutableListOf() + + if (shouldSuggestPodmanSupport(section.containingFile)) { + fixes.add(EnablePodmanQuadletSupportQuickFix()) + } + + holder.registerProblem(property.keyNode.psi, INSPECTION_TOOL_TIP_TEXT, ProblemHighlightType.GENERIC_ERROR_OR_WARNING, *fixes.toTypedArray()) } } } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/EnablePodmanQuadletSupportQuickFix.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/EnablePodmanQuadletSupportQuickFix.kt new file mode 100644 index 00000000..3ca11c83 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/intentions/EnablePodmanQuadletSupportQuickFix.kt @@ -0,0 +1,36 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.intentions + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.codeInsight.intention.IntentionAction +import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiFile +import com.intellij.ui.EditorNotifications +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings + +class EnablePodmanQuadletSupportQuickFix : LocalQuickFix, IntentionAction { + + override fun getFamilyName(): String = "Enable Podman Quadlet support (experimental)" + + override fun getText(): String = familyName + + override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = true + + override fun startInWriteAction(): Boolean = false + + override fun invoke(project: Project, editor: Editor?, file: PsiFile?) { + enablePodmanSupport(project) + } + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + enablePodmanSupport(project) + } + + private fun enablePodmanSupport(project: Project) { + PodmanQuadletSettings.getInstance(project).state.enabled = true + EditorNotifications.getInstance(project).updateAllNotifications() + DaemonCodeAnalyzer.getInstance(project).restart() + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/SemanticDataRepository.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/SemanticDataRepository.kt index 156d10c6..a295a7ca 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/SemanticDataRepository.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/SemanticDataRepository.kt @@ -11,8 +11,15 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.util.NotNullLazyValue import com.intellij.openapi.util.text.StringUtil import com.intellij.psi.PsiFile +import com.intellij.psi.util.CachedValueProvider +import com.intellij.psi.util.CachedValuesManager +import com.intellij.psi.util.PsiModificationTracker +import com.intellij.psi.util.PsiTreeUtil import com.intellij.util.ObjectUtils +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType +import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.* +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings import org.apache.commons.io.IOUtils import java.io.BufferedReader import java.io.File @@ -27,14 +34,59 @@ enum class FileClass(val fileClass: String, val gperfFile: String) { NSPAWN("nspawn", "nspawn-gperf.gperf"), NETDEV("netdev", "netdev-gperf.gperf"), NETWORK("network", "networkd-network-gperf.gperf"), - LINK("link", "link-config-gperf.gperf") + LINK("link", "link-config-gperf.gperf"), + PODMAN_NETWORK("podman_network", "podman-network-gperf.gperf") } fun PsiFile.fileClass(): FileClass { + if (name.endsWith(".network")) { + val settings = PodmanQuadletSettings.getInstance(project) + if (settings.state.enabled) { + // Cache the detection result so we don't re-walk the PSI tree on every call + // during a single highlighting pass. Invalidated when the PSI tree changes. + return CachedValuesManager.getCachedValue(originalFile) { + CachedValueProvider.Result.create( + detectNetworkFileClass(originalFile), + PsiModificationTracker.getInstance(project).forLanguage(language) + ) + } + } + } return SemanticDataRepository.getFileClassForFilename(this.name) } +private val PODMAN_ONLY_KEYS = setOf( + "Subnet", "NetworkName", "Gateway", "DisableDNS", "IPAMDriver", + "IPRange", "Internal", "NetworkDeleteOnStop", "Driver", "Options", + "ContainersConfModule", "GlobalArgs", "PodmanArgs", "ServiceName", "Label" +) + +private val SYSTEMD_NETWORKD_ONLY_SECTIONS = setOf( + "Match", "Route", "Address", "DHCP", "DHCPv4", "DHCPv6", "DHCPServer", + "Bridge", "BridgeFDB", "BridgeMDB", "BridgeVLAN", "IPv6AcceptRA", + "IPv6SendRA", "Neighbor", "NextHop", "RoutingPolicyRule", "QDisc" +) + +fun detectNetworkFileClass(file: PsiFile): FileClass { + val sections = PsiTreeUtil.findChildrenOfType(file, UnitFileSectionGroups::class.java) + for (section in sections) { + val sectionName = section.sectionName + if (sectionName in SYSTEMD_NETWORKD_ONLY_SECTIONS) { + return FileClass.NETWORK + } + if (sectionName == "Network") { + val properties = PsiTreeUtil.findChildrenOfType(section, UnitFilePropertyType::class.java) + for (prop in properties) { + if (prop.key in PODMAN_ONLY_KEYS) { + return FileClass.PODMAN_NETWORK + } + } + } + } + return FileClass.NETWORK +} + class SemanticDataRepository private constructor() { private val validatorMap: Map @@ -289,6 +341,19 @@ class SemanticDataRepository private constructor() { * @param sectionName - the name of the section name * @return best URL for the section name or null if the section is unknown */ + fun getUrlForSectionName(fileClass: FileClass, sectionName: String?): String? { + if (fileClass == FileClass.PODMAN_NETWORK) { + return when (sectionName) { + "Network" -> "https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html#network-units-network" + "Unit" -> "https://www.freedesktop.org/software/systemd/man/systemd.unit.html#%5BUnit%5D%20Section%20Options" + "Install" -> "https://www.freedesktop.org/software/systemd/man/systemd.unit.html#%5BInstall%5D%20Section%20Options" + "Service" -> "https://www.freedesktop.org/software/systemd/man/systemd.service.html" + else -> null + } + } + return getUrlForSectionName(sectionName) + } + fun getUrlForSectionName(sectionName: String?): String? { return when (sectionName) { "Mount" -> "https://www.freedesktop.org/software/systemd/man/systemd.mount.html" @@ -407,6 +472,19 @@ class SemanticDataRepository private constructor() { * @param sectionName the name of the section. * @return string */ + fun getDocumentationContentForSection(fileClass: FileClass, sectionName: String?): String? { + if (fileClass == FileClass.PODMAN_NETWORK) { + return when (sectionName) { + "Network" -> """The [Network] section defines a Podman network. Network units are used to create named Podman networks as one-time systemd services that ensure the network exists on the host, creating it if needed.""" + "Unit" -> getDocumentationContentForSection("Unit") + "Install" -> getDocumentationContentForSection("Install") + "Service" -> getDocumentationContentForSection("Service") + else -> null + } + } + return getDocumentationContentForSection(sectionName) + } + fun getDocumentationContentForSection(sectionName: String?): String? { return when (sectionName) { "Mount" -> """ Mount files must include a [Mount] section, which carries information @@ -685,6 +763,14 @@ unit types. These options are documented in { val completeSections = when (getUnitType(fileName)) { diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanNetworkEditorNotificationProvider.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanNetworkEditorNotificationProvider.kt new file mode 100644 index 00000000..88c56fc7 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanNetworkEditorNotificationProvider.kt @@ -0,0 +1,43 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.settings + +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiManager +import com.intellij.ui.EditorNotificationPanel +import com.intellij.ui.EditorNotificationProvider +import com.intellij.ui.EditorNotifications +import java.util.function.Function +import javax.swing.JComponent + +class PodmanNetworkEditorNotificationProvider : EditorNotificationProvider { + + override fun collectNotificationData( + project: Project, + file: VirtualFile + ): Function? { + if (!file.name.endsWith(".network")) return null + + val settings = PodmanQuadletSettings.getInstance(project) + if (settings.state.notificationDismissed) return null + + val psiFile = PsiManager.getInstance(project).findFile(file) ?: return null + if (!shouldSuggestPodmanSupport(psiFile)) return null + + return Function { fileEditor -> + val panel = EditorNotificationPanel(fileEditor, EditorNotificationPanel.Status.Info) + panel.text = "This file appears to be a Podman Quadlet network file." + panel.createActionLabel("Enable Podman Quadlet support (experimental)") { + settings.state.enabled = true + EditorNotifications.getInstance(project).updateAllNotifications() + DaemonCodeAnalyzer.getInstance(project).restart() + } + panel.createActionLabel("Dismiss") { + settings.state.notificationDismissed = true + EditorNotifications.getInstance(project).updateAllNotifications() + } + panel + } + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletConfigurable.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletConfigurable.kt new file mode 100644 index 00000000..cf30971f --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletConfigurable.kt @@ -0,0 +1,44 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.settings + +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.project.Project +import com.intellij.ui.components.JBCheckBox +import com.intellij.util.ui.FormBuilder +import javax.swing.JComponent +import javax.swing.JPanel + +class PodmanQuadletConfigurable(private val project: Project) : Configurable { + + private var enabledCheckbox: JBCheckBox? = null + + override fun getDisplayName(): String = "systemd Unit Files" + + override fun createComponent(): JComponent { + val settings = PodmanQuadletSettings.getInstance(project) + enabledCheckbox = JBCheckBox("Enable Podman Quadlet support (experimental)", settings.state.enabled) + + return FormBuilder.createFormBuilder() + .addComponent(enabledCheckbox!!) + .addComponentFillVertically(JPanel(), 0) + .panel + } + + override fun isModified(): Boolean { + val settings = PodmanQuadletSettings.getInstance(project) + return enabledCheckbox?.isSelected != settings.state.enabled + } + + override fun apply() { + val settings = PodmanQuadletSettings.getInstance(project) + val newEnabled = enabledCheckbox?.isSelected ?: false + if (settings.state.enabled != newEnabled) { + settings.state.notificationDismissed = false + } + settings.state.enabled = newEnabled + } + + override fun reset() { + val settings = PodmanQuadletSettings.getInstance(project) + enabledCheckbox?.isSelected = settings.state.enabled + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletSettings.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletSettings.kt new file mode 100644 index 00000000..69017338 --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletSettings.kt @@ -0,0 +1,32 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.settings + +import com.intellij.openapi.components.PersistentStateComponent +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import com.intellij.openapi.project.Project + +@Service(Service.Level.PROJECT) +@State(name = "PodmanQuadletSettings", storages = [Storage("podmanQuadlet.xml")]) +class PodmanQuadletSettings : PersistentStateComponent { + + private var myState = State() + + class State { + var enabled: Boolean = false + var notificationDismissed: Boolean = false + } + + override fun getState(): State = myState + + override fun loadState(state: State) { + myState = state + } + + companion object { + @JvmStatic + fun getInstance(project: Project): PodmanQuadletSettings { + return project.getService(PodmanQuadletSettings::class.java) + } + } +} diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletUtil.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletUtil.kt new file mode 100644 index 00000000..9809bfec --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletUtil.kt @@ -0,0 +1,16 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.settings + +import com.intellij.psi.PsiFile +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.FileClass +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.detectNetworkFileClass + +/** + * Checks whether a .network file looks like a Podman Quadlet file but the feature is currently disabled. + * Used to conditionally offer quick fixes and notifications. + */ +fun shouldSuggestPodmanSupport(file: PsiFile): Boolean { + if (!file.name.endsWith(".network")) return false + val settings = PodmanQuadletSettings.getInstance(file.project) + if (settings.state.enabled) return false + return detectNetworkFileClass(file.originalFile) == FileClass.PODMAN_NETWORK +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 063172d3..60764a09 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -87,6 +87,13 @@ + + + + diff --git a/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-network.gperf b/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-network.gperf new file mode 100644 index 00000000..89ef93db --- /dev/null +++ b/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-network.gperf @@ -0,0 +1,18 @@ +Network.ContainersConfModule, config_parse_unit_string_printf, 0, 0 +Network.DisableDNS, config_parse_bool, 0, 0 +Network.DNS, config_parse_unit_string_printf, 0, 0 +Network.Driver, config_parse_unit_string_printf, 0, 0 +Network.Gateway, config_parse_unit_string_printf, 0, 0 +Network.GlobalArgs, config_parse_unit_string_printf, 0, 0 +Network.InterfaceName, config_parse_unit_string_printf, 0, 0 +Network.Internal, config_parse_bool, 0, 0 +Network.IPAMDriver, config_parse_unit_string_printf, 0, 0 +Network.IPRange, config_parse_unit_string_printf, 0, 0 +Network.IPv6, config_parse_bool, 0, 0 +Network.Label, config_parse_unit_string_printf, 0, 0 +Network.NetworkDeleteOnStop, config_parse_bool, 0, 0 +Network.NetworkName, config_parse_unit_string_printf, 0, 0 +Network.Options, config_parse_unit_string_printf, 0, 0 +Network.PodmanArgs, config_parse_unit_string_printf, 0, 0 +Network.ServiceName, config_parse_unit_string_printf, 0, 0 +Network.Subnet, config_parse_unit_string_printf, 0, 0 diff --git a/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-sectionToKeywordMapFromDoc.json b/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-sectionToKeywordMapFromDoc.json new file mode 100644 index 00000000..3dfef2d9 --- /dev/null +++ b/src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-sectionToKeywordMapFromDoc.json @@ -0,0 +1,60 @@ +{ + "podman_network": { + "Network": { + "ContainersConfModule": { + "description": "Load the specified containers.conf(5) module." + }, + "DisableDNS": { + "description": "If true, disables the DNS plugin for this network. Defaults to false." + }, + "DNS": { + "description": "Set network-scoped DNS resolver/nameserver for containers in this network." + }, + "Driver": { + "description": "Driver to manage the network. Currently bridge, macvlan and ipvlan are supported. Defaults to bridge." + }, + "Gateway": { + "description": "Define a gateway for the subnet. If not set, the first IP address of the subnet will be used." + }, + "GlobalArgs": { + "description": "Additional arguments to pass between podman and the network subcommand." + }, + "InterfaceName": { + "description": "The network interface name on the host. For bridge, this is the bridge interface; for macvlan/ipvlan, the parent device." + }, + "Internal": { + "description": "If true, restrict external access to this network. Defaults to false." + }, + "IPAMDriver": { + "description": "Set the IPAM driver (IP Address Management driver) for the network. Currently host-local, dhcp and none are supported." + }, + "IPRange": { + "description": "Allocate container IP from a range. The range must be a complete subnet in CIDR notation." + }, + "IPv6": { + "description": "Enable IPv6 (Dual Stack) networking. Defaults to false." + }, + "Label": { + "description": "Set one or more OCI labels on the network. Can be specified multiple times." + }, + "NetworkDeleteOnStop": { + "description": "If true, the network is deleted when the systemd service stops. Defaults to false." + }, + "NetworkName": { + "description": "The name of the Podman network. If not specified, the default name is systemd-." + }, + "Options": { + "description": "Set driver-specific options. Can be specified multiple times." + }, + "PodmanArgs": { + "description": "Additional arguments to pass at the end of the podman network create command." + }, + "ServiceName": { + "description": "The name of the systemd service unit to generate. If not specified, uses the default name." + }, + "Subnet": { + "description": "The subnet in CIDR notation. Can be specified multiple times for multiple subnets." + } + } + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/NetworkFileTypeDetectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/NetworkFileTypeDetectionTest.kt new file mode 100644 index 00000000..fbeaaffa --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/NetworkFileTypeDetectionTest.kt @@ -0,0 +1,100 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.podman + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.FileClass +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings + +class NetworkFileTypeDetectionTest : AbstractUnitFileTest() { + + override fun setUp() { + super.setUp() + PodmanQuadletSettings.getInstance(project).state.enabled = true + } + + override fun tearDown() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + super.tearDown() + } + + fun testDetectsPodmanNetworkBySubnetKey() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + Gateway=192.168.1.1 + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + assertEquals(FileClass.PODMAN_NETWORK, psiFile.fileClass()) + } + + fun testDetectsPodmanNetworkByNetworkNameKey() { + // language="unit file (systemd)" + val file = """ + [Network] + NetworkName=mynet + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + assertEquals(FileClass.PODMAN_NETWORK, psiFile.fileClass()) + } + + fun testDetectsSystemdNetworkdByMatchSection() { + // language="unit file (systemd)" + val file = """ + [Match] + Name=eth0 + [Network] + DHCP=yes + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + assertEquals(FileClass.NETWORK, psiFile.fileClass()) + } + + fun testDetectsSystemdNetworkdByRouteSection() { + // language="unit file (systemd)" + val file = """ + [Network] + DHCP=yes + [Route] + Gateway=10.0.0.1 + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + assertEquals(FileClass.NETWORK, psiFile.fileClass()) + } + + fun testDefaultsToSystemdNetworkdForAmbiguousFile() { + // language="unit file (systemd)" + val file = """ + [Network] + DNS=8.8.8.8 + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + // DNS exists in both, so default to systemd-networkd + assertEquals(FileClass.NETWORK, psiFile.fileClass()) + } + + fun testDisabledSettingReturnsSystemdNetworkd() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + """.trimIndent() + + val psiFile = setupFileInEditor("file.network", file) + + // Even with podman content, disabled means systemd-networkd + assertEquals(FileClass.NETWORK, psiFile.fileClass()) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkCompletionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkCompletionTest.kt new file mode 100644 index 00000000..d252041e --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkCompletionTest.kt @@ -0,0 +1,92 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.podman + +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings + +class PodmanNetworkCompletionTest : AbstractUnitFileTest() { + + override fun setUp() { + super.setUp() + PodmanQuadletSettings.getInstance(project).state.enabled = true + } + + override fun tearDown() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + super.tearDown() + } + + fun testCompletionInNetworkSectionIncludesPodmanKeys() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=10.0.0.0/8 + $COMPLETION_POSITION + """.trimIndent() + setupFileInEditor("file.network", file) + + val completions = basicCompletionResultStrings + + assertContainsElements(completions, "Gateway", "Driver", "DisableDNS", "NetworkName") + } + + fun testCompletionInNetworkSectionIncludesBooleanKeys() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=10.0.0.0/8 + D$COMPLETION_POSITION + """.trimIndent() + setupFileInEditor("file.network", file) + + val completions = basicCompletionResultStrings + + assertContainsElements(completions, "DisableDNS", "DNS", "Driver") + } + + fun testCompletionInUnitSectionWorksForPodmanNetwork() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=10.0.0.0/8 + [Unit] + D$COMPLETION_POSITION + """.trimIndent() + setupFileInEditor("file.network", file) + + val completions = basicCompletionResultStrings + + assertContainsElements(completions, "Description", "Documentation", "DefaultDependencies") + } + + fun testSectionCompletionIncludesPodmanSections() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + [$COMPLETION_POSITION + """.trimIndent() + setupFileInEditor("file.network", file) + + val completions = basicCompletionResultStrings + + assertContainsElements(completions, "Network", "Unit", "Install", "Service") + // Should not include systemd-networkd-only sections + assertDoesntContain(completions, "Match", "Route", "Address", "DHCPv4") + } + + fun testCompletionWithoutFeatureEnabledUsesSystemdNetworkd() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + + // language="unit file (systemd)" + val file = """ + [Network] + $COMPLETION_POSITION + """.trimIndent() + setupFileInEditor("file.network", file) + + val completions = basicCompletionResultStrings + + // With podman disabled, this should use systemd-networkd which does not have Subnet + assertDoesntContain(completions, "Subnet") + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkInspectionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkInspectionTest.kt new file mode 100644 index 00000000..7b328b23 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanNetworkInspectionTest.kt @@ -0,0 +1,98 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.podman + +import com.intellij.codeInsight.daemon.impl.HighlightInfoType +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.InvalidValueInspection +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.UnknownKeyInSectionInspection +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings + +class PodmanNetworkInspectionTest : AbstractUnitFileTest() { + + override fun setUp() { + super.setUp() + PodmanQuadletSettings.getInstance(project).state.enabled = true + } + + override fun tearDown() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + super.tearDown() + } + + fun testPodmanNetworkKeysDoNotTriggerUnknownKeyInspection() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + Gateway=192.168.1.1 + Driver=bridge + DisableDNS=true + Internal=false + NetworkName=mynet + """.trimIndent() + + setupFileInEditor("file.network", file) + enableInspection(UnknownKeyInSectionInspection::class.java) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.WARNING || it.type == HighlightInfoType.ERROR } + + assertEmpty(highlights) + } + + fun testBooleanValidationWorksForPodmanKeys() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=10.0.0.0/8 + DisableDNS=notabool + """.trimIndent() + + setupFileInEditor("file.network", file) + enableInspection(InvalidValueInspection::class.java) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.WARNING } + + assertSize(1, highlights) + assertStringContains("notabool", highlights[0]!!.description) + } + + fun testValidBooleanValuePassesValidation() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=10.0.0.0/8 + DisableDNS=true + Internal=false + IPv6=yes + NetworkDeleteOnStop=no + """.trimIndent() + + setupFileInEditor("file.network", file) + enableInspection(InvalidValueInspection::class.java) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.WARNING } + + assertEmpty(highlights) + } + + fun testPodmanKeysAreUnknownWhenFeatureDisabled() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + """.trimIndent() + + setupFileInEditor("file.network", file) + enableInspection(UnknownKeyInSectionInspection::class.java) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.WARNING } + + // Subnet should be unknown in systemd-networkd's Network section + assertSize(1, highlights) + } +} diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanQuickFixTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanQuickFixTest.kt new file mode 100644 index 00000000..1c5df453 --- /dev/null +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/podman/PodmanQuickFixTest.kt @@ -0,0 +1,75 @@ +package net.sjrx.intellij.plugins.systemdunitfiles.podman + +import com.intellij.codeInsight.daemon.impl.HighlightInfoType +import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest +import net.sjrx.intellij.plugins.systemdunitfiles.inspections.UnknownKeyInSectionInspection +import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings + +class PodmanQuickFixTest : AbstractUnitFileTest() { + + override fun setUp() { + super.setUp() + PodmanQuadletSettings.getInstance(project).state.enabled = false + } + + override fun tearDown() { + PodmanQuadletSettings.getInstance(project).state.enabled = false + super.tearDown() + } + + fun testUnknownKeyInspectionOffersEnablePodmanQuickFix() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + """.trimIndent() + + setupFileInEditor("file.network", file) + enableInspection(UnknownKeyInSectionInspection::class.java) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.WARNING } + + assertSize(1, highlights) + assertContainsQuickfix(highlights[0], "Enable Podman Quadlet support (experimental)") + } + + fun testInvalidSectionAnnotationOffersEnablePodmanQuickFix() { + // language="unit file (systemd)" + val file = """ + [Network] + Subnet=192.168.1.0/24 + [Install] + WantedBy=default.target + """.trimIndent() + + setupFileInEditor("file.network", file) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.ERROR && it.description?.contains("not allowed") == true } + + assertSize(1, highlights) + assertContainsQuickfix(highlights[0], "Enable Podman Quadlet support (experimental)") + } + + fun testNoQuickFixWhenFileDoesNotLookLikePodman() { + // language="unit file (systemd)" + val file = """ + [Network] + DHCP=yes + [Install] + WantedBy=default.target + """.trimIndent() + + setupFileInEditor("file.network", file) + + val highlights = myFixture.doHighlighting() + .filter { it.type == HighlightInfoType.ERROR && it.description?.contains("not allowed") == true } + + // Install is not allowed in systemd-networkd .network files, but since the file + // doesn't look like podman (no podman-only keys), there should be no podman quick fix + assertSize(1, highlights) + val quickFixes = highlights[0].quickFixActionRanges + assertTrue("Expected no quick fixes for non-podman file", quickFixes == null || quickFixes.isEmpty()) + } +}