Skip to content

Commit 5f0e7a8

Browse files
Steve Ramageclaude
andcommitted
feat: add experimental support for Podman Quadlet network files to fix errors (Resolves #421)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b548703 commit 5f0e7a8

19 files changed

Lines changed: 849 additions & 8 deletions

File tree

CLAUDE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
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.
8+
9+
## Build Commands
10+
11+
```bash
12+
./gradlew buildPlugin # Build plugin distribution zip
13+
./gradlew test # Run all tests
14+
./gradlew test --tests "net.sjrx.intellij.plugins.systemdunitfiles.lexer.UnitFileLexerTest" # Single test class
15+
./gradlew runIde # Launch dev IDE with plugin loaded
16+
./gradlew checkstyleMain # Run checkstyle (excludes generated code)
17+
./gradlew generateLexerTask # Regenerate lexer from .flex grammar
18+
./gradlew generateParserTask # Regenerate parser from .bnf grammar
19+
./gradlew generateDataFromManPages # Parse systemd XML man pages → JSON semantic data
20+
```
21+
22+
The metadata generation pipeline uses Docker: `docker compose --project-directory ./systemd-build up --build`
23+
24+
## Architecture
25+
26+
**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/`.
27+
28+
**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/`.
29+
30+
**Plugin Feature Modules** (all under `src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/`):
31+
- `semanticdata/` — Core data models, validators, option value parsers, documentation repository. This is the foundational layer other features depend on.
32+
- `completion/` — Auto-completion contributors for keys, values, and sections
33+
- `inspections/` — Code inspections (InvalidValue, UnknownKey, Deprecated, MissingRequiredKey, ShellSyntax, IPAddress)
34+
- `annotators/` — Real-time inline annotations (invalid sections, deprecated options, whitespace)
35+
- `documentation/` — Inline documentation provider
36+
- `filetypes/` — File type definitions for each supported extension
37+
- `lexer/` — Lexical analysis adapter
38+
39+
**Java source** (`src/main/java/`) contains syntax highlighting (`coloring/`), parser definition (`parser/`), and generated PSI elements (`psi/`).
40+
41+
## Testing
42+
43+
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.
44+
45+
## Conventions
46+
47+
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) format
48+
- Java 21 for compilation, targets IntelliJ 2024.2 (platform version 242)
49+
- Plugin version range: 242.0–270.0
50+
- Generated code in `src/main/gen/` — do not edit manually; regenerate with grammar-kit tasks

build.gradle.kts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,76 @@ tasks.register<Copy>("generateOptionValidator") {
220220
into("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/")
221221
}
222222

223+
tasks.register("mergePodmanDocumentation") {
224+
description = "Merge podman quadlet documentation JSON into the generated sectionToKeywordMapFromDoc.json"
225+
group = "generation"
226+
227+
val semanticDataDir = file("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata")
228+
val podmanJsonFile = file("./src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-sectionToKeywordMapFromDoc.json")
229+
val targetJsonFile = file("${semanticDataDir}/sectionToKeywordMapFromDoc.json")
230+
231+
inputs.file(podmanJsonFile)
232+
233+
dependsOn("generateDataFromManPages")
234+
mustRunAfter("processResources", "generateOptionValidator", "generateUnitAutoCompleteData")
235+
236+
doLast {
237+
val slurper = groovy.json.JsonSlurper()
238+
@Suppress("UNCHECKED_CAST")
239+
val mainData = slurper.parse(targetJsonFile) as MutableMap<String, Any>
240+
@Suppress("UNCHECKED_CAST")
241+
val podmanData = slurper.parse(podmanJsonFile) as MutableMap<String, Any>
242+
243+
// Copy Unit, Install, and Service sections from the "unit" file class into podman_network
244+
@Suppress("UNCHECKED_CAST")
245+
val unitFileClassData = mainData["unit"] as? Map<String, Any>
246+
@Suppress("UNCHECKED_CAST")
247+
val podmanNetworkData = podmanData.getOrDefault("podman_network", mutableMapOf<String, Any>()) as MutableMap<String, Any>
248+
if (unitFileClassData != null) {
249+
for (section in listOf("Unit", "Install", "Service")) {
250+
val sectionData = unitFileClassData[section]
251+
if (sectionData != null) {
252+
podmanNetworkData[section] = sectionData
253+
}
254+
}
255+
}
256+
podmanData["podman_network"] = podmanNetworkData
257+
258+
mainData.putAll(podmanData)
259+
260+
val output = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(mainData))
261+
targetJsonFile.writeText(output)
262+
}
263+
}
264+
265+
tasks.register("generatePodmanNetworkGperf") {
266+
description = "Generate podman-network-gperf.gperf by merging systemd unit sections with podman quadlet network keys"
267+
group = "generation"
268+
269+
val loadFragmentFile = file("./systemd-build/build/load-fragment-gperf.gperf")
270+
val podmanNetworkFile = file("./src/main/resources/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/podman/podman-network.gperf")
271+
val outputDir = file("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/")
272+
val outputFile = file("${outputDir}/podman-network-gperf.gperf")
273+
274+
inputs.file(loadFragmentFile)
275+
inputs.file(podmanNetworkFile)
276+
outputs.file(outputFile)
277+
278+
dependsOn("generateOptionValidator")
279+
280+
doLast {
281+
val unitSections = setOf("Unit", "Install", "Service")
282+
val unitLines = loadFragmentFile.readLines().filter { line ->
283+
val trimmed = line.trim()
284+
trimmed.isNotEmpty() && unitSections.any { section -> trimmed.startsWith("$section.") }
285+
}
286+
val podmanLines = podmanNetworkFile.readLines()
287+
288+
outputFile.parentFile.mkdirs()
289+
outputFile.writeText((unitLines + podmanLines).joinToString("\n") + "\n")
290+
}
291+
}
292+
223293

224294
tasks {
225295
runIde {
@@ -232,6 +302,7 @@ tasks {
232302
tasks {
233303
classes {
234304
dependsOn("generateOptionValidator")
305+
dependsOn("generatePodmanNetworkGperf")
235306
}
236307
}
237308

@@ -269,7 +340,9 @@ if (!(project.file("./systemd-build/build/ubuntu-units.txt").exists())) {
269340
tasks {
270341
jar {
271342
dependsOn("generateDataFromManPages")
343+
dependsOn("mergePodmanDocumentation")
272344
dependsOn("generateOptionValidator")
345+
dependsOn("generatePodmanNetworkGperf")
273346
dependsOn("generateUnitAutoCompleteData")
274347
}
275348

@@ -280,17 +353,21 @@ tasks {
280353

281354
instrumentedJar {
282355
dependsOn("generateDataFromManPages")
356+
dependsOn("mergePodmanDocumentation")
357+
dependsOn("generatePodmanNetworkGperf")
283358
dependsOn("generateUnitAutoCompleteData")
284359
}
285360

286361
compileTestKotlin {
287362
dependsOn("generateUnitAutoCompleteData")
288363
dependsOn("generateDataFromManPages")
364+
dependsOn("mergePodmanDocumentation")
289365
}
290366

291367
compileTestJava {
292368
dependsOn("generateUnitAutoCompleteData")
293369
dependsOn("generateDataFromManPages")
370+
dependsOn("mergePodmanDocumentation")
294371
}
295372
}
296373

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/annotators/InvalidSectionHeaderNameAnnotator.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import com.intellij.lang.annotation.AnnotationHolder
44
import com.intellij.lang.annotation.Annotator
55
import com.intellij.lang.annotation.HighlightSeverity
66
import com.intellij.psi.PsiElement
7+
import net.sjrx.intellij.plugins.systemdunitfiles.intentions.EnablePodmanQuadletSupportQuickFix
78
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionType
89
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository
10+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.shouldSuggestPodmanSupport
911
import java.util.regex.Pattern
1012

1113
class InvalidSectionHeaderNameAnnotator : Annotator {
@@ -29,7 +31,7 @@ class InvalidSectionHeaderNameAnnotator : Annotator {
2931

3032
if (validSection) {
3133
val sectionName = text.substring(1, text.length - 1)
32-
val allowedSections = SemanticDataRepository.instance.getAllowedSectionsInFile(element.containingFile.name)
34+
val allowedSections = SemanticDataRepository.instance.getAllowedSectionsInFile(element.containingFile)
3335

3436
val unitType = SemanticDataRepository.instance.getUnitType(element.containingFile.name)
3537

@@ -38,7 +40,13 @@ class InvalidSectionHeaderNameAnnotator : Annotator {
3840
// 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).
3941
if ((sectionName !in allowedSections) && !sectionName.startsWith("X-") && !allowedSections.isEmpty()) {
4042
val errorString = SECTION_IN_WRONG_FILE.format(sectionName, unitType, allowedSections)
41-
holder.newAnnotation(HighlightSeverity.ERROR, errorString).range(element.getFirstChild()).create()
43+
val annotation = holder.newAnnotation(HighlightSeverity.ERROR, errorString).range(element.getFirstChild())
44+
45+
if (shouldSuggestPodmanSupport(element.containingFile)) {
46+
annotation.withFix(EnablePodmanQuadletSupportQuickFix())
47+
}
48+
49+
annotation.create()
4250
}
4351
}
4452

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileSectionCompletionContributor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class UnitFileSectionCompletionContributor() : CompletionContributor() {
2222
resultSet: CompletionResultSet) {
2323
parameters.position.containingFile.name.substringAfterLast(".", "")
2424

25-
val completeSections = SemanticDataRepository.instance.getAllowedSectionsInFile(parameters.position.containingFile.name)
25+
val completeSections = SemanticDataRepository.instance.getAllowedSectionsInFile(parameters.position.containingFile)
2626

2727
resultSet.addAllElements(
2828
completeSections.map {

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/documentation/UnitFileDocumentationProvider.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ class UnitFileDocumentationProvider : AbstractDocumentationProvider() {
3737
val section = PsiTreeUtil.getParentOfType(element, UnitFileSectionType::class.java) ?: return null
3838
val sectionName = section.sectionName
3939
val sdr: SemanticDataRepository = SemanticDataRepository.instance
40-
val sectionComment = sdr.getDocumentationContentForSection(sectionName)
40+
val fileClass = section.containingFile.fileClass()
41+
val sectionComment = sdr.getDocumentationContentForSection(fileClass, sectionName)
4142
if (sectionComment != null) {
4243
return DocumentationMarkup.DEFINITION_START + sectionName + DocumentationMarkup.DEFINITION_END + DocumentationMarkup.CONTENT_START + sectionComment + DocumentationMarkup.CONTENT_END
4344
}
@@ -77,7 +78,8 @@ class UnitFileDocumentationProvider : AbstractDocumentationProvider() {
7778
val section = PsiTreeUtil.getParentOfType(element, UnitFileSectionType::class.java) ?: return null
7879
val sectionName = section.sectionName
7980
val sdr: SemanticDataRepository = SemanticDataRepository.instance
80-
val sectionUrl = sdr.getUrlForSectionName(sectionName)
81+
val fileClass = section.containingFile.fileClass()
82+
val sectionUrl = sdr.getUrlForSectionName(fileClass, sectionName)
8183
if (sectionUrl != null) {
8284
return listOf(sectionUrl)
8385
}

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/inspections/UnknownKeyInSectionInspection.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package net.sjrx.intellij.plugins.systemdunitfiles.inspections
22

33
import com.intellij.codeInspection.LocalInspectionTool
4+
import com.intellij.codeInspection.LocalQuickFix
45
import com.intellij.codeInspection.ProblemHighlightType
56
import com.intellij.codeInspection.ProblemsHolder
67
import com.intellij.psi.PsiElementVisitor
78
import com.intellij.psi.util.PsiTreeUtil
89
import net.sjrx.intellij.plugins.systemdunitfiles.UnitFileLanguage
10+
import net.sjrx.intellij.plugins.systemdunitfiles.intentions.EnablePodmanQuadletSupportQuickFix
911
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFile
1012
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType
1113
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups
1214
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileVisitor
1315
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository
1416
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass
17+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.shouldSuggestPodmanSupport
1518

1619
/**
1720
* 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() {
4851

4952
val fileClass = section.containingFile.fileClass()
5053
if (!SemanticDataRepository.instance.getAllowedKeywordsInSectionFromValidators(fileClass, sectionName).contains(key)) {
51-
// TODO Figure out what highlight to use
52-
holder.registerProblem(property.keyNode.psi, INSPECTION_TOOL_TIP_TEXT, ProblemHighlightType.GENERIC_ERROR_OR_WARNING)
54+
val fixes = mutableListOf<LocalQuickFix>()
55+
56+
if (shouldSuggestPodmanSupport(section.containingFile)) {
57+
fixes.add(EnablePodmanQuadletSupportQuickFix())
58+
}
59+
60+
holder.registerProblem(property.keyNode.psi, INSPECTION_TOOL_TIP_TEXT, ProblemHighlightType.GENERIC_ERROR_OR_WARNING, *fixes.toTypedArray())
5361
}
5462
}
5563
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.intentions
2+
3+
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
4+
import com.intellij.codeInsight.intention.IntentionAction
5+
import com.intellij.codeInspection.LocalQuickFix
6+
import com.intellij.codeInspection.ProblemDescriptor
7+
import com.intellij.openapi.editor.Editor
8+
import com.intellij.openapi.project.Project
9+
import com.intellij.psi.PsiFile
10+
import com.intellij.ui.EditorNotifications
11+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings
12+
13+
class EnablePodmanQuadletSupportQuickFix : LocalQuickFix, IntentionAction {
14+
15+
override fun getFamilyName(): String = "Enable Podman Quadlet support (experimental)"
16+
17+
override fun getText(): String = familyName
18+
19+
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = true
20+
21+
override fun startInWriteAction(): Boolean = false
22+
23+
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
24+
enablePodmanSupport(project)
25+
}
26+
27+
override fun applyFix(project: Project, descriptor: ProblemDescriptor) {
28+
enablePodmanSupport(project)
29+
}
30+
31+
private fun enablePodmanSupport(project: Project) {
32+
PodmanQuadletSettings.getInstance(project).state.enabled = true
33+
EditorNotifications.getInstance(project).updateAllNotifications()
34+
DaemonCodeAnalyzer.getInstance(project).restart()
35+
}
36+
}

0 commit comments

Comments
 (0)