diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarOptionValue.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarOptionValue.kt index 68bb0ab..33375ed 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarOptionValue.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/GrammarOptionValue.kt @@ -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, @@ -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 { @@ -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() + 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) } diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt index 7fca0fb..f861626 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/semanticdata/optionvalues/grammar/Parse.kt @@ -77,13 +77,22 @@ fun Combinator.fullParses(value: String): Sequence = * * 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() + 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) { diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/ExperimentalSettings.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/ExperimentalSettings.kt new file mode 100644 index 0000000..f61239b --- /dev/null +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/ExperimentalSettings.kt @@ -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 { + + 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) + } + } +} 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 index cf30971..c4cc703 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletConfigurable.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/settings/PodmanQuadletConfigurable.kt @@ -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() { @@ -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 } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 60764a0..94771c6 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -89,6 +89,7 @@ + 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()) + } +}