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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
98 changes: 98 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,97 @@ tasks.register<Copy>("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<String, Any>
@Suppress("UNCHECKED_CAST")
val podmanData = slurper.parse(podmanJsonFile) as MutableMap<String, Any>

@Suppress("UNCHECKED_CAST")
val unitFileClassData = mainData["unit"] as? Map<String, Any>
@Suppress("UNCHECKED_CAST")
val podmanNetworkData = podmanData.getOrDefault("podman_network", mutableMapOf<String, Any>()) as MutableMap<String, Any>
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<String, Any>
@Suppress("UNCHECKED_CAST")
val unitUndocData = undocData["unit"] as? Map<String, Any>
if (unitUndocData != null) {
val podmanUndocData = mutableMapOf<String, Any>()
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 {
Expand All @@ -231,6 +322,7 @@ tasks {
tasks {
classes {
dependsOn("generateOptionValidator")
dependsOn("generatePodmanNetworkGperf")
}
}

Expand Down Expand Up @@ -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")
}

Expand All @@ -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")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)

Expand All @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<LocalQuickFix>()

if (shouldSuggestPodmanSupport(section.containingFile)) {
fixes.add(EnablePodmanQuadletSupportQuickFix())
}

holder.registerProblem(property.keyNode.psi, INSPECTION_TOOL_TIP_TEXT, ProblemHighlightType.GENERIC_ERROR_OR_WARNING, *fixes.toTypedArray())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading