diff --git a/.github/workflows/IJ-latest.yml b/.github/workflows/IJ-latest.yml index c13985c2e..3e6ddf448 100644 --- a/.github/workflows/IJ-latest.yml +++ b/.github/workflows/IJ-latest.yml @@ -23,7 +23,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/.github/workflows/IJ.yml b/.github/workflows/IJ.yml index f5444cef6..22589807f 100644 --- a/.github/workflows/IJ.yml +++ b/.github/workflows/IJ.yml @@ -29,7 +29,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/.github/workflows/buildZip.yml b/.github/workflows/buildZip.yml index 76746fa7d..698529d4f 100644 --- a/.github/workflows/buildZip.yml +++ b/.github/workflows/buildZip.yml @@ -27,7 +27,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 299f8deac..ca85f54b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 27ac973c3..9589620eb 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,7 +53,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c671231b..3d4374c9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 17 + java-version: 21 cache: 'gradle' # Setup Gradle diff --git a/README.md b/README.md index 76984ee8d..ede8793ef 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Here are some projects that use LSP4IJ: * [IntelliJ SumnekoLua](https://github.com/CppCXY/Intellij-SumnekoLua) * [Haskell LSP for IntelliJ](https://github.com/rockofox/intellij-haskell-lsp) * [Zowe™ COBOL Language Support](https://github.com/zowe/zowe-cobol-language-support-intellij) + * [Nim using nimlangserver](https://codeberg.org/laamella-gad/nim-intellij-plugin) * [ZigBrains](https://github.com/FalsePattern/ZigBrains) * [Pyright for PyCharm](https://github.com/InSyncWithFoo/pyright-for-pycharm) * [Intellij EmmyLua2](https://github.com/EmmyLua/Intellij-EmmyLua2) @@ -73,6 +74,7 @@ Here are some projects that use LSP4IJ: * [Intellij KCL](https://github.com/kcl-lang/intellij-kcl) * [Ruff for PyCharm](https://github.com/koxudaxi/ruff-pycharm-plugin) * [Intellij Gleam](https://github.com/themartdev/intellij-gleam) + * [intellij-lumos](https://github.com/getlumos/intellij-lumos) * [Liberty Tools for IntelliJ](https://github.com/OpenLiberty/liberty-tools-intellij) * [Vespa Schema Language Support](https://github.com/vespa-engine/vespa/tree/master/integration/schema-language-server/clients/intellij) * [Jimmer DTO LSP](https://github.com/Enaium/jimmer-dto-lsp) @@ -89,10 +91,24 @@ Here are some projects that use LSP4IJ: * [SDLB LSP](https://github.com/smart-data-lake/sdl-lsp) * [Bloc Linter Support for IntelliJ](https://github.com/felangel/bloc) * [Vala JetBrains Plugin](https://github.com/Tbusk/vala-jetbrains-plugin) + * [BAML JetBrains Plugin](http://github.com/BoundaryML/baml/tree/canary/jetbrains) + * [intellij-cangjie](https://github.com/lin-qingying/intellij-cangjie) + * [poryscript-idea](https://github.com/okafke/poryscript-idea) + * [t-ruby](https://github.com/type-ruby/t-ruby) + * [Rovo LSP - IntelliJ](https://github.com/arthurdw/rovo) + * [Fish Shell for JetBrains](https://github.com/tox-dev/jetbrains-fish) + * [GN Language Server](https://github.com/google/gn-language-server/) + * [OPA IDEA Plugin](https://github.com/open-policy-agent/opa-idea-plugin) + * [aer Local Apex Debugger](https://www.octoberswimmer.com/tools/aer/docs/intellij-debugging/) + * [Rider Unreal Angelscript](https://github.com/scriptacus/rider-unreal-angelscript) + * [agnix](https://github.com/agent-sh/agnix) + * [Xonsh plugin for JetBrains IDEs](https://github.com/nahoj/xonsh-jetbrains) + * [Rell IntelliJ Plugin](https://bitbucket.org/chromawallet/rell-jetbrains) + * [Dart](https://github.com/flutter/dart-intellij-third-party) ## Requirements -* Intellij IDEA 2023.3 or more recent (we **try** to support the last 4 major IDEA releases) +* Intellij IDEA 2024.2 or more recent (we **try** to support the last 4 major IDEA releases) * Java JDK (or JRE) 17 or more recent ## Contributing diff --git a/build.gradle.kts b/build.gradle.kts index 41716c4f1..acdd130ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,10 @@ import com.adarshr.gradle.testlogger.theme.ThemeType import org.jetbrains.changelog.markdownToHTML +import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.FailureLevel.* import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask.VerificationReportsFormats.* import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.models.ProductRelease fun properties(key: String) = providers.gradleProperty(key) @@ -21,8 +23,21 @@ plugins { jacoco // Code coverage } -group = prop("pluginGroup") -version = prop("pluginVersion") +val snapshotVersion = providers.gradleProperty("snapshotVersion").orNull +val pluginVersion = providers.gradleProperty("pluginVersion").get() + +group = providers.gradleProperty("pluginGroup").get() + +version = kotlin.run { + val buildVersion = providers.gradleProperty("buildVersion") + if (buildVersion.isPresent) { + return@run when { + buildVersion.get().startsWith("openide.") -> buildVersion.get().substring(8) + else -> buildVersion.get() + } + } + return@run "${pluginVersion}.${snapshotVersion ?: "SNAPSHOT"}" +} val lsp4jVersion = prop("lsp4jVersion") val lsp4jDebugVersion = prop("lsp4jDebugVersion") @@ -39,6 +54,7 @@ val ideaVersionInt = when { // Configure project's dependencies repositories { mavenLocal() + maven("https://cache-redirector.jetbrains.com/repo1.maven.org/maven2") mavenCentral() // IntelliJ Platform Gradle Plugin Repositories Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-repositories-extension.html @@ -54,7 +70,9 @@ dependencies { // IntelliJ Platform Gradle Plugin Dependencies Extension - read more: https://plugins.jetbrains.com/docs/intellij/tools-intellij-platform-gradle-plugin-dependencies-extension.html intellijPlatform { - create(properties("platformType"), properties("platformVersion")) + // Disabling the installer dependency since IntelliJ 253 (2025.3.x) and potentially other + // early access/snapshot versions may lack direct installer packages in the artifact repositories. + create(properties("platformType"), properties("platformVersion"), useInstaller = false) // Plugin Dependencies. Uses `platformBundledPlugins` property from the gradle.properties file for bundled IntelliJ Platform plugins. // starting from 2024.3, all json related code is know on its own plugin @@ -72,6 +90,11 @@ dependencies { // Plugin Dependencies. Uses `platformPlugins` property from the gradle.properties file for plugin from JetBrains Marketplace. plugins(properties("platformPlugins").map { it.split(',') }) + if (ideaVersionInt >= 252) { + // Since 2025.2, JetBrains moved spellchecker in a new "intellij.spellchecker" module + bundledModule("intellij.spellchecker") + } + pluginVerifier() zipSigner() testFramework(TestFrameworkType.Platform) @@ -97,13 +120,30 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.junit.jupiter.api) + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly(libs.junit.vintage.engine) + // Required for IntelliJ Platform 2026.1 to discover and run tests via JUnit Platform + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.0") } -// Set the JVM language level used to build the project. +// Set the JDK version used by the toolchain to build the project (Java 21 is required for platform 2025.3). +// Output compatibility and API usage are restricted to Java 17 in the tasks below. kotlin { jvmToolchain { - languageVersion = JavaLanguageVersion.of(17) - vendor = JvmVendorSpec.JETBRAINS + languageVersion = JavaLanguageVersion.of(21) + } +} + +// Maintain compatibility with Java 17 runtimes, ensuring the built plugin +// can run on older IDEs even though the toolchain uses Java 21 for the 253 platform. +tasks.withType { + options.release.set(17) +} + +// Set Kotlin JVM language level. While there's currently no Kotlin in this project, it is configured with the Kotlin plugin. +tasks.withType { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } } @@ -135,23 +175,36 @@ intellijPlatform { } signing { - certificateChain = environment("CERTIFICATE_CHAIN") - privateKey = environment("PRIVATE_KEY") - password = environment("PRIVATE_KEY_PASSWORD") + if (providers.gradleProperty("useSign").isPresent) { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } } publishing { - token = environment("PUBLISH_TOKEN") - channels = properties("channel").map { listOf(it) } + val publishToken = providers.gradleProperty("publishToken") + if (publishToken.isPresent) { + token = publishToken.get() + host = "https://plugins.openide.ru" + } } pluginVerification { failureLevel = listOf(INVALID_PLUGIN, COMPATIBILITY_PROBLEMS, MISSING_DEPENDENCIES) verificationReportsFormats = listOf(MARKDOWN, PLAIN) ides { - recommended() + select { + types = listOf(IntelliJPlatformType.IntellijIdea) + channels = listOf(ProductRelease.Channel.RELEASE) // Only stable releases + sinceBuild = "242" // From your minimum supported version + untilBuild = "261.*" // Up to current major version + } } } + + // Don't need this since we don't provide any custom settings + buildSearchableOptions.set(false) } idea { @@ -194,6 +247,7 @@ changelog { } tasks.withType { + useJUnitPlatform() environment("GRADLE_RELEASE_REPOSITORY","https://services.gradle.org/distributions") systemProperty("idea.log.leaked.projects.in.tests", "false") systemProperty( "idea.maven.test.mirror", "https://repo1.maven.org/maven2") @@ -240,7 +294,81 @@ tasks { } } - check { - dependsOn(jacocoTestReport) + val checkNativeLibraries by registering { + group = "verification" + description = "Checks plugin dependencies for native libraries (.dll, .so, .dylib)" + notCompatibleWithConfigurationCache("Accesses resolved configurations at task execution time") + + doLast { + val nativeExtensions = listOf(".dll", ".so", ".dylib", ".jnilib") + val jetbrainsGroups = listOf("org.jetbrains", "com.jetbrains", "com.intellij") + + val runtimeConfig = configurations.getByName("runtimeClasspath") + val findings = mutableMapOf>() + + runtimeConfig.resolvedConfiguration.resolvedArtifacts.forEach { artifact -> + val moduleId = artifact.moduleVersion.id + val group = moduleId.group.lowercase() + + if (jetbrainsGroups.any { group.startsWith(it.lowercase()) }) { + return@forEach + } + + val file = artifact.file + if (file.extension != "jar") { + return@forEach + } + + val nativeFiles = mutableListOf() + try { + project.zipTree(file).visit { + if (!isDirectory) { + val path = relativePath.pathString + val lowercasePath = path.lowercase() + + val hasNativeExtension = nativeExtensions.any { + lowercasePath.endsWith(it) + } + + val isVersionedSo = lowercasePath.matches(Regex(".*\\.so\\.\\d+.*")) + + val inNativeDir = lowercasePath.contains("/native/") || + lowercasePath.contains("/natives/") + + if (hasNativeExtension || isVersionedSo || inNativeDir) { + nativeFiles.add(path) + } + } + } + } catch (e: Exception) { + logger.warn("Failed to inspect JAR ${file.name}: ${e.message}") + } + + if (nativeFiles.isNotEmpty()) { + val depKey = "${moduleId.group}:${moduleId.name}:${moduleId.version}" + findings[depKey] = nativeFiles + } + } + + if (findings.isNotEmpty()) { + logger.error("==========================================") + logger.error("Native libraries found in dependencies:") + logger.error("==========================================") + findings.forEach { (dep, files) -> + logger.error("===> $dep") + files.forEach { file -> + logger.error(" └─ $file") + } + } + logger.error("==========================================") + logger.error("Total: ${findings.size} dependencies with native libraries") + + throw GradleException( + "Build failed: Native libraries detected in ${findings.size} dependencies" + ) + } else { + logger.lifecycle("✓ No native libraries found in plugin dependencies") + } + } } } diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 0e6bedd8c..8eae49306 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -757,6 +757,468 @@ CommandExecutor.executeCommand(commandContext) * `LanguageClientImpl#createSettings()` which must return a Gson JsonObject of your configuration. * or `LanguageClientImpl#findSettings(String section)` if you don't want to work with GSon JsonObject. + +# Workspace Folders + +LSP4IJ provides support for [workspace/workspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders) to control which directories are sent to the language server as workspace roots +with [WorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategy.java) API. + +## Default Behavior + +By default, LSP4IJ uses the [ProjectWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java) which sends all project base directories as workspace folders during initialization. + +## Workspace Folder Strategy + +You can customize workspace folder discovery by using one of the built-in strategies, configuring a strategy with JSON, or implementing your own [WorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategy.java). + +### Built-in Strategies + +LSP4IJ provides several built-in strategies: + +#### ProjectWorkspaceFolderStrategy (Default) + +Uses the IntelliJ [ProjectWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java) project base directories as workspace folders. By default, all folders are sent during initialization. + +To customize the workspace folder strategy, you need to: + +1. Create a class that extends `LSPWorkspaceFolderFeature` and overrides `createStrategy()`: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ProjectWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new ProjectWorkspaceFolderStrategy(); + } +} +``` + +2. In your `LanguageServerFactory`, create `LSPClientFeatures` and set the workspace folder feature: + +```java +package my.language.server; + +import com.intellij.openapi.project.Project; +import com.redhat.devtools.lsp4ij.LanguageServerFactory; +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; +import org.jetbrains.annotations.NotNull; + +public class MyLanguageServerFactory implements LanguageServerFactory { + + @Override + public @NotNull LSPClientFeatures createClientFeatures() { + LSPClientFeatures clientFeatures = new LSPClientFeatures(); + clientFeatures.setWorkspaceFolderFeature(new MyWorkspaceFolderFeature()); + return clientFeatures; + } +} +``` + +**Enable lazy loading:** + +You can extend the strategy to enable lazy loading, sending workspace folders progressively via `workspace/didChangeWorkspaceFolders` as files are opened: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ProjectWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new ProjectWorkspaceFolderStrategy(true /* Enable lazy loading */); + } +} +``` + +This is useful for large projects where you want to avoid sending all workspace folders upfront. + +#### SourceRootsWorkspaceFolderStrategy + +Uses IntelliJ module [SourceRootsWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/SourceRootsWorkspaceFolderStrategy.java) source roots as workspace folders. By default, all folders are sent during initialization. + +Useful when you want to expose only source directories (not the entire project root) to the language server. + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.SourceRootsWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new SourceRootsWorkspaceFolderStrategy(); + } +} +``` + +**Enable lazy loading for source roots:** + +You can extend the strategy to enable lazy loading, sending workspace folders on-demand as files are opened: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.SourceRootsWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new SourceRootsWorkspaceFolderStrategy(true /* Enable lazy loading */); + } +} +``` + +#### MarkersWorkspaceFolderStrategy + +Use [MarkersWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/MarkersWorkspaceFolderStrategy.java) +to discover workspace folders dynamically by walking up the directory tree looking for marker files. +Folders are discovered lazily as files are opened. + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.MarkersWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new MarkersWorkspaceFolderStrategy("pyproject.toml", "setup.py"); + } +} +``` + +This is particularly useful for: +- **Monorepos** with multiple projects +- **Python projects** with `pyproject.toml` or `setup.py` +- **Node.js projects** with multiple `package.json` files + +For example, when opening `/monorepo/backend/src/app.py` with markers `["pyproject.toml"]`: +1. Checks `/monorepo/backend/src/` for `pyproject.toml` +2. Checks `/monorepo/backend/` for `pyproject.toml` ← **Found!** +3. Returns `/monorepo/backend/` as workspace folder + +#### ConfigurableWorkspaceFolderStrategy + +[ConfigurableWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java) is a flexible strategy that can be configured with JSON to support different root types, markers, and lazy loading options: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ConfigurableWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + // language=json + String config = """ + { + "markers": ["pyproject.toml", "setup.py"], + "lazy": true + } + """; + ConfigurableWorkspaceFolderStrategy strategy = new ConfigurableWorkspaceFolderStrategy(); + strategy.configure(config); + return strategy; + } +} +``` + +### Lazy vs Eager Loading + +All workspace folder strategies support two loading modes: + +#### Eager Loading (default: `lazy: false`) + +All workspace folders are sent **once during initialization**: + +``` +Client → Server: initialize(workspaceFolders: ["/project/backend", "/project/frontend"]) +Server → Client: initialized +``` + +**Pros:** +- Server has complete workspace context from the start +- Simpler implementation + +**Cons:** +- Slower IDE startup for large projects +- Sends folders that may never be used + +#### Lazy Loading (`lazy: true`) + +Workspace folders are sent **progressively via notifications** as files are opened. + +**Initialization with empty workspace folders:** + +```json +Client → Server: initialize +{ + "workspaceFolders": [] +} + +Server → Client: initialized +``` + +**When a file is opened, the workspace folder is discovered and sent:** + +``` +User opens: C:/Users/Foo/lsp4ij-demo/src/Main.java + +[Trace] Sending notification 'workspace/didChangeWorkspaceFolders' +Params: { + "event": { + "added": [ + { + "uri": "file:///C:/Users/Foo/lsp4ij-demo", + "name": "lsp4ij-demo" + } + ], + "removed": [] + } +} +``` + +**Subsequent files in the same workspace folder don't trigger notifications:** + +``` +User opens: C:/Users/Foo/lsp4ij-demo/src/Utils.java +// No notification sent - workspace folder already known +``` + +**Opening a file from a different workspace folder:** + +``` +User opens: C:/Users/Foo/lsp4ij-demo/another-project/src/App.java + +[Trace] Sending notification 'workspace/didChangeWorkspaceFolders' +Params: { + "event": { + "added": [ + { + "uri": "file:///C:/Users/Foo/lsp4ij-demo/another-project", + "name": "another-project" + } + ], + "removed": [] + } +} +``` + +**Pros:** +- Faster IDE startup (no full project scan) +- Reduced memory usage (only active workspace folders) +- Ideal for monorepos + +**Cons:** +- Server must support `workspace/didChangeWorkspaceFolders` +- More complex notification flow + +**When to use lazy loading:** +- Large monorepos with multiple projects +- Projects with many modules (only load modules being edited) +- Marker-based strategies (avoid scanning entire filesystem) + +### Configuration Options + +The [ConfigurableWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java) supports the following JSON configuration: + +#### Root Type + +Determines how workspace folders are discovered: + +- **`PROJECT_BASE`** (default): Uses IntelliJ project base directories +- **`SOURCE_ROOTS`**: Uses module source roots +- **`NONE`**: No workspace folders + +```json +{ + "rootType": "SOURCE_ROOTS" +} +``` + +#### Marker-based Discovery + +When `markers` is specified, LSP4IJ walks up the directory tree from each opened file looking for the specified marker files: + +```json +{ + "markers": ["pyproject.toml", "setup.py", "requirements.txt"] +} +``` + +For example, when opening `/project/src/main/app.py`, LSP4IJ will: +1. Check if `/project/src/main/` contains a marker file +2. Check if `/project/src/` contains a marker file +3. Check if `/project/` contains a marker file +4. Return the first directory containing any of the marker files + +This is useful for monorepos with multiple language server roots. + +#### Lazy Loading + +Controls when and how workspace folders are sent to the server: + +- **`lazy: false`** (default): All workspace folders are sent **during initialization** via the `initialize` request +- **`lazy: true`**: Workspace folders are discovered and sent **on-demand** as files are opened via `workspace/didChangeWorkspaceFolders` notification + +**How lazy loading works:** + +When `lazy: true`, the language server receives workspace folders progressively: + +1. **Initialization**: Empty or minimal workspace folders list sent in `initialize` request +2. **File opened**: User opens `/project/backend/src/app.py` +3. **Discovery**: LSP4IJ discovers the workspace folder (e.g., `/project/backend/`) +4. **Notification**: Sends `workspace/didChangeWorkspaceFolders` with the new folder added +5. **Repeat**: Each time a file from a new workspace folder is opened, a notification is sent + +**Benefits:** +- Faster IDE startup (no full project scan) +- Reduced memory usage (server only loads active workspace folders) +- Better for large monorepos with many projects + +**Example - Lazy loading with markers:** +```json +{ + "markers": ["pyproject.toml"] +} +``` + +**Example - Lazy loading with source roots:** +```json +{ + "rootType": "SOURCE_ROOTS", + "lazy": true +} +``` + +### Configuration Examples + +**Python project with pyproject.toml:** +```json +{ + "markers": ["pyproject.toml"] +} +``` + +**Multi-module project using source roots:** +```json +{ + "rootType": "SOURCE_ROOTS", + "lazy": false +} +``` + +**Monorepo with multiple package.json:** +```json +{ + "markers": ["package.json"] +} +``` + +### Custom Strategy + +For complete control, implement your own `WorkspaceFolderStrategy`: + +1. Create your custom strategy implementation: + +```java +package my.language.server; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class MyWorkspaceFolderStrategy implements WorkspaceFolderStrategy { + + @Override + public @NotNull List getInitialWorkspaceFolders( + @NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + // Return folders to send during initialization + List folders = new ArrayList<>(); + // ... custom logic ... + return folders; + } + + @Override + public @Nullable WorkspaceFolder getWorkspaceFolderForFile( + @NotNull VirtualFile file, + @NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + // Return the workspace folder for a given file + // ... custom logic ... + return null; + } + + @Override + public boolean sendAllFoldersOnInitialization() { + // Return true to send all folders at init, false for lazy loading + return true; + } +} +``` + +2. Create a workspace folder feature that uses your custom strategy: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + return new MyWorkspaceFolderStrategy(); + } +} +``` + +## User-Defined Language Server Configuration + +For user-defined language servers, workspace folder configuration can be specified in the `workspaceFolderSettings.json` template file or configured in the UI: + +![Workspace Folders Configuration](./images/WorkspaceFoldersConfiguration.png) + +The JSON configuration follows the same format as the programmatic API. + # Semantic tokens colors provider diff --git a/docs/LSPApi.md b/docs/LSPApi.md index e15dcec86..41bef3ea1 100644 --- a/docs/LSPApi.md +++ b/docs/LSPApi.md @@ -27,9 +27,11 @@ The [LSPClientFeatures](https://github.com/redhat-developer/lsp4ij/blob/main/src - [LSP typeDefinition feature](#lsp-typeDefinition-feature) - [LSP usage feature](#lsp-usage-feature) - [LSP workspace symbol feature](#lsp-workspace-symbol-feature) +- [LSP workspace folder feature](#lsp-workspace-folder-feature) - [LSP breadcrumbs feature](#lsp-breadcrumbs-feature) - [LSP editor behavior feature](#lsp-editor-behavior-feature) - [JSON-RPC communication feature](#json-rpc-communication-feature) +- [Custom launcher builder](#custom-launcher-builder) - [Language server installer](#language-server-installer) You can extend these default features by: @@ -494,21 +496,24 @@ public class MyLSPDocumentSymbolFeature extends LSPDocumentSymbolFeature { ## LSP Formatting Feature -| API | Description | Default Behaviour | -|----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| -| boolean isEnabled(PsiFile file) | Returns `true` if the LSP feature is enabled for the given file and `false` otherwise. | `true` | -| boolean isSupported(PsiFile file) | Returns `true` if the LSP feature is supported for the given file and `false` otherwise.
This supported state is called after starting the language server, which matches the file and user with the LSP server capabilities. | Check the server capability | -| boolean isRangeFormattingSupported(PsiFile file) | Returns `true` if the range formatting is supported for the given file and `false` otherwise. | Check the server capability | -| boolean isExistingFormatterOverrideable(PsiFile file) | Returns `true` if existing formatters are overrideable and `false` otherwise. | `false` | -| boolean isOnTypeFormattingEnabled(PsiFile file) | Whether or not server-side on-type formatting is enabled if `textDocument/onTypeFormatting` is supported by the server. | `true` | -| boolean isFormatOnCloseBrace(PsiFile file) | Whether or not to format the file when close braces are typed. | `false` | -| boolean getFormatOnCloseBraceCharacters(PsiFile file) | The specific close brace characters that should trigger on-type formatting in the file. | The language's standard close brace characters. | -| boolean getFormatOnCloseBraceScope(PsiFile file) | The scope that should be formatted when a close brace is typed. Allowed values are `CODE_BLOCK` and `FILE`. | `CODE_BLOCK` | -| boolean isFormatOnStatementTerminator(PsiFile file) | Whether or not to format the file when statement terminators are typed. | `false` | -| boolean getFormatOnStatementTerminatorCharacters(PsiFile file) | The specific statement terminator characters that should trigger on-type formatting in the file. | None. | -| boolean getFormatOnStatementTerminatorScope(PsiFile file) | The scope that should be formatted when a statement terminator is typed. Allowed values are `STATEMENT`, `CODE_BLOCK` and `FILE`. | `STATEMENT` | -| boolean isFormatOnCompletionTrigger(PsiFile file) | Whether or not to format the file when completion triggers are typed. | `false` | -| boolean getFormatOnCompletionTriggerCharacters(PsiFile file) | The specific completion trigger characters that should trigger on-type formatting in the file. | The language's standard completion trigger characters. | +| API | Description | Default Behaviour | +|---------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------| +| boolean isEnabled(PsiFile file) | Returns `true` if the LSP feature is enabled for the given file and `false` otherwise. | `true` | +| boolean isSupported(PsiFile file) | Returns `true` if the LSP feature is supported for the given file and `false` otherwise.
This supported state is called after starting the language server, which matches the file and user with the LSP server capabilities. | Check the server capability | +| boolean isRangeFormattingSupported(PsiFile file) | Returns `true` if the range formatting is supported for the given file and `false` otherwise. | Check the server capability | +| FormattingOptions getFormattingOptions(PsiFile file, Editor editor) | Returns the formatting options for the given file and editor. | Return a formatting options with tabSize and insertSpaces initialized | +| Integer getTabSize(PsiFile file, Editor editor) | Returns the tab size to use for the given file and editor. | Return the default tab size of the IDE | +| Boolean getInsertSpaces(PsiFile file, Editor editor) | Returns the insert spaces to use for the given file and editor. | Return the default insert spaces of the IDE | +| boolean isExistingFormatterOverrideable(PsiFile file) | Returns `true` if existing formatters are overrideable and `false` otherwise. | `false` | +| boolean isOnTypeFormattingEnabled(PsiFile file) | Whether or not server-side on-type formatting is enabled if `textDocument/onTypeFormatting` is supported by the server. | `true` | +| boolean isFormatOnCloseBrace(PsiFile file) | Whether or not to format the file when close braces are typed. | `false` | +| boolean getFormatOnCloseBraceCharacters(PsiFile file) | The specific close brace characters that should trigger on-type formatting in the file. | The language's standard close brace characters. | +| boolean getFormatOnCloseBraceScope(PsiFile file) | The scope that should be formatted when a close brace is typed. Allowed values are `CODE_BLOCK` and `FILE`. | `CODE_BLOCK` | +| boolean isFormatOnStatementTerminator(PsiFile file) | Whether or not to format the file when statement terminators are typed. | `false` | +| boolean getFormatOnStatementTerminatorCharacters(PsiFile file) | The specific statement terminator characters that should trigger on-type formatting in the file. | None. | +| boolean getFormatOnStatementTerminatorScope(PsiFile file) | The scope that should be formatted when a statement terminator is typed. Allowed values are `STATEMENT`, `CODE_BLOCK` and `FILE`. | `STATEMENT` | +| boolean isFormatOnCompletionTrigger(PsiFile file) | Whether or not to format the file when completion triggers are typed. | `false` | +| boolean getFormatOnCompletionTriggerCharacters(PsiFile file) | The specific completion trigger characters that should trigger on-type formatting in the file. | The language's standard completion trigger characters. | Here is an example of code that allows executing the LSP formatter even if there is a specific formatter registered by an IntelliJ plugin: @@ -742,10 +747,15 @@ or, if appropriate, the the child/descendant element that represents the declara ## LSP Usage Feature -| API | Description | Default Behaviour | -|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------| -| boolean isEnabled(PsiFile file) | Returns `true` if the LSP feature is enabled for the given file and `false` otherwise. | `true` | -| boolean isSupported(PsiFile file) | Returns `true` if the LSP feature is supported for the given file and `false` otherwise.
This supported state is called after starting the language server, which matches the file and user with the LSP server capabilities. | Check the server capability | +The LSP Usage Feature determines whether the "Find Usages" functionality is available for a given file or element. This feature aggregates support from multiple LSP capabilities (declaration, type definition, definition, references, and implementation). + +| API | Description | Default Behaviour | +|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| boolean isEnabled(PsiFile file) | Returns `true` if the LSP feature is enabled for the given file and `false` otherwise. | `true` | +| boolean isSupported(PsiFile file) | Returns `true` if the LSP feature is supported for the given file and `false` otherwise.
This supported state is called after starting the language server, which matches the file and user with the LSP server capabilities. | Delegates to `isUsageSupported(PsiFile)` | +| boolean isUsageSupported(PsiFile file) | Returns `true` if the file supports usage searching based on the language server's capabilities for declaration, type definition, definition, references, or implementation features. | Checks if at least one of the following features is supported: declaration, type definition, definition, references, or implementation | +| boolean isUsageSupported(PsiElement element) | Returns `true` if the given PSI element supports usage searching and `false` otherwise.
For semantic token elements, this delegates to `isUsageSupported(LSPSemanticTokenPsiElement)`. Non-semantic token elements are considered supported by default. | `true` for non-semantic token elements; delegates to semantic token check otherwise | +| boolean isUsageSupported(LSPSemanticTokenPsiElement element) | Returns `true` if the given semantic token element supports usage searching and `false` otherwise.
Excludes certain semantic token types that do not represent referenceable symbols (comments, keywords, modifiers, operators). | `false` for comments, keywords, modifiers, and operators; `true` for all other semantic token types | ## LSP Workspace Symbol Feature @@ -755,13 +765,84 @@ or, if appropriate, the the child/descendant element that represents the declara | boolean isSupported() | Returns `true` if the LSP feature is supported and `false` otherwise.
This supported state is called after starting the language server, which matches the LSP server capabilities. | Check the server capability | | boolean supportsGotoClass() | Returns `true` if the LSP feature is efficient enough to support the IDE's Go To Class action which may be invoked frequently and `false` otherwise. | `false` | +## LSP Workspace Folder Feature + +The `LSPWorkspaceFolderFeature` manages how workspace folders are discovered and sent to the language server via [workspace/workspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders) and [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders). + +See [Workspace Folders](./DeveloperGuide.md#workspace-folders) for complete documentation. + +| API | Description | Default Behaviour | +|-------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------| +| WorkspaceFolderStrategy getStrategy() | Returns the workspace folder strategy. | `ProjectWorkspaceFolderStrategy` | +| WorkspaceFolderStrategy createStrategy() | Creates the default workspace folder strategy. Override this method to provide a different default strategy. | Returns a `ProjectWorkspaceFolderStrategy` | +| List\ getInitialWorkspaceFolders(Project) | Returns the initial workspace folders to send during initialization. | Delegates to the configured strategy | +| WorkspaceFolder computeWorkspaceFolderToNotify(VirtualFile) | Computes the workspace folder to notify for the given file without marking it as notified. | Checks if folder needs notification based on lazy loading mode | +| boolean markFolderAsNotified(WorkspaceFolder) | Marks a workspace folder as notified. | Adds folder URI to internal tracking set | +| void reset() | Clears the tracking of notified workspace folders. This should be called when the language server is restarted. | Clears the internal tracking set | + +### Customizing Workspace Folder Strategy + +To customize workspace folder discovery, extend `LSPWorkspaceFolderFeature` and override `createStrategy()`: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.MarkersWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.jetbrains.annotations.NotNull; + +public class MyWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @Override + protected @NotNull WorkspaceFolderStrategy createStrategy() { + // Use marker-based discovery for Python projects + return new MarkersWorkspaceFolderStrategy("pyproject.toml", "setup.py"); + } +} +``` + +Then register it in your `LSPClientFeatures`: + +```java +package my.language.server; + +import com.redhat.devtools.lsp4ij.LanguageServerFactory; +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; +import org.jetbrains.annotations.NotNull; + +public class MyLanguageServerFactory implements LanguageServerFactory { + + @Override + public @NotNull LSPClientFeatures createClientFeatures() { + LSPClientFeatures clientFeatures = new LSPClientFeatures(); + clientFeatures.setWorkspaceFolderFeature(new MyWorkspaceFolderFeature()); + return clientFeatures; + } +} +``` + +### Available Workspace Folder Strategies + +LSP4IJ provides several built-in strategies: + +- **[ProjectWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java)** (default): Uses IntelliJ project base directories +- **[SourceRootsWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/SourceRootsWorkspaceFolderStrategy.java)**: Uses module source roots +- **[MarkersWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/MarkersWorkspaceFolderStrategy.java)**: Discovers workspace folders by walking up the directory tree looking for marker files (e.g., `pyproject.toml`, `package.json`) +- **[ConfigurableWorkspaceFolderStrategy](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java)**: Configurable strategy using JSON configuration + +All strategies support both eager loading (all folders sent during initialization) and lazy loading (folders sent progressively as files are opened). + +For more details, see the [Workspace Folders documentation](./DeveloperGuide.md#workspace-folders). + ## LSP Breadcrumbs Feature Unlike most features above, `LSPBreadcrumbsFeature` does **not** correspond directly to an LSP feature, but it does build upon `LSPDocumentSymbolFeature` to add breadcrumbs and sticky lines behavior to the IDE. It is _enabled by default_ for all language server definitions except for the HTML and XML language server definition templates. Those implementing custom language server integrations can opt out of these features if desired by overriding the respective methods listed below. -| API | Description | Default Behaviour | -|---------------------------------|--------------------------------------------------------------------------------------------------------------|-------------------| -| boolean isEnabled(PsiFile file) | Returns `true` if the the document symbols-based breadcrumbs info provider is enabled and `false` otherwise. | `true` | +| API | Description | Default Behaviour | +|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|-------------------| +| boolean isEnabled(PsiFile file) | Returns `true` if the the document symbols-based breadcrumbs info provider is enabled and `false` otherwise. | `true` | +| boolean isExistingBreadcrumbsProviderOverrideable(PsiFile file) | Returns `true` if existing breadcrumbs provider are overrideable and `false` otherwise. | `false` | ======= ## LSP Editor Behavior Feature @@ -787,6 +868,102 @@ You can customize JSON-RPC communication behavior by overriding the `isUseIntAsJ This feature is useful when working with language servers that require integer IDs for JSON-RPC messages instead of the default string IDs used by LSP4J. +## Custom Launcher Builder + +You can customize how the JSON-RPC launcher is built by overriding the `createLauncherBuilder()` method in your `LSPClientFeatures` subclass. This gives you full control over the lsp4j `Launcher.Builder`, including the ability to replace the default `MessageProducer` that reads LSP messages from the server's output stream. + +| Method signature | Description | Default Behaviour | +|-------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| +| ` Launcher.Builder createLauncherBuilder()` | Returns the `Launcher.Builder` used to create the JSON-RPC launcher for communicating with the language server. | Returns a `DefaultLauncherBuilder` that reads messages from the standard input stream. | + +The [DefaultLauncherBuilder](https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/DefaultLauncherBuilder.java) provides a protected `createMessageProducer()` method that you can override to inject a custom `MessageProducer`: + +| Method signature | Description | Default Behaviour | +|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|----------------------------------------------------------| +| `MessageProducer createMessageProducer(InputStream input, MessageJsonHandler jsonHandler, MessageIssueHandler issueHandler)` | Creates the `MessageProducer` that feeds JSON-RPC messages into the lsp4j processing pipeline. | Returns an `ExtendedStreamMessageProducer` that reads from the input stream. | + +### Use case: Non-standard message transport + +This API is useful when your language server does not communicate via standard `stdin`/`stdout` streams with Content-Length framing. For example, the Dart Analysis Server uses a legacy JSON protocol and requires a proxy layer that translates between the legacy protocol and LSP. Instead of piping responses through a stream, the proxy can feed parsed messages directly into lsp4j via a custom `MessageProducer` backed by a queue. + +Here is how the Dart plugin uses this API: + +**Step 1:** Create a custom `MessageProducer` that accepts messages via a queue instead of reading from a stream: + +```java +public class DartMessageProducer implements MessageProducer { + + private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); + private final MessageJsonHandler jsonHandler; + + public DartMessageProducer(MessageJsonHandler jsonHandler) { + this.jsonHandler = jsonHandler; + } + + /** Called by the proxy layer to enqueue a JSON-RPC message. */ + public void enqueue(String json) { + queue.add(json); + } + + @Override + public void listen(MessageConsumer callback) { + try { + while (true) { + String json = queue.take(); + Message message = jsonHandler.parseMessage(new StringReader(json)); + callback.consume(message); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void close() { + // no-op + } +} +``` + +**Step 2:** Create a custom `DefaultLauncherBuilder` subclass that overrides `createMessageProducer()`: + +```java +class DartLauncherBuilder extends DefaultLauncherBuilder { + + DartLauncherBuilder(@NotNull LSPClientFeatures clientFeatures) { + super(clientFeatures); + } + + @Override + protected @NotNull MessageProducer createMessageProducer(@NotNull InputStream input, + @NotNull MessageJsonHandler jsonHandler, + @NotNull MessageIssueHandler issueHandler) { + DartMessageProducer producer = new DartMessageProducer(jsonHandler); + // Register the producer so the proxy layer can enqueue responses into it. + Project project = getClientFeatures().getProject(); + DartMessageProducerRegistry.register(project, producer); + return producer; + } +} +``` + +**Step 3:** Override `createLauncherBuilder()` in your `LSPClientFeatures` subclass: + +```java +public class DartLSPClientFeatures extends LSPClientFeatures { + + public DartLSPClientFeatures() { + super.setCompletionFeature(new DartLSPCompletionFeature()); + super.setDiagnosticFeature(new DartLSPDiagnosticFeature()); + } + + @Override + public @NotNull Launcher.Builder createLauncherBuilder() { + return new DartLauncherBuilder<>(this); + } +} +``` + ## Language server installer If you need to verify whether your language server is correctly installed, and install it if necessary, diff --git a/docs/LSPSupport.md b/docs/LSPSupport.md index 4e7a29dfe..de8363ed2 100644 --- a/docs/LSPSupport.md +++ b/docs/LSPSupport.md @@ -57,7 +57,7 @@ Current state of [Language Features]( https://microsoft.github.io/language-serve * ✅ [textDocument/incomingCalls](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#callHierarchy_incomingCalls). * ✅ [textDocument/outgoingCalls](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#callHierarchy_outgoingCalls). * ✅ [textDocument/prepareTypeHierarchy](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_prepareTypeHierarchy) (see [implementation details](#type-hierarchy)) -* ✅ [typeHierarchy/subtypes](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#typeHierarchy_subtypes). + * ✅ [typeHierarchy/subtypes](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#typeHierarchy_subtypes). * ✅ [typeHierarchy/supertypes](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#typeHierarchy_supertypes). * ✅ [textDocument/foldingRange](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_foldingRange) (see [implementation details](#folding-range)) * ✅ [textDocument/selectionRange](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_selectionRange) (see [implementation details](#selection-range)) @@ -84,14 +84,15 @@ Current state of [Workspace Features]( https://microsoft.github.io/language-serv * ✅ [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) (see [implementation details](#workspace-symbol)) * ❌ [workspace/symbolResolve](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbolResolve). * ✅ [workspace/configuration](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_configuration) (see [implementation details](./DeveloperGuide.md#workspace-configuration)) - * ❌ [workspace/workspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders). - * ❌ [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders). - * ❌ [workspace/willCreateFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willCreateFiles). - * ❌ [workspace/didCreateFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didCreateFiles). + * ✅ [workspace/workspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_workspaceFolders) (see [implementation details](./DeveloperGuide.md#workspace-folders)) + * ✅ [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders) (see [implementation details](./DeveloperGuide.md#workspace-folders)) + * ✅ [workspace/willCreateFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willCreateFiles). + * ✅ [workspace/didCreateFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didCreateFiles). * ✅ [workspace/willRenameFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willRenameFiles). * ✅ [workspace/didRenameFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didRenameFiles). - * ❌ [workspace/willDeleteFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willDeleteFiles). - * ❌ [workspace/didDeleteFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didDeleteFiles). + * ✅ [workspace/willDeleteFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willDeleteFiles). + * ✅ [workspace/didDeleteFiles](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didDeleteFiles). + * ❌ [workspace/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_diagnostic). ## Window Features diff --git a/docs/UserDefinedLanguageServer.md b/docs/UserDefinedLanguageServer.md index 6094bf911..e0892f5a1 100644 --- a/docs/UserDefinedLanguageServer.md +++ b/docs/UserDefinedLanguageServer.md @@ -137,6 +137,136 @@ with task to execute like: This installer content will be used when you will create the language server. See [here](UserDefinedLanguageServerTemplate.md#installer-descriptor) to understand how to write this JSON installer descriptor. +### Workspace Folders tab + +[Workspace folders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspaceFolder) define the scope of files that the language server will analyze. They can be sent to the language server: + + * At startup via the [initialize](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) request + * Dynamically via [workspace/didChangeWorkspaceFolders](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders) notifications + +This tab allows you to configure and preview how workspace folders will be discovered and sent to your language server. + +#### Configuration strategies + +##### Root type + +The `rootType` defines which IntelliJ project structure to use as workspace folders: + + * `PROJECT_BASE` (default): Uses [content roots](https://www.jetbrains.com/help/idea/content-roots.html#adding_content_root) + * `SOURCE_ROOTS`: Uses [source roots](https://www.jetbrains.com/help/idea/content-roots.html#folder-categories) + * `NONE`: No workspace folders + +```json +{ + "rootType": "SOURCE_ROOTS" +} +``` + +This configuration will populate `InitializeParams.workspaceFolders` when the language server starts: + +```json +{ + "workspaceFolders": [ + { + "name": "my-module", + "uri": "file:///path/to/my-module" + } + ] +} +``` + +##### Marker-based discovery + +If `rootType` strategies don't match your project structure, use markers to define workspace folders based on specific files. This is useful for: + +- Mono-repos with multiple independent modules +- Python projects with `pyproject.toml` or `setup.py` +- Projects where workspace boundaries are defined by configuration files + +```json +{ + "markers": [ + "pyproject.toml", + "pom.xml", + ".git" + ] +} +``` + +When a file is opened, LSP4IJ walks up the directory tree to find the closest marker file and uses its directory as the workspace folder. + +**Note:** Marker-based discovery always uses [lazy mode](#lazy-mode). + +#### Lazy mode + +By default (eager mode), all workspace folders are sent at startup via the `initialize` request. For large projects, this can cause the language server to load and analyze many files unnecessarily. + +**Lazy mode** defers workspace folder discovery: folders are sent progressively via `workspace/didChangeWorkspaceFolders` notifications as you open files. + +```json +{ + "rootType": "PROJECT_BASE", + "lazy": true +} +``` + +**Example:** Consider a project with 2 [content roots](https://www.jetbrains.com/help/idea/content-roots.html#adding_content_root): `client` and `common`. + +![Project content roots](./images/workspace-folders/ProjectContentRoots.png) + +**Eager mode** (`lazy: false`): +- All folders sent at startup in the `initialize` request: + ```json + { + "workspaceFolders": [ + { "uri": "file:///.../client", "name": "client" }, + { "uri": "file:///.../common", "name": "common" } + ] + } + ``` +- Language server loads **all** files from both `client` and `common`, even if you only open a file in `common` + +You can see this behavior with the following configuration: + +![Root type - PROJECT_BASE](./images/workspace-folders/RootType_PROJECT_BASE.png) + +**Lazy mode** (`lazy: true`): +- Empty workspace folders at startup: + ```json + { + "workspaceFolders": [] + } + ``` +- When you open a file from `common`, only that folder is sent via `workspace/didChangeWorkspaceFolders`: + ```json + { + "event": { + "added": [{ "uri": "file:///.../common", "name": "common" }], + "removed": [] + } + } + ``` + +#### Testing your configuration + +The **preview panel** on the right shows how workspace folders will be sent based on your configuration. + +**Checkbox unchecked** - Shows all discovered workspace folders (discovery mode): + +![Root type - PROJECT_BASE - lazy](./images/workspace-folders/RootType_PROJECT_BASE_lazy.png) + +**Checkbox checked** - Shows only folders sent at initialization (empty in lazy mode): + +![Root type - PROJECT_BASE - lazy](./images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_empty.png) + +**Drag & drop or click "Open file..."** to simulate opening a file and see which workspace folder it belongs to: + +![Root type - PROJECT_BASE - lazy](./images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_openFile.png) + +If a file is not included in any workspace folder, it will appear under a "(No root)" node: + +![Root type - PROJECT_BASE - lazy](./images/workspace-folders/RootType_PROJECT_BASE_noRoot.png) + ## Using template Template can be used to quickly create user defined language server pre-filled with server name, command, mappings and potential configurations. @@ -155,9 +285,11 @@ pre-filled with server name, command, mappings and potential configuration. * [Clojure LSP](./user-defined-ls/clojure-lsp.md) * [Dart LSP](./user-defined-ls/dart-lsp.md) * [Docker Language Server](./user-defined-ls/docker-language-server.md) +* [EO LSP Server](./user-defined-ls/eo-lsp-server.md) * [Erlang Language Server](./user-defined-ls/erlang-ls.md) * [ESLint Language Server](./user-defined-ls/vscode-eslint-language-server.md) * [Go Language Server](./user-defined-ls/gopls.md) +* [Harper Language Server](./user-defined-ls/harper-ls.md) * [JQ Language Server](./user-defined-ls/jq-lsp.md) * [Julia Language Server](./user-defined-ls/julia.md) * [Ruby LSP](./user-defined-ls/ruby-lsp.md) @@ -169,6 +301,7 @@ pre-filled with server name, command, mappings and potential configuration. * [Terraform Language Server](./user-defined-ls/terraform-ls.md) * [TypeScript Language Server](./user-defined-ls/typescript-language-server.md) * [Vue Language Server](./user-defined-ls/vue-js-language-server.md) +* [WGSL Analyzer](./user-defined-ls/wgsl-analyzer.md) If the template directory contains a `README.md` file, you can open the instructions by pressing the help icon. diff --git a/docs/UserGuide.md b/docs/UserGuide.md index 59737d865..9290c6b00 100644 --- a/docs/UserGuide.md +++ b/docs/UserGuide.md @@ -62,6 +62,25 @@ or configure LSP CodeLens on the `Right`: ![LSP CodeLens settings - Right](./images/LSPCodeLensSettingsRight.png) +## Structure View + +The Structure View displays the file outline using LSP [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol): + +![LSP Structure View](./images/structure-view/LSPStructureView.png) + +### Filters + +You can filter symbols by [SymbolKind](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#symbolKind) +(Class, Method, Field, etc.) using the filter dropdown. + +Show all symbols: + +![Show all symbols](./images/structure-view/LSPStructureViewShowAll.png) + +Hide specific symbol types (e.g., constants): + +![Hide constants](./images/structure-view/LSPStructureViewHideConstant.png) + ## Actions ### Formatting diff --git a/docs/dap/DAPSupport.md b/docs/dap/DAPSupport.md index dd0749333..4255f2b68 100644 --- a/docs/dap/DAPSupport.md +++ b/docs/dap/DAPSupport.md @@ -31,13 +31,13 @@ Current state of [Requests](https://microsoft.github.io/debug-adapter-protocol// * ✅ [Attach](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Attach). * ❌ [BreakpointLocations](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_BreakpointLocations). -* ✅ [Completions](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Completions). +* ✅ [Completions](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Completions) (see [user guide](./UserGuide.md#completion)). * ✅ [ConfigurationDone](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_ConfigurationDone). * ✅ [Continue](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Continue). * ❌ [DataBreakpointInfo](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_DataBreakpointInfo). -* ❌ [Disassemble](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Disassemble). +* ✅ [Disassemble](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Disassemble) (see [user guide](./UserGuide.md#disassemble)). * ✅ [Disconnect](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Disconnect). -* ✅ [Evaluate](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Evaluate). +* ✅ [Evaluate](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Evaluate) (see [user guide](./UserGuide.md#evaluate-expression)). * ❌ [ExceptionInfo](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_ExceptionInfo). * ❌ [Goto](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Goto). * ❌ [GotoTargets](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_GotoTargets). @@ -53,18 +53,18 @@ Current state of [Requests](https://microsoft.github.io/debug-adapter-protocol// * ❌ [RestartFrame](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_RestartFrame). * ❌ [ReverseContinue](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_ReverseContinue). * ✅ [Scopes](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Scopes). -* ✅ [SetBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetBreakpoints). +* ✅ [SetBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetBreakpoints) (see [user guide](./UserGuide.md#adding-breakpoint)). * ❌ [SetDataBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetDataBreakpoints). -* ❌ [SetExceptionBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetExceptionBreakpoints). +* ✅ [SetExceptionBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetExceptionBreakpoints) (see [user guide](./UserGuide.md#exception-breakpoint)) * ❌ [SetExpression](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetExpression). * ❌ [SetFunctionBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetFunctionBreakpoints). -* ❌ [SetInstructionBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetInstructionBreakpoints). +* ✅ [SetInstructionBreakpoints](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetInstructionBreakpoints) (see [user guide](./UserGuide.md#disassemble)). * ✅ [SetVariable](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetVariable). -* ❌ [Source](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Source). +* ✅ [Source](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Source). * ✅ [StackTrace](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StackTrace). * ❌ [StepBack](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepBack). * ✅ [StepIn](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepIn). -* ❌ [StepInTargets](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepInTargets). +* ✅ [StepInTargets](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepInTargets). * ✅ [StepOut](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepOut). * ✅ [Terminate](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Terminate). * ❌ [TerminateThreads](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_TerminateThreads). @@ -76,5 +76,5 @@ Current state of [Requests](https://microsoft.github.io/debug-adapter-protocol// Current state of [Reverse Requests](https://microsoft.github.io/debug-adapter-protocol//specification.html#Reverse_Requests) support: -* ❌ [RunInTerminal](https://microsoft.github.io/debug-adapter-protocol//specification.html#Reverse_Requests_RunInTerminal). +* ✅ [RunInTerminal](https://microsoft.github.io/debug-adapter-protocol//specification.html#Reverse_Requests_RunInTerminal) (see [user guide](./UserGuide.md#run-in-terminal)). * ✅ [StartDebugging](https://microsoft.github.io/debug-adapter-protocol//specification.html#Reverse_Requests_StartDebugging). \ No newline at end of file diff --git a/docs/dap/UserGuide.md b/docs/dap/UserGuide.md index fb1cc621e..e085edcec 100644 --- a/docs/dap/UserGuide.md +++ b/docs/dap/UserGuide.md @@ -83,6 +83,34 @@ Take a sample JavaScript file containing an error: In this example, no breakpoints are defined. However, when you start the DAP server, it stops at the line with the line error: +### Smart Step Into + +[Smart Step Into](https://www.jetbrains.com/help/idea/stepping-through-the-program.html#smart-step-into) is available +if the DAP server supports [StepInTargets](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepInTargets). + +When you're stopped at a breakpoint on a line with multiple function calls, press **Shift+F7** (or select **Smart Step Into** from the debug menu). +A popup will appear showing all available functions you can step into. +Use the arrow keys to navigate between functions, and press **Enter** to step into the selected one. +You can also click directly on a function name in the editor. + +For example, on this line: +```python +result = multiply(add(x, y), subtract(x, y)) +``` + +Smart Step Into will let you choose between `multiply`, `add`, and `subtract`. + +The feature also handles duplicate function names on the same line correctly. For instance: +```python +result = add(1, 2) + add(3, 4) +``` + +Each `add` call will be highlighted separately, allowing you to step into the specific occurrence you want. + +Here's a demo with [Python Debugpy](./user-defined-dap/python-debugpy.md) which supports [StepInTargets](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_StepInTargets): + +![DAP Smart Step Into](./images/DAP_SmartStepIntoDemo.gif) + ![DAP exception breakpoint / Syntax error](./images/DAP_exception_breakpoint_sample_stop.png) This happens because `Caught Exceptions` is selected. @@ -127,6 +155,70 @@ You should edit the variable: the edit apply will consume the [SetVariable request](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_SetVariable): +## Run In Terminal + +If the DAP server supports the [Run In Terminal](https://microsoft.github.io/debug-adapter-protocol/specification.html#Reverse_Requests_RunInTerminal) request, **LSP4IJ** can handle it automatically. +This feature allows launching a debug session in either the `integrated terminal` or an `external terminal`. + +Most DAP servers let you choose which terminal to use through the `console` field in the launch parameters: + +- `"console": "integratedTerminal"` → Use the IDE's integrated terminal +- `"console": "externalTerminal"` → Open a separate external terminal window + +The [VS Code JS Debug](./user-defined-dap/vscode-js-debug.md) adapter supports `Run In Terminal`. Below are two sample configurations: + +### Integrated terminal + +Here a sample with VSCode JS Debug and `integratedTerminal`: + +```json +{ + "type": "pwa-node", + "name": "Launch JavaScript file", + "request": "launch", + "program": "${file}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" +} +``` + +![Run In Terminal integrated](./images/DAP_runInTerminal_integrated.png) + +### External terminal + +Here a sample with VSCode JS Debug and `externalTerminal`: + +```json +{ + "type": "pwa-node", + "name": "Launch JavaScript file", + "request": "launch", + "program": "${file}", + "cwd": "${workspaceFolder}", + "console": "externalTerminal" +} +``` + +![Run In Terminal external](./images/DAP_runInTerminal_external.png) + +The `externalTerminal` is supported on **Windows** and **Linux**, but **not on macOS**. + +## Disassemble + +If the DAP server [supports disassembly](https://microsoft.github.io/debug-adapter-protocol//specification.html#Requests_Disassemble), open the Disassembly view from the stack trace context menu: `Open Disassembly View`. + +![Open Disassembly View](./images/DAP_open_disassembly_view.png) + +The Disassembly view shows the instructions for the current stack frame. You can add breakpoints and step through instructions one by one. + +![Disassembly View](./images/DAP_disassembly_view.gif) + +You can switch between source files and the Disassembly view at any time to continue step-by-step debugging. + +Example: using [CodeLLDB](./user-defined-dap/codelldb.md) to debug a Rust executable built with `cargo build`. + +![Disassembly demo](./images/DAP_disassembly_demo.gif) + ## Contextual Menu Click on right button open existing / new DAP run configuration: diff --git a/docs/dap/images/DAP_SmartStepIntoDemo.gif b/docs/dap/images/DAP_SmartStepIntoDemo.gif new file mode 100644 index 000000000..cd01fe718 Binary files /dev/null and b/docs/dap/images/DAP_SmartStepIntoDemo.gif differ diff --git a/docs/dap/images/DAP_disassembly_demo.gif b/docs/dap/images/DAP_disassembly_demo.gif new file mode 100644 index 000000000..af335a069 Binary files /dev/null and b/docs/dap/images/DAP_disassembly_demo.gif differ diff --git a/docs/dap/images/DAP_disassembly_view.gif b/docs/dap/images/DAP_disassembly_view.gif new file mode 100644 index 000000000..bb64373d4 Binary files /dev/null and b/docs/dap/images/DAP_disassembly_view.gif differ diff --git a/docs/dap/images/DAP_open_disassembly_view.png b/docs/dap/images/DAP_open_disassembly_view.png new file mode 100644 index 000000000..cf0527a6a Binary files /dev/null and b/docs/dap/images/DAP_open_disassembly_view.png differ diff --git a/docs/dap/images/DAP_runInTerminal_external.png b/docs/dap/images/DAP_runInTerminal_external.png new file mode 100644 index 000000000..47fca0270 Binary files /dev/null and b/docs/dap/images/DAP_runInTerminal_external.png differ diff --git a/docs/dap/images/DAP_runInTerminal_integrated.png b/docs/dap/images/DAP_runInTerminal_integrated.png new file mode 100644 index 000000000..69df91c59 Binary files /dev/null and b/docs/dap/images/DAP_runInTerminal_integrated.png differ diff --git a/docs/dap/images/buildx-dockerfile/config-01-dap-parameters.png b/docs/dap/images/buildx-dockerfile/config-01-dap-parameters.png new file mode 100644 index 000000000..ae595f4e8 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/config-01-dap-parameters.png differ diff --git a/docs/dap/images/buildx-dockerfile/features-01-environment.png b/docs/dap/images/buildx-dockerfile/features-01-environment.png new file mode 100644 index 000000000..d2557db8c Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/features-01-environment.png differ diff --git a/docs/dap/images/buildx-dockerfile/features-02-exec-command.png b/docs/dap/images/buildx-dockerfile/features-02-exec-command.png new file mode 100644 index 000000000..5a6f37a67 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/features-02-exec-command.png differ diff --git a/docs/dap/images/buildx-dockerfile/features-02-modify-contents.png b/docs/dap/images/buildx-dockerfile/features-02-modify-contents.png new file mode 100644 index 000000000..64785f48f Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/features-02-modify-contents.png differ diff --git a/docs/dap/images/buildx-dockerfile/features-02-shell-reset.png b/docs/dap/images/buildx-dockerfile/features-02-shell-reset.png new file mode 100644 index 000000000..5fcae8902 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/features-02-shell-reset.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-01-create-config.png b/docs/dap/images/buildx-dockerfile/setup-01-create-config.png new file mode 100644 index 000000000..5290ae01c Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-01-create-config.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-02-create-server.png b/docs/dap/images/buildx-dockerfile/setup-02-create-server.png new file mode 100644 index 000000000..482ca52e2 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-02-create-server.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-03-template-selection.png b/docs/dap/images/buildx-dockerfile/setup-03-template-selection.png new file mode 100644 index 000000000..eca2c2dc8 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-03-template-selection.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-04-env-var.png b/docs/dap/images/buildx-dockerfile/setup-04-env-var.png new file mode 100644 index 000000000..f15d38fa3 Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-04-env-var.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-05-file-selection.png b/docs/dap/images/buildx-dockerfile/setup-05-file-selection.png new file mode 100644 index 000000000..ddcbb13ec Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-05-file-selection.png differ diff --git a/docs/dap/images/buildx-dockerfile/setup-06-start-debug.png b/docs/dap/images/buildx-dockerfile/setup-06-start-debug.png new file mode 100644 index 000000000..ba527259d Binary files /dev/null and b/docs/dap/images/buildx-dockerfile/setup-06-start-debug.png differ diff --git a/docs/dap/images/codelldb/configuration_tab.png b/docs/dap/images/codelldb/configuration_tab.png new file mode 100644 index 000000000..0c9c4f49b Binary files /dev/null and b/docs/dap/images/codelldb/configuration_tab.png differ diff --git a/docs/dap/images/codelldb/debug_threads_tab.png b/docs/dap/images/codelldb/debug_threads_tab.png new file mode 100644 index 000000000..b147570a0 Binary files /dev/null and b/docs/dap/images/codelldb/debug_threads_tab.png differ diff --git a/docs/dap/images/codelldb/disassembly.png b/docs/dap/images/codelldb/disassembly.png new file mode 100644 index 000000000..36690dd70 Binary files /dev/null and b/docs/dap/images/codelldb/disassembly.png differ diff --git a/docs/dap/images/codelldb/file_mappings.png b/docs/dap/images/codelldb/file_mappings.png new file mode 100644 index 000000000..0e5cc730f Binary files /dev/null and b/docs/dap/images/codelldb/file_mappings.png differ diff --git a/docs/dap/images/codelldb/generated_executable.png b/docs/dap/images/codelldb/generated_executable.png new file mode 100644 index 000000000..22f418779 Binary files /dev/null and b/docs/dap/images/codelldb/generated_executable.png differ diff --git a/docs/dap/images/codelldb/installer_tab.png b/docs/dap/images/codelldb/installer_tab.png new file mode 100644 index 000000000..65cbcba28 Binary files /dev/null and b/docs/dap/images/codelldb/installer_tab.png differ diff --git a/docs/dap/images/codelldb/select_new_server.png b/docs/dap/images/codelldb/select_new_server.png new file mode 100644 index 000000000..45ab4c2d8 Binary files /dev/null and b/docs/dap/images/codelldb/select_new_server.png differ diff --git a/docs/dap/images/codelldb/select_template.png b/docs/dap/images/codelldb/select_template.png new file mode 100644 index 000000000..c1cffaced Binary files /dev/null and b/docs/dap/images/codelldb/select_template.png differ diff --git a/docs/dap/images/codelldb/server_installing.png b/docs/dap/images/codelldb/server_installing.png new file mode 100644 index 000000000..fa9068bae Binary files /dev/null and b/docs/dap/images/codelldb/server_installing.png differ diff --git a/docs/dap/images/codelldb/set_breakpoint.png b/docs/dap/images/codelldb/set_breakpoint.png new file mode 100644 index 000000000..97cb22a55 Binary files /dev/null and b/docs/dap/images/codelldb/set_breakpoint.png differ diff --git a/docs/dap/images/codelldb/set_traces.png b/docs/dap/images/codelldb/set_traces.png new file mode 100644 index 000000000..dde856943 Binary files /dev/null and b/docs/dap/images/codelldb/set_traces.png differ diff --git a/docs/dap/images/codelldb/traces_in_console.png b/docs/dap/images/codelldb/traces_in_console.png new file mode 100644 index 000000000..1c3ba8198 Binary files /dev/null and b/docs/dap/images/codelldb/traces_in_console.png differ diff --git a/docs/dap/user-defined-dap/buildx-dockerfile.md b/docs/dap/user-defined-dap/buildx-dockerfile.md new file mode 100644 index 000000000..d35ddd048 --- /dev/null +++ b/docs/dap/user-defined-dap/buildx-dockerfile.md @@ -0,0 +1,65 @@ +# Buildx Debugging for Dockerfiles + +To debug [Dockerfiles](https://docs.docker.com/build/concepts/dockerfile/), you can use the DAP implementation provided by [Buildx](https://github.com/docker/buildx). + +## Requirements + +You will need Buildx v0.28.0. You can get Buildx by [installing Docker Desktop](https://docs.docker.com/install/) or by [installing Buildx manually](https://github.com/docker/buildx?tab=readme-ov-file#manual-download). + +1. Run `docker buildx version` to check your Buildx version. +2. Run `BUILDX_EXPERIMENTAL=1 docker buildx dap` to check that the `dap` subcommand is available in your Buildx installation. + +## Setup + +1. Create a Debug Adapter Protocol launch configuration. + +![Use the plus button to create a new Debug Adapter Protocol configuration](../images/buildx-dockerfile/setup-01-create-config.png) + +2. Click on the 'Create a new server' hyperlink. + +![Create a new server instead of using an existing Debug Adapter Server](../images/buildx-dockerfile/setup-02-create-server.png) + +3. Click 'Choose template...' and select the 'Docker: Dockerfile Build Debugging' template. + +![Select the Dockerfile Build Debugging template](../images/buildx-dockerfile/setup-03-template-selection.png) + +4. In 'Environment variables:', enter in `BUILDX_EXPERIMENTAL=1`. + +![Set the environment variable in the dialog](../images/buildx-dockerfile/setup-04-env-var.png) + +5. Click the 'OK' button to save the server configuration. +6. Now go to the 'Configuration' tab and find select a Dockerfile in the 'File:' field. + +![Set the Dockerfile to debug](../images/buildx-dockerfile/setup-05-file-selection.png) + +7. Click the 'OK' button to save the launch configuration. +8. Open your Dockerfile and place a breakpoint. +9. Launch your configuration in debug mode to pause the Dockerfile build! + +![Debug Dockerfiles with LSP4IJ](../images/buildx-dockerfile/setup-06-start-debug.png) + +## Configuration + +If you would like to make further configurations to how the build is launched, you can refer to [this document](https://github.com/docker/buildx/blob/77315f947ea4f800639bbd0132ff3012780ed6fc/docs/reference/buildx_dap_build.md#examples) to modify the DAP JSON parameters that are passed. + +![Debug Dockerfiles with LSP4IJ](../images/buildx-dockerfile/config-01-dap-parameters.png) + +## Features + +### View the environment of the image being built + +While your build is in a suspended state, you can review variables, the working directory, and the files and folders of the image. + +![See variables and files while the build is suspended](../images/buildx-dockerfile/features-01-environment.png) + +### Open a shell inside the image + +Evaluate the `exec` expression and Buildx will open a terminal for you to type commands into the image directly. + +![Run the exec command in the expressions input field](../images/buildx-dockerfile/features-02-exec-command.png) + +![Modify and experiment with the contents of the image in its suspended state](../images/buildx-dockerfile/features-02-modify-contents.png) + +Please note however that any changes you make will be wiped when you advance the debugger. + +![Observe that modifications are not permanent and will be reset when the build continues](../images/buildx-dockerfile/features-02-shell-reset.png) diff --git a/docs/dap/user-defined-dap/codelldb.md b/docs/dap/user-defined-dap/codelldb.md index f6641c758..9a2732702 100644 --- a/docs/dap/user-defined-dap/codelldb.md +++ b/docs/dap/user-defined-dap/codelldb.md @@ -1,3 +1,151 @@ # CodeLLDB -TODO : write doc \ No newline at end of file +To debug a [Rust application](#rust-application) or [C++ application](#c-application), you can use the [CodeLLDB](https://github.com/vadimcn/codelldb) DAP server. + +# Rust Application + +Let's debug the following `main.rs` Rust file: + +```rust +fn main() { + let name = "world"; + println!("Hello, {}!", name); +} +``` + +![Set Breakpoint](../images/codelldb/set_breakpoint.png) + +You will need to install [cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html), which is used to create a Rust application with `cargo init` and to build an executable with `cargo build`. +The executable is required for debugging. + +## Initialize Rust Application + +```shell +cargo init +``` + +This command generates a Rust application with a `src/main.rs` file. Update this file with the following content: + +```rust +fn main() { + let name = "world"; + println!("Hello, {}!", name); +} +``` + +To debug the Rust application, you first need to build the executable that will be used by the debugger. +In the project directory, run: + +```shell +cargo build +``` + +This will generate an executable in the `target/debug` folder. + +![Target executable](../images/codelldb/generated_executable.png) + +## Configure DAP server + +1. Create a DAP Run/Debug configuration: + + ![DAP Configuration Type](../images/DAP_config_type.png) + +2. In the `Server` tab, click on `create a new server`: + + ![Create a new server](../images/DAP_server_create_link.png) + +3. A new dialog will open to create a DAP server. Select the `CodeLLDB` template: + + ![Select Template](../images/codelldb/select_template.png) + +4. After clicking the `OK` button, the new server will be selected and the configuration automatically pre-filled: + +![Select New server](../images/codelldb/select_new_server.png) + +5. Enable DAP server traces + +If you wish to show DAP request/response traces when you will debug: + +![Show DAP traces](../images/codelldb/traces_in_console.png) + +you need to select `Trace` with `verbose`. + +![Set verbose traces](../images/codelldb/set_traces.png) + +## Configure file mappings + +To allows settings breakpoints to Rust files, you need configure mappings in the `Mappings` tab. +As you have selected `CodeLLDB` server, it will automatically populate the file mappings like this: + +![File mappings](../images/codelldb/file_mappings.png) + +## Configure the executable to run/debug + +Since you selected the `CodeLLDB` server, the `Launch` configuration will be automatically populated like this: + +1. `Launch` as `Debug mode` should be selected. +2. The DAP parameters of the launch should look like this: + +```json +{ + "type": "lldb", + "name": "Launch Rust file", + "request": "launch", + "program": "${file}", + "cwd": "${workspaceFolder}" +} +``` + +You also need to `select the executable` in the `File` field: + +![DAP Configuration/Configuration](../images/codelldb/configuration_tab.png) + +## Set Breakpoint + +After applying the run configuration, set breakpoints in files that match the file mappings. +For example, set a breakpoint in the `main.rs` file: + +![Set Breakpoint](../images/codelldb/set_breakpoint.png) + +## Debugging + +You can start the run configuration in either Run or Debug mode. + +The first time you run it, [CodeLLDB](https://github.com/vadimcn/codelldb), +the DAP server, will be downloaded and installed: + +![Debugging / Installing](../images/codelldb/server_installing.png) + +If you encounter an error during installation, go to the `Installer` tab, update the +[Installer Descriptor](https://github.com/redhat-developer/lsp4ij/blob/main/docs/UserDefinedLanguageServerTemplate.md#installer-descriptor) +, and run `Run Installation` again: + +![Installer](../images/codelldb/installer_tab.png) + +Once started, you should see DAP traces in the console: + +![Debugging / Console](../images/codelldb/traces_in_console.png) + +You will also see `Threads` and `Variables`: + +![Debugging / Threads](../images/codelldb/debug_threads_tab.png) + +You can [open the Disassembly view](../UserGuide.md#disassemble) +to debug step-by-step instructions at the assembly level: + +![Debugging / Disassembly](../images/codelldb/disassembly.png) + +## Language Support + +If you need language support for Rust (completion, validation, etc.), you can [configure the Rust Language Server](../../user-defined-ls/rust-analyzer.md). + +![Rust demo](../../images/user-defined-ls/rust-analyzer/demo_ls.gif) + +# C++ Application + +TODO + +## Language Support + +If you need language support for C++ files (completion, validation, etc.), you can [configure the Clangd Language Server](../../user-defined-ls/clangd.md). + diff --git a/docs/dap/user-defined-dap/go-delve.md b/docs/dap/user-defined-dap/go-delve.md index 45ab2e7e1..ee3be14bb 100644 --- a/docs/dap/user-defined-dap/go-delve.md +++ b/docs/dap/user-defined-dap/go-delve.md @@ -2,7 +2,7 @@ To debug `Go` files, you can use the [Delve](https://github.com/go-delve/delve) DAP server. -Let’s debugging the following `test.go` file: +Let’s debug the following `test.go` file: ```go package main diff --git a/docs/dap/user-defined-dap/julia.md b/docs/dap/user-defined-dap/julia.md index b5178a391..762018540 100644 --- a/docs/dap/user-defined-dap/julia.md +++ b/docs/dap/user-defined-dap/julia.md @@ -2,7 +2,7 @@ To debug [Julia](https://julialang.org/) files, you can use the [Julia](https://github.com/julia-vscode/DebugAdapter.jl) DAP server. -Let’s debugging the following `test.julia` file: +Let’s debug the following `test.julia` file: ```julia function main() diff --git a/docs/dap/user-defined-dap/python-debugpy.md b/docs/dap/user-defined-dap/python-debugpy.md index 23f1fd6fc..e56103682 100644 --- a/docs/dap/user-defined-dap/python-debugpy.md +++ b/docs/dap/user-defined-dap/python-debugpy.md @@ -2,7 +2,7 @@ To debug [Python](https://www.python.org/) files, you can use the [Debugpy](https://github.com/microsoft/debugpy) DAP server. -Let’s debugging the following `test.py` file: +Let’s debug the following `test.py` file: ```python def main(): diff --git a/docs/dap/user-defined-dap/ruby-rdbg.md b/docs/dap/user-defined-dap/ruby-rdbg.md index 83637b588..80bbde95e 100644 --- a/docs/dap/user-defined-dap/ruby-rdbg.md +++ b/docs/dap/user-defined-dap/ruby-rdbg.md @@ -2,7 +2,7 @@ To debug [Ruby](https://www.ruby-lang.org/) files, you can use the [Ruby rdbg](https://github.com/ruby/debug) DAP server. -Let’s debugging the following `hello.rb` file: +Let’s debug the following `hello.rb` file: ```ruby def main diff --git a/docs/dap/user-defined-dap/swift-lldb.md b/docs/dap/user-defined-dap/swift-lldb.md index 5253038a2..553ae029f 100644 --- a/docs/dap/user-defined-dap/swift-lldb.md +++ b/docs/dap/user-defined-dap/swift-lldb.md @@ -2,7 +2,7 @@ To debug `Swift` files, you can use the [lldb-dap](https://github.com/llvm/llvm-project/blob/main/lldb/tools/lldb-dap/README.md) DAP server. -Let’s debugging the following `Sources/main.swift` file: +Let’s debug the following `Sources/main.swift` file: ```swift // The Swift Programming Language diff --git a/docs/dap/user-defined-dap/vscode-js-debug.md b/docs/dap/user-defined-dap/vscode-js-debug.md index c03c96232..caac29bf2 100644 --- a/docs/dap/user-defined-dap/vscode-js-debug.md +++ b/docs/dap/user-defined-dap/vscode-js-debug.md @@ -2,7 +2,7 @@ To debug JavaScript or TypeScript files, you can use the [VSCode JS Debug](https://github.com/microsoft/vscode-js-debug) DAP server. -Let’s debugging the following `test.js` file: +Let’s debug the following `test.js` file: ```js const s = "foo"; @@ -203,6 +203,6 @@ TypeScript debugging should be available: ## Language Support -If you need language support for TypeScript/JavaScript (completion, validation, etc) you can [configure the TypeScript Language Server](../../user-defined-ls/typescript-language-server.md) +If you need language support for TypeScript/JavaScript (completion, validation, etc.), you can [configure the TypeScript Language Server](../../user-defined-ls/typescript-language-server.md). ![TypeScript demo](../../images/user-defined-ls/typescript-language-server/demo_ls.gif) \ No newline at end of file diff --git a/docs/images/structure-view/LSPStructureView.png b/docs/images/structure-view/LSPStructureView.png new file mode 100644 index 000000000..0a9a2bab7 Binary files /dev/null and b/docs/images/structure-view/LSPStructureView.png differ diff --git a/docs/images/structure-view/LSPStructureViewHideConstant.png b/docs/images/structure-view/LSPStructureViewHideConstant.png new file mode 100644 index 000000000..fae451bcb Binary files /dev/null and b/docs/images/structure-view/LSPStructureViewHideConstant.png differ diff --git a/docs/images/structure-view/LSPStructureViewShowAll.png b/docs/images/structure-view/LSPStructureViewShowAll.png new file mode 100644 index 000000000..8e47493e0 Binary files /dev/null and b/docs/images/structure-view/LSPStructureViewShowAll.png differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/demo_ls.gif b/docs/images/user-defined-ls/eo-lsp-server/demo_ls.gif new file mode 100644 index 000000000..10b56dade Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/demo_ls.gif differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/installer_tab.png b/docs/images/user-defined-ls/eo-lsp-server/installer_tab.png new file mode 100644 index 000000000..f676b6591 Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/installer_tab.png differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/language_server_installing.png b/docs/images/user-defined-ls/eo-lsp-server/language_server_installing.png new file mode 100644 index 000000000..6a6d4b758 Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/language_server_installing.png differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/new_language_server_dialog.png b/docs/images/user-defined-ls/eo-lsp-server/new_language_server_dialog.png new file mode 100644 index 000000000..a5c20fe5d Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/new_language_server_dialog.png differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/open_file.png b/docs/images/user-defined-ls/eo-lsp-server/open_file.png new file mode 100644 index 000000000..51798077b Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/open_file.png differ diff --git a/docs/images/user-defined-ls/eo-lsp-server/textmate_bundles_settings.png b/docs/images/user-defined-ls/eo-lsp-server/textmate_bundles_settings.png new file mode 100644 index 000000000..573847b9c Binary files /dev/null and b/docs/images/user-defined-ls/eo-lsp-server/textmate_bundles_settings.png differ diff --git a/docs/images/user-defined-ls/harper-ls/demo_ls.gif b/docs/images/user-defined-ls/harper-ls/demo_ls.gif new file mode 100644 index 000000000..694d2f434 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/demo_ls.gif differ diff --git a/docs/images/user-defined-ls/harper-ls/installer_tab.png b/docs/images/user-defined-ls/harper-ls/installer_tab.png new file mode 100644 index 000000000..aeba77406 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/installer_tab.png differ diff --git a/docs/images/user-defined-ls/harper-ls/language_server_diagnostics.png b/docs/images/user-defined-ls/harper-ls/language_server_diagnostics.png new file mode 100644 index 000000000..048325b90 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/language_server_diagnostics.png differ diff --git a/docs/images/user-defined-ls/harper-ls/language_server_installing.png b/docs/images/user-defined-ls/harper-ls/language_server_installing.png new file mode 100644 index 000000000..9a8c006fc Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/language_server_installing.png differ diff --git a/docs/images/user-defined-ls/harper-ls/language_server_mappings.png b/docs/images/user-defined-ls/harper-ls/language_server_mappings.png new file mode 100644 index 000000000..20d176e68 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/language_server_mappings.png differ diff --git a/docs/images/user-defined-ls/harper-ls/new_language_server_dialog.png b/docs/images/user-defined-ls/harper-ls/new_language_server_dialog.png new file mode 100644 index 000000000..6072d6531 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/new_language_server_dialog.png differ diff --git a/docs/images/user-defined-ls/harper-ls/new_language_server_dialog_ok.png b/docs/images/user-defined-ls/harper-ls/new_language_server_dialog_ok.png new file mode 100644 index 000000000..b72ca28e1 Binary files /dev/null and b/docs/images/user-defined-ls/harper-ls/new_language_server_dialog_ok.png differ diff --git a/docs/images/user-defined-ls/wgsl-analyzer/demo_ls.gif b/docs/images/user-defined-ls/wgsl-analyzer/demo_ls.gif new file mode 100644 index 000000000..75deba73f Binary files /dev/null and b/docs/images/user-defined-ls/wgsl-analyzer/demo_ls.gif differ diff --git a/docs/images/user-defined-ls/wgsl-analyzer/installer_tab.png b/docs/images/user-defined-ls/wgsl-analyzer/installer_tab.png new file mode 100644 index 000000000..511f33b2b Binary files /dev/null and b/docs/images/user-defined-ls/wgsl-analyzer/installer_tab.png differ diff --git a/docs/images/user-defined-ls/wgsl-analyzer/language_server_installing.png b/docs/images/user-defined-ls/wgsl-analyzer/language_server_installing.png new file mode 100644 index 000000000..3ee4c2c99 Binary files /dev/null and b/docs/images/user-defined-ls/wgsl-analyzer/language_server_installing.png differ diff --git a/docs/images/user-defined-ls/wgsl-analyzer/new_language_server_dialog.png b/docs/images/user-defined-ls/wgsl-analyzer/new_language_server_dialog.png new file mode 100644 index 000000000..49d23f0d7 Binary files /dev/null and b/docs/images/user-defined-ls/wgsl-analyzer/new_language_server_dialog.png differ diff --git a/docs/images/user-defined-ls/wgsl-analyzer/open_file.png b/docs/images/user-defined-ls/wgsl-analyzer/open_file.png new file mode 100644 index 000000000..1731c23af Binary files /dev/null and b/docs/images/user-defined-ls/wgsl-analyzer/open_file.png differ diff --git a/docs/images/workspace-folders/ProjectContentRoots.png b/docs/images/workspace-folders/ProjectContentRoots.png new file mode 100644 index 000000000..4cb7d4343 Binary files /dev/null and b/docs/images/workspace-folders/ProjectContentRoots.png differ diff --git a/docs/images/workspace-folders/RootType_PROJECT_BASE.png b/docs/images/workspace-folders/RootType_PROJECT_BASE.png new file mode 100644 index 000000000..20f8bf581 Binary files /dev/null and b/docs/images/workspace-folders/RootType_PROJECT_BASE.png differ diff --git a/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy.png b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy.png new file mode 100644 index 000000000..3f8c3171a Binary files /dev/null and b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy.png differ diff --git a/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_empty.png b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_empty.png new file mode 100644 index 000000000..731ef7363 Binary files /dev/null and b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_empty.png differ diff --git a/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_openFile.png b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_openFile.png new file mode 100644 index 000000000..6325f19c6 Binary files /dev/null and b/docs/images/workspace-folders/RootType_PROJECT_BASE_lazy_checkInit_openFile.png differ diff --git a/docs/images/workspace-folders/RootType_PROJECT_BASE_noRoot.png b/docs/images/workspace-folders/RootType_PROJECT_BASE_noRoot.png new file mode 100644 index 000000000..e7da55bb7 Binary files /dev/null and b/docs/images/workspace-folders/RootType_PROJECT_BASE_noRoot.png differ diff --git a/docs/user-defined-ls/eo-lsp-server.md b/docs/user-defined-ls/eo-lsp-server.md new file mode 100644 index 000000000..0d5f3d62d --- /dev/null +++ b/docs/user-defined-ls/eo-lsp-server.md @@ -0,0 +1,53 @@ +# EO Language Server + +To enable [EO](https://www.eolang.org/) language support in your IDE, you can integrate the [EO LSP Server](https://github.com/objectionary/eo-lsp-server) by following these steps: + +![EO LS demo](../images/user-defined-ls/eo-lsp-server/demo_ls.gif) + +--- + +## Step 1: Install the Language Server + +1. Open an `.eo` file in your project. +2. Click on **Install LSP Server for EOLANG**: + + ![Open file](../images/user-defined-ls/eo-lsp-server/open_file.png) + +3. This will open the [New Language Server Dialog](../UserDefinedLanguageServer.md#new-language-server-dialog) with `LSP Server for EOLANG` pre-selected: + + ![New Language Server Dialog](../images/user-defined-ls/eo-lsp-server/new_language_server_dialog.png) + +4. Click **OK**. This will create the `LSP Server for EOLANG` definition and start the installation: + + ![Installing Language Server](../images/user-defined-ls/eo-lsp-server/language_server_installing.png) + +5. Once the installation completes, the server should start automatically and provide EO language support (diagnostics and semantic tokens.). + +### Troubleshooting Installation + +If the installation fails, you can customize the installation settings in the **Installer** tab, +then click on the **Run Installation** hyperlink to reinstall the server: + +![Installer tab](../images/user-defined-ls/eo-lsp-server/installer_tab.png) + +See [Installer descriptor](../UserDefinedLanguageServerTemplate.md#installer-descriptor) for more information. + +--- + +## Step 2: Install TextMate Bundle + +Even if [EO LSP Server](https://github.com/objectionary/eo-lsp-server) can support LSP semantic tokens, You can also set up the EO TextMate bundle manually. + +* Clone the [eo-vscode](https://github.com/objectionary/eo-vscode) repository: + + ```bash + git clone https://github.com/objectionary/eo-vscode.git + +* Open TextMate Bundles settings + +![TextMate Bundles Settings](../images/user-defined-ls/eo-lsp-server/textmate_bundles_settings.png) + +* Click the `+` button and select the folder [eo-vscode](https://github.com/objectionary/eo-vscode) folder. + This folder contains the modified package.json and the TextMate grammar. + +Once done, IntelliJ will apply syntax highlighting, bracket matching, and other basic editor features for .eo files. \ No newline at end of file diff --git a/docs/user-defined-ls/harper-ls.md b/docs/user-defined-ls/harper-ls.md new file mode 100644 index 000000000..ba5b3e68a --- /dev/null +++ b/docs/user-defined-ls/harper-ls.md @@ -0,0 +1,38 @@ +# Harper Language Server + +[Harper](https://github.com/Automattic/harper) is an English grammar checker designed to be _just right._ +To enable [Harper](https://github.com/Automattic/harper) language support in your IDE, +you can integrate the [Harper Language Server](https://writewithharper.com/docs/integrations/language-server) by following these steps: + +![Harper LS demo](../images/user-defined-ls/harper-ls/demo_ls.gif) + +--- + +## Install the Language Server + +1. Open the [New Language Server Dialog](../UserDefinedLanguageServer.md#new-language-server-dialog) and select `Harper Language Server`: + + ![New Language Server Dialog](../images/user-defined-ls/harper-ls/new_language_server_dialog.png) + +2. Click **OK**. This will create the `Harper Language Server` definition with predefined file mappings (that you can update): + + ![File mappings](../images/user-defined-ls/harper-ls/language_server_mappings.png) + +3. Open a file (ex: a text file) which belongs to the file mapping. It will start the installation + + ![Installing Language Server](../images/user-defined-ls/harper-ls/language_server_installing.png) + +4. Once the installation completes, the server should start automatically and provide Harper language support (diagnostics and code actions). + + ![Installing Language Server](../images/user-defined-ls/harper-ls/language_server_diagnostics.png) + +### Troubleshooting Installation + +If the installation fails, you can customize the installation settings in the **Installer** tab, +then click on the **Run Installation** hyperlink to reinstall the server: + +![Installer tab](../images/user-defined-ls/harper-ls/installer_tab.png) + +See [Installer descriptor](../UserDefinedLanguageServerTemplate.md#installer-descriptor) for more information. + +--- \ No newline at end of file diff --git a/docs/user-defined-ls/wgsl-analyzer.md b/docs/user-defined-ls/wgsl-analyzer.md new file mode 100644 index 000000000..7e160afa8 --- /dev/null +++ b/docs/user-defined-ls/wgsl-analyzer.md @@ -0,0 +1,53 @@ +# WGSL Analyzer + +To enable [WGSL](https://www.w3.org/TR/WGSL/) language support in your IDE, you can integrate the [wgsl-analyzer](https://github.com/wgsl-analyzer/wgsl-analyzer) by following these steps: + +![WGSL Analyzer demo](../images/user-defined-ls/wgsl-analyzer/demo_ls.gif) + +--- + +## Step 1: Install the Language Server + +1. Open an `.wgsl` file in your project. +2. Click on **Install WGSL Analyzer**: + + ![Open file](../images/user-defined-ls/wgsl-analyzer/open_file.png) + +3. This will open the [New Language Server Dialog](../UserDefinedLanguageServer.md#new-language-server-dialog) with `WGSL Analyzer` pre-selected: + + ![New Language Server Dialog](../images/user-defined-ls/wgsl-analyzer/new_language_server_dialog.png) + +4. Click **OK**. This will create the `WGSL Analyzer` definition and start the installation: + + ![Installing Language Server](../images/user-defined-ls/wgsl-analyzer/language_server_installing.png) + +5. Once the installation completes, the server should start automatically and provide [WGSL](https://www.w3.org/TR/WGSL/) language support (autocomplete, diagnostics, etc.). + +### Troubleshooting Installation + +If the installation fails, you can customize the installation settings in the **Installer** tab, +then click on the **Run Installation** hyperlink to reinstall the server: + +![Installer tab](../images/user-defined-ls/wgsl-analyzer/installer_tab.png) + +See [Installer descriptor](../UserDefinedLanguageServerTemplate.md#installer-descriptor) for more information. + +--- + +## Step 2: Install TextMate Bundle + +Since IntelliJ does not provide native WGSL TextMate support, and the language server does not handle syntax highlighting, you need to set up a TextMate bundle manually. + +* Clone the wgsl-analyzer repository: + + ```bash + git clone https://github.com/wgsl-analyzer/wgsl-analyzer.git + +* Open TextMate Bundles settings + +![TextMate Bundles Settings](../images/user-defined-ls/textmate_bundles_settings.png) + +* Click the `+` button and select the folder [wgsl-analyzer/editors/code](https://github.com/wgsl-analyzer/wgsl-analyzer/tree/main/editors/code) folder. + This folder contains the package.json and the TextMate grammar. + +Once done, IntelliJ will apply syntax highlighting, bracket matching, and other basic editor features for .wgsl files. \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 3bb6b7bf5..33f3155f3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,22 +5,22 @@ pluginName=LSP4IJ pluginRepositoryUrl=https://github.com/redhat-developer/lsp4ij # SemVer format -> https://semver.org -pluginVersion=0.15.0-SNAPSHOT +pluginVersion=0.19.4.4 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html -pluginSinceBuild=233 +pluginSinceBuild=242 #pluginUntilBuild=243.* # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension platformType=IC -platformVersion=2023.3 -platformBundledPlugins=org.jetbrains.plugins.textmate,com.intellij.properties +platformVersion=2024.2 +platformBundledPlugins=org.jetbrains.plugins.textmate,com.intellij.properties,org.jetbrains.plugins.terminal # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html platformPlugins=com.redhat.devtools.intellij.telemetry:1.2.1.62 # Gradle Releases -> https://github.com/gradle/gradle/releases -gradleVersion=8.5 +gradleVersion=8.6 channel=nightly # Align LSP4J with JetBrains LSP-API # org.eclipse.lsp4j.debug:0.14.0 0.21.0 @@ -38,3 +38,6 @@ org.gradle.configuration-cache=true # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html org.gradle.caching=true + +# Required for build starting in 2025.1 +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9368a20a..37c272388 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,14 +5,15 @@ junit-jupiter = "5.10.3" # plugins testlogger = "3.2.0" -kotlin = "2.0.20" +kotlin = "2.2.0" changelog = "2.2.1" -gradleIntelliJPlugin = "2.2.1" +gradleIntelliJPlugin = "2.9.0" kover = "0.8.3" [libraries] junit = { group = "junit", name = "junit", version.ref = "junit" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit-jupiter" } +junit-vintage-engine = { group = "org.junit.vintage", name = "junit-vintage-engine", version.ref = "junit-jupiter" } [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e0930..a80b22ce5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index f12a66389..ab13b3318 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,11 @@ +pluginManagement { + repositories { + maven("https://cache-redirector.jetbrains.com/repo1.maven.org/maven2") + gradlePluginPortal() + mavenCentral() + } +} + plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ClosedDocument.java b/src/main/java/com/redhat/devtools/lsp4ij/ClosedDocument.java index d4b2271c4..1fa76b7f0 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ClosedDocument.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ClosedDocument.java @@ -42,7 +42,6 @@ public boolean updateDiagnostics(@NotNull String identifier, .stream() .anyMatch(diagnostic -> diagnostic.getSeverity() != null && diagnostic.getSeverity() == DiagnosticSeverity.Error); return changed; - } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ConnectDocumentToLanguageServerSetupParticipant.java b/src/main/java/com/redhat/devtools/lsp4ij/ConnectDocumentToLanguageServerSetupParticipant.java index d176ea532..c5e02d234 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ConnectDocumentToLanguageServerSetupParticipant.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ConnectDocumentToLanguageServerSetupParticipant.java @@ -11,67 +11,94 @@ package com.redhat.devtools.lsp4ij; import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerEvent; import com.intellij.openapi.fileEditor.FileEditorManagerListener; import com.intellij.openapi.project.Project; import com.intellij.openapi.project.ProjectManagerListener; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; import com.redhat.devtools.lsp4ij.lifecycle.LanguageServerLifecycleManager; import org.jetbrains.annotations.NotNull; /** - * Track file opened / closed to start language servers / disconnect file from language servers. + * Manages the connection between open editor files and Language Servers: + *
    + *
  • Starts Language Servers when a supported file becomes active in the editor.
  • + *
  • Releases resources when a file is closed.
  • + *
  • Properly stops Language Servers when the project is closed.
  • + *
*/ public class ConnectDocumentToLanguageServerSetupParticipant implements ProjectManagerListener, FileEditorManagerListener { + private static void connectToLanguageServer(@NotNull VirtualFile file, @NotNull Project project) { + PsiFile psiFile = LSPIJUtils.getPsiFile(file, project); + if (psiFile != null) { + // Force the startup of all Language Servers mapped to this file. + LanguageServiceAccessor.getInstance(project) + .getLanguageServers(psiFile, null, null); + } + } + @Override public void projectOpened(@NotNull Project project) { + // Subscribe to file open/close/selection events for this project. project.getMessageBus().connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, this); } @Override public void projectClosing(@NotNull Project project) { + // Stop active Language Servers and clean up resources before the project is closed. LanguageServerLifecycleManager.getInstance(project).dispose(); LanguageServiceAccessor.getInstance(project).projectClosing(project); } @Override public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) { - Project project = source.getProject(); - // As document matcher requires read action, and language server starts can take some times, we connect the file in a future - // to avoid starting language server in the EDT Thread which could freeze IJ. - // Wait for indexing is finished and read action is enabled - // --> force the start of all languages servers mapped with the given file when indexing is finished and read action is allowed - /*ProjectIndexingManager + // Do not send textDocument/didOpen here: + // this method is also called when a file is opened in the background (tab not visible). + // To optimize performance, Language Servers are only started when the editor tab becomes active. + } + + @Override + public void selectionChanged(@NotNull FileEditorManagerEvent event) { + VirtualFile file = event.getNewFile(); + if (file == null) { + return; + } + var project = event.getManager().getProject(); + + // Since file-to-server matching requires a read action, + // and Language Server startup may be expensive, + // schedule the connection asynchronously after indexing is finished. + // Note: selectionChanged is always called on EDT, so we move getPsiFile() + // and isFileSupported() calls off EDT to avoid slow operation violations. + ProjectIndexingManager .waitForIndexingAll() .thenApplyAsync(unused -> { + if (!LanguageServersRegistry.getInstance().isFileSupported(file, project)) { + return null; + } + PsiFile psiFile = LSPIJUtils.getPsiFile(file, project); + if (psiFile == null || LSPFileSupport.hasSupport(psiFile)) { + // No PsiFile available, or the file is already connected to a Language Server. + return null; + } connectToLanguageServer(file, project); return null; - });*/ + }); } @Override public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { - // file is closed + // When a file is closed: PsiFile psiFile = LSPIJUtils.getPsiFile(file, source.getProject()); if (psiFile != null) { if (LSPFileSupport.hasSupport(psiFile)) { - // The closed file is mapped with language servers, dispose it - // to cancel all LSP codeLens, inlayHint, color, etc futures + // If the file is associated with a Language Server, + // release its resources (code lenses, inlay hints, color providers, etc.). LSPFileSupport.getSupport(psiFile).dispose(); } } } - - private static void connectToLanguageServer(@NotNull VirtualFile file, @NotNull Project project) { - PsiFile psiFile = LSPIJUtils.getPsiFile(file, project); - if (psiFile != null) { - // Force the start of all languages servers mapped with the given file - // Server capabilities filter is set to null to avoid waiting - // for the start of the server when server capabilities are checked - LanguageServiceAccessor.getInstance(project) - .getLanguageServers(psiFile, null, null); - } - } - } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/DocumentContentSynchronizer.java b/src/main/java/com/redhat/devtools/lsp4ij/DocumentContentSynchronizer.java index 1b33e009e..a88d747d5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/DocumentContentSynchronizer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/DocumentContentSynchronizer.java @@ -66,12 +66,11 @@ public DocumentContentSynchronizer(@NotNull LanguageServerWrapper languageServer @NotNull VirtualFile file, @NotNull Document document, @Nullable String documentText, - @Nullable String languageId, - @NotNull TextDocumentSyncOptions syncOptions) { + @Nullable String languageId) { this.languageServerWrapper = languageServerWrapper; this.file = file; this.fileUri = fileUri; - this.syncOptions = syncOptions; + this.syncOptions = languageServerWrapper.getSyncOptions(); this.document = document; this.documentText = documentText; this.languageId = languageId; @@ -225,8 +224,7 @@ private TextDocumentContentChangeEvent createChangeEvent(DocumentEvent event) { } public void documentSaved() { - var saveOptions = syncOptions.getSave(); - if ((saveOptions.isLeft() && !saveOptions.getLeft()) || (saveOptions.isRight() && saveOptions.getRight() == null)) { + if (!languageServerWrapper.isSaveSupported()) { // Don't send textDocument/didSave return; } @@ -234,6 +232,7 @@ public void documentSaved() { // Send textDocument/didSave languageServerWrapper.sendNotification(ls -> { TextDocumentIdentifier identifier = new TextDocumentIdentifier(fileUri); + var saveOptions = syncOptions.getSave(); boolean includedText = saveOptions.isRight() && saveOptions.getRight().getIncludeText() != null && saveOptions.getRight().getIncludeText(); DidSaveTextDocumentParams params = new DidSaveTextDocumentParams(identifier, includedText ? document.getText() : null); ls.getTextDocumentService().didSave(params); @@ -244,6 +243,17 @@ public void documentSaved() { public void documentClosed() { // When LS is shut down all documents are being disconnected. No need to send "didClose" message to the LS that is being shut down or not yet started if (syncOptions.getOpenClose() && languageServerWrapper.isActive()) { + // Ensure pending textDocument/didChange events are sent before the file is closed. + // + // This handles the specific case where a file is being renamed: + // the original file is closed (triggering a textDocument/didClose), + // but workspace/willRenameFiles may modify the file before that. + // Without this call, the didChange notification could be sent + // after didClose, breaking the expected LSP event order. + if (!changeEvents.isEmpty()) { + sendDidChangeEvents(); + } + // Send textDocument/didClose languageServerWrapper.sendNotification(ls -> { TextDocumentIdentifier identifier = new TextDocumentIdentifier(fileUri); @@ -353,13 +363,16 @@ private void processPullDiagnosticIfNeeded(@Nullable CompletableFuture { // didOpen, didChange notification has been sent, consume 'textDocument/diagnostic' with debounce mode. - debouncePullDiagnosticsAlarm.addRequest(() -> { - if (version != -1 && version != this.version) { - // The document has changed, do nothing - return; - } - refreshPullDiagnostic(version, ls); - }, 500); + // Check if alarm is not disposed (can happen during rapid start/stop cycles) + if (debouncePullDiagnosticsAlarm != null && !debouncePullDiagnosticsAlarm.isDisposed()) { + debouncePullDiagnosticsAlarm.addRequest(() -> { + if (version != -1 && version != this.version) { + // The document has changed, do nothing + return; + } + refreshPullDiagnostic(version, ls); + }, 500); + } return ls; }); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileListener.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileListener.java index 7d011bb65..692ecfbb6 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileListener.java @@ -11,40 +11,64 @@ package com.redhat.devtools.lsp4ij; import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.fileEditor.FileEditorManagerListener; -import com.intellij.openapi.vfs.*; -import com.redhat.devtools.lsp4ij.features.files.watcher.FileSystemWatcherManager; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.features.files.AbstractLSPFileListener; +import com.redhat.devtools.lsp4ij.internal.CompletableFutures; import org.eclipse.lsp4j.*; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import static com.redhat.devtools.lsp4ij.LSPIJUtils.findResourceFor; /** - * LSP file listener. + * {@code LSPFileListener} bridges IntelliJ Virtual File System (VFS) events + * with the Language Server Protocol (LSP) notifications. + *

+ * This concrete implementation of {@link AbstractLSPFileListener} handles: + *

    + *
  • File creation, deletion, and renaming.
  • + *
  • Sending {@code workspace/will*} and {@code workspace/did*} events.
  • + *
  • Document synchronization between IntelliJ and the language server.
  • + *
+ * + *

It also manages file closure events via {@link FileEditorManager}, + * sending a {@code textDocument/didClose} notification when the last + * editor tab for a file is closed.

*/ -class LSPFileListener implements FileEditorManagerListener, VirtualFileListener { +class LSPFileListener extends AbstractLSPFileListener { private static final Logger LOGGER = LoggerFactory.getLogger(LSPFileListener.class); - private final LanguageServerWrapper languageServerWrapper; - private final FileSystemWatcherManager fileSystemWatcherManager; - + /** + * Creates a new {@link LSPFileListener} for the given language server wrapper. + * + * @param languageServerWrapper the language server wrapper + */ public LSPFileListener(@NotNull LanguageServerWrapper languageServerWrapper) { - this.languageServerWrapper = languageServerWrapper; - this.fileSystemWatcherManager = new FileSystemWatcherManager(); + super(languageServerWrapper); } + /** + * Called when a file is closed in the IntelliJ editor. + *

+ * If the file is still open in another editor instance (e.g., via "Split Right"), + * the event is ignored to ensure {@code textDocument/didClose} is only sent + * when the last editor tab for the file is actually closed. + * + * @param source the editor source + * @param file the closed file + */ @Override public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { if (!Objects.equals(source.getProject(), languageServerWrapper.getProject())) { - // The file has been closed from another project,don't send textDocument/didClose + // The file has been closed from another project, don't send textDocument/didClose return; } if (source.getAllEditors(file).length > 0) { @@ -58,157 +82,206 @@ public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile f // Disconnect the given file from the current language servers languageServerWrapper.disconnect(file, !languageServerWrapper.isDisposed()); } catch (Exception e) { - LOGGER.warn("Error while disconnecting the file '" + file.getUrl() + "' from language server '" + languageServerWrapper.getServerDefinition().getDisplayName() + "'.", e); + LOGGER.warn("Error while disconnecting the file '" + file.getUrl() + + "' from language server '" + + languageServerWrapper.getServerDefinition().getDisplayName() + "'.", e); } } + /** + * Handles a file creation event before it occurs, preparing a {@code workspace/willCreateFiles} request. + * + * @param createdFile the file that will be created + * @param fileCreates the list of LSP {@link FileCreate} events to populate + */ @Override - public void propertyChanged(@NotNull VirtualFilePropertyEvent event) { - // Either a file (Test1.java) has been renamed (to Test2.java) by using Refactor / Rename from IJ - // or a "properties" file is changed or saved. - if (isRenameFile(event)) { - // 1. Send a textDocument/didClose for the old file name (Test1.java) followed - // by a textDocument/didOpen for the new file name (Test2.java) - VirtualFile newFile = event.getFile(); - URI oldFileUri = didRename(newFile.getParent(), (String) event.getOldValue(), newFile); - - // 2. Send a workspace/didChangeWatchedFiles - moveFile(oldFileUri, newFile); + protected void onFileCreateBefore(@NotNull VirtualFile createdFile, + @NotNull List fileCreates) { + if (languageServerWrapper.isWillCreateFilesSupported(createdFile)) { + URI createdFileUri = languageServerWrapper.toUri(createdFile); + fileCreates.add(new FileCreate(languageServerWrapper.toUriString(createdFileUri))); } } - private static boolean isRenameFile(@NotNull VirtualFilePropertyEvent event) { - if (event.getPropertyName().equals(VirtualFile.PROP_NAME) && - event.getOldValue() instanceof String oldValue && - event.getNewValue() instanceof String newValue) { - return !Objects.equals(oldValue, newValue); - } - return false; - } - - private void moveFile(URI oldFileUri, VirtualFile newFile) { - if (hasFilePatterns()) { - List changes = new ArrayList<>(2); - if (isMatchFilePatterns(oldFileUri, WatchKind.Delete)) { - changes.add(fe(oldFileUri, FileChangeType.Deleted)); - } - URI newFileUri = languageServerWrapper.toUri(newFile); - if (isMatchFilePatterns(newFileUri, WatchKind.Create)) { - changes.add(fe(newFileUri, FileChangeType.Created)); - } - if (!changes.isEmpty()) { - didChangeWatchedFiles(changes.toArray(new FileEvent[changes.size()])); - } + /** + * Handles a file deletion event before it occurs, preparing a {@code workspace/willDeleteFiles} request. + * + * @param deletedFile the file that will be deleted + * @param fileDeletes the list of LSP {@link FileDelete} events to populate + */ + @Override + protected void onFileDeleteBefore(@NotNull VirtualFile deletedFile, + @NotNull List fileDeletes) { + if (languageServerWrapper.isWillDeleteFilesSupported(deletedFile)) { + URI deletedFileUri = languageServerWrapper.toUri(deletedFile); + fileDeletes.add(new FileDelete(languageServerWrapper.toUriString(deletedFileUri))); } } + /** + * Handles a file rename (or move) before it occurs, preparing a {@code workspace/willRenameFiles} request. + * + * @param parentFile the parent directory + * @param oldFile the file being renamed + * @param newFileName the new name + * @param fileRenames the list of LSP {@link FileRename} events to populate + */ @Override - public void contentsChanged(@NotNull VirtualFileEvent event) { - VirtualFile file = event.getFile(); - URI uri = languageServerWrapper.toUri(file); - if (uri != null) { - OpenedDocument documentListener = languageServerWrapper.openedDocuments.get(uri); - if (documentListener != null) { - // 1. Send a textDocument/didSave for the saved file - documentListener.getSynchronizer().documentSaved(); - } - if (isMatchFilePatterns(uri, WatchKind.Change)) { - // 2. Send a workspace/didChangeWatchedFiles - didChangeWatchedFiles(fe(uri, FileChangeType.Changed)); - } + protected void onFileRenameBefore(@NotNull VirtualFile parentFile, + @NotNull VirtualFile oldFile, + @NotNull String newFileName, + @NotNull List fileRenames) { + if (languageServerWrapper.isWillRenameFilesSupported(oldFile)) { + URI oldFileUri = languageServerWrapper.toUri(oldFile); + URI newFileUri = getUri(parentFile, newFileName); + fileRenames.add(new FileRename(languageServerWrapper.toUriString(oldFileUri), + languageServerWrapper.toUriString(newFileUri))); } } + /** + * Handles a file creation event after it occurs. + *

Adds events for {@code workspace/didCreateFiles} and {@code workspace/didChangeWatchedFiles}.

+ * + * @param createdFile the created file + * @param fileEvents the list of {@link FileEvent}s for didChangeWatchedFiles + * @param fileCreates the list of {@link FileCreate}s for didCreateFiles + */ @Override - public void fileCreated(@NotNull VirtualFileEvent event) { - VirtualFile file = event.getFile(); - URI uri = languageServerWrapper.toUri(file); + protected void onFileCreateAfter(@NotNull VirtualFile createdFile, + @NotNull List fileEvents, + @NotNull List fileCreates) { + URI uri = languageServerWrapper.toUri(createdFile); + if (languageServerWrapper.isDidCreateFilesSupported(createdFile)) { + // Add file create event to send a workspace/didCreateFiles + fileCreates.add(new FileCreate(languageServerWrapper.toUriString(uri))); + } if (isMatchFilePatterns(uri, WatchKind.Create)) { - // 2. Send a workspace/didChangeWatchedFiles with 'Created' file change type. - didChangeWatchedFiles(fe(uri, FileChangeType.Created)); + // Add file event to send a workspace/didChangeWatchedFiles with 'Created' file change type. + fileEvents.add(fe(uri, FileChangeType.Created)); } } + /** + * Handles a file deletion event after it occurs. + *

Adds events for {@code workspace/didDeleteFiles} and {@code workspace/didChangeWatchedFiles}.

+ * + * @param deletedFile the deleted file + * @param fileDeletes the list of {@link FileDelete}s for didDeleteFiles + * @param fileEvents the list of {@link FileEvent}s for didChangeWatchedFiles + */ @Override - public void fileDeleted(@NotNull VirtualFileEvent event) { - VirtualFile file = event.getFile(); - URI uri = languageServerWrapper.toUri(file); + protected void onFileDeleteAfter(@NotNull VirtualFile deletedFile, + @NotNull List fileDeletes, + @NotNull List fileEvents) { + URI uri = languageServerWrapper.toUri(deletedFile); + if (languageServerWrapper.isDidDeleteFilesSupported(deletedFile)) { + // Add file create event to send a workspace/didDeleteFiles + fileDeletes.add(new FileDelete(languageServerWrapper.toUriString(uri))); + } if (isMatchFilePatterns(uri, WatchKind.Delete)) { - // Send a workspace/didChangeWatchedFiles with 'Deleted' file change type. - didChangeWatchedFiles(fe(uri, FileChangeType.Deleted)); + // Add file event to send a workspace/didChangeWatchedFiles with 'Deleted' file change type. + fileEvents.add(fe(uri, FileChangeType.Deleted)); } } + /** + * Handles file content changes after they occur. + *

+ * If the document is tracked by the LSP, this method triggers a + * {@code textDocument/didSave}. It also adds the corresponding + * {@code didChangeWatchedFiles} event if applicable. + *

+ * + * @param changedFile the file whose content changed + * @param fileEvents the list of {@link FileEvent}s for didChangeWatchedFiles + */ @Override - public void fileMoved(@NotNull VirtualFileMoveEvent event) { - // A file (foo.Test1.java) has been moved (to bar1.Test1.java) - - // 1. Send a textDocument/didClose for the old file location (foo.Test1.java) followed - // by a textDocument/didOpen for the new file location (bar1.Test1.java) - VirtualFile movedFile = event.getFile(); - URI oldFileUri = didRename(event.getOldParent(), movedFile.getName(), movedFile); - - // 2. Send a workspace/didChangeWatchedFiles - moveFile(oldFileUri, movedFile); - } - - private FileEvent fe(URI uri, FileChangeType type) { - return new FileEvent(languageServerWrapper.toUriString(uri), type); + protected void onFileContentChangedAfter(@NotNull VirtualFile changedFile, + @NotNull List fileEvents) { + URI fileChangedUri = languageServerWrapper.toUri(changedFile); + OpenedDocument openedDocument = languageServerWrapper.getOpenedDocument(fileChangedUri); + if (openedDocument != null && openedDocument.getSynchronizer() != null) { + // 1. Send a textDocument/didSave for the saved file + openedDocument.getSynchronizer().documentSaved(); + } + if (isMatchFilePatterns(fileChangedUri, WatchKind.Change)) { + // Add file event to send a workspace/didChangeWatchedFiles with 'Changed' file change type. + fileEvents.add(fe(fileChangedUri, FileChangeType.Changed)); + } } /** - * Implements the - * LSP specification for document renaming. Used for both file renames within the same directory and file shifts - * to a different directory. + * Handles file rename or move events after they occur. + *

+ * This includes: + *

    + *
  • Disconnecting the old URI and connecting the new one.
  • + *
  • Sending {@code workspace/didRenameFiles} and {@code didChangeWatchedFiles} events.
  • + *
* - * @param virtualParentFile directory of the file after renaming - * @param oldFileName file name before renaming - * @param virtualNewFile virtual file after renaming - * @return URI of the file before renaming + * @param virtualParentFile the parent directory of the original file + * @param oldFileName the previous file name + * @param newFile the renamed or moved file + * @param fileEvents the list of {@link FileEvent}s + * @param fileRenames the list of {@link FileRename}s */ - private @NotNull URI didRename(@NotNull VirtualFile virtualParentFile, - @NotNull String oldFileName, - @NotNull VirtualFile virtualNewFile) { - URI parentFileUri = languageServerWrapper.toUri(virtualParentFile); - URI oldUri = parentFileUri.resolve(oldFileName); - boolean docIsConnected = languageServerWrapper.isConnectedTo(oldUri); + @Override + protected void onFileRenameAfter(@NotNull VirtualFile virtualParentFile, + @NotNull String oldFileName, + @NotNull VirtualFile newFile, + @NotNull List fileEvents, + @NotNull List fileRenames) { + // A file (foo.Test1.java) has been moved or renamed (to bar1.Test1.java) + + // 1. Send a textDocument/didClose for the old file location (foo.Test1.java) followed + // by a textDocument/didOpen for the new file location (bar1.Test1.java) + + URI oldFileUri = getUri(virtualParentFile, oldFileName); + URI newFileUri = languageServerWrapper.toUri(newFile); + boolean docIsConnected = languageServerWrapper.isConnectedTo(oldFileUri); if (docIsConnected) { // 1. Send a textDocument/didClose for the old file name - languageServerWrapper.disconnect(oldUri, false); + languageServerWrapper.disconnect(oldFileUri, false); // 2. Send a textDocument/didOpen for the new file name - URI newUri = languageServerWrapper.toUri(virtualNewFile); - if (!languageServerWrapper.isConnectedTo(newUri)) { - languageServerWrapper.connect(virtualNewFile, new LanguageServerWrapper.LSPFileConnectionInfo(null, null, null, true)); + if (!languageServerWrapper.isConnectedTo(newFileUri)) { + VirtualFile documentFile = findResourceFor(newFileUri.toASCIIString()); + var document = LSPIJUtils.getDocument(documentFile); + var didOpen = languageServerWrapper.connect(newFile, + languageServerWrapper.createFileConnectionInfo(documentFile, document, true)); + try { + CompletableFutures.waitUntilDone(didOpen, null, 1000); + } catch (ExecutionException e) { + } catch (TimeoutException e) { + } } } - return oldUri; - } - - private void didChangeWatchedFiles(FileEvent... changes) { - languageServerWrapper.sendNotification(ls -> { - DidChangeWatchedFilesParams params = new DidChangeWatchedFilesParams(Arrays.asList(changes)); - ls.getWorkspaceService() - .didChangeWatchedFiles(params); - return ls; - }); - } - private boolean hasFilePatterns() { - return fileSystemWatcherManager.hasFilePatterns(); - } - - private boolean isMatchFilePatterns(@Nullable URI uri, int kind) { - if (uri == null || !hasFilePatterns()) { - return false; + // 2. Prepare file events to send workspace/didRenameFiles + if (languageServerWrapper.isDidRenameFilesSupported(newFile)) { + fileRenames.add(new FileRename(languageServerWrapper.toUriString(oldFileUri), + languageServerWrapper.toUriString(newFileUri))); } - return fileSystemWatcherManager.isMatchFilePattern(uri, kind); - } - public void registerFileSystemWatchers(String id, List watchers) { - fileSystemWatcherManager.registerFileSystemWatchers(id, watchers); + // 3. Prepare file events to send workspace/didChangeWatchedFiles + if (isMatchFilePatterns(newFileUri, WatchKind.Create)) { + fileEvents.add(fe(newFileUri, FileChangeType.Created)); + } + if (isMatchFilePatterns(oldFileUri, WatchKind.Delete)) { + fileEvents.add(fe(oldFileUri, FileChangeType.Deleted)); + } } - public void unregisterFileSystemWatchers(String id) { - fileSystemWatcherManager.unregisterFileSystemWatchers(id); + /** + * Resolves a new file URI from a parent directory and a file name. + * + * @param parentFile the parent directory + * @param fileName the new file name + * @return the resolved {@link URI} + */ + private URI getUri(@NotNull VirtualFile parentFile, + @NotNull String fileName) { + URI parentFileUri = languageServerWrapper.toUri(parentFile); + return parentFileUri.resolve(fileName); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java index da96ea786..242a3e5a1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPFileSupport.java @@ -52,7 +52,10 @@ import org.eclipse.lsp4j.jsonrpc.CancelChecker; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.Callable; /** @@ -62,65 +65,40 @@ @ApiStatus.Internal public class LSPFileSupport extends UserDataHolderBase implements Disposable { - private static final Key LSP_FILE_SUPPORT_KEY = Key.create("lsp.file.support"); public static final @NotNull CancelChecker NO_CANCELLABLE_CHECKER = () -> { }; - - private volatile Alarm refreshPsiFileAlarm = null; - + private static final Key LSP_FILE_SUPPORT_KEY = Key.create("lsp.file.support"); private final PsiFile file; - private final LSPCodeLensSupport codeLensSupport; - private final LSPInlayHintsSupport inlayHintsSupport; - private final LSPColorSupport colorSupport; - private final LSPFoldingRangeSupport foldingRangeSupport; - private final LSPSelectionRangeSupport selectionRangeSupport; - private final LSPFormattingSupport formattingSupport; - private final LSPOnTypeFormattingSupport onTypeFormattingSupport; - private final LSPHighlightSupport highlightSupport; - private final LSPSignatureHelpSupport signatureHelpSupport; - private final LSPDocumentLinkSupport documentLinkSupport; - private final LSPHoverSupport hoverSupport; - private final LSPIntentionCodeActionSupport intentionCodeActionSupport; - private final LSPPrepareRenameSupport prepareRenameSupport; - private final LSPRenameSupport renameSupport; - private final LSPCompletionSupport completionSupport; - private final LSPImplementationSupport implementationSupport; - private final LSPReferenceSupport referenceSupport; - private final LSPDefinitionSupport definitionSupport; - private final LSPDeclarationSupport declarationSupport; - private final LSPTypeDefinitionSupport typeDefinitionSupport; - private final LSPSemanticTokensSupport semanticTokensSupport; - private final LSPDocumentSymbolSupport documentSymbolSupport; - private final LSPPrepareCallHierarchySupport prepareCallHierarchySupport; private final LSPCallHierarchyIncomingCallsSupport callHierarchyIncomingCallsSupport; private final LSPCallHierarchyOutgoingCallsSupport callHierarchyOutgoingCallsSupport; - private final LSPPrepareTypeHierarchySupport prepareTypeHierarchySupport; private final LSPTypeHierarchySubtypesSupport typeHierarchySubtypesSupport; private final LSPTypeHierarchySupertypesSupport typeHierarchySupertypesSupport; + private final List keys = new ArrayList<>(); + private volatile Alarm refreshPsiFileAlarm = null; private boolean disposed; private LSPFileSupport(@NotNull PsiFile file) { @@ -159,6 +137,39 @@ private LSPFileSupport(@NotNull PsiFile file) { } + /** + * Return the existing LSP file support for the given Psi file, or create a new one if necessary. + * + * @param file the Psi file. + * @return the existing LSP file support for the given Psi file, or create a new one if necessary. + */ + public static @NotNull LSPFileSupport getSupport(@NotNull PsiFile file) { + LSPFileSupport support = file.getUserData(LSP_FILE_SUPPORT_KEY); + if (support == null) { + // create LSP support by taking care of multiple threads which could call it. + support = createSupport(file); + } + return support; + } + + private synchronized static @NotNull LSPFileSupport createSupport(@NotNull PsiFile file) { + LSPFileSupport support = file.getUserData(LSP_FILE_SUPPORT_KEY); + if (support != null) { + return support; + } + return new LSPFileSupport(file); + } + + /** + * Returns true if the given Psi file has LSP support and false otherwise. + * + * @param file the Psi file. + * @return true if the given Psi file has LSP support and false otherwise. + */ + public static boolean hasSupport(@NotNull PsiFile file) { + return file.getUserData(LSP_FILE_SUPPORT_KEY) != null; + } + @Override public void dispose() { this.disposed = true; @@ -191,9 +202,8 @@ public void dispose() { getPrepareTypeHierarchySupport().cancel(); getTypeHierarchySubtypesSupport().cancel(); getTypeHierarchySupertypesSupport().cancel(); - var map = getUserMap(); - for (var key : map.getKeys()) { - var value = map.get(key); + for (var key : keys) { + var value = super.getUserData(key); if (value instanceof Disposable disposable) { disposable.dispose(); } @@ -505,41 +515,14 @@ private Alarm getRefreshPsiFileAlarm() { return refreshPsiFileAlarm.isDisposed() ? null : refreshPsiFileAlarm; } - /** - * Return the existing LSP file support for the given Psi file, or create a new one if necessary. - * - * @param file the Psi file. - * @return the existing LSP file support for the given Psi file, or create a new one if necessary. - */ - public static @NotNull LSPFileSupport getSupport(@NotNull PsiFile file) { - LSPFileSupport support = file.getUserData(LSP_FILE_SUPPORT_KEY); - if (support == null) { - // create LSP support by taking care of multiple threads which could call it. - support = createSupport(file); - } - return support; - } - - private synchronized static @NotNull LSPFileSupport createSupport(@NotNull PsiFile file) { - LSPFileSupport support = file.getUserData(LSP_FILE_SUPPORT_KEY); - if (support != null) { - return support; - } - return new LSPFileSupport(file); - } - - /** - * Returns true if the given Psi file has LSP support and false otherwise. - * - * @param file the Psi file. - * @return true if the given Psi file has LSP support and false otherwise. - */ - public static boolean hasSupport(@NotNull PsiFile file) { - return file.getUserData(LSP_FILE_SUPPORT_KEY) != null; - } - public PsiFile getFile() { return file; } + @Override + public void putUserData(@NotNull Key key, @Nullable T value) { + keys.add(key); + super.putUserData(key, value); + } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJEditorUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJEditorUtils.java index 00d06088b..0c4390bcf 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJEditorUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJEditorUtils.java @@ -187,11 +187,13 @@ public static boolean isQuoteCharacter(@NotNull PsiFile file, char character) { private static final Map.Entry BRACES_ENTRY = Map.entry('{', '}'); private static final Map.Entry BRACKETS_ENTRY = Map.entry('[', ']'); private static final Map.Entry PARENTHESES_ENTRY = Map.entry('(', ')'); + private static final Map.Entry TRE_ENTRY = Map.entry('<', '>'); @ApiStatus.Internal public static final Map DEFAULT_BRACE_PAIRS = Map.ofEntries( BRACES_ENTRY, BRACKETS_ENTRY, - PARENTHESES_ENTRY + PARENTHESES_ENTRY, + TRE_ENTRY ); /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java index aebf0fe20..5be5882ba 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LSPIJUtils.java @@ -43,6 +43,7 @@ import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; import com.redhat.devtools.lsp4ij.internal.SimpleLanguageUtils; import com.redhat.devtools.lsp4ij.internal.StringUtils; +import com.redhat.devtools.lsp4ij.internal.uri.UriConverterManager; import com.redhat.devtools.lsp4ij.usages.LocationData; import org.apache.commons.io.FileUtils; import org.eclipse.lsp4j.*; @@ -68,33 +69,49 @@ public class LSPIJUtils { private static final Logger LOGGER = LoggerFactory.getLogger(LSPIJUtils.class); - public static final String JAR_PROTOCOL = "jar"; - - public static final String JRT_PROTOCOL = "jrt"; - - private static final String JAR_SCHEME = JAR_PROTOCOL + ":"; - - private static final String JRT_SCHEME = JRT_PROTOCOL + ":"; - public static final String HASH_SEPARATOR = "#"; private static final String ENCODED_HASH_SEPARATOR = "%23"; - private static final Comparator TEXT_EDITS_ASCENDING_COMPARATOR = (a, b) -> { - int diff = a.getRange().getStart().getLine() - b.getRange().getStart().getLine(); - if (diff == 0) { - return a.getRange().getStart().getCharacter() - b.getRange().getStart().getCharacter(); + /** + * Helper record to track original index of TextEdit for stable sorting. + */ + private record IndexedTextEdit(int index, TextEdit edit) {} + + /** + * Sort text edits by position, preserving array order for edits at the same position + * as required by LSP spec. + * + * @param edits the text edits to sort + * @return a new sorted list of text edits + */ + private static List sortTextEdits(List edits) { + // No need to sort if there's 0 or 1 edit + if (edits.size() <= 1) { + return new ArrayList<>(edits); + } + + // Create indexed entries to preserve original order for edits at the same position + List indexedEdits = new ArrayList<>(edits.size()); + for (int i = 0; i < edits.size(); i++) { + indexedEdits.add(new IndexedTextEdit(i, edits.get(i))); } - return diff; - }; - private static final Comparator TEXT_EDITS_DESCENDING_COMPARATOR = (a, b) -> { - int diff = b.getRange().getStart().getLine() - a.getRange().getStart().getLine(); - if (diff == 0) { - return b.getRange().getStart().getCharacter() - a.getRange().getStart().getCharacter(); + // Sort text edits by position, then by index when positions are equal. + // The index preserves the original array order for same-position edits, + // as required by the LSP spec. + indexedEdits.sort(Comparator + .comparingInt((IndexedTextEdit e) -> e.edit.getRange().getStart().getLine()) + .thenComparingInt(e -> e.edit.getRange().getStart().getCharacter()) + .thenComparingInt((IndexedTextEdit e) -> e.index)); + + // Extract sorted edits + List result = new ArrayList<>(indexedEdits.size()); + for (IndexedTextEdit indexed : indexedEdits) { + result.add(indexed.edit); } - return diff; - }; + return result; + } /** * Open the LSP location in an editor. @@ -434,8 +451,8 @@ private static boolean doOpenInEditor(@NotNull VirtualFile file, return FileEditorManager.getInstance(project).openFile(file, true).length > 0; } else { if (document != null) { - OpenFileDescriptor desc = new OpenFileDescriptor(project, file, LSPIJUtils.toOffset(startPosition, document)); - var startOffset = LSPIJUtils.toOffset(startPosition, document); + int startOffset = LSPIJUtils.toOffset(startPosition, document); + OpenFileDescriptor desc = new OpenFileDescriptor(project, file, startOffset); var editor = FileEditorManager.getInstance(project).openTextEditor(desc, focusEditor); if (editor != null && endPosition != null && !startPosition.equals(endPosition)) { var endOffset = LSPIJUtils.toOffset(endPosition, document); @@ -490,13 +507,7 @@ private static Language doGetFileLanguage(@NotNull VirtualFile file, @NotNull Pr } public static @NotNull URI toUri(@NotNull File file) { - // URI scheme specified by language server protocol and LSP - try { - return new URI("file", "", file.getAbsoluteFile().toURI().getPath(), null); //$NON-NLS-1$ //$NON-NLS-2$ - } catch (URISyntaxException e) { - LOGGER.warn(e.getLocalizedMessage(), e); - return file.getAbsoluteFile().toURI(); - } + return UriConverterManager.getInstance().toUri(file); } public static @Nullable URI toUri(@NotNull PsiFile psiFile) { @@ -505,7 +516,7 @@ private static Language doGetFileLanguage(@NotNull VirtualFile file, @NotNull Pr } public static @NotNull URI toUri(@NotNull VirtualFile file) { - return toUri(VfsUtilCore.virtualToIoFile(file)); + return UriConverterManager.getInstance().toUri(file); } public static @Nullable String toUriAsString(@NotNull PsiFile psFile) { @@ -874,6 +885,11 @@ public static TextRange getWordRangeAt(@NotNull Document document, } } + if (offset < 0) { + // No word at a negative offset; also avoids charAt(-1) in getRightOffsetOfPart + // (offset can be -1 here for an empty document via the clamp above). + return null; + } int start = getLeftOffsetOfPart(document, offset); int end = getRightOffsetOfPart(document, offset); return (start < end) ? new TextRange(start, end) : null; @@ -942,7 +958,7 @@ public static void applyWorkspaceEdit(@NotNull WorkspaceEdit edit, if (file != null) { Document document = getDocument(file); if (document != null) { - applyEdits(null, document, textDocumentEdit.getEdits()); + applyEdits(null, document, textDocumentEdit.getEdits(), false); } } } else if (change.isRight()) { @@ -970,7 +986,7 @@ public static void applyWorkspaceEdit(@NotNull WorkspaceEdit edit, if (file != null) { Document document = getDocument(file); if (document != null) { - applyEdits(null, document, change.getValue()); + applyEdits(null, document, change.getValue(), false); } } } @@ -984,7 +1000,7 @@ private static void applyCreateFile(CreateFile createFile) { Document document = getDocument(targetFile); if (document != null) { TextEdit textEdit = new TextEdit(new Range(toPosition(0, document), toPosition(document.getTextLength(), document)), ""); - applyEdits(null, document, Collections.singletonList(textEdit)); + applyEdits(null, document, Collections.singletonList(textEdit), false); } } } else { @@ -1059,8 +1075,8 @@ private static void applyRenameFile(RenameFile renameFile) { return VfsUtil.findFileByIoFile(newFile, true); } - public static @Nullable VirtualFile findResourceFor(@NotNull URI uri) { - return LocalFileSystem.getInstance().findFileByIoFile(Paths.get(uri).toFile()); + public static @Nullable VirtualFile findResourceFor(@NotNull File file) { + return findResourceFor(toUri(file).toString()); } /** @@ -1070,23 +1086,7 @@ private static void applyRenameFile(RenameFile renameFile) { * @return the virtual file from the given uri and null otherwise. */ public static @Nullable VirtualFile findResourceFor(@NotNull String uri) { - if (uri.startsWith(JAR_SCHEME) || uri.startsWith(JRT_SCHEME)) { - // ex : jar:file:///C:/Users/azerr/.m2/repository/io/quarkus/quarkus-core/3.0.1.Final/quarkus-core-3.0.1.Final.jar!/io/quarkus/runtime/ApplicationConfig.class - try { - return VfsUtil.findFileByURL(new URL(uri)); - } catch (MalformedURLException e) { - return null; - } - } - if (uri.contains("%")) { - // ex : file:///c%3A/Users/azerr/IdeaProjects/untitled7/test.js - // the uri must be decoded (ex : file:///c:/Users) otherwise IntelliJ cannot retrieve the virtual file. - // Keep the original '+' after decoding the Uri - uri = uri.replace("+", "%2B"); - // Decode the uri - uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); - } - return VirtualFileManager.getInstance().findFileByUrl(VfsUtilCore.fixURLforIDEA(uri)); + return UriConverterManager.getInstance().findResourceFor(uri); } /** @@ -1144,7 +1144,8 @@ private static void applyRenameFile(RenameFile renameFile) { } /** - * Apply text edits to the given document and move the caret offset of the given editor if needed. + * Apply text edits to the given document and move the caret offset of the given editor if needed + * without saving the document. * * @param editor the editor used to update the caret offset after the apply edits and null otherwise. * @param document the document to update. @@ -1153,10 +1154,24 @@ private static void applyRenameFile(RenameFile renameFile) { public static void applyEdits(@Nullable Editor editor, @NotNull Document document, @NotNull List edits) { + applyEdits(editor, document, edits, false); + } + /** + * Apply text edits to the given document and move the caret offset of the given editor if needed. + * + * @param editor the editor used to update the caret offset after the apply edits and null otherwise. + * @param document the document to update. + * @param edits the text edit list to apply to the given document. + * @param saveDocument true if document must be saved after the update of the document and false otherwise. + */ + public static void applyEdits(@Nullable Editor editor, + @NotNull Document document, + @NotNull List edits, + boolean saveDocument) { if (ApplicationManager.getApplication().isWriteAccessAllowed()) { - doApplyEdits(editor, document, edits); + doApplyEdits(editor, document, edits, saveDocument); } else { - WriteAction.run(() -> doApplyEdits(editor, document, edits)); + WriteAction.run(() -> doApplyEdits(editor, document, edits, saveDocument)); } } @@ -1167,14 +1182,21 @@ public static void applyEdits(@Nullable Editor editor, * This method is called in Write Action. *

* - * @param editor the editor used to update the caret offset after the apply edits and null otherwise. - * @param document the document to update. - * @param edits the text edit list to apply to the given document. + * @param editor the editor used to update the caret offset after the apply edits and null otherwise. + * @param document the document to update. + * @param edits the text edit list to apply to the given document. + * @param saveDocument true if document must be saved after the update of the document and false otherwise. */ private static void doApplyEdits(@Nullable Editor editor, @NotNull Document document, - @NotNull List edits) { + @NotNull List edits, + boolean saveDocument) { // Create an owned copy to insulate against modification of the provided list while processing it + // We don't sort here because: + // 1. RangeMarkers capture positions from the original document (S1 in LSP spec) + // 2. All edits refer to the original document, not intermediate states + // 3. RangeMarkers automatically adjust as edits are applied + // 4. The original array order must be preserved for same-position edits List ownedEdits = new ArrayList<>(edits); if (ownedEdits.isEmpty()) { @@ -1212,6 +1234,13 @@ private static void doApplyEdits(@Nullable Editor editor, if (newCaretOffset > -1 && oldCaretOffset != newCaretOffset) { editor.getCaretModel().moveToOffset(newCaretOffset); } + + if (saveDocument) { + // Explicit document save is required to trigger LSPFileListener#contentsChanged immediately for files + // that are not open in the editor, which will send didChangeWatchedFiles notification to the language server. + // By default, for such files, IDEA's auto-save logic is used which causes a delay until auto-save happens. + FileDocumentManager.getInstance().saveDocument(document); + } } /** @@ -1322,13 +1351,9 @@ private static int getIncrement(int start, int end, int caret) { */ public static String applyEdits(@NotNull Document document, @NotNull List edits) { - // Create an owned mutable copy since we're going to modify the list - List mutableEdits = new ArrayList<>(edits); + // Sort text edits by position, preserving array order for same-position edits + List mutableEdits = sortTextEdits(edits); - // Sort text edits - if (mutableEdits.size() > 1) { - mutableEdits.sort(TEXT_EDITS_ASCENDING_COMPARATOR); - } String text = document.getText(); int lastModifiedOffset = 0; List spans = new ArrayList<>(mutableEdits.size() + 1); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServerWrapper.java b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServerWrapper.java index 6efa8f26b..3ac0911ca 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServerWrapper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServerWrapper.java @@ -16,6 +16,7 @@ import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileEditorManager; import com.intellij.openapi.fileEditor.FileEditorManagerListener; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Disposer; @@ -28,6 +29,7 @@ import com.intellij.util.Alarm; import com.intellij.util.messages.MessageBusConnection; import com.redhat.devtools.lsp4ij.client.LanguageClientImpl; +import com.redhat.devtools.lsp4ij.client.WorkspaceFolderNotificationManager; import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.console.explorer.TracingMessageConsumer; @@ -46,8 +48,8 @@ import com.redhat.devtools.lsp4ij.server.*; import com.redhat.devtools.lsp4ij.server.capabilities.TextDocumentServerCapabilityRegistry; import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; -import com.redhat.devtools.lsp4ij.settings.ServerTrace; import com.redhat.devtools.lsp4ij.settings.ProjectLanguageServerSettings; +import com.redhat.devtools.lsp4ij.settings.ServerTrace; import org.eclipse.lsp4j.*; import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.jsonrpc.MessageConsumer; @@ -68,6 +70,8 @@ import java.util.function.Function; import java.util.function.UnaryOperator; +import static com.redhat.devtools.lsp4ij.LSPIJUtils.findResourceFor; +import static com.redhat.devtools.lsp4ij.features.diagnostics.LSPDiagnosticUtils.clearProblem; import static com.redhat.devtools.lsp4ij.internal.CancellationSupport.showNotificationError; import static com.redhat.devtools.lsp4ij.internal.IntelliJPlatformUtils.getClientInfo; @@ -100,7 +104,6 @@ public class LanguageServerWrapper implements Disposable { protected final InitializeParams initParams = new InitializeParams(); @NotNull private final LanguageServerDefinition serverDefinition; - private final AtomicBoolean stopping = new AtomicBoolean(false); private final ExecutorService dispatcher; private final ExecutorService listener; private final SimpleModificationTracker modificationTracker = new SimpleModificationTracker(); @@ -109,7 +112,7 @@ public class LanguageServerWrapper implements Disposable { */ private final @NotNull Map dynamicRegistrations = new HashMap<>(); - private final LSPFileListener fileListener = new LSPFileListener(this); + private final LSPFileListener fileListener; private final AtomicInteger keepAliveCounter = new AtomicInteger(); protected StreamConnectionProvider lspStreamProvider; private MessageBusConnection messageBusConnection; @@ -119,7 +122,7 @@ public class LanguageServerWrapper implements Disposable { private LanguageServer languageServer; private LanguageClientImpl languageClient; private ServerCapabilities serverCapabilities; - private Timer timer; + private volatile @Nullable Alarm stopAlarm = null; private ServerStatus serverStatus; private boolean disposed; private LanguageServerException serverError; @@ -127,6 +130,7 @@ public class LanguageServerWrapper implements Disposable { private List currentProcessCommandLines; private boolean initiallySupportsWorkspaceFolders = false; private FileOperationsManager fileOperationsManager; + private WorkspaceFolderNotificationManager workspaceFolderNotificationManager; private LSPClientFeatures clientFeatures; // error notification displayed when server start fails. @@ -134,6 +138,8 @@ public class LanguageServerWrapper implements Disposable { private @Nullable TracingMessageConsumer tracing; private volatile @Nullable ConcurrentLinkedQueue traces; private @Nullable Alarm traceFlushAlarm; + private InitializingContext currentInitializingContext; + private @NotNull TextDocumentSyncOptions syncOptions; /* Backwards compatible constructor */ public LanguageServerWrapper(@NotNull Project project, @NotNull LanguageServerDefinition serverDefinition) { @@ -151,17 +157,35 @@ public LanguageServerWrapper(@NotNull Project project, this.serverDefinition = serverDefinition; this.openedDocuments = new HashMap<>(); this.closedDocuments = new HashMap<>(); + this.fileListener = new LSPFileListener(this); + VirtualFileManager.getInstance().addAsyncFileListener(fileListener, this); + String projectName = sanitize(!serverDefinition.isSingleton() ? ("@" + project.getName()) : ""); //$NON-NLS-1$//$NON-NLS-2$ + + // Worker threads must not inherit the plugin classloader, or they pin it and block dynamic unload. + var workerCtxClassLoader = ApplicationManager.class.getClassLoader(); String dispatcherThreadNameFormat = "LS-" + serverDefinition.getId() + projectName + "#dispatcher"; //$NON-NLS-1$ //$NON-NLS-2$ - this.dispatcher = Executors - .newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat(dispatcherThreadNameFormat).build()); + this.dispatcher = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat(dispatcherThreadNameFormat) + .setThreadFactory(r -> { + var t = new Thread(r); + t.setContextClassLoader(workerCtxClassLoader); + return t; + }) + .build()); // Executor service passed through to the LSP4j layer when we attempt to start the LS. It will be used // to create a listener that sits on the input stream and processes inbound messages (responses, or server-initiated // requests). String listenerThreadNameFormat = "LS-" + serverDefinition.getId() + projectName + "#listener-%d"; //$NON-NLS-1$ //$NON-NLS-2$ - this.listener = Executors - .newCachedThreadPool(new ThreadFactoryBuilder().setNameFormat(listenerThreadNameFormat).build()); + this.listener = Executors.newCachedThreadPool(new ThreadFactoryBuilder() + .setNameFormat(listenerThreadNameFormat) + .setThreadFactory(r -> { + var t = new Thread(r); + t.setContextClassLoader(workerCtxClassLoader); + return t; + }) + .build()); updateStatus(ServerStatus.none); // When project is disposed, we dispose the language server @@ -206,6 +230,20 @@ void stopDispatcher() { // If we don't do this then a full test run will generate a lot of threads because we create new // instances of this class for each test this.listener.shutdownNow(); + + // Wait briefly for threads to actually exit. + try { + if (!this.dispatcher.awaitTermination(2, TimeUnit.SECONDS)) { + LOGGER.warn("Dispatcher executor for language server '{}' did not terminate within 2s; classloader unload may be blocked", + serverDefinition.getId()); + } + if (!this.listener.awaitTermination(2, TimeUnit.SECONDS)) { + LOGGER.warn("Listener executor for language server '{}' did not terminate within 2s; classloader unload may be blocked", + serverDefinition.getId()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } } public synchronized void stopAndDisable() { @@ -219,9 +257,10 @@ public synchronized void stopAndDisable() { */ private synchronized void stopAndRefreshEditorFeature(boolean refreshEditorFeature, boolean disable) { // Collect opened files before stopping the language server - List openedFiles = refreshEditorFeature ? openedDocuments.entrySet() + List openedFiles = refreshEditorFeature ? + new ArrayList<>(openedDocuments.values()) .stream() - .map(c -> c.getValue().getFile()) + .map(OpenedDocument::getFile) .toList() : Collections.emptyList(); // Disable the language server @@ -309,19 +348,26 @@ public synchronized void start() throws LanguageServerException { if (this.initializeFuture == null) { final VirtualFile rootURI = getRootURI(); this.launcherFuture = new CompletableFuture<>(); - this.initializeFuture = CompletableFuture.supplyAsync(() -> { + var context = this.currentInitializingContext = new InitializingContext(); + // Use IntelliJ pooled thread instead of ForkJoinPool.commonPool() to avoid + // ForkJoinPool.helpAsyncBlocker() executing blocking operations (like OSProcessHandler.waitFor()) + // in the current thread when called from ReadAction. + // We cannot use 'dispatcher' (single thread executor) as it's reserved for ordered notification dispatch. + // See: https://github.com/redhat-developer/lsp4ij/issues/1442 + CompletableFuture startFuture = new CompletableFuture<>(); + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + var initializingContext = context; + // Starting process... + updateStatus(ServerStatus.starting); // Initialize LSP traces if (tracing != null) { tracing = null; } - var oldProvider = this.lspStreamProvider; - if (oldProvider != null) { - // Ensure process is stopped before starting a new process - oldProvider.stop(); - } - var provider = this.lspStreamProvider = serverDefinition.createConnectionProvider(initialProject); + var provider = serverDefinition.createConnectionProvider(initialProject); + initializingContext.provider = provider; initParams.setInitializationOptions(provider.getInitializationOptions(rootURI)); // Add error log @@ -332,14 +378,12 @@ public synchronized void start() throws LanguageServerException { // 2. the start command takes some times and fails // --> // Stop the language server - stop(); - // Show a notification error with "The server was stopped unexpectedly." error message. serverError = new ServerWasStoppedException("The server was stopped unexpectedly."); + stop(initializingContext); + // Show a notification error with "The server was stopped unexpectedly." error message. showNotificationStartServerError(); }); - // Starting process... - updateStatus(ServerStatus.starting); this.currentProcessId = null; this.currentProcessCommandLines = null; provider.start(); @@ -355,9 +399,15 @@ public synchronized void start() throws LanguageServerException { // Throws the CannotStartProcessException exception if process is not alive. // This use case comes for instance when the start process command fails (not a valid start command) provider.ensureIsAlive(); - return null; - }).thenRun(() -> { - languageClient = serverDefinition.createLanguageClient(initialProject); + startFuture.complete(currentInitializingContext); + } catch (Exception e) { + startFuture.completeExceptionally(e); + } + }); + this.initializeFuture = startFuture + .thenApply(initializingContext -> { + var languageClient = serverDefinition.createLanguageClient(initialProject); + initializingContext.languageClient = languageClient; languageClient.setServerWrapper(this); initParams.setProcessId(getParentProcessId()); @@ -366,6 +416,7 @@ public synchronized void start() throws LanguageServerException { initParams.setRootPath(rootURI.getPath()); } + var provider = initializingContext.provider; UnaryOperator wrapper = consumer -> (message -> { if (isDisposed()) { return; @@ -386,25 +437,27 @@ public synchronized void start() throws LanguageServerException { getLanguageServerLifecycleManager().onError(this, e); throw e; } - final StreamConnectionProvider currentConnectionProvider = this.lspStreamProvider; - if (currentConnectionProvider != null && isActive()) { - currentConnectionProvider.handleMessage(message, this.languageServer, rootURI); + if (provider != null && isActive()) { + provider.handleMessage(message, this.languageServer, rootURI); } }); - Launcher launcher = serverDefinition.createLauncherBuilder(getClientFeatures()) // + + Launcher launcher = getClientFeatures().createLauncherBuilder() // .setLocalService(languageClient)// .setRemoteInterface(serverDefinition.getServerInterface())// - .setInput(lspStreamProvider.getInputStream())// - .setOutput(lspStreamProvider.getOutputStream())// + .setInput(provider.getInputStream())// + .setOutput(provider.getOutputStream())// .setExecutorService(listener)// .wrapMessages(wrapper)// .create(); - this.languageServer = launcher.getRemoteProxy(); + var languageServer = launcher.getRemoteProxy(); + initializingContext.languageServer = languageServer; languageClient.connect(languageServer); - this.launcherFuture = launcher.startListening(); + initializingContext.launcherFuture = launcher.startListening(); + return initializingContext; }) - .thenCompose(unused -> initServer(rootURI)) - .thenAccept(res -> { + .thenCompose(initializingContext -> initServer(rootURI, initializingContext)) + .thenApply(initializingContext -> { serverError = null; if (errorNotification != null) { // Close the current error notification @@ -412,25 +465,39 @@ public synchronized void start() throws LanguageServerException { errorNotification.expire(); errorNotification = null; } - serverCapabilities = res.getCapabilities(); + serverCapabilities = initializingContext.initializeResult.getCapabilities(); + syncOptions = createSyncOptions(); getClientFeatures().setServerCapabilities(serverCapabilities); this.initiallySupportsWorkspaceFolders = supportsWorkspaceFolders(serverCapabilities); - }).thenRun(() -> this.languageServer.initialized(new InitializedParams())).thenRun(() -> { - initializeFuture.thenRunAsync(() -> { - for (VirtualFile fileToReconnect : filesToReconnect) { - connect(fileToReconnect, new LSPFileConnectionInfo(null, null, null, true)); - } - }); - - messageBusConnection = ApplicationManager.getApplication().getMessageBus().connect(); + return initializingContext; + }) + .thenApply(initializingContext -> { + initializingContext.languageServer.initialized(new InitializedParams()); + return initializingContext; + }) + .thenApply(initializingContext -> { + messageBusConnection = ApplicationManager.getApplication().getMessageBus().connect(this); messageBusConnection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, fileListener); - messageBusConnection.subscribe(VirtualFileManager.VFS_CHANGES, new BulkVirtualFileListenerAdapter(fileListener)); fileOperationsManager = new FileOperationsManager(this); fileOperationsManager.setServerCapabilities(serverCapabilities); + workspaceFolderNotificationManager = new WorkspaceFolderNotificationManager(this); + + this.lspStreamProvider = initializingContext.provider; + this.languageClient = initializingContext.languageClient; + this.languageServer = initializingContext.languageServer; + this.launcherFuture = initializingContext.launcherFuture; + updateStatus(ServerStatus.started); - }).exceptionally(e -> { + return initializingContext; + }) + .thenRun(() -> { + for (VirtualFile fileToReconnect : filesToReconnect) { + connect(fileToReconnect, new LSPFileConnectionInfo(null, null, null, true)); + } + }) + .exceptionally(e -> { if (e instanceof CompletionException) { e = e.getCause(); } @@ -442,7 +509,7 @@ public synchronized void start() throws LanguageServerException { showNotificationStartServerError(); initializeFuture.completeExceptionally(serverError); getLanguageServerLifecycleManager().onError(this, e); - stop(false); + stop(context); return null; }); } @@ -470,20 +537,29 @@ private void showNotificationStartServerError() { } } - private CompletableFuture initServer(final VirtualFile rootURI) { + private CompletableFuture initServer(final VirtualFile rootURI, + @NotNull InitializingContext initializingContext) { + var provider = initializingContext.provider; initParams.setCapabilities(ClientCapabilitiesFactory - .create(lspStreamProvider.getExperimentalFeaturesPOJO())); + .create(provider.getExperimentalFeaturesPOJO())); initParams.setClientInfo(getClientInfo()); - initParams.setTrace(this.lspStreamProvider.getTrace(rootURI)); + initParams.setTrace(provider.getTrace(rootURI)); - var folders = LSPIJUtils.toWorkspaceFolders(initialProject, getClientFeatures()); + var folders = getClientFeatures() + .getWorkspaceFolderFeature() + .getInitialWorkspaceFolders(initialProject); initParams.setWorkspaceFolders(folders); // Customize initialize params if needed getClientFeatures().initializeParams(initParams); // no then...Async future here as we want this chain of operation to be sequential and "atomic"-ish - return languageServer.initialize(initParams); + var languageServer = initializingContext.languageServer; + return languageServer.initialize(initParams) + .thenApply(initializeResult -> { + initializingContext.initializeResult = initializeResult; + return initializingContext; + }); } @Nullable @@ -500,11 +576,13 @@ private void logMessage(Message message, MessageConsumer consumer) { } private void removeStopTimer(boolean stopping) { - if (timer != null) { - timer.cancel(); - timer = null; - if (!stopping) { - updateStatus(ServerStatus.started); + Alarm stopAlarm = this.stopAlarm; + if (stopAlarm != null) { + if (stopAlarm.getActiveRequestCount() > 0) { + stopAlarm.cancelAllRequests(); + if (!stopping) { + updateStatus(ServerStatus.started); + } } } } @@ -526,19 +604,26 @@ public void updateStatus(@NotNull ServerStatus serverStatus) { } private void startStopTimer() { - timer = new Timer("Stop Language Server Timer"); //$NON-NLS-1$ updateStatus(ServerStatus.stopping); - timer.schedule(new TimerTask() { - @Override - public void run() { - try { - stop(); - } catch (Throwable t) { - //Need to catch time task exceptions, or it will cancel the timer - LOGGER.error("Failed to stop language server " + LanguageServerWrapper.this.serverDefinition.getId(), t); + int delayMs = (int) TimeUnit.SECONDS.toMillis(serverDefinition.getLastDocumentDisconnectedTimeout()); + getStopAlarm().addRequest(() -> { + try { + stop(); + } catch (Throwable t) { + LOGGER.error("Failed to stop language server {}", serverDefinition.getId(), t); + } + }, delayMs); + } + + private @NotNull Alarm getStopAlarm() { + if (stopAlarm == null) { + synchronized (this) { + if (stopAlarm == null) { + stopAlarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this); } } - }, TimeUnit.SECONDS.toMillis(this.serverDefinition.getLastDocumentDisconnectedTimeout())); + } + return stopAlarm; } /** @@ -548,174 +633,6 @@ public boolean isActive() { return this.launcherFuture != null && !this.launcherFuture.isDone() && !this.launcherFuture.isCancelled(); } - @Override - public void dispose() { - dispose(false); - } - - public void dispose(boolean refreshEditorFeature) { - if (disposed) { - return; - } - this.disposed = true; - stopAndRefreshEditorFeature(refreshEditorFeature, false); - stopDispatcher(); - if (clientFeatures != null) { - clientFeatures.dispose(); - clientFeatures = null; - } - } - - public boolean isDisposed() { - return disposed; - } - - /** - * Returns true if the language server is stopping and false otherwise. - * - * @return true if the language server is stopping and false otherwise. - */ - public boolean isStopping() { - return this.stopping.get(); - } - - public synchronized CompletableFuture stop() { - final boolean alreadyStopping = this.stopping.getAndSet(true); - return stop(alreadyStopping); - } - - public synchronized CompletableFuture stop(boolean alreadyStopping) { - try { - if (alreadyStopping) { - return CompletableFuture.completedFuture(null); - } - updateStatus(ServerStatus.stopping); - - removeStopTimer(true); - if (this.languageClient != null) { - this.languageClient.dispose(); - } - - if (this.initializeFuture != null) { - CancellationSupport.cancel(this.initializeFuture); - this.initializeFuture = null; - } - - this.serverCapabilities = null; - this.dynamicRegistrations.clear(); - - if (isDisposed()) { - // When project is closing we shutdown everything in synch mode - shutdownAll(languageServer, lspStreamProvider, launcherFuture); - return CompletableFuture.completedFuture(null); - } else { - // We need to shutdown, kill and stop the process in a thread to avoid for instance - // stopping the new process created with a new start. - final Future serverFuture = this.launcherFuture; - final StreamConnectionProvider provider = this.lspStreamProvider; - final LanguageServer languageServerInstance = this.languageServer; - - Runnable shutdownKillAndStopFutureAndProvider = () -> { - shutdownAll(languageServerInstance, provider, serverFuture); - this.stopping.set(false); - updateStatus(ServerStatus.stopped); - }; - return CompletableFuture.runAsync(shutdownKillAndStopFutureAndProvider); - } - } finally { - this.launcherFuture = null; - this.lspStreamProvider = null; - if (this.initializeFuture != null) { - CancellationSupport.cancel(this.initializeFuture); - this.initializeFuture = null; - } - - while (!this.openedDocuments.isEmpty()) { - disconnect(this.openedDocuments.keySet().iterator().next(), false); - } - while (!this.closedDocuments.isEmpty()) { - URI fileUri = closedDocuments.keySet().iterator().next(); - closedDocuments.remove(fileUri); - clearProblem(fileUri); - } - this.languageServer = null; - this.languageClient = null; - - if (messageBusConnection != null) { - messageBusConnection.disconnect(); - } - } - } - - private void clearProblem(@NotNull URI fileUri) { - VirtualFile file = FileUriSupport.findFileByUri(fileUri.toASCIIString(), getClientFeatures()); - if (file != null && clientFeatures.getDiagnosticFeature().canReportProblem(file)) { - LSPDiagnosticUtils.clearProblem(file, getProject()); - } - } - - private void shutdownAll(LanguageServer languageServerInstance, StreamConnectionProvider provider, Future serverFuture) { - if (languageServerInstance != null && provider != null && provider.isAlive()) { - // The LSP language server instance and the process which starts the language server is alive. Process - // - shutdown - // - exit - - // shutdown the language server - if (provider.isAlive()) { - try { - shutdownLanguageServerInstance(languageServerInstance); - } catch (Exception ex) { - getLanguageServerLifecycleManager().onError(this, ex); - } - - // exit the language server - // Consume language server exit() before cancelling launcher future (serverFuture.cancel()) - // to avoid having error like "The pipe is being closed". - try { - exitLanguageServerInstance(languageServerInstance); - } catch (Exception ex) { - getLanguageServerLifecycleManager().onError(this, ex); - } - } - } - - CancellationSupport.cancel(serverFuture); - - if (provider != null) { - provider.stop(); - } - - } - - private void shutdownLanguageServerInstance(LanguageServer languageServerInstance) throws Exception { - CompletableFuture shutdown = languageServerInstance.shutdown(); - if (!isDisposed()) { - try { - shutdown.get(5, TimeUnit.SECONDS); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } catch (TimeoutException ex) { - String message = "Timeout error while shutdown the language server '" + serverDefinition.getId() + "'"; - LOGGER.warn(message, ex); - throw new Exception(message, ex); - } catch (Exception ex) { - String message = "Error while shutdown the language server '" + serverDefinition.getId() + "'"; - LOGGER.warn(message, ex); - throw new Exception(message, ex); - } - } - } - - private void exitLanguageServerInstance(LanguageServer languageServerInstance) throws Exception { - try { - languageServerInstance.exit(); - } catch (Exception ex) { - String message = "Error while exit the language server '" + serverDefinition.getId() + "'"; - LOGGER.error(message, ex); - throw new Exception(message, ex); - } - } - /** * Check whether this LS is suitable for provided project. Starts the LS if not * already started. @@ -790,9 +707,18 @@ public boolean canOperate(Project project) { return CompletableFuture.completedFuture(null); } + // Compute workspace folder BEFORE synchronized block to avoid slow I/O operations in critical section + WorkspaceFolder folderToNotify = null; + if (workspaceFolderNotificationManager != null) { + folderToNotify = workspaceFolderNotificationManager.computeWorkspaceFolderToNotify(file); + } + synchronized (closedDocuments) { closedDocuments.remove(fileUri); } + + CompletableFuture result; + boolean shouldNotify = false; synchronized (openedDocuments) { // Check again if file is already opened (within synchronized block) ls2 = getLanguageServerWhenDidOpen(fileUri, waitForDidOpen); @@ -800,16 +726,34 @@ public boolean canOperate(Project project) { return ls2; } + // Mark workspace folder as notified BEFORE didOpen (fast operation in synchronized block) + if (folderToNotify != null) { + shouldNotify = workspaceFolderNotificationManager.markFolderAsNotified(folderToNotify); + } + DocumentContentSynchronizer synchronizer = createDocumentContentSynchronizer(toUriString(fileUri), file, document, fileConnectionInfo.documentText(), fileConnectionInfo.languageId()); - document.addDocumentListener(synchronizer); + // Synchronizer usually is disposed when document editor is closed. + // But in case of closing the project, it does not close all editors, + // so to ensure synchronizer is disposed we also register it here + Disposer.register(this, synchronizer); + document.addDocumentListener(synchronizer, synchronizer); OpenedDocument data = new OpenedDocument(new LanguageServerItem(languageServer, this), file, synchronizer); LanguageServerWrapper.this.openedDocuments.put(fileUri, data); if (waitForDidOpen) { - return getLanguageServerWhenDidOpen(synchronizer.getDidOpenFuture()); + result = getLanguageServerWhenDidOpen(synchronizer.getDidOpenFuture()); + } else { + result = CompletableFuture.completedFuture(languageServer); } - return CompletableFuture.completedFuture(languageServer); } + + // Send notification AFTER synchronized block to avoid blocking other file opens + // Only send if folder was newly marked (not already notified) + if (shouldNotify) { + workspaceFolderNotificationManager.sendFolderAddedNotification(folderToNotify); + } + + return result; }); } @@ -819,7 +763,7 @@ private CompletableFuture getLanguageServerWhenDidOpen(@Nullable return null; } var openedDocument = openedDocuments.get(fileUri); - if (openedDocument != null) { + if (openedDocument != null && openedDocument.getSynchronizer() != null) { if (!waitForDidOpen) { return CompletableFuture.completedFuture(languageServer); } @@ -849,8 +793,27 @@ private DocumentContentSynchronizer createDocumentContentSynchronizer(@NotNull S @NotNull Document document, @Nullable String documentText, @Nullable String languageId) { - TextDocumentSyncOptions syncOptions = createSyncOptions(); - return new DocumentContentSynchronizer(this, fileUri, file, document, documentText, languageId, syncOptions); + return new DocumentContentSynchronizer(this, fileUri, file, document, documentText, languageId); + } + + public @NotNull TextDocumentSyncOptions getSyncOptions() { + if (syncOptions != null) { + return syncOptions; + } + syncOptions = createSyncOptions(); + return syncOptions; + } + + public boolean isSaveSupported() { + if (this.serverCapabilities == null) { + // Language server is not started, save is not supported + return false; + } + var saveOptions = getSyncOptions().getSave(); + if (saveOptions == null) { + return false; + } + return (saveOptions.isLeft() && saveOptions.getLeft()) || (saveOptions.isRight() && saveOptions.getRight() != null); } private @NotNull TextDocumentSyncOptions createSyncOptions() { @@ -865,11 +828,7 @@ private DocumentContentSynchronizer createDocumentContentSynchronizer(@NotNull S newSyncOptions.setSave(true); return newSyncOptions; } else if (syncOptions.isRight()) { - TextDocumentSyncOptions newSyncOptions = syncOptions.getRight(); - if (newSyncOptions.getSave() == null) { - newSyncOptions.setSave(true); - } - return newSyncOptions; + return syncOptions.getRight(); } } return DEFAULT_SYNC_OPTIONS; @@ -888,9 +847,10 @@ void disconnect(@Nullable URI fileUri, boolean stopIfNoOpenedFiles) { if (openedDocument != null) { // Remove the listener from the old document stored in synchronizer DocumentContentSynchronizer synchronizer = openedDocument.getSynchronizer(); - synchronizer.getDocument().removeDocumentListener(synchronizer); - synchronizer.dispose(); - clearProblem(fileUri); + if (synchronizer != null) { + Disposer.dispose(synchronizer); + } + clearProblem(Collections.singleton(fileUri), getClientFeatures(), getProject()); } if (stopIfNoOpenedFiles) { maybeShutdown(); @@ -929,13 +889,45 @@ public boolean isConnectedTo(URI fileUri) { } /** - * Returns the LSP file data coming from this language server for the given file uri. + * Returns the document opened in the editor for the given file URI. + *

+ * If the document is already open, it is returned immediately otherwise it returns null * - * @param fileUri the file Uri. - * @return the LSP file data coming from this language server for the given file uri. + * @param fileUri the file URI + * @return the {@link OpenedDocument} representing the opened editor document, or {@code null} if not found + */ + public @Nullable OpenedDocument getOpenedDocument(@Nullable URI fileUri) { + return getOpenedDocument(fileUri, false); + } + + /** + * Returns the document opened in the editor for the given file URI. + *

+ * If the document is already open, it is returned immediately. If not, and {@code force} is + * {@code true}, this method will try to locate the file and open it in the context of the language server. + * + * @param fileUri the file URI + * @param force if {@code true}, attempt to open the document if it is not already opened + * @return the {@link OpenedDocument} representing the opened editor document, or {@code null} if not found */ - public @Nullable OpenedDocument getOpenedDocument(URI fileUri) { - return openedDocuments.get(fileUri); + public @Nullable OpenedDocument getOpenedDocument(@Nullable URI fileUri, boolean force) { + var openedDocument = fileUri != null ? openedDocuments.get(fileUri) : null; + if (openedDocument != null) { + // Document is already opened (with or without synchronization with the LSP server when content changes) + return openedDocument; + } + if (!force) { + return null; + } + String uri = getClientFeatures().toString(fileUri, false); + if (uri != null) { + VirtualFile file = getClientFeatures().findFileByUri(uri); + if (file != null) { + openedDocument = new OpenedDocument(new LanguageServerItem(languageServer, this), file, null); + openedDocuments.put(fileUri, openedDocument); + } + } + return openedDocument; } /** @@ -1083,7 +1075,7 @@ public void registerCapability(RegistrationParams params) { addRegistration(reg, () -> registry.unregisterCapability(options)); } } catch (Exception e) { - LOGGER.error("Error while getting '" + method + "' capability", e); + LOGGER.error("Error while getting '{}' capability", method, e); } } }); @@ -1156,19 +1148,6 @@ void unregisterCommands(@NotNull List commands) { } } - int getVersion(VirtualFile file) { - if (file != null) { - OpenedDocument data = openedDocuments.get(LSPIJUtils.toUri(file)); - if (data != null) { - var synchronizer = data.getSynchronizer(); - if (synchronizer != null) { - return synchronizer.getVersion(); - } - } - } - return -1; - } - public boolean canOperate(@NotNull VirtualFile file) { if (this.isConnectedTo(toUri(file))) { return true; @@ -1204,8 +1183,6 @@ public List getCurrentProcessCommandLine() { return currentProcessCommandLines; } -// ------------------ Current Process information. - /** * Returns the server status. * @@ -1215,16 +1192,18 @@ public ServerStatus getServerStatus() { return serverStatus; } +// ------------------ Current Process information. + public LanguageServerException getServerError() { return serverError; } -// ------------------ Server status information . - public int getNumberOfRestartAttempts() { return numberOfRestartAttempts; } +// ------------------ Server status information . + public int getMaxNumberOfRestartAttempts() { return MAX_NUMBER_OF_RESTART_ATTEMPTS; } @@ -1239,13 +1218,98 @@ public LanguageServerDefinition getServerDefinition() { return serverDefinition; } + /** + * Returns true if the given file support the 'workspace/willCreateFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/willCreateFiles' and false otherwise. + */ + public boolean isWillCreateFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canWillCreateFiles(uri, file.isDirectory()); + } + + /** + * Returns true if the given file support the 'workspace/didCreateFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/didCreateFiles' and false otherwise. + */ + public boolean isDidCreateFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canDidCreateFiles(uri, file.isDirectory()); + } + + /** + * Returns true if the given file support the 'workspace/willDeleteFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/willDeleteFiles' and false otherwise. + */ + public boolean isWillDeleteFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canWillDeleteFiles(uri, file.isDirectory()); + } + + /** + * Returns true if the given file support the 'workspace/didDeleteFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/didDeleteFiles' and false otherwise. + */ + public boolean isDidDeleteFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canDidDeleteFiles(uri, file.isDirectory()); + } + + /** + * Returns true if the given file support the 'workspace/willRenameFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/willRenameFiles' and false otherwise. + */ + public boolean isWillRenameFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canWillRenameFiles(uri, file.isDirectory()); + } + /** * Returns true if the given file support the 'workspace/willRenameFiles' and false otherwise. * * @param file the file. * @return true if the given file support the 'workspace/willRenameFiles' and false otherwise. */ - public boolean isWillRenameFilesSupported(PsiFile file) { + public boolean isWillRenameFilesSupported(@NotNull PsiFile file) { if (fileOperationsManager == null) { return false; } @@ -1256,6 +1320,23 @@ public boolean isWillRenameFilesSupported(PsiFile file) { return fileOperationsManager.canWillRenameFiles(uri, file.isDirectory()); } + /** + * Returns true if the given file support the 'workspace/didRenameFiles' and false otherwise. + * + * @param file the file. + * @return true if the given file support the 'workspace/didRenameFiles' and false otherwise. + */ + public boolean isDidRenameFilesSupported(@NotNull VirtualFile file) { + if (fileOperationsManager == null) { + return false; + } + var uri = LSPIJUtils.toUri(file); + if (uri == null) { + return false; + } + return fileOperationsManager.canDidRenameFiles(uri, file.isDirectory()); + } + /** * Returns true if the given file support the 'workspace/didRenameFiles' and false otherwise. * @@ -1273,6 +1354,18 @@ public boolean isDidRenameFilesSupported(@NotNull PsiFile file) { return fileOperationsManager.canDidRenameFiles(uri, file.isDirectory()); } + public boolean canCreateFilesSupported() { + return fileOperationsManager != null && (fileOperationsManager.canWillCreateFiles() || fileOperationsManager.canDidCreateFiles()); + } + + public boolean canDeleteFilesSupported() { + return fileOperationsManager != null && (fileOperationsManager.canWillDeleteFiles() || fileOperationsManager.canDidDeleteFiles()); + } + + public boolean canRenameFilesSupported() { + return fileOperationsManager != null && (fileOperationsManager.canWillRenameFiles() || fileOperationsManager.canDidRenameFiles()); + } + /** * Update diagnostics for the given file URi. * @@ -1285,28 +1378,33 @@ public void updateDiagnostics(@NotNull VirtualFile file, @NotNull List diagnostics) { var clientFeatures = getClientFeatures(); var fileUri = FileUriSupport.getFileUri(file, clientFeatures); - LSPDocumentBase openedOrClosedDocument = null; - openedOrClosedDocument = getOpenedDocument(fileUri); - if (openedOrClosedDocument != null) { - // Update diagnostics for opened file - synchronized (openedOrClosedDocument) { - openedOrClosedDocument.updateDiagnostics(identifier, diagnostics); - } - } else { - // Update diagnostics for closed file - openedOrClosedDocument = getClosedDocument(fileUri, true); - synchronized (openedOrClosedDocument) { - openedOrClosedDocument.updateDiagnostics(identifier, diagnostics); - } + if (fileUri == null) { + return; } + OpenedDocument openedDocument = getOpenedDocument(fileUri, false); + // Check if document is open based on if we have 'OpenedDocument', not based on + // FileEditorManager, because we sometimes update diagnostics before FileEditorManager consider document open, + // but we still want to update it in opened document, because it is already opening + boolean isOpen = openedDocument != null; + final LSPDocumentBase openedOrClosedDocument = isOpen ? openedDocument : getClosedDocument(fileUri, true); if (openedOrClosedDocument == null) { return; } + boolean hasErrors = false; + synchronized (openedOrClosedDocument) { + hasErrors = openedOrClosedDocument.hasErrors(); + openedOrClosedDocument.updateDiagnostics(identifier, diagnostics); + } if (clientFeatures.getDiagnosticFeature().canReportProblem(file)) { - // Report problem in the Project View if the opened/closed document - // has at least one diagnosis with a severity of error - LSPDiagnosticUtils.reportProblem(file, openedOrClosedDocument, getProject()); + // For open files, we should always update problem reporting, because when opening file, + // we create new document, which can have incorrect 'hasErrors' state, + // so after fixing error 'hasErrors' does not change + if (isOpen || hasErrors != openedOrClosedDocument.hasErrors()) { + // Report problem in the Project View if the opened/closed document + // has at least one diagnosis with a severity of error + LSPDiagnosticUtils.reportProblem(file, openedOrClosedDocument, getProject()); + } } } @@ -1335,7 +1433,7 @@ private synchronized LSPClientFeatures getOrCreateClientFeatures() { * @return the corresponding URI, or {@code null} if the conversion fails */ @Nullable - URI toUri(@NotNull VirtualFile file) { + public URI toUri(@NotNull VirtualFile file) { return FileUriSupport.getFileUri(file, getClientFeatures()); } @@ -1412,8 +1510,6 @@ private void initLSPTracesIfNeeded() { } } - // ------------- LSP Traces - /** * Returns the LSP cached traces. * @@ -1424,6 +1520,8 @@ public ConcurrentLinkedQueue getTraces() { return traces; } + // ------------- LSP Traces + /** * Returns the configured server trace. * @@ -1466,7 +1564,7 @@ public Alarm getTraceFlushAlarm() { * * @param context the context used to control or customize the installation. * @return a {@link CompletableFuture} that completes with the {@link ServerInstallationStatus} - * once the installation process is done. + * once the installation process is done. */ public @NotNull CompletableFuture install(@NotNull ServerInstallationContext context) { // Get the configured server installer, if any @@ -1502,10 +1600,184 @@ public Alarm getTraceFlushAlarm() { return CompletableFuture.completedFuture(ServerInstallationStatus.INSTALLED); } + /** + * Returns true if the language server is stopping and false otherwise. + * + * @return true if the language server is stopping and false otherwise. + */ + public boolean isStopping() { + return getServerStatus() == ServerStatus.stopping; + } + + public synchronized CompletableFuture stop() { + return stop(isStopping()); + } + + private synchronized CompletableFuture stop(boolean alreadyStopping) { + if (alreadyStopping && currentInitializingContext == null) { + return CompletableFuture.completedFuture(null); + } + return stop(currentInitializingContext); + } + + private synchronized CompletableFuture stop(@Nullable InitializingContext initializingContext) { + @Nullable var languageClient = initializingContext != null ? initializingContext.languageClient : null; + @Nullable var languageServer = initializingContext != null ? initializingContext.languageServer : null; + @Nullable var lspStreamProvider = initializingContext != null ? initializingContext.provider : null; + @Nullable var launcherFuture = initializingContext != null ? initializingContext.launcherFuture : null; + boolean current = initializingContext != null && initializingContext.equals(currentInitializingContext); + try { + updateStatus(ServerStatus.stopping); + removeStopTimer(true); + if (languageClient != null) { + languageClient.dispose(); + } + if (current) { + currentInitializingContext = null; + if (this.initializeFuture != null) { + CancellationSupport.cancel(this.initializeFuture); + this.initializeFuture = null; + } + this.serverCapabilities = null; + this.dynamicRegistrations.clear(); + if (clientFeatures != null) { + clientFeatures.getWorkspaceFolderFeature().reset(); + } + } + + if (isDisposed()) { + // When project is closing we shutdown everything in synch mode + shutdownAll(languageServer, lspStreamProvider, launcherFuture); + return CompletableFuture.completedFuture(null); + } else { + // We need to shutdown, kill and stop the process in a thread to avoid for instance + // stopping the new process created with a new start. + return CompletableFuture.runAsync(() -> { + shutdownAll(languageServer, lspStreamProvider, launcherFuture); + boolean delayedCurrent = currentInitializingContext == null || initializingContext.equals(currentInitializingContext); + if (delayedCurrent) { + updateStatus(ServerStatus.stopped); + } + }); + } + } finally { + if (current) { + this.launcherFuture = null; + this.lspStreamProvider = null; + while (!this.openedDocuments.isEmpty()) { + disconnect(this.openedDocuments.keySet().iterator().next(), false); + } + if (!closedDocuments.isEmpty()) { + Set fileUris = new HashSet<>(closedDocuments.keySet()); + closedDocuments.clear(); + clearProblem(fileUris, getClientFeatures(), getProject()); + } + this.languageServer = null; + this.languageClient = null; + + if (messageBusConnection != null) { + messageBusConnection.disconnect(); + } + } + } + } + + private void shutdownAll(LanguageServer languageServerInstance, StreamConnectionProvider provider, Future serverFuture) { + if (languageServerInstance != null && provider != null && provider.isAlive()) { + // The LSP language server instance and the process which starts the language server is alive. Process + // - shutdown + // - exit + + // shutdown the language server + if (provider.isAlive()) { + try { + shutdownLanguageServerInstance(languageServerInstance); + } catch (Exception ex) { + getLanguageServerLifecycleManager().onError(this, ex); + } + + // exit the language server + // Consume language server exit() before cancelling launcher future (serverFuture.cancel()) + // to avoid having error like "The pipe is being closed". + try { + exitLanguageServerInstance(languageServerInstance); + } catch (Exception ex) { + getLanguageServerLifecycleManager().onError(this, ex); + } + } + } + + CancellationSupport.cancel(serverFuture); + + if (provider != null) { + provider.stop(); + } + + } + + private void shutdownLanguageServerInstance(LanguageServer languageServerInstance) throws Exception { + CompletableFuture shutdown = languageServerInstance.shutdown(); + if (!isDisposed()) { + try { + shutdown.get(5, TimeUnit.SECONDS); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } catch (TimeoutException ex) { + String message = "Timeout error while shutdown the language server '" + serverDefinition.getId() + "'"; + LOGGER.warn(message, ex); + throw new Exception(message, ex); + } catch (Exception ex) { + String message = "Error while shutdown the language server '" + serverDefinition.getId() + "'"; + LOGGER.warn(message, ex); + throw new Exception(message, ex); + } + } + } + + private void exitLanguageServerInstance(LanguageServer languageServerInstance) throws Exception { + try { + languageServerInstance.exit(); + } catch (Exception ex) { + String message = "Error while exit the language server '" + serverDefinition.getId() + "'"; + LOGGER.error(message, ex); + throw new Exception(message, ex); + } + } + + @Override + public void dispose() { + dispose(false); + } + + public void dispose(boolean refreshEditorFeature) { + if (disposed) { + return; + } + this.disposed = true; + stopAndRefreshEditorFeature(refreshEditorFeature, false); + stopDispatcher(); + if (clientFeatures != null) { + clientFeatures.dispose(); + clientFeatures = null; + } + } + + public boolean isDisposed() { + return disposed; + } + record LSPFileConnectionInfo(@Nullable Document document, @Nullable String documentText, @Nullable String languageId, boolean waitForDidOpen) { } + public LanguageServerWrapper.@NotNull LSPFileConnectionInfo createFileConnectionInfo(@NotNull VirtualFile file, + @NotNull Document document, + boolean waitForDidOpen) { + String text = document.getText(); + String languageId = getServerDefinition().getLanguageId(file, getProject()); + return new LanguageServerWrapper.LSPFileConnectionInfo(document, text, languageId, waitForDidOpen); + } + // LSP traces public record LSPTrace(@NotNull Message message, @NotNull MessageConsumer messageConsumer, @@ -1516,5 +1788,14 @@ public String toMessage() { } } + private class InitializingContext { + + public StreamConnectionProvider provider; + public LanguageClientImpl languageClient; + public LanguageServer languageServer; + public Future launcherFuture; + public InitializeResult initializeResult; + } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServersRegistry.java b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServersRegistry.java index 6816d2686..597eedbe8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServersRegistry.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServersRegistry.java @@ -15,9 +15,14 @@ import com.intellij.lang.Language; import com.intellij.lang.findUsages.EmptyFindUsagesProvider; import com.intellij.lang.findUsages.LanguageFindUsages; +import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.extensions.ExtensionPointListener; +import com.intellij.openapi.extensions.ExtensionPointName; +import com.intellij.openapi.extensions.PluginDescriptor; import com.intellij.openapi.fileTypes.*; import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.util.Pair; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; @@ -27,13 +32,13 @@ import com.redhat.devtools.lsp4ij.features.semanticTokens.SemanticTokensColorsProvider; import com.redhat.devtools.lsp4ij.internal.SimpleLanguageUtils; import com.redhat.devtools.lsp4ij.internal.StringUtils; -import com.redhat.devtools.lsp4ij.settings.GlobalLanguageServerSettings; -import com.redhat.devtools.lsp4ij.settings.LanguageServerSettings; -import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import com.redhat.devtools.lsp4ij.launching.UserDefinedLanguageServerSettings; import com.redhat.devtools.lsp4ij.server.definition.*; import com.redhat.devtools.lsp4ij.server.definition.extension.*; import com.redhat.devtools.lsp4ij.server.definition.launching.UserDefinedLanguageServerDefinition; +import com.redhat.devtools.lsp4ij.settings.GlobalLanguageServerSettings; +import com.redhat.devtools.lsp4ij.settings.LanguageServerSettings; +import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import com.redhat.devtools.lsp4ij.usages.LSPFindUsagesProvider; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -42,6 +47,7 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; @@ -51,30 +57,39 @@ /** * Language servers registry. */ -public class LanguageServersRegistry { +public class LanguageServersRegistry implements Disposable { private static final Logger LOGGER = LoggerFactory.getLogger(LanguageServersRegistry.class); public static LanguageServersRegistry getInstance() { return ApplicationManager.getApplication().getService(LanguageServersRegistry.class); } - private final Map serverDefinitions = new HashMap<>(); + private @Nullable Set supportedLanguages; + + // Concurrent: mutated by EP listener callbacks on any thread; read from many call sites. + private final Map serverDefinitions = new ConcurrentHashMap<>(); - private final List fileAssociations = new ArrayList<>(); + private final List fileAssociations = new CopyOnWriteArrayList<>(); private final Map /* file extensions (ex : ts) */> languageIdFileExtensionsCache = new HashMap<>(); + List /* file extensions (ex : ts) */> languageIdFileExtensionsCache = new ConcurrentHashMap<>(); private final Collection listeners = new CopyOnWriteArrayList<>(); - private final Map> declarativeInlayHintsProviders = new HashMap<>(); + // Volatile + replaced wholesale so readers never see a half-rebuilt state. + private volatile Map> declarativeInlayHintsProviders = Map.of(); + + private volatile List> inlayHintsProviders = List.of(); - private final List> inlayHintsProviders = new ArrayList<>(); + private final Set customLanguageFindUsages = ConcurrentHashMap.newKeySet(); - private final Set customLanguageFindUsages = new HashSet<>(); + // Tracks our registrations with the platform-side LanguageFindUsages so we can unregister + // them; otherwise the platform retains a hard ref to the contributing plugin's Language. + private final Map registeredFindUsagesProviders = new ConcurrentHashMap<>(); private LanguageServersRegistry() { initialize(); + registerExtensionPointListeners(); } private void initialize() { @@ -86,6 +101,62 @@ private void initialize() { updateLanguages(); } + private void registerExtensionPointListeners() { + registerEpListener(ServerExtensionPointBean.EP_NAME, + this::handleServerExtensionAdded, + this::handleServerExtensionRemoved); + registerEpListener(LanguageMappingExtensionPointBean.EP_NAME, + this::handleLanguageMappingAdded, + this::handleLanguageMappingRemoved); + registerEpListener(FileTypeMappingExtensionPointBean.EP_NAME, + this::handleFileTypeMappingAdded, + this::handleFileTypeMappingRemoved); + registerEpListener(FileNamePatternMappingExtensionPointBean.EP_NAME, + this::handleFileNamePatternMappingAdded, + this::handleFileNamePatternMappingRemoved); + registerEpListener(SemanticTokensColorsProviderExtensionPointBean.EP_NAME, + this::handleSemanticTokensColorsProviderAdded, + this::handleSemanticTokensColorsProviderRemoved); + } + + private void registerEpListener(@NotNull ExtensionPointName epName, + @NotNull java.util.function.Consumer onAdded, + @NotNull java.util.function.Consumer onRemoved) { + epName.getPoint().addExtensionPointListener(new ExtensionPointListener() { + @Override + public void extensionAdded(@NotNull T extension, @NotNull PluginDescriptor pluginDescriptor) { + try { + onAdded.accept(extension); + } catch (Exception e) { + LOGGER.error("Error while handling extensionAdded for {}", epName.getName(), e); + } + } + + @Override + public void extensionRemoved(@NotNull T extension, @NotNull PluginDescriptor pluginDescriptor) { + try { + onRemoved.accept(extension); + } catch (Exception e) { + LOGGER.error("Error while handling extensionRemoved for {}", epName.getName(), e); + } + } + }, /* invokeForLoadedExtensions */ false, /* parentDisposable */ this); + } + + @Override + public void dispose() { + // Undo platform-side LanguageFindUsages registrations: on lsp4ij's own unload, no + // updateLanguages() call would otherwise prune them. + for (var entry : registeredFindUsagesProviders.entrySet()) { + try { + LanguageFindUsages.INSTANCE.removeExplicitExtension(entry.getKey(), entry.getValue()); + } catch (Exception e) { + LOGGER.warn("Error while unregistering LSPFindUsagesProvider for language '{}'", entry.getKey().getID(), e); + } + } + registeredFindUsagesProviders.clear(); + } + private void loadServersAndMappingsFromExtensionPoint() { Map> mappings = new HashMap<>(); Map semanticTokensColorsProviders = new HashMap<>(); @@ -168,6 +239,7 @@ private void loadServersAndMappingFromSettings() { settings.getServerUrl(), "", settings.getCommandLine(), + settings.getWorkingDir(), settings.getUserEnvironmentVariables(), settings.isIncludeSystemEnvironmentVariables(), globalSettings != null ? globalSettings.getConfigurationContent() : null, @@ -176,7 +248,8 @@ private void loadServersAndMappingFromSettings() { globalSettings != null ? globalSettings.getInitializationOptionsContent() : null, globalSettings != null ? globalSettings.getExperimentalContent() : null, settings.getClientConfigurationContent(), - settings.getInstallerConfigurationContent()), + settings.getInstallerConfigurationContent(), + settings.getWorkspaceFolderStrategyConfiguration()), mappings, false); } } @@ -191,35 +264,80 @@ private void loadServersAndMappingFromSettings() { * LSPFindUsagesProvider to activate inlay hint, color, and find usages for those languages. */ private void updateLanguages() { - Set distinctLanguages = getDistinctLanguages(); - // When a file is not linked to a language - // - if the file is associated to a textmate to support syntax coloration - // - otherwise if the file is associated (by default) to plain/text file type - // the language received in InlayHintProviders - // is "textmate" / "TEXT" language, we add them to support - // LSP codeLens, inlayHint, color for a file which is not linked to a language. - distinctLanguages.addAll(SimpleLanguageUtils.getSupportedSimpleLanguages()); - // When a file is not linked to a language (just with a file type) and not linked to a textmate, - // the language received in InlayHintProviders is plain/text, we add it to support - // LSP inlayHint, color for a file which is not linked to a language. - distinctLanguages.add(PlainTextLanguage.INSTANCE); + var supportedLanguages = getSupportedLanguages(true); // register LSPInlayHintsProvider automatically // for all languages associated with a language server. - updateDeclarativeInlayHintsProviders(distinctLanguages); + updateDeclarativeInlayHintsProviders(supportedLanguages); // register LSPColorProvider automatically // for all languages associated with a language server. - updateInlayHintsProviders(distinctLanguages); + updateInlayHintsProviders(supportedLanguages); // register LSPFindUsagesProvider automatically // for all languages associated with a language server. - updateFindUsagesProvider(distinctLanguages); + updateFindUsagesProvider(supportedLanguages); } /** - * Returns all distinct languages which are associated with a language server. + * Returns all distinct {@link Language} instances that are currently associated + * with at least one registered {@link LanguageServerDefinition}. + *

+ * A language is considered supported if it is linked to a language server + * through a {@link LanguageServerFileAssociation}. This includes mappings defined + * by language, file type, or file name pattern. Additionally, fallback languages + * such as {@code textmate} and {@code PlainTextLanguage.INSTANCE} are included to + * ensure proper LSP support for files without a specific language. + *

* - * @return all distinct languages which are associated with a language server. + * @return a non-null set of distinct supported languages (may be empty) + * @see #getSupportedLanguages(boolean) + * @see #computeSupportedLanguages() */ - private @NotNull Set getDistinctLanguages() { + public @NotNull Set getSupportedLanguages() { + return getSupportedLanguages(false); + } + + /** + * Returns all distinct {@link Language} instances supported by the currently + * registered {@link LanguageServerDefinition}s. + *

+ * This method optionally recomputes the supported languages from the current + * {@link LanguageServerFileAssociation}s if {@code refresh} is {@code true}. + * Otherwise, it may return a cached result from a previous computation. + *

+ * + * @param refreshCache if {@code true}, forces a recomputation of supported languages; + * if {@code false}, may return cached data + * @return a non-null set of distinct supported languages (may be empty) + * @see #computeSupportedLanguages() + * @see #getSupportedLanguages() + */ + public @NotNull Set getSupportedLanguages(boolean refreshCache) { + if (supportedLanguages != null && !refreshCache) { + return supportedLanguages; + } + supportedLanguages = computeSupportedLanguages(); + return supportedLanguages; + } + + /** + * Computes and returns all distinct {@link Language} instances that are currently + * associated with registered {@link LanguageServerDefinition}s. + *

+ * This method scans all {@link LanguageServerFileAssociation}s to determine which + * languages are mapped to an active language server. Associations can be based on + * language ID, file type, or file name pattern. The resulting set may also include + * fallback languages such as {@code textmate} or {@code PlainTextLanguage.INSTANCE} + * to ensure basic LSP support for untyped files. + *

+ *

+ * Unlike {@link #getSupportedLanguages()}, this method always recomputes the result + * and does not rely on cached values. + *

+ * + * @return a non-null set of distinct supported languages (may be empty) + * @see #getSupportedLanguages() + * @see LanguageServerFileAssociation + */ + private @NotNull Set computeSupportedLanguages() { List>> languageMatchers = null; Set distinctLanguages = new HashSet<>(); for (var association : fileAssociations) { @@ -262,6 +380,17 @@ private void updateLanguages() { } } } + // When a file is not linked to a language + // - if the file is associated to a textmate to support syntax coloration + // - otherwise if the file is associated (by default) to plain/text file type + // the language received in InlayHintProviders + // is "textmate" / "TEXT" language, we add them to support + // LSP codeLens, inlayHint, color for a file which is not linked to a language. + distinctLanguages.addAll(SimpleLanguageUtils.getSupportedSimpleLanguages()); + // When a file is not linked to a language (just with a file type) and not linked to a textmate, + // the language received in InlayHintProviders is plain/text, we add it to support + // LSP inlayHint, color for a file which is not linked to a language. + distinctLanguages.add(PlainTextLanguage.INSTANCE); return distinctLanguages; } @@ -277,32 +406,47 @@ private static boolean match(@NotNull String fileName, private void updateDeclarativeInlayHintsProviders(Set distinctLanguages) { LSPInlayHintsProvider lspInlayHintsProvider = new LSPInlayHintsProvider(); - inlayHintsProviders.clear(); + Map> next = new HashMap<>(); for (Language language : distinctLanguages) { List hints = new ArrayList<>(); hints.add(new InlayProviderInfo(lspInlayHintsProvider, LSPInlayHintsProvider.PROVIDER_ID, Collections.emptySet(), true, LanguageServerBundle.message("lsp.hints.declarative.provider.name"))); - declarativeInlayHintsProviders.put(language.getID(), hints); + next.put(language.getID(), hints); } + declarativeInlayHintsProviders = Map.copyOf(next); } private void updateInlayHintsProviders(Set distinctLanguages) { LSPColorProvider lspColorProvider = new LSPColorProvider(); - inlayHintsProviders.clear(); + List> next = new ArrayList<>(distinctLanguages.size()); for (Language language : distinctLanguages) { - inlayHintsProviders.add(new ProviderInfo<>(language, lspColorProvider)); + next.add(new ProviderInfo<>(language, lspColorProvider)); } + inlayHintsProviders = List.copyOf(next); } private void updateFindUsagesProvider(Set distinctLanguages) { customLanguageFindUsages.clear(); - // Associate the LSP find usage provider - // for all languages associated with a language server. - // and which does not already define a provider for the language. + // Drop platform registrations for languages no longer backed by any LSP server; otherwise + // LanguageFindUsages keeps a hard ref to the unloading plugin's Language. + var staleEntries = new ArrayList>(); + for (var entry : registeredFindUsagesProviders.entrySet()) { + if (!distinctLanguages.contains(entry.getKey())) { + staleEntries.add(entry); + } + } + for (var entry : staleEntries) { + LanguageFindUsages.INSTANCE.removeExplicitExtension(entry.getKey(), entry.getValue()); + registeredFindUsagesProviders.remove(entry.getKey()); + } LSPFindUsagesProvider provider = new LSPFindUsagesProvider(); for (Language language : distinctLanguages) { + if (registeredFindUsagesProviders.containsKey(language)) { + continue; + } var existingProviders = LanguageFindUsages.INSTANCE.allForLanguage(language); if (existingProviders.isEmpty() || (existingProviders.size() == 1 && existingProviders.get(0) instanceof EmptyFindUsagesProvider)) { LanguageFindUsages.INSTANCE.addExplicitExtension(language, provider); + registeredFindUsagesProviders.put(language, provider); } else { customLanguageFindUsages.add(language); } @@ -448,10 +592,12 @@ private void addServerDefinitionWithoutNotification(@NotNull LanguageServerDefin settings.setServerName(userDefinedServer.getDisplayName()); settings.setServerUrl(userDefinedServer.getUrl()); settings.setCommandLine(userDefinedServer.getCommandLine()); + settings.setWorkingDir(userDefinedServer.getWorkingDir()); settings.setUserEnvironmentVariables(userDefinedServer.getUserEnvironmentVariables()); settings.setIncludeSystemEnvironmentVariables(userDefinedServer.isIncludeSystemEnvironmentVariables()); settings.setMappings(toServerMappingSettings(mappings)); settings.setClientConfigurationContent(userDefinedServer.getClientConfigurationContent()); + settings.setWorkspaceFolderStrategyConfiguration(userDefinedServer.getWorkspaceFolderStrategyConfiguration()); settings.setInstallerConfigurationContent(userDefinedServer.getInstallerConfigurationContent()); UserDefinedLanguageServerSettings.getInstance().setUserDefinedLanguageServerSettings(languageServerId, settings); } @@ -526,19 +672,290 @@ private void removeAssociationsFor(LanguageServerDefinition definition) { definition.removeAssociations(); } + // --------------------------------------------------------------------------------------- + // Dynamic extension point handlers. + // Invoked by the platform when a plugin contributing one of the LSP extension points is + // loaded or unloaded at runtime. They keep the registry in sync without an IDE restart. + // --------------------------------------------------------------------------------------- + + private void handleServerExtensionAdded(@NotNull ServerExtensionPointBean ext) { + String serverId = ext.id; + if (serverId == null || serverId.isEmpty()) { + return; + } + if (serverDefinitions.containsKey(serverId)) { + return; + } + var serverDefinition = new ExtensionLanguageServerDefinition(ext); + for (var stExt : SemanticTokensColorsProviderExtensionPointBean.EP_NAME.getExtensions()) { + if (serverId.equals(stExt.serverId)) { + try { + serverDefinition.setSemanticTokensColorsProvider(stExt.getSemanticTokensColorsProvider()); + } catch (Exception e) { + LOGGER.warn("Error while creating custom semanticTokensColorsProvider for server id='{}'.", serverId, e); + } + break; + } + } + List mappings = collectMappingsFromExtensionPoints(serverId); + addServerDefinitionWithoutNotification(serverDefinition, mappings, false); + updateLanguages(); + notifyServerAdded(Collections.singleton(serverDefinition)); + } + + private void handleServerExtensionRemoved(@NotNull ServerExtensionPointBean ext) { + String serverId = ext.id; + if (serverId == null || serverId.isEmpty()) { + return; + } + LanguageServerDefinition definition = serverDefinitions.remove(serverId); + if (definition == null) { + return; + } + removeAssociationsFor(definition); + updateLanguages(); + notifyServerRemoved(Collections.singleton(definition)); + } + + private void handleLanguageMappingAdded(@NotNull LanguageMappingExtensionPointBean ext) { + Language language = Language.findLanguageByID(ext.language); + if (language == null) { + return; + } + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + // Mapping will be picked up later via collectMappingsFromExtensionPoints when the server arrives. + return; + } + var mapping = new ServerLanguageMapping(language, ext.serverId, ext.languageId, ext.getDocumentMatcher()); + registerAssociation(definition, mapping); + updateLanguages(); + notifyMappingsChanged(definition); + } + + private void handleLanguageMappingRemoved(@NotNull LanguageMappingExtensionPointBean ext) { + Language language = Language.findLanguageByID(ext.language); + if (language == null) { + return; + } + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + boolean removed = fileAssociations.removeIf(association -> + definition.equals(association.getServerDefinition()) + && language.equals(association.getLanguage())); + if (removed) { + // Drop the per-language ref inside the def too — otherwise it pins the plugin's Language. + definition.unregisterAssociation(language); + updateLanguages(); + notifyMappingsChanged(definition); + } + } + + private void handleFileTypeMappingAdded(@NotNull FileTypeMappingExtensionPointBean ext) { + FileType fileType = FileTypeManager.getInstance().findFileTypeByName(ext.fileType); + if (fileType == null) { + return; + } + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + var mapping = new ServerFileTypeMapping(fileType, ext.serverId, ext.languageId, ext.getDocumentMatcher()); + registerAssociation(definition, mapping); + updateLanguages(); + notifyMappingsChanged(definition); + } + + private void handleFileTypeMappingRemoved(@NotNull FileTypeMappingExtensionPointBean ext) { + FileType fileType = FileTypeManager.getInstance().findFileTypeByName(ext.fileType); + if (fileType == null) { + return; + } + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + boolean removed = fileAssociations.removeIf(association -> + definition.equals(association.getServerDefinition()) + && fileType.equals(association.getFileType())); + if (removed) { + definition.unregisterAssociation(fileType); + updateLanguages(); + notifyMappingsChanged(definition); + } + } + + private void handleFileNamePatternMappingAdded(@NotNull FileNamePatternMappingExtensionPointBean ext) { + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + List patterns = Arrays.asList(ext.patterns.split(";")); + var mapping = new ServerFileNamePatternMapping(patterns, ext.serverId, ext.languageId, ext.getDocumentMatcher()); + registerAssociation(definition, mapping); + updateLanguages(); + notifyMappingsChanged(definition); + } + + private void handleFileNamePatternMappingRemoved(@NotNull FileNamePatternMappingExtensionPointBean ext) { + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + List patterns = Arrays.asList(ext.patterns.split(";")); + // Set equality (not anyMatch on individual patterns) so overlapping pattern sets don't co-remove. + Set targetPatternSet = Set.copyOf(patterns); + boolean removed = fileAssociations.removeIf(association -> { + if (!definition.equals(association.getServerDefinition())) return false; + var matchers = association.getFileNameMatchers(); + if (matchers == null) return false; + Set associationPatterns = matchers.stream() + .map(FileNameMatcher::getPresentableString) + .collect(Collectors.toUnmodifiableSet()); + return associationPatterns.equals(targetPatternSet); + }); + if (removed) { + definition.unregisterFileNamePatternAssociation(targetPatternSet, ext.languageId); + if (ext.languageId != null) { + List languageExtensions = languageIdFileExtensionsCache.get(ext.languageId); + if (languageExtensions != null) { + List extensions = patterns.stream() + .map(p -> { + int idx = p.indexOf('.'); + return idx != -1 ? p.substring(idx + 1) : p; + }) + .toList(); + languageExtensions.removeAll(extensions); + if (languageExtensions.isEmpty()) { + languageIdFileExtensionsCache.remove(ext.languageId); + } + } + } + updateLanguages(); + notifyMappingsChanged(definition); + } + } + + private void handleSemanticTokensColorsProviderAdded(@NotNull SemanticTokensColorsProviderExtensionPointBean ext) { + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + try { + definition.setSemanticTokensColorsProvider(ext.getSemanticTokensColorsProvider()); + } catch (Exception e) { + LOGGER.warn("Error while creating custom semanticTokensColorsProvider for server id='{}'.", ext.serverId, e); + } + } + + private void handleSemanticTokensColorsProviderRemoved(@NotNull SemanticTokensColorsProviderExtensionPointBean ext) { + LanguageServerDefinition definition = serverDefinitions.get(ext.serverId); + if (definition == null) { + return; + } + // Prefer any surviving provider for this server before falling back to the default. + for (var stExt : SemanticTokensColorsProviderExtensionPointBean.EP_NAME.getExtensions()) { + if (stExt == ext) { + continue; + } + if (ext.serverId.equals(stExt.serverId)) { + try { + definition.setSemanticTokensColorsProvider(stExt.getSemanticTokensColorsProvider()); + return; + } catch (Exception e) { + LOGGER.warn("Error while creating custom semanticTokensColorsProvider for server id='{}'.", stExt.serverId, e); + } + } + } + definition.setSemanticTokensColorsProvider(LanguageServerDefinition.DEFAULT_SEMANTIC_TOKENS_COLORS_PROVIDER); + } + + private @NotNull List collectMappingsFromExtensionPoints(@NotNull String serverId) { + List result = new ArrayList<>(); + for (var ext : LanguageMappingExtensionPointBean.EP_NAME.getExtensions()) { + if (!serverId.equals(ext.serverId)) continue; + Language language = Language.findLanguageByID(ext.language); + if (language != null) { + result.add(new ServerLanguageMapping(language, serverId, ext.languageId, ext.getDocumentMatcher())); + } + } + for (var ext : FileTypeMappingExtensionPointBean.EP_NAME.getExtensions()) { + if (!serverId.equals(ext.serverId)) continue; + FileType fileType = FileTypeManager.getInstance().findFileTypeByName(ext.fileType); + if (fileType != null) { + result.add(new ServerFileTypeMapping(fileType, serverId, ext.languageId, ext.getDocumentMatcher())); + } + } + for (var ext : FileNamePatternMappingExtensionPointBean.EP_NAME.getExtensions()) { + if (!serverId.equals(ext.serverId)) continue; + List patterns = Arrays.asList(ext.patterns.split(";")); + result.add(new ServerFileNamePatternMapping(patterns, serverId, ext.languageId, ext.getDocumentMatcher())); + } + return result; + } + + private void notifyServerAdded(@NotNull Collection definitions) { + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + var event = new LanguageServerDefinitionListener.LanguageServerAddedEvent(project, definitions); + for (LanguageServerDefinitionListener listener : this.listeners) { + try { + listener.handleAdded(event); + } catch (Exception e) { + LOGGER.error("Error while notifying server added", e); + } + } + } + } + + private void notifyServerRemoved(@NotNull Collection definitions) { + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + var event = new LanguageServerDefinitionListener.LanguageServerRemovedEvent(project, definitions); + for (LanguageServerDefinitionListener listener : this.listeners) { + try { + listener.handleRemoved(event); + } catch (Exception e) { + LOGGER.error("Error while notifying server removed", e); + } + } + } + } + + private void notifyMappingsChanged(@NotNull LanguageServerDefinition definition) { + for (Project project : ProjectManager.getInstance().getOpenProjects()) { + var event = new LanguageServerDefinitionListener.LanguageServerChangedEvent( + LanguageServerDefinitionListener.LanguageServerDefinitionEvent.UpdatedBy.USER, + project, + definition, + /* nameChanged */ false, + /* commandChanged */ false, + /* workingDirChanged */ false, + /* userEnvironmentVariablesChanged */ false, + /* includeSystemEnvironmentVariablesChanged */ false, + /* mappingsChanged */ true, + /* clientConfigurationContentChanged */ false, + /* installerConfigurationContentChanged */ false, + /* workspaceFolderStrategyConfigurationChanged*/ false); + handleChangeEvent(event); + } + } + public LanguageServerDefinitionListener.@Nullable LanguageServerChangedEvent updateServerDefinition(@NotNull UpdateServerDefinitionRequest request, boolean notify) { String languageServerId = request.serverDefinition().getId(); var serverDefinition = request.serverDefinition(); - + if (serverDefinition instanceof UserDefinedLanguageServerDefinition ls) { ls.setName(request.name()); ls.setUrl(request.url()); ls.setCommandLine(request.commandLine()); + ls.setWorkingDir(request.workingDir()); ls.setUserEnvironmentVariables(request.userEnvironmentVariables()); ls.setIncludeSystemEnvironmentVariables(request.includeSystemEnvironmentVariables()); ls.setClientConfigurationContent(request.clientConfigurationContent()); ls.setInstallerConfigurationContent(request.installerConfigurationContent()); + ls.setWorkspaceFolderStrategyConfiguration(request.workspaceFolderStrategyConfiguration()); // remove associations removeAssociationsFor(request.serverDefinition()); @@ -550,6 +967,7 @@ private void removeAssociationsFor(LanguageServerDefinition definition) { UserDefinedLanguageServerSettings.UserDefinedLanguageServerItemSettings settings = UserDefinedLanguageServerSettings.getInstance().getUserDefinedLanguageServerSettings(languageServerId); boolean nameChanged = !Objects.equals(settings.getServerName(), request.name()); boolean commandChanged = !Objects.equals(settings.getCommandLine(), request.commandLine()); + boolean workingDirChanged = !Objects.equals(settings.getWorkingDir(), request.workingDir()); boolean userEnvironmentVariablesChanged = !Objects.equals(settings.getUserEnvironmentVariables(), request.userEnvironmentVariables()); boolean includeSystemEnvironmentVariablesChanged = settings.isIncludeSystemEnvironmentVariables() != request.includeSystemEnvironmentVariables(); boolean mappingsChanged = !Objects.deepEquals(settings.getMappings(), request.mappings()); @@ -559,19 +977,23 @@ private void removeAssociationsFor(LanguageServerDefinition definition) { boolean clientConfigurationContentChanged = !Objects.equals(settings.getClientConfigurationContent(), request.clientConfigurationContent()); boolean installerConfigurationContentChanged = !Objects.equals(settings.getInstallerConfigurationContent(), request.installerConfigurationContent()); + boolean workspaceFolderStrategyConfigurationChanged = !Objects.equals(settings.getWorkspaceFolderStrategyConfiguration(), request.workspaceFolderStrategyConfiguration()); settings.setServerName(request.name()); settings.setServerUrl(request.url()); settings.setCommandLine(request.commandLine()); + settings.setWorkingDir(request.workingDir()); settings.setUserEnvironmentVariables(request.userEnvironmentVariables()); settings.setIncludeSystemEnvironmentVariables(request.includeSystemEnvironmentVariables()); settings.setClientConfigurationContent(request.clientConfigurationContent()); settings.setInstallerConfigurationContent(request.installerConfigurationContent()); + settings.setWorkspaceFolderStrategyConfiguration(request.workspaceFolderStrategyConfiguration()); settings.setMappings(request.mappings()); - if (nameChanged || commandChanged || userEnvironmentVariablesChanged || + if (nameChanged || commandChanged || workingDirChanged || userEnvironmentVariablesChanged || includeSystemEnvironmentVariablesChanged || - mappingsChanged || clientConfigurationContentChanged || installerConfigurationContentChanged) { + mappingsChanged || clientConfigurationContentChanged || installerConfigurationContentChanged || + workspaceFolderStrategyConfigurationChanged) { // Notifications LanguageServerDefinitionListener.LanguageServerChangedEvent event = new LanguageServerDefinitionListener.LanguageServerChangedEvent( LanguageServerDefinitionListener.LanguageServerDefinitionEvent.UpdatedBy.USER, @@ -579,11 +1001,13 @@ private void removeAssociationsFor(LanguageServerDefinition definition) { request.serverDefinition(), nameChanged, commandChanged, + workingDirChanged, userEnvironmentVariablesChanged, includeSystemEnvironmentVariablesChanged, mappingsChanged, clientConfigurationContentChanged, - installerConfigurationContentChanged); + installerConfigurationContentChanged, + workspaceFolderStrategyConfigurationChanged); if (notify) { handleChangeEvent(event); } @@ -709,11 +1133,13 @@ public record UpdateServerDefinitionRequest(@NotNull Project project, @Nullable String name, @Nullable String url, @Nullable String commandLine, + @Nullable String workingDir, @Nullable Map userEnvironmentVariables, boolean includeSystemEnvironmentVariables, @NotNull List mappings, @Nullable String clientConfigurationContent, - @Nullable String installerConfigurationContent) { + @Nullable String installerConfigurationContent, + @Nullable String workspaceFolderStrategyConfiguration) { } /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java index 8b15aac1a..50f86a66e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/LanguageServiceAccessor.java @@ -39,7 +39,10 @@ import org.slf4j.LoggerFactory; import java.util.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -57,7 +60,11 @@ public class LanguageServiceAccessor implements Disposable { private final Project project; private final SimpleModificationTracker modificationTracker = new SimpleModificationTracker(); - private final Set startedServers = new HashSet<>(); + // Thread-safe set: readers (hasAny, processLanguageServers, getStartedServers) iterate it without + // locking, so a plain HashSet would throw ConcurrentModificationException when a writer mutates it + // concurrently. newKeySet() gives weakly-consistent iteration; writers still synchronize on it to + // keep their check-then-add/remove operations atomic. + private final Set startedServers = ConcurrentHashMap.newKeySet(); private final LanguageServerDefinitionListener serverDefinitionListener = new LanguageServerDefinitionListener() { @Override @@ -97,13 +104,15 @@ public void handleChanged(@NotNull LanguageServerChangedEvent event) { // Bump the modification trackers languageServerWrappers.forEach(LanguageServerWrapper::incrementModificationCount); - // Restart all servers where command or mappings has changed + // Restart all servers where command, working dir or mappings has changed // if changes doesn't come from installer. if (event.getUpdatedBy() != LanguageServerDefinitionEvent.UpdatedBy.INSTALLER && (event.commandChanged || + event.workingDirChanged || event.userEnvironmentVariablesChanged || event.includeSystemEnvironmentVariablesChanged || - event.mappingsChanged)) { + event.mappingsChanged || + event.workspaceFolderStrategyConfigurationChanged)) { languageServerWrappers.forEach(LanguageServerWrapper::restart); } } @@ -118,6 +127,28 @@ public static LanguageServiceAccessor getInstance(@NotNull Project project) { return project.getService(LanguageServiceAccessor.class); } + /** + * Handles exceptions from CompletableFuture chains, silently ignoring cancellations + * (which are normal when a language server is stopped/restarted) and re-throwing + * all other exceptions so they are not swallowed. + * + * @param ex the exception thrown in the future chain. + * @return null if the exception is a normal cancellation, otherwise rethrows. + */ + @Nullable + private static T handleFutureException(@NotNull Throwable ex) { + Throwable cause = ex instanceof CompletionException ? ex.getCause() : ex; + if (cause instanceof CancellationException || cause instanceof ProcessCanceledException) { + // A cancellation means the language server was stopped or restarted while + // this request was in flight — this is a normal lifecycle event, not an error. + return null; + } + // Unexpected error: propagate so it is not silently swallowed. + throw ex instanceof CompletionException + ? (CompletionException) ex + : new CompletionException(ex); + } + @Nullable private static Map getFileConnectionInfoMap(@NotNull VirtualFile file, @NotNull Collection languageServers, @@ -133,9 +164,7 @@ private static Map(); } - String text = document.getText(); - String languageId = ls.getServerDefinition().getLanguageId(file, project); - var info = new LanguageServerWrapper.LSPFileConnectionInfo(document, text, languageId, true); + var info = ls.createFileConnectionInfo(file, document, true); connectionFileInfo.put(ls, info); } } @@ -194,9 +223,9 @@ public void findAndStartLanguageServerIfNeeded(@NotNull LanguageServerDefinition *
  • 2. refresh code vision, inlay hints, folding for all opened editors * which edit the files associated to the language server.
  • * - * @param serverDefinition the language server definition. * - * @param project the project. + * @param serverDefinition the language server definition. + * @param project the project. */ void sendDidOpenAndRefreshEditorFeatureForOpenedFiles(@NotNull LanguageServerDefinition serverDefinition, @NotNull Project project) { @@ -221,7 +250,7 @@ private void sendDidOpenAndRefreshEditorFeatureFor(@NotNull PsiFile file, if (servers.isEmpty()) { return; } - // refresh IJ code visions, inlay hints, folding features + // refresh IJ code visions, inlay hints, folding features, color, structure view EditorFeatureManager.getInstance(project) .refreshEditorFeature(file, EditorFeatureType.ALL, true, new PsiFileCancelChecker(file)); }); @@ -311,7 +340,10 @@ public void processLanguageServers(@NotNull PsiFile file, servers.add(new LanguageServerItem(server, wrapper)); } return CompletableFuture.completedFuture(null); - })) + }) + // A cancellation here means the server was stopped/restarted mid-flight; + // silently skip this wrapper instead of surfacing an error to the user. + .exceptionally(LanguageServiceAccessor::handleFutureException)) .toArray(CompletableFuture[]::new)) .thenApply(theVoid -> servers); } catch (ProcessCanceledException cancellation) { @@ -342,7 +374,6 @@ public void processLanguageServers(@NotNull PsiFile file, return CompletableFuture.completedFuture(Collections.emptyList()); } - // getLanguageServers is generally called with Read Access. // We get the Document instance now, to avoid creating a new Read Action thread to get it from the file. // The document instance is only used when the file is opened, to add @@ -384,7 +415,10 @@ public void processLanguageServers(@NotNull PsiFile file, if (server != null) { servers.add(new LanguageServerItem(server, wrapper)); } - }); + }) + // A cancellation here means the server was stopped/restarted mid-flight; + // silently skip this wrapper instead of surfacing an error to the user. + .exceptionally(LanguageServiceAccessor::handleFutureException); } ).toArray(CompletableFuture[]::new))) .thenApply(theVoid -> servers); @@ -626,7 +660,7 @@ public Set getModificationTrackers(@NotNull PsiFile file) { MatchedLanguageServerDefinitions mappings = getMatchedLanguageServerDefinitions(file, true); if (mappings != MatchedLanguageServerDefinitions.NO_MATCH) { Set languageServerDefinitions = mappings.getMatched(); - Set languageServerWrappers = getStartedServers() + Set languageServerWrappers = new HashSet<>(getStartedServers()) .stream() .filter(languageServerWrapper -> languageServerDefinitions.contains(languageServerWrapper.getServerDefinition())) .collect(Collectors.toSet()); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/OpenedDocument.java b/src/main/java/com/redhat/devtools/lsp4ij/OpenedDocument.java index ca47005f9..ea39e264c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/OpenedDocument.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/OpenedDocument.java @@ -18,6 +18,7 @@ import com.redhat.devtools.lsp4ij.features.diagnostics.LSPDiagnosticsForServer; import org.eclipse.lsp4j.Diagnostic; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.List; @@ -34,13 +35,13 @@ public class OpenedDocument extends LSPDocumentBase { private final LSPDiagnosticsForServer diagnosticsForServer; - private final DocumentContentSynchronizer synchronizer; + private final @Nullable DocumentContentSynchronizer synchronizer; private long updatedDiagnosticsTime; // time when diagnostics has been updated private long displayingDiagnosticsTime; // time when diagnostics has been displayed with the LSPDiagnosticAnnotator. public OpenedDocument(@NotNull LanguageServerItem languageServer, @NotNull VirtualFile file, - @NotNull DocumentContentSynchronizer synchronizer) { + @Nullable DocumentContentSynchronizer synchronizer) { this.file = file; this.synchronizer = synchronizer; this.diagnosticsForServer = new LSPDiagnosticsForServer(languageServer,file); @@ -56,7 +57,7 @@ public VirtualFile getFile() { return file; } - public DocumentContentSynchronizer getSynchronizer() { + public @Nullable DocumentContentSynchronizer getSynchronizer() { return synchronizer; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java index 88810b68c..08f4f8edc 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ServerMessageHandler.java @@ -26,6 +26,7 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.popup.JBPopupListener; import com.intellij.openapi.ui.popup.LightweightWindowEvent; +import com.intellij.util.containers.ContainerUtil; import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; import com.redhat.devtools.lsp4ij.console.LSPConsoleToolWindowPanel; import com.redhat.devtools.lsp4ij.features.documentation.MarkdownConverter; @@ -120,21 +121,23 @@ public static CompletableFuture showMessageRequest(@NotNull S content, messageTypeToNotificationType(params.getType())); notification.setIcon(messageTypeToIcon(params.getType())); - for (var action : params.getActions()) { - notification.addAction(new AnAction(action.getTitle()) { - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - MessageActionItem result = new MessageActionItem(); - result.setTitle(action.getTitle()); - future.complete(result); - notification.expire(); - } - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.BGT; - } - }); + if (!ContainerUtil.isEmpty(params.getActions())) { + for (var action : params.getActions()) { + notification.addAction(new AnAction(action.getTitle()) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + MessageActionItem result = new MessageActionItem(); + result.setTitle(action.getTitle()); + future.complete(result); + notification.expire(); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + }); + } } notification.whenExpired(() -> { CancellationSupport.cancel(future); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/LanguageClientImpl.java b/src/main/java/com/redhat/devtools/lsp4ij/client/LanguageClientImpl.java index 8287073f6..99cb2abc7 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/LanguageClientImpl.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/LanguageClientImpl.java @@ -149,7 +149,11 @@ public CompletableFuture unregisterCapability(UnregistrationParams params) @Override public CompletableFuture> workspaceFolders() { - return CompletableFuture.supplyAsync(() -> LSPIJUtils.toWorkspaceFolders(project, wrapper.getClientFeatures())); + return CompletableFuture.supplyAsync(() -> + wrapper.getClientFeatures() + .getWorkspaceFolderFeature() + .getInitialWorkspaceFolders(project) + ); } @@ -190,22 +194,16 @@ public CompletableFuture refreshSemanticTokens() { } private void refreshSemanticTokensForAllOpenedFiles() { - // Received request 'workspace/semanticTokens/refresh - ReadAction.nonBlocking((Callable) () -> { - for (var openedDocument : wrapper.getOpenedDocuments()) { - VirtualFile file = openedDocument.getFile(); - PsiFile psiFile = LSPIJUtils.getPsiFile(file, project); - if (psiFile != null) { - var fileSupport = LSPFileSupport.getSupport(psiFile); - // Evict the semantic tokens cache - fileSupport.getSemanticTokensSupport().cancel(); - // Refresh the UI - fileSupport.restartDaemonCodeAnalyzerWithDebounce(new PsiFileCancelChecker(psiFile)); - } - } - return null; - }).coalesceBy(this) - .submit(AppExecutorUtil.getAppExecutorService()); + for (var openedDocument : wrapper.getOpenedDocuments()) { + VirtualFile file = openedDocument.getFile(); + EditorFeatureManager.getInstance(getProject()) + .refreshEditorFeature(file, EditorFeatureType.SEMANTIC_TOKENS, true, new VirtualFileCancelChecker(file)); + } + } + + @Override + public CompletableFuture refreshInlineValues() { + return CompletableFuture.completedFuture(null); } @Override @@ -216,8 +214,10 @@ public CompletableFuture refreshDiagnostics() { private void refreshDiagnosticsForAllOpenedFiles() { // Received request 'workspace/diagnostic/refresh for (var openedDocument : wrapper.getOpenedDocuments()) { - openedDocument.getSynchronizer() - .refreshPullDiagnostic(DocumentContentSynchronizer.RefreshPullDiagnosticOrigin.ON_WORKSPACE_REFRESH); + if (openedDocument.getSynchronizer() != null) { + openedDocument.getSynchronizer() + .refreshPullDiagnostic(DocumentContentSynchronizer.RefreshPullDiagnosticOrigin.ON_WORKSPACE_REFRESH); + } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/WorkspaceFolderNotificationManager.java b/src/main/java/com/redhat/devtools/lsp4ij/client/WorkspaceFolderNotificationManager.java new file mode 100644 index 000000000..18248266b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/WorkspaceFolderNotificationManager.java @@ -0,0 +1,111 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.client; + +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.LanguageServerWrapper; +import com.redhat.devtools.lsp4ij.ServerStatus; +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; + +/** + * Manager for sending workspace folder notifications to the language server. + */ +public class WorkspaceFolderNotificationManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(WorkspaceFolderNotificationManager.class); + + private final LanguageServerWrapper serverWrapper; + + public WorkspaceFolderNotificationManager(@NotNull LanguageServerWrapper serverWrapper) { + this.serverWrapper = serverWrapper; + } + + /** + * Computes the workspace folder for the given file if it needs to be notified. + * This method performs I/O operations and should be called OUTSIDE synchronized blocks. + * + * @param file the file being connected + * @return the workspace folder to notify, or null if not needed + */ + @Nullable + public WorkspaceFolder computeWorkspaceFolderToNotify(@NotNull VirtualFile file) { + if (serverWrapper.getServerStatus() != ServerStatus.started) { + return null; + } + + var clientFeatures = serverWrapper.getClientFeatures(); + var workspaceFolderFeature = clientFeatures.getWorkspaceFolderFeature(); + + // Compute workspace folder (slow I/O operation) + return workspaceFolderFeature.computeWorkspaceFolderToNotify(file); + } + + /** + * Marks the workspace folder as notified. + * This is a fast operation that should be called INSIDE synchronized blocks. + * + * @param folder the workspace folder to mark + * @return true if the folder was newly marked, false if already notified + */ + public boolean markFolderAsNotified(@NotNull WorkspaceFolder folder) { + var clientFeatures = serverWrapper.getClientFeatures(); + var workspaceFolderFeature = clientFeatures.getWorkspaceFolderFeature(); + return workspaceFolderFeature.markFolderAsNotified(folder); + } + + /** + * Sends workspace/didChangeWorkspaceFolders notification to add a folder. + * This should be called OUTSIDE synchronized blocks as it performs network I/O. + * + * @param folder the workspace folder to notify + */ + public void sendFolderAddedNotification(@NotNull WorkspaceFolder folder) { + notifyWorkspaceFolderAdded(folder); + } + + /** + * Sends a workspace/didChangeWorkspaceFolders notification to add a new folder. + * + * @param folder the workspace folder to add + */ + private void notifyWorkspaceFolderAdded(@NotNull WorkspaceFolder folder) { + var languageServer = serverWrapper.getLanguageServer(); + if (languageServer == null) { + return; + } + + try { + WorkspaceFoldersChangeEvent event = new WorkspaceFoldersChangeEvent(); + event.setAdded(Collections.singletonList(folder)); + event.setRemoved(Collections.emptyList()); + + DidChangeWorkspaceFoldersParams params = new DidChangeWorkspaceFoldersParams(); + params.setEvent(event); + + languageServer.getWorkspaceService().didChangeWorkspaceFolders(params); + + LOGGER.info("Workspace folder added: " + folder.getUri()); + } catch (Exception e) { + LOGGER.error("Error sending workspace folder notification", e); + } + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupport.java index 636c6b94d..32ac30462 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupport.java @@ -93,11 +93,7 @@ public static String toString(@Nullable VirtualFile file, if (file == null) { return null; } - URI fileUri = getFileUri(file, fileUriSupport); - if (fileUri == null) { - return null; - } - return toString(fileUri, file.isDirectory(), fileUriSupport); + return fileUriSupport != null ? fileUriSupport.toString(file) : DEFAULT.toString(file); } @Nullable diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupportBase.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupportBase.java index fe0cdd2d0..07953e3d9 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupportBase.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/FileUriSupportBase.java @@ -10,18 +10,14 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.client.features; -import com.intellij.openapi.vfs.VfsUtilCore; import com.intellij.openapi.vfs.VirtualFile; import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.internal.uri.UriConverterManager; import com.redhat.devtools.lsp4ij.internal.uri.UriFormatter; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.net.URI; -import java.util.Objects; - -import static com.redhat.devtools.lsp4ij.LSPIJUtils.JAR_PROTOCOL; -import static com.redhat.devtools.lsp4ij.LSPIJUtils.JRT_PROTOCOL; /** * Provides a base implementation of the {@link FileUriSupport} interface @@ -45,9 +41,9 @@ public class FileUriSupportBase implements FileUriSupport { @Override public String toString(@NotNull VirtualFile file) { - String protocol = file.getFileSystem().getProtocol(); - if (JAR_PROTOCOL.equals(protocol) || JRT_PROTOCOL.equals(protocol)) { - return Objects.requireNonNull(VfsUtilCore.convertToURL(file.getUrl())).toExternalForm(); + String uriString = UriConverterManager.getInstance().toString(file); + if (uriString != null) { + return uriString; } URI fileUri = getFileUri(file); return fileUri != null ? toString(fileUri, file.isDirectory()) : null; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPBreadcrumbsFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPBreadcrumbsFeature.java index 99ea52e86..d89f8fb76 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPBreadcrumbsFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPBreadcrumbsFeature.java @@ -11,11 +11,20 @@ package com.redhat.devtools.lsp4ij.client.features; +import com.intellij.lang.Language; +import com.intellij.openapi.extensions.ExtensionPointName; import com.intellij.psi.PsiFile; +import com.intellij.ui.breadcrumbs.BreadcrumbsProvider; +import com.redhat.devtools.lsp4ij.features.documentSymbol.LSPBreadcrumbsProvider; import org.eclipse.lsp4j.ServerCapabilities; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.util.Set; /** * Breadcrumbs/sticky lines feature. This does not correspond directly to an LSP feature but is built upon @@ -24,13 +33,86 @@ @ApiStatus.Experimental public class LSPBreadcrumbsFeature extends AbstractLSPDocumentFeature { + private static final Logger LOGGER = LoggerFactory.getLogger(LSPBreadcrumbsFeature.class); + + /** + * The {@link BreadcrumbsProvider} extension point name, resolved once via reflection to avoid + * using the {@code @Internal} {@link BreadcrumbsProvider#EP_NAME} field directly. + * Falls back to {@link ExtensionPointName#create(String)} if reflection fails. + */ + private static final ExtensionPointName BREADCRUMBS_EP_NAME = resolveBreadcrumbsEPName(); + + @SuppressWarnings("unchecked") + private static ExtensionPointName resolveBreadcrumbsEPName() { + try { + Field epNameField = BreadcrumbsProvider.class.getDeclaredField("EP_NAME"); + epNameField.setAccessible(true); + return (ExtensionPointName) epNameField.get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + // Fallback: recreate the extension point by its string name + LOGGER.warn("Unable to resolve BreadcrumbsProvider.EP_NAME via reflection, falling back to ExtensionPointName.create()", e); + return ExtensionPointName.create("com.intellij.breadcrumbsInfoProvider"); + } + } + @Override public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilities) { } + @Override + public boolean isEnabled(@NotNull PsiFile file) { + return isExistingBreadcrumbsProviderOverrideable(file) || !hasThirdPartyBreadcrumbsProvider(file); + } + @Override public boolean isSupported(@NotNull PsiFile file) { // Requires the document symbol feature return getClientFeatures().getDocumentSymbolFeature().isSupported(file); } -} + + /** + * Specifies whether the language server should override a {@link BreadcrumbsProvider} registered + * for languages in a file. + *

    + * Override this method and return {@code true} to always use the language server for breadcrumbs, + * even if another plugin already provides breadcrumbs for the same language. + * + * @return {@code true} to use the language server for breadcrumbs and {@code false} to use + * plugin-provided/built-in breadcrumbs. + * @apiNote This method will only be called with files that contain a language supported by this language server. + */ + public boolean isExistingBreadcrumbsProviderOverrideable(@NotNull PsiFile file) { + return false; + } + + /** + * Returns {@code true} if a {@link BreadcrumbsProvider} other than LSP4IJ's own + * is registered and supports at least one of the languages of the given file. + *

    + * Multi-language files (e.g. HTML+JS, JSX) are handled by checking all languages + * exposed by the file's {@link com.intellij.psi.FileViewProvider}. + */ + private static boolean hasThirdPartyBreadcrumbsProvider(@NotNull PsiFile file) { + if (BREADCRUMBS_EP_NAME == null) { + return false; + } + // Check all languages exposed by the file (handles multi-language files such as HTML+JS or JSX) + Set fileLanguages = file.getViewProvider().getLanguages(); + for (BreadcrumbsProvider provider : BREADCRUMBS_EP_NAME.getExtensionList()) { + // Skip LSP4IJ's own provider to avoid self-detection + if (provider instanceof LSPBreadcrumbsProvider) { + continue; + } + for (Language providerLanguage : provider.getLanguages()) { + for (Language fileLanguage : fileLanguages) { + // Use isKindOf() to respect the language dialect hierarchy + // (e.g. TypeScript extends JavaScript) + if (fileLanguage.isKindOf(providerLanguage)) { + return true; + } + } + } + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPClientFeatures.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPClientFeatures.java index fd9bbd93c..63e71abf4 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPClientFeatures.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPClientFeatures.java @@ -19,8 +19,10 @@ import com.redhat.devtools.lsp4ij.ServerStatus; import com.redhat.devtools.lsp4ij.installation.ServerInstallationStatus; import com.redhat.devtools.lsp4ij.installation.ServerInstaller; +import com.redhat.devtools.lsp4ij.server.DefaultLauncherBuilder; import com.redhat.devtools.lsp4ij.server.capabilities.TextDocumentServerCapabilityRegistry; import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; +import org.eclipse.lsp4j.jsonrpc.Launcher; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.ServerCapabilities; import org.eclipse.lsp4j.TextDocumentRegistrationOptions; @@ -100,9 +102,12 @@ public class LSPClientFeatures implements Disposable, FileUriSupport { private LSPWorkspaceSymbolFeature workspaceSymbolFeature; private LSPConfigurationFeature configurationFeature; - + private EditorBehaviorFeature editorBehaviorFeature; + private LSPWorkspaceFolderFeature workspaceFolderFeature; + private @Nullable LanguageServerDefinition serverDefinition; + public LSPClientFeatures() { setFileUriSupport(FileUriSupport.DEFAULT); } @@ -310,6 +315,22 @@ public boolean isUseIntAsJsonRpcId() { return false; } + /** + * Creates the {@link Launcher.Builder} used to build the LSP launcher. + * + *

    The default implementation returns a {@link DefaultLauncherBuilder}. + * Subclasses can override this to return a custom builder — for example, + * a subclass of {@link DefaultLauncherBuilder} that overrides + * {@link DefaultLauncherBuilder#createMessageProducer} to provide a custom + * {@link org.eclipse.lsp4j.jsonrpc.MessageProducer}.

    + * + * @param the language server interface type. + * @return the launcher builder. + */ + public @NotNull Launcher.Builder createLauncherBuilder() { + return new DefaultLauncherBuilder<>(this); + } + /** * Returns true if the user can stop the language server in LSP console from the context menu and false otherwise. *

    @@ -339,6 +360,9 @@ public final Project getProject() { */ @NotNull public final LanguageServerDefinition getServerDefinition() { + if (serverDefinition != null) { + return serverDefinition; + } return getServerWrapper().getServerDefinition(); } @@ -1325,6 +1349,38 @@ public LSPClientFeatures setEditorBehaviorFeature(@NotNull EditorBehaviorFeature return this; } + /** + * Returns the LSP workspace folder feature. + * + * @return the LSP workspace folder feature. + */ + @NotNull + public final LSPWorkspaceFolderFeature getWorkspaceFolderFeature() { + if (workspaceFolderFeature == null) { + initWorkspaceFolderFeature(); + } + return workspaceFolderFeature; + } + + private synchronized void initWorkspaceFolderFeature() { + if (workspaceFolderFeature != null) { + return; + } + setWorkspaceFolderFeature(new LSPWorkspaceFolderFeature()); + } + + /** + * Initialize the LSP workspace folder feature. + * + * @param workspaceFolderFeature the LSP workspace folder feature. + * @return the LSP client features. + */ + public final LSPClientFeatures setWorkspaceFolderFeature(@NotNull LSPWorkspaceFolderFeature workspaceFolderFeature) { + workspaceFolderFeature.setClientFeatures(this); + this.workspaceFolderFeature = workspaceFolderFeature; + return this; + } + /** * Set the language server wrapper. * @@ -1335,6 +1391,16 @@ public final void setServerWrapper(LanguageServerWrapper serverWrapper) { this.serverWrapper = serverWrapper; } + /** + * Set the language server definition. + * + * @param serverDefinition the language server definition. + */ + @ApiStatus.Internal + public final void setServerDefinition(LanguageServerDefinition serverDefinition) { + this.serverDefinition = serverDefinition; + } + /** * Returns the language server wrapper. * @@ -1422,6 +1488,9 @@ public void dispose() { if (workspaceSymbolFeature != null) { workspaceSymbolFeature.dispose(); } + if (workspaceFolderFeature != null) { + workspaceFolderFeature.reset(); + } } public void setServerCapabilities(@NotNull ServerCapabilities serverCapabilities) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCodeActionFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCodeActionFeature.java index 269468871..4311ac3a4 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCodeActionFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCodeActionFeature.java @@ -22,6 +22,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Map; + /** * LSP codeAction feature. */ @@ -85,6 +87,17 @@ public String getText(@NotNull CodeAction codeAction) { return codeAction.getTitle(); } + private static final Map codeActionPrefixesToKinds = Map.of( + CodeActionKind.QuickFix, LanguageServerBundle.message("lsp.intention.code.action.kind.quickfix"), + CodeActionKind.Refactor, LanguageServerBundle.message("lsp.intention.code.action.kind.refactor"), + CodeActionKind.RefactorExtract, LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.extract"), + CodeActionKind.RefactorInline, LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.inline"), + CodeActionKind.RefactorRewrite, LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.rewrite"), + CodeActionKind.Source, LanguageServerBundle.message("lsp.intention.code.action.kind.source"), + CodeActionKind.SourceFixAll, LanguageServerBundle.message("lsp.intention.code.action.kind.source.fixAll"), + CodeActionKind.SourceOrganizeImports, LanguageServerBundle.message("lsp.intention.code.action.kind.source.organizeImports") + ); + /** * Returns the IntelliJ intention action family name from the given LSP code action. * @@ -95,23 +108,10 @@ public String getText(@NotNull CodeAction codeAction) { public String getFamilyName(@NotNull CodeAction codeAction) { String kind = codeAction.getKind(); if (StringUtils.isNotBlank(kind)) { - switch (kind) { - case CodeActionKind.QuickFix: - return LanguageServerBundle.message("lsp.intention.code.action.kind.quickfix"); - case CodeActionKind.Refactor: - return LanguageServerBundle.message("lsp.intention.code.action.kind.refactor"); - case CodeActionKind.RefactorExtract: - return LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.extract"); - case CodeActionKind.RefactorInline: - return LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.inline"); - case CodeActionKind.RefactorRewrite: - return LanguageServerBundle.message("lsp.intention.code.action.kind.refactor.rewrite"); - case CodeActionKind.Source: - return LanguageServerBundle.message("lsp.intention.code.action.kind.source"); - case CodeActionKind.SourceFixAll: - return LanguageServerBundle.message("lsp.intention.code.action.kind.source.fixAll"); - case CodeActionKind.SourceOrganizeImports: - return LanguageServerBundle.message("lsp.intention.code.action.kind.source.organizeImports"); + + for (Map.Entry entry : codeActionPrefixesToKinds.entrySet()) { + if (kind.startsWith(entry.getKey())) + return entry.getValue(); } } return LanguageServerBundle.message("lsp.intention.code.action.kind.empty"); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java index 2d0db4462..8a8745f67 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionFeature.java @@ -317,4 +317,22 @@ public boolean useTemplateForInvocationOnlySnippet(@NotNull PsiFile file) { // Default to enabled return true; } + + /** + * Whether a plain-text completion should be replicated on all carets when the editor has multiple carets. + * + *

    + * When enabled (the default), accepting a completion with several carets inserts the completion under each + * caret that shares the same prefix as the primary caret, mirroring IntelliJ's native multi-caret completion. + * Genuinely interactive snippets (templates with tab stops or placeholders) are never replicated regardless + * of this flag, since they rely on a single-caret template. + *

    + * + * @param file the file + * @return true if the completion should be replicated across additional carets; otherwise false + */ + public boolean isMultipleCaretsSupported(@NotNull PsiFile file) { + // Default to enabled + return true; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java index da5b9cdc4..232af119e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPCompletionProposal.java @@ -24,6 +24,10 @@ import com.intellij.codeInsight.template.impl.Variable; import com.intellij.model.Pointer; import com.intellij.model.Symbol; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.WriteAction; +import com.intellij.openapi.editor.Caret; +import com.intellij.openapi.editor.CaretModel; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.EditorModificationUtil; @@ -63,6 +67,7 @@ import static com.redhat.devtools.lsp4ij.features.documentation.LSPDocumentationHelper.getValidMarkupContents; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.isDoneNormally; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; +import static com.redhat.devtools.lsp4ij.internal.CompletionUtils.computePrefixStartFromInsertText; /** * LSP completion lookup element. @@ -130,8 +135,22 @@ public void handleInsert(@NotNull InsertionContext context) { updateInsertTextForTemplateProcessing(template.getTemplateText()); } - // Apply all text edits - apply(context.getDocument(), context.getOffset(CompletionInitializationContext.SELECTION_END_OFFSET)); + Document document = context.getDocument(); + int offset = context.getOffset(CompletionInitializationContext.SELECTION_END_OFFSET); + + // Capture (before applying) the data required to replicate this completion on additional carets. + // Must be computed before apply() mutates the document, since it relies on the prefix text under the caret. + MultiCaretInsertion multiCaretInsertion = prepareMultiCaretInsertion(document, template, offset); + + // Apply all text edits (primary caret + additional text edits such as auto-imports) + apply(document, offset); + + // Replicate the plain completion text on the remaining carets (multi-caret editing). + // Additional text edits (e.g. auto-imports) are intentionally not replicated: they are file-global + // and were already applied once by apply() above. + if (multiCaretInsertion != null) { + multiCaretInsertion.apply(document); + } // Just move to the first tab stop if there's a single invocation argument with no default value if (shouldMoveToFirstTabStop(template)) { @@ -160,6 +179,117 @@ public void handleInsert(@NotNull InsertionContext context) { } } + /** + * Prepares the replication of this completion on the additional (non-primary) carets when the editor + * has multiple carets, mirroring the behavior of IntelliJ's native multi-caret completion. + * + *

    + * Only plain-text completions are replicated. Interactive snippets (templates with tab stops or + * placeholders) are skipped since they rely on a single-caret {@link Template}. A non-null {@code template} + * alone is NOT a reliable signal: gopls marks even plain-text items (e.g. "float64") as + * {@link InsertTextFormat#Snippet}, so only genuinely interactive templates + * ({@link #shouldStartTemplate} / {@link #shouldMoveToFirstTabStop}) are excluded. The replication is + * limited to the main insert text; additional text edits (auto-imports, etc.) are file-global and are + * applied only once by {@link #apply}. + *

    + * + * @param document the document (read before {@link #apply} mutates it). + * @param template the snippet template if the completion is a snippet, {@code null} otherwise. + * @param offset the primary caret offset where the completion is being applied. + * @return the data required to replicate the completion, or {@code null} if replication must not happen. + */ + private @Nullable MultiCaretInsertion prepareMultiCaretInsertion(@NotNull Document document, + @Nullable Template template, + int offset) { + if (template != null && (shouldStartTemplate(template) || shouldMoveToFirstTabStop(template))) { + // Real snippets (with tab stops / placeholders) are inherently single-caret, don't replicate. + // Note: gopls marks even plain-text items (e.g. "float64") as InsertTextFormat.Snippet, so a + // non-null template alone is NOT a reliable signal; only an interactive template must be excluded. + return null; + } + if (!completionFeature.isMultipleCaretsSupported(file)) { + return null; + } + CaretModel caretModel = editor.getCaretModel(); + if (caretModel.getCaretCount() < 2) { + return null; + } + if (prefixStartOffset < 0 || offset > document.getTextLength() || offset < prefixStartOffset) { + return null; + } + String insertText = getInsertText(); + if (insertText == null) { + return null; + } + // The prefix consumed on the left of the primary caret (e.g. "fmt.Prin|" -> "Prin"), used to + // recognize carets sharing the same prefix and to compute the range each of them must replace. + String prefixText = document.getText(new TextRange(prefixStartOffset, offset)); + Caret primaryCaret = caretModel.getPrimaryCaret(); + List additionalCarets = new ArrayList<>(); + for (Caret caret : caretModel.getAllCarets()) { + if (caret != primaryCaret) { + additionalCarets.add(caret); + } + } + if (additionalCarets.isEmpty()) { + return null; + } + return new MultiCaretInsertion(additionalCarets, prefixText, insertText); + } + + /** + * Holds the data required to replicate a plain-text completion on additional carets and applies it. + */ + private static final class MultiCaretInsertion { + + private final List additionalCarets; + private final String prefixText; + private final String insertText; + + private MultiCaretInsertion(@NotNull List additionalCarets, + @NotNull String prefixText, + @NotNull String insertText) { + this.additionalCarets = additionalCarets; + this.prefixText = prefixText; + this.insertText = insertText; + } + + /** + * Replicates the completion on each additional caret that shares the same prefix as the primary caret. + * Carets whose prefix differs are left untouched to avoid corrupting unrelated text. + */ + private void apply(@NotNull Document document) { + if (ApplicationManager.getApplication().isWriteAccessAllowed()) { + doApply(document); + } else { + WriteAction.run(() -> doApply(document)); + } + } + + private void doApply(@NotNull Document document) { + int prefixLength = prefixText.length(); + // Edit from the highest offset to the lowest so that not-yet-processed (lower) offsets stay valid. + List carets = new ArrayList<>(additionalCarets); + carets.sort(Comparator.comparingInt(Caret::getOffset).reversed()); + for (Caret caret : carets) { + if (!caret.isValid()) { + continue; + } + int caretOffset = caret.getOffset(); + int start = caretOffset - prefixLength; + if (start < 0 || caretOffset > document.getTextLength()) { + continue; + } + // Only replicate where the caret shares the exact same prefix as the primary caret. + if (!prefixText.equals(document.getText(new TextRange(start, caretOffset)))) { + continue; + } + document.replaceString(start, caretOffset, insertText); + caret.moveToOffset(start + insertText.length()); + } + } + } + private void updateCompletionItemFromResolved() { if (!completionContext.isResolveCompletionSupported() || !completionFeature.shouldResolveOnApply(item, file)) { @@ -434,6 +564,10 @@ public CompletionItem getItem() { * @return the response of the resolved LSP variable and null otherwise. */ private @Nullable String getVariableValue(String variableName) { + if (!variableName.isEmpty() && variableName.charAt(0) == '$') { + // Remove $ + variableName = variableName.substring(1); + } Document document = editor.getDocument(); switch (variableName) { case TM_FILENAME_BASE: @@ -539,11 +673,8 @@ private int getPrefixStartOffset(@NotNull Document document, int completionOffse // case 2: text edit is undefined, try to compute the prefix start offset by using insertText String insertText = getInsertText(); - Integer prefixStartOffset = computePrefixStartFromInsertText(document, completionOffset, insertText); - if (prefixStartOffset != null) { - return prefixStartOffset; - } - return completionOffset; + Integer prefixStartOffset = computePrefixStartFromInsertText(document, file, completionOffset, insertText); + return Objects.requireNonNullElse(prefixStartOffset, completionOffset); } @@ -562,53 +693,6 @@ private static int getPrefixStartOffsetFromTextEdit(@NotNull Document document, return LSPIJUtils.toOffset(textEdit.getRight().getInsert().getStart(), document); } - @Nullable - private Integer computePrefixStartFromInsertText(@NotNull Document document, - int completionOffset, - String insertText) { - - // case 2.1: first strategy, we collect word range at - // ex : - // insertText= '(let [${1:binding} ${2:value}])' - // document= le - // we have to return |le as prefix start offset - - TextRange wordRange = LSPIJUtils.getWordRangeAt(document, file, completionOffset); - if (wordRange != null) { - return wordRange.getStartOffset(); - } - - // case 2.2: second strategy, we check if the left content of the completion offset - // matches the full insertText left content - // ex : - // insertText= 'foo.bar' - // document= {foo.b|} - // we have to return {| as prefix start offset - - Integer prefixStartOffset = getPrefixStartOffsetWhichMatchesLeftContent(document, completionOffset, insertText); - if (prefixStartOffset != null) { - return prefixStartOffset; - } - - return null; - } - - @Nullable - private static Integer getPrefixStartOffsetWhichMatchesLeftContent(@NotNull Document document, - int completionOffset, - @NotNull String insertText) { - int startOffset = Math.max(0, completionOffset - insertText.length()); - int endOffset = startOffset + Math.min(insertText.length(), completionOffset); - String subDoc = document.getText(new TextRange(startOffset, endOffset)); // "".ch - for (int i = 0; i < insertText.length() && i < completionOffset; i++) { - String tentativeCommonString = subDoc.substring(i); - if (insertText.startsWith(tentativeCommonString)) { - return completionOffset - tentativeCommonString.length(); - } - } - return null; - } - @Override public @Nullable LSPCompletionProposal dereference() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPDocumentSymbolFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPDocumentSymbolFeature.java index cdd7fa6d2..1a62065ef 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPDocumentSymbolFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPDocumentSymbolFeature.java @@ -12,6 +12,8 @@ import com.intellij.ide.structureView.StructureViewTreeElement; import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.features.documentSymbol.DocumentSymbolData; @@ -74,22 +76,50 @@ public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilitie } } + /** + * Creates a StructureViewTreeElement for a given document symbol. + * + * @param documentSymbol the document symbol data + * @return the tree element, or null if unavailable + */ @Nullable public StructureViewTreeElement getStructureViewTreeElement(@NotNull DocumentSymbolData documentSymbol) { return new LSPDocumentSymbolStructureViewModel.LSPDocumentSymbolViewElement(documentSymbol); } + /** + * Returns the display text for the given document symbol. + * + * @param documentSymbol the symbol + * @param psiFile the associated PSI file + * @return the presentable text, usually the symbol's name + */ public @Nullable String getPresentableText(@NotNull DocumentSymbol documentSymbol, @NotNull PsiFile psiFile) { return documentSymbol.getName(); } + /** + * Returns the icon for a document symbol based on its kind. + * + * @param documentSymbol the symbol + * @param psiFile the associated PSI file + * @param unused unused parameter + * @return the icon or null + */ public @Nullable Icon getIcon(@NotNull DocumentSymbol documentSymbol, @NotNull PsiFile psiFile, boolean unused) { return IconMapper.getIcon(documentSymbol.getKind()); } + /** + * Returns the location string for a document symbol, typically used in Structure View. + * + * @param documentSymbol the symbol + * @param psiFile the associated PSI file + * @return the location string (detail) or null + */ public @Nullable String getLocationString(@NotNull DocumentSymbol documentSymbol, @NotNull PsiFile psiFile) { return documentSymbol.getDetail(); @@ -115,6 +145,13 @@ public int getTextOffset(@NotNull DocumentSymbol documentSymbol, return 0; } + /** + * Navigates to the symbol in the editor. + * + * @param documentSymbol the symbol + * @param psiFile the associated PSI file + * @param requestFocus whether to request editor focus + */ public void navigate(@NotNull DocumentSymbol documentSymbol, @NotNull PsiFile psiFile, boolean requestFocus) { @@ -122,9 +159,35 @@ public void navigate(@NotNull DocumentSymbol documentSymbol, LSPIJUtils.openInEditor(psiFile.getVirtualFile(), selectionRange.getStart(), requestFocus, psiFile.getProject()); } + /** + * Checks if a symbol can be navigated to (has a valid selection start). + * + * @param documentSymbol the symbol + * @param psiFile the associated PSI file + * @return true if navigation is possible + */ public boolean canNavigate(@NotNull DocumentSymbol documentSymbol, @NotNull PsiFile psiFile) { var selectionRange = documentSymbol.getSelectionRange(); return selectionRange != null && selectionRange.getStart() != null; } + + /** + * Checks if the given {@link DocumentSymbolData} corresponds to the specified {@link PsiElement}. + * + *

    The comparison is performed using the text ranges: + * it uses the selection range if available, otherwise the full symbol range. + * If the ranges are equal, the document symbol is considered to match the PSI element.

    + * + * @param documentSymbolData the document symbol data + * @param psiElement the PSI element to compare with + * @return true if the document symbol represents the given PSI element (text ranges match), false otherwise + */ + public boolean matchesPsiElement(@NotNull DocumentSymbolData documentSymbolData, + @NotNull PsiElement psiElement) { + TextRange textRange = documentSymbolData.getSelectionTextRange() != null + ? documentSymbolData.getSelectionTextRange() + : documentSymbolData.getTextRange(); + return textRange.equals(psiElement.getTextRange()); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPFormattingFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPFormattingFeature.java index c7c6cae5a..752affdcc 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPFormattingFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPFormattingFeature.java @@ -11,9 +11,12 @@ package com.redhat.devtools.lsp4ij.client.features; import com.intellij.lang.LanguageFormatting; +import com.intellij.openapi.editor.Editor; import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.server.capabilities.DocumentFormattingCapabilityRegistry; import com.redhat.devtools.lsp4ij.server.capabilities.DocumentRangeFormattingCapabilityRegistry; +import org.eclipse.lsp4j.FormattingOptions; import org.eclipse.lsp4j.ServerCapabilities; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -66,10 +69,9 @@ public boolean isSupported(@NotNull PsiFile file) { * used if there is no registered formatter available. * * @return true to use the language server for code formatting and false to use plugin-provided/built-in formatters. - * * @apiNote This method will only be called with files that contain a language supported by this language server. */ - protected boolean isExistingFormatterOverrideable(@NotNull PsiFile file) { + public boolean isExistingFormatterOverrideable(@NotNull PsiFile file) { return false; } @@ -268,4 +270,75 @@ public String getFormatOnCompletionTriggerCharacters(@NotNull PsiFile file) { // Default to the language's standard completion trigger characters return null; } + + /** + * Builds and returns the {@link FormattingOptions} used when invoking the + * {@code textDocument/formatting} or {@code textDocument/rangeFormatting} LSP requests. + *

    + * The returned options specify how the language server should indent and align text + * according to the current editor settings. + *

    + * This method determines the {@code tabSize} and {@code insertSpaces} options from the + * associated editor (if available), falling back to {@code null} when no editor context + * is provided. + * + * @param file the PSI file for which formatting options are requested. + * @param editor the editor associated with the PSI file, or {@code null} if the file + * is not currently opened in an editor. + * @return a {@link FormattingOptions} instance configured with the current editor + * tabulation and spacing settings. Never {@code null}. + */ + @NotNull + public FormattingOptions getFormattingOptions(@NotNull PsiFile file, + @Nullable Editor editor) { + Integer tabSize = getTabSize(file, editor); + Boolean insertSpaces = getInsertSpaces(file, editor); + var formattingOptions = new FormattingOptions(); + if (tabSize != null) { + formattingOptions.setTabSize(tabSize); + } + if (insertSpaces != null) { + formattingOptions.setInsertSpaces(insertSpaces); + } + return formattingOptions; + } + + /** + * Determines the tab size (number of spaces per tab) used for the given file and editor. + *

    + * The tab size is typically defined by the editor's code style settings for the file's language. + *

    + * If an editor instance is provided, this method retrieves the tab size using + * {@link LSPIJUtils#getTabSize(Editor)}. If the editor is {@code null}, this method returns + * {@code null}. + * + * @param file the PSI file for which the tab size is requested. + * @param editor the editor associated with the file, or {@code null} if unavailable. + * @return the tab size as an {@link Integer}, or {@code null} if no editor context is provided. + */ + public @Nullable Integer getTabSize(@NotNull PsiFile file, + @Nullable Editor editor) { + return editor != null ? LSPIJUtils.getTabSize(editor) : null; + } + + /** + * Determines whether spaces should be inserted instead of tab characters when indenting. + *

    + * This setting is obtained from the associated editor, which reflects the user's code + * style preferences for the language of the given file. + *

    + * If an editor instance is provided, this method retrieves the value using + * {@link LSPIJUtils#isInsertSpaces(Editor)}. If the editor is {@code null}, this method + * returns {@code null}. + * + * @param file the PSI file for which the setting is requested. + * @param editor the editor associated with the file, or {@code null} if unavailable. + * @return {@code true} if spaces should be inserted instead of tabs, + * {@code false} otherwise, or {@code null} if no editor context is provided. + */ + public @Nullable Boolean getInsertSpaces(@NotNull PsiFile file, + @Nullable Editor editor) { + return editor != null ? LSPIJUtils.isInsertSpaces(editor) : null; + } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPImplementationFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPImplementationFeature.java index 6815f8871..bbc48e14d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPImplementationFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPImplementationFeature.java @@ -40,6 +40,26 @@ public boolean isImplementationSupported(@NotNull PsiFile file) { return getImplementationCapabilityRegistry().isImplementationSupported(file); } + //OPEN IDE BEGIN + /** + * Returns true if the language server supports textDocument/implementation requests for functions, + * and false otherwise. + *

    + * By default, this method returns true. Language server implementations can override this method + * to return false if the server does not support or throws errors when requesting implementations + * for standalone functions (as opposed to methods). + *

    + * For example, the Go language server throws an error when requesting implementations for functions + * because only methods (not standalone functions) can implement interfaces in Go. + * + * @param file the file. + * @return true if the language server supports implementation requests for functions; false otherwise. + */ + public boolean isImplementationForFunctionSupported(@NotNull PsiFile file) { + return true; + } + //OPEN IDE END + public ImplementationCapabilityRegistry getImplementationCapabilityRegistry() { if (implementationCapabilityRegistry == null) { initImplementationCapabilityRegistry(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPReferencesFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPReferencesFeature.java index 82aa75c00..e9e60a0ad 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPReferencesFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPReferencesFeature.java @@ -63,6 +63,16 @@ public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilitie } } + /** + * Returns true if the declaration of the current symbol should be included in the references result. + * + * @param file the file + * @return true if the declaration should be included; false to show only usages + */ + public boolean isIncludeDeclaration(@NotNull PsiFile file) { + return true; + } + /** * Determines whether or not client-side external references should be processed for LSP4IJ symbols in the * specified file. diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPUsageFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPUsageFeature.java index e04721f3f..77f35b302 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPUsageFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPUsageFeature.java @@ -10,7 +10,10 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.client.features; +import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.features.semanticTokens.viewProvider.LSPSemanticTokenPsiElement; +import org.eclipse.lsp4j.SemanticTokenTypes; import org.eclipse.lsp4j.ServerCapabilities; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -18,10 +21,25 @@ /** * LSP usage feature. + *

    + * This feature determines whether the "Find Usages" functionality is available for a given file or element. + * The Find Usages feature allows users to locate all references, declarations, definitions, implementations, + * and type definitions of a symbol in the codebase. + *

    + *

    + * This feature can be overridden by language server implementations to customize which files and elements + * support usage searching based on their specific LSP capabilities. + *

    */ @ApiStatus.Experimental public class LSPUsageFeature extends AbstractLSPDocumentFeature { + /** + * Checks if the file supports the usage feature. + * + * @param file the file to check. + * @return true if usage is supported for this file, false otherwise. + */ @Override public boolean isSupported(@NotNull PsiFile file) { return isUsageSupported(file); @@ -29,21 +47,73 @@ public boolean isSupported(@NotNull PsiFile file) { /** * Returns true if the file associated with a language server can support usage and false otherwise. + *

    + * A file supports usage if at least one of the following LSP features is supported: + *

      + *
    • textDocument/declaration
    • + *
    • textDocument/typeDefinition
    • + *
    • textDocument/definition
    • + *
    • textDocument/references
    • + *
    • textDocument/implementation
    • + *
    + *

    * - * @param file the file. + * @param file the file to check. * @return true if the file associated with a language server can support usage and false otherwise. */ public boolean isUsageSupported(@NotNull PsiFile file) { var clientFeature = getClientFeatures(); - return clientFeature.getDeclarationFeature().isDeclarationSupported(file) || - clientFeature.getTypeDefinitionFeature().isTypeDefinitionSupported(file) || - clientFeature.getDefinitionFeature().isDefinitionSupported(file) || - clientFeature.getReferencesFeature().isReferencesSupported(file) || - clientFeature.getImplementationFeature().isImplementationSupported(file); + return clientFeature.getDeclarationFeature().isSupported(file) || + clientFeature.getTypeDefinitionFeature().isSupported(file) || + clientFeature.getDefinitionFeature().isSupported(file) || + clientFeature.getReferencesFeature().isSupported(file) || + clientFeature.getImplementationFeature().isSupported(file); + } + + /** + * Returns true if the given PSI element supports usage searching and false otherwise. + *

    + * For semantic token elements, this method checks if the token type is appropriate for usage searching. + * Non-semantic token elements are considered supported by default. + *

    + * + * @param element the PSI element to check. + * @return true if the element supports usage, false otherwise. + */ + public boolean isUsageSupported(@NotNull PsiElement element) { + if (element instanceof LSPSemanticTokenPsiElement semanticTokenPsiElement) { + return isUsageSupported(semanticTokenPsiElement); + } + return true; + } + + /** + * Returns true if the given semantic token element supports usage searching and false otherwise. + *

    + * Certain semantic token types are excluded from usage searching because they do not represent + * symbols that can be referenced: + *

      + *
    • Comments - documentation text, not code symbols
    • + *
    • Keywords - language syntax, not user-defined symbols
    • + *
    • Modifiers - syntax modifiers (public, static, etc.)
    • + *
    • Operators - language operators (+, -, *, etc.)
    • + *
    + *

    + * + * @param element the semantic token element to check. + * @return true if the element supports usage, false if it's a comment, keyword, modifier, or operator. + */ + protected boolean isUsageSupported(@NotNull LSPSemanticTokenPsiElement element) { + String tokenType = element.getType(); + return !(SemanticTokenTypes.Comment.equals(tokenType) + || SemanticTokenTypes.Keyword.equals(tokenType) + || SemanticTokenTypes.Modifier.equals(tokenType) + || SemanticTokenTypes.Operator.equals(tokenType)); } @Override public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilities) { // Do nothing } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceFolderFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceFolderFeature.java new file mode 100644 index 000000000..d5243b8c3 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceFolderFeature.java @@ -0,0 +1,149 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.client.features; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ConfigurableWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ProjectWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Workspace folder feature for LSP. + * + *

    This feature manages how workspace folders are discovered and sent to the language server. + * By default, it uses the project base directories. You can customize this by providing a JSON + * configuration to use content roots and/or marker-based detection.

    + */ +@ApiStatus.Experimental +public class LSPWorkspaceFolderFeature { + + private LSPClientFeatures clientFeatures; + + protected WorkspaceFolderStrategy strategy; + + // Thread-safe set for tracking notified workspace folders + private final Set notifiedWorkspaceFolders = ConcurrentHashMap.newKeySet(); + + /** + * Sets the containing client features. + * + * @param clientFeatures the client features to associate with this workspace folder feature + */ + void setClientFeatures(@NotNull LSPClientFeatures clientFeatures) { + this.clientFeatures = clientFeatures; + } + + /** + * Returns the containing client features. + * + * @return the associated {@link LSPClientFeatures} + */ + @NotNull + protected LSPClientFeatures getClientFeatures() { + return clientFeatures; + } + + /** + * Returns the workspace folder strategy. + * + * @return the workspace folder strategy + */ + @NotNull + public WorkspaceFolderStrategy getStrategy() { + if (strategy == null) { + strategy = createStrategy(); + } + return strategy; + } + + /** + * Creates the default workspace folder strategy. + * Override this method to provide a different default strategy. + * + * @return the default workspace folder strategy + */ + @NotNull + protected WorkspaceFolderStrategy createStrategy() { + return new ProjectWorkspaceFolderStrategy(); + } + + /** + * Returns the initial workspace folders to send during initialization. + * + * @param project the project + * @return the list of workspace folders + */ + @NotNull + public List getInitialWorkspaceFolders(@NotNull Project project) { + List folders = getStrategy().getInitialWorkspaceFolders(project, getClientFeatures()); + + // Track folders that were sent during initialization + for (WorkspaceFolder folder : folders) { + notifiedWorkspaceFolders.add(folder.getUri()); + } + + return folders; + } + + /** + * Computes the workspace folder to notify for the given file without marking it as notified. + * This method performs I/O operations and should be called outside synchronized blocks. + * + * @param file the file being opened + * @return the workspace folder to notify, or null if not needed + */ + @Nullable + public WorkspaceFolder computeWorkspaceFolderToNotify(@NotNull VirtualFile file) { + if (getStrategy().sendAllFoldersOnInitialization()) { + // Strategy sends all folders at initialization, no dynamic notification needed + return null; + } + + Project project = getClientFeatures().getProject(); + WorkspaceFolder folder = getStrategy().getWorkspaceFolderForFile(file, project, getClientFeatures()); + + if (folder != null && !notifiedWorkspaceFolders.contains(folder.getUri())) { + return folder; + } + + return null; + } + + /** + * Marks a workspace folder as notified. + * This is a fast operation that should be called inside synchronized blocks. + * + * @param folder the workspace folder to mark as notified + * @return true if the folder was newly added, false if it was already notified + */ + public boolean markFolderAsNotified(@NotNull WorkspaceFolder folder) { + return notifiedWorkspaceFolders.add(folder.getUri()); + } + + /** + * Clears the tracking of notified workspace folders. + * This should be called when the language server is restarted. + */ + public void reset() { + notifiedWorkspaceFolders.clear(); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceSymbolFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceSymbolFeature.java index 223ba71f4..0c7c2f4cb 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceSymbolFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/features/LSPWorkspaceSymbolFeature.java @@ -55,4 +55,20 @@ public boolean supportsGotoClass() { // Default to unsupported return false; } + + /** + * Determines whether workspace/symbol results returned by this language server must be restricted + * to the IDE search scope before being shown in Go To Symbol / Go To Class. + * + * Some language servers legitimately return symbols from files that live outside the IDE search + * scope - for example symbols declared in {@code node_modules}, which is typically registered as an + * excluded folder and therefore is not contained in any {@code GlobalSearchScope}. For such servers + * this method should return {@code false} so those results are not dropped by the scope filter. + * + * @return {@code true} (default) to filter results by the IDE search scope; {@code false} to bypass + * that filter and trust the language server's own scoping. + */ + public boolean filterBySearchScope() { + return true; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingDumbAndScanningStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingDumbAndScanningStrategy.java index bb9c706e8..7f7b8f3b3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingDumbAndScanningStrategy.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingDumbAndScanningStrategy.java @@ -10,6 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.client.indexing; +import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.project.Project; import com.intellij.util.messages.Topic; @@ -42,7 +43,7 @@ *

    */ @ApiStatus.Internal -public class ProjectIndexingDumbAndScanningStrategy extends ProjectIndexingStrategyBase { +public class ProjectIndexingDumbAndScanningStrategy extends ProjectIndexingStrategyBase implements Disposable { private static final Logger LOGGER = LoggerFactory.getLogger(ProjectIndexingDumbAndScanningStrategy.class); @@ -99,9 +100,8 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl getTopicMethod.setAccessible(true); Topic topicInstance = (Topic) getTopicMethod.invoke(companionInstance); - // Subscribe the proxyInstance with the topicInstance - //ApplicationManager.getApplication().getMessageBus().connect(ProjectIndexingActivityHistoryListener.Companion.getTOPIC(), proxyInstance) - ApplicationManager.getApplication().getMessageBus().connect().subscribe(topicInstance, proxyInstance); + // Subscribe the proxyInstance with the topicInstance. + ApplicationManager.getApplication().getMessageBus().connect(this).subscribe(topicInstance, proxyInstance); enabled = true; } catch (Exception e) { @@ -109,6 +109,10 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl } } + @Override + public void dispose() { + } + private @Nullable Project getProject(Object[] args) { /* ProjectIndexingActivityHistory */ Object history = args[0]; Method projectMethod = getProjectMethod(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingManager.java b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingManager.java index b9b7d41fd..beefe0985 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingManager.java @@ -34,10 +34,6 @@ */ public class ProjectIndexingManager implements Disposable { - - private record RefreshFeatures(Set features, CompletableFuture indexingFinished) { - } - final @NotNull Project project; boolean dumbIndexing; boolean scanning; @@ -143,7 +139,7 @@ public static ExecuteLSPFeatureStatus canExecuteLSPFeature(@Nullable VirtualFile private static @NotNull ExecuteLSPFeatureStatus doCanExecuteLSPFeature(@Nullable VirtualFile file, @NotNull Project project) { ProjectIndexingManager manager = getInstance(project); - if (manager.isIndexingAll()) { + if (ProjectIndexingManager.isIndexingAll()) { // The file is associated to a language server, but the project is indexing // Execute the LSP feature when the project indexing process is finished. manager.filesToRefresh.add(file); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingStrategyBase.java b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingStrategyBase.java index 90da079a6..57d333e5a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingStrategyBase.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/client/indexing/ProjectIndexingStrategyBase.java @@ -92,7 +92,7 @@ protected final void onFinishedDumbIndexing(@Nullable Project project) { } private void refreshEditorsFeaturesIfNeeded(ProjectIndexingManager manager) { - if (!manager.isIndexingAll()) { + if (!ProjectIndexingManager.isIndexingAll()) { // All opened project are indexed, // refresh all editors which edit the files to refresh while (!manager.filesToRefresh.isEmpty()) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/commands/LSPCommand.java b/src/main/java/com/redhat/devtools/lsp4ij/commands/LSPCommand.java index 365f966d8..4a40624d5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/commands/LSPCommand.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/commands/LSPCommand.java @@ -33,7 +33,9 @@ public class LSPCommand extends Command { LSPCommand(Command command, ClassLoader classLoader) { this.classLoader = classLoader; - super.setTitle(command.getTitle()); + if (command.getTitle() != null) { + super.setTitle(command.getTitle()); + } super.setCommand(command.getCommand()); this.originalArguments = command.getArguments(); if (originalArguments == null || originalArguments.isEmpty()) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/commands/editor/TriggerParameterHintsAction.java b/src/main/java/com/redhat/devtools/lsp4ij/commands/editor/TriggerParameterHintsAction.java index 5afdaa0e2..fdf012496 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/commands/editor/TriggerParameterHintsAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/commands/editor/TriggerParameterHintsAction.java @@ -10,18 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.commands.editor; -import com.intellij.ide.DataManager; import com.intellij.openapi.actionSystem.*; -import com.intellij.openapi.actionSystem.ex.ActionUtil; -import com.intellij.openapi.actionSystem.impl.SimpleDataContext; -import com.intellij.openapi.fileEditor.FileEditor; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.fileEditor.TextEditor; -import com.intellij.openapi.project.Project; -import com.redhat.devtools.lsp4ij.commands.LSPCommand; -import com.redhat.devtools.lsp4ij.commands.LSPCommandAction; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; /** * Emulates Visual Studio Code's "editor.action.triggerParameterHints" command, to trigger ParameterInfo. diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/LSPConsoleViewBase.java b/src/main/java/com/redhat/devtools/lsp4ij/console/LSPConsoleViewBase.java index cb673a50d..da2d951ef 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/LSPConsoleViewBase.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/LSPConsoleViewBase.java @@ -14,17 +14,12 @@ package com.redhat.devtools.lsp4ij.console; import com.intellij.execution.impl.ConsoleViewImpl; -import com.intellij.execution.impl.EditorHyperlinkSupport; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.editor.actions.ScrollToTheEndToolbarAction; import com.intellij.openapi.project.Project; import com.intellij.psi.search.GlobalSearchScope; -import com.redhat.devtools.lsp4ij.console.actions.AutoFoldingAction; -import com.redhat.devtools.lsp4ij.console.actions.ClearThisConsoleAction; -import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; -import com.redhat.devtools.lsp4ij.settings.ProjectLanguageServerSettings; -import com.redhat.devtools.lsp4ij.settings.ServerTrace; +import com.redhat.devtools.lsp4ij.console.actions.LSPClearConsoleAction; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -53,6 +48,6 @@ public LSPConsoleViewBase(@NotNull Project project, protected void fillConsoleActions(List consoleActions) { consoleActions.add(new ScrollToTheEndToolbarAction(getEditor())); consoleActions.add(ActionManager.getInstance().getAction("Print")); - consoleActions.add(new ClearThisConsoleAction(this)); + consoleActions.add(new LSPClearConsoleAction(this)); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/actions/AutoFoldingAction.java b/src/main/java/com/redhat/devtools/lsp4ij/console/actions/AutoFoldingAction.java index f604b7bf3..f290af954 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/actions/AutoFoldingAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/actions/AutoFoldingAction.java @@ -28,8 +28,6 @@ */ public class AutoFoldingAction extends ToggleAction implements DumbAware { - private boolean initialExpanded; - private final Editor myEditor; public AutoFoldingAction(@NotNull final Editor editor) { @@ -62,7 +60,6 @@ public static boolean shouldLSPTracesBeExpanded(Editor editor) { @Override public void setSelected(@NotNull AnActionEvent e, boolean state) { boolean expanded = state; - initialExpanded = expanded; myEditor.getFoldingModel().runBatchFoldingOperation(() -> { FoldRegion[] allRegions = myEditor.getFoldingModel().getAllFoldRegions(); for (FoldRegion region : allRegions) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/actions/ClearThisConsoleAction.java b/src/main/java/com/redhat/devtools/lsp4ij/console/actions/LSPClearConsoleAction.java similarity index 88% rename from src/main/java/com/redhat/devtools/lsp4ij/console/actions/ClearThisConsoleAction.java rename to src/main/java/com/redhat/devtools/lsp4ij/console/actions/LSPClearConsoleAction.java index 5fec5e6df..dd27fbaf8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/actions/ClearThisConsoleAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/actions/LSPClearConsoleAction.java @@ -23,10 +23,10 @@ * * @author Angelo ZERR */ -public class ClearThisConsoleAction extends ClearConsoleAction { +public class LSPClearConsoleAction extends ClearConsoleAction { private final ConsoleView myConsoleView; - public ClearThisConsoleAction(@NotNull ConsoleView consoleView) { + public LSPClearConsoleAction(@NotNull ConsoleView consoleView) { myConsoleView = consoleView; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerProcessTreeNode.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerProcessTreeNode.java index b0f6bd990..a4b9f286c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerProcessTreeNode.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerProcessTreeNode.java @@ -19,6 +19,7 @@ import com.redhat.devtools.lsp4ij.LanguageServerWrapper; import com.redhat.devtools.lsp4ij.ServerStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import javax.swing.*; import javax.swing.tree.DefaultMutableTreeNode; @@ -50,18 +51,22 @@ public LanguageServerProcessTreeNode(LanguageServerWrapper languageServer, Defau public void setServerStatus(ServerStatus serverStatus) { this.serverStatus = serverStatus; displayName = getDisplayName(serverStatus); - switch (serverStatus) { - case starting: - case stopping: - case checking_installed: - case installing: - startTime = System.currentTimeMillis(); - break; - case stopped: - case started: - case installed: - startTime = -1; - break; + if (languageServer.isEnabled()) { + switch (serverStatus) { + case starting: + case stopping: + case checking_installed: + case installing: + startTime = System.currentTimeMillis(); + break; + case stopped: + case started: + case installed: + startTime = -1; + break; + } + } else { + startTime = -1; } this.setUserObject(displayName); treeModel.nodeChanged(this); @@ -137,7 +142,10 @@ public String getDisplayName() { return displayName; } - public String getElapsedTime() { + public @Nullable String getElapsedTime() { + if (!languageServer.isEnabled()) { + return null; + } long endTime = System.currentTimeMillis(); long duration = endTime - startTime; return Formats.formatDuration(duration, "\u2009"); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerTreeNode.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerTreeNode.java index 952c009c9..cb726bfd2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerTreeNode.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/LanguageServerTreeNode.java @@ -13,7 +13,6 @@ *******************************************************************************/ package com.redhat.devtools.lsp4ij.console.explorer; -import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; import javax.swing.*; @@ -35,10 +34,7 @@ public LanguageServerDefinition getServerDefinition() { } public LanguageServerProcessTreeNode getActiveProcessTreeNode() { - for (int i = 0; i < super.getChildCount(); i++) { - return (LanguageServerProcessTreeNode) super.getChildAt(i); - } - return null; + return super.getChildCount() > 0 ? (LanguageServerProcessTreeNode) super.getChildAt(0) : null; } public Icon getIcon() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/DeleteServerAction.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/DeleteServerAction.java index dd1310a9a..e113d1850 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/DeleteServerAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/DeleteServerAction.java @@ -20,8 +20,6 @@ import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; import org.jetbrains.annotations.NotNull; -import java.util.Objects; - /** * Delete (only) user defined language server action. */ diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/ExportServerAction.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/ExportServerAction.java index 341600c4a..8c33d1bdf 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/ExportServerAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/ExportServerAction.java @@ -55,7 +55,7 @@ public ExportServerAction(@NotNull List languageServer public void actionPerformed(@NotNull AnActionEvent e) { FileChooserFactory fileChooserFactory = FileChooserFactory.getInstance(); FileSaverDescriptor fileSaverDescriptor = new FileSaverDescriptor( - LanguageServerBundle.message("action.lsp.console.explorer.export.servers.zip.save.title"), LanguageServerBundle.message("action.lsp.console.explorer.export.servers.zip.save.description")); + LanguageServerBundle.message("action.lsp.console.explorer.export.servers.zip.save.title"), LanguageServerBundle.message("action.lsp.console.explorer.export.servers.zip.save.description"), new String[]{"zip"}); FileSaverDialog fileSaverDialog = fileChooserFactory.createSaveFileDialog(fileSaverDescriptor, e.getProject()); String currentDate = getCurrentDate(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerAction.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerAction.java index a343487b3..5daa97def 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerAction.java @@ -14,14 +14,8 @@ package com.redhat.devtools.lsp4ij.console.explorer.actions; import com.intellij.icons.AllIcons; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.project.DumbAware; import com.intellij.openapi.project.Project; -import com.intellij.ui.treeStructure.Tree; import com.redhat.devtools.lsp4ij.LanguageServerBundle; -import com.redhat.devtools.lsp4ij.LanguageServerManager; -import com.redhat.devtools.lsp4ij.LanguageServerWrapper; import com.redhat.devtools.lsp4ij.installation.ServerInstallationContext; import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerActionBase.java b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerActionBase.java index dc4b74609..88326e206 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerActionBase.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/console/explorer/actions/InstallServerActionBase.java @@ -13,12 +13,10 @@ *******************************************************************************/ package com.redhat.devtools.lsp4ij.console.explorer.actions; -import com.intellij.icons.AllIcons; import com.intellij.openapi.actionSystem.AnAction; import com.intellij.openapi.actionSystem.AnActionEvent; import com.intellij.openapi.project.DumbAware; import com.intellij.openapi.project.Project; -import com.redhat.devtools.lsp4ij.LanguageServerBundle; import com.redhat.devtools.lsp4ij.LanguageServerManager; import com.redhat.devtools.lsp4ij.installation.ServerInstallationContext; import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugProcess.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugProcess.java index ce8f37a62..26d3493f8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugProcess.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugProcess.java @@ -17,6 +17,7 @@ import com.intellij.execution.ui.RunnerLayoutUi; import com.intellij.execution.ui.layout.PlaceInGrid; import com.intellij.icons.AllIcons; +import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.progress.ProgressIndicator; @@ -34,20 +35,28 @@ import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider; import com.intellij.xdebugger.frame.XStackFrame; import com.intellij.xdebugger.frame.XSuspendContext; +import com.intellij.xdebugger.stepping.XSmartStepIntoHandler; import com.intellij.xdebugger.ui.XDebugTabLayouter; -import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointHandler; +import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointHandlerBase; import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPExceptionBreakpointsPanel; import com.redhat.devtools.lsp4ij.dap.client.DAPClient; import com.redhat.devtools.lsp4ij.dap.client.DAPStackFrame; import com.redhat.devtools.lsp4ij.dap.client.DAPSuspendContext; +import com.redhat.devtools.lsp4ij.dap.client.files.DAPFileRegistry; import com.redhat.devtools.lsp4ij.dap.configurations.DAPCommandLineState; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import com.redhat.devtools.lsp4ij.dap.disassembly.DAPAlternativeSourceHandler; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile; +import com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints.DisassemblyBreakpointHandlerBase; +import com.redhat.devtools.lsp4ij.dap.stepping.DAPSmartStepIntoHandler; +import com.redhat.devtools.lsp4ij.dap.stepping.DAPStepIntoVariant; import com.redhat.devtools.lsp4ij.dap.threads.ThreadsPanel; import com.redhat.devtools.lsp4ij.internal.CancellationSupport; import com.redhat.devtools.lsp4ij.internal.CompletableFutures; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.settings.ServerTrace; import com.redhat.devtools.lsp4ij.settings.ui.InstallerPanel; +import org.eclipse.lsp4j.debug.SteppingGranularity; import org.eclipse.lsp4j.debug.Thread; import org.eclipse.lsp4j.debug.ThreadEventArguments; import org.jetbrains.annotations.NotNull; @@ -65,42 +74,46 @@ /** * Debug Adapter Protocol (DAP) debug process. */ -public class DAPDebugProcess extends XDebugProcess { - - private enum Status { - NONE, - STARTING, - STARTED, - STOPPING, - STOPPED - } +public class DAPDebugProcess extends XDebugProcess implements Disposable { private final @NotNull DAPCommandLineState dapState; private final @NotNull ExecutionResult executionResult; private final @NotNull XDebuggerEditorsProvider editorsProvider; - private final @NotNull DAPBreakpointHandler breakpointHandler; + private final @NotNull DAPBreakpointHandlerBase breakpointHandler; private final @NotNull DebugAdapterDescriptor serverDescriptor; private final @NotNull DAPServerReadyTracker serverReadyFuture; - private @Nullable CompletableFuture connectToServerFuture; + private final ThreadsPanel threadsPanel; - private Status status; + // Disassembly + private final @NotNull DisassemblyBreakpointHandlerBase disassemblyBreakpointHandler; + private final @NotNull DAPAlternativeSourceHandler alternativeSourceHandler; + private final long sessionId; + + // Smart Step Into + private DAPSmartStepIntoHandler smartStepIntoHandler; + private @Nullable CompletableFuture connectToServerFuture; + private Status status; private Supplier streamsSupplier; private DAPClient parentClient; - - private final ThreadsPanel threadsPanel; + // Captured when the debug tab UI is built, so a descriptor can add tabs (e.g. an interactive + // terminal for a runInTerminal request) to the running session. + private @Nullable RunnerLayoutUi runnerLayoutUi; public DAPDebugProcess(@NotNull DAPCommandLineState dapState, @NotNull XDebugSession session, @NotNull ExecutionResult executionResult, boolean isDebug) { super(session); + this.sessionId = System.currentTimeMillis(); this.dapState = dapState; var project = getSession().getProject(); this.executionResult = executionResult; - this.editorsProvider = new DAPDebuggerEditorsProvider(dapState.getFileType(), this); this.serverDescriptor = dapState.getServerDescriptor(); - this.breakpointHandler = new DAPBreakpointHandler(session, serverDescriptor, project); + this.editorsProvider = serverDescriptor.createDebuggerEditorsProvider(dapState.getFileType(), this); + this.breakpointHandler = serverDescriptor.createBreakpointHandler(session, project); + this.disassemblyBreakpointHandler = serverDescriptor.createDisassemblyBreakpointHandler(session, project); + this.alternativeSourceHandler = new DAPAlternativeSourceHandler(this); this.threadsPanel = new ThreadsPanel(this); this.status = Status.NONE; @@ -110,11 +123,10 @@ public DAPDebugProcess(@NotNull DAPCommandLineState dapState, String address = serverReadyFuture.getAddress(); String taskTitle = getStartingServerTaskTitle(dapState.getFile(), dapState.getServerName(), address, port, dapState.getDebugMode()); - ProgressManager.getInstance().run(new Task.Backgroundable(project,taskTitle, true) { + ProgressManager.getInstance().run(new Task.Backgroundable(project, taskTitle, true) { @Override public void run(@NotNull ProgressIndicator indicator) { - DAPDebugProcess.this.status = Status.STARTING; print(taskTitle, ConsoleViewContentType.SYSTEM_OUTPUT); @@ -160,8 +172,8 @@ public void run(@NotNull ProgressIndicator indicator) { .getThreads(true); } } catch (ProcessCanceledException e) { - // Ignore error stop(); + throw e; } catch (CancellationException e) { // Ignore error stop(); @@ -210,7 +222,7 @@ public void run(@NotNull ProgressIndicator indicator) { // Add port info if (address != null || port != null) { title.append(" at "); - if(address != null) { + if (address != null) { title.append(address); if (port != null) { title.append(":"); @@ -224,6 +236,18 @@ public void run(@NotNull ProgressIndicator indicator) { return title.toString(); } + private static TransportStreams getTransportStreams(@NotNull ExecutionResult executionResult, + @Nullable String address, + @Nullable Integer port) throws IOException { + if (port != null) { + return new TransportStreams.SocketTransportStreams(address != null ? address : InetAddress.getLoopbackAddress().getHostAddress(), port); + } + var processHandler = executionResult.getProcessHandler(); + DAPProcessListener processListener = new DAPProcessListener(); + processHandler.addProcessListener(processListener); + return new TransportStreams.DefaultTransportStreams(processListener.getInputStream(), processHandler.getProcessInput()); + } + @Override public @NotNull XDebuggerEditorsProvider getEditorsProvider() { return editorsProvider; @@ -244,17 +268,12 @@ protected ProcessHandler doGetProcessHandler() { @Override public void stop() { - if (status !=Status.STOPPED && status != Status.STOPPING) { + if (status != Status.STOPPED && status != Status.STOPPING) { ApplicationManager.getApplication() .executeOnPooledThread(() -> { try { status = Status.STOPPING; - CancellationSupport.cancel(serverReadyFuture); - CancellationSupport.cancel(connectToServerFuture); - if (parentClient != null) { - parentClient.terminate(); - } - breakpointHandler.dispose(); + dispose(); print("Disconnected successfully from the debug server.", ConsoleViewContentType.SYSTEM_OUTPUT); } catch (Exception e) { @@ -277,7 +296,8 @@ public void startStepOver(@Nullable XSuspendContext context) { Integer threadId = dapContext.getThreadId(); if (threadId != null) { DAPClient client = dapContext.getClient(); - client.next(threadId); + var granularity = getSteppingGranularity(client); + client.next(threadId, granularity); } } } @@ -288,7 +308,8 @@ public void startStepOut(@Nullable XSuspendContext context) { Integer threadId = dapContext.getThreadId(); if (threadId != null) { DAPClient client = dapContext.getClient(); - client.stepOut(threadId); + var granularity = getSteppingGranularity(client); + client.stepOut(threadId, granularity); } } } @@ -299,11 +320,25 @@ public void startStepInto(@Nullable XSuspendContext context) { Integer threadId = dapContext.getThreadId(); if (threadId != null) { DAPClient client = dapContext.getClient(); - client.stepIn(threadId); + var granularity = getSteppingGranularity(client); + client.stepIn(threadId, granularity); } } } + @Override + public @Nullable XSmartStepIntoHandler getSmartStepIntoHandler() { + if (smartStepIntoHandler == null) { + smartStepIntoHandler = new DAPSmartStepIntoHandler(getSession()); + } + return smartStepIntoHandler; + } + + private @Nullable SteppingGranularity getSteppingGranularity(@NotNull DAPClient client) { + return client.canDisassemble() && getAlternativeSourceHandler() + .getAlternativeSourceKindState().getValue() ? SteppingGranularity.INSTRUCTION : null; + } + @Override public void resume(@Nullable XSuspendContext context) { resume(context, null); @@ -328,18 +363,6 @@ private void resume(@Nullable XSuspendContext context, } } - private static TransportStreams getTransportStreams(@NotNull ExecutionResult executionResult, - @Nullable String address, - @Nullable Integer port) throws IOException { - if (port != null) { - return new TransportStreams.SocketTransportStreams(address != null ? address : InetAddress.getLoopbackAddress().getHostAddress(), port); - } - var processHandler = executionResult.getProcessHandler(); - DAPProcessListener processListener = new DAPProcessListener(); - processHandler.addProcessListener(processListener); - return new TransportStreams.DefaultTransportStreams(processListener.getInputStream(), processHandler.getProcessInput()); - } - public Supplier getStreamsSupplier() { return streamsSupplier; } @@ -364,13 +387,17 @@ public void print(@Nullable String message, @NotNull ConsoleViewContentType type @Override public XBreakpointHandler @NotNull [] getBreakpointHandlers() { - return new XBreakpointHandler[]{breakpointHandler}; + return new XBreakpointHandler[]{breakpointHandler, disassemblyBreakpointHandler}; } - public @NotNull DAPBreakpointHandler getBreakpointHandler() { + public @NotNull DAPBreakpointHandlerBase getBreakpointHandler() { return breakpointHandler; } + public @NotNull DisassemblyBreakpointHandlerBase getDisassemblyBreakpointHandler() { + return disassemblyBreakpointHandler; + } + @NotNull public DebugAdapterDescriptor getServerDescriptor() { return serverDescriptor; @@ -399,36 +426,6 @@ public void runToPosition(@NotNull XSourcePosition position, public @NotNull XDebugTabLayouter createTabLayouter() { return new XDebugTabLayouter() { - @Override - public void registerAdditionalContent(@NotNull RunnerLayoutUi ui) { - // Register "Exception Breakpoints" panel - registerBreakpointsPanel(ui); - - // Register "Threads" panel - registerThreadsPanel(ui); - - // Register the "Installer" tab - String installerConfiguration = DAPDebugProcess.this.dapState.getInstallerConfiguration(); - var project = DAPDebugProcess.this.getSession().getProject(); - resisterInstallerTab(ui, installerConfiguration, project); - } - - private void registerBreakpointsPanel(@NotNull RunnerLayoutUi ui) { - var breakpointsPanel = breakpointHandler.getExceptionBreakpointsPanel(); - final Content breakpointsContent = ui.createContent( - DAPExceptionBreakpointsPanel.ID, breakpointsPanel, DAPBundle.message("dap.panels.exception.breakpoints"), null, breakpointsPanel.getDefaultFocusedComponent()); - breakpointsContent.setCloseable(false); - ui.addContent(breakpointsContent, 0, PlaceInGrid.left, false); - } - - private void registerThreadsPanel(@NotNull RunnerLayoutUi ui) { - final Content threadsContent = ui.createContent( - "dap-threads-panel", threadsPanel, DAPBundle.message("dap.panels.threads"), null, threadsPanel.getDefaultFocusedComponent()); - threadsContent.setCloseable(false); - ui.addContent(threadsContent, 0, PlaceInGrid.left, false); - } - - private static void resisterInstallerTab(@NotNull RunnerLayoutUi ui, @Nullable String installerConfiguration, @NotNull Project project) { @@ -452,12 +449,47 @@ private static JComponent createInstallerPanel(@Nullable String installerConfigu } return builder.getPanel(); } + + @Override + public void registerAdditionalContent(@NotNull RunnerLayoutUi ui) { + // Expose the session UI so a descriptor can add tabs later (e.g. an interactive + // terminal fulfilling a runInTerminal request). + DAPDebugProcess.this.runnerLayoutUi = ui; + + // Register "Exception Breakpoints" panel + registerBreakpointsPanel(ui); + + // Register "Threads" panel + registerThreadsPanel(ui); + + if (serverDescriptor.isShowInstallerTab()) { + // Register the "Installer" tab + String installerConfiguration = DAPDebugProcess.this.dapState.getInstallerConfiguration(); + var project = DAPDebugProcess.this.getProject(); + resisterInstallerTab(ui, installerConfiguration, project); + } + } + + private void registerBreakpointsPanel(@NotNull RunnerLayoutUi ui) { + var breakpointsPanel = breakpointHandler.getExceptionBreakpointsPanel(); + final Content breakpointsContent = ui.createContent( + DAPExceptionBreakpointsPanel.ID, breakpointsPanel, DAPBundle.message("dap.panels.exception.breakpoints"), null, breakpointsPanel.getDefaultFocusedComponent()); + breakpointsContent.setCloseable(false); + ui.addContent(breakpointsContent, 0, PlaceInGrid.left, false); + } + + private void registerThreadsPanel(@NotNull RunnerLayoutUi ui) { + final Content threadsContent = ui.createContent( + "dap-threads-panel", threadsPanel, DAPBundle.message("dap.panels.threads"), null, threadsPanel.getDefaultFocusedComponent()); + threadsContent.setCloseable(false); + ui.addContent(threadsContent, 0, PlaceInGrid.left, false); + } }; } public CompletableFuture<@Nullable Thread[]> getThreads() { if (parentClient != null) { - return parentClient + return parentClient .getThreads(false); } return CompletableFuture.completedFuture(null); @@ -470,4 +502,63 @@ public void refreshThreads(Thread[] threads) { public void refreshThread(ThreadEventArguments args) { threadsPanel.refreshThread(args); } + + @Override + public @Nullable DAPAlternativeSourceHandler getAlternativeSourceHandler() { + return alternativeSourceHandler; + } + + public @Nullable DisassemblyFile getDisassemblyFile() { + if (parentClient == null || !parentClient.canDisassemble()) { + return null; + } + String configName = getConfigName(); + return (DisassemblyFile) DAPFileRegistry.getInstance() + .getOrCreateDAPFile(configName, DisassemblyFile.FILE_NAME, getProject()); + } + + public @NotNull String getConfigName() { + return dapState.getEnvironment().getRunProfile().getName(); + } + + /** + * The debug tab's layout UI, available once the session content has been built. Lets a + * descriptor add tabs to the running session (e.g. an interactive terminal for a + * {@code runInTerminal} request). Null before the UI is built. + */ + public @Nullable RunnerLayoutUi getRunnerLayoutUi() { + return runnerLayoutUi; + } + + public @NotNull Project getProject() { + return getSession().getProject(); + } + + @Override + public void dispose() { + CancellationSupport.cancel(serverReadyFuture); + CancellationSupport.cancel(connectToServerFuture); + if (parentClient != null) { + parentClient.terminate(); + } + breakpointHandler.dispose(); + disassemblyBreakpointHandler.dispose(); + getAlternativeSourceHandler().dispose(); + var disassemblyFile = getDisassemblyFile(); + if (disassemblyFile != null) { + disassemblyFile.dispose(); + } + } + + public long getSessionId() { + return sessionId; + } + + private enum Status { + NONE, + STARTING, + STARTED, + STOPPING, + STOPPED + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugRunner.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugRunner.java index cf91339ba..e42a7dc94 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugRunner.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebugRunner.java @@ -24,12 +24,9 @@ import com.intellij.execution.ui.RunContentManager; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.fileEditor.FileDocumentManager; -import com.intellij.xdebugger.XDebugProcess; -import com.intellij.xdebugger.XDebugProcessStarter; -import com.intellij.xdebugger.XDebugSession; -import com.intellij.xdebugger.XDebuggerManager; +import com.intellij.xdebugger.*; import com.redhat.devtools.lsp4ij.dap.configurations.DAPCommandLineState; -import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfiguration; +import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationBase; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; import com.redhat.devtools.lsp4ij.installation.ConsoleProvider; import com.redhat.devtools.lsp4ij.installation.ServerInstallationStatus; @@ -52,6 +49,15 @@ public class DAPDebugRunner extends AsyncProgramRunner { // Unique ID for this runner private static final String RUNNER_ID = "DAPDebugRunner"; + // Cache for API detection (null = not yet checked, true = new API available, false = old API only) + private static volatile Boolean useNewDebuggerApi = null; + + // Cached reflection methods for new debugger API (2026.1+) - initialized once for performance + private static java.lang.reflect.Method newSessionBuilderMethod = null; + private static java.lang.reflect.Method environmentMethod = null; + private static java.lang.reflect.Method startSessionMethod = null; + private static java.lang.reflect.Method getRunContentDescriptorMethod = null; + @Override public @NotNull String getRunnerId() { return RUNNER_ID; @@ -62,7 +68,7 @@ public class DAPDebugRunner extends AsyncProgramRunner { */ @Override public boolean canRun(@NotNull String executorId, @NotNull RunProfile profile) { - return profile instanceof DAPRunConfiguration dap && dap.canRun(executorId); + return profile instanceof DAPRunConfigurationBase dap && dap.canRun(executorId); } /** @@ -141,19 +147,78 @@ public boolean canRun(@NotNull String executorId, @NotNull RunProfile profile) { return promise; } + /** + * Detects which debugger API is available and caches the result along with reflection methods. + * + * @return true if new API (2026.1+) is available, false otherwise + */ + private static boolean isNewDebuggerApiAvailable() { + if (useNewDebuggerApi == null) { + synchronized (DAPDebugRunner.class) { + if (useNewDebuggerApi == null) { + try { + // Check if newSessionBuilder method exists (2026.1+) and cache all required methods + newSessionBuilderMethod = XDebuggerManager.class.getMethod("newSessionBuilder", XDebugProcessStarter.class); + + // Cache the builder methods by inspecting the return type + Class builderClass = newSessionBuilderMethod.getReturnType(); + environmentMethod = builderClass.getMethod("environment", ExecutionEnvironment.class); + startSessionMethod = builderClass.getMethod("startSession"); + + // Cache the result method + Class resultClass = startSessionMethod.getReturnType(); + getRunContentDescriptorMethod = resultClass.getMethod("getRunContentDescriptor"); + + useNewDebuggerApi = true; + } catch (NoSuchMethodException e) { + useNewDebuggerApi = false; + } + } + } + } + return useNewDebuggerApi; + } + /** * Launches the actual debug session using the IntelliJ XDebugger infrastructure. + * + * Uses cached reflection to support both old and new debugger APIs: + * - 2026.1+: newSessionBuilder() + XSessionStartedResult (no warnings) + * - 2024.2-2025.3: startSession() + getRunContentDescriptor() (legacy API) + * + * @see Debugger Architecture Redesign */ private @NotNull RunContentDescriptor doExecute(@NotNull ExecutionEnvironment env, DAPCommandLineState dapState) throws ExecutionException { - return XDebuggerManager.getInstance(env.getProject()) - .startSession(env, new XDebugProcessStarter() { - @Override - public @NotNull XDebugProcess start(@NotNull XDebugSession session) throws ExecutionException { - ExecutionResult result = dapState.execute(env.getExecutor(), DAPDebugRunner.this); - boolean debugMode = DEBUG_EXECUTOR_ID.equals(env.getExecutor().getId()); - return new DAPDebugProcess(dapState, session, result, debugMode); - } - }).getRunContentDescriptor(); + XDebuggerManager manager = XDebuggerManager.getInstance(env.getProject()); + XDebugProcessStarter starter = new XDebugProcessStarter() { + @Override + public @NotNull XDebugProcess start(@NotNull XDebugSession session) throws ExecutionException { + ExecutionResult result = dapState.execute(env.getExecutor(), DAPDebugRunner.this); + boolean debugMode = DEBUG_EXECUTOR_ID.equals(env.getExecutor().getId()); + return new DAPDebugProcess(dapState, session, result, debugMode); + } + }; + + if (isNewDebuggerApiAvailable()) { + // Use new API (2026.1+) via cached reflection methods + try { + // newSessionBuilder(starter) + var builder = newSessionBuilderMethod.invoke(manager, starter); + // .environment(env) + builder = environmentMethod.invoke(builder, env); + // .startSession() + var sessionResult = startSessionMethod.invoke(builder); + // .getRunContentDescriptor() + return (RunContentDescriptor) getRunContentDescriptorMethod.invoke(sessionResult); + } catch (Exception e) { + throw new ExecutionException("Failed to start debug session using new API", e); + } + } else { + // Use old API for versions < 2026.1 + @SuppressWarnings("deprecation") + XDebugSession session = manager.startSession(env, starter); + return session.getRunContentDescriptor(); + } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProvider.java index 8ad995713..3b2ce3a0b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProvider.java @@ -14,6 +14,7 @@ import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.impl.DocumentImpl; import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.fileTypes.LanguageFileType; import com.intellij.openapi.fileTypes.PlainTextFileType; import com.intellij.openapi.project.Project; import com.intellij.psi.PsiElement; @@ -66,7 +67,25 @@ protected PsiFile createExpressionCodeFragment(@NotNull Project project, // Get file type / language of the file which is debugging when debugger is suspended. fileType = file.getFileType(); language = file.getLanguage(); + // If the file's language is a more specific dialect of the file type's language, degrade to the file type's + if (fileType instanceof LanguageFileType languageFileType) { + Language fileTypeLanguage = languageFileType.getLanguage(); + if (!language.is(fileTypeLanguage) && language.isKindOf(fileTypeLanguage)) { + language = fileTypeLanguage; + } + } } - return new DAPExpressionCodeFragment(text, fileType, language, debugProcess, project); + return createExpressionCodeFragment(project, text, fileType, language); + } + + protected @NotNull PsiFile createExpressionCodeFragment(@NotNull Project project, + @NotNull String text, + @NotNull FileType fileType, + @NotNull Language language) { + return new DAPExpressionCodeFragment(text, fileType, language, getDebugProcess(), project); + } + + protected @Nullable DAPDebugProcess getDebugProcess() { + return debugProcess; } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPIJUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPIJUtils.java index 6623b8e88..8eeac0e5d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPIJUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPIJUtils.java @@ -29,7 +29,13 @@ private DAPIJUtils() { @NotNull public static String getFilePath(@NotNull VirtualFile file) { - return file.toNioPath().toString(); + // Files inside JARs or virtual file systems do not support toNioPath() + if (file.isInLocalFileSystem()) { + // File is on disk: return the native OS path (e.g. C:\foo\bar on Windows) + return file.toNioPath().toString(); + } + // Fallback for JARs and virtual files: return the IntelliJ path (e.g. /path/to/lib.jar!/com/foo/Bar.class) + return file.getPath(); } @NotNull diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPProcessListener.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPProcessListener.java index ecec169d9..27b5c025b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPProcessListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DAPProcessListener.java @@ -16,6 +16,8 @@ import com.intellij.execution.process.ProcessOutputType; import com.intellij.openapi.util.Key; import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.*; import java.nio.charset.StandardCharsets; @@ -25,6 +27,8 @@ */ class DAPProcessListener implements ProcessListener { + private static final Logger LOGGER = LoggerFactory.getLogger(DAPProcessListener.class); + private final PipedOutputStream outputStream; private final OutputStreamWriter outputStreamWriter; private final PipedInputStream inputStream; @@ -49,11 +53,14 @@ public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType if (ProcessOutputType.isStdout(outputType)) { // Flush the output try { - this.outputStreamWriter.write(event.getText()); - this.outputStreamWriter.flush(); + synchronized (this.outputStreamWriter) { + this.outputStreamWriter.write(event.getText()); + this.outputStreamWriter.flush(); + } } catch (IOException e) { // Some IO error exception, stop the process ExecutionManagerImpl.stopProcess(event.getProcessHandler()); + LOGGER.warn("DAP process listener crashes.", e); } } } @@ -61,8 +68,10 @@ public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType @Override public void processTerminated(@NotNull ProcessEvent event) { try { - outputStreamWriter.close(); - outputStream.close(); + synchronized (this.outputStreamWriter) { + this.outputStreamWriter.close(); + this.outputStream.close(); + } } catch (IOException e) { // Do nothing } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/DebugAdapterManager.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/DebugAdapterManager.java index 4359dea7f..96275ea76 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/DebugAdapterManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/DebugAdapterManager.java @@ -18,6 +18,8 @@ import com.intellij.openapi.fileTypes.FileTypeManager; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.breakpoints.XBreakpointType; +import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointType; import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfiguration; import com.redhat.devtools.lsp4ij.dap.configurations.DebuggableFile; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; @@ -285,13 +287,41 @@ public void handleChangeEvent(DebugAdapterServerListener.ChangedEvent event) { @Override public boolean isDebuggableFile(@NotNull VirtualFile file, @NotNull Project project) { - // Search canDebug inside the factories - if (findDebugAdapterServerFor(file, project) != null) { + return isDebuggableFile(file, null, project); + } + + public boolean isDebuggableFile(@NotNull VirtualFile file, + @Nullable XBreakpointType breakpointType, + @NotNull Project project) { + // Search canDebug inside the server descriptors + if (findDebugAdapterServerFor(file, breakpointType, project) != null) { return true; } // Search canDebug in existing DAP run configuration - return findExistingConfigurationFor(file, project, false) != null; + // only with DAP breakpoint type + return breakpointType instanceof DAPBreakpointType && findExistingConfigurationFor(file, project, false) != null; + } + + /** + * Returns the DAP descriptor factory for the given file and null otherwise. + * + * @param file the file. + * @param breakpointType the breakpoint toe and null otherwise. + * @param project the project. + * @return the DAP descriptor factory for the given file and null otherwise. + */ + @Nullable + private DebugAdapterServerDefinition findDebugAdapterServerFor(@NotNull VirtualFile file, + @Nullable XBreakpointType breakpointType, + @NotNull Project project) { + for (var serverDefinition : serverDefinitions.values()) { + var factory = serverDefinition.getFactory(); + if (factory.isDebuggableFile(file, project) && (breakpointType == null || factory.supportsBreakpointType(breakpointType))) { + return serverDefinition; + } + } + return null; } /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/BreakpointHandlerBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/BreakpointHandlerBase.java new file mode 100644 index 000000000..5a91d5a16 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/BreakpointHandlerBase.java @@ -0,0 +1,198 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.breakpoints; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.util.containers.ContainerUtil; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.breakpoints.XBreakpoint; +import com.intellij.xdebugger.breakpoints.XBreakpointHandler; +import com.intellij.xdebugger.breakpoints.XBreakpointType; +import com.intellij.xdebugger.breakpoints.XLineBreakpoint; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import org.eclipse.lsp4j.debug.Breakpoint; +import org.eclipse.lsp4j.debug.Capabilities; +import org.eclipse.lsp4j.debug.services.IDebugProtocolServer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Base class for DAP/Disassembly breakpoint handler. + * + * @param the breakpoint + */ +public abstract class BreakpointHandlerBase> extends XBreakpointHandler implements Disposable { + + private static final Logger LOGGER = LoggerFactory.getLogger(BreakpointHandlerBase.class); + + protected static final CompletableFuture[] EMPTY_COMPLETABLE_FUTURES = new CompletableFuture[0]; + protected static final @NotNull Key DAP_BREAKPOINT_ID = Key.create("dap.breakpoint.id"); + + protected final @NotNull XDebugSession debugSession; + protected final @NotNull DebugAdapterDescriptor debugAdapterDescriptor; + protected final @NotNull Project project; + protected final List debugProtocolServers = ContainerUtil.createConcurrentList(); + protected final List breakpoints = ContainerUtil.createConcurrentList(); + + public BreakpointHandlerBase(@NotNull Class> breakpointTypeClass, + @NotNull XDebugSession debugSession, + @NotNull DebugAdapterDescriptor debugAdapterDescriptor, + @NotNull Project project) { + super(breakpointTypeClass); + this.debugSession = debugSession; + this.debugAdapterDescriptor = debugAdapterDescriptor; + this.project = project; + } + + public CompletableFuture<@Nullable Void> initialize(@NotNull IDebugProtocolServer debugProtocolServer, + @NotNull Capabilities capabilities) { + this.debugProtocolServers.add(debugProtocolServer); + return sendBreakpoints(debugProtocolServer, null); + } + + @Override + public void registerBreakpoint(@NotNull B breakpoint) { + if (!breakpoint.isEnabled()) { + return; + } + + if (!supportsBreakpoint(breakpoint)) { + // Log why the breakpoint is not supported to help diagnose issues like #1433 + var sourcePosition = breakpoint.getSourcePosition(); + LOGGER.warn("Breakpoint not supported - enabled={}, sourcePosition={}, type={}", + breakpoint.isEnabled(), + sourcePosition, + breakpoint.getType().getClass().getSimpleName()); + + // Mark the breakpoint as invalid so the user sees visual feedback + if (breakpoint instanceof XLineBreakpoint lineBreakpoint) { + String message = sourcePosition == null + ? "Breakpoint location not available" + : "File not debuggable with current debug adapter"; + debugSession.setBreakpointInvalid(lineBreakpoint, message); + } + return; + } + + breakpoints.add(breakpoint); + sendBreakpoints(null, null); + } + + @Override + public void unregisterBreakpoint(@NotNull B breakpoint, + boolean temporary) { + var sourcePosition = breakpoint.getSourcePosition(); + if (!supportsBreakpoint(breakpoint) || sourcePosition == null) { + return; + } + breakpoints.remove(breakpoint); + sendBreakpoints(null, + breakpoints.isEmpty() ? new TemporaryBreakpoint(sourcePosition, false) : null); + } + + @Override + public void dispose() { + debugProtocolServers.clear(); + } + + protected CompletableFuture<@Nullable Void> sendBreakpoints(@Nullable IDebugProtocolServer debugProtocolServer, + @Nullable TemporaryBreakpoint temporaryBreakpoint) { + if (breakpoints.isEmpty() && temporaryBreakpoint == null) { + // No breakpoints to send + return CompletableFuture.completedFuture(null); + } + if (debugProtocolServers.isEmpty()) { + // DAP server not yet initialized - breakpoints are stored and will be sent + // when initialize() is called (which calls sendBreakpoints with the server) + return CompletableFuture.completedFuture(null); + } + return doSendBreakpoints(debugProtocolServer, temporaryBreakpoint); + } + + /** + * Update IntelliJ breakpoint with the given DAP breakpoint information. + * + * @param breakpoint the DAP breakpoint information. + * @param ijBreakpoint the IJ Breakpoint and null otherwise. + */ + public void updateBreakpoint(@NotNull Breakpoint breakpoint, + @Nullable B ijBreakpoint) { + if (ijBreakpoint == null) { + // Retrieve the IntelliJ breakpoint by the id. + ijBreakpoint = findBreakpointById(breakpoint.getId()); + } + if (ijBreakpoint instanceof XLineBreakpoint lineBreakpoint) { + // Update the IntelliJ breakpoint according the DAP breakpoint 'verified' flag. + if (breakpoint.isVerified()) { + debugSession.setBreakpointVerified(lineBreakpoint); + } else { + debugSession.setBreakpointInvalid(lineBreakpoint, breakpoint.getMessage()); + } + } + } + + + /** + * Returns the IntelliJ breakpoint from the DAP breakpoint id and null otherwise. + * + * @param id the DAP breakpoint id. + * @return the IntelliJ breakpoint from the DAP breakpoint id and null otherwise. + */ + @Nullable + private B findBreakpointById(@Nullable Integer id) { + if (id == null) { + return null; + } + for (var breakpoint : breakpoints) { + Integer breakpointId = breakpoint.getUserData(DAP_BREAKPOINT_ID); + if (id.equals(breakpointId)) { + return breakpoint; + } + } + return null; + } + + /** + * Register the given source position as temporary breakpoint. + * + * @param sourcePosition the temporary breakpoint. + * @return the completable future. + */ + public CompletableFuture<@Nullable Void> registerTemporaryBreakpoint(@NotNull XSourcePosition sourcePosition) { + return sendBreakpoints(null, new TemporaryBreakpoint(sourcePosition, true)); + } + + /** + * Un-register the given source position as temporary breakpoint. + * + * @param sourcePosition the temporary breakpoint. + * @return the completable future. + */ + public CompletableFuture<@Nullable Void> unregisterTemporaryBreakpoint(@NotNull XSourcePosition sourcePosition) { + return sendBreakpoints(null, new TemporaryBreakpoint(sourcePosition, false)); + } + + protected abstract boolean supportsBreakpoint(B breakpoint); + + protected abstract @NotNull CompletableFuture<@Nullable Void> doSendBreakpoints(@Nullable IDebugProtocolServer debugProtocolServer, + @Nullable TemporaryBreakpoint temporaryBreakpoint); + + protected record TemporaryBreakpoint(@NotNull XSourcePosition sourcePosition, boolean add) { + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandler.java index 5b61d1627..11bcd797e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandler.java @@ -10,414 +10,18 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.breakpoints; -import com.intellij.openapi.Disposable; import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Key; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.util.containers.ContainerUtil; import com.intellij.xdebugger.XDebugSession; -import com.intellij.xdebugger.XExpression; -import com.intellij.xdebugger.XSourcePosition; -import com.intellij.xdebugger.breakpoints.XBreakpoint; -import com.intellij.xdebugger.breakpoints.XBreakpointHandler; import com.intellij.xdebugger.breakpoints.XLineBreakpoint; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; -import org.eclipse.lsp4j.debug.*; -import org.eclipse.lsp4j.debug.services.IDebugProtocolServer; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.*; -import java.util.concurrent.CompletableFuture; - -import static com.redhat.devtools.lsp4ij.dap.DAPIJUtils.*; /** - * Debug Adapter Protocol (DAP) breakpoint handler. + * Debug Adapter Protocol (DAP) breakpoint handler which is working with {@link DAPBreakpointType}. */ -public class DAPBreakpointHandler extends XBreakpointHandler> implements Disposable { - - private static final @NotNull Key DAP_BREAKPOINT_ID = Key.create("dap.breakpoint.id"); - private static final SourceBreakpoint[] EMPTY_SOURCE_BREAKPOINTS = new SourceBreakpoint[0]; - public static final CompletableFuture[] EMPTY_COMPLETABLE_FUTURES = new CompletableFuture[0]; - - private final @NotNull XDebugSession debugSession; - private final @NotNull DebugAdapterDescriptor debugAdapterDescriptor; - private final @NotNull Project project; - private final List debugProtocolServers = ContainerUtil.createConcurrentList(); - private @Nullable Capabilities capabilities; - private final List> breakpoints = ContainerUtil.createConcurrentList(); - private final @NotNull DAPExceptionBreakpointsPanel exceptionBreakpointsPanel; - - private record TemporaryBreakpoint(@NotNull XSourcePosition sourcePosition, boolean add) { - } - - public DAPBreakpointHandler(@NotNull XDebugSession debugSession, - @NotNull DebugAdapterDescriptor debugAdapterDescriptor, - @NotNull Project project) { - super(DAPBreakpointType.class); - this.debugSession = debugSession; - this.debugAdapterDescriptor = debugAdapterDescriptor; - this.project = project; - this.exceptionBreakpointsPanel = new DAPExceptionBreakpointsPanel(this); - } - - @Override - public void registerBreakpoint(@NotNull XLineBreakpoint breakpoint) { - if (!(breakpoint.isEnabled() && - supportsBreakpoint(breakpoint))) { - return; - } - - breakpoints.add(breakpoint); - sendBreakpoints(null, null); - } - - @Override - public void unregisterBreakpoint(@NotNull XLineBreakpoint breakpoint, - boolean temporary) { - var sourcePosition = breakpoint.getSourcePosition(); - if (!supportsBreakpoint(breakpoint) || sourcePosition == null) { - return; - } - breakpoints.remove(breakpoint); - sendBreakpoints(null, - new TemporaryBreakpoint(sourcePosition, false)); - } - - public CompletableFuture<@Nullable Void> initialize(@NotNull IDebugProtocolServer debugProtocolServer, - @Nullable Capabilities capabilities) { - this.debugProtocolServers.add(debugProtocolServer); - this.capabilities = capabilities; - return sendBreakpoints(debugProtocolServer, null); - } - - private CompletableFuture<@Nullable Void> sendBreakpoints(@Nullable IDebugProtocolServer debugProtocolServer, - @Nullable TemporaryBreakpoint temporaryBreakpoint) { - if (debugProtocolServers.isEmpty()) { - return CompletableFuture.completedFuture(null); - } - - // Convert list of IJ XBreakpoint -> LSP SourceBreakpoint - Map> targetBreakpoints = new HashMap<>(); - for (XBreakpoint breakpoint : breakpoints) { - var sourcePosition = breakpoint.getSourcePosition(); - if (sourcePosition != null) { - addSourceBreakpoint(sourcePosition, breakpoint.getConditionExpression(), targetBreakpoints); - } - } - - if (temporaryBreakpoint != null) { - if (temporaryBreakpoint.add()) { - addSourceBreakpoint(temporaryBreakpoint.sourcePosition(), null, targetBreakpoints); - } else { - // Removed IntelliJ breakpoint - // Create a Source with empty source breakpoints if it doesn't exist, - // in order to disable removed breakpoint - // if the source contained only this breakpoint. - Source source = toDAPSource(temporaryBreakpoint.sourcePosition()); - targetBreakpoints - .computeIfAbsent(source, s -> new ArrayList<>()); - } - } - - final var setBreakpointsFutures = new ArrayList>(); - for (Iterator>> iterator = targetBreakpoints.entrySet() - .iterator(); iterator.hasNext(); ) { - Map.Entry> entry = iterator.next(); - - Source source = entry.getKey(); - List sourceBreakpoints = entry.getValue(); - - // Call 'setBreakpoints' DAP request for each Source keys. - final var arguments = getSetBreakpointsArguments(source, sourceBreakpoints); - if (debugProtocolServer != null) { - // Call 'setBreakpoints' DAP request for each Source keys - // and the given debug protocol server - setBreakpointsFutures.add(setBreakpoints(debugProtocolServer, arguments)); - } else { - // Call 'setBreakpoints' DAP request for each Source keys - // for each registered debug protocol server - for (var server : debugProtocolServers) { - setBreakpointsFutures.add(setBreakpoints(server, arguments)); - } - } - - // Once we told adapter there are no breakpoints for a source file, we can stop - // tracking that file - if (sourceBreakpoints.isEmpty()) { - iterator.remove(); - } - } - return CompletableFuture.allOf(setBreakpointsFutures.toArray(EMPTY_COMPLETABLE_FUTURES)); - } - - private static @NotNull SetBreakpointsArguments getSetBreakpointsArguments(@NotNull Source source, - @NotNull List sourceBreakpoints) { - int[] lines = sourceBreakpoints - .stream() - .mapToInt(SourceBreakpoint::getLine) - .toArray(); - - final var arguments = new SetBreakpointsArguments(); - arguments.setSource(source); - arguments.setLines(lines); - arguments.setBreakpoints(sourceBreakpoints.toArray(EMPTY_SOURCE_BREAKPOINTS)); - arguments.setSourceModified(false); - return arguments; - } - - private CompletableFuture setBreakpoints(@NotNull IDebugProtocolServer debugProtocolServer, - @NotNull SetBreakpointsArguments arguments) { - CompletableFuture future = debugProtocolServer.setBreakpoints(arguments); - return future - .thenAccept(response -> { - var breakpoints = response.getBreakpoints(); - if (breakpoints == null) { - return; - } - for (int i = 0; i < breakpoints.length; i++) { - Breakpoint breakpointResponse = breakpoints[i]; - Source source = breakpointResponse.getSource(); - if (source == null) { - source = arguments.getSource(); - } - Integer line = breakpointResponse.getLine(); - if (line == null && arguments.getBreakpoints().length > i) { - var sourceBreakpoint = arguments.getBreakpoints()[i]; - line = sourceBreakpoint.getLine(); - } - if (source != null && line != null) { - var ijBreakpoint = findBreakpoint(source, line); - if (ijBreakpoint != null) { - ijBreakpoint.putUserData(DAP_BREAKPOINT_ID, breakpointResponse.getId()); - updateBreakpoint(breakpointResponse, ijBreakpoint); - } - } - } - }); - } - - private static void addSourceBreakpoint(@NotNull XSourcePosition sourcePosition, - @Nullable XExpression conditionExpression, - @NotNull Map> targetBreakpoints) { - Source source = toDAPSource(sourcePosition); - int lineNumber = getLineNumber(sourcePosition); - SourceBreakpoint sourceBreakpoint = new SourceBreakpoint(); - sourceBreakpoint.setLine(lineNumber); - if (conditionExpression != null) { - // Breakpoint with condition - sourceBreakpoint.setCondition(conditionExpression.getExpression()); - } - List sourceBreakpoints = targetBreakpoints - .computeIfAbsent(source, s -> new ArrayList<>()); - sourceBreakpoints.add(sourceBreakpoint); - } - - private static @NotNull Source toDAPSource(@NotNull XSourcePosition sourcePosition) { - Source source = new Source(); - source.setPath(getFilePath(sourcePosition.getFile())); - source.setName(getFileName(sourcePosition.getFile())); - return source; - } - - /** - * Register the given source position as temporary breakpoint. - * - * @param sourcePosition the temporary breakpoint. - * @return the completable future. - */ - public CompletableFuture<@Nullable Void> registerTemporaryBreakpoint(@NotNull XSourcePosition sourcePosition) { - return sendBreakpoints(null, new TemporaryBreakpoint(sourcePosition, true)); - } - - /** - * Un-register the given source position as temporary breakpoint. - * - * @param sourcePosition the temporary breakpoint. - * @return the completable future. - */ - public CompletableFuture<@Nullable Void> unregisterTemporaryBreakpoint(@NotNull XSourcePosition sourcePosition) { - return sendBreakpoints(null, new TemporaryBreakpoint(sourcePosition, false)); - } - - /** - * Returns whether this target can install the given breakpoint. - * - * @param breakpoint breakpoint to consider - * @return whether this target can install the given breakpoint - */ - public boolean supportsBreakpoint(XBreakpoint breakpoint) { - if (!(breakpoint.getType() instanceof DAPBreakpointType)) { - return false; - } - - XSourcePosition sourcePosition = breakpoint.getSourcePosition(); - if (sourcePosition == null) { - return false; - } - return debugAdapterDescriptor.isDebuggableFile(sourcePosition.getFile(), project); - } - - private static int getLineNumber(@NotNull XSourcePosition sourcePosition) { - return sourcePosition.getLine() + 1; - } - - /** - * Returns the IntelliJ breakpoint from the DAP breakpoint id and null otherwise. - * - * @param id the DAP breakpoint id. - * @return the IntelliJ breakpoint from the DAP breakpoint id and null otherwise. - */ - @Nullable - public XBreakpoint findBreakpointById(@Nullable Integer id) { - if (id == null) { - return null; - } - for (var breakpoint : breakpoints) { - Integer breakpointId = breakpoint.getUserData(DAP_BREAKPOINT_ID); - if (id.equals(breakpointId)) { - return breakpoint; - } - } - return null; - } - - /** - * Returns the IntelliJ breakpoint from the DAP stack frame and null otherwise. - * - * @param stackFrame the DAP stack frame. - * @return the IntelliJ breakpoint from the DAP stack frame and null otherwise. - */ - @Nullable - public XBreakpoint findBreakpoint(@NotNull StackFrame stackFrame) { - return findBreakpoint(stackFrame.getSource(), stackFrame.getLine()); - } - - /** - * Returns the IntelliJ breakpoint from the DAP source and given line and null otherwise. - * - * @param source the DAP source. - * @param lineNumber the line number. - * @return the IntelliJ breakpoint from the DAP source and given line and null otherwise. - */ - @Nullable - private XBreakpoint findBreakpoint(@Nullable Source source, - int lineNumber) { - if (source == null) { - return null; - } - Path filePath = getValidFilePath(source); - if (filePath == null) { - return null; - } - for (XBreakpoint breakpoint : breakpoints) { - XSourcePosition breakpointPosition = breakpoint.getSourcePosition(); - if (breakpointPosition == null) { - continue; - } - VirtualFile fileInBreakpoint = breakpointPosition.getFile(); - int line = breakpointPosition.getLine() + 1; - if (fileInBreakpoint.toNioPath().equals(filePath) && line == lineNumber) { - return breakpoint; - } - } - return null; - } - - /** - * Update IntelliJ breakpoint with the given DAP breakpoint information. - * - * @param breakpoint the DAP breakpoint information. - * @param ijBreakpoint the IJ Breakpoint and null otherwise. - */ - public void updateBreakpoint(@NotNull Breakpoint breakpoint, - @Nullable XBreakpoint ijBreakpoint) { - if (ijBreakpoint == null) { - // Retrieve the IntelliJ breakpoint by the id. - ijBreakpoint = findBreakpointById(breakpoint.getId()); - } - if (ijBreakpoint instanceof XLineBreakpoint lineBreakpoint) { - // Update the IntelliJ breakpoint according the DAP breakpoint 'verified' flag. - if (breakpoint.isVerified()) { - debugSession.setBreakpointVerified(lineBreakpoint); - } else { - debugSession.setBreakpointInvalid(lineBreakpoint, breakpoint.getMessage()); - } - } - } - - /** - * Returns the exception breakpoints panel which host exception breakpoint filter - * get during the initialization of the DAP server. - * - * @return the exception breakpoints panel which host exception breakpoint filter - * * get during the initialization of the DAP server. - */ - public @NotNull DAPExceptionBreakpointsPanel getExceptionBreakpointsPanel() { - return exceptionBreakpointsPanel; - } - - /** - * Returns the DAP server descriptor. - * - * @return the DAP server descriptor. - */ - public @NotNull DebugAdapterDescriptor getDebugAdapterDescriptor() { - return debugAdapterDescriptor; - } - - /** - * Send exception breakpoint filters to the all initialized DAP servers. - */ - public void sendExceptionBreakpointFilters() { - var applicableFilters = getExceptionBreakpointsPanel().getApplicableFilters(); - for (var server : debugProtocolServers) { - sendExceptionBreakpointFilters(applicableFilters, server); - } - } - - /** - * Send exception breakpoint filters to the given server. - * - * @param filters all filters (enabled/disabled) coming from the DAP server. - * @param debugProtocolServer the DAP server. - * @return the future. - */ - public CompletableFuture sendExceptionBreakpointFilters(ExceptionBreakpointsFilter[] filters, - IDebugProtocolServer debugProtocolServer) { - var applicableFilters = getExceptionBreakpointsPanel() - .refresh(filters); - return sendExceptionBreakpointFilters( - applicableFilters, debugProtocolServer); - } - - /** - * Send exception breakpoint filters to the given server. - * - * @param applicableFilters the applicable exception breakpoint filters. - * @param debugProtocolServer the DAP server. - * @return the future. - */ - private CompletableFuture sendExceptionBreakpointFilters(Collection applicableFilters, - IDebugProtocolServer debugProtocolServer) { - SetExceptionBreakpointsArguments args = new SetExceptionBreakpointsArguments(); - args.setFilters( - applicableFilters - .stream() - .map(ExceptionBreakpointsFilter::getFilter) - .toArray(String[]::new)); - return debugProtocolServer - .setExceptionBreakpoints(args) - .thenAccept(r -> { - }); - } - +public class DAPBreakpointHandler extends DAPBreakpointHandlerBase> { - @Override - public void dispose() { - debugProtocolServers.clear(); + public DAPBreakpointHandler(@NotNull XDebugSession debugSession, @NotNull DebugAdapterDescriptor debugAdapterDescriptor, @NotNull Project project) { + super(DAPBreakpointType.class, debugSession, debugAdapterDescriptor, project); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandlerBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandlerBase.java new file mode 100644 index 000000000..52c90bd0a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointHandlerBase.java @@ -0,0 +1,317 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.breakpoints; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XExpression; +import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.breakpoints.XBreakpoint; +import com.intellij.xdebugger.breakpoints.XBreakpointType; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import org.eclipse.lsp4j.debug.*; +import org.eclipse.lsp4j.debug.services.IDebugProtocolServer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +import static com.redhat.devtools.lsp4ij.dap.DAPIJUtils.*; + +/** + * Abstract Debug Adapter Protocol (DAP) breakpoint handler. + */ +public abstract class DAPBreakpointHandlerBase> extends BreakpointHandlerBase { + + private static final SourceBreakpoint[] EMPTY_SOURCE_BREAKPOINTS = new SourceBreakpoint[0]; + private final @NotNull DAPExceptionBreakpointsPanel exceptionBreakpointsPanel; + + public DAPBreakpointHandlerBase(@NotNull Class> breakpointTypeClass, + @NotNull XDebugSession debugSession, + @NotNull DebugAdapterDescriptor debugAdapterDescriptor, + @NotNull Project project) { + super(breakpointTypeClass, debugSession, debugAdapterDescriptor, project); + this.exceptionBreakpointsPanel = new DAPExceptionBreakpointsPanel(this); + } + + /** + * Returns whether this target can install the given breakpoint. + * + * @param breakpoint breakpoint to consider + * @return whether this target can install the given breakpoint + */ + @Override + public boolean supportsBreakpoint(B breakpoint) { + if (!supportsBreakpointType(breakpoint)) { + return false; + } + + XSourcePosition sourcePosition = breakpoint.getSourcePosition(); + if (sourcePosition == null) { + return false; + } + return debugAdapterDescriptor.isDebuggableFile(sourcePosition.getFile(), project); + } + + private static @NotNull SetBreakpointsArguments createSetBreakpointsArguments(@NotNull Source source, + @NotNull List sourceBreakpoints) { + int[] lines = sourceBreakpoints + .stream() + .mapToInt(SourceBreakpoint::getLine) + .toArray(); + + final var arguments = new SetBreakpointsArguments(); + arguments.setSource(source); + arguments.setLines(lines); + arguments.setBreakpoints(sourceBreakpoints.toArray(EMPTY_SOURCE_BREAKPOINTS)); + arguments.setSourceModified(false); + return arguments; + } + + private static void addSourceBreakpoint(@NotNull XSourcePosition sourcePosition, + @Nullable XExpression conditionExpression, + @NotNull Map> targetBreakpoints) { + Source source = toDAPSource(sourcePosition); + int lineNumber = getLineNumber(sourcePosition); + SourceBreakpoint sourceBreakpoint = new SourceBreakpoint(); + sourceBreakpoint.setLine(lineNumber); + if (conditionExpression != null) { + // Breakpoint with condition + sourceBreakpoint.setCondition(conditionExpression.getExpression()); + } + List sourceBreakpoints = targetBreakpoints + .computeIfAbsent(source, s -> new ArrayList<>()); + sourceBreakpoints.add(sourceBreakpoint); + } + + private static @NotNull Source toDAPSource(@NotNull XSourcePosition sourcePosition) { + Source source = new Source(); + source.setPath(getFilePath(sourcePosition.getFile())); + source.setName(getFileName(sourcePosition.getFile())); + return source; + } + + private static int getLineNumber(@NotNull XSourcePosition sourcePosition) { + return sourcePosition.getLine() + 1; + } + + @Override + protected @NotNull CompletableFuture<@Nullable Void> doSendBreakpoints(@Nullable IDebugProtocolServer debugProtocolServer, + @Nullable TemporaryBreakpoint temporaryBreakpoint) { + // Convert list of IJ XBreakpoint -> LSP SourceBreakpoint + Map> targetBreakpoints = new HashMap<>(); + for (B breakpoint : breakpoints) { + var sourcePosition = breakpoint.getSourcePosition(); + if (sourcePosition != null) { + addSourceBreakpoint(sourcePosition, breakpoint.getConditionExpression(), targetBreakpoints); + } + } + + if (temporaryBreakpoint != null) { + if (temporaryBreakpoint.add()) { + addSourceBreakpoint(temporaryBreakpoint.sourcePosition(), null, targetBreakpoints); + } else { + // Removed IntelliJ breakpoint + // Create a Source with empty source breakpoints if it doesn't exist, + // in order to disable removed breakpoint + // if the source contained only this breakpoint. + Source source = toDAPSource(temporaryBreakpoint.sourcePosition()); + targetBreakpoints + .computeIfAbsent(source, s -> new ArrayList<>()); + } + } + + final var setBreakpointsFutures = new ArrayList>(); + for (Iterator>> iterator = targetBreakpoints.entrySet() + .iterator(); iterator.hasNext(); ) { + Map.Entry> entry = iterator.next(); + + Source source = entry.getKey(); + List sourceBreakpoints = entry.getValue(); + + // Call 'setBreakpoints' DAP request for each Source keys. + final var arguments = createSetBreakpointsArguments(source, sourceBreakpoints); + if (debugProtocolServer != null) { + // Call 'setBreakpoints' DAP request for each Source keys + // and the given debug protocol server + setBreakpointsFutures.add(setBreakpoints(debugProtocolServer, arguments)); + } else { + // Call 'setBreakpoints' DAP request for each Source keys + // for each registered debug protocol server + for (var server : debugProtocolServers) { + setBreakpointsFutures.add(setBreakpoints(server, arguments)); + } + } + + // Once we told adapter there are no breakpoints for a source file, we can stop + // tracking that file + if (sourceBreakpoints.isEmpty()) { + iterator.remove(); + } + } + return CompletableFuture.allOf(setBreakpointsFutures.toArray(EMPTY_COMPLETABLE_FUTURES)); + } + + private CompletableFuture setBreakpoints(@NotNull IDebugProtocolServer debugProtocolServer, + @NotNull SetBreakpointsArguments arguments) { + CompletableFuture future = debugProtocolServer.setBreakpoints(arguments); + return future + .thenAccept(response -> { + var breakpoints = response.getBreakpoints(); + if (breakpoints == null) { + return; + } + for (int i = 0; i < breakpoints.length; i++) { + Breakpoint breakpointResponse = breakpoints[i]; + Source source = breakpointResponse.getSource(); + if (source == null) { + source = arguments.getSource(); + } + Integer line = breakpointResponse.getLine(); + if (line == null && arguments.getBreakpoints().length > i) { + var sourceBreakpoint = arguments.getBreakpoints()[i]; + line = sourceBreakpoint.getLine(); + } + if (source != null && line != null) { + var ijBreakpoint = findBreakpoint(source, line); + if (ijBreakpoint != null) { + ijBreakpoint.putUserData(DAP_BREAKPOINT_ID, breakpointResponse.getId()); + updateBreakpoint(breakpointResponse, ijBreakpoint); + } + } + } + }); + } + + private > boolean supportsBreakpointType(B breakpoint) { + return breakpoint.getType() instanceof DAPBreakpointTypeBase; + } + + /** + * Returns the IntelliJ breakpoint from the DAP stack frame and null otherwise. + * + * @param stackFrame the DAP stack frame. + * @return the IntelliJ breakpoint from the DAP stack frame and null otherwise. + */ + @Nullable + public B findBreakpoint(@NotNull StackFrame stackFrame) { + return findBreakpoint(stackFrame.getSource(), stackFrame.getLine()); + } + + /** + * Returns the IntelliJ breakpoint from the DAP source and given line and null otherwise. + * + * @param source the DAP source. + * @param lineNumber the line number. + * @return the IntelliJ breakpoint from the DAP source and given line and null otherwise. + */ + @Nullable + private B findBreakpoint(@Nullable Source source, + int lineNumber) { + if (source == null) { + return null; + } + Path filePath = getValidFilePath(source); + if (filePath == null) { + return null; + } + for (B breakpoint : breakpoints) { + XSourcePosition breakpointPosition = breakpoint.getSourcePosition(); + if (breakpointPosition == null) { + continue; + } + VirtualFile fileInBreakpoint = breakpointPosition.getFile(); + int line = breakpointPosition.getLine() + 1; + if (fileInBreakpoint.isInLocalFileSystem()) { + if (fileInBreakpoint.toNioPath().equals(filePath) && line == lineNumber) { + return breakpoint; + } + } else { + // Virtual files (e.g. DAPFile backed by LightVirtualFile) don't support toNioPath(). + // Fall back to comparing the VirtualFile path as a string. See #1447. + if (fileInBreakpoint.getPath().equals(filePath.toString()) && line == lineNumber) { + return breakpoint; + } + } + } + return null; + } + + /** + * Returns the exception breakpoints panel which host exception breakpoint filter + * get during the initialization of the DAP server. + * + * @return the exception breakpoints panel which host exception breakpoint filter + * * get during the initialization of the DAP server. + */ + public @NotNull DAPExceptionBreakpointsPanel getExceptionBreakpointsPanel() { + return exceptionBreakpointsPanel; + } + + /** + * Returns the DAP server descriptor. + * + * @return the DAP server descriptor. + */ + public @NotNull DebugAdapterDescriptor getDebugAdapterDescriptor() { + return debugAdapterDescriptor; + } + + /** + * Send exception breakpoint filters to the all initialized DAP servers. + */ + public void sendExceptionBreakpointFilters() { + var applicableFilters = getExceptionBreakpointsPanel().getApplicableFilters(); + for (var server : debugProtocolServers) { + sendExceptionBreakpointFilters(applicableFilters, server); + } + } + + /** + * Send exception breakpoint filters to the given server. + * + * @param filters all filters (enabled/disabled) coming from the DAP server. + * @param debugProtocolServer the DAP server. + * @return the future. + */ + public CompletableFuture sendExceptionBreakpointFilters(ExceptionBreakpointsFilter[] filters, + IDebugProtocolServer debugProtocolServer) { + var applicableFilters = getExceptionBreakpointsPanel() + .refresh(filters); + return sendExceptionBreakpointFilters( + applicableFilters, debugProtocolServer); + } + + /** + * Send exception breakpoint filters to the given server. + * + * @param applicableFilters the applicable exception breakpoint filters. + * @param debugProtocolServer the DAP server. + * @return the future. + */ + private CompletableFuture sendExceptionBreakpointFilters(Collection applicableFilters, + IDebugProtocolServer debugProtocolServer) { + SetExceptionBreakpointsArguments args = new SetExceptionBreakpointsArguments(); + args.setFilters( + applicableFilters + .stream() + .map(ExceptionBreakpointsFilter::getFilter) + .toArray(String[]::new)); + return debugProtocolServer + .setExceptionBreakpoints(args) + .thenAccept(r -> { + }); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointType.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointType.java index 9037797db..0c23e12ca 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointType.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointType.java @@ -10,28 +10,20 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.breakpoints; -import com.intellij.openapi.fileTypes.FileType; -import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.xdebugger.breakpoints.XLineBreakpoint; -import com.intellij.xdebugger.breakpoints.XLineBreakpointType; -import com.intellij.xdebugger.breakpoints.ui.XBreakpointCustomPropertiesPanel; -import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider; -import com.redhat.devtools.lsp4ij.LSPIJUtils; -import com.redhat.devtools.lsp4ij.dap.DAPDebuggerEditorsProvider; -import com.redhat.devtools.lsp4ij.dap.DebugAdapterManager; +import com.redhat.devtools.lsp4ij.dap.DAPBundle; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** - * Debug Adapter Protocol (DAP) breakpoint type. + * Default Debug Adapter Protocol (DAP) breakpoint type. */ -public class DAPBreakpointType extends XLineBreakpointType { +public class DAPBreakpointType extends DAPBreakpointTypeBase { private static final String BREAKPOINT_ID = "dap-breakpoint"; public DAPBreakpointType() { - super(BREAKPOINT_ID, "DAP Breakpoint"); + super(BREAKPOINT_ID, DAPBundle.message("dap.breakpoints.title")); } @Override @@ -40,32 +32,4 @@ public DAPBreakpointProperties createBreakpointProperties(@NotNull final Virtual return new DAPBreakpointProperties(); } - @Override - public boolean canPutAt(@NotNull VirtualFile file, - int line, - @NotNull Project project) { - return DebugAdapterManager.getInstance().isDebuggableFile(file, project); - } - - @Override - public @Nullable XDebuggerEditorsProvider getEditorsProvider(@NotNull XLineBreakpoint breakpoint, - @NotNull Project project) { - // Returns a non-null XDebuggerEditorsProvider to support Condition in the breakpoint type. - FileType fileType = null; - var file = LSPIJUtils.findResourceFor(breakpoint.getFileUrl()); - if (file != null) { - if (!canSupportConditionBreakpoint(file, project)) { - return null; - } - fileType = file.getFileType(); - } - return new DAPDebuggerEditorsProvider(fileType, null); - } - - private boolean canSupportConditionBreakpoint(VirtualFile file, @NotNull Project project) { - // TODO: manage a settings to know if the given file which is associated to a DAP server - // can support condition breakpoint point - // At this step, the DAP server cannot be started, so we need to manage a DAP server settings for that. - return true; - } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointTypeBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointTypeBase.java new file mode 100644 index 000000000..4a925c232 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPBreakpointTypeBase.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.breakpoints; + +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.breakpoints.XBreakpointProperties; +import com.intellij.xdebugger.breakpoints.XLineBreakpoint; +import com.intellij.xdebugger.breakpoints.XLineBreakpointType; +import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.dap.DAPDebuggerEditorsProvider; +import com.redhat.devtools.lsp4ij.dap.DebugAdapterManager; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for Debug Adapter Protocol (DAP) breakpoint type. + * + * @param

    the breakpoint point properties. + */ +public abstract class DAPBreakpointTypeBase

    > extends XLineBreakpointType

    { + + protected DAPBreakpointTypeBase(@NonNls @NotNull String id, @Nls @NotNull String title) { + super(id, title); + } + + @Override + public @Nullable XDebuggerEditorsProvider getEditorsProvider(@NotNull XLineBreakpoint

    breakpoint, + @NotNull Project project) { + // Returns a non-null XDebuggerEditorsProvider to support Condition in the breakpoint type. + FileType fileType = null; + var file = LSPIJUtils.findResourceFor(breakpoint.getFileUrl()); + if (file != null) { + if (!supportsConditionBreakpoint(file, project)) { + return null; + } + fileType = file.getFileType(); + } + return new DAPDebuggerEditorsProvider(fileType, null); + } + + /** + * Use the short (file name + line) description for the breakpoint balloon header instead of the + * full path-and-line text. + *

    + * The lightweight breakpoint popup ({@code XLightBreakpointPropertiesPanel}) puts this text in a + * non-shrinking bold {@code JBLabel}. When the file path is long, that label's minimum width + * exceeds the popup width, so the {@code GridLayoutManager} pushes the right-hand controls + * ("Restore" link, "Done" button) outside the balloon body and they get clipped. Returning the + * short text keeps the header's minimum width small so the balloon always lays out within its + * bounds (the full path is still shown in the Breakpoints dialog). + */ + @Override + public @NotNull @Nls String getGeneralDescription(@NotNull XLineBreakpoint

    breakpoint) { + return getShortText(breakpoint); + } + + @Override + public boolean canPutAt(@NotNull VirtualFile file, + int line, + @NotNull Project project) { + return DebugAdapterManager.getInstance().isDebuggableFile(file, this, project); + } + + protected boolean supportsConditionBreakpoint(VirtualFile file, @NotNull Project project) { + // TODO: manage a settings to know if the given file which is associated to a DAP server + // can support condition breakpoint point + // At this step, the DAP server cannot be started, so we need to manage a DAP server settings for that. + return true; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPExceptionBreakpointsPanel.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPExceptionBreakpointsPanel.java index 933f50b98..d203ec213 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPExceptionBreakpointsPanel.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/breakpoints/DAPExceptionBreakpointsPanel.java @@ -41,11 +41,11 @@ public class DAPExceptionBreakpointsPanel extends BorderLayoutPanel implements D static final EnabledColumnInfo ENABLED_COLUMN = new EnabledColumnInfo(); static final NameColumnInfo NAME_COLUMN = new NameColumnInfo(); - private final @NotNull DAPBreakpointHandler breakpointHandler; + private final @NotNull DAPBreakpointHandlerBase breakpointHandler; private final @NotNull ListTableModel myModel; private final @NotNull TableView myTable; - public DAPExceptionBreakpointsPanel(@NotNull DAPBreakpointHandler breakpointHandler) { + public DAPExceptionBreakpointsPanel(@NotNull DAPBreakpointHandlerBase breakpointHandler) { this.breakpointHandler = breakpointHandler; // Create Table view diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPClient.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPClient.java index fee5b903e..a992a166f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPClient.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPClient.java @@ -23,6 +23,7 @@ import com.intellij.openapi.project.Project; import com.intellij.ui.LightweightHint; import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XExpression; import com.intellij.xdebugger.breakpoints.XBreakpoint; import com.intellij.xdebugger.frame.XSuspendContext; import com.redhat.devtools.lsp4ij.LSPIJUtils; @@ -30,8 +31,9 @@ import com.redhat.devtools.lsp4ij.dap.DAPDebugProcess; import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.TransportStreams; -import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointProperties; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.RunInTerminalManager; import com.redhat.devtools.lsp4ij.internal.CancellationSupport; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.settings.ServerTrace; @@ -97,10 +99,8 @@ public class DAPClient implements IDebugProtocolClient, Disposable { private final boolean isDebug; private final @NotNull DebugMode debugMode; private final @NotNull ServerTrace serverTrace; - private boolean isConnected; private Future debugProtocolFuture; private IDebugProtocolServer debugProtocolServer; - private @Nullable Capabilities capabilities; private @NotNull final List childrenClient; private boolean sentTerminateRequest; @@ -123,6 +123,7 @@ public DAPClient(@NotNull DAPDebugProcess debugProcess, @NotNull public CompletableFuture connectToServer(@NotNull ProgressIndicator indicator) { + indicator.setIndeterminate(false); TracingMessageConsumer tracing = serverTrace != ServerTrace.off ? new TracingMessageConsumer() : null; UnaryOperator wrapper = consumer -> { @@ -201,7 +202,7 @@ private CompletableFuture initialize(@NotNull Map dapParam .thenCompose(v -> { // client sends zero or more setBreakpoints requests monitor.checkCanceled(); - monitor.setFraction(60d / 100d); + monitor.setFraction(50d / 100d); monitor.setText2("Sending breakpoints"); return debugProcess .getBreakpointHandler() @@ -212,7 +213,7 @@ private CompletableFuture initialize(@NotNull Map dapParam // if one or more exceptionBreakpointFilters have been defined // (or if supportsConfigurationDoneRequest is not true) monitor.checkCanceled(); - monitor.setFraction(70d / 100d); + monitor.setFraction(60d / 100d); monitor.setText2("Sending exception breakpoints filters"); var filters = getCapabilities().getExceptionBreakpointFilters(); @@ -222,6 +223,19 @@ private CompletableFuture initialize(@NotNull Map dapParam .sendExceptionBreakpointFilters(filters, getDebugProtocolServer()); } return CompletableFuture.completedFuture(null); + }) + .thenCompose(v -> { + monitor.checkCanceled(); + monitor.setFraction(70d / 100d); + // client sends a setInstructionsBreakpoints request + // if supportsDisassembleRequest returns true + if (!canDisassemble()) { + return CompletableFuture.completedFuture(null); + } + monitor.setText2("Sending instruction breakpoints"); + return debugProcess + .getDisassemblyBreakpointHandler() + .initialize(getDebugProtocolServer(), getCapabilities()); }); } configurationDoneFuture = configurationDoneFuture @@ -251,11 +265,11 @@ public IDebugProtocolServer getDebugProtocolServer() { * initialized. * * @return the current capabilities if they have been retrieved, or else - * return @{code null} + * return a default capabilities */ - @Nullable + @NotNull Capabilities getCapabilities() { - return capabilitiesFuture.getNow(null); + return capabilitiesFuture.getNow(new Capabilities()); } protected Launcher createLauncher(UnaryOperator wrapper, @@ -306,6 +320,11 @@ private static ConsoleViewContentType getContentType(String category) { return ConsoleViewContentType.LOG_INFO_OUTPUT; } + @Override + public CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args) { + return RunInTerminalManager.getInstance(getProject()).runInTerminal(args, this); + } + @Override public void breakpoint(BreakpointEventArguments args) { if (BreakpointEventArgumentsReason.CHANGED.equals(args.getReason())) { @@ -338,7 +357,7 @@ public void stopped(StoppedEventArguments args) { // The Thread doesn't exist return; } - var thread = threadResult.get(); + var activeThread = threadResult.get(); // get the stack trace StackTraceArguments stackTraceArgs = new StackTraceArguments(); stackTraceArgs.setThreadId(args.getThreadId()); @@ -348,12 +367,16 @@ public void stopped(StoppedEventArguments args) { StackFrame[] stackFrames = stackTraceResponse.getStackFrames(); if (stackFrames != null && stackFrames.length > 0) { var stackFrame = stackFrames[0]; - XBreakpoint breakpoint = debugProcess.getBreakpointHandler().findBreakpoint(stackFrame); + XBreakpoint breakpoint = debugProcess.getBreakpointHandler().findBreakpoint(stackFrame); XSuspendContext context = getSession().getSuspendContext(); if (context == null) { context = new DAPSuspendContext(this); } - ((DAPSuspendContext) context).addToExecutionStack(thread, stackFrames); + // Create an execution stack per thread and + // initialize the stack with current DAP stack frames for the active thread + for(var thread : threads) { + ((DAPSuspendContext) context).addToExecutionStack(thread, thread.equals(activeThread) ? stackFrames : null); + } XDebugSession session = getSession(); if (breakpoint == null) { session.positionReached(context); @@ -391,13 +414,55 @@ public void stopped(StoppedEventArguments args) { } } } else { - session.breakpointReached(breakpoint, null, context); + XExpression logExpression = breakpoint.getLogExpressionObject(); + String logExpressionText = logExpression != null ? logExpression.getExpression() : null; + if (logExpressionText != null && !logExpressionText.isBlank()) { + // "Evaluate and log": evaluate the expression in the current (top) frame, + // then let the platform print its value (and handle suspend/resume). + final XSuspendContext suspendContext = context; + final XBreakpoint logBreakpoint = breakpoint; + evaluate(logExpressionText, stackFrame.getId(), EvaluateArgumentsContext.WATCH) + .handle((response, error) -> { + String value = response != null ? response.getResult() : null; + doBreakpointReached(session, logBreakpoint, value, suspendContext, threadId); + return null; + }); + } else { + doBreakpointReached(session, breakpoint, null, context, threadId); + } } } }); }); } + /** + * Notifies the platform that the given breakpoint has been reached and, when the breakpoint does + * not suspend (suspend policy {@code NONE}, e.g. a logging breakpoint), resumes the DAP server. + *

    + * {@link XDebugSession#breakpointReached} prints the configured logging output ("Breakpoint hit" + * message, stack trace and the {@code evaluatedLogExpression} value) and returns whether the + * session actually suspended. Returning {@code false} means the suspend policy is {@code NONE}: + * the IDE did not suspend, but the DAP server did stop on the breakpoint, so it must be resumed + * to keep running and hit the breakpoint again (otherwise it would fire only once per session). + * + * @param session the debug session. + * @param breakpoint the reached breakpoint. + * @param evaluatedLogExpression the evaluated "Evaluate and log" value, or {@code null}. + * @param context the suspend context. + * @param threadId the stopped thread id (used to resume). + */ + private void doBreakpointReached(@NotNull XDebugSession session, + @NotNull XBreakpoint breakpoint, + @Nullable String evaluatedLogExpression, + @NotNull XSuspendContext context, + int threadId) { + boolean suspended = session.breakpointReached(breakpoint, evaluatedLogExpression, context); + if (!suspended) { + continue_(threadId); + } + } + static void showErrorHint(@NotNull Editor editor, @NotNull String text, int offset) { if (ApplicationManager.getApplication().isUnitTestMode()) { return; @@ -444,6 +509,7 @@ public void dispose() { if (transportStreams != null) { transportStreams.close(); } + RunInTerminalManager.getInstance(getProject()).releaseClientTerminals(this); threadPool.shutdown(); for (DAPClient child : childrenClient) { child.dispose(); @@ -519,33 +585,50 @@ public CompletableFuture continue_(int threadId) { .continue_(args); } - public void next(int threadId) { + public void next(int threadId, @Nullable SteppingGranularity granularity) { if (debugProtocolServer == null) { return; } NextArguments args = new NextArguments(); args.setThreadId(threadId); + args.setGranularity(granularity); debugProtocolServer.next(args); } - public void stepOut(int threadId) { + public void stepOut(int threadId, SteppingGranularity granularity) { if (debugProtocolServer == null) { return; } StepOutArguments args = new StepOutArguments(); args.setThreadId(threadId); + args.setGranularity(granularity); debugProtocolServer.stepOut(args); } - public void stepIn(int threadId) { + public void stepIn(int threadId, SteppingGranularity granularity) { + stepIn(threadId, null, granularity); + } + + public void stepIn(int threadId, @Nullable Integer targetId, @Nullable SteppingGranularity granularity) { if (debugProtocolServer == null) { return; } StepInArguments args = new StepInArguments(); args.setThreadId(threadId); + args.setTargetId(targetId); + args.setGranularity(granularity); debugProtocolServer.stepIn(args); } + public CompletableFuture stepInTargets(int frameId) { + if (debugProtocolServer == null) { + return CompletableFuture.completedFuture(null); + } + StepInTargetsArguments args = new StepInTargetsArguments(); + args.setFrameId(frameId); + return debugProtocolServer.stepInTargets(args); + } + public CompletableFuture evaluate(@NotNull String expression, @Nullable Integer frameId, @NotNull String context) { @@ -600,13 +683,13 @@ public CompletableFuture getThreads(boolean re if (debugProtocolServer == null) { return CompletableFuture.completedFuture(EMPTY_THREADS); } - var result= debugProtocolServer + var result = debugProtocolServer .threads() .thenApply(response -> { if (response == null) { return EMPTY_THREADS; } - return response.getThreads(); + return response.getThreads(); }); if (refreshThreads) { result.thenAccept(threads -> { @@ -618,15 +701,13 @@ public CompletableFuture getThreads(boolean re // Capabilities - /** * Returns true if the debug adapter supports the 'terminate' request and false otherwise. * * @return true if the debug adapter supports the 'terminate' request and false otherwise. */ public boolean isSupportsTerminateRequest() { - var capabilities = getCapabilities(); - return capabilities != null && Boolean.TRUE.equals(capabilities.getSupportsTerminateRequest()); + return Boolean.TRUE.equals(getCapabilities().getSupportsTerminateRequest()); } /** @@ -635,8 +716,7 @@ public boolean isSupportsTerminateRequest() { * @return true if the debug adapter supports the 'completions' request and false otherwise. */ public boolean isSupportsCompletionsRequest() { - var capabilities = getCapabilities(); - return capabilities != null && Boolean.TRUE.equals(capabilities.getSupportsCompletionsRequest()); + return Boolean.TRUE.equals(getCapabilities().getSupportsCompletionsRequest()); } /** @@ -645,8 +725,29 @@ public boolean isSupportsCompletionsRequest() { * @return true if the debug adapter supports setting a variable to a value and false otherwise. */ public boolean isSupportsSetVariable() { - var capabilities = getCapabilities(); - return capabilities != null && Boolean.TRUE.equals(capabilities.getSupportsSetVariable()); + return Boolean.TRUE.equals(getCapabilities().getSupportsSetVariable()); + } + + /** + * Returns true if the debug adapter supports evaluate request for data hovers and false otherwise. + * + * @return true if the debug adapter supports evaluate request for data hovers and false otherwise. + */ + public boolean isSupportsEvaluateForHovers() { + return Boolean.TRUE.equals(getCapabilities().getSupportsEvaluateForHovers()); + } + + /** + * Returns true if the debug adapter supports the 'stepInTargets' request and false otherwise. + * + * @return true if the debug adapter supports the 'stepInTargets' request and false otherwise. + */ + public boolean isSupportsStepInTargetsRequest() { + return Boolean.TRUE.equals(getCapabilities().getSupportsStepInTargetsRequest()); + } + + public boolean canDisassemble() { + return Boolean.TRUE.equals(getCapabilities().getSupportsDisassembleRequest()); } @NotNull @@ -654,8 +755,26 @@ public DebugAdapterDescriptor getServerDescriptor() { return debugProcess.getServerDescriptor(); } + @NotNull + public DAPDebugProcess getDebugProcess() { + return debugProcess; + } + + @Nullable + public DisassemblyFile getDisassemblyFile() { + return debugProcess.getDisassemblyFile(); + } + @NotNull public Project getProject() { return debugProcess.getSession().getProject(); } + + public @NotNull String getConfigName() { + return debugProcess.getConfigName(); + } + + public long getSessionId() { + return debugProcess.getSessionId(); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPExecutionStack.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPExecutionStack.java index 73782a014..cb6c1aca3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPExecutionStack.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPExecutionStack.java @@ -14,11 +14,18 @@ import com.intellij.util.containers.ContainerUtil; import com.intellij.xdebugger.frame.XExecutionStack; import com.intellij.xdebugger.frame.XStackFrame; +import com.intellij.openapi.util.registry.Registry; import com.redhat.devtools.lsp4ij.internal.StringUtils; +import org.eclipse.lsp4j.debug.Source; +import org.eclipse.lsp4j.debug.SourcePresentationHint; +import org.eclipse.lsp4j.debug.StackFrame; +import org.eclipse.lsp4j.debug.StackTraceArguments; import org.eclipse.lsp4j.debug.Thread; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; @@ -29,18 +36,18 @@ public class DAPExecutionStack extends XExecutionStack { private final @NotNull DAPClient client; private final @NotNull DAPSuspendContext suspendContext; - private final @NotNull List stackFrames; private final int threadId; + private @Nullable List stackFrames; public DAPExecutionStack(@NotNull DAPClient client, - @NotNull DAPSuspendContext suspendContext, + @NotNull DAPSuspendContext suspendContext, @NotNull Thread thread, - @NotNull List stackFrames) { + @Nullable StackFrame[] stackFrames) { super(getThreadName(thread)); this.threadId = thread.getId(); this.client = client; this.suspendContext = suspendContext; - this.stackFrames = stackFrames; + this.stackFrames = stackFrames != null ? toDAPStackFrames(stackFrames) : null; } private static @NlsContexts.ListItem String getThreadName(@NotNull Thread thread) { @@ -55,16 +62,55 @@ public XStackFrame getTopFrame() { } @Override - public void computeStackFrames(int firstFrameIndex, @NotNull XStackFrameContainer container) { + public void computeStackFrames(int firstFrameIndex, + @NotNull XStackFrameContainer container) { if (firstFrameIndex == 0) { - container.addStackFrames(stackFrames, true); + if (stackFrames != null) { + // The DAP stack frames was previously loaded + container.addStackFrames(stackFrames, true); + suspendContext.setActiveExecutionStack(this); + } else { + // The DAP stack frames is not loaded, load it + StackTraceArguments stackTraceArgs = new StackTraceArguments(); + stackTraceArgs.setThreadId(getThreadId()); + client.getDebugProtocolServer() + .stackTrace(stackTraceArgs) + .thenAcceptAsync(stackTraceResponse -> { + StackFrame[] stackFrames = stackTraceResponse.getStackFrames(); + this.stackFrames = stackFrames != null ? toDAPStackFrames(stackFrames) : Collections.emptyList(); + container.addStackFrames(this.stackFrames, true); + }); + } } else { container.addStackFrames(new LinkedList<>(), true); } - suspendContext.setActiveExecutionStack(this); } public int getThreadId() { return threadId; } + + private @NotNull List toDAPStackFrames(@NotNull StackFrame[] stackFrames) { + List frames = Arrays.stream(stackFrames) + .filter(DAPExecutionStack::isVisibleFrame) + .map(stackFrame -> new DAPStackFrame(client, stackFrame)) + .toList(); + if (frames.isEmpty() && stackFrames.length > 0) { + // Everything was filtered out (e.g. paused entirely in skipped code) — show all + // frames rather than an empty stack. + return Arrays.stream(stackFrames) + .map(stackFrame -> new DAPStackFrame(client, stackFrame)) + .toList(); + } + return frames; + } + + /** + * Returns false for frames whose source is de-emphasized (e.g. Node.js internals or files + * matched by {@code skipFiles}), so they are hidden from the Frames view. + */ + private static boolean isVisibleFrame(@NotNull StackFrame frame) { + Source source = frame.getSource(); + return source == null || source.getPresentationHint() != SourcePresentationHint.DEEMPHASIZE; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPStackFrame.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPStackFrame.java index b6c531ab6..15ca46256 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPStackFrame.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPStackFrame.java @@ -12,7 +12,8 @@ import com.intellij.icons.AllIcons; import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.vfs.LocalFileSystem; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.ui.ColoredTextContainer; import com.intellij.ui.SimpleTextAttributes; @@ -23,9 +24,12 @@ import com.intellij.xdebugger.frame.XCompositeNode; import com.intellij.xdebugger.frame.XStackFrame; import com.intellij.xdebugger.frame.XValueChildrenList; -import com.redhat.devtools.lsp4ij.dap.DAPIJUtils; +import com.redhat.devtools.lsp4ij.dap.client.files.DAPFileRegistry; +import com.redhat.devtools.lsp4ij.dap.client.files.DAPSourceReferencePosition; +import com.redhat.devtools.lsp4ij.dap.client.files.DeferredLocalFileSourcePosition; import com.redhat.devtools.lsp4ij.dap.client.variables.DAPValueGroup; import com.redhat.devtools.lsp4ij.dap.client.variables.providers.DebugVariableContext; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyDeferredSourcePosition; import com.redhat.devtools.lsp4ij.dap.evaluation.DAPDebuggerEvaluator; import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.eclipse.lsp4j.debug.*; @@ -45,7 +49,8 @@ public class DAPStackFrame extends XStackFrame { private final @NotNull StackFrame stackFrame; private final @NotNull DAPClient client; - private @Nullable XSourcePosition sourcePosition; + private volatile @Nullable XSourcePosition sourcePosition; + private @Nullable DisassemblyDeferredSourcePosition disassemblyInstructionSourcePosition; private XDebuggerEvaluator evaluator; private CompletableFuture variablesContext; @@ -57,6 +62,13 @@ public DAPStackFrame(@NotNull DAPClient client, @Override public void customizePresentation(@NotNull ColoredTextContainer component) { + // Frames with a DAP presentationHint of "label" are separators, not real frames: they carry no + // source (e.g. vscode-js-debug's async-boundary markers "await" / "Immediate"). Render the name + // as a subtle label instead of appending "". + if (stackFrame.getPresentationHint() == StackFramePresentationHint.LABEL) { + component.append(stackFrame.getName(), SimpleTextAttributes.GRAYED_ITALIC_ATTRIBUTES); + return; + } component.append(stackFrame.getName(), SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES); int line = stackFrame.getLine(); if (line > 0) { @@ -64,8 +76,8 @@ public void customizePresentation(@NotNull ColoredTextContainer component) { } component.append(", ", SimpleTextAttributes.REGULAR_ATTRIBUTES); Source source = stackFrame.getSource(); - String sourceName = source != null ? source.getName() : null; - if (sourceName != null && StringUtils.isNotBlank(sourceName)) { + String sourceName = getSourceNameFromNameOrPath(source); + if (sourceName != null) { component.append(sourceName, SimpleTextAttributes.REGULAR_ATTRIBUTES); } else { component.append(XDebuggerBundle.message("invalid.frame"), SimpleTextAttributes.ERROR_ATTRIBUTES); @@ -75,31 +87,56 @@ public void customizePresentation(@NotNull ColoredTextContainer component) { @Override public @Nullable XSourcePosition getSourcePosition() { - if (sourcePosition == null && stackFrame.getSource() != null) { - Path filePath = getValidFilePath(stackFrame.getSource()); - if (filePath == null) { - return null; - } - try { - VirtualFile file = VfsUtil.findFile(filePath, true); - sourcePosition = XDebuggerUtil.getInstance().createPosition(file, stackFrame.getLine() - 1); - } catch (Exception e) { - // Invalid path... - // ex: /internal/modules/cjs/loader - } + if (sourcePosition != null) { + return sourcePosition; } + + var source = stackFrame.getSource(); + if (source == null) { + return null; + } + + sourcePosition = doGetSourcePosition(source, stackFrame.getLine() - 1); return sourcePosition; } + private @Nullable XSourcePosition doGetSourcePosition(@NotNull Source source, int line) { + int sourceReference = source.getSourceReference() != null ? source.getSourceReference() : 0; + if (sourceReference > 0) { + // If the value > 0 the contents of the source must be retrieved through + // the SourceRequest (even if a path is specified). + var file = DAPFileRegistry.getInstance().getOrCreateDAPFile(client.getConfigName(), getValidSourceName(source), client.getProject()); + if (file.shouldReload(getClient().getSessionId())) { + return new DAPSourceReferencePosition(file, sourceReference, line, client); + } + return XDebuggerUtil.getInstance().createPosition(file, line); + } + + Path filePath = getValidFilePath(source); + if (filePath == null) { + return null; + } + if (ApplicationManager.getApplication().isDispatchThread()) { + // getSourcePosition() is queried from UI code on the EDT (e.g. stack frames list rendering). + // LocalFileSystem.findFileByPath() can hit PersistentFS and triggers SlowOperations on the EDT, + // so we defer VFS resolution and notify the debugger UI once the position is ready. + return new DeferredLocalFileSourcePosition(filePath, line); + } + + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(filePath.toString()); + if (file == null || !file.isValid()) { + return null; + } + return XDebuggerUtil.getInstance().createPosition(file, line); + } + public CompletableFuture getSourcePositionFor(@NotNull Variable variable) { var sourcePosition = getSourcePosition(); if (sourcePosition == null) { return CompletableFuture.completedFuture(null); } return getVariablesContext() - .thenApply(context -> { - return context.getSourcePositionFor(variable); - }); + .thenApply(context -> context.getSourcePositionFor(variable)); } private CompletableFuture getVariablesContext() { @@ -164,6 +201,10 @@ public int getFrameId() { return stackFrame.getId(); } + public @Nullable String getInstructionPointerReference() { + return stackFrame.getInstructionPointerReference(); + } + @Override public @Nullable XDebuggerEvaluator getEvaluator() { if (evaluator == null) { @@ -171,4 +212,52 @@ public int getFrameId() { } return evaluator; } + + public @Nullable XSourcePosition getAlternativePosition() { + String instructionPointerReference = getInstructionPointerReference(); + if (StringUtils.isEmpty(instructionPointerReference)) { + return null; + } + var disassemblyFile = getClient().getDisassemblyFile(); + if (disassemblyFile == null || !FileEditorManager.getInstance(client.getProject()).isFileOpen(disassemblyFile)) { + // Don't load the disassembly instruction source position when: + // - the DAP server doesn't support Disassembly + // - the disassembly view is not opened + return null; + } + if (disassemblyInstructionSourcePosition != null && disassemblyInstructionSourcePosition.getModificationCount() == disassemblyFile.getModificationCount()) { + return disassemblyInstructionSourcePosition; + } + disassemblyInstructionSourcePosition = new DisassemblyDeferredSourcePosition(instructionPointerReference, disassemblyFile, client); + return disassemblyInstructionSourcePosition; + } + + + private static @NotNull String getValidSourceName(@NotNull Source source) { + String sourceName = getSourceNameFromNameOrPath(source); + if (sourceName != null) { + return sourceName; + } + return source.getSourceReference() + ""; + } + + private static @Nullable String getSourceNameFromNameOrPath(@Nullable Source source) { + if (source == null) { + return null; + } + if (StringUtils.isNotBlank(source.getName())) { + return source.getName(); + } + if (StringUtils.isNotBlank(source.getPath())) { + // Get the file source name from the path + String path = source.getPath(); + int slashIndex = path.lastIndexOf('/'); + if (slashIndex == -1) { + slashIndex = path.lastIndexOf('\\'); + } + return slashIndex != -1 ? path.substring(slashIndex + 1) : path; + } + return null; + } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPSuspendContext.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPSuspendContext.java index 3ca2f2b25..30c9cbb34 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPSuspendContext.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/DAPSuspendContext.java @@ -17,32 +17,29 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Arrays; import java.util.LinkedList; import java.util.List; /** * Debug Adapter Protocol (DAP) suspend context. */ -public class DAPSuspendContext extends XSuspendContext { +public class DAPSuspendContext extends XSuspendContext { private final @NotNull DAPClient client; private final List myExecutionStacks = new LinkedList<>(); - private DAPExecutionStack myActiveStack; + private @Nullable DAPExecutionStack myActiveStack; public DAPSuspendContext(@NotNull DAPClient client) { this.client = client; } - public void addToExecutionStack(Thread thread, StackFrame[] stackFrames) { + public void addToExecutionStack(@NotNull Thread thread, @Nullable StackFrame[] stackFrames) { // Convert DAP stack frames to IJ stack frames - List dapStackFrames = Arrays.stream(stackFrames) - .map(stackFrame -> new DAPStackFrame(client, stackFrame)) - .toList(); - DAPExecutionStack stack = new DAPExecutionStack(client, this, thread, - dapStackFrames); + DAPExecutionStack stack = new DAPExecutionStack(client, this, thread, stackFrames); myExecutionStacks.add(stack); - myActiveStack = stack; + if (stackFrames != null) { + setActiveExecutionStack(stack); + } } @Nullable @@ -66,6 +63,6 @@ void setActiveExecutionStack(DAPExecutionStack stack) { } public Integer getThreadId() { - return myActiveStack != null ? myActiveStack.getThreadId() : null; + return myActiveStack != null ? myActiveStack.getThreadId() : null; } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/IntegerPreservingMapDeserializer.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/IntegerPreservingMapDeserializer.java index cbcd3ae19..fe63eb105 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/IntegerPreservingMapDeserializer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/IntegerPreservingMapDeserializer.java @@ -15,11 +15,6 @@ import java.lang.reflect.Type; import java.util.*; -import com.google.gson.*; - -import java.lang.reflect.Type; -import java.util.*; - /** * Custom Gson deserializer for Map that preserves integer values. * diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFile.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFile.java new file mode 100644 index 000000000..d447951d4 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFile.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.NlsSafe; +import com.intellij.openapi.vfs.VirtualFileManager; +import com.intellij.openapi.vfs.VirtualFileSystem; +import com.intellij.testFramework.LightVirtualFile; +import com.intellij.util.LocalTimeCounter; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFileType; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +/** + * Represents a virtual, writable file in IntelliJ for content coming from a DAP client. + *

    + * This class extends {@link LightVirtualFile}, so it exists entirely in memory + * and does not correspond to a physical file on disk. + *

    + * The {@link #path} field has the form: + *

    + *   $projectLocationHash/$configName/$sourceName
    + * 
    + * where: + *
      + *
    • $projectLocationHash = a hash or identifier for the project location
    • + *
    • $configName = the run/debug configuration name
    • + *
    • $sourceName = the name of the file or source reference
    • + *
    + * The {@link #getUrl()} method returns: + *
    + *   dap-file:///$path
    + * 
    + * This URL is used by IntelliJ's {@link com.intellij.openapi.vfs.VirtualFileSystem} API. + */ +public abstract class DAPFile extends LightVirtualFile { + + /** Virtual file path in the form $projectLocationHash/$configName/$sourceName */ + private final @NotNull String path; + + /** URL used by IntelliJ VirtualFileSystem, automatically generated from path */ + private final @NotNull String url; + + /** + * IntelliJ project associated with this disassembly file. + */ + private final @NotNull Project project; + private long sessionId; + + public DAPFile(@NlsSafe String name, + @NotNull String path, + @NotNull FileType fileType, + @NotNull Project project) { + super(name, fileType, "", LocalTimeCounter.currentTime()); + this.project = project; + this.path = path; + this.url = DAPFileSystem.PROTOCOL + ":///" + getPath(); + } + + public boolean shouldReload(long sessionId) { + if (this.sessionId == sessionId) { + return false; + } + this.sessionId = sessionId; + return true; + } + + @Override + public boolean isWritable() { + return true; + } + + @Override + public @NotNull String getUrl() { + return url; + } + + @Override + public @NotNull String getPath() { + return path; + } + + public @NotNull Project getProject() { + return project; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + DAPFile that = (DAPFile) o; + return Objects.equals(url, that.url); + } + + @Override + public int hashCode() { + return Objects.hashCode(url); + } + + @Override + public @NotNull VirtualFileSystem getFileSystem() { + return VirtualFileManager.getInstance().getFileSystem(DAPFileSystem.PROTOCOL); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileRegistry.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileRegistry.java new file mode 100644 index 000000000..ed11cc64a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileRegistry.java @@ -0,0 +1,86 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; + +/** + * Registry of {@link DAPFile} instances for all projects and run/debug configurations. + *

    + * This registry ensures that each disassembly file is unique per project and configuration + * and provides methods to retrieve or create files on demand. + *

    + * The virtual path of a disassembly file follows the format: + *

    + *     $projectLocationHash/$configName
    + * 
    + * where {@code $projectLocationHash} is a unique identifier of the project location, + * and {@code $configName} is the name of the run/debug configuration. + */ +public class DAPFileRegistry { + + /** + * Cache mapping virtual paths to DisassemblyFile instances. + */ + private static final Map dapFilesCache = new HashMap<>(); + + /** + * Returns the singleton instance of this registry for the current application. + * + * @return the DisassemblyFileRegistry instance + */ + public static @NotNull DAPFileRegistry getInstance() { + return ApplicationManager.getApplication().getService(DAPFileRegistry.class); + } + + /** + * Retrieves an existing {@link DAPFile} for the given project and configuration, + * or creates a new one if it does not exist. + * + * @param configName the name of the run/debug configuration + * @param configName the name of the run/debug configuration + * @param project the project owning this disassembly file + * @return the corresponding DisassemblyFile instance + */ + public @NotNull DAPFile getOrCreateDAPFile(@NotNull String configName, + @NotNull String sourceName, + @NotNull Project project) { + // Construct the virtual path used as key in the cache + String path = project.getLocationHash() + "/" + configName + "/" + sourceName; + + // First attempt to get from cache without synchronization (fast path) + var file = dapFilesCache.get(path); + if (file != null) { + return file; + } + + // Synchronize on cache to avoid creating duplicates in multithreaded environment + synchronized (dapFilesCache) { + file = dapFilesCache.get(path); + if (file != null) { + return file; + } + + // Create a new DisassemblyFile and put it into the cache + file = DisassemblyFile.FILE_NAME.equals(sourceName) ? new DisassemblyFile(configName, path, project) : new SourceReferenceFile(sourceName, path, project); + dapFilesCache.put(file.getPath(), file); + return file; + } + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileSystem.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileSystem.java new file mode 100644 index 000000000..e21fc9732 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPFileSystem.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.vfs.ex.dummy.DummyCachingFileSystem; +import org.jetbrains.annotations.NotNull; + +/** + * A virtual file system used to represent disassembly or sourceReference + * files generated by Debug Adapter Protocol (DAP) debug sessions in IntelliJ. + *

    + * Each {@link DAPFile} is stored in this file system and can be + * accessed via a virtual URL of the form: + *

    + *     dap-file:///$projectLocationHash/$configName/$sourceName
    + * 
    + *
      + *
    • {@code $projectLocationHash} is a unique identifier of the project location.
    • + *
    • {@code $configName} is the name of the run/debug configuration that produced the disassembly.
    • + *
    • {@code $sourceName} is the name of the source reference or "disassembly" if the file is a Disassembly file
    • + *
    + *

    + * This file system does not correspond to any real files on disk and is + * used solely for displaying disassembly content in the IDE. + */ +public class DAPFileSystem extends DummyCachingFileSystem { + + /** + * Protocol name used to identify this virtual file system. + */ + public static final String PROTOCOL = "dap-file"; + + /** + * Constructs the DisassemblyFileSystem using the {@link #PROTOCOL}. + */ + public DAPFileSystem() { + super(PROTOCOL); + } + + /** + * Resolves a virtual file path into a {@link DAPFile}. + *

    + * The expected path format is "/$projectLocationHash/$configName/$sourceName". + *

    + * Example of a valid path: + *

    +     *     "/abc123/MyRunConfiguration"
    +     * 
    + * where "abc123" is the projectId and "MyRunConfiguration" is the configName. + *

    + * If the path is invalid or the project cannot be found, this method returns {@code null}. + * + * @param path the virtual file path to resolve + * @return the {@link DAPFile} associated with the path, or {@code null} if not found + */ + @Override + protected DAPFile findFileByPathInner(@NotNull String path) { + // Remove the leading slash from the virtual path + String dapFilePath = path.substring(1); + + // Split the path into two components: projectId and configName + var result = dapFilePath.split("/"); + + // Expect exactly 2 components; otherwise the path is invalid + // Example: + // - "abc123/MyRunConfiguration/disassembly" + // - "abc123/MyRunConfiguration/@MySourceName" + if (result.length != 3) { + return null; + } + + // The first component is the unique identifier of the project location + String projectId = result[0]; + var project = getProject(projectId); + if (project == null) { + return null; + } + + // The second component is the name of the run/debug configuration that generated this disassembly + String configName = result[1]; + + // The third component is the name of the source name + String sourceName = result[2]; + + // Retrieve or create the corresponding DisassemblyFile for this project and configuration + return DAPFileRegistry.getInstance().getOrCreateDAPFile(configName, sourceName, project); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPSourceReferencePosition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPSourceReferencePosition.java new file mode 100644 index 000000000..369469083 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DAPSourceReferencePosition.java @@ -0,0 +1,106 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import org.eclipse.lsp4j.debug.SourceArguments; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Represents a deferred source position obtained from a DAP {@code sourceReference}. + *

    + * The source content is fetched asynchronously from the debug adapter using the provided + * sourceReference. Once the content is loaded, the associated editor document is updated + * and the requested line number is returned. + */ +public class DAPSourceReferencePosition extends DeferredSourcePosition { + + /** + * Encapsulates the parameters required to resolve a DAP source reference. + * + * @param sourceReference the DAP sourceReference identifier + * @param line the initial line number to navigate to after loading + */ + public record SourceParams(@NotNull int sourceReference, int line) {} + + /** + * Creates a deferred source position based on a DAP sourceReference. + * + * @param file the file associated with this position + * @param sourceReference the DAP sourceReference to fetch content + * @param line the target line number in the source + * @param client the DAP client used to communicate with the debug adapter + */ + public DAPSourceReferencePosition(@NotNull DAPFile file, + int sourceReference, + int line, + @NotNull DAPClient client) { + super(new SourceParams(sourceReference, line), file, client); + } + + /** + * Asynchronously loads the source content and resolves the line number. + *

    + * If the server returns content, the editor document is updated in a write command action: + *

      + *
    • Line endings are normalized to LF.
    • + *
    • The document is set to read-only.
    • + *
    • The requested line number is returned.
    • + *
    + * If no content is returned, the provided line number is returned as is. + * + * @param params the source reference parameters including sourceReference and line + * @param file the file to update with fetched content + * @param client the DAP client used to fetch the content + * @return a CompletableFuture completing with the resolved line number + */ + @Override + protected CompletableFuture loadAndResolveLineAsync(@NotNull SourceParams params, + @NotNull DAPFile file, + @NotNull DAPClient client) { + var args = new SourceArguments(); + args.setSourceReference(params.sourceReference); + + // Fetch source content from the debug adapter server + return client.getDebugProtocolServer() + .source(args) + .thenCompose(sourceResponse -> { + if (sourceResponse != null) { + String content = sourceResponse.getContent() != null ? sourceResponse.getContent() : ""; + + // CompletableFuture that completes once the document is updated + CompletableFuture updatedDoc = new CompletableFuture<>(); + + // Update the editor document inside a write command action + WriteCommandAction.runWriteCommandAction(client.getProject(), () -> { + var doc = FileDocumentManager.getInstance().getDocument(getFile()); + if (doc != null && doc.isWritable()) { + // Normalize line endings to LF + String normalized = content.replace("\r\n", "\n"); + doc.setText(normalized); + doc.setReadOnly(true); // Make the document read-only + } + // Complete the future with the requested line number + updatedDoc.complete(params.line()); + }); + + return updatedDoc; + } + + // If no content returned, just return the provided line + return CompletableFuture.completedFuture(params.line()); + }); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredLocalFileSourcePosition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredLocalFileSourcePosition.java new file mode 100644 index 000000000..8f877acc3 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredLocalFileSourcePosition.java @@ -0,0 +1,126 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.testFramework.LightVirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Local file-based {@link com.intellij.xdebugger.XSourcePosition} that resolves {@link VirtualFile} off the EDT. + * + * LocalFileSystem.findFileByPath() can touch PersistentFS and triggers SlowOperations warnings on the EDT. + * This implementation returns a lightweight placeholder until the VFS lookup completes on a pooled thread. + */ +public final class DeferredLocalFileSourcePosition extends DeferredSourcePositionBase { + + private final @NotNull Path filePath; + private final int line; + private volatile @Nullable VirtualFile placeholderFile; + + private final AtomicBoolean resolutionStarted = new AtomicBoolean(false); + private volatile @Nullable VirtualFile resolvedFile; + private volatile int offset = -1; + + public DeferredLocalFileSourcePosition(@NotNull Path filePath, int line) { + this.filePath = filePath; + this.line = line; + + startResolveAsync(); + } + + @Override + public int getLine() { + return line; + } + + @Override + public int getOffset() { + return offset; + } + + @Override + public @NotNull VirtualFile getFile() { + VirtualFile file = resolvedFile; + if (file != null) { + return file; + } + + startResolveAsync(); + + if (!ApplicationManager.getApplication().isDispatchThread()) { + resolveNow(); + file = resolvedFile; + if (file != null) { + return file; + } + } + + VirtualFile placeholder = placeholderFile; + if (placeholder != null) { + return placeholder; + } + String name = filePath.getFileName() != null ? filePath.getFileName().toString() : filePath.toString(); + placeholder = new LightVirtualFile(name); + placeholderFile = placeholder; + return placeholder; + } + + private void startResolveAsync() { + if (!resolutionStarted.compareAndSet(false, true)) { + return; + } + ApplicationManager.getApplication().executeOnPooledThread(() -> { + try { + resolveNow(); + } finally { + resolveFuture.complete(null); + } + }); + } + + private void resolveNow() { + if (resolvedFile != null) { + return; + } + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(filePath.toString()); + if (file == null || !file.isValid()) { + return; + } + int resolvedOffset = ReadAction.compute(() -> computeOffset(file, line)); + resolvedFile = file; + offset = resolvedOffset; + markResolved(); + resolveFuture.complete(null); + } + + private static int computeOffset(@NotNull VirtualFile file, int line) { + Document document = FileDocumentManager.getInstance().getDocument(file); + if (document == null) { + return -1; + } + int l = Math.max(0, line); + int offset = l < document.getLineCount() ? document.getLineStartOffset(l) : -1; + if (offset >= document.getTextLength()) { + offset = document.getTextLength() - 1; + } + return offset; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePosition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePosition.java new file mode 100644 index 000000000..f6deb6bfd --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePosition.java @@ -0,0 +1,142 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.util.NotNullLazyValue; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Represents a source position whose actual location (line and offset) is resolved asynchronously. + *

    + * This class is used in the context of the Debug Adapter Protocol (DAP) to handle scenarios where + * the source content might not be immediately available: + *

      + *
    • The source comes from a {@code SourceReference} provided by the DAP.
    • + *
    • The source is dynamically generated, e.g., assembly instructions for disassembly view.
    • + *
    + *

    + * Once the source content is loaded and the line is resolved, this position can be used for navigation, + * highlighting, and other editor features. + * + * @param the type of parameters required to load the source (e.g., DAP request parameters) + * @param the type of VirtualFile handled by this position + */ +public abstract class DeferredSourcePosition extends DeferredSourcePositionBase { + + /** The virtual file associated with this position. */ + private final F file; + + /** Modification count at the time of creation, used to detect changes in the file. */ + private final long modificationCount; + + /** Resolved line number once the source has been loaded, or {@code null} if not yet resolved. */ + private Integer line; + + /** Lazy value that computes the offset only when needed, once the line is known. */ + private NotNullLazyValue offset; + + /** + * Creates a deferred source position that will load and resolve the position asynchronously. + * + * @param parameters the parameters required to load the source + * @param file the virtual file associated with this position + * @param client the DAP client used to fetch source or disassembly data + */ + public DeferredSourcePosition(@NotNull T parameters, + @NotNull F file, + @NotNull DAPClient client) { + this.file = file; + this.modificationCount = file.getModificationCount(); + + // Start the asynchronous process to load the source and resolve the line. + loadAndResolveLineAsync(parameters, file, client) + .whenComplete((resolvedLine, error) -> { + try { + if (error != null || resolvedLine == null) { + return; + } + this.line = resolvedLine; + // Compute the offset lazily, based on the resolved line. + this.offset = NotNullLazyValue.atomicLazy(() -> ReadAction.compute(() -> { + int column = 0; // Default column to 0 for now, may be extended later + Document document = FileDocumentManager.getInstance().getDocument(file); + if (document == null) { + return -1; + } else { + int l = Math.max(0, resolvedLine); + int c = Math.max(0, column); + int offset = l < document.getLineCount() ? document.getLineStartOffset(l) + c : -1; + if (offset >= document.getTextLength()) { + offset = document.getTextLength() - 1; + } + return offset; + } + })); + markResolved(); + } finally { + resolveFuture.complete(null); + } + }); + } + + /** + * Loads the source (from a DAP source reference or disassembly) and resolves the + * line number corresponding to the current position. + *

    + * This method is asynchronous and returns a future that completes when the line is known. + * + * @param parameters the parameters required to load the source + * @param file the virtual file to associate with the loaded content + * @param client the DAP client used to fetch the source or disassembly + * @return a future that will complete with the resolved line number + */ + protected abstract CompletableFuture loadAndResolveLineAsync(@NotNull T parameters, + @NotNull F file, + @NotNull DAPClient client); + + /** + * @return the resolved line number, or {@code -1} if not yet resolved + */ + @Override + public int getLine() { + return isResolved() ? line : -1; + } + + /** + * @return the resolved offset, or {@code -1} if not yet resolved + */ + @Override + public int getOffset() { + return isResolved() ? offset.get() : -1; + } + + /** + * @return the file associated with this source position + */ + @Override + public @NotNull VirtualFile getFile() { + return file; + } + + /** + * @return the modification count captured when this position was created + */ + public long getModificationCount() { + return modificationCount; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePositionBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePositionBase.java new file mode 100644 index 000000000..a7d9478c6 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/DeferredSourcePositionBase.java @@ -0,0 +1,134 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileNavigator; +import com.intellij.openapi.fileEditor.OpenFileDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.pom.Navigatable; +import com.intellij.xdebugger.impl.XSourcePositionEx; +import kotlinx.coroutines.flow.Flow; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.StateFlowKt; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Base class for an {@link com.intellij.xdebugger.XSourcePosition} whose final location is resolved asynchronously. + * The debugger UI listens to {@link #getPositionUpdateFlow()} to refresh the execution point highlighting. + */ +abstract class DeferredSourcePositionBase implements XSourcePositionEx { + + private final MutableStateFlow resolvedFlow = StateFlowKt.MutableStateFlow(false); + protected final @NotNull CompletableFuture resolveFuture = new CompletableFuture<>(); + + protected final void markResolved() { + resolvedFlow.setValue(true); + } + + protected final boolean isResolved() { + return resolvedFlow.getValue(); + } + + @Override + public final @NotNull Flow getPositionUpdateFlow() { + return resolvedFlow; + } + + @Override + public @NotNull Navigatable createNavigatable(@NotNull Project project) { + return new DeferredNavigatable(project, this); + } + + private static final class DeferredNavigatable extends OpenFileDescriptor { + + private final @NotNull DeferredSourcePositionBase position; + + private DeferredNavigatable(@NotNull Project project, @NotNull DeferredSourcePositionBase position) { + super(project, position.getFile()); + this.position = position; + } + + @Override + public void navigate(boolean requestFocus) { + if (position.isResolved()) { + doNavigate(requestFocus); + } else { + position.resolveFuture.thenRun(() -> { + if (!position.isResolved()) { + return; + } + ApplicationManager.getApplication().invokeLater(() -> { + if (position.isResolved()) { + doNavigate(requestFocus); + } + }); + }); + } + } + + private void doNavigate(boolean requestFocus) { + VirtualFile file = position.getFile(); + if (!file.isValid()) { + return; + } + FileNavigator.getInstance().navigate(createDescriptor(file), requestFocus); + } + + @Override + public void navigateIn(@NotNull Editor e) { + if (position.isResolved()) { + doNavigateIn(e); + } else { + position.resolveFuture.thenRun(() -> { + if (!position.isResolved()) { + return; + } + ApplicationManager.getApplication().invokeLater(() -> { + if (position.isResolved()) { + doNavigateIn(e); + } + }); + }); + } + } + + private void doNavigateIn(@NotNull Editor editor) { + VirtualFile file = position.getFile(); + if (!file.isValid()) { + return; + } + createDescriptor(file).navigateIn(editor); + } + + private @NotNull OpenFileDescriptor createDescriptor(@NotNull VirtualFile file) { + int offset = position.getOffset(); + if (offset != -1) { + return new OpenFileDescriptor(getProject(), file, offset); + } + return new OpenFileDescriptor(getProject(), file, Math.max(0, position.getLine()), 0); + } + + @Override + public boolean canNavigate() { + return position.getFile().isValid(); + } + + @Override + public boolean canNavigateToSource() { + return canNavigate(); + } + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/SourceReferenceFile.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/SourceReferenceFile.java new file mode 100644 index 000000000..73ae21c8d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/client/files/SourceReferenceFile.java @@ -0,0 +1,43 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.client.files; + +import com.intellij.openapi.fileTypes.PlainTextFileType; +import com.intellij.openapi.project.Project; +import org.jetbrains.annotations.NotNull; + +/** + * Represents a virtual source file referenced in a DAP (Debug Adapter Protocol) session. + *

    + * This class is a simple specialization of {@link DAPFile} and is used to represent + * source files that exist in memory but may be referenced by a debug session. + *

    + * The {@link #getPath()} method inherited from {@link DAPFile} has the form: + *

    + *   $projectLocationHash/$configName/$sourceName
    + * 
    + * The {@link #getUrl()} method inherited from {@link DAPFile} returns: + *
    + *   dap-file:///$path
    + * 
    + *

    + * Since this file is virtual, it does not correspond to a real file on disk. + * Its content can be accessed and modified through the {@link com.intellij.openapi.editor.Document} + * obtained via {@link com.intellij.openapi.fileEditor.FileDocumentManager#getDocument(com.intellij.openapi.vfs.VirtualFile)}. + */ +public class SourceReferenceFile extends DAPFile { + + public SourceReferenceFile(String name, + @NotNull String path, + @NotNull Project project) { + super(name, path, PlainTextFileType.INSTANCE, project); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPCompletionProcessor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPCompletionProcessor.java index 8ab7bf3cb..a1a122814 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPCompletionProcessor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPCompletionProcessor.java @@ -108,6 +108,7 @@ public void fillCompletionVariants(@NotNull CompletionParameters parameters, Arrays.sort(items, new DAPCompletionItemComparator(prefixMatcher, currentWord, caseSensitive)); int size = items.length; + int completionOffset = parameters.getOffset(); // Items now sorted by priority, low index == high priority for (int i = 0; i < size; i++) { var item = items[i]; @@ -115,8 +116,9 @@ public void fillCompletionVariants(@NotNull CompletionParameters parameters, LookupElement lookupElement = createLookupElement(parameters, item); if (lookupElement != null) { + lookupElement.putUserData(CodeCompletionHandlerBase.DIRECT_INSERTION, true); int priority = size - i; - var prioritizedLookupItem = PrioritizedLookupElement.withPriority(new DAPLookupElementDecorator(lookupElement, item), priority); + var prioritizedLookupItem = PrioritizedLookupElement.withPriority(new DAPLookupElementDecorator(lookupElement, item, completionOffset), priority); result.addElement(prioritizedLookupItem); } } @@ -130,6 +132,9 @@ public void fillCompletionVariants(@NotNull CompletionParameters parameters, private LookupElement createLookupElement(@NotNull CompletionParameters parameters, @NotNull CompletionItem item) { String label = item.getLabel(); + if (StringUtils.isBlank(label)) { + return null; + } String insertText = label; if (StringUtils.isNotBlank(item.getText())) { insertText = item.getText(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPLookupElementDecorator.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPLookupElementDecorator.java index 397bc02cc..d08304715 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPLookupElementDecorator.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/completion/DAPLookupElementDecorator.java @@ -10,16 +10,24 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.completion; +import com.intellij.codeInsight.completion.CompletionInitializationContext; +import com.intellij.codeInsight.completion.InsertionContext; import com.intellij.codeInsight.lookup.LookupElement; import com.intellij.codeInsight.lookup.LookupElementDecorator; import com.intellij.codeInsight.lookup.LookupElementPresentation; import com.intellij.icons.AllIcons; +import com.intellij.openapi.application.WriteAction; +import com.intellij.openapi.editor.Document; +import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.eclipse.lsp4j.debug.CompletionItem; import org.eclipse.lsp4j.debug.CompletionItemType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.*; +import java.util.Objects; + +import static com.redhat.devtools.lsp4ij.internal.CompletionUtils.computePrefixStartFromInsertText; /** * Debug Adapter Protocol (DAP) lookup element decorator. @@ -27,11 +35,14 @@ public class DAPLookupElementDecorator extends LookupElementDecorator { private final @NotNull CompletionItem item; + private final int completionOffset; protected DAPLookupElementDecorator(@NotNull LookupElement delegate, - @NotNull CompletionItem item) { + @NotNull CompletionItem item, + int completionOffset) { super(delegate); this.item = item; + this.completionOffset = completionOffset; } @Override @@ -43,68 +54,118 @@ public void renderElement(@NotNull LookupElementPresentation presentation) { presentation.setTypeText(this.getTypeText(item)); presentation.setIcon(this.getIcon(item.getType())); presentation.setTypeGrayed(true); -} + } -private boolean isBold(@NotNull CompletionItem item) { - return false; -} + @Override + public void handleInsert(@NotNull InsertionContext context) { + WriteAction.run(() -> handleInsertInWriteAction(context)); + } -private boolean isStrikeout(@NotNull CompletionItem item) { - return false; -} + private void handleInsertInWriteAction(@NotNull InsertionContext context) { + var editor = context.getEditor(); + final int oldCaretOffset = editor.getCaretModel().getOffset(); + var document = context.getDocument(); -private @Nullable String getTailText(@NotNull CompletionItem item) { - return null; -} + int startOffset = getStartOffset(document); + int endOffset = getEndOffset(startOffset, context); + String newText = getNewtText(); -private @Nullable String getTypeText(@NotNull CompletionItem item) { - return item.getDetail(); -} + // Update document with the new completion item text to insert + if (endOffset <= 0) { + document.insertString(startOffset, newText); + } else { + document.replaceString(startOffset, endOffset, newText); + } + + // Update caret offset / selection (if needed) + Integer selectionStart = item.getSelectionStart(); + if (selectionStart != null) { + // Update selection + selectionStart += startOffset; + Integer selectionLength = item.getSelectionLength(); + int selectionEnd = selectionStart + Objects.requireNonNullElse(selectionLength, 0); + editor.getSelectionModel().setSelection(selectionStart, selectionEnd); + + // Update caret offset + if (oldCaretOffset != selectionEnd) { + editor.getCaretModel().moveToOffset(selectionEnd); + } -private static @Nullable Icon getIcon(@Nullable CompletionItemType type) { - if (type == null) { + } else { + // No selection, update caret offset only + int newCaretOffset = editor.getCaretModel().getOffset() + newText.length() - (endOffset - startOffset); + if (oldCaretOffset != newCaretOffset) { + editor.getCaretModel().moveToOffset(newCaretOffset); + } + } + } + + private int getStartOffset(@NotNull Document document) { + Integer start = item.getStart(); + if (start != null) { + return start; + } + Integer prefixStartOffset = computePrefixStartFromInsertText(document, null, completionOffset, getNewtText()); + return Objects.requireNonNullElse(prefixStartOffset, completionOffset); + } + + private int getEndOffset(int startOffset, @NotNull InsertionContext context) { + int selectionEndOffset = context.getOffset(CompletionInitializationContext.SELECTION_END_OFFSET); + Integer length = item.getLength(); + if (length == null) { + return selectionEndOffset; + } + return startOffset + (selectionEndOffset - completionOffset + length); + } + + private @NotNull String getNewtText() { + String newText = item.getText(); + if (StringUtils.isBlank(newText)) { + return item.getLabel(); + } + return newText; + } + + private boolean isBold(@NotNull CompletionItem item) { + return false; + } + + private boolean isStrikeout(@NotNull CompletionItem item) { + return false; + } + + private @Nullable String getTailText(@NotNull CompletionItem item) { return null; } - switch (type) { - case CLASS: - return AllIcons.Nodes.Class; - case CUSTOMCOLOR: - case COLOR: - return AllIcons.Nodes.EmptyNode; - case CONSTRUCTOR: - return AllIcons.Nodes.ClassInitializer; - case ENUM: - return AllIcons.Nodes.Enum; - case FIELD: - return AllIcons.Nodes.Field; - case FILE: - return AllIcons.FileTypes.Any_type; - case FUNCTION: - return AllIcons.Nodes.Function; - case INTERFACE: - return AllIcons.Nodes.Interface; - case KEYWORD: - return AllIcons.Nodes.Constant; - case METHOD: - return AllIcons.Nodes.Method; - case MODULE: - return AllIcons.Nodes.Module; - case PROPERTY: - return AllIcons.Nodes.Property; - case SNIPPET: - return AllIcons.Nodes.Template; - case TEXT: - return AllIcons.Nodes.Word; - case UNIT: - return AllIcons.Nodes.Test; - case VALUE: - case REFERENCE: - case VARIABLE: - return AllIcons.Nodes.Variable; - default: - return AllIcons.Nodes.EmptyNode; + private @Nullable String getTypeText(@NotNull CompletionItem item) { + return item.getDetail(); + } + + private @Nullable Icon getIcon(@Nullable CompletionItemType type) { + if (type == null) { + return null; + } + + return switch (type) { + case CLASS -> AllIcons.Nodes.Class; + case CUSTOMCOLOR, COLOR -> AllIcons.Nodes.EmptyNode; + case CONSTRUCTOR -> AllIcons.Nodes.ClassInitializer; + case ENUM -> AllIcons.Nodes.Enum; + case FIELD -> AllIcons.Nodes.Field; + case FILE -> AllIcons.FileTypes.Any_type; + case FUNCTION -> AllIcons.Nodes.Function; + case INTERFACE -> AllIcons.Nodes.Interface; + case KEYWORD -> AllIcons.Nodes.Constant; + case METHOD -> AllIcons.Nodes.Method; + case MODULE -> AllIcons.Nodes.Module; + case PROPERTY -> AllIcons.Nodes.Property; + case SNIPPET -> AllIcons.Nodes.Template; + case TEXT -> AllIcons.Nodes.Word; + case UNIT -> AllIcons.Nodes.Test; + case VALUE, REFERENCE, VARIABLE -> AllIcons.Nodes.Variable; + default -> AllIcons.Nodes.EmptyNode; + }; } -} } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPCommandLineState.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPCommandLineState.java index 5ef7f412a..5f8d82ee5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPCommandLineState.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPCommandLineState.java @@ -11,15 +11,18 @@ package com.redhat.devtools.lsp4ij.dap.configurations; import com.intellij.execution.ExecutionException; +import com.intellij.execution.Executor; import com.intellij.execution.configurations.CommandLineState; import com.intellij.execution.configurations.RunConfigurationOptions; import com.intellij.execution.process.NopProcessHandler; import com.intellij.execution.process.ProcessHandler; import com.intellij.execution.runners.ExecutionEnvironment; +import com.intellij.execution.ui.ConsoleView; import com.intellij.openapi.fileTypes.FileType; import com.redhat.devtools.lsp4ij.dap.DAPServerReadyTracker; import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.configurations.options.FileOptionConfigurable; +import com.redhat.devtools.lsp4ij.dap.configurations.options.ServerTraceConfigurable; import com.redhat.devtools.lsp4ij.dap.console.DAPTextConsoleBuilderImpl; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; import com.redhat.devtools.lsp4ij.dap.descriptors.ServerReadyConfig; @@ -63,6 +66,15 @@ public DAPCommandLineState(@NotNull DebugAdapterDescriptor serverDescriptor, return processHandler; } + @Override + protected @Nullable ConsoleView createConsole(@NotNull Executor executor) throws ExecutionException { + ConsoleView console = serverDescriptor.createSessionConsole(getEnvironment().getProject()); + if (console != null) { + return console; + } + return super.createConsole(executor); + } + @Nullable public FileType getFileType() { return serverDescriptor.getFileType(); @@ -86,6 +98,12 @@ public DebugMode getDebugMode() { } public ServerTrace getServerTrace() { + if (options instanceof ServerTraceConfigurable serverTraceConfigurable) { + ServerTrace serverTrace = serverTraceConfigurable.getServerTrace(); + if (serverTrace != null) { + return serverTrace; + } + } return serverDescriptor.getServerTrace(); } @@ -115,6 +133,7 @@ public void setCommandLine(String commandLine) { commandLineUpdater.setCommandLine(commandLine); } } + public @Nullable String getInstallerConfiguration() { if (options instanceof DAPRunConfigurationOptions dapOptions) { return dapOptions.getInstallerConfiguration(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationType.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPConfigurationType.java similarity index 78% rename from src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationType.java rename to src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPConfigurationType.java index c69439505..3416b31ff 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationType.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPConfigurationType.java @@ -1,38 +1,38 @@ -/******************************************************************************* - * Copyright (c) 2025 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.dap.configurations; - -import com.intellij.execution.configurations.ConfigurationTypeBase; -import com.intellij.execution.configurations.ConfigurationTypeUtil; -import com.intellij.icons.AllIcons; -import com.intellij.openapi.util.NotNullLazyValue; -import com.redhat.devtools.lsp4ij.dap.DAPBundle; - -/** - * Default Debug Adapter Protocol (DAP) configuration type. - */ -final class DAPRunConfigurationType extends ConfigurationTypeBase { - - public static final String ID = "DAPRunConfiguration"; - - public static DAPRunConfigurationType getInstance() { - return ConfigurationTypeUtil.findConfigurationType(DAPRunConfigurationType.class); - } - - DAPRunConfigurationType() { - super(ID, - DAPBundle.message("DAPRunConfigurationType.displayName"), - DAPBundle.message("DAPRunConfigurationType.description"), - NotNullLazyValue.createValue(() -> AllIcons.Debugger.Console)); - addFactory(new DAPConfigurationFactory(this)); - } - +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations; + +import com.intellij.execution.configurations.ConfigurationTypeBase; +import com.intellij.execution.configurations.ConfigurationTypeUtil; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.util.NotNullLazyValue; +import com.redhat.devtools.lsp4ij.dap.DAPBundle; + +/** + * Default Debug Adapter Protocol (DAP) configuration type. + */ +final class DAPConfigurationType extends ConfigurationTypeBase { + + public static final String ID = "DAPConfiguration"; + + public static DAPConfigurationType getInstance() { + return ConfigurationTypeUtil.findConfigurationType(DAPConfigurationType.class); + } + + DAPConfigurationType() { + super(ID, + DAPBundle.message("DAPRunConfigurationType.displayName"), + DAPBundle.message("DAPRunConfigurationType.description"), + NotNullLazyValue.createValue(() -> AllIcons.Debugger.Console)); + addFactory(new DAPConfigurationFactory(this)); + } + } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfiguration.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfiguration.java index 6791a286f..694527601 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfiguration.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfiguration.java @@ -10,35 +10,37 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.configurations; -import com.intellij.execution.Executor; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.intellij.execution.configurations.*; -import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.openapi.options.SettingsEditor; import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.InvalidDataException; import com.intellij.openapi.vfs.VirtualFile; import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.DebugServerWaitStrategy; +import com.redhat.devtools.lsp4ij.dap.configurations.options.AttachConfigurable; +import com.redhat.devtools.lsp4ij.dap.configurations.options.EnvironmentVariablesDataConfigurable; import com.redhat.devtools.lsp4ij.dap.configurations.options.FileOptionConfigurable; import com.redhat.devtools.lsp4ij.dap.configurations.options.WorkingDirectoryConfigurable; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; -import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; -import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptorFactory; -import com.redhat.devtools.lsp4ij.dap.descriptors.DefaultDebugAdapterDescriptor; import com.redhat.devtools.lsp4ij.internal.StringUtils; -import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import com.redhat.devtools.lsp4ij.settings.ServerTrace; +import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; +import org.jdom.Element; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.List; /** - * Debug Adapter Protocol (DAP) run configuration. + * Default Debug Adapter Protocol (DAP) run configuration. */ -public class DAPRunConfiguration extends RunConfigurationBase implements FileOptionConfigurable, WorkingDirectoryConfigurable, DebuggableFile { +public class DAPRunConfiguration extends DAPRunConfigurationBase implements FileOptionConfigurable, WorkingDirectoryConfigurable, AttachConfigurable, EnvironmentVariablesDataConfigurable, DebuggableFile { public static final String DEBUG_ADAPTER_CONFIGURATION = "Debug Adapter Configuration"; + private @NotNull EnvironmentVariablesData envData = EnvironmentVariablesData.DEFAULT; + protected DAPRunConfiguration(@NotNull Project project, @NotNull ConfigurationFactory factory, @NotNull String name) { @@ -85,6 +87,16 @@ public void setCommand(String command) { getOptions().setCommand(command); } + @Override + public @NotNull EnvironmentVariablesData getEnvData() { + return envData; + } + + @Override + public void setEnvData(@NotNull EnvironmentVariablesData envData) { + this.envData = envData; + } + /** * Retrieves the current strategy used to wait for the debug server. * @@ -120,7 +132,6 @@ public void setConnectTimeout(int connectTimeout) { } /** - * * @return */ public String getDebugServerReadyPattern() { @@ -131,22 +142,26 @@ public void setDebugServerReadyPattern(String debugServerReadyPattern) { getOptions().setDebugServerReadyPattern(debugServerReadyPattern); } - public String getAttachAddress() { + @Override + public @Nullable String getAttachAddress() { return getOptions().getAttachAddress(); } - public void setAttachAddress(String attachAddress) { + @Override + public void setAttachAddress(@Nullable String attachAddress) { getOptions().setAttachAddress(attachAddress); } - public String getAttachPort() { + @Override + public @Nullable String getAttachPort() { return getOptions().getAttachPort(); } - public void setAttachPort(String attachPort) { + @Override + public void setAttachPort(@Nullable String attachPort) { getOptions().setAttachPort(attachPort); } - + public ServerTrace getServerTrace() { return getOptions().getServerTrace(); } @@ -187,7 +202,7 @@ public void setWorkingDirectory(@Nullable String workingDirectory) { public void setFile(@Nullable String file) { getOptions().setFile(file); } - + public DebugMode getDebugMode() { return getOptions().getDebugMode(); } @@ -245,33 +260,11 @@ public SettingsEditor getConfigurationEditor() { new DAPSettingsEditor(getProject()); } - @Nullable - @Override - public RunProfileState getState(@NotNull Executor executor, - @NotNull ExecutionEnvironment environment) { - var debugAdapterServer = getDebugAdapterServer(); - DebugAdapterDescriptor serverDescriptor = debugAdapterServer != null ? - debugAdapterServer.getFactory().createDebugAdapterDescriptor(getOptions(), environment) : - new DefaultDebugAdapterDescriptor(getOptions(), environment, getName()); - return new DAPCommandLineState(serverDescriptor, getOptions(), environment); - } - @Override public boolean isDebuggableFile(@NotNull VirtualFile file, @NotNull Project project) { return getOptions().isDebuggableFile(file, project); } - /** - * Returns true if the given executor id (ex: 'Debug') can be executed and false otherwise. - * - * @param executorId the executor id (ex: 'Debug'). - * @return true if the given executor id (ex: 'Debug') can be executed and false otherwise. - */ - public boolean canRun(@NotNull String executorId) { - var serverFactory = getServerFactory(); - return serverFactory != null ? serverFactory.canRun(executorId) : true; - } - @Override public void checkConfiguration() throws RuntimeConfigurationException { if (StringUtils.isBlank(getCommand()) && StringUtils.isBlank(getServerId())) { @@ -279,19 +272,8 @@ public void checkConfiguration() throws RuntimeConfigurationException { } } - /** - * Returns the server DAP factory descriptor and null otherwise. - * - * @return the server DAP factory descriptor and null otherwise. - */ - @Nullable - private DebugAdapterDescriptorFactory getServerFactory() { - var server = getDebugAdapterServer(); - return server != null ? server.getFactory() : null; - } - - @Nullable - private DebugAdapterServerDefinition getDebugAdapterServer() { + @Override + protected @Nullable DebugAdapterServerDefinition getDebugAdapterServer() { return getOptions().getDebugAdapterServer(); } @@ -315,6 +297,7 @@ public void copyTo(@NotNull DAPRunConfiguration configuration) { configuration.setServerId(getServerId()); configuration.setServerName(getServerName()); configuration.setCommand(getCommand()); + configuration.setEnvData(getEnvData()); configuration.setDebugServerWaitStrategy(getDebugServerWaitStrategy()); configuration.setConnectTimeout(getConnectTimeout()); configuration.setDebugServerReadyPattern(getDebugServerReadyPattern()); @@ -323,4 +306,15 @@ public void copyTo(@NotNull DAPRunConfiguration configuration) { configuration.setServerTrace(getServerTrace()); } + @Override + public void writeExternal(@NotNull Element element) { + super.writeExternal(element); + envData.writeExternal(element); + } + + @Override + public void readExternal(@NotNull Element element) throws InvalidDataException { + super.readExternal(element); + envData = EnvironmentVariablesData.readExternal(element); + } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationBase.java new file mode 100644 index 000000000..3232bb725 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationBase.java @@ -0,0 +1,101 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations; + +import com.intellij.execution.Executor; +import com.intellij.execution.configurations.ConfigurationFactory; +import com.intellij.execution.configurations.RunConfiguration; +import com.intellij.execution.configurations.RunConfigurationBase; +import com.intellij.execution.configurations.RunProfileState; +import com.intellij.execution.runners.ExecutionEnvironment; +import com.intellij.openapi.options.SettingsEditor; +import com.intellij.openapi.project.Project; +import com.redhat.devtools.lsp4ij.dap.configurations.options.ServerTraceConfigurable; +import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptorFactory; +import com.redhat.devtools.lsp4ij.dap.descriptors.DefaultDebugAdapterDescriptor; +import com.redhat.devtools.lsp4ij.settings.ServerTrace; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Debug Adapter Protocol (DAP) run configuration base. + * + * @param the run configuration options. + */ +public abstract class DAPRunConfigurationBase extends RunConfigurationBase implements ServerTraceConfigurable { + + protected DAPRunConfigurationBase(@NotNull Project project, + @Nullable ConfigurationFactory factory, + @Nullable String name) { + super(project, factory, name); + } + + @Nullable + @Override + public RunProfileState getState(@NotNull Executor executor, + @NotNull ExecutionEnvironment environment) { + var debugAdapterServer = getDebugAdapterServer(); + DebugAdapterDescriptor serverDescriptor = debugAdapterServer != null ? + debugAdapterServer.getFactory().createDebugAdapterDescriptor(getOptions(), environment) : + new DefaultDebugAdapterDescriptor(getOptions(), environment, getName()); + return new DAPCommandLineState(serverDescriptor, getOptions(), environment); + } + + /** + * Returns the server DAP factory descriptor and null otherwise. + * + * @return the server DAP factory descriptor and null otherwise. + */ + @Nullable + protected DebugAdapterDescriptorFactory getServerFactory() { + var server = getDebugAdapterServer(); + return server != null ? server.getFactory() : null; + } + + /** + * Returns true if the given executor id (ex: 'Debug') can be executed and false otherwise. + * + * @param executorId the executor id (ex: 'Debug'). + * @return true if the given executor id (ex: 'Debug') can be executed and false otherwise. + */ + public boolean canRun(@NotNull String executorId) { + var serverFactory = getServerFactory(); + return serverFactory == null || serverFactory.canRun(executorId); + } + + protected abstract @Nullable DebugAdapterServerDefinition getDebugAdapterServer(); + + @NotNull + @Override + public SettingsEditor getConfigurationEditor() { + var serverFactory = getServerFactory(); + return serverFactory != null ? + serverFactory.getConfigurationEditor(getProject()) : + new DAPSettingsEditor(getProject()); + } + + @Override + public void setServerTrace(ServerTrace serverTrace) { + if (getOptions() instanceof ServerTraceConfigurable serverTraceConfigurable) { + serverTraceConfigurable.setServerTrace(serverTrace); + } + } + + @Override + public ServerTrace getServerTrace() { + if (getOptions() instanceof ServerTraceConfigurable serverTraceConfigurable) { + return serverTraceConfigurable.getServerTrace(); + } + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptions.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptions.java index 0025a7ee2..8e3cd0b60 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptions.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptions.java @@ -10,7 +10,6 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.configurations; -import com.intellij.execution.configurations.RunConfigurationOptions; import com.intellij.lang.Language; import com.intellij.openapi.components.StoredProperty; import com.intellij.openapi.project.Project; @@ -20,13 +19,13 @@ import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.DebugServerWaitStrategy; import com.redhat.devtools.lsp4ij.dap.configurations.extractors.NetworkAddressExtractor; +import com.redhat.devtools.lsp4ij.dap.configurations.options.AttachConfigurable; import com.redhat.devtools.lsp4ij.dap.configurations.options.FileOptionConfigurable; import com.redhat.devtools.lsp4ij.dap.configurations.options.WorkingDirectoryConfigurable; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; -import com.redhat.devtools.lsp4ij.settings.ServerTrace; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.jps.model.fileTypes.FileNameMatcherFactory; @@ -36,7 +35,7 @@ /** * Debug Adapter Protocol (DAP) run configuration options. */ -public class DAPRunConfigurationOptions extends RunConfigurationOptions implements FileOptionConfigurable, WorkingDirectoryConfigurable, DebuggableFile, CommandLineUpdater { +public class DAPRunConfigurationOptions extends DAPRunConfigurationOptionsBase implements FileOptionConfigurable, WorkingDirectoryConfigurable, AttachConfigurable, DebuggableFile, CommandLineUpdater { @Nullable private NetworkAddressExtractor networkAddressExtractor; @@ -95,9 +94,6 @@ public class DAPRunConfigurationOptions extends RunConfigurationOptions implemen private final StoredProperty attachPort = string("") .provideDelegate(this, "attachPort"); - private final StoredProperty serverTrace = string(ServerTrace.getDefaultValue().name()) - .provideDelegate(this, "serverTrace"); - // Installer settings private final StoredProperty installerConfiguration = string("") .provideDelegate(this, "installerConfiguration"); @@ -259,7 +255,7 @@ public DebugServerWaitStrategy getDebugServerWaitStrategy() { /** * Set the current strategy used to wait for the debug server. */ - public void setDebugServerWaitStrategy(DebugServerWaitStrategy debugServerWaitStrategy) { + public void setDebugServerWaitStrategy(@NotNull DebugServerWaitStrategy debugServerWaitStrategy) { this.debugServerWaitStrategy.setValue(this, debugServerWaitStrategy.name()); } @@ -289,30 +285,26 @@ public void setDebugServerReadyPattern(String debugServerReadyPattern) { this.debugServerReadyPattern.setValue(this, debugServerReadyPattern); this.networkAddressExtractor = null; } - - public String getAttachAddress() { + + @Override + public @Nullable String getAttachAddress() { return attachAddress.getValue(this); } - - public void setAttachAddress(String attachAddress) { + + @Override + public void setAttachAddress(@Nullable String attachAddress) { this.attachAddress.setValue(this, attachAddress); } - public String getAttachPort() { + @Override + public @Nullable String getAttachPort() { return attachPort.getValue(this); } - public void setAttachPort(String attachPort) { + @Override + public void setAttachPort(@Nullable String attachPort) { this.attachPort.setValue(this, attachPort); } - - public ServerTrace getServerTrace() { - return ServerTrace.get(serverTrace.getValue(this)); - } - - public void setServerTrace(ServerTrace serverTrace) { - this.serverTrace.setValue(this, serverTrace.name()); - } // Installer settings @@ -323,7 +315,7 @@ public String getInstallerConfiguration() { public void setInstallerConfiguration(String installerConfiguration) { this.installerConfiguration.setValue(this, installerConfiguration); } - + public @Nullable NetworkAddressExtractor getNetworkAddressExtractor() { if (networkAddressExtractor != null) { return networkAddressExtractor; @@ -379,4 +371,5 @@ public boolean isDebuggableFile(@NotNull VirtualFile file, @NotNull Project proj } return false; } + } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptionsBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptionsBase.java new file mode 100644 index 000000000..14359810b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationOptionsBase.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations; + +import com.intellij.execution.configurations.RunConfigurationOptions; +import com.intellij.openapi.components.StoredProperty; +import com.redhat.devtools.lsp4ij.dap.configurations.options.ServerTraceConfigurable; +import com.redhat.devtools.lsp4ij.settings.ServerTrace; + +/** + * Debug Adapter Protocol (DAP) run configuration options base. + */ +public class DAPRunConfigurationOptionsBase extends RunConfigurationOptions implements ServerTraceConfigurable { + + private final StoredProperty serverTrace = string(ServerTrace.getDefaultValue().name()) + .provideDelegate(this, "serverTrace"); + + @Override + public ServerTrace getServerTrace() { + return ServerTrace.get(serverTrace.getValue(this)); + } + + @Override + public void setServerTrace(ServerTrace serverTrace) { + this.serverTrace.setValue(this, serverTrace.name()); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationProvider.java index 94f8ef5bb..08d511ed1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/DAPRunConfigurationProvider.java @@ -25,7 +25,7 @@ public class DAPRunConfigurationProvider extends LazyRunConfigurationProducer extends DAPRunConfigurationBase implements AttachConfigurable { + + private final @NotNull String serverId; + + protected DAPAttachRunConfiguration(@NotNull Project project, + @Nullable ConfigurationFactory factory, + @Nullable String name, + @NotNull String serverId) { + super(project, factory, name); + this.serverId = serverId; + } + + @Override + public @NotNull SettingsEditor getConfigurationEditor() { + return new DAPAttachSettingsEditor<>(getProject()); + } + + @Override + protected @Nullable DebugAdapterServerDefinition getDebugAdapterServer() { + return DebugAdapterManager.getInstance().getDebugAdapterServerById(serverId); + } + + @Override + public @Nullable String getAttachAddress() { + return getOptions().getAttachAddress(); + } + + @Override + public void setAttachAddress(@Nullable String attachAddress) { + getOptions().setAttachAddress(attachAddress); + } + + @Override + public @Nullable String getAttachPort() { + return getOptions().getAttachPort(); + } + + @Override + public void setAttachPort(@Nullable String attachPort) { + getOptions().setAttachPort(attachPort); + } + + @Override + protected @NotNull T getOptions() { + return (T) super.getOptions(); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachRunConfigurationOptions.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachRunConfigurationOptions.java new file mode 100644 index 000000000..7e1010f77 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachRunConfigurationOptions.java @@ -0,0 +1,49 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations.attach; + +import com.intellij.openapi.components.StoredProperty; +import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationOptionsBase; +import com.redhat.devtools.lsp4ij.dap.configurations.options.AttachConfigurable; +import org.jetbrains.annotations.Nullable; + +/** + * DAP run configuration options for "attach" mode. + */ +public class DAPAttachRunConfigurationOptions extends DAPRunConfigurationOptionsBase implements AttachConfigurable { + + private final StoredProperty attachAddress = string("") + .provideDelegate(this, "attachAddress"); + + private final StoredProperty attachPort = string("") + .provideDelegate(this, "attachPort"); + + @Override + public @Nullable String getAttachAddress() { + return attachAddress.getValue(this); + } + + @Override + public void setAttachAddress(@Nullable String attachAddress) { + this.attachAddress.setValue(this, attachAddress); + } + + @Override + public @Nullable String getAttachPort() { + return attachPort.getValue(this); + } + + @Override + public void setAttachPort(@Nullable String attachPort) { + this.attachPort.setValue(this, attachPort); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachSettingsEditor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachSettingsEditor.java new file mode 100644 index 000000000..51c48a61f --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/attach/DAPAttachSettingsEditor.java @@ -0,0 +1,98 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations.attach; + +import com.intellij.execution.ExecutionBundle; +import com.intellij.openapi.options.ConfigurationException; +import com.intellij.openapi.options.SettingsEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.ComponentValidator; +import com.intellij.openapi.ui.ValidationInfo; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.ui.DocumentAdapter; +import com.intellij.util.ui.FormBuilder; +import com.redhat.devtools.lsp4ij.dap.DAPBundle; +import com.redhat.devtools.lsp4ij.settings.ServerTrace; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import java.awt.*; + +/** + * Settings editor to configure DAP client with "attach". + * + * @param the "attach" configurable. + */ +public class DAPAttachSettingsEditor> extends SettingsEditor { + + private final JPanel root; + // Server trace + private final ComboBox serverTraceComboBox; + private final JTextField myAddress = new JTextField(); + private final JTextField myPort = new JTextField(Integer.toString(65535)); + + public DAPAttachSettingsEditor(@NotNull Project project) { + this.myPort.setMinimumSize(this.myPort.getPreferredSize()); + (new ComponentValidator(project)).withValidator(() -> { + String pt = this.myPort.getText(); + if (StringUtil.isNotEmpty(pt)) { + try { + int portValue = Integer.parseInt(pt); + return portValue >= 0 && portValue <= 65535 ? null : new ValidationInfo(ExecutionBundle.message("incorrect.port.range.set.value.between"), this.myPort); + } catch (NumberFormatException var3) { + return new ValidationInfo(ExecutionBundle.message("port.value.should.be.a.number.between"), this.myPort); + } + } else { + return null; + } + }).installOn(this.myPort); + this.myPort.getDocument().addDocumentListener(new DocumentAdapter() { + protected void textChanged(@NotNull DocumentEvent e) { + ComponentValidator.getInstance(DAPAttachSettingsEditor.this.myPort).ifPresent((v) -> v.revalidate()); + } + }); + + FormBuilder builder = FormBuilder + .createFormBuilder(); + + builder.addLabeledComponent("Address:", myAddress); + builder.addLabeledComponent("Port:", myPort); + serverTraceComboBox = new ComboBox<>(new DefaultComboBoxModel<>(ServerTrace.values())); + builder.addLabeledComponent(DAPBundle.message("dap.settings.editor.server.serverTrace.field"), serverTraceComboBox); + + JPanel panel = new JPanel(new BorderLayout()); + panel.add(builder.getPanel(), BorderLayout.CENTER); + root = panel; + + } + + @Override + protected void resetEditorFrom(@NotNull Settings settings) { + myAddress.setText(settings.getAttachAddress()); + myPort.setText(settings.getAttachPort()); + serverTraceComboBox.setSelectedItem(settings.getServerTrace()); + } + + @Override + protected void applyEditorTo(@NotNull Settings settings) throws ConfigurationException { + settings.setAttachAddress(myAddress.getText()); + settings.setAttachPort(myPort.getText()); + settings.setServerTrace((ServerTrace) serverTraceComboBox.getSelectedItem()); + } + + @Override + protected @NotNull JComponent createEditor() { + return root; + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/AttachConfigurable.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/AttachConfigurable.java new file mode 100644 index 000000000..0143d0c8a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/AttachConfigurable.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations.options; + +import org.jetbrains.annotations.Nullable; + +/** + * Attach configurable. + */ +public interface AttachConfigurable { + + @Nullable String getAttachAddress(); + + void setAttachAddress(@Nullable String attachAddress); + + @Nullable String getAttachPort(); + + void setAttachPort(@Nullable String attachPort); + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/EnvironmentVariablesDataConfigurable.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/EnvironmentVariablesDataConfigurable.java new file mode 100644 index 000000000..b00057b27 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/EnvironmentVariablesDataConfigurable.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations.options; + +import com.intellij.execution.configuration.EnvironmentVariablesData; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Interface to be implemented by classes that are configurable with a {@link com.intellij.execution.configuration.EnvironmentVariablesData} option. + * This interface provides methods to get and set the environment variables data associated with the object. + */ +public interface EnvironmentVariablesDataConfigurable { + + /** + * Gets the environment variables data associated with this configuration. + * + * @return the environment variables data as a String, or null. + */ + @NotNull + EnvironmentVariablesData getEnvData(); + + /** + * Sets the environment variables data associated with this configuration. + * + * @param envData the environment variables data to set, or null. + */ + void setEnvData(@NotNull EnvironmentVariablesData envData); +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/ServerTraceConfigurable.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/ServerTraceConfigurable.java new file mode 100644 index 000000000..4a472ee97 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/configurations/options/ServerTraceConfigurable.java @@ -0,0 +1,24 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.configurations.options; + +import com.redhat.devtools.lsp4ij.settings.ServerTrace; + +/** + * Server trace configurable. + */ +public interface ServerTraceConfigurable { + + ServerTrace getServerTrace(); + + void setServerTrace(ServerTrace serverTrace); + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleView.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleView.java index d9a38cf97..2934c79e6 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleView.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleView.java @@ -22,7 +22,7 @@ import com.intellij.openapi.project.Project; import com.intellij.psi.search.GlobalSearchScope; import com.redhat.devtools.lsp4ij.console.actions.AutoFoldingAction; -import com.redhat.devtools.lsp4ij.console.actions.ClearThisConsoleAction; +import com.redhat.devtools.lsp4ij.console.actions.LSPClearConsoleAction; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; @@ -130,7 +130,7 @@ private static int findLastCompleteJsonBlockEnd(@NotNull String text) { consoleActions.add(new ScrollToTheEndToolbarAction(editor)); } consoleActions.add(ActionManager.getInstance().getAction("Print")); - consoleActions.add(new ClearThisConsoleAction(this)); + consoleActions.add(new LSPClearConsoleAction(this)); return consoleActions.toArray(AnAction.EMPTY_ARRAY); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptor.java new file mode 100644 index 000000000..a1cb0685f --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptor.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.definitions.userdefined; + +import com.intellij.execution.configurations.RunConfigurationOptions; +import com.intellij.execution.runners.ExecutionEnvironment; +import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; +import com.redhat.devtools.lsp4ij.dap.descriptors.DefaultDebugAdapterDescriptor; +import org.jetbrains.annotations.NotNull; + +public class UserDefinedDebugAdapterDescriptor extends DefaultDebugAdapterDescriptor { + + public UserDefinedDebugAdapterDescriptor(@NotNull RunConfigurationOptions options, @NotNull ExecutionEnvironment environment, @NotNull DebugAdapterServerDefinition serverDefinition) { + super(options, environment, serverDefinition); + } + + @Override + public boolean isShowInstallerTab() { + return true; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptorFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptorFactory.java index d8a906cd0..07cfa3f65 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptorFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterDescriptorFactory.java @@ -11,10 +11,13 @@ package com.redhat.devtools.lsp4ij.dap.definitions.userdefined; import com.intellij.execution.configurations.RunConfiguration; +import com.intellij.execution.configurations.RunConfigurationOptions; +import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.redhat.devtools.lsp4ij.dap.DebugServerWaitStrategy; import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfiguration; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptorFactory; import com.redhat.devtools.lsp4ij.installation.ServerInstaller; import com.redhat.devtools.lsp4ij.internal.StringUtils; @@ -31,6 +34,11 @@ */ public class UserDefinedDebugAdapterDescriptorFactory extends DebugAdapterDescriptorFactory { + @Override + public DebugAdapterDescriptor createDebugAdapterDescriptor(@NotNull RunConfigurationOptions options, @NotNull ExecutionEnvironment environment) { + return new UserDefinedDebugAdapterDescriptor(options, environment, getServerDefinition()); + } + @Override public boolean prepareConfiguration(@NotNull RunConfiguration configuration, @NotNull VirtualFile file, diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterServerDefinition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterServerDefinition.java index 459bf3dc2..c27b05357 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterServerDefinition.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/definitions/userdefined/UserDefinedDebugAdapterServerDefinition.java @@ -15,10 +15,8 @@ import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptorFactory; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; -import com.redhat.devtools.lsp4ij.installation.ServerInstaller; import com.redhat.devtools.lsp4ij.installation.definition.ServerInstallerDescriptor; import com.redhat.devtools.lsp4ij.installation.definition.ServerInstallerManager; -import com.redhat.devtools.lsp4ij.server.definition.launching.UserDefinedLanguageServerInstaller; import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import com.redhat.devtools.lsp4ij.server.definition.ServerMapping; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptor.java index 794a0f992..edb9e345f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptor.java @@ -18,27 +18,41 @@ import com.intellij.execution.process.ProcessHandlerFactory; import com.intellij.execution.process.ProcessTerminatedListener; import com.intellij.execution.runners.ExecutionEnvironment; +import com.intellij.execution.ui.ConsoleView; import com.intellij.openapi.fileTypes.FileType; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.util.net.NetUtils; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.evaluation.XDebuggerEditorsProvider; import com.redhat.devtools.lsp4ij.dap.DAPDebugProcess; +import com.redhat.devtools.lsp4ij.dap.DAPDebuggerEditorsProvider; import com.redhat.devtools.lsp4ij.dap.DebugMode; +import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointHandler; +import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointHandlerBase; import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.client.LaunchUtils; +import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationOptions; import com.redhat.devtools.lsp4ij.dap.configurations.DebuggableFile; +import com.redhat.devtools.lsp4ij.dap.configurations.options.AttachConfigurable; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; +import com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints.DisassemblyBreakpointHandler; +import com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints.DisassemblyBreakpointHandlerBase; import com.redhat.devtools.lsp4ij.internal.IntelliJPlatformUtils; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.settings.ServerTrace; import org.eclipse.lsp4j.debug.InitializeRequestArguments; import org.eclipse.lsp4j.debug.InitializeRequestArgumentsPathFormat; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.debug.RunInTerminalResponse; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.Collections; import java.util.Map; +import java.util.concurrent.CompletableFuture; import static com.redhat.devtools.lsp4ij.server.definition.launching.CommandUtils.createCommandLine; @@ -75,6 +89,41 @@ public String getId() { */ public abstract ProcessHandler startServer() throws ExecutionException; + /** + * Optionally provides the console for this debug session, used by + * {@link com.redhat.devtools.lsp4ij.dap.configurations.DAPCommandLineState#createConsole} + * instead of the default DAP text console. Lets a descriptor host a custom console in the debug + * tab — e.g. an interactive evaluation REPL for a browser session. Return {@code null} to keep + * the default behavior. + * + * @param project the project. + * @return the session console, or {@code null} to use the default. + */ + @Nullable + public ConsoleView createSessionConsole(@NotNull Project project) { + return null; + } + + /** + * Optionally fulfils a DAP {@code runInTerminal} reverse-request itself, instead of the default + * integrated/external terminal handling ({@link com.redhat.devtools.lsp4ij.dap.runInTerminal.RunInTerminalManager}). + *

    + * Lets a descriptor run the requested command in its own PTY and surface it inside the debug + * session UI (e.g. via {@link DAPDebugProcess#getRunnerLayoutUi()}) — giving an interactive + * (stdin + ANSI) console in the debug tab while the adapter's bootloader still auto-attaches the + * spawned child processes. Return {@code null} to keep the default behavior. + * + * @param args the {@code runInTerminal} arguments (argv, cwd, env, kind). + * @param client the DAP client that issued the request. + * @return a future completing with the {@link RunInTerminalResponse} (process id), or + * {@code null} to fall back to the default terminal services. + */ + @Nullable + public CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull DAPClient client) { + return null; + } + /** * Start the Debug Adapter server with the given command line. * @@ -163,6 +212,7 @@ public static Integer getServerPort(ProcessHandler processHandler) { args.setLinesStartAt1(true); args.setColumnsStartAt1(true); args.setSupportsStartDebuggingRequest(true); + args.setSupportsRunInTerminalRequest(true); return args; } @@ -213,6 +263,12 @@ public DebugMode getDebugMode() { @NotNull public ServerTrace getServerTrace() { + if (options instanceof DAPRunConfigurationOptions dapOptions) { + var serverTrace = dapOptions.getServerTrace(); + if (serverTrace != null) { + return serverTrace; + } + } return ServerTrace.getDefaultValue(); } @@ -222,7 +278,24 @@ public ServerTrace getServerTrace() { * @return the strategy to use to know when DAP server is started and DAP client can connect to it. */ @NotNull - public abstract ServerReadyConfig getServerReadyConfig(@NotNull DebugMode debugMode); + public ServerReadyConfig getServerReadyConfig(@NotNull DebugMode debugMode) { + if (options instanceof AttachConfigurable dapOptions) { + if (debugMode == DebugMode.ATTACH) { + String address = LaunchUtils.resolveAttachAddress(dapOptions.getAttachAddress(), getDapParameters()); + int port = LaunchUtils.resolveAttachPort(dapOptions.getAttachPort(), getDapParameters()); + return new ServerReadyConfig(address, port); + } + } + if (options instanceof DAPRunConfigurationOptions dapOptions) { + var strategy = dapOptions.getDebugServerWaitStrategy(); + return switch (strategy) { + case TIMEOUT -> new ServerReadyConfig(dapOptions.getConnectTimeout()); + case TRACE -> new ServerReadyConfig(dapOptions.getNetworkAddressExtractor()); + default -> new ServerReadyConfig(0); + }; + } + return new ServerReadyConfig(500); + } public abstract @Nullable FileType getFileType(); @@ -244,4 +317,26 @@ public String getServerName() { public boolean isDebuggableFile(@NotNull VirtualFile file, @NotNull Project project) { return serverDefinition != null && serverDefinition.isDebuggableFile(file, project); } + + public @NotNull DAPBreakpointHandlerBase createBreakpointHandler(@NotNull XDebugSession session, Project project) { + return new DAPBreakpointHandler(session, this, project); + } + + public @NotNull DisassemblyBreakpointHandlerBase createDisassemblyBreakpointHandler(@NotNull XDebugSession session, Project project) { + return new DisassemblyBreakpointHandler(session, this, project); + } + + public @NotNull XDebuggerEditorsProvider createDebuggerEditorsProvider(@Nullable FileType fileType, + @NotNull DAPDebugProcess debugProcess) { + return new DAPDebuggerEditorsProvider(fileType, debugProcess); + } + + /** + * Returns true if "Installer" tab must be shown and false otherwise. + * + * @return true if "Installer" tab must be shown and false otherwise. + */ + public boolean isShowInstallerTab() { + return false; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptorFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptorFactory.java index 792b6251e..ad4bc2308 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptorFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterDescriptorFactory.java @@ -11,18 +11,20 @@ package com.redhat.devtools.lsp4ij.dap.descriptors; import com.intellij.execution.configurations.RunConfiguration; +import com.intellij.execution.configurations.RunConfigurationOptions; import com.intellij.execution.runners.ExecutionEnvironment; import com.intellij.ide.plugins.IdeaPluginDescriptor; -import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.ide.plugins.PluginManager; import com.intellij.openapi.extensions.PluginId; import com.intellij.openapi.options.SettingsEditor; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.breakpoints.XBreakpointType; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.LaunchConfiguration; +import com.redhat.devtools.lsp4ij.dap.breakpoints.DAPBreakpointType; import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfiguration; -import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationOptions; import com.redhat.devtools.lsp4ij.dap.configurations.DAPSettingsEditor; import com.redhat.devtools.lsp4ij.dap.configurations.DebuggableFile; import com.redhat.devtools.lsp4ij.dap.configurations.options.FileOptionConfigurable; @@ -81,7 +83,7 @@ public abstract class DebugAdapterDescriptorFactory implements DebuggableFile { private ServerTrace serverTrace; - public DebugAdapterDescriptor createDebugAdapterDescriptor(@NotNull DAPRunConfigurationOptions options, + public DebugAdapterDescriptor createDebugAdapterDescriptor(@NotNull RunConfigurationOptions options, @NotNull ExecutionEnvironment environment) { return new DefaultDebugAdapterDescriptor(options, environment, getServerDefinition()); } @@ -157,7 +159,7 @@ public final void setServerDefinition(DebugAdapterServerDefinition serverDefinit public static Path getDebugAdapterServerPath(@NotNull String pluginId, @NotNull String serverPath) { - IdeaPluginDescriptor descriptor = PluginManagerCore.getPlugin(PluginId.getId(pluginId)); + IdeaPluginDescriptor descriptor = PluginManager.getInstance().findEnabledPlugin(PluginId.getId(pluginId)); assert descriptor != null; Path pluginPath = descriptor.getPluginPath(); assert pluginPath != null; @@ -182,4 +184,18 @@ public static Path getDebugAdapterServerPath(@NotNull String pluginId, public ServerInstaller createServerInstaller() { return null; } + + /** + * Determines whether the given breakpoint type is supported by this debugger. + *

    + * The default implementation returns {@code true} if the given {@code breakpointType} + * is exactly of type {@link DAPBreakpointType} (i.e., {@code breakpointType.getClass() == DAPBreakpointType.class}). + *

    + * + * @param breakpointType the breakpoint type to check + * @return {@code true} if the breakpoint type is supported, {@code false} otherwise + */ + public boolean supportsBreakpointType(@NotNull XBreakpointType breakpointType) { + return breakpointType.getClass() == DAPBreakpointType.class; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterVariableSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterVariableSupport.java index 0a6844051..d0ff6fcf8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterVariableSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DebugAdapterVariableSupport.java @@ -11,12 +11,18 @@ package com.redhat.devtools.lsp4ij.dap.descriptors; import com.intellij.icons.AllIcons; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.evaluation.ExpressionInfo; import com.intellij.xdebugger.frame.presentation.*; import com.redhat.devtools.lsp4ij.dap.client.variables.providers.DebugVariablePositionProvider; import com.redhat.devtools.lsp4ij.dap.client.variables.providers.HighlighterDebugVariablePositionProvider; import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.eclipse.lsp4j.debug.Variable; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import javax.swing.*; import java.util.Collection; @@ -33,7 +39,6 @@ public class DebugAdapterVariableSupport { * Returns the icon for the given variable. * * @param variable the variable. - * * @return the icon for the given variable. */ @NotNull @@ -64,7 +69,6 @@ public Icon getIcon(@NotNull Variable variable) { * Returns the value representation for the given variable. * * @param variable the variable. - * * @return value representation for the given variable. */ public @NotNull XValuePresentation getValuePresentation(@NotNull Variable variable) { @@ -154,4 +158,66 @@ private static String removeQuotes(@NotNull String value) { .replaceAll("^\"+|\"+$", "") .replaceAll("^\'+|\'+$", ""); } + + public @Nullable ExpressionInfo getExpressionInfo(@NotNull Project project, + @NotNull VirtualFile file, + @NotNull Document document, + int offset, + boolean sideEffectsAllowed) { + TextRange textRange = getTextRange(project, file, document, offset, sideEffectsAllowed); + if (textRange == null) { + return null; + } + // Extract the expression text + String expression = getExpression(project, file, document, offset, sideEffectsAllowed, textRange); + + // Build ExpressionInfo with the text range and expression string + return new ExpressionInfo(textRange, expression); + } + + protected @Nullable TextRange getTextRange(@NotNull Project project, + @NotNull VirtualFile file, + @NotNull Document document, + int offset, + boolean sideEffectsAllowed) { + // Get the entire document text + String text = document.getText(); + + // Safety check: ensure offset is within bounds + if (offset < 0 || offset >= text.length()) { + return null; + } + + // Find the start of the identifier at the given offset + int start = offset; + while (start > 0) { + char ch = text.charAt(start - 1); + if (Character.isJavaIdentifierPart(ch) || ch == '.') { + start--; + } else { + break; + } + } + + // Find the end of the identifier at the given offset + int end = offset; + while (end < text.length() && Character.isJavaIdentifierPart(text.charAt(end))) { + end++; + } + + // If there is no identifier at the offset, do not return an expression + if (start == end) { + return null; + } + return new TextRange(start, end); + } + + protected @NotNull String getExpression(@NotNull Project project, + @NotNull VirtualFile file, + @NotNull Document document, + int offset, + boolean sideEffectsAllowed, + @NotNull TextRange textRange) { + return document.getText(textRange); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DefaultDebugAdapterDescriptor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DefaultDebugAdapterDescriptor.java index 181bc6bf5..60ee18103 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DefaultDebugAdapterDescriptor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/DefaultDebugAdapterDescriptor.java @@ -11,6 +11,7 @@ package com.redhat.devtools.lsp4ij.dap.descriptors; import com.intellij.execution.ExecutionException; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.configurations.RunConfigurationOptions; import com.intellij.execution.process.ProcessHandler; @@ -22,11 +23,11 @@ import com.redhat.devtools.lsp4ij.dap.DebugMode; import com.redhat.devtools.lsp4ij.dap.client.LaunchUtils; import com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationOptions; +import com.redhat.devtools.lsp4ij.dap.configurations.options.EnvironmentVariablesDataConfigurable; +import com.redhat.devtools.lsp4ij.dap.configurations.options.FileOptionConfigurable; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; -import com.redhat.devtools.lsp4ij.dap.definitions.userdefined.UserDefinedDebugAdapterServerDefinition; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; import com.redhat.devtools.lsp4ij.internal.StringUtils; -import com.redhat.devtools.lsp4ij.settings.ServerTrace; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -52,7 +53,7 @@ public DefaultDebugAdapterDescriptor(@NotNull RunConfigurationOptions options, this.id = serverDefinition.getId(); } - public DefaultDebugAdapterDescriptor(@NotNull DAPRunConfigurationOptions options, + public DefaultDebugAdapterDescriptor(@NotNull RunConfigurationOptions options, @NotNull ExecutionEnvironment environment, @NotNull String runConfigurationName) { super(options, environment, null); @@ -85,47 +86,34 @@ protected GeneralCommandLine createStartServerCommandLine(@NotNull RunConfigurat command = userDefinedServer.getCommandLine(); } } - // TODO : store env configuration in the options + // Environment variables Map userEnvironmentVariables = new HashMap<>(); boolean includeSystemEnvironmentVariables = true; + var envData = getEnvData(); + if (envData != null) { + userEnvironmentVariables.putAll(envData.getEnvs()); + includeSystemEnvironmentVariables = envData.isPassParentEnvs(); + } + String resolvedCommandLine = resolveCommandLine(command, environment.getProject()); return createStartServerCommandLine(resolvedCommandLine, userEnvironmentVariables, includeSystemEnvironmentVariables); } return null; } - /** - * Returns the strategy to use to know when DAP server is started and DAP client can connect to it. - * - * @return the strategy to use to know when DAP server is started and DAP client can connect to it. - */ - @NotNull - public ServerReadyConfig getServerReadyConfig(@NotNull DebugMode debugMode) { - if (options instanceof DAPRunConfigurationOptions dapOptions) { - if (debugMode == DebugMode.ATTACH) { - String address = LaunchUtils.resolveAttachAddress(dapOptions.getAttachAddress(), getDapParameters()); - int port = LaunchUtils.resolveAttachPort(dapOptions.getAttachPort(), getDapParameters()); - return new ServerReadyConfig(address, port); - } - var strategy = dapOptions.getDebugServerWaitStrategy(); - switch (strategy) { - case TIMEOUT: - return new ServerReadyConfig(dapOptions.getConnectTimeout()); - case TRACE: - return new ServerReadyConfig(dapOptions.getNetworkAddressExtractor()); - default: - return new ServerReadyConfig(0); - } + protected @Nullable EnvironmentVariablesData getEnvData() { + if (environment.getRunProfile() instanceof EnvironmentVariablesDataConfigurable runConfiguration) { + return runConfiguration.getEnvData(); } - return new ServerReadyConfig(500); + return null; } public @Nullable FileType getFileType() { - if (options instanceof DAPRunConfigurationOptions dapOptions) { + if (options instanceof FileOptionConfigurable dapOptions) { String file = dapOptions.getFile(); int index = file != null ? file.lastIndexOf('.') : -1; if (index != -1) { - String fileExtension = file.substring(index + 1, file.length()); + String fileExtension = file.substring(index + 1); return FileTypeManager.getInstance().getFileTypeByExtension(fileExtension); } } @@ -153,14 +141,6 @@ public DebugMode getDebugMode() { return super.getDebugMode(); } - @NotNull - public ServerTrace getServerTrace() { - if (options instanceof DAPRunConfigurationOptions dapOptions) { - return dapOptions.getServerTrace(); - } - return super.getServerTrace(); - } - /** * Returns the DAP server name. * @@ -172,7 +152,7 @@ public String getServerName() { if (options instanceof DAPRunConfigurationOptions dapOptions) { serverName = dapOptions.getServerName(); } - if (StringUtils.isBlank(serverName) ) { + if (StringUtils.isBlank(serverName)) { return super.getServerName(); } return serverName; @@ -180,7 +160,7 @@ public String getServerName() { @Override public boolean isDebuggableFile(@NotNull VirtualFile file, @NotNull Project project) { - if(getServerDefinition() != null && + if (getServerDefinition() != null && super.isDebuggableFile(file, project)) { return true; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateDeserializer.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateDeserializer.java index 34a5720ee..b82d64352 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateDeserializer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateDeserializer.java @@ -12,15 +12,12 @@ import com.google.common.reflect.TypeToken; import com.google.gson.*; -import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; -import com.redhat.devtools.lsp4ij.templates.ServerTemplate; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.redhat.devtools.lsp4ij.templates.ServerTemplateJsonDeserializer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import static com.redhat.devtools.lsp4ij.dap.descriptors.templates.DAPTemplate.*; @@ -51,6 +48,7 @@ protected void deserializeCustom(@NotNull DAPTemplate dapTemplate, Map programArgsMap = gson.fromJson(programArgs, mapType); dapTemplate.setProgramArgs(programArgsMap); } + dapTemplate.setEnvData(deserializeEnvData(jsonObject)); } JsonElement connectTimeout = jsonObject.get(CONNECT_TIMEOUT_JSON_PROPERTY); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateManager.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateManager.java index bfab6130e..76e830394 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/descriptors/templates/DAPTemplateManager.java @@ -10,29 +10,32 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.descriptors.templates; -import com.google.gson.*; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.vfs.JarFileSystem; -import com.intellij.openapi.vfs.VfsUtilCore; -import com.intellij.openapi.vfs.VirtualFile; -import com.redhat.devtools.lsp4ij.dap.DebugMode; -import com.redhat.devtools.lsp4ij.dap.LaunchConfiguration; -import com.redhat.devtools.lsp4ij.templates.ServerTemplateManager; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import static com.redhat.devtools.lsp4ij.dap.descriptors.templates.DAPTemplate.ATTACH_FILE_START_NAME; +import static com.redhat.devtools.lsp4ij.dap.descriptors.templates.DAPTemplate.LAUNCH_FILE_START_NAME; +import static com.redhat.devtools.lsp4ij.templates.ServerTemplate.INSTALLER_FILE_NAME; +import static com.redhat.devtools.lsp4ij.templates.ServerTemplate.TEMPLATE_FILE_NAME; import java.io.FileNotFoundException; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.net.URL; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; -import static com.redhat.devtools.lsp4ij.dap.descriptors.templates.DAPTemplate.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.dap.DebugMode; +import com.redhat.devtools.lsp4ij.dap.LaunchConfiguration; +import com.redhat.devtools.lsp4ij.templates.ServerTemplateManager; /** * DAP (Debug Adapter Protocol) template manager. diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DAPAlternativeSourceHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DAPAlternativeSourceHandler.java new file mode 100644 index 000000000..b339c60fc --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DAPAlternativeSourceHandler.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.openapi.Disposable; +import com.intellij.openapi.fileEditor.FileEditorManagerEvent; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.xdebugger.XAlternativeSourceHandler; +import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.frame.XStackFrame; +import com.intellij.xdebugger.frame.XSuspendContext; +import com.redhat.devtools.lsp4ij.dap.DAPDebugProcess; +import com.redhat.devtools.lsp4ij.dap.client.DAPStackFrame; +import kotlinx.coroutines.flow.StateFlow; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.StateFlowKt; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Alternative source handler that provides a disassembly view for a stack frame. + *

    + * This handler listens for editor tab selection changes and activates the disassembly view + * when the currently selected file corresponds to a disassembly file associated + * with the {@link DAPDebugProcess}. + */ +public class DAPAlternativeSourceHandler implements XAlternativeSourceHandler, Disposable { + + /** + * State flow indicating whether the alternative source (disassembly) is currently available. + */ + private final MutableStateFlow alternativeSourceAvailable = StateFlowKt.MutableStateFlow(false); + + /** + * The DAP debug process associated with this handler. + */ + private final DAPDebugProcess debugProcess; + + /** + * Listener that tracks changes to the selected editor tab in order to activate the disassembly view. + */ + private final FileEditorManagerListener selectedFileListener; + + /** + * Constructs a new DAPAlternativeSourceHandler for the given DAP debug process. + * + * @param debugProcess the debug process this handler is associated with + */ + public DAPAlternativeSourceHandler(@NotNull DAPDebugProcess debugProcess) { + this.debugProcess = debugProcess; + this.alternativeSourceAvailable.setValue(false); + + // Initialize the listener for editor tab changes + this.selectedFileListener = new FileEditorManagerListener() { + @Override + public void selectionChanged(@NotNull FileEditorManagerEvent event) { + // Activate the disassembly view if the newly selected file is the disassembly file + var disassemblyFile = debugProcess.getDisassemblyFile(); + boolean activateDisassembly = (disassemblyFile != null && disassemblyFile.equals(event.getNewFile())); + alternativeSourceAvailable.setValue(activateDisassembly); + } + }; + + // Subscribe the listener to the project's message bus + debugProcess.getProject().getMessageBus().connect() + .subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, selectedFileListener); + } + + /** + * Returns the state flow representing whether an alternative source (disassembly) is available. + * + * @return a {@link StateFlow} of boolean indicating alternative source availability + */ + @Override + public @NotNull StateFlow<@NotNull Boolean> getAlternativeSourceKindState() { + return alternativeSourceAvailable; + } + + /** + * Determines if the alternative source kind (disassembly) should be preferred for a given suspend context. + * + * @param context the current suspend context + * @return true if the disassembly view should be preferred, false otherwise + */ + @Override + public boolean isAlternativeSourceKindPreferred(@NotNull XSuspendContext context) { + return getAlternativeSourceKindState().getValue(); + } + + /** + * Returns the alternative source position for the given stack frame. + * + * @param stackFrame the stack frame to obtain an alternative position for + * @return the {@link XSourcePosition} corresponding to the disassembly, or null if not available + */ + @Override + public @Nullable XSourcePosition getAlternativePosition(@NotNull XStackFrame stackFrame) { + if (!(stackFrame instanceof DAPStackFrame dapFrame)) { + return null; + } + return dapFrame.getAlternativePosition(); + } + + /** + * Disposes this handler and unregisters its listener from the message bus. + */ + @Override + public void dispose() { + debugProcess.getProject().getMessageBus().connect(debugProcess) + .subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, selectedFileListener); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassembledInstructionEntry.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassembledInstructionEntry.java new file mode 100644 index 000000000..9162b061d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassembledInstructionEntry.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly virtual file support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import org.eclipse.lsp4j.debug.DisassembledInstruction; + +import java.math.BigInteger; + +/** + * Represents a single disassembled instruction entry in a DisassemblyFile. + *

    + * Each entry contains: + *

      + *
    • {@code instructionReference} - the memory reference or identifier from the DAP server
    • + *
    • {@code instructionReferenceOffset} - offset from the instruction reference in memory
    • + *
    • {@code instructionOffset} - offset of the instruction within a batch of instructions
    • + *
    • {@code address} - the resolved memory address of this instruction
    • + *
    • {@code instr} - the original {@link DisassembledInstruction} object from the DAP server
    • + *
    + */ +public record DisassembledInstructionEntry( + String instructionReference, + Integer instructionReferenceOffset, + Integer instructionOffset, + BigInteger address, + DisassembledInstruction instr +) { +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyDeferredSourcePosition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyDeferredSourcePosition.java new file mode 100644 index 000000000..6c2bffdfe --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyDeferredSourcePosition.java @@ -0,0 +1,64 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.client.files.DeferredSourcePosition; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Represents a deferred source position for a disassembly instruction in a {@link DisassemblyFile}. + *

    + * The line corresponding to the instruction pointer reference is resolved asynchronously. + * If the instruction is not yet loaded in the document, the required range of instructions + * will be fetched from the debug adapter, the document will be updated, and then the line is returned. + */ +public class DisassemblyDeferredSourcePosition extends DeferredSourcePosition { + + /** + * Creates a deferred source position for a disassembly instruction. + * + * @param instructionPointerReference the instruction pointer reference identifying the instruction + * @param file the disassembly file associated with this position + * @param client the DAP client used to fetch information if needed + */ + public DisassemblyDeferredSourcePosition(@NotNull String instructionPointerReference, + @NotNull DisassemblyFile file, + @NotNull DAPClient client) { + super(instructionPointerReference, file, client); + } + + /** + * Asynchronously loads the line number corresponding to the instruction pointer. + *

    + * This implementation delegates to {@link DisassemblyFile#getInstructionIndex(String, int, DAPClient)}. + *

      + *
    • If the instruction is already present in the document, it returns the existing line index.
    • + *
    • If the instruction is not present, it fetches the relevant instruction range from the debug adapter, + * updates the document with the new instructions, and then returns the resolved line.
    • + *
    + * + * @param instructionPointerReference the instruction pointer reference to resolve + * @param disassemblyFile the file containing the disassembly instructions + * @param client the DAP client used to fetch additional instructions if necessary + * @return a {@link CompletableFuture} completing with the resolved line index + */ + @Override + protected CompletableFuture loadAndResolveLineAsync(@NotNull String instructionPointerReference, + @NotNull DisassemblyFile disassemblyFile, + @NotNull DAPClient client) { + // Resolves the line of the instruction asynchronously. + // If not found, fetches the instruction range, updates the document, then resolves the line. + return disassemblyFile.getInstructionIndex(instructionPointerReference, 0, client); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFile.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFile.java new file mode 100644 index 000000000..e092440a9 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFile.java @@ -0,0 +1,284 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly virtual file support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.NlsSafe; +import com.intellij.openapi.util.SimpleModificationTracker; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.client.files.DAPFile; +import org.eclipse.lsp4j.debug.DisassembleArguments; +import org.eclipse.lsp4j.debug.DisassembleResponse; +import org.eclipse.lsp4j.debug.DisassembledInstruction; +import org.eclipse.lsp4j.debug.Source; +import org.eclipse.lsp4j.debug.services.IDebugProtocolServer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyUtils.binarySearch2; +import static com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyUtils.splice; + +/** + * Represents a virtual file that contains disassembled machine instructions + * from a DAP (Debug Adapter Protocol) server. + *

    + * Each instance is associated with a specific run configuration and project. + * The file dynamically fetches disassembly instructions from the server + * and caches them for display in the disassembly editor. + */ +public class DisassemblyFile extends DAPFile { + + public static final String FILE_NAME = "disassembly"; + private static final Logger LOGGER = LoggerFactory.getLogger(DisassemblyFile.class); + /** + * Number of instructions to load initially or per batch. + */ + private static final int NUM_INSTRUCTIONS_TO_LOAD = 50; + private final SimpleModificationTracker modificationTracker = new SimpleModificationTracker(); + /** + * Maps instruction references to memory addresses. + */ + private final Map referenceToMemoryAddress = new HashMap<>(); + + /** + * All disassembled instructions loaded so far. + */ + private final List disassembledInstructions = new ArrayList<>(); + private @NotNull + @NlsSafe String presentableName; + + /** + * Creates a new DisassemblyFile for the given configuration and project. + * + * @param configName the run configuration name + * @param path the unique path for this file (project hash + config) + * @param project the associated IntelliJ project + */ + public DisassemblyFile(@NotNull String configName, + @NotNull String path, + @NotNull Project project) { + super("Disassembly (" + configName + ").disasm", path, DisassemblyFileType.INSTANCE, project); + presentableName = "Disassembly (" + configName + ")"; + } + + @Override + public @NotNull @NlsSafe String getPresentableName() { + return presentableName; + } + + /** + * Sends a disassemble request to the DAP server. + * + * @param instructionReference memory reference to disassemble + * @param offset memory offset from reference + * @param instructionOffset instruction offset for batch loading + * @param instructionCount number of instructions to request + * @param server the DAP server instance + * @return a future containing the disassembly response + */ + private static CompletableFuture disassemble(String instructionReference, + Integer offset, + Integer instructionOffset, + Integer instructionCount, + IDebugProtocolServer server) { + DisassembleArguments args = new DisassembleArguments(); + args.setMemoryReference(instructionReference); + args.setOffset(offset); + args.setInstructionOffset(instructionOffset); + args.setInstructionCount(instructionCount); + args.setResolveSymbols(true); + return server.disassemble(args); + } + + /** + * Returns the index of the instruction corresponding to a memory reference + * and offset, loading instructions from the server if necessary. + * + * @param instructionReference the instruction reference identifier + * @param offset offset in memory from the reference + * @param client the DAP client to fetch instructions + * @return a CompletableFuture with the index of the instruction in the list + */ + public CompletableFuture getInstructionIndex(String instructionReference, int offset, DAPClient client) { + BigInteger addr = referenceToMemoryAddress.get(instructionReference); + if (addr == null) { + return loadDisassembledInstructions(instructionReference, 0, -NUM_INSTRUCTIONS_TO_LOAD, NUM_INSTRUCTIONS_TO_LOAD * 2, client) + .thenApply(_unused -> { + BigInteger memoryAddress = referenceToMemoryAddress.get(instructionReference); + if (memoryAddress != null) { + return getIndexFromAddress(memoryAddress.add(BigInteger.valueOf(offset))); + } + return -1; + }); + } else { + return CompletableFuture.completedFuture(getIndexFromAddress(addr.add(BigInteger.valueOf(offset)))); + } + } + + /** + * Loads disassembled instructions from the DAP server, merges them with + * the existing list, and updates the document text in the editor. + * + * @param instructionReference the instruction reference identifier + * @param offset memory offset + * @param instructionOffset instruction offset within batch + * @param instructionCount number of instructions to fetch + * @param client DAP client to use for fetching + * @return a future with the number of instructions loaded + */ + private CompletableFuture loadDisassembledInstructions(String instructionReference, + Integer offset, + Integer instructionOffset, + Integer instructionCount, + DAPClient client) { + var server = client.getDebugProtocolServer(); + if (server == null) { + return CompletableFuture.completedFuture(0); + } + + CompletableFuture resultEntriesFuture = + disassemble(instructionReference, offset, instructionOffset, instructionCount, server); + + CompletableFuture ensureBaseLineInstructions = null; + if (referenceToMemoryAddress.containsKey(instructionReference) && + instructionOffset != null && instructionOffset != 0) { + ensureBaseLineInstructions = + this.loadDisassembledInstructions(instructionReference, 0, 0, NUM_INSTRUCTIONS_TO_LOAD, client); + } + + CompletableFuture result = ensureBaseLineInstructions != null ? + ensureBaseLineInstructions.thenCompose(index -> resultEntriesFuture) : + resultEntriesFuture; + + return result.thenCompose(disassembleResponse -> { + if (disassembleResponse == null) { + return CompletableFuture.completedFuture(0); + } + + var resultEntries = disassembleResponse.getInstructions(); + if (resultEntries == null || resultEntries.length == 0) { + return CompletableFuture.completedFuture(0); + } + + List newEntries = new ArrayList<>(); + Source lastLocation = null; + + for (int i = 0; i < resultEntries.length; i++) { + DisassembledInstruction instr = resultEntries[i]; + int thisInstructionOffset = instructionOffset + i; + + if (instr.getLocation() != null) lastLocation = instr.getLocation(); + if (instr.getLine() != null) instr.setLocation(lastLocation); + + BigInteger address; + try { + address = DisassemblyUtils.parseBigInteger(instr.getAddress()); + } catch (Exception ex) { + LOGGER.warn("Could not parse disassembly address {}", instr.getAddress()); + continue; + } + if (address.equals(BigInteger.valueOf(-1))) continue; + + DisassembledInstructionEntry entry = new DisassembledInstructionEntry( + instructionReference, offset, thisInstructionOffset, address, instr); + newEntries.add(entry); + + if (offset == 0 && thisInstructionOffset == 0) { + referenceToMemoryAddress.put(instructionReference, address); + } + } + + if (newEntries.isEmpty()) { + return CompletableFuture.completedFuture(0); + } + + BigInteger firstAddr = newEntries.get(0).address(); + BigInteger lastAddr = newEntries.get(newEntries.size() - 1).address(); + + int startN = binarySearch2(disassembledInstructions.size(), i -> + disassembledInstructions.get(i).address().subtract(firstAddr).intValue()); + int start = startN < 0 ? ~startN : startN; + int endN = binarySearch2(disassembledInstructions.size(), i -> + disassembledInstructions.get(i).address().subtract(lastAddr).intValue()); + int end = endN < 0 ? ~endN : endN + 1; + + int toDelete = end - start; + splice(disassembledInstructions, start, toDelete, newEntries); + + // Update the editor document asynchronously + CompletableFuture updatedDoc = new CompletableFuture<>(); + WriteCommandAction.runWriteCommandAction(getProject(), () -> { + var doc = FileDocumentManager.getInstance().getDocument(this); + StringBuilder sb = new StringBuilder(); + for (var instr : new ArrayList<>(disassembledInstructions)) { + sb.append(String.format("%s: %s %s\n", + instr.instr().getAddress(), + instr.instr().getInstructionBytes(), + instr.instr().getInstruction())); + } + doc.setText(sb.toString()); + DisassemblyFile.this.modificationTracker.incModificationCount(); + updatedDoc.complete(newEntries.size() - toDelete); + }); + return updatedDoc; + }); + } + + /** + * Returns the index of the instruction in the loaded list that corresponds + * to the given memory address. Returns -1 if not found. + */ + private int getIndexFromAddress(BigInteger address) { + int low = 0, high = disassembledInstructions.size() - 1; + while (low <= high) { + int mid = (low + high) >>> 1; + BigInteger rowAddr = disassembledInstructions.get(mid).address(); + int cmp = rowAddr.compareTo(address); + if (cmp == 0) return mid; + if (cmp < 0) low = mid + 1; + else high = mid - 1; + } + return -1; + } + + /** + * Returns the memory address corresponding to an instruction reference, if loaded. + */ + public @Nullable BigInteger getReferenceAddress(String instructionReference) { + return referenceToMemoryAddress.get(instructionReference); + } + + public void dispose() { + // Nothing special to dispose for now + } + + public @Nullable DisassembledInstructionEntry getInstructionAt(int line) { + return line < disassembledInstructions.size() ? disassembledInstructions.get(line) : null; + } + + @Override + public long getModificationCount() { + return modificationTracker.getModificationCount(); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditor.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditor.java new file mode 100644 index 000000000..f2f1b4af0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditor.java @@ -0,0 +1,156 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly editor support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.EditorFactory; +import com.intellij.openapi.editor.EditorKind; +import com.intellij.openapi.editor.ex.EditorEx; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorState; +import com.intellij.openapi.fileEditor.OpenFileDescriptor; +import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.pom.Navigatable; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.beans.PropertyChangeListener; + +/** + * A custom editor for {@link DisassemblyFile} instances. + *

    + * Displays machine-level disassembly instructions generated by a DAP server + * in the main editor area, using a read-only text editor with features like + * line numbers and folding enabled. + */ +public class DisassemblyFileEditor implements TextEditor { + + /** The virtual disassembly file being displayed. */ + private final @NotNull DisassemblyFile file; + + /** The IntelliJ editor instance used to render the disassembly content. */ + private final @NotNull EditorEx editor; + + /** + * Constructs a new DisassemblyFileEditor for the given file and project. + * + * @param file the disassembly virtual file + * @param project the IntelliJ project + */ + public DisassemblyFileEditor(@NotNull DisassemblyFile file, @NotNull Project project) { + this.file = file; + this.editor = createEditor(file, project); + } + + /** + * Creates and configures an editor instance for the given virtual file. + *

    + * The editor is read-only, with line numbers and folding enabled. + * + * @param file the virtual file to edit + * @param project the project + * @return a configured EditorEx instance + */ + private static @NotNull EditorEx createEditor(@NotNull VirtualFile file, @NotNull Project project) { + var doc = FileDocumentManager.getInstance().getDocument(file); + var disassemblyEditor = (EditorEx) EditorFactory.getInstance().createEditor(doc, project, file, true, EditorKind.MAIN_EDITOR); + + // Editor settings for disassembly display + disassemblyEditor.getSettings().setLineNumbersShown(true); + disassemblyEditor.getSettings().setFoldingOutlineShown(true); + disassemblyEditor.getSettings().setCaretRowShown(false); + disassemblyEditor.getSettings().setRightMarginShown(false); + disassemblyEditor.getSettings().setGutterIconsShown(true); + + return disassemblyEditor; + } + + @Override + public @NotNull JComponent getComponent() { + return editor.getComponent(); + } + + @Override + public @Nullable JComponent getPreferredFocusedComponent() { + return editor.getComponent(); + } + + @Override + public @NotNull String getName() { + return "Disassembly"; + } + + @Override + public VirtualFile getFile() { + return file; + } + + @Override + public void setState(@NotNull FileEditorState state) { + // No special editor state to restore + } + + @Override + public boolean isModified() { + return false; // Disassembly is read-only + } + + @Override + public boolean isValid() { + return true; + } + + @Override + public void addPropertyChangeListener(@NotNull PropertyChangeListener listener) { + editor.addPropertyChangeListener(listener); + } + + @Override + public void removePropertyChangeListener(@NotNull PropertyChangeListener listener) { + editor.removePropertyChangeListener(listener); + } + + @Override + public void dispose() { + // The editor will be disposed by IntelliJ editor factory + } + + @Override + public @Nullable T getUserData(@NotNull Key key) { + return editor.getUserData(key); + } + + @Override + public void putUserData(@NotNull Key key, @Nullable T value) { + editor.putUserData(key, value); + } + + @Override + public @NotNull Editor getEditor() { + return editor; + } + + @Override + public boolean canNavigateTo(@NotNull Navigatable navigatable) { + return navigatable instanceof OpenFileDescriptor + && file.equals(((OpenFileDescriptor) navigatable).getFile()); + } + + @Override + public void navigateTo(@NotNull Navigatable navigatable) { + ((OpenFileDescriptor) navigatable).navigateIn(getEditor()); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditorProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditorProvider.java new file mode 100644 index 000000000..0794f2468 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileEditorProvider.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly editor support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.fileEditor.FileEditor; +import com.intellij.openapi.fileEditor.FileEditorPolicy; +import com.intellij.openapi.fileEditor.FileEditorProvider; +import com.redhat.devtools.lsp4ij.dap.client.files.DAPFileSystem; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; + +/** + * Provides a custom editor for {@link DisassemblyFile} instances from the + * {@link DAPFileSystem}. + *

    + * This allows disassembly content (machine instructions generated by the + * Debug Adapter Protocol) to be displayed directly in the editor area + * instead of a separate tool window. + *

    + * Only files that are instances of {@link DisassemblyFile} are accepted. + */ +public class DisassemblyFileEditorProvider implements FileEditorProvider, DumbAware { + + /** + * Determines whether this provider can handle the given file. + * + * @param project the current project + * @param file the virtual file to check + * @return true if the file is a {@link DisassemblyFile}, false otherwise + */ + @Override + public boolean accept(@NotNull Project project, @NotNull VirtualFile file) { + // Only accept files from our custom VFS (DisassemblyFile instances) + return file instanceof DisassemblyFile; + } + + /** + * Creates the actual editor instance for the given DisassemblyFile. + * + * @param project the current project + * @param file the virtual file to edit (must be a DisassemblyFile) + * @return a FileEditor that displays the disassembly content + */ + @Override + public @NotNull FileEditor createEditor(@NotNull Project project, @NotNull VirtualFile file) { + // Wrap the DisassemblyFile inside a DisassemblyFileEditor + return new DisassemblyFileEditor((DisassemblyFile) file, project); + } + + /** + * Returns the unique editor type ID for this editor provider. + * + * @return the editor type ID string + */ + @Override + public @NotNull @NonNls String getEditorTypeId() { + return "lsp.disassembly"; + } + + /** + * Defines the policy for displaying editors of this type. + * + * @return {@link FileEditorPolicy#HIDE_DEFAULT_EDITOR} to hide the default text editor + * and show only the custom disassembly editor + */ + @Override + public @NotNull FileEditorPolicy getPolicy() { + return FileEditorPolicy.HIDE_DEFAULT_EDITOR; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileType.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileType.java new file mode 100644 index 000000000..4665925c4 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyFileType.java @@ -0,0 +1,92 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.fileTypes.LanguageFileType; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; + +/** + * A special IntelliJ file type used to represent a disassembly view + * generated from a Debug Adapter Protocol (DAP) session. + *

    + * This file type: + *

      + *
    • Represents virtual files containing machine-level disassembly instructions.
    • + *
    • Is read-only and does not map to any actual file on disk.
    • + *
    • Uses no file extension since it is only generated at runtime.
    • + *
    • Is specifically associated with instances of {@link DisassemblyFile}.
    • + *
    + */ +public class DisassemblyFileType extends LanguageFileType { + + /** + * Singleton instance of the disassembly file type. + */ + public static final DisassemblyFileType INSTANCE = new DisassemblyFileType(); + + protected DisassemblyFileType() { + super(DisassemblyLanguage.INSTANCE); + } + + @Override + public @NotNull String getName() { + return "Disassembly"; + } + + @Override + public @NotNull String getDefaultExtension() { + return ""; + } + + @Override + public @Nullable Icon getIcon() { + return AllIcons.FileTypes.Archive; + } + + /** + * Returns a description of this file type for display in settings or tooltips. + * + * @return a user-friendly description of the disassembly file type + */ + @Override + public @NotNull String getDescription() { + return "Virtual file type representing machine-level disassembly instructions " + + "generated from a Debug Adapter Protocol (DAP) debug session."; + } + + /** + * Returns the display name of this file type for use in IntelliJ UI components. + * + * @return a user-friendly display name + */ + @Override + public @Nls @NotNull String getDisplayName() { + return "Disassembly View"; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public @NonNls @Nullable String getCharset(@NotNull VirtualFile file, byte @NotNull [] content) { + return super.getCharset(file, content); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyLanguage.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyLanguage.java new file mode 100644 index 000000000..597d79bef --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyLanguage.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.lang.Language; + +/** + * Disassembly language. + */ +public class DisassemblyLanguage extends Language { + public static final DisassemblyLanguage INSTANCE = new DisassemblyLanguage(); + + private DisassemblyLanguage() { + super("Disassembly"); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblySpellcheckingStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblySpellcheckingStrategy.java new file mode 100644 index 000000000..e98fcdf93 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblySpellcheckingStrategy.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.psi.PsiElement; +import com.intellij.spellchecker.tokenizer.SpellcheckingStrategy; +import com.intellij.spellchecker.tokenizer.Tokenizer; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly spell checking strategy. + */ +public class DisassemblySpellcheckingStrategy extends SpellcheckingStrategy { + + @Override + public @NotNull Tokenizer getTokenizer(@NotNull PsiElement element) { + return EMPTY_TOKENIZER; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyTextMateBundleProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyTextMateBundleProvider.java new file mode 100644 index 000000000..c903afd6b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyTextMateBundleProvider.java @@ -0,0 +1,80 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly editor support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.ide.plugins.IdeaPluginDescriptor; +import com.intellij.ide.plugins.PluginManager; +import com.intellij.openapi.application.PathManager; +import com.intellij.openapi.extensions.PluginId; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.plugins.textmate.api.TextMateBundleProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Objects; + +/** + * TextMate bundle provider for Disassembly file. + */ +public class DisassemblyTextMateBundleProvider implements TextMateBundleProvider { + + private static final Logger LOGGER = LoggerFactory.getLogger(DisassemblyTextMateBundleProvider.class); + + private static final String RESOURCE_PATH = "/textmate"; + private static final String BUNDLE_NAME = "disasm"; + + @NotNull + @Override + public List getBundles() { + Path bundlePath = getBundlePath(); + PluginBundle pluginBundle = new PluginBundle(BUNDLE_NAME, bundlePath); + return List.of(pluginBundle); + } + + private Path getBundlePath() { + try { + IdeaPluginDescriptor plugin = PluginManager.getInstance().findEnabledPlugin(PluginId.getId("com.redhat.devtools.lsp4ij")); + String path = PathManager.getSystemDir().resolve("lsp4ij/bundles/" + plugin.getVersion()).toString(); + return copyResourceDirectory(path, List.of("package.json", "syntaxes/disassembly.json")); + } catch (IOException ex) { + LOGGER.error("Bundles error: " + ex); + throw new UncheckedIOException(ex); + } + } + + private Path copyResourceDirectory(@NotNull String target, List fileNames) throws IOException { + Path targetPath = Paths.get(target); + if (Files.notExists(targetPath)) { + Files.createDirectories(targetPath); + } + for (String fileName: fileNames) { + Path pathToCopyFile = new File(target + "/" + fileName).toPath(); + if (Files.notExists(pathToCopyFile)) { + var dir = pathToCopyFile.getParent(); + if (Files.notExists(dir)) { + Files.createDirectories(dir); + } + Files.copy( + Objects.requireNonNull(DisassemblyTextMateBundleProvider.class.getResourceAsStream(RESOURCE_PATH + "/" + fileName)), + pathToCopyFile); + } + } + return targetPath; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtils.java new file mode 100644 index 000000000..4a85540d9 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtils.java @@ -0,0 +1,194 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; +import java.util.function.IntFunction; + +/** + * Utility class providing helper methods for disassembly operations used by the + * Debug Adapter Protocol (DAP) integration. + *

    + * This class provides a set of static utility methods commonly required when + * dealing with memory addresses, instruction offsets, and array-like manipulations + * that are inspired by JavaScript behavior, such as {@code BigInt} parsing and + * {@code Array.splice}. + *

    + * + *

    Main features:

    + *
      + *
    • Parsing string representations of numbers with different prefixes + * (hexadecimal, octal, binary, decimal) into {@link BigInteger}.
    • + *
    • Emulating JavaScript's {@code Array.splice} behavior for modifying lists.
    • + *
    • Performing efficient binary searches using a comparator function.
    • + *
    + * + *

    Example usage:

    + *
    {@code
    + * // Parsing different numeric representations
    + * BigInteger hex = DisassemblyUtils.parse("0xFF");
    + * BigInteger binary = DisassemblyUtils.parse("0b1010");
    + * BigInteger octal = DisassemblyUtils.parse("0o755");
    + * BigInteger decimal = DisassemblyUtils.parse("1234");
    + *
    + * // Using splice to modify a list
    + * List list = new ArrayList<>(List.of("A", "B", "C", "D"));
    + * List removed = DisassemblyUtils.splice(list, 1, 2, List.of("X", "Y"));
    + * // list becomes ["A", "X", "Y", "D"], removed contains ["B", "C"]
    + *
    + * // Performing a binary search
    + * int index = DisassemblyUtils.binarySearch2(10, i -> Integer.compare(i, 7));
    + * }
    + * + * @author Red Hat + */ +public class DisassemblyUtils { + + /** + * Parses a numeric string and converts it into a {@link BigInteger} instance. + *

    + * This method mimics the behavior of the TypeScript {@code BigInt} constructor by + * supporting multiple numeric notations: + *

    + *
      + *
    • Hexadecimal: Strings starting with {@code "0x"} or {@code "0X"} → base 16
    • + *
    • Octal: Strings starting with {@code "0o"} or {@code "0O"} → base 8
    • + *
    • Binary: Strings starting with {@code "0b"} or {@code "0B"} → base 2
    • + *
    • Decimal: Any other numeric string → base 10
    • + *
    + * + * @param value the string representation of the number to parse (must not be {@code null}). + * @return a {@link BigInteger} representing the parsed value. + * @throws NumberFormatException if the string is {@code null}, empty, or not a valid number + * according to the detected base. + */ + public static BigInteger parseBigInteger(String value) { + if (value == null) { + throw new NumberFormatException("null"); + } + + value = value.trim(); + + if (value.startsWith("0x") || value.startsWith("0X")) { + return new BigInteger(value.substring(2), 16); + } + if (value.startsWith("0o") || value.startsWith("0O")) { + return new BigInteger(value.substring(2), 8); + } + if (value.startsWith("0b") || value.startsWith("0B")) { + return new BigInteger(value.substring(2), 2); + } + + // Default to decimal + return new BigInteger(value, 10); + } + + /** + * Emulates the behavior of JavaScript's {@code Array.splice} method. + *

    + * This method modifies a given list by removing a certain number of elements starting + * at a specified index, and optionally inserting new elements at that position. + * It returns a list containing the removed elements. + *

    + * + *

    Behavior details:

    + *
      + *
    • If {@code start} is negative, it is treated as {@code size + start}, with a minimum of 0.
    • + *
    • If {@code start} is greater than the list size, it is clamped to the list size.
    • + *
    • {@code deleteCount} is clamped between 0 and the number of elements from {@code start} to the end of the list.
    • + *
    • If {@code items} is provided, those elements are inserted at the {@code start} position after the removal.
    • + *
    + * + * @param list the list to modify (must be modifiable, e.g., an {@link ArrayList}). + * @param start the starting index for removal or insertion. Can be negative. + * @param deleteCount the number of elements to remove. + * @param items the elements to insert at the start index. Can be {@code null} or empty if no insertion is needed. + * @param the type of elements in the list. + * @return a list containing the elements that were removed. + * + * @throws NullPointerException if {@code list} is {@code null}. + * @see List#subList(int, int) + */ + public static List splice(List list, int start, int deleteCount, List items) { + int size = list.size(); + + // Adjust negative index like in JavaScript + if (start < 0) { + start = Math.max(size + start, 0); + } else { + start = Math.min(start, size); + } + + // Clamp deleteCount + deleteCount = Math.max(0, Math.min(deleteCount, size - start)); + + // 1. Copy the elements to be removed + List removed = new ArrayList<>(list.subList(start, start + deleteCount)); + + // 2. Remove these elements from the original list + list.subList(start, start + deleteCount).clear(); + + // 3. Insert the new elements, if provided + if (items != null && !items.isEmpty()) { + list.addAll(start, items); + } + + return removed; + } + + /** + * Performs a binary search on a conceptual list of given {@code length}. + *

    + * Instead of passing an actual list, this method uses a provided function to compare + * an index to the target key. This allows efficient searches on virtual or generated + * data without building a concrete list. + *

    + * + *

    Return value:

    + *
      + *
    • If the target is found, returns the index of the matching element.
    • + *
    • If the target is not found, returns {@code -(insertion point + 1)}, where + * insertion point is the index where the key should be inserted to maintain order.
    • + *
    + * + *

    This behavior is consistent with {@link java.util.Collections#binarySearch(List, Object)}.

    + * + * @param length the size of the conceptual list to search. + * @param compareToKey a function that compares the element at a given index to the target key. + *
      + *
    • Should return a negative value if the element is less than the key.
    • + *
    • Zero if the element matches the key.
    • + *
    • A positive value if the element is greater than the key.
    • + *
    + * @return the index of the found element, or {@code -(insertion point + 1)} if not found. + */ + public static int binarySearch2(int length, IntFunction compareToKey) { + int low = 0; + int high = length - 1; + + while (low <= high) { + int mid = (low + high) >>> 1; + int comp = compareToKey.apply(mid); + + if (comp < 0) { + low = mid + 1; + } else if (comp > 0) { + high = mid - 1; + } else { + return mid; + } + } + + return -(low + 1); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/OpenDisassemblyAction.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/OpenDisassemblyAction.java new file mode 100644 index 000000000..6292d6d75 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/OpenDisassemblyAction.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.xdebugger.XDebugSession; +import com.redhat.devtools.lsp4ij.dap.DAPDebugProcess; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * IntelliJ action that adds the menu entry "Open Disassembly View" to the debugger UI. + *

    + * This action appears in the context menu when a user selects a stack frame and the connected + * DAP (Debug Adapter Protocol) server supports disassembly instructions. + * When executed, it opens the disassembly view in a read-only editor tab, allowing + * the user to inspect machine-level instructions for the selected frame. + */ +public class OpenDisassemblyAction extends AnAction { + + /** + * Retrieves the {@link DisassemblyFile} associated with the current debug session, if available. + *

    + * This method checks whether the active debug session is based on a {@link DAPDebugProcess} + * that provides a disassembly file for the current stack frame. + * + * @param e the action event containing the current data context + * @return the disassembly file if supported by the current DAP server, otherwise {@code null} + */ + private static @Nullable DisassemblyFile getDisassemblyFile(AnActionEvent e) { + var session = e.getDataContext().getData(XDebugSession.DATA_KEY); + if (session != null && session.getDebugProcess() instanceof DAPDebugProcess dapDebugProcess) { + return dapDebugProcess.getDisassemblyFile(); + } + return null; + } + + /** + * Updates the visibility and enabled state of this action. + *

    + * The action will only be visible and enabled when: + *

      + *
    • The user has selected a stack frame in the debugger UI.
    • + *
    • The current debug process is based on a DAP server that supports disassembly.
    • + *
    + * + * @param e the action event containing the current data context + */ + @Override + public void update(@NotNull AnActionEvent e) { + // Show the "Open Disassembly View" menu only if a valid disassembly file is available. + e.getPresentation().setEnabledAndVisible(getDisassemblyFile(e) != null); + } + + /** + * Invoked when the action is triggered by the user. + *

    + * Opens the disassembly file in a standard IntelliJ editor tab in read-only mode, allowing + * the user to inspect low-level instructions for the currently selected stack frame. + * + * @param e the action event containing the current project and debug session context + */ + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Project project = e.getProject(); + if (project == null) { + return; + } + DisassemblyFile file = getDisassemblyFile(e); + if (file == null) { + return; + } + // The selected frame corresponds to a DAP frame from a server that supports disassembly. + // Open the disassembly file in a standard editor in read-only mode + // to display the disassembly instructions. + FileEditorManager.getInstance(project).openFile(file, true); + } + + /** + * Specifies that this action's update and execution logic should run on the Event Dispatch Thread (EDT), + * as it interacts directly with the IntelliJ UI components. + * + * @return {@link ActionUpdateThread#EDT} + */ + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandler.java new file mode 100644 index 000000000..f83dd042d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandler.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints; + +import com.intellij.openapi.project.Project; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.breakpoints.XLineBreakpoint; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import org.jetbrains.annotations.NotNull; + +/** + * Default handler for disassembly breakpoints. + *

    + * This class specializes {@link DisassemblyBreakpointHandlerBase} for line breakpoints + * with {@link DisassemblyBreakpointProperties}. It is used to manage instruction breakpoints + * in {@link com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile} instances. + */ +public class DisassemblyBreakpointHandler extends DisassemblyBreakpointHandlerBase> { + + /** + * Constructs a new disassembly breakpoint handler. + * + * @param debugSession the XDebugSession this handler is associated with + * @param debugAdapterDescriptor the descriptor of the debug adapter + * @param project the current IntelliJ project + */ + public DisassemblyBreakpointHandler(@NotNull XDebugSession debugSession, + @NotNull DebugAdapterDescriptor debugAdapterDescriptor, + @NotNull Project project) { + super(DisassemblyBreakpointType.class, debugSession, debugAdapterDescriptor, project); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandlerBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandlerBase.java new file mode 100644 index 000000000..53ea6c18b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointHandlerBase.java @@ -0,0 +1,162 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints; + +import com.intellij.openapi.project.Project; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.breakpoints.XBreakpoint; +import com.intellij.xdebugger.breakpoints.XBreakpointType; +import com.redhat.devtools.lsp4ij.dap.DAPDebugProcess; +import com.redhat.devtools.lsp4ij.dap.breakpoints.BreakpointHandlerBase; +import com.redhat.devtools.lsp4ij.dap.descriptors.DebugAdapterDescriptor; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile; +import org.eclipse.lsp4j.debug.Breakpoint; +import org.eclipse.lsp4j.debug.InstructionBreakpoint; +import org.eclipse.lsp4j.debug.SetInstructionBreakpointsArguments; +import org.eclipse.lsp4j.debug.SetInstructionBreakpointsResponse; +import org.eclipse.lsp4j.debug.services.IDebugProtocolServer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; + +/** + * Base class for handling breakpoints in disassembly files. + *

    + * This handler installs instruction breakpoints for {@link DisassemblyFile} instances + * and communicates with the DAP server to set or update breakpoints at the instruction level. + * + * @param the type of XBreakpoint handled + */ +public class DisassemblyBreakpointHandlerBase> extends BreakpointHandlerBase { + + /** + * Constructs a new handler for disassembly breakpoints. + * + * @param breakpointTypeClass the breakpoint type class + * @param debugSession the XDebugSession this handler is associated with + * @param debugAdapterDescriptor the descriptor of the debug adapter + * @param project the current IntelliJ project + */ + public DisassemblyBreakpointHandlerBase(@NotNull Class> breakpointTypeClass, + @NotNull XDebugSession debugSession, + @NotNull DebugAdapterDescriptor debugAdapterDescriptor, + @NotNull Project project) { + super(breakpointTypeClass, debugSession, debugAdapterDescriptor, project); + } + + /** + * Returns whether this handler can install the given breakpoint. + * + * @param breakpoint the breakpoint to test + * @return true if the breakpoint is supported and located in a DisassemblyFile, false otherwise + */ + @Override + public boolean supportsBreakpoint(B breakpoint) { + if (!supportsBreakpointType(breakpoint)) { + return false; + } + + XSourcePosition sourcePosition = breakpoint.getSourcePosition(); + if (sourcePosition == null) { + return false; + } + return sourcePosition.getFile() instanceof DisassemblyFile; + } + + /** + * Checks if the breakpoint type is a Disassembly breakpoint type. + * + * @param breakpoint the breakpoint to check + * @param generic breakpoint type + * @return true if the breakpoint type is supported, false otherwise + */ + private > boolean supportsBreakpointType(B1 breakpoint) { + return breakpoint.getType() instanceof DisassemblyBreakpointTypeBase; + } + + /** + * Sends all current breakpoints to the DAP server. + * + * @param debugProtocolServer the debug protocol server to send breakpoints to (nullable) + * @param temporaryBreakpoint a temporary breakpoint used for testing (nullable) + * @return a CompletableFuture that completes when all breakpoints are set + */ + @Override + protected @NotNull CompletableFuture<@Nullable Void> doSendBreakpoints(@Nullable IDebugProtocolServer debugProtocolServer, + @Nullable TemporaryBreakpoint temporaryBreakpoint) { + var disassemblyFile = ((DAPDebugProcess) debugSession.getDebugProcess()).getDisassemblyFile(); + if (disassemblyFile == null) { + // Should never occur + return CompletableFuture.completedFuture(null); + } + + var arguments = new SetInstructionBreakpointsArguments(); + var instructionBreakpoints = new ArrayList(); + + for (B breakpoint : breakpoints) { + var file = (DisassemblyFile) breakpoint.getSourcePosition().getFile(); + if (disassemblyFile.equals(file)) { + var instr = file.getInstructionAt(breakpoint.getSourcePosition().getLine()); + if (instr != null) { + String instructionReference = instr.instructionReference(); + BigInteger referenceAddress = file.getReferenceAddress(instructionReference); + int offset = referenceAddress != null + ? instr.address().subtract(referenceAddress).intValue() + : instr.address().intValue(); + InstructionBreakpoint instructionBreakpoint = new InstructionBreakpoint(); + instructionBreakpoint.setInstructionReference(instructionReference); + instructionBreakpoint.setOffset(offset); + instructionBreakpoints.add(instructionBreakpoint); + } + } + } + + arguments.setBreakpoints(instructionBreakpoints.toArray(new InstructionBreakpoint[0])); + + if (debugProtocolServer != null) { + // Send breakpoints to the given debug protocol server + return setInstructionBreakpoints(debugProtocolServer, arguments); + } + + // Send breakpoints to all registered debug protocol servers + var setInstructionBreakpointsFutures = new ArrayList>(); + for (var server : debugProtocolServers) { + setInstructionBreakpointsFutures.add(setInstructionBreakpoints(server, arguments)); + } + return CompletableFuture.allOf(setInstructionBreakpointsFutures.toArray(EMPTY_COMPLETABLE_FUTURES)); + } + + /** + * Sends a setInstructionBreakpoints request to the given DAP server. + * + * @param debugProtocolServer the DAP server + * @param arguments the instruction breakpoints arguments + * @return a CompletableFuture that completes when the breakpoints have been set + */ + private CompletableFuture setInstructionBreakpoints(@NotNull IDebugProtocolServer debugProtocolServer, + @NotNull SetInstructionBreakpointsArguments arguments) { + CompletableFuture future = debugProtocolServer.setInstructionBreakpoints(arguments); + return future.thenAccept(response -> { + var breakpoints = response.getBreakpoints(); + if (breakpoints == null) { + return; + } + for (int i = 0; i < breakpoints.length; i++) { + Breakpoint breakpointResponse = breakpoints[i]; + // TODO: implement mapping between DAP response and IntelliJ breakpoint if needed + } + }); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointProperties.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointProperties.java new file mode 100644 index 000000000..856ebfd12 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointProperties.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints; + +import com.intellij.xdebugger.breakpoints.XBreakpointProperties; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Debug Adapter Protocol (DAP) Disassembly breakpoint properties. + */ +public class DisassemblyBreakpointProperties extends XBreakpointProperties { + + @Override + public @Nullable DisassemblyBreakpointProperties getState() { + return this; + } + + @Override + public void loadState(@NotNull DisassemblyBreakpointProperties state) { + + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointType.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointType.java new file mode 100644 index 000000000..5400f5c9d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointType.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints; + +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.dap.DAPBundle; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Debug Adapter Protocol (DAP) Disassembly breakpoint type. + */ +public class DisassemblyBreakpointType extends DisassemblyBreakpointTypeBase { + + private static final String BREAKPOINT_ID = "dap-disassembly-breakpoint"; + + protected DisassemblyBreakpointType() { + super(BREAKPOINT_ID, DAPBundle.message("dap.disassembly.breakpoints.title")); + } + + @Override + public @Nullable DisassemblyBreakpointProperties createBreakpointProperties(@NotNull VirtualFile file, int line) { + return new DisassemblyBreakpointProperties(); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointTypeBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointTypeBase.java new file mode 100644 index 000000000..404ff24f6 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/breakpoints/DisassemblyBreakpointTypeBase.java @@ -0,0 +1,36 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.breakpoints; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.xdebugger.breakpoints.XBreakpointProperties; +import com.intellij.xdebugger.breakpoints.XLineBreakpointType; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFile; +import org.jetbrains.annotations.Nls; +import org.jetbrains.annotations.NonNls; +import org.jetbrains.annotations.NotNull; + +/** + * Debug Adapter Protocol (DAP) Disassembly breakpoint type. + */ +public abstract class DisassemblyBreakpointTypeBase

    > extends XLineBreakpointType

    { + + protected DisassemblyBreakpointTypeBase(@NonNls @NotNull String id, @Nls @NotNull String title) { + super(id, title); + } + + @Override + public boolean canPutAt(@NotNull VirtualFile file, int line, @NotNull Project project) { + return file instanceof DisassemblyFile; + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProvider.java new file mode 100644 index 000000000..0775d26a0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProvider.java @@ -0,0 +1,28 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiManager; +import com.intellij.psi.SingleRootFileViewProvider; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly file view provider. + */ +public class DisassemblyFileViewProvider extends SingleRootFileViewProvider { + + public DisassemblyFileViewProvider(@NotNull PsiManager manager, + @NotNull VirtualFile file) { + super(manager, file); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProviderFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProviderFactory.java new file mode 100644 index 000000000..84e2d2c7e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyFileViewProviderFactory.java @@ -0,0 +1,33 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.lang.Language; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.FileViewProvider; +import com.intellij.psi.FileViewProviderFactory; +import com.intellij.psi.PsiManager; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly file view provider factory. + */ +public class DisassemblyFileViewProviderFactory implements FileViewProviderFactory { + + @Override + public @NotNull FileViewProvider createFileViewProvider(@NotNull VirtualFile file, + Language language, + @NotNull PsiManager manager, + boolean eventSystemEnabled) { + return new DisassemblyFileViewProvider(manager, file); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParser.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParser.java new file mode 100644 index 000000000..1051d7eb2 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParser.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.lang.ASTNode; +import com.intellij.lang.PsiBuilder; +import com.intellij.lang.PsiParser; +import com.intellij.psi.tree.IElementType; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly Psi parser. + */ +class DisassemblyParser implements PsiParser { + @NotNull + @Override + public ASTNode parse(@NotNull IElementType root, PsiBuilder builder) { + PsiBuilder.Marker mark = builder.mark(); + while (!builder.eof()) { + builder.advanceLexer(); + } + mark.done(root); + return builder.getTreeBuilt(); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParserDefinition.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParserDefinition.java new file mode 100644 index 000000000..33db26ed8 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyParserDefinition.java @@ -0,0 +1,78 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.lang.ASTNode; +import com.intellij.lang.ParserDefinition; +import com.intellij.lang.PsiParser; +import com.intellij.lexer.EmptyLexer; +import com.intellij.lexer.Lexer; +import com.intellij.openapi.project.Project; +import com.intellij.psi.FileViewProvider; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.tree.IFileElementType; +import com.intellij.psi.tree.TokenSet; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyLanguage; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly parser definition. + */ +public class DisassemblyParserDefinition implements ParserDefinition { + + private final static IFileElementType FILE_ELEMENT_TYPE = new IFileElementType("Disassembly", DisassemblyLanguage.INSTANCE); + + @NotNull + @Override + public Lexer createLexer(Project project) { + return new EmptyLexer(); + } + + @Override + public @NotNull PsiParser createParser(Project project) { + return new DisassemblyParser(); + } + + @Override + public @NotNull IFileElementType getFileNodeType() { + return FILE_ELEMENT_TYPE; + } + + @NotNull + @Override + public TokenSet getCommentTokens() { + return TokenSet.EMPTY; + } + + @NotNull + @Override + public TokenSet getStringLiteralElements() { + return TokenSet.EMPTY; + } + + @NotNull + @Override + public PsiElement createElement(ASTNode node) { + return new DisassemblyPsiElement(node.getElementType()); + } + + @Override + public @NotNull SpaceRequirements spaceExistenceTypeBetweenTokens(ASTNode left, ASTNode right) { + return SpaceRequirements.MAY; + } + + @Override + public @NotNull PsiFile createFile(@NotNull FileViewProvider viewProvider) { + return new PsiDisassemblyFile(viewProvider); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyPsiElement.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyPsiElement.java new file mode 100644 index 000000000..2dcf9e695 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/DisassemblyPsiElement.java @@ -0,0 +1,24 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.psi.impl.source.tree.CompositePsiElement; +import com.intellij.psi.tree.IElementType; + +/** + * Disassembly Psi element. + */ +class DisassemblyPsiElement extends CompositePsiElement { + protected DisassemblyPsiElement(IElementType type) { + super(type); + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/PsiDisassemblyFile.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/PsiDisassemblyFile.java new file mode 100644 index 000000000..6660cc32d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/disassembly/psi/PsiDisassemblyFile.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * Angelo Zerr - implementation of DAP disassembly support + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly.psi; + +import com.intellij.extapi.psi.PsiFileBase; +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.psi.FileViewProvider; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyFileType; +import com.redhat.devtools.lsp4ij.dap.disassembly.DisassemblyLanguage; +import org.jetbrains.annotations.NotNull; + +/** + * Disassembly Psi file. + */ +public class PsiDisassemblyFile extends PsiFileBase { + + protected PsiDisassemblyFile(@NotNull FileViewProvider viewProvider) { + super(viewProvider, DisassemblyLanguage.INSTANCE); + } + + @Override + public @NotNull FileType getFileType() { + return DisassemblyFileType.INSTANCE; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/evaluation/DAPDebuggerEvaluator.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/evaluation/DAPDebuggerEvaluator.java index 9b0ed4574..6a250ca43 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/evaluation/DAPDebuggerEvaluator.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/evaluation/DAPDebuggerEvaluator.java @@ -10,9 +10,16 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.dap.evaluation; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.util.concurrency.AppExecutorUtil; import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.evaluation.ExpressionInfo; import com.intellij.xdebugger.evaluation.XDebuggerEvaluator; import com.intellij.xdebugger.frame.XValueCallback; +import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.dap.client.DAPClient; import com.redhat.devtools.lsp4ij.dap.client.DAPStackFrame; import com.redhat.devtools.lsp4ij.dap.client.variables.DAPValue; @@ -22,6 +29,8 @@ import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.Promise; +import org.jetbrains.concurrency.Promises; import java.lang.reflect.Method; import java.util.concurrent.CompletionException; @@ -42,31 +51,8 @@ public DAPDebuggerEvaluator(@NotNull DAPStackFrame stackFrame) { this.stackFrame = stackFrame; } - @Override - public void evaluate(@NotNull String expression, - @NotNull XEvaluationCallback callback, - @Nullable XSourcePosition expressionPosition) { - DAPClient client = stackFrame.getClient(); - String context = getEvaluationContext(callback); - int frameId = stackFrame.getFrameId(); - client.evaluate(expression, frameId, context) - .thenAccept(evaluateResponse -> { - Variable variable = new Variable(); - variable.setName(expression); // DAPValue which extends XNamedValue requires a non-null name - variable.setValue(evaluateResponse.getResult()); - variable.setType(evaluateResponse.getType()); - variable.setVariablesReference(evaluateResponse.getVariablesReference()); - variable.setNamedVariables(evaluateResponse.getNamedVariables()); - variable.setIndexedVariables(evaluateResponse.getIndexedVariables()); - callback.evaluated(new DAPValue(stackFrame, variable, null)); - }).exceptionally(error -> { - errorOccurred(error, callback); - return null; - }); - } - - public static @NotNull String getEvaluationContext(@NotNull XEvaluationCallback callback) { - // As XEvaluationCallbackWithOrigin is not available on old IntelliJ version (<=2023.3), we need to use Java reflection. + private static @NotNull String getEvaluationContext(@NotNull XEvaluationCallback callback) { + // As XEvaluationCallbackWithOrigin is not available on old IntelliJ version (<=2023.3) and i snot available with >=2025.3, we need to use Java reflection. if (reflectionUnsupported) { return EvaluateArgumentsContext.WATCH; } @@ -86,10 +72,14 @@ public void evaluate(@NotNull String expression, Object origin = getOriginMethod.invoke(callback); if (origin != null) { String originName = origin.toString(); - // REPL for XEvaluationOrigin.INLINE, XEvaluationOrigin.CONSOLE, XEvaluationOrigin.DIALOG - if ("INLINE".equals(originName) || "CONSOLE".equals(originName) || "DIALOG".equals(originName)) { - return EvaluateArgumentsContext.REPL; - } + return switch (originName) { + case "INLINE", "CONSOLE", + "DIALOG" -> // XEvaluationOrigin.INLINE, XEvaluationOrigin.CONSOLE, XEvaluationOrigin.DIALOG + EvaluateArgumentsContext.REPL; + case "EDITOR" -> // XEvaluationOrigin.EDITOR + EvaluateArgumentsContext.HOVER; + default -> EvaluateArgumentsContext.WATCH; + }; } } } catch (ClassNotFoundException e) { @@ -101,7 +91,6 @@ public void evaluate(@NotNull String expression, return EvaluateArgumentsContext.WATCH; } - public static void errorOccurred(@NotNull Throwable error, @NotNull XValueCallback callback) { if (error instanceof CompletionException && error.getCause() != null) { @@ -123,4 +112,53 @@ private static String getErrorMessage(@NotNull Throwable error) { return error.getMessage(); } + @Override + public void evaluate(@NotNull String expression, + @NotNull XEvaluationCallback callback, + @Nullable XSourcePosition expressionPosition) { + DAPClient client = stackFrame.getClient(); + String context = getEvaluationContext(callback); + int frameId = stackFrame.getFrameId(); + client.evaluate(expression, frameId, context) + .thenAccept(evaluateResponse -> { + Variable variable = new Variable(); + variable.setName(expression); // DAPValue which extends XNamedValue requires a non-null name + variable.setValue(evaluateResponse.getResult()); + variable.setType(evaluateResponse.getType()); + variable.setVariablesReference(evaluateResponse.getVariablesReference()); + variable.setNamedVariables(evaluateResponse.getNamedVariables()); + variable.setIndexedVariables(evaluateResponse.getIndexedVariables()); + callback.evaluated(new DAPValue(stackFrame, variable, null)); + }).exceptionally(error -> { + errorOccurred(error, callback); + return null; + }); + } + + @Override + public @NotNull Promise getExpressionInfoAtOffsetAsync( + @NotNull Project project, + @NotNull Document document, + int offset, + boolean sideEffectsAllowed + ) { + var client = stackFrame.getClient(); + if (!client.isSupportsEvaluateForHovers()) { + // DAP server doesn't support evaluate for hovers + return Promises.resolvedPromise(null); + } + return ReadAction.nonBlocking(() -> { + + var file = LSPIJUtils.getFile(document); + if (file == null || !client.getServerDescriptor().isDebuggableFile(file, project)) { + // The current document which is hovered is not managed by the DAP server from the current stack frame + return null; + } + + var variableSupport = client.getServerDescriptor().getVariableSupport(); + return variableSupport.getExpressionInfo(project, file, document, offset, sideEffectsAllowed); + }) + .withDocumentsCommitted(project) + .submit(AppExecutorUtil.getAppExecutorService()); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DebugAdapterServerInstallerBase.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DebugAdapterServerInstallerBase.java index cf8e6a797..9c464d353 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DebugAdapterServerInstallerBase.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DebugAdapterServerInstallerBase.java @@ -12,15 +12,9 @@ import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectManager; import com.redhat.devtools.lsp4ij.*; -import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatureAware; -import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; -import com.redhat.devtools.lsp4ij.installation.ServerInstallationStatus; import com.redhat.devtools.lsp4ij.installation.ServerInstallerBase; -import com.redhat.devtools.lsp4ij.server.definition.LanguageServerDefinition; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DeclarativeDebugAdapterServerInstaller.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DeclarativeDebugAdapterServerInstaller.java index 088113440..b9004c760 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DeclarativeDebugAdapterServerInstaller.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/installation/DeclarativeDebugAdapterServerInstaller.java @@ -15,7 +15,6 @@ import com.intellij.openapi.progress.ProgressIndicator; import com.redhat.devtools.lsp4ij.dap.definitions.DebugAdapterServerDefinition; -import com.redhat.devtools.lsp4ij.installation.LanguageServerInstallerBase; import com.redhat.devtools.lsp4ij.installation.ServerInstallationStatus; import com.redhat.devtools.lsp4ij.installation.definition.InstallerContext; import com.redhat.devtools.lsp4ij.installation.definition.ServerInstallerDescriptor; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalManager.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalManager.java new file mode 100644 index 000000000..debb945ca --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalManager.java @@ -0,0 +1,87 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.external.LinuxExternalTerminalService; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.external.MacExternalTerminalService; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.external.WindowsExternalTerminalService; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.integrated.RunInIntegratedTerminalService; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArgumentsKind; +import org.eclipse.lsp4j.debug.RunInTerminalResponse; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Manages Debug Adapter Protocol (DAP) {@code RunInTerminal} requests, + * supporting integrated terminal execution within IntelliJ IDEA. + */ +public class RunInTerminalManager { + + private final @NotNull Project project; + private final @NotNull RunInTerminalService runInIntegratedTerminalService; + private final @NotNull RunInTerminalService runInExternalTerminalService; + + public RunInTerminalManager(@NotNull Project project) { + this.project = project; + this.runInIntegratedTerminalService = new RunInIntegratedTerminalService(); + this.runInExternalTerminalService = createExternalTerminalService(); + } + + private @NotNull RunInTerminalService createExternalTerminalService() { + if (SystemInfo.isWindows) { + return new WindowsExternalTerminalService(); + } + if (SystemInfo.isMac) { + return new MacExternalTerminalService(); + } + return new LinuxExternalTerminalService(); + } + + public static RunInTerminalManager getInstance(@NotNull Project project) { + return project.getService(RunInTerminalManager.class); + } + + /** + * Executes a command in either an integrated or external terminal based on the request kind. + * + * @param args DAP {@link RunInTerminalRequestArguments} + * @return a {@link CompletableFuture} resolving to the {@link RunInTerminalResponse} + */ + public CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull DAPClient client) { + // Let the descriptor fulfil it (e.g. run the command in an interactive PTY inside the + // debug tab); fall back to the integrated/external terminal services otherwise. + var custom = client.getServerDescriptor().runInTerminal(args, client); + if (custom != null) { + return custom; + } + if (args.getKind() == RunInTerminalRequestArgumentsKind.INTEGRATED) { + if (runInIntegratedTerminalService.isApplicable()) { + return runInIntegratedTerminalService.runInTerminal(args, client); + } + } + return runInExternalTerminalService.runInTerminal(args, client); + } + + /** + * Releases all terminals currently assigned to the given client. + * + * @param client the DAP client whose terminals should be released + */ + public void releaseClientTerminals(@NotNull DAPClient client) { + runInIntegratedTerminalService.releaseClientTerminals(client); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalService.java new file mode 100644 index 000000000..61c28050f --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/RunInTerminalService.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal; + +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.debug.RunInTerminalResponse; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * Service API for handling Debug Adapter Protocol {@code runInTerminal} requests. + *

    + * Implementations of this service are responsible for spawning a new terminal instance + * (either external or integrated) and executing the command specified in the + * {@link RunInTerminalRequestArguments}. Implementations are typically platform-specific + * (e.g., Windows, Linux, macOS). + *

    + * + *

    This service is used to support the DAP {@code runInTerminal} feature, allowing + * debug adapters to request that a program be launched in a user-visible terminal.

    + * + *

    Implementation notes:

    + *
      + *
    • Implementations should check {@link #isApplicable()} to determine whether the service + * can run on the current OS/environment.
    • + *
    • The {@link #runInTerminal(RunInTerminalRequestArguments, DAPClient)} method should + * execute the command asynchronously and return a {@link CompletableFuture} that + * completes with the {@link RunInTerminalResponse} when the terminal is launched.
    • + *
    + */ +public interface RunInTerminalService { + + /** + * Determines whether this implementation is applicable in the current environment. + *

    + * This is typically used to filter OS-specific services (e.g., Windows, Linux, macOS) + * so that only the correct one is used at runtime. + *

    + * + * @return {@code true} if this service can handle {@code runInTerminal} requests + * on the current platform, {@code false} otherwise. + */ + boolean isApplicable(); + + /** + * Launches a new terminal and executes the command specified in the given + * {@link RunInTerminalRequestArguments}. + * + *

    + * The implementation should spawn a terminal process (external or integrated), + * set the working directory and environment variables as specified, + * and start the target program. This method must return immediately + * with a {@link CompletableFuture} that completes when the terminal is successfully + * launched or completes exceptionally if an error occurs. + *

    + * + * @param args the {@link RunInTerminalRequestArguments} containing the command, + * working directory, environment variables, and terminal type + * @param client the DAP client {@link DAPClient} in which this terminal is being launched + * @return a {@link CompletableFuture} resolving to a {@link RunInTerminalResponse} + * containing the process ID or {@code null} if not supported. + */ + CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull DAPClient client); + + /** + * Releases all terminals currently assigned to the given client. + * + * @param client the DAP client whose terminals should be released + */ + void releaseClientTerminals(@NotNull DAPClient client); +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/ExternalTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/ExternalTerminalService.java new file mode 100644 index 000000000..1124e361f --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/ExternalTerminalService.java @@ -0,0 +1,114 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal.external; + +import com.intellij.openapi.project.Project; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.RunInTerminalService; +import com.redhat.devtools.lsp4ij.internal.ResponseErrorExceptionWrapper; +import com.redhat.devtools.lsp4ij.internal.StringUtils; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.debug.RunInTerminalResponse; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Base class for external terminal services that handle Debug Adapter Protocol + * {@code runInTerminal} requests. + *

    + * This abstract class implements the {@link RunInTerminalService} API and provides + * a standard mechanism to wrap the process ID into a {@link RunInTerminalResponse}. + * Subclasses are responsible for providing the OS-specific logic to actually + * spawn the terminal and execute the command via {@link #doRunInTerminal(RunInTerminalRequestArguments, Project)}. + *

    + * + *

    + * Implementations typically include: + *

      + *
    • {@code WindowsExternalTerminalService}
    • + *
    • {@code LinuxExternalTerminalService}
    • + *
    • {@code MacExternalTerminalService}
    • + *
    + *

    + */ +public abstract class ExternalTerminalService implements RunInTerminalService { + + /** + * Executes a DAP {@code runInTerminal} request by delegating to the + * platform-specific {@link #doRunInTerminal(RunInTerminalRequestArguments, Project)} + * implementation and wrapping the resulting process ID into a + * {@link RunInTerminalResponse}. + * + * @param args the {@link RunInTerminalRequestArguments} containing the command, + * working directory, environment variables, and terminal kind + * @param client the DAP client {@link DAPClient} in which this terminal is being launched + * @return a {@link CompletableFuture} that resolves to a {@link RunInTerminalResponse} + * containing the process ID of the spawned terminal process. + */ + @Override + public CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull DAPClient client) { + + return doRunInTerminal(args, client.getProject()) + .thenApply(unused -> new RunInTerminalResponse()); + } + + /** + * Spawns an external terminal process and executes the command specified in + * the given {@link RunInTerminalRequestArguments}. + *

    + * Subclasses must implement this method to handle the platform-specific logic + * (Windows, Linux, macOS). + *

    + * + * @param args the {@link RunInTerminalRequestArguments} with the command and environment settings + * @param project the IntelliJ {@link Project} context + * @return a {@link CompletableFuture} resolving the spawned terminal + */ + protected abstract CompletableFuture doRunInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull Project project); + + protected @NotNull ProcessBuilder createProcessBuilder(@NotNull RunInTerminalRequestArguments args, + @NotNull Function<@NotNull RunInTerminalRequestArguments, @NotNull List> commandBuilder) { + + // Configure the process builder with working directory and environment variables. + ProcessBuilder pb = new ProcessBuilder(commandBuilder.apply(args)); + if (StringUtils.isNotBlank(args.getCwd())) { + pb.directory(new File(args.getCwd())); + } + if (args.getEnv() != null) { + pb.environment().putAll(args.getEnv()); + } + return pb; + } + + protected static Process startProcess(ProcessBuilder pb, long timeout) { + try { + var p = pb.start(); + if (timeout > 0) { + p.waitFor(timeout, TimeUnit.MILLISECONDS); + } + return p; + } catch (Exception e) { + throw new ResponseErrorExceptionWrapper(e); + } + } + + @Override + public void releaseClientTerminals(@NotNull DAPClient client) { + // Do nothing + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/LinuxExternalTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/LinuxExternalTerminalService.java new file mode 100644 index 000000000..352fec6d7 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/LinuxExternalTerminalService.java @@ -0,0 +1,151 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal.external; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import com.redhat.devtools.lsp4ij.internal.ResponseErrorExceptionWrapper; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * This class code is the vscode TypeScript translate from + * LinuxExternalTerminalService + */ +public class LinuxExternalTerminalService extends ExternalTerminalService { + + // List of common Linux terminals in the same order VS Code tries them + private static final String[] LINUX_TERMINALS = new String[]{ + "gnome-terminal", + "konsole", + "xfce4-terminal", + "xterm", + "lxterminal", + "mate-terminal", + "terminator", + "tilix", + "deepin-terminal", + "kitty", + "alacritty", + "x-terminal-emulator" + }; + + @Override + public boolean isApplicable() { + return SystemInfo.isLinux; + } + + /** + * Executes the {@code runInTerminal} request in an external Windows terminal. + * + * @param args the DAP {@link RunInTerminalRequestArguments} containing command, environment and working directory. + * @param project the IntelliJ {@link Project} in which this terminal is being launched + * @return a {@link CompletableFuture} that completes with the spawned process. + */ + @Override + protected CompletableFuture doRunInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull Project project) { + CompletableFuture result = new CompletableFuture<>(); + CompletableFuture.supplyAsync(() -> { + // Configure the process builder with working directory and environment variables. + ProcessBuilder pb = createProcessBuilder(args, LinuxExternalTerminalService::createCommands); + return startProcess(pb, 0); + }).handle((process, error) -> { + if (process == null || error != null) { + if (error instanceof ResponseErrorException) { + result.completeExceptionally(error); + } else { + result.completeExceptionally(new ResponseErrorExceptionWrapper(error)); + } + } else { + process.onExit().thenRun(() -> { + if (process.exitValue() == 0) { + result.complete(null); + } else { + result.completeExceptionally(new ResponseErrorExceptionWrapper("Linux terminal failed with exit code " + process.exitValue())); + } + }); + } + return null; + }); + return result; + } + + private static @NotNull List createCommands(@NotNull RunInTerminalRequestArguments args) { + String terminal = findAvailableTerminal(); + if (terminal == null) { + throw new ResponseErrorExceptionWrapper("No supported Linux terminal found in PATH"); + } + + List termArgs = new ArrayList<>(); + if (terminal.contains("gnome-terminal")) { + termArgs.add("-x"); + } else { + termArgs.add("-e"); + } + + termArgs.add("bash"); + termArgs.add("-c"); + + String bashCommand = quote(args.getArgs()) + "; echo; read -p \"" + + "Press any key to continue..." + "\" -n1;"; + // wrapping argument in two sets of ' because node is so "friendly" that it removes one set... + termArgs.add("''" + bashCommand + "''"); + termArgs.add(0, terminal); + return termArgs; + } + + /** + * Finds the first available terminal from the list in the PATH. + */ + private static String findAvailableTerminal() { + for (String term : LINUX_TERMINALS) { + if (isExecutableInPath(term)) { + return term; + } + } + return null; + } + + /** + * Checks if the given executable is available in the PATH using 'which'. + */ + private static boolean isExecutableInPath(String exec) { + try { + Process p = new ProcessBuilder("which", exec).start(); + return p.waitFor() == 0; + } catch (Exception e) { + return false; + } + } + + /** + * Quote args if necessary and combine into a space separated string. + */ + private static String quote(String[] args) { + StringBuilder r = new StringBuilder(); + for (String a : args) { + if (a.contains(" ")) { + r.append('"').append(a).append('"'); + } else { + r.append(a); + } + r.append(' '); + } + return r.toString().trim(); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/MacExternalTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/MacExternalTerminalService.java new file mode 100644 index 000000000..e70a1c94a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/MacExternalTerminalService.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal.external; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.CompletableFuture; + +/** + * This class code is the vscode TypeScript translate from + * MacExternalTerminalService + */ +public class MacExternalTerminalService extends ExternalTerminalService { + + @Override + public boolean isApplicable() { + return SystemInfo.isMac; + } + + @Override + protected CompletableFuture doRunInTerminal(@NotNull RunInTerminalRequestArguments args, @NotNull Project project) { + return CompletableFuture.failedFuture(new UnsupportedOperationException("MacExternalTerminalService not implemented")); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/WindowsExternalTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/WindowsExternalTerminalService.java new file mode 100644 index 000000000..cba573c9f --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/external/WindowsExternalTerminalService.java @@ -0,0 +1,117 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal.external; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.SystemInfo; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Windows-specific implementation of {@link ExternalTerminalService} used to spawn external + * terminals when handling the Debug Adapter Protocol (DAP) {@code runInTerminal} request. + *

    + * This class is based on the VS Code implementation + * + * WindowsExternalTerminalService, translated from TypeScript to Java. + *

    + * The service detects whether the Windows Terminal (`wt.exe`) or the classic `cmd.exe` should be used, + * constructs the appropriate command-line arguments, injects environment variables, and runs the + * process asynchronously, returning its exit code once completed. + */ +public class WindowsExternalTerminalService extends ExternalTerminalService { + + /** + * Default Windows command-line interpreter. + */ + private static final String CMD = "cmd.exe"; + + /** + * Cached default terminal path to avoid recomputing. + */ + private static String DEFAULT_TERMINAL_WINDOWS; + + /** + * Indicates if this service is applicable on the current platform. + * + * @return {@code true} if running on Windows, {@code false} otherwise. + */ + @Override + public boolean isApplicable() { + return SystemInfo.isWindows; + } + + /** + * Executes the {@code runInTerminal} request in an external Windows terminal. + * + * @param args the DAP {@link RunInTerminalRequestArguments} containing command, environment and working directory. + * @param project the current IntelliJ project context. + * @return a {@link CompletableFuture} that completes with the spawned process. + */ + @Override + protected CompletableFuture doRunInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull Project project) { + return CompletableFuture.supplyAsync(() -> { + // Configure the process builder with working directory and environment variables. + ProcessBuilder pb = createProcessBuilder(args, WindowsExternalTerminalService::createCommands); + startProcess(pb, 1000); + return null; + }); + } + + private static @NotNull List createCommands(@NotNull RunInTerminalRequestArguments args) { + // Resolve the terminal executable. Fallback to default Windows terminal if not configured. + String exec = /* settings.getWindowsExec() != null ? + settings.getWindowsExec() : */ getDefaultTerminalWindows(); + + // Prepare the title and the command to be executed. + String fullTitle = "\"" + args.getTitle() + "\""; + String command = "\"" + String.join("\" \"", args.getArgs()) + "\" & pause"; + + List cmdArgs = new ArrayList<>(); + String spawnExec; + + // Detect if the configured executable is Windows Terminal (wt.exe) or a classic cmd.exe session. + if (new File(exec).getName().equalsIgnoreCase("wt.exe")) { + spawnExec = exec; + // Use Windows Terminal with cmd.exe as shell. + cmdArgs.addAll(Arrays.asList("-d", ".", CMD, "/c", command)); + } else { + spawnExec = CMD; + // Use classic cmd.exe and spawn the configured terminal as a subprocess. + cmdArgs.addAll(Arrays.asList("/c", "start", fullTitle, "/wait", exec, "/c", "\"" + command + "\"")); + } + cmdArgs.add(0, spawnExec); + return cmdArgs; + } + + /** + * Determines the default Windows terminal executable path depending on the architecture (Sysnative/System32). + *

    + * This method caches the computed path to avoid recomputing it for every invocation. + * + * @return the absolute path of the default terminal executable (cmd.exe). + */ + public static String getDefaultTerminalWindows() { + if (DEFAULT_TERMINAL_WINDOWS == null) { + boolean isWoW64 = System.getenv().containsKey("PROCESSOR_ARCHITEW6432"); + String winDir = System.getenv().getOrDefault("windir", "C:\\Windows"); + DEFAULT_TERMINAL_WINDOWS = winDir + "\\" + (isWoW64 ? "Sysnative" : "System32") + "\\cmd.exe"; + } + return DEFAULT_TERMINAL_WINDOWS; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/integrated/RunInIntegratedTerminalService.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/integrated/RunInIntegratedTerminalService.java new file mode 100644 index 000000000..219a18b42 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/runInTerminal/integrated/RunInIntegratedTerminalService.java @@ -0,0 +1,278 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.runInTerminal.integrated; + +import com.intellij.ide.plugins.PluginManager; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.extensions.PluginId; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.terminal.JBTerminalWidget; +import com.intellij.terminal.ui.TerminalWidget; +import com.intellij.util.Alarm; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.runInTerminal.RunInTerminalService; +import com.redhat.devtools.lsp4ij.internal.StringUtils; +import org.eclipse.lsp4j.debug.RunInTerminalRequestArguments; +import org.eclipse.lsp4j.debug.RunInTerminalResponse; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.plugins.terminal.ShellTerminalWidget; +import org.jetbrains.plugins.terminal.TerminalToolWindowManager; + +import java.util.Arrays; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * Integrated terminal implementation of {@link RunInTerminalService} for Debug Adapter Protocol (DAP). + *

    + * This service allows running commands inside IntelliJ integrated terminals, supports injecting + * environment variables, and formats commands depending on the OS and shell. + *

    + * + *

    Features:

    + *
      + *
    • Creates a local shell widget in the integrated terminal window.
    • + *
    • Supports PowerShell, cmd.exe, and Unix-style shells.
    • + *
    • Injects environment variables with proper shell syntax.
    • + *
    • Returns the process ID of the spawned terminal via {@link RunInTerminalResponse}.
    • + *
    • Ensures that terminals with the same title are not shared between clients.
    • + *
    + * + *

    + * This service requires the IntelliJ Terminal plugin to be installed. {@link #isApplicable()} returns + * {@code false} if the plugin is missing. + *

    + */ +public class RunInIntegratedTerminalService implements RunInTerminalService { + + /** + * Map of terminals currently assigned to clients, keyed by terminal title. + */ + private static final Map clientTerminals = new ConcurrentHashMap<>(); + + /** + * Acquires a terminal for the given client. + *

    + * If a terminal with the requested title is already assigned to this client, it is reused. + * Otherwise, it searches for an existing unassigned terminal with the same title in IntelliJ. + * If none is found, a new terminal is created. + * + * @param workingDirectory the working directory for the terminal, or null for default + * @param title the terminal title, may be null + * @param client the DAP client requesting the terminal + * @return a {@link ShellTerminalWidget} ready for command execution + */ + private static @NotNull ShellTerminalWidget acquireTerminal(@Nullable String workingDirectory, + @Nullable String title, + @NotNull DAPClient client) { + var project = client.getProject(); + TerminalToolWindowManager tm = TerminalToolWindowManager.getInstance(project); + + // 1) Terminal with same title used by another client + ClientTerminal assigned = StringUtils.isNotBlank(title) ? clientTerminals.get(title) : null; + if (assigned != null && !assigned.client.equals(client)) { + return createNewTerminal(workingDirectory, title, client); + } + + // 2) Look for existing unassigned terminal in IntelliJ + for (TerminalWidget terminalWidget : tm.getTerminalWidgets()) { + if (title != null && title.equals(terminalWidget.getTerminalTitle().getDefaultTitle())) { + boolean used = clientTerminals.values().stream() + .anyMatch(ct -> ct.terminalWidget == terminalWidget); + if (!used) { + JBTerminalWidget jbTerminalWidget = JBTerminalWidget.asJediTermWidget(terminalWidget); + if (jbTerminalWidget instanceof ShellTerminalWidget shellTerminalWidget) { + clientTerminals.put(title, new ClientTerminal(title, shellTerminalWidget, client)); + return shellTerminalWidget; + } + } + } + } + + // 3) Create a new terminal if nothing found + return createNewTerminal(workingDirectory, title, client); + } + + /** + * Helper method to create a new terminal and register it for the client. + */ + private static @NotNull ShellTerminalWidget createNewTerminal(@Nullable String workingDirectory, + @Nullable String title, + @NotNull DAPClient client) { + var project = client.getProject(); + TerminalToolWindowManager tm = TerminalToolWindowManager.getInstance(project); + ShellTerminalWidget shellTerminalWidget = tm.createLocalShellWidget(workingDirectory, title); + if (StringUtils.isNotBlank(title)) { + // Cache the terminal widget to reuse if title is filled + clientTerminals.put(title, new ClientTerminal(title, shellTerminalWidget, client)); + } + return shellTerminalWidget; + } + + + /** + * Checks if the IntelliJ Terminal plugin is installed. + * + * @return true if the terminal plugin is installed, false otherwise + */ + @Override + public boolean isApplicable() { + PluginId pluginId = PluginId.getId("org.jetbrains.plugins.terminal"); + return PluginManager.getInstance().findEnabledPlugin(pluginId) != null; + } + + /** + * Executes a command in an integrated terminal for the given client. + * + * @param args the request arguments containing command, working directory, environment, and title + * @param client the DAP client executing the command + * @return a {@link CompletableFuture} resolving to {@link RunInTerminalResponse} with the process ID + */ + @Override + public CompletableFuture runInTerminal(@NotNull RunInTerminalRequestArguments args, + @NotNull DAPClient client) { + CompletableFuture future = new CompletableFuture<>(); + + ApplicationManager.getApplication().invokeLater(() -> { + try { + var project = client.getProject(); + String title = args.getTitle(); + String workingDirectory = StringUtils.isBlank(args.getCwd()) ? null : args.getCwd(); + + ShellTerminalWidget shellWidget = acquireTerminal(workingDirectory, title, client); + + String command = prepareCommandWithEnv(args); + shellWidget.executeCommand(command); + + // Poll until the terminal process PID becomes available + Alarm alarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, project); + Runnable checkProcess = new Runnable() { + @Override + public void run() { + var tty = shellWidget.getProcessTtyConnector(); + if (tty != null) { + RunInTerminalResponse resp = new RunInTerminalResponse(); + resp.setProcessId((int) tty.getProcess().pid()); + future.complete(resp); + } else if (!future.isDone()) { + alarm.addRequest(this, 100); + } + } + }; + alarm.addRequest(checkProcess, 100); + + } catch (Exception e) { + future.completeExceptionally(e); + } + }); + + return future; + } + + /** + * Prepares the full command line with environment variable injection according to the current OS and shell. + * + * @param args the request arguments containing command and environment variables + * @return the formatted command string + */ + private String prepareCommandWithEnv(@NotNull RunInTerminalRequestArguments args) { + String command = formatCommandArguments(args.getArgs()); + + if (args.getEnv() == null || args.getEnv().isEmpty()) { + return command; + } + + boolean isWindows = SystemInfo.isWindows; + boolean isPowerShell = isWindows && isPowerShellEnvironment(); + StringBuilder envCmd = new StringBuilder(); + + if (isPowerShell) { + args.getEnv().forEach((k, v) -> { + envCmd.append("${env:").append(k).append("}='") + .append(v.replace("'", "''")).append("'; "); + }); + envCmd.append(command); + } else if (isWindows) { + args.getEnv().forEach((k, v) -> { + envCmd.append("set ").append(k).append("=").append(v).append(" && "); + }); + envCmd.append(command); + } else { + args.getEnv().forEach((k, v) -> { + envCmd.append(k).append("='") + .append(v.replace("'", "'\"'\"'")).append("' "); + }); + envCmd.append(command); + } + + return envCmd.toString(); + } + + /** + * Formats command-line arguments for the active OS and shell. + * + * @param args array of command arguments + * @return properly quoted and escaped command string + */ + private String formatCommandArguments(String[] args) { + boolean isWindows = SystemInfo.isWindows; + boolean isPowerShell = isWindows && isPowerShellEnvironment(); + + if (args.length == 0) return ""; + + if (isPowerShell) { + String first = args[0]; + first = first.contains(" ") ? "& \"" + first + "\"" : "& " + first; + + String rest = Arrays.stream(args, 1, args.length) + .map(arg -> arg.contains(" ") ? "\"" + arg + "\"" : arg) + .collect(Collectors.joining(" ")); + + return first + (rest.isEmpty() ? "" : " " + rest); + } else if (isWindows) { + return Arrays.stream(args) + .map(arg -> arg.contains(" ") ? arg.replace(" ", "^ ") : arg) + .collect(Collectors.joining(" ")); + } else { + return Arrays.stream(args) + .map(arg -> arg.contains(" ") ? "'" + arg.replace("'", "'\"'\"'") + "'" : arg) + .collect(Collectors.joining(" ")); + } + } + + /** + * Detects whether the current environment is running PowerShell on Windows. + * + * @return true if PowerShell is detected, false otherwise + */ + private boolean isPowerShellEnvironment() { + String psModule = System.getenv("PSModulePath"); + return psModule != null && psModule.contains("PowerShell"); + } + + @Override + public void releaseClientTerminals(@NotNull DAPClient client) { + clientTerminals.values().stream() + .filter(ct -> ct.client.equals(client)) + .toList() + .forEach(ct -> clientTerminals.remove(ct.title, ct)); + } + + /** + * Represents a terminal currently assigned to a DAP client. + */ + private record ClientTerminal(String title, ShellTerminalWidget terminalWidget, DAPClient client) { + } +} + diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerPanel.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerPanel.java index ca9cba4f5..f0908e271 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerPanel.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerPanel.java @@ -11,6 +11,7 @@ package com.redhat.devtools.lsp4ij.dap.settings.ui; import com.intellij.execution.configuration.EnvironmentVariablesComponent; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.intellij.openapi.Disposable; import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; import com.intellij.openapi.project.Project; @@ -77,7 +78,7 @@ public class DebugAdapterServerPanel implements Disposable { private @Nullable String serverUrl; private HyperlinkLabel serverUrlHyperlink; // Server / Launch settings - private EnvironmentVariablesComponent environmentVariables; + private @Nullable EnvironmentVariablesComponent environmentVariables; private CommandLineWidget commandLine; private DAPDebugServerWaitStrategyPanel debugServerWaitStrategyPanel; // Server trace @@ -503,6 +504,7 @@ private void loadFromServerDefinition(@NotNull UserDefinedDebugAdapterServerDefi // Update command String command = getCommandLine(serverDefinition); this.setCommandLine(command); + setEnvData(EnvironmentVariablesData.create(serverDefinition.getUserEnvironmentVariables(), serverDefinition.isIncludeSystemEnvironmentVariables())); // Update mappings mappingsPanel.refreshMappings(serverDefinition.getLanguageMappings(), serverDefinition.getFileTypeMappings()); @@ -583,7 +585,7 @@ public void updateDebugServerWaitStrategy(@Nullable DebugServerWaitStrategy debu debugServerWaitStrategyPanel.update(debugServerWaitStrategy, waitForTimeout, debugServerReadyPattern); } - public EnvironmentVariablesComponent getEnvironmentVariables() { + private @Nullable EnvironmentVariablesComponent getEnvironmentVariables() { return environmentVariables; } @@ -603,6 +605,19 @@ public CommandLineWidget getCommandLineWidget() { return commandLine; } + public void setEnvData(@Nullable EnvironmentVariablesData envData) { + if (environmentVariables != null) { + environmentVariables.setEnvData(envData != null ? envData : EnvironmentVariablesData.DEFAULT); + } + } + + public @NotNull EnvironmentVariablesData getEnvData() { + if (environmentVariables != null) { + return environmentVariables.getEnvData(); + } + return EnvironmentVariablesData.DEFAULT; + } + public DAPDebugServerWaitStrategyPanel getDebugServerWaitStrategyPanel() { return debugServerWaitStrategyPanel; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerView.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerView.java index e7e6b1364..9a806d5f2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerView.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/DebugAdapterServerView.java @@ -273,14 +273,12 @@ private JComponent createDescription(String description) { return titledComponent; } - public void setEnvData(EnvironmentVariablesData envData) { - if (envData != null) { - debugAdapterServerPanel.getEnvironmentVariables().setEnvData(envData); - } + public void setEnvData(@Nullable EnvironmentVariablesData envData) { + debugAdapterServerPanel.setEnvData(envData); } public @NotNull EnvironmentVariablesData getEnvData() { - return debugAdapterServerPanel.getEnvironmentVariables().getEnvData(); + return debugAdapterServerPanel.getEnvData(); } public JComponent getComponent() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/NewDebugAdapterServerDialog.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/NewDebugAdapterServerDialog.java index 7cc0a7695..a4b90d496 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/NewDebugAdapterServerDialog.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/settings/ui/NewDebugAdapterServerDialog.java @@ -108,6 +108,7 @@ private void loadFromTemplate(DAPTemplate template) { // Update command String command = getCommandLine(template); this.debugAdapterServerPanel.setCommandLine(command); + this.debugAdapterServerPanel.setEnvData(template.getEnvData()); // Update wait for trace this.debugAdapterServerPanel.getDebugServerWaitStrategyPanel().update(null, @@ -172,8 +173,8 @@ protected void doOKAction() { // Register language server and mappings definition String serverName = this.debugAdapterServerPanel.getServerName(); String serverUrl = this.debugAdapterServerPanel.getServerUrl(); - Map userEnvironmentVariables = this.debugAdapterServerPanel.getEnvironmentVariables().getEnvs(); - boolean includeSystemEnvironmentVariables = this.debugAdapterServerPanel.getEnvironmentVariables().isPassParentEnvs(); + Map userEnvironmentVariables = this.debugAdapterServerPanel.getEnvData().getEnvs(); + boolean includeSystemEnvironmentVariables = this.debugAdapterServerPanel.getEnvData().isPassParentEnvs(); String commandLine = this.debugAdapterServerPanel.getCommandLine(); int connectTimeout = this.debugAdapterServerPanel.getDebugServerWaitStrategyPanel().getConnectTimeout(); String trackTrace = this.debugAdapterServerPanel.getDebugServerWaitStrategyPanel().getTrace(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandler.java new file mode 100644 index 000000000..baf442fd7 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandler.java @@ -0,0 +1,229 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.stepping; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.xdebugger.XDebugSession; +import com.intellij.xdebugger.XSourcePosition; +import com.intellij.xdebugger.frame.XSuspendContext; +import com.intellij.xdebugger.stepping.XSmartStepIntoHandler; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.dap.client.DAPClient; +import com.redhat.devtools.lsp4ij.dap.client.DAPStackFrame; +import com.redhat.devtools.lsp4ij.dap.client.DAPSuspendContext; +import com.redhat.devtools.lsp4ij.internal.CompletableFutures; +import org.eclipse.lsp4j.debug.StepInTarget; +import org.eclipse.lsp4j.debug.StepInTargetsResponse; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.concurrency.AsyncPromise; +import org.jetbrains.concurrency.Promise; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** + * Handles "Smart Step Into" functionality for DAP debug sessions. + * When multiple function calls exist on a single line, this handler allows the user + * to choose which function to step into via the DAP stepInTargets request. + */ +public class DAPSmartStepIntoHandler extends XSmartStepIntoHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(DAPSmartStepIntoHandler.class); + + private final @NotNull XDebugSession session; + + /** + * Creates a new Smart Step Into handler. + * + * @param session the debug session + */ + public DAPSmartStepIntoHandler(@NotNull XDebugSession session) { + this.session = session; + } + + @Override + public @NotNull List computeSmartStepVariants(@NotNull XSourcePosition position) { + CompletableFuture> future = getStepInTargetsFuture(position); + + try { + CompletableFutures.waitUntilDone(future, (com.intellij.psi.PsiFile) null, 5000); + } catch (ProcessCanceledException e) { + throw e; + } catch (ExecutionException e) { + LOGGER.warn("Error getting smart step variants", e); + return Collections.emptyList(); + } catch (java.util.concurrent.TimeoutException e) { + return Collections.emptyList(); + } + + List result = future.getNow(null); + return result != null ? result : Collections.emptyList(); + } + + /** + * Helper method to get the CompletableFuture for step-in targets. + * Extracted to avoid code duplication between sync and async versions. + */ + private CompletableFuture> getStepInTargetsFuture(@NotNull XSourcePosition position) { + XSuspendContext suspendContext = session.getSuspendContext(); + if (!(suspendContext instanceof DAPSuspendContext dapContext)) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + var activeStack = dapContext.getActiveExecutionStack(); + if (activeStack == null) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + var topFrame = activeStack.getTopFrame(); + if (!(topFrame instanceof DAPStackFrame dapFrame)) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + DAPClient client = dapFrame.getClient(); + + // Check capability + if (!client.isSupportsStepInTargetsRequest()) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + + int frameId = dapFrame.getFrameId(); + CompletableFuture responseFuture = client.stepInTargets(frameId); + + // Transform the response into variants + Document document = LSPIJUtils.getDocument(position.getFile()); + int zeroBasedLine = position.getLine(); + + return responseFuture.handle((response, throwable) -> { + if (throwable != null) { + LOGGER.error("Error while fetching step-in targets from DAP server", throwable); + return Collections.emptyList(); + } + + if (response == null || response.getTargets() == null || response.getTargets().length == 0) { + return Collections.emptyList(); + } + + List variants = new ArrayList<>(); + + // Track how many times we've seen each function name to handle duplicates + Map functionNameCounts = new java.util.HashMap<>(); + + for (StepInTarget target : response.getTargets()) { + // Extract function name from label to count occurrences + String label = target.getLabel(); + String functionName = label; + int parenIndex = label.indexOf('('); + if (parenIndex > 0) { + functionName = label.substring(0, parenIndex); + } + + // Get the occurrence index for this function name + int occurrenceIndex = functionNameCounts.getOrDefault(functionName, 0); + functionNameCounts.put(functionName, occurrenceIndex + 1); + + DAPStepIntoVariant variant = new DAPStepIntoVariant(target, document, zeroBasedLine, occurrenceIndex); + variants.add(variant); + } + return variants; + }); + } + + @Override + public @NotNull Promise> computeSmartStepVariantsAsync( + @NotNull XSourcePosition position) { + + // Get the CompletableFuture and wrap it in an AsyncPromise + CompletableFuture> future = getStepInTargetsFuture(position); + + AsyncPromise> promise = new AsyncPromise<>(); + future.whenComplete((result, throwable) -> { + if (throwable != null) { + promise.setResult(Collections.emptyList()); + } else { + promise.setResult(result != null ? result : Collections.emptyList()); + } + }); + + return promise; + } + + @Override + public void startStepInto(@NotNull DAPStepIntoVariant variant, @Nullable XSuspendContext context) { + if (!(context instanceof DAPSuspendContext dapContext)) { + return; + } + + Integer threadId = dapContext.getThreadId(); + if (threadId == null) { + return; + } + + var activeStack = dapContext.getActiveExecutionStack(); + if (activeStack == null) { + return; + } + + var topFrame = activeStack.getTopFrame(); + if (!(topFrame instanceof DAPStackFrame dapFrame)) { + return; + } + + DAPClient client = dapFrame.getClient(); + + // Get stepping granularity (null for normal stepping, INSTRUCTION for disassembly) + var granularity = getSteppingGranularity(client); + + // Execute stepIn with targetId + Integer targetId = variant.getTargetId(); + client.stepIn(threadId, targetId, granularity); + } + + @Override + public void stepIntoEmpty(XDebugSession session) { + // Fallback to regular step into when no variants available + session.stepInto(); + } + + @Override + public @Nullable String getPopupTitle(@NotNull XSourcePosition position) { + return getPopupTitle(); + } + + //@Override + public @Nullable String getPopupTitle() { + // In 2026.1 this method must be implemented + // See https://github.com/JetBrains/intellij-community/blob/f2a2af473deafff16e900aa725b60c8fb0712039/platform/xdebugger-api/src/com/intellij/xdebugger/stepping/XSmartStepIntoHandler.java#L79 + return "Choose Method to Step Into"; + } + + /** + * Gets the stepping granularity for the current session. + * Returns INSTRUCTION granularity when disassembly mode is active, null otherwise. + * + * @param client the DAP client + * @return the stepping granularity, or null for default (source line) granularity + */ + private @Nullable org.eclipse.lsp4j.debug.SteppingGranularity getSteppingGranularity(@NotNull DAPClient client) { + // Note: For Smart Step Into, we typically use source-level granularity + // Disassembly-level stepping with target selection is rarely needed + // If needed in the future, this could access DAPDebugProcess.getAlternativeSourceHandler() + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariant.java b/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariant.java new file mode 100644 index 000000000..6eb187bc0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariant.java @@ -0,0 +1,158 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.stepping; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.xdebugger.stepping.XSmartStepIntoVariant; +import org.eclipse.lsp4j.debug.StepInTarget; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Represents a step-into target from the Debug Adapter Protocol (DAP). + * Wraps {@link StepInTarget} to integrate with IntelliJ's Smart Step Into UI. + */ +public class DAPStepIntoVariant extends XSmartStepIntoVariant { + + private final @NotNull StepInTarget target; + private final @Nullable Document document; + private final int zeroBasedLine; + private final int occurrenceIndex; + + /** + * Creates a new step-into variant. + * + * @param target the DAP step-in target + * @param document the document containing the target (for highlighting) + * @param zeroBasedLine the 0-based line number in IntelliJ coordinates + * @param occurrenceIndex the occurrence index when multiple targets have the same function name (0-based) + */ + public DAPStepIntoVariant(@NotNull StepInTarget target, + @Nullable Document document, + int zeroBasedLine, + int occurrenceIndex) { + this.target = target; + this.document = document; + this.zeroBasedLine = zeroBasedLine; + this.occurrenceIndex = occurrenceIndex; + } + + @Override + public @NotNull String getText() { + return target.getLabel(); + } + + @Override + public @Nullable TextRange getHighlightRange() { + // If DAP provides line/column, use it for precise highlighting + Integer line = target.getLine(); + Integer column = target.getColumn(); + + if (document != null && line != null && column != null) { + // DAP uses 1-based line numbers + int startLineNumber = line - 1; + + if (startLineNumber >= 0 && startLineNumber < document.getLineCount()) { + int lineStartOffset = document.getLineStartOffset(startLineNumber); + + // Column is 1-based, convert to offset + int columnOffset = column - 1; + if (columnOffset >= 0) { + int startOffset = lineStartOffset + columnOffset; + + // Calculate end offset using endLine and endColumn if provided + Integer endLine = target.getEndLine(); + Integer endColumn = target.getEndColumn(); + int endOffset; + + if (endLine != null && endColumn != null) { + // Multi-line or precise single-line range + int endLineNumber = endLine - 1; + if (endLineNumber >= 0 && endLineNumber < document.getLineCount()) { + int endLineStartOffset = document.getLineStartOffset(endLineNumber); + endOffset = endLineStartOffset + (endColumn - 1); + } else { + // Fallback if endLine is invalid + int lineEndOffset = document.getLineEndOffset(startLineNumber); + endOffset = Math.min(startOffset + 20, lineEndOffset); + } + } else if (endColumn != null && endColumn > column) { + // Same line, use endColumn + int lineEndOffset = document.getLineEndOffset(startLineNumber); + endOffset = Math.min(lineStartOffset + (endColumn - 1), lineEndOffset); + } else { + // No end info, estimate function name length + int lineEndOffset = document.getLineEndOffset(startLineNumber); + endOffset = Math.min(startOffset + 20, lineEndOffset); + } + + return new TextRange(startOffset, endOffset); + } + } + } + + // Fallback: try to find the function name in the current line + if (document != null && zeroBasedLine >= 0 && zeroBasedLine < document.getLineCount()) { + int lineStartOffset = document.getLineStartOffset(zeroBasedLine); + int lineEndOffset = document.getLineEndOffset(zeroBasedLine); + String lineText = document.getText(new TextRange(lineStartOffset, lineEndOffset)); + + // Extract function name from label (e.g., "add(x, y)" -> "add") + // This avoids overlapping ranges when labels are nested (e.g., "multiply(add(x, y), subtract(x, y))") + String label = target.getLabel(); + String functionName = label; + int parenIndex = label.indexOf('('); + if (parenIndex > 0) { + functionName = label.substring(0, parenIndex); + } + + // Search for the N-th occurrence of the function name in the line + // to handle cases like: add(1, 2) + add(3, 4) + int index = -1; + for (int i = 0; i <= occurrenceIndex; i++) { + index = lineText.indexOf(functionName, index + 1); + if (index < 0) { + break; + } + } + + if (index >= 0) { + int startOffset = lineStartOffset + index; + int endOffset = startOffset + functionName.length(); + return new TextRange(startOffset, endOffset); + } + + // Ultimate fallback: highlight entire line + return new TextRange(lineStartOffset, lineEndOffset); + } + + return null; + } + + /** + * Gets the target ID for use with the DAP stepIn request. + * + * @return the target ID + */ + public int getTargetId() { + return target.getId(); + } + + /** + * Gets the underlying DAP step-in target. + * + * @return the step-in target + */ + public @NotNull StepInTarget getTarget() { + return target; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/AbstractLSPDocumentFeatureSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/AbstractLSPDocumentFeatureSupport.java index 961ac8d6f..b10ba5d20 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/AbstractLSPDocumentFeatureSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/AbstractLSPDocumentFeatureSupport.java @@ -19,7 +19,6 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.net.URI; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/LSPLazyCodeActionIntentionAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/LSPLazyCodeActionIntentionAction.java index 808e8a0d2..6be26efbc 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/LSPLazyCodeActionIntentionAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/LSPLazyCodeActionIntentionAction.java @@ -64,13 +64,13 @@ public void setLazyCodeActions(LSPLazyCodeActionProvider lazyCodeActions) { @Override public @IntentionName @NotNull String getText() { loadCodeActionIfNeeded(); - return title != null? title : ""; + return title != null ? title : ""; } @Override public @NotNull @IntentionFamilyName String getFamilyName() { loadCodeActionIfNeeded(); - return familyName != null ? familyName : LanguageServerBundle.message("lsp.intention.code.action.kind.empty"); + return familyName != null ? familyName : LanguageServerBundle.message("lsp.intention.code.action.kind.empty"); } @Override @@ -144,9 +144,9 @@ private void apply(@Nullable CodeAction codeaction, } private static void executeCommand(@NotNull Command command, - @NotNull PsiFile file, - @NotNull Editor editor, - @NotNull LanguageServerItem languageServer) { + @NotNull PsiFile file, + @NotNull Editor editor, + @NotNull LanguageServerItem languageServer) { CommandExecutor.executeCommand(new LSPCommandContext(command, file, LSPCommandContext.ExecutedBy.CODE_ACTION, editor, languageServer)); } @@ -193,7 +193,7 @@ private void loadCodeActionIfNeeded() { private static LanguageServerItem getLanguageServer(@Nullable Either action) { return action != null && action.isLeft() ? action.getLeft().languageServer() : null; } - + private static boolean isValidCodeAction(@Nullable Either action) { return action != null && action.isLeft(); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/intention/LSPIntentionAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/intention/LSPIntentionAction.java index fd1c8a64a..a2973af2e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/intention/LSPIntentionAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/intention/LSPIntentionAction.java @@ -10,6 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.codeAction.intention; +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo; import com.intellij.openapi.editor.Caret; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; @@ -58,7 +59,7 @@ private static CodeActionParams createCodeActionParams(@NotNull Editor editor, Caret caret = editor.getCaretModel().getPrimaryCaret(); Range range = LSPIJUtils.toRange(caret.getSelectionRange(), document); CodeActionContext context = new CodeActionContext(Collections.emptyList()); - context.setTriggerKind(CodeActionTriggerKind.Automatic); + context.setTriggerKind(CodeActionTriggerKind.Invoked); return new CodeActionParams(new TextDocumentIdentifier(), range, context); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/quickfix/LSPLazyCodeActions.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/quickfix/LSPLazyCodeActions.java index b0d3f57bc..9e2775c19 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/quickfix/LSPLazyCodeActions.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeAction/quickfix/LSPLazyCodeActions.java @@ -16,7 +16,6 @@ import com.intellij.codeInsight.intention.IntentionAction; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.vfs.VirtualFile; -import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.LanguageServerItem; import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; import com.redhat.devtools.lsp4ij.features.codeAction.CodeActionData; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/CodeLensData.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/CodeLensData.java index dda64ac7f..5e2c6047b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/CodeLensData.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/CodeLensData.java @@ -10,7 +10,9 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.codeLens; +import com.redhat.devtools.lsp4ij.LSPRequestConstants; import com.redhat.devtools.lsp4ij.LanguageServerItem; +import com.redhat.devtools.lsp4ij.internal.CancellationSupport; import org.eclipse.lsp4j.CodeLens; import org.jetbrains.annotations.NotNull; @@ -21,8 +23,8 @@ */ public class CodeLensData { - private @NotNull CodeLens codeLens; private final @NotNull LanguageServerItem languageServer; + private @NotNull CodeLens codeLens; private boolean toResolve; private CompletableFuture resolveCodeLensFuture; private CodeLensDataResult result; @@ -51,12 +53,18 @@ public CompletableFuture resolveCodeLens() { if (resolveCodeLensFuture != null) { return resolveCodeLensFuture; } - resolveCodeLensFuture = languageServer - .getTextDocumentService() - .resolveCodeLens(codeLens); + // Use a CancellationSupport to catch errors like ResponseErrorException + // and ignore theme + CancellationSupport cancellationSupport = new CancellationSupport(); + resolveCodeLensFuture = cancellationSupport.execute(languageServer + .getTextDocumentService() + .resolveCodeLens(codeLens), + languageServer, + LSPRequestConstants.CODE_LENS_RESOLVE); resolveCodeLensFuture .thenAccept(cl -> { - if(cl != null) { + // mark codelens as resolved + if (cl != null) { codeLens = cl; toResolve = false; result.decrementResolve(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensEditorFactoryListener.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensEditorFactoryListener.java index 5e5d144d5..fdd921b8c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensEditorFactoryListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensEditorFactoryListener.java @@ -10,14 +10,19 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.codeLens; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.application.ModalityState; +import com.intellij.openapi.application.ReadAction; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.editor.event.EditorFactoryEvent; import com.intellij.openapi.editor.event.EditorFactoryListener; -import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; +import com.intellij.util.concurrency.AppExecutorUtil; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.awt.*; + /** * Listens to editor events and manages the context for unresolved code lenses. * It updates the viewport lines and triggers the resolution and refresh of unresolved code lenses in the editor. @@ -29,54 +34,125 @@ public class LSPCodeLensEditorFactoryListener implements EditorFactoryListener { private static final Key CODELENS_SUPPORT_KEY = Key.create("codelens.support.context"); /** - * Called when an editor is created. - * Checks if the editor's file is supported and attaches a scroll listener. + * Retrieves the unresolved code lens resolution context for the given editor. + * If no context exists, it synchronously creates and stores a new one. * - * @param event The event associated with the editor creation. + * @param editor The editor for which the context is retrieved. + * @return The unresolved code lens viewport context for the editor. */ - @Override - public void editorCreated(@NotNull EditorFactoryEvent event) { - Editor editor = event.getEditor(); - Project project = editor.getProject(); - if (project != null) { - attachScrollListener(editor, project); + @NotNull + public static UnresolvedCodeLensViewportContext getCodeLensResolveContext(@NotNull Editor editor) { + UnresolvedCodeLensViewportContext context = editor.getUserData(UNRESOLVED_CODELENS_CONTEXT_KEY); + if (context != null) { + return context; } + return createCodeLensResolveContextSync(editor); } - @Override - public void editorReleased(@NotNull EditorFactoryEvent event) { - Editor editor = event.getEditor(); + /** + * Synchronously creates and stores a new unresolved code lens resolution context for the editor. + * + * @param editor The editor for which the context is created. + * @return The newly created unresolved code lens viewport context. + */ + @NotNull + private synchronized static UnresolvedCodeLensViewportContext createCodeLensResolveContextSync(@NotNull Editor editor) { UnresolvedCodeLensViewportContext context = editor.getUserData(UNRESOLVED_CODELENS_CONTEXT_KEY); if (context != null) { - context.dispose(); - editor.putUserData(UNRESOLVED_CODELENS_CONTEXT_KEY, null); + return context; } - editor.putUserData(CODELENS_SUPPORT_KEY, null); + context = new UnresolvedCodeLensViewportContext(editor); + editor.putUserData(UNRESOLVED_CODELENS_CONTEXT_KEY, context); + return context; + } + + public static void setCodelensSupport(@NotNull Editor editor, LSPCodeLensSupport codeLensSupport) { + editor.putUserData(CODELENS_SUPPORT_KEY, codeLensSupport); + } + + @Nullable + public static LSPCodeLensSupport getCodeLensSupport(@NotNull Editor editor) { + return editor.getUserData(CODELENS_SUPPORT_KEY); + } + + private static void updateViewportLinesAndRefreshCodeVision(@NotNull CodeLensDataResult result, LSPCodeLensSupport codeLensSupport, UnresolvedCodeLensViewportContext context) { + if (!context.isViewportInitialized()) { + // Viewport not initialized yet, cannot determine what to resolve + return; + } + if (!result.hasToResolve(context.getFirstViewportLine(), context.getLastViewportLine())) { + // No codelens to resolve to the current viewport + return; + } + // Refresh the LSP code vision to resolve codelens visible in the viewport + var file = codeLensSupport.getFile(); + final int firstViewportLine = context.getFirstViewportLine(); + final int lastViewportLine = context.getLastViewportLine(); + context.refreshCodeVision(result, + file, + firstViewportLine, + lastViewportLine); + } + + private static boolean updateViewportLinesAndRefreshCodeVision(@NotNull CodeLensDataResult result, + @NotNull Rectangle newRect, + @NotNull LSPCodeLensSupport codeLensSupport, + @NotNull UnresolvedCodeLensViewportContext context, + boolean forceReadAction) { + // Step 1: Update the first/last visible lines from the viewport + // This update requires EDT+ReadAction + if (!ApplicationManager.getApplication().isReadAccessAllowed()) { + // We are not in ReadAction + if (!forceReadAction) { + // Don't force Read Action, do nothing + return false; + } + // Update viewport lines in blocking ReadAction + ReadAction.run(() -> context.updateViewportLines(newRect)); + } else { + // We are in ReadAction + context.updateViewportLines(newRect); + } + + // Step 2: Refresh code visions + updateViewportLinesAndRefreshCodeVision(result, codeLensSupport, context); + return true; } /** * Attaches a listener to the editor's scrolling model to track the visible area changes. * When the visible area changes, it updates the viewport context and triggers the resolution of code lenses. * - * @param editor The editor to which the scroll listener is attached. - * @param project the project. + * @param editor The editor to which the scroll listener is attached. */ - private static void attachScrollListener(@NotNull Editor editor, - @NotNull Project project) { + private void attachScrollListener(@NotNull Editor editor) { // Initialize context final var context = getCodeLensResolveContext(editor); + // Adding a listener for visible area changes editor.getScrollingModel().addVisibleAreaListener((e) -> { - if (e.getNewRectangle().equals(e.getOldRectangle())) { - // View port range has no changed + Rectangle newRect = e.getNewRectangle(); + + // Initialize viewport on first event (when editor is fully rendered) + if (!context.isViewportInitialized() && newRect.width > 0 && newRect.height > 0) { + // First time - initialize viewport even if it hasn't "changed" + // We're on EDT here, and we have a valid rectangle + if (ApplicationManager.getApplication().isReadAccessAllowed()) { + context.updateViewportLines(newRect); + } else { + ReadAction.run(() -> context.updateViewportLines(newRect)); + } + // Don't return - continue to process the event normally + } + + if (newRect.equals(e.getOldRectangle())) { + // View port range has not changed return; } - // Update the first/last visible lines from the viewport - context.updateViewportLines(e.getNewRectangle()); LSPCodeLensSupport codeLensSupport = getCodeLensSupport(editor); if (codeLensSupport == null) { - // The file is not linked to a language server which have LSP codeLens support. + // The file is not linked to a language server which has LSP codeLens support. return; } @@ -89,61 +165,51 @@ private static void attachScrollListener(@NotNull Editor editor, var resultFuture = codeLensSupport.getFuture(); if (resultFuture != null && resultFuture.isDone()) { var result = resultFuture.getNow(null); - if (result == null || !result.hasToResolve(context.getFirstViewportLine(), context.getLastViewportLine())) { - // No codelens to resolve to the current viewport + if (result == null) { return; } - // Refresh the LSP code vision to resolve codelens visible in the viewport - var file = codeLensSupport.getFile(); - final int firstViewportLine = context.getFirstViewportLine(); - final int lastViewportLine = context.getLastViewportLine(); - context.refreshCodeVision(result, - file, - firstViewportLine, - lastViewportLine); + + // call of context.updateViewportLines requires EDT+ReadAction + if (!updateViewportLinesAndRefreshCodeVision(result, newRect, codeLensSupport, context, false)) { + // Use NonBlockingReadAction to safely read PSI and file data + ReadAction.nonBlocking(() -> { + return null; + }) + .expireWhen(() -> editor.isDisposed()) + .coalesceBy(editor, LSPCodeLensEditorFactoryListener.this) + .finishOnUiThread(ModalityState.any(), unused -> { + // Refresh Code visions by forcing ReadAction if needed + updateViewportLinesAndRefreshCodeVision(result, newRect, codeLensSupport, context, true); + }) + .submit(AppExecutorUtil.getAppExecutorService()); + } } }); } /** - * Retrieves the unresolved code lens resolution context for the given editor. - * If no context exists, it synchronously creates and stores a new one. + * Called when an editor is created. + * Checks if the editor's file is supported and attaches a scroll listener. * - * @param editor The editor for which the context is retrieved. - * @return The unresolved code lens viewport context for the editor. + * @param event The event associated with the editor creation. */ - @NotNull - public static UnresolvedCodeLensViewportContext getCodeLensResolveContext(@NotNull Editor editor) { - UnresolvedCodeLensViewportContext context = editor.getUserData(UNRESOLVED_CODELENS_CONTEXT_KEY); - if (context != null) { - return context; + @Override + public void editorCreated(@NotNull EditorFactoryEvent event) { + Editor editor = event.getEditor(); + if (editor.getVirtualFile() != null) { + // Attach listener only for editor which is editing file + attachScrollListener(editor); } - return createCodeLensResolveContextSync(editor); } - /** - * Synchronously creates and stores a new unresolved code lens resolution context for the editor. - * - * @param editor The editor for which the context is created. - * @return The newly created unresolved code lens viewport context. - */ - @NotNull - private synchronized static UnresolvedCodeLensViewportContext createCodeLensResolveContextSync(@NotNull Editor editor) { + @Override + public void editorReleased(@NotNull EditorFactoryEvent event) { + Editor editor = event.getEditor(); UnresolvedCodeLensViewportContext context = editor.getUserData(UNRESOLVED_CODELENS_CONTEXT_KEY); if (context != null) { - return context; + context.dispose(); + editor.putUserData(UNRESOLVED_CODELENS_CONTEXT_KEY, null); } - context = new UnresolvedCodeLensViewportContext(editor); - editor.putUserData(UNRESOLVED_CODELENS_CONTEXT_KEY, context); - return context; - } - - public static void setCodelensSupport(@NotNull Editor editor, LSPCodeLensSupport codeLensSupport) { - editor.putUserData(CODELENS_SUPPORT_KEY, codeLensSupport); - } - - @Nullable - public static LSPCodeLensSupport getCodeLensSupport(@NotNull Editor editor) { - return editor.getUserData(CODELENS_SUPPORT_KEY); + editor.putUserData(CODELENS_SUPPORT_KEY, null); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensProvider.java index ed36706c4..0ce03aa93 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/LSPCodeLensProvider.java @@ -134,12 +134,21 @@ public CodeVisionState computeCodeVision(@NotNull Editor editor, Void uiData) { // There is some codelens to resolve applicableCodeLens = new ArrayList<>(); var resolveContext = LSPCodeLensEditorFactoryListener.getCodeLensResolveContext(editor); + // Get viewport lines - if not initialized yet (viewport is updated by scroll listener on EDT), + // use a default range to resolve code lenses in the first visible area + int firstLine = resolveContext.getFirstViewportLine(); + int lastLine = resolveContext.getLastViewportLine(); + if (firstLine == -1) { + // Viewport not yet initialized, use a reasonable default (first ~100 lines) + firstLine = 0; + lastLine = 100; + } // Get the codelens to resolve which are inside the view port range (visible lines) @Nullable CompletableFuture visibleCodeLensToResolve = collectApplicableCodeLens(codelensData, applicableCodeLens, - resolveContext.getFirstViewportLine(), - resolveContext.getLastViewportLine()); + firstLine, + lastLine); if (visibleCodeLensToResolve != null) { // Resolve all visible code lens try { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/UnresolvedCodeLensViewportContext.java b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/UnresolvedCodeLensViewportContext.java index 39f5ecd93..3852330ed 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/UnresolvedCodeLensViewportContext.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/codeLens/UnresolvedCodeLensViewportContext.java @@ -32,8 +32,8 @@ public class UnresolvedCodeLensViewportContext implements Disposable { private static final long VIEWPORT_CHANGE_DELAY_MS = 500L; // Debounce delay before processing viewport changes private final @NotNull Editor editor; - private int firstViewportLine; - private int lastViewportLine; + private int firstViewportLine = -1; + private int lastViewportLine = -1; private long modificationStamp; private volatile Alarm scrollStopAlarm = null; @@ -61,7 +61,7 @@ public void updateViewportLines(@NotNull Rectangle visibleArea) { /** * Gets the first visible line in the editor's viewport. * - * @return The first visible line number. + * @return The first visible line number, or -1 if not yet initialized. */ public int getFirstViewportLine() { return firstViewportLine; @@ -70,12 +70,21 @@ public int getFirstViewportLine() { /** * Gets the last visible line in the editor's viewport. * - * @return The last visible line number. + * @return The last visible line number, or -1 if not yet initialized. */ public int getLastViewportLine() { return lastViewportLine; } + /** + * Checks if the viewport has been initialized. + * + * @return true if viewport lines have been set, false otherwise. + */ + public boolean isViewportInitialized() { + return firstViewportLine != -1; + } + /** * Resolves unresolved code lens elements in the current viewport and refreshes the editor accordingly. * If a previous refresh task is pending, it will be cancelled before scheduling a new one. @@ -136,8 +145,14 @@ public void dispose() { * @return true if the file content has changed and false otherwise. */ public boolean hasFileChanged(@NotNull PsiFile file) { - if (modificationStamp != file.getModificationStamp()) { - modificationStamp = file.getModificationStamp(); + long currentStamp = file.getModificationStamp(); + if (modificationStamp == 0) { + // First time initialization - don't consider this as a change + modificationStamp = currentStamp; + return false; + } + if (modificationStamp != currentStamp) { + modificationStamp = currentStamp; return true; } return false; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/color/ColorInlayPresentation.java b/src/main/java/com/redhat/devtools/lsp4ij/features/color/ColorInlayPresentation.java index 3f0c40563..6545c8e66 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/color/ColorInlayPresentation.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/color/ColorInlayPresentation.java @@ -13,9 +13,7 @@ import com.intellij.codeInsight.hints.presentation.BasePresentation; import com.intellij.codeInsight.hints.presentation.PresentationFactory; import com.intellij.openapi.editor.markup.TextAttributes; -import com.intellij.ui.ColorUtil; import com.intellij.ui.Gray; -import com.intellij.ui.JBColor; import org.jetbrains.annotations.NotNull; import java.awt.*; @@ -70,10 +68,4 @@ public void paint(@NotNull Graphics2D g, @NotNull TextAttributes textAttributes) g.setColor(preservedBackground); } - - private static boolean isDarkTheme() { - Color color = JBColor.background(); - return ColorUtil.isDark(color); - } - } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/declaration/LSPGoToDeclarationAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/declaration/LSPGoToDeclarationAction.java index c04cd319a..3a4794e8b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/declaration/LSPGoToDeclarationAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/declaration/LSPGoToDeclarationAction.java @@ -68,7 +68,7 @@ protected CompletableFuture> getLocations(@NotNull PsiFile ps @Override protected boolean canSupportFeature(@NotNull LSPClientFeatures clientFeatures, @NotNull PsiFile file) { - return clientFeatures.getDeclarationFeature().isDeclarationSupported(file); + return clientFeatures.getDeclarationFeature().isSupported(file); } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticAnnotator.java b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticAnnotator.java index f6ce8d079..e12dd41e5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticAnnotator.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticAnnotator.java @@ -23,7 +23,6 @@ import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPIJUtils; -import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; import com.redhat.devtools.lsp4ij.OpenedDocument; import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; @@ -51,21 +50,23 @@ public LSPDiagnosticAnnotator() { @Nullable @Override public Boolean collectInformation(@NotNull PsiFile file, @NotNull Editor editor, boolean hasErrors) { - if (!LanguageServersRegistry.getInstance().isFileSupported(file)) { - return Boolean.FALSE; - } // Loop for language server which report diagnostics for the given file // and mark all opened documents as 'displaying diagnostics' var servers = LanguageServiceAccessor.getInstance(file.getProject()) .getStartedServers(); + if (servers.isEmpty()) { + return Boolean.FALSE; + } + boolean hasOpenedDocument = false; for (var ls : servers) { URI fileUri = FileUriSupport.getFileUri(file.getVirtualFile(), ls.getClientFeatures()); OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); if (openedDocument != null) { + hasOpenedDocument = true; openedDocument.markAsDisplayingDiagnostics(); } } - return Boolean.TRUE; + return hasOpenedDocument; } @Override @@ -78,6 +79,11 @@ public void doApply(@NotNull PsiFile psiFile, Boolean applyAnnotator, @NotNull A if (!applyAnnotator) { return; } + var servers = LanguageServiceAccessor.getInstance(psiFile.getProject()) + .getStartedServers(); + if (servers.isEmpty()) { + return; + } VirtualFile file = psiFile.getVirtualFile(); if (file == null) { return; @@ -88,8 +94,6 @@ public void doApply(@NotNull PsiFile psiFile, Boolean applyAnnotator, @NotNull A return; } // Loop for language server which report diagnostics for the given file - var servers = LanguageServiceAccessor.getInstance(psiFile.getProject()) - .getStartedServers(); for (var ls : servers) { URI fileUri = FileUriSupport.getFileUri(file, ls.getClientFeatures()); OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticHandler.java index 96c2856de..2b7fc559c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticHandler.java @@ -31,14 +31,14 @@ */ public class LSPDiagnosticHandler implements Consumer { - private final LanguageServerWrapper languageServerWrapper; + private final @NotNull LanguageServerWrapper languageServerWrapper; - public LSPDiagnosticHandler(LanguageServerWrapper languageServerWrapper) { + public LSPDiagnosticHandler(@NotNull LanguageServerWrapper languageServerWrapper) { this.languageServerWrapper = languageServerWrapper; } @Override - public void accept(PublishDiagnosticsParams params) { + public void accept(@NotNull PublishDiagnosticsParams params) { Project project = languageServerWrapper.getProject(); if (project.isDisposed()) { return; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticUtils.java index 9b63d7be2..66968cbf0 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticUtils.java @@ -1,163 +1,173 @@ -/******************************************************************************* - * Copyright (c) 2025 Red Hat Inc. and others. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 - * which is available at https://www.apache.org/licenses/LICENSE-2.0. - * - * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 - * - * Contributors: - * Red Hat Inc. - initial API and implementation - *******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.diagnostics; - -import com.intellij.openapi.application.ReadAction; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.problems.WolfTheProblemSolver; -import com.intellij.util.concurrency.AppExecutorUtil; -import com.redhat.devtools.lsp4ij.ClosedDocument; -import com.redhat.devtools.lsp4ij.LSPDocumentBase; -import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; -import com.redhat.devtools.lsp4ij.OpenedDocument; -import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; -import org.eclipse.lsp4j.Diagnostic; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.net.URI; -import java.util.Collection; -import java.util.concurrent.Callable; - -/** - * LSP diagnostic utilities. - */ -public class LSPDiagnosticUtils { - - private static final @NotNull Object LSP4IJ_REPORT_PROBLEM_SOURCE = new Object(); - - private LSPDiagnosticUtils() { - - } - - /** - * Returns true if the old and new diagnostics list changed and false otherwise. - * - * @param oldDiagnostics old diagnostics - * @param newDiagnostics new diagnostics - * @return true if the old and new diagnostics list changed and false otherwise. - */ - public static boolean isDiagnosticsChanged(@NotNull Collection oldDiagnostics, - @NotNull Collection newDiagnostics) { - if (oldDiagnostics.size() != newDiagnostics.size()) { - return true; - } - for (var d : newDiagnostics) { - if (!oldDiagnostics.contains(d)) { - return true; - } - } - return false; - } - - /** - * Report problem in the Project View if the opened/closed document - * has at least one diagnosis with a severity of error - * - * @param file the file - * @param document the opened/closed dpocument which trigger the report problem. - * @param project the project. - */ - public static void reportProblem(@NotNull VirtualFile file, - @Nullable LSPDocumentBase document, - @NotNull Project project) { - ReadAction.nonBlocking((Callable) () -> { - boolean hasErrors = isHasErrors(file, document, project); - reportProblem(file, project, hasErrors); - return null; - }) - .coalesceBy(file, project) - .submit(AppExecutorUtil.getAppExecutorService()); - } - - private static boolean isHasErrors(@NotNull VirtualFile file, - @Nullable LSPDocumentBase document, - @NotNull Project project) { - boolean hasErrors = document != null && document.hasErrors(); - if (!hasErrors) { - hasErrors = hasErrors(file, project); - } - return hasErrors; - } - - private static void reportProblem(@NotNull VirtualFile file, - @NotNull Project project, - boolean hasErrors) { - WolfTheProblemSolver wolf = WolfTheProblemSolver.getInstance(project); - if (hasErrors) { - wolf.reportProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); - } else { - wolf.clearProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); - } - } - - private static boolean hasErrors(@NotNull VirtualFile file, - @NotNull Project project) { - var servers = LanguageServiceAccessor.getInstance(project) - .getStartedServers(); - for (var ls : servers) { - var clientFeatures = ls.getClientFeatures(); - URI fileUri = FileUriSupport.getFileUri(file, clientFeatures); - OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); - if (openedDocument != null && openedDocument.hasErrors()) { - return true; - } else { - ClosedDocument closedDocument = ls.getClosedDocument(fileUri, false); - if (closedDocument != null && closedDocument.hasErrors()) { - return true; - } - } - } - return false; - } - - /** - * Returns true if the given file can report problem and false otherwise. - * - * @param file the file. - * @param project the project. - * @return true if the given file can report problem and false otherwise. - */ - public static boolean canReportProblem(@NotNull VirtualFile file, - @NotNull Project project) { - var servers = LanguageServiceAccessor.getInstance(project) - .getStartedServers(); - for (var ls : servers) { - var clientFeatures = ls.getClientFeatures(); - URI fileUri = FileUriSupport.getFileUri(file, clientFeatures); - OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); - if (openedDocument != null) { - return true; - } else { - ClosedDocument closedDocument = ls.getClosedDocument(fileUri, false); - if (closedDocument != null) { - return true; - } - } - } - return false; - } - - public static void clearProblem(@NotNull VirtualFile file, - @NotNull Project project) { - WolfTheProblemSolver wolf = WolfTheProblemSolver.getInstance(project); - ReadAction.nonBlocking((Callable) () -> { - wolf.clearProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); - return null; - }) - .coalesceBy(file, project) - .submit(AppExecutorUtil.getAppExecutorService()); - } -} +/******************************************************************************* + * Copyright (c) 2025 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.diagnostics; + +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.problems.WolfTheProblemSolver; +import com.intellij.util.concurrency.AppExecutorUtil; +import com.redhat.devtools.lsp4ij.*; +import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; +import org.eclipse.lsp4j.Diagnostic; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.util.Collection; +import java.util.Set; +import java.util.concurrent.Callable; + +/** + * LSP diagnostic utilities. + */ +public class LSPDiagnosticUtils { + + private static final @NotNull Object LSP4IJ_REPORT_PROBLEM_SOURCE = new Object(); + + private LSPDiagnosticUtils() { + + } + + /** + * Returns true if the old and new diagnostics list changed and false otherwise. + * + * @param oldDiagnostics old diagnostics + * @param newDiagnostics new diagnostics + * @return true if the old and new diagnostics list changed and false otherwise. + */ + public static boolean isDiagnosticsChanged(@NotNull Collection oldDiagnostics, + @NotNull Collection newDiagnostics) { + if (oldDiagnostics.size() != newDiagnostics.size()) { + return true; + } + for (var d : newDiagnostics) { + if (!oldDiagnostics.contains(d)) { + return true; + } + } + return false; + } + + /** + * Report problem in the Project View if the opened/closed document + * has at least one diagnosis with a severity of error + * + * @param file the file + * @param document the opened/closed dpocument which trigger the report problem. + * @param project the project. + */ + public static void reportProblem(@NotNull VirtualFile file, + @Nullable LSPDocumentBase document, + @NotNull Project project) { + ReadAction.nonBlocking((Callable) () -> { + boolean hasErrors = isHasErrors(file, document, project); + reportProblem(file, project, hasErrors); + return null; + }) + .coalesceBy(LSP4IJ_REPORT_PROBLEM_SOURCE, file, project) + .submit(AppExecutorUtil.getAppExecutorService()); + } + + private static boolean isHasErrors(@NotNull VirtualFile file, + @Nullable LSPDocumentBase document, + @NotNull Project project) { + boolean hasErrors = document != null && document.hasErrors(); + if (!hasErrors) { + hasErrors = hasErrors(file, project); + } + return hasErrors; + } + + private static void reportProblem(@NotNull VirtualFile file, + @NotNull Project project, + boolean hasErrors) { + WolfTheProblemSolver wolf = WolfTheProblemSolver.getInstance(project); + if (hasErrors) { + wolf.reportProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); + } else { + wolf.clearProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); + } + } + + private static boolean hasErrors(@NotNull VirtualFile file, + @NotNull Project project) { + var servers = LanguageServiceAccessor.getInstance(project) + .getStartedServers(); + for (var ls : servers) { + var clientFeatures = ls.getClientFeatures(); + URI fileUri = FileUriSupport.getFileUri(file, clientFeatures); + OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); + if (openedDocument != null) { + if (openedDocument.hasErrors()) { + return true; + } + // If we have opened document, but it has no errors, just ignore this document, + // do not check closed document (because we can have stale closed document + // with incorrect 'hasErrors' state) + } else { + ClosedDocument closedDocument = ls.getClosedDocument(fileUri, false); + if (closedDocument != null && closedDocument.hasErrors()) { + return true; + } + } + } + return false; + } + + /** + * Returns true if the given file can report problem and false otherwise. + * + * @param file the file. + * @param project the project. + * @return true if the given file can report problem and false otherwise. + */ + public static boolean canReportProblem(@NotNull VirtualFile file, + @NotNull Project project) { + var servers = LanguageServiceAccessor.getInstance(project) + .getStartedServers(); + for (var ls : servers) { + var clientFeatures = ls.getClientFeatures(); + URI fileUri = FileUriSupport.getFileUri(file, clientFeatures); + OpenedDocument openedDocument = ls.getOpenedDocument(fileUri); + if (openedDocument != null) { + return true; + } else { + ClosedDocument closedDocument = ls.getClosedDocument(fileUri, false); + if (closedDocument != null) { + return true; + } + } + } + return false; + } + + public static void clearProblem(@NotNull Set fileUris, + @NotNull LSPClientFeatures clientFeatures, + @NotNull Project project) { + WolfTheProblemSolver wolf = WolfTheProblemSolver.getInstance(project); + ReadAction.nonBlocking((Callable) () -> { + for (URI fileUri : fileUris) { + VirtualFile file = FileUriSupport.findFileByUri(fileUri.toASCIIString(), clientFeatures); + if (file != null && clientFeatures.getDiagnosticFeature().canReportProblem(file)) { + wolf.clearProblemsFromExternalSource(file, LSP4IJ_REPORT_PROBLEM_SOURCE); + } + } + return null; + }) + .coalesceBy(fileUris, clientFeatures, project) + .submit(AppExecutorUtil.getAppExecutorService()); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticsForServer.java b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticsForServer.java index 1ff042f4c..266c62449 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticsForServer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/diagnostics/LSPDiagnosticsForServer.java @@ -162,16 +162,16 @@ private Map toMap(@NotNull Collection { // Get the existing LSP lazy code actions for the current diagnostic - LSPLazyCodeActions actions = existingDiagnostics != null ? existingDiagnostics.get(d) : null; - if (actions != null) { + LSPLazyCodeActions oldCodeActions = existingDiagnostics != null ? existingDiagnostics.get(d) : null; + if (oldCodeActions != null) { // cancel the LSP textDocument/codeAction request if needed - actions.cancel(); + oldCodeActions.cancel(); } - map.put(d, action); + map.put(d, newCodeActions); }); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/DocumentSymbolData.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/DocumentSymbolData.java index 002b19829..18893aa90 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/DocumentSymbolData.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/DocumentSymbolData.java @@ -1,13 +1,16 @@ /******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html + * Copyright (c) 2024 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 * * Contributors: - * CppCXY - ******************************************************************************/ + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ package com.redhat.devtools.lsp4ij.features.documentSymbol; import com.intellij.openapi.editor.Document; @@ -25,6 +28,7 @@ import org.jetbrains.annotations.Nullable; import javax.swing.Icon; +import java.util.Objects; /** @@ -39,6 +43,7 @@ public class DocumentSymbolData extends FakePsiElement { private final DocumentSymbolData parent; private final @NotNull LanguageServerItem languageServer; private volatile TextRange textRange = null; + private volatile TextRange selectionTextRange = null; private DocumentSymbolData[] cachedChildren; public DocumentSymbolData(@NotNull DocumentSymbol documentSymbol, @@ -95,6 +100,42 @@ public TextRange getTextRange() { return textRange; } + public @Nullable TextRange getSelectionTextRange() { + if (documentSymbol.getSelectionRange() == null) { + return null; + } + if (selectionTextRange == null) { + synchronized (this) { + if (selectionTextRange == null) { + Range range = documentSymbol.getSelectionRange(); + Document document = LSPIJUtils.getDocument(psiFile); + this.selectionTextRange = (document != null) ? LSPIJUtils.toTextRange(range, document) : null; + } + } + } + return selectionTextRange; + } + + @Override + public boolean equals(Object o) { + if (o == null) { + return false; + } + if (getClass() == o.getClass()) { + DocumentSymbolData that = (DocumentSymbolData) o; + return Objects.equals(documentSymbol, that.documentSymbol); + } + if (o instanceof PsiElement psiElement) { + return getClientFeatures().getDocumentSymbolFeature().matchesPsiElement(this, psiElement); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(documentSymbol); + } + @Override public void navigate(boolean requestFocus) { getClientFeatures().getDocumentSymbolFeature().navigate(documentSymbol, psiFile, requestFocus); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPBreadcrumbsProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPBreadcrumbsProvider.java index ab63fe426..e1eb53ef4 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPBreadcrumbsProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPBreadcrumbsProvider.java @@ -16,13 +16,12 @@ import com.intellij.ide.structureView.StructureViewTreeElement; import com.intellij.lang.Language; -import com.intellij.lang.LanguageUtil; import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.Conditions; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.ui.breadcrumbs.BreadcrumbsProvider; import com.intellij.util.containers.ContainerUtil; +import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; import com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewModel.LSPDocumentSymbolViewElement; import com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewModel.LSPFileStructureViewElement; @@ -42,9 +41,7 @@ public class LSPBreadcrumbsProvider implements BreadcrumbsProvider { @Override public Language[] getLanguages() { - // Register for all languages and filter in isSupported() - List languages = LanguageUtil.getLanguages(Conditions.alwaysTrue()); - return languages.toArray(Language.EMPTY_ARRAY); + return LanguageServersRegistry.getInstance().getSupportedLanguages().toArray(Language.EMPTY_ARRAY); } private boolean isSupported(@NotNull PsiElement element) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewFactory.java index df2e91571..6f177eb4b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewFactory.java @@ -1,13 +1,16 @@ /******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html + * Copyright (c) 2024 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 * * Contributors: - * CppCXY - ******************************************************************************/ + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ package com.redhat.devtools.lsp4ij.features.documentSymbol; import com.intellij.ide.structureView.StructureViewBuilder; @@ -16,6 +19,7 @@ import com.intellij.lang.PsiStructureViewFactory; import com.intellij.openapi.editor.Editor; import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; import com.redhat.devtools.lsp4ij.client.ExecuteLSPFeatureStatus; import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; import org.jetbrains.annotations.NotNull; @@ -28,7 +32,8 @@ public class LSPDocumentSymbolStructureViewFactory implements PsiStructureViewFa @Override public @Nullable StructureViewBuilder getStructureViewBuilder(@NotNull PsiFile psiFile) { - if (ProjectIndexingManager.canExecuteLSPFeature(psiFile) != ExecuteLSPFeatureStatus.NOW) { + if (ProjectIndexingManager.canExecuteLSPFeature(psiFile) != ExecuteLSPFeatureStatus.NOW + || !isSymbolsSupportedByLanguageServer(psiFile)) { return null; } return new LSPDocumentSymbolStructureViewBuilder(psiFile); @@ -46,4 +51,22 @@ public LSPDocumentSymbolStructureViewBuilder(@NotNull PsiFile psiFile) { return new LSPDocumentSymbolStructureViewModel(psiFile, editor); } } + + /** + * Checks if document symbols are supported by at least one language server for the given file. + *

    + * Document symbols are supported when a language server provides the textDocument/documentSymbol LSP feature + * and it is enabled and supported for the given file. + *

    + * + * @param file the PSI file to check. + * @return {@code true} if at least one language server supports document symbols for this file, {@code false} otherwise. + */ + public static boolean isSymbolsSupportedByLanguageServer(@NotNull PsiFile file) { + return LanguageServiceAccessor.getInstance(file.getProject()) + .hasAny(file, ls -> { + var symbolFeature = ls.getClientFeatures().getDocumentSymbolFeature(); + return symbolFeature.isEnabled(file) && symbolFeature.isSupported(file); + }); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewModel.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewModel.java index ba50a32b9..97f1a8bc1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewModel.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolStructureViewModel.java @@ -1,27 +1,38 @@ /******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html + * Copyright (c) 2024 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 * * Contributors: - * CppCXY - ******************************************************************************/ + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ package com.redhat.devtools.lsp4ij.features.documentSymbol; import com.intellij.ide.structureView.StructureViewModel; import com.intellij.ide.structureView.StructureViewModelBase; import com.intellij.ide.structureView.StructureViewTreeElement; import com.intellij.ide.structureView.impl.common.PsiTreeElementBase; +import com.intellij.ide.util.treeView.smartTree.Filter; +import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.registry.Registry; +import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.util.ArrayUtil; import com.redhat.devtools.lsp4ij.LSPFileSupport; import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; -import com.redhat.devtools.lsp4ij.internal.PsiFileChangedException; +import com.redhat.devtools.lsp4ij.features.documentSymbol.filter.*; +import org.eclipse.lsp4j.DocumentSymbol; import org.eclipse.lsp4j.DocumentSymbolParams; +import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,10 +41,9 @@ import java.util.Collection; import java.util.Collections; import java.util.Objects; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ExecutionException; import java.util.stream.Stream; +import static com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewFactory.isSymbolsSupportedByLanguageServer; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.isDoneNormally; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; @@ -42,11 +52,8 @@ */ public class LSPDocumentSymbolStructureViewModel extends StructureViewModelBase implements StructureViewModel.ElementInfoProvider { - private final PsiFile psiFile; - public LSPDocumentSymbolStructureViewModel(@NotNull PsiFile psiFile, @Nullable Editor editor) { super(psiFile, editor, new LSPFileStructureViewElement(psiFile)); - this.psiFile = psiFile; } @Override @@ -59,6 +66,44 @@ public boolean isAlwaysLeaf(StructureViewTreeElement element) { return element.getChildren().length == 0; } + @Override + protected Class @NotNull [] getSuitableClasses() { + // Any PSI element + return new Class[]{PsiElement.class}; + } + + @Override + public Filter @NotNull [] getFilters() { + return new Filter[]{ + new HideArraysFilter(), + new HideBooleansFilter(), + new HideClassesFilter(), + new HideConstantsFilter(), + new HideConstructorsFilter(), + new HideEnumMembersFilter(), + new HideEnumsFilter(), + new HideEventsFilter(), + new HideFieldsFilter(), + new HideFilesFilter(), + new HideFunctionsFilter(), + new HideInterfacesFilter(), + new HideKeysFilter(), + new HideMethodsFilter(), + new HideModulesFilter(), + new HideNamespacesFilter(), + new HideNullsFilter(), + new HideNumbersFilter(), + new HideObjectsFilter(), + new HideOperatorsFilter(), + new HidePackagesFilter(), + new HidePropertiesFilter(), + new HideStringsFilter(), + new HideStructsFilter(), + new HideTypeParametersFilter(), + new HideVariablesFilter() + }; + } + @Override public void dispose() { super.dispose(); @@ -72,12 +117,19 @@ public void dispose() { static class LSPFileStructureViewElement extends PsiTreeElementBase { + @Nullable + private volatile Collection cachedChildren; + public LSPFileStructureViewElement(@NotNull PsiFile psiFile) { super(psiFile); } @Override public @NotNull Collection getChildrenBase() { + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + Collection cached = cachedChildren; + if (cached != null) return cached; + } PsiFile file = getElement(); return file != null ? collectElements(file) : Collections.emptyList(); } @@ -87,9 +139,11 @@ public LSPFileStructureViewElement(@NotNull PsiFile psiFile) { return Collections.emptyList(); } - if (!LSPFileSupport.hasSupport(psiFile)) { + if (!LSPFileSupport.hasSupport(psiFile) || !isSymbolsSupportedByLanguageServer(psiFile)) { // Don't force file support creation - // Ex: when file is closed, document symbol must return an empty list. + // Ex: + // 1. when file is closed, document symbol must return an empty list. + // 2. when language servers are stopped, document symbol must return an empty list. return Collections.emptyList(); } @@ -100,27 +154,30 @@ public LSPFileStructureViewElement(@NotNull PsiFile psiFile) { var documentSymbolFuture = documentSymbolSupport.getDocumentSymbols(params); try { waitUntilDone(documentSymbolFuture, psiFile); - } catch ( - PsiFileChangedException e) { //Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility - documentSymbolSupport.cancel(); + } catch (ProcessCanceledException e) { throw e; - } catch (CancellationException e) { - //documentSymbolSupport.cancel(); - throw e; - } catch (ExecutionException e) { + } catch (Exception e) { return Collections.emptyList(); } if (isDoneNormally(documentSymbolFuture)) { var documentSymbols = documentSymbolFuture.getNow(null); if (documentSymbols == null) { + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + cachedChildren = Collections.emptyList(); + } return Collections.emptyList(); } - return documentSymbols.stream() + Collection result = documentSymbols.stream() .map(LSPDocumentSymbolStructureViewModel::getStructureViewTreeElement) .filter(Objects::nonNull) .toList(); + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + cachedChildren = result; + } + return result; } + // Future not yet done — don't cache so the next call retries return Collections.emptyList(); } @@ -145,12 +202,52 @@ public LSPFileStructureViewElement(@NotNull PsiFile psiFile) { public static class LSPDocumentSymbolViewElement extends PsiTreeElementBase { + @Nullable + private volatile Collection cachedChildren; + + // Stored directly to avoid PSI-anchor read lock during BFS traversal in getContainingDocumentSymbolDatas + private final @NotNull DocumentSymbol documentSymbolDirect; + + // Cached on first BFS pass; safe because the model itself is invalidated on document modification stamp change + @Nullable + private volatile TextRange cachedTextRange; + private volatile boolean textRangeCached = false; + public LSPDocumentSymbolViewElement(DocumentSymbolData documentSymbolData) { super(documentSymbolData); + this.documentSymbolDirect = documentSymbolData.getDocumentSymbol(); + } + + /** + * Returns the underlying LSP {@link DocumentSymbol} without acquiring a read lock. + * Use this instead of {@code getElement().getDocumentSymbol()} in performance-sensitive paths. + */ + public @NotNull DocumentSymbol getDocumentSymbolDirect() { + return documentSymbolDirect; + } + + /** + * Returns the {@link TextRange} for this symbol, cached after the first computation. + * Safe to cache because the parent model is invalidated whenever the document modification stamp changes. + */ + public @Nullable TextRange getTextRangeDirect(@NotNull Document document, int docLength) { + if (textRangeCached) return cachedTextRange; + Range range = documentSymbolDirect.getRange(); + TextRange result = range != null ? LSPIJUtils.toTextRange(range, document, docLength) : null; + cachedTextRange = result; + textRangeCached = true; + return result; } @Override public @NotNull Collection getChildrenBase() { + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + Collection cached = cachedChildren; + if (cached != null) return cached; + Collection result = collectElements(getElement()); + cachedChildren = result; + return result; + } return collectElements(getElement()); } @@ -175,5 +272,4 @@ public LSPDocumentSymbolViewElement(DocumentSymbolData documentSymbolData) { private static @Nullable StructureViewTreeElement getStructureViewTreeElement(DocumentSymbolData documentSymbol) { return documentSymbol.getClientFeatures().getDocumentSymbolFeature().getStructureViewTreeElement(documentSymbol); } - } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolSupport.java index c7635a124..f03ce21ad 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolSupport.java @@ -1,13 +1,16 @@ /******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html + * Copyright (c) 2024 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 * * Contributors: - * CppCXY - ******************************************************************************/ + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ package com.redhat.devtools.lsp4ij.features.documentSymbol; import com.intellij.psi.PsiFile; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolUtils.java index ed6adaaab..e0d44342a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPDocumentSymbolUtils.java @@ -11,13 +11,14 @@ * Contributors: * Red Hat Inc. - initial API and implementation *******************************************************************************/ - package com.redhat.devtools.lsp4ij.features.documentSymbol; import com.intellij.ide.structureView.StructureViewTreeElement; import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.util.Key; import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.registry.Registry; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.util.containers.ContainerUtil; @@ -37,25 +38,46 @@ */ final class LSPDocumentSymbolUtils { + private static final Key STRUCTURE_VIEW_MODEL_KEY = Key.create("LSP.DocumentSymbol.StructureViewModel"); + + private record CachedModel(long stamp, @NotNull LSPDocumentSymbolStructureViewModel model) {} + private LSPDocumentSymbolUtils() { // Pure utility class } /** * Returns the structure view model for the provided element. + * The model is cached per-file and invalidated when the document modification stamp changes, + * so multiple callbacks from the breadcrumbs/sticky-lines provider within a single pass reuse + * the same instance instead of rebuilding it for every element. * * @param element the element * @return the structure view model for the element, or null if none was found */ @Nullable static LSPDocumentSymbolStructureViewModel getStructureViewModel(@NotNull PsiElement element) { + PsiFile file = element.getContainingFile(); + if (file == null) return null; Editor editor = LSPIJUtils.editorForElement(element); - if (editor != null) { - PsiFile file = element.getContainingFile(); + if (editor == null) return null; + + if (!Registry.is("lsp4ij.performance.optimizations.enabled")) { return new LSPDocumentSymbolStructureViewModel(file, editor); } - return null; + Document document = LSPIJUtils.getDocument(file); + if (document == null) return null; + + long stamp = document.getModificationStamp(); + CachedModel cached = file.getUserData(STRUCTURE_VIEW_MODEL_KEY); + if (cached != null && cached.stamp() == stamp) { + return cached.model(); + } + + LSPDocumentSymbolStructureViewModel model = new LSPDocumentSymbolStructureViewModel(file, editor); + file.putUserData(STRUCTURE_VIEW_MODEL_KEY, new CachedModel(stamp, model)); + return model; } /** @@ -113,16 +135,30 @@ private static List getContainingDocumentSymbolDatas(@NotNul // Otherwise add document symbol datas that contain the offset in a breadth-first manner so that the last one is // the closest one for the offset else if (structureViewTreeElement instanceof LSPDocumentSymbolViewElement documentSymbolViewElement) { - DocumentSymbolData documentSymbolData = documentSymbolViewElement.getElement(); - DocumentSymbol documentSymbol = documentSymbolData != null ? documentSymbolData.getDocumentSymbol() : null; - Range documentSymbolRange = documentSymbol != null ? documentSymbol.getRange() : null; - TextRange textRange = documentSymbolRange != null ? LSPIJUtils.toTextRange(documentSymbolRange, document, docLength) : null; - if ((textRange != null) && textRange.containsOffset(offset)) { - // Add this one - containingDocumentSymbolDatas.add(documentSymbolData); - - // And all children/descendants that also contain the offset - pending.addAll(documentSymbolViewElement.getChildrenBase()); + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + // getTextRangeDirect() avoids both the PSI-anchor read lock and repeated LSP→IJ range conversion + TextRange textRange = documentSymbolViewElement.getTextRangeDirect(document, docLength); + if ((textRange != null) && textRange.containsOffset(offset)) { + // Add this one — only acquire the read lock for matching nodes (typically 1–3 per file) + DocumentSymbolData documentSymbolData = documentSymbolViewElement.getElement(); + if (documentSymbolData != null) { + containingDocumentSymbolDatas.add(documentSymbolData); + } + + // And all children/descendants that also contain the offset + pending.addAll(documentSymbolViewElement.getChildrenBase()); + } + } else { + DocumentSymbolData documentSymbolData = documentSymbolViewElement.getElement(); + DocumentSymbol documentSymbol = documentSymbolData != null ? documentSymbolData.getDocumentSymbol() : null; + Range documentSymbolRange = documentSymbol != null ? documentSymbol.getRange() : null; + TextRange textRange = documentSymbolRange != null ? LSPIJUtils.toTextRange(documentSymbolRange, document, docLength) : null; + if ((textRange != null) && textRange.containsOffset(offset)) { + containingDocumentSymbolDatas.add(documentSymbolData); + + // And all children/descendants that also contain the offset + pending.addAll(documentSymbolViewElement.getChildrenBase()); + } } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPStructureViewBuilderProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPStructureViewBuilderProvider.java new file mode 100644 index 000000000..5b6b44566 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/LSPStructureViewBuilderProvider.java @@ -0,0 +1,102 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol; + +import com.intellij.ide.structureView.StructureViewBuilder; +import com.intellij.ide.structureView.StructureViewBuilderProvider; +import com.intellij.lang.LanguageStructureViewBuilder; +import com.intellij.lang.PsiStructureViewFactory; +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.fileTypes.impl.AbstractFileType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.redhat.devtools.lsp4ij.LanguageServersRegistry; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * Structure View provider for all {@link AbstractFileType} instances that are associated with + * an active Language Server. + * + *

    IntelliJ Community Edition and other versions may define certain file types as + * {@link AbstractFileType} (for example, CSS in IntelliJ CE). These file types do not + * provide a native Structure View because they lack a fully featured PSI or + * StructureView implementation.

    + * + *

    This provider enables a Structure View for such files if a Language Server + * supports them. It does so by leveraging the PSI language detected for the file + * and obtaining the corresponding {@link PsiStructureViewFactory}.

    + * + *

    Workflow:

    + *
      + *
    1. Check if the file type is an {@link AbstractFileType}. + * Only these file types are considered, since PSI-backed languages already have + * a Structure View.
    2. + *
    3. Verify if the file is supported by a registered Language Server. + * If not, return {@code null} to disable the LSP Structure View.
    4. + *
    5. Retrieve the PSI for the file. IntelliJ assigns the file a language, + * for example {@code CssLanguage} for CSS files.
    6. + *
    7. Obtain the {@link PsiStructureViewFactory} for the PSI language. + * In the context of LSP4IJ, this factory is generally an instance of + * {@link LSPDocumentSymbolStructureViewFactory} provided by the plugin, + * which uses LSP document symbols to build the Structure View.
    8. + *
    9. Return the {@link StructureViewBuilder} from the factory, + * enabling a Structure View (outline) for this AbstractFileType.
    10. + *
    + * + *

    This mechanism is generic and works for any AbstractFileType that has an LSP + * providing document symbols. CSS is used here as an example in IntelliJ CE, but + * other AbstractFileType instances (e.g., log files, custom text-based file types) + * are handled the same way.

    + */ +public class LSPStructureViewBuilderProvider implements StructureViewBuilderProvider { + + @Override + public @Nullable StructureViewBuilder getStructureViewBuilder( + @NotNull FileType fileType, + @NotNull VirtualFile file, + @NotNull Project project + ) { + // 1. Only handle AbstractFileType (e.g., CSS in IntelliJ CE) + if (!(fileType instanceof AbstractFileType)) { + return null; + } + + // 2. Activate Structure View only if a Language Server supports this file + if (!LanguageServersRegistry.getInstance().isFileSupported(file, project)) { + return null; + } + + // 3. Retrieve the PSI for the file. Even AbstractFileType files may have a PSI language + PsiFile psiFile = PsiManager.getInstance(project).findFile(file); + if (psiFile == null) { + return null; + } + + // 4. Obtain the StructureView factory for the PSI language. + // In the LSP4IJ context, this factory is generally an instance of + // LSPDocumentSymbolStructureViewFactory, which builds the StructureView + // from LSP document symbols. + PsiStructureViewFactory factory = + LanguageStructureViewBuilder.getInstance().forLanguage(psiFile.getLanguage()); + if (factory == null) { + return null; + } + + // 5. Return the StructureViewBuilder provided by the factory + return factory.getStructureViewBuilder(psiFile); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideArraysFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideArraysFilter.java new file mode 100644 index 000000000..533c0ea4c --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideArraysFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide arrays in the structure view. + */ +public class HideArraysFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_ARRAYS"; + + public HideArraysFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.arrays"), SymbolKind.Array); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideBooleansFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideBooleansFilter.java new file mode 100644 index 000000000..5188b0d2d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideBooleansFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide booleans in the structure view. + */ +public class HideBooleansFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_BOOLEANS"; + + public HideBooleansFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.booleans"), SymbolKind.Boolean); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideClassesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideClassesFilter.java new file mode 100644 index 000000000..36dab967a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideClassesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide classes in the structure view. + */ +public class HideClassesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_CLASSES"; + + public HideClassesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.classes"), SymbolKind.Class); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstantsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstantsFilter.java new file mode 100644 index 000000000..3562dad1e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstantsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide constants in the structure view. + */ +public class HideConstantsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_CONSTANTS"; + + public HideConstantsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.constants"), SymbolKind.Constant); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstructorsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstructorsFilter.java new file mode 100644 index 000000000..60a5f4d86 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideConstructorsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide constructors in the structure view. + */ +public class HideConstructorsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_CONSTRUCTORS"; + + public HideConstructorsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.constructors"), SymbolKind.Constructor); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumMembersFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumMembersFilter.java new file mode 100644 index 000000000..1caadd749 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumMembersFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide enum members in the structure view. + */ +public class HideEnumMembersFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_ENUM_MEMBERS"; + + public HideEnumMembersFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.enumMembers"), SymbolKind.EnumMember); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumsFilter.java new file mode 100644 index 000000000..c80648fb8 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEnumsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide enums in the structure view. + */ +public class HideEnumsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_ENUMS"; + + public HideEnumsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.enums"), SymbolKind.Enum); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEventsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEventsFilter.java new file mode 100644 index 000000000..77bea8ce2 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideEventsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide events in the structure view. + */ +public class HideEventsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_EVENTS"; + + public HideEventsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.events"), SymbolKind.Event); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFieldsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFieldsFilter.java new file mode 100644 index 000000000..65ec88805 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFieldsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide fields in the structure view. + */ +public class HideFieldsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_FIELDS"; + + public HideFieldsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.fields"), SymbolKind.Field); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFilesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFilesFilter.java new file mode 100644 index 000000000..1c41b3648 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFilesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide files in the structure view. + */ +public class HideFilesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_FILES"; + + public HideFilesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.files"), SymbolKind.File); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFunctionsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFunctionsFilter.java new file mode 100644 index 000000000..7b95f5ebe --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideFunctionsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide functions in the structure view. + */ +public class HideFunctionsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_FUNCTIONS"; + + public HideFunctionsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.functions"), SymbolKind.Function); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideInterfacesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideInterfacesFilter.java new file mode 100644 index 000000000..7936bc99d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideInterfacesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide interfaces in the structure view. + */ +public class HideInterfacesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_INTERFACES"; + + public HideInterfacesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.interfaces"), SymbolKind.Interface); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideKeysFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideKeysFilter.java new file mode 100644 index 000000000..000397c1b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideKeysFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide keys in the structure view. + */ +public class HideKeysFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_KEYS"; + + public HideKeysFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.keys"), SymbolKind.Key); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideMethodsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideMethodsFilter.java new file mode 100644 index 000000000..cd94de92d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideMethodsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide methods in the structure view. + */ +public class HideMethodsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_METHODS"; + + public HideMethodsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.methods"), SymbolKind.Method); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideModulesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideModulesFilter.java new file mode 100644 index 000000000..aac04e2c7 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideModulesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide modules in the structure view. + */ +public class HideModulesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_MODULES"; + + public HideModulesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.modules"), SymbolKind.Module); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNamespacesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNamespacesFilter.java new file mode 100644 index 000000000..5941548c0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNamespacesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide namespaces in the structure view. + */ +public class HideNamespacesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_NAMESPACES"; + + public HideNamespacesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.namespaces"), SymbolKind.Namespace); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNullsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNullsFilter.java new file mode 100644 index 000000000..6286c58e1 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNullsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide nulls in the structure view. + */ +public class HideNullsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_NULLS"; + + public HideNullsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.nulls"), SymbolKind.Null); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNumbersFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNumbersFilter.java new file mode 100644 index 000000000..3664a7b06 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideNumbersFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide numbers in the structure view. + */ +public class HideNumbersFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_NUMBERS"; + + public HideNumbersFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.numbers"), SymbolKind.Number); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideObjectsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideObjectsFilter.java new file mode 100644 index 000000000..401de97c2 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideObjectsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide objects in the structure view. + */ +public class HideObjectsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_OBJECTS"; + + public HideObjectsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.objects"), SymbolKind.Object); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideOperatorsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideOperatorsFilter.java new file mode 100644 index 000000000..36288e42e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideOperatorsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide operators in the structure view. + */ +public class HideOperatorsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_OPERATORS"; + + public HideOperatorsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.operators"), SymbolKind.Operator); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePackagesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePackagesFilter.java new file mode 100644 index 000000000..5f99549af --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePackagesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide packages in the structure view. + */ +public class HidePackagesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_PACKAGES"; + + public HidePackagesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.packages"), SymbolKind.Package); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePropertiesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePropertiesFilter.java new file mode 100644 index 000000000..224056d00 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HidePropertiesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide properties in the structure view. + */ +public class HidePropertiesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_PROPERTIES"; + + public HidePropertiesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.properties"), SymbolKind.Property); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStringsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStringsFilter.java new file mode 100644 index 000000000..0d4bac571 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStringsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide strings in the structure view. + */ +public class HideStringsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_STRINGS"; + + public HideStringsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.strings"), SymbolKind.String); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStructsFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStructsFilter.java new file mode 100644 index 000000000..dd532c2eb --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideStructsFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide structs in the structure view. + */ +public class HideStructsFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_STRUCTS"; + + public HideStructsFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.structs"), SymbolKind.Struct); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideTypeParametersFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideTypeParametersFilter.java new file mode 100644 index 000000000..c442cf0ec --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideTypeParametersFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide type parameters in the structure view. + */ +public class HideTypeParametersFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_TYPE_PARAMETERS"; + + public HideTypeParametersFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.typeParameters"), SymbolKind.TypeParameter); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideVariablesFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideVariablesFilter.java new file mode 100644 index 000000000..6ea586140 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/HideVariablesFilter.java @@ -0,0 +1,29 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import org.eclipse.lsp4j.SymbolKind; + +/** + * Filter to hide variables in the structure view. + */ +public class HideVariablesFilter extends LSPSymbolKindFilter { + + public static final String ID = "SHOW_VARIABLES"; + + public HideVariablesFilter() { + super(ID, LanguageServerBundle.message("structure.view.filter.show.variables"), SymbolKind.Variable); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/LSPSymbolKindFilter.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/LSPSymbolKindFilter.java new file mode 100644 index 000000000..140e2c8f3 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentSymbol/filter/LSPSymbolKindFilter.java @@ -0,0 +1,85 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.documentSymbol.filter; + +import com.intellij.ide.util.treeView.smartTree.ActionPresentation; +import com.intellij.ide.util.treeView.smartTree.ActionPresentationData; +import com.intellij.ide.util.treeView.smartTree.Filter; +import com.intellij.ide.util.treeView.smartTree.TreeElement; +import com.redhat.devtools.lsp4ij.features.documentSymbol.DocumentSymbolData; +import com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewModel; +import com.redhat.devtools.lsp4ij.ui.IconMapper; +import org.eclipse.lsp4j.SymbolKind; +import org.jetbrains.annotations.NotNull; + +import javax.swing.*; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Base class for LSP symbol kind filters in the structure view. + * Filters can hide/show specific types of symbols based on their SymbolKind. + */ +public abstract class LSPSymbolKindFilter implements Filter { + + private final String id; + private final String text; + private final Set filteredKinds; + private volatile ActionPresentation cachedPresentation; + + protected LSPSymbolKindFilter(@NotNull String id, + @NotNull String text, + @NotNull SymbolKind... kinds) { + this.id = id; + this.text = text; + this.filteredKinds = Arrays.stream(kinds).collect(Collectors.toSet()); + } + + @Override + public boolean isVisible(TreeElement treeNode) { + if (treeNode instanceof LSPDocumentSymbolStructureViewModel.LSPDocumentSymbolViewElement element) { + DocumentSymbolData data = element.getValue(); + if (data != null) { + SymbolKind kind = data.getDocumentSymbol().getKind(); + return !filteredKinds.contains(kind); + } + } + return true; + } + + @Override + public @NotNull ActionPresentation getPresentation() { + if (cachedPresentation == null) { + synchronized (this) { + if (cachedPresentation == null) { + // Use IconMapper to get the dynamic icon for the first filtered kind + Icon icon = filteredKinds.isEmpty() ? null : IconMapper.getIcon(filteredKinds.iterator().next()); + cachedPresentation = new ActionPresentationData(text, null, icon); + } + } + } + return cachedPresentation; + } + + @Override + public @NotNull String getName() { + return id; + } + + @Override + public boolean isReverted() { + return true; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationHelper.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationHelper.java index c6b0682d3..5cf64822d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationHelper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/LSPDocumentationHelper.java @@ -10,7 +10,6 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.documentation; -import com.intellij.openapi.project.Project; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LanguageServerItem; import com.redhat.devtools.lsp4ij.client.features.LSPHoverFeature; @@ -129,7 +128,6 @@ private static boolean isValidContent(@Nullable String value) { public static String convertToHtml(@NotNull List contents, @Nullable LanguageServerItem languageServer, @NotNull PsiFile file) { - Project project = file.getProject(); StringBuilder htmlBody = new StringBuilder(); for (int i = 0; i < contents.size(); i++) { if (i > 0) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java index ad2afba1b..15516bb65 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/LSPLinkResolver.java @@ -28,6 +28,8 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Custom link resolver used to resolve relative path by using the {@link com.intellij.psi.PsiFile} @@ -36,11 +38,16 @@ public class LSPLinkResolver implements LinkResolver { private final Path fileBaseDir; + //supports both file:/ and file:/// + private static final String fileRegex = "^file:/{1,3}"; + private static final Pattern pattern = Pattern.compile(fileRegex); + public static final String FILE_PROTOCOL_RFC = "file:///"; private enum FileUrlKind { RELATIVE, ABSOLUTE, - NONE; + NONE, + FILE; } public LSPLinkResolver(LinkResolverBasicContext context) { @@ -69,6 +76,17 @@ public LSPLinkResolver(LinkResolverBasicContext context) { } } + else if(FileUrlKind.FILE.equals(fileUrlKind)){ + if(url.contains(FILE_PROTOCOL_RFC)){ + return link.withStatus(LinkStatus.VALID) + .withUrl(url); + } + // convert to RFC 8089 standard + // sometimes URL will contain only file:/ + // this needs to be converted so that markdown will be resolved properly + return link.withStatus(LinkStatus.VALID) + .withUrl(url.replace("file:/", FILE_PROTOCOL_RFC)); + } } return link; } @@ -89,12 +107,18 @@ private static FileUrlKind getFileUrlKind(String url) { // ex : /path/to/file.txt return FileUrlKind.ABSOLUTE; } + Matcher matcher = pattern.matcher(url); + if (matcher.find()) { + // ex : file:///C:/path/to/file.txt -> RFC 8089 standard + // ex : file:/path/to/file.txt -> standard format for local file system without authority specified + return FileUrlKind.FILE; + } int index = url.indexOf("://"); if (index == -1) { // ex : path/to/file.txt return FileUrlKind.RELATIVE; } - // ex : file:///C:/path/to/file.txt + // ex : https://github.com/redhat-developer/lsp4ij return FileUrlKind.NONE; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java index 6aa9733c5..7dec4cfc4 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/documentation/markdown/TextMateHighlighterHelper.java @@ -18,7 +18,6 @@ import com.intellij.openapi.fileTypes.SyntaxHighlighter; import com.intellij.openapi.util.registry.Registry; import com.redhat.devtools.lsp4ij.LanguageServersRegistry; -import com.redhat.devtools.lsp4ij.features.documentation.markdown.SyntaxColorationCodeBlockRenderer; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.vladsch.flexmark.html.HtmlWriter; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/files/AbstractLSPFileListener.java b/src/main/java/com/redhat/devtools/lsp4ij/features/files/AbstractLSPFileListener.java new file mode 100644 index 000000000..1818b5e80 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/files/AbstractLSPFileListener.java @@ -0,0 +1,477 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.files; + +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.vfs.AsyncFileListener; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.VirtualFilePropertyEvent; +import com.intellij.openapi.vfs.newvfs.events.*; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.LanguageServerWrapper; +import com.redhat.devtools.lsp4ij.features.files.watcher.FileSystemWatcherManager; +import com.redhat.devtools.lsp4ij.internal.CancellationSupport; +import com.redhat.devtools.lsp4ij.internal.CompletableFutures; +import org.eclipse.lsp4j.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +/** + * Abstract listener that bridges IntelliJ Virtual File System (VFS) events + * with Language Server Protocol (LSP) workspace notifications. + *

    + * This listener handles creation, deletion, renaming, moving, and content change + * events on files and directories, translating them into corresponding LSP events: + *

      + *
    • {@code workspace/willCreateFiles}
    • + *
    • {@code workspace/willDeleteFiles}
    • + *
    • {@code workspace/willRenameFiles}
    • + *
    • {@code workspace/didCreateFiles}
    • + *
    • {@code workspace/didDeleteFiles}
    • + *
    • {@code workspace/didRenameFiles}
    • + *
    • {@code workspace/didChangeWatchedFiles}
    • + *
    + *

    + * Subclasses must implement the abstract hook methods to specify how IntelliJ + * VFS events are converted into LSP {@link FileEvent}, {@link FileCreate}, + * {@link FileDelete}, and {@link FileRename} structures. + */ +public abstract class AbstractLSPFileListener implements FileEditorManagerListener, AsyncFileListener { + + /** + * The associated language server wrapper. + */ + protected final LanguageServerWrapper languageServerWrapper; + + /** + * Manages file system watchers declared by the LSP client. + */ + protected final FileSystemWatcherManager fileSystemWatcherManager; + + /** + * Creates a new LSP-aware file listener. + * + * @param languageServerWrapper the language server wrapper instance + */ + public AbstractLSPFileListener(@NotNull LanguageServerWrapper languageServerWrapper) { + this.languageServerWrapper = languageServerWrapper; + this.fileSystemWatcherManager = new FileSystemWatcherManager(languageServerWrapper.getProject()); + } + + @Override + public @Nullable ChangeApplier prepareChange(@NotNull List events) { + var supportedEvents = getSupportedEvents(events); + if (supportedEvents.isEmpty()) { + return null; + } + return new ChangeApplier() { + @Override + public void beforeVfsChange() { + before(supportedEvents); + } + + @Override + public void afterVfsChange() { + after(supportedEvents); + } + }; + } + + /** + * Filters the list of VFS events to only those supported by the current LSP server. + * + * @param events the original list of VFS events + * @return a filtered list containing only events supported by the LSP server + */ + private @NotNull List getSupportedEvents(@NotNull List events) { + if (!languageServerWrapper.isActive()) { + return Collections.emptyList(); + } + boolean createFilesSupported = canCreateFilesSupported(); + boolean deleteFilesSupported = canDeleteFilesSupported(); + boolean renameFilesSupported = canRenameFilesSupported(); + boolean changeFilesSupported = canChangeFilesSupported(); + boolean saveSupported = languageServerWrapper.isSaveSupported(); + if (!createFilesSupported && !deleteFilesSupported && !renameFilesSupported && !changeFilesSupported && !saveSupported) { + return Collections.emptyList(); + } + List<@NotNull VFileEvent> supportedEvents = null; + for (VFileEvent event : events) { + if (event instanceof VFileCreateEvent || event instanceof VFileCopyEvent) { + if (createFilesSupported) { + if (supportedEvents == null) { + supportedEvents = new ArrayList<>(); + } + supportedEvents.add(event); + } + } else if (event instanceof VFileDeleteEvent) { + if (deleteFilesSupported) { + if (supportedEvents == null) { + supportedEvents = new ArrayList<>(); + } + supportedEvents.add(event); + } + } else if (event instanceof VFilePropertyChangeEvent || event instanceof VFileMoveEvent) { + if (renameFilesSupported) { + if (supportedEvents == null) { + supportedEvents = new ArrayList<>(); + } + supportedEvents.add(event); + } + } else if (event instanceof VFileContentChangeEvent) { + if (changeFilesSupported || saveSupported && isOpened(event.getFile())) { + if (supportedEvents == null) { + supportedEvents = new ArrayList<>(); + } + supportedEvents.add(event); + } + } + } + return supportedEvents != null ? supportedEvents : Collections.emptyList(); + } + + private boolean isOpened(@Nullable VirtualFile file) { + if (file == null) { + return false; + } + URI fileUri = languageServerWrapper.toUri(file); + return languageServerWrapper.getOpenedDocument(fileUri) != null; + } + + private boolean canCreateFilesSupported() { + return languageServerWrapper.canCreateFilesSupported() || fileSystemWatcherManager.hasFilePatternsFor(WatchKind.Create); + } + + private boolean canDeleteFilesSupported() { + return languageServerWrapper.canDeleteFilesSupported() || fileSystemWatcherManager.hasFilePatternsFor(WatchKind.Delete); + } + + private boolean canRenameFilesSupported() { + return languageServerWrapper.canRenameFilesSupported(); + } + + private boolean canChangeFilesSupported() { + return fileSystemWatcherManager.hasFilePatternsFor(WatchKind.Change); + } + + /** + * Called before VFS events are applied. + *

    + * Sends "will*" LSP requests to the server for file creation, deletion, and renaming/movement. + * + * @param events the list of VFS events before execution + */ + private void before(@NotNull List events) { + List fileCreates = new ArrayList<>(); // workspace/didCreateFiles events + List fileDeletes = new ArrayList<>(); // workspace/didDeleteFiles events + List fileRenames = new ArrayList<>(); // workspace/didRenameFiles events + for (VFileEvent event : events) { + if (event instanceof VFileCreateEvent ce) { + // Create file + VirtualFile createdFile = ce.getFile(); + if (createdFile != null) { + onFileCreateBefore(createdFile, fileCreates); + } + } else if (event instanceof VFileCopyEvent ce) { + // Copy file --> same as Create file + VirtualFile copiedFile = ce.getNewParent().findChild(ce.getNewChildName()); + if (copiedFile != null) { + onFileCreateBefore(copiedFile, fileCreates); + } + } else if (event instanceof VFileDeleteEvent de) { + // Delete file + VirtualFile deletedFile = de.getFile(); + if (deletedFile != null) { + onFileDeleteBefore(deletedFile, fileDeletes); + } + } else if (event instanceof VFilePropertyChangeEvent pce) { + // Rename file + var pe = new VirtualFilePropertyEvent(event.getRequestor(), pce.getFile(), pce.getPropertyName(), pce.getOldValue(), pce.getNewValue()); + if (isRenameFile(pe)) { + VirtualFile parentFile = pe.getParent(); + String newFileName = (String) pe.getNewValue(); + VirtualFile oldFile = pe.getFile(); + onFileRenameBefore(parentFile, oldFile, newFileName, fileRenames); + } + } else if (event instanceof VFileMoveEvent me) { + // Move file --> same as Rename file + VirtualFile parentFile = me.getOldParent(); + VirtualFile oldFile = event.getFile(); + String newFileName = me.getOldPath(); + onFileRenameBefore(parentFile, oldFile, newFileName, fileRenames); + } + } + var ls = languageServerWrapper.getLanguageServer(); + if (ls != null) { + if (!fileCreates.isEmpty()) { + var params = new CreateFilesParams(fileCreates); + applyWorkspaceEdit(ls.getWorkspaceService().willCreateFiles(params)); + } + if (!fileDeletes.isEmpty()) { + var params = new DeleteFilesParams(fileDeletes); + applyWorkspaceEdit(ls.getWorkspaceService().willDeleteFiles(params)); + } + if (!fileRenames.isEmpty()) { + var params = new RenameFilesParams(fileRenames); + applyWorkspaceEdit(ls.getWorkspaceService().willRenameFiles(params)); + } + } + } + + /** + * Applies a {@link WorkspaceEdit} returned by a "will*" LSP request. + * Waits for up to 500ms to avoid blocking the UI thread. + * + * @param future the future returning the workspace edit + */ + private static void applyWorkspaceEdit(CompletableFuture future) { + try { + CancellationSupport cancellationSupport = new CancellationSupport(); + future = cancellationSupport.execute(future); + CompletableFutures.waitUntilDone(future, null, 500); + } catch (ExecutionException e) { + + } catch (TimeoutException e) { + + } + if (CompletableFutures.isDoneNormally(future)) { + var workspaceEdit = future.getNow(null); + if (workspaceEdit != null) { + LSPIJUtils.applyWorkspaceEdit(workspaceEdit); + } + } + } + + /** + * Called after VFS events are applied. + *

    + * Sends "did*" LSP notifications for file creation, deletion, renaming/movement, and content changes. + * + * @param events the list of VFS events after execution + */ + private void after(@NotNull List events) { + List fileEvents = new ArrayList<>(); // workspace/didChangeWatchedFiles events + List fileCreates = new ArrayList<>(); // workspace/didCreateFiles events + List fileDeletes = new ArrayList<>(); // workspace/didDeleteFiles events + List fileRenames = new ArrayList<>(); // workspace/didRenameFiles events + + for (VFileEvent event : events) { + if (event instanceof VFileCreateEvent ce) { + // Create file + VirtualFile createdFile = ce.getFile(); + if (createdFile != null) { + onFileCreateAfter(createdFile, fileEvents, fileCreates); + } + } else if (event instanceof VFileCopyEvent ce) { + // Copy file --> same as Create file + VirtualFile copiedFile = ce.getNewParent().findChild(ce.getNewChildName()); + if (copiedFile != null) { + onFileCreateAfter(copiedFile, fileEvents, fileCreates); + } + } else if (event instanceof VFileDeleteEvent de) { + // Delete file + VirtualFile deletedFile = de.getFile(); + if (deletedFile != null) { + onFileDeleteAfter(deletedFile, fileDeletes, fileEvents); + } + } else if (event instanceof VFileContentChangeEvent ce) { + // File content changed + VirtualFile changedFile = ce.getFile(); + onFileContentChangedAfter(changedFile, fileEvents); + } else if (event instanceof VFilePropertyChangeEvent pce) { + // Rename file + var pe = new VirtualFilePropertyEvent(event.getRequestor(), pce.getFile(), pce.getPropertyName(), pce.getOldValue(), pce.getNewValue()); + if (isRenameFile(pe)) { + VirtualFile parentFile = pe.getParent(); + String oldFileName = (String) pe.getOldValue(); + VirtualFile newFile = pe.getFile(); + onFileRenameAfter(parentFile, oldFileName, newFile, fileEvents, fileRenames); + } + } else if (event instanceof VFileMoveEvent me) { + // Move file --> same as Rename file + VirtualFile parentFile = me.getOldParent(); + VirtualFile movedFile = event.getFile(); + String oldFileName = movedFile.getName(); + onFileRenameAfter(parentFile, oldFileName, movedFile, fileEvents, fileRenames); + } + } + + if (!fileCreates.isEmpty()) { + didCreateFiles(fileCreates); + } + if (!fileDeletes.isEmpty()) { + didDeleteFiles(fileDeletes); + } + if (!fileRenames.isEmpty()) { + didRenameFiles(fileRenames); + } + if (!fileEvents.isEmpty()) { + didChangeWatchedFiles(fileEvents); + } + } + + // -------------------- Abstract hooks -------------------- + + /** + * Called before a file is created. + */ + protected abstract void onFileCreateBefore(@NotNull VirtualFile createdFile, + @NotNull List fileCreates); + + /** + * Called before a file is deleted. + */ + protected abstract void onFileDeleteBefore(@NotNull VirtualFile deletedFile, + @NotNull List fileDeletes); + + /** + * Called before a file is renamed or moved. + */ + protected abstract void onFileRenameBefore(@NotNull VirtualFile parentFile, + @NotNull VirtualFile oldFile, + @NotNull String newFileName, + @NotNull List fileRenames); + + /** + * Called after a file has been created. + */ + protected abstract void onFileCreateAfter(@NotNull VirtualFile createdFile, + @NotNull List fileEvents, + @NotNull List fileCreates); + + /** + * Called after a file has been deleted. + */ + protected abstract void onFileDeleteAfter(@NotNull VirtualFile deletedFile, + @NotNull List fileDeletes, + @NotNull List fileEvents); + + /** + * Called after the content of a file has changed. + */ + protected abstract void onFileContentChangedAfter(@NotNull VirtualFile changedFile, + @NotNull List fileEvents); + + /** + * Called after a file has been renamed or moved. + */ + protected abstract void onFileRenameAfter(@NotNull VirtualFile virtualParentFile, + @NotNull String oldFileName, + @NotNull VirtualFile newFile, + @NotNull List fileEvents, + @NotNull List fileRenames); + + // -------------------- Utilities -------------------- + + /** + * Determines if the given property change event represents a file rename. + */ + private static boolean isRenameFile(@NotNull VirtualFilePropertyEvent event) { + if (event.getPropertyName().equals(VirtualFile.PROP_NAME) && + event.getOldValue() instanceof String oldValue && + event.getNewValue() instanceof String newValue) { + return !Objects.equals(oldValue, newValue); + } + return false; + } + + /** + * Sends a {@code workspace/didCreateFiles} notification. + */ + private void didCreateFiles(@NotNull List fileCreates) { + languageServerWrapper.sendNotification(ls -> { + var params = new CreateFilesParams(fileCreates); + ls.getWorkspaceService().didCreateFiles(params); + return ls; + }); + } + + /** + * Sends a {@code workspace/didDeleteFiles} notification. + */ + private void didDeleteFiles(@NotNull List fileDeletes) { + languageServerWrapper.sendNotification(ls -> { + var params = new DeleteFilesParams(fileDeletes); + ls.getWorkspaceService().didDeleteFiles(params); + return ls; + }); + } + + /** + * Sends a {@code workspace/didRenameFiles} notification. + */ + private void didRenameFiles(@NotNull List fileRenames) { + languageServerWrapper.sendNotification(ls -> { + var params = new RenameFilesParams(fileRenames); + ls.getWorkspaceService().didRenameFiles(params); + return ls; + }); + } + + /** + * Sends a {@code workspace/didChangeWatchedFiles} notification. + */ + private void didChangeWatchedFiles(@NotNull List fileEvents) { + languageServerWrapper.sendNotification(ls -> { + DidChangeWatchedFilesParams params = new DidChangeWatchedFilesParams(fileEvents); + ls.getWorkspaceService().didChangeWatchedFiles(params); + return ls; + }); + } + + /** + * Returns whether any file watchers are currently registered. + */ + private boolean hasFilePatterns() { + return fileSystemWatcherManager.hasFilePatterns(); + } + + /** + * Checks if the given URI matches any registered file watcher patterns. + */ + protected boolean isMatchFilePatterns(@Nullable URI uri, int kind) { + if (uri == null || !hasFilePatterns()) { + return false; + } + return fileSystemWatcherManager.isMatchFilePattern(uri, kind); + } + + /** + * Registers new file system watchers with a given ID. + */ + public void registerFileSystemWatchers(String id, List watchers) { + fileSystemWatcherManager.registerFileSystemWatchers(id, watchers); + } + + /** + * Unregisters file system watchers associated with the given ID. + */ + public void unregisterFileSystemWatchers(String id) { + fileSystemWatcherManager.unregisterFileSystemWatchers(id); + } + + /** + * Creates a new {@link FileEvent} for the given URI and change type. + */ + protected FileEvent fe(URI uri, FileChangeType type) { + return new FileEvent(languageServerWrapper.toUriString(uri), type); + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcher.java b/src/main/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcher.java index 7df0d1d6f..80ccfd9a2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcher.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcher.java @@ -11,6 +11,9 @@ *******************************************************************************/ package com.redhat.devtools.lsp4ij.features.files; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + import java.net.URI; import java.nio.file.FileSystems; import java.nio.file.Path; @@ -26,69 +29,120 @@ */ public class PathPatternMatcher { - record Parts(List parts, List cols) {} - + private final @NotNull String pattern; + private final @Nullable Path basePath; private List pathMatchers; - private final String pattern; - - public PathPatternMatcher(String pattern) { + public PathPatternMatcher(@NotNull String pattern, + @Nullable Path basePath) { this.pattern = pattern; + this.basePath = basePath; } - public String getPattern() { - return pattern; - } + /** + * Creates a PathPatternMatcher by automatically extracting the base path from the pattern. + *

    + * The base path is the portion of the pattern before any glob characters (*, ?, [, {). + * The pattern is then adjusted to be relative to this base path. + * + * @param fullPattern the glob pattern with optional absolute base path + * @return a new PathPatternMatcher with extracted base path and relative pattern + */ + public static @NotNull PathPatternMatcher fromPattern(@NotNull final String fullPattern, + @Nullable Path defaultBasePath) { + // Find the first occurrence of glob characters: * ? [ { + int firstGlobChar = findFirstGlobCharacter(fullPattern); - public boolean matches(String uri) { - try { - return matches(new URI(uri)); - } catch (Exception e) { - return false; + if (firstGlobChar == -1) { + // For literal patterns (no glob characters) that are relative (e.g., "node_modules"), + // we assume they are relative to the workspace root. By setting defaultBasePath, + // the FileSystemWatcherManager can relativize absolute file paths against it + // and match the relative pattern correctly. + Path patternPath = Paths.get(fullPattern.replace('\\', '/')); + if (!patternPath.isAbsolute()) { + return new PathPatternMatcher(fullPattern, defaultBasePath); + } + // No glob characters, treat entire string as pattern + return new PathPatternMatcher(fullPattern, null); } - } - public boolean matches(URI uri) { - if (pattern.isEmpty()) { - return false; + if (firstGlobChar == 0) { + // Pattern starts with a glob character (e.g., "**/*.rs") + return new PathPatternMatcher(fullPattern, defaultBasePath); } - if (pathMatchers == null) { - createPathMatchers(); + + // Find the last path separator before the first glob character + String beforeGlob = fullPattern.substring(0, firstGlobChar); + + // Support both forward slash and backslash + int lastSeparator = Math.max( + beforeGlob.lastIndexOf('/'), + beforeGlob.lastIndexOf('\\') + ); + + if (lastSeparator == -1) { + // No separator found before glob, no valid base path + return new PathPatternMatcher(fullPattern, defaultBasePath); } + + // Extract base path (everything before the last separator before glob) + String basePathStr = fullPattern.substring(0, lastSeparator); + + // Extract relative pattern (everything after the base path) + // Skip the separator itself (lastSeparator + 1) + String relativePattern = fullPattern.substring(lastSeparator + 1); + try { - Path path = Paths.get(uri); - for (PathMatcher pathMatcher : pathMatchers) { - try { - if (pathMatcher.matches(path)) { - return true; - } - } catch (Exception e) { - // Do nothing - } + // Normalize separators for Path + Path extractedPath = Paths.get(basePathStr.replace('\\', '/')); + // If the extracted base path is relative (e.g., "some" from "some/**/*.js"), + // we resolve it against defaultBasePath so it can be compared against absolute paths. + // Otherwise, absolute paths being tested won't match the relative base path prefix. + Path basePath; + if (extractedPath.isAbsolute()) { + basePath = extractedPath; + } else if (defaultBasePath != null) { + basePath = defaultBasePath.resolve(extractedPath); + } else { + basePath = extractedPath; } + return new PathPatternMatcher(relativePattern, basePath); } catch (Exception e) { - // Do nothing + // If path creation fails, return with original pattern and no base + return new PathPatternMatcher(fullPattern, defaultBasePath); } - return false; } - private synchronized void createPathMatchers() { - if (pathMatchers != null) { - return; + /** + * Finds the index of the first glob metacharacter in the string. + * Glob characters: * ? [ { + * + * @param pattern the pattern to search + * @return the index of the first glob character, or -1 if none found + */ + private static int findFirstGlobCharacter(@NotNull String pattern) { + int minIndex = Integer.MAX_VALUE; + + int asterisk = pattern.indexOf('*'); + if (asterisk != -1 && asterisk < minIndex) { + minIndex = asterisk; } - String glob = pattern.replace("\\", "/"); - // As Java NIO glob doesn't support **/, /** as optional - // we need to expand the pattern, ex: **/foo -> foo, **/foo. - List expandedPatterns = expandPatterns(glob); - List pathMatchers = new ArrayList<>(); - for (var expandedPattern : expandedPatterns) { - try { - PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:" + expandedPattern); - pathMatchers.add(pathMatcher); - } catch (Exception e) { - // Do nothing - } + + int question = pattern.indexOf('?'); + if (question != -1 && question < minIndex) { + minIndex = question; } - this.pathMatchers = pathMatchers; + + int bracket = pattern.indexOf('['); + if (bracket != -1 && bracket < minIndex) { + minIndex = bracket; + } + + int brace = pattern.indexOf('{'); + if (brace != -1 && brace < minIndex) { + minIndex = brace; + } + + return minIndex == Integer.MAX_VALUE ? -1 : minIndex; } /** @@ -125,7 +179,6 @@ static List expandPatterns(String pattern) { return Collections.singletonList(pattern); } - private static Parts getParts(String pattern) { int from = 0; int index = getNextIndex(pattern, from); @@ -181,6 +234,67 @@ private static void generateCombinationsHelper(int N, int[] combination, int ind } } + public @NotNull String getPattern() { + return pattern; + } + + public @Nullable Path getBasePath() { + return basePath; + } + + public boolean matches(@NotNull URI uri) { + return internalMatches(uri, null); + } + + public boolean matches(@NotNull Path path) { + return internalMatches(null, path); + } + + private boolean internalMatches(@Nullable URI uri, + @Nullable Path path) { + if (pattern.isEmpty()) { + return false; + } + if (pathMatchers == null) { + createPathMatchers(); + } + try { + path = path == null ? Paths.get(uri) : path; + for (PathMatcher pathMatcher : pathMatchers) { + try { + if (pathMatcher.matches(path)) { + return true; + } + } catch (Exception e) { + // Do nothing + } + } + } catch (Exception e) { + // Do nothing + } + return false; + } + + private synchronized void createPathMatchers() { + if (pathMatchers != null) { + return; + } + String glob = pattern.replace("\\", "/"); + // As Java NIO glob doesn't support **/, /** as optional + // we need to expand the pattern, ex: **/foo -> foo, **/foo. + List expandedPatterns = expandPatterns(glob); + List pathMatchers = new ArrayList<>(); + for (var expandedPattern : expandedPatterns) { + try { + PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:" + expandedPattern); + pathMatchers.add(pathMatcher); + } catch (Exception e) { + // Do nothing + } + } + this.pathMatchers = pathMatchers; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -195,4 +309,7 @@ public boolean equals(Object obj) { } return false; } + + record Parts(@NotNull List parts, @NotNull List cols) { + } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationFilterMatcher.java b/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationFilterMatcher.java index 763d92bee..cded09e7b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationFilterMatcher.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationFilterMatcher.java @@ -58,7 +58,7 @@ private PatternKind getPatternKind(String matches) { @Nullable private PathPatternMatcher toPathPattern(@NotNull FileOperationPattern pattern) { String glob = pattern.getGlob() != null && !pattern.getGlob().isEmpty() ? pattern.getGlob() : null; - return glob != null ? new PathPatternMatcher(glob) : null; + return glob != null ? new PathPatternMatcher(glob, null) : null; } public boolean isMatch(URI fileUri, boolean isFolder) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationsManager.java b/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationsManager.java index c1d9d98a9..a87e29bbc 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationsManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/files/operations/FileOperationsManager.java @@ -10,7 +10,6 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.files.operations; -import com.intellij.openapi.project.Project; import com.redhat.devtools.lsp4ij.LanguageServerWrapper; import org.eclipse.lsp4j.FileOperationOptions; import org.eclipse.lsp4j.FileOperationsServerCapabilities; @@ -23,7 +22,25 @@ import java.util.Map; /** - * File operation manager. + * Manages support for file operations (create, delete, rename) declared + * in a Language Server's {@link FileOperationsServerCapabilities}. + *

    + * The {@code FileOperationsManager} determines, for a given URI and context, + * whether the corresponding LSP notifications can be sent to the server. + * It supports the following operations: + *

      + *
    • {@code workspace/willCreateFiles} / {@code workspace/didCreateFiles}
    • + *
    • {@code workspace/willDeleteFiles} / {@code workspace/didDeleteFiles}
    • + *
    • {@code workspace/willRenameFiles} / {@code workspace/didRenameFiles}
    • + *
    + *

    + * Internally, this class maintains a cache of {@link FileOperationMatcher} + * instances, one per {@link FileOperationOptions}, to efficiently test + * whether a given URI matches the file filters declared by the server. + * + * @see org.eclipse.lsp4j.FileOperationsServerCapabilities + * @see org.eclipse.lsp4j.FileOperationOptions + * @see FileOperationMatcher */ public class FileOperationsManager { @@ -34,109 +51,223 @@ public class FileOperationsManager { @NotNull private final Map matchers; + /** + * Creates a new {@code FileOperationsManager} bound to the given language server. + * + * @param languageServerWrapper the {@link LanguageServerWrapper} instance associated with this manager. + */ public FileOperationsManager(LanguageServerWrapper languageServerWrapper) { this.languageServerWrapper = languageServerWrapper; this.matchers = new HashMap<>(); } + /** + * Updates the current {@link ServerCapabilities} and clears any cached file matchers. + * + * @param serverCapabilities the capabilities to use, or {@code null} if not available. + */ public void setServerCapabilities(@Nullable ServerCapabilities serverCapabilities) { this.serverCapabilities = serverCapabilities; this.matchers.clear(); } - // ----------------- Create files operations + // ------------------------------------------------------------------------- + // Create files operations + // ------------------------------------------------------------------------- /** - * Returns true if the given file uri can send a 'workspace/willCreateFiles' notification and false otherwise. + * Determines whether a {@code workspace/willCreateFiles} notification + * can be sent for the given file or folder URI. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/willCreateFiles' notification and false otherwise. + * @param fileUri the URI of the file or folder being created. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. */ - public boolean canWillCreateFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canWillCreateFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getWillCreate()); } /** - * Returns true if the given file uri can send a 'workspace/didCreateFiles' notification and false otherwise. + * Returns {@code true} if the language server declares support for + * {@code workspace/willCreateFiles}, regardless of the target URI. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/didCreateFiles' notification and false otherwise. + * @return {@code true} if at least one filter is defined, {@code false} otherwise. */ - public boolean canDidCreateFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canWillCreateFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getWillCreate()); + } + + /** + * Determines whether a {@code workspace/didCreateFiles} notification + * can be sent for the given file or folder URI. + * + * @param fileUri the URI of the file or folder being created. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. + */ + public boolean canDidCreateFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getDidCreate()); } - // ----------------- Delete files operations + /** + * Returns {@code true} if the language server declares support for + * {@code workspace/didCreateFiles}. + * + * @return {@code true} if at least one filter is defined, {@code false} otherwise. + */ + public boolean canDidCreateFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getDidCreate()); + } + + // ------------------------------------------------------------------------- + // Delete files operations + // ------------------------------------------------------------------------- /** - * Returns true if the given file uri can send a 'workspace/willDeleteFiles' notification and false otherwise. + * Determines whether a {@code workspace/willDeleteFiles} notification + * can be sent for the given file or folder URI. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/willDeleteFiles' notification and false otherwise. + * @param fileUri the URI of the file or folder being deleted. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. */ - public boolean canWillDeleteFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canWillDeleteFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getWillDelete()); } /** - * Returns true if the given file uri can send a 'workspace/didDeleteFiles' notification and false otherwise. + * Returns {@code true} if the language server declares support for + * {@code workspace/willDeleteFiles}. + * + * @return {@code true} if at least one filter is defined, {@code false} otherwise. + */ + public boolean canWillDeleteFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getWillDelete()); + } + + /** + * Determines whether a {@code workspace/didDeleteFiles} notification + * can be sent for the given file or folder URI. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/didDeleteFiles' notification and false otherwise. + * @param fileUri the URI of the file or folder being deleted. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. */ - public boolean canDidDeleteFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canDidDeleteFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getDidDelete()); } - // ----------------- Rename files operations + /** + * Returns {@code true} if the language server declares support for + * {@code workspace/didDeleteFiles}. + * + * @return {@code true} if at least one filter is defined, {@code false} otherwise. + */ + public boolean canDidDeleteFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getDidDelete()); + } + + // ------------------------------------------------------------------------- + // Rename files operations + // ------------------------------------------------------------------------- /** - * Returns true if the given file uri can send a 'workspace/willRenameFiles' notification and false otherwise. + * Determines whether a {@code workspace/willRenameFiles} notification + * can be sent for the given file or folder URI. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/willRenameFiles' notification and false otherwise. + * @param fileUri the URI of the file or folder being renamed. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. */ - public boolean canWillRenameFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canWillRenameFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getWillRename()); } /** - * Returns true if the given file uri can send a 'workspace/didRenameFiles' notification and false otherwise. + * Returns {@code true} if the language server declares support for + * {@code workspace/willRenameFiles}. * - * @param fileUri the renamed file uri. - * @param isFolder true if it is a folder and false otherwise. - * @return true if the given file uri can send a 'workspace/didRenameFiles' notification and false otherwise. + * @return {@code true} if at least one filter is defined, {@code false} otherwise. */ - public boolean canDidRenameFiles(@NotNull URI fileUri, - boolean isFolder) { + public boolean canWillRenameFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getWillRename()); + } + + /** + * Determines whether a {@code workspace/didRenameFiles} notification + * can be sent for the given file or folder URI. + * + * @param fileUri the URI of the file or folder being renamed. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @return {@code true} if the operation is supported for this URI, {@code false} otherwise. + */ + public boolean canDidRenameFiles(@NotNull URI fileUri, boolean isFolder) { var capabilities = getFileOperationsServerCapabilities(); return capabilities != null && canSupportFileOperation(fileUri, isFolder, capabilities.getDidRename()); } + /** + * Returns {@code true} if the language server declares support for + * {@code workspace/didRenameFiles}. + * + * @return {@code true} if at least one filter is defined, {@code false} otherwise. + */ + public boolean canDidRenameFiles() { + var capabilities = getFileOperationsServerCapabilities(); + return capabilities != null && hasFilters(capabilities.getDidRename()); + } + + // ------------------------------------------------------------------------- + // Internal utilities + // ------------------------------------------------------------------------- + + /** + * Checks whether a specific file operation is supported for the given URI and options. + * + * @param fileUri the URI of the file or folder. + * @param isFolder {@code true} if the target is a folder, {@code false} otherwise. + * @param fileOperationOptions the file operation configuration from the server. + * @return {@code true} if the file matches any filter defined in the operation, {@code false} otherwise. + */ private boolean canSupportFileOperation(@NotNull URI fileUri, boolean isFolder, @Nullable FileOperationOptions fileOperationOptions) { - if (fileOperationOptions == null || fileOperationOptions.getFilters() == null || fileOperationOptions.getFilters().isEmpty()) { + if (!hasFilters(fileOperationOptions)) { return false; } return getMatcher(fileOperationOptions).isMatch(fileUri, isFolder); } + /** + * Returns {@code true} if the given {@link FileOperationOptions} defines + * at least one file filter. + * + * @param fileOperationOptions the file operation options. + * @return {@code true} if one or more filters exist, {@code false} otherwise. + */ + private static boolean hasFilters(@Nullable FileOperationOptions fileOperationOptions) { + return fileOperationOptions != null + && fileOperationOptions.getFilters() != null + && !fileOperationOptions.getFilters().isEmpty(); + } + + /** + * Retrieves or creates a cached {@link FileOperationMatcher} for the given + * {@link FileOperationOptions}. + * + * @param fileOperationOptions the file operation configuration. + * @return a matcher used to test URIs against the operation filters. + */ @NotNull private FileOperationMatcher getMatcher(FileOperationOptions fileOperationOptions) { var matcher = matchers.get(fileOperationOptions); @@ -144,11 +275,17 @@ private FileOperationMatcher getMatcher(FileOperationOptions fileOperationOption return matcher; } synchronized (matchers) { - matchers.putIfAbsent(fileOperationOptions, new FileOperationMatcher((fileOperationOptions.getFilters()))); + matchers.putIfAbsent(fileOperationOptions, new FileOperationMatcher(fileOperationOptions.getFilters())); } return matchers.get(fileOperationOptions); } + /** + * Returns the {@link FileOperationsServerCapabilities} declared by the server, if any. + * + * @return the file operations capabilities, or {@code null} if unavailable. + */ + @Nullable private FileOperationsServerCapabilities getFileOperationsServerCapabilities() { if (serverCapabilities != null && serverCapabilities.getWorkspace() != null) { return serverCapabilities.getWorkspace().getFileOperations(); @@ -156,12 +293,12 @@ private FileOperationsServerCapabilities getFileOperationsServerCapabilities() { return null; } + /** + * Returns the {@link LanguageServerWrapper} associated with this manager. + * + * @return the language server wrapper. + */ public LanguageServerWrapper getLanguageServerWrapper() { return languageServerWrapper; } - - private Project getProject() { - return languageServerWrapper.getProject(); - } - } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/files/watcher/FileSystemWatcherManager.java b/src/main/java/com/redhat/devtools/lsp4ij/features/files/watcher/FileSystemWatcherManager.java index 4a5212830..160b3f634 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/files/watcher/FileSystemWatcherManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/files/watcher/FileSystemWatcherManager.java @@ -10,15 +10,21 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.features.files.watcher; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectUtil; import com.redhat.devtools.lsp4ij.features.files.PathPatternMatcher; +import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.eclipse.lsp4j.FileSystemWatcher; import org.eclipse.lsp4j.RelativePattern; import org.eclipse.lsp4j.WatchKind; +import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; @@ -29,16 +35,167 @@ public class FileSystemWatcherManager { private static final int WatchKindAny = 7; + /** + * JAR/ZIP entry separator used in paths like "/path/to/lib.jar!/com/example/Class.class" + */ + private static final String JAR_SEPARATOR = "!/"; + + /** + * Archive URI schemes that should be skipped to avoid ZipFileSystemProvider + * triggering toRealPath() which causes EDT freezes on Windows. + */ + private static final Set ARCHIVE_SCHEMES = Set.of( + "jar", "zip", "war", "ear" + ); + + /** + * Archive file extensions that should be skipped to avoid ZipFileSystemProvider + * triggering toRealPath() which causes EDT freezes on Windows. + */ + private static final Set ARCHIVE_EXTENSIONS = Set.of( + // Java archives (most common in IDE context) + ".jar", ".war", ".ear", ".aar", ".sar", ".par", + + // ZIP-based archives + ".zip", ".zipx", + + // Android + ".apk", ".apks", ".xapk", ".aab", + + // Compression formats + ".gz", ".gzip", ".bz2", ".bzip2", ".xz", ".lz", ".lzma", + ".z", ".Z", + + // Tar archives + ".tar", ".tgz", ".tar.gz", ".tbz", ".tbz2", ".tar.bz2", + ".txz", ".tar.xz", ".tlz", ".tar.lz", ".tar.Z", + + // RAR and 7-Zip + ".rar", ".7z", ".7zip", + + // Windows archives + ".cab", ".msi", ".msix", ".appx", + + // macOS/iOS + ".dmg", ".pkg", ".ipa", + + // Linux packages + ".deb", ".rpm", ".snap", ".appimage", + + // ISO images + ".iso", ".img", + + // Other common archives + ".ace", ".arj", ".lha", ".lzh", ".sit", ".sitx", + + // Office/Document archives (ZIP-based internally) + ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp", ".epub" + ); + private final Map> registry; + private final @Nullable Path basePath; private Set fileSystemWatchers; private Map> pathPatternMatchers; - public FileSystemWatcherManager() { + public FileSystemWatcherManager(@NotNull Project project) { + this(getProjectBasePath(project)); + } + + public FileSystemWatcherManager(@Nullable Path basePath) { + this.basePath = basePath; this.registry = new HashMap<>(); } + private static @Nullable Path getProjectBasePath(@NotNull Project project) { + var baseDir = ProjectUtil.guessProjectDir(project); + if (baseDir != null) { + try { + return baseDir.toNioPath(); + } catch (UnsupportedOperationException e) { + // TempFileSystem (used in light tests) doesn't support toNioPath() + } + } + // Fallback to project.getBasePath(), which returns a path string like "temp:///src" + // in light tests using TempFileSystem. Path.of() handles this without throwing. + String basePath = project.getBasePath(); + return basePath != null ? Path.of(basePath) : null; + } + + private static void tryAddingMatcher(@NotNull PathPatternMatcher matcher, + @NotNull Map> matchers, + @Nullable Integer watcherKind, + int kind) { + if (!isWatchKind(watcherKind, kind)) { + return; + } + List matchersForKind = matchers.computeIfAbsent(kind, k -> new ArrayList<>()); + matchersForKind.add(matcher); + } + + /** + * Checks if the combined value contains a specific kind. + */ + private static boolean isWatchKind(Integer watcherKind, int kind) { + return watcherKind == null || (watcherKind & kind) != 0; + } + + @Nullable + private static PathPatternMatcher getPathPatternMatcher(@NotNull FileSystemWatcher fileSystemMatcher, + @Nullable Path basePath) { + Either globPattern = fileSystemMatcher.getGlobPattern(); + if (globPattern != null) { + if (globPattern.isLeft()) { + String pattern = globPattern.getLeft(); + if (StringUtils.isBlank(pattern)) { + // Invalid pattern, ignore the watcher + return null; + } + return PathPatternMatcher.fromPattern(pattern, basePath); + } else { + RelativePattern relativePattern = globPattern.getRight(); + if (relativePattern != null) { + // Implement relative pattern like glob string pattern + // by waiting for finding a concrete use case. + String pattern = relativePattern.getPattern(); + if (StringUtils.isBlank(pattern)) { + // Invalid pattern, ignore the watcher + return null; + } + Path relativeBasePath = getRelativeBasePath(relativePattern.getBaseUri()); + if (relativeBasePath == null) { + // Invalid baseUri, ignore the watcher + return null; + } + return new PathPatternMatcher(pattern, relativeBasePath); + } + } + } + return null; + } + + private static @Nullable Path getRelativeBasePath(@Nullable Either baseUri) { + if (baseUri == null) { + return null; + } + String baseDir = null; + if (baseUri.isRight()) { + baseDir = baseUri.getRight(); + } else if (baseUri.isLeft()) { + var workspaceFolder = baseUri.getLeft(); + baseDir = workspaceFolder != null ? workspaceFolder.getUri() : null; + } + if (StringUtils.isBlank(baseDir)) { + return null; + } + try { + return Paths.get(URI.create(baseDir)); + } catch(Exception e) { + return null; + } + } + /** * Register the file system watcher list with the given id. * @@ -57,6 +214,7 @@ public void registerFileSystemWatchers(String id, List watche /** * Unregister the file system watcher list with the given id. + * * @param id the id. */ public void unregisterFileSystemWatchers(String id) { @@ -93,6 +251,52 @@ public boolean hasFilePatterns() { return fileSystemWatchers != null && !fileSystemWatchers.isEmpty(); } + public boolean hasFilePatternsFor(int kind) { + if (!hasFilePatterns()) { + return false; + } + + // Ensure pattern matchers are initialized before use + computePatternMatchersIfNeed(); + + var matchers = pathPatternMatchers.get(kind); + return matchers != null && !matchers.isEmpty(); + } + + /** + * Checks if the URI points to an archive file. + * Archive files should be skipped to avoid triggering ZipFileSystemProvider + * which calls toRealPath() -> FindFirstFile0 on Windows, causing EDT freezes. + */ + private static boolean isArchiveFile(@NotNull URI uri) { + // First check: URI scheme (jar:, zip:, etc.) + String scheme = uri.getScheme(); + if (scheme != null && ARCHIVE_SCHEMES.contains(scheme.toLowerCase())) { + return true; + } + + // Second check: path contains JAR entry separator + String path = uri.getPath(); + if (path == null) { + return false; + } + + // Paths containing JAR_SEPARATOR are JAR/ZIP entry paths (e.g., /path/to/lib.jar!/com/example/Class.class) + if (path.contains(JAR_SEPARATOR)) { + return true; + } + + // Third check: file extension + // Extract the extension (including the dot) + int lastDot = path.lastIndexOf('.'); + if (lastDot == -1 || lastDot == path.length() - 1) { + return false; // No extension or ends with a dot + } + + String extension = path.substring(lastDot).toLowerCase(); + return ARCHIVE_EXTENSIONS.contains(extension); + } + /** * Returns true if the given uri matches a pattern for the given watch kind and false otherwise. * @@ -101,11 +305,35 @@ public boolean hasFilePatterns() { * @return true if the given uri matches a pattern for the given watch kind and false otherwise. */ public boolean isMatchFilePattern(@Nullable URI uri, int kind) { + // If no URI or no patterns are registered, there can be no match if (uri == null || !hasFilePatterns()) { return false; } + + // FIX: Skip archive files to avoid ZipFileSystemProvider triggering toRealPath() + // which causes WindowsNativeDispatcher.FindFirstFile0 freeze on slow disks + if (isArchiveFile(uri)) { + return false; + } + + // Ensure pattern matchers are initialized before use computePatternMatchersIfNeed(); - return (match(uri, kind) || match(uri, WatchKindAny)); + + // Cache: basePath -> relative path if included, false otherwise + Map> basePathToRelativePath = new HashMap<>(); + + try { + // Convert the URI to a Path for matching + Path path = Paths.get(uri); + + // Match against the given kind or the "any" kind + return match(path, kind, basePathToRelativePath) + || match(path, WatchKindAny, basePathToRelativePath); + + } catch (Exception e) { + // Any failure in URI-to-Path conversion or matching is treated as "no match" + return false; + } } private void computePatternMatchersIfNeed() { @@ -120,7 +348,7 @@ private synchronized void computePatternMatchers() { } Map> matchers = new HashMap<>(); for (var fileSystemMatcher : fileSystemWatchers) { - PathPatternMatcher matcher = getPathPatternMatcher(fileSystemMatcher); + PathPatternMatcher matcher = getPathPatternMatcher(fileSystemMatcher, basePath); if (matcher != null) { Integer kind = fileSystemMatcher.getKind(); tryAddingMatcher(matcher, matchers, kind, WatchKind.Create); @@ -131,55 +359,186 @@ private synchronized void computePatternMatchers() { pathPatternMatchers = matchers; } - private static void tryAddingMatcher(@NotNull PathPatternMatcher matcher, - @NotNull Map> matchers, - @Nullable Integer watcherKind, - int kind) { - if (!isWatchKind(watcherKind, kind)) { - return; + /** + * Checks whether the given {@link Path} matches any registered {@link PathPatternMatcher} + * for the specified watch kind. + * + *

    This method iterates through all pattern matchers for the given {@code kind}, + * checks if the provided path is under each matcher's base path, and if so, applies the matcher + * to the relative path.

    + * + *

    To optimize performance, a cache map is used to store intermediate results for base path checks: + *

      + *
    • Key: the base path of a matcher
    • + *
    • Value: Either: + *
        + *
      • Left - the relative path if the path is under the base path
      • + *
      • Right(false) - indicates the path is not under this base path
      • + *
      + *
    • + *
    + *

    + * + * @param path the file path to test, must not be {@code null} + * @param kind the watch kind to check against (e.g., {@link WatchKind#Create}, + * {@link WatchKind#Change}, {@link WatchKind#Delete}, or {@code WatchKindAny} for a wildcard match) + * @param basePathToRelativePath a cache for storing relative paths or negative results + * @return {@code true} if the path matches any pattern for the given kind, {@code false} otherwise + */ + private boolean match(@NotNull Path path, + int kind, + @NotNull Map> basePathToRelativePath) { + + // Retrieve all matchers registered for the given kind + List matchers = pathPatternMatchers.get(kind); + if (matchers == null) { + return false; // No matchers for this kind } - List matchersForKind = matchers.computeIfAbsent(kind, k -> new ArrayList<>()); - matchersForKind.add(matcher); + + // Iterate over each matcher + for (var matcher : matchers) { + // Check if the path is under the matcher's base path + var relativePath = matchBasePath(path, matcher.getBasePath(), basePathToRelativePath); + if (relativePath != null) { + // Apply the matcher to the relative path + if (matcher.matches(relativePath)) { + return true; + } + } else if (matcher.getBasePath() == null) { + // ex: "globPattern": "**/*.rs" (no base path extracted) + // Apply the matcher to the path + if (matcher.matches(path)) { + return true; + } + } + } + + // No matcher matched + return false; } /** - * Checks if the combined value contains a specific kind. + * Checks whether the given {@link Path} is located under a specified base path. + * + *

    If the path is under the base path, the relative path is returned and cached. + * If the path is not under the base path, the result is cached as a negative match.

    + * + * @param path the path to check, must not be {@code null} + * @param basePath the base path to test against, may be {@code null} + * @param basePathToRelativePath cache map to store computed results + * @return the relative path between {@code basePath} and {@code path} if included, + * or {@code null} if the path is not under the base path */ - private static boolean isWatchKind(Integer watcherKind, int kind) { - return watcherKind == null || (watcherKind & kind) != 0; - } + private @Nullable Path matchBasePath(@NotNull Path path, + @Nullable Path basePath, + @NotNull Map> basePathToRelativePath) { + if (basePath == null) { + return null; // No base path to check + } - @Nullable - private static PathPatternMatcher getPathPatternMatcher(FileSystemWatcher fileSystemMatcher) { - Either globPattern = fileSystemMatcher.getGlobPattern(); - if (globPattern != null) { - if (globPattern.isLeft()) { - String pattern = globPattern.getLeft(); - return new PathPatternMatcher(pattern); - } else { - RelativePattern relativePattern = globPattern.getRight(); - if (relativePattern != null) { - // Implement relative pattern like glob string pattern - // by waiting for finding a concrete use case. - String pattern = relativePattern.getPattern(); - return new PathPatternMatcher(pattern); - } + // Check cache first + var matches = basePathToRelativePath.get(basePath); + if (matches != null) { + if (matches.isLeft()) { + return matches.getLeft(); // Cached positive result } + return null; // Cached negative result + } + + // Compute for the first time + if (path.startsWith(basePath)) { + var relativePath = basePath.relativize(path); + basePathToRelativePath.put(basePath, Either.forLeft(relativePath)); // Cache positive result + return relativePath; } + + // Path is not under base path, cache negative result + basePathToRelativePath.put(basePath, Either.forRight(Boolean.FALSE)); return null; } - private boolean match(URI uri, int kind) { - List matchers = pathPatternMatchers.get(kind); - if (matchers == null) { - return false; + /** + * Extracts the base path from a glob pattern string. + * The base path is the portion before any glob characters (*, ?, [, {). + * + * @param globPattern the glob pattern (e.g., "C:\\Users\\project/**\/*.rs") + * @return the base path before the glob pattern, or null if no valid base path exists + * + * Examples: + * - "C:\\Users\\project\\test-rust/**\/*.rs" -> Path("C:\\Users\\project\\test-rust") + * - "/home/user/project/**\/*.java" -> Path("/home/user/project") + * - "**\/*.txt" -> null (pattern starts with glob) + */ + private static @Nullable Path extractBasePath(String globPattern) { + if (StringUtils.isBlank(globPattern)) { + return null; } - for (var matcher : matchers) { - if (matcher.matches(uri)) { - return true; + + // Find the first occurrence of glob characters: * ? [ { + int firstGlobChar = findFirstGlobCharacter(globPattern); + + if (firstGlobChar == -1) { + // No glob characters, the entire string is a path + try { + return Paths.get(globPattern); + } catch (Exception e) { + return null; } } - return false; + + if (firstGlobChar == 0) { + // Pattern starts with a glob character (e.g., "**/*.rs") + return null; + } + + // Find the last path separator before the first glob character + String beforeGlob = globPattern.substring(0, firstGlobChar); + int lastSeparator = Math.max( + beforeGlob.lastIndexOf('/'), + beforeGlob.lastIndexOf('\\') + ); + + if (lastSeparator == -1) { + // No separator found before glob, no valid base path + return null; + } + + String basePath = globPattern.substring(0, lastSeparator); + + try { + return Paths.get(basePath); + } catch (Exception e) { + return null; + } } -} + /** + * Finds the index of the first glob metacharacter in the string. + * Glob characters: * ? [ { + */ + private static int findFirstGlobCharacter(String pattern) { + int minIndex = Integer.MAX_VALUE; + + int asterisk = pattern.indexOf('*'); + if (asterisk != -1 && asterisk < minIndex) { + minIndex = asterisk; + } + + int question = pattern.indexOf('?'); + if (question != -1 && question < minIndex) { + minIndex = question; + } + + int bracket = pattern.indexOf('['); + if (bracket != -1 && bracket < minIndex) { + minIndex = bracket; + } + + int brace = pattern.indexOf('{'); + if (brace != -1 && brace < minIndex) { + minIndex = brace; + } + + return minIndex == Integer.MAX_VALUE ? -1 : minIndex; + } +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java b/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java index 767813d5d..64e8bfdd1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/foldingRange/LSPFoldingRangeBuilder.java @@ -187,6 +187,7 @@ public static TextRange getTextRange( int end = getEndOffset(foldingRange, document); // The end offsets can fall a bit short, so look for the closing brace character if (closeBraceChar != null) { + end--; while ((end < documentLength) && (documentChars.charAt(end) != closeBraceChar)) { end++; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/AbstractLSPFormattingService.java b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/AbstractLSPFormattingService.java index b0c18cebf..7109cd17a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/AbstractLSPFormattingService.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/AbstractLSPFormattingService.java @@ -43,7 +43,7 @@ protected FormattingTask createFormattingTask(@NotNull AsyncFormattingRequest fo if (ioFile == null) { return null; } - VirtualFile file = LSPIJUtils.findResourceFor(ioFile.toURI()); + VirtualFile file = LSPIJUtils.findResourceFor(ioFile); if (file == null) { return null; } @@ -80,7 +80,7 @@ public void run() { Editor editor = editors.length > 0 ? editors[0] : null; Document document = editor != null ? editor.getDocument() : LSPIJUtils.getDocument(psiFile.getVirtualFile()); if (document != null) { - formattingSupport.format(document, editor, formattingRange, formattingRequest); + formattingSupport.format(document, psiFile, editor, formattingRange, formattingRequest); } } @@ -120,7 +120,8 @@ public final boolean canFormat(@NotNull PsiFile file) { .hasAny(file, ls -> canSupportFormatting(ls, file)); } - private boolean canSupportFormatting(LanguageServerWrapper ls, PsiFile file) { + private boolean canSupportFormatting(@NotNull LanguageServerWrapper ls, + @NotNull PsiFile file) { var formattingFeature= ls.getClientFeatures().getFormattingFeature(); if (!formattingFeature.isEnabled(file)) { return false; @@ -128,9 +129,10 @@ private boolean canSupportFormatting(LanguageServerWrapper ls, PsiFile file) { return canSupportFormatting(formattingFeature, file); } - protected abstract boolean canSupportFormatting(LSPFormattingFeature formattingFeature, PsiFile file); + protected abstract boolean canSupportFormatting(@NotNull LSPFormattingFeature formattingFeature, + @NotNull PsiFile file); - private static TextRange getFormattingRange(AsyncFormattingRequest formattingRequest) { + private static @Nullable TextRange getFormattingRange(@NotNull AsyncFormattingRequest formattingRequest) { List ranges = formattingRequest.getFormattingRanges(); if (ranges.isEmpty()) { return null; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingParams.java b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingParams.java index c558198b7..ede47dd50 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingParams.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingParams.java @@ -12,17 +12,22 @@ import com.intellij.openapi.editor.Document; import com.intellij.openapi.util.TextRange; +import com.redhat.devtools.lsp4ij.LanguageServerItem; +import org.eclipse.lsp4j.FormattingOptions; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** * LSP formatting parameters. * - * @param tabSize the tab size and null otherwise. - * @param insertSpaces the insert spaces and null otherwise. - * @param textRange the text range and null otherwise. - * @param document the document. + * @param textRange the text range and null otherwise. + * @param document the document. + * @param formattingServer + * @param formattingOptions */ -public record LSPFormattingParams(@Nullable Integer tabSize, @Nullable Boolean insertSpaces, @Nullable TextRange textRange, @NotNull Document document) { +public record LSPFormattingParams(@Nullable TextRange textRange, + @NotNull Document document, + @NotNull LanguageServerItem formattingServer, + @NotNull FormattingOptions formattingOptions) { } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingSupport.java index 8f73ca91d..ad9a47633 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/formatting/LSPFormattingSupport.java @@ -14,11 +14,10 @@ import com.intellij.openapi.editor.Document; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.util.Ref; import com.intellij.openapi.util.TextRange; import com.intellij.psi.PsiFile; -import com.redhat.devtools.lsp4ij.LSPIJUtils; -import com.redhat.devtools.lsp4ij.LSPRequestConstants; -import com.redhat.devtools.lsp4ij.LanguageServerItem; +import com.redhat.devtools.lsp4ij.*; import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.features.AbstractLSPDocumentFeatureSupport; @@ -27,19 +26,16 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.util.Collections; import java.util.List; -import java.util.Optional; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.function.Predicate; import static com.redhat.devtools.lsp4ij.LSPIJUtils.applyEdits; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; /** - * Abstract class for LSP formatting and range formatting. + * LSP formatting and range formatting support. */ public class LSPFormattingSupport extends AbstractLSPDocumentFeatureSupport> { @@ -48,20 +44,25 @@ public LSPFormattingSupport(@NotNull PsiFile file) { } public void format(@NotNull Document document, + @NotNull PsiFile file, @Nullable Editor editor, @Nullable TextRange textRange, @NotNull AsyncFormattingRequest formattingRequest) { - Integer tabSize = editor != null ? LSPIJUtils.getTabSize(editor) : null; - Boolean insertSpaces = editor != null ? LSPIJUtils.isInsertSpaces(editor) : null; - LSPFormattingParams params = new LSPFormattingParams(tabSize, insertSpaces, textRange, document); + LanguageServerItem formattingServer = findFormattingServer(file, textRange); + if (formattingServer == null) { + return; + } + // We are currently in a ReadAction; this call must occur here because it may access PSI or CodeStyleSettings. + var formattingOptions = formattingServer.getClientFeatures().getFormattingFeature().getFormattingOptions(file, editor); + LSPFormattingParams params = new LSPFormattingParams(textRange, document, formattingServer, formattingOptions); CompletableFuture> formatFuture = this.getFeatureData(params); try { waitUntilDone(formatFuture, getFile()); } catch ( ProcessCanceledException e) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility //TODO delete block when minimum required version is 2024.2 - handleError(formattingRequest, e); - return; + //handleError(formattingRequest, e); + throw e; } catch (CancellationException e) { // cancel the LSP requests textDocument/formatting / textDocument/rangeFormatting handleError(formattingRequest, e); @@ -93,93 +94,87 @@ private static void handleError(@NotNull AsyncFormattingRequest formattingReques @Override protected CompletableFuture> doLoad(LSPFormattingParams params, CancellationSupport cancellationSupport) { PsiFile file = super.getFile(); - return getFormatting(file, params, cancellationSupport); + return getFormatting(params, cancellationSupport); } - protected @NotNull CompletableFuture> getFormatting(@NotNull PsiFile file, - @NotNull LSPFormattingParams params, + protected @NotNull CompletableFuture> getFormatting(@NotNull LSPFormattingParams params, @NotNull CancellationSupport cancellationSupport) { boolean isRangeFormatting = params.textRange() != null; - Predicate filter = !isRangeFormatting ? - f -> f.getFormattingFeature().isFormattingSupported(file) : - f -> f.getFormattingFeature().isFormattingSupported(file) || - f.getFormattingFeature().isRangeFormattingSupported(file); - return getLanguageServers(file, - f -> f.getFormattingFeature().isEnabled(file), - filter) - .thenComposeAsync(languageServers -> { - // Here languageServers is the list of language servers which matches the given file - // and which have formatting capability - if (languageServers.isEmpty()) { - return CompletableFuture.completedFuture(Collections.emptyList()); - } + var formattingServer = params.formattingServer(); + if (isRangeFormatting && formattingServer.isDocumentRangeFormattingSupported()) { + // Range formatting + DocumentRangeFormattingParams lspParams = createDocumentRangeFormattingParams(params.formattingOptions(), params.textRange(), params.document(), formattingServer); + return cancellationSupport.execute(formattingServer + .getTextDocumentService() + .rangeFormatting(lspParams), formattingServer, LSPRequestConstants.TEXT_DOCUMENT_RANGE_FORMATTING); + } - // Get the first language server which supports range formatting (if it requires) or formatting - LanguageServerItem languageServer = getFormattingLanguageServer(languageServers, isRangeFormatting); + // Full document formatting + DocumentFormattingParams lspParams = createDocumentFormattingParams(params.formattingOptions(), formattingServer); + return cancellationSupport.execute(formattingServer + .getTextDocumentService() + .formatting(lspParams), formattingServer, LSPRequestConstants.TEXT_DOCUMENT_FORMATTING); + } - cancellationSupport.checkCanceled(); - if (isRangeFormatting && languageServer.isDocumentRangeFormattingSupported()) { - // Range formatting - DocumentRangeFormattingParams lspParams = createDocumentRangeFormattingParams(params.tabSize(), params.insertSpaces(), params.textRange(), params.document(), languageServer); - return cancellationSupport.execute(languageServer - .getTextDocumentService() - .rangeFormatting(lspParams), languageServer, LSPRequestConstants.TEXT_DOCUMENT_RANGE_FORMATTING); + /** + * Finds a {@link LanguageServerItem} that supports document formatting for the given PSI file. + *

    + * Depending on whether a {@link TextRange} is provided, this method looks for a language server + * that supports either: + *

      + *
    • {@code textDocument/formatting} – when {@code textRange} is {@code null},
    • + *
    • {@code textDocument/rangeFormatting} or {@code textDocument/formatting} – when a range is provided.
    • + *
    + *

    + * The method iterates over all language servers registered for the file's project and returns the + * first server that declares formatting support through its {@link LSPClientFeatures}. + * If no suitable server is found, {@code null} is returned. + * + * @param file the PSI file for which formatting is requested. + * @param textRange the range to format, or {@code null} to format the entire document. + * @return a {@link LanguageServerItem} supporting formatting, or {@code null} if none is available. + */ + private static @Nullable LanguageServerItem findFormattingServer(@NotNull PsiFile file, + @Nullable TextRange textRange) { + // Get the first language server which supports range formatting (if it requires) or formatting + boolean isRangeFormatting = textRange != null; + Ref server = Ref.create(null); + LanguageServiceAccessor.getInstance(file.getProject()).processLanguageServers(file, ls -> { + var formattingFeature = ls.getClientFeatures().getFormattingFeature(); + if (formattingFeature.isEnabled(file)) { + boolean overridable = formattingFeature.isExistingFormatterOverrideable(file); + if (!isRangeFormatting) { + if ((overridable || server.isNull()) && formattingFeature.isFormattingSupported(file)) { + server.set(new LanguageServerItem(ls.getLanguageServer(), ls)); } - - // Full document formatting - DocumentFormattingParams lspParams = createDocumentFormattingParams(params.tabSize(), params.insertSpaces(), languageServer); - return cancellationSupport.execute(languageServer - .getTextDocumentService() - .formatting(lspParams), languageServer, LSPRequestConstants.TEXT_DOCUMENT_FORMATTING); - }); - } - - private static LanguageServerItem getFormattingLanguageServer(List languageServers, boolean isRangeFormatting) { - if (isRangeFormatting) { - // Range formatting, try to get the first language server which have the range formatting capability - Optional result = languageServers - .stream() - .filter(LanguageServerItem::isDocumentRangeFormattingSupported) - .findFirst(); - if (result.isPresent()) { - return result.get(); + } else { + if (formattingFeature.isFormattingSupported(file) || + formattingFeature.isRangeFormattingSupported(file)) { + if (overridable || server.isNull() || !server.get().isDocumentRangeFormattingSupported()) { + server.set(new LanguageServerItem(ls.getLanguageServer(), ls)); + } + } + } } - } - // Get the first language server - return languageServers.get(0); + }); + return server.isNull() ? null : server.get(); } - private @NotNull DocumentFormattingParams createDocumentFormattingParams(@Nullable Integer tabSize, - @Nullable Boolean insertSpaces, + private @NotNull DocumentFormattingParams createDocumentFormattingParams(@NotNull FormattingOptions options, @NotNull LanguageServerItem languageServer) { DocumentFormattingParams params = new DocumentFormattingParams(); params.setTextDocument(new TextDocumentIdentifier(FileUriSupport.toString(getFile().getVirtualFile(), languageServer.getClientFeatures()))); - FormattingOptions options = new FormattingOptions(); - if (tabSize != null) { - options.setTabSize(tabSize); - } - if (insertSpaces != null) { - options.setInsertSpaces(insertSpaces); - } params.setOptions(options); return params; } - private @NotNull DocumentRangeFormattingParams createDocumentRangeFormattingParams(@Nullable Integer tabSize, - @Nullable Boolean insertSpaces, + private @NotNull DocumentRangeFormattingParams createDocumentRangeFormattingParams(@NotNull FormattingOptions options, @NotNull TextRange textRange, @NotNull Document document, @NotNull LanguageServerItem languageServer) { DocumentRangeFormattingParams params = new DocumentRangeFormattingParams(); params.setTextDocument(new TextDocumentIdentifier(FileUriSupport.toString(getFile().getVirtualFile(), languageServer.getClientFeatures()))); - FormattingOptions options = new FormattingOptions(); - if (tabSize != null) { - options.setTabSize(tabSize); - } - if (insertSpaces != null) { - options.setInsertSpaces(insertSpaces); - } params.setOptions(options); Range range = LSPIJUtils.toRange(textRange, document); params.setRange(range); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPHighlightUsagesHandlerFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPHighlightUsagesHandlerFactory.java index e78662756..4ea4fbb23 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPHighlightUsagesHandlerFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPHighlightUsagesHandlerFactory.java @@ -17,7 +17,9 @@ import com.intellij.openapi.editor.Editor; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.progress.ProgressManager; +import com.intellij.openapi.progress.util.ProgressIndicatorUtils; import com.intellij.openapi.util.TextRange; +import com.intellij.openapi.util.registry.Registry; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPFileSupport; @@ -29,6 +31,7 @@ import org.eclipse.lsp4j.TextDocumentIdentifier; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,19 +81,41 @@ private List getTargets(Editor editor, PsiFile psiFile) var params = new LSPDocumentHighlightParams(new TextDocumentIdentifier(), LSPIJUtils.toPosition(offset, document), offset); LSPHighlightSupport highlightSupport = LSPFileSupport.getSupport(psiFile).getHighlightSupport(); CompletableFuture> highlightFuture = highlightSupport.getHighlights(params); - try { - waitUntilDone(highlightFuture, psiFile); - } catch (ProcessCanceledException e) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility - //TODO delete block when minimum required version is 2024.2 - highlightSupport.cancel(); - return Collections.emptyList(); - } catch (CancellationException e) { - // cancel the LSP requests textDocument/documentHighlight - highlightSupport.cancel(); - return Collections.emptyList(); - } catch (ExecutionException e) { - LOGGER.error("Error while consuming LSP 'textDocument/documentHighlight' request", e); - return Collections.emptyList(); + if (Registry.is("lsp4ij.performance.optimizations.enabled")) { + final long modificationStampBefore = psiFile.getModificationStamp(); + try { + // Wait for gopls while cooperating with IntelliJ's read/write lock protocol. + // If a write action needs priority the progress indicator is cancelled, PCE is + // thrown here, the read lock is released immediately, and IntelliJ reschedules + // this background highlighting pass once the write action completes. + ProgressIndicatorUtils.awaitWithCheckCanceled(highlightFuture); + } catch (ProcessCanceledException e) { + // Write action requested priority — don't cancel the LSP future; the cached + // result will be reused when the pass is rescheduled. + throw e; + } catch (CancellationException e) { + highlightSupport.cancel(); + return Collections.emptyList(); + } + if (psiFile.getModificationStamp() != modificationStampBefore) { + // File was edited while waiting — discard stale results. + highlightSupport.cancel(); + return Collections.emptyList(); + } + } else { + try { + waitUntilDone(highlightFuture, psiFile); + } catch (ProcessCanceledException e) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility + //TODO delete block when minimum required version is 2024.2 + highlightSupport.cancel(); + return Collections.emptyList(); + } catch (CancellationException e) { + highlightSupport.cancel(); + return Collections.emptyList(); + } catch (ExecutionException e) { + LOGGER.error("Error while consuming LSP 'textDocument/documentHighlight' request", e); + return Collections.emptyList(); + } } if (isDoneNormally(highlightFuture)) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPTargetElementEvaluator.java b/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPTargetElementEvaluator.java index 24a8339a4..b75ea8dd3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPTargetElementEvaluator.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/highlight/LSPTargetElementEvaluator.java @@ -43,13 +43,18 @@ public PsiElement adjustReferenceOrReferencedElement(@NotNull PsiFile file, return null; } - // See if the view provider can provide an element + // See if the view provider can provide a specific token element LSPSemanticTokensFileViewProvider semanticTokensFileViewProvider = LSPSemanticTokensFileViewProvider.getInstance(file); if (semanticTokensFileViewProvider != null) { - return semanticTokensFileViewProvider.findElementAt(offset); + PsiElement element = semanticTokensFileViewProvider.findElementAt(offset); + // Only return the element if it's a specific token, not the whole-file fallback + // returned when semantic tokens haven't loaded yet (TextMate files have no PSI structure) + if (element != null && !element.getTextRange().equals(file.getTextRange())) { + return element; + } } - // Nope. Try to find the word at the caret and return a fake PSI element for it + // Try to find the word at the caret and return a fake PSI element for it TextRange targetTextRange = LSPIJUtils.getWordRangeAt(editor.getDocument(), file, offset); return targetTextRange != null ? new LSPPsiElement(file, targetTextRange) : null; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/implementation/LSPGoToImplementationAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/implementation/LSPGoToImplementationAction.java index 10efe437d..22c05ff34 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/implementation/LSPGoToImplementationAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/implementation/LSPGoToImplementationAction.java @@ -68,7 +68,7 @@ protected CompletableFuture> getLocations(@NotNull PsiFile ps @Override protected boolean canSupportFeature(@NotNull LSPClientFeatures clientFeatures, @NotNull PsiFile file) { - return clientFeatures.getImplementationFeature().isImplementationSupported(file); + return clientFeatures.getImplementationFeature().isSupported(file); } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsProvider.java index 498c1f8e5..36f05b05f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsProvider.java @@ -140,6 +140,9 @@ private void buildMultipartInlayHint(@NotNull PsiFile psiFile, @NotNull InlayHintData hintData, @NotNull InlayPosition position, @NotNull InlayTreeSink sink) { + if (parts.isEmpty()) { + return; + } int index = 0; var builds = new ArrayList>(); var tooltip = new StringBuilder(); @@ -156,7 +159,7 @@ private void buildMultipartInlayHint(@NotNull PsiFile psiFile, } index++; } - sink.addPresentation(position, null, tooltip.toString(), true, builder -> { + sink.addPresentation(position, null, hasTooltip ? tooltip.toString() : null, true, builder -> { for (var build: builds) { build.accept(builder); } @@ -188,7 +191,9 @@ private InlayHintPartBuildInfo buildSingleInlayHint( data = null; } return new InlayHintPartBuildInfo(tooltipStr, builder -> { - builder.text(text, data); + if (!text.isEmpty()) { + builder.text(text, data); + } }); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsSupport.java index ca704bf1b..68361687c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/inlayhint/LSPInlayHintsSupport.java @@ -16,11 +16,11 @@ import com.redhat.devtools.lsp4ij.features.AbstractLSPDocumentRefreshableFeatureSupport; import com.redhat.devtools.lsp4ij.internal.CancellationSupport; import com.redhat.devtools.lsp4ij.internal.CompletableFutures; -import com.redhat.devtools.lsp4ij.internal.PsiFileCancelChecker; -import com.redhat.devtools.lsp4ij.internal.editor.EditorFeatureManager; import com.redhat.devtools.lsp4ij.internal.editor.EditorFeatureType; import org.eclipse.lsp4j.InlayHint; +import org.eclipse.lsp4j.InlayHintLabelPart; import org.eclipse.lsp4j.InlayHintParams; +import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; @@ -104,8 +104,8 @@ private static CompletableFuture> getInlayHintsFor(@NotNull .getTextDocumentService() .resolveInlayHint(inlayHint), languageServer, LSPRequestConstants.INLAY_HINT_RESOLVE); } - if (inlayHint.getLabel() != null || resolvedInlayHintFuture != null) { - // The inlayHint content is filled or the inlayHint must be resolved + if (hasValidLabel(inlayHint) || resolvedInlayHintFuture != null) { + // The inlayHint content is filled with valid label or the inlayHint must be resolved data.add(new InlayHintData(inlayHint, languageServer, resolvedInlayHintFuture)); } }); @@ -113,4 +113,25 @@ private static CompletableFuture> getInlayHintsFor(@NotNull }); } + /** + * Checks if an InlayHint has a valid label (non-empty). + * + * @param inlayHint the inlay hint to check + * @return true if the label is valid, false otherwise + */ + private static boolean hasValidLabel(@NotNull InlayHint inlayHint) { + Either> label = inlayHint.getLabel(); + if (label == null) { + return false; + } + if (label.isLeft()) { + // Simple string label: must not be empty + return !label.getLeft().isEmpty(); + } else { + // Multi-part label: at least one part must have non-empty text + List parts = label.getRight(); + return parts != null && parts.stream().anyMatch(part -> part.getValue() != null && !part.getValue().isEmpty()); + } + } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPDefinitionSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPDefinitionSupport.java index 950cc2b99..b84739291 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPDefinitionSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPDefinitionSupport.java @@ -85,7 +85,7 @@ private static CompletableFuture> getDefinitionFor(@NotNull L updateTextDocumentUri(params.getTextDocument(), file, languageServer); return cancellationSupport.execute(languageServer .getTextDocumentService() - .definition(params), languageServer, LSPRequestConstants.TEXT_DOCUMENT_DECLARATION) + .definition(params), languageServer, LSPRequestConstants.TEXT_DOCUMENT_DEFINITION) .thenApplyAsync(locations -> LSPIJUtils.getLocations(locations, languageServer)); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPGotoDeclarationHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPGotoDeclarationHandler.java index 5368ca187..7c0e3de38 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPGotoDeclarationHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/navigation/LSPGotoDeclarationHandler.java @@ -60,6 +60,7 @@ public PsiElement[] getGotoDeclarationTargets(@Nullable PsiElement sourceElement if (project == null || project.isDisposed()) { return PsiElement.EMPTY_ARRAY; } + PsiFile psiFile = sourceElement != null ? sourceElement.getContainingFile() : null; if (psiFile == null) { return PsiElement.EMPTY_ARRAY; @@ -75,7 +76,16 @@ public PsiElement[] getGotoDeclarationTargets(@Nullable PsiElement sourceElement if (semanticTokensFileViewProvider.isReference(offset)) { PsiReference reference = semanticTokensFileViewProvider.findReferenceAt(offset); PsiElement target = reference != null ? reference.resolve() : null; - return target != null ? new PsiElement[]{target} : PsiElement.EMPTY_ARRAY; + if (target != null) { + //OPEN IDE BEGIN + if (isSameElement(sourceElement, target)) { + return PsiElement.EMPTY_ARRAY; + } else { + return new PsiElement[]{target}; + } + //OPEN IDE END + } + return PsiElement.EMPTY_ARRAY; } // If it's definitely a declaration, just return an empty set of targets else if (semanticTokensFileViewProvider.isDeclaration(offset)) { @@ -85,6 +95,14 @@ else if (semanticTokensFileViewProvider.isDeclaration(offset)) { // Use LSP to find targets PsiElement[] targets = getGotoDeclarationTargets(sourceElement, offset); + if (targets.length == 1) { + // Some language servers return the definition of the element itself when already at the declaration. + // In this case, we need to return an empty array to let IntelliJ fall back to finding usages. + // IntelliJ's navigation logic tries declarations first, then usages if no declarations are found. + if (isAlreadyAtDeclaration(targets[0], psiFile, offset)) { + return PsiElement.EMPTY_ARRAY; + } + } // If this is a semantic token-backed file and there were targets but this wasn't represented in semantic tokens // as a reference, stub a reference for the word at the current offset @@ -110,6 +128,22 @@ else if (semanticTokensFileViewProvider.isDeclaration(offset)) { return targets; } + //OPEN IDE BEGIN + private boolean isSameElement(PsiElement sourcePsiElement, PsiElement targetPsiElement) { + VirtualFile targetVirtualFile = LSPIJUtils.getFile(targetPsiElement); + VirtualFile sourceVirtualFile = LSPIJUtils.getFile(sourcePsiElement); + + if (!Objects.equals(targetVirtualFile, sourceVirtualFile)) { + return false; + } + + TextRange targetRange = targetPsiElement.getTextRange(); + TextRange currentElementRange = sourcePsiElement.getTextRange(); + + return currentElementRange.intersects(targetRange); + } + //OPEN IDE END + /** * Uses LSP to resolve the target elements for the reference at the specified offset in the file containing the * provided source element. @@ -161,4 +195,48 @@ else if (semanticTokensFileViewProvider.isDeclaration(offset)) { } return PsiElement.EMPTY_ARRAY; } + + /** + * Checks if the target element is at the same location as the source offset. + *

    + * Some language servers return the definition of the element itself when the user is already + * positioned at the declaration. By detecting this case and returning true, we allow IntelliJ's + * navigation system to fall back to finding usages instead of navigating to the same location. + *

    + * This is important because IntelliJ's navigation logic works in two phases: + * 1. First, it tries to find declarations (via GotoDeclarationHandler) + * 2. If no declarations are found (empty array), it falls back to finding usages + * + * @param target the target PsiElement from LSP textDocument/definition + * @param sourcePsiFile the source file where the navigation was triggered + * @param sourceOffset the offset in the source file where the user clicked + * @return true if the target is at the same location as the source, false otherwise + */ + private static boolean isAlreadyAtDeclaration(@NotNull PsiElement target, + @NotNull PsiFile sourcePsiFile, + int sourceOffset) { + // Get the file containing the target element + PsiFile targetFile = target.getContainingFile(); + if (targetFile == null) { + return false; + } + + // Get the virtual files to compare + VirtualFile sourceVirtualFile = sourcePsiFile.getVirtualFile(); + VirtualFile targetVirtualFile = targetFile.getVirtualFile(); + + // If not in the same file, they can't be the same element + if (sourceVirtualFile == null || targetVirtualFile == null || !sourceVirtualFile.equals(targetVirtualFile)) { + return false; + } + + // Check if the source offset falls within the target element's text range + TextRange targetRange = target.getTextRange(); + if (targetRange == null) { + return false; + } + + // If the source offset is within the target range, we're already at the declaration + return targetRange.contains(sourceOffset); + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressInfo.java b/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressInfo.java index f98a28bf4..e1d1a459d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressInfo.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressInfo.java @@ -26,6 +26,14 @@ class LSPProgressInfo { private boolean cancellable; private boolean done; + // Flag to prevent creating multiple IntelliJ tasks for the same token + // (non-compliant servers may send multiple 'begin' notifications with the same token) + private boolean taskStarted; + + // Counter to track active 'begin' notifications + // Handles non-compliant servers that reuse tokens: done=true only when all begins have their corresponding end + private int beginCount; + private final LinkedBlockingDeque progressNotifications; public LSPProgressInfo(String token) { @@ -117,4 +125,37 @@ public boolean isCancelled() { public void cancel() { this.cancelled = true; } + + public boolean isTaskStarted() { + return taskStarted; + } + + public void setTaskStarted(boolean taskStarted) { + this.taskStarted = taskStarted; + } + + /** + * Increments the begin count when a 'begin' notification arrives. + * Resets the done flag to ensure the task continues processing. + * This handles the case where a non-compliant server sends multiple 'begin' with the same token. + */ + public synchronized void incrementBeginCount() { + this.beginCount++; + // Reset done flag when a new begin arrives + if (this.beginCount > 0) { + this.done = false; + } + } + + /** + * Decrements the begin count when an 'end' notification arrives. + * Marks the progress as done only when all 'begin' notifications have their corresponding 'end'. + */ + public synchronized void decrementBeginCount() { + this.beginCount--; + // Mark as done only when all begins have their corresponding end + if (this.beginCount <= 0) { + this.done = true; + } + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressManager.java b/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressManager.java index dac49cd62..10d8b6917 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/progress/LSPProgressManager.java @@ -71,6 +71,15 @@ private void createTask(LSPProgressInfo progressInfo) { progressMap.remove(token); return; } + if (progressInfo.isTaskStarted()) { + // A task has already been started for this progress. + // Non-compliant servers may send multiple 'begin' with the same token. + // Without this check, we would create multiple IntelliJ tasks reading from the same queue, + // causing a race condition where notifications are consumed by different tasks. + // --> don't create another background task. + return; + } + progressInfo.setTaskStarted(true); String title = languageServerWrapper.getClientFeatures().getProgressFeature() .getProgressTaskTitle(progressInfo.getTitle()); boolean cancellable = progressInfo.isCancellable(); @@ -108,6 +117,12 @@ public void run(@NotNull ProgressIndicator indicator) { onProgressBegin((WorkDoneProgressBegin) progressNotification, indicator); case report -> // 'report' has been notified onProgressReport((WorkDoneProgressReport) progressNotification, indicator); + case end -> { + // 'end' has been notified + // Process 'end' in the background task (with ProgressIndicator context) + // rather than in the notification thread, ensuring consistent UI updates. + onProgressEnd((WorkDoneProgressEnd) progressNotification); + } } } } @@ -186,14 +201,18 @@ public void notifyProgress(final @NotNull ProgressParams params) { WorkDoneProgressBegin begin = (WorkDoneProgressBegin) progressNotification; progress.setTitle(begin.getTitle()); progress.setCancellable(begin.getCancellable() != null && begin.getCancellable()); + // Increment begin count to track active progress operations. + // This handles non-compliant servers that may send multiple 'begin' with the same token. + // The progress is marked as done only when all 'begin' have their corresponding 'end'. + progress.incrementBeginCount(); // The IJ task is created on 'begin' and not on 'create' to initialize // the Task with the 'begin' title. createTask(progress); } case end -> { - WorkDoneProgressEnd end = (WorkDoneProgressEnd) progressNotification; - onProgressEnd(end); - progress.setDone(true); + // Decrement begin count. The progress is automatically marked as done when count reaches 0, + // ensuring all 'begin' notifications have been matched with their 'end'. + progress.decrementBeginCount(); } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRefactoringListener.java b/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRefactoringListener.java deleted file mode 100644 index d1e1b5501..000000000 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRefactoringListener.java +++ /dev/null @@ -1,64 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.refactoring; - -import com.intellij.refactoring.listeners.RefactoringEventData; -import com.intellij.refactoring.listeners.RefactoringEventListener; -import org.eclipse.lsp4j.RenameFilesParams; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * LSP refactoring listener which tracks refactoring to send 'workspace/didRenameFiles' when - * a rename file occurs if they were 'workspace.willRenameFiles' has been consumed. - */ -public class LSPRefactoringListener implements RefactoringEventListener { - - @Override - public void refactoringStarted(@NotNull String refactoringId, @Nullable RefactoringEventData beforeData) { - - } - - @Override - public void refactoringDone(@NotNull String refactoringId, @Nullable RefactoringEventData afterData) { - LSPRenameFilesContext context = LSPRenameFilesContextHolder.get(); - if (context != null) { - var file = context.file(); - // A rename file has been done and some language servers have consumed their - // LSP 'workspace/willRenameFiles' request to collect and apply WorkspaceEdit, notify - // Send for those language servers an LSP 'workspace/didRenameFiles' notifications. - context.servers() - .forEach(languageServerItem -> { - var serverWrapper = languageServerItem.getServerWrapper(); - if (languageServerItem.getClientFeatures().getRenameFeature().isDidRenameFilesSupported(file)) { - serverWrapper.sendNotification(ls -> { - RenameFilesParams params = context.params(); - ls.getWorkspaceService() - .didRenameFiles(params); - return ls; - }); - } - }); - } - LSPRenameFilesContextHolder.set(null); - } - - @Override - public void conflictsDetected(@NotNull String refactoringId, @NotNull RefactoringEventData conflictsData) { - - } - - @Override - public void undoRefactoring(@NotNull String refactoringId) { - - } - -} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContext.java b/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContext.java deleted file mode 100644 index f88ac1241..000000000 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContext.java +++ /dev/null @@ -1,27 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.refactoring; - -import com.intellij.psi.PsiFile; -import com.redhat.devtools.lsp4ij.LanguageServerItem; -import org.eclipse.lsp4j.RenameFilesParams; - -import java.util.List; - -/** - * LSP rename files context. - * - * @param params - * @param servers - * @param file - */ -public record LSPRenameFilesContext(RenameFilesParams params, List servers, PsiFile file) { -} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContextHolder.java b/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContextHolder.java deleted file mode 100644 index e30d0526b..000000000 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenameFilesContextHolder.java +++ /dev/null @@ -1,29 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.refactoring; - -/** - * As the IntelliJ {@link com.intellij.refactoring.listeners.RefactoringEventListener} API - * doesn't provide the capability to store data during the refactoring process, - * the LSP rename files context is stored in a Thread local. - */ -public class LSPRenameFilesContextHolder { - - private static ThreadLocal contextThreadLocal = new ThreadLocal(); - - public static void set(LSPRenameFilesContext serverIds) { - contextThreadLocal.set(serverIds); - } - - public static LSPRenameFilesContext get() { - return contextThreadLocal.get(); - } -} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElement.java b/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElement.java deleted file mode 100644 index a465834d3..000000000 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElement.java +++ /dev/null @@ -1,37 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.refactoring; - -import com.intellij.openapi.editor.Document; -import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiFile; -import com.redhat.devtools.lsp4ij.LSPIJUtils; -import com.redhat.devtools.lsp4ij.features.LSPPsiElement; -import org.eclipse.lsp4j.TextEdit; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; - -public class LSPRenamePsiElement extends LSPPsiElement { - private final TextEdit textEdit; - - public LSPRenamePsiElement(@NotNull PsiFile file, @NotNull TextRange textRange, TextEdit textEdit) { - super(file, textRange); - this.textEdit = textEdit; - } - - public void rename() { - Document document = LSPIJUtils.getDocument(getContainingFile().getVirtualFile()); - Editor[] editors = LSPIJUtils.editorsForFile(getContainingFile().getVirtualFile(), getContainingFile().getProject()); - LSPIJUtils.applyEdits(editors.length > 0 ? editors[0] : null, document, Arrays.asList(textEdit)); - } -} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElementProcessor.java b/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElementProcessor.java deleted file mode 100644 index 3adbb4177..000000000 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/LSPRenamePsiElementProcessor.java +++ /dev/null @@ -1,197 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.refactoring; - -import com.intellij.openapi.editor.Document; -import com.intellij.openapi.progress.ProcessCanceledException; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.util.TextRange; -import com.intellij.openapi.vfs.VirtualFile; -import com.intellij.psi.PsiElement; -import com.intellij.psi.PsiFile; -import com.intellij.psi.search.SearchScope; -import com.intellij.refactoring.listeners.RefactoringElementListener; -import com.intellij.refactoring.rename.RenamePsiElementProcessor; -import com.intellij.usageView.UsageInfo; -import com.intellij.util.IncorrectOperationException; -import com.redhat.devtools.lsp4ij.*; -import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; -import com.redhat.devtools.lsp4ij.features.LSPPsiElement; -import com.redhat.devtools.lsp4ij.internal.CancellationSupport; -import org.eclipse.lsp4j.FileRename; -import org.eclipse.lsp4j.RenameFilesParams; -import org.eclipse.lsp4j.TextEdit; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; - -import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.isDoneNormally; -import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; - -/** - * LSP {@link RenamePsiElementProcessor} implementation to consume - * LSP 'workspace/willRenameFiles' request when a file is renamed. - */ -public class LSPRenamePsiElementProcessor extends RenamePsiElementProcessor { - - private static final Logger LOGGER = LoggerFactory.getLogger(LSPRenamePsiElementProcessor.class); - - @Override - public boolean canProcessElement(@NotNull PsiElement element) { - return (element instanceof LSPPsiElement) || - ((element instanceof PsiFile) && LanguageServersRegistry.getInstance().isFileSupported((PsiFile) element)); - } - - @Override - public void prepareRenaming(@NotNull PsiElement element, - @NotNull String newName, - @NotNull Map allRenames, - @NotNull SearchScope scope) { - PsiFile file = element.getContainingFile(); - var project = file.getProject(); - - // Create LSP rename files parameters. - Map paramsPerServer = new HashMap<>(); - - // Prepare future of LSP 'workspace/willRenameFiles' request - CancellationSupport cancellationSupport = new CancellationSupport(); - var willRenameFilesFuture = - LanguageServiceAccessor.getInstance(project) - .getLanguageServers(file, - null, - f -> f.getRenameFeature().isWillRenameFilesSupported(file)) - .thenComposeAsync(languageServerItems -> { - - if (languageServerItems.isEmpty()) { - return CompletableFuture.completedFuture(null); - } - - List> r = languageServerItems - .stream() - .filter(ls -> ls.isWillRenameFilesSupported(file)) - .map(ls -> { - var params = createtRenameFilesParams(file, newName, ls.getClientFeatures()); - paramsPerServer.put(ls.getServerWrapper(), params); - return cancellationSupport - .execute(ls.getWorkspaceService().willRenameFiles(params), - ls, - LSPRequestConstants.WORKSPACE_WILL_RENAME_FILES) - .thenApplyAsync(we -> new WorkspaceEditData(we, ls)); - }) - .toList(); - - // TODO: we return the WorkspaceEdit of the first language server, what about when there are several - // language servers which returns WorkspaceEdit? - return r.isEmpty() ? CompletableFuture.completedFuture(null) : r.get(0); - }); - - try { - // Wait until the future of LSP 'workspace/willRenameFiles' request - // is finished and stop the wait if there are some ProcessCanceledException. - waitUntilDone(willRenameFilesFuture, file); - } catch ( - ProcessCanceledException ignore) {//Since 2024.2 ProcessCanceledException extends CancellationException so we can't use multicatch to keep backward compatibility - //TODO delete block when minimum required version is 2024.2 - } catch (CancellationException ignore) { - } catch (ExecutionException e) { - LOGGER.error("Error while consuming LSP 'workspace/willRenameFiles' request", e); - } - - if (isDoneNormally(willRenameFilesFuture)) { - // Future has been done correctly, create a fake PsiElement which stores the WorkspaceEdit - WorkspaceEditData editData = willRenameFilesFuture.getNow(null); - if (editData != null && editData.edit() != null) { - var params = paramsPerServer.get(editData.languageServer().getServerWrapper()); - LSPRenameFilesContextHolder.set(new LSPRenameFilesContext(params, Collections.singletonList(editData.languageServer()), file)); - - var edit = editData.edit(); - var fileUriSupport = editData.languageServer().getClientFeatures(); - var documentChanges = edit.getDocumentChanges(); - if (documentChanges != null) { - for (var documentChange : documentChanges) { - if (documentChange.isLeft()) { - var ed = documentChange.getLeft(); - String uri = ed.getTextDocument().getUri(); - List textEdits = ed.getEdits(); - addPsiElementRename(uri, textEdits, allRenames, fileUriSupport, project); - } - } - } - - var changes = edit.getChanges(); - if (changes != null) { - changes.entrySet().forEach(entry -> { - String uri = entry.getKey(); - List textEdits = entry.getValue(); - addPsiElementRename(uri, textEdits, allRenames, fileUriSupport, project); - }); - } - } - } - } - - private static void addPsiElementRename(@NotNull String uri, - @NotNull List textEdits, - @NotNull Map allRenames, - @Nullable FileUriSupport fileUriSupport, - @NotNull Project project) { - VirtualFile file = FileUriSupport.findFileByUri(uri, fileUriSupport); - if (file == null) { - return; - } - Document document = LSPIJUtils.getDocument(file); - if (document == null) { - return; - } - PsiFile psiFile = LSPIJUtils.getPsiFile(file, project); - if (psiFile == null) { - return; - } - textEdits - .forEach(textEdit -> { - TextRange textRange = LSPIJUtils.toTextRange(textEdit.getRange(), document); - var elt = new LSPRenamePsiElement(psiFile, textRange, textEdit); - allRenames.put(elt, textEdit.getNewText()); - }); - } - - @Override - public void renameElement(@NotNull PsiElement element, @NotNull String newName, UsageInfo @NotNull [] usages, @Nullable RefactoringElementListener listener) throws IncorrectOperationException { - if (element instanceof LSPRenamePsiElement) { - ((LSPRenamePsiElement) element).setName(newName); - ((LSPRenamePsiElement) element).rename(); - if (listener != null) { - listener.elementRenamed(element); - } - } - } - - @NotNull - private static RenameFilesParams createtRenameFilesParams(@NotNull PsiFile file, - @NotNull String newName, - @Nullable FileUriSupport fileUriSupport) { - String oldFileUri = FileUriSupport.toString(file.getVirtualFile(), fileUriSupport); - int index = oldFileUri.lastIndexOf('/'); - String newFileUri = oldFileUri.substring(0, index) + "/" + newName; - return new RenameFilesParams(List.of(new FileRename(oldFileUri, newFileUri))); - } - - -} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPGoToReferenceAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPGoToReferenceAction.java index 5088699f1..e31f1761c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPGoToReferenceAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPGoToReferenceAction.java @@ -68,7 +68,7 @@ protected CompletableFuture> getLocations(@NotNull PsiFile ps @Override protected boolean canSupportFeature(@NotNull LSPClientFeatures clientFeatures, @NotNull PsiFile file) { - return clientFeatures.getReferencesFeature().isReferencesSupported(file); + return clientFeatures.getReferencesFeature().isSupported(file); } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPReferenceSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPReferenceSupport.java index d0ecd6525..f08f2fcee 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPReferenceSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/references/LSPReferenceSupport.java @@ -84,6 +84,8 @@ private static CompletableFuture> getReferenceFor(@NotNull LS @NotNull CancellationSupport cancellationSupport) { // Update textDocument Uri with custom file Uri if needed updateTextDocumentUri(params.getTextDocument(), file, languageServer); + boolean includeDeclaration = languageServer.getClientFeatures().getReferencesFeature().isIncludeDeclaration(file); + params.getContext().setIncludeDeclaration(includeDeclaration); return cancellationSupport.execute(languageServer .getTextDocumentService() .references(params), languageServer, LSPRequestConstants.TEXT_DOCUMENT_REFERENCES) diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameRefactoringDialog.java b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameRefactoringDialog.java index 8571e5389..bf2f56dc9 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameRefactoringDialog.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameRefactoringDialog.java @@ -31,7 +31,6 @@ import com.redhat.devtools.lsp4ij.LSPFileSupport; import com.redhat.devtools.lsp4ij.LSPIJUtils; import com.redhat.devtools.lsp4ij.LanguageServerBundle; -import com.redhat.devtools.lsp4ij.features.refactoring.WorkspaceEditData; import com.redhat.devtools.lsp4ij.internal.CancellationUtil; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.usages.LSPExternalReferencesFinder; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameSupport.java index 2811d2fd7..3ce16da00 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/LSPRenameSupport.java @@ -14,7 +14,6 @@ import com.redhat.devtools.lsp4ij.LSPRequestConstants; import com.redhat.devtools.lsp4ij.LanguageServerItem; import com.redhat.devtools.lsp4ij.features.AbstractLSPDocumentFeatureSupport; -import com.redhat.devtools.lsp4ij.features.refactoring.WorkspaceEditData; import com.redhat.devtools.lsp4ij.internal.CancellationSupport; import com.redhat.devtools.lsp4ij.internal.CompletableFutures; import org.eclipse.lsp4j.RenameParams; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/WorkspaceEditData.java b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/WorkspaceEditData.java similarity index 74% rename from src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/WorkspaceEditData.java rename to src/main/java/com/redhat/devtools/lsp4ij/features/rename/WorkspaceEditData.java index 91ed6adce..a4ce2cdbb 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/refactoring/WorkspaceEditData.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/rename/WorkspaceEditData.java @@ -1,6 +1,6 @@ -package com.redhat.devtools.lsp4ij.features.refactoring; +package com.redhat.devtools.lsp4ij.features.rename; import org.eclipse.lsp4j.WorkspaceEdit; import com.redhat.devtools.lsp4ij.LanguageServerItem; public record WorkspaceEditData(WorkspaceEdit edit, LanguageServerItem languageServer ) { -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LSPSemanticTokensHighlightVisitor.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LSPSemanticTokensHighlightVisitor.java index 257894345..32d7699c3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LSPSemanticTokensHighlightVisitor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LSPSemanticTokensHighlightVisitor.java @@ -13,6 +13,8 @@ import com.intellij.codeInsight.daemon.impl.HighlightVisitor; import com.intellij.codeInsight.daemon.impl.analysis.HighlightInfoHolder; +import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.util.ProgressIndicatorUtils; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPFileSupport; @@ -20,7 +22,6 @@ import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.client.ExecuteLSPFeatureStatus; import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; -import com.redhat.devtools.lsp4ij.internal.PsiFileChangedException; import org.eclipse.lsp4j.SemanticTokensParams; import org.eclipse.lsp4j.TextDocumentIdentifier; import org.jetbrains.annotations.ApiStatus; @@ -31,23 +32,58 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.isDoneNormally; -import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; /** * LSP 'textDocument/semanticTokens' support by implementing IntelliJ {@link HighlightVisitor}. * *

    - * Implementing {@link HighlightVisitor} gives the capability to have an existing highlighter (custom highlighter, TextMate highlighter) - * and add semantic coloration. + * Implementing {@link HighlightVisitor} gives the capability to have an existing highlighter + * (custom highlighter, TextMate highlighter) and add semantic coloration. + *

    + * + *

    + * Threading note: {@link #analyze} is invoked inside a read action (via + * {@code GeneralHighlightingPass} → {@code DumbService.runReadActionInSmartMode}). + * We must not hold the read lock while blocking on the LSP future, otherwise any + * pending write action (e.g. opening the Git Push dialog) will be blocked on the + * EDT, causing a visible UI freeze. + *
    + * We use {@link ProgressIndicatorUtils#awaitWithCheckCanceled} instead of the + * previous {@code CompletableFutures.waitUntilDone}, because it cooperates with + * IntelliJ's read/write lock protocol: it yields to write actions by throwing + * {@link ProcessCanceledException} when the current progress indicator is + * cancelled, allowing the framework to release the read lock and reschedule + * the highlighting pass once the write action completes. + *

    + * + *

    + * Stale token protection — two layers: + *

      + *
    1. Stamp check (primary): {@link #getSemanticTokens} captures the file's + * modification stamp before awaiting the LSP future and returns {@code null} if + * the stamp changed while waiting. The stamp is then threaded through to + * {@link #highlightSemanticTokens}, which re-checks it immediately before + * applying any highlights. If the document changed in the narrow window between + * those two points the entire highlighting pass is cancelled — no stale token + * is ever applied.
    2. + *
    3. Defensive guards (safety net): {@link SemanticTokensData#highlight} and + * {@link LazyHighlightInfo#resolve} both validate that computed offsets are + * within the current document bounds before creating a + * {@link com.intellij.codeInsight.daemon.impl.HighlightInfo}. + * This covers the rare race where a write action lands in the narrow window + * between the two stamp checks (e.g. between the document-length snapshot in + * {@code SemanticTokensData} and the array allocation in + * {@code highlightSemanticTokens}).
    4. + *
    + * In both cases IntelliJ will reschedule the highlighting pass and the next pass + * will produce correct results. *

    */ @ApiStatus.Internal public class LSPSemanticTokensHighlightVisitor implements HighlightVisitor { - private static final Logger LOGGER = LoggerFactory.getLogger(LSPSemanticTokensHighlightVisitor.class); private @Nullable SemanticTokensData semanticTokens; @@ -61,25 +97,31 @@ public boolean suitableForFile(@NotNull PsiFile file) { private LazyHighlightInfo[] lazyInfos; @Override - public boolean analyze(@NotNull PsiFile file, boolean updateWholeFile, @NotNull HighlightInfoHolder holder, @NotNull Runnable action) { + public boolean analyze(@NotNull PsiFile file, + boolean updateWholeFile, + @NotNull HighlightInfoHolder holder, + @NotNull Runnable action) { if (ProjectIndexingManager.canExecuteLSPFeature(file) != ExecuteLSPFeatureStatus.NOW) { return true; } try { this.lazyInfos = null; this.holder = null; - this.semanticTokens = getSemanticTokens(file); - if (semanticTokens != null) { - if (!semanticTokens.shouldVisitPsiElement(file)) { - // the PsiFile must highlight the file without using HighlightVisitor#visit(PsiElement) - // ex : file is a TextMate, PlainText file. - highlightSemanticTokens(file, semanticTokens, holder); + this.semanticTokens = null; + + SemanticTokensResult result = getSemanticTokens(file); + if (result != null) { + this.semanticTokens = result.data(); + if (!result.data().shouldVisitPsiElement(file)) { + // The PsiFile must highlight without using HighlightVisitor#visit(PsiElement) + // e.g. TextMate or PlainText files. + highlightSemanticTokens(file, result, holder); this.lazyInfos = null; this.holder = null; } else { - // The PsiFile is a custom PsiFile with proper tokenization of PsiElements - // the file must be highlighted with by using HighlightVisitor#visit(PsiElement) - this.lazyInfos = highlightSemanticTokens(file, semanticTokens, null); + // The PsiFile is a custom PsiFile with proper tokenization of PsiElements; + // highlighting is driven by HighlightVisitor#visit(PsiElement). + this.lazyInfos = highlightSemanticTokens(file, result, null); this.holder = holder; } } @@ -105,62 +147,170 @@ public void visit(@NotNull PsiElement element) { for (int i = start; i < end && i < lazyInfos.length; i++) { var info = lazyInfos[i]; if (info != null) { - holder.add(info.resolve(i)); + // resolve() returns null when the stored end offset is invalid (defensive + // guard for the rare race described in the class Javadoc). Skip silently. + var highlight = info.resolve(i); + if (highlight != null) { + holder.add(highlight); + } lazyInfos[i] = null; } } } - private static @Nullable SemanticTokensData getSemanticTokens(@NotNull PsiFile file) { - // Consume LSP 'textDocument/semanticTokens/full' request + /** + * Pairs {@link SemanticTokensData} with the file modification stamp captured at + * fetch time. Threading the stamp through to {@link #highlightSemanticTokens} + * allows a second staleness check right before highlights are applied, without + * an extra {@code getModificationStamp()} call. + */ + private record SemanticTokensResult(@NotNull SemanticTokensData data, long modificationStamp) {} + + /** + * Fetches semantic tokens from the LSP server for the given file. + * + *

    + * Why {@link ProgressIndicatorUtils#awaitWithCheckCanceled} and not + * {@code CompletableFutures.waitUntilDone}? + *
    + * This method is called from {@link #analyze}, which runs inside a read action. + * The old {@code waitUntilDone} polled {@code future.get(25ms)} in a tight loop + * while holding the read lock for the entire duration. If the LSP server was slow + * to respond, any write action (e.g. opening the Git Push dialog via + * {@code EditorTextField.setText}) would be stuck waiting for the read lock on + * the EDT, causing a UI freeze (see issue #1437). + *
    + * {@code ProgressIndicatorUtils.awaitWithCheckCanceled} integrates with + * IntelliJ's concurrency model: when a write action is requested, the current + * progress indicator is cancelled → ProcessCanceledException is thrown → + * the read lock is released → the write action can proceed immediately. + *
    + * IntelliJ will reschedule the GeneralHighlightingPass (and therefore this + * visitor) once the write action completes, so semantic tokens will still be + * applied — just after a short delay. + *

    + * + * @param file the PSI file for which semantic tokens are requested + * @return the semantic tokens data paired with the modification stamp at fetch + * time, or {@code null} if unavailable or stale + * @throws ProcessCanceledException if the progress indicator is cancelled + * (e.g. a write action is pending); the caller + * (IntelliJ's highlighting framework) handles + * the reschedule automatically + */ + private static @Nullable SemanticTokensResult getSemanticTokens(@NotNull PsiFile file) { LSPSemanticTokensSupport semanticTokensSupport = LSPFileSupport.getSupport(file).getSemanticTokensSupport(); var params = new SemanticTokensParams(new TextDocumentIdentifier()); CompletableFuture semanticTokensFuture = semanticTokensSupport.getSemanticTokens(params); + + // Capture the modification stamp before waiting so we can detect file edits + // that occurred while the LSP server was responding. + final long modificationStampBefore = file.getModificationStamp(); + try { - waitUntilDone(semanticTokensFuture, file); - } catch (PsiFileChangedException e) { - semanticTokensSupport.cancel(); - return null; + // Wait for the LSP future while cooperating with IntelliJ's lock model. + // See class Javadoc for a detailed explanation of why this is safe. + ProgressIndicatorUtils.awaitWithCheckCanceled(semanticTokensFuture); + + } catch (ProcessCanceledException e) { + // A write action requested priority, or the pass was cancelled for another + // reason (e.g. the editor was closed). Do NOT cancel the LSP future here: + // the server response may arrive before the next rescheduled pass, in which + // case it will be served from the support's internal cache at no extra cost. + throw e; + } catch (CancellationException e) { - // cancel the LSP requests textDocument/semanticTokens/full - //semanticTokensSupport.cancel(); + // The CompletableFuture itself was cancelled (e.g. the file was closed, + // or the language server shut down). Nothing to do — propagate as-is. throw e; - } catch (ExecutionException e) { - LOGGER.error("Error while consuming LSP 'textDocument/semanticTokens/full' request", e); + } + + // Primary staleness check: if the file was modified while we were waiting, + // the tokens no longer match the document. Discard them and let the next + // highlighting pass request fresh ones. + if (file.getModificationStamp() != modificationStampBefore) { + semanticTokensSupport.cancel(); return null; } if (isDoneNormally(semanticTokensFuture)) { - return semanticTokensFuture.getNow(null); + SemanticTokensData data = semanticTokensFuture.getNow(null); + if (data != null) { + // Return the data together with the stamp so highlightSemanticTokens() + // can perform its own staleness check before applying any highlights. + return new SemanticTokensResult(data, modificationStampBefore); + } } return null; } - private static LazyHighlightInfo[] highlightSemanticTokens(@NotNull PsiFile file, - @NotNull SemanticTokensData semanticTokens, - @Nullable HighlightInfoHolder holder) { - - // textDocument/semanticTokens/full has been collected correctly, create list of IJ HighlightInfo from LSP SemanticTokens data + /** + * Applies semantic token highlights to the given file. + * + *

    The modification stamp carried by {@code result} is re-checked immediately + * before any highlight is created. If the file was edited in the narrow window + * between {@link #getSemanticTokens} returning and this method being called, the + * tokens are already stale and the entire pass is cancelled — no partial or + * incorrect highlights are applied. The defensive guards in + * {@link SemanticTokensData#highlight} and {@link LazyHighlightInfo#resolve} cover + * the even narrower race where a write action arrives mid-highlight.

    + * + * @param file the PSI file + * @param result the tokens + stamp returned by {@link #getSemanticTokens} + * @param holder the highlight holder, or {@code null} for the lazy (PSI-driven) path + * @return a {@link LazyHighlightInfo} array indexed by start offset (lazy path), + * or {@code null} (direct path or stale tokens) + */ + private static @Nullable LazyHighlightInfo[] highlightSemanticTokens(@NotNull PsiFile file, + @NotNull SemanticTokensResult result, + @Nullable HighlightInfoHolder holder) { var document = LSPIJUtils.getDocument(file.getVirtualFile()); if (document == null) { return null; } + + // Secondary staleness check: cancel the entire pass if the document changed + // between getSemanticTokens() and now. Applying tokens from a previous document + // version would produce inverted or out-of-bounds offsets and cause + // IllegalArgumentException inside HighlightInfo (see issue #XXX). + if (file.getModificationStamp() != result.modificationStamp()) { + return null; + } + if (holder != null) { - semanticTokens.highlight(file, document, (start, end, colorKey) -> - holder.add(LazyHighlightInfo.resolve(start, end, colorKey))); + // Direct highlight path (TextMate / PlainText files). + // LazyHighlightInfo.resolve() returns null for invalid ranges (defensive + // guard for the rare race described in the class Javadoc); skip those. + result.data().highlight(file, document, (start, end, colorKey) -> { + var info = LazyHighlightInfo.resolve(start, end, colorKey); + if (info != null) { + holder.add(info); + } + }); return null; } else { + // Lazy highlight path (custom PSI files): one LazyHighlightInfo per start + // offset; visit() resolves them per PsiElement. + // + // The array size is snapshotted here. A write action arriving after this + // line could shrink the document; the guard in SemanticTokensData.highlight() + // rejects out-of-bounds tokens, and the bounds check below covers the + // remaining window between that snapshot and the array allocation. var infos = new LazyHighlightInfo[document.getTextLength()]; - semanticTokens.highlight(file, document, (start, end, colorKey) -> - infos[start] = new LazyHighlightInfo(end, colorKey)); + result.data().highlight(file, document, (start, end, colorKey) -> { + // SemanticTokensData validated start/end against its own docLength + // snapshot. This guard covers the window between that snapshot and the + // infos[] allocation above. + if (start < infos.length && end <= infos.length) { + infos[start] = new LazyHighlightInfo(end, colorKey); + } + }); return infos; } } - @Override public @NotNull HighlightVisitor clone() { return new LSPSemanticTokensHighlightVisitor(); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LazyHighlightInfo.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LazyHighlightInfo.java index 6d08d1757..54f04540a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LazyHighlightInfo.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/LazyHighlightInfo.java @@ -13,6 +13,7 @@ import com.intellij.codeInsight.daemon.impl.HighlightInfo; import com.intellij.openapi.editor.colors.TextAttributesKey; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import static com.intellij.codeHighlighting.RainbowHighlighter.RAINBOW_ELEMENT; @@ -23,15 +24,35 @@ public interface Consumer { void accept(int start, int end, TextAttributesKey colorKey); } - public HighlightInfo resolve(int start) { + public @Nullable HighlightInfo resolve(int start) { return resolve(start, end, colorKey); } - public static HighlightInfo resolve(int start, int end, @NotNull TextAttributesKey colorKey) { + /** + * Creates a {@link HighlightInfo} for the given range and color key. + * + *

    Returns {@code null} (instead of throwing {@link IllegalArgumentException}) when the + * range is invalid. This can happen when the document is mutated between the moment semantic + * tokens are received from the LSP server and the moment IntelliJ applies the highlights: + * a concurrent edit may shift or invalidate offsets, producing a start ≥ end situation. + * Returning {@code null} is safe here — the highlight is simply skipped for this pass, and + * the next highlighting pass will request fresh tokens with correct offsets.

    + * + * @param start the start offset (inclusive) + * @param end the end offset (exclusive) + * @param colorKey the text attributes key to apply + * @return a {@link HighlightInfo}, or {@code null} if the range is invalid + */ + public static @Nullable HighlightInfo resolve(int start, int end, @NotNull TextAttributesKey colorKey) { + // Guard against inverted or zero-length ranges caused by stale/desynchronised + // semantic token offsets (document edited while LSP response was in flight). + if (start < 0 || start >= end) { + return null; + } return HighlightInfo .newHighlightInfo(RAINBOW_ELEMENT) .range(start, end) .textAttributes(colorKey) .create(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/SemanticTokensData.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/SemanticTokensData.java index 7b5cd615d..c21bfbbb2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/SemanticTokensData.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/SemanticTokensData.java @@ -68,7 +68,6 @@ public SemanticTokensData(@NotNull SemanticTokens semanticTokens, public void highlight(@NotNull PsiFile file, @NotNull Document document, @NotNull LazyHighlightInfo.Consumer addInfo) { - // Try to populate the file's view provider with these tokens if possible LSPSemanticTokensFileViewProvider semanticTokensFileViewProvider = LSPSemanticTokensFileViewProvider.getInstance(file); var inspector = SemanticTokensInspectorManager.getInstance(file.getProject()); @@ -81,6 +80,16 @@ public void highlight(@NotNull PsiFile file, return; } + // Snapshot the document length once at the start of the highlight pass. + // + // Semantic tokens were computed against the document as it was when the LSP + // request was sent. Even though LSPSemanticTokensHighlightVisitor performs a + // stamp check before calling this method, a write action can still arrive in + // the narrow window between that check and this point. Using a single snapshot + // here ensures all token ranges are validated against a consistent length and + // prevents any single token from causing an out-of-bounds HighlightInfo. + int docLength = document.getTextLength(); + int idx = 0; int prevLine = 0; int line = 0; @@ -118,12 +127,29 @@ public void highlight(@NotNull PsiFile file, List tokenModifiers = tokenModifiers(data, semanticTokensLegend.getTokenModifiers()); int start = offset; int end = offset + length; + + // Guard: skip tokens whose computed range is invalid or extends + // beyond the current document length. + // + // - start >= end : should never happen per the LSP spec, but + // defended against for robustness. + // - end > docLength: the token extends beyond the document, which + // would cause HighlightInfo to throw + // IllegalArgumentException. This can happen when + // a write action arrives between the stamp check + // in LSPSemanticTokensHighlightVisitor and this + // method being called. + // In both cases, skip the token silently. The next highlighting + // pass will fetch fresh tokens for the updated document content. + if (start < 0 || start >= end || end > docLength) { + break; + } + TextAttributesKey colorKey = tokenType != null ? semanticTokensColorsProvider.getTextAttributesKey(tokenType, tokenModifiers, file) : null; if (colorKey != null) { addInfo.accept(start, end, colorKey); } - // If this file uses a view provider based on semantic tokens, add this one if (semanticTokensFileViewProvider != null) { semanticTokensFileViewProvider.addSemanticToken(TextRange.create(start, end), tokenType, tokenModifiers); } @@ -184,4 +210,4 @@ public boolean shouldVisitPsiElement(@NotNull PsiFile file) { public boolean isEligibleForSemanticHighlighting(@NotNull PsiElement element) { return semanticTokensFeature.isEligibleForSemanticHighlighting(element); } -} +} \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticToken.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticToken.java index 7f1e3b491..148434b42 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticToken.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticToken.java @@ -209,6 +209,20 @@ ThreeState isType() { return ThreeState.UNSURE; } + //OPEN IDE BEGIN + @NotNull + ThreeState isFunction() { + if (tokenType != null) { + if (SemanticTokenTypes.Function.equals(tokenType)) { + return ThreeState.YES; + } else if (ALL_TOKEN_TYPES.contains(tokenType)) { + return ThreeState.NO; + } + } + return ThreeState.UNSURE; + } + //OPEN IDE END + @NotNull List getTokenModifiers() { return tokenModifiers; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokenDocumentationProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokenDocumentationProvider.java index 6b1c7f9eb..bbd13025f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokenDocumentationProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokenDocumentationProvider.java @@ -92,7 +92,11 @@ private static String getDocumentation(@Nullable PsiElement sourceElement, } } - return getFallbackDocumentation(sourceElement, targetElement); + // Don't return result of getFallbackDocumentation because LSPSemanticTokenDocumentationProvider could be + // process before some other provider which will generate a proper documentation + // see https://github.com/redhat-developer/lsp4ij/issues/1240 + // return getFallbackDocumentation(sourceElement, targetElement); + return null; } @Nullable diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensContainer.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensContainer.java index 4a76b2d81..4733c1f72 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensContainer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensContainer.java @@ -124,6 +124,19 @@ interface LSPSemanticTokensContainer { @NotNull ThreeState isType(int offset); + //OPEN IDE BEGIN + /** + * Whether or not the semantic token at the offset is for a function (not a method). + * + * @param offset the offset + * @return {@link ThreeState#YES} if the semantic token at the offset is conclusively for a function; + * {@link ThreeState#NO} if the semantic token at the offset is conclusively not for a function; + * otherwise {@link ThreeState#UNSURE} + */ + @NotNull + ThreeState isFunction(int offset); + //OPEN IDE END + /** * Whether or not the offset is for whitespace. * diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensFileViewProviderHelper.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensFileViewProviderHelper.java index bceef13c7..d7ba8c76a 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensFileViewProviderHelper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensFileViewProviderHelper.java @@ -127,6 +127,15 @@ public ThreeState isType(int offset) { return (semanticToken != null) ? semanticToken.isType() : ThreeState.UNSURE; } + //OPEN IDE BEGIN + @Override + @NotNull + public ThreeState isFunction(int offset) { + LSPSemanticToken semanticToken = getSemanticToken(offset); + return (semanticToken != null) ? semanticToken.isFunction() : ThreeState.UNSURE; + } + //OPEN IDE END + @Override public boolean isWhitespace(int offset) { PsiFile file = getFile(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensSingleRootFileViewProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensSingleRootFileViewProvider.java index 633f2de3a..42ae46d17 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensSingleRootFileViewProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensSingleRootFileViewProvider.java @@ -129,6 +129,13 @@ public ThreeState isType(int offset) { return helper.isType(offset); } + //OPEN IDE BEGIN + @Override + public @NotNull ThreeState isFunction(int offset) { + return helper.isFunction(offset); + } + //OPEN IDE END + @Override public boolean isWhitespace(int offset) { return helper.isWhitespace(offset); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java index a31053240..e81958125 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/semanticTokens/viewProvider/LSPSemanticTokensStructurelessFileViewProvider.java @@ -12,10 +12,14 @@ package com.redhat.devtools.lsp4ij.features.semanticTokens.viewProvider; import com.intellij.lang.Language; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.PsiReference; +import com.redhat.devtools.lsp4ij.LSPIJUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -56,25 +60,70 @@ public boolean supportsIncrementalReparse(@NotNull Language rootLanguage) { @Nullable private PsiElement getSemanticTokenElement(int offset) { LSPSemanticToken semanticToken = isEnabled() ? getSemanticToken(offset) : null; - return semanticToken != null ? semanticToken.getElement() : null; + // Only a concrete (non file-level) token carries the reference/declaration info needed by + // hover and go-to-declaration. The file-level stub spans the whole file, so it's handled by + // the narrow-element fallback below instead. + return (semanticToken != null && !semanticToken.isFileLevel()) ? semanticToken.getElement() : null; + } + + /** + * Returns the element to expose at {@code offset} when no concrete semantic token is available. + *

    + * TextMate/plain-text files are structureless: {@code super.findElementAt()} returns a single leaf + * element spanning the ENTIRE file (empty lexer + trivial parser), and the file-level stub token + * does the same. Neither can be exposed verbatim: + *

      + *
    • Returning the whole-file element makes {@code UsagePreviewPanel#getNameElementTextRange} + * highlight the entire file in Find Usages.
    • + *
    • Returning {@code null} makes {@code EditorMouseHoverPopupManager#createContext} skip the + * documentation popup (so {@code textDocument/hover} is never sent) and leaves + * {@code GotoDeclarationHandler} without a source element (so go-to-declaration never runs).
    • + *
    + * So we synthesize a NON-null element with the word range at the offset (or {@code null} on + * whitespace/punctuation, where hover/navigation shouldn't trigger anyway). + */ + @Nullable + private PsiElement narrowElementAt(int offset, @Nullable PsiElement superElement) { + PsiFile psiFile = getPsi(getBaseLanguage()); + // A real (sub-file) super element is fine to return as-is. + if (superElement != null && (psiFile == null || !superElement.getTextRange().equals(psiFile.getTextRange()))) { + return superElement; + } + if (psiFile == null) { + return null; + } + Document document = LSPIJUtils.getDocument(psiFile.getVirtualFile()); + if (document == null) { + return null; + } + TextRange wordRange = LSPIJUtils.getWordRangeAt(document, psiFile, offset); + if (wordRange == null) { + return null; + } + // Build a synthetic (unknown-type) semantic-token element scoped to the word at the offset. + // Using a real LSPSemanticTokenPsiElement (rather than a whole-file stub or a bare + // LSPPsiElement) keeps it consistent with the platform symbol model and LSP4IJ's own + // documentation provider, so the hover popup is built while Find Usages highlights only the + // word — its range always contains the offset, so DeclarationOrReference#rangeWithOffset holds. + return new LSPSemanticToken(psiFile, wordRange, null, null).getElement(); } @Override public PsiElement findElementAt(int offset) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : super.findElementAt(offset); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset)); } @Override public PsiElement findElementAt(int offset, @NotNull Class lang) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : super.findElementAt(offset, lang); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset, lang)); } @Override public PsiElement findElementAt(int offset, @NotNull Language language) { PsiElement element = getSemanticTokenElement(offset); - return element != null ? element : super.findElementAt(offset, language); + return element != null ? element : narrowElementAt(offset, super.findElementAt(offset, language)); } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/signatureHelp/LSPParameterInfoHandler.java b/src/main/java/com/redhat/devtools/lsp4ij/features/signatureHelp/LSPParameterInfoHandler.java index 4bcd5984b..f2c31cb2e 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/signatureHelp/LSPParameterInfoHandler.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/signatureHelp/LSPParameterInfoHandler.java @@ -144,7 +144,8 @@ public void updateParameterInfo(@NotNull LSPSignatureHelperPsiElement psiElement public void updateUI(SignatureInformation signatureInformation, @NotNull ParameterInfoUIContext context) { int currentParameter = context.getCurrentParameterIndex(); List parameters = signatureInformation.getParameters(); - int numParams = parameters.size(); + // parameters is optional per the LSP spec; gopls may omit it (e.g. functions without params) + int numParams = parameters != null ? parameters.size() : 0; StringBuilder html = new StringBuilder(numParams * 8); int highlightStartOffset = -1; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/typeDefinition/LSPGoToTypeDefinitionAction.java b/src/main/java/com/redhat/devtools/lsp4ij/features/typeDefinition/LSPGoToTypeDefinitionAction.java index e0113e89f..48268e508 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/typeDefinition/LSPGoToTypeDefinitionAction.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/typeDefinition/LSPGoToTypeDefinitionAction.java @@ -68,7 +68,7 @@ protected CompletableFuture> getLocations(@NotNull PsiFile ps @Override protected boolean canSupportFeature(@NotNull LSPClientFeatures clientFeatures, @NotNull PsiFile file) { - return clientFeatures.getTypeDefinitionFeature().isTypeDefinitionSupported(file); + return clientFeatures.getTypeDefinitionFeature().isSupported(file); } @Override diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/BaseWorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/BaseWorkspaceFolderStrategy.java new file mode 100644 index 000000000..f51c0cc91 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/BaseWorkspaceFolderStrategy.java @@ -0,0 +1,238 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +import com.intellij.openapi.module.Module; +import com.intellij.openapi.module.ModuleManager; +import com.intellij.openapi.project.BaseProjectDirectories; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ModuleRootManager; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * Base workspace folder strategy with configurable root type, lazy loading, and markers. + */ +public abstract class BaseWorkspaceFolderStrategy implements WorkspaceFolderStrategy { + + protected RootType rootType = RootType.PROJECT_BASE; + protected boolean lazy = false; + protected List markers = null; + + protected void setLazy(boolean lazy) { + this.lazy = lazy; + } + + protected void setRootType(@NotNull RootType rootType) { + this.rootType = rootType; + } + + protected void setMarkers(@Nullable List markers) { + this.markers = markers; + } + + public boolean isLazy() { + return lazy; + } + + @Nullable + public List getMarkers() { + return markers; + } + + @NotNull + public RootType getRootType() { + return rootType; + } + + /** + * Returns the roots based on the configured root type. + * Can be overridden for custom root discovery logic. + * + * @param project the project + * @return the list of root directories + */ + @NotNull + public List getRoots(@NotNull Project project) { + return switch (rootType) { + case SOURCE_ROOTS -> getModuleSourceRoots(project); + case PROJECT_BASE -> new ArrayList<>(BaseProjectDirectories.Companion.getBaseDirectories(project)); + default -> Collections.emptyList(); + }; + } + + /** + * Returns all module source roots in the project. + * + * @param project the project + * @return the list of source roots + */ + @NotNull + public List getModuleSourceRoots(@NotNull Project project) { + List roots = new ArrayList<>(); + ModuleManager moduleManager = ModuleManager.getInstance(project); + for (Module module : moduleManager.getModules()) { + ModuleRootManager rootManager = ModuleRootManager.getInstance(module); + roots.addAll(Arrays.asList(rootManager.getSourceRoots())); + } + return roots; + } + + @Override + public final boolean sendAllFoldersOnInitialization() { + return !isLazy() && (getMarkers() == null || getMarkers().isEmpty()); + } + + @NotNull + @Override + public List getWorkspaceFolders(@NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + List roots = getRoots(project); + return toWorkspaceFolders(roots, fileUriSupport); + } + + @NotNull + @Override + public List getInitialWorkspaceFolders(@NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + if (!sendAllFoldersOnInitialization()) { + return Collections.emptyList(); + } + return getWorkspaceFolders(project, fileUriSupport); + } + + @Nullable + @Override + public WorkspaceFolder getWorkspaceFolderForFile(@NotNull VirtualFile file, + @NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + // Only use NIO Path optimization for local files + boolean isLocal = file.isInLocalFileSystem(); + List markers = getMarkers(); + + if (markers != null && !markers.isEmpty()) { + if (isLocal) { + // Marker-based discovery: walk up the directory tree looking for marker files + // Use NIO Path for better performance instead of VirtualFile.findChild() + Path filePath = Paths.get(file.getPath()); + Path parentPath = filePath.getParent(); + + while (parentPath != null) { + for (String marker : markers) { + Path markerPath = parentPath.resolve(marker); + if (Files.exists(markerPath)) { + // Found a marker, this directory is a workspace folder + // Convert back to VirtualFile for creating workspace folder + VirtualFile parentVFile = LocalFileSystem.getInstance() + .findFileByPath(parentPath.toString()); + if (parentVFile != null) { + return createWorkspaceFolder(parentVFile, fileUriSupport); + } + } + } + parentPath = parentPath.getParent(); + } + } else { + // Fallback to VirtualFile API for non-local files + VirtualFile parent = file.getParent(); + while (parent != null) { + for (String marker : markers) { + VirtualFile markerFile = parent.findChild(marker); + if (markerFile != null) { + return createWorkspaceFolder(parent, fileUriSupport); + } + } + parent = parent.getParent(); + } + } + } else { + // Find the closest root containing this file + List rootsList = getRoots(project); + + if (isLocal) { + // Use NIO Path for better performance with startsWith() instead of walking up + Path filePath = Paths.get(file.getPath()).normalize(); + + // Find the deepest (most specific) root that contains this file + VirtualFile bestMatch = null; + int maxDepth = -1; + + for (VirtualFile root : rootsList) { + if (root.isInLocalFileSystem()) { + Path rootPath = Paths.get(root.getPath()).normalize(); + if (filePath.startsWith(rootPath)) { + int depth = rootPath.getNameCount(); + if (depth > maxDepth) { + maxDepth = depth; + bestMatch = root; + } + } + } + } + + if (bestMatch != null) { + return createWorkspaceFolder(bestMatch, fileUriSupport); + } + } else { + // Fallback to VirtualFile API for non-local files + // Convert to Set for O(1) lookup + Set roots = new HashSet<>(rootsList); + VirtualFile parent = file.getParent(); + while (parent != null) { + if (roots.contains(parent)) { + return createWorkspaceFolder(parent, fileUriSupport); + } + parent = parent.getParent(); + } + } + } + + return null; + } + + @Nullable + protected WorkspaceFolder createWorkspaceFolder(@NotNull VirtualFile file, + @NotNull FileUriSupport fileUriSupport) { + String workspaceUri = FileUriSupport.toString(file, fileUriSupport); + if (workspaceUri != null) { + WorkspaceFolder folder = new WorkspaceFolder(); + folder.setUri(workspaceUri); + folder.setName(file.getName()); + return folder; + } + return null; + } + + @NotNull + protected List toWorkspaceFolders(@NotNull List roots, + @NotNull FileUriSupport fileUriSupport) { + List workspaceFolders = new ArrayList<>(roots.size()); + for (VirtualFile root : roots) { + WorkspaceFolder folder = createWorkspaceFolder(root, fileUriSupport); + if (folder != null) { + workspaceFolders.add(folder); + } + } + return workspaceFolders; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java new file mode 100644 index 000000000..96771326c --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ConfigurableWorkspaceFolderStrategy.java @@ -0,0 +1,132 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Configurable workspace folder strategy that can be customized via JSON. + * + *

    This strategy supports lazy loading and marker-based discovery. + * Configuration examples: + *

    + * // Project base directories, send all at initialization (default)
    + * {}
    + *
    + * // Project base directories with lazy loading
    + * { "lazy": true }
    + *
    + * // Source roots with lazy loading
    + * { "rootType": "SOURCE_ROOTS", "lazy": true }
    + *
    + * // Marker-based discovery - find folders by walking up to marker files
    + * { "markers": [".git", "pyproject.toml", "pom.xml"] }
    + *
    + * // No workspace folders
    + * { "rootType": "NONE" }
    + * 
    + *

    + */ +public class ConfigurableWorkspaceFolderStrategy extends BaseWorkspaceFolderStrategy { + + private static final String PROP_ROOT_TYPE = "rootType"; + private static final String PROP_LAZY = "lazy"; + private static final String PROP_MARKERS = "markers"; + + private final Gson gson = new Gson(); + + public void configure(@Nullable String jsonContent) { + if (jsonContent == null || jsonContent.trim().isEmpty()) { + // Reset to defaults + setRootType(RootType.PROJECT_BASE); + setLazy(false); + setMarkers(null); + return; + } + + try { + JsonObject config = gson.fromJson(jsonContent, JsonObject.class); + if (config != null) { + // If markers are specified, force markers mode + if (config.has(PROP_MARKERS)) { + List markersList = new ArrayList<>(); + config.getAsJsonArray(PROP_MARKERS).forEach(element -> markersList.add(element.getAsString())); + setMarkers(markersList); + setRootType(RootType.MARKERS); + + // Configure lazy if specified, default to true for markers + if (config.has(PROP_LAZY)) { + setLazy(config.get(PROP_LAZY).getAsBoolean()); + } else { + setLazy(true); // Default to lazy for markers + } + } else { + // Not in markers mode, reset markers + setMarkers(null); + + // Configure rootType if specified, otherwise reset to default + if (config.has(PROP_ROOT_TYPE)) { + String rootTypeStr = config.get(PROP_ROOT_TYPE).getAsString(); + try { + setRootType(RootType.valueOf(rootTypeStr)); + } catch (IllegalArgumentException e) { + // Invalid rootType, reset to default + setRootType(RootType.PROJECT_BASE); + } + } else { + // No rootType specified, reset to default + setRootType(RootType.PROJECT_BASE); + } + + // Configure lazy if specified, otherwise reset to default + if (config.has(PROP_LAZY)) { + setLazy(config.get(PROP_LAZY).getAsBoolean()); + } else { + // No lazy specified, reset to default + setLazy(false); + } + } + } + } catch (JsonSyntaxException e) { + // Keep current configuration on error + } + } + + @NotNull + public String getJsonConfiguration() { + JsonObject config = new JsonObject(); + + if (rootType == RootType.MARKERS && markers != null && !markers.isEmpty()) { + // Markers mode + config.add(PROP_MARKERS, gson.toJsonTree(markers)); + } else { + // Standard mode + if (rootType != RootType.PROJECT_BASE) { + config.addProperty(PROP_ROOT_TYPE, rootType.name()); + } + if (lazy) { + config.addProperty(PROP_LAZY, true); + } + } + + return config.size() == 0 ? "{}" : gson.toJson(config); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/MarkersWorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/MarkersWorkspaceFolderStrategy.java new file mode 100644 index 000000000..c811fbc64 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/MarkersWorkspaceFolderStrategy.java @@ -0,0 +1,37 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +import java.util.Arrays; +import java.util.List; + +/** + * Workspace folder strategy based on marker files/directories. + * + *

    This strategy discovers workspace folders dynamically by walking up the directory tree + * looking for marker files like .git, pyproject.toml, pom.xml, etc. + * Folders are always discovered lazily as files are opened.

    + */ +public class MarkersWorkspaceFolderStrategy extends BaseWorkspaceFolderStrategy { + + public MarkersWorkspaceFolderStrategy(String... markers) { + this(Arrays.asList(markers)); + } + + public MarkersWorkspaceFolderStrategy(List markers) { + setRootType(RootType.MARKERS); + setLazy(true); + setMarkers(markers); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java new file mode 100644 index 000000000..4544acd81 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/ProjectWorkspaceFolderStrategy.java @@ -0,0 +1,33 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +/** + * Default workspace folder strategy based on project base directories. + * + *

    This strategy uses the IntelliJ project's base directories as workspace folders. + * All folders are sent during initialization.

    + */ +public class ProjectWorkspaceFolderStrategy extends BaseWorkspaceFolderStrategy { + + public ProjectWorkspaceFolderStrategy() { + this(false); + } + + public ProjectWorkspaceFolderStrategy(boolean lazy) { + setRootType(RootType.PROJECT_BASE); + setLazy(lazy); + setMarkers(null); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/RootType.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/RootType.java new file mode 100644 index 000000000..b8c1f07cb --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/RootType.java @@ -0,0 +1,39 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +/** + * Type of roots to use for workspace folders. + */ +public enum RootType { + /** + * Use project base directories. + */ + PROJECT_BASE, + + /** + * Use module source roots. + */ + SOURCE_ROOTS, + + /** + * Discover folders dynamically using marker files. + */ + MARKERS, + + /** + * No workspace folders. + */ + NONE +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/SourceRootsWorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/SourceRootsWorkspaceFolderStrategy.java new file mode 100644 index 000000000..e6c79be47 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/SourceRootsWorkspaceFolderStrategy.java @@ -0,0 +1,33 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +/** + * Workspace folder strategy based on module source roots. + * + *

    This strategy uses the IntelliJ module source roots as workspace folders. + * All folders are sent during initialization.

    + */ +public class SourceRootsWorkspaceFolderStrategy extends BaseWorkspaceFolderStrategy { + + public SourceRootsWorkspaceFolderStrategy() { + this(false); + } + + public SourceRootsWorkspaceFolderStrategy(boolean lazy) { + setRootType(RootType.SOURCE_ROOTS); + setLazy(lazy); + setMarkers(null); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategy.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategy.java new file mode 100644 index 000000000..a40bae51e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategy.java @@ -0,0 +1,104 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +import com.intellij.openapi.extensions.ExtensionPointName; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.List; + +/** + * Strategy for determining workspace folders for a language server. + * + *

    Implementations of this interface define how workspace folders are discovered + * and reported to the language server. Different strategies can be used for different + * language servers or project setups.

    + * + *

    Implementations can be registered via the extension point: + * {@code com.redhat.devtools.lsp4ij.workspaceFolderStrategy}

    + */ +@ApiStatus.Experimental +public interface WorkspaceFolderStrategy { + + ExtensionPointName EP_NAME = ExtensionPointName.create("com.redhat.devtools.lsp4ij.workspaceFolderStrategy"); + + /** + * Returns the default workspace folder strategy (project-based). + * + * @return the default workspace folder strategy + */ + @NotNull + static WorkspaceFolderStrategy getDefault() { + return new ProjectWorkspaceFolderStrategy(); + } + + /** + * Returns true if this strategy sends all workspace folders during initialization, + * false if folders are sent dynamically as files are opened. + * + * @return true if all folders are sent at initialization + */ + boolean sendAllFoldersOnInitialization(); + + /** + * Returns the list of workspace folders for the given project. + * This represents all potential workspace folders, regardless of lazy loading. + * Used for UI display. + * + * @param project the project + * @param fileUriSupport file URI support for converting files to URIs + * @return the list of workspace folders + */ + @NotNull + List getWorkspaceFolders(@NotNull Project project, + @NotNull FileUriSupport fileUriSupport); + + /** + * Returns the list of workspace folders to send during initialization. + * In lazy mode, this returns an empty list. + * + * @param project the project + * @param fileUriSupport file URI support for converting files to URIs + * @return the list of workspace folders for initialization + */ + @NotNull + default List getInitialWorkspaceFolders(@NotNull Project project, + @NotNull FileUriSupport fileUriSupport) { + if (!sendAllFoldersOnInitialization()) { + return Collections.emptyList(); + } + return getWorkspaceFolders(project, fileUriSupport); + } + + /** + * Returns the workspace folder for the given file, or null if the file + * is not part of any workspace folder. + * + * @param file the file + * @param project the project + * @param fileUriSupport file URI support for converting files to URIs + * @return the workspace folder containing the file, or null + */ + @Nullable + WorkspaceFolder getWorkspaceFolderForFile(@NotNull VirtualFile file, + @NotNull Project project, + @NotNull FileUriSupport fileUriSupport); +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategyProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategyProvider.java new file mode 100644 index 000000000..4bfbfde88 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceFolder/WorkspaceFolderStrategyProvider.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.workspaceFolder; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * Provider for workspace folder strategies. + * + *

    This interface is used to register workspace folder strategies via the extension point.

    + */ +@ApiStatus.Experimental +public interface WorkspaceFolderStrategyProvider { + + /** + * Creates a new instance of the workspace folder strategy. + * + * @return a new workspace folder strategy instance + */ + @NotNull + WorkspaceFolderStrategy createStrategy(); +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/AbstractLSPWorkspaceSymbolContributor.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/AbstractLSPWorkspaceSymbolContributor.java index dd127a204..7c14fb71b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/AbstractLSPWorkspaceSymbolContributor.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/AbstractLSPWorkspaceSymbolContributor.java @@ -14,7 +14,6 @@ import com.intellij.navigation.ChooseByNameContributorEx; import com.intellij.navigation.NavigationItem; import com.intellij.openapi.application.ReadAction; -import com.intellij.openapi.progress.util.ProgressIndicatorUtils; import com.intellij.openapi.project.Project; import com.intellij.psi.search.GlobalSearchScope; import com.intellij.util.Processor; @@ -24,19 +23,16 @@ import com.redhat.devtools.lsp4ij.internal.CompletableFutures; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.util.List; import java.util.concurrent.CompletableFuture; +import static com.redhat.devtools.lsp4ij.internal.CancellationSupport.awaitWithCheckCanceled; + /** * Abstract base class for LSP goto symbol contributors */ abstract class AbstractLSPWorkspaceSymbolContributor implements ChooseByNameContributorEx { - private final static Logger LOGGER = LoggerFactory.getLogger(AbstractLSPWorkspaceSymbolContributor.class); - @Override public void processNames(@NotNull Processor processor, @NotNull GlobalSearchScope scope, @@ -51,12 +47,13 @@ public void processNames(@NotNull Processor processor, } var workspaceSymbolsFuture = getWorkspaceSymbols(queryString, true, project); - ProgressIndicatorUtils.awaitWithCheckCanceled(workspaceSymbolsFuture); + awaitWithCheckCanceled(workspaceSymbolsFuture); if (CompletableFutures.isDoneNormally(workspaceSymbolsFuture)) { var items = workspaceSymbolsFuture.getNow(null); if (items != null) { items.stream() - .filter(data -> data.getFile() != null && ReadAction.compute(() -> scope.accept(data.getFile()))) + .filter(data -> data.getFile() != null + && (!data.isFilterBySearchScope() || ReadAction.compute(() -> scope.accept(data.getFile())))) .map(NavigationItem::getName) .forEach(processor::process); } @@ -68,13 +65,14 @@ public void processElementsWithName(@NotNull String name, @NotNull Processor processor, @NotNull FindSymbolParameters parameters) { var workspaceSymbolsFuture = getWorkspaceSymbols(name, false, parameters.getProject()); - ProgressIndicatorUtils.awaitWithCheckCanceled(workspaceSymbolsFuture); + awaitWithCheckCanceled(workspaceSymbolsFuture); if (CompletableFutures.isDoneNormally(workspaceSymbolsFuture)) { var items = workspaceSymbolsFuture.getNow(null); if (items != null) { items .stream() - .filter(data -> data.getFile() != null && ReadAction.compute(() -> parameters.getSearchScope().accept(data.getFile()))) + .filter(data -> data.getFile() != null + && (!data.isFilterBySearchScope() || ReadAction.compute(() -> parameters.getSearchScope().accept(data.getFile())))) .forEach(processor::process); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceImplementationsSearch.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceImplementationsSearch.java index efb836f94..4f0db9864 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceImplementationsSearch.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceImplementationsSearch.java @@ -80,7 +80,7 @@ public void processQuery(DefinitionsScopedSearch.@NotNull SearchParameters query // Check if the file can support the feature if (!LanguageServiceAccessor.getInstance(project) - .hasAny(file, ls -> ls.getClientFeatures().getImplementationFeature().isImplementationSupported(file))) { + .hasAny(file, ls -> ls.getClientFeatures().getImplementationFeature().isSupported(file))) { return; } @@ -111,10 +111,7 @@ public void processQuery(DefinitionsScopedSearch.@NotNull SearchParameters query if (isDoneNormally(implementationsFuture)) { List implementations = implementationsFuture.getNow(null); - if (ContainerUtil.isEmpty(implementations)) { - // No implementations found - LSP4IJUiUtils.showErrorHint(file, CodeInsightBundle.message("goto.implementation.notFound")); - } else { + if (!ContainerUtil.isEmpty(implementations)) { // textDocument/implementations has been collected correctly for (LocationData implementation : implementations) { if (!consumer.process(LSPPsiElementFactory.toPsiElement(implementation.location(), implementation.languageServer().getClientFeatures(), project))) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceSymbolSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceSymbolSupport.java index d1fb7275b..fb4d3e87c 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceSymbolSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceSymbolSupport.java @@ -86,13 +86,18 @@ private static CompletableFuture> getWorkspaceSymbolFo // workspace/symbol may return null return null; } + boolean filterBySearchScope = languageServer.getClientFeatures() + .getWorkspaceSymbolFeature() + .filterBySearchScope(); List items = new ArrayList<>(); if (symbols.isLeft()) { List s = symbols.getLeft(); for (var si : s) { if (params.accept(si)) { - items.add(new WorkspaceSymbolData( - si.getName(), si.getKind(), si.getLocation(), languageServer.getClientFeatures(), project)); + WorkspaceSymbolData item = new WorkspaceSymbolData( + si.getName(), si.getKind(), si.getLocation(), languageServer.getClientFeatures(), project); + item.setFilterBySearchScope(filterBySearchScope); + items.add(item); } } } else if (symbols.isRight()) { @@ -100,6 +105,7 @@ private static CompletableFuture> getWorkspaceSymbolFo for (var si : ws) { if (params.accept(si)) { WorkspaceSymbolData item = createItem(si, languageServer.getClientFeatures(),project); + item.setFilterBySearchScope(filterBySearchScope); items.add(item); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceTypeDeclarationProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceTypeDeclarationProvider.java index a5e7428f5..612a5a1e5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceTypeDeclarationProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/LSPWorkspaceTypeDeclarationProvider.java @@ -84,7 +84,7 @@ public class LSPWorkspaceTypeDeclarationProvider implements TypeDeclarationPlace } if (!LanguageServiceAccessor.getInstance(project) - .hasAny(file, ls -> ls.getClientFeatures().getTypeDefinitionFeature().isTypeDefinitionSupported(file))) { + .hasAny(file, ls -> ls.getClientFeatures().getTypeDefinitionFeature().isSupported(file))) { return null; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/WorkspaceSymbolData.java b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/WorkspaceSymbolData.java index b1512f5ef..0385de0cb 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/WorkspaceSymbolData.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/features/workspaceSymbol/WorkspaceSymbolData.java @@ -51,6 +51,7 @@ public class WorkspaceSymbolData extends LightElement implements NavigationItem, private @Nullable PsiFile psiFile; private @Nullable String locationString; private @Nullable TextRange textRange; + private boolean filterBySearchScope = true; public WorkspaceSymbolData(String name, @@ -159,6 +160,14 @@ public String getText() { return symbolKind; } + public boolean isFilterBySearchScope() { + return filterBySearchScope; + } + + public void setFilterBySearchScope(boolean filterBySearchScope) { + this.filterBySearchScope = filterBySearchScope; + } + @Override public boolean isValid() { return true; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerContext.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerContext.java index f21b6eb00..511f3bc7f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerContext.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerContext.java @@ -10,13 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.installation.definition; -import com.intellij.execution.impl.ConsoleViewImpl; -import com.intellij.execution.ui.ConsoleView; import com.intellij.execution.ui.ConsoleViewContentType; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.command.WriteCommandAction; -import com.intellij.openapi.editor.Document; -import com.intellij.openapi.editor.Editor; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.project.Project; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; @@ -29,10 +23,8 @@ import java.io.StringWriter; import java.io.Writer; import java.util.*; -import java.util.function.Consumer; import java.util.function.Supplier; import java.util.logging.Level; -import java.util.logging.Logger; /** * Represents the execution context during a Language Server installation or verification process. diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskFactoryPointBean.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskFactoryPointBean.java index 51bbf20de..c3fd75f2b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskFactoryPointBean.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskFactoryPointBean.java @@ -18,8 +18,6 @@ import com.intellij.serviceContainer.BaseKeyedLazyInstance; import com.intellij.util.xmlb.annotations.Attribute; import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Server extension point bean. diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskRegistry.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskRegistry.java index 8b654fd25..ab4a9d86b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskRegistry.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/InstallerTaskRegistry.java @@ -18,8 +18,8 @@ import org.jetbrains.annotations.Nullable; import java.io.StringReader; -import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Installer task registry. @@ -34,16 +34,20 @@ public class InstallerTaskRegistry { private static final @NotNull String TASK_REF_JSON_PROPERTY = "ref"; - private final @NotNull Map factories; + // Concurrent: EP listener callbacks mutate this on arbitrary threads, readers run on background tasks. + private final @NotNull Map factories = new ConcurrentHashMap<>(); public InstallerTaskRegistry() { - factories = new HashMap<>(); } public void registerFactory(String type, InstallerTaskFactory factory) { factories.put(type, factory); } + public void unregisterFactory(String type) { + factories.remove(type); + } + public @NotNull ServerInstallerDescriptor loadInstaller(@NotNull String json) { JsonObject installer = loadJson(json); return loadInstaller(installer); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/ServerInstallerManager.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/ServerInstallerManager.java index 4ec57b1b3..b74f553c2 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/ServerInstallerManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/ServerInstallerManager.java @@ -10,20 +10,22 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.installation.definition; -import com.intellij.ide.util.DelegatingProgressIndicator; import com.intellij.notification.Notification; import com.intellij.notification.NotificationListener; import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; +import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.extensions.ExtensionPointListener; +import com.intellij.openapi.extensions.PluginDescriptor; import com.intellij.openapi.progress.ProgressIndicator; import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.progress.Task; -import com.intellij.openapi.util.NlsContexts; import com.redhat.devtools.lsp4ij.ServerMessageHandler; import com.redhat.devtools.lsp4ij.installation.PrintableProgressIndicator; -import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Manages the installation of Language Servers(LSP) and Debug Adapter Protocol (DAP) servers @@ -41,18 +43,44 @@ * {@link #getInstance()}. *

    */ -public class ServerInstallerManager extends InstallerTaskRegistry { +public class ServerInstallerManager extends InstallerTaskRegistry implements Disposable { + + private static final Logger LOGGER = LoggerFactory.getLogger(ServerInstallerManager.class); private ServerInstallerManager() { super(); var beans = InstallerTaskFactoryPointBean.EP_NAME.getExtensions(); for (InstallerTaskFactoryPointBean bean : beans) { - try { - super.registerFactory(bean.type, bean.getInstance()); - } catch (Exception e) { - // Do nothing - } + registerFactoryFromBean(bean); } + InstallerTaskFactoryPointBean.EP_NAME.getPoint().addExtensionPointListener( + new ExtensionPointListener<>() { + @Override + public void extensionAdded(@NotNull InstallerTaskFactoryPointBean bean, + @NotNull PluginDescriptor pluginDescriptor) { + registerFactoryFromBean(bean); + } + + @Override + public void extensionRemoved(@NotNull InstallerTaskFactoryPointBean bean, + @NotNull PluginDescriptor pluginDescriptor) { + unregisterFactory(bean.type); + } + }, + /* invokeForLoadedExtensions */ false, + /* parentDisposable */ this); + } + + private void registerFactoryFromBean(@NotNull InstallerTaskFactoryPointBean bean) { + try { + registerFactory(bean.type, bean.getInstance()); + } catch (Exception e) { + LOGGER.warn("Failed to instantiate installerTaskFactory for type='{}'", bean.type, e); + } + } + + @Override + public void dispose() { } /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/DownloadTask.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/DownloadTask.java index f5495af22..32ef4fdec 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/DownloadTask.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/DownloadTask.java @@ -19,7 +19,6 @@ import com.redhat.devtools.lsp4ij.installation.definition.ServerInstallerDescriptor; import com.redhat.devtools.lsp4ij.installation.download.AssetFetcher; import com.redhat.devtools.lsp4ij.installation.download.DownloadUtils; -import com.redhat.devtools.lsp4ij.installation.download.GitHubAssetFetcher; import com.redhat.devtools.lsp4ij.launching.templates.LanguageServerTemplate; import com.redhat.devtools.lsp4ij.server.definition.launching.CommandUtils; import org.jetbrains.annotations.NotNull; @@ -84,8 +83,8 @@ public boolean run(@NotNull InstallerContext context) { // language=html String htmlError = """

    Unable to retrieve the download URL for your platform %s and architecture %s.

    -

    You must define a valid url - or github.asset +

    You must define a valid url + or github.asset in your installer.json, matching your OS and architecture.

    """.formatted(LanguageServerTemplate.OS_KEY, CpuArch.CURRENT.name().toLowerCase()); context.printError(htmlError, true); @@ -166,7 +165,7 @@ public boolean run(@NotNull InstallerContext context) { // language=HTML String htmlError = """

    The file name %s specified in output.file.name - for your platform %s and architecture %s + for your platform %s and architecture %s was not found in the downloaded archive at %s.

    Please update the output.file.name section of your installer.json to correctly match your OS and architecture.

    """.formatted(outputFileName, LanguageServerTemplate.OS_KEY, CpuArch.CURRENT.name().toLowerCase(), outputDir.toString()); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/ExecTask.java b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/ExecTask.java index 5b428e3a3..0a8348924 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/ExecTask.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/installation/definition/tasks/ExecTask.java @@ -13,7 +13,6 @@ import com.intellij.execution.configurations.GeneralCommandLine; import com.intellij.execution.process.*; import com.intellij.openapi.progress.ProcessCanceledException; -import com.intellij.openapi.project.ProjectManager; import com.intellij.openapi.project.ProjectUtil; import com.intellij.openapi.util.Key; import com.intellij.util.EnvironmentUtil; @@ -44,8 +43,6 @@ */ public class ExecTask extends InstallerTask { - private static final int DEFAULT_TIMEOUT = 2000; - private final @NotNull List command; private final @Nullable Integer timeout; private final @Nullable String workingDir; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/CancellationSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/CancellationSupport.java index 922ac4660..6d0686d36 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/CancellationSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/CancellationSupport.java @@ -15,7 +15,11 @@ import com.intellij.notification.NotificationType; import com.intellij.notification.Notifications; import com.intellij.openapi.progress.ProcessCanceledException; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.ProgressManager; import com.intellij.openapi.project.Project; +import com.intellij.util.ConcurrencyUtil; +import com.intellij.util.ExceptionUtil; import com.redhat.devtools.lsp4ij.LSP4IJWebsiteUrlConstants; import com.redhat.devtools.lsp4ij.LanguageServerItem; import com.redhat.devtools.lsp4ij.ServerMessageHandler; @@ -27,6 +31,7 @@ import com.redhat.devtools.lsp4ij.settings.actions.ReportErrorInLogAction; import com.redhat.devtools.lsp4ij.settings.actions.ShowErrorLogAction; import org.eclipse.lsp4j.jsonrpc.CancelChecker; +import org.eclipse.lsp4j.jsonrpc.MessageIssueException; import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -38,8 +43,10 @@ import java.util.concurrent.*; import java.util.function.BiFunction; +import static com.intellij.openapi.progress.util.ProgressIndicatorUtils.checkCancelledEvenWithPCEDisabled; import static com.redhat.devtools.lsp4ij.internal.CancellationUtil.isContentModified; import static com.redhat.devtools.lsp4ij.internal.CancellationUtil.isRequestCancelled; +import static java.util.concurrent.TimeUnit.MILLISECONDS; /** * LSP cancellation support hosts the list of LSP requests to cancel when a @@ -53,6 +60,8 @@ public class CancellationSupport implements CancelChecker { private static final Logger LOGGER = LoggerFactory.getLogger(CancellationSupport.class); + private static final int MAX_REJECTED_EXECUTIONS_BEFORE_CANCELLATION = 16; + private final List> futuresToCancel; private boolean cancelled; @@ -141,6 +150,29 @@ private static BiFunction handleLSPFeatureResult(@NotNull L } } if (error != null) { + if (error instanceof CancellationException) { + // This case occurs when an LSP request is cancelled and call https://github.com/eclipse-lsp4j/lsp4j/blob/783b6e788c1e374b6de63f69804c7d0f96be4da2/org.eclipse.lsp4j.jsonrpc/src/main/java/org/eclipse/lsp4j/jsonrpc/RemoteEndpoint.java#L161 + // if CancellationException is rethrown, the call of super.cancel(...) throws again CancellationException + // and logs strange error like + // - https://github.com/redhat-developer/lsp4ij/issues/1204 + // - Caused by: java.util.concurrent.CancellationException + // at java.base/java.util.concurrent.CompletableFuture.cancel(CompletableFuture.java:2478) + // To fix those issues, we ignore CancellationException in this case. + // Note: ProcessCanceledException extends CancellationException, so it's also caught here + return null; + } + // Check if the error is a CompletionException wrapping a CancellationException (including ProcessCanceledException) + if (error instanceof CompletionException && error.getCause() instanceof CancellationException) { + // Same as above, but for wrapped cancellation exceptions + return null; + } + if (error instanceof MessageIssueException) { + return null; + } + // Check if the error is a CompletionException wrapping a MessageIssueException + if (error instanceof CompletionException && error.getCause() instanceof MessageIssueException) { + return null; + } if (error instanceof RuntimeException) { throw (RuntimeException) error; } @@ -267,4 +299,56 @@ public static void cancel(@Nullable Future future) { } } + public static T awaitWithCheckCanceled(@NotNull Future future) { + ProgressIndicator indicator = ProgressManager.getInstance().getProgressIndicator(); + return (T)awaitWithCheckCanceled(future, indicator); + } + + public static T awaitWithCheckCanceled(@NotNull Future future, @Nullable ProgressIndicator indicator) { + int rejectedExecutions = 0; + while (true) { + checkCancelledEvenWithPCEDisabled(indicator); + try { + return future.get(ConcurrencyUtil.DEFAULT_TIMEOUT_MS, MILLISECONDS); + } + catch (TimeoutException ignore) { + } + //TODO RC: in a non-cancellable section we could still (re-)throw a (P)CE if the _awaited_ code gets cancelled + // (nowadays it is mistakenly considered an error) -- [Daniil et all, private conversation] + catch (CancellationException e) { + // In LSP4IJ context, the future can be cancelled, we need to catch this exception + // to avoid logging CancellationException as an error + // See https://github.com/JetBrains/intellij-community/blob/b764e70363e0c967a536256d0e057ea1f9053ff7/platform/ide-core-impl/src/com/intellij/openapi/progress/util/ProgressIndicatorUtils.java#L373 + throw new ProcessCanceledException(e); + } + catch (RejectedExecutionException ree) { + //EA-225412: FJP throws REE (which propagates through futures) e.g. when FJP reaches max + // threads while compensating for too many managedBlockers -- or when it is shutdown. + + //This branch creates a risk of infinite loop -- i.e. if the current thread itself is somehow + // responsible for FJP resource exhaustion, hence can't release anything, each consequent + // future.get() will throw the same REE again and again. So let's limit retries: + rejectedExecutions++; + if (rejectedExecutions > MAX_REJECTED_EXECUTIONS_BEFORE_CANCELLATION) { + //RC: It would be clearer to rethrow ree itself -- but I doubt many callers are ready for it, + // while all callers are ready for PCE, hence... + throw new ProcessCanceledException(ree); + } + } + catch (InterruptedException e) { + throw new ProcessCanceledException(e); + } + catch (Throwable e) { + Throwable cause = e.getCause(); + if (cause instanceof ProcessCanceledException) { + throw (ProcessCanceledException)cause; + } + if (cause instanceof CancellationException) { + throw new ProcessCanceledException(cause); + } + ExceptionUtil.rethrow(e); + } + } + + } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java new file mode 100644 index 000000000..21aca9705 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/CompletionUtils.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + *****************************************************************************/ +package com.redhat.devtools.lsp4ij.internal; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * LSP/DAP Completion utilities. + */ +public class CompletionUtils { + + @Nullable + public static Integer computePrefixStartFromInsertText(@NotNull Document document, + @Nullable PsiFile file, + int completionOffset, + String insertText) { + + // case 2.1: first strategy, we collect word range at + // ex : + // insertText= '(let [${1:binding} ${2:value}])' + // document= le + // we have to return |le as prefix start offset + + TextRange wordRange = LSPIJUtils.getWordRangeAt(document, file, completionOffset); + if (wordRange != null) { + return wordRange.getStartOffset(); + } + + // case 2.2: second strategy, we check if the left content of the completion offset + // matches the full insertText left content + // ex : + // insertText= 'foo.bar' + // document= {foo.b|} + // we have to return {| as prefix start offset + + return getPrefixStartOffsetWhichMatchesLeftContent(document, completionOffset, insertText); + } + + @Nullable + private static Integer getPrefixStartOffsetWhichMatchesLeftContent(@NotNull Document document, + int completionOffset, + @NotNull String insertText) { + int startOffset = Math.max(0, completionOffset - insertText.length()); + int endOffset = startOffset + Math.min(insertText.length(), completionOffset); + String subDoc = document.getText(new TextRange(startOffset, endOffset)); // "".ch + for (int i = 0; i < insertText.length() && i < completionOffset; i++) { + String tentativeCommonString = subDoc.substring(i); + if (insertText.startsWith(tentativeCommonString)) { + return completionOffset - tentativeCommonString.length(); + } + } + return null; + } + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedConcurrentMessageProcessor.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedConcurrentMessageProcessor.java new file mode 100644 index 000000000..052e6103d --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedConcurrentMessageProcessor.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + *****************************************************************************/ +package com.redhat.devtools.lsp4ij.internal; + +import org.eclipse.lsp4j.jsonrpc.MessageConsumer; +import org.eclipse.lsp4j.jsonrpc.MessageProducer; +import org.eclipse.lsp4j.jsonrpc.json.ConcurrentMessageProcessor; + +/** + * Extension of {@link ConcurrentMessageProcessor} that customizes + * the message processing loop to avoid silently logging errors. + *

    + * By default, {@link ConcurrentMessageProcessor} catches exceptions + * during the message processing loop and only logs them. This can lead to: + *

      + *
    • The processor ({@link ConcurrentMessageProcessor}) continuing to read invalid messages.
    • + *
    • A flood of errors and inconsistent state in the language server.
    • + *
    + *

    + * By removing the try-catch block that only logs exceptions: + *

      + *
    • Any unexpected exception thrown by {@link MessageProducer#listen(MessageConsumer)} + * will propagate and stop the processor ({@link ConcurrentMessageProcessor}).
    • + *
    • The {@link java.util.concurrent.Future} associated with this processor will complete exceptionally, + * allowing LSP4IJ to detect the failure and restart the language server if needed.
    • + *
    + */ +public class ExtendedConcurrentMessageProcessor extends ConcurrentMessageProcessor { + + private final MessageProducer messageProducer; + private final MessageConsumer messageConsumer; + + /** + * Creates a new concurrent message processor using the provided + * message producer and consumer. + * + * @param messageProducer the producer of messages + * @param messageConsumer the consumer that handles messages + */ + public ExtendedConcurrentMessageProcessor(MessageProducer messageProducer, MessageConsumer messageConsumer) { + super(messageProducer, messageConsumer); + this.messageProducer = messageProducer; + this.messageConsumer = messageConsumer; + } + + /** + * Starts the message processing loop. + *

    + * Overrides the default {@link ConcurrentMessageProcessor#run()} method + * to remove the catch-and-log behavior and ensure that exceptions stop the processor. + *

    + * Note: + * The original {@link ConcurrentMessageProcessor} implementation catches all exceptions: + *

    +     * } catch (Exception e) {
    +     *     LOG.log(Level.SEVERE, e.getMessage(), e);
    +     * }
    +     * 
    + * This catch block is commented out intentionally. By allowing exceptions to propagate: + *
      + *
    • The Future returned by the processor ({@link ConcurrentMessageProcessor}) will complete exceptionally.
    • + *
    • LSP4IJ can detect that the processor has failed.
    • + *
    • The language server can then be restarted automatically.
    • + *
    + */ + @Override + public void run() { + processingStarted(); + try { + messageProducer.listen(messageConsumer); + // Original catch/log block is intentionally commented out: + // } catch (Exception e) { + // LOG.log(Level.SEVERE, e.getMessage(), e); + // } + // By not catching here, exceptions propagate to stop the processor. + } finally { + processingEnded(); + } + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedStreamMessageProducer.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedStreamMessageProducer.java new file mode 100644 index 000000000..70e9c42e0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/ExtendedStreamMessageProducer.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + *****************************************************************************/ +package com.redhat.devtools.lsp4ij.internal; + +import com.redhat.devtools.lsp4ij.server.LanguageServerException; +import org.eclipse.lsp4j.jsonrpc.MessageIssueHandler; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; +import org.eclipse.lsp4j.jsonrpc.json.StreamMessageProducer; + +import java.io.InputStream; + +/** + * Extension of {@link StreamMessageProducer} that ensures errors are not only logged, + * but actually thrown as exceptions. + *

    + * By default, {@link StreamMessageProducer} logs errors without throwing them. + * This can lead to situations where the processor ({@link StreamMessageProducer}) + * keeps reading the input stream even if it is producing invalid messages. As a result: + *

      + *
    • You may get a flood of errors such as + * {@code java.lang.IllegalStateException: Missing header Content-Length in input}.
    • + *
    • The processor does not stop, and the language server is never marked as crashed.
    • + *
    + * (see issue #1238) + *

    + * By throwing exceptions instead: + *

      + *
    • The processor ({@link ExtendedConcurrentMessageProcessor}) is immediately stopped when an error occurs.
    • + *
    • LSP4IJ can detect the failure and automatically restart the language server ({@link LanguageServerException}).
    • + *
    + */ +public class ExtendedStreamMessageProducer extends StreamMessageProducer { + + public ExtendedStreamMessageProducer(InputStream input, + MessageJsonHandler jsonHandler, + MessageIssueHandler issueHandler) { + super(input, jsonHandler, issueHandler); + } + + /** + * Overrides the default error handling to immediately throw the error. + *

    + * Prevents the processor ({@link StreamMessageProducer}) from continuing + * to read invalid messages and flooding the log with errors. + * + * @param error the error that occurred during message processing + */ + @Override + protected void fireError(Throwable error) { + throwError(error); + } + + /** + * Overrides the default stream closed handling. + *

    + * Ensures that unexpected stream closures stop the processor + * ({@link StreamMessageProducer}) and allow LSP4IJ to restart the server. + * + * @param cause the exception that caused the stream to close + */ + @Override + protected void fireStreamClosed(Exception cause) { + if (cause.getMessage() != null) { + // If the cause has a message, rethrow it to stop processing + throwError(cause); + } + // Otherwise, explicitly signal the stream closure + throw new LanguageServerException("The input stream was closed.", cause); + } + + /** + * Utility method that rethrows the given error. + *

    + * - If it is a {@link RuntimeException}, it is thrown directly. + * - Otherwise, it is wrapped in a {@link LanguageServerException}. + * + * @param error the error to rethrow + */ + private static void throwError(Throwable error) { + if (error instanceof RuntimeException runtimeException) { + throw runtimeException; + } + throw new LanguageServerException(error); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/ResponseErrorExceptionWrapper.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/ResponseErrorExceptionWrapper.java index 7a54f5018..4d6055009 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/ResponseErrorExceptionWrapper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/ResponseErrorExceptionWrapper.java @@ -14,6 +14,7 @@ import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.ByteArrayOutputStream; import java.io.PrintWriter; @@ -21,28 +22,34 @@ /** * LSP4J Response error wrapper. */ -public class ResponseErrorExceptionWrapper extends ResponseErrorException { +public class ResponseErrorExceptionWrapper extends ResponseErrorException { public ResponseErrorExceptionWrapper(@NotNull Throwable throwable) { super(toResponseError(throwable.getMessage(), throwable)); } + public ResponseErrorExceptionWrapper(@NotNull String message) { + this(message, null); + } + public ResponseErrorExceptionWrapper(@NotNull String message, - @NotNull Throwable throwable) { + @Nullable Throwable throwable) { super(toResponseError(message, throwable)); } @NotNull private static ResponseError toResponseError(@NotNull String message, - @NotNull Throwable throwable) { + @Nullable Throwable throwable) { ResponseError error = new ResponseError(); error.setMessage(message); error.setCode(ResponseErrorCode.InternalError); - ByteArrayOutputStream stackTrace = new ByteArrayOutputStream(); - PrintWriter stackTraceWriter = new PrintWriter(stackTrace); - throwable.printStackTrace(stackTraceWriter); - stackTraceWriter.flush(); - error.setData(stackTrace.toString()); + if (throwable != null) { + ByteArrayOutputStream stackTrace = new ByteArrayOutputStream(); + PrintWriter stackTraceWriter = new PrintWriter(stackTrace); + throwable.printStackTrace(stackTraceWriter); + stackTraceWriter.flush(); + error.setData(stackTrace.toString()); + } return error; } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/capabilities/ClientCapabilitiesFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/capabilities/ClientCapabilitiesFactory.java index 7af527b73..9d647a630 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/capabilities/ClientCapabilitiesFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/capabilities/ClientCapabilitiesFactory.java @@ -13,7 +13,6 @@ package com.redhat.devtools.lsp4ij.internal.capabilities; import org.eclipse.lsp4j.*; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import java.util.Arrays; @@ -81,9 +80,9 @@ private static GeneralClientCapabilities getGeneralClientCapabilities() { FileOperationsWorkspaceCapabilities fileOperationsWorkspaceCapabilities = new FileOperationsWorkspaceCapabilities(); fileOperationsWorkspaceCapabilities.setDynamicRegistration(Boolean.TRUE); //fileOperationsWorkspaceCapabilities.setWillCreate(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willCreateFiles - //fileOperationsWorkspaceCapabilities.setDidCreate(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didCreateFiles + fileOperationsWorkspaceCapabilities.setDidCreate(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didCreateFiles //fileOperationsWorkspaceCapabilities.setWillDelete(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willDeleteFiles - //fileOperationsWorkspaceCapabilities.setDidDelete(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didDeleteFiles + fileOperationsWorkspaceCapabilities.setDidDelete(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didDeleteFiles fileOperationsWorkspaceCapabilities.setWillRename(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_willRenameFiles fileOperationsWorkspaceCapabilities.setDidRename(Boolean.TRUE); // See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didRenameFiles workspaceClientCapabilities.setFileOperations(fileOperationsWorkspaceCapabilities); @@ -101,6 +100,9 @@ private static GeneralClientCapabilities getGeneralClientCapabilities() { // Refresh support for SemanticTokens workspaceClientCapabilities.setSemanticTokens(new SemanticTokensWorkspaceCapabilities(Boolean.TRUE)); + // Refresh support for Diagnostic + workspaceClientCapabilities.setDiagnostics(new DiagnosticWorkspaceCapabilities(Boolean.TRUE)); + return workspaceClientCapabilities; } @@ -300,13 +302,19 @@ private static GeneralClientCapabilities getGeneralClientCapabilities() { private static WindowClientCapabilities getWindowClientCapabilities() { final var windowClientCapabilities = new WindowClientCapabilities(); - windowClientCapabilities.setShowDocument(new ShowDocumentCapabilities(true)); /** * LSP4IJ supports server initiated progress using the * `window/workDoneProgress/create` request. */ windowClientCapabilities.setWorkDoneProgress(true); - windowClientCapabilities.setShowMessage(new WindowShowMessageRequestCapabilities()); + + // Support for window/showDocument + windowClientCapabilities.setShowDocument(new ShowDocumentCapabilities(true)); + + // Support for window/showMessage and window/showMessageRequest both + var showMessageRequestCapabilities = new WindowShowMessageRequestCapabilities(); + showMessageRequestCapabilities.setMessageActionItem(new WindowShowMessageRequestActionItemCapabilities(Boolean.FALSE)); + windowClientCapabilities.setShowMessage(showMessageRequestCapabilities); return windowClientCapabilities; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeature.java index db47f3456..eeafbe5d3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeature.java @@ -31,6 +31,18 @@ public interface EditorFeature { */ EditorFeatureType getFeatureType(); + /** + * Check if this feature should be processed for the given file. + * By default, returns true for all files. + * Override this method to add specific conditions (e.g., only for selected file). + * + * @param file the Psi file. + * @return true if the feature should be processed, false otherwise. + */ + default boolean shouldProcess(@NotNull PsiFile file) { + return true; + } + /** * Clear notification stamp from the given editor. * diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureManager.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureManager.java index ca527aaa4..6a1e3892d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureManager.java @@ -47,7 +47,7 @@ @ApiStatus.Internal public class EditorFeatureManager implements Disposable { - record RefreshEditorFeatureContext(@NotNull PsiFile file, @NotNull List runnables) { + record RefreshEditorFeatureContext(@NotNull PsiFile file, @NotNull List runnables, Editor[] editors) { } private final @NotNull Project project; @@ -61,6 +61,8 @@ private EditorFeatureManager(@NotNull Project project) { addEditorFeature(new FoldingEditorFeature()); addEditorFeature(new InlayHintsEditorFeature()); addEditorFeature(new DeclarativeInlayHintsEditorFeature()); + addEditorFeature(new SemanticTokensEditorFeature()); + addEditorFeature(new StructureViewEditorFeature()); } private void addEditorFeature(EditorFeature editorFeature) { @@ -135,20 +137,22 @@ private void refreshEditorFeature(@Nullable PsiFile psiFile, final List runnables = new ArrayList<>(); for (Editor editor : editors) { - // Clear editor cache according the feature type - // (IntelliJ stores generally the modification time stamp of the Psi file to avoid refreshing the feature if Psi file doesn't change) - clearEditorCache(editor, project, featureType); // Collect runnable which must be executed on UI step. collectUiRunnables(pf, editor, featureType, runnables); } - return new RefreshEditorFeatureContext(pf, runnables); + return new RefreshEditorFeatureContext(pf, runnables, editors); }) - .coalesceBy(file, featureType, clearLSPCache) + .coalesceBy(file != null ? file : psiFile, featureType, clearLSPCache) .finishOnUiThread(ModalityState.any(), context -> { if (context == null) { // No opened editors associated from any language servers. return; } + for (Editor editor : context.editors()) { + // Clear editor cache according the feature type + // (IntelliJ stores generally the modification time stamp of the Psi file to avoid refreshing the feature if Psi file doesn't change) + clearEditorCache(editor, project, featureType); + } LSPFileSupport.getSupport(context.file()).restartDaemonCodeAnalyzerWithDebounce(cancelChecker); for (var runnable : context.runnables()) { runnable.run(); @@ -189,9 +193,16 @@ private void clearEditorCache(@NotNull Editor editor, @NotNull Project project, private void clearLSPCache(PsiFile psiFile, @NotNull EditorFeatureType featureType) { if (featureType == EditorFeatureType.ALL) { - editorFeatures.values().forEach(feature -> feature.clearLSPCache(psiFile)); + editorFeatures.values().forEach(feature -> { + if (feature.shouldProcess(psiFile)) { + feature.clearLSPCache(psiFile); + } + }); } else { - getEditorFeature(featureType).clearLSPCache(psiFile); + EditorFeature feature = getEditorFeature(featureType); + if (feature.shouldProcess(psiFile)) { + feature.clearLSPCache(psiFile); + } } } @@ -201,9 +212,16 @@ private void collectUiRunnables(@NotNull PsiFile psiFile, @NotNull EditorFeatureType featureType, @NotNull List runnables) { if (featureType == EditorFeatureType.ALL) { - editorFeatures.values().forEach(feature -> feature.collectUiRunnable(editor, psiFile, runnables)); + editorFeatures.values().forEach(feature -> { + if (feature.shouldProcess(psiFile)) { + feature.collectUiRunnable(editor, psiFile, runnables); + } + }); } else { - getEditorFeature(featureType).collectUiRunnable(editor, psiFile, runnables); + EditorFeature feature = getEditorFeature(featureType); + if (feature.shouldProcess(psiFile)) { + feature.collectUiRunnable(editor, psiFile, runnables); + } } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureType.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureType.java index c87ee70e5..e98a3ed82 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureType.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/EditorFeatureType.java @@ -13,7 +13,7 @@ import org.jetbrains.annotations.ApiStatus; /** - * The editor feature type ro refresh. + * The editor feature type to refresh. */ @ApiStatus.Internal public enum EditorFeatureType { @@ -21,5 +21,7 @@ public enum EditorFeatureType { DECLARATIVE_INLAY_HINT, INLAY_HINT, FOLDING, + SEMANTIC_TOKENS, + STRUCTURE_VIEW, ALL } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/InlayHintsEditorFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/InlayHintsEditorFeature.java index 858cf958b..4fff4f1b0 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/InlayHintsEditorFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/InlayHintsEditorFeature.java @@ -10,47 +10,22 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.internal.editor; +import com.intellij.codeInsight.daemon.impl.InlayHintsPassFactoryInternal; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.project.Project; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.LSPFileSupport; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; import java.util.List; /** * Inlay hints feature to refresh IntelliJ inlay hints (even if Psi file doesn't change). - * - * Bridge class which consumes InlayHintsFactory with Java Reflection to support any IntelliJ version: - * - *

      - *
    • with old version (< 2024.1): com.intellij.codeInsight.hints.InlayHintsPassFactory
    • - *
    • with new version (>= 2024.1): com.intellij.codeInsight.hints.InlayHintsFactory
    • - *
    */ @ApiStatus.Internal public class InlayHintsEditorFeature implements EditorFeature { - private static final Logger LOGGER = LoggerFactory.getLogger(InlayHintsEditorFeature.class); - - private static final String[] INLAY_HINTS_PASS_FACTORY_CLASSES = { - "com.intellij.codeInsight.hints.InlayHintsFactory", - "com.intellij.codeInsight.hints.InlayHintsPassFactory"}; - - private static final Object INVALID_COMPANION_INSTANCE = new Object(); - - // InlayHintsPassFactory.Companion - private Object companionInstance; - - // InlayHintsPassFactory.Companion.clearModificationStamp(editor) - private Method clearModificationStampMethod; - @Override public EditorFeatureType getFeatureType() { return EditorFeatureType.INLAY_HINT; @@ -58,20 +33,7 @@ public EditorFeatureType getFeatureType() { @Override public void clearEditorCache(@NotNull Editor editor, @NotNull Project project) { - // old class: com.intellij.codeInsight.hints.InlayHintsPassFactory - // --> InlayHintsPassFactory.Companion.clearModificationStamp(editor); - // - // new class: com.intellij.codeInsight.hints.InlayHintsFactory - // --> InlayHintsFactory.Companion.clearModificationStamp(editor); - try { - loadInlayHintsPassFactoryIfNeeded(); - if (companionInstance != INVALID_COMPANION_INSTANCE) { - clearModificationStampMethod.invoke(companionInstance, editor); - } - } catch (Exception e) { - LOGGER.error("Error while calling InlayHintsPassFactory.Companion.clearModificationStamp(editor)", e); - this.companionInstance = INVALID_COMPANION_INSTANCE; - } + InlayHintsPassFactoryInternal.Companion.clearModificationStamp(editor); } @Override @@ -88,44 +50,5 @@ public void collectUiRunnable(@NotNull Editor editor, // Do nothing } - private void loadInlayHintsPassFactoryIfNeeded() throws ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchFieldException, NoSuchMethodException { - if (companionInstance != null) { - return; - } - loadInlayHintsPassFactory(); - } - - private synchronized void loadInlayHintsPassFactory() throws ClassNotFoundException, InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchFieldException, NoSuchMethodException { - if (companionInstance != null) { - return; - } - Class inlayHintsPassFactoryClass = loadInlayHintsPassFactoryClass(); - // Create instance of InlayHintsPassFactory - Object inlayHintsPassFactoryInstance = inlayHintsPassFactoryClass.getConstructors()[0].newInstance(); - - // Get InlayHintsPassFactory.Companion which defines relevant methods - Class companionClass = inlayHintsPassFactoryClass.getClasses()[0]; - - // Get InlayHintsPassFactory.Companion.clearModificationStamp(editor) method - this.clearModificationStampMethod = companionClass.getMethod("clearModificationStamp", Editor.class); - this.clearModificationStampMethod.setAccessible(true); - - Field companionField = inlayHintsPassFactoryClass.getDeclaredField("Companion"); - companionField.setAccessible(true); - this.companionInstance = companionField.get(inlayHintsPassFactoryInstance); - } - - private static Class loadInlayHintsPassFactoryClass() throws ClassNotFoundException { - for (var className : INLAY_HINTS_PASS_FACTORY_CLASSES) { - try { - return Class.forName(className); - } catch (Exception e) { - // Do nothing - } - } - String classNames = "[" + String.join(",", INLAY_HINTS_PASS_FACTORY_CLASSES) + "]"; - LOGGER.error("Error while trying to load InlayHintsPassFactory from classes " + classNames); - throw new ClassNotFoundException(classNames); - } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/SemanticTokensEditorFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/SemanticTokensEditorFeature.java new file mode 100644 index 000000000..619544fc5 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/SemanticTokensEditorFeature.java @@ -0,0 +1,51 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.editor; + +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPFileSupport; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Semantic tokens editor feature. + */ +@ApiStatus.Internal +public class SemanticTokensEditorFeature implements EditorFeature { + + @Override + public EditorFeatureType getFeatureType() { + return EditorFeatureType.SEMANTIC_TOKENS; + } + + @Override + public void clearEditorCache(@NotNull Editor editor, @NotNull Project project) { + // Semantic tokens don't use editor-specific cache + // Refresh is handled by DaemonCodeAnalyzer + } + + @Override + public void clearLSPCache(PsiFile file) { + // Evict the cache of LSP requests from semantic tokens support + var fileSupport = LSPFileSupport.getSupport(file); + fileSupport.getSemanticTokensSupport().cancel(); + } + + @Override + public void collectUiRunnable(@NotNull Editor editor, @NotNull PsiFile file, @NotNull List runnableList) { + // Semantic tokens refresh is handled by DaemonCodeAnalyzer restart + // No additional UI runnable needed + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/StructureViewEditorFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/StructureViewEditorFeature.java new file mode 100644 index 000000000..ba7efec80 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/editor/StructureViewEditorFeature.java @@ -0,0 +1,74 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.editor; + +import com.intellij.ide.impl.StructureViewWrapperImpl; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LSPFileSupport; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Structure view editor feature to refresh IntelliJ structure view when language servers start/stop. + */ +@ApiStatus.Internal +public class StructureViewEditorFeature implements EditorFeature { + + @Override + public EditorFeatureType getFeatureType() { + return EditorFeatureType.STRUCTURE_VIEW; + } + + @Override + public boolean shouldProcess(@NotNull PsiFile file) { + // Structure view should only be refreshed for the currently selected file + VirtualFile virtualFile = file.getVirtualFile(); + if (virtualFile != null) { + VirtualFile[] selectedFiles = FileEditorManager.getInstance(file.getProject()).getSelectedFiles(); + return selectedFiles.length > 0 && selectedFiles[0].equals(virtualFile); + } + return false; + } + + @Override + public void clearEditorCache(@NotNull Editor editor, @NotNull Project project) { + // Nothing to clear on editor level for structure view + } + + @Override + public void clearLSPCache(PsiFile file) { + // Invalidate document symbol cache + // clearCaches() changes the modification stamp which invalidates the LSP future cache + if (LSPFileSupport.hasSupport(file)) { + file.clearCaches(); + } + } + + @Override + public void collectUiRunnable(@NotNull Editor editor, + @NotNull PsiFile file, + @NotNull List runnableList) { + Runnable runnable = () -> { + // Refresh the structure view + ApplicationManager.getApplication().getMessageBus() + .syncPublisher(StructureViewWrapperImpl.STRUCTURE_CHANGED) + .run(); + }; + runnableList.add(runnable); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/DefaultUriConverter.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/DefaultUriConverter.java new file mode 100644 index 000000000..e99a1f10b --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/DefaultUriConverter.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.uri; + +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.VirtualFileManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Default {@link UriConverter} implementation for standard {@code file://} URIs. + * + *

    + * This converter is used for regular files located on the local file system. + * It follows the Language Server Protocol (LSP) specification for file URIs + * and ensures compatibility with IntelliJ's Virtual File System (VFS). + *

    + */ +class DefaultUriConverter implements UriConverter { + + /** + * Resolves a {@link VirtualFile} from a {@code file://} URI string. + * + *

    + * The URI is first normalized using {@link VfsUtilCore#fixURLforIDEA(String)} + * to handle platform-specific issues (e.g. Windows paths), + * then resolved through IntelliJ's {@link VirtualFileManager}. + *

    + * + * @param uri the file URI string + * @return the corresponding {@link VirtualFile}, or {@code null} if it cannot be resolved + */ + @Override + public @Nullable VirtualFile findResourceFor(@NotNull String uri) { + return VirtualFileManager.getInstance() + .findFileByUrl(VfsUtilCore.fixURLforIDEA(uri)); + } + + /** + * Converts a Java {@link File} to an LSP-compliant {@link URI}. + * + *

    + * The Language Server Protocol requires {@code file://} URIs with + * properly encoded paths and without host components. + *

    + * + *

    + * This implementation explicitly builds the URI to avoid + * platform-specific issues, especially on Windows. + *

    + * + * @param file the Java file + * @return the corresponding {@link URI} + */ + @Override + public @Nullable URI toUri(@NotNull File file) { + try { + // LSP-compliant file URI: file:///absolute/path + return new URI( + "file", + "", // no authority + file.getAbsoluteFile().toURI().getPath(), + null + ); + } catch (URISyntaxException e) { + // Fallback to the standard Java URI representation + return file.getAbsoluteFile().toURI(); + } + } + + /** + * Converts an IntelliJ {@link VirtualFile} to its {@code file://} URI string representation. + * + *

    + * This method is intentionally left unimplemented here and may be + * provided by a higher-level or composite {@link UriConverter}. + *

    + * + * @param file the IntelliJ virtual file + * @return {@code null} + */ + @Override + public @Nullable String toString(@NotNull VirtualFile file) { + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/JarUriConverter.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/JarUriConverter.java new file mode 100644 index 000000000..af5e1a792 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/JarUriConverter.java @@ -0,0 +1,154 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.uri; + +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.Objects; + +/** + * {@link UriConverter} implementation dedicated to {@code jar:} and {@code jrt:} URIs. + * + *

    + * This converter is responsible for handling resources located inside: + *

      + *
    • JAR files ({@code jar:file://...!/path})
    • + *
    • Java runtime images ({@code jrt:/modules/...})
    • + *
    + * + *

    + * These URI types are commonly used by language servers when referring to: + *

      + *
    • dependencies from Maven/Gradle caches
    • + *
    • JDK classes (via the {@code jrt} file system)
    • + *
    + * + *

    + * This converter does not handle regular {@code file://} URIs. + *

    + */ +class JarUriConverter implements UriConverter { + + /** IntelliJ file system protocol for JAR files */ + public static final String JAR_PROTOCOL = "jar"; + + /** IntelliJ file system protocol for Java runtime image (JRT) */ + public static final String JRT_PROTOCOL = "jrt"; + + /** URI scheme prefix for JAR resources */ + private static final String JAR_SCHEME = JAR_PROTOCOL + ":"; + + /** URI scheme prefix for JRT resources */ + private static final String JRT_SCHEME = JRT_PROTOCOL + ":"; + + /** + * Resolves a {@link VirtualFile} from a {@code jar:} or {@code jrt:} URI string. + * + *

    + * Example: + *

    +     * jar:file:///C:/Users/.m2/repository/.../artifact.jar!/com/example/Foo.class
    +     * 
    + *

    + * + * @param uri the URI string to resolve + * @return the corresponding {@link VirtualFile}, or {@code null} if the URI + * is not a JAR/JRT URI or cannot be resolved + */ + @Override + public @Nullable VirtualFile findResourceFor(@NotNull String uri) { + if (uri.startsWith(JAR_SCHEME) || uri.startsWith(JRT_SCHEME)) { + try { + return VfsUtil.findFileByURL(new URL(uri)); + } catch (MalformedURLException e) { + // Invalid URI format → resource cannot be resolved + return null; + } + } + return null; + } + + /** + * Converts a JAR or JRT {@link VirtualFile} into a {@link URI}. + * + *

    + * The {@link VirtualFile#getUrl()} value is converted into a {@link URL} + * and then into a {@link URI}. + *

    + * + * @param file the IntelliJ virtual file + * @return the corresponding {@link URI}, or {@code null} if the file + * does not belong to a JAR or JRT file system + */ + @Override + public @Nullable URI toUri(@NotNull VirtualFile file) { + String protocol = file.getFileSystem().getProtocol(); + if (JAR_PROTOCOL.equals(protocol) || JRT_PROTOCOL.equals(protocol)) { + try { + return Objects + .requireNonNull(VfsUtilCore.convertToURL(file.getUrl())) + .toURI(); + } catch (URISyntaxException e) { + // Ignore invalid URI conversion + } + } + return null; + } + + /** + * Converts a {@link File} into a {@link URI}. + * + *

    + * This implementation does not support conversion from {@link File} + * because JAR and JRT resources are not directly backed by {@link File} + * instances. + *

    + * + * @param file the Java file + * @return always {@code null} + */ + @Override + public @Nullable URI toUri(@NotNull File file) { + return null; + } + + /** + * Converts a JAR or JRT {@link VirtualFile} into its URI string representation. + * + *

    + * The returned string is suitable for use in Language Server Protocol (LSP) + * messages. + *

    + * + * @param file the IntelliJ virtual file + * @return the URI string, or {@code null} if the file does not belong to + * a JAR or JRT file system + */ + @Override + public @Nullable String toString(@NotNull VirtualFile file) { + String protocol = file.getFileSystem().getProtocol(); + if (JAR_PROTOCOL.equals(protocol) || JRT_PROTOCOL.equals(protocol)) { + return Objects + .requireNonNull(VfsUtilCore.convertToURL(file.getUrl())) + .toExternalForm(); + } + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverter.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverter.java new file mode 100644 index 000000000..efaca7653 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverter.java @@ -0,0 +1,107 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.uri; + +import com.intellij.openapi.vfs.VfsUtilCore; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.net.URI; + +/** + * Responsible for converting between: + *
      + *
    • LSP-style URIs (file://, jar://, etc.)
    • + *
    • IntelliJ {@link VirtualFile}
    • + *
    • Java {@link File}
    • + *
    + * + *

    + * This abstraction centralizes all URI ↔ file conversions needed by LSP4IJ, + * including resolution of {@link VirtualFile} instances from URI strings + * received from language servers. + *

    + * + *

    + * This interface is internal to LSP4IJ and is not intended for public API use. + *

    + */ +@ApiStatus.Internal +public interface UriConverter { + + /** + * Resolves an IntelliJ {@link VirtualFile} from a URI string. + * + *

    + * The URI is typically an LSP URI (e.g. {@code file:///path/to/file}, + * {@code jar://...}, etc.). + *

    + * + * @param uri the URI string to resolve + * @return the corresponding {@link VirtualFile}, or {@code null} if + * the resource cannot be resolved + */ + @Nullable + VirtualFile findResourceFor(@NotNull String uri); + + /** + * Converts an IntelliJ {@link VirtualFile} to a {@link URI}. + * + *

    + * This default implementation first converts the {@link VirtualFile} + * to a Java {@link File} using + * {@link VfsUtilCore#virtualToIoFile(VirtualFile)}, then delegates + * to {@link #toUri(File)}. + *

    + * + * @param file the IntelliJ virtual file + * @return the corresponding {@link URI}, or {@code null} if the conversion fails + */ + default @Nullable URI toUri(@NotNull VirtualFile file) { + return toUri(VfsUtilCore.virtualToIoFile(file)); + } + + /** + * Converts a Java {@link File} to a {@link URI}. + * + *

    + * Implementations may handle special cases such as: + *

      + *
    • non-local file systems
    • + *
    • case normalization (important on Windows)
    • + *
    • symbolic links
    • + *
    + *

    + * + * @param file the Java file + * @return the corresponding {@link URI}, or {@code null} if it cannot be created + */ + @Nullable + URI toUri(@NotNull File file); + + /** + * Converts an IntelliJ {@link VirtualFile} to its URI string representation. + * + *

    + * The returned string is expected to be compatible with the Language Server + * Protocol (LSP), typically using {@code file://} URIs. + *

    + * + * @param file the IntelliJ virtual file + * @return the URI string, or {@code null} if the conversion fails + */ + @Nullable + String toString(@NotNull VirtualFile file); + +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverterManager.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverterManager.java new file mode 100644 index 000000000..17c92fc23 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/UriConverterManager.java @@ -0,0 +1,192 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.uri; + +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Central {@link UriConverter} dispatcher used by LSP4IJ. + * + *

    + * This manager delegates URI conversion operations to a chain of + * specialized {@link UriConverter} implementations, selecting the + * first one able to handle the request. + *

    + * + *

    + * It follows a chain-of-responsibility pattern, allowing + * new URI types to be supported without modifying existing converters. + *

    + * + *

    + * This class is internal to LSP4IJ and exposed as a singleton. + *

    + */ +@ApiStatus.Internal +public class UriConverterManager implements UriConverter { + + /** Singleton instance */ + private static final UriConverterManager INSTANCE; + + static { + INSTANCE = new UriConverterManager(); + } + + /** + * Returns the singleton instance of the URI converter manager. + * + * @return the shared {@link UriConverterManager} instance + */ + public static UriConverterManager getInstance() { + return INSTANCE; + } + + /** Ordered list of URI converters */ + private final List uriConverters; + + /** + * Creates the URI converter manager and initializes the converter chain. + * + *

    + * The order is significant: + *

      + *
    1. {@link JarUriConverter} – JAR and JRT resources
    2. + *
    3. {@link WslUriConverter} – WSL UNC paths
    4. + *
    5. {@link DefaultUriConverter} – standard file system
    6. + *
    + *

    + */ + UriConverterManager() { + uriConverters = List.of( + new JarUriConverter(), + new WslUriConverter(), + new DefaultUriConverter() + ); + } + + /** + * Resolves a {@link VirtualFile} from a URI string. + * + *

    + * Some language servers send percent-encoded URIs that IntelliJ cannot + * resolve directly (e.g. Windows drive letters such as {@code c%3A}). + *

    + * + *

    + * To work around this: + *

      + *
    • {@code +} characters are preserved
    • + *
    • the URI is URL-decoded before resolution
    • + *
    + *

    + * + * @param uri the URI string + * @return the resolved {@link VirtualFile}, or {@code null} if no converter + * can handle the URI + */ + @Override + public @Nullable VirtualFile findResourceFor(@NotNull String uri) { + if (uri.contains("%")) { + // Example: + // file:///c%3A/Users/azerr/IdeaProjects/untitled7/test.js + // + // IntelliJ cannot resolve percent-encoded drive letters, + // so the URI must be decoded first. + + // Preserve '+' characters during decoding + uri = uri.replace("+", "%2B"); + + // Decode percent-encoded characters + uri = URLDecoder.decode(uri, StandardCharsets.UTF_8); + } + + for (UriConverter uriConverter : uriConverters) { + VirtualFile file = uriConverter.findResourceFor(uri); + if (file != null) { + return file; + } + } + return null; + } + + /** + * Converts an IntelliJ {@link VirtualFile} into a {@link URI}. + * + *

    + * The first converter in the chain capable of handling the file + * is used. + *

    + * + * @param file the IntelliJ virtual file + * @return the corresponding {@link URI}, or {@code null} if unsupported + */ + @Override + public @Nullable URI toUri(@NotNull VirtualFile file) { + for (UriConverter uriConverter : uriConverters) { + URI uri = uriConverter.toUri(file); + if (uri != null) { + return uri; + } + } + return null; + } + + /** + * Converts a Java {@link File} into a {@link URI}. + * + *

    + * This is primarily used when sending file references to a language server. + *

    + * + * @param file the Java file + * @return the corresponding {@link URI}, or {@code null} if unsupported + */ + @Override + public @Nullable URI toUri(@NotNull File file) { + for (UriConverter uriConverter : uriConverters) { + URI uri = uriConverter.toUri(file); + if (uri != null) { + return uri; + } + } + return null; + } + + /** + * Converts an IntelliJ {@link VirtualFile} into its URI string representation. + * + *

    + * This is typically used when serializing URIs in LSP messages. + *

    + * + * @param file the IntelliJ virtual file + * @return the URI string, or {@code null} if unsupported + */ + @Override + public @Nullable String toString(@NotNull VirtualFile file) { + for (UriConverter uriConverter : uriConverters) { + String uri = uriConverter.toString(file); + if (uri != null) { + return uri; + } + } + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/WslUriConverter.java b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/WslUriConverter.java new file mode 100644 index 000000000..1a91c1588 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/internal/uri/WslUriConverter.java @@ -0,0 +1,131 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.internal.uri; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.util.SystemInfo; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * {@link UriConverter} implementation dedicated to Windows Subsystem for Linux (WSL) + * paths exposed as Windows UNC paths. + * + *

    + * WSL file systems are typically accessible on Windows using UNC paths such as: + *

    + * \\wsl$\Ubuntu\home\\user\project\file.txt
    + * 
    + * + *

    + * Language servers expect these paths to be represented as valid {@code file://} + * URIs with an authority component. + *

    + */ +class WslUriConverter implements UriConverter { + + private static final Logger LOGGER = Logger.getInstance(WslUriConverter.class); + + /** + * Resolving a {@link VirtualFile} from a WSL URI. + * + *

    + * Handle WSL UNC paths ('\\wsl$\...' or '\\wsl.localhost\...') on Windows + * Corresponding URIs have the format: 'file://wsl$/...' or 'file://wsl.localhost/...' + *

    + * + * @param uri the URI string + * @return the virtual file + */ + @Override + public @Nullable VirtualFile findResourceFor(@NotNull String uri) { + // Handle WSL UNC paths ('\\wsl$\...' or '\\wsl.localhost\...') on Windows + // Corresponding URIs have the format: 'file://wsl$/...' or 'file://wsl.localhost/...' + if (SystemInfo.isWindows && (uri.startsWith("file://wsl$/") || uri.startsWith("file://wsl.localhost/"))) { + try { + URI parsedUri = new URI(uri); + String authority = parsedUri.getAuthority(); + String path = parsedUri.getPath(); + if (authority != null && path != null && authority.startsWith("wsl")) { + String uncPath = "\\\\" + authority + path.replace('/', '\\'); + return LocalFileSystem.getInstance().findFileByPath(uncPath); + } + } catch (URISyntaxException e) { + LOGGER.warn("Failed to parse WSL URI: " + uri, e); + // Delegate to a more generic converter + } + } + return null; + } + + /** + * Converts a Windows UNC path pointing to a WSL file into a {@code file://} URI. + * + *

    + * Example conversion: + *

    +     * \\wsl$\Ubuntu\home\\user\file.txt
    +     * →
    +     * file://wsl$/Ubuntu/home/user/file.txt
    +     * 
    + * + *

    + * A lenient URI construction is used to support the non-standard but commonly + * used {@code wsl$} authority. + *

    + * + * @param file the Java file representing a WSL UNC path + * @return the corresponding {@link URI}, or {@code null} if the file + * is not a WSL UNC path + */ + @Override + public @Nullable URI toUri(@NotNull File file) { + String path = file.getPath(); + if (path.startsWith("\\\\wsl$\\") || path.startsWith("\\\\wsl.localhost\\")) { + // Strip leading UNC prefix (\\) + // Format: authority\path\... + String uncPath = path.substring(2); + + // Extract authority and remaining path + int firstSep = uncPath.indexOf('\\'); + if (firstSep > 0) { + String authority = uncPath.substring(0, firstSep); + String uriPath = uncPath.substring(firstSep).replace('\\', '/'); + + // Use lenient parsing to allow WSL authority such as "wsl$" + return URI.create("file://" + authority + uriPath); + } + } + return null; + } + + /** + * Converts an IntelliJ {@link VirtualFile} to a URI string. + * + *

    + * This operation is not currently supported for WSL virtual files and is + * expected to be handled by a higher-level converter if needed. + *

    + * + * @param file the IntelliJ virtual file + * @return always {@code null} + */ + @Override + public @Nullable String toString(@NotNull VirtualFile file) { + return null; + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/UserDefinedLanguageServerSettings.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/UserDefinedLanguageServerSettings.java index bda1f8e8c..a6f175a71 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/UserDefinedLanguageServerSettings.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/UserDefinedLanguageServerSettings.java @@ -23,8 +23,6 @@ import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.*; @@ -108,8 +106,6 @@ private void fireStateChanged() { public static class UserDefinedLanguageServerItemSettings { - private static final Logger LOGGER = LoggerFactory.getLogger(UserDefinedLanguageServerItemSettings.class); - private String serverId; private String templateId; @@ -120,6 +116,8 @@ public static class UserDefinedLanguageServerItemSettings { private String commandLine; + private String workingDir; + private Map userEnvironmentVariables; private boolean includeSystemEnvironmentVariables = true; @@ -130,6 +128,8 @@ public static class UserDefinedLanguageServerItemSettings { private boolean installAlreadyDone; + private String workspaceFolderStrategyConfiguration; + @XCollection(elementTypes = ServerMappingSettings.class) private List mappings; @@ -173,6 +173,14 @@ public void setCommandLine(String commandLine) { this.commandLine = commandLine; } + public String getWorkingDir() { + return workingDir; + } + + public void setWorkingDir(String workingDir) { + this.workingDir = workingDir; + } + /** * Returns the User environment variables used to start the language server process. * @@ -241,6 +249,14 @@ public boolean isInstallAlreadyDone() { public void setInstallAlreadyDone(boolean installAlreadyDone) { this.installAlreadyDone = installAlreadyDone; } + + public String getWorkspaceFolderStrategyConfiguration() { + return workspaceFolderStrategyConfiguration; + } + + public void setWorkspaceFolderStrategyConfiguration(String workspaceFolderStrategyConfiguration) { + this.workspaceFolderStrategyConfiguration = workspaceFolderStrategyConfiguration; + } } public static class MyState { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializer.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializer.java index 361d08915..127e7d991 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializer.java @@ -33,6 +33,11 @@ public JsonElement serialize(UserDefinedLanguageServerDefinition src, Type typeO programArgs.addProperty(DEFAULT_JSON_PROPERTY, src.getCommandLine()); userDefinedLSDefinitionJson.add(PROGRAM_ARGS_JSON_PROPERTY, programArgs); + // Working directory + if (src.getWorkingDir() != null && !src.getWorkingDir().isBlank()) { + userDefinedLSDefinitionJson.addProperty(WORKING_DIR_JSON_PROPERTY, src.getWorkingDir()); + } + JsonArray languageMappings = new JsonArray(); for (var languageEntry : src.getLanguageMappings().entrySet()) { JsonObject language = new JsonObject(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplate.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplate.java index 48cc47fc3..24e50473d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplate.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplate.java @@ -10,7 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.launching.templates; -import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.ide.plugins.PluginManager; import com.intellij.openapi.extensions.PluginId; import com.redhat.devtools.lsp4ij.LanguageServerBundle; import com.redhat.devtools.lsp4ij.templates.ServerTemplate; @@ -34,6 +34,7 @@ public String getName() { public static final String SETTINGS_FILE_NAME = "settings.json"; public static final String SETTINGS_SCHEMA_FILE_NAME = "settings.schema.json"; public static final String CLIENT_SETTINGS_FILE_NAME = "clientSettings.json"; + public static final String WORKSPACE_FOLDER_SETTINGS_FILE_NAME = "workspaceFolderSettings.json"; public static final String README_FILE_NAME = "README.md"; public static final String DISABLE_PROMOTION_FOR = "disablePromotionFor"; @@ -48,6 +49,7 @@ public String getName() { private String initializationOptions; private String experimental; private String clientConfiguration; + private String workspaceFolderConfiguration; private Set disablePromotionFor; private Boolean promotable; @@ -108,6 +110,14 @@ public void setClientConfiguration(String clientConfiguration) { this.clientConfiguration = clientConfiguration; } + public String getWorkspaceFolderConfiguration() { + return workspaceFolderConfiguration; + } + + public void setWorkspaceFolderConfiguration(String workspaceFolderConfiguration) { + this.workspaceFolderConfiguration = workspaceFolderConfiguration; + } + public void setDisablePromotionFor(Set disablePromotionFor) { this.disablePromotionFor = disablePromotionFor; } @@ -127,26 +137,27 @@ public Set getDisablePromotionFor() { * @return {@code true} if the template should be promoted; {@code false} otherwise. */ public boolean isPromotable() { + if (promotable != null) { + return promotable; + } if (disablePromotionFor == null || disablePromotionFor.isEmpty()) { return true; } - if (promotable == null) { - promotable = computePromotable(); - } + promotable = computePromotable(); return promotable; } private boolean computePromotable() { for (var pluginId : disablePromotionFor) { if (isPluginInstalled(pluginId)) { - return true; + return false; } } - return false; + return true; } private static boolean isPluginInstalled(String pluginIdString) { PluginId pluginId = PluginId.getId(pluginIdString); - return PluginManagerCore.getPlugin(pluginId) != null; + return PluginManager.getInstance().findEnabledPlugin(pluginId) != null; } } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateDeserializer.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateDeserializer.java index 497e1361c..68fa78e0f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateDeserializer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateDeserializer.java @@ -14,13 +14,10 @@ import com.google.common.reflect.TypeToken; import com.google.gson.*; import com.redhat.devtools.lsp4ij.JSONUtils; -import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; import com.redhat.devtools.lsp4ij.templates.ServerTemplateJsonDeserializer; import org.jetbrains.annotations.NotNull; import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -46,6 +43,11 @@ protected void deserializeCustom(@NotNull LanguageServerTemplate languageServerT Map programArgsMap = gson.fromJson(programArgs, mapType); languageServerTemplate.setProgramArgs(programArgsMap); } + String workingDir = JSONUtils.getString(jsonObject, WORKING_DIR_JSON_PROPERTY); + if (workingDir != null) { + languageServerTemplate.setWorkingDir(workingDir); + } + languageServerTemplate.setEnvData(deserializeEnvData(jsonObject)); boolean expandConfiguration = JSONUtils.getBoolean(jsonObject, EXPAND_CONFIGURATION_JSON_PROPERTY); languageServerTemplate.setExpandConfiguration(expandConfiguration); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManager.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManager.java index 8717bb170..ed469d70b 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManager.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManager.java @@ -87,6 +87,7 @@ public LanguageServerTemplate createLsTemplate(@NotNull VirtualFile templateFold String experimentalJson = null; String clientSettingsJson = null; String installerSettingsJson = null; + String workspaceFolderSettingsJson = null; String description = null; for (VirtualFile file : templateFolder.getChildren()) { @@ -115,6 +116,9 @@ public LanguageServerTemplate createLsTemplate(@NotNull VirtualFile templateFold case INSTALLER_FILE_NAME: installerSettingsJson = VfsUtilCore.loadText(file); break; + case WORKSPACE_FOLDER_SETTINGS_FILE_NAME: + workspaceFolderSettingsJson = VfsUtilCore.loadText(file); + break; case README_FILE_NAME: description = VfsUtilCore.loadText(file); break; @@ -151,6 +155,7 @@ public LanguageServerTemplate createLsTemplate(@NotNull VirtualFile templateFold template.setExperimental(experimentalJson); template.setClientConfiguration(clientSettingsJson); template.setInstallerConfiguration(installerSettingsJson); + template.setWorkspaceFolderConfiguration(workspaceFolderSettingsJson); if (StringUtils.isNotBlank(description)) { template.setDescription(description); } @@ -200,6 +205,7 @@ private SimpleEntry createZipFromLanguageServers(@NotNull List< String settingsSchema = ((UserDefinedLanguageServerDefinition) lsDefinition).getDefaultConfigurationSchemaContent(); String clientSettings = ((UserDefinedLanguageServerDefinition) lsDefinition).getClientConfigurationContent(); String installerSettings = ((UserDefinedLanguageServerDefinition) lsDefinition).getInstallerConfigurationContent(); + String workspaceFolderSettings = ((UserDefinedLanguageServerDefinition) lsDefinition).getWorkspaceFolderStrategyConfiguration(); lsName = lsDefinition.getDisplayName(); writeToZip(TEMPLATE_FILE_NAME, template, zos); @@ -209,6 +215,7 @@ private SimpleEntry createZipFromLanguageServers(@NotNull List< writeToZip(SETTINGS_SCHEMA_FILE_NAME, settingsSchema, zos); writeToZip(CLIENT_SETTINGS_FILE_NAME, clientSettings, zos); writeToZip(INSTALLER_FILE_NAME, installerSettings, zos); + writeToZip(WORKSPACE_FOLDER_SETTINGS_FILE_NAME, workspaceFolderSettings, zos); zos.closeEntry(); count++; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/NewLanguageServerDialog.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/NewLanguageServerDialog.java index 0145fce82..1c4e85d6f 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/NewLanguageServerDialog.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/NewLanguageServerDialog.java @@ -27,7 +27,6 @@ import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.dap.DAPBundle; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; -import com.redhat.devtools.lsp4ij.installation.definition.InstallerContext; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.settings.UIConfiguration; import com.redhat.devtools.lsp4ij.templates.ServerMappingSettings; @@ -214,6 +213,7 @@ public void loadFromTemplate(@NotNull LanguageServerTemplate template) { // Update command String command = getCommandLine(template); languageServerPanel.setCommandLine(command); + languageServerPanel.setEnvData(template.getEnvData()); // Update mappings var mappingsPanel = this.languageServerPanel.getMappingsPanel(); @@ -231,12 +231,15 @@ public void loadFromTemplate(@NotNull LanguageServerTemplate template) { // Update experimental this.languageServerPanel.setExperimentalContent(template.getExperimental()); - + // Update client configuration this.languageServerPanel.setClientConfigurationContent(template.getClientConfiguration()); // Update installer configuration this.languageServerPanel.setInstallerConfigurationContent(template.getInstallerConfiguration()); + + // Update workspace folder configuration + this.languageServerPanel.setWorkspaceFolderStrategyConfiguration(template.getWorkspaceFolderConfiguration()); } @Override @@ -304,12 +307,16 @@ protected void doOKAction() { String experimental = this.languageServerPanel.getExperimentalContent(); String clientConfiguration = this.languageServerPanel.getClientConfigurationContent(); String installerConfiguration = this.languageServerPanel.getInstallerConfigurationContent(); + String workspaceFolderStrategyConfiguration = ""; + String workingDir = this.languageServerPanel.getWorkingDir(); + UserDefinedLanguageServerDefinition definition = new UserDefinedLanguageServerDefinition(serverId, templateId, serverName, serverUrl, "", commandLine != null ? commandLine : "", + workingDir, userEnvironmentVariables != null ? userEnvironmentVariables : Collections.emptyMap(), includeSystemEnvironmentVariables, configuration, @@ -318,7 +325,8 @@ protected void doOKAction() { initializationOptions, experimental, clientConfiguration, - installerConfiguration); + installerConfiguration, + workspaceFolderStrategyConfiguration); definition.setUrl(this.languageServerPanel.getServerUrl()); LanguageServersRegistry.getInstance().addServerDefinition(project, definition, mappingSettings); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/UICommandLineUpdater.java b/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/UICommandLineUpdater.java index f845b9db1..e3ee90494 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/UICommandLineUpdater.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/launching/ui/UICommandLineUpdater.java @@ -75,13 +75,15 @@ private void sendNotification(@NotNull Project project) { LanguageServerDefinitionListener.LanguageServerDefinitionEvent.UpdatedBy.INSTALLER, project, definition, - false, - true, - false, - false, - false, - false, - false); + /* nameChanged */ false, + /* commandChanged */ true, + /* workingDirChanged */ false, + /* userEnvironmentVariablesChanged */ false, + /* includeSystemEnvironmentVariablesChanged */ false, + /* mappingsChanged */ false, + /* clientConfigurationContentChanged */ false, + /* installerConfigurationContentChanged */ false, + /* workspaceFolderStrategyConfigurationChanged*/ false); LanguageServersRegistry.getInstance().handleChangeEvent(event); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/DefaultLauncherBuilder.java b/src/main/java/com/redhat/devtools/lsp4ij/server/DefaultLauncherBuilder.java new file mode 100644 index 000000000..26a84ca75 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/DefaultLauncherBuilder.java @@ -0,0 +1,171 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.server; + +import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; +import com.redhat.devtools.lsp4ij.internal.ExtendedConcurrentMessageProcessor; +import com.redhat.devtools.lsp4ij.internal.ExtendedStreamMessageProducer; +import com.redhat.devtools.lsp4ij.internal.capabilities.CodeLensOptionsAdapter; +import org.eclipse.lsp4j.CodeLensOptions; +import org.eclipse.lsp4j.jsonrpc.*; +import org.eclipse.lsp4j.jsonrpc.json.ConcurrentMessageProcessor; +import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; +import org.eclipse.lsp4j.jsonrpc.json.StreamMessageConsumer; +import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage; +import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; +import org.eclipse.lsp4j.jsonrpc.services.ServiceEndpoints; +import org.eclipse.lsp4j.services.LanguageServer; +import org.jetbrains.annotations.NotNull; + +import java.io.InputStream; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +/** + * Default implementation of {@link Launcher.Builder} for lsp4ij that: + *
      + *
    • Uses {@link ExtendedStreamMessageProducer} for error-propagating message reading
    • + *
    • Uses {@link ExtendedConcurrentMessageProcessor} for error-propagating message processing
    • + *
    • Supports integer JSON-RPC IDs via {@link LSPClientFeatures#isUseIntAsJsonRpcId()}
    • + *
    • Registers {@link CodeLensOptionsAdapter} for backward compatibility with legacy servers
    • + *
    + * + *

    Subclasses can override {@link #createMessageProducer} to provide a custom + * {@link MessageProducer} (e.g., for non-stdio message sources).

    + * + * @param the language server interface type + */ +public class DefaultLauncherBuilder extends Launcher.Builder { + + private final @NotNull LSPClientFeatures clientFeatures; + + public DefaultLauncherBuilder(@NotNull LSPClientFeatures clientFeatures) { + this.clientFeatures = clientFeatures; + configureGson(builder -> { + // Add a custom CodeLensOptionsAdapter to support old language server + // which declares codeLensProvider with a boolean instead of Json object. + builder.registerTypeAdapter(CodeLensOptions.class, new CodeLensOptionsAdapter()); + }); + } + + /** + * Returns the {@link LSPClientFeatures} associated with this builder. + * + * @return the client features. + */ + @NotNull + protected LSPClientFeatures getClientFeatures() { + return clientFeatures; + } + + @Override + public Launcher create() { + // Validate input + if (input == null) + throw new IllegalStateException("Input stream must be configured."); + if (output == null) + throw new IllegalStateException("Output stream must be configured."); + if (localServices == null) + throw new IllegalStateException("Local service must be configured."); + if (remoteInterfaces == null) + throw new IllegalStateException("Remote interface must be configured."); + + // Create the JSON handler, remote endpoint and remote proxy + MessageJsonHandler jsonHandler = createJsonHandler(); + RemoteEndpoint remoteEndpoint = createRemoteEndpoint(jsonHandler); + S remoteProxy = createProxy(remoteEndpoint); + + // Create the message processor + MessageProducer reader = createMessageProducer(input, jsonHandler, remoteEndpoint); + MessageConsumer messageConsumer = wrapMessageConsumer(remoteEndpoint); + ConcurrentMessageProcessor msgProcessor = createMessageProcessor(reader, messageConsumer, remoteProxy); + ExecutorService execService = executorService != null ? executorService : Executors.newCachedThreadPool(); + return createLauncher(execService, remoteProxy, remoteEndpoint, msgProcessor); + } + + /** + * Creates the {@link MessageProducer} that reads incoming LSP messages. + * + *

    The default implementation returns an {@link ExtendedStreamMessageProducer} + * that reads from the given input stream. Subclasses can override this method + * to provide a custom message source (e.g., a queue-based producer for + * non-stdio transports).

    + * + * @param input the input stream to read messages from. + * @param jsonHandler the JSON handler for message parsing. + * @param issueHandler the handler for message issues. + * @return the message producer. + */ + protected @NotNull MessageProducer createMessageProducer(@NotNull InputStream input, + @NotNull MessageJsonHandler jsonHandler, + @NotNull MessageIssueHandler issueHandler) { + return new ExtendedStreamMessageProducer(input, jsonHandler, issueHandler); + } + + @Override + protected ConcurrentMessageProcessor createMessageProcessor(MessageProducer reader, + MessageConsumer messageConsumer, + S remoteProxy) { + return new ExtendedConcurrentMessageProcessor(reader, messageConsumer); + } + + @Override + protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { + boolean useIntAsId = clientFeatures.isUseIntAsJsonRpcId(); + if (!useIntAsId) { + // Use JSON-RPC as String (default behavior of LSP4J) + return super.createRemoteEndpoint(jsonHandler); + } + + // Override the remote endpoint to use JSON-RPC id as int + MessageConsumer outgoingMessageStream = new StreamMessageConsumer(output, jsonHandler); + outgoingMessageStream = wrapMessageConsumer(outgoingMessageStream); + Endpoint localEndpoint = ServiceEndpoints.toEndpoint(localServices); + RemoteEndpoint remoteEndpoint; + if (exceptionHandler == null) + remoteEndpoint = new RemoteEndpointWithIdAsInt(outgoingMessageStream, localEndpoint); + else + remoteEndpoint = new RemoteEndpointWithIdAsInt(outgoingMessageStream, localEndpoint, exceptionHandler); + jsonHandler.setMethodProvider(remoteEndpoint); + return remoteEndpoint; + } + + /** + * Custom RemoteEndpoint that uses integer IDs instead of string IDs for JSON-RPC messages. + */ + private static class RemoteEndpointWithIdAsInt extends RemoteEndpoint { + + private final AtomicInteger nextRequestId = new AtomicInteger(); + + public RemoteEndpointWithIdAsInt(MessageConsumer out, + Endpoint localEndpoint, + Function exceptionHandler) { + super(out, localEndpoint, exceptionHandler); + } + + public RemoteEndpointWithIdAsInt(MessageConsumer out, + Endpoint localEndpoint) { + super(out, localEndpoint); + } + + @Override + protected RequestMessage createRequestMessage(String method, Object parameter) { + RequestMessage requestMessage = new RequestMessage(); + // Use int as JSON-RPC id instead of String (by default from LSP4J) + requestMessage.setId(nextRequestId.incrementAndGet()); + requestMessage.setMethod(method); + requestMessage.setParams(parameter); + return requestMessage; + } + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java b/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java index f09ece224..644d4d584 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java @@ -16,6 +16,8 @@ import com.intellij.execution.process.ProcessOutputType; import com.intellij.openapi.util.Key; import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.*; import java.nio.charset.StandardCharsets; @@ -26,10 +28,13 @@ */ class LSPProcessListener implements ProcessListener { + private static final Logger LOGGER = LoggerFactory.getLogger(LSPProcessListener.class); + private final PipedOutputStream outputStream; private final OutputStreamWriter outputStreamWriter; private final PipedInputStream inputStream; private final @NotNull OSProcessStreamConnectionProvider provider; + private int nbMessagesForEmulatingMissingHeader; public LSPProcessListener(@NotNull OSProcessStreamConnectionProvider provider) throws IOException { this.outputStream = new PipedOutputStream(); @@ -38,21 +43,37 @@ public LSPProcessListener(@NotNull OSProcessStreamConnectionProvider provider) t this.provider = provider; } + private static String removeEndLine(String text) { + if (text.endsWith("\r\n")) { + return text.substring(0, text.length() - 2); + } + if (text.endsWith("\n")) { + return text.substring(0, text.length() - 1); + } + return text; + } + @NotNull public final InputStream getInputStream() { return this.inputStream; } + //private int i = 0; @Override public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType) { if (ProcessOutputType.isStdout(outputType)) { // Flush the output try { - this.outputStreamWriter.write(event.getText()); - this.outputStreamWriter.flush(); + synchronized (this.outputStreamWriter) { + this.outputStreamWriter.write(event.getText()); + this.outputStreamWriter.flush(); + } + // Uncomment this code to emulate the "Missing header" problem in StreamMessageProducer + // emulateMissingHeader(); } catch (IOException e) { // Some IO error exception, stop the process ExecutionManagerImpl.stopProcess(event.getProcessHandler()); + LOGGER.warn("LSP process crashes.", e); } } else if (ProcessOutputType.isStderr(outputType)) { // Log the error in the 'Log' tab console @@ -62,22 +83,25 @@ public void onTextAvailable(@NotNull ProcessEvent event, @NotNull Key outputType } } - private static String removeEndLine(String text) { - if (text.endsWith("\r\n")) { - return text.substring(0, text.length() - 2); - } - if (text.endsWith("\n")) { - return text.substring(0, text.length() - 1); + private void emulateMissingHeader() throws IOException { + nbMessagesForEmulatingMissingHeader++; + if (nbMessagesForEmulatingMissingHeader == 100) { + nbMessagesForEmulatingMissingHeader = 0; + synchronized (this.outputStreamWriter) { + this.outputStreamWriter.write("\n\n\n"); + this.outputStreamWriter.flush(); + } } - return text; } @Override public void processTerminated(@NotNull ProcessEvent event) { // Close the output try { - outputStreamWriter.close(); - outputStream.close(); + synchronized (this.outputStreamWriter) { + this.outputStreamWriter.close(); + this.outputStream.close(); + } } catch (IOException e) { // Do nothing } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/DiagnosticCapabilityRegistry.java b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/DiagnosticCapabilityRegistry.java index 6f65d6eb1..074f13da5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/DiagnosticCapabilityRegistry.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/DiagnosticCapabilityRegistry.java @@ -15,7 +15,6 @@ import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.DocumentContentSynchronizer; import com.redhat.devtools.lsp4ij.JSONUtils; -import com.redhat.devtools.lsp4ij.LSPDocumentBase; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.eclipse.lsp4j.DiagnosticRegistrationOptions; @@ -84,8 +83,10 @@ public boolean isDiagnosticSupported(@NotNull VirtualFile file) { // - didOpen have not processed pull diagnostic // - and if language server support it for now if textDocument/diagnostic has been dynamically registered. for (var openedDocument : getClientFeatures().getServerWrapper().getOpenedDocuments()) { - openedDocument.getSynchronizer() - .refreshPullDiagnostic(DocumentContentSynchronizer.RefreshPullDiagnosticOrigin.ON_REGISTER_CAPABILITY); + if (openedDocument.getSynchronizer() != null) { + openedDocument.getSynchronizer() + .refreshPullDiagnostic(DocumentContentSynchronizer.RefreshPullDiagnosticOrigin.ON_REGISTER_CAPABILITY); + } } return options; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/ExtendedDocumentSelector.java b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/ExtendedDocumentSelector.java index 8b798587f..c5b4347ef 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/ExtendedDocumentSelector.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/ExtendedDocumentSelector.java @@ -50,7 +50,7 @@ public PathPatternMatcher getPathPattern() { if (StringUtils.isEmpty(pattern)) { return null; } - patternMatcher = new PathPatternMatcher(pattern); + patternMatcher = new PathPatternMatcher(pattern, null); return patternMatcher; } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/InlayHintCapabilityRegistry.java b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/InlayHintCapabilityRegistry.java index 664020488..046385f62 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/InlayHintCapabilityRegistry.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/capabilities/InlayHintCapabilityRegistry.java @@ -14,7 +14,6 @@ import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.JSONUtils; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; -import com.redhat.devtools.lsp4ij.internal.editor.EditorFeature; import com.redhat.devtools.lsp4ij.internal.editor.EditorFeatureType; import org.eclipse.lsp4j.InlayHintRegistrationOptions; import org.eclipse.lsp4j.ServerCapabilities; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinition.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinition.java index b7a48ae30..c725ffeef 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinition.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinition.java @@ -30,18 +30,9 @@ import com.redhat.devtools.lsp4ij.features.semanticTokens.DefaultSemanticTokensColorsProvider; import com.redhat.devtools.lsp4ij.features.semanticTokens.SemanticTokensColorsProvider; import com.redhat.devtools.lsp4ij.installation.ServerInstaller; -import com.redhat.devtools.lsp4ij.internal.capabilities.CodeLensOptionsAdapter; +import com.redhat.devtools.lsp4ij.server.DefaultLauncherBuilder; import com.redhat.devtools.lsp4ij.settings.contributors.LanguageServerSettingsContributor; -import org.eclipse.lsp4j.CodeLensOptions; -import org.eclipse.lsp4j.jsonrpc.Endpoint; import org.eclipse.lsp4j.jsonrpc.Launcher; -import org.eclipse.lsp4j.jsonrpc.MessageConsumer; -import org.eclipse.lsp4j.jsonrpc.RemoteEndpoint; -import org.eclipse.lsp4j.jsonrpc.json.MessageJsonHandler; -import org.eclipse.lsp4j.jsonrpc.json.StreamMessageConsumer; -import org.eclipse.lsp4j.jsonrpc.messages.RequestMessage; -import org.eclipse.lsp4j.jsonrpc.messages.ResponseError; -import org.eclipse.lsp4j.jsonrpc.services.ServiceEndpoints; import org.eclipse.lsp4j.services.LanguageServer; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -51,10 +42,9 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; /** * Base class for Language server definition. @@ -63,7 +53,8 @@ public abstract class LanguageServerDefinition implements LanguageServerFactory, private static final int DEFAULT_LAST_DOCUMENTED_DISCONNECTED_TIMEOUT = 5; - private static final SemanticTokensColorsProvider DEFAULT_SEMANTIC_TOKENS_COLORS_PROVIDER = new DefaultSemanticTokensColorsProvider(); + @ApiStatus.Internal + public static final SemanticTokensColorsProvider DEFAULT_SEMANTIC_TOKENS_COLORS_PROVIDER = new DefaultSemanticTokensColorsProvider(); private final @NotNull String id; private final @NotNull String name; @@ -200,6 +191,30 @@ public void registerAssociation(List matchers, String languageI this.languageIdFileNameMatcherMappings.add(Pair.create(matchers, languageId)); } + @ApiStatus.Internal + public void unregisterAssociation(@NotNull Language language) { + this.languageIdLanguageMappings.remove(language); + } + + @ApiStatus.Internal + public void unregisterAssociation(@NotNull FileType fileType) { + this.languageIdFileTypeMappings.remove(fileType); + } + + @ApiStatus.Internal + public void unregisterFileNamePatternAssociation(@NotNull Set presentablePatterns, + @Nullable String languageId) { + this.languageIdFileNameMatcherMappings.removeIf(p -> { + if (!java.util.Objects.equals(p.getSecond(), languageId)) { + return false; + } + Set existing = p.getFirst().stream() + .map(FileNameMatcher::getPresentableString) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + return existing.equals(presentablePatterns); + }); + } + public Map getLanguageMappings() { return languageIdLanguageMappings; } @@ -213,63 +228,19 @@ public List, String>> getFilenameMatcherMappings() { } /** - * Custom RemoteEndpoint that uses integer IDs instead of string IDs for JSON-RPC messages. + * Creates the {@link Launcher.Builder} used to build the LSP launcher. + * + *

    The default implementation returns a {@link DefaultLauncherBuilder}. Subclasses + * can override this to return a custom builder, but the preferred extension point + * is {@link LSPClientFeatures#createLauncherBuilder()} which avoids requiring + * a custom {@link LanguageServerDefinition}.

    + * + * @param clientFeatures the client features. + * @param the language server interface type. + * @return the launcher builder. */ - private static class RemoteEndpointWithIdAsInt extends RemoteEndpoint { - - private final AtomicInteger nextRequestId = new AtomicInteger(); - - public RemoteEndpointWithIdAsInt(MessageConsumer out, - Endpoint localEndpoint, - Function exceptionHandler) { - super(out, localEndpoint, exceptionHandler); - } - - public RemoteEndpointWithIdAsInt(MessageConsumer out, - Endpoint localEndpoint) { - super(out, localEndpoint); - } - - @Override - protected RequestMessage createRequestMessage(String method, Object parameter) { - RequestMessage requestMessage = new RequestMessage(); - // Use int as JSON-RPC id instead of String (by default from LSP4J) - requestMessage.setId(nextRequestId.incrementAndGet()); - requestMessage.setMethod(method); - requestMessage.setParams(parameter); - return requestMessage; - } - } - public Launcher.Builder createLauncherBuilder(@NotNull LSPClientFeatures clientFeatures) { - - return new Launcher.Builder() { - @Override - protected RemoteEndpoint createRemoteEndpoint(MessageJsonHandler jsonHandler) { - - boolean useIntAsId = clientFeatures.isUseIntAsJsonRpcId(); - if (!useIntAsId) { - // Use JSON-RPC as String (default behavior of LSP4J) - return super.createRemoteEndpoint(jsonHandler); - } - - // Override the remote endpoint to use JSON-RPC id as int - MessageConsumer outgoingMessageStream = new StreamMessageConsumer(output, jsonHandler); - outgoingMessageStream = wrapMessageConsumer(outgoingMessageStream); - Endpoint localEndpoint = ServiceEndpoints.toEndpoint(localServices); - RemoteEndpoint remoteEndpoint; - if (exceptionHandler == null) - remoteEndpoint = new RemoteEndpointWithIdAsInt(outgoingMessageStream, localEndpoint); - else - remoteEndpoint = new RemoteEndpointWithIdAsInt(outgoingMessageStream, localEndpoint, exceptionHandler); - jsonHandler.setMethodProvider(remoteEndpoint); - return remoteEndpoint; - } - }.configureGson(builder -> { - // Add a custom CodeLensOptionsAdapter to support old language server - // which declares codeLenProvider with a boolean instead of Json object. - builder.registerTypeAdapter(CodeLensOptions.class, new CodeLensOptionsAdapter()); - }); + return new DefaultLauncherBuilder<>(clientFeatures); } public boolean supportsCurrentEditMode(@NotNull Project project) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinitionListener.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinitionListener.java index c16cbedb3..735a8f0d3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinitionListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/LanguageServerDefinitionListener.java @@ -76,31 +76,37 @@ class LanguageServerChangedEvent extends LanguageServerDefinitionEvent { public final boolean nameChanged; public final boolean commandChanged; + public final boolean workingDirChanged; public final boolean userEnvironmentVariablesChanged; public final boolean includeSystemEnvironmentVariablesChanged; public final boolean mappingsChanged; public final boolean clientConfigurationContentChanged; public final boolean installerConfigurationContentChanged; + public final boolean workspaceFolderStrategyConfigurationChanged; public LanguageServerChangedEvent(@NotNull UpdatedBy updatedBy, @NotNull Project project, @NotNull LanguageServerDefinition serverDefinition, boolean nameChanged, boolean commandChanged, + boolean workingDirChanged, boolean userEnvironmentVariablesChanged, boolean includeSystemEnvironmentVariablesChanged, boolean mappingsChanged, boolean clientConfigurationContentChanged, - boolean installerConfigurationContentChanged) { + boolean installerConfigurationContentChanged, + boolean workspaceFolderStrategyConfigurationChanged) { super(updatedBy, project); this.serverDefinition = serverDefinition; this.nameChanged = nameChanged; this.commandChanged = commandChanged; + this.workingDirChanged = workingDirChanged; this.userEnvironmentVariablesChanged = userEnvironmentVariablesChanged; this.includeSystemEnvironmentVariablesChanged = includeSystemEnvironmentVariablesChanged; this.mappingsChanged = mappingsChanged; this.clientConfigurationContentChanged = clientConfigurationContentChanged; this.installerConfigurationContentChanged = installerConfigurationContentChanged; + this.workspaceFolderStrategyConfigurationChanged = workspaceFolderStrategyConfigurationChanged; } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/extension/ExtensionLanguageServerDefinition.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/extension/ExtensionLanguageServerDefinition.java index 4ca01ac27..adf1a72df 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/extension/ExtensionLanguageServerDefinition.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/extension/ExtensionLanguageServerDefinition.java @@ -89,7 +89,6 @@ public ExtensionLanguageServerDefinition(@NotNull ServerExtensionPointBean eleme return clientFeatures; } - @SuppressWarnings("unchecked") @Override public @NotNull Class getServerInterface() { Class serverInterface = null; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java index 5c543069b..fa61d5bb9 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/ClientConfigurationSettings.java @@ -53,6 +53,8 @@ public static class ClientConfigurationBreadcrumbsSettings { * Whether or not the document symbols-based breadcrumbs info provider is enabled. Defaults to true. */ public boolean enabled = true; + + public @Nullable Boolean existingBreadcrumbsProviderOverrideable; } /** @@ -170,10 +172,20 @@ public static class OnTypeFormattingSettings { * Client-side formatter settings. */ public static class ClientConfigurationFormatSettings { + + public @Nullable Boolean enabled; + + public @Nullable Integer tabSize; + + public @Nullable Boolean insertSpaces; + + public @Nullable Boolean existingFormatterOverrideable; + /** * On-type formatting settings */ public @NotNull OnTypeFormattingSettings onTypeFormatting = new OnTypeFormattingSettings(); + } /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/CommandUtils.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/CommandUtils.java index df03c1d3a..22e74f970 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/CommandUtils.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/CommandUtils.java @@ -77,13 +77,25 @@ private static void addArg(StringBuilder commandPart, List commands) { public static GeneralCommandLine createCommandLine(@NotNull String commandLine, @NotNull Map userEnvironmentVariables, boolean includeSystemEnvironmentVariables) { + return createCommandLine(commandLine, null, userEnvironmentVariables, includeSystemEnvironmentVariables); + } + + @NotNull + public static GeneralCommandLine createCommandLine(@NotNull String commandLine, + @Nullable String workingDir, + @NotNull Map userEnvironmentVariables, + boolean includeSystemEnvironmentVariables) { Map environmentVariables = new HashMap<>(userEnvironmentVariables); // Add System environment variables if (includeSystemEnvironmentVariables) { environmentVariables.putAll(EnvironmentUtil.getEnvironmentMap()); } - return new GeneralCommandLine(CommandUtils.createCommands(commandLine)) + GeneralCommandLine generalCommandLine = new GeneralCommandLine(CommandUtils.createCommands(commandLine)) .withEnvironment(environmentVariables); + if (workingDir != null && !workingDir.isBlank()) { + generalCommandLine.setWorkDirectory(workingDir); + } + return generalCommandLine; } /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedBreadcrumbsFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedBreadcrumbsFeature.java index a674a4d7a..9a2ffe431 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedBreadcrumbsFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedBreadcrumbsFeature.java @@ -34,6 +34,12 @@ private ClientConfigurationBreadcrumbsSettings getSettings() { public boolean isEnabled(@NotNull PsiFile file) { ClientConfigurationBreadcrumbsSettings breadcrumbsSettings = getSettings(); // Note that this defaults to enabled for user-defined language server definitions - return (breadcrumbsSettings == null) || breadcrumbsSettings.enabled; + return (super.isEnabled(file) && breadcrumbsSettings == null) || breadcrumbsSettings.enabled; + } + + @Override + public boolean isExistingBreadcrumbsProviderOverrideable(@NotNull PsiFile file) { + var breadcrumbsSettings = getSettings(); + return breadcrumbsSettings != null && breadcrumbsSettings.existingBreadcrumbsProviderOverrideable != null ? breadcrumbsSettings.existingBreadcrumbsProviderOverrideable : super.isExistingBreadcrumbsProviderOverrideable(file); } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java index 7a586cc7c..fec4c8178 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedClientFeatures.java @@ -15,6 +15,8 @@ import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures; +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ConfigurableWorkspaceFolderStrategy; import com.redhat.devtools.lsp4ij.installation.ServerInstaller; import com.redhat.devtools.lsp4ij.server.definition.ClientConfigurableLanguageServerDefinition; import org.jetbrains.annotations.NotNull; @@ -36,6 +38,9 @@ public UserDefinedClientFeatures() { setBreadcrumbsFeature(new UserDefinedBreadcrumbsFeature()); setEditorBehaviorFeature(new UserDefinedEditorBehaviorFeature(this)); setFileUriSupport(new UserDefinedFileUriSupport(this)); + + // Use configurable workspace folder strategy for user-defined language servers + setWorkspaceFolderFeature(new UserDefinedWorkspaceFolderFeature()); } public boolean isCaseSensitive(@NotNull PsiFile file) { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedFormattingFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedFormattingFeature.java index 5557c13cb..4ecbc95f4 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedFormattingFeature.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedFormattingFeature.java @@ -13,6 +13,7 @@ *******************************************************************************/ package com.redhat.devtools.lsp4ij.server.definition.launching; +import com.intellij.openapi.editor.Editor; import com.intellij.openapi.util.text.StringUtil; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.client.features.LSPFormattingFeature; @@ -27,13 +28,43 @@ */ public class UserDefinedFormattingFeature extends LSPFormattingFeature { + @Nullable + private ClientConfigurationSettings.ClientConfigurationFormatSettings getClientConfigurationFormatSettings() { + ClientConfigurableLanguageServerDefinition serverDefinition = (ClientConfigurableLanguageServerDefinition) getClientFeatures().getServerDefinition(); + ClientConfigurationSettings clientConfiguration = serverDefinition.getLanguageServerClientConfiguration(); + return clientConfiguration != null ? clientConfiguration.format : null; + } + + @Override + public boolean isEnabled(@NotNull PsiFile file) { + var format = getClientConfigurationFormatSettings(); + return format != null && format.enabled != null ? format.enabled : super.isEnabled(file); + } + + @Override + public @Nullable Integer getTabSize(@NotNull PsiFile file, @Nullable Editor editor) { + var format = getClientConfigurationFormatSettings(); + return format != null && format.tabSize != null ? format.tabSize : super.getTabSize(file, editor); + } + + @Override + public @Nullable Boolean getInsertSpaces(@NotNull PsiFile file, @Nullable Editor editor) { + var format = getClientConfigurationFormatSettings(); + return format != null && format.insertSpaces != null ? format.insertSpaces : super.getInsertSpaces(file, editor); + } + + @Override + public boolean isExistingFormatterOverrideable(@NotNull PsiFile file) { + var format = getClientConfigurationFormatSettings(); + return format != null && format.existingFormatterOverrideable != null ? format.existingFormatterOverrideable : super.isExistingFormatterOverrideable(file); + } + // Server-side on-type formatting @Nullable private ServerSideOnTypeFormattingSettings getServerSideOnTypeFormattingSettings() { - ClientConfigurableLanguageServerDefinition serverDefinition = (ClientConfigurableLanguageServerDefinition) getClientFeatures().getServerDefinition(); - ClientConfigurationSettings clientConfiguration = serverDefinition.getLanguageServerClientConfiguration(); - return clientConfiguration != null ? clientConfiguration.format.onTypeFormatting.serverSide : null; + var format = getClientConfigurationFormatSettings(); + return format != null ? format.onTypeFormatting.serverSide : null; } @Override @@ -46,9 +77,8 @@ public boolean isOnTypeFormattingEnabled(@NotNull PsiFile file) { @Nullable private ClientSideOnTypeFormattingSettings getClientSideOnTypeFormattingSettings() { - ClientConfigurableLanguageServerDefinition serverDefinition = (ClientConfigurableLanguageServerDefinition) getClientFeatures().getServerDefinition(); - ClientConfigurationSettings clientConfiguration = serverDefinition.getLanguageServerClientConfiguration(); - return clientConfiguration != null ? clientConfiguration.format.onTypeFormatting.clientSide : null; + var format = getClientConfigurationFormatSettings(); + return format != null ? format.onTypeFormatting.clientSide : null; } @Override @@ -105,4 +135,5 @@ public String getFormatOnCompletionTriggerCharacters(@NotNull PsiFile file) { ClientSideOnTypeFormattingSettings settings = getClientSideOnTypeFormattingSettings(); return settings != null ? settings.formatOnCompletionTriggerCharacters : super.getFormatOnCompletionTriggerCharacters(file); } + } \ No newline at end of file diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageClient.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageClient.java index 6b71ff2bc..0c98b05e5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageClient.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageClient.java @@ -11,10 +11,8 @@ package com.redhat.devtools.lsp4ij.server.definition.launching; import com.intellij.openapi.project.Project; -import com.redhat.devtools.lsp4ij.LanguageServersRegistry; import com.redhat.devtools.lsp4ij.ServerStatus; import com.redhat.devtools.lsp4ij.client.LanguageClientImpl; -import com.redhat.devtools.lsp4ij.launching.UserDefinedLanguageServerSettings; import org.jetbrains.annotations.NotNull; /** diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageServerDefinition.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageServerDefinition.java index a4c84190a..75141b1f1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageServerDefinition.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedLanguageServerDefinition.java @@ -51,6 +51,7 @@ public class UserDefinedLanguageServerDefinition extends LanguageServerDefinitio private String name; private String url; private String commandLine; + private String workingDir; private Map userEnvironmentVariables; private boolean includeSystemEnvironmentVariables; @@ -64,6 +65,7 @@ public class UserDefinedLanguageServerDefinition extends LanguageServerDefinitio private ClientConfigurationSettings clientConfiguration; private @Nullable ServerInstallerDescriptor serverInstallerDescriptor; private String installerConfigurationContent; + private String workspaceFolderStrategyConfiguration; public UserDefinedLanguageServerDefinition(@NotNull String id, @Nullable String templateId, @@ -71,6 +73,7 @@ public UserDefinedLanguageServerDefinition(@NotNull String id, @Nullable String url, @Nullable String description, @NotNull String commandLine, + @Nullable String workingDir, @NotNull Map userEnvironmentVariables, boolean includeSystemEnvironmentVariables, @Nullable String defaultConfigurationContent, @@ -79,12 +82,14 @@ public UserDefinedLanguageServerDefinition(@NotNull String id, @Nullable String defaultInitializationOptionsContent, @Nullable String defaultExperimentalContent, @Nullable String clientConfigurationContent, - @Nullable String installerConfigurationContent) { + @Nullable String installerConfigurationContent, + @Nullable String workspaceFolderStrategyConfiguration) { super(id, name, description, true, null, false); this.name = name; this.url = url; this.templateId = templateId; this.commandLine = commandLine; + this.workingDir = workingDir; this.userEnvironmentVariables = userEnvironmentVariables; this.includeSystemEnvironmentVariables = includeSystemEnvironmentVariables; this.defaultConfigurationContent = defaultConfigurationContent; @@ -94,6 +99,7 @@ public UserDefinedLanguageServerDefinition(@NotNull String id, this.defaultExperimentalContent = defaultExperimentalContent; this.clientConfigurationContent = clientConfigurationContent; this.installerConfigurationContent = installerConfigurationContent; + this.workspaceFolderStrategyConfiguration = workspaceFolderStrategyConfiguration; } // Backward-compatible signature for clients calling without client configuration content @@ -111,6 +117,7 @@ public UserDefinedLanguageServerDefinition(@NotNull String id, null, description, commandLine, + null, userEnvironmentVariables, includeSystemEnvironmentVariables, defaultConfigurationContent, @@ -119,13 +126,16 @@ public UserDefinedLanguageServerDefinition(@NotNull String id, defaultInitializationOptionsContent, null, null, + null, null); } @Override public @NotNull StreamConnectionProvider createConnectionProvider(@NotNull Project project) { String resolvedCommandLine = resolveCommandLine(commandLine, project); + String resolvedWorkingDir = workingDir != null ? resolveCommandLine(workingDir, project) : null; return new UserDefinedStreamConnectionProvider(resolvedCommandLine, + resolvedWorkingDir, userEnvironmentVariables, includeSystemEnvironmentVariables, this, @@ -174,6 +184,14 @@ public void setCommandLine(String commandLine) { this.commandLine = commandLine; } + public String getWorkingDir() { + return workingDir; + } + + public void setWorkingDir(String workingDir) { + this.workingDir = workingDir; + } + public Map getUserEnvironmentVariables() { return userEnvironmentVariables; } @@ -232,6 +250,14 @@ public void setInstallerConfigurationContent(String installerConfigurationConten this.serverInstallerDescriptor = null; } + public String getWorkspaceFolderStrategyConfiguration() { + return workspaceFolderStrategyConfiguration; + } + + public void setWorkspaceFolderStrategyConfiguration(String workspaceFolderStrategyConfiguration) { + this.workspaceFolderStrategyConfiguration = workspaceFolderStrategyConfiguration; + } + @Override @Nullable public ClientConfigurationSettings getLanguageServerClientConfiguration() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedStreamConnectionProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedStreamConnectionProvider.java index 9e74f77a3..162e7c411 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedStreamConnectionProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedStreamConnectionProvider.java @@ -16,7 +16,6 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.redhat.devtools.lsp4ij.settings.GlobalLanguageServerSettings; -import com.redhat.devtools.lsp4ij.settings.ProjectLanguageServerSettings; import com.redhat.devtools.lsp4ij.server.OSProcessStreamConnectionProvider; import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider; import org.jetbrains.annotations.NotNull; @@ -36,11 +35,12 @@ public class UserDefinedStreamConnectionProvider extends OSProcessStreamConnecti private final @NotNull Project project; public UserDefinedStreamConnectionProvider(@NotNull String commandLine, + @Nullable String workingDir, @NotNull Map userEnvironmentVariables, boolean includeSystemEnvironmentVariables, @NotNull UserDefinedLanguageServerDefinition serverDefinition, @NotNull Project project) { - super(createCommandLine(commandLine, userEnvironmentVariables, includeSystemEnvironmentVariables)); + super(createCommandLine(commandLine, workingDir, userEnvironmentVariables, includeSystemEnvironmentVariables)); this.project = project; this.serverDefinition = serverDefinition; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedWorkspaceFolderFeature.java b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedWorkspaceFolderFeature.java new file mode 100644 index 000000000..4b496b2f0 --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/server/definition/launching/UserDefinedWorkspaceFolderFeature.java @@ -0,0 +1,52 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.server.definition.launching; + +import com.redhat.devtools.lsp4ij.client.features.LSPWorkspaceFolderFeature; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ConfigurableWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.launching.UserDefinedLanguageServerSettings; +import org.jetbrains.annotations.NotNull; + +/** + * Workspace folder feature for user-defined language servers. + * Uses configurable strategy by default, loading configuration from settings. + */ +public class UserDefinedWorkspaceFolderFeature extends LSPWorkspaceFolderFeature { + + @NotNull + @Override + protected WorkspaceFolderStrategy createStrategy() { + ConfigurableWorkspaceFolderStrategy strategy = new ConfigurableWorkspaceFolderStrategy(); + + // Load workspace folder configuration from settings + var serverDefinition = getClientFeatures().getServerDefinition(); + var settings = UserDefinedLanguageServerSettings.getInstance() + .getUserDefinedLanguageServerSettings(serverDefinition.getId()); + if (settings != null) { + String config = settings.getWorkspaceFolderStrategyConfiguration(); + if (config != null && !config.trim().isEmpty()) { + strategy.configure(config); + } + } + + return strategy; + } + + @Override + public void reset() { + strategy = null; + super.reset(); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettings.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettings.java index bcf54e1ef..61cb741e9 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettings.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettings.java @@ -140,12 +140,20 @@ public LanguageServerSettingsListener.LanguageServerSettingsChangedEvent updateS existingSettings.setServerTrace(newSettings.getServerTrace()); } + // Workspace folders + boolean workspaceFolderStrategyConfigurationChanged = newSettings.getWorkspaceFolderStrategyConfiguration() != null && !(isEquals(existingSettings.getWorkspaceFolderStrategyConfiguration(), newSettings.getWorkspaceFolderStrategyConfiguration())); + if (workspaceFolderStrategyConfigurationChanged) { + existingSettings.setWorkspaceFolderStrategyConfiguration(newSettings.getWorkspaceFolderStrategyConfiguration()); + } + if (configurationContentChanged || expandConfigurationChanged || configurationSchemaContentChanged || experimentalContentChanged || - debugPortChanged || debugSuspendChanged || errorReportingKindChanged || serverTraceChanged){ + debugPortChanged || debugSuspendChanged || errorReportingKindChanged || serverTraceChanged || + workspaceFolderStrategyConfigurationChanged){ // There are some changes, fire the changed event. return handleChanged(languageServerId, existingSettings, notify, configurationContentChanged, expandConfigurationChanged, configurationSchemaContentChanged, initializationOptionsContentChanged, experimentalContentChanged, - debugPortChanged, debugSuspendChanged, errorReportingKindChanged, serverTraceChanged); + debugPortChanged, debugSuspendChanged, errorReportingKindChanged, serverTraceChanged, + workspaceFolderStrategyConfigurationChanged); } } else { // The settings don't exist @@ -160,11 +168,14 @@ public LanguageServerSettingsListener.LanguageServerSettingsChangedEvent updateS boolean debugSuspendChanged = newSettings.isDebugSuspend(); boolean errorReportingKindChanged = newSettings.getErrorReportingKind() != null && !(isEquals(ErrorReportingKind.getDefaultValue(), newSettings.getErrorReportingKind())); boolean serverTraceChanged = newSettings.getServerTrace() != null && !(isEquals(ServerTrace.getDefaultValue(), newSettings.getServerTrace())); + boolean workspaceFolderStrategyConfigurationChanged = !StringUtils.isBlank(newSettings.getWorkspaceFolderStrategyConfiguration()); if (configurationContentChanged || expandConfigurationChanged || configurationSchemaContentChanged || experimentalContentChanged || - debugPortChanged || debugSuspendChanged || errorReportingKindChanged || serverTraceChanged) { + debugPortChanged || debugSuspendChanged || errorReportingKindChanged || serverTraceChanged || + workspaceFolderStrategyConfigurationChanged) { return handleChanged(languageServerId, newSettings, notify, configurationContentChanged, expandConfigurationChanged, configurationSchemaContentChanged, initializationOptionsContentChanged, experimentalContentChanged, - debugPortChanged, debugSuspendChanged, errorReportingKindChanged, serverTraceChanged); + debugPortChanged, debugSuspendChanged, errorReportingKindChanged, serverTraceChanged, + workspaceFolderStrategyConfigurationChanged); } } return null; @@ -183,7 +194,8 @@ private LanguageServerSettingsListener.LanguageServerSettingsChangedEvent handle boolean debugPortChanged, boolean debugSuspendChanged, boolean errorReportingKindChanged, - boolean serverTraceChanged) { + boolean serverTraceChanged, + boolean workspaceFolderStrategyConfigurationChanged) { if (listeners.isEmpty()) { return null; } @@ -198,7 +210,8 @@ private LanguageServerSettingsListener.LanguageServerSettingsChangedEvent handle debugPortChanged, debugSuspendChanged, errorReportingKindChanged, - serverTraceChanged); + serverTraceChanged, + workspaceFolderStrategyConfigurationChanged); if (notify) { handleChanged(event); } @@ -290,6 +303,8 @@ public static class LanguageServerDefinitionSettings { private ServerTrace serverTrace; private ErrorReportingKind errorReportingKind; + private String workspaceFolderStrategyConfiguration; + public String getConfigurationContent() { return configurationContent; } @@ -422,6 +437,15 @@ public LanguageServerDefinitionSettings setErrorReportingKind(ErrorReportingKind return this; } + public String getWorkspaceFolderStrategyConfiguration() { + return workspaceFolderStrategyConfiguration; + } + + public LanguageServerDefinitionSettings setWorkspaceFolderStrategyConfiguration(String workspaceFolderStrategyConfiguration) { + this.workspaceFolderStrategyConfiguration = workspaceFolderStrategyConfiguration; + return this; + } + } public static class MyState { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettingsListener.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettingsListener.java index c8346be6a..1416368a0 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettingsListener.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerSettingsListener.java @@ -31,7 +31,8 @@ record LanguageServerSettingsChangedEvent(@NotNull String languageServerId, boolean debugPortChanged, boolean debugSuspendChanged, boolean errorReportingKindChanged, - boolean serverTraceChanged) { + boolean serverTraceChanged, + boolean workspaceFolderStrategyConfigurationChanged) { } void handleChanged(@NotNull LanguageServerSettingsListener.LanguageServerSettingsChangedEvent event); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerView.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerView.java index 9f94ffb81..219e4a0bb 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerView.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/LanguageServerView.java @@ -138,11 +138,13 @@ public boolean isModified() { } if (!(isEquals(getDisplayName(), settings.getServerName()) && isEquals(this.getCommandLine(), settings.getCommandLine()) + && isEquals(this.getWorkingDir(), settings.getWorkingDir()) && Objects.equals(this.getUserEnvironmentVariables(), settings.getUserEnvironmentVariables()) && this.isIncludeSystemEnvironmentVariables() == settings.isIncludeSystemEnvironmentVariables() && Objects.deepEquals(this.getMappings(), settings.getMappings()) && isEquals(this.getClientConfigurationContent(), settings.getClientConfigurationContent()) - && isEquals(this.getInstallerConfigurationContent(), settings.getInstallerConfigurationContent()))) { + && isEquals(this.getInstallerConfigurationContent(), settings.getInstallerConfigurationContent()) + && isEquals(this.getWorkspaceFolderStrategyConfiguration(), settings.getWorkspaceFolderStrategyConfiguration()))) { return true; } } @@ -221,11 +223,13 @@ public void reset() { // User defined language server if (userSettings != null) { this.setCommandLine(userSettings.getCommandLine()); + this.setWorkingDir(userSettings.getWorkingDir()); this.setEnvData(EnvironmentVariablesData.create( userSettings.getUserEnvironmentVariables(), userSettings.isIncludeSystemEnvironmentVariables())); this.setClientConfigurationContent(userSettings.getClientConfigurationContent()); this.setInstallerConfigurationContent(userSettings.getInstallerConfigurationContent()); + this.setWorkspaceFolderStrategyConfiguration(userSettings.getWorkspaceFolderStrategyConfiguration()); List languageMappings = userSettings.getMappings() .stream() @@ -339,11 +343,13 @@ public void apply() { getDisplayName(), getServerUrl(), getCommandLine(), + getWorkingDir(), getEnvData() != null ? getEnvData().getEnvs() : null, isIncludeSystemEnvironmentVariables(), getMappings(), getClientConfigurationContent(), - getInstallerConfigurationContent()), + getInstallerConfigurationContent(), + getWorkspaceFolderStrategyConfiguration()), false); if (projectSettingsChangedEvent != null) { // Settings has changed, fire the event @@ -386,10 +392,22 @@ private JPanel createSettings(JComponent description, boolean launchingServerDef .createFormBuilder() .setFormLeftIndent(10); var uiConfiguration = createUIConfiguration(); + + // Get workspace folder strategy from language server definition + com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy workspaceFolderStrategy = null; + try { + var clientFeatures = languageServerDefinition.createClientFeatures(); + clientFeatures.setServerDefinition(languageServerDefinition); + workspaceFolderStrategy = clientFeatures.getWorkspaceFolderFeature().getStrategy(); + } catch (Exception e) { + // Ignore, will use default + } + this.languageServerPanel = new LanguageServerPanel(builder, description, uiConfiguration, canExecuteInstaller, + workspaceFolderStrategy, project); if (languageServerDefinition instanceof UserDefinedLanguageServerDefinition def) { languageServerPanel.setCommandLineUpdater(new UICommandLineUpdater(def, project)); @@ -439,6 +457,9 @@ private UIConfiguration createUIConfiguration() { // Installer tab configuration configuration.setShowInstaller(isUserDefined); + // Workspace folders tab configuration + configuration.setShowWorkspaceFolders(true); + return configuration; } @@ -518,6 +539,14 @@ public void setCommandLine(@Nullable String commandLine) { languageServerPanel.setCommandLine(commandLine); } + public @Nullable String getWorkingDir() { + return languageServerPanel.getWorkingDir(); + } + + public void setWorkingDir(@Nullable String workingDir) { + languageServerPanel.setWorkingDir(workingDir); + } + public String getServerUrl() { return languageServerPanel.getServerUrl(); } @@ -610,6 +639,17 @@ public void setInstallerConfigurationContent(String installerConfigurationConten languageServerPanel.setInstallerConfigurationContent(installerConfigurationContent); } + public String getWorkspaceFolderStrategyConfiguration() { + return languageServerPanel.getWorkspaceFoldersPanel() != null ? + languageServerPanel.getWorkspaceFoldersPanel().getJsonConfiguration() : null; + } + + public void setWorkspaceFolderStrategyConfiguration(String workspaceFolderStrategyConfiguration) { + if (languageServerPanel.getWorkspaceFoldersPanel() != null) { + languageServerPanel.getWorkspaceFoldersPanel().setJsonConfiguration(workspaceFolderStrategyConfiguration); + } + } + @Override public void dispose() { languageServerPanel.dispose(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/ProjectLanguageServerSettings.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/ProjectLanguageServerSettings.java index 3bb531bd7..2e872644d 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/ProjectLanguageServerSettings.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/ProjectLanguageServerSettings.java @@ -16,11 +16,8 @@ import com.intellij.openapi.components.State; import com.intellij.openapi.components.Storage; import com.intellij.openapi.project.Project; -import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -import static com.redhat.devtools.lsp4ij.settings.LanguageServerView.isEquals; - /** * Language server settings for a given Language server definition stored in Project scope. * diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/UIConfiguration.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/UIConfiguration.java index 895fd7647..476f8c750 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/UIConfiguration.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/UIConfiguration.java @@ -40,6 +40,9 @@ public class UIConfiguration { // Installer tab configuration private boolean showInstaller; + // Workspace folders tab configuration + private boolean showWorkspaceFolders; + /** * Returns whether the server name should be displayed in the UI. * @@ -219,4 +222,22 @@ public boolean isShowInstaller() { public void setShowInstaller(boolean showInstaller) { this.showInstaller = showInstaller; } + + /** + * Returns whether the workspace folders tab is shown. + * + * @return true if shown, false otherwise + */ + public boolean isShowWorkspaceFolders() { + return showWorkspaceFolders; + } + + /** + * Sets whether the workspace folders tab is shown. + * + * @param showWorkspaceFolders true to show the workspace folders tab, false to hide it + */ + public void setShowWorkspaceFolders(boolean showWorkspaceFolders) { + this.showWorkspaceFolders = showWorkspaceFolders; + } } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPJsonSchemaProviderFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPJsonSchemaProviderFactory.java index a6b894a31..bb2775b74 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPJsonSchemaProviderFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPJsonSchemaProviderFactory.java @@ -31,6 +31,7 @@ public class LSPJsonSchemaProviderFactory implements JsonSchemaProviderFactory { public List getProviders(@NotNull Project project) { List providers = new ArrayList<>(); providers.add(new LSPClientConfigurationJsonSchemaFileProvider()); + providers.add(new LSPWorkspaceFoldersJsonSchemaFileProvider()); providers.add(new ServerInstallerJsonSchemaFileProvider()); // Create 100 dummy JsonSchemaFileProvider used by Server / Configuration editors. providers.addAll(LSPServerConfigurationJsonSchemaManager.getInstance(project).getProviders()); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPWorkspaceFoldersJsonSchemaFileProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPWorkspaceFoldersJsonSchemaFileProvider.java new file mode 100644 index 000000000..c0b1f011a --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/jsonSchema/LSPWorkspaceFoldersJsonSchemaFileProvider.java @@ -0,0 +1,27 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.settings.jsonSchema; + +/** + * JSON schema file provider for workspace folders configuration. + */ +public class LSPWorkspaceFoldersJsonSchemaFileProvider extends AbstractLSPJsonSchemaFileSystemProvider { + + private static final String WORKSPACE_FOLDERS_SCHEMA_JSON_PATH = "/jsonSchema/workspaceFolders.schema.json"; + public static final String WORKSPACE_FOLDERS_JSON_FILE_NAME = "workspaceFolders.json"; + + LSPWorkspaceFoldersJsonSchemaFileProvider() { + super(WORKSPACE_FOLDERS_SCHEMA_JSON_PATH, WORKSPACE_FOLDERS_JSON_FILE_NAME); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/JsonTextField.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/JsonTextField.java index fe2dab4a1..787355f51 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/JsonTextField.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/JsonTextField.java @@ -120,7 +120,7 @@ public JComponent getComponent() { public boolean hasErrors() { VirtualFile file = LSPIJUtils.getFile(editorTextField.getDocument()); - return PsiErrorElementUtil.hasErrors(getProject(), file); + return file != null && PsiErrorElementUtil.hasErrors(getProject(), file); } public @NotNull Project getProject() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/LanguageServerPanel.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/LanguageServerPanel.java index f56724935..fb36b0bfa 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/LanguageServerPanel.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/LanguageServerPanel.java @@ -14,8 +14,10 @@ import com.intellij.execution.configuration.EnvironmentVariablesData; import com.intellij.ide.BrowserUtil; import com.intellij.openapi.Disposable; +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; import com.intellij.openapi.project.Project; import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; import com.intellij.openapi.util.NlsContexts; import com.intellij.ui.ContextHelpLabel; import com.intellij.ui.HyperlinkLabel; @@ -30,6 +32,7 @@ import com.intellij.util.ui.UIUtil; import com.intellij.util.ui.components.BorderLayoutPanel; import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; import com.redhat.devtools.lsp4ij.installation.CommandLineUpdater; import com.redhat.devtools.lsp4ij.internal.StringUtils; import com.redhat.devtools.lsp4ij.settings.ErrorReportingKind; @@ -67,11 +70,13 @@ public class LanguageServerPanel implements Disposable { private final PortField debugPortField = new PortField(); private final JBCheckBox debugSuspendCheckBox = new JBCheckBox(LanguageServerBundle.message("language.server.debug.suspend")); private final boolean canExecuteInstaller; + private final WorkspaceFolderStrategy workspaceFolderStrategy; private final JBCheckBox expandConfigurationCheckBox = new JBCheckBox(LanguageServerBundle.message("language.server.configuration.expand")); private HyperlinkLabel editJsonSchemaAction; private JBTextField serverName; private @Nullable EnvironmentVariablesComponent environmentVariables; private CommandLineWidget commandLine; + private TextFieldWithBrowseButton workingDir; private ServerMappingsPanel mappingsPanel; private @Nullable SchemaBackedJsonTextField configurationWidget; private String configurationSchemaContent; @@ -81,13 +86,24 @@ public class LanguageServerPanel implements Disposable { private @Nullable InstallerPanel installerPanel; private @Nullable String serverUrl; private HyperlinkLabel serverUrlHyperlink; + private WorkspaceFoldersPanel workspaceFoldersPanel; public LanguageServerPanel(@NotNull FormBuilder builder, @Nullable JComponent description, @NotNull UIConfiguration uiConfiguration, boolean canExecuteInstaller, @Nullable Project project) { + this(builder, description, uiConfiguration, canExecuteInstaller, null, project); + } + + public LanguageServerPanel(@NotNull FormBuilder builder, + @Nullable JComponent description, + @NotNull UIConfiguration uiConfiguration, + boolean canExecuteInstaller, + @Nullable WorkspaceFolderStrategy workspaceFolderStrategy, + @Nullable Project project) { this.canExecuteInstaller = canExecuteInstaller; + this.workspaceFolderStrategy = workspaceFolderStrategy; this.project = project; createUI(builder, description, uiConfiguration); } @@ -152,6 +168,53 @@ public void changedUpdate(DocumentEvent e) { }); } + /** + * Update preview working directory label with expanded macro response if needed. + * + * @param workingDir the working directory field which could contains macro syntax. + * @param previewLabel the preview label. + * @param project the project. + * @see Built In Macro + */ + private static void updatePreviewWorkingDir(@NotNull TextFieldWithBrowseButton workingDir, + @NotNull JLabel previewLabel, + @NotNull Project project) { + workingDir.getTextField().getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + updateLabel(previewLabel); + } + + private void updateLabel(JLabel previewLabel) { + String text = workingDir.getText(); + if (text.isBlank()) { + previewLabel.setToolTipText(""); + previewLabel.setText(""); + return; + } + String preview = resolveCommandLine(text, project); + if (preview.equals(text)) { + previewLabel.setToolTipText(""); + previewLabel.setText(""); + } else { + previewLabel.setToolTipText(preview); + String shortPreview = preview.length() > COMMAND_LENGTH_MAX ? preview.substring(0, COMMAND_LENGTH_MAX) + "..." : preview; + previewLabel.setText(shortPreview); + } + } + + @Override + public void removeUpdate(DocumentEvent e) { + updateLabel(previewLabel); + } + + @Override + public void changedUpdate(DocumentEvent e) { + updateLabel(previewLabel); + } + }); + } + private static JLabel createLabelForComponent(@NotNull @NlsContexts.Label String labelText, @NotNull JComponent component) { JLabel label = new JLabel(UIUtil.replaceMnemonicAmpersand(labelText)); label.setLabelFor(component); @@ -172,6 +235,9 @@ private void createUI(@NotNull FormBuilder builder, // Configuration tab to fill LSP Configuration + LSP Initialize Options addConfigurationTab(tabbedPane, uiConfiguration); + // Workspace Folders tab + addWorkspaceFoldersTab(tabbedPane, uiConfiguration); + // Installer tab to fill installer of the LSP server addInstallerTab(tabbedPane, uiConfiguration); @@ -196,6 +262,8 @@ private void addServerTab(@NotNull JBTabbedPane tabbedPane, serverTab.addComponent(environmentVariables); // Command line createCommandLineField(serverTab); + // Working directory + createWorkingDirField(serverTab); } } @@ -209,7 +277,7 @@ private void createServerUrl(@NotNull FormBuilder serverTab) { private void addMappingsTab(@NotNull JBTabbedPane tabbedPane, @NotNull UIConfiguration configuration) { - FormBuilder mappingsTab = addTab(tabbedPane, LanguageServerBundle.message("language.server.tab.mappings")); + FormBuilder mappingsTab = addTab(tabbedPane, LanguageServerBundle.message("language.server.tab.mappings"), false); this.mappingsPanel = new ServerMappingsPanel(mappingsTab, configuration.isServerMappingsEditable()); } @@ -277,6 +345,20 @@ public void addPostInstallAction(@NotNull Runnable action) { } } + private void addWorkspaceFoldersTab(@NotNull JBTabbedPane tabbedPane, @NotNull UIConfiguration uiConfiguration) { + if (!uiConfiguration.isShowWorkspaceFolders()) { + return; + } + workspaceFoldersPanel = new WorkspaceFoldersPanel(project, workspaceFolderStrategy); + JScrollPane scrollPane = new JBScrollPane(workspaceFoldersPanel); + tabbedPane.addTab(LanguageServerBundle.message("language.server.tab.workspaceFolders"), scrollPane); + } + + @Nullable + public WorkspaceFoldersPanel getWorkspaceFoldersPanel() { + return workspaceFoldersPanel; + } + private void addDebugTab(@NotNull JBTabbedPane tabbedPane, @NotNull UIConfiguration configuration) { if (!configuration.isShowDebug()) { @@ -341,6 +423,15 @@ private void createCommandLineField(@NotNull FormBuilder builder) { builder.addComponent(previewCommandLabel, 0); } + private void createWorkingDirField(@NotNull FormBuilder builder) { + workingDir = new TextFieldWithBrowseButton(); + workingDir.addBrowseFolderListener(null, null, project, FileChooserDescriptorFactory.createSingleFolderDescriptor()); + JLabel previewWorkingDirLabel = createLabelForComponent("", workingDir); + updatePreviewWorkingDir(workingDir, previewWorkingDirLabel, project); + builder.addLabeledComponent(LanguageServerBundle.message("language.server.workingDir"), workingDir); + builder.addComponent(previewWorkingDirLabel, 0); + } + private void createConfigurationField(@NotNull FormBuilder builder) { // Create the hyperlink "Edit JSON Schema" / "Associate with JSON Schema". editJsonSchemaAction = new HyperlinkLabel(LanguageServerBundle.message("language.server.configuration.json.schema.associate")); @@ -416,6 +507,17 @@ public void setCommandLine(@Nullable String commandLine) { this.commandLine.setText(commandLine != null ? commandLine : ""); } + public @Nullable String getWorkingDir() { + return workingDir != null ? workingDir.getText() : null; + } + + public void setWorkingDir(@Nullable String workingDir) { + if (this.workingDir == null) { + return; + } + this.workingDir.setText(workingDir != null ? workingDir : ""); + } + public ServerMappingsPanel getMappingsPanel() { return mappingsPanel; } @@ -507,6 +609,13 @@ public void setInstallerConfigurationContent(@Nullable String installerConfigura installerConfigurationWidget.setCaretPosition(0); } + public void setWorkspaceFolderStrategyConfiguration(@Nullable String workspaceFolderStrategyConfiguration) { + if (workspaceFoldersPanel == null) { + return; + } + workspaceFoldersPanel.setJsonConfiguration(workspaceFolderStrategyConfiguration); + } + public JBCheckBox getDebugSuspendCheckBox() { return debugSuspendCheckBox; } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/ServerMappingsPanel.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/ServerMappingsPanel.java index 4495a415b..44ca4edb5 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/ServerMappingsPanel.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/ServerMappingsPanel.java @@ -50,7 +50,7 @@ public ServerMappingsPanel(@NotNull FormBuilder builder, boolean editable) { private void createContent(@NotNull FormBuilder builder, boolean editable) { tabbedPane = new JBTabbedPane(); - builder.addLabeledComponent(LanguageServerBundle.message("language.server.mappings.title"), tabbedPane, true); + builder.addLabeledComponentFillVertically(LanguageServerBundle.message("language.server.mappings.title"), tabbedPane); // Language mappings createLanguageMappingsContent(tabbedPane, editable); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/WorkspaceFoldersPanel.java b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/WorkspaceFoldersPanel.java new file mode 100644 index 000000000..bd687b75e --- /dev/null +++ b/src/main/java/com/redhat/devtools/lsp4ij/settings/ui/WorkspaceFoldersPanel.java @@ -0,0 +1,735 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 + * which is available at https://www.apache.org/licenses/LICENSE-2.0. + * + * SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 + * + * Contributors: + * Red Hat Inc. - initial API and implementation + *******************************************************************************/ +package com.redhat.devtools.lsp4ij.settings.ui; + +import com.intellij.icons.AllIcons; +import com.intellij.ide.BrowserUtil; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.editor.event.DocumentEvent; +import com.intellij.openapi.editor.event.DocumentListener; +import com.intellij.openapi.fileChooser.FileChooser; +import com.intellij.openapi.fileChooser.FileChooserDescriptor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.ColoredTreeCellRenderer; +import com.intellij.ui.HyperlinkLabel; +import com.intellij.ui.OnePixelSplitter; +import com.intellij.ui.SimpleTextAttributes; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.ui.treeStructure.Tree; +import com.intellij.util.Alarm; +import com.intellij.util.ui.JBUI; +import com.intellij.util.ui.StatusText; +import com.intellij.util.ui.UIUtil; +import com.redhat.devtools.lsp4ij.LSPIJUtils; +import com.redhat.devtools.lsp4ij.LanguageServerBundle; +import com.redhat.devtools.lsp4ij.client.features.FileUriSupport; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ConfigurableWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.ProjectWorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.RootType; +import com.redhat.devtools.lsp4ij.features.workspaceFolder.WorkspaceFolderStrategy; +import com.redhat.devtools.lsp4ij.settings.jsonSchema.LSPWorkspaceFoldersJsonSchemaFileProvider; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; +import java.awt.*; +import java.awt.datatransfer.DataFlavor; +import java.awt.dnd.DnDConstants; +import java.awt.dnd.DropTarget; +import java.awt.dnd.DropTargetAdapter; +import java.awt.dnd.DropTargetDropEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.io.File; +import java.net.URI; +import java.nio.file.Paths; +import java.util.*; +import java.util.List; + +/** + * Panel for configuring workspace folders with testing capabilities. + */ +public class WorkspaceFoldersPanel extends JPanel implements Disposable { + + private static final String NO_ROOT_NODE_KEY = "language.server.workspaceFolders.noRoot"; + + private final Project project; + private final WorkspaceFolderStrategy strategy; + private final boolean showJsonEditor; + private JsonTextField jsonEditor; + private final Tree workspaceFoldersTree; + private final DefaultTreeModel treeModel; + private final DefaultMutableTreeNode rootNode; + private final Map folderNodes = new HashMap<>(); + private DefaultMutableTreeNode noRootNode; + private final Set openedFiles = new HashSet<>(); + + private JBCheckBox sendAtInitCheckbox; + + private Alarm updateAlarm; + + public WorkspaceFoldersPanel(@NotNull Project project, @Nullable WorkspaceFolderStrategy strategy) { + super(new BorderLayout()); + this.project = project; + this.strategy = strategy != null ? strategy : new ProjectWorkspaceFolderStrategy(); + this.showJsonEditor = this.strategy instanceof ConfigurableWorkspaceFolderStrategy; + + // Create tree model + rootNode = new DefaultMutableTreeNode("Workspace Folders"); + treeModel = new DefaultTreeModel(rootNode); + workspaceFoldersTree = new Tree(treeModel); + workspaceFoldersTree.setRootVisible(false); + workspaceFoldersTree.setCellRenderer(new WorkspaceFolderTreeCellRenderer()); + + // Setup empty text with hyperlink for scanning + StatusText emptyText = workspaceFoldersTree.getEmptyText(); + emptyText.clear(); + + // Setup drag and drop on tree + setupDragAndDrop(workspaceFoldersTree); + + // Setup context menu and delete key for tested files + setupTreeActions(); + + // Setup mouse listener for clickable links in tree + setupTreeLinkListener(); + + JPanel jsonEditorPanel = null; + if (showJsonEditor) { + this.updateAlarm = new Alarm(Alarm.ThreadToUse.POOLED_THREAD, this); + + // Create JSON editor + jsonEditor = new JsonTextField(project); + jsonEditor.setJsonFilename(LSPWorkspaceFoldersJsonSchemaFileProvider.WORKSPACE_FOLDERS_JSON_FILE_NAME); + + // Initialize with current configuration + if (this.strategy instanceof ConfigurableWorkspaceFolderStrategy configurableWorkspaceFolderStrategy) { + String currentConfig = configurableWorkspaceFolderStrategy.getJsonConfiguration(); + jsonEditor.setText(currentConfig); + } + + // Add document listener to refresh workspace folders list with debounce + jsonEditor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void documentChanged(@NotNull DocumentEvent event) { + updateAlarm.cancelAllRequests(); + updateAlarm.addRequest(() -> { + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater(() -> { + updateWorkspaceFoldersDisplay(); + }); + }, 500); + } + }); + + jsonEditorPanel = new JPanel(new BorderLayout()); + jsonEditorPanel.add(jsonEditor, BorderLayout.CENTER); + jsonEditorPanel.setBorder(JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())); + } + + // Main layout + if (showJsonEditor) { + OnePixelSplitter splitter = new OnePixelSplitter(false, 0.5f); + splitter.setFirstComponent(createLeftPanel(jsonEditorPanel)); + splitter.setSecondComponent(createRightPanel()); + add(splitter, BorderLayout.CENTER); + } else { + add(createRightPanel(), BorderLayout.CENTER); + } + + // Initialize + updateWorkspaceFoldersDisplay(); + } + + private JPanel createLeftPanel(JPanel jsonEditorPanel) { + JPanel panel = new JPanel(new BorderLayout()); + + // Create header panel with label and doc link + JPanel headerPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 5)); + JLabel label = new JLabel(LanguageServerBundle.message("language.server.workspaceFolders.configuration")); + headerPanel.add(label); + + HyperlinkLabel docLink = new HyperlinkLabel("Learn more"); + docLink.addHyperlinkListener(e -> BrowserUtil.browse("https://github.com/redhat-developer/lsp4ij/blob/main/docs/UserDefinedLanguageServer.md#workspace-folders-tab")); + headerPanel.add(docLink); + + panel.add(headerPanel, BorderLayout.NORTH); + panel.add(jsonEditorPanel, BorderLayout.CENTER); + return panel; + } + + private JPanel createRightPanel() { + JPanel panel = new JPanel(new BorderLayout()); + + // Header with label and description + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBorder(JBUI.Borders.empty(5)); + + JPanel labelPanel = new JPanel(new BorderLayout()); + JLabel label = new JLabel(LanguageServerBundle.message("language.server.workspaceFolders.detected")); + labelPanel.add(label, BorderLayout.NORTH); + + // Add description to explain this is a preview/test panel + JBLabel descriptionLabel = new JBLabel(LanguageServerBundle.message("language.server.workspaceFolders.detected.description")); + descriptionLabel.setForeground(UIUtil.getLabelDisabledForeground()); + descriptionLabel.setBorder(JBUI.Borders.emptyTop(2)); + descriptionLabel.setFont(JBUI.Fonts.smallFont()); + labelPanel.add(descriptionLabel, BorderLayout.CENTER); + + headerPanel.add(labelPanel, BorderLayout.WEST); + + // Test zone (above tree) + JPanel testPanel = createTestPanel(); + + // Combine header and test panel + JPanel topPanel = new JPanel(new BorderLayout()); + topPanel.add(headerPanel, BorderLayout.NORTH); + topPanel.add(testPanel, BorderLayout.SOUTH); + + // Tree + JPanel foldersPanel = new JPanel(new BorderLayout()); + foldersPanel.add(topPanel, BorderLayout.NORTH); + + JScrollPane treeScrollPane = new JBScrollPane(workspaceFoldersTree); + treeScrollPane.setBorder(JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())); + foldersPanel.add(treeScrollPane, BorderLayout.CENTER); + + panel.add(foldersPanel, BorderLayout.CENTER); + + return panel; + } + + private JPanel createTestPanel() { + JPanel panel = new JPanel(new BorderLayout()); + panel.setBorder(JBUI.Borders.empty(5)); + + // Compact drop zone + JPanel dropZone = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 3)); + dropZone.setBorder(JBUI.Borders.customLine(JBUI.CurrentTheme.CustomFrameDecorations.separatorForeground())); + dropZone.setBackground(UIUtil.getPanelBackground()); + + JBLabel testLabel = new JBLabel(LanguageServerBundle.message("language.server.workspaceFolders.test")); + testLabel.setForeground(UIUtil.getLabelDisabledForeground()); + testLabel.setToolTipText(LanguageServerBundle.message("language.server.workspaceFolders.test.description")); + dropZone.add(testLabel); + + HyperlinkLabel dropZoneLabel = new HyperlinkLabel(); + dropZoneLabel.setTextWithHyperlink(LanguageServerBundle.message("language.server.workspaceFolders.dropZone")); + dropZoneLabel.addHyperlinkListener(e -> browseFile()); + dropZone.add(dropZoneLabel); + + // Send at init checkbox (only for configurable strategy) + if (showJsonEditor) { + dropZone.add(new JLabel(" - ")); + + // Create checkbox without text + sendAtInitCheckbox = new JBCheckBox("", false); + sendAtInitCheckbox.setOpaque(false); + sendAtInitCheckbox.setToolTipText(LanguageServerBundle.message("language.server.workspaceFolders.sendAtInit.tooltip")); + sendAtInitCheckbox.addActionListener(e -> { + ApplicationManager.getApplication().invokeLater(this::updateWorkspaceFoldersDisplay); + }); + dropZone.add(sendAtInitCheckbox); + + // Create label with clickable link + HyperlinkLabel sendAtInitLabel = new HyperlinkLabel(); + sendAtInitLabel.setTextWithHyperlink(LanguageServerBundle.message("language.server.workspaceFolders.sendAtInit")); + sendAtInitLabel.addHyperlinkListener(e -> BrowserUtil.browse("https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize")); + dropZone.add(sendAtInitLabel); + } + + // Setup drag and drop + setupDragAndDrop(dropZone); + + panel.add(dropZone, BorderLayout.CENTER); + + return panel; + } + + private void setupDragAndDrop(Component component) { + new DropTarget(component, new DropTargetAdapter() { + @Override + public void drop(DropTargetDropEvent dtde) { + try { + dtde.acceptDrop(DnDConstants.ACTION_COPY); + @SuppressWarnings("unchecked") + List files = (List) + dtde.getTransferable().getTransferData(DataFlavor.javaFileListFlavor); + if (!files.isEmpty()) { + VirtualFile vFile = com.intellij.openapi.vfs.LocalFileSystem.getInstance() + .findFileByIoFile(files.get(0)); + if (vFile != null) { + testFile(vFile); + } + } + dtde.dropComplete(true); + } catch (Exception e) { + dtde.dropComplete(false); + } + } + }); + } + + private void setupTreeActions() { + // Add Delete key support + workspaceFoldersTree.addKeyListener(new java.awt.event.KeyAdapter() { + @Override + public void keyPressed(java.awt.event.KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DELETE) { + removeSelectedFile(); + } + } + }); + + // Add context menu + workspaceFoldersTree.addMouseListener(new java.awt.event.MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (e.isPopupTrigger()) { + showContextMenu(e); + } + } + + @Override + public void mouseReleased(java.awt.event.MouseEvent e) { + if (e.isPopupTrigger()) { + showContextMenu(e); + } + } + }); + } + + private void setupTreeLinkListener() { + workspaceFoldersTree.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + TreePath path = workspaceFoldersTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + + DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent(); + if (node.getUserObject() instanceof FolderNodeData) { + FolderNodeData data = (FolderNodeData) node.getUserObject(); + + // Open LSP specification link + String url; + if (data.sentAtInit) { + url = "https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize"; + } else { + url = "https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_didChangeWorkspaceFolders"; + } + + BrowserUtil.browse(url); + } + } + }); + + // Change cursor to hand when hovering over folder nodes + workspaceFoldersTree.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + TreePath path = workspaceFoldersTree.getPathForLocation(e.getX(), e.getY()); + if (path != null) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent(); + if (node.getUserObject() instanceof FolderNodeData) { + workspaceFoldersTree.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + return; + } + } + workspaceFoldersTree.setCursor(Cursor.getDefaultCursor()); + } + }); + } + + private void showContextMenu(MouseEvent e) { + TreePath path = workspaceFoldersTree.getPathForLocation(e.getX(), e.getY()); + if (path == null) { + return; + } + + DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent(); + if (node.getUserObject() instanceof FileNodeData) { + workspaceFoldersTree.setSelectionPath(path); + + JPopupMenu popup = new JPopupMenu(); + JMenuItem removeItem = new JMenuItem(LanguageServerBundle.message("language.server.workspaceFolders.remove")); + removeItem.addActionListener(evt -> removeSelectedFile()); + popup.add(removeItem); + popup.show(e.getComponent(), e.getX(), e.getY()); + } + } + + private void removeSelectedFile() { + TreePath selectedPath = workspaceFoldersTree.getSelectionPath(); + if (selectedPath == null) { + return; + } + + DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) selectedPath.getLastPathComponent(); + if (!(selectedNode.getUserObject() instanceof FileNodeData)) { + return; + } + + FileNodeData fileData = (FileNodeData) selectedNode.getUserObject(); + + // Remove from opened files tracking + openedFiles.remove(fileData.file); + + // Remove the file node + DefaultMutableTreeNode parentNode = (DefaultMutableTreeNode) selectedNode.getParent(); + treeModel.removeNodeFromParent(selectedNode); + + // If parent folder is now empty and not in the config folders, remove it too + if (parentNode != null && parentNode.getChildCount() == 0) { + Object parentUserObject = parentNode.getUserObject(); + + if (parentUserObject instanceof FolderNodeData) { + FolderNodeData folderData = (FolderNodeData) parentUserObject; + String folderUri = FileUriSupport.toString(folderData.file, FileUriSupport.DEFAULT); + + // Only remove folder node if it's not in the current config + boolean isConfigFolder = false; + List configFolders; + + boolean showInitFolders = sendAtInitCheckbox != null && sendAtInitCheckbox.isSelected(); + if (showInitFolders) { + configFolders = strategy.getInitialWorkspaceFolders(project, FileUriSupport.DEFAULT); + } else { + configFolders = strategy.getWorkspaceFolders(project, FileUriSupport.DEFAULT); + } + + for (WorkspaceFolder folder : configFolders) { + if (folder.getUri().equals(folderUri)) { + isConfigFolder = true; + break; + } + } + + if (!isConfigFolder) { + treeModel.removeNodeFromParent(parentNode); + if (folderUri != null) { + folderNodes.remove(folderUri); + } + } + } else if (NO_ROOT_NODE_KEY.equals(parentUserObject)) { + // Remove "No root" node if empty + treeModel.removeNodeFromParent(parentNode); + noRootNode = null; + } + } + } + + private void browseFile() { + FileChooserDescriptor descriptor = new FileChooserDescriptor(true, true, false, false, false, false); + VirtualFile file = FileChooser.chooseFile(descriptor, project, null); + if (file != null) { + testFile(file); + } + } + + private void testFile(@NotNull VirtualFile file) { + // Always run slow operation in background thread + ApplicationManager.getApplication().executeOnPooledThread(() -> { + WorkspaceFolder folder = strategy.getWorkspaceFolderForFile(file, project, FileUriSupport.DEFAULT); + + // Update UI on EDT + ApplicationManager.getApplication().invokeLater(() -> { + addTestedFile(file, folder); + }); + }); + } + + private void addTestedFile(@NotNull VirtualFile file, @Nullable WorkspaceFolder folder) { + // Track opened file + openedFiles.add(file); + + if (folder != null) { + // Find or create the folder node + DefaultMutableTreeNode folderNode = folderNodes.get(folder.getUri()); + if (folderNode == null) { + VirtualFile folderFile = LSPIJUtils.findResourceFor(folder.getUri()); + if (folderFile != null) { + folderNode = new DefaultMutableTreeNode(new FolderNodeData(folderFile, true)); + folderNodes.put(folder.getUri(), folderNode); + treeModel.insertNodeInto(folderNode, rootNode, rootNode.getChildCount()); + } + } + + if (folderNode != null) { + // Add file under folder + DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode(new FileNodeData(file)); + treeModel.insertNodeInto(fileNode, folderNode, folderNode.getChildCount()); + workspaceFoldersTree.expandPath(new TreePath(folderNode.getPath())); + } + } else { + // Add to "No root" node + if (noRootNode == null) { + noRootNode = new DefaultMutableTreeNode(NO_ROOT_NODE_KEY); + treeModel.insertNodeInto(noRootNode, rootNode, rootNode.getChildCount()); + } + DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode(new FileNodeData(file)); + treeModel.insertNodeInto(fileNode, noRootNode, noRootNode.getChildCount()); + workspaceFoldersTree.expandPath(new TreePath(noRootNode.getPath())); + } + } + + + private void updateWorkspaceFoldersDisplay() { + // Clear tree + rootNode.removeAllChildren(); + folderNodes.clear(); + noRootNode = null; + + try { + // Apply JSON configuration if available + if (showJsonEditor && jsonEditor != null && strategy instanceof ConfigurableWorkspaceFolderStrategy) { + String jsonContent = jsonEditor.getText().trim(); + ((ConfigurableWorkspaceFolderStrategy) strategy).configure(jsonContent); + } + + // Update empty text based on strategy type + updateEmptyText(); + + // Get folders based on checkbox state (for preview/testing) + List folders; + boolean showInitFolders = sendAtInitCheckbox != null && sendAtInitCheckbox.isSelected(); + + if (showInitFolders) { + // Show only what would be sent at initialization (respects lazy config) + folders = strategy.getInitialWorkspaceFolders(project, FileUriSupport.DEFAULT); + } else { + // Show all available workspace folders (discovery/preview mode) + folders = strategy.getWorkspaceFolders(project, FileUriSupport.DEFAULT); + } + + // Add folders to tree + for (WorkspaceFolder folder : folders) { + VirtualFile file = LSPIJUtils.findResourceFor(folder.getUri()); + if (file != null) { + DefaultMutableTreeNode node = new DefaultMutableTreeNode(new FolderNodeData(file, strategy.sendAllFoldersOnInitialization())); + folderNodes.put(folder.getUri(), node); + treeModel.insertNodeInto(node, rootNode, rootNode.getChildCount()); + } + } + + // Re-test opened files with new configuration + for (VirtualFile openedFile : openedFiles) { + WorkspaceFolder folder = strategy.getWorkspaceFolderForFile(openedFile, project, FileUriSupport.DEFAULT); + + if (folder != null) { + // Find or create folder node + DefaultMutableTreeNode folderNode = folderNodes.get(folder.getUri()); + if (folderNode == null) { + VirtualFile folderFile = LSPIJUtils.findResourceFor(folder.getUri()); + if (folderFile != null) { + folderNode = new DefaultMutableTreeNode(new FolderNodeData(folderFile, strategy.sendAllFoldersOnInitialization())); + folderNodes.put(folder.getUri(), folderNode); + treeModel.insertNodeInto(folderNode, rootNode, rootNode.getChildCount()); + } + } + + if (folderNode != null) { + // Add file under folder + DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode(new FileNodeData(openedFile)); + treeModel.insertNodeInto(fileNode, folderNode, folderNode.getChildCount()); + } + } else { + // File has no workspace folder, add to "No root" + if (noRootNode == null) { + noRootNode = new DefaultMutableTreeNode(NO_ROOT_NODE_KEY); + treeModel.insertNodeInto(noRootNode, rootNode, rootNode.getChildCount()); + } + DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode(new FileNodeData(openedFile)); + treeModel.insertNodeInto(fileNode, noRootNode, noRootNode.getChildCount()); + } + } + + treeModel.reload(); + expandAll(); + } catch (Exception e) { + // On error, clear the tree + treeModel.reload(); + } + } + + private void updateEmptyText() { + StatusText emptyText = workspaceFoldersTree.getEmptyText(); + emptyText.clear(); + + if (!(strategy instanceof ConfigurableWorkspaceFolderStrategy)) { + emptyText.setText("No workspace folders"); + return; + } + + ConfigurableWorkspaceFolderStrategy configurableStrategy = (ConfigurableWorkspaceFolderStrategy) strategy; + RootType rootType = configurableStrategy.getRootType(); + boolean isLazy = !strategy.sendAllFoldersOnInitialization(); + + if (rootType == RootType.MARKERS) { + // Multi-line message for markers mode + boolean hasOpenedFiles = !openedFiles.isEmpty(); + + if (hasOpenedFiles) { + // Files have been tested + emptyText.setText("Workspace folders are discovered when you open a file,"); + emptyText.appendLine("by walking up to find marker files."); + } else { + // No files tested yet, explicit instruction to open a file + emptyText.setText("Workspace folders are discovered when you open a file,"); + emptyText.appendLine("by walking up to find marker files."); + emptyText.appendLine(""); + emptyText.appendText("Please "); + emptyText.appendText("Open a file", SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES, e -> browseFile()); + emptyText.appendText(" to test discovery."); + } + } else if (isLazy) { + boolean hasOpenedFiles = !openedFiles.isEmpty(); + if (hasOpenedFiles) { + emptyText.setText("Workspace folders will be discovered dynamically as files are opened"); + } else { + emptyText.setText("Workspace folders are discovered when you open a file."); + emptyText.appendLine(""); + emptyText.appendText("Drag & drop a file here or "); + emptyText.appendText("Open a file", SimpleTextAttributes.LINK_PLAIN_ATTRIBUTES, e -> browseFile()); + emptyText.appendText(" to test discovery."); + } + } else { + emptyText.setText("No workspace folders"); + } + } + + private void expandAll() { + for (int i = 0; i < workspaceFoldersTree.getRowCount(); i++) { + workspaceFoldersTree.expandRow(i); + } + } + + @Nullable + public String getJsonConfiguration() { + if (!showJsonEditor || jsonEditor == null) { + return null; + } + String jsonContent = jsonEditor.getText().trim(); + return (jsonContent.isEmpty() || jsonContent.equals("{}")) ? null : jsonContent; + } + + public void setJsonConfiguration(@Nullable String jsonConfiguration) { + if (!showJsonEditor || jsonEditor == null) { + return; + } + if (jsonConfiguration == null || jsonConfiguration.trim().isEmpty()) { + jsonEditor.setText("{}"); + } else { + jsonEditor.setText(jsonConfiguration); + } + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater(() -> { + updateWorkspaceFoldersDisplay(); + }); + } + + @Override + public void dispose() { + if (updateAlarm != null && !updateAlarm.isDisposed()) { + updateAlarm.cancelAllRequests(); + } + } + + // Data classes for tree nodes + private static class FolderNodeData { + final VirtualFile file; + final boolean sentAtInit; + + FolderNodeData(VirtualFile file, boolean sentAtInit) { + this.file = file; + this.sentAtInit = sentAtInit; + } + } + + private static class FileNodeData { + final VirtualFile file; + + FileNodeData(VirtualFile file) { + this.file = file; + } + } + + // Tree cell renderer + private static class WorkspaceFolderTreeCellRenderer extends ColoredTreeCellRenderer { + @Override + public void customizeCellRenderer(@NotNull JTree tree, Object value, boolean selected, + boolean expanded, boolean leaf, int row, boolean hasFocus) { + DefaultMutableTreeNode node = (DefaultMutableTreeNode) value; + Object userObject = node.getUserObject(); + + if (userObject instanceof FolderNodeData) { + FolderNodeData data = (FolderNodeData) userObject; + setIcon(AllIcons.Nodes.Folder); + append(data.file.getPath(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + // Parse and render message with hyperlink + String message = data.sentAtInit ? + LanguageServerBundle.message("language.server.workspaceFolders.sentAtInit") : + LanguageServerBundle.message("language.server.workspaceFolders.lazy"); + appendTextWithHyperlink(" " + message); + } else if (userObject instanceof FileNodeData) { + FileNodeData data = (FileNodeData) userObject; + setIcon(data.file.getFileType().getIcon()); + append(data.file.getName(), SimpleTextAttributes.REGULAR_ATTRIBUTES); + append(" - " + data.file.getPath(), SimpleTextAttributes.GRAYED_ATTRIBUTES); + } else if (userObject instanceof String && NO_ROOT_NODE_KEY.equals(userObject)) { + // It's the NO_ROOT node + setIcon(AllIcons.General.Warning); + append(LanguageServerBundle.message(NO_ROOT_NODE_KEY), SimpleTextAttributes.ERROR_ATTRIBUTES); + } + } + + private void appendTextWithHyperlink(String text) { + // Parse text tags + int start = text.indexOf(""); + if (start == -1) { + append(text, SimpleTextAttributes.GRAYED_ATTRIBUTES); + return; + } + + int end = text.indexOf("", start); + if (end == -1) { + append(text, SimpleTextAttributes.GRAYED_ATTRIBUTES); + return; + } + + // Append text before hyperlink + if (start > 0) { + append(text.substring(0, start), SimpleTextAttributes.GRAYED_ATTRIBUTES); + } + + // Append hyperlink text + String linkText = text.substring(start + "".length(), end); + append(linkText, SimpleTextAttributes.LINK_ATTRIBUTES); + + // Append text after hyperlink + if (end + "".length() < text.length()) { + append(text.substring(end + "".length()), SimpleTextAttributes.GRAYED_ATTRIBUTES); + } + } + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplate.java b/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplate.java index eabc6e387..7a7d42a43 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplate.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplate.java @@ -10,6 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.templates; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.intellij.lang.Language; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.SystemInfo; @@ -52,6 +53,11 @@ public abstract class ServerTemplate { public static final String URL_JSON_PROPERTY = "url"; public static final String DEFAULT_JSON_PROPERTY = "default"; + // Env + public static final String ENV_JSON_PROPERTY = "env"; + public static final String ENV_INCLUDE_SYSTEM_JSON_PROPERTY = "includeSystemEnvironmentVariables"; + public static final String ENV_VARIABLES_JSON_PROPERTY = "variables"; + // File mappings public static final String LANGUAGE_ID_JSON_PROPERTY = "languageId"; public static final String FILE_TYPE_JSON_PROPERTY = "fileType"; @@ -60,10 +66,15 @@ public abstract class ServerTemplate { public static final String PATTERNS_JSON_PROPERTY = "patterns"; public static final String FILE_TYPE_MAPPINGS_JSON_PROPERTY = "fileTypeMappings"; + // Working directory + public static final String WORKING_DIR_JSON_PROPERTY = "workingDir"; + private String id; private String name; private @Nullable String url; private Map programArgs; + private @Nullable String workingDir; + private @Nullable EnvironmentVariablesData envData; private List fileTypeMappings; private List languageMappings; @@ -125,6 +136,22 @@ public void setProgramArgs(Map programArgs) { this.programArgs = programArgs; } + public @Nullable String getWorkingDir() { + return workingDir; + } + + public void setWorkingDir(@Nullable String workingDir) { + this.workingDir = workingDir; + } + + public @Nullable EnvironmentVariablesData getEnvData() { + return envData; + } + + public void setEnvData(@Nullable EnvironmentVariablesData envData) { + this.envData = envData; + } + // ---------- Commons Methods for file mappings public @NotNull List getLanguageMappings() { diff --git a/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplateJsonDeserializer.java b/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplateJsonDeserializer.java index cf7b65a76..a76579946 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplateJsonDeserializer.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/templates/ServerTemplateJsonDeserializer.java @@ -10,15 +10,20 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.templates; +import com.google.common.reflect.TypeToken; import com.google.gson.*; +import com.intellij.execution.configuration.EnvironmentVariablesData; import com.redhat.devtools.lsp4ij.JSONUtils; +import com.redhat.devtools.lsp4ij.dap.configurations.options.EnvironmentVariablesDataConfigurable; import com.redhat.devtools.lsp4ij.internal.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; import static com.redhat.devtools.lsp4ij.templates.ServerTemplate.*; @@ -120,5 +125,29 @@ protected abstract void deserializeCustom(@NotNull T serverTemplate, return null; } + protected @Nullable EnvironmentVariablesData deserializeEnvData(@NotNull JsonObject jsonObject) { + JsonElement env = jsonObject.get(ENV_JSON_PROPERTY); + if (env != null && env.isJsonObject()) { + JsonObject envObject = env.getAsJsonObject(); + // includeSystemEnvironmentVariables + boolean includeSystemEnvironmentVariables = false; + JsonElement includeSystem = envObject.get(ENV_INCLUDE_SYSTEM_JSON_PROPERTY); + if (includeSystem != null && includeSystem.isJsonPrimitive() && includeSystem.getAsJsonPrimitive().isBoolean()) { + includeSystemEnvironmentVariables = includeSystem.getAsJsonPrimitive().getAsBoolean(); + } + // variables + Map envs = Collections.emptyMap(); + JsonElement variables = envObject.get(ENV_VARIABLES_JSON_PROPERTY); + if (variables != null && variables.isJsonObject()) { + Gson gson = new Gson(); + Type mapType = new TypeToken>() { + }.getType(); + envs = gson.fromJson(variables, mapType); + } + return EnvironmentVariablesData.create(envs , includeSystemEnvironmentVariables); + } + return null; + } + protected abstract T createServerTemplateInstance(); } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ui/IconMapper.java b/src/main/java/com/redhat/devtools/lsp4ij/ui/IconMapper.java index d45f132eb..d6af0a0c8 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ui/IconMapper.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ui/IconMapper.java @@ -27,7 +27,6 @@ import org.jetbrains.annotations.Nullable; import javax.swing.*; -import java.awt.*; import java.awt.Color; import java.util.Locale; import java.util.Map; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/ui/LanguageServerSuggestionEditorNotificationProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/ui/LanguageServerSuggestionEditorNotificationProvider.java index ce6dbf893..1ca12abc3 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/ui/LanguageServerSuggestionEditorNotificationProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/ui/LanguageServerSuggestionEditorNotificationProvider.java @@ -11,6 +11,7 @@ package com.redhat.devtools.lsp4ij.ui; import com.intellij.ide.util.PropertiesComponent; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.fileEditor.FileEditor; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; @@ -80,6 +81,11 @@ public class LanguageServerSuggestionEditorNotificationProvider implements Edito return null; } + // If the file is already supported by a registered language server, don't suggest installing one + if (LanguageServersRegistry.getInstance().isFileSupported(file)) { + return null; + } + // Collect language server templates which match the file. List matchedTemplates = new ArrayList<>(); Set patterns = new HashSet<>(); @@ -138,9 +144,11 @@ private EditorNotificationPanel buildPanel(@NotNull String filePattern, NewLanguageServerDialog dialog = new NewLanguageServerDialog(project); dialog.loadFromTemplate(template); dialog.show(); - // Refresh notifications to hide it - EditorNotifications.getInstance(project) - .updateAllNotifications(); + // Refresh notifications to hide it (deferred to avoid race condition) + ApplicationManager.getApplication().invokeLater(() -> { + EditorNotifications.getInstance(project) + .updateAllNotifications(); + }); }); }); @@ -148,9 +156,11 @@ private EditorNotificationPanel buildPanel(@NotNull String filePattern, panel.createActionLabel(LanguageServerBundle.message("language.server.suggest.install.dismiss"), () -> { // Store preference to disable future notifications PropertiesComponent.getInstance().setValue(DISABLE_KEY, true); - // Refresh notifications to hide the panel - EditorNotifications.getInstance(project) - .updateAllNotifications(); + // Refresh notifications to hide the panel (deferred to avoid race condition) + ApplicationManager.getApplication().invokeLater(() -> { + EditorNotifications.getInstance(project) + .updateAllNotifications(); + }); }); return panel; diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPFindUsagesHandlerFactory.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPFindUsagesHandlerFactory.java index 2ab7983f0..21d9962cf 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPFindUsagesHandlerFactory.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPFindUsagesHandlerFactory.java @@ -14,48 +14,160 @@ import com.intellij.find.findUsages.FindUsagesHandlerFactory; import com.intellij.openapi.progress.ProcessCanceledException; import com.intellij.openapi.project.IndexNotReadyException; +import com.intellij.openapi.util.Key; import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.LanguageServiceAccessor; import com.redhat.devtools.lsp4ij.client.ExecuteLSPFeatureStatus; import com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingManager; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** - * LSP find usage handler factory. + * LSP find usages handler factory. + *

    + * This factory provides "Find Usages" functionality for LSP-managed files by delegating to the language server. + * It integrates with IntelliJ's Find Usages infrastructure to discover references, declarations, definitions, + * implementations, and type definitions using LSP protocol requests. + *

    + *

    + * The factory is registered as "last" in the extension point to avoid overriding language-specific + * FindUsagesHandlerFactory implementations (e.g., JavaFindUsagesHandlerFactory). It only activates when: + *

      + *
    • The file is associated with a language server
    • + *
    • The language server supports usage-related LSP features
    • + *
    • No other FindUsagesHandlerFactory already handles the element
    • + *
    • Project indexing allows LSP features to execute
    • + *
    + *

    */ public class LSPFindUsagesHandlerFactory extends FindUsagesHandlerFactory { + + private static final Key CHECKING_PLUGIN_FACTORY_KEY = Key.create("lsp.find.usages.checking.plugin.factory"); + + /** + * Determines if this factory can provide Find Usages support for the given element. + *

    + * This method returns {@code true} only when all the following conditions are met: + *

      + *
    • Project indexing state allows LSP features to execute now (not during indexing/scanning)
    • + *
    • The element's file is associated with a language server that supports usage features
    • + *
    • No plugin-defined FindUsagesHandlerFactory already handles this element (to avoid conflicts)
    • + *
    + *

    + *

    + * The check for plugin-defined factories ensures LSP acts as a fallback and doesn't override + * dedicated language plugins. For example, if a Java file is also managed by an LSP server, + * JavaFindUsagesHandlerFactory (from the Java plugin) should take precedence since it provides + * more comprehensive Java-specific functionality. + *

    + * + * @param element the PSI element to check. + * @return {@code true} if this factory should handle Find Usages for the element, {@code false} otherwise. + */ @Override public boolean canFindUsages(@NotNull PsiElement element) { - // Execute the dummy LSP find usage handler to collect references, implementations - // with LSPUsageSearcher if file is associated to a language server. - if (ProjectIndexingManager.canExecuteLSPFeature(element.getContainingFile()) == ExecuteLSPFeatureStatus.NOW) { - // To avoid overriding existing FindUsagesHandlerFactory for the given PsiElement - // (ex: JavaFindUsagesHandlerFactory which is also defined as "last".) - // we check if it exists a FindUsagesHandlerFactory for the element. - return !canExternalFindUsages(element); + if (element.getUserData(CHECKING_PLUGIN_FACTORY_KEY) != null) { + return false; + } + if (ProjectIndexingManager.canExecuteLSPFeature(element.getContainingFile()) == ExecuteLSPFeatureStatus.NOW + && isUsageSupportedByLanguageServer(element)) { + // Defer to plugin-defined factories (e.g., JavaFindUsagesHandlerFactory) to avoid conflicts. + // Both this factory and language-specific factories may be registered as "last" priority. + try { + element.putUserData(CHECKING_PLUGIN_FACTORY_KEY, true); + return !hasPluginDefinedFactory(element); + } finally { + element.putUserData(CHECKING_PLUGIN_FACTORY_KEY, null); + } } return false; } + /** + * Checks if usage searching is supported by at least one language server for the given element. + *

    + * Usage searching is supported when a language server provides at least one of the following LSP features: + *

      + *
    • textDocument/declaration
    • + *
    • textDocument/typeDefinition
    • + *
    • textDocument/definition
    • + *
    • textDocument/references
    • + *
    • textDocument/implementation
    • + *
    + * and the specific element type is eligible for usage searching (e.g., not a comment, keyword, modifier, or operator + * in the case of semantic token elements). + *

    + * + * @param element the PSI element to check, or {@code null}. + * @return {@code true} if at least one language server supports usage features for this element, {@code false} otherwise. + */ + public static boolean isUsageSupportedByLanguageServer(@Nullable PsiElement element) { + if (element == null) { + return false; + } + PsiFile file = element instanceof PsiFile ? (PsiFile) element : element.getContainingFile(); + if (file == null) { + return false; + } + return LanguageServiceAccessor.getInstance(file.getProject()) + .hasAny(file, ls -> { + var usageFeature = ls.getClientFeatures().getUsageFeature(); + return usageFeature.isSupported(file) && usageFeature.isUsageSupported(element); + }); + } + + /** + * Creates an LSP-based Find Usages handler for the given element. + *

    + * The handler uses {@link LSPUsageSearcher} to query the language server for references, declarations, + * definitions, implementations, and type definitions via LSP protocol. + *

    + * + * @param element the PSI element to find usages for. + * @param forHighlightUsages {@code true} if the handler is being created for highlighting usages in the editor, + * {@code false} if for the Find Usages dialog. + * @return a new {@link LSPFindUsagesHandler} instance. + */ @Override public @Nullable FindUsagesHandler createFindUsagesHandler(@NotNull PsiElement element, boolean forHighlightUsages) { return new LSPFindUsagesHandler(element); } - private boolean canExternalFindUsages(@NotNull PsiElement element) { + /** + * Checks if a plugin has already defined a FindUsagesHandlerFactory that should be preferred for the given element. + *

    + * This method iterates through all registered FindUsagesHandlerFactory extensions and checks if any + * plugin-defined factory (excluding this LSP factory) can handle find usages for the element. + *

    + *

    + * This ensures LSP acts as a fallback mechanism rather than overriding specialized plugin support. + * Language-specific plugins typically provide richer functionality than generic LSP support. + * For example: + *

      + *
    • Java plugin's JavaFindUsagesHandlerFactory understands Java-specific concepts like overridden methods
    • + *
    • Kotlin plugin provides Kotlin-specific usage searching with better semantic understanding
    • + *
    • Python plugin handles dynamic features that LSP may not fully capture
    • + *
    + * By deferring to these plugin-defined factories, we ensure users get the best possible Find Usages experience. + *

    + * + * @param element the PSI element to check. + * @return {@code true} if a plugin-defined factory should handle Find Usages for this element, {@code false} otherwise. + */ + private boolean hasPluginDefinedFactory(@NotNull PsiElement element) { for (FindUsagesHandlerFactory factory : FindUsagesHandlerFactory.EP_NAME.getExtensions(element.getProject())) { try { - if (!factory.equals(this) && factory.canFindUsages(element)) { + if (!factory.equals(this) && factory.canFindUsages(element)) { return true; } - } - catch (IndexNotReadyException | ProcessCanceledException e) { + } catch (IndexNotReadyException | ProcessCanceledException e) { throw e; - } - catch (Exception e) { + } catch (Exception e) { } } return false; } + } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsagePsiElement.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsagePsiElement.java index b205876ba..36627f5e1 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsagePsiElement.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsagePsiElement.java @@ -1,49 +1,55 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at http://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.usages; - -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiFile; -import com.redhat.devtools.lsp4ij.features.LSPPsiElement; -import org.jetbrains.annotations.NotNull; - -/** - * LSP usage Psi element. - */ -public class LSPUsagePsiElement extends LSPPsiElement { - - private UsageKind kind; - - public static enum UsageKind { - declarations, - definitions, - typeDefinitions, - references, - implementations; - } - - public LSPUsagePsiElement(@NotNull PsiFile file, @NotNull TextRange textRange) { - super(file, textRange); - } - - /** - * Returns the usage kind (references, implementations, etc) - * - * @return the usage kind (references, implementations, etc) - */ - public UsageKind getKind() { - return kind; - } - - public void setKind(UsageKind kind) { - this.kind = kind; - } -} +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.usages; + +import com.intellij.lang.Language; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.redhat.devtools.lsp4ij.features.LSPPsiElement; +import org.jetbrains.annotations.NotNull; + +/** + * LSP usage Psi element. + */ +public class LSPUsagePsiElement extends LSPPsiElement { + + private UsageKind kind; + + public static enum UsageKind { + declarations, + definitions, + typeDefinitions, + references, + implementations; + } + + public LSPUsagePsiElement(@NotNull PsiFile file, @NotNull TextRange textRange) { + super(file, textRange); + } + + /** + * Returns the usage kind (references, implementations, etc) + * + * @return the usage kind (references, implementations, etc) + */ + public UsageKind getKind() { + return kind; + } + + public void setKind(UsageKind kind) { + this.kind = kind; + } + + @Override + public @NotNull Language getLanguage() { + return getContainingFile().getLanguage(); + } +} diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSearcher.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSearcher.java index 3d02ae75c..26bba34b9 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSearcher.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSearcher.java @@ -39,26 +39,76 @@ import java.util.concurrent.CompletableFuture; import static com.redhat.devtools.lsp4ij.internal.CompletableFutures.waitUntilDone; +import static com.redhat.devtools.lsp4ij.usages.LSPFindUsagesHandlerFactory.isUsageSupportedByLanguageServer; /** - * LSP usage searcher to retrieve for a given file and offset: - * + * LSP usage searcher to retrieve all usages of a symbol via language server protocol. + *

    + * This searcher extends IntelliJ's {@link CustomUsageSearcher} to provide Find Usages functionality + * for LSP-managed files. It queries the language server for multiple types of usages and aggregates + * them into a unified result set. + *

    + *

    + * For a given file and offset, this searcher collects: *

      - *
    • Declarations
    • - *
    • Definitions
    • - *
    • Type Definitions
    • - *
    • References
    • - *
    • Implementations
    • - *
    • External References
    • - *
    + *
  • Declarations - via textDocument/declaration
  • + *
  • Definitions - via textDocument/definition
  • + *
  • Type Definitions - via textDocument/typeDefinition
  • + *
  • References - via textDocument/references
  • + *
  • Implementations - via textDocument/implementation
  • + *
  • External References - cross-language references to LSP elements
  • + * + *

    + *

    + * The searcher handles two scenarios: + *

      + *
    1. Cached usages: If the element is an {@link LSPUsageTriggeredPsiElement} with pre-computed + * references (from a previous search), those are returned immediately without querying the server again.
    2. + *
    3. Fresh search: Otherwise, it sends LSP requests via {@link LSPUsageSupport} and waits for + * all results to complete before processing them.
    4. + *
    + *

    + *

    + * Results are deduplicated to remove overlapping ranges (e.g., when a definition range fully contains + * a declaration range, only the smaller, more specific range is kept). + *

    */ public class LSPUsageSearcher extends CustomUsageSearcher { private static final Logger LOGGER = LoggerFactory.getLogger(LSPUsageSearcher.class); + /** + * Processes all usages of the given element by querying the language server. + *

    + * This method is called by IntelliJ's Find Usages infrastructure. It performs the following steps: + *

      + *
    1. Validates that the element has a containing file and project
    2. + *
    3. Checks if a language server supports usage features for the element
    4. + *
    5. Either returns cached references (if available) or queries the language server
    6. + *
    7. Filters out duplicate/overlapping results
    8. + *
    9. Respects the search scope specified in the Find Usages options
    10. + *
    11. Passes each usage to the processor for display in the Find Usages view
    12. + *
    13. Additionally collects external references (cross-language references to LSP elements)
    14. + *
    + *

    + *

    + * Performance optimization: If the element is an {@link LSPUsageTriggeredPsiElement} with + * cached references, those are used directly without making new LSP requests. This avoids redundant + * server queries when the user navigates through previously found usages. + *

    + *

    + * Deduplication logic: When multiple LSP features return overlapping ranges (e.g., a method + * definition that contains its name declaration), the smaller/more specific range is preferred. + * This prevents the Find Usages view from showing both the entire method and the method name as + * separate results. + *

    + * + * @param element the PSI element to find usages for (typically an {@link LSPUsageTriggeredPsiElement}). + * @param processor the processor to handle each found usage. + * @param options the Find Usages options, including the search scope. + */ @Override public void processElementUsages(@NotNull PsiElement element, @NotNull Processor processor, @NotNull FindUsagesOptions options) { - // Ensure that LSP process elements usage is executed in a ReadAction. ReadAction.run(() -> { PsiFile file = element.getContainingFile(); if (file == null) { @@ -66,16 +116,13 @@ public void processElementUsages(@NotNull PsiElement element, @NotNull Processor } Project project = element.getProject(); - if (!LanguageServiceAccessor.getInstance(project).hasAny( - file, - l -> l.getClientFeatures().getUsageFeature().isSupported(file) - )) { + if (!isUsageSupportedByLanguageServer(element)) { return; } - // Make sure that the search scope is respected when adding usages SearchScope searchScope = options.searchScope; + // Fast path: return cached references if available (avoids redundant LSP requests) if (element instanceof LSPUsageTriggeredPsiElement elt) { if (elt.getLSPReferences() != null) { elt.getLSPReferences() @@ -92,31 +139,30 @@ public void processElementUsages(@NotNull PsiElement element, @NotNull Processor } } - // Get position where the "Find Usages" has been triggered Position position = getPosition(element, file); if (position == null) { return; } - // Collect textDocument/definition, textDocument/references, etc + LSPUsageSupport usageSupport = new LSPUsageSupport(file); LSPUsageSupport.LSPUsageSupportParams params = new LSPUsageSupport.LSPUsageSupportParams(position); CompletableFuture> usagesFuture = usageSupport.getFeatureData(params); try { - // Wait for completion of textDocument/definition, textDocument/references, etc waitUntilDone(usagesFuture); if (usagesFuture.isDone()) { - // Show response of textDocument/definition, textDocument/references, etc as usage info. List usages = usagesFuture.getNow(null); - if (usages != null) { + if (usages != null && !usages.isEmpty()) { List filteredUsages = new ArrayList<>(usages); + // Remove invalid usages and deduplicate overlapping ranges filteredUsages.removeIf(usage -> ContainerUtil.exists(usages, otherUsage -> { VirtualFile usageFile = LSPIJUtils.getFile(usage); - if ((usageFile == null) /*||*!searchScope.contains(usageFile)*/) { + if (usageFile == null) { return true; } + // TODO: respect search scope - currently disabled + // if (!searchScope.contains(usageFile)) return true; - // Remove any usages that fully contain other usages, e.g., definitions when what's really - // wanted is the contained declaration/name identifier + // Remove usages that fully contain other usages (keep the more specific one) if (usage != otherUsage) { TextRange textRange = usage.getTextRange(); TextRange otherTextRange = otherUsage.getTextRange(); @@ -140,7 +186,6 @@ public void processElementUsages(@NotNull PsiElement element, @NotNull Processor LOGGER.error("Error while collection LSP Usages", e); } - // For completeness' sake, also collect external usages to LSP (pseudo-)elements LSPExternalReferencesFinder.processExternalReferences( file, element.getTextOffset(), @@ -150,6 +195,27 @@ public void processElementUsages(@NotNull PsiElement element, @NotNull Processor }); } + /** + * Computes the LSP position to use for querying usages of the given element. + *

    + * The position is calculated as {@code startOffset + 1}, clamped to not exceed {@code endOffset}. + * This ensures the position is inside the element's text range (not at the very start boundary), + * which is important for language servers that may not recognize positions at token boundaries. + *

    + *

    + * For example, if the element is the identifier {@code myVar} spanning offsets [10, 15]: + *

      + *
    • startOffset = 10
    • + *
    • endOffset = 15
    • + *
    • returned position = offset 11 (inside the identifier)
    • + *
    + * This gives the language server a position clearly inside the symbol to query. + *

    + * + * @param element the PSI element to get the position for. + * @param psiFile the PSI file containing the element. + * @return the LSP position (line and character), or {@code null} if the file or document is unavailable. + */ @Nullable private static Position getPosition(@NotNull PsiElement element, @NotNull PsiFile psiFile) { VirtualFile file = psiFile.getVirtualFile(); diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSupport.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSupport.java index 1f9b10b42..22fab4f51 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSupport.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageSupport.java @@ -46,7 +46,8 @@ */ public class LSPUsageSupport extends AbstractLSPDocumentFeatureSupport> { - public record LSPUsageSupportParams(@NotNull Position position) {} + public record LSPUsageSupportParams(@NotNull Position position) { + } public LSPUsageSupport(@NotNull PsiFile file) { super(file, false); @@ -64,8 +65,8 @@ protected CompletableFuture> doLoad(LSPUsageSupportPara var textDocumentIdentifier = new TextDocumentIdentifier(); Project project = file.getProject(); return getLanguageServers(file, - f -> f.getUsageFeature().isEnabled(file), - f -> f.getUsageFeature().isSupported(file)) + f -> f.getUsageFeature().isEnabled(file), + f -> f.getUsageFeature().isSupported(file)) .thenComposeAsync(languageServers -> { // Here languageServers is the list of language servers which matches the given file // and which have usage (references, implementation, etc) capability @@ -76,6 +77,7 @@ protected CompletableFuture> doLoad(LSPUsageSupportPara // Assume we need to collect everything boolean collectDefinitions = true; boolean collectTypeDefinitions = true; + boolean collectFunctionImplementations = true; // If we can determine the type of element being searched, see if we can/should search for less Position position = params.position(); @@ -93,6 +95,15 @@ protected CompletableFuture> doLoad(LSPUsageSupportPara else if (isType == ThreeState.NO) { collectTypeDefinitions = false; } + + //OPEN IDE BEGIN + // Don't collect implementations for functions (only methods can have implementations) + // This prevents errors from language servers like Go that throw errors for functions + ThreeState isFunction = semanticTokensFileViewProvider.isFunction(offset); + if (isFunction == ThreeState.YES) { + collectFunctionImplementations = false; + } + //OPEN IDE END } } @@ -107,7 +118,8 @@ else if (isType == ThreeState.NO) { var clientFeature = ls.getClientFeatures(); // Collect declarations - if (clientFeature.getDeclarationFeature().isDeclarationSupported(file)) { + if (clientFeature.getDeclarationFeature().isEnabled(file) + && clientFeature.getDeclarationFeature().isSupported(file)) { updateTextDocumentUri(declarationParams.getTextDocument(), file, ls); allFutures.add( // Update textDocument Uri with custom file Uri if needed @@ -119,7 +131,9 @@ else if (isType == ThreeState.NO) { } // Collect definitions - if (collectDefinitions && clientFeature.getDefinitionFeature().isDefinitionSupported(file)) { + if (collectDefinitions + && clientFeature.getDefinitionFeature().isEnabled(file) + && clientFeature.getDefinitionFeature().isSupported(file)) { updateTextDocumentUri(definitionParams.getTextDocument(), file, ls); allFutures.add( cancellationSupport.execute(ls @@ -130,7 +144,9 @@ else if (isType == ThreeState.NO) { } // Collect type definitions - if (collectTypeDefinitions && clientFeature.getTypeDefinitionFeature().isTypeDefinitionSupported(file)) { + if (collectTypeDefinitions + && clientFeature.getTypeDefinitionFeature().isEnabled(file) + && clientFeature.getTypeDefinitionFeature().isSupported(file)) { updateTextDocumentUri(typeDefinitionParams.getTextDocument(), file, ls); allFutures.add( cancellationSupport.execute(ls @@ -141,8 +157,10 @@ else if (isType == ThreeState.NO) { } // Collect references - if (clientFeature.getReferencesFeature().isReferencesSupported(file)) { + if (clientFeature.getReferencesFeature().isEnabled(file) + && clientFeature.getReferencesFeature().isSupported(file)) { updateTextDocumentUri(referenceParams.getTextDocument(), file, ls); + referenceParams.getContext().setIncludeDeclaration(clientFeature.getReferencesFeature().isIncludeDeclaration(file)); allFutures.add( cancellationSupport.execute(ls .getTextDocumentService() @@ -152,14 +170,29 @@ else if (isType == ThreeState.NO) { } // Collect implementations - if (clientFeature.getImplementationFeature().isImplementationSupported(file)) { - updateTextDocumentUri(implementationParams.getTextDocument(), file, ls); - allFutures.add( - cancellationSupport.execute(ls - .getTextDocumentService() - .implementation(implementationParams), ls, LSPRequestConstants.TEXT_DOCUMENT_IMPLEMENTATION) - .handle(reportUsages(ls, project, LSPUsagePsiElement.UsageKind.implementations)) - ); + if (clientFeature.getImplementationFeature().isEnabled(file) + && clientFeature.getImplementationFeature().isSupported(file)) { + //OPEN IDE BEGIN + if (collectFunctionImplementations) { + if (clientFeature.getImplementationFeature().isImplementationForFunctionSupported(file)) { + updateTextDocumentUri(implementationParams.getTextDocument(), file, ls); + allFutures.add( + cancellationSupport.execute(ls + .getTextDocumentService() + .implementation(implementationParams), ls, LSPRequestConstants.TEXT_DOCUMENT_IMPLEMENTATION) + .handle(reportUsages(ls, project, LSPUsagePsiElement.UsageKind.implementations)) + ); + } + } else { + updateTextDocumentUri(implementationParams.getTextDocument(), file, ls); + allFutures.add( + cancellationSupport.execute(ls + .getTextDocumentService() + .implementation(implementationParams), ls, LSPRequestConstants.TEXT_DOCUMENT_IMPLEMENTATION) + .handle(reportUsages(ls, project, LSPUsagePsiElement.UsageKind.implementations)) + ); + } + //OPEN IDE END } } @@ -246,5 +279,4 @@ private static ReferenceParams createReferenceParams(@NotNull TextDocumentIdenti } - } diff --git a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java index 194b071de..97530cc84 100644 --- a/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java +++ b/src/main/java/com/redhat/devtools/lsp4ij/usages/LSPUsageTargetProvider.java @@ -24,43 +24,123 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import static com.redhat.devtools.lsp4ij.usages.LSPFindUsagesHandlerFactory.isUsageSupportedByLanguageServer; + /** - * LSP Usage target provider. + * LSP usage target provider. + *

    + * This provider creates usage targets for LSP-managed files, enabling IntelliJ's Find Usages infrastructure + * to discover symbols and their references via the language server. A usage target represents the element + * at the caret position or the element to find usages for. + *

    + *

    + * The provider acts as a bridge between IntelliJ's usage system and LSP protocol, converting the caret + * position or PSI element into an {@link LSPUsageTriggeredPsiElement} that can be queried via LSP requests + * (textDocument/definition, textDocument/references, etc.). + *

    + *

    + * This provider only activates when: + *

      + *
    • The language doesn't have a custom FindUsagesProvider (to avoid overriding native language support)
    • + *
    • Project indexing allows LSP features to execute
    • + *
    • A language server supports usage features for the element
    • + *
    + *

    */ public class LSPUsageTargetProvider implements UsageTargetProvider { + /** + * Creates usage targets from the current editor position. + *

    + * This method is called by IntelliJ's Find Usages action when invoked from the editor (e.g., via + * keyboard shortcut or menu action). It determines the element at the caret position and creates + * a usage target if the language server supports usage features for that element. + *

    + *

    + * Returns {@code null} (delegating to other providers) when: + *

      + *
    • The file's language has a custom FindUsagesProvider (e.g., Java, Kotlin) to avoid conflicts
    • + *
    • Project is currently indexing or scanning
    • + *
    • No language server supports usage features for the element at the caret
    • + *
    + *

    + * + * @param editor the editor where Find Usages was invoked. + * @param file the PSI file being edited. + * @return an array containing a single LSP usage target, or {@code null} if LSP should not handle this request. + */ @Override public UsageTarget @Nullable [] getTargets(@NotNull Editor editor, @NotNull PsiFile file) { if (LanguageServersRegistry.getInstance().hasCustomLanguageFindUsages(file.getLanguage())) { - // the file language is associated to a custom FindUsagesProvider (ex: Java FindUsagesProvider) - // don't create LSP UsageTarget to avoid breaking the "Find Usages" from Java, etc files + // Defer to custom FindUsagesProvider (e.g., JavaFindUsagesHandlerFactory) to avoid conflicts return null; } if (ProjectIndexingManager.canExecuteLSPFeature(file) != ExecuteLSPFeatureStatus.NOW) { - // The file is not associated to a language server + // Wait for indexing/scanning to complete before executing LSP features + return null; + } + int offset = editor.getCaretModel().getOffset(); + PsiElement element = file.findElementAt(offset); + // For structureless files (e.g., TextMate/plain text backed by LSP semantic tokens), + // findElementAt may return null when no semantic token starts at the exact caret offset. + // Fall back to checking the file itself for LSP support in that case. + if (!isUsageSupportedByLanguageServer(element != null ? element : file)) { + // No language server provides usage support for this element return null; } - // Get LSP usage targets return getLSPTargets(editor, file); } + /** + * Creates usage targets from a specific PSI element. + *

    + * This method is called when Find Usages is invoked on a specific PSI element (e.g., from a tree view + * or programmatic invocation). It creates a usage target if the language server supports usage features + * for the element. + *

    + *

    + * Returns {@code null} (delegating to other providers) when: + *

      + *
    • The element's language has a custom FindUsagesProvider (e.g., Java, Kotlin) to avoid conflicts
    • + *
    • No language server supports usage features for the element
    • + *
    • No editor can be found for the element (required to determine the word range)
    • + *
    + *

    + * + * @param psiElement the PSI element to find usages for. + * @return an array containing a single LSP usage target, or {@code null} if LSP should not handle this request. + */ @Override public UsageTarget @Nullable [] getTargets(@NotNull PsiElement psiElement) { if (LanguageServersRegistry.getInstance().hasCustomLanguageFindUsages(psiElement.getLanguage())) { - // the Psi element language is associated to a custom FindUsagesProvider (ex: Java FindUsagesProvider) - // don't create LSP UsageTarget to avoid breaking the "Find Usages" from Java, etc files + // Defer to custom FindUsagesProvider (e.g., JavaFindUsagesHandlerFactory) to avoid conflicts return null; } - PsiFile file = psiElement.getContainingFile(); - if (!LanguageServersRegistry.getInstance().isFileSupported(file)) { - // The file is not associated to a language server + if (!isUsageSupportedByLanguageServer(psiElement)) { + // No language server provides usage support for this element return null; } - // Get LSP usage targets + PsiFile file = psiElement.getContainingFile(); Editor editor = LSPIJUtils.editorForElement(psiElement); return editor != null ? getLSPTargets(editor, file) : null; } + /** + * Creates an LSP usage target from the word at the caret position. + *

    + * This method determines the word boundary at the caret position and wraps it in an + * {@link LSPUsageTriggeredPsiElement}. This pseudo-element will later be queried by + * {@link LSPUsageSearcher} to collect usages via LSP protocol requests. + *

    + *

    + * The word range is computed using {@link LSPIJUtils#getWordRangeAt}, which respects + * language-specific word boundaries (identifiers, keywords, etc.). + *

    + * + * @param editor the editor containing the target. + * @param file the PSI file being searched. + * @return an array containing a single usage target, or an empty array if no word is found at the caret. + */ @NotNull private static UsageTarget[] getLSPTargets(@NotNull Editor editor, @NotNull PsiFile file) { TextRange targetTextRange = LSPIJUtils.getWordRangeAt(editor.getDocument(), file, editor.getCaretModel().getOffset()); @@ -68,7 +148,7 @@ private static UsageTarget[] getLSPTargets(@NotNull Editor editor, @NotNull PsiF return UsageTarget.EMPTY_ARRAY; } LSPUsageTriggeredPsiElement triggeredElement = new LSPUsageTriggeredPsiElement(file, targetTextRange); - // force to compute of the name by using token range + // Pre-compute the element name from the text range for usage display triggeredElement.getName(); UsageTarget target = new PsiElement2UsageTargetAdapter(triggeredElement, true); return new UsageTarget[]{target}; diff --git a/src/main/resources/META-INF/plugin-terminal.xml b/src/main/resources/META-INF/plugin-terminal.xml new file mode 100644 index 000000000..f88266f08 --- /dev/null +++ b/src/main/resources/META-INF/plugin-terminal.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin-textmate.xml b/src/main/resources/META-INF/plugin-textmate.xml index 085bd3ad2..6d4d56c0e 100644 --- a/src/main/resources/META-INF/plugin-textmate.xml +++ b/src/main/resources/META-INF/plugin-textmate.xml @@ -1,3 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 0d8ccc4e1..e24accfff 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -5,6 +5,87 @@ Red-Hat 0.19.4 +
      +
    • Added support for debugger Smart Step Into action via DAP StepInTargets
    • +
    • Added Advanced Workspace folders support
    • +
    • Added filters for LSP Structure View
    • +
    • Improved definition navigation: open references if definition result has the same range as clicked offset
    • +
    • Support for dynamic plugin unloading via disposable listeners and cleanup logic
    • +
    • Fixed text edits ordering to comply with LSP spec (same position edits)
    • +
    • Fixed various DAP issues including AbstractMethodError in Smart Step Into handler
    • +
    • Fixed InlayHintsPassFactory compatibility with IntelliJ 2026.2 EAP
    • +
    • Fixed definition/declaration request error reporting
    • +
    • Replaced deprecated internal API for IntelliJ 2026.2+ compatibility
    • +
    + See the full list of changes in the changelog. + +

    0.19.3

    +
      +
    • Provide Custom launch Builder API
    • +
    • Improve performance
    • +
    • Fixed several bugs and improved overall stability for LSP and DAP
    • +
    + See the full list of changes in the changelog. + +

    0.19.2

    +
      +
    • Improve performance
    • +
    • Fixing bugs
    • +
    + See the full list of changes in the changelog. + +

    0.19.1

    +
      +
    • Fix file Uri support for JAR and WSL
    • +
    + See the full list of changes in the changelog. + +

    0.19.0

    +
      +
    • Fixed several bugs and improved overall stability for LSP and DAP
    • +
    + See the full list of changes in the changelog. + +

    0.18.0

    +
      +
    • Added API to customize tab size and insert spaces behavior in LSP formatting
    • +
    • Added syntax highlighting for DAP disassembly
    • +
    • Introduced API to customize DAP expressions
    • +
    • Fixed several bugs and improved overall stability for LSP and DAP
    • +
    + See the full list of changes in the changelog. + +

    0.17.0

    +
      +
    • Improved language server lifecycle management (start/stop/restart) to prevent unexpected errors
    • +
    • Fixed various bugs and improved stability for LSP and DAP
    • +
    + See the full list of changes in the changelog. + +

    0.16.1

    +
      +
    • fix Hit CancellationException when using "Shift+Shift" (Search Everywhere)
    • +
    • fix DAPDebuggerEvaluator.getExpressionInfoAtOffsetAsync must not return null
    • +
    + Learn more in the changelog. + +

    0.16.0

    +
      +
    • DAP support for Disassembly
    • +
    • Various bug fixes and stability improvements for LSP and DAP
    • +
    + Learn more in the changelog. + +

    0.15.0

    +
      +
    • Improved DAP support: added runInTerminal and thread panels
    • +
    • DAP API enhancements: customize breakpoint types and settings editors
    • +
    • Various bug fixes and stability improvements
    • +
    + Learn more in the changelog. +

    0.14.2

    • Fix bug to show the “Install Server” notification only for TextMate and PlainText files.
    • @@ -248,8 +329,9 @@ org.jetbrains.plugins.textmate com.redhat.devtools.intellij.telemetry com.intellij.modules.json + org.jetbrains.plugins.terminal - + @@ -257,8 +339,10 @@ - + beanClass="com.redhat.devtools.lsp4ij.installation.definition.InstallerTaskFactoryPointBean" + dynamic="true"> + @@ -266,25 +350,28 @@ + factoryClass="com.redhat.devtools.lsp4ij.installation.definition.tasks.ExecTaskFactory"/> + factoryClass="com.redhat.devtools.lsp4ij.installation.definition.tasks.ShowMessageTaskFactory"/> + factoryClass="com.redhat.devtools.lsp4ij.installation.definition.tasks.DownloadTaskFactory"/> + factoryClass="com.redhat.devtools.lsp4ij.installation.definition.tasks.ConfigureServerTaskFactory"/> + serviceImplementation="com.redhat.devtools.lsp4ij.installation.download.GitHubAssetFetcherManager"/> + serviceImplementation="com.redhat.devtools.lsp4ij.installation.definition.ServerInstallerManager"/> + @@ -293,35 +380,46 @@ + beanClass="com.redhat.devtools.lsp4ij.server.definition.extension.ServerExtensionPointBean" + dynamic="true"> + beanClass="com.redhat.devtools.lsp4ij.server.definition.extension.LanguageMappingExtensionPointBean" + dynamic="true"> + beanClass="com.redhat.devtools.lsp4ij.server.definition.extension.FileTypeMappingExtensionPointBean" + dynamic="true"> + beanClass="com.redhat.devtools.lsp4ij.server.definition.extension.FileNamePatternMappingExtensionPointBean" + dynamic="true"> + beanClass="com.redhat.devtools.lsp4ij.server.definition.extension.SemanticTokensColorsProviderExtensionPointBean" + dynamic="true"> + + + @@ -331,7 +429,7 @@ + implementation="com.redhat.devtools.lsp4ij.ui.LanguageServerSuggestionEditorNotificationProvider"/> - @@ -406,6 +504,10 @@ implementationClass="com.redhat.devtools.lsp4ij.features.documentLink.LSPDocumentLinkDocumentationProvider"/> + + implementation="com.redhat.devtools.lsp4ij.features.codeLens.LSPCodeLensEditorFactoryListener"/> + implementationClass="com.redhat.devtools.lsp4ij.features.typeHierarchy.LSPTypeHierarchyProvider"/> + implementationClass="com.redhat.devtools.lsp4ij.features.typeHierarchy.LSPTypeHierarchyProvider"/> + implementationClass="com.redhat.devtools.lsp4ij.features.callHierarchy.LSPCallHierarchyProvider"/> + implementationClass="com.redhat.devtools.lsp4ij.features.callHierarchy.LSPCallHierarchyProvider"/> - - messages.LanguageServerBundle @@ -820,7 +917,7 @@ - + @@ -941,12 +1038,10 @@ topic="com.intellij.openapi.project.ProjectManagerListener" class="com.redhat.devtools.lsp4ij.ConnectDocumentToLanguageServerSetupParticipant"/> + class="com.redhat.devtools.lsp4ij.client.indexing.ProjectIndexingAppLifecycleListener"/> - - + beanClass="com.redhat.devtools.lsp4ij.dap.definitions.extension.DebugAdapterServerExtensionPointBean" + dynamic="true"> + @@ -970,17 +1067,19 @@ + + implementation="com.redhat.devtools.lsp4ij.dap.configurations.DAPConfigurationType"/> + implementation="com.redhat.devtools.lsp4ij.dap.configurations.DAPRunConfigurationProvider"/> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/jsonSchema/clientSettings.schema.json b/src/main/resources/jsonSchema/clientSettings.schema.json index d7ec14a8b..21993bade 100644 --- a/src/main/resources/jsonSchema/clientSettings.schema.json +++ b/src/main/resources/jsonSchema/clientSettings.schema.json @@ -122,6 +122,26 @@ "title": "Client-side formatter configuration", "additionalProperties": false, "properties": { + "enabled": { + "type": "boolean", + "title": "Server-side formatting enabled", + "description": "Server-side formatting enabled" + }, + "tabSize": { + "type": "integer", + "title": "Size of a tab in spaces.", + "description": "Size of a tab in spaces." + }, + "insertSpaces": { + "type": "boolean", + "title": "Prefer spaces over tabs.", + "description": "Prefer spaces over tabs." + }, + "existingFormatterOverrideable": { + "type": "boolean", + "title": "Returns true to use the language server for code formatting and false to use plugin-provided/built-in formatters.", + "description": "Returns true to use the language server for code formatting and false to use plugin-provided/built-in formatters." + }, "onTypeFormatting": { "type": "object", "title": "On-type formatting configuration", diff --git a/src/main/resources/jsonSchema/workspaceFolders.schema.json b/src/main/resources/jsonSchema/workspaceFolders.schema.json new file mode 100644 index 000000000..40f020345 --- /dev/null +++ b/src/main/resources/jsonSchema/workspaceFolders.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://github.com/redhat-developer/lsp4ij/tree/main/src/main/resources/jsonSchema/workspaceFolders.schema.json", + "title": "LSP4IJ workspace folders configuration JSON schema", + "type": "object", + "additionalProperties": false, + "properties": { + "rootType": { + "type": "string", + "title": "Root type", + "description": "Type of roots to use as workspace folders", + "enum": ["PROJECT_BASE", "SOURCE_ROOTS", "NONE"], + "default": "PROJECT_BASE" + }, + "lazy": { + "type": "boolean", + "title": "Lazy loading", + "description": "Enable lazy loading of workspace folders (discover as files are opened)", + "default": false + }, + "markers": { + "type": "array", + "title": "Root markers", + "description": "Marker files/folders to search for when discovering workspace folders (e.g., .git, pyproject.toml, pom.xml). When specified, workspace folders are discovered dynamically by walking up the directory tree, and rootType is automatically set to MARKERS.", + "items": { + "type": "string" + }, + "default": [".git", "pyproject.toml", "pom.xml", "package.json"] + } + } +} diff --git a/src/main/resources/messages/DAPBundle.properties b/src/main/resources/messages/DAPBundle.properties index 66535c4f6..8e8152749 100644 --- a/src/main/resources/messages/DAPBundle.properties +++ b/src/main/resources/messages/DAPBundle.properties @@ -64,3 +64,9 @@ dap.settings.editor.installer.tab=Installer # Custom panels dap.panels.exception.breakpoints=Exception Breakpoints dap.panels.threads=Threads + +# Breakpoints +dap.breakpoints.title=DAP Breakpoint + +# Disassembly +dap.disassembly.breakpoints.title=DAP Disassembly Breakpoint diff --git a/src/main/resources/messages/DAPBundle_zh_CN.properties b/src/main/resources/messages/DAPBundle_zh_CN.properties index c739265c1..4c4fd1ae2 100644 --- a/src/main/resources/messages/DAPBundle_zh_CN.properties +++ b/src/main/resources/messages/DAPBundle_zh_CN.properties @@ -12,48 +12,61 @@ ############################################################################### ## DAP RunConfigurationType -DAPRunConfigurationType.displayName=\u8C03\u8BD5\u9002\u914D\u5668\u534F\u8BAE -DAPRunConfigurationType.description=\u8C03\u8BD5\u9002\u914D\u5668\u534F\u8BAE\u914D\u7F6E\u7C7B\u578B +DAPRunConfigurationType.displayName=调试适配器协议 +DAPRunConfigurationType.description=调试适配器协议配置类型 ## List configurable -debug.adapter=\u8C03\u8BD5\u9002\u914D\u5668\u534F\u8BAE -debug.adapter.action.add=\u6DFB\u52A0\u8C03\u8BD5\u9002\u914D\u5668\u670D\u52A1\u5668 -debug.adapter.action.remove=\u79FB\u9664\u6240\u9009\u8C03\u8BD5\u9002\u914D\u5668\u670D\u52A1\u5668 +debug.adapter=调试适配器协议 +debug.adapter.action.add=添加调试适配器服务器 +debug.adapter.action.remove=移除选中的调试适配器服务器 ## New Debug Adapter descriptor factory dialog -new.debug.adapter.dialog.title=\u65B0\u5EFA\u8C03\u8BD5\u9002\u914D\u5668\u670D\u52A1\u5668 -new.debug.adapter.dialog.template=\u6A21\u677F\uFF1A -new.debug.adapter.dialog.validation.serverName.must.be.set=\u5FC5\u987B\u8BBE\u7F6E\u670D\u52A1\u5668\u540D\u79F0 -new.debug.adapter.dialog.validation.commandLine.must.be.set=\u5FC5\u987B\u8BBE\u7F6E\u547D\u4EE4 +new.debug.adapter.dialog.title=新建调试适配器服务器 +new.debug.adapter.dialog.template=模板: +new.debug.adapter.dialog.choose.template=选择模板... +new.debug.adapter.dialog.choose.template.title=选择调试服务器模板 +new.debug.adapter.dialog.validation.serverName.must.be.set=必须设置服务器名称 # DAP settings editor # - Server settings -dap.settings.editor.server.tab=\u670D\u52A1\u5668 -dap.settings.editor.server.factory.field=\u4F7F\u7528\u5DF2\u6709\u8C03\u8BD5\u9002\u914D\u5668\u670D\u52A1\u5668\uFF1A -dap.settings.editor.server.factory.or=\u6216 -dap.settings.editor.server.factory.create=\u521B\u5EFA\u65B0\u670D\u52A1\u5668\u3002 -dap.settings.editor.server.name.field=\u540D\u79F0\uFF1A -dap.settings.editor.server.command.field=\u547D\u4EE4\uFF1A -dap.settings.editor.server.connecting.strategy.label=\u901A\u8FC7\u7B49\u5F85\u8FDE\u63A5\u670D\u52A1\u5668\uFF1A -dap.settings.editor.server.connecting.strategy.timeout=\u5C31\u7EEA\u524D\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF09\uFF1A -dap.settings.editor.server.connecting.strategy.trace=\u7EE7\u7EED\u524D\u7684\u65E5\u5FD7\u6A21\u5F0F\uFF1A -dap.settings.editor.server.serverTrace.field=\u8DDF\u8E2A\uFF1A +dap.settings.editor.server.tab=服务器 +dap.settings.editor.server.factory.field=使用现有的调试适配器服务器: +dap.settings.editor.server.factory.or=或 +dap.settings.editor.server.factory.create=创建新服务器。 +dap.settings.editor.server.name.field=名称: +dap.settings.editor.server.command.field=命令: +dap.settings.editor.server.connecting.strategy.label=通过等待连接到服务器: +dap.settings.editor.server.connecting.strategy.timeout=就绪前的超时时间(毫秒): +dap.settings.editor.server.connecting.strategy.trace=继续前的日志模式: +dap.settings.editor.server.serverTrace.field=跟踪: # - Mappings settings -dap.settings.editor.mappings.tab=\u6620\u5C04 -dap.settings.editor.mappings.title=\u58F0\u660E\u6587\u4EF6\u5173\u8054\u4EE5\u5141\u8BB8\u8BBE\u7F6E\u65AD\u70B9\u3002 -dap.settings.editor.mappings.language=\u8BED\u8A00 -dap.settings.editor.mappings.fileType=\u6587\u4EF6\u7C7B\u578B -dap.settings.editor.mappings.fileNamePattern=\u6587\u4EF6\u540D\u6A21\u5F0F +dap.settings.editor.mappings.tab=映射 +dap.settings.editor.mappings.title=声明文件关联以允许设置断点。 +dap.settings.editor.mappings.language=语言 +dap.settings.editor.mappings.fileType=文件类型 +dap.settings.editor.mappings.fileNamePattern=文件名模式 # - Configuration settings -dap.settings.editor.configuration.tab=\u914D\u7F6E -dap.settings.editor.configuration.cwd.field=\u5DE5\u4F5C\u76EE\u5F55\uFF1A -dap.settings.editor.configuration.file.field=\u6587\u4EF6\uFF1A -dap.settings.editor.configuration.debug.mode=\u8C03\u8BD5\u6A21\u5F0F\uFF1A -dap.settings.editor.configuration.debug.mode.launch=\u542F\u52A8 -dap.settings.editor.configuration.debug.mode.attach=\u9644\u52A0 -dap.settings.editor.configuration.launch.use=\u4F7F\u7528\u914D\u7F6E\uFF1A -dap.settings.editor.configuration.parameters.field=DAP \u53C2\u6570\uFF08JSON\uFF09\uFF1A -dap.settings.editor.configuration.parameters.launch.tab=\u542F\u52A8 -dap.settings.editor.configuration.parameters.attach.tab=\u9644\u52A0 -dap.settings.editor.configuration.attach.address.field=\u5730\u5740\uFF1A -dap.settings.editor.configuration.attach.port.field=\u7AEF\u53E3\uFF1A \ No newline at end of file +dap.settings.editor.configuration.tab=配置 +dap.settings.editor.configuration.cwd.field=工作目录: +dap.settings.editor.configuration.file.field=文件: +dap.settings.editor.configuration.debug.mode=调试模式: +dap.settings.editor.configuration.debug.mode.launch=启动 +dap.settings.editor.configuration.debug.mode.attach=附加 +dap.settings.editor.configuration.launch.use=使用配置: +dap.settings.editor.configuration.parameters.field=DAP 参数 (JSON): +dap.settings.editor.configuration.parameters.launch.tab=启动 +dap.settings.editor.configuration.parameters.attach.tab=附加 +dap.settings.editor.configuration.attach.address.field=地址: +dap.settings.editor.configuration.attach.port.field=端口: +# - Installer tab +dap.settings.editor.installer.tab=安装程序 + +# Custom panels +dap.panels.exception.breakpoints=异常断点 +dap.panels.threads=线程 + +# Breakpoints +dap.breakpoints.title=DAP 断点 + +# Disassembly +dap.disassembly.breakpoints.title=DAP 反汇编断点 \ No newline at end of file diff --git a/src/main/resources/messages/LanguageServerBundle.properties b/src/main/resources/messages/LanguageServerBundle.properties index ebd229860..d92b67038 100644 --- a/src/main/resources/messages/LanguageServerBundle.properties +++ b/src/main/resources/messages/LanguageServerBundle.properties @@ -17,6 +17,7 @@ language.servers=Language Servers language.server.tab.server=Server language.server.serverName=Name: language.server.command=Command: +language.server.workingDir=Working Directory: language.server.url=Need help with install, config, or usage? Click here language.server.tab.mappings=Mappings @@ -48,6 +49,20 @@ language.server.installer.check=Check Installation language.server.installer.run=Run Installation language.server.installer.check.and.run=Check / Run Installation +language.server.tab.workspaceFolders=Workspace Folders +language.server.workspaceFolders.configuration=Configuration (optional): +language.server.workspaceFolders.detected=Preview - Workspace Folders Behavior: +language.server.workspaceFolders.detected.description=This preview shows how workspace folders will be sent to the language server based on the configuration +language.server.workspaceFolders.test=Test file discovery: +language.server.workspaceFolders.test.description=Drag & drop or browse a file to see which workspace folder it belongs to +language.server.workspaceFolders.dropZone=Drag and drop a file here or Open file... +language.server.workspaceFolders.sendAtInit=Preview mode: Show folders sent via initialize request +language.server.workspaceFolders.sendAtInit.tooltip=When checked: shows folders sent via initialize request
      When unchecked: shows all available workspace folders (sent via workspace/didChangeWorkspaceFolders notification as files are opened) +language.server.workspaceFolders.remove=Remove +language.server.workspaceFolders.sentAtInit=(initialize request) +language.server.workspaceFolders.lazy=(workspace/didChangeWorkspaceFolders notification) +language.server.workspaceFolders.noRoot=(No root) + language.server.tab.debug=Debug language.server.error.reporting=Error reporting: language.server.error.reporting.none=None @@ -250,4 +265,32 @@ server.installer.lsp.progress.installing=Installing ''{0}'' server... # Language server install suggestion editor notification language.server.suggest.install.title={0} files are supported by language servers (LSP4IJ) language.server.suggest.install.template=Install {0} -language.server.suggest.install.dismiss=Dismiss \ No newline at end of file +language.server.suggest.install.dismiss=Dismiss + +# Structure View Filters +structure.view.filter.show.files=Show Files +structure.view.filter.show.modules=Show Modules +structure.view.filter.show.namespaces=Show Namespaces +structure.view.filter.show.packages=Show Packages +structure.view.filter.show.classes=Show Classes +structure.view.filter.show.methods=Show Methods +structure.view.filter.show.properties=Show Properties +structure.view.filter.show.fields=Show Fields +structure.view.filter.show.constructors=Show Constructors +structure.view.filter.show.enums=Show Enums +structure.view.filter.show.interfaces=Show Interfaces +structure.view.filter.show.functions=Show Functions +structure.view.filter.show.variables=Show Variables +structure.view.filter.show.constants=Show Constants +structure.view.filter.show.strings=Show Strings +structure.view.filter.show.numbers=Show Numbers +structure.view.filter.show.booleans=Show Booleans +structure.view.filter.show.arrays=Show Arrays +structure.view.filter.show.objects=Show Objects +structure.view.filter.show.keys=Show Keys +structure.view.filter.show.nulls=Show Nulls +structure.view.filter.show.enumMembers=Show Enum Members +structure.view.filter.show.structs=Show Structs +structure.view.filter.show.events=Show Events +structure.view.filter.show.operators=Show Operators +structure.view.filter.show.typeParameters=Show Type Parameters \ No newline at end of file diff --git a/src/main/resources/messages/LanguageServerBundle_zh_CN.properties b/src/main/resources/messages/LanguageServerBundle_zh_CN.properties index 0a40643c0..201859bfb 100644 --- a/src/main/resources/messages/LanguageServerBundle_zh_CN.properties +++ b/src/main/resources/messages/LanguageServerBundle_zh_CN.properties @@ -1,5 +1,5 @@ ############################################################################### -# Copyright (c) 2025 Red Hat Inc. and others. +# Copyright (c) 2023 Red Hat Inc. and others. # All rights reserved. This program and the accompanying materials # are made available under the terms of the Eclipse Public License v2.0 # which accompanies this distribution, and is available at @@ -15,8 +15,9 @@ language.servers=语言服务器 language.server.tab.server=服务器 -language.server.serverName=名称: -language.server.command=命令: +language.server.serverName=名称: +language.server.command=命令: +language.server.url=需要安装、配置或使用方面的帮助?点击此处 language.server.tab.mappings=映射 language.server.command.emptyText=定义启动语言服务器的命令 @@ -30,42 +31,52 @@ language.server.mappings.fileType.no=未配置文件类型映射 language.server.mappings.fileNamePattern=文件名模式 language.server.mappings.fileNamePattern.column=文件名模式 language.server.mappings.fileNamePattern.no=未配置文件名模式映射 -language.server.mappings.languageId.column=语言ID +language.server.mappings.languageId.column=语言 ID language.server.tab.configuration=配置 language.server.tab.configuration.server=服务器 language.server.tab.configuration.client=客户端 -language.server.configuration=配置: -language.server.initializationOptions=初始化选项: -language.server.configuration.json.schema.associate=关联到JSON Schema -language.server.configuration.json.schema.edit=编辑JSON Schema +language.server.configuration=配置: +language.server.configuration.expand=将配置发送到服务器时展开 JSON +language.server.initializationOptions=初始化选项: +language.server.experimental=实验性功能: +language.server.configuration.json.schema.associate=关联 JSON 架构 +language.server.configuration.json.schema.edit=编辑 JSON 架构 + +language.server.tab.installer=安装程序 +language.server.installer.check=检查安装 +language.server.installer.run=运行安装 +language.server.installer.check.and.run=检查 / 运行安装 language.server.tab.debug=调试 -language.server.error.reporting=错误报告: +language.server.error.reporting=错误报告: language.server.error.reporting.none=无 language.server.error.reporting.as_notification=作为通知 -language.server.error.reporting.in_log=在日志中 +language.server.error.reporting.in_log=记录到日志 language.server.error.reporting.context.help.tooltip=报告语言服务器中的技术错误。
      \ - 你可以配置错误报告:
        \ -
      • 无:忽略语言服务器错误。
      • \ -
      • 作为通知:语言服务器错误将作为通知出现。
      • \ -
      • 在日志中:语言服务器错误将记录在IntelliJ标准日志中。
      -language.server.error.reporting.context.help.link=详见LSP规范 + 您可以配置错误报告:
        \ +
      • 无: 将忽略语言服务器错误。
      • \ +
      • 作为通知: 语言服务器错误将显示为通知。
      • \ +
      • 记录到日志: 语言服务器错误将记录在标准 IntelliJ 日志中。
      +language.server.error.reporting.context.help.link=更多信息请参阅 LSP 规范 action.language.server.error.reporting.disable.text=禁用错误报告 action.language.server.error.reporting.in_log.text=在日志中报告错误 -action.open.website.text=打开LSP4IJ文档 +action.open.website.text=打开 LSP4IJ 文档 action.language.server.error.show.logs.text=显示日志 -language.server.trace=跟踪: -language.server.debug.port=调试端口: +language.server.trace=跟踪: +language.server.debug.port=调试端口: language.server.debug.suspend=挂起并等待调试器? language.server.action.add=添加语言服务器 -language.server.action.remove=移除所选语言服务器 +language.server.action.remove=移除选中的语言服务器 ## New Language Server dialog new.language.server.dialog.title=新建语言服务器 -new.language.server.dialog.template=模板: +new.language.server.dialog.template=模板: +new.language.server.dialog.choose.template=选择模板... +new.language.server.dialog.choose.template.title=选择语言服务器模板 +new.language.server.dialog.import.template=导入模板... new.language.server.dialog.export.template.title=加载模板 new.language.server.dialog.export.template.description=从目录加载模板 new.language.server.dialog.import.template.error.title=模板导入失败 @@ -75,11 +86,11 @@ new.language.server.dialog.validation.serverName.must.be.set=必须设置服务 new.language.server.dialog.validation.commandLine.must.be.set=必须设置命令 ## Edit JSON Schema dialog -edit.json.schema.dialog.title=编辑JSON Schema -edit.json.schema.dialog.schema=JSON Schema +edit.json.schema.dialog.title=编辑 JSON 架构 +edit.json.schema.dialog.schema=JSON 架构 ## LSP console -lsp.console.title=LSP控制台 +lsp.console.title=LSP 控制台 lsp.console.tabs.traces.title=跟踪 lsp.console.tabs.logs.title=日志 action.lsp.console.explorer.restart.text=重启 @@ -91,24 +102,28 @@ action.lsp.console.explorer.pause.description=暂停语言服务器 action.lsp.console.explorer.copy.command.text=复制启动命令 action.lsp.console.explorer.copy.command.description=复制启动语言服务器的命令 action.lsp.console.explorer.delete.server.text=删除服务器 -action.lsp.console.explorer.delete.server.description=删除所选语言服务器 +action.lsp.console.explorer.delete.server.description=删除选中的语言服务器 action.lsp.console.explorer.delete.server.confirm.dialog.title=删除语言服务器? -action.lsp.console.explorer.delete.server.confirm.dialog.message=是否要删除此"{0}"语言服务器? +action.lsp.console.explorer.delete.server.confirm.dialog.message=您要删除此"{0}"语言服务器吗? action.lsp.console.explorer.edit.server.text=编辑服务器 -action.lsp.console.explorer.edit.server.description=编辑所选语言服务器 +action.lsp.console.explorer.edit.server.description=编辑选中的语言服务器 action.lsp.console.explorer.export.server.text=导出服务器 -action.lsp.console.explorer.export.server.description=导出所选语言服务器 +action.lsp.console.explorer.export.server.description=导出选中的语言服务器 action.lsp.console.explorer.export.servers.text=导出服务器 -action.lsp.console.explorer.export.servers.description=导出所选语言服务器 -action.lsp.console.explorer.export.servers.zip.save.title=保存LS定义 -action.lsp.console.explorer.export.servers.zip.save.description=选择LS定义导出的保存位置 -action.lsp.console.explorer.export.servers.notification.title=LSP4IJ语言服务器导出 +action.lsp.console.explorer.export.servers.description=导出选中的语言服务器 +action.lsp.console.explorer.export.servers.zip.save.title=保存 LS 定义 +action.lsp.console.explorer.export.servers.zip.save.description=选择 LS 定义导出的位置 +action.lsp.console.explorer.export.servers.notification.title=LSP4IJ 语言服务器导出 action.lsp.console.explorer.export.servers.notification.message.error=语言服务器导出失败 -action.lsp.console.explorer.export.servers.notification.message.single=成功导出1个语言服务器。 -action.lsp.console.explorer.export.servers.notification.message.multi=成功导出{0}个语言服务器。 +action.lsp.console.explorer.export.servers.notification.message.single=成功导出 1 个语言服务器。 +action.lsp.console.explorer.export.servers.notification.message.multi=成功导出 {0} 个语言服务器。 +action.lsp.console.explorer.install.server.text=按需安装 +action.lsp.console.explorer.install.server.description=检查服务器是否可以启动。仅在必要时安装。 +action.lsp.console.explorer.reinstall.server.text=重新安装 +action.lsp.console.explorer.reinstall.server.description=强制完全重新安装,即使服务器已经正常工作。 action.lsp.console.folding.text=全部折叠/展开 action.lsp.console.new.language.server.text=新建语言服务器 -action.lsp.console.new.language.server.description=打开对话框以创建新语言服务器 +action.lsp.console.new.language.server.description=打开对话框创建新的语言服务器 action.lsp.detail.apply.text=应用 action.lsp.detail.apply.description=应用此语言服务器设置的更改 action.lsp.detail.apply.balloon=点击此处保存设置 @@ -117,15 +132,15 @@ action.lsp.detail.reset.text=重置 action.lsp.detail.reset.description=回滚此语言服务器设置的更改 ## Dialog lsp.create.file.confirm.dialog.title=创建文件? -lsp.create.file.confirm.dialog.message=无法打开文件"{0}"。是否要创建? +lsp.create.file.confirm.dialog.message=无法打开文件"{0}"。您要创建它吗? lsp.create.file.error.dialog.title=创建文件问题 -lsp.create.file.error.dialog.message=创建文件"{0}"时出错:"{1}"。 +lsp.create.file.error.dialog.message=创建文件"{0}"时出错: "{1}"。 # Language server template template.show.instruction.dialog.title="{0}"的说明 # Find Usages -usage.description=LSP符号 +usage.description=LSP 符号 usage.type.declarations=声明 usage.type.definitions=定义 usage.type.typeDefinitions=类型定义 @@ -141,8 +156,8 @@ no.usage.type.implementations=未找到实现。 goto.typeDeclaration.notFound=未找到类型声明 # LSP CodeLens (based on CodeVision) -codeLens.group.name=LSP代码透镜 -codeLens.group.description=显示与文件关联的语言服务器收集的LSP代码透镜。 +codeLens.group.name=LSP CodeLens +codeLens.group.description=显示与文件关联的语言服务器收集的 LSP CodeLens。 # LSP Intention lsp.intention.group.name=语言服务器 @@ -151,24 +166,24 @@ lsp.intention.code.action.kind.refactor=重构 lsp.intention.code.action.kind.refactor.extract=提取 lsp.intention.code.action.kind.refactor.inline=内联 lsp.intention.code.action.kind.refactor.rewrite=重写 -lsp.intention.code.action.kind.source=源 +lsp.intention.code.action.kind.source=源代码 lsp.intention.code.action.kind.source.fixAll=全部修复 -lsp.intention.code.action.kind.source.organizeImports=整理导入 +lsp.intention.code.action.kind.source.organizeImports=组织导入 lsp.intention.code.action.kind.empty=空 # LSP Rename -lsp.refactor.rename.symbol.dialog.title=将"{0}"符号重命名为: -lsp.refactor.rename.symbol.handler.title=重命名LSP符号 +lsp.refactor.rename.symbol.dialog.title=将"{0}"符号重命名为: +lsp.refactor.rename.symbol.handler.title=重命名 LSP 符号 lsp.refactor.rename.cannot.be.renamed.error=该元素无法重命名。 -lsp.refactor.rename.prepare.progress.title=为"{0}"文件在{1}偏移量准备重命名... -lsp.refactor.rename.progress.title=正在将"{0}"文件重命名为"{1}"... +lsp.refactor.rename.prepare.progress.title=为"{0}"文件在 {1} 偏移处准备重命名... +lsp.refactor.rename.progress.title=将"{0}"文件重命名为"{1}"... # LSP Command lsp.command.error.title=无法执行"{0}"命令。 -lsp.command.error.with.ls.content=缺少"{0}"命令!它被"{1}"引用。需要由[IntelliJ插件](https://github.com/redhat-developer/lsp4ij/blob/main/docs/DeveloperGuide.md#lsp-commands)贡献。 -lsp.command.error.without.ls.content=缺少"{0}"命令!需要由[IntelliJ插件](https://github.com/redhat-developer/lsp4ij/blob/main/docs/DeveloperGuide.md#lsp-commands)贡献。 -lsp.command.error.ls.undefined.title=未定义语言服务器 -lsp.command.error.ls.undefined.content=无法执行语言服务器"{0}"命令。ID为"{1}"的语言服务器不存在。 +lsp.command.error.with.ls.content=缺少"{0}"命令!它被"{1}"引用。需要由 [IntelliJ 插件](https://github.com/redhat-developer/lsp4ij/blob/main/docs/DeveloperGuide.md#lsp-commands) 提供。 +lsp.command.error.without.ls.content=缺少"{0}"命令!需要由 [IntelliJ 插件](https://github.com/redhat-developer/lsp4ij/blob/main/docs/DeveloperGuide.md#lsp-commands) 提供。 +lsp.command.error.ls.undefined.title=未定义的语言服务器 +lsp.command.error.ls.undefined.content=无法执行语言服务器"{0}"命令。ID 为"{1}"的语言服务器不存在。 # LSP colors used by LSP semanticTokens options.lsp.attribute.descriptor.namespace.declaration=命名空间//命名空间声明 @@ -206,30 +221,33 @@ options.lsp.attribute.descriptor.keyword=关键字 options.lsp.attribute.descriptor.number=数字 options.lsp.attribute.descriptor.regexp=正则表达式 options.lsp.attribute.descriptor.modifier=修饰符 -options.lsp.attribute.descriptor.operator=操作符 +options.lsp.attribute.descriptor.operator=运算符 # Semantic Tokens -lsp.semantic.tokens.inspector.title=语义标记检查器 +lsp.semantic.tokens.inspector.title=语义令牌检查器 lsp.semantic.tokens.inspector.show.text.attributes=显示文本属性 -lsp.semantic.tokens.inspector.show.token.type=显示标记类型 -lsp.semantic.tokens.inspector.show.token.modifiers=显示标记修饰符 +lsp.semantic.tokens.inspector.show.token.type=显示令牌类型 +lsp.semantic.tokens.inspector.show.token.modifiers=显示令牌修饰符 lsp.semantic.tokens.color.settings.name=语言服务器 # LSP GoTo actions -lsp.goto.declaration.progress.title=查找"{0}"在"{1}"的声明 -lsp.goto.implementation.progress.title=查找"{0}"在"{1}"的实现 -lsp.goto.reference.progress.title=查找"{0}"在"{1}"的类型引用 -lsp.goto.typeDefinition.progress.title=查找"{0}"在"{1}"的类型定义 +lsp.goto.declaration.progress.title=在"{1}"中查找"{0}"的声明 +lsp.goto.implementation.progress.title=在"{1}"中查找"{0}"的实现 +lsp.goto.reference.progress.title=在"{1}"中查找"{0}"的类型引用 +lsp.goto.typeDefinition.progress.title=在"{1}"中查找"{0}"的类型定义 # Inlay hints lsp.hints.declarative.provider.name=语言服务器 # Server installer -server.installer.task.installing=如有需要正在安装服务器... -server.installer.progress.check.installed=正在检查服务器是否已安装... +server.installer.task.installing=按需安装服务器... +server.installer.progress.check.installed=检查服务器是否已安装... server.installer.progress.installing=正在安装服务器... -server.installer.lsp.task.installing=如有需要正在安装"{0}"服务器... -server.installer.lsp.progress.check.installed=正在检查"{0}"服务器是否已安装... -server.installer.lsp.progress.installing=正在安装"{0}"服务器... -language.server.url=Click here -language.server.suggest.install.title=\ {0} files are supported by language servers (LSP4IJ) \ No newline at end of file +server.installer.lsp.task.installing=按需安装"{0}"服务器... +server.installer.lsp.progress.check.installed=检查"{0}"服务器是否已安装... +server.installer.lsp.progress.installing=正在安装"{0}"服务器... + +# Language server install suggestion editor notification +language.server.suggest.install.title={0} 文件由语言服务器 (LSP4IJ) 支持 +language.server.suggest.install.template=安装 {0} +language.server.suggest.install.dismiss=忽略 \ No newline at end of file diff --git a/src/main/resources/templates/dap/buildx-dockerfile/launch.json b/src/main/resources/templates/dap/buildx-dockerfile/launch.json new file mode 100644 index 000000000..d8452f5ff --- /dev/null +++ b/src/main/resources/templates/dap/buildx-dockerfile/launch.json @@ -0,0 +1,5 @@ +{ + "request": "launch", + "dockerfile": "${file}", + "contextPath": "${workspaceFolder}" +} \ No newline at end of file diff --git a/src/main/resources/templates/dap/buildx-dockerfile/template.json b/src/main/resources/templates/dap/buildx-dockerfile/template.json new file mode 100644 index 000000000..c405e7d15 --- /dev/null +++ b/src/main/resources/templates/dap/buildx-dockerfile/template.json @@ -0,0 +1,24 @@ +{ + "id": "buildx-dockerfile", + "name": "Docker: Dockerfile Build Debugging", + "launch": { + "default": "docker buildx dap build" + }, + "env": { + "includeSystemEnvironmentVariables": true, + "variables": { + "BUILDX_EXPERIMENTAL": "1" + } + }, + "fileTypeMappings": [ + { + "fileType": { + "name": "Dockerfile", + "patterns": [ + "Dockerfile*" + ] + }, + "languageId": "dockerfile" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/eo-lsp-server/clientSettings.json b/src/main/resources/templates/lsp/eo-lsp-server/clientSettings.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/main/resources/templates/lsp/eo-lsp-server/clientSettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/eo-lsp-server/installer.json b/src/main/resources/templates/lsp/eo-lsp-server/installer.json new file mode 100644 index 000000000..3249b4356 --- /dev/null +++ b/src/main/resources/templates/lsp/eo-lsp-server/installer.json @@ -0,0 +1,37 @@ +{ + "id": "eo-lsp-server", + "name": "LSP Server for EOLANG", + "executeOnStartServer": false, + "properties": { + "workingDir" : "$USER_HOME$/.lsp4ij/lsp/eo-lsp-server/node_modules" + }, + "check": { + "exec": { + "name": "Trying current command", + "command": "${server.command}", + "timeout": 2000 + } + }, + "run": { + "exec": { + "name": "Install LSP Server for EOLANG", + "workingDir": "${workingDir}", + "ignoreStderr": true, + "command": { + "windows": "npm.cmd install eo-lsp-server --force", + "default": "npm install eo-lsp-server --force" + }, + "onSuccess": { + "configureServer": { + "name": "Configure LSP Server for EOLANG command", + "command": { + "windows": "${workingDir}/.bin/eo-lsp-server.cmd --stdio", + "default": "${workingDir}/.bin/eo-lsp-server --stdio" + }, + "update": true + } + } + } + } +} + diff --git a/src/main/resources/templates/lsp/eo-lsp-server/settings.json b/src/main/resources/templates/lsp/eo-lsp-server/settings.json new file mode 100644 index 000000000..649a6a359 --- /dev/null +++ b/src/main/resources/templates/lsp/eo-lsp-server/settings.json @@ -0,0 +1,3 @@ +{ + "languageServerExample.maxNumberOfProblems": 100 +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/eo-lsp-server/settings.schema.json b/src/main/resources/templates/lsp/eo-lsp-server/settings.schema.json new file mode 100644 index 000000000..0d27ca2a2 --- /dev/null +++ b/src/main/resources/templates/lsp/eo-lsp-server/settings.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "LSP4IJ/eo-lsp-server/settings.schema.json", + "title": "LSP4IJ EOLANG LSP server settings JSON schema", + "description": "JSON schema for EOLANG LSP server server settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "languageServerExample.maxNumberOfProblems": { + "type": "number", + "default": 100, + "description": "Controls the maximum number of problems produced by the server." + } + } +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/eo-lsp-server/template.json b/src/main/resources/templates/lsp/eo-lsp-server/template.json new file mode 100644 index 000000000..45dde6c13 --- /dev/null +++ b/src/main/resources/templates/lsp/eo-lsp-server/template.json @@ -0,0 +1,19 @@ +{ + "id": "eo-lsp-server", + "name": "LSP Server for EOLANG", + "programArgs": { + "windows": "cmd /C eo-lsp-server.cmd --stdio", + "default": "sh -c eo-lsp-server --stdio" + }, + "fileTypeMappings": [ + { + "fileType": { + "name": "EO", + "patterns": [ + "*.eo" + ] + }, + "languageId": "eo" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/clientSettings.json b/src/main/resources/templates/lsp/harper-ls/clientSettings.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/clientSettings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/experimental.json b/src/main/resources/templates/lsp/harper-ls/experimental.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/experimental.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/initializationOptions.json b/src/main/resources/templates/lsp/harper-ls/initializationOptions.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/initializationOptions.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/installer.json b/src/main/resources/templates/lsp/harper-ls/installer.json new file mode 100644 index 000000000..a76384256 --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/installer.json @@ -0,0 +1,51 @@ +{ + "id": "harper-ls-installer", + "name": "harper-ls", + "executeOnStartServer": false, + "check": { + "exec": { + "name": "Trying current command", + "command": "${server.command}", + "timeout": 2000 + } + }, + "run": { + "download": { + "name": "Download harper-ls", + "github": { + "owner": "Automattic", + "repository": "harper", + "prerelease": false, + "asset": { + "windows": "harper-ls-x86_64-pc-windows-msvc.zip", + "unix": { + "x86_64": "harper-ls-x86_64-unknown-linux-gnu.tar.gz", + "arm64": "harper-ls-aarch64-unknown-linux-gnu.tar.gz" + }, + "mac": { + "x86_64": "harper-ls-x86_64-apple-darwin.tar.gz", + "arm64": "harper-ls-aarch64-apple-darwin.tar.gz" + } + } + }, + "output": { + "dir": "$USER_HOME$/.lsp4ij/lsp/harper-ls", + "file": { + "name": { + "windows": "harper-ls.exe", + "unix": "harper-ls", + "mac": "harper-ls" + }, + "executable": true + } + }, + "onSuccess": { + "configureServer": { + "name": "Configure Harper Language server command", + "command": "${output.dir}/${output.file.name} --stdio", + "update": true + } + } + } + } +} diff --git a/src/main/resources/templates/lsp/harper-ls/settings.json b/src/main/resources/templates/lsp/harper-ls/settings.json new file mode 100644 index 000000000..a48a25078 --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/settings.json @@ -0,0 +1,483 @@ +{ + "harper-ls.codeActions.ForceStable": false, + "harper-ls.dialect": "American", + "harper-ls.diagnosticSeverity": "information", + "harper-ls.isolateEnglish": false, + "harper-ls.markdown.IgnoreLinkTitle": false, + "harper-ls.maxFileLength": 120000, + "harper-ls.linters.ACoupleMore": true, + "harper-ls.linters.ALongTime": true, + "harper-ls.linters.APart": true, + "harper-ls.linters.AWhile": true, + "harper-ls.linters.Addicting": true, + "harper-ls.linters.AdjectiveDoubleDegree": true, + "harper-ls.linters.AdjectiveOfA": true, + "harper-ls.linters.Ado": true, + "harper-ls.linters.AfterAWhile": true, + "harper-ls.linters.AfterLater": true, + "harper-ls.linters.AheadAnd": true, + "harper-ls.linters.AllIntentsAndPurposes": true, + "harper-ls.linters.AllOfASudden": true, + "harper-ls.linters.AllowTo": true, + "harper-ls.linters.Alongside": true, + "harper-ls.linters.AlzheimersDisease": true, + "harper-ls.linters.AmInTheMorning": true, + "harper-ls.linters.AmazonNames": true, + "harper-ls.linters.Americas": true, + "harper-ls.linters.AmountsFor": true, + "harper-ls.linters.AnA": true, + "harper-ls.linters.AnAnother": true, + "harper-ls.linters.AndIn": true, + "harper-ls.linters.AndTheLike": true, + "harper-ls.linters.AnotherAn": true, + "harper-ls.linters.AnotherOnes": true, + "harper-ls.linters.AnotherThingComing": true, + "harper-ls.linters.AnotherThings": true, + "harper-ls.linters.AnotherThinkComing": false, + "harper-ls.linters.Anybody": true, + "harper-ls.linters.Anyhow": true, + "harper-ls.linters.Anywhere": true, + "harper-ls.linters.AppleNames": true, + "harper-ls.linters.AsFarAsIKnow": true, + "harper-ls.linters.AsFarBackAs": true, + "harper-ls.linters.AsIfThough": true, + "harper-ls.linters.AsItHappens": true, + "harper-ls.linters.AsLongAs": true, + "harper-ls.linters.AsOfLate": true, + "harper-ls.linters.AsSoonAsPossible": true, + "harper-ls.linters.AskNoPreposition": true, + "harper-ls.linters.AtFaceValue": true, + "harper-ls.linters.AtTheEndOfTheDay": true, + "harper-ls.linters.Australia": true, + "harper-ls.linters.AvoidAndAlso": true, + "harper-ls.linters.AvoidCurses": true, + "harper-ls.linters.AwaitFor": true, + "harper-ls.linters.AzureNames": true, + "harper-ls.linters.BackInTheDay": true, + "harper-ls.linters.Backplane": true, + "harper-ls.linters.BadRap": true, + "harper-ls.linters.BanTogether": true, + "harper-ls.linters.BareInMind": true, + "harper-ls.linters.BatedBreath": true, + "harper-ls.linters.BeAllowed": true, + "harper-ls.linters.BeRightBack": true, + "harper-ls.linters.BeckAndCall": true, + "harper-ls.linters.BeenThere": true, + "harper-ls.linters.Beforehand": true, + "harper-ls.linters.BesideThePoint": true, + "harper-ls.linters.BestOfAllTime": true, + "harper-ls.linters.BestRegards": true, + "harper-ls.linters.BlanketStatement": true, + "harper-ls.linters.BoringWords": false, + "harper-ls.linters.Bought": true, + "harper-ls.linters.Brutality": true, + "harper-ls.linters.BuiltIn": true, + "harper-ls.linters.ByAccident": true, + "harper-ls.linters.ByTheWay": true, + "harper-ls.linters.CanBeSeen": true, + "harper-ls.linters.Canada": true, + "harper-ls.linters.Cant": true, + "harper-ls.linters.CapitalizePersonalPronouns": true, + "harper-ls.linters.CaseInPoint": true, + "harper-ls.linters.CaseSensitive": true, + "harper-ls.linters.CautionaryTale": true, + "harper-ls.linters.ChampAtTheBit": true, + "harper-ls.linters.ChangeTack": true, + "harper-ls.linters.ChineseCommunistParty": true, + "harper-ls.linters.ChockFull": true, + "harper-ls.linters.ClientOrServerSide": true, + "harper-ls.linters.CommaFixes": true, + "harper-ls.linters.CompaniesProductsAndTrademarks": true, + "harper-ls.linters.CompoundNouns": true, + "harper-ls.linters.CompoundSubjectI": true, + "harper-ls.linters.CondenseAllThe": true, + "harper-ls.linters.Confident": true, + "harper-ls.linters.ConfirmThat": true, + "harper-ls.linters.Copyright": true, + "harper-ls.linters.CorrectNumberSuffix": true, + "harper-ls.linters.Countries": true, + "harper-ls.linters.CoursingThroughVeins": true, + "harper-ls.linters.CriteriaPhenomena": true, + "harper-ls.linters.CurrencyPlacement": true, + "harper-ls.linters.DampSquib": true, + "harper-ls.linters.Dashes": true, + "harper-ls.linters.DayAndAge": true, + "harper-ls.linters.DayOneNames": true, + "harper-ls.linters.DefiniteArticle": true, + "harper-ls.linters.DegreesKelvin": true, + "harper-ls.linters.DegreesKelvinSymbol": true, + "harper-ls.linters.Desktop": true, + "harper-ls.linters.DespiteOf": true, + "harper-ls.linters.Devops": true, + "harper-ls.linters.Didnt": true, + "harper-ls.linters.DigestiveTract": true, + "harper-ls.linters.DiscourseMarkers": true, + "harper-ls.linters.Discuss": true, + "harper-ls.linters.DoNotWant": true, + "harper-ls.linters.DoesOrDose": true, + "harper-ls.linters.DontCan": true, + "harper-ls.linters.DotInitialisms": true, + "harper-ls.linters.DoubleClick": true, + "harper-ls.linters.DoubleModal": true, + "harper-ls.linters.EachAndEveryOne": true, + "harper-ls.linters.EggYolk": true, + "harper-ls.linters.EllipsisLength": true, + "harper-ls.linters.ElsePossessive": true, + "harper-ls.linters.EludedTo": true, + "harper-ls.linters.EnMasse": true, + "harper-ls.linters.EnRoute": true, + "harper-ls.linters.EverPresent": true, + "harper-ls.linters.EverSince": true, + "harper-ls.linters.EveryTime": true, + "harper-ls.linters.Everybody": true, + "harper-ls.linters.Everyday": true, + "harper-ls.linters.Everyone": true, + "harper-ls.linters.Everywhere": true, + "harper-ls.linters.Excellent": true, + "harper-ls.linters.ExpandArgument": true, + "harper-ls.linters.ExpandBecause": true, + "harper-ls.linters.ExpandDependencies": true, + "harper-ls.linters.ExpandMemoryShorthands": true, + "harper-ls.linters.ExpandMinimum": true, + "harper-ls.linters.ExpandStandardInputAndOutput": true, + "harper-ls.linters.ExpandTimeShorthands": true, + "harper-ls.linters.ExpandWith": true, + "harper-ls.linters.ExpandWithout": true, + "harper-ls.linters.Expat": true, + "harper-ls.linters.Expatriate": true, + "harper-ls.linters.ExplainLikeImFive": true, + "harper-ls.linters.ExplanationMark": true, + "harper-ls.linters.ExtendOrExtent": true, + "harper-ls.linters.FaceFirst": true, + "harper-ls.linters.FairBit": true, + "harper-ls.linters.FarAndFewBetween": true, + "harper-ls.linters.FarBeIt": true, + "harper-ls.linters.FastPaste": true, + "harper-ls.linters.FatalOutcome": true, + "harper-ls.linters.FeelFell": true, + "harper-ls.linters.FetalPosition": true, + "harper-ls.linters.FewUnitsOfTimeAgo": true, + "harper-ls.linters.FillerWords": true, + "harper-ls.linters.FindFine": true, + "harper-ls.linters.FirstAidKit": true, + "harper-ls.linters.FootTheBill": true, + "harper-ls.linters.ForALongTime": true, + "harper-ls.linters.ForAWhile": true, + "harper-ls.linters.ForNoun": true, + "harper-ls.linters.ForWhatItsWorth": true, + "harper-ls.linters.ForYourInformation": true, + "harper-ls.linters.FreePredicate": true, + "harper-ls.linters.FreeRein": true, + "harper-ls.linters.Freezing": true, + "harper-ls.linters.FriendOfMe": true, + "harper-ls.linters.FromTheGetGo": true, + "harper-ls.linters.Furthermore": true, + "harper-ls.linters.GetRidOf": true, + "harper-ls.linters.GildedAge": true, + "harper-ls.linters.GoSoFarAsTo": true, + "harper-ls.linters.GoingTo": true, + "harper-ls.linters.GoogleNames": true, + "harper-ls.linters.GuineaBissau": true, + "harper-ls.linters.HadOf": true, + "harper-ls.linters.HalfAnHour": true, + "harper-ls.linters.Haphazard": true, + "harper-ls.linters.HavePassed": true, + "harper-ls.linters.HavePronoun": true, + "harper-ls.linters.HaveTakeALook": true, + "harper-ls.linters.Hedging": true, + "harper-ls.linters.HelloGreeting": true, + "harper-ls.linters.Henceforth": true, + "harper-ls.linters.Hereby": true, + "harper-ls.linters.Holidays": true, + "harper-ls.linters.HolyWar": true, + "harper-ls.linters.HomeInOn": true, + "harper-ls.linters.HopHope": true, + "harper-ls.linters.HowItLooksLike": true, + "harper-ls.linters.HowTo": true, + "harper-ls.linters.However": true, + "harper-ls.linters.HumanBeings": true, + "harper-ls.linters.HumanLife": true, + "harper-ls.linters.HungerPang": true, + "harper-ls.linters.HyphenateNumberDay": true, + "harper-ls.linters.IAm": true, + "harper-ls.linters.IAmAgreement": true, + "harper-ls.linters.IDo": true, + "harper-ls.linters.IDontKnow": true, + "harper-ls.linters.IfIRecallCorrectly": true, + "harper-ls.linters.IfWouldve": true, + "harper-ls.linters.IfYouKnowYouKnow": true, + "harper-ls.linters.InAWhile": true, + "harper-ls.linters.InAnyWay": true, + "harper-ls.linters.InCaseYouMissedIt": true, + "harper-ls.linters.InDetail": true, + "harper-ls.linters.InLieuOf": true, + "harper-ls.linters.InMyHumbleOpinion": true, + "harper-ls.linters.InMyOpinion": true, + "harper-ls.linters.InNeedOf": true, + "harper-ls.linters.InOfItself": true, + "harper-ls.linters.InOnTheCards": true, + "harper-ls.linters.InOneFellSwoop": true, + "harper-ls.linters.InRealLife": true, + "harper-ls.linters.InThe": true, + "harper-ls.linters.InflectedVerbAfterTo": true, + "harper-ls.linters.Insensitive": true, + "harper-ls.linters.Insofar": true, + "harper-ls.linters.Instead": true, + "harper-ls.linters.InsteadOf": true, + "harper-ls.linters.Insurmountable": true, + "harper-ls.linters.Intact": true, + "harper-ls.linters.InterestedIn": true, + "harper-ls.linters.InvestIn": true, + "harper-ls.linters.IsKnownFor": true, + "harper-ls.linters.ItCan": true, + "harper-ls.linters.ItLooksLikeThat": true, + "harper-ls.linters.ItsContraction": true, + "harper-ls.linters.ItsPossessive": true, + "harper-ls.linters.Itself": true, + "harper-ls.linters.IveGotTo": true, + "harper-ls.linters.JawDropping": true, + "harper-ls.linters.JetpackNames": true, + "harper-ls.linters.JustDeserts": true, + "harper-ls.linters.KindOf": true, + "harper-ls.linters.KindRegards": true, + "harper-ls.linters.Koreas": true, + "harper-ls.linters.Laptop": true, + "harper-ls.linters.LastButNotLeast": true, + "harper-ls.linters.LastDitch": true, + "harper-ls.linters.LeftRightHand": true, + "harper-ls.linters.LessWorse": true, + "harper-ls.linters.LetAlone": true, + "harper-ls.linters.LetToDo": true, + "harper-ls.linters.LetsConfusion": true, + "harper-ls.linters.LikeAsIf": true, + "harper-ls.linters.LikeThePlague": true, + "harper-ls.linters.Likewise": true, + "harper-ls.linters.LitotesDirectPositive": true, + "harper-ls.linters.LongSentences": true, + "harper-ls.linters.LookingForwardTo": true, + "harper-ls.linters.LowHangingFruit": true, + "harper-ls.linters.MakeDoWith": true, + "harper-ls.linters.MakeItSeem": true, + "harper-ls.linters.Malaysia": true, + "harper-ls.linters.ManagerialReins": true, + "harper-ls.linters.MassPlurals": true, + "harper-ls.linters.MergeWords": true, + "harper-ls.linters.MetaNames": true, + "harper-ls.linters.MicrosoftNames": true, + "harper-ls.linters.Middleware": true, + "harper-ls.linters.MissingPreposition": true, + "harper-ls.linters.MissingTo": true, + "harper-ls.linters.Misspell": true, + "harper-ls.linters.Misunderstand": true, + "harper-ls.linters.Misunderstood": true, + "harper-ls.linters.Misuse": true, + "harper-ls.linters.Misused": true, + "harper-ls.linters.MixedBag": true, + "harper-ls.linters.ModalOf": true, + "harper-ls.linters.ModalSeem": true, + "harper-ls.linters.Months": true, + "harper-ls.linters.Monumentous": true, + "harper-ls.linters.MootPoint": true, + "harper-ls.linters.MoreBetter": true, + "harper-ls.linters.MostNumber": true, + "harper-ls.linters.MostOfTheTimes": true, + "harper-ls.linters.Multicore": true, + "harper-ls.linters.Multimedia": true, + "harper-ls.linters.MultipleSequentialPronouns": true, + "harper-ls.linters.Multithreading": true, + "harper-ls.linters.MyHouse": true, + "harper-ls.linters.Myself": true, + "harper-ls.linters.NailOnTheHead": true, + "harper-ls.linters.NationalCapitals": true, + "harper-ls.linters.NeedHelp": true, + "harper-ls.linters.NeedToNoun": true, + "harper-ls.linters.NerveRacking": true, + "harper-ls.linters.NervousWreck": true, + "harper-ls.linters.NeverMind": true, + "harper-ls.linters.NoFrenchSpaces": true, + "harper-ls.linters.NoMatchFor": true, + "harper-ls.linters.NoOxfordComma": false, + "harper-ls.linters.Nobody": true, + "harper-ls.linters.NominalWants": true, + "harper-ls.linters.Nonetheless": true, + "harper-ls.linters.NotIn": true, + "harper-ls.linters.NotTo": true, + "harper-ls.linters.NotablePlaces": true, + "harper-ls.linters.Nothing": true, + "harper-ls.linters.Notwithstanding": true, + "harper-ls.linters.NounCountability": true, + "harper-ls.linters.NounVerbConfusion": true, + "harper-ls.linters.Nowhere": true, + "harper-ls.linters.NumberSuffixCapitalization": true, + "harper-ls.linters.OceansAndSeas": true, + "harper-ls.linters.OfCourse": true, + "harper-ls.linters.OffTheCuff": true, + "harper-ls.linters.OhMyGod": true, + "harper-ls.linters.OldWivesTale": true, + "harper-ls.linters.OnFloor": true, + "harper-ls.linters.OnSecondThought": true, + "harper-ls.linters.OnTheSpurOfTheMoment": true, + "harper-ls.linters.OnTopOf": true, + "harper-ls.linters.OnceInAWhile": true, + "harper-ls.linters.OnceOrTwice": true, + "harper-ls.linters.OneAndTheSame": true, + "harper-ls.linters.OpenCompounds": true, + "harper-ls.linters.OpenTheLight": true, + "harper-ls.linters.OperatingSystem": true, + "harper-ls.linters.OrthographicConsistency": true, + "harper-ls.linters.OughtToBe": true, + "harper-ls.linters.OutOfDate": true, + "harper-ls.linters.Overall": true, + "harper-ls.linters.Overclocking": true, + "harper-ls.linters.Overload": true, + "harper-ls.linters.Overnight": true, + "harper-ls.linters.OxfordComma": true, + "harper-ls.linters.Oxymorons": true, + "harper-ls.linters.PassersBy": true, + "harper-ls.linters.PeaceOfMind": true, + "harper-ls.linters.PerSe": true, + "harper-ls.linters.PhrasalVerbAsCompoundNoun": true, + "harper-ls.linters.Piggyback": true, + "harper-ls.linters.PiqueInterest": true, + "harper-ls.linters.PleaseTakeALook": true, + "harper-ls.linters.PocketCastsNames": true, + "harper-ls.linters.PointsOfView": true, + "harper-ls.linters.PortAuPrince": true, + "harper-ls.linters.PortoNovo": true, + "harper-ls.linters.PossessiveNoun": false, + "harper-ls.linters.PossessiveYour": true, + "harper-ls.linters.Postpone": true, + "harper-ls.linters.PrayingMantis": true, + "harper-ls.linters.ProgressiveNeedsBe": true, + "harper-ls.linters.PronounAre": true, + "harper-ls.linters.PronounContraction": true, + "harper-ls.linters.PronounInflectionBe": true, + "harper-ls.linters.PronounKnew": true, + "harper-ls.linters.Proofread": true, + "harper-ls.linters.ProperNouns": true, + "harper-ls.linters.QuantifierNeedsOf": true, + "harper-ls.linters.QuantifierNumeralConflict": true, + "harper-ls.linters.QuiteMany": true, + "harper-ls.linters.QuiteQuiet": true, + "harper-ls.linters.QuoteSpacing": true, + "harper-ls.linters.RapidFire": true, + "harper-ls.linters.RealTrouper": true, + "harper-ls.linters.Really": true, + "harper-ls.linters.RedundantAdditiveAdverbs": true, + "harper-ls.linters.RedundantIIRC": true, + "harper-ls.linters.RedundantSuperlatives": true, + "harper-ls.linters.RedundantThat": true, + "harper-ls.linters.Regardless": true, + "harper-ls.linters.Regionalisms": true, + "harper-ls.linters.RepeatedWords": true, + "harper-ls.linters.RifeWith": true, + "harper-ls.linters.RiseTheQuestion": true, + "harper-ls.linters.RoadMap": true, + "harper-ls.linters.RollerSkated": true, + "harper-ls.linters.RulesOfThumb": true, + "harper-ls.linters.SafeToSave": true, + "harper-ls.linters.SameAs": true, + "harper-ls.linters.SaveToSafe": true, + "harper-ls.linters.ScantilyClad": true, + "harper-ls.linters.SemicolonApostrophe": true, + "harper-ls.linters.SentenceCapitalization": true, + "harper-ls.linters.ShootOneselfInTheFoot": true, + "harper-ls.linters.SimpleGrammatical": true, + "harper-ls.linters.SimplePastToPastParticiple": true, + "harper-ls.linters.SinceDuration": true, + "harper-ls.linters.SingleBe": true, + "harper-ls.linters.SneakingSuspicion": true, + "harper-ls.linters.SomeOfThe": true, + "harper-ls.linters.SomeWithoutArticle": true, + "harper-ls.linters.Somebody": true, + "harper-ls.linters.SomebodyElses": true, + "harper-ls.linters.Somehow": true, + "harper-ls.linters.Someone": true, + "harper-ls.linters.SomethingIs": true, + "harper-ls.linters.SomewhatSomething": true, + "harper-ls.linters.Somewhere": true, + "harper-ls.linters.SoonerOrLater": true, + "harper-ls.linters.SoughtAfter": true, + "harper-ls.linters.Spaces": true, + "harper-ls.linters.SpecialAttention": true, + "harper-ls.linters.SpellCheck": true, + "harper-ls.linters.SpelledNumbers": false, + "harper-ls.linters.SplitWords": true, + "harper-ls.linters.Starving": true, + "harper-ls.linters.StateOfTheArt": true, + "harper-ls.linters.StatuteOfLimitations": true, + "harper-ls.linters.SubjectPronoun": true, + "harper-ls.linters.SufficeItToSay": true, + "harper-ls.linters.SupposedTo": true, + "harper-ls.linters.TakeItPersonally": true, + "harper-ls.linters.TalkToYouLater": true, + "harper-ls.linters.ThanksALot": true, + "harper-ls.linters.ThatChallenged": true, + "harper-ls.linters.ThatThan": true, + "harper-ls.linters.ThatThis": true, + "harper-ls.linters.ThatWhich": true, + "harper-ls.linters.The": true, + "harper-ls.linters.TheAnother": true, + "harper-ls.linters.TheHowWhy": true, + "harper-ls.linters.TheMy": true, + "harper-ls.linters.ThenThan": true, + "harper-ls.linters.Therefore": true, + "harper-ls.linters.Theres": true, + "harper-ls.linters.Thereupon": true, + "harper-ls.linters.ThesesThese": true, + "harper-ls.linters.ThingThink": true, + "harper-ls.linters.ThoughThought": true, + "harper-ls.linters.ThoughtProcess": true, + "harper-ls.linters.ThrowAway": true, + "harper-ls.linters.ThrowRubbish": true, + "harper-ls.linters.TickingTimeClock": true, + "harper-ls.linters.ToAdverb": true, + "harper-ls.linters.ToBeHonest": true, + "harper-ls.linters.ToDoHyphen": true, + "harper-ls.linters.ToGreatLengths": true, + "harper-ls.linters.ToTheMannerBorn": true, + "harper-ls.linters.ToTooIdioms": true, + "harper-ls.linters.ToTwoToo": true, + "harper-ls.linters.ToWorryAbout": true, + "harper-ls.linters.TongueInCheek": true, + "harper-ls.linters.TooTo": true, + "harper-ls.linters.Touristic": true, + "harper-ls.linters.Towards": true, + "harper-ls.linters.TrialAndError": true, + "harper-ls.linters.TumblrNames": true, + "harper-ls.linters.TurnItOff": true, + "harper-ls.linters.USUniversities": true, + "harper-ls.linters.UnclosedQuotes": true, + "harper-ls.linters.Underclock": true, + "harper-ls.linters.UnitedOrganizations": true, + "harper-ls.linters.Unless": true, + "harper-ls.linters.UpdatePlaceNames": true, + "harper-ls.linters.Upset": true, + "harper-ls.linters.Upward": true, + "harper-ls.linters.UseGenitive": false, + "harper-ls.linters.VerbToAdjective": true, + "harper-ls.linters.VeryUnique": true, + "harper-ls.linters.ViceVersa": true, + "harper-ls.linters.WantBe": true, + "harper-ls.linters.WasAloud": true, + "harper-ls.linters.WaveFunction": true, + "harper-ls.linters.WayTooAdjective": true, + "harper-ls.linters.WellBeing": true, + "harper-ls.linters.WellEducated": true, + "harper-ls.linters.WellKept": true, + "harper-ls.linters.Whereas": true, + "harper-ls.linters.Whereupon": true, + "harper-ls.linters.WhetYourAppetite": true, + "harper-ls.linters.WholeEntire": true, + "harper-ls.linters.WidelyAccepted": true, + "harper-ls.linters.Widespread": true, + "harper-ls.linters.WillContain": true, + "harper-ls.linters.WinPrize": true, + "harper-ls.linters.WishCould": true, + "harper-ls.linters.WordPressDotcom": true, + "harper-ls.linters.Worldwide": true, + "harper-ls.linters.WorseOrWorst": true, + "harper-ls.linters.WorstCaseScenario": true, + "harper-ls.linters.WouldNeverHave": true, + "harper-ls.linters.WroughtIron": true +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/settings.schema.json b/src/main/resources/templates/lsp/harper-ls/settings.schema.json new file mode 100644 index 000000000..9accf9003 --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/settings.schema.json @@ -0,0 +1,2447 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "LSP4IJ/harper/settings.schema.json", + "title": "LSP4IJ harper server settings JSON schema", + "description": "JSON schema for harper server settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "harper-ls.path": { + "type": "string", + "description": "Optional path to a harper-ls executable to use." + }, + "harper-ls.codeActions.ForceStable": { + "type": "boolean", + "default": false, + "description": "Make code actions appear in \"stable\" positions by placing code actions that should always be available, like adding misspelled words in the dictionary, first." + }, + "harper-ls.dialect": { + "type": "string", + "enum": [ + "British", + "American", + "Canadian", + "Australian" + ], + "default": "American", + "description": "Set which dialect of English Harper should expect." + }, + "harper-ls.diagnosticSeverity": { + "type": "string", + "enum": [ + "error", + "hint", + "information", + "warning" + ], + "default": "information", + "description": "How severe do you want diagnostics to appear in the editor?" + }, + "harper-ls.excludePatterns": { + "type": "array", + "description": "A set of globs to ignore. If a file matches any of the globs, it will not be linted." + }, + "harper-ls.fileDictPath": { + "type": "string", + "description": "Optional path to a file dictionary directory to use." + }, + "harper-ls.isolateEnglish": { + "type": "boolean", + "default": false, + "description": "Only lint English text in documents that are a mixture of English and another language." + }, + "harper-ls.markdown.IgnoreLinkTitle": { + "type": "boolean", + "default": false, + "description": "Skip linting link titles." + }, + "harper-ls.maxFileLength": { + "type": "number", + "default": 120000, + "description": "Maximum length of file to be linted (in bytes). If a file is larger than this, it will not be linted." + }, + "harper-ls.userDictPath": { + "type": "string", + "description": "Optional path to a user dictionary file to use." + }, + "harper-ls.workspaceDictPath": { + "type": "string", + "description": "Optional path to a workspace-local dictionary file to use." + }, + "harper-ls.linters.ACoupleMore": { + "type": "boolean", + "default": true, + "description": "Corrects `a couple of more` to `a couple more`." + }, + "harper-ls.linters.ALongTime": { + "type": "boolean", + "default": true, + "description": "Corrects `along time` to `a long time`." + }, + "harper-ls.linters.APart": { + "type": "boolean", + "default": true, + "description": "Finds and corrects common mistakes between \u0027a part\u0027 and \u0027apart\u0027" + }, + "harper-ls.linters.AWhile": { + "type": "boolean", + "default": true, + "description": "Enforces `awhile` after verbs and `a while` everywhere else." + }, + "harper-ls.linters.Addicting": { + "type": "boolean", + "default": true, + "description": "Replaces `addicting` with `addictive` when used as an adjective." + }, + "harper-ls.linters.AdjectiveDoubleDegree": { + "type": "boolean", + "default": true, + "description": "Finds adjectives that are used as double degrees (e.g. `more prettier`)." + }, + "harper-ls.linters.AdjectiveOfA": { + "type": "boolean", + "default": true, + "description": "This rule looks for sequences of words of the form `adjective of a`." + }, + "harper-ls.linters.Ado": { + "type": "boolean", + "default": true, + "description": "Corrects `adieu` to `ado`." + }, + "harper-ls.linters.AfterAWhile": { + "type": "boolean", + "default": true, + "description": "Corrects the missing article in `after while`, forming `after a while`." + }, + "harper-ls.linters.AfterLater": { + "type": "boolean", + "default": true, + "description": "Checks for the word `later` following `after [a period of time]`." + }, + "harper-ls.linters.AheadAnd": { + "type": "boolean", + "default": true, + "description": "Corrects `an` to `and` after `ahead`." + }, + "harper-ls.linters.AllIntentsAndPurposes": { + "type": "boolean", + "default": true, + "description": "Finds and corrects common wrong forms of the phrase \u0027for all intents and purposes\u0027 / \u0027to all intents and purposes\u0027." + }, + "harper-ls.linters.AllOfASudden": { + "type": "boolean", + "default": true, + "description": "Guides this expression toward the standard `all of a sudden`." + }, + "harper-ls.linters.AllowTo": { + "type": "boolean", + "default": true, + "description": "Flags erroneous usage of `allow to` without a subject." + }, + "harper-ls.linters.Alongside": { + "type": "boolean", + "default": true, + "description": "Replaces the spaced form `along side` with `alongside`." + }, + "harper-ls.linters.AlzheimersDisease": { + "type": "boolean", + "default": true, + "description": "Fixes the common misnomer `old-timers\u0027 disease`, ensuring the correct medical term `Alzheimer’s disease` is used." + }, + "harper-ls.linters.AmInTheMorning": { + "type": "boolean", + "default": true, + "description": "Finds redundant am/pm indicators used together with time periods such as \u0027in the morning\u0027 or \u0027at night\u0027." + }, + "harper-ls.linters.AmazonNames": { + "type": "boolean", + "default": true, + "description": "When referring to the various products of Amazon.com, make sure to treat them as a proper noun." + }, + "harper-ls.linters.Americas": { + "type": "boolean", + "default": true, + "description": "When referring to North, Central, and South America, make sure to treat them as a proper noun." + }, + "harper-ls.linters.AmountsFor": { + "type": "boolean", + "default": true, + "description": "Corrects `amounts for` to either `amounts to` or `accounts for`" + }, + "harper-ls.linters.AnA": { + "type": "boolean", + "default": true, + "description": "A rule that looks for incorrect indefinite articles. For example, `this is an mule` would be flagged as incorrect." + }, + "harper-ls.linters.AnAnother": { + "type": "boolean", + "default": true, + "description": "Corrects `an another` and `a another`." + }, + "harper-ls.linters.AndIn": { + "type": "boolean", + "default": true, + "description": "Fixes the incorrect phrase `an in` to `and in` for proper conjunction usage." + }, + "harper-ls.linters.AndTheLike": { + "type": "boolean", + "default": true, + "description": "Corrects mistakes in `and the like` and `or the like`." + }, + "harper-ls.linters.AnotherAn": { + "type": "boolean", + "default": true, + "description": "Corrects `another an` to `another`." + }, + "harper-ls.linters.AnotherOnes": { + "type": "boolean", + "default": true, + "description": "Corrects `another ones`." + }, + "harper-ls.linters.AnotherThingComing": { + "type": "boolean", + "default": true, + "description": "Though `another think coming` is the original phrase, `another thing coming` is now more common." + }, + "harper-ls.linters.AnotherThings": { + "type": "boolean", + "default": true, + "description": "Corrects `another things`." + }, + "harper-ls.linters.AnotherThinkComing": { + "type": "boolean", + "default": false, + "description": "Though `another thing coming` is now more common, `another think coming` is the original phrase." + }, + "harper-ls.linters.Anybody": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `anybody`." + }, + "harper-ls.linters.Anyhow": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `anyhow`." + }, + "harper-ls.linters.Anywhere": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `anywhere`." + }, + "harper-ls.linters.AppleNames": { + "type": "boolean", + "default": true, + "description": "When referring to Apple products and services, make sure to treat them as proper nouns." + }, + "harper-ls.linters.AsFarAsIKnow": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.AsFarBackAs": { + "type": "boolean", + "default": true, + "description": "Corrects nonstandard `as early back as` to `as far back as`." + }, + "harper-ls.linters.AsIfThough": { + "type": "boolean", + "default": true, + "description": "Corrects redundant `as if though`." + }, + "harper-ls.linters.AsItHappens": { + "type": "boolean", + "default": true, + "description": "Corrects `as it so happens` to `as it happens`." + }, + "harper-ls.linters.AsLongAs": { + "type": "boolean", + "default": true, + "description": "Corrects `aslong as` to `as long as`." + }, + "harper-ls.linters.AsOfLate": { + "type": "boolean", + "default": true, + "description": "Corrects `as of lately` to `as of late`." + }, + "harper-ls.linters.AsSoonAsPossible": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.AskNoPreposition": { + "type": "boolean", + "default": true, + "description": "Identifies sequences like `ask to us` or `tell to him` and recommends removing the superfluous “to”." + }, + "harper-ls.linters.AtFaceValue": { + "type": "boolean", + "default": true, + "description": "Corrects `on face value` to the more usual `at face value`." + }, + "harper-ls.linters.AtTheEndOfTheDay": { + "type": "boolean", + "default": true, + "description": "Corrects `in the end of the day` to `at the end of the day`." + }, + "harper-ls.linters.Australia": { + "type": "boolean", + "default": true, + "description": "When referring to the states of Australia, make sure to treat them as a proper noun." + }, + "harper-ls.linters.AvoidAndAlso": { + "type": "boolean", + "default": true, + "description": "Reduces redundancy by replacing `and also` with `and`." + }, + "harper-ls.linters.AvoidCurses": { + "type": "boolean", + "default": true, + "description": "Flags offensive language and offers various ways to censor or replace with euphemisms." + }, + "harper-ls.linters.AwaitFor": { + "type": "boolean", + "default": true, + "description": "Suggests using either `await` or `wait for` but not both, as they express the same meaning." + }, + "harper-ls.linters.AzureNames": { + "type": "boolean", + "default": true, + "description": "When referring to Azure cloud services, make sure to treat them as proper nouns." + }, + "harper-ls.linters.BackInTheDay": { + "type": "boolean", + "default": true, + "description": "This linter flags instances of the nonstandard phrase `back in the days`. The correct, more accepted form is `back in the day`" + }, + "harper-ls.linters.Backplane": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `backplane`." + }, + "harper-ls.linters.BadRap": { + "type": "boolean", + "default": true, + "description": "Changes `bed rap` to the proper idiom `bad rap`." + }, + "harper-ls.linters.BanTogether": { + "type": "boolean", + "default": true, + "description": "Detects and corrects the common error of using `ban together` instead of the idiom `band together`, which means to unite or join forces." + }, + "harper-ls.linters.BareInMind": { + "type": "boolean", + "default": true, + "description": "Ensures the phrase `bear in mind` is used correctly instead of `bare in mind`." + }, + "harper-ls.linters.BatedBreath": { + "type": "boolean", + "default": true, + "description": "Changes `baited breath` to the correct `bated breath`." + }, + "harper-ls.linters.BeAllowed": { + "type": "boolean", + "default": true, + "description": "Ensures the passive form uses `be allowed` after future negatives." + }, + "harper-ls.linters.BeRightBack": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.BeckAndCall": { + "type": "boolean", + "default": true, + "description": "Fixes `back and call` to `beck and call`." + }, + "harper-ls.linters.BeenThere": { + "type": "boolean", + "default": true, + "description": "Corrects the misspelling `bee there` to the proper phrase `been there`." + }, + "harper-ls.linters.Beforehand": { + "type": "boolean", + "default": true, + "description": "`Beforehand` functions as a fixed adverb meaning ‘in advance’; writing it as two words or with a hyphen is nonstandard and can jar readers." + }, + "harper-ls.linters.BesideThePoint": { + "type": "boolean", + "default": true, + "description": "Corrects `besides the point` to `beside the point`." + }, + "harper-ls.linters.BestOfAllTime": { + "type": "boolean", + "default": true, + "description": "Checks for nonstandard `of all times` in superlatives instead of singular `time`" + }, + "harper-ls.linters.BestRegards": { + "type": "boolean", + "default": true, + "description": "In valedictions, `best` expresses your highest regard—avoid the typo `beat regards`." + }, + "harper-ls.linters.BlanketStatement": { + "type": "boolean", + "default": true, + "description": "Corrects common errors in the phrase `blanket statement`." + }, + "harper-ls.linters.BoringWords": { + "type": "boolean", + "default": false, + "description": "This rule looks for particularly boring or overused words. Using varied language is an easy way to keep a reader\u0027s attention." + }, + "harper-ls.linters.Bought": { + "type": "boolean", + "default": true, + "description": "Replaces the incorrect past-tense spelling `bough` with `bought` after subject pronouns." + }, + "harper-ls.linters.Brutality": { + "type": "boolean", + "default": true, + "description": "Suggests the more standard and common synonym `brutality`." + }, + "harper-ls.linters.BuiltIn": { + "type": "boolean", + "default": true, + "description": "English convention treats `built-in` as a single, attributive adjective—meaning something integrated from the outset—whereas other forms like `in built` are non-standard and can feel awkward to readers." + }, + "harper-ls.linters.ByAccident": { + "type": "boolean", + "default": true, + "description": "Incorrect preposition: `by accident` is the idiomatic expression." + }, + "harper-ls.linters.ByTheWay": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.CanBeSeen": { + "type": "boolean", + "default": true, + "description": "Corrects `can be seem` to the proper phrase `can be seen`." + }, + "harper-ls.linters.Canada": { + "type": "boolean", + "default": true, + "description": "When referring to the provinces of Canada, make sure to treat them as a proper noun." + }, + "harper-ls.linters.Cant": { + "type": "boolean", + "default": true, + "description": "Suggests correcting `cant` to `can\u0027t`." + }, + "harper-ls.linters.CapitalizePersonalPronouns": { + "type": "boolean", + "default": true, + "description": "Forgetting to capitalize personal pronouns, like \"I\" or \"I\u0027m\" is one of the most common errors. This rule helps with that." + }, + "harper-ls.linters.CaseInPoint": { + "type": "boolean", + "default": true, + "description": "Corrects `case and point` to `case in point`." + }, + "harper-ls.linters.CaseSensitive": { + "type": "boolean", + "default": true, + "description": "Ensures `case-sensitive` is correctly hyphenated." + }, + "harper-ls.linters.CautionaryTale": { + "type": "boolean", + "default": true, + "description": "Corrects confusion between `tale` (story) and `tail` (appendage) in common phrases." + }, + "harper-ls.linters.ChampAtTheBit": { + "type": "boolean", + "default": true, + "description": "Corrects `chomp at the bit` to the idiom `champ at the bit`, which has an equestrian origin referring to the way an anxious horse grinds its teeth against the metal part of the bridle." + }, + "harper-ls.linters.ChangeTack": { + "type": "boolean", + "default": true, + "description": "Locates errors in the idioms `to change tack` and `change of tack` to convey the correct meaning of altering one\u0027s course or strategy." + }, + "harper-ls.linters.ChineseCommunistParty": { + "type": "boolean", + "default": true, + "description": "When referring to the political party, make sure to treat them as a proper noun." + }, + "harper-ls.linters.ChockFull": { + "type": "boolean", + "default": true, + "description": "Flags common soundalikes of \"chock-full\" and makes sure they\u0027re hyphenated." + }, + "harper-ls.linters.ClientOrServerSide": { + "type": "boolean", + "default": true, + "description": "Corrects extraneous apostrophe in `client\u0027s side` and `server\u0027s side`." + }, + "harper-ls.linters.CommaFixes": { + "type": "boolean", + "default": true, + "description": "Fix common comma errors such as no space after, erroneous space before, etc., Asian commas instead of English commas, etc." + }, + "harper-ls.linters.CompaniesProductsAndTrademarks": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of companies, products, and trademarks." + }, + "harper-ls.linters.CompoundNouns": { + "type": "boolean", + "default": true, + "description": "Detects compound nouns split by a space and suggests merging them when both parts form a valid noun." + }, + "harper-ls.linters.CompoundSubjectI": { + "type": "boolean", + "default": true, + "description": "Promotes `I` in compound subjects headed by a possessive determiner." + }, + "harper-ls.linters.CondenseAllThe": { + "type": "boolean", + "default": true, + "description": "Suggests removing `of` in `all of the` for a more concise phrase." + }, + "harper-ls.linters.Confident": { + "type": "boolean", + "default": true, + "description": "This linter detects instances where the noun `confidant` is incorrectly used in place of the adjective `confident`. `Confidant` refers to a trusted person, whereas `confident` describes certainty or self-assurance. The rule suggests replacing `confidant` with `confident` when used in an adjectival context." + }, + "harper-ls.linters.ConfirmThat": { + "type": "boolean", + "default": true, + "description": "Corrects `conform` typos to `confirm`." + }, + "harper-ls.linters.Copyright": { + "type": "boolean", + "default": true, + "description": "Corrects `copywrite` to `copyright`. `Copywrite` refers to writing copy, while `copyright` is the legal right to creative works." + }, + "harper-ls.linters.CorrectNumberSuffix": { + "type": "boolean", + "default": true, + "description": "When making quick edits, it is common for authors to change the value of a number without changing its suffix. This rule looks for these cases, for example: `2st`." + }, + "harper-ls.linters.Countries": { + "type": "boolean", + "default": true, + "description": "When referring to Countries, make sure to treat it as a proper noun." + }, + "harper-ls.linters.CoursingThroughVeins": { + "type": "boolean", + "default": true, + "description": "In English idioms, “to course” means to flow rapidly—so avoid the eggcorn `cursing through veins.`" + }, + "harper-ls.linters.CriteriaPhenomena": { + "type": "boolean", + "default": true, + "description": "The words “criteria” and “phenomena” are the plurals of “criterion” and “phenomenon”, respectively. They are often incorrectly used as singular." + }, + "harper-ls.linters.CurrencyPlacement": { + "type": "boolean", + "default": true, + "description": "The location of currency symbols varies by country. The rule looks for and corrects improper positioning." + }, + "harper-ls.linters.DampSquib": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `damp squid` to `damp squib`, ensuring the intended meaning of a failed or underwhelming outcome." + }, + "harper-ls.linters.Dashes": { + "type": "boolean", + "default": true, + "description": "Writers often type `--` or `---` expecting their editor to convert them into proper dashes. Replace these sequences with the correct characters: use an en dash (–) for ranges or connections and an em dash (—) for a break in thought." + }, + "harper-ls.linters.DayAndAge": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `day in age` to `day and age`, which properly means the current era or time period." + }, + "harper-ls.linters.DayOneNames": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of Day One and Day One Premium as brand names." + }, + "harper-ls.linters.DefiniteArticle": { + "type": "boolean", + "default": true, + "description": "The name of the word `the` is `definite article`." + }, + "harper-ls.linters.DegreesKelvin": { + "type": "boolean", + "default": true, + "description": "Corrects use of `degrees kelvin` to `kelvins`." + }, + "harper-ls.linters.DegreesKelvinSymbol": { + "type": "boolean", + "default": true, + "description": "Corrects use of `°K` to `K`." + }, + "harper-ls.linters.Desktop": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `desktop`." + }, + "harper-ls.linters.DespiteOf": { + "type": "boolean", + "default": true, + "description": "Corrects the misuse of `despite of` and suggests the proper alternatives `despite` or `in spite of`." + }, + "harper-ls.linters.Devops": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `devops`." + }, + "harper-ls.linters.Didnt": { + "type": "boolean", + "default": true, + "description": "Corrects `dint` to `didn\u0027t` after subject pronouns." + }, + "harper-ls.linters.DigestiveTract": { + "type": "boolean", + "default": true, + "description": "Corrects `digestive track` to `digestive tract`." + }, + "harper-ls.linters.DiscourseMarkers": { + "type": "boolean", + "default": true, + "description": "Flags sentences that begin with a discourse marker but omit the required following comma." + }, + "harper-ls.linters.Discuss": { + "type": "boolean", + "default": true, + "description": "Removes unnecessary `about` after `discuss`." + }, + "harper-ls.linters.DoNotWant": { + "type": "boolean", + "default": true, + "description": "In English, negation still requires the complete verb form (“want”), so avoid truncating it to “wan.”" + }, + "harper-ls.linters.DoesOrDose": { + "type": "boolean", + "default": true, + "description": "Tries to correct typos of `dose` to `does`." + }, + "harper-ls.linters.DontCan": { + "type": "boolean", + "default": true, + "description": "Corrects `don\u0027t can` to `can\u0027t` or `cannot`." + }, + "harper-ls.linters.DotInitialisms": { + "type": "boolean", + "default": true, + "description": "Ensures common initialisms (like \"i.e.\") are properly dot-separated." + }, + "harper-ls.linters.DoubleClick": { + "type": "boolean", + "default": true, + "description": "Encourages hyphenating `double-click` and its inflections." + }, + "harper-ls.linters.DoubleModal": { + "type": "boolean", + "default": true, + "description": "Two modal verbs in a row are rarely grammatical; remove one of them." + }, + "harper-ls.linters.EachAndEveryOne": { + "type": "boolean", + "default": true, + "description": "Corrects `each and everyone` to `each and every one`." + }, + "harper-ls.linters.EggYolk": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `egg yoke`, replacing it with the standard culinary term `egg yolk`." + }, + "harper-ls.linters.EllipsisLength": { + "type": "boolean", + "default": true, + "description": "Make sure you have the correct number of dots in your ellipsis." + }, + "harper-ls.linters.ElsePossessive": { + "type": "boolean", + "default": true, + "description": "Detects missing apostrophes in phrases like `someone elses book` and suggests the correct possessive form `else’s`." + }, + "harper-ls.linters.EludedTo": { + "type": "boolean", + "default": true, + "description": "Corrects `eluded to` to `alluded to` in contexts referring to indirect references." + }, + "harper-ls.linters.EnMasse": { + "type": "boolean", + "default": true, + "description": "Detects variants like `on mass` or `in mass` and suggests `en masse`." + }, + "harper-ls.linters.EnRoute": { + "type": "boolean", + "default": true, + "description": "Detects variants like `on route` or `in route` and suggests `en route`." + }, + "harper-ls.linters.EverPresent": { + "type": "boolean", + "default": true, + "description": "Corrects the missing hyphen in `ever present` to the compound adjective `ever-present`." + }, + "harper-ls.linters.EverSince": { + "type": "boolean", + "default": true, + "description": "Corrects `every since` to `ever since`." + }, + "harper-ls.linters.EveryTime": { + "type": "boolean", + "default": true, + "description": "Corrects `everytime` to `every time`." + }, + "harper-ls.linters.Everybody": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `everybody`." + }, + "harper-ls.linters.Everyday": { + "type": "boolean", + "default": true, + "description": "This rule tries to sort out confusing the adjective `everyday` and the adverb `every day`." + }, + "harper-ls.linters.Everyone": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `everyone`." + }, + "harper-ls.linters.Everywhere": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `everywhere`." + }, + "harper-ls.linters.Excellent": { + "type": "boolean", + "default": true, + "description": "Provides a stronger word choice by replacing `very good` with `excellent` for clarity and emphasis." + }, + "harper-ls.linters.ExpandArgument": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviation `arg` to the full word `argument` for clarity." + }, + "harper-ls.linters.ExpandBecause": { + "type": "boolean", + "default": true, + "description": "Expands the informal abbreviation `cuz` to the full word `because` for formality." + }, + "harper-ls.linters.ExpandDependencies": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviation `deps` to the full word `dependencies` for clarity." + }, + "harper-ls.linters.ExpandMemoryShorthands": { + "type": "boolean", + "default": true, + "description": "Expands memory-related abbreviations (`B`, `kB`, `MB`, `GB`, `TB`, `PB`, `KiB`, `MiB`, `GiB`, `TiB`, `PiB`, etc.) to their full forms (`byte`, `kilobyte`, `megabyte`, `gigabyte`, `terabyte`, `petabyte`, `kibibyte`, `mebibyte`, `gibibyte`, `tebibyte`, `pebibyte`, etc.)." + }, + "harper-ls.linters.ExpandMinimum": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviation `min` to the full word `minimum` for clarity." + }, + "harper-ls.linters.ExpandStandardInputAndOutput": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviations `stdin`, `stdout`, and `stderr` to the full words `standard input`, etc. for clarity." + }, + "harper-ls.linters.ExpandTimeShorthands": { + "type": "boolean", + "default": true, + "description": "Expands time-related abbreviations (`hr`, `hrs`, `min`, `mins`, `sec`, `secs`, `ms`, `msec`, `msecs`) to their full forms (`hour`, `hours`, `minute`, `minutes`, `second`, `seconds`, `millisecond`, `milliseconds`)." + }, + "harper-ls.linters.ExpandWith": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviation `w/` to the full word `with` for clarity." + }, + "harper-ls.linters.ExpandWithout": { + "type": "boolean", + "default": true, + "description": "Expands the abbreviation `w/o` to the full word `without` for clarity." + }, + "harper-ls.linters.Expat": { + "type": "boolean", + "default": true, + "description": "Corrects the mistake of writing `expat` as two words." + }, + "harper-ls.linters.Expatriate": { + "type": "boolean", + "default": true, + "description": "Fixes the misinterpretation of `expatriate`, ensuring the correct term is used for individuals residing abroad." + }, + "harper-ls.linters.ExplainLikeImFive": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.ExplanationMark": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `explanation mark/point` to `exclamation mark/point`." + }, + "harper-ls.linters.ExtendOrExtent": { + "type": "boolean", + "default": true, + "description": "Corrects `extend` to `extent` when the context is a noun." + }, + "harper-ls.linters.FaceFirst": { + "type": "boolean", + "default": true, + "description": "Ensures `face first` is correctly hyphenated as `face-first` when used before `into`." + }, + "harper-ls.linters.FairBit": { + "type": "boolean", + "default": true, + "description": "Corrects malapropisms of `a fair bit`." + }, + "harper-ls.linters.FarAndFewBetween": { + "type": "boolean", + "default": true, + "description": "Corrects `far and few between` to the standard idiom `few and far between`." + }, + "harper-ls.linters.FarBeIt": { + "type": "boolean", + "default": true, + "description": "Flags misuse of `far be it` and suggests using `from` when it is followed by `for`" + }, + "harper-ls.linters.FastPaste": { + "type": "boolean", + "default": true, + "description": "Detects incorrect usage of `fast paste` or `fast-paste` and suggests `fast-paced` as the correct phrase." + }, + "harper-ls.linters.FatalOutcome": { + "type": "boolean", + "default": true, + "description": "Replaces `fatal outcome` with the more direct term `death` for conciseness." + }, + "harper-ls.linters.FeelFell": { + "type": "boolean", + "default": true, + "description": "Corrects some expressions using `fell` where `feel` is correct." + }, + "harper-ls.linters.FetalPosition": { + "type": "boolean", + "default": true, + "description": "Ensures the correct use of `fetal position`, avoiding confusion with `feeble position`, which is not a standard phrase." + }, + "harper-ls.linters.FewUnitsOfTimeAgo": { + "type": "boolean", + "default": true, + "description": "Corrects some expressions using `few` where `a few` is correct." + }, + "harper-ls.linters.FillerWords": { + "type": "boolean", + "default": true, + "description": "Removes filler words." + }, + "harper-ls.linters.FindFine": { + "type": "boolean", + "default": true, + "description": "Fixes the common typo where writers write `find` when they mean `fine`." + }, + "harper-ls.linters.FirstAidKit": { + "type": "boolean", + "default": true, + "description": "Detects when “kid” after “aid”, “starter”, “travel”, or “tool” should be “kit” (a set of supplies)." + }, + "harper-ls.linters.FootTheBill": { + "type": "boolean", + "default": true, + "description": "Corrects `flip the bill` to `foot the bill`." + }, + "harper-ls.linters.ForALongTime": { + "type": "boolean", + "default": true, + "description": "Eliminates the incorrect merging in `for along time`." + }, + "harper-ls.linters.ForAWhile": { + "type": "boolean", + "default": true, + "description": "Corrects the missing article in `for while`, forming `for a while`." + }, + "harper-ls.linters.ForNoun": { + "type": "boolean", + "default": true, + "description": "Corrects the archaic or mistaken `fro` to `for` when followed by a noun." + }, + "harper-ls.linters.ForWhatItsWorth": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.ForYourInformation": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.FreePredicate": { + "type": "boolean", + "default": true, + "description": "Helps swap in `free` when a linking verb is followed by the noun `fee`." + }, + "harper-ls.linters.FreeRein": { + "type": "boolean", + "default": true, + "description": "Ensures the correct use of `free rein`, avoiding confusion with `free reign`, which incorrectly suggests authority rather than freedom of action." + }, + "harper-ls.linters.Freezing": { + "type": "boolean", + "default": true, + "description": "Encourages vivid writing by suggesting `freezing` instead of weaker expressions like `very cold.`" + }, + "harper-ls.linters.FriendOfMe": { + "type": "boolean", + "default": true, + "description": "Corrects wrong pronoun usage in constructions like `a friend of me`." + }, + "harper-ls.linters.FromTheGetGo": { + "type": "boolean", + "default": true, + "description": "Ensures `from the get-go` is correctly hyphenated, preserving the idiom’s meaning of ‘from the very beginning’." + }, + "harper-ls.linters.Furthermore": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `furthermore`." + }, + "harper-ls.linters.GetRidOf": { + "type": "boolean", + "default": true, + "description": "Corrects common misspellings of the idiom `get rid of`." + }, + "harper-ls.linters.GildedAge": { + "type": "boolean", + "default": true, + "description": "If referring to the period of economic prosperity, the correct term is `Gilded Age`." + }, + "harper-ls.linters.GoSoFarAsTo": { + "type": "boolean", + "default": true, + "description": "Flags \u0027go so far to\u0027 when it should be \u0027go so far as to\u0027 to express going beyond expectations" + }, + "harper-ls.linters.GoingTo": { + "type": "boolean", + "default": true, + "description": "Corrects `gong to` to the intended phrase `going to`." + }, + "harper-ls.linters.GoogleNames": { + "type": "boolean", + "default": true, + "description": "When referring to Google products and services, make sure to treat them as proper nouns." + }, + "harper-ls.linters.GuineaBissau": { + "type": "boolean", + "default": true, + "description": "Checks for the correct official name of the African country." + }, + "harper-ls.linters.HadOf": { + "type": "boolean", + "default": true, + "description": "Flags the unnecessary use of `of` after `had` and suggests the correct forms." + }, + "harper-ls.linters.HalfAnHour": { + "type": "boolean", + "default": true, + "description": "Fixes the eggcorn `half an our` to the accepted `half an hour`." + }, + "harper-ls.linters.Haphazard": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `half hazard` to `haphazard`, which properly means lacking organization or being random." + }, + "harper-ls.linters.HavePassed": { + "type": "boolean", + "default": true, + "description": "Suggests `past` for `passed` in case a verb was intended." + }, + "harper-ls.linters.HavePronoun": { + "type": "boolean", + "default": true, + "description": "Flags questions that begin with `has` followed by a pronoun that requires `have`, such as `Has we …` or `Has I …`, and suggests the correct auxiliary." + }, + "harper-ls.linters.HaveTakeALook": { + "type": "boolean", + "default": true, + "description": "Corrects either `have a look` or `take a look` to the other, depending on the dialect." + }, + "harper-ls.linters.Hedging": { + "type": "boolean", + "default": true, + "description": "Flags hedging language (e.g. `I would argue that`, `..., so to speak`, `to a certain degree`)." + }, + "harper-ls.linters.HelloGreeting": { + "type": "boolean", + "default": true, + "description": "Encourages greeting someone with `hello` instead of the homophone `halo`." + }, + "harper-ls.linters.Henceforth": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `henceforth`." + }, + "harper-ls.linters.Hereby": { + "type": "boolean", + "default": true, + "description": "`Here by` in some contexts should be `hereby`" + }, + "harper-ls.linters.Holidays": { + "type": "boolean", + "default": true, + "description": "When referring to holidays, make sure to treat them as a proper noun." + }, + "harper-ls.linters.HolyWar": { + "type": "boolean", + "default": true, + "description": "Corrects misspellings of `holy war`." + }, + "harper-ls.linters.HomeInOn": { + "type": "boolean", + "default": true, + "description": "Corrects `hone in on` to `home in on`." + }, + "harper-ls.linters.HopHope": { + "type": "boolean", + "default": true, + "description": "Handles common errors involving `hop` and `hope`. Ensures `hop` is used correctly in phrases like `hop on a bus` while correcting mistaken uses of `hope` in contexts where `hop` is expected." + }, + "harper-ls.linters.HowItLooksLike": { + "type": "boolean", + "default": true, + "description": "Corrects `how ... looks like` to `how ... looks` or `what ... looks like`." + }, + "harper-ls.linters.HowTo": { + "type": "boolean", + "default": true, + "description": "Detects the omission of `to` in constructions like `how clone / how install` and suggests `how to …`." + }, + "harper-ls.linters.However": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `however`." + }, + "harper-ls.linters.HumanBeings": { + "type": "boolean", + "default": true, + "description": "Eliminates the incorrect possessive/plural usage like `human\u0027s beings` or `humans beings`." + }, + "harper-ls.linters.HumanLife": { + "type": "boolean", + "default": true, + "description": "Changes `human live` to `human life`." + }, + "harper-ls.linters.HungerPang": { + "type": "boolean", + "default": true, + "description": "Corrects `hunger pain` to `hunger pang`." + }, + "harper-ls.linters.HyphenateNumberDay": { + "type": "boolean", + "default": true, + "description": "Ensures a hyphen is used in `X-day` when it is part of a compound adjective, such as `4-day work week`." + }, + "harper-ls.linters.IAm": { + "type": "boolean", + "default": true, + "description": "Fixes the incorrect spacing in `I a m` to properly form `I am`." + }, + "harper-ls.linters.IAmAgreement": { + "type": "boolean", + "default": true, + "description": "Corrects `I are` to `I am`." + }, + "harper-ls.linters.IDo": { + "type": "boolean", + "default": true, + "description": "Corrects `I does` to `I do`." + }, + "harper-ls.linters.IDontKnow": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.IfIRecallCorrectly": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.IfWouldve": { + "type": "boolean", + "default": true, + "description": "Corrects `if I would\u0027ve done` etc. to `if I had done` etc." + }, + "harper-ls.linters.IfYouKnowYouKnow": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.InAWhile": { + "type": "boolean", + "default": true, + "description": "Corrects the missing article in `in while`, forming `in a while`." + }, + "harper-ls.linters.InAnyWay": { + "type": "boolean", + "default": true, + "description": "Corrects ungrammatical `in anyway` to `in any way`." + }, + "harper-ls.linters.InCaseYouMissedIt": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.InDetail": { + "type": "boolean", + "default": true, + "description": "Corrects unidiomatic plural `in details` to `in detail`." + }, + "harper-ls.linters.InLieuOf": { + "type": "boolean", + "default": true, + "description": "Corrects the misspelling `in lue of` to `in lieu of`." + }, + "harper-ls.linters.InMyHumbleOpinion": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.InMyOpinion": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.InNeedOf": { + "type": "boolean", + "default": true, + "description": "Corrects `in need for` to `in need of`." + }, + "harper-ls.linters.InOfItself": { + "type": "boolean", + "default": true, + "description": "Corrects nonstandard `in of itself` to standard `in itself` or `in and of itself`." + }, + "harper-ls.linters.InOnTheCards": { + "type": "boolean", + "default": true, + "description": "Corrects either `in the cards` or `on the cards` to the other, depending on the dialect." + }, + "harper-ls.linters.InOneFellSwoop": { + "type": "boolean", + "default": true, + "description": "Corrects `in one foul swoop` to `in one fell swoop`, preserving the phrase’s original meaning of sudden and complete action." + }, + "harper-ls.linters.InRealLife": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.InThe": { + "type": "boolean", + "default": true, + "description": "Detects and corrects a spacing error where `in the` is mistakenly written as `int he`. Proper spacing is essential for readability and grammatical correctness in common phrases." + }, + "harper-ls.linters.InflectedVerbAfterTo": { + "type": "boolean", + "default": true, + "description": "This rule looks for `to verb` where `verb` is not in the infinitive form." + }, + "harper-ls.linters.Insensitive": { + "type": "boolean", + "default": true, + "description": "Suggests the more standard and common synonym `insensitive`." + }, + "harper-ls.linters.Insofar": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `insofar`." + }, + "harper-ls.linters.Instead": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `instead`." + }, + "harper-ls.linters.InsteadOf": { + "type": "boolean", + "default": true, + "description": "Corrects the archaic or mistaken separation `in stead of` to `instead of` in everyday usage." + }, + "harper-ls.linters.Insurmountable": { + "type": "boolean", + "default": true, + "description": "Suggests the more standard and common synonym `insurmountable`." + }, + "harper-ls.linters.Intact": { + "type": "boolean", + "default": true, + "description": "Prevents the erroneous spacing in `in tact`; `intact` is the single correct word." + }, + "harper-ls.linters.InterestedIn": { + "type": "boolean", + "default": true, + "description": "Ensures the correct preposition is used with the word `interested` (e.g. `interested in`)." + }, + "harper-ls.linters.InvestIn": { + "type": "boolean", + "default": true, + "description": "`Invest` is traditionally followed by \u0027in,\u0027 not `into.`" + }, + "harper-ls.linters.IsKnownFor": { + "type": "boolean", + "default": true, + "description": "Typo: `known` is the correct past participle." + }, + "harper-ls.linters.ItCan": { + "type": "boolean", + "default": true, + "description": "Corrects the misspelling `It cam` to the proper phrase `It can`." + }, + "harper-ls.linters.ItLooksLikeThat": { + "type": "boolean", + "default": true, + "description": "Corrects `it looks like that` to just `it looks like`." + }, + "harper-ls.linters.ItsContraction": { + "type": "boolean", + "default": true, + "description": "Detects the possessive `its` before `had`, `been`, or `got` and offers `it\u0027s` or `it has`." + }, + "harper-ls.linters.ItsPossessive": { + "type": "boolean", + "default": true, + "description": "In English, possessive pronouns never take an apostrophe. Use `its` to show ownership (e.g. “its texture”) and avoid confusing it with `it\u0027s`, which always means “it is” or “it has.”" + }, + "harper-ls.linters.Itself": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `itself`." + }, + "harper-ls.linters.IveGotTo": { + "type": "boolean", + "default": true, + "description": "Corrects the slip `I\u0027ve go to` to the idiomatic `I\u0027ve got to`." + }, + "harper-ls.linters.JawDropping": { + "type": "boolean", + "default": true, + "description": "Corrects `jar-dropping` to `jaw-dropping`, ensuring the intended meaning of something that causes amazement." + }, + "harper-ls.linters.JetpackNames": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of Jetpack-related terms." + }, + "harper-ls.linters.JustDeserts": { + "type": "boolean", + "default": true, + "description": "Ensures `just deserts` is used correctly, preserving its meaning of receiving an appropriate outcome for one\u0027s actions." + }, + "harper-ls.linters.KindOf": { + "type": "boolean", + "default": true, + "description": "Corrects `kinda of` to `kind of`." + }, + "harper-ls.linters.KindRegards": { + "type": "boolean", + "default": true, + "description": "Changes `kid regards` to `kind regards`." + }, + "harper-ls.linters.Koreas": { + "type": "boolean", + "default": true, + "description": "When referring to the nations, make sure to treat them as a proper noun." + }, + "harper-ls.linters.Laptop": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `laptop`." + }, + "harper-ls.linters.LastButNotLeast": { + "type": "boolean", + "default": true, + "description": "Corrects common errors in the phrase `last but not least`." + }, + "harper-ls.linters.LastDitch": { + "type": "boolean", + "default": true, + "description": "Corrects wrong variations of the idiomatic adjective `last-ditch`." + }, + "harper-ls.linters.LeftRightHand": { + "type": "boolean", + "default": true, + "description": "Ensures `left hand` and `right hand` are hyphenated when used as adjectives before a noun, such as in `left-hand side` or `right-hand corner`." + }, + "harper-ls.linters.LessWorse": { + "type": "boolean", + "default": true, + "description": "Suggests alternatives to `less/least worse/worst` for more standard, clearer comparisons." + }, + "harper-ls.linters.LetAlone": { + "type": "boolean", + "default": true, + "description": "Changes `let along` to `let alone`." + }, + "harper-ls.linters.LetToDo": { + "type": "boolean", + "default": true, + "description": "Corrects extraneous `to` after `let`." + }, + "harper-ls.linters.LetsConfusion": { + "type": "boolean", + "default": true, + "description": "It\u0027s often hard to determine where the subject should go with the word `let`. This rule attempts to find common errors with redundancy and contractions that may lead to confusion for readers." + }, + "harper-ls.linters.LikeAsIf": { + "type": "boolean", + "default": true, + "description": "Corrects redundant `like as if` to `like` or `as if`." + }, + "harper-ls.linters.LikeThePlague": { + "type": "boolean", + "default": true, + "description": "Corrects `like a plague` to `like the plague`." + }, + "harper-ls.linters.Likewise": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `likewise`." + }, + "harper-ls.linters.LitotesDirectPositive": { + "type": "boolean", + "default": true, + "description": "Offers direct-positive alternatives when double negatives might feel heavy." + }, + "harper-ls.linters.LongSentences": { + "type": "boolean", + "default": true, + "description": "This rule looks for run-on sentences, which can make your work harder to grok." + }, + "harper-ls.linters.LookingForwardTo": { + "type": "boolean", + "default": true, + "description": "This rule identifies instances where the phrase `looking forward to` is followed by a base form verb instead of the required gerund (verb + `-ing` form)." + }, + "harper-ls.linters.LowHangingFruit": { + "type": "boolean", + "default": true, + "description": "Corrects non-standard variants of `low-hanging fruit`." + }, + "harper-ls.linters.MakeDoWith": { + "type": "boolean", + "default": true, + "description": "Corrects `make due` to `make do` when followed by `with`." + }, + "harper-ls.linters.MakeItSeem": { + "type": "boolean", + "default": true, + "description": "Corrects `make it seems` to `make it seem`." + }, + "harper-ls.linters.Malaysia": { + "type": "boolean", + "default": true, + "description": "When referring to the states of Malaysia and their capitals, make sure to treat them as a proper noun." + }, + "harper-ls.linters.ManagerialReins": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `managerial reigns` to the idiomatic `managerial reins`." + }, + "harper-ls.linters.MassPlurals": { + "type": "boolean", + "default": true, + "description": "Looks for plural forms of mass nouns that have no plural." + }, + "harper-ls.linters.MergeWords": { + "type": "boolean", + "default": true, + "description": "Accidentally inserting a space inside a word is common. This rule looks for valid words that are split by whitespace." + }, + "harper-ls.linters.MetaNames": { + "type": "boolean", + "default": true, + "description": "When referring to Meta products and services, make sure to treat them as proper nouns." + }, + "harper-ls.linters.MicrosoftNames": { + "type": "boolean", + "default": true, + "description": "When referring to Microsoft products and services, make sure to treat them as proper nouns." + }, + "harper-ls.linters.Middleware": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `middleware`." + }, + "harper-ls.linters.MissingPreposition": { + "type": "boolean", + "default": true, + "description": "Locates potentially missing prepositions." + }, + "harper-ls.linters.MissingTo": { + "type": "boolean", + "default": true, + "description": "Flags verbs and adjectives like `need`, `want`, or `ready` that are missing `to` before an infinitive." + }, + "harper-ls.linters.Misspell": { + "type": "boolean", + "default": true, + "description": "Ensures `misspell` and its inflected forms are written as a single word." + }, + "harper-ls.linters.Misunderstand": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `misunderstand`." + }, + "harper-ls.linters.Misunderstood": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `misunderstood`." + }, + "harper-ls.linters.Misuse": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `misuse`." + }, + "harper-ls.linters.Misused": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `misused`." + }, + "harper-ls.linters.MixedBag": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `mixed bad` to `mixed bag`." + }, + "harper-ls.linters.ModalOf": { + "type": "boolean", + "default": true, + "description": "Detects `of` mistakenly used with `would`, `could`, `should`, etc." + }, + "harper-ls.linters.ModalSeem": { + "type": "boolean", + "default": true, + "description": "Detects modal verbs followed by `seen` before adjectives and suggests `seem` or `be`." + }, + "harper-ls.linters.Months": { + "type": "boolean", + "default": true, + "description": "Detects months written with a lowercase first letter." + }, + "harper-ls.linters.Monumentous": { + "type": "boolean", + "default": true, + "description": "Advises using `momentous` or `monumental` instead of `monumentous` for serious usage." + }, + "harper-ls.linters.MootPoint": { + "type": "boolean", + "default": true, + "description": "Corrects `mute` to `moot` in the phrase `moot point`." + }, + "harper-ls.linters.MoreBetter": { + "type": "boolean", + "default": true, + "description": "Finds redundant paring of `more` or `most` with adjectives already in the comparative or superlative form." + }, + "harper-ls.linters.MostNumber": { + "type": "boolean", + "default": true, + "description": "Corrects `most number` and `most amount`" + }, + "harper-ls.linters.MostOfTheTimes": { + "type": "boolean", + "default": true, + "description": "Corrects `a lot of the times` and `most of the times` to use singular `time`." + }, + "harper-ls.linters.Multicore": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `multicore`." + }, + "harper-ls.linters.Multimedia": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `multimedia`." + }, + "harper-ls.linters.MultipleSequentialPronouns": { + "type": "boolean", + "default": true, + "description": "When editing work to change point of view (i.e. first-person or third-person) it is common to add pronouns while neglecting to remove old ones. This rule catches cases where you have multiple disparate pronouns in sequence." + }, + "harper-ls.linters.Multithreading": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `multithreading`." + }, + "harper-ls.linters.MyHouse": { + "type": "boolean", + "default": true, + "description": "Fixes the typo `mu house` to `my house`." + }, + "harper-ls.linters.Myself": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `myself`." + }, + "harper-ls.linters.NailOnTheHead": { + "type": "boolean", + "default": true, + "description": "Replaces hat/had/hit/hid in the idiom `nail on the head` with `head`." + }, + "harper-ls.linters.NationalCapitals": { + "type": "boolean", + "default": true, + "description": "When referring to national capitals, make sure to treat it as a proper noun." + }, + "harper-ls.linters.NeedHelp": { + "type": "boolean", + "default": true, + "description": "Changes `ned help` to the correct `need help`." + }, + "harper-ls.linters.NeedToNoun": { + "type": "boolean", + "default": true, + "description": "Flags `need to` when it is immediately followed by a noun, which usually means the infinitive verb is missing." + }, + "harper-ls.linters.NerveRacking": { + "type": "boolean", + "default": true, + "description": "Corrects common misspellings and missing hyphen in `nerve-racking`." + }, + "harper-ls.linters.NervousWreck": { + "type": "boolean", + "default": true, + "description": "Suggests using `nervous wreck` when referring to a person\u0027s emotional state." + }, + "harper-ls.linters.NeverMind": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.NoFrenchSpaces": { + "type": "boolean", + "default": true, + "description": "Stops users from accidentally inserting French spaces." + }, + "harper-ls.linters.NoMatchFor": { + "type": "boolean", + "default": true, + "description": "No match for" + }, + "harper-ls.linters.NoOxfordComma": { + "type": "boolean", + "default": false, + "description": "The Oxford comma is one of the more controversial rules in common use today. Enabling this lint checks that there is no comma before `and`, `or` or `nor` when listing out more than two ideas." + }, + "harper-ls.linters.Nobody": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `nobody`." + }, + "harper-ls.linters.NominalWants": { + "type": "boolean", + "default": true, + "description": "Ensures you use the correct `want` / `wants` after a nominal." + }, + "harper-ls.linters.Nonetheless": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `nonetheless`." + }, + "harper-ls.linters.NotIn": { + "type": "boolean", + "default": true, + "description": "Replaces `no in` with `not in`." + }, + "harper-ls.linters.NotTo": { + "type": "boolean", + "default": true, + "description": "Corrects `no to` to `not to`, ensuring proper negation." + }, + "harper-ls.linters.NotablePlaces": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of notable places that are significant regional centers, travel destinations, or have international importance." + }, + "harper-ls.linters.Nothing": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `nothing`." + }, + "harper-ls.linters.Notwithstanding": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `notwithstanding`." + }, + "harper-ls.linters.NounCountability": { + "type": "boolean", + "default": true, + "description": "Correct mass nouns that are preceded by the wrong determiners or quantifiers." + }, + "harper-ls.linters.NounVerbConfusion": { + "type": "boolean", + "default": true, + "description": "Handles common confusions between related nouns and verbs (e.g., \u0027advice/advise\u0027, \u0027breath/breathe\u0027)" + }, + "harper-ls.linters.Nowhere": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `nowhere`." + }, + "harper-ls.linters.NumberSuffixCapitalization": { + "type": "boolean", + "default": true, + "description": "You should never capitalize number suffixes." + }, + "harper-ls.linters.OceansAndSeas": { + "type": "boolean", + "default": true, + "description": "When referring to the world\u0027s oceans and seas, ensure they are treated as proper nouns." + }, + "harper-ls.linters.OfCourse": { + "type": "boolean", + "default": true, + "description": "Detects the common mistake `off course` and suggests the correct form `of course`." + }, + "harper-ls.linters.OffTheCuff": { + "type": "boolean", + "default": true, + "description": "Ensures `off-the-cuff` is correctly hyphenated." + }, + "harper-ls.linters.OhMyGod": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.OldWivesTale": { + "type": "boolean", + "default": true, + "description": "Corrects `old wise tale` to `old wives\u0027 tale`, preserving the phrase’s meaning as an unfounded traditional belief." + }, + "harper-ls.linters.OnFloor": { + "type": "boolean", + "default": true, + "description": "This rule identifies incorrect uses of the prepositions `in` or `at` when referring to locations inside a building and recommends using `on the floor` instead." + }, + "harper-ls.linters.OnSecondThought": { + "type": "boolean", + "default": true, + "description": "Replaces the nonstandard `on second though` with the common idiom `on second thought` to indicate reconsideration." + }, + "harper-ls.linters.OnTheSpurOfTheMoment": { + "type": "boolean", + "default": true, + "description": "Ensures the correct use of `on the spur of the moment`, avoiding nonstandard variations." + }, + "harper-ls.linters.OnTopOf": { + "type": "boolean", + "default": true, + "description": "Corrects `ontop of` to `on top of`." + }, + "harper-ls.linters.OnceInAWhile": { + "type": "boolean", + "default": true, + "description": "Corrects two common malapropisms of `once in a while`." + }, + "harper-ls.linters.OnceOrTwice": { + "type": "boolean", + "default": true, + "description": "Detects the mistaken phrase `once a twice` and suggests `once or twice`." + }, + "harper-ls.linters.OneAndTheSame": { + "type": "boolean", + "default": true, + "description": "This linter flags instances of the nonstandard phrase `one in the same`. The correct, more accepted form is `one and the same`" + }, + "harper-ls.linters.OpenCompounds": { + "type": "boolean", + "default": true, + "description": "Corrects compound words that should be written as two words." + }, + "harper-ls.linters.OpenTheLight": { + "type": "boolean", + "default": true, + "description": "Corrects using `open` instead of `turn on` or `switch on`" + }, + "harper-ls.linters.OperatingSystem": { + "type": "boolean", + "default": true, + "description": "Ensures `operating system` is used correctly instead of `operative system`." + }, + "harper-ls.linters.OrthographicConsistency": { + "type": "boolean", + "default": true, + "description": "Ensures word casing matches the dictionary\u0027s canonical orthography." + }, + "harper-ls.linters.OughtToBe": { + "type": "boolean", + "default": true, + "description": "Detects the mistaken `out to be` and suggests `ought to be`, while ignoring legitimate phrasal-verb uses such as `turn out to be` and `make it out to be`." + }, + "harper-ls.linters.OutOfDate": { + "type": "boolean", + "default": true, + "description": "Ensures that the phrase `out of date` is written with a hyphen as `out-of-date` when used as a compound adjective." + }, + "harper-ls.linters.Overall": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `overall`." + }, + "harper-ls.linters.Overclocking": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `overclocking`." + }, + "harper-ls.linters.Overload": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `overload`." + }, + "harper-ls.linters.Overnight": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `overnight`." + }, + "harper-ls.linters.OxfordComma": { + "type": "boolean", + "default": true, + "description": "The Oxford comma is one of the more controversial rules in common use today. Enabling this lint checks that there is a comma before `and`, `or`, or `nor` when listing out more than two ideas." + }, + "harper-ls.linters.Oxymorons": { + "type": "boolean", + "default": true, + "description": "Flags oxymoronic phrases (e.g. `amateur expert`, `increasingly less`, etc.)." + }, + "harper-ls.linters.PassersBy": { + "type": "boolean", + "default": true, + "description": "Corrects `passerbys` and `passer-bys` to `passersby` or `passers-by`." + }, + "harper-ls.linters.PeaceOfMind": { + "type": "boolean", + "default": true, + "description": "Corrects `piece of mind` to `peace of mind`." + }, + "harper-ls.linters.PerSe": { + "type": "boolean", + "default": true, + "description": "Corrects common misspellings of `per se`." + }, + "harper-ls.linters.PhrasalVerbAsCompoundNoun": { + "type": "boolean", + "default": true, + "description": "This rule looks for phrasal verbs written as compound nouns." + }, + "harper-ls.linters.Piggyback": { + "type": "boolean", + "default": true, + "description": "Corrects the eggcorn `piggy bag` to `piggyback`, which is the proper term for riding on someone’s back or using an existing system." + }, + "harper-ls.linters.PiqueInterest": { + "type": "boolean", + "default": true, + "description": "Detects incorrect usage of `peak` or `peek` when the intended word is `pique`, as in the phrase `you\u0027ve peaked my interest`." + }, + "harper-ls.linters.PleaseTakeALook": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.PocketCastsNames": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of Pocket Casts and Pocket Casts Plus as brand names." + }, + "harper-ls.linters.PointsOfView": { + "type": "boolean", + "default": true, + "description": "Corrects pluralizing the wrong noun in `point of view`." + }, + "harper-ls.linters.PortAuPrince": { + "type": "boolean", + "default": true, + "description": "Checks for the correct official name of the capital of Haiti." + }, + "harper-ls.linters.PortoNovo": { + "type": "boolean", + "default": true, + "description": "Checks for the correct official name of the capital of Benin." + }, + "harper-ls.linters.PossessiveNoun": { + "type": "boolean", + "default": false, + "description": "Use an apostrophe and `s` to form a noun’s possessive." + }, + "harper-ls.linters.PossessiveYour": { + "type": "boolean", + "default": true, + "description": "The possessive form of `you` is more likely before nouns." + }, + "harper-ls.linters.Postpone": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `postpone`." + }, + "harper-ls.linters.PrayingMantis": { + "type": "boolean", + "default": true, + "description": "Corrects `preying mantis` to `praying mantis`, ensuring accurate reference to the insect’s characteristic pose." + }, + "harper-ls.linters.ProgressiveNeedsBe": { + "type": "boolean", + "default": true, + "description": "Detects the ungrammatical patterns `\u003cpronoun\u003e have …ing` (e.g., `I have …ing`) and `\u003cpronoun\u003e\u0027ve …ing` (e.g., `I\u0027ve …ing`) and suggests either the present progressive (e.g., `I\u0027m/We\u0027re/You\u0027re/They\u0027re …`) or the present perfect progressive (e.g., `I/We/You/They have been …` or `I\u0027ve/We\u0027ve/You\u0027ve/They\u0027ve been …`)." + }, + "harper-ls.linters.PronounAre": { + "type": "boolean", + "default": true, + "description": "Spots the letter `r` used in place of `are` or `you\u0027re` after plural first- or second-person pronouns." + }, + "harper-ls.linters.PronounContraction": { + "type": "boolean", + "default": true, + "description": "Choosing when to contract pronouns is a challenging art. This rule looks for faults." + }, + "harper-ls.linters.PronounInflectionBe": { + "type": "boolean", + "default": true, + "description": "Checks subject–verb agreement for the verb `be`. Third-person singular pronouns (`he`, `she`, `it`) require the singular form `is`, while the plural pronoun `they` takes `are`. The linter flags mismatches such as `He are` or `They is` and offers the correct concord." + }, + "harper-ls.linters.PronounKnew": { + "type": "boolean", + "default": true, + "description": "Detects when “new” following a pronoun (optionally with an adverb) is a typo for the past tense “knew.”" + }, + "harper-ls.linters.Proofread": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `proofread`." + }, + "harper-ls.linters.ProperNouns": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of proper nouns." + }, + "harper-ls.linters.QuantifierNeedsOf": { + "type": "boolean", + "default": true, + "description": "Detects missing `of` after the quantifier “a couple” when it precedes a plural noun" + }, + "harper-ls.linters.QuantifierNumeralConflict": { + "type": "boolean", + "default": true, + "description": "Detects quantifier-numeral conflicts" + }, + "harper-ls.linters.QuiteMany": { + "type": "boolean", + "default": true, + "description": "Corrects `quite many` to `quite a few`, which is the more natural and idiomatic phrase in standard English. `Quite many` is considered non-standard usage." + }, + "harper-ls.linters.QuiteQuiet": { + "type": "boolean", + "default": true, + "description": "Helps distinguish between ‘quiet’ (making ‘little noise’) and ‘quite’ (meaning ‘rather’)." + }, + "harper-ls.linters.QuoteSpacing": { + "type": "boolean", + "default": true, + "description": "Checks that quotation marks are preceded or succeeded by whitespace." + }, + "harper-ls.linters.RapidFire": { + "type": "boolean", + "default": true, + "description": "Checks to ensure writers hyphenate `rapid-fire`." + }, + "harper-ls.linters.RealTrouper": { + "type": "boolean", + "default": true, + "description": "Ensures the correct use of `real trouper`, distinguishing it from `trooper`, which refers to a soldier or police officer." + }, + "harper-ls.linters.Really": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.RedundantAdditiveAdverbs": { + "type": "boolean", + "default": true, + "description": "Detects redundant additive adverbs." + }, + "harper-ls.linters.RedundantIIRC": { + "type": "boolean", + "default": true, + "description": "Flags redundant use of \u0027if\u0027 or \u0027correctly\u0027 with `IIRC`, since `IIRC` already stands for \u0027if I recall correctly\u0027." + }, + "harper-ls.linters.RedundantSuperlatives": { + "type": "boolean", + "default": true, + "description": "Simplifies redundant double positives like `most optimal` to the base form." + }, + "harper-ls.linters.RedundantThat": { + "type": "boolean", + "default": true, + "description": "There is rarely a situation where `that that` cannot be condensed into a single token." + }, + "harper-ls.linters.Regardless": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `regardless`." + }, + "harper-ls.linters.Regionalisms": { + "type": "boolean", + "default": true, + "description": "Regionalisms" + }, + "harper-ls.linters.RepeatedWords": { + "type": "boolean", + "default": true, + "description": "This rule looks for repetitions of words that are not homographs." + }, + "harper-ls.linters.RifeWith": { + "type": "boolean", + "default": true, + "description": "Corrects `ripe with` to `rife with`, preserving the phrase’s meaning of being filled with something, often undesirable." + }, + "harper-ls.linters.RiseTheQuestion": { + "type": "boolean", + "default": true, + "description": "Corrects `rise the question` to `raise the question`." + }, + "harper-ls.linters.RoadMap": { + "type": "boolean", + "default": true, + "description": "Detects when `roadmap` is used instead of `road map`, prompting the correct spacing." + }, + "harper-ls.linters.RollerSkated": { + "type": "boolean", + "default": true, + "description": "Encourages hyphenating the past tense of `roller-skate`." + }, + "harper-ls.linters.RulesOfThumb": { + "type": "boolean", + "default": true, + "description": "Corrects pluralizing the wrong noun in `rule of thumb`." + }, + "harper-ls.linters.SafeToSave": { + "type": "boolean", + "default": true, + "description": "Detects `safe` (adjective) when `save` (verb) is intended after modal verbs like `could` or `should`." + }, + "harper-ls.linters.SameAs": { + "type": "boolean", + "default": true, + "description": "Corrects the incorrect phrase `same then` to the standard `same as`." + }, + "harper-ls.linters.SaveToSafe": { + "type": "boolean", + "default": true, + "description": "Corrects `save to \u003cverb\u003e` to `safe to \u003cverb\u003e` after a form of `be`." + }, + "harper-ls.linters.ScantilyClad": { + "type": "boolean", + "default": true, + "description": "Fixes `scandally clad` to `scantily clad`, ensuring clarity in describing minimal attire." + }, + "harper-ls.linters.SemicolonApostrophe": { + "type": "boolean", + "default": true, + "description": "Corrects semicolons accidentally typed instead of apostrophes." + }, + "harper-ls.linters.SentenceCapitalization": { + "type": "boolean", + "default": true, + "description": "The opening word of a sentence should almost always be capitalized." + }, + "harper-ls.linters.ShootOneselfInTheFoot": { + "type": "boolean", + "default": true, + "description": "Corrects non-standard variants of \u0027shoot oneself in the foot\u0027." + }, + "harper-ls.linters.SimpleGrammatical": { + "type": "boolean", + "default": true, + "description": "Corrects `simply grammatical` to `simple grammatical` for proper adjective usage." + }, + "harper-ls.linters.SimplePastToPastParticiple": { + "type": "boolean", + "default": true, + "description": "Corrects simple past tense verbs to past participle after auxiliary verbs like \"have\" or \"be\"." + }, + "harper-ls.linters.SinceDuration": { + "type": "boolean", + "default": true, + "description": "Detects the use of \u0027since\u0027 with a duration instead of a point in time." + }, + "harper-ls.linters.SingleBe": { + "type": "boolean", + "default": true, + "description": "Removes adjacent duplicate inflections of `be`, including contracted forms followed by another `be` verb." + }, + "harper-ls.linters.SneakingSuspicion": { + "type": "boolean", + "default": true, + "description": "Changes `sneaky suspicion` to `sneaking suspicion`." + }, + "harper-ls.linters.SomeOfThe": { + "type": "boolean", + "default": true, + "description": "Quantity words such as `some` normally take `of` before a definite article. Including `of` signals that you mean a subset of a larger set, preventing a momentary stumble in comprehension." + }, + "harper-ls.linters.SomeWithoutArticle": { + "type": "boolean", + "default": true, + "description": "Detects the redundant article in front of `some` and suggests more natural phrasing." + }, + "harper-ls.linters.Somebody": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `somebody`." + }, + "harper-ls.linters.SomebodyElses": { + "type": "boolean", + "default": true, + "description": "Corrects `somebody else\u0027s` when the `\u0027s` is in the wrong place." + }, + "harper-ls.linters.Somehow": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `somehow`." + }, + "harper-ls.linters.Someone": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `someone`." + }, + "harper-ls.linters.SomethingIs": { + "type": "boolean", + "default": true, + "description": "Flags forms like `somethings` before progressive verbs and suggests using `something\u0027s` or `something is`." + }, + "harper-ls.linters.SomewhatSomething": { + "type": "boolean", + "default": true, + "description": "Flags the phrase `somewhat of a` in favor of `something of a`, which can be considered more traditional." + }, + "harper-ls.linters.Somewhere": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `somewhere`." + }, + "harper-ls.linters.SoonerOrLater": { + "type": "boolean", + "default": true, + "description": "Fixes the improper phrase `sooner than later` by suggesting standard alternatives." + }, + "harper-ls.linters.SoughtAfter": { + "type": "boolean", + "default": true, + "description": "Correct `sort after` to `sought after`" + }, + "harper-ls.linters.Spaces": { + "type": "boolean", + "default": true, + "description": "Words should be separated by at most one space." + }, + "harper-ls.linters.SpecialAttention": { + "type": "boolean", + "default": true, + "description": "Changes `spacial attention` to `special attention`." + }, + "harper-ls.linters.SpellCheck": { + "type": "boolean", + "default": true, + "description": "Looks and provides corrections for misspelled words." + }, + "harper-ls.linters.SpelledNumbers": { + "type": "boolean", + "default": false, + "description": "Most style guides recommend that you spell out numbers less than ten." + }, + "harper-ls.linters.SplitWords": { + "type": "boolean", + "default": true, + "description": "Finds missing spaces in improper compound words." + }, + "harper-ls.linters.Starving": { + "type": "boolean", + "default": true, + "description": "Encourages vivid writing by suggesting `starving` instead of weaker expressions like `very hungry.`" + }, + "harper-ls.linters.StateOfTheArt": { + "type": "boolean", + "default": true, + "description": "Detects incorrect usage of `state of art` and suggests `state of the art` as the correct phrase." + }, + "harper-ls.linters.StatuteOfLimitations": { + "type": "boolean", + "default": true, + "description": "Corrects `statue of limitations` to `statute of limitations`." + }, + "harper-ls.linters.SubjectPronoun": { + "type": "boolean", + "default": true, + "description": "Fixes sentences that start with `me and X` by putting the proper noun first and using `I`." + }, + "harper-ls.linters.SufficeItToSay": { + "type": "boolean", + "default": true, + "description": "Corrects `suffice to say` to `suffice it to say`." + }, + "harper-ls.linters.SupposedTo": { + "type": "boolean", + "default": true, + "description": "Fixes `suppose to` to the correct `supposed to`." + }, + "harper-ls.linters.TakeItPersonally": { + "type": "boolean", + "default": true, + "description": "Corrects `take it personal` to `take it personally`." + }, + "harper-ls.linters.TalkToYouLater": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.ThanksALot": { + "type": "boolean", + "default": true, + "description": "Corrects the missing article in `thanks lot`, forming `thanks a lot`." + }, + "harper-ls.linters.ThatChallenged": { + "type": "boolean", + "default": true, + "description": "Corrects `the challenged` to `that challenged` for proper relative clause usage." + }, + "harper-ls.linters.ThatThan": { + "type": "boolean", + "default": true, + "description": "Corrects the typo `that` to `than` in comparisons." + }, + "harper-ls.linters.ThatThis": { + "type": "boolean", + "default": true, + "description": "Fixes `the this` to the correct phrase `that this`." + }, + "harper-ls.linters.ThatWhich": { + "type": "boolean", + "default": true, + "description": "Repeating the word \"that\" is often redundant. The phrase `that which` is easier to read." + }, + "harper-ls.linters.The": { + "type": "boolean", + "default": true, + "description": "Fixes especially common misspellings of the word `the`" + }, + "harper-ls.linters.TheAnother": { + "type": "boolean", + "default": true, + "description": "Corrects `the another`." + }, + "harper-ls.linters.TheHowWhy": { + "type": "boolean", + "default": true, + "description": "Removes the extra `the` from expressions like `the how`, skipping `how to` and `who\u0027s who`." + }, + "harper-ls.linters.TheMy": { + "type": "boolean", + "default": true, + "description": "Flags the definite article used together with a possessive." + }, + "harper-ls.linters.ThenThan": { + "type": "boolean", + "default": true, + "description": "Corrects mixing up `then` and `than`." + }, + "harper-ls.linters.Therefore": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `therefore`." + }, + "harper-ls.linters.Theres": { + "type": "boolean", + "default": true, + "description": "Replaces the mistaken possessive `their\u0027s` before a determiner with the contraction `there\u0027s`." + }, + "harper-ls.linters.Thereupon": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `thereupon`." + }, + "harper-ls.linters.ThesesThese": { + "type": "boolean", + "default": true, + "description": "Corrects the common misspelling of `these` as `theses`." + }, + "harper-ls.linters.ThingThink": { + "type": "boolean", + "default": true, + "description": "Corrects the typo `thing` when it should be `think`." + }, + "harper-ls.linters.ThoughThought": { + "type": "boolean", + "default": true, + "description": "Corrects `though` when it\u0027s a typo for `thought`." + }, + "harper-ls.linters.ThoughtProcess": { + "type": "boolean", + "default": true, + "description": "Changes `though process` to `thought process`." + }, + "harper-ls.linters.ThrowAway": { + "type": "boolean", + "default": true, + "description": "Finds the typo `through away` and suggests `throw away` or `threw away` instead." + }, + "harper-ls.linters.ThrowRubbish": { + "type": "boolean", + "default": true, + "description": "Checks for throwing rubbish rather than throwing it away." + }, + "harper-ls.linters.TickingTimeClock": { + "type": "boolean", + "default": true, + "description": "Corrects `ticking time clock` to `ticking time bomb` for idiomatic urgency or `ticking clock` otherwise." + }, + "harper-ls.linters.ToAdverb": { + "type": "boolean", + "default": true, + "description": "Flags duplicated `to` around certain adverbs (e.g. `to never to`) and offers fixes that keep only one `to`." + }, + "harper-ls.linters.ToBeHonest": { + "type": "boolean", + "default": true, + "description": "Expands an initialism." + }, + "harper-ls.linters.ToDoHyphen": { + "type": "boolean", + "default": true, + "description": "Ensures `to-do` is correctly hyphenated." + }, + "harper-ls.linters.ToGreatLengths": { + "type": "boolean", + "default": true, + "description": "Corrects `through great lengths` to `to great lengths`." + }, + "harper-ls.linters.ToTheMannerBorn": { + "type": "boolean", + "default": true, + "description": "Corrects `to the manor born` to `to the manner born`, ensuring the intended meaning of being naturally suited to a way of life." + }, + "harper-ls.linters.ToTooIdioms": { + "type": "boolean", + "default": true, + "description": "Corrects `to` used instead of `too`." + }, + "harper-ls.linters.ToTwoToo": { + "type": "boolean", + "default": true, + "description": "Corrects homophone confusion between `to` and `too`." + }, + "harper-ls.linters.ToWorryAbout": { + "type": "boolean", + "default": true, + "description": "Fixes incorrect use of `to worried about`." + }, + "harper-ls.linters.TongueInCheek": { + "type": "boolean", + "default": true, + "description": "Corrects the idiom when `and` replaces the needed preposition." + }, + "harper-ls.linters.TooTo": { + "type": "boolean", + "default": true, + "description": "Corrects `too` used instead of `to`." + }, + "harper-ls.linters.Touristic": { + "type": "boolean", + "default": true, + "description": "Suggests replacing the uncommon word `touristic` with `tourist`, `tourism`, and/or `touristy`." + }, + "harper-ls.linters.Towards": { + "type": "boolean", + "default": true, + "description": "Removes redundant `to` before `towards`." + }, + "harper-ls.linters.TrialAndError": { + "type": "boolean", + "default": true, + "description": "Corrects `trail` to `trial` in `trial and error`." + }, + "harper-ls.linters.TumblrNames": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of Tumblr-related terms." + }, + "harper-ls.linters.TurnItOff": { + "type": "boolean", + "default": true, + "description": "Fixes the mistake in the phrase `turn it off`." + }, + "harper-ls.linters.USUniversities": { + "type": "boolean", + "default": true, + "description": "Ensure proper capitalization of major universities in the United States." + }, + "harper-ls.linters.UnclosedQuotes": { + "type": "boolean", + "default": true, + "description": "Quotation marks should always be closed. Unpaired quotation marks are a hallmark of sloppy work." + }, + "harper-ls.linters.Underclock": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `underclock`." + }, + "harper-ls.linters.UnitedOrganizations": { + "type": "boolean", + "default": true, + "description": "When referring to national or international organizations, make sure to treat them as a proper noun." + }, + "harper-ls.linters.Unless": { + "type": "boolean", + "default": true, + "description": "Corrects `unless if`." + }, + "harper-ls.linters.UpdatePlaceNames": { + "type": "boolean", + "default": true, + "description": "This rule looks for deprecated place names and offers to update them." + }, + "harper-ls.linters.Upset": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `upset`." + }, + "harper-ls.linters.Upward": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `upward`." + }, + "harper-ls.linters.UseGenitive": { + "type": "boolean", + "default": false, + "description": "Looks for situations where the genitive case of \"there\" should be used." + }, + "harper-ls.linters.VerbToAdjective": { + "type": "boolean", + "default": true, + "description": "Looks for situations where a verb was written where an adjective is often intended." + }, + "harper-ls.linters.VeryUnique": { + "type": "boolean", + "default": true, + "description": "Flags phrases like `very unique`, `pretty unique`, etc., and suggests using `unique` alone or a more precise adjective such as `special`, `rare`, or `unusual`." + }, + "harper-ls.linters.ViceVersa": { + "type": "boolean", + "default": true, + "description": "Recommends writing ‘vice versa’ without hyphens." + }, + "harper-ls.linters.WantBe": { + "type": "boolean", + "default": true, + "description": "Detects incorrect usage of `want be` and suggests `won\u0027t be` or `want to be` based on context." + }, + "harper-ls.linters.WasAloud": { + "type": "boolean", + "default": true, + "description": "Ensures `was aloud` and `were aloud` are corrected to `was allowed` or `were allowed` when referring to permission." + }, + "harper-ls.linters.WaveFunction": { + "type": "boolean", + "default": true, + "description": "Identifies the mistake of merging `wave` and `function` into one word. In quantum mechanics, a `wave function` (written as two words) describes the mathematical function that represents the quantum state of a particle or system. Correct usage is crucial for clear and accurate scientific communication." + }, + "harper-ls.linters.WayTooAdjective": { + "type": "boolean", + "default": true, + "description": "Replaces the preposition `to` with the adverb `too` after `way` when followed by an adjective (e.g. `way too fast`)" + }, + "harper-ls.linters.WellBeing": { + "type": "boolean", + "default": true, + "description": "Ensures `well-being` is correctly hyphenated." + }, + "harper-ls.linters.WellEducated": { + "type": "boolean", + "default": true, + "description": "Replaces `good-educated` with the accepted compound `well-educated`." + }, + "harper-ls.linters.WellKept": { + "type": "boolean", + "default": true, + "description": "Flags `highly-kept` and recommends `well-kept` as an alternative." + }, + "harper-ls.linters.Whereas": { + "type": "boolean", + "default": true, + "description": "The Whereas rule is designed to identify instances where the phrase `where as` is used in text and suggests replacing it with the single word `whereas`." + }, + "harper-ls.linters.Whereupon": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `whereupon`." + }, + "harper-ls.linters.WhetYourAppetite": { + "type": "boolean", + "default": true, + "description": "Ensures `whet your appetite` is used correctly, distinguishing it from the incorrect `wet` variation." + }, + "harper-ls.linters.WholeEntire": { + "type": "boolean", + "default": true, + "description": "Corrects the redundancy in `whole entire` to `whole` or `entire`." + }, + "harper-ls.linters.WidelyAccepted": { + "type": "boolean", + "default": true, + "description": "Flags `wide accepted`, `wide acceptable`, or `wide used` and recommends switching `wide` to the adverb `widely`." + }, + "harper-ls.linters.Widespread": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `widespread`." + }, + "harper-ls.linters.WillContain": { + "type": "boolean", + "default": true, + "description": "Incorrect verb form: `will` should be followed by the base form `contain`." + }, + "harper-ls.linters.WinPrize": { + "type": "boolean", + "default": true, + "description": "Catches the mix-up between `price`/`prise` and `prize` after the verb `win`." + }, + "harper-ls.linters.WishCould": { + "type": "boolean", + "default": true, + "description": "Checks for `can` being used after `wish` when it should be `could`." + }, + "harper-ls.linters.WordPressDotcom": { + "type": "boolean", + "default": true, + "description": "Ensures correct capitalization of WordPress.com. This rule verifies that the official stylization of WordPress.com is used when referring to the hosting provider." + }, + "harper-ls.linters.Worldwide": { + "type": "boolean", + "default": true, + "description": "Looks for incorrect spacing inside the closed compound `worldwide`." + }, + "harper-ls.linters.WorseOrWorst": { + "type": "boolean", + "default": true, + "description": "Corrects `worse` and `worst` used in contexts where the other belongs." + }, + "harper-ls.linters.WorstCaseScenario": { + "type": "boolean", + "default": true, + "description": "Corrects `worst-case scenario` when the hyphen is missing or `worse` is used instead of `worst`." + }, + "harper-ls.linters.WouldNeverHave": { + "type": "boolean", + "default": true, + "description": "Corrects `would/could have never` to `never would/could have`." + }, + "harper-ls.linters.WroughtIron": { + "type": "boolean", + "default": true, + "description": "`Wrought iron` is low-carbon, malleable iron used for decorative work; variants like `rod iron` or `rot iron` are phonetic misspellings that may confuse readers." + } + } +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/harper-ls/template.json b/src/main/resources/templates/lsp/harper-ls/template.json new file mode 100644 index 000000000..a22a62488 --- /dev/null +++ b/src/main/resources/templates/lsp/harper-ls/template.json @@ -0,0 +1,408 @@ +{ + "id": "harper-ls", + "name": "Harper Language Server", + "programArgs": { + "default": "sh -c ${BASE_DIR}/harper-ls --stdio", + "windows": "${BASE_DIR}/harper-ls.exe --stdio" + }, + "expandConfiguration": true, + "fileTypeMappings": [ + { + "fileType": { + "patterns": [ + "*.c", + "*.i" + ] + }, + "languageId": "c" + }, + { + "fileType": { + "name": "Clojure", + "patterns": [ + "*.clj", + "*.cljs", + "*.cljc", + "*.cljd", + "*.cljr", + "*.cljx", + "*.clojure", + "*.edn", + "*.joke", + "*.boot", + "*.bb", + "*.ly", + "*.lpy", + "*.calva-repl", + "*.fiddle" + ] + }, + "languageId": "clojure" + }, + { + "fileType": { + "patterns": [ + "*.cmake", + "CMakelists.txt" + ] + }, + "languageId": "cmake" + }, + { + "fileType": { + "patterns": [ + "*.cpp", + "*.cppm", + "*.cc", + "*.ccm", + "*.cxx", + "*.cxxm", + "*.c++", + "*.c++m", + "*.hpp", + "*.hh", + "*.hxx", + "*.h++", + "*.h", + "*.ii", + "*.ino", + "*.inl", + "*.ipp", + "*.ixx", + "*.tpp", + "*.txx", + "*.hpp.in", + "*.h.in" + ] + }, + "languageId": "cpp" + }, + { + "fileType": { + "name": "C#", + "patterns": [ + "*.cs", + "*.csx", + "*.cake" + ] + }, + "languageId": "csharp" + }, + { + "fileType": { + "name": "Dart", + "patterns": [ + "*.dart" + ] + }, + "languageId": "dart" + }, + { + "fileType": { + "name": "git-commit", + "patterns": [ + "git-commit" + ] + }, + "languageId": "git-commit" + }, + { + "fileType": { + "name": "Go", + "patterns": [ + "*.go" + ] + }, + "languageId": "go" + }, + { + "fileType": { + "name": "Haskell", + "patterns": [ + "*.hs" + ] + }, + "languageId": "haskell" + }, + { + "fileType": { + "name": "HTML", + "patterns": [ + "*.html", + "*.htm", + "*.shtml", + "*.xhtml", + "*.xht", + "*.mdoc", + "*.jsp", + "*.asp", + "*.aspx", + "*.jshtm", + "*.volt", + "*.ejs", + "*.rhtml" + ] + }, + "languageId": "html" + }, + { + "fileType": { + "name": "ink", + "patterns": [ + "*.ink" + ] + }, + "languageId": "ink" + }, + { + "fileType": { + "name": "JAVA", + "patterns": [ + "*.java" + ] + }, + "languageId": "java" + }, + { + "fileType": { + "name": "JavaScript", + "patterns": [ + "*.js" + ] + }, + "languageId": "javascript" + }, + { + "fileType": { + "name": "JavaScript-React", + "patterns": [ + "*.jsx" + ] + }, + "languageId": "javascriptreact" + }, + { + "fileType": { + "name": "Kotlin", + "patterns": [ + "*.kt" + ] + }, + "languageId": "kotlin" + }, + { + "fileType": { + "name": "Lua", + "patterns": [ + "*.lua" + ] + }, + "languageId": "lua" + }, + { + "fileType": { + "name": "Markdown", + "patterns": [ + "*.md" + ] + }, + "languageId": "markdown" + }, + { + "fileType": { + "name": "PHP", + "patterns": [ + "*.php" + ] + }, + "languageId": "php" + }, + { + "fileType": { + "patterns": [ + "*.txt" + ] + }, + "languageId": "plaintext" + }, + { + "fileType": { + "patterns": [ + "*.py", + "*.rpy", + "*.pyw", + "*.cpy", + "*.gyp", + "*.gypi", + "*.pyi", + "*.ipy", + "*.pyt" + ] + }, + "languageId": "python" + }, + { + "fileType": { + "patterns": [ + "*.rb", + "*.rbx", + "*.rjs", + "*.gemspec", + "*.rake", + "*.ru", + "*.erb", + "*.podspec", + "*.rbi", + "rakefile", + "gemfile", + "guardfile", + "podfile", + "capfile", + "cheffile", + "hobofile", + "vagrantfile", + "appraisals", + "rantfile", + "berksfile", + "berksfile.lock", + "thorfile", + "puppetfile", + "dangerfile", + "brewfile", + "fastfile", + "appfile", + "deliverfile", + "matchfile", + "scanfile", + "snapfile", + "gymfile" + ] + }, + "languageId": "ruby" + }, + { + "fileType": { + "name": "Rust", + "patterns": [ + "*.rs" + ] + }, + "languageId": "rust" + }, + { + "fileType": { + "name": "Scala", + "patterns": [ + "*.scala", + "*.sbt", + "*.sc" + ] + }, + "languageId": "scala" + }, + { + "fileType": { + "name": "Scala", + "patterns": [ + "*.sh", + "*.bash", + "*.bashrc", + "*.bash_aliases", + "*.bash_profile", + "*.bash_login", + "*.ebuild", + "*.eclass", + "*.profile", + "*.bash_logout", + "*.xprofile", + "*.xsession", + "*.xsessionrc", + "*.Xsession", + "*.zsh", + "*.zshrc", + "*.zprofile", + "*.zlogin", + "*.zlogout", + "*.zshenv", + "*.zsh-theme", + "*.fish", + "*.ksh", + "*.csh", + "*.cshrc", + "*.tcshrc", + "*.yashrc", + "*.yash_profile", + "APKBUILD", + "PKGBUILD", + ".envrc", + ".hushlogin", + "zshrc", + "zshenv", + "zlogin", + "zprofile", + "zlogout", + "bashrc_Apple_Terminal", + "zshrc_Apple_Terminal" + ] + }, + "languageId": "shellscript" + }, + { + "fileType": { + "patterns": [ + "*.swiftinterface", + "*.swift" + ] + }, + "languageId": "swift" + }, + { + "fileType": { + "name": "Solidity", + "patterns": [ + "*.sol", + "*.s.sol", + "*.t.sol" + ] + }, + "languageId": "solidity" + }, + { + "fileType": { + "name": "toml", + "patterns": [ + "*.toml", + "*.tml" + ] + }, + "languageId": "toml" + }, + { + "fileType": { + "name": "TypeScript", + "patterns": [ + "*.ts" + ] + }, + "languageId": "typescript" + }, + { + "fileType": { + "name": "TypeScript-React", + "patterns": [ + "*.tsx" + ] + }, + "languageId": "typescriptreact" + }, + { + "fileType": { + "name": "Typst", + "patterns": [ + "*.typ" + ] + }, + "languageId": "typst" + } + ] +} diff --git a/src/main/resources/templates/lsp/metals/initializationOptions.json b/src/main/resources/templates/lsp/metals/initializationOptions.json new file mode 100644 index 000000000..009a0de68 --- /dev/null +++ b/src/main/resources/templates/lsp/metals/initializationOptions.json @@ -0,0 +1,3 @@ +{ + "isHttpEnabled": true +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/metals/installer.json b/src/main/resources/templates/lsp/metals/installer.json index d2fcde61b..42c2d45f7 100644 --- a/src/main/resources/templates/lsp/metals/installer.json +++ b/src/main/resources/templates/lsp/metals/installer.json @@ -41,11 +41,11 @@ "name": "Configure Scala Language Server command", "command": { "windows": "${workingDir}/metals.bat", - "default": "${workingDir}/metal" + "default": "${workingDir}/metals" }, "update": true } } } } -} \ No newline at end of file +} diff --git a/src/main/resources/templates/lsp/metals/template.json b/src/main/resources/templates/lsp/metals/template.json index f3a0790d6..8add4a89b 100644 --- a/src/main/resources/templates/lsp/metals/template.json +++ b/src/main/resources/templates/lsp/metals/template.json @@ -2,8 +2,8 @@ "id": "metals", "name": "Scala Language Server (Metals)", "programArgs": { - "windows": "\"${BASE_DIR}/metals.bat\"", - "default": "\"${BASE_DIR}/metals\"" + "windows": "\"$USER_HOME$/.lsp4ij/lsp/metals/metals.bat\"", + "default": "\"$USER_HOME$/.lsp4ij/lsp/metals/metals\"" }, "expandConfiguration": true, "fileTypeMappings": [ diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/clientSettings.json b/src/main/resources/templates/lsp/wgsl-analyzer/clientSettings.json new file mode 100644 index 000000000..95ed44b14 --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/clientSettings.json @@ -0,0 +1,14 @@ +{ + "caseSensitive": true, + "lineCommentPrefix": "//", + "blockCommentPrefix": "/*", + "blockCommentSuffix": "*/", + "statementTerminatorCharacters": ";", + "completion": { + "useContextAwareSorting": true, + "useTemplateForInvocationOnlySnippet": false + }, + "workspaceSymbol": { + "supportsGotoClass": true + } +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/experimental.json b/src/main/resources/templates/lsp/wgsl-analyzer/experimental.json new file mode 100644 index 000000000..175e46b84 --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/experimental.json @@ -0,0 +1,3 @@ +{ + "testTree": true +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/initializationOptions.json b/src/main/resources/templates/lsp/wgsl-analyzer/initializationOptions.json new file mode 100644 index 000000000..9f69bc31f --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/initializationOptions.json @@ -0,0 +1,6 @@ +{ + "dependency-scheme": "jar", + "auto-add-ns-to-new-files?": true, + "document-formatting?": false, + "document-range-formatting?": false +} \ No newline at end of file diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/installer.json b/src/main/resources/templates/lsp/wgsl-analyzer/installer.json new file mode 100644 index 000000000..51808dfa1 --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/installer.json @@ -0,0 +1,55 @@ +{ + "id": "wgsl-analyzer-installer", + "name": "wgsl-analyzer", + "executeOnStartServer": false, + "check": { + "exec": { + "name": "Trying current command", + "command": "${server.command}", + "timeout": 2000 + } + }, + "run": { + "download": { + "name": "Download wgsl-analyzer", + "github": { + "owner": "wgsl-analyzer", + "repository": "wgsl-analyzer", + "prerelease": false, + "asset": { + "windows": { + "x86_64": "wgsl-analyzer-x86_64-pc-windows-msvc.zip", + "x86": "wgsl-analyzer-i686-pc-windows-msvc.zip", + "arm64": "wgsl-analyzer-aarch64-pc-windows-msvc.zip" + }, + "unix": { + "x86_64": "wgsl-analyzer-x86_64-unknown-linux-gnu.gz", + "arm64": "wgsl-analyzer-aarch64-unknown-linux-gnu.gz" + }, + "mac": "wgsl-analyzer-aarch64-apple-darwin.gz" + } + }, + "output": { + "dir": "$USER_HOME$/.lsp4ij/lsp/wgsl-analyzer", + "file": { + "name": { + "windows": "wgsl-analyzer.exe", + "unix": { + "x86_64": "wgsl-analyzer-x86_64-unknown-linux-gnu", + "arm64": "wgsl-analyzer-aarch64-unknown-linux-gnu" + }, + "mac": "wgsl-analyzer-aarch64-apple-darwin" + }, + "executable": true + } + }, + "onSuccess": { + "configureServer": { + "name": "Configure WSGL Language server command", + "command": "${output.dir}/${output.file.name}", + "update": true + } + } + } + } +} diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/settings.json b/src/main/resources/templates/lsp/wgsl-analyzer/settings.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/settings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/main/resources/templates/lsp/wgsl-analyzer/template.json b/src/main/resources/templates/lsp/wgsl-analyzer/template.json new file mode 100644 index 000000000..aafd9a070 --- /dev/null +++ b/src/main/resources/templates/lsp/wgsl-analyzer/template.json @@ -0,0 +1,38 @@ +{ + "id": "wgsl-analyzer", + "name": "WGSL Analyzer", + "programArgs": { + "default": "sh -c ${BASE_DIR}/wgsl-analyzer", + "windows": "${BASE_DIR}/wgsl-analyzer.exe" + }, + "expandConfiguration": true, + "fileTypeMappings": [ + { + "fileType": { + "name": "wa_syntax_tree", + "patterns": [ + "*.wast" + ] + }, + "languageId": "wa_syntax_tree" + }, + { + "fileType": { + "name": "WGSL", + "patterns": [ + "*.wgsl" + ] + }, + "languageId": "wgsl" + }, + { + "fileType": { + "name": "WESL", + "patterns": [ + "*.wesl" + ] + }, + "languageId": "wesl" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/textmate/package.json b/src/main/resources/textmate/package.json new file mode 100644 index 000000000..bea64a172 --- /dev/null +++ b/src/main/resources/textmate/package.json @@ -0,0 +1,31 @@ +{ + "name": "vscode-lldb", + "displayName": "CodeLLDB", + "version": "@VERSION@", + "publisher": "vadimcn", + "description": "A native debugger powered by LLDB. Debug C++, Rust and other compiled languages.", + "license": "MIT", + "author": { + "name": "vadimcn" + }, + "contributes": { + "languages": [ + { + "id": "lldb.disassembly", + "aliases": [ + "Disassembly" + ], + "extensions": [ + ".disasm" + ] + } + ], + "grammars": [ + { + "language": "lldb.disassembly", + "scopeName": "source.disassembly", + "path": "./syntaxes/disassembly.json" + } + ] + } +} \ No newline at end of file diff --git a/src/main/resources/textmate/syntaxes/disassembly.json b/src/main/resources/textmate/syntaxes/disassembly.json new file mode 100644 index 000000000..6ac29a0d6 --- /dev/null +++ b/src/main/resources/textmate/syntaxes/disassembly.json @@ -0,0 +1,32 @@ + +{ + "name": "LLDB Disassembly", + "scopeName": "source.disassembly", + "uuid": "db74b3e8-fa2c-4b4e-b973-1ecc54d45ed0", + "patterns": [ + { + "comment": "Address, bytes and opcode", + "name": "meta.instruction", + "match": "^([A-Za-z0-9]+):\\s([A-Z0-9]{2}\\s)+>?\\s+(\\w+)", + "captures": { + "1": { "name": "constant.numeric" }, + "3": { "name": "keyword.opcode" } + } + }, + { + "comment": "Numeric constant", + "name": "constant.numeric", + "match": "(\\$|\\b)((0x)|[0-9])[A-Za-z0-9]+\\b" + }, + { + "comment": "Register", + "name": "variable.language", + "match": "%[A-Za-z][A-Za-z0-9]*" + }, + { + "comment": "End of line comment", + "name": "comment.line.semicolon", + "match": ";.*$" + } + ] +} \ No newline at end of file diff --git a/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java new file mode 100644 index 000000000..28785e587 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_applyEdits_SamePositionTest.java @@ -0,0 +1,89 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij; + +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.editor.impl.DocumentImpl; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextEdit; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; + +/** + * Tests for {@link LSPIJUtils#applyEdits(Document, List)} with multiple text edits at the same position. + * According to LSP spec: "if multiple inserts have the same position, the order in the array + * defines the order in which the inserted strings appear in the resulting text." + * + * This method is used for formatting, where text edits must be sorted by position and + * same-position edits must preserve array order. + */ +public class LSPIJUtils_applyEdits_SamePositionTest extends BasePlatformTestCase { + + /** + * Test that multiple insertions at the same position maintain array order. + */ + public void testMultipleInsertsAtSamePosition() { + Document document = new DocumentImpl("world"); + + List edits = new ArrayList<>(); + // Insert three strings at position 0:0 in order: "Hello ", ", ", "beautiful " + // Expected result: "Hello , beautiful world" (in that exact order) + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "Hello ")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), ", ")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "beautiful ")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("Hello , beautiful world", result); + } + + /** + * Test multiple insertions at the same position with mixed line/character positions. + */ + public void testMultipleInsertsAtSamePositionMidLine() { + Document document = new DocumentImpl("start end"); + + List edits = new ArrayList<>(); + // Insert three strings at position 0:6 (between "start " and "end") + // In order: "one ", "two ", "three " + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "one ")); + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "two ")); + edits.add(new TextEdit(new Range(new Position(0, 6), new Position(0, 6)), "three ")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("start one two three end", result); + } + + /** + * Test that edits maintain order even when mixed with edits at different positions. + */ + public void testMixedPositionEditsWithSamePositionInserts() { + Document document = new DocumentImpl("line1\nline2\nline3\n"); + + List edits = new ArrayList<>(); + // Multiple edits: + // - Two inserts at 0:0 (should maintain order: "A", "B") + // - One insert at 1:0 + // - Two inserts at 2:0 (should maintain order: "X", "Y") + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "A")); + edits.add(new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "B")); + edits.add(new TextEdit(new Range(new Position(1, 0), new Position(1, 0)), "C")); + edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "X")); + edits.add(new TextEdit(new Range(new Position(2, 0), new Position(2, 0)), "Y")); + + String result = LSPIJUtils.applyEdits(document, edits); + assertEquals("ABline1\nCline2\nXYline3\n", result); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_findResourceForTest.java b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_findResourceForTest.java index 1f71b85b8..272067b78 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_findResourceForTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_findResourceForTest.java @@ -17,6 +17,7 @@ import java.io.File; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -35,6 +36,14 @@ public void testEncodedUri() throws IOException { assertVirtualFileExists("base.dir", "file.txt", getUri("base.dir/file.txt", true)); } + public void testUncUriParsing() { + // WSL paths should be parsed (but return null since the paths don't exist) + Assert.assertNull(LSPIJUtils.findResourceFor("file://wsl$/Ubuntu/home/user4270d2e/test.txt")); + Assert.assertNull(LSPIJUtils.findResourceFor("file://wsl.localhost/Ubuntu/home/user4270d2e/test.txt")); + // Non-WSL UNC paths should not use special handling + Assert.assertNull(LSPIJUtils.findResourceFor("file://server42cc119/share/folder/file.txt")); + } + private static @NotNull String getUri(String s) { return getUri(s, false); } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_toUriTest.java b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_toUriTest.java new file mode 100644 index 000000000..b3c0e4af0 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/LSPIJUtils_toUriTest.java @@ -0,0 +1,86 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + * SAP SE - additional test cases + ******************************************************************************/ +package com.redhat.devtools.lsp4ij; + +import com.intellij.openapi.util.SystemInfo; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Tests for {@link LSPIJUtils#toUri(File)} method. + */ +public class LSPIJUtils_toUriTest extends BasePlatformTestCase { + + public void testToUriWithLocalPaths() { + if (SystemInfo.isWindows) { + // Local path + URI localUri = LSPIJUtils.toUri(new File("C:\\Users\\user\\file.txt")); + assertEquals("file:///C:/Users/user/file.txt", localUri.toString()); + + // Local path with spaces + URI spacesUri = LSPIJUtils.toUri(new File("C:\\Users\\user name\\file.txt")); + assertEquals("/C:/Users/user%20name/file.txt", spacesUri.getRawPath()); + } else { + // Local path + URI localUri = LSPIJUtils.toUri(new File("/home/user/file.txt")); + assertEquals("file:///home/user/file.txt", localUri.toString()); + + // Local path with spaces + URI spacesUri = LSPIJUtils.toUri(new File("/home/user name/file.txt")); + assertEquals("/home/user%20name/file.txt", spacesUri.getRawPath()); + } + } + + public void testToUriWithUncPaths() { + if (SystemInfo.isWindows) { + // WSL UNC path with $ character + URI wslUri = LSPIJUtils.toUri(new File("\\\\wsl$\\Ubuntu\\home\\user\\file.txt")); + assertEquals("file://wsl$/Ubuntu/home/user/file.txt", wslUri.toString()); + assertEquals("wsl$", wslUri.getAuthority()); + assertEquals("/Ubuntu/home/user/file.txt", wslUri.getPath()); + + // WSL UNC path with wsl.localhost + URI wslLocalhostUri = LSPIJUtils.toUri(new File("\\\\wsl.localhost\\Ubuntu\\home\\user\\file.txt")); + assertEquals("file://wsl.localhost/Ubuntu/home/user/file.txt", wslLocalhostUri.toString()); + assertEquals("wsl.localhost", wslLocalhostUri.getAuthority()); + assertEquals("/Ubuntu/home/user/file.txt", wslLocalhostUri.getPath()); + + // Regular UNC path (not WSL): should use standard file URI + URI uncUri = LSPIJUtils.toUri(new File("\\\\server\\share\\folder\\file.txt")); + assertEquals("file", uncUri.getScheme()); + + // Malformed UNC path (no backslash separator) + URI malformedUri = LSPIJUtils.toUri(new File("\\\\server.txt")); + assertEquals("file:////server.txt", malformedUri.toString()); + } + } + + public void testUriToPathAssumptions() { + if (SystemInfo.isWindows) { + try { + // This verifies the assumptions in the URI-to-path conversion in LSPIJUtils#findResourceFor(String) + String wslUri = "file://wsl$/Ubuntu/home/user/file.txt"; + URI parsed = new URI(wslUri); + String authority = parsed.getAuthority(); + String path = parsed.getPath(); + + assertEquals("wsl$", authority); + assertEquals("/Ubuntu/home/user/file.txt", path); + } catch (URISyntaxException e) { + throw new RuntimeException("URI parsing failed", e); + } + } + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProviderTest.java b/src/test/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProviderTest.java new file mode 100644 index 000000000..465c5fa68 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/dap/DAPDebuggerEditorsProviderTest.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ + +package com.redhat.devtools.lsp4ij.dap; + +import com.intellij.json.JsonFileType; +import com.intellij.json.JsonLanguage; +import com.intellij.json.json5.Json5Language; +import com.intellij.openapi.fileTypes.MockLanguageFileType; +import com.intellij.openapi.fileTypes.PlainTextFileType; +import com.intellij.openapi.fileTypes.PlainTextLanguage; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiFileFactory; +import com.intellij.testFramework.LightPlatformTestCase; +import com.intellij.testFramework.LightVirtualFile; + +public class DAPDebuggerEditorsProviderTest extends LightPlatformTestCase { + + public void testWithSimpleLanguage() { + PsiFile textFile = createFile("test.txt", "Hello, world."); + assertEquals(PlainTextFileType.INSTANCE, textFile.getFileType()); + assertEquals(PlainTextLanguage.INSTANCE, textFile.getLanguage()); + + DAPDebuggerEditorsProvider debuggerEditorsProvider = new DAPDebuggerEditorsProvider(PlainTextFileType.INSTANCE, null); + + PsiFile codeFragment = debuggerEditorsProvider.createExpressionCodeFragment(getProject(), "", textFile, true); + assertEquals(PlainTextFileType.INSTANCE, codeFragment.getFileType()); + assertEquals(PlainTextLanguage.INSTANCE, codeFragment.getLanguage()); + } + + public void testWithDialectLanguage() { + // Create a file with the dialect language + PsiFile dialectFile = PsiFileFactory.getInstance(getProject()).createFileFromText("test.json", Json5Language.INSTANCE, "{}"); + VirtualFile dialectVirtualFile = dialectFile.getVirtualFile(); + // Force the file type to be the base instead of the one inferred from the dialect language used above + ((LightVirtualFile) dialectVirtualFile).setFileType(JsonFileType.INSTANCE); + assertEquals(JsonFileType.INSTANCE, dialectFile.getFileType()); + assertEquals(Json5Language.INSTANCE, dialectFile.getLanguage()); + + // This should also use the base file type + DAPDebuggerEditorsProvider debuggerEditorsProvider = new DAPDebuggerEditorsProvider(MockLanguageFileType.INSTANCE, null); + + // If the original issue exists, this will fail with "JSON5 doesn't participate in view provider..." + PsiFile codeFragment = debuggerEditorsProvider.createExpressionCodeFragment(getProject(), "", dialectFile, true); + assertEquals(JsonFileType.INSTANCE, codeFragment.getFileType()); + // It should have degraded to the base language + assertEquals(JsonLanguage.INSTANCE, codeFragment.getLanguage()); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleViewTest.java b/src/test/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleViewTest.java index 249761e7e..2360dbc10 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleViewTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/dap/console/DAPConsoleViewTest.java @@ -11,7 +11,6 @@ package com.redhat.devtools.lsp4ij.dap.console; import com.intellij.execution.ui.ConsoleViewContentType; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtilsTest.java b/src/test/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtilsTest.java new file mode 100644 index 000000000..1bc51fbc0 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/dap/disassembly/DisassemblyUtilsTest.java @@ -0,0 +1,209 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.disassembly; + +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link DisassemblyUtils}. + * Covers parsing of BigInteger values, splice behavior, and custom binary search logic. + */ +class DisassemblyUtilsTest { + + // ------------------------------------------------------------------------ + // Tests for parseBigInteger + // ------------------------------------------------------------------------ + + @Test + void testParseBigInteger_HexadecimalLowercase() { + // Should correctly parse lowercase hexadecimal strings + assertEquals(new BigInteger("255"), DisassemblyUtils.parseBigInteger("0xff")); + } + + @Test + void testParseBigInteger_HexadecimalUppercase() { + // Should correctly parse uppercase hexadecimal strings + assertEquals(new BigInteger("255"), DisassemblyUtils.parseBigInteger("0XFF")); + } + + @Test + void testParseBigInteger_OctalLowercase() { + // Should correctly parse lowercase octal strings + assertEquals(new BigInteger("8"), DisassemblyUtils.parseBigInteger("0o10")); + } + + @Test + void testParseBigInteger_OctalUppercase() { + // Should correctly parse uppercase octal strings + assertEquals(new BigInteger("8"), DisassemblyUtils.parseBigInteger("0O10")); + } + + @Test + void testParseBigInteger_BinaryLowercase() { + // Should correctly parse lowercase binary strings + assertEquals(new BigInteger("10"), DisassemblyUtils.parseBigInteger("0b1010")); + } + + @Test + void testParseBigInteger_BinaryUppercase() { + // Should correctly parse uppercase binary strings + assertEquals(new BigInteger("10"), DisassemblyUtils.parseBigInteger("0B1010")); + } + + @Test + void testParseBigInteger_Decimal() { + // Should correctly parse decimal strings + assertEquals(new BigInteger("12345"), DisassemblyUtils.parseBigInteger("12345")); + } + + @Test + void testParseBigInteger_WithWhitespaces() { + // Should trim leading and trailing whitespaces + assertEquals(new BigInteger("255"), DisassemblyUtils.parseBigInteger(" 0xFF ")); + } + + @Test + void testParseBigInteger_InvalidInput_Null() { + // Should throw exception for null input + assertThrows(NumberFormatException.class, () -> DisassemblyUtils.parseBigInteger(null)); + } + + @Test + void testParseBigInteger_InvalidInput_NotANumber() { + // Should throw exception for non-numeric string + assertThrows(NumberFormatException.class, () -> DisassemblyUtils.parseBigInteger("hello")); + } + + @Test + void testParseBigInteger_InvalidBinaryFormat() { + // Should throw exception for invalid binary string + assertThrows(NumberFormatException.class, () -> DisassemblyUtils.parseBigInteger("0b102")); + } + + // ------------------------------------------------------------------------ + // Tests for splice + // ------------------------------------------------------------------------ + + @Test + void testSplice_RemoveElements() { + // Should remove two elements starting from index 1 + List list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); + List removed = DisassemblyUtils.splice(list, 1, 2, null); + + assertEquals(Arrays.asList("B", "C"), removed); + assertEquals(Arrays.asList("A", "D"), list); + } + + @Test + void testSplice_InsertElements() { + // Should insert two elements without removing any + List list = new ArrayList<>(Arrays.asList("A", "D")); + List removed = DisassemblyUtils.splice(list, 1, 0, Arrays.asList("B", "C")); + + assertTrue(removed.isEmpty()); + assertEquals(Arrays.asList("A", "B", "C", "D"), list); + } + + @Test + void testSplice_ReplaceElements() { + // Should replace two elements with two new elements + List list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); + List removed = DisassemblyUtils.splice(list, 1, 2, Arrays.asList("X", "Y")); + + assertEquals(Arrays.asList("B", "C"), removed); + assertEquals(Arrays.asList("A", "X", "Y", "D"), list); + } + + @Test + void testSplice_StartNegative() { + // Negative start index should be counted from the end + List list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); + List removed = DisassemblyUtils.splice(list, -2, 1, Arrays.asList("X")); + + assertEquals(List.of("C"), removed); + assertEquals(Arrays.asList("A", "B", "X", "D"), list); + } + + @Test + void testSplice_DeleteCountTooLarge() { + // Delete count larger than remaining elements should be adjusted + List list = new ArrayList<>(Arrays.asList("A", "B", "C")); + List removed = DisassemblyUtils.splice(list, 1, 10, null); + + assertEquals(Arrays.asList("B", "C"), removed); + assertEquals(List.of("A"), list); + } + + @Test + void testSplice_StartBeyondEnd() { + // Start index beyond list size should append elements at the end + List list = new ArrayList<>(Arrays.asList("A", "B")); + List removed = DisassemblyUtils.splice(list, 10, 2, Arrays.asList("C")); + + assertTrue(removed.isEmpty()); + assertEquals(Arrays.asList("A", "B", "C"), list); + } + + // ------------------------------------------------------------------------ + // Tests for binarySearch2 + // ------------------------------------------------------------------------ + + @Test + void testBinarySearch2_Found() { + // Should return the index of an existing element + int[] array = {1, 3, 5, 7, 9}; + int index = DisassemblyUtils.binarySearch2(array.length, i -> Integer.compare(array[i], 5)); + + assertEquals(2, index); // element found at index 2 + } + + @Test + void testBinarySearch2_NotFound_InsertPosition() { + // Should return a negative value indicating the insertion position + int[] array = {1, 3, 5, 7, 9}; + int index = DisassemblyUtils.binarySearch2(array.length, i -> Integer.compare(array[i], 4)); + + assertEquals(-3, index); // -(insertion position + 1) + } + + @Test + void testBinarySearch2_EmptyArray() { + // Empty array should always return -1 + int index = DisassemblyUtils.binarySearch2(0, i -> 0); + + assertEquals(-1, index); // insertion at position 0 + } + + @Test + void testBinarySearch2_FoundAtStart() { + // Should find element at the very beginning + int[] array = {1, 2, 3}; + int index = DisassemblyUtils.binarySearch2(array.length, i -> Integer.compare(array[i], 1)); + + assertEquals(0, index); + } + + @Test + void testBinarySearch2_FoundAtEnd() { + // Should find element at the very end + int[] array = {1, 2, 3}; + int index = DisassemblyUtils.binarySearch2(array.length, i -> Integer.compare(array[i], 3)); + + assertEquals(2, index); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandlerTest.java b/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandlerTest.java new file mode 100644 index 000000000..1a77f1ea0 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPSmartStepIntoHandlerTest.java @@ -0,0 +1,49 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.stepping; + +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import org.eclipse.lsp4j.debug.StepInTarget; +import org.jetbrains.annotations.NotNull; + +/** + * Unit tests for DAPSmartStepIntoHandler. + * + * Note: These are basic structural tests. Full integration tests require a running + * DAP server and debug session, which should be covered by manual testing or + * integration test suites. + */ +public class DAPSmartStepIntoHandlerTest extends BasePlatformTestCase { + + public void testGetPopupTitle() { + // The popup title is a simple method that doesn't require mocking + // We can test it in isolation once we have a session mock implementation + String expectedTitle = "Choose Method to Step Into"; + // Test will be completed when integration testing framework is ready + assertNotNull(expectedTitle); + } + + /** + * Test that verifies the basic structure of the handler. + * Full testing requires DAP server integration. + */ + public void testHandlerStructure() { + // Verify the handler class exists and can be instantiated + // Full functional tests will be done via integration tests + assertNotNull(DAPSmartStepIntoHandler.class); + } + + @NotNull + @Override + protected String getTestDataPath() { + return "src/test/resources/testData/dap/stepping"; + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariantTest.java b/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariantTest.java new file mode 100644 index 000000000..eea414ab1 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/dap/stepping/DAPStepIntoVariantTest.java @@ -0,0 +1,62 @@ +/******************************************************************************* + * Copyright (c) 2026 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.dap.stepping; + +import com.intellij.testFramework.LightPlatformTestCase; +import org.eclipse.lsp4j.debug.StepInTarget; +import org.jetbrains.annotations.NotNull; + +/** + * Unit tests for DAPStepIntoVariant. + */ +public class DAPStepIntoVariantTest extends LightPlatformTestCase { + + public void testGetText() { + StepInTarget target = createTarget(1, "foo(x, y)"); + DAPStepIntoVariant variant = new DAPStepIntoVariant(target, null, 0, 0); + + assertEquals("foo(x, y)", variant.getText()); + } + + public void testGetTargetId() { + StepInTarget target = createTarget(42, "bar()"); + DAPStepIntoVariant variant = new DAPStepIntoVariant(target, null, 0, 0); + + assertEquals(42, variant.getTargetId()); + } + + public void testGetHighlightRange_withNullDocument() { + StepInTarget target = createTarget(1, "foo()"); + target.setLine(1); + target.setColumn(5); + + DAPStepIntoVariant variant = new DAPStepIntoVariant(target, null, 0, 0); + var range = variant.getHighlightRange(); + + assertNull(range); + } + + public void testGetTarget() { + StepInTarget target = createTarget(10, "test()"); + DAPStepIntoVariant variant = new DAPStepIntoVariant(target, null, 0, 0); + + assertSame(target, variant.getTarget()); + } + + @NotNull + private static StepInTarget createTarget(int id, String label) { + StepInTarget target = new StepInTarget(); + target.setId(id); + target.setLabel(label); + return target; + } +} + diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/QuteCompletionTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/QuteCompletionTest.java index 21ef8b825..59d742bb6 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/QuteCompletionTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/QuteCompletionTest.java @@ -22,14 +22,16 @@ public QuteCompletionTest() { super("*.html"); } - public void testCompletion() { + public void testCompletionWithFor() { // 1. Test completion items response assertCompletion("test.html", - """ + // language=html + """
        f
      - """, """ + """, // language=json + """ { "isIncomplete": false, "items": [ @@ -64,7 +66,7 @@ public void testCompletion() { "detail": "Loop section with alias", "documentation": { "kind": "markdown", - "value": "\r\n```\r\n{#for item in items}\n\t{item.name}\n{/for}\r\n```\r\n\r\n\r\nSee [Loop section](https://quarkus.io/guides/qute-reference#loop_section) for more information." + "value": "\\r\\n```\\r\\n{#for item in items}\\n\\t{item.name}\\n{/for}\\r\\n```\\r\\n\\r\\n\\r\\nSee [Loop section](https://quarkus.io/guides/qute-reference#loop_section) for more information." }, "filterText": "for", "insertTextFormat": 2, @@ -80,16 +82,18 @@ public void testCompletion() { "character": 6 } }, - "newText": "{#for ${1:item} in ${2:items}}\n\t{${1:item}.${3:name}}$0\n{/for}" + "newText": "{#for ${1:item} in ${2:items}}\\n\\t{${1:item}.${3:name}}$0\\n{/for}" } } ] }""" , - "if", - "for"); + "if", "for"); // 2. Test new editor content after applying the first completion item - assertApplyCompletionItem(0, """ + + // Apply completion with #for + assertApplyCompletionItem(0, // language=html + """
        {#for item in items} {item.name} @@ -98,4 +102,82 @@ public void testCompletion() { """); } + public void testCompletionWithIf() { + // 1. Test completion items response + assertCompletion("test.html", + // language=html + """ +
          + f +
        + """, // language=json + """ + { + "isIncomplete": false, + "items": [ + { + "label": "if", + "kind": 15, + "detail": "If section", + "documentation": { + "kind": "markdown", + "value": "\\r\\n```\\r\\n{#if condition}\\n\\t\\n{/if}\\r\\n```\\r\\n\\r\\n\\r\\nSee [If section](https://quarkus.io/guides/qute-reference#if_section) for more information." + }, + "filterText": "if", + "insertTextFormat": 2, + "insertTextMode": 2, + "textEdit": { + "range": { + "start": { + "line": 1, + "character": 4 + }, + "end": { + "line": 1, + "character": 6 + } + }, + "newText": "{#if ${1:condition}}\\n\\t$0\\n{/if}" + } + }, + { + "label": "for", + "kind": 15, + "detail": "Loop section with alias", + "documentation": { + "kind": "markdown", + "value": "\\r\\n```\\r\\n{#for item in items}\\n\\t{item.name}\\n{/for}\\r\\n```\\r\\n\\r\\n\\r\\nSee [Loop section](https://quarkus.io/guides/qute-reference#loop_section) for more information." + }, + "filterText": "for", + "insertTextFormat": 2, + "insertTextMode": 2, + "textEdit": { + "range": { + "start": { + "line": 1, + "character": 4 + }, + "end": { + "line": 1, + "character": 6 + } + }, + "newText": "{#for ${1:item} in ${2:items}}\\n\\t{${1:item}.${3:name}}$0\\n{/for}" + } + } + ] + }""" + , + "if", "for"); + // 2. Test new editor content after applying the first second item + // Apply completion with #if + assertApplyCompletionItem(1, // language=html + """ +
          + {#if condition} + + {/if} +
        + """); + } } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/VariableCompletionTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/VariableCompletionTest.java new file mode 100644 index 000000000..56dd21710 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/VariableCompletionTest.java @@ -0,0 +1,110 @@ +/******************************************************************************* + * Copyright (c) 2025 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.completion; + +import com.redhat.devtools.lsp4ij.fixtures.LSPCompletionFixtureTestCase; + +/** + * Completion tests with 'textDocument/completion' responses + * which returns snippets with LSP standard variables}. + * + * @see {@link com.redhat.devtools.lsp4ij.features.completion.snippet.LspSnippetVariableConstants} + */ +public class VariableCompletionTest extends LSPCompletionFixtureTestCase { + + public VariableCompletionTest() { + super("*.ts"); + } + + public void testCompletionWith$TM_FILENAME() { + // 1. Test completion items response + assertCompletion("MyComponent.ts", + "", // language=json + """ + + { + "isIncomplete": false, + "items": [ + { + "label": "Type", + "kind": 15, + "insertTextFormat": 2, + "insertTextMode": 2, + "textEdit": { + "range": { + "start": { + "line": 1, + "character": 1 + }, + "end": { + "line": 1, + "character": 1 + } + }, + "newText": "Type ${1:$TM_FILENAME} {\\n |\\n}" + } + } + ] + }""" + , + "Type"); + // 2. Test new editor content after applying the first completion item + + // Apply completion with Type + assertApplyCompletionItem(0, + """ + Type MyComponent.ts { + | + }"""); + } + + public void testCompletionWith$TM_FILENAME_BASE() { + // 1. Test completion items response + assertCompletion("MyComponent.ts", + "", // language=json + """ + + { + "isIncomplete": false, + "items": [ + { + "label": "Type", + "kind": 15, + "insertTextFormat": 2, + "insertTextMode": 2, + "textEdit": { + "range": { + "start": { + "line": 1, + "character": 1 + }, + "end": { + "line": 1, + "character": 1 + } + }, + "newText": "Type ${1:$TM_FILENAME_BASE} {\\n |\\n}" + } + } + ] + }""" + , + "Type"); + // 2. Test new editor content after applying the first completion item + + // Apply completion with Type + assertApplyCompletionItem(0, + """ + Type MyComponent { + | + }"""); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/snippet/EscapeTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/snippet/EscapeTest.java index 7f8df2978..3d82be5a5 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/completion/snippet/EscapeTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/completion/snippet/EscapeTest.java @@ -50,7 +50,7 @@ public void escapedClosingCurlyBracketWithTabstop() { @Test(expected = ParseException.class) public void escapedMismatchEscapedClosingCurlyBracket() { - LspSnippetNode[] actual = LspSnippetAssert.parse("func foo() {${0\\}"); + LspSnippetAssert.parse("func foo() {${0\\}"); } @Test diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java index d212c0373..a8dcd9f82 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/documentation/MarkdownConverterWithPsiFileTest.java @@ -87,8 +87,8 @@ public void testXmlHighlightCodeBlockConversion() { } public void testAbsoluteFileLink() { - String markdown = "[foo](file://" + USER_HOME + "/lsp/foo.txt)"; - assertMarkdownConverter("bar.txt", markdown, "

        foo

        \n"); + String markdown = "[foo](file:///" + USER_HOME + "/lsp/foo.txt)"; + assertMarkdownConverter("bar.txt", markdown, "

        foo

        \n"); } public void testAbsoluteFileLinkWithSlash() { diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/files/ExpandPatternsTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/files/ExpandPatternsTest.java new file mode 100644 index 000000000..a8059eb0a --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/files/ExpandPatternsTest.java @@ -0,0 +1,118 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.files; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; + +/** + * Test with pattern expansion. +**/ +public class ExpandPatternsTest { + + @Test + public void noExpansion() { + assertExpandPatterns("foo", "foo"); + } + + @Test + public void oneExpansion() { + assertExpandPatterns("**/foo", "foo", "**/foo"); + } + + @Test + public void twoExpansion() { + assertExpandPatterns("**/foo/**", "foo", "**/foo", "foo/**", "**/foo/**"); + } + + @Test + public void sixExpansion() { + assertExpandPatterns("{**/node_modules/**,**/.git/**,**/bower_components/**}", + "{node_modules,.git,bower_components}", + "{node_modules,.git,bower_components/**}", + "{node_modules,.git,**/bower_components}", + "{node_modules,.git,**/bower_components/**}", + "{node_modules,.git/**,bower_components}", + "{node_modules,.git/**,bower_components/**}", + "{node_modules,.git/**,**/bower_components}", + "{node_modules,.git/**,**/bower_components/**}", + "{node_modules,**/.git,bower_components}", + "{node_modules,**/.git,bower_components/**}", + "{node_modules,**/.git,**/bower_components}", + "{node_modules,**/.git,**/bower_components/**}", + "{node_modules,**/.git/**,bower_components}", + "{node_modules,**/.git/**,bower_components/**}", + "{node_modules,**/.git/**,**/bower_components}", + "{node_modules,**/.git/**,**/bower_components/**}", + "{node_modules/**,.git,bower_components}", + "{node_modules/**,.git,bower_components/**}", + "{node_modules/**,.git,**/bower_components}", + "{node_modules/**,.git,**/bower_components/**}", + "{node_modules/**,.git/**,bower_components}", + "{node_modules/**,.git/**,bower_components/**}", + "{node_modules/**,.git/**,**/bower_components}", + "{node_modules/**,.git/**,**/bower_components/**}", + "{node_modules/**,**/.git,bower_components}", + "{node_modules/**,**/.git,bower_components/**}", + "{node_modules/**,**/.git,**/bower_components}", + "{node_modules/**,**/.git,**/bower_components/**}", + "{node_modules/**,**/.git/**,bower_components}", + "{node_modules/**,**/.git/**,bower_components/**}", + "{node_modules/**,**/.git/**,**/bower_components}", + "{node_modules/**,**/.git/**,**/bower_components/**}", + "{**/node_modules,.git,bower_components}", + "{**/node_modules,.git,bower_components/**}", + "{**/node_modules,.git,**/bower_components}", + "{**/node_modules,.git,**/bower_components/**}", + "{**/node_modules,.git/**,bower_components}", + "{**/node_modules,.git/**,bower_components/**}", + "{**/node_modules,.git/**,**/bower_components}", + "{**/node_modules,.git/**,**/bower_components/**}", + "{**/node_modules,**/.git,bower_components}", + "{**/node_modules,**/.git,bower_components/**}", + "{**/node_modules,**/.git,**/bower_components}", + "{**/node_modules,**/.git,**/bower_components/**}", + "{**/node_modules,**/.git/**,bower_components}", + "{**/node_modules,**/.git/**,bower_components/**}", + "{**/node_modules,**/.git/**,**/bower_components}", + "{**/node_modules,**/.git/**,**/bower_components/**}", + "{**/node_modules/**,.git,bower_components}", + "{**/node_modules/**,.git,bower_components/**}", + "{**/node_modules/**,.git,**/bower_components}", + "{**/node_modules/**,.git,**/bower_components/**}", + "{**/node_modules/**,.git/**,bower_components}", + "{**/node_modules/**,.git/**,bower_components/**}", + "{**/node_modules/**,.git/**,**/bower_components}", + "{**/node_modules/**,.git/**,**/bower_components/**}", + "{**/node_modules/**,**/.git,bower_components}", + "{**/node_modules/**,**/.git,bower_components/**}", + "{**/node_modules/**,**/.git,**/bower_components}", + "{**/node_modules/**,**/.git,**/bower_components/**}", + "{**/node_modules/**,**/.git/**,bower_components}", + "{**/node_modules/**,**/.git/**,bower_components/**}", + "{**/node_modules/**,**/.git/**,**/bower_components}", + "{**/node_modules/**,**/.git/**,**/bower_components/**}"); + } + + private static void assertExpandPatterns(String pattern, String... expectedPatterns) { + List actual = PathPatternMatcher.expandPatterns(pattern); + Collections.sort(actual); + List expected = Arrays.asList(expectedPatterns); + Collections.sort(expected); + assertArrayEquals("'" + pattern + "' pattern expansion should match [\"" + String.join("\",\"", actual) + "\"]", + actual.toArray(new String[0]), expected.toArray(new String[0])); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/files/FileSystemWatcherManagerTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/files/FileSystemWatcherManagerTest.java index 3f959277b..a7273ef06 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/files/FileSystemWatcherManagerTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/files/FileSystemWatcherManagerTest.java @@ -16,8 +16,11 @@ import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; import org.eclipse.lsp4j.WatchKind; import org.junit.Test; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import java.net.URI; +import java.nio.file.Paths; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -42,50 +45,167 @@ */ public class FileSystemWatcherManagerTest { - final private FileSystemWatcherManager manager = new FileSystemWatcherManager(); + public static final String DEFAULT_WATCHER = "default"; + final private FileSystemWatcherManager manager = new FileSystemWatcherManager(Paths.get(getProjectDir())); + + @BeforeEach + void setUp() { + unregisterWatchers(DEFAULT_WATCHER); + } + + @AfterEach + void tearDown() { + unregisterWatchers(DEFAULT_WATCHER); + } @Test - public void jdt_ls() { + public void sap_cds_ls() { // On Windows OS, we generate a base dir with lower case because JDT LS generate this base dir. - String baseDir = SystemInfo.isWindows ? getBaseDir().toLowerCase() : getBaseDir(); - registerWatchers(""" - {"watchers": [ - { - "globPattern": "**/*.java" - }, - { - "globPattern": "**/.project" - }, - { - "globPattern": "**/.classpath" - }, - { - "globPattern": "**/.settings/*.prefs" - }, - { - "globPattern": "**/src/**" - }, - { - "globPattern": "**/*.gradle" - }, - { - "globPattern": "**/*.gradle.kts" - }, - { - "globPattern": "**/gradle.properties" - }, - { - "globPattern": "**/pom.xml" - }, - { - "globPattern": "%sUsers/X/foo/lib/**" - }, - { - "globPattern": "**/.settings" - } - ] - } - """.formatted(baseDir)); + String baseDir = SystemInfo.isWindows ? getProjectDir().toLowerCase() : getProjectDir(); + registerWatchers( // language=json + """ + {"watchers": [ + { + "kind": 7, + "globPattern": "package.json" + }, + { + "kind": 7, + "globPattern": "{.git,.cds}ignore" + }, + { + "kind": 7, + "globPattern": ".cdsrc.json" + }, + { + "kind": 7, + "globPattern": "**/{_i18n,i18n}/i18n{*.properties,*.json,*.csv}" + } + ] + } + """.formatted(baseDir)); + + // Match package.json + assertMatchFile(getBaseUri() + "package.json"); // file:///C:/package.json + assertMatchFile(getBaseUri() + "package.json", WatchKind.Create); // file:///C:/package.json + assertMatchFile(getBaseUri() + "package.json", WatchKind.Change); // file:///C:/package.json + assertMatchFile(getBaseUri() + "package.json", WatchKind.Delete); // file:///C:/package.json + + assertNoMatchFile(getBaseUri() + "package.jso"); // file:///C:/package.jso + assertNoMatchFile(getBaseUri() + "foo/package.json"); // file:///C:/foo/package.json + assertNoMatchFile(getExternalDir() + "package.json"); // file:///D:/package.json + assertNoMatchFile(getExternalDir() + "package.json", WatchKind.Create); // file:///D:/package.json + assertNoMatchFile(getExternalDir() + "package.json", WatchKind.Change); // file:///D:/package.json + assertNoMatchFile(getExternalDir() + "package.json", WatchKind.Delete); // file:///D:/package.json + + // Match {.git,.cds}ignore + assertNoMatchFile(getBaseUri() + "gitignore"); // file:///C:/gitignore + assertMatchFile(getBaseUri() + ".gitignore"); // file:///C:/.gitignore + assertMatchFile(getBaseUri() + ".gitignore", WatchKind.Create); // file:///C:/.gitignore + assertMatchFile(getBaseUri() + ".gitignore", WatchKind.Change); // file:///C:/.gitignore + assertMatchFile(getBaseUri() + ".gitignore", WatchKind.Delete); // file:///C:/.gitignore + + assertNoMatchFile(getBaseUri() + "cdsignore"); // file:///C:/cdsignore + assertMatchFile(getBaseUri() + ".cdsignore"); // file:///C:/.cdsignore + assertMatchFile(getBaseUri() + ".cdsignore", WatchKind.Create); // file:///C:/.cdsignore + assertMatchFile(getBaseUri() + ".cdsignore", WatchKind.Change); // file:///C:/.cdsignore + assertMatchFile(getBaseUri() + ".cdsignore", WatchKind.Delete); // file:///C:/.cdsignore + + // Match .cdsrc.json + assertNoMatchFile(getBaseUri() + "cdsrc.json"); // file:///C:/cdsrc.json + assertMatchFile(getBaseUri() + ".cdsrc.json"); // file:///C:/package.json + assertMatchFile(getBaseUri() + ".cdsrc.json", WatchKind.Create); // file:///C:/.cdsrc.json + assertMatchFile(getBaseUri() + ".cdsrc.json", WatchKind.Change); // file:///C:/.cdsrc.json + assertMatchFile(getBaseUri() + ".cdsrc.json", WatchKind.Delete); // file:///C:/.cdsrc.json + + // Match **/{_i18n,i18n}/i18n{*.properties,*.json,*.csv} + assertNoMatchFile(getBaseUri() + "/_i18n/i19n.properties"); // file:///C:/_i18n/i19n.properties + assertMatchFile(getBaseUri() + "/_i18n/i18n.properties"); // file:///C:/_i18n/i18n.properties + assertMatchFile(getBaseUri() + "/_i18n/i18nFoo.properties"); // file:///C:/_i18n/i18nFoo.properties + + assertNoMatchFile(getBaseUri() + "/_i18n/i19n.json"); // file:///C:/_i18n/i19n.json + assertMatchFile(getBaseUri() + "/_i18n/i18n.json"); // file:///C:/_i18n/i18n.json + assertMatchFile(getBaseUri() + "/_i18n/i18nFoo.json"); // file:///C:/_i18n/i18nFoo.json + + assertNoMatchFile(getBaseUri() + "/_i18n/i19n.csv"); // file:///C:/_i18n/i19n.csv + assertMatchFile(getBaseUri() + "/_i18n/i18n.csv"); // file:///C:/_i18n/i18n.csv + assertMatchFile(getBaseUri() + "/_i18n/i18nFoo.csv"); // file:///C:/_i18n/i18nFoo.csv + + assertNoMatchFile(getBaseUri() + "/_i18n/i18n.xml"); // file:///C:/_i18n/i18n.xml + } + + @Test + public void relativePattern() { + // On Windows OS, we generate a base dir with lower case because JDT LS generate this base dir. + String externalBaseDir = SystemInfo.isWindows ? getExternalDir().toLowerCase() : getExternalDir(); + registerWatchers( // language=json + """ + {"watchers": [ + { + "kind": 7, + "globPattern": { + "baseUri": "%s", + "pattern": "package.json" + } + }, + { + "kind": 7, + "globPattern": "*/package.json" + } + ] + } + """.formatted(externalBaseDir)); + + // Match **/package.json + assertNoMatchFile(getBaseUri() + "package.json"); // file:///C:/package.json + assertMatchFile(getBaseUri() + "/foo/package.json"); // file:///C:/package.json + + // Match package.json + assertMatchFile(getExternalDir() + "package.json"); // file:///C:/package.json + assertNoMatchFile(getExternalDir() + "/foo/package.json"); // file:///C:/package.json + } + + @Test + public void jdt_ls() { + registerWatchers(// language=json + """ + {"watchers": [ + { + "globPattern": "**/*.java" + }, + { + "globPattern": "**/.project" + }, + { + "globPattern": "**/.classpath" + }, + { + "globPattern": "**/.settings/*.prefs" + }, + { + "globPattern": "**/src/**" + }, + { + "globPattern": "**/*.gradle" + }, + { + "globPattern": "**/*.gradle.kts" + }, + { + "globPattern": "**/gradle.properties" + }, + { + "globPattern": "**/pom.xml" + }, + { + "globPattern": "Users/X/foo/lib/**" + }, + { + "globPattern": "**/.settings" + } + ] + } + """); // Match "**/*.java" assertMatchFile(getBaseUri() + "foo.java"); // file:///C:/foo.java @@ -96,8 +216,9 @@ public void jdt_ls() { assertMatchFile(getBaseUri() + "src/foo.ts"); // "file:///C:/src/foo.ts" // Match c:/Users/X/foo/lib/** - assertNoMatchFile(getBaseUri() + "Users/X/lib/bar.jar"); // file:///C:/Users/X/lib/bar.jar - assertMatchFile(getBaseUri() + "Users/X/foo/lib/bar.jar"); // file:///C:/Users/X/foo/lib/bar.jar + // Using .txt instead of .jar because FileSystemWatcherManager explicitly skips archive files to prevent IDE freezes. + assertNoMatchFile(getBaseUri() + "Users/X/lib/bar.txt"); // file:///C:/Users/X/lib/bar.txt + assertMatchFile(getBaseUri() + "Users/X/foo/lib/bar.txt"); // file:///C:/Users/X/foo/lib/bar.txt // Match "**/.settings assertNoMatchFile(getBaseUri() + "foo.ts"); // file:///C:/foo.ts @@ -107,34 +228,35 @@ public void jdt_ls() { assertNoMatchFile(getBaseUri() + ".settings/foo.pref"); // file:///C:/.settings/foo.pref assertNoMatchFile(getBaseUri() + ".settings/bar/foo.prefs"); // file:///C:/.settings/bar/foo.prefs assertMatchFile(getBaseUri() + ".settings/foo.prefs"); // file:///C:/.settings/foo.prefs - } @Test public void register_unregister_watchers() { // Register Java watcher String id_java = "watcher-java"; - registerWatchers(id_java, """ - {"watchers": [ - { - "globPattern": "**/*.java" - } - ] - } - """); + registerWatchers(id_java, // language=json + """ + {"watchers": [ + { + "globPattern": "**/*.java" + } + ] + } + """); assertMatchFile(getBaseUri() + "foo.java"); // file:///C:/foo.java// Match "**/*.java" assertNoMatchFile(getBaseUri() + "foo.ts"); // file:///C:/foo.ts // Register TypeScript watcher String id_ts = "watcher-ts"; - registerWatchers(id_ts, """ - {"watchers": [ - { - "globPattern": "**/*.ts" - } - ] - } - """); + registerWatchers(id_ts,// language=json + """ + {"watchers": [ + { + "globPattern": "**/*.ts" + } + ] + } + """); assertMatchFile(getBaseUri() + "foo.java"); // file:///C:/foo.java// Match "**/*.java" assertMatchFile(getBaseUri() + "foo.ts"); // file:///C:/foo.java// Match "**/*.ts" @@ -152,30 +274,31 @@ public void register_unregister_watchers() { @Test public void watcherKind() { // Register Java watcher - registerWatchers("watcher-kind", """ - {"watchers": [ - { - "globPattern": "**/*.kind_null" - }, - { - "globPattern": "**/*.kind_7", - "kind": 7 - }, - { - "globPattern": "**/*.kind_Create", - "kind": 1 - }, - { - "globPattern": "**/*.kind_Change", - "kind": 2 - }, - { - "globPattern": "**/*.kind_Delete", - "kind": 4 - } - ] - } - """); + registerWatchers("watcher-kind", // language=json + """ + {"watchers": [ + { + "globPattern": "**/*.kind_null" + }, + { + "globPattern": "**/*.kind_7", + "kind": 7 + }, + { + "globPattern": "**/*.kind_Create", + "kind": 1 + }, + { + "globPattern": "**/*.kind_Change", + "kind": 2 + }, + { + "globPattern": "**/*.kind_Delete", + "kind": 4 + } + ] + } + """); assertMatchFile(getBaseUri() + "foo.kind_null", WatchKind.Create); // file:///C:/foo.king_null// Match "**/*.kind_null" assertMatchFile(getBaseUri() + "foo.kind_null", WatchKind.Change); // file:///C:/foo.king_null// Match "**/*.kind_null" @@ -692,11 +815,12 @@ private void assertGlobMatch(String pattern, String uri, boolean expected) { // Tests must be adapted to use file uri - String baseDir = getBaseDir(); - pattern = baseDir + pattern; - String baseUri = getBaseUri(); - uri = baseUri + uri; + if (uri.startsWith("/")) { + uri = baseUri + uri.substring(1); + } else { + uri = baseUri + uri; + } registerWatchers(""" {"watchers": [ @@ -712,7 +836,6 @@ private void assertGlobMatch(String pattern, String uri, boolean expected) { } } - private void assertMatchFile(String fileUri) { assertMatchFile(fileUri, WatchKind.Create); } @@ -736,7 +859,7 @@ private void assertNoMatchFile(String fileUri, int kind) { } private void registerWatchers(String watchers) { - registerWatchers("foo", watchers); + registerWatchers(DEFAULT_WATCHER, watchers); } private void registerWatchers(String id, String watchers) { @@ -745,15 +868,21 @@ private void registerWatchers(String id, String watchers) { } private static String getBaseUri() { - return (SystemInfo.isWindows ? "file:///" : "file://") + getBaseDir(); + return (SystemInfo.isWindows ? "file:///" : "file://") + getProjectDir(); + } + + private static String getExternalDir() { + if (SystemInfo.isWindows) { + return "file:///C://Users/user/a-project/"; + } + return "file:///home/user/a-project/"; + } - private static String getBaseDir() { - String baseDir = System.getProperty("user.home") - .replace('\\', '/'); - if (!baseDir.endsWith("/")) { - return baseDir + "/"; + private static String getProjectDir() { + if (SystemInfo.isWindows) { + return "C:/Users/user/current-project/"; } - return baseDir; + return "/home/user/current-project/"; } } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcherTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcherTest.java index d1cbe9161..f6eb1e2e2 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcherTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcherTest.java @@ -1,118 +1,320 @@ -/******************************************************************************* - * Copyright (c) 2024 Red Hat, Inc. - * Distributed under license by Red Hat, Inc. All rights reserved. - * This program is made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, - * and is available at https://www.eclipse.org/legal/epl-v20.html - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - ******************************************************************************/ -package com.redhat.devtools.lsp4ij.features.files; - -import org.junit.Test; - -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.junit.Assert.assertArrayEquals; - -/** - * Test with pattern expansion. -**/ -public class PathPatternMatcherTest { - - @Test - public void noExpansion() { - assertExpandPatterns("foo", "foo"); - } - - @Test - public void oneExpansion() { - assertExpandPatterns("**/foo", "foo", "**/foo"); - } - - @Test - public void twoExpansion() { - assertExpandPatterns("**/foo/**", "foo", "**/foo", "foo/**", "**/foo/**"); - } - - @Test - public void sixExpansion() { - assertExpandPatterns("{**/node_modules/**,**/.git/**,**/bower_components/**}", - "{node_modules,.git,bower_components}", - "{node_modules,.git,bower_components/**}", - "{node_modules,.git,**/bower_components}", - "{node_modules,.git,**/bower_components/**}", - "{node_modules,.git/**,bower_components}", - "{node_modules,.git/**,bower_components/**}", - "{node_modules,.git/**,**/bower_components}", - "{node_modules,.git/**,**/bower_components/**}", - "{node_modules,**/.git,bower_components}", - "{node_modules,**/.git,bower_components/**}", - "{node_modules,**/.git,**/bower_components}", - "{node_modules,**/.git,**/bower_components/**}", - "{node_modules,**/.git/**,bower_components}", - "{node_modules,**/.git/**,bower_components/**}", - "{node_modules,**/.git/**,**/bower_components}", - "{node_modules,**/.git/**,**/bower_components/**}", - "{node_modules/**,.git,bower_components}", - "{node_modules/**,.git,bower_components/**}", - "{node_modules/**,.git,**/bower_components}", - "{node_modules/**,.git,**/bower_components/**}", - "{node_modules/**,.git/**,bower_components}", - "{node_modules/**,.git/**,bower_components/**}", - "{node_modules/**,.git/**,**/bower_components}", - "{node_modules/**,.git/**,**/bower_components/**}", - "{node_modules/**,**/.git,bower_components}", - "{node_modules/**,**/.git,bower_components/**}", - "{node_modules/**,**/.git,**/bower_components}", - "{node_modules/**,**/.git,**/bower_components/**}", - "{node_modules/**,**/.git/**,bower_components}", - "{node_modules/**,**/.git/**,bower_components/**}", - "{node_modules/**,**/.git/**,**/bower_components}", - "{node_modules/**,**/.git/**,**/bower_components/**}", - "{**/node_modules,.git,bower_components}", - "{**/node_modules,.git,bower_components/**}", - "{**/node_modules,.git,**/bower_components}", - "{**/node_modules,.git,**/bower_components/**}", - "{**/node_modules,.git/**,bower_components}", - "{**/node_modules,.git/**,bower_components/**}", - "{**/node_modules,.git/**,**/bower_components}", - "{**/node_modules,.git/**,**/bower_components/**}", - "{**/node_modules,**/.git,bower_components}", - "{**/node_modules,**/.git,bower_components/**}", - "{**/node_modules,**/.git,**/bower_components}", - "{**/node_modules,**/.git,**/bower_components/**}", - "{**/node_modules,**/.git/**,bower_components}", - "{**/node_modules,**/.git/**,bower_components/**}", - "{**/node_modules,**/.git/**,**/bower_components}", - "{**/node_modules,**/.git/**,**/bower_components/**}", - "{**/node_modules/**,.git,bower_components}", - "{**/node_modules/**,.git,bower_components/**}", - "{**/node_modules/**,.git,**/bower_components}", - "{**/node_modules/**,.git,**/bower_components/**}", - "{**/node_modules/**,.git/**,bower_components}", - "{**/node_modules/**,.git/**,bower_components/**}", - "{**/node_modules/**,.git/**,**/bower_components}", - "{**/node_modules/**,.git/**,**/bower_components/**}", - "{**/node_modules/**,**/.git,bower_components}", - "{**/node_modules/**,**/.git,bower_components/**}", - "{**/node_modules/**,**/.git,**/bower_components}", - "{**/node_modules/**,**/.git,**/bower_components/**}", - "{**/node_modules/**,**/.git/**,bower_components}", - "{**/node_modules/**,**/.git/**,bower_components/**}", - "{**/node_modules/**,**/.git/**,**/bower_components}", - "{**/node_modules/**,**/.git/**,**/bower_components/**}"); - } - - private static void assertExpandPatterns(String pattern, String... expectedPatterns) { - List actual = PathPatternMatcher.expandPatterns(pattern); - Collections.sort(actual); - List expected = Arrays.asList(expectedPatterns); - Collections.sort(expected); - assertArrayEquals("'" + pattern + "' pattern expansion should match [\"" + String.join("\",\"", actual) + "\"]", - actual.toArray(new String[0]), expected.toArray(new String[0])); - } -} +package com.redhat.devtools.lsp4ij.features.files; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.*; + +class PathPatternMatcherTest { + + @Test + @DisplayName("Pattern with Windows absolute path and glob") + void testWindowsAbsolutePathWithGlob() { + String pattern = "C:\\Users\\XXX\\IdeaProjects\\test-rust/**/*.rs"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.rs", matcher.getPattern()); + assertNotNull(matcher.getBasePath()); + assertEquals(Paths.get("C:/Users/XXX/IdeaProjects/test-rust"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with Unix absolute path and glob") + void testUnixAbsolutePathWithGlob() { + String pattern = "/home/user/project/**/*.java"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.java", matcher.getPattern()); + assertNotNull(matcher.getBasePath()); + assertEquals(Paths.get("/home/user/project"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with relative path and glob") + void testRelativePathWithGlob() { + String pattern = "src/main/java/**/*.kt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.kt", matcher.getPattern()); + assertNotNull(matcher.getBasePath()); + assertEquals(Paths.get("src/main/java"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern starting with ** (no base path)") + void testPatternStartingWithGlob() { + String pattern = "**/*.txt"; + Path defaultBase = Paths.get("/default/path"); + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, defaultBase); + + assertEquals("**/*.txt", matcher.getPattern()); + assertEquals(defaultBase, matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern starting with * (no base path)") + void testPatternStartingWithAsterisk() { + String pattern = "*.rs"; + Path defaultBase = Paths.get("/default"); + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, defaultBase); + + assertEquals("*.rs", matcher.getPattern()); + assertEquals(defaultBase, matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with single level wildcard") + void testSingleLevelWildcard() { + String pattern = "/usr/local/bin/*.sh"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("*.sh", matcher.getPattern()); + assertEquals(Paths.get("/usr/local/bin"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with question mark wildcard") + void testQuestionMarkWildcard() { + String pattern = "C:\\test\\folder\\file?.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("file?.txt", matcher.getPattern()); + assertEquals(Paths.get("C:/test/folder"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with bracket wildcard") + void testBracketWildcard() { + String pattern = "/var/log/app[123].log"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("app[123].log", matcher.getPattern()); + assertEquals(Paths.get("/var/log"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with brace wildcard") + void testBraceWildcard() { + String pattern = "/etc/config/{prod,dev,test}.yml"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("{prod,dev,test}.yml", matcher.getPattern()); + assertEquals(Paths.get("/etc/config"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with multiple glob types") + void testMultipleGlobTypes() { + String pattern = "C:\\projects\\myapp\\**/*.{java,kt,rs}"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.{java,kt,rs}", matcher.getPattern()); + assertEquals(Paths.get("C:/projects/myapp"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with mixed separators (backslash and forward slash)") + void testMixedSeparators() { + String pattern = "C:\\Users/XXX/project\\src/**/*.java"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.java", matcher.getPattern()); + assertNotNull(matcher.getBasePath()); + // Should handle mixed separators + } + + @Test + @DisplayName("Pattern with no glob characters") + void testNoGlobCharacters() { + String pattern = "/home/user/file.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("/home/user/file.txt", matcher.getPattern()); + assertNull(matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with glob at the very beginning") + void testGlobAtBeginning() { + String pattern = "**/src/main/**/*.java"; + Path defaultBase = Paths.get("/project"); + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, defaultBase); + + assertEquals("**/src/main/**/*.java", matcher.getPattern()); + assertEquals(defaultBase, matcher.getBasePath()); + } + + @Test + @DisplayName("Empty pattern") + void testEmptyPattern() { + String pattern = ""; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("", matcher.getPattern()); + assertNull(matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with only separator and glob") + void testOnlySeparatorAndGlob() { + String pattern = "/*.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("*.txt", matcher.getPattern()); + // Root path "/" is extracted but might be null depending on implementation + } + + @Test + @DisplayName("Deep nested path with glob") + void testDeepNestedPath() { + String pattern = "/a/b/c/d/e/f/g/h/**/*.xml"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.xml", matcher.getPattern()); + assertEquals(Paths.get("/a/b/c/d/e/f/g/h"), matcher.getBasePath()); + } + + @Test + @DisplayName("Windows drive letter only") + void testWindowsDriveLetterOnly() { + String pattern = "C:/**/*.dll"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.dll", matcher.getPattern()); + // C: might result in special handling + } + + @Test + @DisplayName("Pattern with trailing slash before glob") + void testTrailingSlashBeforeGlob() { + String pattern = "/home/user/project//**/*.js"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.js", matcher.getPattern()); + assertEquals(Paths.get("/home/user/project"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with UNC path (Windows network)") + void testUNCPath() { + String pattern = "\\\\server\\share\\folder/**/*.doc"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.doc", matcher.getPattern()); + assertNotNull(matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with only filename glob") + void testOnlyFilenameGlob() { + String pattern = "test*.java"; + Path defaultBase = Paths.get("/src"); + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, defaultBase); + + assertEquals("test*.java", matcher.getPattern()); + assertEquals(defaultBase, matcher.getBasePath()); + } + + @Test + @DisplayName("Default base path is used when pattern has no path component") + void testDefaultBasePathUsed() { + String pattern = "*.rs"; + Path defaultBase = Paths.get("/my/default/path"); + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, defaultBase); + + assertNotNull(matcher.getBasePath()); + assertEquals(defaultBase, matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with consecutive asterisks") + void testConsecutiveAsterisks() { + String pattern = "/path/to/***/*.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("***/*.txt", matcher.getPattern()); + assertEquals(Paths.get("/path/to"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with dot in directory name") + void testDotInDirectoryName() { + String pattern = "/home/user/.config/**/*.conf"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.conf", matcher.getPattern()); + assertEquals(Paths.get("/home/user/.config"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with spaces in path") + void testSpacesInPath() { + String pattern = "/home/my documents/project/**/*.pdf"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.pdf", matcher.getPattern()); + assertEquals(Paths.get("/home/my documents/project"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with Unicode characters in path") + void testUnicodeInPath() { + String pattern = "/home/utilisateur/projét/**/*.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.txt", matcher.getPattern()); + assertEquals(Paths.get("/home/utilisateur/projét"), matcher.getBasePath()); + } + + @Test + @DisplayName("Complex real-world pattern - Rust project") + void testRealWorldRustProject() { + String pattern = "C:\\Users\\Developer\\RustProjects\\my-app\\src/**/*.rs"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.rs", matcher.getPattern()); + assertEquals(Paths.get("C:/Users/Developer/RustProjects/my-app/src"), matcher.getBasePath()); + } + + @Test + @DisplayName("Complex real-world pattern - Java Maven project") + void testRealWorldJavaMavenProject() { + String pattern = "/home/dev/projects/my-service/src/main/java/**/*.java"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.java", matcher.getPattern()); + assertEquals(Paths.get("/home/dev/projects/my-service/src/main/java"), matcher.getBasePath()); + } + + @Test + @DisplayName("Complex real-world pattern - Node.js project") + void testRealWorldNodeJsProject() { + String pattern = "D:\\workspace\\my-node-app\\src\\**\\*.{ts,js}"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**\\*.{ts,js}", matcher.getPattern()); + assertEquals(Paths.get("D:/workspace/my-node-app/src"), matcher.getBasePath()); + } + + @Test + @DisplayName("Pattern with multiple consecutive separators") + void testMultipleConsecutiveSeparators() { + String pattern = "/home//user///project/**/*.txt"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.txt", matcher.getPattern()); + // Should handle multiple separators gracefully + } + + @Test + @DisplayName("Null default base path") + void testNullDefaultBasePath() { + String pattern = "**/*.java"; + PathPatternMatcher matcher = PathPatternMatcher.fromPattern(pattern, null); + + assertEquals("**/*.java", matcher.getPattern()); + assertNull(matcher.getBasePath()); + } +} \ No newline at end of file diff --git a/src/test/java/com/redhat/devtools/lsp4ij/features/rename/ClojureRenameTest.java b/src/test/java/com/redhat/devtools/lsp4ij/features/rename/ClojureRenameTest.java new file mode 100644 index 000000000..da0accf11 --- /dev/null +++ b/src/test/java/com/redhat/devtools/lsp4ij/features/rename/ClojureRenameTest.java @@ -0,0 +1,130 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. + * Distributed under license by Red Hat, Inc. All rights reserved. + * This program is made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, + * and is available at http://www.eclipse.org/legal/epl-v20.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + ******************************************************************************/ +package com.redhat.devtools.lsp4ij.features.rename; + +import com.redhat.devtools.lsp4ij.fixtures.LSPRenameFixtureTestCase; + +import java.io.IOException; + +/** + * Rename tests by emulating LSP 'textDocument/prepareRename' and 'textDocument/rename' + * responses from the Clojure language server. + */ +public class ClojureRenameTest extends LSPRenameFixtureTestCase { + + public ClojureRenameTest() { + super("*.clj"); + } + + public void testRenameNamespaceInEditor() { + // Clojure LSP uses range as prepare rename response + assertWillRename("AAAA.clj", + """ + ns( AAAA)""", + // language=json + """ + { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 8 + } + } + """, // Prepare rename managed by Clojure LSP + "AAAA", // expected placeholder returned by prepare rename + // language=json + """ + { + "changes": {}, + "documentChanges": [ + { + "oldUri": "%sAAAA.clj", + "newUri": "%sBBBB.clj", + "kind": "rename" + } + ] + }""", + // language=json + """ + { + "changes": {}, + "documentChanges": [ + { + "textDocument": { + "version": 0, + "uri": "%sAAAA.clj" + }, + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 8 + } + }, + "newText": "BBBB" + } + ] + } + ] + } + """, + "BBBB.clj", + """ + ns( BBBB)"""); + } + + public void testRenameFile() throws IOException { + // Clojure LSP uses range as prepare rename response + assertRenameFile("AAAA.clj", + """ + ns( AAAA)""", + "BBBB.clj", + // language=json + """ + { + "changes": {}, + "documentChanges": [ + { + "textDocument": { + "version": 0, + "uri": "%sAAAA.clj" + }, + "edits": [ + { + "range": { + "start": { + "line": 0, + "character": 4 + }, + "end": { + "line": 0, + "character": 8 + } + }, + "newText": "BBBB" + } + ] + } + ] + } + """, + """ + ns( BBBB)"""); + } +} diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCompletionFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCompletionFixtureTestCase.java index 7ea6a7139..801b7a53b 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCompletionFixtureTestCase.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPCompletionFixtureTestCase.java @@ -10,6 +10,7 @@ ******************************************************************************/ package com.redhat.devtools.lsp4ij.fixtures; +import com.intellij.codeInsight.completion.BaseCompletionService; import com.intellij.codeInsight.lookup.LookupElement; import com.redhat.devtools.lsp4ij.JSONUtils; import com.redhat.devtools.lsp4ij.mock.MockLanguageServer; @@ -119,6 +120,8 @@ public void assertAutoCompletion(@NotNull String fileName, MockLanguageServer.INSTANCE.setCompletionList(JSONUtils.getLsp4jGson().fromJson(jsonCompletionList, CompletionList.class)); // Open editor for a given file name and content (which declares to know where the completion is triggered). myFixture.configureByText(fileName, editorContentText); + // Forbid word completion (required for 2023.3 version) + myFixture.getFile().putUserData(BaseCompletionService.FORBID_WORD_COMPLETION, true); // Process completion assertNull(myFixture.completeBasic()); String expected = expectedEditorContentText; diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java index 1af911723..b485f6059 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPFoldingRangeFixtureTestCase.java @@ -88,6 +88,12 @@ protected void assertFoldingRanges(@NotNull String fileName, PsiFile file = myFixture.configureByText(fileName, stripTokens(fileBody)); Editor editor = myFixture.getEditor(); + // Move the caret to the end to avoid implicitly opening a fold region that would + // interfere with the test assertions. This was required because JB 2026.1 + // started accounting for caret positions in tests in the: + // EditorTestUtil.buildInitialFoldingsInBackground() + // function. + editor.getCaretModel().moveToOffset(file.getTextLength()); // Initialize the language server try { diff --git a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPRenameFixtureTestCase.java b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPRenameFixtureTestCase.java index af1786e22..411ea0636 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPRenameFixtureTestCase.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/fixtures/LSPRenameFixtureTestCase.java @@ -11,6 +11,8 @@ package com.redhat.devtools.lsp4ij.fixtures; import com.intellij.openapi.actionSystem.IdeActions; +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiFile; import com.redhat.devtools.lsp4ij.JSONUtils; import com.redhat.devtools.lsp4ij.LSPIJUtils; @@ -26,6 +28,8 @@ import org.jetbrains.annotations.Nullable; import org.junit.Assert; +import java.io.IOException; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -242,6 +246,11 @@ private static Either3 } private static void updateRenameCapabilities(@Nullable String jsonPrepareRenameResult) { + updateRenameCapabilities(jsonPrepareRenameResult, null); + } + + private static void updateRenameCapabilities(@Nullable String jsonPrepareRenameResult, + @Nullable WorkspaceServerCapabilities workspaceServerCapabilities) { Either renameProvider; if (jsonPrepareRenameResult != null) { RenameOptions prepareRenameProvider = new RenameOptions(); @@ -252,7 +261,219 @@ private static void updateRenameCapabilities(@Nullable String jsonPrepareRenameR } ServerCapabilities serverCapabilities = MockLanguageServer.defaultServerCapabilities(); serverCapabilities.setRenameProvider(renameProvider); + serverCapabilities.setWorkspace(workspaceServerCapabilities); MockLanguageServer.reset(() -> serverCapabilities); } + /** + * Test LSP rename. + * + * @param fileName the file name used to match registered language servers. + * @param text the editor content text. + * @param jsonPrepareRenameResponse the emulated JSON prepare rename response and null otherwise. + * @param expectedPlaceholder the expected placeholder and null otherwise. + * @param jsonRenameResponse the emulated rename JSON rename response WorkspaceEdit and null otherwise. + * @param expectedRenamedText the expected renamed text. + */ + protected void assertWillRename(@NotNull String fileName, + @NotNull String text, + @Nullable String jsonPrepareRenameResponse, + @NotNull String expectedPlaceholder, + @NotNull String jsonRenameResponse, + @Nullable String jsonWillRenameResponse, + @NotNull String newFileName, + @NotNull String expectedRenamedText) { + assertWillRename(fileName, + text, + jsonPrepareRenameResponse, + expectedPlaceholder, + jsonRenameResponse, + jsonWillRenameResponse, + newFileName, + expectedRenamedText, + null, + true); + } + + /** + * Test LSP rename. + * + * @param fileName the file name used to match registered language servers. + * @param text the editor content text. + * @param jsonPrepareRenameResponse the emulated JSON prepare rename response and null otherwise. + * @param expectedPlaceholder the expected placeholder and null otherwise. + * @param jsonRenameResponse the emulated rename JSON rename response WorkspaceEdit and null otherwise. + * @param expectedRenamedText the expected renamed text and null otherwise. + * @param expectedError the expected error and null otherwise. + * @param waitFor wait for some ms to take some times to start the language server. + */ + private void assertWillRename(@NotNull String fileName, + @NotNull String text, + @Nullable String jsonPrepareRenameResponse, + @Nullable String expectedPlaceholder, + @Nullable String jsonRenameResponse, + @Nullable String jsonWillRenameResponse, + @NotNull String newFileName, + @Nullable String expectedRenamedText, + @Nullable String expectedError, + boolean waitFor) { + var workspace = new WorkspaceServerCapabilities(); + var fileOperations = new FileOperationsServerCapabilities(); + var willRename = new FileOperationOptions(); + willRename.setFilters(List.of(new FileOperationFilter(new FileOperationPattern("**/**")))); + fileOperations.setWillRename(willRename); + workspace.setFileOperations(fileOperations); + updateRenameCapabilities(jsonPrepareRenameResponse, workspace); + int delay = waitFor ? 100 : 750; // if not waiting for the server, make sure the server is "slow" + MockLanguageServer.INSTANCE.setTimeToProceedQueries(delay); + PsiFile file = myFixture.configureByText(fileName, text); + var baseDir = file.getVirtualFile().getParent(); + + // Prepare rename response + if (jsonPrepareRenameResponse != null) { + if (PREPARE_RENAME_NO_RESULT.equals(jsonPrepareRenameResponse)) { + // Prepare rename should return null. + MockLanguageServer.INSTANCE.setPrepareRenameProcessor(params -> null); + } else if (jsonPrepareRenameResponse.contains("code")) { + // prepare rename with error + // { + // "code": -32602, + // "message": "no identifier found" + // } + ResponseError error = JSONUtils.getLsp4jGson().fromJson(jsonPrepareRenameResponse, ResponseError.class); + MockLanguageServer.INSTANCE.setPrepareRenameProcessor(params -> { + throw new ResponseErrorException(error); + }); + } else { + // Prepare rename response + Either3 prepareRenameResponse = getPrepareRenameResponse(jsonPrepareRenameResponse); + MockLanguageServer.INSTANCE.setPrepareRenameProcessor(params -> prepareRenameResponse); + } + } + + // Rename response + if (jsonRenameResponse != null) { + if (jsonRenameResponse.contains("code")) { + // rename with error + // { + // "code": -32602, + // "message": "no identifier found" + // } + ResponseError error = JSONUtils.getLsp4jGson().fromJson(jsonRenameResponse, ResponseError.class); + MockLanguageServer.INSTANCE.setRenameProcessor(params -> { + throw new ResponseErrorException(error); + }); + } else { + // WorkspaceEdit + jsonRenameResponse = jsonRenameResponse.formatted(LSPIJUtils.toUri(baseDir), LSPIJUtils.toUri(baseDir)); + WorkspaceEdit workspaceEdit = JSONUtils.getLsp4jGson().fromJson(jsonRenameResponse, WorkspaceEdit.class); + MockLanguageServer.INSTANCE.setRenameProcessor(params -> workspaceEdit); + } + } + + // WillRename response + if (jsonWillRenameResponse != null) { + jsonWillRenameResponse = jsonWillRenameResponse.formatted(LSPIJUtils.toUri(baseDir)); + WorkspaceEdit workspaceEdit = JSONUtils.getLsp4jGson().fromJson(jsonWillRenameResponse, WorkspaceEdit.class); + MockLanguageServer.INSTANCE.setWillRename(workspaceEdit); + } + + if (waitFor) { + // As rename support works only when language server is started + // to avoid having the error "Rename... is not available during language servers starting." + // we wait for some ms. + try { + LanguageServiceAccessor.getInstance(file.getProject()) + .getLanguageServers(file, null, null) + .get(5000, TimeUnit.MILLISECONDS); + } catch (Exception e) { + e.printStackTrace(); + } + } + + final AtomicReference actualError = new AtomicReference<>(); + + var handler = new LSPRenameUnitTestMode.LSPRenameUnitTestModeHandler() { + + @Override + public void showErrorHint(String errorHintText) { + if (expectedError == null) { + Assert.assertNull("Should not have error while renaming", errorHintText); + } + actualError.set(errorHintText); + } + + @Override + public void showRenameRefactoringDialog(RenameParams renameParams) { + Assert.assertEquals("Placeholder should be equals", expectedPlaceholder, renameParams.getNewName()); + } + }; + LSPRenameUnitTestMode.set(handler); + + // Do Rename... + myFixture.performEditorAction(IdeActions.ACTION_RENAME); + + if (expectedError != null) { + assertEquals(expectedError, actualError.get()); + } else { + VirtualFile newFile = baseDir.findChild(newFileName); + var newDocument = LSPIJUtils.getDocument(newFile); + assertEquals(expectedRenamedText, newDocument.getText()); + } + } + + /** + * Test LSP rename. + * + * @param fileName the file name used to match registered language servers. + * @param text the editor content text. + * @param newFileName the new file name. + * @param jsonWillRenameResponse the emulated rename JSON will rename response WorkspaceEdit and null otherwise. + * @param expectedRenamedText the expected renamed text. + */ + protected void assertRenameFile(@NotNull String fileName, + @NotNull String text, + @Nullable String newFileName, + @Nullable String jsonWillRenameResponse, + @NotNull String expectedRenamedText) throws IOException { + var workspace = new WorkspaceServerCapabilities(); + var fileOperations = new FileOperationsServerCapabilities(); + var willRename = new FileOperationOptions(); + willRename.setFilters(List.of(new FileOperationFilter(new FileOperationPattern("**/**")))); + fileOperations.setWillRename(willRename); + workspace.setFileOperations(fileOperations); + updateRenameCapabilities(null, workspace); + + int delay = 750; // if not waiting for the server, make sure the server is "slow" + MockLanguageServer.INSTANCE.setTimeToProceedQueries(delay); + PsiFile file = myFixture.configureByText(fileName, text); + var baseDir = file.getVirtualFile().getParent(); + + // WillRename response + if (jsonWillRenameResponse != null) { + jsonWillRenameResponse = jsonWillRenameResponse.formatted(LSPIJUtils.toUri(baseDir)); + WorkspaceEdit workspaceEdit = JSONUtils.getLsp4jGson().fromJson(jsonWillRenameResponse, WorkspaceEdit.class); + MockLanguageServer.INSTANCE.setWillRename(workspaceEdit); + } + + try { + LanguageServiceAccessor.getInstance(file.getProject()) + .getLanguageServers(file, null, null) + .get(5000, TimeUnit.MILLISECONDS); + } catch (Exception e) { + e.printStackTrace(); + } + + WriteCommandAction.runWriteCommandAction(file.getProject(), () -> { + try { + file.getVirtualFile().rename(this, newFileName); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + VirtualFile newFile = baseDir.findChild(newFileName); + var newDocument = LSPIJUtils.getDocument(newFile); + assertEquals(expectedRenamedText, newDocument.getText()); + } + } diff --git a/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializerTest.java b/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializerTest.java index beeb60001..40c03d364 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializerTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerDefinitionSerializerTest.java @@ -37,6 +37,7 @@ public void testBasicUserDefinedLsSerialization() { null, "description", "./start.sh", + null, Map.of(), false, "", @@ -45,6 +46,7 @@ public void testBasicUserDefinedLsSerialization() { "", "", "", + "", ""); Gson gson = new GsonBuilder() @@ -66,6 +68,7 @@ public void testLanguageMapping() { null, "description", "./start.sh", + null, Map.of(), false, "", @@ -74,6 +77,7 @@ public void testLanguageMapping() { "", "", "", + "", ""); lsDef.getLanguageMappings().put(Language.ANY, "testing"); @@ -99,6 +103,7 @@ public void testFilePatternsMapping() { null, "description", "./start.sh", + null, Map.of(), false, "", @@ -107,6 +112,7 @@ public void testFilePatternsMapping() { "", "", "", + "", ""); FileNameMatcher fileNameMatcher1 = new ExtensionFileNameMatcher("rs"); FileNameMatcher fileNameMatcher2 = new WildcardFileNameMatcher("*kt"); @@ -139,6 +145,7 @@ public void testFileTypeMappings() { null, "description", "./start.sh", + null, Map.of(), false, "", @@ -147,6 +154,7 @@ public void testFileTypeMappings() { "", "", "", + "", ""); FileTypeManager fileTypeManager = FileTypeManager.getInstance(); FileType fileType = fileTypeManager.getFileTypeByExtension("any"); diff --git a/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManagerTest.java b/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManagerTest.java index 11e69c02e..d3fc5ec21 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManagerTest.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/launching/templates/LanguageServerTemplateManagerTest.java @@ -108,6 +108,7 @@ private UserDefinedLanguageServerDefinition createUserDefinedLanguageServerDefin null, null, "cmd", + null, new HashMap<>(), false, null, @@ -116,6 +117,7 @@ private UserDefinedLanguageServerDefinition createUserDefinedLanguageServerDefin null, null, null, + null, null); } } \ No newline at end of file diff --git a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java index a719f0ca3..3ce8ff309 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockLanguageServer.java @@ -314,22 +314,22 @@ public NotebookDocumentService getNotebookDocumentService() { return new NotebookDocumentService() { @Override public void didSave(DidSaveNotebookDocumentParams params) { - // TODO Auto-generated method stub + // No-op } @Override public void didOpen(DidOpenNotebookDocumentParams params) { - // TODO Auto-generated method stub + // No-op } @Override public void didClose(DidCloseNotebookDocumentParams params) { - // TODO Auto-generated method stub + // No-op } @Override public void didChange(DidChangeNotebookDocumentParams params) { - // TODO Auto-generated method stub + // No-op } }; } @@ -338,6 +338,10 @@ public void setFoldingRanges(List foldingRanges) { this.textDocumentService.setFoldingRanges(foldingRanges); } + public void setWillRename(WorkspaceEdit willRename) { + this.workspaceService.setWillRename(willRename); + } + @Override public String toString() { return "MockLanguageServer [started=" + started + ", delay=" + delay + ", remoteProxies=" + remoteProxies.size() diff --git a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockWorkspaceService.java b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockWorkspaceService.java index 20b735326..906e14f59 100644 --- a/src/test/java/com/redhat/devtools/lsp4ij/mock/MockWorkspaceService.java +++ b/src/test/java/com/redhat/devtools/lsp4ij/mock/MockWorkspaceService.java @@ -29,12 +29,22 @@ public class MockWorkspaceService implements WorkspaceService { private Function _futureFactory; private CompletableFuture executedCommand = new CompletableFuture<>(); private List workspaceFoldersEvents = new ArrayList<>(); + private WorkspaceEdit willRename; public MockWorkspaceService(Function> futureFactory) { this._futureFactory = futureFactory; } - /** + public void setWillRename(WorkspaceEdit willRename) { + this.willRename = willRename; + } + + @Override + public CompletableFuture willRenameFiles(RenameFilesParams params) { + return CompletableFuture.supplyAsync(() -> this.willRename); + } + + /** * Use this method to get a future that will wait specified delay before returning * value * @param value the value that will be returned by the future @@ -43,24 +53,22 @@ public MockWorkspaceService(Function> futureFactory) private CompletableFuture futureFactory(U value) { return ((Function>)this._futureFactory).apply(value); } - + @Override public CompletableFuture, List>> symbol( WorkspaceSymbolParams params) { - // TODO Auto-generated method stub + // No-op return null; } @Override public void didChangeConfiguration(DidChangeConfigurationParams params) { - // TODO Auto-generated method stub - + // No-op } @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { - // TODO Auto-generated method stub - + // No-op } @Override