Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import com.intellij.codeInspection.LocalQuickFix
import com.intellij.codeInspection.ProblemHighlightType
import com.intellij.codeInspection.ProblemsHolder
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.TextRange
import net.sjrx.intellij.plugins.systemdunitfiles.intentions.ReplaceInvalidLiteralChoiceQuickFix
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFilePropertyType
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.OptionValueInformation
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings

open class GrammarOptionValue(
override val validatorName: String,
Expand All @@ -34,6 +37,11 @@ open class GrammarOptionValue(
override fun generateProblemDescriptors(property: UnitFilePropertyType, holder: ProblemsHolder) {
val value = property.valueText ?: return

if (ExperimentalSettings.getInstance(property.project).state.useGrammarParseEngine) {
generateProblemDescriptorsViaParse(property, value, holder)
return
}

val syntaticMatch = combinator.SyntacticMatch(value, 0)

try {
Expand Down Expand Up @@ -114,6 +122,55 @@ open class GrammarOptionValue(

}

/**
* Experimental path (#467): validate via the list-of-successes engine and map the [ParseOutcome]
* onto the same problem descriptors the SyntacticMatch/SemanticMatch path produces. Gated behind
* [ExperimentalSettings.useGrammarParseEngine] so the original engine remains the default.
*/
private fun generateProblemDescriptorsViaParse(property: UnitFilePropertyType, value: String, holder: ProblemsHolder) {
val outcome = try {
combinator.validate(value) { ProgressManager.checkCanceled() }
} catch (e: ProcessCanceledException) {
throw e
} catch (e: RuntimeException) {
LOG.error("Error while processing ${property.key} with value $value", e)
holder.registerProblem(property.valueNode.psi, "Internal error, please report an bug to the systemd plugin. Include the Key and Value used.", ProblemHighlightType.ERROR)
return
}

when (outcome) {
is ParseOutcome.Valid -> return

is ParseOutcome.SyntaxError -> {
// Highlight from where parsing got stuck to the end (or everything if it reached the end).
val tr = if (outcome.furthest < value.length) {
TextRange(outcome.furthest, value.length)
} else {
TextRange(0, value.length)
}
holder.registerProblem(property.valueNode.psi, "${property.key}'s value does not match the expected format. Possible reasons include unrecognized characters or premature end of input.", ProblemHighlightType.GENERIC_ERROR_OR_WARNING, tr)
}

is ParseOutcome.SemanticError -> {
// Well-formed but invalid: highlight the offending token, and offer literal replacements.
val bad = outcome.badToken
val tr = TextRange(bad.start, bad.end)

val quickFixes = mutableListOf<LocalQuickFix>()
val choices = when (val terminal = bad.terminal) {
is LiteralChoiceTerminal -> terminal.choices
is FlexibleLiteralChoiceTerminal -> terminal.choices
else -> emptyArray()
}
for (choice in choices) {
quickFixes.add(ReplaceInvalidLiteralChoiceQuickFix(bad.start, bad.text, choice))
}

holder.registerProblem(property.valueNode.psi, "${property.key}'s value is correctly formatted but seems invalid.", ProblemHighlightType.GENERIC_ERROR_OR_WARNING, tr, *quickFixes.toTypedArray())
}
}
}

companion object {
private val LOG = Logger.getInstance(SemanticDataRepository::class.java)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,22 @@ fun Combinator.fullParses(value: String): Sequence<Parse> =
*
* On failure we fold the [Stuck] values back into the deepest offset reached and the union of what
* was expected there — the "frontier", computed from the return value rather than mutated into it.
*
* The matcher is exhaustive, so a pathologically ambiguous grammar could explore a huge number of
* steps. Two pure guards keep this safe to run on a UI/highlighting thread without any IntelliJ
* dependency here: [onStep] is invoked once per explored step (the IntelliJ layer passes a callback
* that throws on cancellation), and [maxSteps] caps total work. If the cap is hit we fail OPEN —
* return [ParseOutcome.Valid] rather than flag a value we could not fully explore.
*/
fun Combinator.validate(value: String): ParseOutcome {
fun Combinator.validate(value: String, maxSteps: Int = 1_000_000, onStep: () -> Unit = {}): ParseOutcome {
var firstBad: ParsedToken? = null
var furthest = 0
var expected = emptySet<Combinator>()
var steps = 0

for (step in parse(value, 0)) {
onStep()
if (++steps > maxSteps) return ParseOutcome.Valid
when (step) {
is Parse -> {
if (step.end == value.length) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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

/**
* Opt-in flags for unfinished/experimental behaviour (GitHub #467).
*
* Kept separate from [PodmanQuadletSettings] so each experimental area owns its own storage; the
* checkboxes are surfaced on the shared "systemd Unit Files" settings page.
*/
@Service(Service.Level.PROJECT)
@State(name = "SystemdUnitFileExperimentalSettings", storages = [Storage("systemdUnitFileExperimental.xml")])
class ExperimentalSettings : PersistentStateComponent<ExperimentalSettings.State> {

private var myState = State()

class State {
/**
* Use the new list-of-successes grammar engine (Combinator.parse / validate) for value
* validation instead of the original SyntacticMatch/SemanticMatch path.
*/
var useGrammarParseEngine: Boolean = false
}

override fun getState(): State = myState

override fun loadState(state: State) {
myState = state
}

companion object {
@JvmStatic
fun getInstance(project: Project): ExperimentalSettings {
return project.getService(ExperimentalSettings::class.java)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,31 @@ import javax.swing.JPanel
class PodmanQuadletConfigurable(private val project: Project) : Configurable {

private var enabledCheckbox: JBCheckBox? = null
private var grammarEngineCheckbox: JBCheckBox? = null

override fun getDisplayName(): String = "systemd Unit Files"

override fun createComponent(): JComponent {
val settings = PodmanQuadletSettings.getInstance(project)
val experimental = ExperimentalSettings.getInstance(project)
enabledCheckbox = JBCheckBox("Enable Podman Quadlet support (experimental)", settings.state.enabled)
grammarEngineCheckbox = JBCheckBox(
"Use the new grammar engine for value validation (experimental)",
experimental.state.useGrammarParseEngine,
)

return FormBuilder.createFormBuilder()
.addComponent(enabledCheckbox!!)
.addComponent(grammarEngineCheckbox!!)
.addComponentFillVertically(JPanel(), 0)
.panel
}

override fun isModified(): Boolean {
val settings = PodmanQuadletSettings.getInstance(project)
return enabledCheckbox?.isSelected != settings.state.enabled
val experimental = ExperimentalSettings.getInstance(project)
return enabledCheckbox?.isSelected != settings.state.enabled ||
grammarEngineCheckbox?.isSelected != experimental.state.useGrammarParseEngine
}

override fun apply() {
Expand All @@ -35,10 +44,13 @@ class PodmanQuadletConfigurable(private val project: Project) : Configurable {
settings.state.notificationDismissed = false
}
settings.state.enabled = newEnabled

ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = grammarEngineCheckbox?.isSelected ?: false
}

override fun reset() {
val settings = PodmanQuadletSettings.getInstance(project)
enabledCheckbox?.isSelected = settings.state.enabled
grammarEngineCheckbox?.isSelected = ExperimentalSettings.getInstance(project).state.useGrammarParseEngine
}
}
1 change: 1 addition & 0 deletions src/main/resources/META-INF/plugin.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
<completion.contributor language="Unit File (systemd)" implementationClass="net.sjrx.intellij.plugins.systemdunitfiles.completion.UnitFileSectionCompletionContributor"/>

<projectService serviceImplementation="net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletSettings"/>
<projectService serviceImplementation="net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings"/>
<projectConfigurable instance="net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletConfigurable"
id="net.sjrx.intellij.plugins.systemdunitfiles.settings.PodmanQuadletConfigurable"
displayName="systemd Unit Files"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package net.sjrx.intellij.plugins.systemdunitfiles.inspections

import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
import org.junit.Test

/**
* End-to-end check that, with the experimental flag on, the InvalidValue inspection is driven by the
* new list-of-successes engine (GrammarOptionValue.validate) and reproduces the expected behaviour.
*
* We only spot-check here rather than re-running the whole inspection suite under both engines — the
* engine itself is covered by ParseTest; this just proves the wiring and the ParseOutcome -> problem
* descriptor mapping.
*/
class GrammarParseEngineInspectionTest : AbstractUnitFileTest() {

private fun enableNewEngine() {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = true
}

// The light-test project is shared across test classes, so the opt-in flag must not leak into
// other tests (which assume the original engine).
override fun tearDown() {
try {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
} finally {
super.tearDown()
}
}

@Test
fun testValidAddressFamiliesUnderNewEngine() {
enableNewEngine()
// language="unit file (systemd)"
val file = """
[Service]
RestrictAddressFamilies=none
RestrictAddressFamilies=AF_INET AF_INET6
RestrictAddressFamilies=~AF_UNIX AF_NETLINK
""".trimIndent()

setupFileInEditor("file.service", file)
enableInspection(InvalidValueInspection::class.java)

assertSize(0, myFixture.doHighlighting())
}

@Test
fun testInvalidAddressFamiliesUnderNewEngine() {
enableNewEngine()
// Raw values so the count is unambiguous: an unknown family (semantic error), a malformed list
// (syntax error at the comma), and a non-AF token (semantic error) -> one highlight each.
// language="unit file (systemd)"
val file = """
[Service]
RestrictAddressFamilies=AF_BOGUS
RestrictAddressFamilies=AF_INET, AF_INET6
RestrictAddressFamilies=inet
""".trimIndent()

setupFileInEditor("file.service", file)
enableInspection(InvalidValueInspection::class.java)

assertSize(3, myFixture.doHighlighting())
}

@Test
fun testFlagOffStillUsesOriginalEngine() {
// Sanity: with the flag left off, the same valid input is accepted (the default path runs).
// language="unit file (systemd)"
val file = """
[Service]
RestrictAddressFamilies=AF_INET AF_INET6
""".trimIndent()

setupFileInEditor("file.service", file)
enableInspection(InvalidValueInspection::class.java)

assertSize(0, myFixture.doHighlighting())
}
}
Loading