diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e017dce6fed..1d5acb6e703 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -106,6 +106,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = kotlin-stdLibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } +mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } mockk = { module = "io.mockk:mockk", version.ref="mockk" } nimbus-jose-jwt = {module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimbus-jose-jwt"} @@ -121,7 +122,7 @@ zjsonpatch = { module = "com.flipkart.zjsonpatch:zjsonpatch", version.ref = "zjs [bundles] jackson = ["jackson-datetime", "jackson-kotlin", "jackson-yaml", "jackson-xml"] kotlin = ["kotlin-stdLibJdk8", "kotlin-reflect"] -mockito = ["mockito-core", "mockito-kotlin"] +mockito = ["mockito-core", "mockito-junit-jupiter", "mockito-kotlin"] sshd = ["sshd-core", "sshd-scp", "sshd-sftp"] [plugins] diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt index f3995104fd1..b85d94db10f 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt @@ -19,6 +19,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection import software.aws.toolkits.jetbrains.core.gettingstarted.emitUserState import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.amazonq.project.ProjectContextController import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow @@ -56,6 +57,7 @@ class AmazonQStartupActivity : ProjectActivity { QRegionProfileManager.getInstance().validateProfile(project) + AmazonQLspService.getInstance(project) startLsp(project) if (runOnce.get()) return emitUserState(project) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt index e0d19aa2910..50a579fedd1 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/service/CodeWhispererService.kt @@ -53,6 +53,9 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager import software.aws.toolkits.jetbrains.core.credentials.pinning.CodeWhispererConnection import software.aws.toolkits.jetbrains.services.amazonq.SUPPLEMENTAL_CONTEXT_TIMEOUT +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.GetConfigurationFromServerParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererClientAdaptor import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator @@ -92,6 +95,9 @@ import software.aws.toolkits.resources.message import software.aws.toolkits.telemetry.CodewhispererCompletionType import software.aws.toolkits.telemetry.CodewhispererSuggestionState import software.aws.toolkits.telemetry.CodewhispererTriggerType +import java.net.URI +import java.nio.file.Paths +import java.util.concurrent.CompletableFuture import java.util.concurrent.TimeUnit @Service @@ -233,7 +239,8 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { requestContext.fileContextInfo, requestContext.awaitSupplementalContext(), requestContext.customizationArn, - requestContext.profileArn + requestContext.profileArn, + requestContext.workspaceId, ) ) @@ -670,10 +677,42 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { val profileArn = QRegionProfileManager.getInstance().activeProfile(project)?.arn + var workspaceId: String? = null + try { + val workspacesInfos = getWorkspaceIds(project).get().workspaces + for (workspaceInfo in workspacesInfos) { + val workspaceRootPath = Paths.get(URI(workspaceInfo.workspaceRoot)).toString() + if (psiFile.virtualFile.path.startsWith(workspaceRootPath)) { + workspaceId = workspaceInfo.workspaceId + LOG.info { "Found workspaceId from LSP '$workspaceId'" } + break + } + } + } catch (e: Exception) { + LOG.warn { "Cannot get workspaceId from LSP'$e'" } + } return RequestContext( - project, editor, triggerTypeInfo, caretPosition, fileContext, - supplementalContext, connection, latencyContext, customizationArn, profileArn + project, + editor, + triggerTypeInfo, + caretPosition, + fileContext, + supplementalContext, + connection, + latencyContext, + customizationArn, + profileArn, + workspaceId, + ) + } + + private fun getWorkspaceIds(project: Project): CompletableFuture { + val payload = GetConfigurationFromServerParams( + section = "aws.q.workspaceContext" ) + return AmazonQLspService.executeIfRunning(project) { server -> + server.getConfigurationFromServer(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) } fun validateResponse(response: GenerateCompletionsResponse): GenerateCompletionsResponse { @@ -808,6 +847,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { supplementalContext: SupplementalContextInfo?, customizationArn: String?, profileArn: String?, + workspaceId: String?, ): GenerateCompletionsRequest { val programmingLanguage = ProgrammingLanguage.builder() .languageName(fileContextInfo.programmingLanguage.toCodeWhispererRuntimeLanguage().languageId) @@ -837,6 +877,7 @@ class CodeWhispererService(private val cs: CoroutineScope) : Disposable { .customizationArn(customizationArn) .optOutPreference(getTelemetryOptOutPreference()) .profileArn(profileArn) + .workspaceId(workspaceId) .build() } } @@ -853,6 +894,7 @@ data class RequestContext( val latencyContext: LatencyContext, val customizationArn: String?, val profileArn: String?, + val workspaceId: String?, ) { // TODO: should make the entire getRequestContext() suspend function instead of making supplemental context only var supplementalContext: SupplementalContextInfo? = null diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt index 8d035faf09a..77f901950c7 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt @@ -5,13 +5,16 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.settings import com.intellij.icons.AllIcons import com.intellij.ide.DataManager +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.BoundConfigurable import com.intellij.openapi.options.Configurable import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.options.ex.Settings import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.emptyText import com.intellij.ui.components.ActionLink import com.intellij.ui.components.fields.ExpandableTextField +import com.intellij.ui.dsl.builder.Align import com.intellij.ui.dsl.builder.bindIntText import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText @@ -24,6 +27,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWh import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import software.aws.toolkits.jetbrains.settings.LspSettings import software.aws.toolkits.resources.message import java.awt.Font import java.util.concurrent.TimeUnit @@ -61,6 +65,24 @@ class CodeWhispererConfigurable(private val project: Project) : } } + group(message("amazonqFeatureDev.placeholder.lsp")) { + row(message("amazonqFeatureDev.placeholder.select_lsp_artifact")) { + val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor() + fileChooserDescriptor.isForcedToUseIdeaFileChooser = true + + textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor) + .bindText( + { LspSettings.getInstance().getArtifactPath().orEmpty() }, + { LspSettings.getInstance().setArtifactPath(it) } + ) + .applyToComponent { + emptyText.text = message("executableCommon.auto_managed") + } + .resizableColumn() + .align(Align.FILL) + } + } + group(message("aws.settings.codewhisperer.group.general")) { row { checkBox(message("aws.settings.codewhisperer.include_code_with_reference")).apply { @@ -116,6 +138,20 @@ class CodeWhispererConfigurable(private val project: Project) : } group(message("aws.settings.codewhisperer.group.q_chat")) { + row { + checkBox(message("aws.settings.codewhisperer.workspace_context")).apply { + connect.subscribe( + ToolkitConnectionManagerListener.TOPIC, + object : ToolkitConnectionManagerListener { + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + enabled(isCodeWhispererEnabled(project)) + } + } + ) + enabled(invoke) + bindSelected(codeWhispererSettings::isWorkspaceContextEnabled, codeWhispererSettings::toggleWorkspaceContextEnabled) + }.comment(message("aws.settings.codewhisperer.workspace_context.tooltip")) + }.visible(false) row { checkBox(message("aws.settings.codewhisperer.project_context")).apply { connect.subscribe( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt index 1d959b50c57..bf3db6cdf29 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererCodeCoverageTrackerTest.kt @@ -177,7 +177,8 @@ internal class CodeWhispererCodeCoverageTrackerTestPython : CodeWhispererCodeCov null, mock(), aString(), - aString() + aString(), + aString(), ) val responseContext = ResponseContext("sessionId") val recommendationContext = RecommendationContext( diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt index 5224284b42a..67aff7a14e3 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererConfigurableTest.kt @@ -39,17 +39,18 @@ class CodeWhispererConfigurableTest : CodeWhispererTestBase() { val checkboxes = panel.components.filterIsInstance() - assertThat(checkboxes.size).isEqualTo(5) + assertThat(checkboxes.size).isEqualTo(6) assertThat(checkboxes.map { it.text }).containsExactlyInAnyOrder( message("aws.settings.codewhisperer.include_code_with_reference"), message("aws.settings.codewhisperer.configurable.opt_out.title"), message("aws.settings.codewhisperer.automatic_import_adder"), + "Workspace context", message("aws.settings.codewhisperer.project_context"), message("aws.settings.codewhisperer.project_context_gpu") ) val comments = panel.components.filterIsInstance() - assertThat(comments.size).isEqualTo(8) + assertThat(comments.size).isEqualTo(9) mockCodeWhispererEnabledStatus(false) ApplicationManager.getApplication().messageBus.syncPublisher(ToolkitConnectionManagerListener.TOPIC) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt index 962806f6327..4ac3f45eddd 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererServiceTest.kt @@ -213,7 +213,8 @@ class CodeWhispererServiceTest { connection = ToolkitConnectionManager.getInstance(projectRule.project).activeConnection(), latencyContext = LatencyContext(), customizationArn = "fake-arn", - profileArn = "fake-arn" + profileArn = "fake-arn", + workspaceId = null, ) ) diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt index 643831a96a2..84263c62ad2 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt @@ -13,10 +13,14 @@ import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager import com.intellij.testFramework.replaceService import com.intellij.testFramework.runInEdtAndWait import com.intellij.util.xmlb.XmlSerializer +import io.mockk.every +import io.mockk.junit4.MockKRule +import io.mockk.mockkObject import org.assertj.core.api.Assertions.assertThat import org.jdom.output.XMLOutputter import org.junit.Before import org.junit.Ignore +import org.junit.Rule import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.never @@ -24,6 +28,7 @@ import org.mockito.kotlin.spy import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreActionState import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled @@ -40,6 +45,9 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { private lateinit var codewhispererServiceSpy: CodeWhispererService private lateinit var toolWindowHeadlessManager: ToolWindowHeadlessManagerImpl + @get:Rule + val mockkRule = MockKRule(this) + @Before override fun setUp() { super.setUp() @@ -246,6 +254,18 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() { assertThat(sut.getProjectContextIndexMaxSize()).isEqualTo(expected) } } + + @Test + fun `toggleMetricOptIn should trigger LSP didChangeConfiguration`() { + mockkObject(AmazonQLspService) + every { AmazonQLspService.didChangeConfiguration(any()) } returns Unit + settingsManager.toggleMetricOptIn(true) + settingsManager.toggleMetricOptIn(false) + + io.mockk.verify(atLeast = 2) { + AmazonQLspService.didChangeConfiguration(any()) + } + } } class CodeWhispererSettingUnitTest { diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt index 172afa6fcf9..1178a7ff967 100644 --- a/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt +++ b/plugins/amazonq/codewhisperer/jetbrains-community/tstFixtures/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererTestUtil.kt @@ -258,7 +258,8 @@ fun aRequestContext( aString() ), customizationArn = null, - profileArn = null + profileArn = null, + workspaceId = null, ) } diff --git a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml index 6336e3526d6..95bb5c886e2 100644 --- a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml +++ b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml @@ -9,4 +9,5 @@ + diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt new file mode 100644 index 00000000000..8932881568f --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt @@ -0,0 +1,18 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageClient +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import java.util.concurrent.CompletableFuture + +/** + * Requests sent by server to client + */ +@Suppress("unused") +interface AmazonQLanguageClient : LanguageClient { + @JsonRequest("aws/credentials/getConnectionMetadata") + fun getConnectionMetadata(): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt new file mode 100644 index 00000000000..50b1be3626d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -0,0 +1,96 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.intellij.notification.NotificationType +import com.intellij.openapi.project.Project +import org.eclipse.lsp4j.ConfigurationParams +import org.eclipse.lsp4j.MessageActionItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.ShowMessageRequestParams +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings +import java.util.concurrent.CompletableFuture + +/** + * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server + */ +class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageClient { + override fun telemetryEvent(`object`: Any) { + println(`object`) + } + + override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { + println(diagnostics) + } + + override fun showMessage(messageParams: MessageParams) { + val type = when (messageParams.type) { + MessageType.Error -> NotificationType.ERROR + MessageType.Warning -> NotificationType.WARNING + MessageType.Info, MessageType.Log -> NotificationType.INFORMATION + } + println("$type: ${messageParams.message}") + } + + override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture? { + println(requestParams) + + return CompletableFuture.completedFuture(null) + } + + override fun logMessage(message: MessageParams) { + showMessage(message) + } + + override fun getConnectionMetadata(): CompletableFuture = + CompletableFuture.supplyAsync { + val connection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + + when (connection) { + is AwsBearerTokenConnection -> { + ConnectionMetadata( + SsoProfileData(connection.startUrl) + ) + } + else -> { + // If no connection or not a bearer token connection return default builderID start url + ConnectionMetadata( + SsoProfileData(AmazonQLspConstants.AWS_BUILDER_ID_URL) + ) + } + } + } + + override fun configuration(params: ConfigurationParams): CompletableFuture> { + if (params.items.isEmpty()) { + return CompletableFuture.completedFuture(null) + } + + return CompletableFuture.completedFuture( + buildList { + params.items.forEach { + when (it.section) { + AmazonQLspConstants.LSP_CW_CONFIGURATION_KEY -> { + add( + CodeWhispererLspConfiguration( + shouldShareData = CodeWhispererSettings.getInstance().isMetricOptIn(), + shouldShareCodeReferences = CodeWhispererSettings.getInstance().isIncludeCodeWithReference(), + shouldEnableWorkspaceContext = CodeWhispererSettings.getInstance().isWorkspaceContextEnabled() + ) + ) + } + } + } + } + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt new file mode 100644 index 00000000000..2396e273f18 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt @@ -0,0 +1,36 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest +import org.eclipse.lsp4j.services.LanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.GetConfigurationFromServerParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.UpdateConfigurationParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +/** + * Remote interface exposed by the Amazon Q language server + */ +@Suppress("unused") +interface AmazonQLanguageServer : LanguageServer { + @JsonNotification("aws/didChangeDependencyPaths") + fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture + + @JsonRequest("aws/credentials/token/update") + fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture + + @JsonNotification("aws/credentials/token/delete") + fun deleteTokenCredentials(): CompletableFuture + + @JsonRequest("aws/getConfigurationFromServer") + fun getConfigurationFromServer(params: GetConfigurationFromServerParams): CompletableFuture + + @JsonRequest("aws/updateConfiguration") + fun updateConfiguration(params: UpdateConfigurationParams): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt new file mode 100644 index 00000000000..ca8fffcbb51 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt @@ -0,0 +1,12 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +object AmazonQLspConstants { + const val AWS_BUILDER_ID_URL = "https://view.awsapps.com/start" + const val LSP_CW_CONFIGURATION_KEY = "aws.codeWhisperer" + const val LSP_CW_OPT_OUT_KEY = "shareCodeWhispererContentWithAWS" + const val LSP_CODE_REFERENCES_OPT_OUT_KEY = "includeSuggestionsWithCodeReferences" + const val LSP_WORKSPACE_CONTEXT_ENABLED_KEY = "workspaceContext" +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt new file mode 100644 index 00000000000..59658c3a878 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -0,0 +1,352 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.google.gson.ToNumberPolicy +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.impl.ExecutionManagerImpl +import com.intellij.execution.process.KillableColoredProcessHandler +import com.intellij.execution.process.KillableProcessHandler +import com.intellij.execution.process.ProcessEvent +import com.intellij.execution.process.ProcessListener +import com.intellij.execution.process.ProcessOutputType +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.SystemInfo +import com.intellij.util.io.await +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import org.eclipse.lsp4j.ClientCapabilities +import org.eclipse.lsp4j.ClientInfo +import org.eclipse.lsp4j.DidChangeConfigurationParams +import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities +import org.eclipse.lsp4j.InitializeParams +import org.eclipse.lsp4j.InitializeResult +import org.eclipse.lsp4j.InitializedParams +import org.eclipse.lsp4j.SynchronizationCapabilities +import org.eclipse.lsp4j.TextDocumentClientCapabilities +import org.eclipse.lsp4j.WorkspaceClientCapabilities +import org.eclipse.lsp4j.jsonrpc.Launcher +import org.eclipse.lsp4j.launch.LSPLauncher +import org.slf4j.event.Level +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.isDeveloperMode +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDocumentServiceHandler +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders +import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata +import software.aws.toolkits.jetbrains.settings.LspSettings +import java.io.IOException +import java.io.OutputStreamWriter +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.charset.StandardCharsets +import java.util.concurrent.Future +import kotlin.time.Duration.Companion.seconds + +// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java +// JB impl and redhat both use a wrapper to handle input buffering issue +internal class LSPProcessListener : ProcessListener { + private val outputStream = PipedOutputStream() + private val outputStreamWriter = OutputStreamWriter(outputStream, StandardCharsets.UTF_8) + val inputStream = PipedInputStream(outputStream) + + override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) { + if (ProcessOutputType.isStdout(outputType)) { + try { + this.outputStreamWriter.write(event.text) + this.outputStreamWriter.flush() + } catch (_: IOException) { + ExecutionManagerImpl.stopProcess(event.processHandler) + } + } else if (ProcessOutputType.isStderr(outputType)) { + LOG.warn { "LSP process stderr: ${event.text}" } + } + } + + override fun processTerminated(event: ProcessEvent) { + try { + this.outputStreamWriter.close() + this.outputStream.close() + } catch (_: IOException) { + } + } + + companion object { + private val LOG = getLogger() + } +} + +@Service(Service.Level.PROJECT) +class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable { + private var instance: Deferred + val capabilities + get() = instance.getCompleted().initializeResult.getCompleted().capabilities + + // dont allow lsp commands if server is restarting + private val mutex = Mutex(false) + + private fun start() = cs.async { + // manage lifecycle RAII-like so we can restart at arbitrary time + // and suppress IDE error if server fails to start + var attempts = 0 + while (attempts < 3) { + try { + return@async withTimeout(30.seconds) { + val instance = AmazonQServerInstance(project, cs).also { + Disposer.register(this@AmazonQLspService, it) + } + // wait for handshake to complete + instance.initializeResult.join() + + instance + } + } catch (e: Exception) { + LOG.warn(e) { "Failed to start LSP server" } + } + attempts++ + } + + error("Failed to start LSP server in 3 attempts") + } + + init { + instance = start() + } + + override fun dispose() { + } + + suspend fun restart() = mutex.withLock { + // stop if running + instance.let { + if (it.isActive) { + // not even running yet + return + } + + try { + val i = it.await() + if (i.initializeResult.isActive) { + // not initialized + return + } + + Disposer.dispose(i) + } catch (e: Exception) { + LOG.info(e) { "Exception while disposing LSP server" } + } + } + + instance = start() + } + + suspend fun execute(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T { + val lsp = withTimeout(10.seconds) { + val holder = mutex.withLock { instance }.await() + holder.initializeResult.join() + + holder.languageServer + } + return runnable(lsp) + } + + fun executeSync(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T = + runBlocking(cs.coroutineContext) { + execute(runnable) + } + + companion object { + private val LOG = getLogger() + fun getInstance(project: Project) = project.service() + + fun executeIfRunning(project: Project, runnable: AmazonQLspService.(AmazonQLanguageServer) -> T): T? = + project.serviceIfCreated()?.executeSync(runnable) + + fun didChangeConfiguration(project: Project) { + executeIfRunning(project) { + it.workspaceService.didChangeConfiguration(DidChangeConfigurationParams()) + } + } + } +} + +private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable { + private val encryptionManager = JwtEncryptionManager() + + private val launcher: Launcher + + val languageServer: AmazonQLanguageServer + get() = launcher.remoteProxy + + @Suppress("ForbiddenVoid") + private val launcherFuture: Future + private val launcherHandler: KillableProcessHandler + val initializeResult: Deferred + + private fun createClientCapabilities(): ClientCapabilities = + ClientCapabilities().apply { + textDocument = TextDocumentClientCapabilities().apply { + // For didSaveTextDocument, other textDocument/ messages always mandatory + synchronization = SynchronizationCapabilities().apply { + didSave = true + } + } + + workspace = WorkspaceClientCapabilities().apply { + applyEdit = false + + // For workspace folder changes + workspaceFolders = true + + // For file operations (create, delete) + fileOperations = FileOperationsWorkspaceCapabilities().apply { + didCreate = true + didDelete = true + didRename = true + } + } + } + + private fun createClientInfo(): ClientInfo { + val metadata = ClientMetadata.getDefault() + return ClientInfo().apply { + name = metadata.awsProduct.toString() + version = metadata.awsVersion + } + } + + private fun createInitializeParams(): InitializeParams = + InitializeParams().apply { + processId = ProcessHandle.current().pid().toInt() + capabilities = createClientCapabilities() + clientInfo = createClientInfo() + workspaceFolders = createWorkspaceFolders(project) + initializationOptions = createExtendedClientMetadata() + } + + init { + // will cause slow service init, but maybe fine for now. will not block UI since fetch/extract will be under background progress + val artifact = runBlocking { ArtifactManager(project, manifestRange = null).fetchArtifact() }.toAbsolutePath() + val node = if (SystemInfo.isWindows) "node.exe" else "node" + val cmd = GeneralCommandLine( + artifact.resolve(node).toString(), + LspSettings.getInstance().getArtifactPath() ?: artifact.resolve("aws-lsp-codewhisperer.js").toString(), + "--stdio", + "--set-credentials-encryption-key", + ) + + launcherHandler = KillableColoredProcessHandler.Silent(cmd) + val inputWrapper = LSPProcessListener() + launcherHandler.addProcessListener(inputWrapper) + launcherHandler.startNotify() + + launcher = LSPLauncher.Builder() + .setLocalService(AmazonQLanguageClientImpl(project)) + .setRemoteInterface(AmazonQLanguageServer::class.java) + .configureGson { + // TODO: maybe need adapter for initialize: + // https://github.com/aws/amazon-q-eclipse/blob/b9d5bdcd5c38e1dd8ad371d37ab93a16113d7d4b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QLspTypeAdapterFactory.java + + // otherwise Gson treats all numbers as double which causes deser issues + it.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + }.traceMessages( + PrintWriter( + object : StringWriter() { + private val traceLogger = LOG.atLevel(if (isDeveloperMode()) Level.INFO else Level.DEBUG) + + override fun flush() { + traceLogger.log { buffer.toString() } + buffer.setLength(0) + } + } + ) + ) + .setInput(inputWrapper.inputStream) + .setOutput(launcherHandler.process.outputStream) + .create() + + launcherFuture = launcher.startListening() + + initializeResult = cs.async { + // encryption info must be sent within 5s or Flare process will exit + encryptionManager.writeInitializationPayload(launcherHandler.process.outputStream) + + val initializeResult = try { + withTimeout(5.seconds) { + languageServer.initialize(createInitializeParams()).await() + } + } catch (_: TimeoutCancellationException) { + LOG.warn { "LSP initialization timed out" } + null + } catch (e: Exception) { + LOG.warn(e) { "LSP initialization failed" } + null + } + + // then if this succeeds then we can allow the client to send requests + if (initializeResult == null) { + launcherHandler.destroyProcess() + error("LSP initialization failed") + } + languageServer.initialized(InitializedParams()) + + initializeResult + } + + // invokeOnCompletion results in weird lock/timeout error + initializeResult.asCompletableFuture().handleAsync { r, ex -> + if (ex != null) { + return@handleAsync + } + + this@AmazonQServerInstance.apply { + DefaultAuthCredentialsService(project, encryptionManager, this) + TextDocumentServiceHandler(project, this) + WorkspaceServiceHandler(project, this) + DefaultModuleDependenciesService(project, this) + } + } + } + + override fun dispose() { + if (!launcherFuture.isDone) { + try { + languageServer.apply { + shutdown().thenRun { exit() } + } + } catch (e: Exception) { + LOG.warn(e) { "LSP shutdown failed" } + launcherHandler.destroyProcess() + } + } else if (!launcherHandler.isProcessTerminated) { + launcherHandler.destroyProcess() + } + } + + companion object { + private val LOG = getLogger() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt new file mode 100644 index 00000000000..d54acf55fbe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt @@ -0,0 +1,17 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.google.gson.annotations.SerializedName + +data class CodeWhispererLspConfiguration( + @SerializedName(AmazonQLspConstants.LSP_CW_OPT_OUT_KEY) + val shouldShareData: Boolean? = null, + + @SerializedName(AmazonQLspConstants.LSP_WORKSPACE_CONTEXT_ENABLED_KEY) + val shouldEnableWorkspaceContext: Boolean? = null, + + @SerializedName(AmazonQLspConstants.LSP_CODE_REFERENCES_OPT_OUT_KEY) + val shouldShareCodeReferences: Boolean? = null, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt new file mode 100644 index 00000000000..8787259bf08 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt @@ -0,0 +1,215 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.util.io.createDirectories +import com.intellij.util.text.SemVer +import kotlinx.coroutines.CancellationException +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import software.aws.toolkits.resources.AwsCoreBundle +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger + +class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) { + + companion object { + private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers") + private val logger = getLogger() + private const val MAX_DOWNLOAD_ATTEMPTS = 3 + } + private val currentAttempt = AtomicInteger(0) + + fun removeDelistedVersions(delistedVersions: List) { + val localFolders = getSubFolders(lspArtifactsPath) + + delistedVersions.forEach { delistedVersion -> + val versionToDelete = delistedVersion.serverVersion ?: return@forEach + + localFolders + .filter { folder -> folder.fileName.toString() == versionToDelete } + .forEach { folder -> + try { + folder.toFile().deleteRecursively() + logger.info { "Successfully deleted deListed version: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" } + } + } + } + } + + fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) { + val validVersions = getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + + // Keep the latest 2 versions, delete others + validVersions.drop(2).forEach { (folder, _) -> + try { + folder.toFile().deleteRecursively() + logger.info { "Deleted older LSP artifact: ${folder.fileName}" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" } + } + } + } + + fun getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange): List> { + val localFolders = getSubFolders(lspArtifactsPath) + + return localFolders + .mapNotNull { localFolder -> + SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer -> + if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) { + localFolder to semVer + } else { + null + } + } + } + .sortedByDescending { (_, semVer) -> semVer } + } + + fun getExistingLspArtifacts(versions: List, target: ManifestManager.VersionTarget?): Boolean { + if (versions.isEmpty() || target?.contents == null) return false + + val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + if (!localLSPPath.exists()) return false + + val hasInvalidFiles = target.contents.any { content -> + content.filename?.let { filename -> + val filePath = localLSPPath.resolve(filename) + !filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull()) + } ?: false + } + + if (hasInvalidFiles) { + try { + localLSPPath.toFile().deleteRecursively() + logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" } + } catch (e: Exception) { + logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" } + } + } + return !hasInvalidFiles + } + + suspend fun tryDownloadLspArtifacts(project: Project, versions: List, target: ManifestManager.VersionTarget?): Path? { + val temporaryDownloadPath = lspArtifactsPath.resolve("temp") + val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString()) + + while (currentAttempt.get() < maxDownloadAttempts) { + currentAttempt.incrementAndGet() + logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" } + + try { + return withBackgroundProgress( + project, + AwsCoreBundle.message("amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts"), + cancellable = true + ) { + if (downloadLspArtifacts(temporaryDownloadPath, target) && target != null && !target.contents.isNullOrEmpty()) { + moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath) + target.contents + .mapNotNull { it.filename } + .forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) } + logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" } + + return@withBackgroundProgress downloadPath + } + + return@withBackgroundProgress null + } + } catch (e: Exception) { + when (e) { + is CancellationException -> { + logger.error(e) { "User cancelled download and extracting of LSP artifacts.." } + currentAttempt.set(maxDownloadAttempts) // To exit the while loop. + } + else -> { logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } } + } + temporaryDownloadPath.toFile().deleteRecursively() + downloadPath.toFile().deleteRecursively() + } + } + logger.error { "Failed to download LSP artifacts after $maxDownloadAttempts attempts" } + return null + } + + @VisibleForTesting + internal fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean { + if (target == null || target.contents.isNullOrEmpty()) { + logger.warn { "No target contents available for download" } + return false + } + try { + downloadPath.createDirectories() + target.contents.forEach { content -> + if (content.url == null || content.filename == null) { + logger.warn { "Missing URL or filename in content" } + return@forEach + } + val filePath = downloadPath.resolve(content.filename) + val contentHash = content.hashes?.firstOrNull() ?: run { + logger.warn { "No hash available for ${content.filename}" } + return@forEach + } + downloadAndValidateFile(content.url, filePath, contentHash) + } + validateDownloadedFiles(downloadPath, target.contents) + } catch (e: Exception) { + logger.error(e) { "Failed to download LSP artifacts: ${e.message}" } + downloadPath.toFile().deleteRecursively() + return false + } + return true + } + + private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) { + try { + if (!filePath.exists()) { + logger.info { "Downloading file: ${filePath.fileName}" } + saveFileFromUrl(url, filePath) + } + if (!validateFileHash(filePath, expectedHash)) { + logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" } + filePath.deleteIfExists() + saveFileFromUrl(url, filePath) + if (!validateFileHash(filePath, expectedHash)) { + throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH) + } + } + } catch (e: Exception) { + throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e) + } + } + + @VisibleForTesting + internal fun validateFileHash(filePath: Path, expectedHash: String?): Boolean { + if (expectedHash == null) return false + val contentHash = generateSHA384Hash(filePath) + return "sha384:$contentHash" == expectedHash + } + + private fun validateDownloadedFiles(downloadPath: Path, contents: List) { + val missingFiles = contents + .mapNotNull { it.filename } + .filter { filename -> + !downloadPath.resolve(filename).exists() + } + if (missingFiles.isNotEmpty()) { + val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}" + logger.error { errorMessage } + throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt new file mode 100644 index 00000000000..b74bbde2886 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt @@ -0,0 +1,108 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.util.text.SemVer +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +class ArtifactManager( + private val project: Project, + private val manifestFetcher: ManifestFetcher = ManifestFetcher(), + private val artifactHelper: ArtifactHelper = ArtifactHelper(), + manifestRange: SupportedManifestVersionRange?, +) { + + data class SupportedManifestVersionRange( + val startVersion: SemVer, + val endVersion: SemVer, + ) + data class LSPVersions( + val deListedVersions: List, + val inRangeVersions: List, + ) + + private val manifestVersionRanges: SupportedManifestVersionRange = manifestRange ?: DEFAULT_VERSION_RANGE + + companion object { + private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + private val logger = getLogger() + } + + suspend fun fetchArtifact(): Path { + val manifest = manifestFetcher.fetch() ?: throw LspException( + "Language Support is not available, as manifest is missing.", + LspException.ErrorCode.MANIFEST_FETCH_FAILED + ) + val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest) + + this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions) + + if (lspVersions.inRangeVersions.isEmpty()) { + // No versions are found which are in the given range. Fallback to local lsp artifacts. + val localLspArtifacts = this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + if (localLspArtifacts.isNotEmpty()) { + return localLspArtifacts.first().first + } + throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) + } + + // If there is an LSP Manifest with the same version + val target = getTargetFromLspManifest(lspVersions.inRangeVersions) + // Get Local LSP files and check if we can re-use existing LSP Artifacts + val artifactPath: Path = if (this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) { + this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges).first().first + } else { + this.artifactHelper.tryDownloadLspArtifacts(project, lspVersions.inRangeVersions, target) + ?: throw LspException("Failed to download LSP artifacts", LspException.ErrorCode.DOWNLOAD_FAILED) + } + this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + return artifactPath + } + + @VisibleForTesting + internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions { + if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList()) + + val (deListed, inRange) = manifest.versions.mapNotNull { version -> + version.serverVersion?.let { serverVersion -> + SemVer.parseFromText(serverVersion)?.let { semVer -> + when { + version.isDelisted != false -> Pair(version, true) // Is deListed + semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range + else -> null + } + } + } + }.partition { it.second } + + return LSPVersions( + deListedVersions = deListed.map { it.first }, + inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer } + ) + } + + private fun getTargetFromLspManifest(versions: List): ManifestManager.VersionTarget { + val currentOS = getCurrentOS() + val currentArchitecture = getCurrentArchitecture() + + val currentTarget = versions.first().targets?.find { target -> + target.platform == currentOS && target.arch == currentArchitecture + } + if (currentTarget == null) { + logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" } + throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND) + } + logger.info { "Target found in the current Version: ${versions.first().serverVersion}" } + return currentTarget + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt new file mode 100644 index 00000000000..110acd14b5d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt @@ -0,0 +1,21 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) { + + enum class ErrorCode { + MANIFEST_FETCH_FAILED, + DOWNLOAD_FAILED, + HASH_MISMATCH, + TARGET_NOT_FOUND, + NO_COMPATIBLE_LSP_VERSION, + UNZIP_FAILED, + } + + override fun toString(): String = buildString { + append("LSP Error [$errorCode]: $message") + cause?.let { append(", Cause: ${it.message}") } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt new file mode 100644 index 00000000000..7724a8c2255 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt @@ -0,0 +1,101 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.text.StringUtil +import com.intellij.util.io.DigestUtil +import com.intellij.util.system.CpuArch +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX +import software.aws.toolkits.core.utils.createParentDirectories +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.hasPosixFilePermissions +import java.io.FileNotFoundException +import java.net.URI +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries + +fun getToolkitsCommonCacheRoot(): Path = when { + SystemInfo.isWindows -> { + Paths.get(System.getenv("LOCALAPPDATA")) + } + SystemInfo.isMac -> { + Paths.get(System.getProperty("user.home"), "Library", "Caches") + } + else -> { + Paths.get(System.getProperty("user.home"), ".cache") + } +} + +fun getCurrentOS(): String = when { + SystemInfo.isWindows -> "windows" + SystemInfo.isMac -> "darwin" + else -> "linux" +} + +fun getCurrentArchitecture() = when (CpuArch.CURRENT) { + CpuArch.X86_64 -> "x64" + CpuArch.ARM64 -> "arm64" + else -> "unknown" +} + +fun generateMD5Hash(filePath: Path): String { + val messageDigest = DigestUtil.md5() + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun generateSHA384Hash(filePath: Path): String { + val messageDigest = MessageDigest.getInstance("SHA-384") + DigestUtil.updateContentHash(messageDigest, filePath) + return StringUtil.toHexString(messageDigest.digest()) +} + +fun getSubFolders(basePath: Path): List = try { + basePath.listDirectoryEntries() + .filter { it.isDirectory() } +} catch (e: Exception) { + emptyList() +} + +fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) { + try { + Files.createDirectories(targetDir.parent) + Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING) + } catch (e: Exception) { + throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e) + } +} + +fun extractZipFile(zipFilePath: Path, destDir: Path) { + if (!zipFilePath.exists()) { + throw FileNotFoundException("Zip file not found: $zipFilePath") + } + + try { + FileSystems.newFileSystem( + // jar prefix due to potentially ambiguous resolution to wrong fs impl for zipfs on windows + URI("jar:${zipFilePath.toUri()}"), + mapOf(ZIP_PROPERTY_POSIX to destDir.hasPosixFilePermissions()) + ).use { zipfs -> + Files.walk(zipfs.getPath("/")).use { paths -> + paths + .filter { !it.isDirectory() } + .forEach { zipEntry -> + val destPath = Paths.get(destDir.toString(), zipEntry.toString()) + destPath.createParentDirectories() + Files.copy(zipEntry, destPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES) + } + } + } + } catch (e: Exception) { + throw LspException("Failed to extract zip file: ${e.message}", LspException.ErrorCode.UNZIP_FAILED, cause = e) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt new file mode 100644 index 00000000000..74656d5665b --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt @@ -0,0 +1,115 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import org.jetbrains.annotations.VisibleForTesting +import software.aws.toolkits.core.utils.deleteIfExists +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info +import software.aws.toolkits.core.utils.readText +import software.aws.toolkits.jetbrains.core.getETagFromUrl +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.core.saveFileFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +class ManifestFetcher( + private val lspManifestUrl: String = DEFAULT_MANIFEST_URL, + private val manifestManager: ManifestManager = ManifestManager(), + private val manifestPath: Path = DEFAULT_MANIFEST_PATH, +) { + companion object { + private val logger = getLogger() + + private const val DEFAULT_MANIFEST_URL = + "https://aws-toolkit-language-servers.amazonaws.com/remoteWorkspaceContext/0/manifest.json" + + private val DEFAULT_MANIFEST_PATH: Path = getToolkitsCommonCacheRoot() + .resolve("aws") + .resolve("toolkits") + .resolve("language-servers") + .resolve("jetbrains-lsp-manifest.json") + } + + @get:VisibleForTesting + internal val lspManifestFilePath: Path + get() = manifestPath + + /** + * Method which will be used to fetch latest manifest. + * */ + fun fetch(): ManifestManager.Manifest? { + val localManifest = fetchManifestFromLocal() + if (localManifest != null) { + return localManifest + } + return fetchManifestFromRemote() + } + + @VisibleForTesting + internal fun fetchManifestFromRemote(): ManifestManager.Manifest? { + val manifest: ManifestManager.Manifest? + try { + val manifestString = getTextFromUrl(lspManifestUrl) + manifest = manifestManager.readManifestFile(manifestString) ?: return null + } catch (e: Exception) { + logger.error(e) { "error fetching lsp manifest from remote URL ${e.message}" } + return null + } + if (manifest.isManifestDeprecated == true) { + logger.info { "Manifest is deprecated" } + return null + } + updateManifestCache() + logger.info { "Using manifest found from remote URL" } + return manifest + } + + private fun updateManifestCache() { + try { + saveFileFromUrl(lspManifestUrl, lspManifestFilePath) + } catch (e: Exception) { + logger.error(e) { "error occurred while saving lsp manifest to local cache ${e.message}" } + } + } + + @VisibleForTesting + internal fun fetchManifestFromLocal(): ManifestManager.Manifest? { + val localETag = getManifestETagFromLocal() + val remoteETag = getManifestETagFromUrl() + // If local and remote have same ETag, we can re-use the manifest file from local to fetch artifacts. + // If remote manifest is null or system is offline, re-use localManifest + if ((localETag != null && remoteETag != null && localETag == remoteETag) or (localETag != null && remoteETag == null)) { + try { + val manifestContent = lspManifestFilePath.readText() + val manifest = manifestManager.readManifestFile(manifestContent) + if (manifest != null) return manifest + lspManifestFilePath.deleteIfExists() // delete manifest if it fails to de-serialize + } catch (e: Exception) { + logger.error(e) { "error reading lsp manifest file from local ${e.message}" } + return null + } + } + return null + } + + private fun getManifestETagFromLocal(): String? { + if (lspManifestFilePath.exists()) { + return generateMD5Hash(lspManifestFilePath) + } + return null + } + + private fun getManifestETagFromUrl(): String? { + try { + val actualETag = getETagFromUrl(lspManifestUrl) + return actualETag.trim('"') + } catch (e: Exception) { + logger.error(e) { "error fetching ETag of lsp manifest from url." } + } + return null + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt new file mode 100644 index 00000000000..a38c8da4bbc --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt @@ -0,0 +1,12 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth + +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import java.util.concurrent.CompletableFuture + +interface AuthCredentialsService { + fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture + fun deleteTokenCredentials(): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt new file mode 100644 index 00000000000..d3a99a1f4fe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt @@ -0,0 +1,137 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManagerListener +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.LspServerConfigurations +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.UpdateConfigurationParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.BearerCredentials +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayloadData +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileManager +import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfileSelectedListener +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import java.util.concurrent.CompletableFuture + +class DefaultAuthCredentialsService( + private val project: Project, + private val encryptionManager: JwtEncryptionManager, + serverInstance: Disposable, +) : AuthCredentialsService, + BearerTokenProviderListener, + ToolkitConnectionManagerListener, + QRegionProfileSelectedListener { + + init { + project.messageBus.connect(serverInstance).apply { + subscribe(BearerTokenProviderListener.TOPIC, this@DefaultAuthCredentialsService) + subscribe(ToolkitConnectionManagerListener.TOPIC, this@DefaultAuthCredentialsService) + subscribe(QRegionProfileSelectedListener.TOPIC, this@DefaultAuthCredentialsService) + } + + if (isQConnected(project) && !isQExpired(project)) { + updateTokenFromActiveConnection() + .thenRun { + updateConfiguration() + } + } + } + + override fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture { + val payload = createUpdateCredentialsPayload(accessToken, encrypted) + + return AmazonQLspService.executeIfRunning(project) { server -> + server.updateTokenCredentials(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + } + + override fun deleteTokenCredentials(): CompletableFuture = + CompletableFuture().also { completableFuture -> + AmazonQLspService.executeIfRunning(project) { server -> + server.deleteTokenCredentials() + completableFuture.complete(null) + } ?: completableFuture.completeExceptionally(IllegalStateException("LSP Server not running")) + } + + override fun onChange(providerId: String, newScopes: List?) { + updateTokenFromActiveConnection() + } + + override fun activeConnectionChanged(newConnection: ToolkitConnection?) { + val qConnection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + ?: return + if (newConnection?.id != qConnection.id) return + + updateTokenFromConnection(newConnection) + } + + private fun updateTokenFromActiveConnection(): CompletableFuture { + val connection = ToolkitConnectionManager.getInstance(project) + .activeConnectionForFeature(QConnection.getInstance()) + ?: return CompletableFuture.failedFuture(IllegalStateException("No active Q connection")) + + return updateTokenFromConnection(connection) + } + + private fun updateTokenFromConnection(connection: ToolkitConnection): CompletableFuture = + (connection.getConnectionSettings() as? TokenConnectionSettings) + ?.tokenProvider + ?.delegate + ?.let { it as? BearerTokenProvider } + ?.currentToken() + ?.accessToken + ?.let { token -> updateTokenCredentials(token, true) } + ?: CompletableFuture.failedFuture(IllegalStateException("Unable to get token from connection")) + + override fun invalidate(providerId: String) { + deleteTokenCredentials() + } + + private fun createUpdateCredentialsPayload(token: String, encrypted: Boolean): UpdateCredentialsPayload = + if (encrypted) { + UpdateCredentialsPayload( + data = encryptionManager.encrypt( + UpdateCredentialsPayloadData( + BearerCredentials(token) + ) + ), + encrypted = true + ) + } else { + UpdateCredentialsPayload( + data = token, + encrypted = false + ) + } + + override fun onProfileSelected(project: Project, profile: QRegionProfile?) { + updateConfiguration() + } + + private fun updateConfiguration(): CompletableFuture { + val payload = UpdateConfigurationParams( + section = "aws.q", + settings = mapOf( + "profileArn" to QRegionProfileManager.getInstance().activeProfile(project)?.arn + ) + ) + return AmazonQLspService.executeIfRunning(project) { server -> + server.updateConfiguration(payload) + } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt new file mode 100644 index 00000000000..80239696d14 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt @@ -0,0 +1,52 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import com.intellij.openapi.Disposable +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.openapi.roots.ModuleRootListener +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +class DefaultModuleDependenciesService( + private val project: Project, + serverInstance: Disposable, +) : ModuleDependenciesService, + ModuleRootListener { + + init { + project.messageBus.connect(serverInstance).subscribe( + ModuleRootListener.TOPIC, + this + ) + // project initiation with initial list of dependencies + syncAllModules() + } + + override fun rootsChanged(event: ModuleRootEvent) { + if (event.isCausedByFileTypesChange) return + // call on change with updated dependencies + syncAllModules() + } + + override fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture = + AmazonQLspService.executeIfRunning(project) { languageServer -> + languageServer.didChangeDependencyPaths(params) + }?.toCompletableFuture() ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")) + + private fun syncAllModules() { + ModuleManager.getInstance(project).modules.forEach { module -> + EP_NAME.forEachExtensionSafe { + if (it.isApplicable(module)) { + didChangeDependencyPaths(it.createParams(module)) + return@forEachExtensionSafe + } + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt new file mode 100644 index 00000000000..82370dad895 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt @@ -0,0 +1,11 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture + +interface ModuleDependenciesService { + fun didChangeDependencyPaths(params: DidChangeDependencyPathsParams): CompletableFuture +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt new file mode 100644 index 00000000000..e8d0087e7d2 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt @@ -0,0 +1,25 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.module.Module +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString + +interface ModuleDependencyProvider { + companion object { + val EP_NAME = ExtensionPointName("software.aws.toolkits.jetbrains.moduleDependencyProvider") + } + + fun isApplicable(module: Module): Boolean + fun createParams(module: Module): DidChangeDependencyPathsParams + + fun getWorkspaceFolderPath(module: Module): String { + val contentRoots: Array = ModuleRootManager.getInstance(module).contentRoots + return contentRoots.firstOrNull()?.let { toUriString(it) }.orEmpty() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt new file mode 100644 index 00000000000..25d1d36c00f --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt @@ -0,0 +1,35 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers + +import com.intellij.openapi.module.Module +import com.intellij.openapi.projectRoots.JavaSdkType +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.OrderRootType +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams + +internal class JavaModuleDependencyProvider : ModuleDependencyProvider { + override fun isApplicable(module: Module): Boolean = + ModuleRootManager.getInstance(module).sdk?.sdkType is JavaSdkType + + override fun createParams(module: Module): DidChangeDependencyPathsParams { + val dependencies = mutableListOf() + + ModuleRootManager.getInstance(module).orderEntries().forEachLibrary { library -> + library.getFiles(OrderRootType.CLASSES).forEach { file -> + dependencies.add(file.path.removeSuffix("!/")) + } + true + } + + return DidChangeDependencyPathsParams( + moduleName = getWorkspaceFolderPath(module), + runtimeLanguage = "java", + paths = dependencies, + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt new file mode 100644 index 00000000000..9a7961d4fbe --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt @@ -0,0 +1,39 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers + +import com.intellij.openapi.module.Module +import com.jetbrains.python.packaging.management.PythonPackageManager +import com.jetbrains.python.sdk.PythonSdkUtil +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams + +internal class PythonModuleDependencyProvider : ModuleDependencyProvider { + override fun isApplicable(module: Module): Boolean = + PythonSdkUtil.findPythonSdk(module) != null + + override fun createParams(module: Module): DidChangeDependencyPathsParams { + val dependencies = mutableListOf() + + PythonSdkUtil.findPythonSdk(module)?.let { sdk -> + PythonSdkUtil.getSitePackagesDirectory(sdk)?.let { sitePackagesDir -> + val packageManager = PythonPackageManager.forSdk(module.project, sdk) + packageManager.installedPackages.forEach { pkg -> + val packageDir = sitePackagesDir.findChild(pkg.name) + if (packageDir != null) { + dependencies.add(packageDir.path) + } + } + } + } + + return DidChangeDependencyPathsParams( + moduleName = getWorkspaceFolderPath(module), + runtimeLanguage = "python", + paths = dependencies, + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt new file mode 100644 index 00000000000..bca385682ea --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt @@ -0,0 +1,65 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.JWEObject +import com.nimbusds.jose.Payload +import com.nimbusds.jose.crypto.DirectDecrypter +import com.nimbusds.jose.crypto.DirectEncrypter +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.EncryptionInitializationRequest +import java.io.OutputStream +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +class JwtEncryptionManager(private val key: SecretKey) { + constructor() : this(generateHmacKey()) + + private val mapper = jacksonObjectMapper() + + fun writeInitializationPayload(os: OutputStream) { + val payload = EncryptionInitializationRequest( + EncryptionInitializationRequest.Version.V1_0, + EncryptionInitializationRequest.Mode.JWT, + Base64.getUrlEncoder().withoutPadding().encodeToString(key.encoded) + ) + + // write directly to stream because utils are closing the underlying stream + os.write("${mapper.writeValueAsString(payload)}\n".toByteArray()) + } + + fun encrypt(data: Any): String { + val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM) + val payload = if (data is String) { + Payload(data) + } else { + Payload(mapper.writeValueAsBytes(data)) + } + + val jweObject = JWEObject(header, payload) + jweObject.encrypt(DirectEncrypter(key)) + + return jweObject.serialize() + } + + fun decrypt(jwt: String): String { + val jweObject = JWEObject.parse(jwt) + jweObject.decrypt(DirectDecrypter(key)) + + return jweObject.payload.toString() + } + + private companion object { + private fun generateHmacKey(): SecretKey { + val keyBytes = ByteArray(32) + SecureRandom().nextBytes(keyBytes) + return SecretKeySpec(keyBytes, "HmacSHA256") + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt new file mode 100644 index 00000000000..53748475195 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt @@ -0,0 +1,20 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model + +import com.fasterxml.jackson.annotation.JsonValue + +data class EncryptionInitializationRequest( + val version: Version, + val mode: Mode, + val key: String, +) { + enum class Version(@JsonValue val value: String) { + V1_0("1.0"), + } + + enum class Mode(@JsonValue val value: String) { + JWT("JWT"), + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt new file mode 100644 index 00000000000..b25150c36bb --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt @@ -0,0 +1,57 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model + +import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata + +data class ExtendedClientMetadata( + val aws: AwsMetadata, +) + +data class AwsMetadata( + val clientInfo: ClientInfoMetadata, + val awsClientCapabilities: AwsClientCapabilities, +) + +data class AwsClientCapabilities( + val q: DeveloperProfiles, +) + +data class DeveloperProfiles( + val developerProfiles: Boolean, +) + +data class ClientInfoMetadata( + val extension: ExtensionMetadata, + val clientId: String, + val version: String, + val name: String, +) + +data class ExtensionMetadata( + val name: String, + val version: String, +) + +fun createExtendedClientMetadata(): ExtendedClientMetadata { + val metadata = ClientMetadata.getDefault() + return ExtendedClientMetadata( + aws = AwsMetadata( + clientInfo = ClientInfoMetadata( + extension = ExtensionMetadata( + name = metadata.awsProduct.toString(), + version = metadata.awsVersion + ), + clientId = metadata.clientId, + version = metadata.parentProductVersion, + name = metadata.parentProduct + ), + awsClientCapabilities = AwsClientCapabilities( + q = DeveloperProfiles( + developerProfiles = true + ) + ) + ) + ) +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt new file mode 100644 index 00000000000..551ddfa97b0 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/GetConfigurationFromServerParams.kt @@ -0,0 +1,8 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws + +data class GetConfigurationFromServerParams( + val section: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt new file mode 100644 index 00000000000..a0f23875b62 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/LspServerConfigurations.kt @@ -0,0 +1,17 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws + +// This represents each item in the array +data class WorkspaceInfo(val workspaceRoot: String, val workspaceId: String) + +// This represents the entire array +data class LspServerConfigurations(val workspaces: List) + +data class UpdateConfigurationParams( + val section: String, + val settings: LSPAny, +) + +typealias LSPAny = Any? diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt new file mode 100644 index 00000000000..c6216b97cff --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt @@ -0,0 +1,12 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials + +data class ConnectionMetadata( + val sso: SsoProfileData, +) + +data class SsoProfileData( + val startUrl: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt new file mode 100644 index 00000000000..a427330c055 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt @@ -0,0 +1,17 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials + +data class UpdateCredentialsPayload( + val data: String, + val encrypted: Boolean, +) + +data class UpdateCredentialsPayloadData( + val data: BearerCredentials, +) + +data class BearerCredentials( + val token: String, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt new file mode 100644 index 00000000000..8436b985825 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/DidChangeDependencyPathsParams.kt @@ -0,0 +1,12 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies + +class DidChangeDependencyPathsParams( + val moduleName: String, + val runtimeLanguage: String, + val paths: List, + val includePatterns: List, + val excludePatterns: List, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt new file mode 100644 index 00000000000..bbf60200810 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -0,0 +1,144 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument + +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileDocumentManagerListener +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.TextDocumentContentChangeEvent +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString +import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread + +class TextDocumentServiceHandler( + private val project: Project, + serverInstance: Disposable, +) : FileDocumentManagerListener, + FileEditorManagerListener, + BulkFileListener { + + init { + // didOpen & didClose events + project.messageBus.connect(serverInstance).subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + this + ) + + // didChange events + project.messageBus.connect(serverInstance).subscribe( + VirtualFileManager.VFS_CHANGES, + this + ) + + // didSave events + project.messageBus.connect(serverInstance).subscribe( + FileDocumentManagerListener.TOPIC, + this + ) + + // open files on startup + val fileEditorManager = FileEditorManager.getInstance(project) + fileEditorManager.openFiles.forEach { file -> + handleFileOpened(file) + } + } + + private fun handleFileOpened(file: VirtualFile) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didOpen( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = file.inputStream.readAllBytes().decodeToString() + languageId = file.fileType.name.lowercase() + version = file.modificationStamp.toInt() + } + } + ) + } + } + } + + override fun beforeDocumentSaving(document: Document) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didSave( + DidSaveTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + text = document.text + } + ) + } + } + } + + override fun after(events: MutableList) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + pluginAwareExecuteOnPooledThread { + events.filterIsInstance().forEach { event -> + val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach + toUriString(event.file)?.let { uri -> + languageServer.textDocumentService.didChange( + DidChangeTextDocumentParams().apply { + textDocument = VersionedTextDocumentIdentifier().apply { + this.uri = uri + version = document.modificationStamp.toInt() + } + contentChanges = listOf( + TextDocumentContentChangeEvent().apply { + text = document.text + } + ) + } + ) + } + } + } + } + } + + override fun fileOpened( + source: FileEditorManager, + file: VirtualFile, + ) { + handleFileOpened(file) + } + + override fun fileClosed( + source: FileEditorManager, + file: VirtualFile, + ) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt new file mode 100644 index 00000000000..b2821257a49 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt @@ -0,0 +1,42 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.warn +import java.io.File +import java.net.URI +import java.net.URISyntaxException + +object FileUriUtil { + + fun toUriString(virtualFile: VirtualFile): String? { + val protocol = virtualFile.fileSystem.protocol + val uri = when (protocol) { + "jar" -> VfsUtilCore.convertToURL(virtualFile.url)?.toExternalForm() + "jrt" -> virtualFile.url + else -> toUri(VfsUtilCore.virtualToIoFile(virtualFile)).toASCIIString() + } ?: return null + + return if (virtualFile.isDirectory) { + uri.trimEnd('/', '\\') + } else { + uri + } + } + + private fun toUri(file: File): URI { + try { + // URI scheme specified by language server protocol + return URI("file", "", file.absoluteFile.toURI().path, null) + } catch (e: URISyntaxException) { + LOG.warn { "${e.localizedMessage}: $e" } + return file.absoluteFile.toURI() + } + } + + private val LOG = getLogger() +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt new file mode 100644 index 00000000000..87e570b9f48 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt @@ -0,0 +1,26 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import org.eclipse.lsp4j.WorkspaceFolder +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString + +object WorkspaceFolderUtil { + fun createWorkspaceFolders(project: Project): List = + if (project.isDefault) { + emptyList() + } else { + ModuleManager.getInstance(project).modules.mapNotNull { module -> + ModuleRootManager.getInstance(module).contentRoots.firstOrNull()?.let { contentRoot -> + WorkspaceFolder().apply { + name = module.name + uri = toUriString(contentRoot) + } + } + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt new file mode 100644 index 00000000000..a457a6e0ef5 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt @@ -0,0 +1,287 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace + +import com.intellij.openapi.Disposable +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.openapi.roots.ModuleRootListener +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.openapi.vfs.newvfs.BulkFileListener +import com.intellij.openapi.vfs.newvfs.events.VFileCopyEvent +import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import org.eclipse.lsp4j.CreateFilesParams +import org.eclipse.lsp4j.DeleteFilesParams +import org.eclipse.lsp4j.DidChangeWatchedFilesParams +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileChangeType +import org.eclipse.lsp4j.FileCreate +import org.eclipse.lsp4j.FileDelete +import org.eclipse.lsp4j.FileEvent +import org.eclipse.lsp4j.FileRename +import org.eclipse.lsp4j.RenameFilesParams +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.WorkspaceFolder +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil.toUriString +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders +import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread +import java.nio.file.FileSystems +import java.nio.file.Paths + +class WorkspaceServiceHandler( + private val project: Project, + serverInstance: Disposable, +) : BulkFileListener, + ModuleRootListener { + + private var lastSnapshot: List = emptyList() + private val supportedFilePatterns = FileSystems.getDefault().getPathMatcher( + "glob:**/*.{ts,js,py,java}" + ) + + init { + project.messageBus.connect(serverInstance).subscribe( + VirtualFileManager.VFS_CHANGES, + this + ) + + project.messageBus.connect(serverInstance).subscribe( + ModuleRootListener.TOPIC, + this + ) + } + + private fun didCreateFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validFiles = events.mapNotNull { event -> + when (event) { + is VFileCopyEvent -> { + val newFile = event.newParent.findChild(event.newChildName)?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(newFile)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } + } + else -> { + val file = event.file?.takeIf { shouldHandleFile(it) } + ?: return@mapNotNull null + toUriString(file)?.let { uri -> + FileCreate().apply { + this.uri = uri + } + } + } + } + } + + if (validFiles.isNotEmpty()) { + languageServer.workspaceService.didCreateFiles( + CreateFilesParams().apply { + files = validFiles + } + ) + } + } + } + + private fun didDeleteFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validFiles = events.mapNotNull { event -> + when (event) { + is VFileDeleteEvent -> { + val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(file) + } + is VFileMoveEvent -> { + val oldFile = event.oldParent?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + toUriString(oldFile) + } + else -> null + }?.let { uri -> + FileDelete().apply { + this.uri = uri + } + } + } + + if (validFiles.isNotEmpty()) { + languageServer.workspaceService.didDeleteFiles( + DeleteFilesParams().apply { + files = validFiles + } + ) + } + } + } + + private fun didRenameFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validRenames = events + .filter { it.propertyName == VirtualFile.PROP_NAME } + .mapNotNull { event -> + val renamedFile = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + val oldFileName = event.oldValue as? String ?: return@mapNotNull null + val parentFile = renamedFile.parent ?: return@mapNotNull null + + val oldUri = toUriString(parentFile)?.let { parentUri -> "$parentUri/$oldFileName" } + val newUri = toUriString(renamedFile) + + if (!renamedFile.isDirectory) { + oldUri?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + + newUri?.let { uri -> + languageServer.textDocumentService.didOpen( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = renamedFile.inputStream.readAllBytes().decodeToString() + languageId = renamedFile.fileType.name.lowercase() + version = renamedFile.modificationStamp.toInt() + } + } + ) + } + } + + FileRename().apply { + this.oldUri = oldUri + this.newUri = newUri + } + } + + if (validRenames.isNotEmpty()) { + languageServer.workspaceService.didRenameFiles( + RenameFilesParams().apply { + files = validRenames + } + ) + } + } + } + + private fun didChangeWatchedFiles(events: List) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val validChanges = events.flatMap { event -> + when (event) { + is VFileCopyEvent -> { + event.newParent.findChild(event.newChildName)?.let { newFile -> + toUriString(newFile)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = FileChangeType.Created + } + ) + } + }.orEmpty() + } + is VFileMoveEvent -> { + listOfNotNull( + toUriString(event.oldParent)?.let { oldUri -> + FileEvent().apply { + uri = oldUri + type = FileChangeType.Deleted + } + }, + toUriString(event.file)?.let { newUri -> + FileEvent().apply { + uri = newUri + type = FileChangeType.Created + } + } + ) + } + else -> { + event.file?.let { file -> + toUriString(file)?.let { uri -> + listOf( + FileEvent().apply { + this.uri = uri + type = when (event) { + is VFileCreateEvent -> FileChangeType.Created + is VFileDeleteEvent -> FileChangeType.Deleted + else -> FileChangeType.Changed + } + } + ) + } + }.orEmpty() + } + } + } + + if (validChanges.isNotEmpty()) { + languageServer.workspaceService.didChangeWatchedFiles( + DidChangeWatchedFilesParams().apply { + changes = validChanges + } + ) + } + } + } + + override fun after(events: List) { + // since we are using synchronous FileListener + pluginAwareExecuteOnPooledThread { + didCreateFiles(events.filter { it is VFileCreateEvent || it is VFileMoveEvent || it is VFileCopyEvent }) + didDeleteFiles(events.filter { it is VFileMoveEvent || it is VFileDeleteEvent }) + didRenameFiles(events.filterIsInstance()) + didChangeWatchedFiles(events) + } + } + + override fun beforeRootsChange(event: ModuleRootEvent) { + lastSnapshot = createWorkspaceFolders(project) + } + + override fun rootsChanged(event: ModuleRootEvent) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val currentSnapshot = createWorkspaceFolders(project) + val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } } + val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } } + + if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) { + languageServer.workspaceService.didChangeWorkspaceFolders( + DidChangeWorkspaceFoldersParams().apply { + this.event = WorkspaceFoldersChangeEvent().apply { + added = addedFolders + removed = removedFolders + } + } + ) + } + + lastSnapshot = currentSnapshot + } + } + + private fun shouldHandleFile(file: VirtualFile): Boolean { + if (file.isDirectory) { + return true // Matches "**/*" with matches: "folder" + } + val path = Paths.get(file.path) + val result = supportedFilePatterns.matches(path) + return result + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt index 5b61aedf94d..2da14c2dd50 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt @@ -21,15 +21,13 @@ import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import org.apache.commons.codec.digest.DigestUtils import software.amazon.awssdk.utils.UserHomeDirectoryUtils -import software.aws.toolkits.core.utils.createParentDirectories -import software.aws.toolkits.core.utils.exists import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.tryDirOp import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.extractZipFile import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings -import java.io.FileOutputStream import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -39,7 +37,6 @@ import java.security.Key import java.security.SecureRandom import java.util.Base64 import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipFile import javax.crypto.spec.SecretKeySpec class EncoderServer(val project: Project) : Disposable { @@ -183,7 +180,7 @@ class EncoderServer(val project: Project) : Disposable { if (serverContent?.url != null) { if (validateHash(serverContent.hashes?.first(), HttpRequests.request(serverContent.url).readBytes(null))) { downloadFromRemote(serverContent.url, zipFilePath) - unzipFile(zipFilePath, cachePath) + extractZipFile(zipFilePath, cachePath) } } } catch (e: Exception) { @@ -231,26 +228,6 @@ class EncoderServer(val project: Project) : Disposable { Files.setPosixFilePermissions(filePath, permissions) } - private fun unzipFile(zipFilePath: Path, destDir: Path) { - if (!zipFilePath.exists()) return - try { - val zipFile = ZipFile(zipFilePath.toFile()) - zipFile.use { file -> - file.entries().asSequence() - .filterNot { it.isDirectory } - .map { zipEntry -> - val destPath = destDir.resolve(zipEntry.name) - destPath.createParentDirectories() - FileOutputStream(destPath.toFile()).use { targetFile -> - zipFile.getInputStream(zipEntry).copyTo(targetFile) - } - }.toList() - } - } catch (e: Exception) { - logger.warn { "error while unzipping project context artifact: ${e.message}" } - } - } - private fun downloadFromRemote(url: String, path: Path) { try { HttpRequests.request(url).saveToFile(path, null) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt index 69eb7ddf78f..484c319db52 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt @@ -3,8 +3,8 @@ package software.aws.toolkits.jetbrains.services.amazonq.project.manifest -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.intellij.openapi.util.SystemInfo @@ -18,10 +18,9 @@ class ManifestManager { val currentVersion = "0.1.49" val currentOs = getOs() private val arch = CpuArch.CURRENT - private val mapper = jacksonObjectMapper() + private val mapper = jacksonObjectMapper().apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) } data class TargetContent( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("filename") val filename: String? = null, @JsonProperty("url") @@ -33,7 +32,6 @@ class ManifestManager { ) data class VersionTarget( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("platform") val platform: String? = null, @JsonProperty("arch") @@ -41,8 +39,8 @@ class ManifestManager { @JsonProperty("contents") val contents: List? = emptyList(), ) + data class Version( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("serverVersion") val serverVersion: String? = null, @JsonProperty("isDelisted") @@ -52,7 +50,6 @@ class ManifestManager { ) data class Manifest( - @JsonIgnoreProperties(ignoreUnknown = true) @JsonProperty("manifestSchemaVersion") val manifestSchemaVersion: String? = null, @JsonProperty("artifactId") @@ -67,7 +64,7 @@ class ManifestManager { fun getManifest(): Manifest? = fetchFromRemoteAndSave() - private fun readManifestFile(content: String): Manifest? { + fun readManifestFile(content: String): Manifest? { try { return mapper.readValue(content) } catch (e: Exception) { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt index 30f46dd6636..2c5cac62e57 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt @@ -10,7 +10,9 @@ import com.intellij.openapi.components.Service import com.intellij.openapi.components.State import com.intellij.openapi.components.Storage import com.intellij.openapi.components.service +import com.intellij.openapi.project.ProjectManager import com.intellij.util.xmlb.annotations.Property +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.utils.notifyInfo import software.aws.toolkits.resources.AmazonQBundle @@ -21,6 +23,13 @@ class CodeWhispererSettings : PersistentStateComponent { + private var state = LspConfiguration() + + override fun getState(): LspConfiguration = state + + override fun loadState(state: LspConfiguration) { + this.state = state + } + + fun getArtifactPath() = state.artifactPath + + fun setArtifactPath(artifactPath: String?) { + state.artifactPath = artifactPath.nullize(nullizeSpaces = true) + } + + companion object { + fun getInstance(): LspSettings = service() + } +} + +class LspConfiguration : BaseState() { + var artifactPath by string() +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt new file mode 100644 index 00000000000..83131a45cc9 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt @@ -0,0 +1,135 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("BannedImports") + +package software.aws.toolkits.jetbrains.services.amazonq.lsp + +import com.google.gson.Gson +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.ConfigurationParams +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData +import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings + +@ExtendWith(ApplicationExtension::class) +class AmazonQLanguageClientImplTest { + private val project: Project = mockk(relaxed = true) + private val sut = AmazonQLanguageClientImpl(project) + + @Test + fun `getConnectionMetadata returns connection metadata with start URL for bearer token connection`() { + val mockConnectionManager = mockk() + every { project.service() } returns mockConnectionManager + + val expectedStartUrl = "https://test.aws.com" + val mockConnection = mockk { + every { startUrl } returns expectedStartUrl + } + + every { mockConnectionManager.activeConnectionForFeature(QConnection.getInstance()) } returns mockConnection + + assertThat(sut.getConnectionMetadata().get()) + .isEqualTo(ConnectionMetadata(SsoProfileData(expectedStartUrl))) + } + + @Test + fun `getConnectionMetadata returns empty start URL when no active connection`() { + val mockConnectionManager = mockk() + every { project.service() } returns mockConnectionManager + + every { mockConnectionManager.activeConnectionForFeature(QConnection.getInstance()) } returns null + + assertThat(sut.getConnectionMetadata().get()) + .isEqualTo(ConnectionMetadata(SsoProfileData(AmazonQLspConstants.AWS_BUILDER_ID_URL))) + } + + @Test + fun `configuration null if no attributes requested`() { + assertThat(sut.configuration(configurationParams()).get()).isNull() + } + + @Test + fun `configuration for codeWhisperer respects opt-out`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(false) + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(true) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = false, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = true + ) + ) + } + + @Test + fun `configuration for codeWhisperer respects opt-in`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(true) + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(true) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = true + ) + ) + } + + @Test + fun `configuration for workspace context respects opt-in`() { + CodeWhispererSettings.getInstance().toggleWorkspaceContextEnabled(false) + assertThat(sut.configuration(configurationParams("aws.codeWhisperer")).get()) + .singleElement() + .isEqualTo( + CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = false, + shouldEnableWorkspaceContext = false + ) + ) + } + + @Test + fun `configuration empty if attributes unknown`() { + CodeWhispererSettings.getInstance().toggleMetricOptIn(true) + assertThat(sut.configuration(configurationParams("something random")).get()).isEmpty() + } + + @Test + fun `Gson serializes CodeWhispererLspConfiguration serializes correctly`() { + val sut = CodeWhispererLspConfiguration( + shouldShareData = true, + shouldShareCodeReferences = true + ) + assertThat(Gson().toJson(sut)).isEqualToIgnoringWhitespace( + """ + { + "shareCodeWhispererContentWithAWS": true, + "includeSuggestionsWithCodeReferences": true + } + """.trimIndent() + ) + } + + private fun configurationParams(vararg attributes: String) = ConfigurationParams( + attributes.map { + ConfigurationItem().apply { + section = it + } + } + ) +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt new file mode 100644 index 00000000000..5fede85b27e --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt @@ -0,0 +1,237 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ApplicationExtension +import com.intellij.util.io.createDirectories +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.mock +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +@ExtendWith(ApplicationExtension::class) +class ArtifactHelperTest { + @TempDir + lateinit var tempDir: Path + + private lateinit var artifactHelper: ArtifactHelper + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + private lateinit var mockManifestManager: ManifestManager + private lateinit var contents: List + private lateinit var mockProject: Project + + @BeforeEach + fun setUp() { + artifactHelper = ArtifactHelper(tempDir, 3) + mockManifestManager = mock() + contents = listOf( + ManifestManager.TargetContent( + filename = "server.zip", + hashes = listOf("sha384:1234") + ) + ) + mockProject = mockk(relaxed = true) { + every { basePath } returns tempDir.toString() + every { name } returns "TestProject" + } + } + + @Test + fun `removeDelistedVersions removes specified versions`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("2.0.0").apply { toFile().mkdirs() } + + val delistedVersions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + artifactHelper.removeDelistedVersions(delistedVersions) + + assertThat(version1Dir.toFile().exists()).isFalse() + assertThat(version2Dir.toFile().exists()).isTrue() + } + + @Test + fun `deleteOlderLspArtifacts should not delete if there are only two version`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() } + + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + + assertThat(version1Dir.toFile().exists()).isTrue() + assertThat(version2Dir.toFile().exists()).isTrue() + } + + @Test + fun `deleteOlderLspArtifacts should delete if there are more than two versions`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() } + val version3Dir = tempDir.resolve("1.0.2").apply { toFile().mkdirs() } + + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges) + + assertThat(version1Dir.toFile().exists()).isFalse() + assertThat(version2Dir.toFile().exists()).isTrue() + assertThat(version3Dir.toFile().exists()).isTrue() + } + + @Test + fun `getAllLocalLspArtifactsWithinManifestRange should return matching folder path`() { + tempDir.resolve("1.0.0").createDirectories() + tempDir.resolve("1.0.1").createDirectories() + tempDir.resolve("1.0.2").createDirectories() + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + + val actualResult = artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges) + assertThat(actualResult).isNotNull() + assertThat(actualResult.size).isEqualTo(3) + assertThat(actualResult.first().first.fileName.toString()).isEqualTo("1.0.2") + } + + @Test + fun `getExistingLspArtifacts should find all the artifacts`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + + val serverZipPath = version1Dir.resolve("server.zip") + serverZipPath.parent.toFile().mkdirs() + serverZipPath.toFile().createNewFile() + + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + val target = ManifestManager.VersionTarget(contents = contents) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + + val result = artifactHelper.getExistingLspArtifacts(versions, target) + + assertThat(result).isTrue() + assertThat(serverZipPath.toFile().exists()).isTrue() + version1Dir.toFile().deleteRecursively() + } + + @Test + fun `getExistingLspArtifacts should return false due to hash mismatch`() { + val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() } + + val serverZipPath = version1Dir.resolve("server.zip") + serverZipPath.parent.toFile().mkdirs() + serverZipPath.toFile().createNewFile() + + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + + val target = ManifestManager.VersionTarget(contents = contents) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1235" + + val result = artifactHelper.getExistingLspArtifacts(versions, target) + + assertThat(result).isFalse() + assertThat(serverZipPath.toFile().exists()).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if versions are empty`() { + val versions = emptyList() + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if target does not have contents`() { + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `getExistingLspArtifacts should return false if Lsp path does not exist`() { + val versions = listOf( + ManifestManager.Version(serverVersion = "1.0.0") + ) + assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse() + } + + @Test + fun `tryDownloadLspArtifacts should not download artifacts if target does not have contents`() { + val versions = listOf(ManifestManager.Version(serverVersion = "2.0.0")) + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null) + assertThat(tempDir.resolve("2.0.0").toFile().exists()).isFalse() + } + + @Test + fun `tryDownloadLspArtifacts should throw error if failed to download`() { + val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0")) + + val spyArtifactHelper = spyk(artifactHelper) + every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns false + + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null) + } + + @Test + fun `tryDownloadLspArtifacts should throw error after attempts are exhausted`() { + val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0")) + val target = ManifestManager.VersionTarget(contents = contents) + val spyArtifactHelper = spyk(artifactHelper) + + every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns true + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { moveFilesFromSourceToDestination(any(), any()) } just Runs + every { extractZipFile(any(), any()) } just Runs + + assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, target) }).isEqualTo(null) + } + + @Test + fun `validateFileHash should return false if expected hash is null`() { + assertThat(artifactHelper.validateFileHash(tempDir, null)).isFalse() + } + + @Test + fun `validateFileHash should return false if hash did not match`() { + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + assertThat(artifactHelper.validateFileHash(tempDir, "1234")).isFalse() + } + + @Test + fun `validateFileHash should return true if hash matched`() { + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { generateSHA384Hash(any()) } returns "1234" + assertThat(artifactHelper.validateFileHash(tempDir, "sha384:1234")).isTrue() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt new file mode 100644 index 00000000000..4e163900bff --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt @@ -0,0 +1,145 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.openapi.project.Project +import com.intellij.util.text.SemVer +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.jetbrains.annotations.TestOnly +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path + +@TestOnly +class ArtifactManagerTest { + + @TempDir + lateinit var tempDir: Path + + private lateinit var artifactHelper: ArtifactHelper + private lateinit var artifactManager: ArtifactManager + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifestVersionRanges: SupportedManifestVersionRange + private lateinit var mockProject: Project + + @BeforeEach + fun setUp() { + artifactHelper = spyk(ArtifactHelper(tempDir, 3)) + manifestFetcher = spyk(ManifestFetcher()) + manifestVersionRanges = SupportedManifestVersionRange( + startVersion = SemVer("1.0.0", 1, 0, 0), + endVersion = SemVer("2.0.0", 2, 0, 0) + ) + mockProject = mockk(relaxed = true) { + every { basePath } returns tempDir.toString() + every { name } returns "TestProject" + } + artifactManager = ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges) + } + + @Test + fun `fetch artifact fetcher throws exception if manifest is null`() { + every { manifestFetcher.fetch() }.returns(null) + + assertThatThrownBy { + runBlocking { artifactManager.fetchArtifact() } + } + .isInstanceOf(LspException::class.java) + .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.MANIFEST_FETCH_FAILED) + } + + @Test + fun `fetch artifact does not have any valid lsp versions`() { + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList()) + ) + + assertThatThrownBy { + runBlocking { artifactManager.fetchArtifact() } + } + .isInstanceOf(LspException::class.java) + .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION) + } + + @Test + fun `fetch artifact if inRangeVersions are not available should fallback to local lsp`() { + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 1) { manifestFetcher.fetch() } + verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) } + } + + @Test + fun `fetch artifact have valid version in local system`() { + val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target))) + + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false) + coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 1) { runBlocking { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } + + @Test + fun `fetch artifact does not have valid version in local system`() { + val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp") + val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target))) + val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0))) + + artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)) + + every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns( + ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions) + ) + every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest()) + + mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt") + every { getCurrentOS() }.returns("temp") + every { getCurrentArchitecture() }.returns("temp") + + every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true) + every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs + every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult) + + runBlocking { artifactManager.fetchArtifact() } + + verify(exactly = 0) { runBlocking { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } } + verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt new file mode 100644 index 00000000000..607b94f34bf --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtilsTest.kt @@ -0,0 +1,103 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.testFramework.utils.io.createDirectory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import software.aws.toolkits.core.utils.ZIP_PROPERTY_POSIX +import software.aws.toolkits.core.utils.hasPosixFilePermissions +import software.aws.toolkits.core.utils.putNextEntry +import software.aws.toolkits.core.utils.test.assertPosixPermissions +import software.aws.toolkits.core.utils.writeText +import software.aws.toolkits.jetbrains.utils.satisfiesKt +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions +import java.util.zip.ZipOutputStream +import kotlin.io.path.isRegularFile +import kotlin.io.path.setPosixFilePermissions + +class LspUtilsTest { + @Test + fun `extractZipFile works`(@TempDir tempDir: Path) { + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("file1").writeText("contents1") + source.resolve("file2").writeText("contents2") + source.resolve("file3").writeText("contents3") + + val sourceZip = tempDir.resolve("source.zip") + ZipOutputStream(Files.newOutputStream(sourceZip)).use { zip -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { + zip.putNextEntry(source.relativize(it).toString(), it) + } + val precedingSlashFile = source.resolve("file4").also { it.writeText("contents4") } + zip.putNextEntry("/${source.relativize(precedingSlashFile)}", precedingSlashFile) + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(4) + assertThat(target.resolve("file1")).hasContent("contents1") + assertThat(target.resolve("file2")).hasContent("contents2") + assertThat(target.resolve("file3")).hasContent("contents3") + assertThat(target.resolve("file4")).hasContent("contents4") + } + } + + @Test + fun `extractZipFile respects posix`(@TempDir tempDir: Path) { + assumeTrue(tempDir.hasPosixFilePermissions()) + + val source = tempDir.resolve("source").also { it.createDirectory() } + val target = tempDir.resolve("target").also { it.createDirectory() } + + source.resolve("regularFile").also { + it.writeText("contents1") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rw-r--r--")) + } + source.resolve("executableFile").also { + it.writeText("contents2") + it.setPosixFilePermissions(PosixFilePermissions.fromString("rwxr-xr-x")) + } + + val sourceZip = tempDir.resolve("source.zip") + FileSystems.newFileSystem( + sourceZip, + mapOf( + "create" to true, + ZIP_PROPERTY_POSIX to true, + ) + ).use { zipfs -> + Files.walk(source).use { paths -> + paths + .filter { it.isRegularFile() } + .forEach { file -> + Files.copy(file, zipfs.getPath("/").resolve(source.relativize(file).toString()), StandardCopyOption.COPY_ATTRIBUTES) + } + } + } + + extractZipFile(sourceZip, target) + + assertThat(target).satisfiesKt { + val files = Files.list(it).use { stream -> stream.toList() } + assertThat(files.size).isEqualTo(2) + assertPosixPermissions(target.resolve("regularFile"), "rw-r--r--") + assertPosixPermissions(target.resolve("executableFile"), "rwxr-xr-x") + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt new file mode 100644 index 00000000000..b5a1bd32fac --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt @@ -0,0 +1,122 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts + +import com.intellij.testFramework.ApplicationExtension +import com.intellij.testFramework.utils.io.createFile +import io.mockk.every +import io.mockk.junit5.MockKExtension +import io.mockk.mockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.io.TempDir +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import software.aws.toolkits.jetbrains.core.getTextFromUrl +import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager +import java.nio.file.Path +import java.nio.file.Paths + +@ExtendWith(ApplicationExtension::class, MockitoExtension::class, MockKExtension::class) +class ManifestFetcherTest { + + private lateinit var manifestFetcher: ManifestFetcher + private lateinit var manifest: ManifestManager.Manifest + private lateinit var manifestManager: ManifestManager + + @BeforeEach + fun setup() { + manifestFetcher = spy(ManifestFetcher()) + manifestManager = spy(ManifestManager()) + manifest = ManifestManager.Manifest() + } + + @Test + fun `should return null when both local and remote manifests are null`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(null) + + assertThat(manifestFetcher.fetch()).isNull() + } + + @Test + fun `should return valid result from local should not execute remote method`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, never()).fetchManifestFromRemote() + } + + @Test + fun `should return valid result from remote`() { + whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null) + whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(manifest) + + assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest) + verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal() + verify(manifestFetcher, atLeastOnce()).fetchManifestFromRemote() + } + + @Test + fun `fetchManifestFromRemote should return null due to invalid manifestString`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns "ManifestContent" + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromRemote should return manifest and update manifest`() { + val validManifest = ManifestManager.Manifest(manifestSchemaVersion = "1.0") + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + + every { getTextFromUrl(any()) } returns "{ \"manifestSchemaVersion\": \"1.0\" }" + + val result = manifestFetcher.fetchManifestFromRemote() + assertThat(result).isNotNull().isEqualTo(validManifest) + } + + @Test + fun `fetchManifestFromRemote should return null if manifest is deprecated`() { + mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt") + every { getTextFromUrl(any()) } returns + // language=JSON + """ + { + "manifestSchemaVersion": "1.0", + "isManifestDeprecated": true + } + """.trimIndent() + + assertThat(manifestFetcher.fetchManifestFromRemote()).isNull() + } + + @Test + fun `fetchManifestFromLocal should return null if path does not exist locally`() { + whenever(manifestFetcher.lspManifestFilePath).thenReturn(Paths.get("does", "not", "exist")) + assertThat(manifestFetcher.fetchManifestFromLocal()).isNull() + } + + @Test + fun `fetchManifestFromLocal should return local path if exists locally`(@TempDir tempDir: Path) { + val manifestFile = tempDir.createFile("manifest.json") + manifestFile.toFile().writeText( + // language=JSON + """ + { + "manifestSchemaVersion": "1.0" + } + """.trimIndent() + ) + whenever(manifestFetcher.lspManifestFilePath).thenReturn(manifestFile) + assertThat(manifestFetcher.fetchManifestFromLocal()).isNull() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt new file mode 100644 index 00000000000..d141268d2c3 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt @@ -0,0 +1,253 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.service +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.project.Project +import com.intellij.testFramework.ProjectExtension +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.spyk +import io.mockk.verify +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import software.aws.toolkits.core.TokenConnectionSettings +import software.aws.toolkits.core.credentials.ToolkitBearerTokenProvider +import software.aws.toolkits.jetbrains.core.credentials.AwsBearerTokenConnection +import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager +import software.aws.toolkits.jetbrains.core.credentials.sso.PKCEAuthorizationGrantToken +import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.InteractiveBearerTokenProvider +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload +import software.aws.toolkits.jetbrains.utils.isQConnected +import software.aws.toolkits.jetbrains.utils.isQExpired +import java.time.Instant +import java.util.concurrent.CompletableFuture + +class DefaultAuthCredentialsServiceTest { + companion object { + @JvmField + @RegisterExtension + val projectExtension = ProjectExtension() + + private const val TEST_ACCESS_TOKEN = "test-access-token" + } + + private lateinit var project: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockEncryptionManager: JwtEncryptionManager + private lateinit var mockConnectionManager: ToolkitConnectionManager + private lateinit var mockConnection: AwsBearerTokenConnection + private lateinit var sut: DefaultAuthCredentialsService + + @BeforeEach + fun setUp() { + project = spyk(projectExtension.project) + setupMockLspService() + setupMockMessageBus() + setupMockConnectionManager() + } + + private fun setupMockLspService() { + mockLanguageServer = mockk() + mockEncryptionManager = mockk { + every { encrypt(any()) } returns "mock-encrypted-data" + } + + val mockLspService = mockk() + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + every { + mockLanguageServer.updateTokenCredentials(any()) + } returns CompletableFuture() + + every { + mockLanguageServer.deleteTokenCredentials() + } returns CompletableFuture.completedFuture(Unit) + + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + } + + private fun setupMockMessageBus() { + val messageBus = mockk() + val mockConnection = mockk { + every { subscribe(any(), any()) } just runs + } + every { project.messageBus } returns messageBus + every { messageBus.connect(any()) } returns mockConnection + } + + private fun setupMockConnectionManager(accessToken: String = TEST_ACCESS_TOKEN) { + mockConnection = createMockConnection(accessToken) + mockConnectionManager = mockk { + every { activeConnectionForFeature(any()) } returns mockConnection + } + every { project.service() } returns mockConnectionManager + mockkStatic("software.aws.toolkits.jetbrains.utils.FunctionUtilsKt") + // these set so init doesn't always emit + every { isQConnected(any()) } returns false + every { isQExpired(any()) } returns true + } + + private fun createMockConnection( + accessToken: String, + connectionId: String = "test-connection-id", + ): AwsBearerTokenConnection = mockk { + every { id } returns connectionId + every { getConnectionSettings() } returns createMockTokenSettings(accessToken) + } + + private fun createMockTokenSettings(accessToken: String): TokenConnectionSettings { + val token = PKCEAuthorizationGrantToken( + issuerUrl = "https://example.com", + refreshToken = "refreshToken", + accessToken = accessToken, + expiresAt = Instant.MAX, + createdAt = Instant.now(), + region = "us-fake-1", + ) + + val tokenDelegate = mockk { + every { currentToken() } returns token + } + + val provider = mockk { + every { delegate } returns tokenDelegate + } + + return mockk { + every { tokenProvider } returns provider + } + } + + @Test + fun `activeConnectionChanged updates token when connection ID matches Q connection`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + val newConnection = createMockConnection("new-token", "connection-id") + every { mockConnection.id } returns "connection-id" + + sut.activeConnectionChanged(newConnection) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `activeConnectionChanged does not update token when connection ID differs`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + val newConnection = createMockConnection("new-token", "different-id") + every { mockConnection.id } returns "q-connection-id" + + sut.activeConnectionChanged(newConnection) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `onChange updates token with new connection`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + setupMockConnectionManager("updated-token") + + sut.onChange("providerId", listOf("new-scope")) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `init does not update token when Q is not connected`() { + every { isQConnected(project) } returns false + every { isQExpired(project) } returns false + + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `init does not update token when Q is expired`() { + every { isQConnected(project) } returns true + every { isQExpired(project) } returns true + + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 0) { mockLanguageServer.updateTokenCredentials(any()) } + } + + @Test + fun `test updateTokenCredentials unencrypted success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + val token = "unencryptedToken" + val isEncrypted = false + + sut.updateTokenCredentials(token, isEncrypted) + + verify(exactly = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + token, + isEncrypted + ) + ) + } + } + + @Test + fun `test updateTokenCredentials encrypted success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + val encryptedToken = "encryptedToken" + val decryptedToken = "decryptedToken" + val isEncrypted = true + + every { mockEncryptionManager.encrypt(any()) } returns encryptedToken + + sut.updateTokenCredentials(decryptedToken, isEncrypted) + + verify(atLeast = 1) { + mockLanguageServer.updateTokenCredentials( + UpdateCredentialsPayload( + encryptedToken, + isEncrypted + ) + ) + } + } + + @Test + fun `test deleteTokenCredentials success`() { + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + every { mockLanguageServer.deleteTokenCredentials() } returns CompletableFuture.completedFuture(Unit) + + sut.deleteTokenCredentials() + + verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() } + } + + @Test + fun `init results in token update`() { + every { isQConnected(any()) } returns true + every { isQExpired(any()) } returns false + sut = DefaultAuthCredentialsService(project, mockEncryptionManager, mockk()) + + verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt new file mode 100644 index 00000000000..618b768a69a --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt @@ -0,0 +1,202 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.verify +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.DidChangeDependencyPathsParams +import java.util.concurrent.CompletableFuture +import java.util.function.Consumer + +class DefaultModuleDependenciesServiceTest { + private lateinit var project: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockModuleManager: ModuleManager + private lateinit var sut: DefaultModuleDependenciesService + private lateinit var mockApplication: Application + private lateinit var mockDependencyProvider: ModuleDependencyProvider + + @BeforeEach + fun setUp() { + project = mockk() + mockModuleManager = mockk() + mockDependencyProvider = mockk() + mockLanguageServer = mockk() + + every { mockLanguageServer.didChangeDependencyPaths(any()) } returns CompletableFuture() + + // Mock Application + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + // Mock ModuleManager + mockkStatic(ModuleManager::class) + every { ModuleManager.getInstance(project) } returns mockModuleManager + every { mockModuleManager.modules } returns Array(0) { mockk() } + + // Mock LSP service + val mockLspService = mockk() + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock extension point + mockkObject(ModuleDependencyProvider.Companion) + val epName = mockk>() + every { EP_NAME } returns epName + every { epName.forEachExtensionSafe(any()) } answers { + val callback = firstArg<(ModuleDependencyProvider) -> Unit>() + callback(mockDependencyProvider) + } + } + + @Test + fun `test initial sync on construction`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + + every { mockModuleManager.modules } returns arrayOf(module) + prepDependencyProvider(listOf(Pair(module, params))) + + sut = DefaultModuleDependenciesService(project, mockk()) + + verify { mockLanguageServer.didChangeDependencyPaths(params) } + } + + @Test + fun `test rootsChanged with multiple modules`() { + // Arrange + val module1 = mockk() + val module2 = mockk() + val params1 = DidChangeDependencyPathsParams( + moduleName = "module1", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency1.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + val params2 = DidChangeDependencyPathsParams( + moduleName = "module2", + runtimeLanguage = "python", + paths = listOf("/path/to/site-packages/package1"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + + prepDependencyProvider( + listOf( + Pair(module1, params1), + Pair(module2, params2) + ) + ) + + sut = DefaultModuleDependenciesService(project, mockk()) + + verify { mockLanguageServer.didChangeDependencyPaths(params1) } + verify { mockLanguageServer.didChangeDependencyPaths(params2) } + } + + @Test + fun `test rootsChanged withFileTypesChange`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + prepDependencyProvider(listOf(Pair(module, params))) + val event = mockk() + every { event.isCausedByFileTypesChange } returns true + + sut = DefaultModuleDependenciesService(project, mockk()) + + sut.rootsChanged(event) + + verify(exactly = 1) { mockLanguageServer.didChangeDependencyPaths(params) } + } + + @Test + fun `test rootsChanged after module changes`() { + // Arrange + val module = mockk() + val params = DidChangeDependencyPathsParams( + moduleName = "testModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dependency.jar"), + includePatterns = emptyList(), + excludePatterns = emptyList() + ) + val event = mockk() + + every { mockModuleManager.modules } returns arrayOf(module) + every { event.isCausedByFileTypesChange } returns false + + prepDependencyProvider(listOf(Pair(module, params))) + + sut = DefaultModuleDependenciesService(project, mockk()) + + sut.rootsChanged(event) + + verify(exactly = 2) { mockLanguageServer.didChangeDependencyPaths(params) } + } + + private fun prepDependencyProvider(moduleParamPairs: List>) { + every { mockModuleManager.modules } returns moduleParamPairs.map { it.first }.toTypedArray() + + every { + EP_NAME.forEachExtensionSafe(any>()) + } answers { + val consumer = firstArg>() + moduleParamPairs.forEach { (module, params) -> + every { mockDependencyProvider.isApplicable(module) } returns true + every { mockDependencyProvider.createParams(module) } returns params + } + consumer.accept(mockDependencyProvider) + } + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt new file mode 100644 index 00000000000..365fd2759b0 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt @@ -0,0 +1,77 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption + +import com.nimbusds.jose.JOSEException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import javax.crypto.spec.SecretKeySpec +import kotlin.random.Random + +class JwtEncryptionManagerTest { + @Test + fun `uses a different encryption key for each instance`() { + val blob = Random.Default.nextBytes(256) + val sut1 = JwtEncryptionManager() + val encrypted = sut1.encrypt(blob) + + assertThrows { + assertThat(sut1.decrypt(encrypted)) + .isNotEqualTo(JwtEncryptionManager().decrypt(encrypted)) + } + } + + @Test + @OptIn(ExperimentalStdlibApi::class) + fun `encryption is stable with static key`() { + val blob = Random.Default.nextBytes(256) + val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes + val key = SecretKeySpec(bytes, "HmacSHA256") + val sut1 = JwtEncryptionManager(key) + val encrypted = sut1.encrypt(blob) + + // each encrypt() call will use a different IV so we can't just directly compare + assertThat(sut1.decrypt(encrypted)) + .isEqualTo(JwtEncryptionManager(key).decrypt(encrypted)) + } + + @Test + fun `encryption can be round-tripped`() { + val sut = JwtEncryptionManager() + val blob = "DEADBEEF".repeat(8) + assertThat(sut.decrypt(sut.encrypt(blob))).isEqualTo(blob) + } + + @Test + @OptIn(ExperimentalStdlibApi::class) + fun writeInitializationPayload() { + val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes + val key = SecretKeySpec(bytes, "HmacSHA256") + + val closed = AtomicBoolean(false) + val os = object : ByteArrayOutputStream() { + override fun close() { + closed.set(true) + } + } + JwtEncryptionManager(key).writeInitializationPayload(os) + assertThat(os.toString()) + // Flare requires encryption ends with new line + // https://github.com/aws/language-server-runtimes/blob/4d7f81295dc12b59ed2e1c0ebaedb85ccb86cf76/runtimes/README.md#encryption + .endsWith("\n") + .isEqualTo( + // language=JSON + """ + |{"version":"1.0","mode":"JWT","key":"3q2-796tvu_erb7v3q2-796tvu_erb7v3q2-796tvu8"} + | + """.trimMargin() + ) + + // writeInitializationPayload should not close the stream + assertThat(closed.get()).isFalse + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt new file mode 100644 index 00000000000..96da1fc4318 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -0,0 +1,341 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.editor.Document +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.services.TextDocumentService +import org.junit.Before +import org.junit.Test +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.FileUriUtil +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture + +class TextDocumentServiceHandlerTest { + private lateinit var project: Project + private lateinit var mockFileEditorManager: FileEditorManager + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockTextDocumentService: TextDocumentService + private lateinit var sut: TextDocumentServiceHandler + private lateinit var mockApplication: Application + + @Before + fun setup() { + project = mockk() + mockTextDocumentService = mockk() + mockLanguageServer = mockk() + + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + every { mockApplication.executeOnPooledThread(any>()) } answers { + CompletableFuture.completedFuture(firstArg>().call()) + } + + // Mock the LSP service + val mockLspService = mockk() + + // Mock the service methods on Project + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + + // Mock the LSP service's executeSync method as a suspend function + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock workspace service + every { mockLanguageServer.textDocumentService } returns mockTextDocumentService + every { mockTextDocumentService.didChange(any()) } returns Unit + every { mockTextDocumentService.didSave(any()) } returns Unit + every { mockTextDocumentService.didOpen(any()) } returns Unit + every { mockTextDocumentService.didClose(any()) } returns Unit + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + // Mock FileEditorManager + mockFileEditorManager = mockk() + every { mockFileEditorManager.openFiles } returns emptyArray() + every { project.getService(FileEditorManager::class.java) } returns mockFileEditorManager + + sut = TextDocumentServiceHandler(project, mockk()) + } + + @Test + fun `didSave runs on beforeDocumentSaving`() = runTest { + // Create test document and file + val uri = URI.create("file:///test/path/file.txt") + val document = mockk { + every { text } returns "test content" + } + + val file = createMockVirtualFile(uri) + + // Mock FileDocumentManager + val fileDocumentManager = mockk { + every { getFile(document) } returns file + } + + // Replace the FileDocumentManager instance + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + // Call the handler method + sut.beforeDocumentSaving(document) + + // Verify the correct LSP method was called with matching parameters + val paramsSlot = slot() + verify { mockTextDocumentService.didSave(capture(paramsSlot)) } + + with(paramsSlot.captured) { + assertThat(textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo("test content") + } + } + } + + @Test + fun `didOpen runs on service init`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val content = "test content" + val file = createMockVirtualFile(uri, content) + + every { mockFileEditorManager.openFiles } returns arrayOf(file) + + sut = TextDocumentServiceHandler(project, mockk()) + + val paramsSlot = slot() + verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } + + with(paramsSlot.captured.textDocument) { + assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo(content) + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(1) + } + } + + @Test + fun `didOpen runs on fileOpened`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val content = "test content" + val file = createMockVirtualFile(uri, content) + + sut.fileOpened(mockk(), file) + + val paramsSlot = slot() + verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } + + with(paramsSlot.captured.textDocument) { + assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(text).isEqualTo(content) + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(1) + } + } + + @Test + fun `didClose runs on fileClosed`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val file = createMockVirtualFile(uri) + + sut.fileClosed(mockk(), file) + + val paramsSlot = slot() + verify { mockTextDocumentService.didClose(capture(paramsSlot)) } + + assertThat(paramsSlot.captured.textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + } + + @Test + fun `didChange runs on content change events`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val document = mockk { + every { text } returns "changed content" + every { modificationStamp } returns 123L + } + + val file = createMockVirtualFile(uri) + + val changeEvent = mockk { + every { this@mockk.file } returns file + } + + // Mock FileDocumentManager + val fileDocumentManager = mockk { + every { getCachedDocument(file) } returns document + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + // Call the handler method + sut.after(mutableListOf(changeEvent)) + } + + // Verify the correct LSP method was called with matching parameters + val paramsSlot = slot() + verify { mockTextDocumentService.didChange(capture(paramsSlot)) } + + with(paramsSlot.captured) { + assertThat(textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) + assertThat(textDocument.version).isEqualTo(123) + assertThat(contentChanges[0].text).isEqualTo("changed content") + } + } + + @Test + fun `didSave does not run when URI is empty`() = runTest { + val document = mockk() + val file = createMockVirtualFile(URI.create("")) + + mockkObject(FileUriUtil) { + every { FileUriUtil.toUriString(file) } returns null + + val fileDocumentManager = mockk { + every { getFile(document) } returns file + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.beforeDocumentSaving(document) + + verify(exactly = 0) { mockTextDocumentService.didSave(any()) } + } + } + } + + @Test + fun `didSave does not run when file is null`() = runTest { + val document = mockk() + + val fileDocumentManager = mockk { + every { getFile(document) } returns null + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.beforeDocumentSaving(document) + + verify(exactly = 0) { mockTextDocumentService.didSave(any()) } + } + } + + @Test + fun `didChange ignores non-content change events`() = runTest { + val nonContentEvent = mockk() // Some other type of VFileEvent + + sut.after(mutableListOf(nonContentEvent)) + + verify(exactly = 0) { mockTextDocumentService.didChange(any()) } + } + + @Test + fun `didChange skips files without cached documents`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val path = mockk { + every { toUri() } returns uri + } + val file = mockk { + every { toNioPath() } returns path + } + val changeEvent = mockk { + every { this@mockk.file } returns file + } + + val fileDocumentManager = mockk { + every { getCachedDocument(file) } returns null + } + + mockkStatic(FileDocumentManager::class) { + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + sut.after(mutableListOf(changeEvent)) + + verify(exactly = 0) { mockTextDocumentService.didChange(any()) } + } + } + + private fun createMockVirtualFile( + uri: URI, + content: String = "", + fileTypeName: String = "JAVA", + modificationStamp: Long = 1L, + ): VirtualFile { + val path = mockk { + every { toUri() } returns uri + } + val inputStream = content.byteInputStream() + + val mockFileType = mockk { + every { name } returns fileTypeName + } + + return mockk { + every { url } returns uri.path + every { toNioPath() } returns path + every { isDirectory } returns false + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + every { this@mockk.inputStream } returns inputStream + every { fileType } returns mockFileType + every { this@mockk.modificationStamp } returns modificationStamp + } + } + + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt new file mode 100644 index 00000000000..4418cb33ac8 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt @@ -0,0 +1,153 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApplicationExtension::class) +class FileUriUtilTest { + + private fun createMockVirtualFile(path: String, mockProtocol: String = "file", mockIsDirectory: Boolean = false): VirtualFile = + mockk { + every { fileSystem } returns mockk { + every { protocol } returns mockProtocol + } + every { url } returns path + every { isDirectory } returns mockIsDirectory + } + + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } + + @Test + fun `test basic unix path`() { + val virtualFile = createMockVirtualFile("/path/to/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test unix directory path`() { + val virtualFile = createMockVirtualFile("/path/to/directory/", mockIsDirectory = true) + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/directory") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test path with spaces`() { + val virtualFile = createMockVirtualFile("/path/with spaces/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/with%20spaces/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test root path`() { + val virtualFile = createMockVirtualFile("/") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test path with multiple separators`() { + val virtualFile = createMockVirtualFile("/path//to///file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("file:///path/to/file.txt") + assertThat(uri).isEqualTo(expected) + } + + @Test + fun `test very long path`() { + val longPath = "/a".repeat(256) + "/file.txt" + val virtualFile = createMockVirtualFile(longPath) + val uri = FileUriUtil.toUriString(virtualFile) + if (uri != null) { + assertThat(uri.startsWith("file:///")).isTrue + assertThat(uri.endsWith("/file.txt")).isTrue + } + } + + @Test + fun `test relative path`() { + val virtualFile = createMockVirtualFile("./relative/path/file.txt") + val uri = FileUriUtil.toUriString(virtualFile) + if (uri != null) { + assertThat(uri.contains("file.txt")).isTrue + assertThat(uri.startsWith("file:///")).isTrue + } + } + + @Test + fun `test jar protocol conversion`() { + val virtualFile = createMockVirtualFile( + "jar:file:///path/to/archive.jar!/com/example/Test.class", + "jar" + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example/Test.class") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test jrt protocol conversion`() { + val virtualFile = createMockVirtualFile( + "jrt://java.base/java/lang/String.class", + "jrt" + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jrt://java.base/java/lang/String.class") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test invalid jar url returns null`() { + val virtualFile = createMockVirtualFile( + "invalid:url:format", + "jar" + ) + val result = FileUriUtil.toUriString(virtualFile) + assertThat(result).isNull() + } + + @Test + fun `test jar protocol with directory`() { + val virtualFile = createMockVirtualFile( + "jar:file:///path/to/archive.jar!/com/example/", + "jar", + true + ) + val result = FileUriUtil.toUriString(virtualFile) + val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example") + assertThat(result).isEqualTo(expected) + } + + @Test + fun `test empty url in jar protocol`() { + val virtualFile = createMockVirtualFile( + "", + "jar", + true + ) + val result = FileUriUtil.toUriString(virtualFile) + assertThat(result).isNull() + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt new file mode 100644 index 00000000000..3ab9fd37c70 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt @@ -0,0 +1,110 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.vfs.VirtualFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI + +class WorkspaceFolderUtilTest { + + @Test + fun `createWorkspaceFolders returns empty list when project is default`() { + val mockProject = mockk() + every { mockProject.isDefault } returns true + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).isEmpty() + } + + @Test + fun `createWorkspaceFolders returns workspace folders for non-default project with modules`() { + val mockProject = mockk() + val mockModuleManager = mockk() + val mockModule1 = mockk() + val mockModule2 = mockk() + val mockModuleRootManager1 = mockk() + val mockModuleRootManager2 = mockk() + + val mockContentRoot1 = createMockVirtualFile( + URI("file:///path/to/root1"), + name = "root1" + ) + val mockContentRoot2 = createMockVirtualFile( + URI("file:///path/to/root2"), + name = "root2" + ) + + mockkStatic(ModuleManager::class, ModuleRootManager::class) + + every { mockProject.isDefault } returns false + every { ModuleManager.getInstance(mockProject) } returns mockModuleManager + every { mockModuleManager.modules } returns arrayOf(mockModule1, mockModule2) + every { mockModule1.name } returns "module1" + every { mockModule2.name } returns "module2" + every { ModuleRootManager.getInstance(mockModule1) } returns mockModuleRootManager1 + every { ModuleRootManager.getInstance(mockModule2) } returns mockModuleRootManager2 + every { mockModuleRootManager1.contentRoots } returns arrayOf(mockContentRoot1) + every { mockModuleRootManager2.contentRoots } returns arrayOf(mockContentRoot2) + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).hasSize(2) + assertThat(result[0].uri).isEqualTo(normalizeFileUri("file:///path/to/root1")) + assertThat(result[1].uri).isEqualTo(normalizeFileUri("file:///path/to/root2")) + assertThat(result[0].name).isEqualTo("module1") + assertThat(result[1].name).isEqualTo("module2") + } + + @Test + fun `createWorkspaceFolders handles modules with no content roots`() { + val mockProject = mockk() + val mockModuleManager = mockk() + val mockModule = mockk() + val mockModuleRootManager = mockk() + + mockkStatic(ModuleManager::class, ModuleRootManager::class) + + every { mockProject.isDefault } returns false + every { ModuleManager.getInstance(mockProject) } returns mockModuleManager + every { mockModuleManager.modules } returns arrayOf(mockModule) + every { ModuleRootManager.getInstance(mockModule) } returns mockModuleRootManager + every { mockModuleRootManager.contentRoots } returns emptyArray() + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertThat(result).isEmpty() + } + + private fun createMockVirtualFile(uri: URI, name: String): VirtualFile = + mockk { + every { url } returns uri.toString() + every { getName() } returns name + every { isDirectory } returns false + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + } + + // for windows unit tests + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + if (!uri.startsWith("file:///")) { + return uri + } + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt new file mode 100644 index 00000000000..73a959d11b3 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt @@ -0,0 +1,760 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.Application +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.fileTypes.FileType +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.newvfs.events.VFileCopyEvent +import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent +import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent +import com.intellij.util.messages.MessageBus +import com.intellij.util.messages.MessageBusConnection +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.eclipse.lsp4j.CreateFilesParams +import org.eclipse.lsp4j.DeleteFilesParams +import org.eclipse.lsp4j.DidChangeWatchedFilesParams +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.FileChangeType +import org.eclipse.lsp4j.RenameFilesParams +import org.eclipse.lsp4j.WorkspaceFolder +import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage +import org.eclipse.lsp4j.services.TextDocumentService +import org.eclipse.lsp4j.services.WorkspaceService +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil +import java.net.URI +import java.nio.file.Path +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture + +class WorkspaceServiceHandlerTest { + private lateinit var project: Project + private lateinit var mockLanguageServer: AmazonQLanguageServer + private lateinit var mockWorkspaceService: WorkspaceService + private lateinit var mockTextDocumentService: TextDocumentService + private lateinit var sut: WorkspaceServiceHandler + private lateinit var mockApplication: Application + + @BeforeEach + fun setup() { + project = mockk() + mockWorkspaceService = mockk() + mockTextDocumentService = mockk() + mockLanguageServer = mockk() + + mockApplication = mockk() + mockkStatic(ApplicationManager::class) + every { ApplicationManager.getApplication() } returns mockApplication + every { mockApplication.executeOnPooledThread(any>()) } answers { + CompletableFuture.completedFuture(firstArg>().call()) + } + + // Mock the LSP service + val mockLspService = mockk() + + // Mock the service methods on Project + every { project.getService(AmazonQLspService::class.java) } returns mockLspService + every { project.serviceIfCreated() } returns mockLspService + + // Mock the LSP service's executeSync method as a suspend function + every { + mockLspService.executeSync>(any()) + } coAnswers { + val func = firstArg CompletableFuture>() + func.invoke(mockLspService, mockLanguageServer) + } + + // Mock workspace service + every { mockLanguageServer.workspaceService } returns mockWorkspaceService + every { mockWorkspaceService.didCreateFiles(any()) } returns Unit + every { mockWorkspaceService.didDeleteFiles(any()) } returns Unit + every { mockWorkspaceService.didRenameFiles(any()) } returns Unit + every { mockWorkspaceService.didChangeWatchedFiles(any()) } returns Unit + every { mockWorkspaceService.didChangeWorkspaceFolders(any()) } returns Unit + + // Mock textDocument service (for didRename calls) + every { mockLanguageServer.textDocumentService } returns mockTextDocumentService + every { mockTextDocumentService.didOpen(any()) } returns Unit + every { mockTextDocumentService.didClose(any()) } returns Unit + + // Mock message bus + val messageBus = mockk() + every { project.messageBus } returns messageBus + val mockConnection = mockk() + every { messageBus.connect(any()) } returns mockConnection + every { mockConnection.subscribe(any(), any()) } just runs + + sut = WorkspaceServiceHandler(project, mockk()) + } + + @Test + fun `test didCreateFiles with Python file`() = runTest { + val pyUri = URI("file:///test/path") + val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Created, false, "py") + + sut.after(listOf(pyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) + } + + @Test + fun `test didCreateFiles with TypeScript file`() = runTest { + val tsUri = URI("file:///test/path") + val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Created, false, "ts") + + sut.after(listOf(tsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) + } + + @Test + fun `test didCreateFiles with JavaScript file`() = runTest { + val jsUri = URI("file:///test/path") + val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Created, false, "js") + + sut.after(listOf(jsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) + } + + @Test + fun `test didCreateFiles with Java file`() = runTest { + val javaUri = URI("file:///test/path") + val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Created, false, "java") + + sut.after(listOf(javaEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) + } + + @Test + fun `test didCreateFiles called for directory`() = runTest { + val dirUri = URI("file:///test/directory/path") + val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Created, true, "") + + sut.after(listOf(dirEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) + } + + @Test + fun `test didCreateFiles not called for unsupported file extension`() = runTest { + val txtUri = URI("file:///test/path") + val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Created, false, "txt") + + sut.after(listOf(txtEvent)) + + verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } + } + + @Test + fun `test didCreateFiles with move event`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + } + + @Test + fun `test didCreateFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + } + + @Test + fun `test didDeleteFiles with Python file`() = runTest { + val pyUri = URI("file:///test/path") + val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Deleted, false, "py") + + sut.after(listOf(pyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(pyUri.toString())) + } + + @Test + fun `test didDeleteFiles with TypeScript file`() = runTest { + val tsUri = URI("file:///test/path") + val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Deleted, false, "ts") + + sut.after(listOf(tsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(tsUri.toString())) + } + + @Test + fun `test didDeleteFiles with JavaScript file`() = runTest { + val jsUri = URI("file:///test/path") + val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Deleted, false, "js") + + sut.after(listOf(jsEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(jsUri.toString())) + } + + @Test + fun `test didDeleteFiles with Java file`() = runTest { + val javaUri = URI("file:///test/path") + val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Deleted, false, "java") + + sut.after(listOf(javaEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(javaUri.toString())) + } + + @Test + fun `test didDeleteFiles not called for unsupported file extension`() = runTest { + val txtUri = URI("file:///test/path") + val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Deleted, false, "txt") + + sut.after(listOf(txtEvent)) + + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + } + + @Test + fun `test didDeleteFiles called for directory`() = runTest { + val dirUri = URI("file:///test/directory/path") + val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Deleted, true, "") + + sut.after(listOf(dirEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(dirUri.toString())) + } + + @Test + fun `test didDeleteFiles handles both delete and move events in same batch`() = runTest { + val deleteUri = URI("file:///test/deleteFile") + val oldMoveUri = URI("file:///test/oldMoveFile") + val newMoveUri = URI("file:///test/newMoveFile") + + val deleteEvent = createMockVFileEvent(deleteUri, FileChangeType.Deleted, false, "py") + val moveEvent = createMockVFileMoveEvent(oldMoveUri, newMoveUri, "test.py") + + sut.after(listOf(deleteEvent, moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertThat(deleteParamsSlot.captured.files).hasSize(2) + assertThat(deleteParamsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(deleteUri.toString())) + assertThat(deleteParamsSlot.captured.files[1].uri).isEqualTo(normalizeFileUri(oldMoveUri.toString())) + } + + @Test + fun `test didDeleteFiles with move event of unsupported file type`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.txt") + + sut.after(listOf(moveEvent)) + + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + } + + @Test + fun `test didDeleteFiles with move event of directory`() = runTest { + val oldUri = URI("file:///test/oldDir") + val newUri = URI("file:///test/newDir") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "", true) + + sut.after(listOf(moveEvent)) + + val deleteParamsSlot = slot() + verify { mockWorkspaceService.didDeleteFiles(capture(deleteParamsSlot)) } + assertThat(deleteParamsSlot.captured.files[0].uri).isEqualTo(normalizeFileUri(oldUri.toString())) + } + + @Test + fun `test didChangeWatchedFiles with valid events`() = runTest { + // Arrange + val createURI = URI("file:///test/pathOfCreation") + val deleteURI = URI("file:///test/pathOfDeletion") + val changeURI = URI("file:///test/pathOfChange") + + val virtualFileCreate = createMockVFileEvent(createURI, FileChangeType.Created, false) + val virtualFileDelete = createMockVFileEvent(deleteURI, FileChangeType.Deleted, false) + val virtualFileChange = createMockVFileEvent(changeURI, FileChangeType.Changed, false) + + // Act + sut.after(listOf(virtualFileCreate, virtualFileDelete, virtualFileChange)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(createURI.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Created) + assertThat(paramsSlot.captured.changes[1].uri).isEqualTo(normalizeFileUri(deleteURI.toString())) + assertThat(paramsSlot.captured.changes[1].type).isEqualTo(FileChangeType.Deleted) + assertThat(paramsSlot.captured.changes[2].uri).isEqualTo(normalizeFileUri(changeURI.toString())) + assertThat(paramsSlot.captured.changes[2].type).isEqualTo(FileChangeType.Changed) + } + + @Test + fun `test didChangeWatchedFiles with move event reports both delete and create`() = runTest { + val oldUri = URI("file:///test/oldPath") + val newUri = URI("file:///test/newPath") + val moveEvent = createMockVFileMoveEvent(oldUri, newUri, "test.py") + + sut.after(listOf(moveEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + + assertThat(paramsSlot.captured.changes).hasSize(2) + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(oldUri.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Deleted) + assertThat(paramsSlot.captured.changes[1].uri).isEqualTo(normalizeFileUri(newUri.toString())) + assertThat(paramsSlot.captured.changes[1].type).isEqualTo(FileChangeType.Created) + } + + @Test + fun `test didChangeWatchedFiles with copy event`() = runTest { + val originalUri = URI("file:///test/original") + val newUri = URI("file:///test/new") + val copyEvent = createMockVFileCopyEvent(originalUri, newUri, "test.py") + + sut.after(listOf(copyEvent)) + + val paramsSlot = slot() + verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.changes[0].uri).isEqualTo(normalizeFileUri(newUri.toString())) + assertThat(paramsSlot.captured.changes[0].type).isEqualTo(FileChangeType.Created) + } + + @Test + fun `test no invoked messages when events are empty`() = runTest { + // Act + sut.after(emptyList()) + + // Assert + verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) } + verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) } + verify(exactly = 0) { mockWorkspaceService.didChangeWatchedFiles(any()) } + } + + @Test + fun `test didRenameFiles with supported file`() = runTest { + // Arrange + val oldName = "oldFile.java" + val newName = "newFile.java" + val propertyEvent = createMockPropertyChangeEvent( + oldName = oldName, + newName = newName, + isDirectory = false, + fileTypeName = "JAVA", + modificationStamp = 123L + ) + + // Act + sut.after(listOf(propertyEvent)) + + val closeParams = slot() + verify { mockTextDocumentService.didClose(capture(closeParams)) } + assertThat(closeParams.captured.textDocument.uri).isEqualTo(normalizeFileUri("file:///testDir/$oldName")) + + val openParams = slot() + verify { mockTextDocumentService.didOpen(capture(openParams)) } + with(openParams.captured.textDocument) { + assertThat(uri).isEqualTo(normalizeFileUri("file:///testDir/$newName")) + assertThat(text).isEqualTo("content") + assertThat(languageId).isEqualTo("java") + assertThat(version).isEqualTo(123) + } + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertThat(oldUri).isEqualTo(normalizeFileUri("file:///testDir/$oldName")) + assertThat(newUri).isEqualTo(normalizeFileUri("file:///testDir/$newName")) + } + } + + @Test + fun `test didRenameFiles with unsupported file type`() = runTest { + // Arrange + val propertyEvent = createMockPropertyChangeEvent( + oldName = "oldFile.txt", + newName = "newFile.txt", + isDirectory = false, + ) + + // Act + sut.after(listOf(propertyEvent)) + + // Assert + verify(exactly = 0) { mockTextDocumentService.didClose(any()) } + verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } + verify(exactly = 0) { mockWorkspaceService.didRenameFiles(any()) } + } + + @Test + fun `test didRenameFiles with directory`() = runTest { + // Arrange + val propertyEvent = createMockPropertyChangeEvent( + oldName = "oldDir", + newName = "newDir", + isDirectory = true + ) + + // Act + sut.after(listOf(propertyEvent)) + + // Assert + verify(exactly = 0) { mockTextDocumentService.didClose(any()) } + verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertThat(oldUri).isEqualTo(normalizeFileUri("file:///testDir/oldDir")) + assertThat(newUri).isEqualTo(normalizeFileUri("file:///testDir/newDir")) + } + } + + @Test + fun `test didRenameFiles with multiple files`() = runTest { + // Arrange + val event1 = createMockPropertyChangeEvent( + oldName = "old1.java", + newName = "new1.java", + fileTypeName = "JAVA", + modificationStamp = 123L + ) + val event2 = createMockPropertyChangeEvent( + oldName = "old2.py", + newName = "new2.py", + fileTypeName = "Python", + modificationStamp = 456L + ) + + // Act + sut.after(listOf(event1, event2)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + assertThat(paramsSlot.captured.files).hasSize(2) + + // Verify didClose and didOpen for both files + verify(exactly = 2) { mockTextDocumentService.didClose(any()) } + + val openParamsSlot = mutableListOf() + verify(exactly = 2) { mockTextDocumentService.didOpen(capture(openParamsSlot)) } + + assertThat(openParamsSlot[0].textDocument.languageId).isEqualTo("java") + assertThat(openParamsSlot[0].textDocument.version).isEqualTo(123) + assertThat(openParamsSlot[1].textDocument.languageId).isEqualTo("python") + assertThat(openParamsSlot[1].textDocument.version).isEqualTo(456) + } + + @Test + fun `rootsChanged does not notify when no changes`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val folders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + every { WorkspaceFolderUtil.createWorkspaceFolders(any()) } returns folders + + // Act + sut.beforeRootsChange(mockk()) + sut.rootsChanged(mockk()) + + // Assert + verify(exactly = 0) { mockWorkspaceService.didChangeWorkspaceFolders(any()) } + } + + // rootsChanged handles + @Test + fun `rootsChanged handles init`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = emptyList() + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder1") + } + + // rootsChanged handles additional files added to root + @Test + fun `rootsChanged handles additional files added to root`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder2") + } + + // rootsChanged handles removal of files from root + @Test + fun `rootsChanged handles removal of files from root`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.removed).hasSize(1) + assertThat(paramsSlot.captured.event.removed[0].name).isEqualTo("folder2") + } + + @Test + fun `rootsChanged handles multiple simultaneous additions and removals`() = runTest { + // Arrange + mockkObject(WorkspaceFolderUtil) + val oldFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder2" + uri = "file:///path/to/folder2" + } + ) + val newFolders = listOf( + WorkspaceFolder().apply { + name = "folder1" + uri = "file:///path/to/folder1" + }, + WorkspaceFolder().apply { + name = "folder3" + uri = "file:///path/to/folder3" + } + ) + + // Act + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders + sut.beforeRootsChange(mockk()) + every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders + sut.rootsChanged(mockk()) + + // Assert + val paramsSlot = slot() + verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) } + assertThat(paramsSlot.captured.event.added).hasSize(1) + assertThat(paramsSlot.captured.event.removed).hasSize(1) + assertThat(paramsSlot.captured.event.added[0].name).isEqualTo("folder3") + assertThat(paramsSlot.captured.event.removed[0].name).isEqualTo("folder2") + } + + private fun createMockVirtualFile( + uri: URI, + fileName: String, + isDirectory: Boolean = false, + fileTypeName: String = "PLAIN_TEXT", + modificationStamp: Long = 1L, + ): VirtualFile { + val nioPath = mockk { + every { toUri() } returns uri + } + val mockFileType = mockk { + every { name } returns fileTypeName + } + return mockk { + every { this@mockk.isDirectory } returns isDirectory + every { toNioPath() } returns nioPath + every { url } returns uri.path + every { path } returns "${uri.path}/$fileName" + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + every { this@mockk.inputStream } returns "content".byteInputStream() + every { fileType } returns mockFileType + every { this@mockk.modificationStamp } returns modificationStamp + } + } + + private fun createMockVFileEvent( + uri: URI, + type: FileChangeType = FileChangeType.Changed, + isDirectory: Boolean = false, + extension: String = "py", + ): VFileEvent { + val virtualFile = createMockVirtualFile(uri, "test.$extension", isDirectory) + return when (type) { + FileChangeType.Deleted -> mockk() + FileChangeType.Created -> mockk() + else -> mockk() + }.apply { + every { file } returns virtualFile + } + } + + private fun createMockPropertyChangeEvent( + oldName: String, + newName: String, + isDirectory: Boolean = false, + fileTypeName: String = "PLAIN_TEXT", + modificationStamp: Long = 1L, + ): VFilePropertyChangeEvent { + val parent = createMockVirtualFile(URI("file:///testDir/"), "testDir", true) + val newUri = URI("file:///testDir/$newName") + val file = createMockVirtualFile(newUri, newName, isDirectory, fileTypeName, modificationStamp) + every { file.parent } returns parent + + return mockk().apply { + every { propertyName } returns VirtualFile.PROP_NAME + every { this@apply.file } returns file + every { oldValue } returns oldName + every { newValue } returns newName + } + } + + private fun createMockVFileMoveEvent(oldUri: URI, newUri: URI, fileName: String, isDirectory: Boolean = false): VFileMoveEvent { + val oldFile = createMockVirtualFile(oldUri, fileName, isDirectory) + val newFile = createMockVirtualFile(newUri, fileName, isDirectory) + return mockk().apply { + every { file } returns newFile + every { oldPath } returns oldUri.path + every { oldParent } returns oldFile + } + } + + private fun createMockVFileCopyEvent(originalUri: URI, newUri: URI, fileName: String): VFileCopyEvent { + val newParent = mockk { + every { findChild(any()) } returns createMockVirtualFile(newUri, fileName) + every { fileSystem } returns mockk { + every { protocol } returns "file" + } + } + return mockk().apply { + every { file } returns createMockVirtualFile(originalUri, fileName) + every { this@apply.newParent } returns newParent + every { newChildName } returns fileName + } + } + + // for windows unit tests + private fun normalizeFileUri(uri: String): String { + if (!System.getProperty("os.name").lowercase().contains("windows")) { + return uri + } + + if (!uri.startsWith("file:///")) { + return uri + } + + val path = uri.substringAfter("file:///") + return "file:///C:/$path" + } +} diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt new file mode 100644 index 00000000000..6b9d425ba3d --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt @@ -0,0 +1,95 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.settings + +import com.intellij.util.xmlb.XmlSerializer +import org.assertj.core.api.Assertions.assertThat +import org.jdom.output.XMLOutputter +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import software.aws.toolkits.jetbrains.utils.xmlElement + +class LspSettingsTest { + private lateinit var lspSettings: LspSettings + + @BeforeEach + fun setUp() { + lspSettings = LspSettings() + lspSettings.loadState(LspConfiguration()) + } + + @Test + fun `artifact path is null by default`() { + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `artifact path can be set`() { + lspSettings.setArtifactPath("test\\lsp.js") + assertThat(lspSettings.getArtifactPath()) + .isEqualTo("test\\lsp.js") + } + + @Test + fun `empty artifact path is null`() { + lspSettings.setArtifactPath("") + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `blank artifact path is null`() { + lspSettings.setArtifactPath(" ") + assertThat(lspSettings.getArtifactPath()).isNull() + } + + @Test + fun `serialize settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """.trimIndent() + ) + lspSettings.setArtifactPath("temp\\lsp.js") + + XmlSerializer.serializeInto(lspSettings.state, element) + + val actual = XMLOutputter().outputString(element) + + // language=XML + val expected = """ + + + """.trimIndent() + + assertThat(actual).isEqualToIgnoringWhitespace(expected) + } + + @Test + fun `deserialize empty settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """ + ) + val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java) + assertThat(actual.artifactPath).isNull() + } + + @Test + fun `deserialize existing settings to ensure backwards compatibility`() { + val element = xmlElement( + """ + + + """.trimIndent() + ) + val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java) + assertThat(actual.artifactPath).isNotEmpty() + assertThat(actual.artifactPath).isEqualTo("temp\\lsp.js") + } +} diff --git a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml index 0c8b7b396e4..f1616e4db80 100644 --- a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml +++ b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-java.xml @@ -7,4 +7,10 @@ + + + + + diff --git a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml index 7998b8bbaa1..56887d944f0 100644 --- a/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml +++ b/plugins/amazonq/src/main/resources/META-INF/amazonq-ext-python.xml @@ -1,9 +1,15 @@ + + + + + diff --git a/plugins/amazonq/src/main/resources/META-INF/plugin.xml b/plugins/amazonq/src/main/resources/META-INF/plugin.xml index 527df280cb8..3d8f95579e5 100644 --- a/plugins/amazonq/src/main/resources/META-INF/plugin.xml +++ b/plugins/amazonq/src/main/resources/META-INF/plugin.xml @@ -94,6 +94,12 @@ defaultValue="false" restartRequired="true"/> + + + + diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt index 9e209a1a32d..dc4755688b0 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/PathUtils.kt @@ -212,3 +212,6 @@ private fun tryOrLogShortException(log: Logger, block: () -> T) = try { log.warn { "${e::class.simpleName}: ${e.message}" } null } + +// https://github.com/corretto/corretto-21/blob/364eb35886643e504344136075f4a2442d6c0cb0/src/jdk.zipfs/share/classes/jdk/nio/zipfs/ZipFileSystem.java#L90C33-L90C78 +const val ZIP_PROPERTY_POSIX = "enablePosixFileAttributes" diff --git a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt index a91ea7b9686..b1229f570ab 100644 --- a/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt +++ b/plugins/core/core/src/software/aws/toolkits/core/utils/ZipUtils.kt @@ -3,7 +3,6 @@ package software.aws.toolkits.core.utils -import java.io.BufferedInputStream import java.io.ByteArrayInputStream import java.io.IOException import java.io.InputStream @@ -17,7 +16,7 @@ import java.util.zip.ZipOutputStream */ fun ZipOutputStream.putNextEntry(entryName: String, file: Path) { try { - BufferedInputStream(Files.newInputStream(file)).use { inputStream -> + Files.newInputStream(file).buffered().use { inputStream -> putNextEntry(entryName, inputStream) } } catch (e: IOException) { diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt index af45361ac3d..1caf2e8e463 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt @@ -4,7 +4,6 @@ package software.aws.toolkits.jetbrains.core import com.intellij.openapi.application.PathManager -import com.intellij.util.io.HttpRequests import com.intellij.util.io.createDirectories import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver import software.aws.toolkits.core.utils.UrlFetcher @@ -41,11 +40,7 @@ class DefaultRemoteResourceResolverProvider : RemoteResourceResolverProvider { } override fun getETag(url: String): String = - HttpRequests.head(url) - .userAgent("AWS Toolkit for JetBrains") - .connect { request -> - request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() - } + getETagFromUrl(url) } } } diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt index 27958005fe4..8456be0f2bc 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt @@ -24,3 +24,10 @@ fun writeJsonToUrl(url: String, jsonString: String, indicator: ProgressIndicator request.write(jsonString) request.readString(indicator) } + +fun getETagFromUrl(url: String): String = + HttpRequests.head(url) + .userAgent(AwsClientManager.getUserAgent()) + .connect { request -> + request.connection.headerFields["ETag"]?.firstOrNull().orEmpty() + } diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties index e90647fa030..c7e667e7bc8 100644 --- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties +++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties @@ -141,9 +141,12 @@ amazonqFeatureDev.placeholder.after_code_generation=Choose an option to proceed amazonqFeatureDev.placeholder.after_monthly_limit=Chat input is disabled amazonqFeatureDev.placeholder.closed_session=Open a new chat tab to continue amazonqFeatureDev.placeholder.context_gathering_complete=Gathering context... +amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts=Downloading and Extracting Lsp Artifacts... amazonqFeatureDev.placeholder.generating_code=Generating code... +amazonqFeatureDev.placeholder.lsp=LSP amazonqFeatureDev.placeholder.new_plan=Describe your task or issue in as much detail as possible amazonqFeatureDev.placeholder.provide_code_feedback=Provide feedback or comments +amazonqFeatureDev.placeholder.select_lsp_artifact=Select LSP Artifact amazonqFeatureDev.placeholder.write_new_prompt=Write a new prompt apprunner.action.configure=Configure Service apprunner.action.create.service=Create Service... @@ -294,6 +297,8 @@ aws.settings.codewhisperer.project_context_index_max_size.tooltip=The maximum si aws.settings.codewhisperer.project_context_index_thread=Workspace index worker threads aws.settings.codewhisperer.project_context_index_thread.tooltip=Number of worker threads of Amazon Q local index process. Set to 0 to use system default worker threads for balanced performance. Please restart or reload IntelliJ after changing worker threads. aws.settings.codewhisperer.warning=To use Amazon Q, login with AWS Builder ID or AWS IAM Identity Center. +aws.settings.codewhisperer.workspace_context=Workspace context +aws.settings.codewhisperer.workspace_context.tooltip=When checked, Amazon Q will enable server side project context. aws.settings.dynamic_resources_configurable.clear_all=Clear All aws.settings.dynamic_resources_configurable.select_all=Select All aws.settings.dynamic_resources_configurable.suggest_types.dialog.message=Please suggest additional AWS resource types (e.g. AWS::S3::Bucket)\nyou would like to see supported in future releases.\n\n(max length: 2000 chars) @@ -1256,7 +1261,7 @@ ecs.service.not_found=Service {0} not found in cluster {1} ecs.task_definition.json_schema_name=AWS ECS Task Definition ecs.task_definitions=Task Definitions environment.variables.dialog.title=Environment Variables -executableCommon.auto_managed=Managed by AWS Toolkit +executableCommon.auto_managed=Managed by AWS executableCommon.auto_resolved=Auto-detected: {0} executableCommon.cli_not_configured={0} executable not configured executableCommon.configurable.title=External Tools