Skip to content

Commit 1e3ea0d

Browse files
SJrXSteve Ramageclaude
authored
feat: grammar-based value completion (#494)
* feat: grammar-based value completion behind the experimental flag The frontier built for error localization answers exactly the question completion needs — "what was expected at this offset?" is "what could come next here?". Adds Combinator.nextTokenChoices(prefix), which reads the Stuck values at the end of the typed prefix and collects the enumerable choices expected there (literal / flexible-literal terminals; numbers/regexes/whitespace contribute nothing to suggest). Wires it into UnitFileValueCompletionContributor, gated behind ExperimentalSettings.useGrammarParseEngine AND validator is GrammarOptionValue: with the flag off the existing completion path is untouched. Two behaviours: - completing a partial token (sets the prefix matcher so "AF_IN" -> AF_INET/6) - chaining through forced separators on accept (e.g. accepting "home" inserts "home=" and re-opens completion, never auto-inserting a content token) Bundles the stacked PRs #477 (completion) and #478 (forced-separator chaining), re-cut against current 242.x. New behaviour, so flag-gated; correctness of existing validators is unchanged. Tests enable the flag and reset it in tearDown to avoid leaking into the shared light-test project. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover the flag-off gating and non-enumerable completion cases The completion bundle tested the flag-on behaviours but not two things worth guaranteeing: that grammar completion stays suppressed when the experimental engine is off (the gating contract), and that a non-enumerable next token (number/regex) offers nothing rather than crashing or suggesting junk. - GrammarValueCompletionTest.testFlagOffOffersNoGrammarCompletions: flag off, the AF_IN partial yields no AF_* names (the flag-on path's suggestions are absent). - NextTokenChoicesTest.testNonEnumerableNextTokenOffersNothing: an IntegerTerminal port grammar returns an empty choice set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Steve Ramage <gitcommits@sjrx.net> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 360c662 commit 1e3ea0d

5 files changed

Lines changed: 276 additions & 0 deletions

File tree

src/main/kotlin/net/sjrx/intellij/plugins/systemdunitfiles/completion/UnitFileValueCompletionContributor.kt

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package net.sjrx.intellij.plugins.systemdunitfiles.completion
22

3+
import com.intellij.codeInsight.AutoPopupController
34
import com.intellij.codeInsight.completion.*
5+
import com.intellij.codeInsight.lookup.LookupElement
46
import com.intellij.codeInsight.lookup.LookupElementBuilder
7+
import com.intellij.openapi.progress.ProgressManager
58
import com.intellij.patterns.PlatformPatterns
69
import com.intellij.psi.util.PsiTreeUtil
710
import com.intellij.util.ProcessingContext
@@ -11,6 +14,10 @@ import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileProperty
1114
import net.sjrx.intellij.plugins.systemdunitfiles.psi.UnitFileSectionGroups
1215
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.SemanticDataRepository
1316
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.fileClass
17+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.Combinator
18+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.GrammarOptionValue
19+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.nextTokenChoices
20+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
1421
import java.util.function.Supplier
1522
import java.util.stream.Collectors
1623

@@ -48,6 +55,14 @@ class UnitFileValueCompletionContributor : CompletionContributor() {
4855
val fileClass = section.containingFile.fileClass()
4956

5057
val validator = sdr.getOptionValidator(fileClass, sectionName, keyName)
58+
59+
if (ExperimentalSettings.getInstance(property.project).state.useGrammarParseEngine &&
60+
validator is GrammarOptionValue
61+
) {
62+
addGrammarCompletions(parameters, validator, property, resultSet)
63+
return
64+
}
65+
5166
resultSet.addAllElements(
5267
validator.getAutoCompleteOptions(property.project)
5368
.stream()
@@ -59,4 +74,79 @@ class UnitFileValueCompletionContributor : CompletionContributor() {
5974
}
6075
)
6176
}
77+
78+
/**
79+
* Experimental grammar-based completion (#467 / #343): suggest the literal/choice tokens the
80+
* grammar could accept at the caret. We read the value text up to the caret, find the tightest
81+
* split where the grammar expects an enumerable token whose choices match what's already typed,
82+
* and set that as the prefix so the platform filters correctly (otherwise a partial token like
83+
* "~AF_IN" would be the prefix and match nothing).
84+
*/
85+
private fun addGrammarCompletions(
86+
parameters: CompletionParameters,
87+
validator: GrammarOptionValue,
88+
property: UnitFileProperty,
89+
resultSet: CompletionResultSet,
90+
) {
91+
val valueStart = property.valueNode?.psi?.textRange?.startOffset ?: return
92+
val caret = parameters.offset
93+
if (caret < valueStart) return
94+
val pre = parameters.position.containingFile.text.substring(valueStart, caret)
95+
val combinator = validator.combinator
96+
97+
// Case 1 — completing a partial token. Find the token start: the longest non-empty trailing
98+
// word for which the grammar expects an enumerable choice that STRICTLY extends it (i.e. there's
99+
// more to type). This beats just advancing, so "h" completes to "home" rather than offering "="
100+
// (the lenient terminal would otherwise treat "h" as a finished identifier).
101+
for (split in 0 until pre.length) {
102+
ProgressManager.checkCanceled()
103+
val word = pre.substring(split)
104+
val choices = combinator.nextTokenChoices(pre.substring(0, split))
105+
if (choices.any { it.length > word.length && it.startsWith(word) }) {
106+
resultSet.withPrefixMatcher(word).addAllElements(lookupElements(choices, combinator))
107+
return
108+
}
109+
}
110+
111+
// Case 2 — at a fresh token boundary (e.g. empty value, or after a complete token like "~" or
112+
// "root="). Offer whatever can come next, matched against an empty prefix.
113+
ProgressManager.checkCanceled()
114+
val choices = combinator.nextTokenChoices(pre)
115+
if (choices.isNotEmpty()) {
116+
resultSet.withPrefixMatcher("").addAllElements(lookupElements(choices, combinator))
117+
}
118+
}
119+
120+
private fun lookupElements(choices: Set<String>, combinator: Combinator): List<LookupElement> {
121+
val handler = chainingInsertHandler(combinator)
122+
return choices.map { LookupElementBuilder.create(it).withInsertHandler(handler) }
123+
}
124+
125+
/**
126+
* After a choice is accepted, walk the grammar forward: while the only thing it can accept next is
127+
* a single forced separator (punctuation, e.g. "=" after a partition designator), insert it
128+
* automatically, then re-open completion. So accepting "home" yields "home=" with the policy-flag
129+
* popup, rather than stopping on a separator the user must type by hand.
130+
*/
131+
private fun chainingInsertHandler(combinator: Combinator) = InsertHandler<LookupElement> { context, _ ->
132+
context.commitDocument()
133+
val element = context.file.findElementAt(maxOf(0, context.tailOffset - 1)) ?: return@InsertHandler
134+
val property = PsiTreeUtil.getParentOfType(element, UnitFileProperty::class.java) ?: return@InsertHandler
135+
val valueStart = property.valueNode?.psi?.textRange?.startOffset ?: return@InsertHandler
136+
val document = context.document
137+
138+
var caret = context.tailOffset
139+
var guard = 0
140+
while (guard++ < 8 && caret in valueStart..document.textLength) {
141+
val pre = document.charsSequence.subSequence(valueStart, caret).toString()
142+
val separator = combinator.nextTokenChoices(pre).singleOrNull() ?: break
143+
// Only auto-insert a forced punctuation separator; never a content token the user should pick.
144+
if (separator.isEmpty() || separator.any { it.isLetterOrDigit() }) break
145+
document.insertString(caret, separator)
146+
caret += separator.length
147+
}
148+
context.commitDocument()
149+
context.editor.caretModel.moveToOffset(caret)
150+
AutoPopupController.getInstance(context.project).scheduleAutoPopup(context.editor)
151+
}
62152
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
/*
4+
* Grammar-based completion support (GitHub #467 / #343).
5+
*
6+
* The frontier we built for error localization is exactly what completion needs: "what was expected
7+
* at this offset?" is the same question as "what could come next here?". [nextTokenChoices] answers
8+
* it for the position at the end of [prefix].
9+
*
10+
* It reads the FIRST set at the end of [prefix] from the parse frontier — the Stuck values whose
11+
* offset is the end of [prefix] — and collects the enumerable choices of the terminals expected
12+
* there (literal choices and flexible-literal choices). Non-enumerable terminals (numbers, regexes,
13+
* whitespace, EOF) contribute nothing to suggest.
14+
*
15+
* Pure Kotlin, no IntelliJ types. The matcher is exhaustive, so [maxSteps] bounds the work; callers
16+
* running on a UI/highlighting thread should also poll cancellation between calls.
17+
*/
18+
fun Combinator.nextTokenChoices(prefix: String, maxSteps: Int = 100_000): Set<String> {
19+
val choices = linkedSetOf<String>()
20+
var steps = 0
21+
for (step in parse(prefix, 0)) {
22+
if (++steps > maxSteps) break
23+
if (step is Stuck && step.offset == prefix.length) {
24+
for (matcher in step.expected) {
25+
when (matcher) {
26+
is LiteralChoiceTerminal -> choices += matcher.choices
27+
is FlexibleLiteralChoiceTerminal -> choices += matcher.choices
28+
else -> {} // not enumerable — nothing concrete to suggest
29+
}
30+
}
31+
}
32+
}
33+
return choices
34+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.completion
2+
3+
import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest
4+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
5+
import org.junit.Test
6+
7+
/**
8+
* End-to-end grammar-based completion (#467 / #343), behind the experimental flag.
9+
*/
10+
class GrammarValueCompletionTest : AbstractUnitFileTest() {
11+
12+
// The light-test project is shared across classes; don't leak the opt-in.
13+
override fun tearDown() {
14+
try {
15+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
16+
} finally {
17+
super.tearDown()
18+
}
19+
}
20+
21+
private fun enableNewEngine() {
22+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = true
23+
}
24+
25+
@Test
26+
fun testCompletesPartialAddressFamily() {
27+
enableNewEngine()
28+
setupFileInEditor("file.service", "[Service]\nRestrictAddressFamilies=AF_IN${COMPLETION_POSITION}")
29+
30+
val results = basicCompletionResultStrings
31+
assertContainsElements(results, "AF_INET", "AF_INET6")
32+
}
33+
34+
@Test
35+
fun testCompletesSubsequentFamilyInList() {
36+
enableNewEngine()
37+
setupFileInEditor("file.service", "[Service]\nRestrictAddressFamilies=AF_UNIX AF_IN${COMPLETION_POSITION}")
38+
39+
val results = basicCompletionResultStrings
40+
assertContainsElements(results, "AF_INET", "AF_INET6")
41+
}
42+
43+
@Test
44+
fun testFlagOffOffersNoGrammarCompletions() {
45+
// Gating guarantee: with the experimental engine OFF (the default), grammar completion must not
46+
// fire. A GrammarOptionValue has no legacy autocomplete options, so the same partial token that
47+
// the flag-on path completes to AF_INET/AF_INET6 yields nothing grammar-derived here.
48+
setupFileInEditor("file.service", "[Service]\nRestrictAddressFamilies=AF_IN${COMPLETION_POSITION}")
49+
50+
assertDoesntContain(basicCompletionResultStrings, "AF_INET", "AF_INET6")
51+
}
52+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.completion
2+
3+
import net.sjrx.intellij.plugins.systemdunitfiles.AbstractUnitFileTest
4+
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
5+
import org.junit.Test
6+
7+
/**
8+
* Grammar completion for the more complex RootImagePolicy= grammar (#467 / #343), behind the flag.
9+
* Exercises a fresh boundary (empty), completing a partial partition identifier, and the position
10+
* after "partition=" where policy flags are expected.
11+
*/
12+
class ImagePolicyCompletionTest : AbstractUnitFileTest() {
13+
14+
override fun tearDown() {
15+
try {
16+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
17+
} finally {
18+
super.tearDown()
19+
}
20+
}
21+
22+
private fun enableNewEngine() {
23+
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = true
24+
}
25+
26+
@Test
27+
fun testEmptyValueOffersPartitionsAndDefault() {
28+
enableNewEngine()
29+
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=${COMPLETION_POSITION}")
30+
assertContainsElements(basicCompletionResultStrings, "root", "usr", "swap", "+")
31+
}
32+
33+
@Test
34+
fun testPartialPartitionIdentifierIsCompleted() {
35+
// Regression for the bug: typing "r" used to offer "=" (the lenient terminal treated "r" as a
36+
// finished identifier); it should complete the identifier instead.
37+
enableNewEngine()
38+
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=r${COMPLETION_POSITION}")
39+
assertContainsElements(basicCompletionResultStrings, "root", "root-verity", "root-verity-sig")
40+
}
41+
42+
@Test
43+
fun testAfterPartitionEqualsOffersPolicyFlags() {
44+
enableNewEngine()
45+
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=root=${COMPLETION_POSITION}")
46+
assertContainsElements(basicCompletionResultStrings, "verity", "signed", "encrypted")
47+
}
48+
49+
@Test
50+
fun testAcceptingPartitionChainsTheEqualsSeparator() {
51+
// Accepting a partition designator auto-inserts the forced "=" (then re-opens completion),
52+
// so the user goes straight to choosing a policy flag.
53+
enableNewEngine()
54+
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=hom${COMPLETION_POSITION}")
55+
myFixture.completeBasic() // single match "home" -> auto-inserted -> handler appends "="
56+
myFixture.checkResult("[Service]\nRootImagePolicy=home=${COMPLETION_POSITION}")
57+
}
58+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar
2+
3+
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseAddressFamiliesOptionValue
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertTrue
6+
import org.junit.Test
7+
8+
/** Unit tests for the grammar-completion FIRST-set computation (no IntelliJ fixture needed). */
9+
class NextTokenChoicesTest {
10+
11+
private val addressFamilies = ConfigParseAddressFamiliesOptionValue().combinator
12+
13+
@Test
14+
fun testEmptyValueOffersStartTokens() {
15+
val choices = addressFamilies.nextTokenChoices("")
16+
assertTrue(choices.contains("none"))
17+
assertTrue(choices.contains("~"))
18+
assertTrue(choices.contains("AF_INET"))
19+
}
20+
21+
@Test
22+
fun testAfterInversionOffersFamiliesNotNone() {
23+
// After "~" the grammar expects an address family, not "none" or another "~".
24+
val choices = addressFamilies.nextTokenChoices("~")
25+
assertTrue(choices.contains("AF_INET"))
26+
assertFalse(choices.contains("none"))
27+
assertFalse(choices.contains("~"))
28+
}
29+
30+
@Test
31+
fun testAfterFamilyAndSpaceOffersAnotherFamily() {
32+
// Context awareness: after a family and a separator, another family is expected.
33+
assertTrue(addressFamilies.nextTokenChoices("AF_INET ").contains("AF_INET6"))
34+
}
35+
36+
@Test
37+
fun testNonEnumerableNextTokenOffersNothing() {
38+
// Numbers/regexes/whitespace/EOF aren't enumerable, so there is nothing concrete to suggest.
39+
val portGrammar = SequenceCombinator(IntegerTerminal(0, 65536), EOF())
40+
assertTrue(portGrammar.nextTokenChoices("").isEmpty())
41+
}
42+
}

0 commit comments

Comments
 (0)