[terminal] IJPL-180081 Fix completion after quotes

Exclude starting quote from the `ShellRuntimeContext.typedPrefix`. So, clients that provide suggestions shouldn't take quotes into account.
This way, now suggestions of any type can be shown after a quote.

GitOrigin-RevId: c3aeed2d3e5a2b1349f3ad5f54d9366442bcabe9
This commit is contained in:
Konstantin Hudyakov
2026-01-27 16:20:36 +00:00
committed by intellij-monorepo-bot
parent f5c1f5f009
commit 6f0132fd23
16 changed files with 122 additions and 50 deletions
@@ -34,14 +34,11 @@ object JavaShellCommandUtils {
val separator = getClassPathSeparator()
val typedPrefix = context.typedPrefix
val separatorIndex = typedPrefix.lastIndexOf(separator)
val isStartWithQuote = ShellDataGenerators.isStartWithQuote(typedPrefix)
val quoteOffset = if (isStartWithQuote) 1 else 0
return if (separatorIndex == SEPARATOR_NOT_FOUND_INDEX) {
PathInfo(typedPrefix.substring(quoteOffset))
PathInfo(typedPrefix)
} else {
val adjustedSeparatorIndex = if (isStartWithQuote) separatorIndex else separatorIndex + 1
PathInfo(typedPrefix.substring(separatorIndex + 1), adjustedSeparatorIndex)
PathInfo(typedPrefix.substring(separatorIndex + 1), separatorIndex + 1)
}
}
@@ -70,7 +70,7 @@ class JavaShellCommandSpecsProviderTest(private val engine: TerminalEngine) : Ba
val fixture = createFixture()
val completion = fixture.getCompletions("java -cp '")
assertSameElements(completion.map { it.name }, listOf("file1.jar", "file2.jar", "dir1/"))
assertTrue(completion.all { it.prefixReplacementIndex == 1})
assertTrue(completion.all { it.prefixReplacementIndex == 0 })
}
@Test
@@ -80,7 +80,7 @@ class JavaShellCommandSpecsProviderTest(private val engine: TerminalEngine) : Ba
val argument = "'file1.jar$separator"
val completion = fixture.getCompletions("java -cp $argument")
assertSameElements(completion.map { it.name }, listOf("file1.jar", "file2.jar", "dir1/"))
assertTrue(completion.all { it.prefixReplacementIndex == argument.length })
assertTrue(completion.all { it.prefixReplacementIndex == argument.length - 1 })
}
@Test
@@ -88,7 +88,7 @@ class JavaShellCommandSpecsProviderTest(private val engine: TerminalEngine) : Ba
val fixture = createFixture()
val completion = fixture.getCompletions("java -cp \"")
assertSameElements(completion.map { it.name }, listOf("file1.jar", "file2.jar", "dir1/"))
assertTrue(completion.all { it.prefixReplacementIndex == 1})
assertTrue(completion.all { it.prefixReplacementIndex == 0 })
}
@Test
@@ -98,7 +98,7 @@ class JavaShellCommandSpecsProviderTest(private val engine: TerminalEngine) : Ba
val argument = "\"file1.jar$separator"
val completion = fixture.getCompletions("java -cp $argument")
assertSameElements(completion.map { it.name }, listOf("file1.jar", "file2.jar", "dir1/"))
assertTrue(completion.all { it.prefixReplacementIndex == argument.length })
assertTrue(completion.all { it.prefixReplacementIndex == argument.length - 1 })
}
@@ -52,7 +52,8 @@ internal class ShellCommandTreeBuilder private constructor(
private suspend fun buildSubcommandTree(root: ShellCommandNode) {
while (curIndex < arguments.size) {
val name = arguments[curIndex]
val curArgument = arguments[curIndex]
val name = curArgument.removeQuotes()
val currentTokens = arguments.take(curIndex + 1)
val suggestionsProvider = createSuggestionsProvider(currentTokens)
val suggestions = suggestionsProvider.getSuggestionsOfNext(root)
@@ -67,7 +68,8 @@ internal class ShellCommandTreeBuilder private constructor(
curIndex++
}
else {
val node = suggestion?.let { createChildNode(name, it, root) } ?: ShellUnknownNode(name, root)
val node = suggestion?.let { createChildNode(curArgument, it, root) }
?: ShellUnknownNode(curArgument, root)
if (node !is ShellAliasNode) {
root.children.add(node)
curIndex++
@@ -87,7 +89,8 @@ internal class ShellCommandTreeBuilder private constructor(
private suspend fun buildOptionTree(root: ShellOptionNode) {
while (curIndex < arguments.size) {
val name = arguments[curIndex]
val curArgument = arguments[curIndex]
val name = curArgument.removeQuotes()
val currentTokens = arguments.take(curIndex + 1)
val suggestionsProvider = createSuggestionsProvider(currentTokens)
val suggestions = suggestionsProvider.getDirectSuggestionsOfNext(root)
@@ -95,11 +98,11 @@ internal class ShellCommandTreeBuilder private constructor(
val node = if (suggestion == null) {
// option requires an argument, then probably provided name is this argument
suggestionsProvider.getAvailableArguments(root).find { !it.isOptional }?.let {
ShellArgumentNode(name, it, root)
ShellArgumentNode(curArgument, it, root)
}
}
else {
createChildNode(name, suggestion, root)
createChildNode(curArgument, suggestion, root)
}
if (node != null) {
root.children.add(node)
@@ -173,4 +176,12 @@ internal class ShellCommandTreeBuilder private constructor(
val context = contextProvider.getContext(commandTokens)
return ShellCommandTreeSuggestionsProvider(context, generatorsExecutor)
}
private fun String.removeQuotes(): String {
return if (startsWith('"') && endsWith('"')
|| startsWith("'") && endsWith("'")) {
if (length > 1) substring(1, length - 1) else ""
}
else this
}
}
@@ -31,9 +31,9 @@ interface ShellRuntimeContext {
val commandTokens: List<String>
/**
* User-typed prefix of the current token we are trying to complete.
* User-typed prefix of the current token we are trying to complete. Without starting quotes.
*
* For example, if [commandTokens] are `[ls, -al, ~/Documents/proj]`,
* For example, if [commandTokens] are `[ls, -al, "~/Documents/proj]`,
* then typed prefix is `~/Documents/proj`
*/
val typedPrefix: String
@@ -7,6 +7,7 @@ import com.intellij.terminal.completion.ShellCommandSpecsManager
import com.intellij.terminal.completion.ShellDataGeneratorsExecutor
import com.intellij.terminal.completion.ShellRuntimeContextProvider
import com.intellij.terminal.completion.spec.ShellCompletionSuggestion
import org.jetbrains.plugins.terminal.block.completion.TerminalCompletionUtil
import org.jetbrains.plugins.terminal.block.completion.spec.impl.TerminalCommandCompletionServices
import org.jetbrains.plugins.terminal.exp.completion.TerminalShellSupport
import org.jetbrains.plugins.terminal.util.ShellType
@@ -20,7 +21,7 @@ internal class TerminalCommandSpecCompletionContributor : TerminalCommandComplet
return null
}
val prefix = commandTokens.last()
val prefix = TerminalCompletionUtil.getTypedPrefix(commandTokens)
if (context.isAutoPopup && prefix.startsWith("-") && prefix.length <= 2) {
// Do not show the completion popup automatically for short options like `-a` or `-h`
// Most probably, it will cause only distraction.
@@ -93,9 +93,10 @@ private fun calculateInsertionInfo(
val prefixStartOffset = outputModel.cursorOffset - typedPrefixLength.toLong()
val tokenStartOffset = prefixStartOffset - suggestion.prefixReplacementIndex.toLong()
val tokenText = outputModel.getText(tokenStartOffset, prefixStartOffset).toString()
val beforeTokenText = outputModel.getText((tokenStartOffset - 1).coerceAtLeast(outputModel.startOffset), tokenStartOffset).toString()
if (tokenText.startsWith("'") || tokenText.startsWith("\"")) {
// Token already starts with a quote, so let's insert the token as is.
if (beforeTokenText == "'" || beforeTokenText == "\"") {
// There is a quote before the token, so let's insert it as is.
return CompletionItemInsertionInfo(baseInsertValue, initialBeforeReplacementLength, initialAfterReplacementLength)
}
@@ -1,6 +1,7 @@
package com.intellij.terminal.frontend.view.completion
import com.intellij.terminal.completion.spec.ShellCompletionSuggestion
import org.jetbrains.plugins.terminal.block.completion.TerminalCompletionUtil
import org.jetbrains.plugins.terminal.block.completion.spec.ShellDataGenerators
import org.jetbrains.plugins.terminal.block.completion.spec.impl.TerminalCommandCompletionServices
@@ -15,7 +16,7 @@ internal class TerminalFilesCompletionContributor : TerminalCommandCompletionCon
return null
}
val prefix = commandTokens.last()
val prefix = TerminalCompletionUtil.getTypedPrefix(commandTokens)
val completionServices = context.editor.getUserData(TerminalCommandCompletionServices.KEY) ?: return null
val suggestions = computeFileSuggestions(commandTokens, completionServices)
return TerminalCommandCompletionResult(suggestions, prefix)
@@ -103,7 +103,7 @@ internal class TerminalCommandSpecCompletionContributorGen1 : CompletionContribu
shellType: ShellType,
) {
val prefixReplacementIndex = suggestions.firstOrNull()?.prefixReplacementIndex ?: 0
val prefix = allTokens.last().substring(prefixReplacementIndex)
val prefix = TerminalCompletionUtil.getTypedPrefix(allTokens).substring(prefixReplacementIndex)
val resultSet = result.withPrefixMatcher(PlainPrefixMatcher(prefix, true))
val elements = suggestions.map { it.toLookupElement(shellType) }
@@ -104,4 +104,17 @@ object TerminalCompletionUtil {
fun throwUnsupportedInExpTerminalException(): Nothing {
throw UnsupportedOperationException("This API is not supported in Experimental 2024 Terminal")
}
/**
* [tokens] - the words of the command text.
* Considers the last token as the currently typed prefix without starting quotes.
*/
fun getTypedPrefix(tokens: List<String>): String {
check(tokens.isNotEmpty()) { "tokens should not be empty" }
val last = tokens.last()
return if (last.startsWith("'") || last.startsWith('"')) {
last.drop(1)
}
else last
}
}
@@ -52,7 +52,7 @@ object ShellDataGenerators {
): List<ShellCompletionSuggestion> {
val path = getParentPath(pathPrefix)
val files: List<ShellFileInfo> = context.getChildFiles(path, onlyDirectories)
val prefixReplacementIndex = path.length + (if (isStartWithQuote(context.typedPrefix)) 1 else 0) + replacementIndexDelta
val prefixReplacementIndex = path.length + replacementIndexDelta
val suggestions = files.flatMap {
val type = if (it.type == ShellFileInfo.Type.DIRECTORY) ShellSuggestionType.FOLDER else ShellSuggestionType.FILE
val name = it.name + if (type == ShellSuggestionType.FOLDER) File.separator else ""
@@ -74,7 +74,7 @@ object ShellDataGenerators {
listOf(suggestion, hiddenSuggestion)
}
}
val adjustedPrefix = pathPrefix.removePrefix("\"").removeSuffix("'")
val adjustedPrefix = pathPrefix.removeSuffix("\"").removeSuffix("'")
// If the base path is the same as the typed prefix, then add an empty suggestion.
// Because the current typed prefix is already a valid value of the file argument.
// It is necessary for the parser to consider the current typed prefix as a valid file suggestion.
@@ -151,28 +151,10 @@ object ShellDataGenerators {
charArrayOf('\\', '/')
}
else charArrayOf('/')
// Remove possible quotes before and after
// TODO: quotes should not be handled there, typed prefix should already contain no quotes.
val adjustedPrefix = typedPrefix.removePrefix(DOUBLE_QUOTE).removeSuffix(DOUBLE_QUOTE)
.removePrefix(SINGLE_QUOTE).removeSuffix(SINGLE_QUOTE)
val lastSeparatorIndex = adjustedPrefix.lastIndexOfAny(pathSeparators)
val lastSeparatorIndex = typedPrefix.lastIndexOfAny(pathSeparators)
return if (lastSeparatorIndex != -1) {
adjustedPrefix.substring(0, lastSeparatorIndex + 1)
typedPrefix.substring(0, lastSeparatorIndex + 1)
}
else ""
}
/**
* Modern shells supports wrapping command arguments in single or double quotes.
* For example,
* 1. `ls "<dir_path>"`
* 2. `cat '<file_path>'`
* It is expected to skip the quote, because actually it is not part of the path.
* @see [com.intellij.terminal.completion.spec.ShellCompletionSuggestion.prefixReplacementIndex]
* @return true if [typedPrefix] starts with single or double quote, false otherwise
*/
fun isStartWithQuote(typedPrefix: String): Boolean = typedPrefix.startsWith(DOUBLE_QUOTE) || typedPrefix.startsWith(SINGLE_QUOTE)
private const val SINGLE_QUOTE = "'"
private const val DOUBLE_QUOTE = "\""
}
@@ -4,6 +4,7 @@ package org.jetbrains.plugins.terminal.block.completion.spec.impl
import com.intellij.openapi.util.UserDataHolderBase
import com.intellij.terminal.completion.spec.*
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.plugins.terminal.block.completion.TerminalCompletionUtil
import org.jetbrains.plugins.terminal.block.completion.TerminalCompletionUtil.throwUnsupportedInExpTerminalException
import org.jetbrains.plugins.terminal.block.completion.spec.ShellDataGeneratorProcessExecutor
import org.jetbrains.plugins.terminal.block.completion.spec.ShellFileSystemSupport
@@ -18,7 +19,7 @@ class ShellRuntimeContextImpl(
private val generatorProcessExecutor: ShellDataGeneratorProcessExecutor? = null,
private val fileSystemSupport: ShellFileSystemSupport? = null,
) : ShellRuntimeContext, UserDataHolderBase() {
override val typedPrefix: String = commandTokens.last()
override val typedPrefix: String = TerminalCompletionUtil.getTypedPrefix(commandTokens)
override val shellName: ShellName
get() = definedShellName ?: throw UnsupportedOperationException("Not supported in Reworked Terminal")
@@ -22,7 +22,7 @@ internal fun cdCommandSpec() = ShellCommandSpec("cd") {
) { context ->
val path = getParentPath(context.typedPrefix)
val directories = context.getChildFiles(path, onlyDirectories = true)
val replacementIndex = path.length + if (context.typedPrefix.startsWith('"')) 1 else 0
val replacementIndex = path.length
val suggestions = directories.flatMap {
val suggestion = ShellCompletionSuggestion(it.name + File.separator) {
type(ShellSuggestionType.FOLDER)
@@ -35,8 +35,7 @@ internal fun cdCommandSpec() = ShellCommandSpec("cd") {
}
listOf(suggestion, hiddenSuggestion)
}
val adjustedPrefix = context.typedPrefix.removePrefix("\"").removeSuffix("'")
if (path.isNotEmpty() && path == adjustedPrefix) {
if (path.isNotEmpty() && path == context.typedPrefix) {
val emptySuggestion = ShellCompletionSuggestion("") {
prefixReplacementIndex(replacementIndex)
hidden()
@@ -443,6 +443,31 @@ internal class ShellCommandSpecSuggestionsTest(private val engine: TerminalEngin
)
}
@Test
fun `suggest subcommands after quote`() {
assertSameElements(
getSuggestions(arguments = listOf("reqSub"), incompleteToken = "'"),
listOf("abc")
)
}
@Test
fun `suggest options after quote`() {
assertSameElements(
getSuggestions(arguments = listOf("sub"), incompleteToken = "'"),
listOf("-o", "--opt1", "-a", "--long", "--withReqArg", "--withOptArg", "--bcde", "file")
)
}
@Test
fun `suggest filenames and options after quote`() {
mockFilePathsSuggestions("file.txt", "dir$separator", "folder$separator")
assertSameElements(
getSuggestions(arguments = listOf("cdWithSuggestions"), incompleteToken = "'"),
listOf("dir$separator", "folder$separator", "-", "~", "--bcde")
)
}
@Test
fun `suggest filenames for path in single quotes`() {
mockFilePathsSuggestions("file.txt", "dir$separator", "folder$separator")
@@ -370,6 +370,47 @@ internal class ShellCommandTreeBuilderTest(private val engine: TerminalEngine) {
}
}
@Test
fun `subcommand in quotes is resolved`() {
doTest("'sub'") {
assertSubcommandOf("'sub'", commandName)
}
}
@Test
fun `option in quotes is resolved`() {
doTest("'--argum'") {
assertOptionOf("'--argum'", commandName)
}
}
@Test
fun `argument in quotes is resolved`() {
doTest("--argum", "'someArg'") {
assertOptionOf("--argum", commandName)
assertArgumentOfOption("'someArg'", "--argum")
}
}
@Test
fun `options and arguments in quotes are resolved`() {
doTest("--argum", "'someArg'", "'bbb'") {
assertOptionOf("--argum", commandName)
assertArgumentOfOption("'someArg'", "--argum")
assertArgumentOfSubcommand("'bbb'", commandName)
}
}
@Test
fun `file path in quotes is resolved`() {
mockFilePathsSuggestions("dir${separator}" to listOf("file.txt", "folder${separator}", "file"))
doTest("withFiles", "'dir${separator}'") {
assertSubcommandOf("withFiles", commandName)
assertArgumentOfSubcommand("'dir${separator}'", "withFiles")
}
}
@Test
fun `option is resolved after unknown token`() {
doTest("unknown", "--bcde") {
@@ -54,7 +54,7 @@ internal class TerminalCompletionPowerShellEscapingTest : BasePlatformTestCase()
fun `suggestion is inserted as is if there is a starting quote`() {
doTest { fixture ->
fixture.mockSuggestions(
prefixReplacementIndex = 4,
prefixReplacementIndex = 3,
"with spaces",
"dummy"
)
@@ -85,7 +85,7 @@ internal class TerminalCompletionPowerShellEscapingTest : BasePlatformTestCase()
fun `closing quote is not duplicated when starting with quote`() {
doTest { fixture ->
fixture.mockSuggestions(
prefixReplacementIndex = 4,
prefixReplacementIndex = 3,
"with spaces",
"dummy"
)
@@ -21,7 +21,7 @@ internal class TerminalCompletionUnixShellsEscapingTest : BasePlatformTestCase()
fun `suggestion is inserted as is if there is a starting quote`() {
doTest { fixture ->
fixture.mockSuggestions(
prefixReplacementIndex = 8,
prefixReplacementIndex = 7,
"with spaces",
"dummy"
)