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 @@ -2,6 +2,7 @@ package net.sjrx.intellij.plugins.systemdunitfiles.completion

import com.intellij.codeInsight.completion.*
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.progress.ProgressManager
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.ProcessingContext
Expand All @@ -11,6 +12,9 @@ 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.GrammarOptionValue
import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar.nextTokenChoices
import net.sjrx.intellij.plugins.systemdunitfiles.settings.ExperimentalSettings
import java.util.function.Supplier
import java.util.stream.Collectors

Expand Down Expand Up @@ -48,6 +52,14 @@ class UnitFileValueCompletionContributor : CompletionContributor() {
val fileClass = section.containingFile.fileClass()

val validator = sdr.getOptionValidator(fileClass, sectionName, keyName)

if (ExperimentalSettings.getInstance(property.project).state.useGrammarParseEngine &&
validator is GrammarOptionValue
) {
addGrammarCompletions(parameters, validator, property, resultSet)
return
}

resultSet.addAllElements(
validator.getAutoCompleteOptions(property.project)
.stream()
Expand All @@ -59,4 +71,46 @@ class UnitFileValueCompletionContributor : CompletionContributor() {
}
)
}

/**
* Experimental grammar-based completion (#467 / #343): suggest the literal/choice tokens the
* grammar could accept at the caret. We read the value text up to the caret, find the tightest
* split where the grammar expects an enumerable token whose choices match what's already typed,
* and set that as the prefix so the platform filters correctly (otherwise a partial token like
* "~AF_IN" would be the prefix and match nothing).
*/
private fun addGrammarCompletions(
parameters: CompletionParameters,
validator: GrammarOptionValue,
property: UnitFileProperty,
resultSet: CompletionResultSet,
) {
val valueStart = property.valueNode?.psi?.textRange?.startOffset ?: return
val caret = parameters.offset
if (caret < valueStart) return
val pre = parameters.position.containingFile.text.substring(valueStart, caret)
val combinator = validator.combinator

// Case 1 — completing a partial token. Find the token start: the longest non-empty trailing
// word for which the grammar expects an enumerable choice that STRICTLY extends it (i.e. there's
// more to type). This beats just advancing, so "h" completes to "home" rather than offering "="
// (the lenient terminal would otherwise treat "h" as a finished identifier).
for (split in 0 until pre.length) {
ProgressManager.checkCanceled()
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) })
return
}
}

// Case 2 — at a fresh token boundary (e.g. empty value, or after a complete token like "~" or
// "root="). Offer whatever can come next, matched against an empty prefix.
ProgressManager.checkCanceled()
val choices = combinator.nextTokenChoices(pre)
if (choices.isNotEmpty()) {
resultSet.withPrefixMatcher("").addAllElements(choices.map { LookupElementBuilder.create(it) })
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar

/*
* Grammar-based completion support (GitHub #467 / #343).
*
* The frontier we built for error localization is exactly what completion needs: "what was expected
* at this offset?" is the same question as "what could come next here?". [nextTokenChoices] answers
* it for the position at the end of [prefix].
*
* It reads the FIRST set at the end of [prefix] from the parse frontier — the Stuck values whose
* offset is the end of [prefix] — and collects the enumerable choices of the terminals expected
* there (literal choices and flexible-literal choices). Non-enumerable terminals (numbers, regexes,
* whitespace, EOF) contribute nothing to suggest.
*
* Pure Kotlin, no IntelliJ types. The matcher is exhaustive, so [maxSteps] bounds the work; callers
* running on a UI/highlighting thread should also poll cancellation between calls.
*/
fun Combinator.nextTokenChoices(prefix: String, maxSteps: Int = 100_000): Set<String> {
val choices = linkedSetOf<String>()
var steps = 0
for (step in parse(prefix, 0)) {
if (++steps > maxSteps) break
if (step is Stuck && step.offset == prefix.length) {
for (matcher in step.expected) {
when (matcher) {
is LiteralChoiceTerminal -> choices += matcher.choices
is FlexibleLiteralChoiceTerminal -> choices += matcher.choices
else -> {} // not enumerable — nothing concrete to suggest
}
}
}
}
return choices
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package net.sjrx.intellij.plugins.systemdunitfiles.completion

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

/**
* End-to-end grammar-based completion (#467 / #343), behind the experimental flag.
*/
class GrammarValueCompletionTest : AbstractUnitFileTest() {

// The light-test project is shared across classes; don't leak the opt-in.
override fun tearDown() {
try {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
} finally {
super.tearDown()
}
}

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

@Test
fun testCompletesPartialAddressFamily() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nRestrictAddressFamilies=AF_IN${COMPLETION_POSITION}")

val results = basicCompletionResultStrings
assertContainsElements(results, "AF_INET", "AF_INET6")
}

@Test
fun testCompletesSubsequentFamilyInList() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nRestrictAddressFamilies=AF_UNIX AF_IN${COMPLETION_POSITION}")

val results = basicCompletionResultStrings
assertContainsElements(results, "AF_INET", "AF_INET6")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package net.sjrx.intellij.plugins.systemdunitfiles.completion

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

/**
* Grammar completion for the more complex RootImagePolicy= grammar (#467 / #343), behind the flag.
* Exercises a fresh boundary (empty), completing a partial partition identifier, and the position
* after "partition=" where policy flags are expected.
*/
class ImagePolicyCompletionTest : AbstractUnitFileTest() {

override fun tearDown() {
try {
ExperimentalSettings.getInstance(project).state.useGrammarParseEngine = false
} finally {
super.tearDown()
}
}

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

@Test
fun testEmptyValueOffersPartitionsAndDefault() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=${COMPLETION_POSITION}")
assertContainsElements(basicCompletionResultStrings, "root", "usr", "swap", "+")
}

@Test
fun testPartialPartitionIdentifierIsCompleted() {
// Regression for the bug: typing "r" used to offer "=" (the lenient terminal treated "r" as a
// finished identifier); it should complete the identifier instead.
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=r${COMPLETION_POSITION}")
assertContainsElements(basicCompletionResultStrings, "root", "root-verity", "root-verity-sig")
}

@Test
fun testAfterPartitionEqualsOffersPolicyFlags() {
enableNewEngine()
setupFileInEditor("file.service", "[Service]\nRootImagePolicy=root=${COMPLETION_POSITION}")
assertContainsElements(basicCompletionResultStrings, "verity", "signed", "encrypted")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.grammar

import net.sjrx.intellij.plugins.systemdunitfiles.semanticdata.optionvalues.ai.ConfigParseAddressFamiliesOptionValue
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/** Unit tests for the grammar-completion FIRST-set computation (no IntelliJ fixture needed). */
class NextTokenChoicesTest {

private val addressFamilies = ConfigParseAddressFamiliesOptionValue().combinator

@Test
fun testEmptyValueOffersStartTokens() {
val choices = addressFamilies.nextTokenChoices("")
assertTrue(choices.contains("none"))
assertTrue(choices.contains("~"))
assertTrue(choices.contains("AF_INET"))
}

@Test
fun testAfterInversionOffersFamiliesNotNone() {
// After "~" the grammar expects an address family, not "none" or another "~".
val choices = addressFamilies.nextTokenChoices("~")
assertTrue(choices.contains("AF_INET"))
assertFalse(choices.contains("none"))
assertFalse(choices.contains("~"))
}

@Test
fun testAfterFamilyAndSpaceOffersAnotherFamily() {
// Context awareness: after a family and a separator, another family is expected.
assertTrue(addressFamilies.nextTokenChoices("AF_INET ").contains("AF_INET6"))
}
}