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 index 0d774e5069a..18d878a46d7 100644 --- 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 @@ -47,6 +47,7 @@ import software.aws.toolkits.jetbrains.isDeveloperMode import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService 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 @@ -296,6 +297,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs } DefaultAuthCredentialsService(project, encryptionManager, this) + TextDocumentServiceHandler(project, this) WorkspaceServiceHandler(project, this) } 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..4cda131efe6 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -0,0 +1,131 @@ +// 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.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 + ) + } + + override fun beforeDocumentSaving(document: Document) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning + file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.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 + event.file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.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, + ) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri -> + languageServer.textDocumentService.didOpen( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = file.inputStream.readAllBytes().decodeToString() + } + } + ) + } + } + } + + override fun fileClosed( + source: FileEditorManager, + file: VirtualFile, + ) { + AmazonQLspService.executeIfRunning(project) { languageServer -> + file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + } + ) + } + } + } +} 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..86421e0acae --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -0,0 +1,301 @@ +// 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.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.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +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.Assert.assertEquals +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 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 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(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 + + 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 path = mockk { + every { toUri() } returns uri + } + + val file = mockk { + every { this@mockk.path } returns uri.path + every { toNioPath() } returns path + } + + // 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) { + assertEquals(uri.toString(), textDocument.uri) + assertEquals("test content", text) + } + } + } + + @Test + fun `didOpen runs on fileOpened`() = runTest { + // Create test file + val uri = URI.create("file:///test/path/file.txt") + val content = "test content" + val inputStream = content.byteInputStream() + + val path = mockk { + every { toUri() } returns uri + } + + val file = mockk { + every { this@mockk.path } returns uri.path + every { toNioPath() } returns path + every { this@mockk.inputStream } returns inputStream + } + + // Call the handler method + sut.fileOpened(mockk(), file) + + // Verify the correct LSP method was called with matching parameters + val paramsSlot = slot() + verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } + + with(paramsSlot.captured.textDocument) { + assertEquals(uri.toString(), this.uri) + assertEquals(content, text) + } + } + + @Test + fun `didClose runs on fileClosed`() = runTest { + val uri = URI.create("file:///test/path/file.txt") + val path = mockk { + every { toUri() } returns uri + } + val file = mockk { + every { this@mockk.path } returns uri.path + every { toNioPath() } returns path + } + + sut.fileClosed(mockk(), file) + + val paramsSlot = slot() + verify { mockTextDocumentService.didClose(capture(paramsSlot)) } + + assertEquals(uri.toString(), paramsSlot.captured.textDocument.uri) + } + + @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 path = mockk { + every { toUri() } returns uri + } + + val file = mockk { + every { this@mockk.path } returns uri.path + every { toNioPath() } returns path + } + + 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) { + assertEquals(uri.toString(), textDocument.uri) + assertEquals(123, textDocument.version) + assertEquals("changed content", contentChanges[0].text) + } + } + + @Test + fun `didSave does not run when URI is empty`() = runTest { + val document = mockk() + val path = mockk { + every { toUri() } returns URI.create("") + } + val file = mockk { + every { toNioPath() } returns path + } + + 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()) } + } + } +}