mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[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:
committed by
intellij-monorepo-bot
parent
f5c1f5f009
commit
6f0132fd23
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
|
||||
+16
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-22
@@ -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 = "\""
|
||||
}
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+2
-3
@@ -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()
|
||||
|
||||
+25
@@ -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")
|
||||
|
||||
+41
@@ -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") {
|
||||
|
||||
+2
-2
@@ -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"
|
||||
)
|
||||
|
||||
+1
-1
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user