From cbbfef2c96f05b9afa8c4477de029611d2bfbbe4 Mon Sep 17 00:00:00 2001 From: Steve Ramage Date: Sun, 21 Jun 2026 12:05:18 -0700 Subject: [PATCH] feat: chain through forced separators in grammar completion (#467 #343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting a completion now auto-inserts a following forced separator and re-opens completion, so e.g. accepting "home" yields "home=" and immediately offers the policy flags — instead of stopping on a single mandatory "=" that the user must type by hand (and which the platform won't even autopopup since it's a lone punctuation result). InsertHandler walks the grammar forward from the accepted token: while the only thing that can come next is a single punctuation separator (",", "=", "+", ":" depending on grammar), it inserts it; it never auto-inserts a content token the user should choose. Then it schedules the autopopup. Bounded loop; resolves the value start via PSI. Test: accepting a partition designator chains the "=" (RootImagePolicy=hom -> home=). Refs #467 #343 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../UnitFileValueCompletionContributor.kt | 40 ++++++++++++++++++- .../completion/ImagePolicyCompletionTest.kt | 10 +++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileValueCompletionContributor.kt b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileValueCompletionContributor.kt index ce23ee5..b72b622 100644 --- a/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileValueCompletionContributor.kt +++ b/src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileValueCompletionContributor.kt @@ -1,6 +1,8 @@ package net.sjrx.intellij.plugins.systemdunitfiles.completion +import com.intellij.codeInsight.AutoPopupController import com.intellij.codeInsight.completion.* +import com.intellij.codeInsight.lookup.LookupElement import com.intellij.codeInsight.lookup.LookupElementBuilder import com.intellij.openapi.progress.ProgressManager import com.intellij.patterns.PlatformPatterns @@ -12,6 +14,7 @@ import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileProperty import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass +import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Combinator import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.nextTokenChoices import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings @@ -100,7 +103,7 @@ class UnitFileValueCompletionContributor : CompletionContributor() { val word = pre.substring(split) val choices = combinator.nextTokenChoices(pre.substring(0, split)) if (choices.any { it.length > word.length && it.startsWith(word) }) { - resultSet.withPrefixMatcher(word).addAllElements(choices.map { LookupElementBuilder.create(it) }) + resultSet.withPrefixMatcher(word).addAllElements(lookupElements(choices, combinator)) return } } @@ -110,7 +113,40 @@ class UnitFileValueCompletionContributor : CompletionContributor() { ProgressManager.checkCanceled() val choices = combinator.nextTokenChoices(pre) if (choices.isNotEmpty()) { - resultSet.withPrefixMatcher("").addAllElements(choices.map { LookupElementBuilder.create(it) }) + resultSet.withPrefixMatcher("").addAllElements(lookupElements(choices, combinator)) } } + + private fun lookupElements(choices: Set, combinator: Combinator): List { + val handler = chainingInsertHandler(combinator) + return choices.map { LookupElementBuilder.create(it).withInsertHandler(handler) } + } + + /** + * After a choice is accepted, walk the grammar forward: while the only thing it can accept next is + * a single forced separator (punctuation, e.g. "=" after a partition designator), insert it + * automatically, then re-open completion. So accepting "home" yields "home=" with the policy-flag + * popup, rather than stopping on a separator the user must type by hand. + */ + private fun chainingInsertHandler(combinator: Combinator) = InsertHandler { context, _ -> + context.commitDocument() + val element = context.file.findElementAt(maxOf(0, context.tailOffset - 1)) ?: return@InsertHandler + val property = PsiTreeUtil.getParentOfType(element, UnitFileProperty::class.java) ?: return@InsertHandler + val valueStart = property.valueNode?.psi?.textRange?.startOffset ?: return@InsertHandler + val document = context.document + + var caret = context.tailOffset + var guard = 0 + while (guard++ < 8 && caret in valueStart..document.textLength) { + val pre = document.charsSequence.subSequence(valueStart, caret).toString() + val separator = combinator.nextTokenChoices(pre).singleOrNull() ?: break + // Only auto-insert a forced punctuation separator; never a content token the user should pick. + if (separator.isEmpty() || separator.any { it.isLetterOrDigit() }) break + document.insertString(caret, separator) + caret += separator.length + } + context.commitDocument() + context.editor.caretModel.moveToOffset(caret) + AutoPopupController.getInstance(context.project).scheduleAutoPopup(context.editor) + } } diff --git a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt index 0b0004b..b42b9d2 100644 --- a/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt +++ b/src/test/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/ImagePolicyCompletionTest.kt @@ -45,4 +45,14 @@ class ImagePolicyCompletionTest : AbstractUnitFileTest() { setupFileInEditor("file.service", "[Service]\nRootImagePolicy=root=${COMPLETION_POSITION}") assertContainsElements(basicCompletionResultStrings, "verity", "signed", "encrypted") } + + @Test + fun testAcceptingPartitionChainsTheEqualsSeparator() { + // Accepting a partition designator auto-inserts the forced "=" (then re-opens completion), + // so the user goes straight to choosing a policy flag. + enableNewEngine() + setupFileInEditor("file.service", "[Service]\nRootImagePolicy=hom${COMPLETION_POSITION}") + myFixture.completeBasic() // single match "home" -> auto-inserted -> handler appends "=" + myFixture.checkResult("[Service]\nRootImagePolicy=home=${COMPLETION_POSITION}") + } }