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 db49cb5a56e..0d774e5069a 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 @@ -37,7 +37,6 @@ import org.eclipse.lsp4j.InitializedParams import org.eclipse.lsp4j.SynchronizationCapabilities import org.eclipse.lsp4j.TextDocumentClientCapabilities import org.eclipse.lsp4j.WorkspaceClientCapabilities -import org.eclipse.lsp4j.WorkspaceFolder import org.eclipse.lsp4j.jsonrpc.Launcher import org.eclipse.lsp4j.launch.LSPLauncher import org.slf4j.event.Level @@ -48,6 +47,8 @@ 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.util.WorkspaceFolderUtil.createWorkspaceFolders +import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata import java.io.IOException import java.io.OutputStreamWriter @@ -55,7 +56,6 @@ import java.io.PipedInputStream import java.io.PipedOutputStream import java.io.PrintWriter import java.io.StringWriter -import java.net.URI import java.nio.charset.StandardCharsets import java.util.concurrent.Future import kotlin.time.Duration.Companion.seconds @@ -211,21 +211,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs fileOperations = FileOperationsWorkspaceCapabilities().apply { didCreate = true didDelete = true + didRename = true } } } - // needs case handling when project's base path is null: default projects/unit tests - private fun createWorkspaceFolders(): List = - project.basePath?.let { basePath -> - listOf( - WorkspaceFolder( - URI("file://$basePath").toString(), - project.name - ) - ) - }.orEmpty() // no folders to report or workspace not folder based - private fun createClientInfo(): ClientInfo { val metadata = ClientMetadata.getDefault() return ClientInfo().apply { @@ -239,7 +229,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs processId = ProcessHandle.current().pid().toInt() capabilities = createClientCapabilities() clientInfo = createClientInfo() - workspaceFolders = createWorkspaceFolders() + workspaceFolders = createWorkspaceFolders(project) initializationOptions = createExtendedClientMetadata() } @@ -306,6 +296,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs } DefaultAuthCredentialsService(project, encryptionManager, this) + WorkspaceServiceHandler(project, this) } override fun dispose() { 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..9722ab8c85e --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt @@ -0,0 +1,22 @@ +// 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.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import org.eclipse.lsp4j.WorkspaceFolder + +object WorkspaceFolderUtil { + fun createWorkspaceFolders(project: Project): List = + if (project.isDefault) { + emptyList() + } else { + ProjectRootManager.getInstance(project).contentRoots.map { contentRoot -> + WorkspaceFolder().apply { + name = contentRoot.name + this.uri = contentRoot.url + } + } + } +} 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..30e1e08713c --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt @@ -0,0 +1,198 @@ +// 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.VFileCreateEvent +import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent +import com.intellij.openapi.vfs.newvfs.events.VFileEvent +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.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.WorkspaceFolder +import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService +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 -> + val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.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 -> + val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.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 file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null + val oldName = event.oldValue as? String ?: return@mapNotNull null + if (event.newValue !is String) return@mapNotNull null + + // Construct old and new URIs + val parentPath = file.parent?.toNioPath() ?: return@mapNotNull null + val oldUri = parentPath.resolve(oldName).toUri().toString() + val newUri = file.toNioPath().toUri().toString() + + 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.mapNotNull { event -> + event.file?.toNioPath()?.toUri()?.toString()?.takeIf { it.isNotEmpty() }?.let { uri -> + FileEvent().apply { + this.uri = uri + type = when (event) { + is VFileCreateEvent -> FileChangeType.Created + is VFileDeleteEvent -> FileChangeType.Deleted + else -> FileChangeType.Changed + } + } + } + } + + if (validChanges.isNotEmpty()) { + languageServer.workspaceService.didChangeWatchedFiles( + DidChangeWatchedFilesParams().apply { + changes = validChanges + } + ) + } + } + } + + override fun after(events: List) { + // since we are using synchronous FileListener + pluginAwareExecuteOnPooledThread { + didCreateFiles(events.filterIsInstance()) + didDeleteFiles(events.filterIsInstance()) + 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/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..962d55c955e --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt @@ -0,0 +1,64 @@ +// 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.project.Project +import com.intellij.openapi.roots.ProjectRootManager +import com.intellij.openapi.vfs.VirtualFile +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WorkspaceFolderUtilTest { + + @Test + fun `createWorkspaceFolders returns empty list when no workspace folders`() { + val mockProject = mockk() + every { mockProject.isDefault } returns true + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertEquals(emptyList(), result) + } + + @Test + fun `createWorkspaceFolders returns workspace folders for non-default project`() { + val mockProject = mockk() + val mockProjectRootManager = mockk() + val mockContentRoot1 = mockk() + val mockContentRoot2 = mockk() + + every { mockProject.isDefault } returns false + every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager + every { mockProjectRootManager.contentRoots } returns arrayOf(mockContentRoot1, mockContentRoot2) + + every { mockContentRoot1.name } returns "root1" + every { mockContentRoot1.url } returns "file:///path/to/root1" + every { mockContentRoot2.name } returns "root2" + every { mockContentRoot2.url } returns "file:///path/to/root2" + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertEquals(2, result.size) + assertEquals("file:///path/to/root1", result[0].uri) + assertEquals("file:///path/to/root2", result[1].uri) + assertEquals("root1", result[0].name) + assertEquals("root2", result[1].name) + } + + @Test + fun `reateWorkspaceFolders returns empty list when project has no content roots`() { + val mockProject = mockk() + val mockProjectRootManager = mockk() + + every { mockProject.isDefault } returns false + every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager + every { mockProjectRootManager.contentRoots } returns emptyArray() + + val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject) + + assertEquals(emptyList(), result) + } +} 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..adb9e105f2c --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt @@ -0,0 +1,560 @@ +// 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.project.Project +import com.intellij.openapi.vfs.VirtualFile +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.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.eclipse.lsp4j.CreateFilesParams +import org.eclipse.lsp4j.DeleteFilesParams +import org.eclipse.lsp4j.DidChangeWatchedFilesParams +import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams +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.WorkspaceService +import org.junit.jupiter.api.Assertions.assertEquals +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 sut: WorkspaceServiceHandler + private lateinit var mockApplication: Application + + @BeforeEach + fun setup() { + project = mockk() + mockWorkspaceService = 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.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 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)) } + assertEquals(pyUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(tsUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(jsUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(javaUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(dirUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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 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)) } + assertEquals(pyUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(tsUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(jsUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(javaUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(dirUri.toString(), paramsSlot.captured.files[0].uri) + } + + @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)) } + assertEquals(createURI.toString(), paramsSlot.captured.changes[0].uri) + assertEquals(FileChangeType.Created, paramsSlot.captured.changes[0].type) + assertEquals(deleteURI.toString(), paramsSlot.captured.changes[1].uri) + assertEquals(FileChangeType.Deleted, paramsSlot.captured.changes[1].type) + assertEquals(changeURI.toString(), paramsSlot.captured.changes[2].uri) + assertEquals(FileChangeType.Changed, paramsSlot.captured.changes[2].type) + } + + @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, + ) + + // Act + sut.after(listOf(propertyEvent)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertEquals("file:///test/$oldName", oldUri) + assertEquals("file:///test/$newName", newUri) + } + } + + @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) { 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 + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + with(paramsSlot.captured.files[0]) { + assertEquals("file:///test/oldDir", oldUri) + assertEquals("file:///test/newDir", newUri) + } + } + + @Test + fun `test didRenameFiles with multiple files`() = runTest { + // Arrange + val event1 = createMockPropertyChangeEvent( + oldName = "old1.java", + newName = "new1.java", + ) + val event2 = createMockPropertyChangeEvent( + oldName = "old2.py", + newName = "new2.py", + ) + + // Act + sut.after(listOf(event1, event2)) + + // Assert + val paramsSlot = slot() + verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) } + assertEquals(2, paramsSlot.captured.files.size) + } + + @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)) } + assertEquals(1, paramsSlot.captured.event.added.size) + assertEquals("folder1", paramsSlot.captured.event.added[0].name) + } + + // 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)) } + assertEquals(1, paramsSlot.captured.event.added.size) + assertEquals("folder2", paramsSlot.captured.event.added[0].name) + } + + // 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)) } + assertEquals(1, paramsSlot.captured.event.removed.size) + assertEquals("folder2", paramsSlot.captured.event.removed[0].name) + } + + @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)) } + assertEquals(1, paramsSlot.captured.event.added.size) + assertEquals(1, paramsSlot.captured.event.removed.size) + assertEquals("folder3", paramsSlot.captured.event.added[0].name) + assertEquals("folder2", paramsSlot.captured.event.removed[0].name) + } + + private fun createMockVFileEvent(uri: URI, type: FileChangeType = FileChangeType.Changed, isDirectory: Boolean, extension: String = "py"): VFileEvent { + val virtualFile = mockk() + val nioPath = mockk() + + every { virtualFile.isDirectory } returns isDirectory + every { virtualFile.toNioPath() } returns nioPath + every { nioPath.toUri() } returns uri + every { virtualFile.path } returns "${uri.path}.$extension" + + return when (type) { + FileChangeType.Deleted -> mockk() + FileChangeType.Created -> mockk() + else -> mockk() + }.apply { + every { file } returns virtualFile + } + } + + // for didRename events + private fun createMockPropertyChangeEvent( + oldName: String, + newName: String, + isDirectory: Boolean = false, + ): VFilePropertyChangeEvent { + val file = mockk() + val parent = mockk() + val parentPath = mockk() + val filePath = mockk() + + every { file.parent } returns parent + every { parent.toNioPath() } returns parentPath + every { file.toNioPath() } returns filePath + every { file.isDirectory } returns isDirectory + every { file.path } returns "/test/$newName" + + every { parentPath.resolve(oldName) } returns mockk { + every { toUri() } returns URI("file:///test/$oldName") + } + every { filePath.toUri() } returns URI("file:///test/$newName") + + return mockk().apply { + every { propertyName } returns VirtualFile.PROP_NAME + every { this@apply.file } returns file + every { oldValue } returns oldName + every { newValue } returns newName + } + } +}