[Java. Terminal] Add support for completion in jar files for java command in new terminal

IDEA-364892

GitOrigin-RevId: c79b5d4e370a2d77fcc5960e19d414d4cf1ed3a6
This commit is contained in:
Georgii Ustinov
2025-01-08 09:20:28 +00:00
committed by intellij-monorepo-bot
parent 4c202ca663
commit 9fe79cd05b
5 changed files with 185 additions and 35 deletions
@@ -53,6 +53,7 @@ class JavaShellCommandSpecsProvider : ShellCommandSpecsProvider {
exclusiveOn = listOf("--class-path")
description(JavaTerminalBundle.message("java.command.terminal.classpath.option.description"))
argument {
suggestions(JavaShellCommandUtils.classpathSuggestionsGenerator())
displayName(CLASSPATH_ARGUMENT_NAME)
}
}
@@ -114,6 +115,7 @@ class JavaShellCommandSpecsProvider : ShellCommandSpecsProvider {
option("--class-path") {
description(JavaTerminalBundle.message("java.command.terminal.classpath.option.description"))
argument {
suggestions(JavaShellCommandUtils.classpathSuggestionsGenerator())
displayName(CLASSPATH_ARGUMENT_NAME)
}
}
@@ -2,10 +2,48 @@
package com.intellij.java.terminal
import com.intellij.openapi.util.SystemInfo
import com.intellij.terminal.completion.spec.ShellCompletionSuggestion
import com.intellij.terminal.completion.spec.ShellRuntimeContext
import com.intellij.terminal.completion.spec.ShellRuntimeDataGenerator
import org.jetbrains.plugins.terminal.block.completion.spec.ShellDataGenerators
import org.jetbrains.plugins.terminal.block.completion.spec.ShellRuntimeDataGenerator
object JavaShellCommandUtils {
fun getClassPathSeparator() = when {
private const val SEPARATOR_NOT_FOUND_INDEX = -1
fun getClassPathSeparator(): String = when {
SystemInfo.isWindows -> ";"
else -> ":"
}
fun classpathSuggestionsGenerator(): ShellRuntimeDataGenerator<List<ShellCompletionSuggestion>> {
val key = "classpath generator"
return ShellRuntimeDataGenerator(
debugName = key,
getCacheKey = {
val pathInfo = getPathInfo(it)
"$key:${pathInfo.typedPrefix}:${pathInfo.replacementIndexDelta}"
}
) { context ->
val pathInfo = getPathInfo(context)
ShellDataGenerators.getFileSuggestions(context, pathInfo.typedPrefix, false, pathInfo.replacementIndexDelta)
}
}
private fun getPathInfo(context: ShellRuntimeContext): PathInfo {
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))
} else {
val adjustedSeparatorIndex = if (isStartWithQuote) separatorIndex else separatorIndex + 1
PathInfo(typedPrefix.substring(separatorIndex + 1), adjustedSeparatorIndex)
}
}
private data class PathInfo(val typedPrefix: String, val replacementIndexDelta: Int = 0)
}
@@ -3,6 +3,7 @@ package com.intellij.java.terminal.completion
import com.intellij.execution.vmOptions.*
import com.intellij.java.terminal.JavaShellCommandContext
import com.intellij.java.terminal.JavaShellCommandUtils
import com.intellij.openapi.application.ApplicationManager
import com.intellij.terminal.completion.spec.ShellCommandResult
import com.intellij.terminal.completion.spec.ShellCompletionSuggestion
@@ -55,19 +56,89 @@ class JavaShellCommandSpecsProviderTest : BasePlatformTestCase() {
UsefulTestCase.assertDoesntContain(fixture.getCompletionNames(), listOf("--add-experimental-exports", "--add-diagnostic-exports", "-XXadvanced"))
}
@Test
fun `classpath suggestion generator with single quote`() = runBlocking {
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})
}
@Test
fun `classpath suggestion generator single quote and after separator`() = runBlocking {
val separator = JavaShellCommandUtils.getClassPathSeparator()
val fixture = createFixture()
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 })
}
@Test
fun `classpath suggestion generator with double quote`() = runBlocking {
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})
}
@Test
fun `classpath suggestion generator with double quote and after separator`() = runBlocking {
val separator = JavaShellCommandUtils.getClassPathSeparator()
val fixture = createFixture()
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 })
}
@Test
fun `classpath suggestion generator simple`() = runBlocking {
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 == 0})
}
@Test
fun `classpath suggestion generator after separator`() = runBlocking {
val separator = JavaShellCommandUtils.getClassPathSeparator()
val fixture = createFixture()
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})
}
@Test
fun `classpath suggestion generator after double separator`() = runBlocking {
val separator = JavaShellCommandUtils.getClassPathSeparator()
val fixture = createFixture()
val argument = "file1.jar$separator$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})
}
private fun createFixture(javaVersion: Int = 11): ShellCompletionTestFixture {
ApplicationManager.getApplication().replaceService(VMOptionsService::class.java, MockVMOptionsService(), testRootDisposable)
val fixture = ShellCompletionTestFixture.builder(project).mockShellCommandResults { command ->
if (command == JavaShellCommandContext.JAVA_SHOW_SETTINGS_PROPERTIES_VERSION_COMMAND) {
return@mockShellCommandResults ShellCommandResult.create("java.home = /jre/home\njava.version = ${javaVersion}", exitCode = 0)
}
if (command.startsWith("__jetbrains_intellij_get_directory_files")) {
return@mockShellCommandResults ShellCommandResult.create("file1.jar\nfile2.jar\ndir1/", exitCode = 0)
}
return@mockShellCommandResults ShellCommandResult.create("", exitCode = 1)
}.build()
return fixture
}
private suspend fun ShellCompletionTestFixture.getCompletionNames(): List<String> {
val actual: List<ShellCompletionSuggestion> = getCompletions("java ")
private suspend fun ShellCompletionTestFixture.getCompletionNames(command: String = "java "): List<String> {
val actual: List<ShellCompletionSuggestion> = getCompletions(command)
return actual.map { it.name }
}
@@ -31,39 +31,56 @@ object ShellDataGenerators {
debugName = key,
getCacheKey = { "$key:${getParentPath(it.typedPrefix, it.shellName)}" }
) { context ->
val path = getParentPath(context.typedPrefix, context.shellName)
val files: List<String> = context.getChildFiles(path, onlyDirectories)
val prefixReplacementIndex = path.length + if (context.typedPrefix.startsWith('"')) 1 else 0
val suggestions = files.flatMap {
val type = if (it.endsWith(File.separatorChar)) ShellSuggestionType.FOLDER else ShellSuggestionType.FILE
val suggestion = ShellCompletionSuggestion(name = it, type = type, prefixReplacementIndex = prefixReplacementIndex)
if (type == ShellSuggestionType.FILE) {
listOf(suggestion)
}
else {
// Directory suggestion has a trailing file separator, but suggestion without it is also valid.
// It is needed for the parser to consider it as a valid suggestion and not mark it as something unknown.
val hiddenSuggestion = ShellCompletionSuggestion(
name = it.removeSuffix(File.separator),
type = ShellSuggestionType.FOLDER,
prefixReplacementIndex = prefixReplacementIndex,
isHidden = true
)
listOf(suggestion, hiddenSuggestion)
}
}
val adjustedPrefix = context.typedPrefix.removePrefix("\"").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 needed for the parser to consider current typed prefix as a valid file suggestion.
if (path.isNotEmpty() && path == adjustedPrefix) {
val emptySuggestion = ShellCompletionSuggestion(name = "", prefixReplacementIndex = prefixReplacementIndex, isHidden = true)
suggestions + emptySuggestion
}
else suggestions
getFileSuggestions(context, context.typedPrefix, onlyDirectories)
}
}
/**
* Generates file suggestions for a given typed prefix in the specified shell context.
*
* @param context the runtime context of the shell containing information about the current environment
* @param pathPrefix prefix for which completions should be generated
* @param onlyDirectories if only directories should be suggested
* @param replacementIndexDelta adjustment to the replacement starting index for the typed prefix
*/
suspend fun getFileSuggestions(
context: ShellRuntimeContext,
pathPrefix: String,
onlyDirectories: Boolean,
replacementIndexDelta: Int = 0,
): List<ShellCompletionSuggestion> {
val path = getParentPath(pathPrefix, context.shellName)
val files: List<String> = context.getChildFiles(path, onlyDirectories)
val prefixReplacementIndex = path.length + (if (isStartWithQuote(context.typedPrefix)) 1 else 0) + replacementIndexDelta
val suggestions = files.flatMap {
val type = if (it.endsWith(File.separatorChar)) ShellSuggestionType.FOLDER else ShellSuggestionType.FILE
val suggestion = ShellCompletionSuggestion(name = it, type = type, prefixReplacementIndex = prefixReplacementIndex)
if (type == ShellSuggestionType.FILE) {
listOf(suggestion)
}
else {
// Directory suggestion has a trailing file separator, but suggestion without it is also valid.
// It is needed for the parser to consider it as a valid suggestion and not mark it as something unknown.
val hiddenSuggestion = ShellCompletionSuggestion(
name = it.removeSuffix(File.separator),
type = ShellSuggestionType.FOLDER,
prefixReplacementIndex = prefixReplacementIndex,
isHidden = true
)
listOf(suggestion, hiddenSuggestion)
}
}
val adjustedPrefix = pathPrefix.removePrefix("\"").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 needed for the parser to consider current typed prefix as a valid file suggestion.
return if (path.isNotEmpty() && path == adjustedPrefix) {
val emptySuggestion = ShellCompletionSuggestion(name = "", prefixReplacementIndex = prefixReplacementIndex, isHidden = true)
suggestions + emptySuggestion
}
else suggestions
}
/**
* Provides the list of all available commands, functions, keywords and aliases available in the Shell.
* Useful for the commands that accept the other shell command as an argument.
@@ -125,11 +142,26 @@ object ShellDataGenerators {
else charArrayOf(separator)
// Remove possible quotes before and after
// TODO: quotes should not be handled there, typed prefix should already contain no quotes.
val adjustedPrefix = typedPrefix.removePrefix("\"").removeSuffix("'")
val adjustedPrefix = typedPrefix.removePrefix(DOUBLE_QUOTE).removeSuffix(DOUBLE_QUOTE)
.removePrefix(SINGLE_QUOTE).removeSuffix(SINGLE_QUOTE)
val lastSeparatorIndex = adjustedPrefix.lastIndexOfAny(pathSeparators)
return if (lastSeparatorIndex != -1) {
adjustedPrefix.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 = "\""
}
@@ -346,7 +346,14 @@ internal class ShellCommandSpecSuggestionsTest {
}
@Test
fun `suggest filenames for path in quotes`() {
fun `suggest filenames for path in single quotes`() {
val separator = File.separatorChar
mockFilePathsSuggestions("file.txt", "dir$separator", "folder$separator")
assertSameElements(getSuggestions(listOf("cd"), "'someDir$separator"), listOf("dir$separator", "folder$separator"))
}
@Test
fun `suggest filenames for path in double quotes`() {
val separator = File.separatorChar
mockFilePathsSuggestions("file.txt", "dir$separator", "folder$separator")
assertSameElements(getSuggestions(listOf("cd"), "\"someDir$separator"), listOf("dir$separator", "folder$separator"))