Skip to content

Commit 0c0ec44

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 0c0ec44

File tree

20 files changed

+882
-10
lines changed

20 files changed

+882
-10
lines changed

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: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ dependencies {
8282
create(type, version)
8383

8484
pluginVerifier()
85-
instrumentationTools()
8685
testFramework(TestFrameworkType.Platform)
8786
}
8887
}
@@ -220,6 +219,97 @@ tasks.register<Copy>("generateOptionValidator") {
220219
into("${sourceSets["main"].output.resourcesDir?.getAbsolutePath()}/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/")
221220
}
222221

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

224314
tasks {
225315
runIde {
@@ -232,6 +322,7 @@ tasks {
232322
tasks {
233323
classes {
234324
dependsOn("generateOptionValidator")
325+
dependsOn("generatePodmanNetworkGperf")
235326
}
236327
}
237328

@@ -269,7 +360,9 @@ if (!(project.file("./systemd-build/build/ubuntu-units.txt").exists())) {
269360
tasks {
270361
jar {
271362
dependsOn("generateDataFromManPages")
363+
dependsOn("mergePodmanDocumentation")
272364
dependsOn("generateOptionValidator")
365+
dependsOn("generatePodmanNetworkGperf")
273366
dependsOn("generateUnitAutoCompleteData")
274367
}
275368

@@ -280,17 +373,21 @@ tasks {
280373

281374
instrumentedJar {
282375
dependsOn("generateDataFromManPages")
376+
dependsOn("mergePodmanDocumentation")
377+
dependsOn("generatePodmanNetworkGperf")
283378
dependsOn("generateUnitAutoCompleteData")
284379
}
285380

286381
compileTestKotlin {
287382
dependsOn("generateUnitAutoCompleteData")
288383
dependsOn("generateDataFromManPages")
384+
dependsOn("mergePodmanDocumentation")
289385
}
290386

291387
compileTestJava {
292388
dependsOn("generateUnitAutoCompleteData")
293389
dependsOn("generateDataFromManPages")
390+
dependsOn("mergePodmanDocumentation")
294391
}
295392
}
296393

gradle/wrapper/gradle-wrapper.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
distributionBase=GRADLE_USER_HOME
22
distributionPath=wrapper/dists
3-
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
3+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
44
networkTimeout=10000
55
validateDistributionUrl=true
66
zipStoreBase=GRADLE_USER_HOME

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)