diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48d4c49..c537241 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,11 @@ jobs: shell: bash run: pwd && ls -la - - name: Build plugin + - name: Test plugin + shell: bash + run: ./gradlew test + + - name: Build plugin's binary shell: bash run: ./gradlew buildPlugin diff --git a/.idea/LanguageServersSettings.xml b/.idea/LanguageServersSettings.xml new file mode 100644 index 0000000..ff5bb67 --- /dev/null +++ b/.idea/LanguageServersSettings.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 0000000..2b0747c --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/copyright/zowe_ijmp.xml b/.idea/copyright/zowe_ijmp.xml new file mode 100644 index 0000000..3b5ccef --- /dev/null +++ b/.idea/copyright/zowe_ijmp.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index b2b3b86..fe0cee8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,10 +16,14 @@ plugins { id("java") id("org.jetbrains.kotlin.jvm") version "1.9.21" id("org.jetbrains.intellij") version "1.16.1" + id("org.jetbrains.kotlinx.kover") version "0.8.1" } group = properties("pluginGroup").get() version = properties("pluginVersion").get() +val kotestVersion = "5.9.1" +val mockkVersion = "1.13.11" +val junitVersion = "" repositories { mavenCentral() @@ -32,7 +36,20 @@ intellij { // pluginsRepositories { // custom("https://plugins.jetbrains.com/plugins/nightly/23257") // } - plugins.set(listOf("org.jetbrains.plugins.textmate", "com.redhat.devtools.lsp4ij:0.0.1")) + plugins.set(listOf("org.jetbrains.plugins.textmate", "com.redhat.devtools.lsp4ij:0.0.2")) +} + +dependencies { + // ===== Test env setup ===== + // Kotest + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") + // MockK + testImplementation("io.mockk:mockk:$mockkVersion") + // JUnit Platform (needed for Kotest) + testImplementation("org.junit.platform:junit-platform-launcher:1.10.2") + // ========================== + } tasks { @@ -50,4 +67,12 @@ tasks { sinceBuild.set(properties("pluginSinceBuild").get()) untilBuild.set(properties("pluginUntilBuild").get()) } + + test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + finalizedBy("koverHtmlReport") + } } diff --git a/src/main/kotlin/org/zowe/cobol/CobolProjectManagerListener.kt b/src/main/kotlin/org/zowe/cobol/CobolProjectManagerListener.kt new file mode 100644 index 0000000..f8ffba1 --- /dev/null +++ b/src/main/kotlin/org/zowe/cobol/CobolProjectManagerListener.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.project.ProjectManagerListener +import org.zowe.cobol.state.CobolPluginState +import org.zowe.cobol.state.InitializationOnly +import org.zowe.cobol.state.LanguageSupportStateService + +/** COBOL project manager listener. Listens to projects changes and react to them respectively */ +class CobolProjectManagerListener : ProjectManagerListener { + + /** + * Delete TextMate bundle if the last opened project is being closed + * (the only possible way to handle plug-in's TextMate bundle to be deleted when the plug-in is uninstalled) + */ + @OptIn(InitializationOnly::class) + override fun projectClosing(project: Project) { + val lsStateService = LanguageSupportStateService.instance + val pluginState = lsStateService.getPluginState(project) { CobolPluginState(project) } + + if (isLastProjectClosing() && (pluginState.isLSPClientReady() || pluginState.isLSPServerConnectionReady())) { + pluginState.unloadLSPClient {} + pluginState.finishDeinitialization {} + } + } + + /** Check if the project being closed is the last one that was opened */ + private fun isLastProjectClosing(): Boolean { + return ProjectManager.getInstance().openProjects.size == 1 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/zowe/cobol/init/CobolPluginState.kt b/src/main/kotlin/org/zowe/cobol/init/CobolPluginState.kt deleted file mode 100644 index c27ff5e..0000000 --- a/src/main/kotlin/org/zowe/cobol/init/CobolPluginState.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * This program and the accompanying materials are made available under the terms of the - * Eclipse Public License v2.0 which accompanies this distribution, and is available at - * https://www.eclipse.org/legal/epl-v20.html - * - * SPDX-License-Identifier: EPL-2.0 - * - * Copyright Contributors to the Zowe Project - */ - -package org.zowe.cobol.init - -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.PathManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import com.intellij.util.io.ZipUtil -import com.jetbrains.rd.util.firstOrNull -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.jetbrains.plugins.textmate.TextMateService -import org.jetbrains.plugins.textmate.configuration.TextMateUserBundlesSettings -import org.jetbrains.plugins.textmate.plist.JsonPlistReader -import com.intellij.openapi.util.io.FileUtil -import com.redhat.devtools.lsp4ij.client.LanguageClientImpl -import com.redhat.devtools.lsp4ij.server.JavaProcessCommandBuilder -import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider -import org.zowe.cobol.lsp.CobolLanguageClient -import java.nio.file.Path -import kotlin.io.path.exists -import kotlin.io.path.pathString -import kotlin.io.path.readText - -// https://github.com/eclipse-che4z/che-che4z-lsp-for-cobol -private const val VSIX_NAME = "cobol-language-support" -private const val VSIX_VERSION = "2.1.2" -private const val TEXTMATE_BUNDLE_NAME = "cobol" - -/** - * State of the COBOL plug-in. Provides initialization methods to set up all the things before the correct usage of - * the syntax highlighting and the LSP features - */ -class CobolPluginState private constructor() : Disposable { - - companion object { - private val projectToPluginState = mutableMapOf() - - /** - * Get initialized plug-in state by the project. If there is no plugin state initialized for the project, - * the new state is initialized - * @param project the project to get or initialize the plug-in's state - * @return initialized plug-in's state - */ - fun getPluginState(project: Project): CobolPluginState { - val pluginState = projectToPluginState[project] ?: CobolPluginState() - projectToPluginState[project] = pluginState - return pluginState - } - - /** Get all initialized plug-in's states */ - fun getAllPluginStates() = projectToPluginState - } - - private var currState: InitStates = InitStates.DOWN - private lateinit var stateProject: Project - private lateinit var vsixPlacingRootPath: Path - private lateinit var vsixUnpackedPath: Path - private lateinit var packageJsonPath: Path - private lateinit var lspServerPath: Path - - /** - * Compute all the paths needed for the plug-in's setup - * @return boolean that indicates if the paths are already exist - */ - private fun computeVSIXPlacingPaths(): Boolean { - vsixPlacingRootPath = PathManager.getConfigDir().resolve(VSIX_NAME) - vsixUnpackedPath = vsixPlacingRootPath.resolve("extension") - packageJsonPath = vsixUnpackedPath.resolve("package.json") - lspServerPath = vsixUnpackedPath.resolve("server").resolve("jar").resolve("server.jar") - val syntaxesPath = vsixUnpackedPath.resolve("syntaxes") - return vsixUnpackedPath.exists() && packageJsonPath.exists() && lspServerPath.exists() && syntaxesPath.exists() - } - - /** - * Unzip .vsix file in the 'resources' folder into the 'build' path, and later use the unzipped files to activate - * a TextMate bundle and an LSP server. If the paths of the unzipped .vsix are already exist, the processing is skipped - */ - @InitializationOnly - suspend fun unpackVSIX() { -// if (currState != InitStates.DOWN) throw IllegalStateException("Invalid plug-in state. Expected: ${InitStates.DOWN}, current: $currState") - val doPathsAlreadyExist = computeVSIXPlacingPaths() - if (!doPathsAlreadyExist) { - val activeClassLoader = this::class.java.classLoader - currState = InitStates.VSIX_UNPACK_TRIGGERED - val vsixNameWithVersion = "$VSIX_NAME-$VSIX_VERSION" - val vsixWithExt = "$vsixNameWithVersion.vsix" - return withContext(Dispatchers.IO) { - val vsixTempFile = FileUtil.createTempFile(VSIX_NAME, ".vsix") - val vsixResource = activeClassLoader - .getResourceAsStream(vsixWithExt) - ?: throw Exception("No $vsixWithExt found") - vsixTempFile.writeBytes(vsixResource.readAllBytes()) - ZipUtil.extract(vsixTempFile.toPath(), vsixPlacingRootPath, null) - currState = InitStates.VSIX_UNPACKED - } - } else { - currState = InitStates.VSIX_UNPACKED - } - } - - /** - * Load a TextMate bundle from previously unzipped .vsix. The version of the bundle to activate is the same as the - * .vsix package has. If there is an already activated version of the bundle with the same name, it will be deleted - * if the version is less than the one it is trying to activate. If the versions are the same, or there are any - * troubles unzipping/using the provided bundle, the processing does not continue, and the bundle that is already - * loaded to the IDE stays there - */ - @InitializationOnly - fun loadLanguageClientDefinition(project: Project): LanguageClientImpl { -// if (currState < InitStates.VSIX_UNPACKED) throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.VSIX_UNPACKED}, current: $currState") - currState = InitStates.TEXTMATE_BUNDLE_LOAD_TRIGGERED - val emptyBundleName = "$TEXTMATE_BUNDLE_NAME-0.0.0" - val newBundleName = "$TEXTMATE_BUNDLE_NAME-$VSIX_VERSION" - var existingBundles = TextMateUserBundlesSettings.instance?.bundles - val existingBundle = existingBundles - ?.filter { it.value.name.contains(TEXTMATE_BUNDLE_NAME) } - ?.firstOrNull() - val existingBundleName = existingBundle?.value?.name ?: emptyBundleName - if (existingBundleName < newBundleName) { - existingBundles = existingBundles?.filter { it.value.name != existingBundleName } ?: emptyMap() - TextMateUserBundlesSettings.instance?.setBundlesConfig(existingBundles) - TextMateUserBundlesSettings.instance?.addBundle(vsixUnpackedPath.toString(), newBundleName) - TextMateService.getInstance().reloadEnabledBundles() - } - currState = InitStates.TEXTMATE_BUNDLE_LOADED - return CobolLanguageClient(project) - } - - /** Extract COBOL language extensions, supported for recognition, from package.json in resources */ - private fun extractExtensionsFromPackageJson(): List { - val packageJsonContent = packageJsonPath.readText() - val cobolExtensions = mutableListOf() - try { - val json = JsonPlistReader.createJsonReader() - .readValue(packageJsonContent, Any::class.java) - if (json is Map<*, *>) { - val contributes = json["contributes"] - if (contributes is Map<*, *>) { - val languages = contributes["languages"] - if (languages is ArrayList<*>) { - for (language in languages) { - if (language is Map<*, *>) { - val id = language["id"] - if (id is String && id == "cobol") { - val extensions = language["extensions"] - if (extensions is ArrayList<*>) { - val extensionsStrs = extensions.map { - ext: Any? -> if (ext is String) { ext.trimStart('.') } else { "" } - } - cobolExtensions.addAll(extensionsStrs) - } - val filenames = language["filenames"] - if (filenames is ArrayList<*>) { - val filenamesStrs = filenames.map { - filename: Any? -> if (filename is String) { filename } else { "" } - } - cobolExtensions.addAll(filenamesStrs) - } - } - } - } - } - } - } - } catch (ignored: Exception) { - } - return cobolExtensions - } - - /** Initialize language server definition. Will run the LSP server command */ - @InitializationOnly - fun loadLanguageServerDefinition(project: Project): ProcessStreamConnectionProvider { -// if (currState < InitStates.VSIX_UNPACKED) throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.VSIX_UNPACKED}, current: $currState") - currState = InitStates.LSP_LOAD_TRIGGERED - val lspServerPathString = lspServerPath.pathString -// val extensions = extractExtensionsFromPackageJson() - val commands: MutableList = JavaProcessCommandBuilder(project, "cobol") - .setJar(lspServerPathString) - .create() - commands.add("pipeEnabled") - currState = InitStates.LSP_LOADED - return object : ProcessStreamConnectionProvider(commands) {} - } - - /** Initialization final step, no direct purposes for now */ - @InitializationOnly - fun finishInitialization(project: Project) { - if (currState != InitStates.LSP_LOADED || currState != InitStates.TEXTMATE_BUNDLE_LOADED) throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.LSP_LOADED}, current: $currState") - stateProject = project - currState = InitStates.UP - } - - /** Disable the COBOL plug-in TextMate bundle before the plug-in is unloaded */ - @InitializationOnly - fun disableTextMateBundle() { - if (currState != InitStates.UP) throw IllegalStateException("Invalid plug-in state. Expected: ${InitStates.UP}, current: $currState") - currState = InitStates.TEXTMATE_BUNDLE_UNLOAD_TRIGGERED - var existingBundles = TextMateUserBundlesSettings.instance?.bundles - existingBundles = existingBundles?.filter { it.value.name.contains(TEXTMATE_BUNDLE_NAME) } ?: emptyMap() - TextMateUserBundlesSettings.instance?.setBundlesConfig(existingBundles) - TextMateService.getInstance().reloadEnabledBundles() - currState = InitStates.TEXTMATE_BUNDLE_UNLOADED - } - - // TODO: finish, doc -// /** Disable LSP server wrappers together with LSP servers for the project before the plug-in's state is disposed */ -// @InitializationOnly -// fun disableLSP() { -// if (currState > InitStates.TEXTMATE_BUNDLE_UNLOADED) throw IllegalStateException("Invalid plug-in state. Expected: at most ${InitStates.TEXTMATE_BUNDLE_UNLOADED}, current: $currState") -// currState = InitStates.LSP_UNLOAD_TRIGGERED -// val projectPath = FileUtils.projectToUri(stateProject) -// val serverWrappers = IntellijLanguageClient.getAllServerWrappersFor(projectPath) -// serverWrappers.forEach { it.stop(true) } -// currState = InitStates.LSP_UNLOADED -// } - - /** Deinitialization final step, disposing purposes */ - @InitializationOnly - fun finishDeinitialization() { - if (currState > InitStates.LSP_UNLOADED) throw IllegalStateException("Invalid plug-in state. Expected: at most ${InitStates.LSP_UNLOADED}, current: $currState") - currState = InitStates.DOWN - this.dispose() - } - - override fun dispose() { - Disposer.dispose(this) - } -} diff --git a/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageClient.kt b/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageClient.kt index 9f58143..f509b14 100644 --- a/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageClient.kt +++ b/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageClient.kt @@ -1,11 +1,15 @@ /* + * Copyright (c) 2024 IBA Group. + * * This program and the accompanying materials are made available under the terms of the * Eclipse Public License v2.0 which accompanies this distribution, and is available at * https://www.eclipse.org/legal/epl-v20.html * * SPDX-License-Identifier: EPL-2.0 * - * Copyright Contributors to the Zowe Project + * Contributors: + * IBA Group + * Zowe Community */ package org.zowe.cobol.lsp @@ -13,16 +17,23 @@ package org.zowe.cobol.lsp import com.intellij.openapi.project.Project import com.redhat.devtools.lsp4ij.client.LanguageClientImpl import org.eclipse.lsp4j.ConfigurationParams +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType import java.util.concurrent.CompletableFuture -private const val DIALECT_REGISTRY_SECTION = "cobol-lsp.dialect.registry" -private const val SETTINGS_DIALECT = "cobol-lsp.dialects" -private const val SETTINGS_CPY_LOCAL_PATH = "cobol-lsp.cpy-manager.paths-local" -private const val DIALECT_LIBS = "cobol-lsp.dialect.libs" -private const val SETTINGS_CPY_EXTENSIONS = "cobol-lsp.cpy-manager.copybook-extensions" -private const val SETTINGS_SQL_BACKEND = "cobol-lsp.target-sql-backend" -private const val SETTINGS_CPY_FILE_ENCODING = "cobol-lsp.cpy-manager.copybook-file-encoding" -private const val SETTINGS_COMPILE_OPTIONS = "cobol-lsp.compiler.options" +const val DIALECT_REGISTRY_SECTION = "cobol-lsp.dialect.registry" +const val SETTINGS_DIALECT = "cobol-lsp.dialects" +const val SETTINGS_CPY_LOCAL_PATH = "cobol-lsp.cpy-manager.paths-local" +const val DIALECT_LIBS = "cobol-lsp.dialect.libs" +const val SETTINGS_CPY_EXTENSIONS = "cobol-lsp.cpy-manager.copybook-extensions" +const val SETTINGS_SQL_BACKEND = "cobol-lsp.target-sql-backend" +const val SETTINGS_CPY_FILE_ENCODING = "cobol-lsp.cpy-manager.copybook-file-encoding" +const val SETTINGS_COMPILE_OPTIONS = "cobol-lsp.compiler.options" +const val SETTINGS_CLIENT_LOGGING_LEVEL = "cobol-lsp.logging.level.root" +const val SETTINGS_LOCALE = "cobol-lsp.locale" +const val SETTINGS_COBOL_PROGRAM_LAYOUT = "cobol-lsp.cobol.program.layout" +const val SETTINGS_SUBROUTINE_LOCAL_PATH = "cobol-lsp.subroutine-manager.paths-local" +const val SETTINGS_CICS_TRANSLATOR = "cobol-lsp.cics.translator" /** COBOL LSP client wrapper. Provides a comprehensive support for the COBOL LSP communications */ class CobolLanguageClient(project: Project) : LanguageClientImpl(project) { @@ -39,7 +50,7 @@ class CobolLanguageClient(project: Project) : LanguageClientImpl(project) { for (item in configurationParams?.items ?: emptyList()) { try { if (item.section == DIALECT_REGISTRY_SECTION) { - System.err.println("${item.section} is not correctly recognized yet 1") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 1")) result.add(emptyList()) // val computed = DialectRegistry.getDialects() // result.add(computed) @@ -47,77 +58,77 @@ class CobolLanguageClient(project: Project) : LanguageClientImpl(project) { // val cfg = vscode.workspace.getConfiguration().get(item.section) when (item.section) { SETTINGS_DIALECT -> { - System.err.println("${item.section} is not correctly recognized yet 2") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 2")) result.add(emptyList()) // val computed = loadProcessorGroupDialectConfig(item, cfg) // result.add(computed) } SETTINGS_CPY_LOCAL_PATH -> { - System.err.println("${item.section} is not recognized yet 3") + logMessage(MessageParams(MessageType.Info, "${item.section} is not recognized yet 3")) // val computed = loadProcessorGroupCopybookPathsConfig(item, cfg as List) // result.add(computed) // } else if (item.section === DIALECT_LIBS && !!item.dialect) { } DIALECT_LIBS -> { - System.err.println("${item.section} is not recognized yet 4") + logMessage(MessageParams(MessageType.Info, "${item.section} is not recognized yet 4")) // val dialectLibs = SettingsService.getCopybookLocalPath(item.scopeUri, item.dialect) // result.add(dialectLibs) } SETTINGS_CPY_EXTENSIONS -> { - System.err.println("${item.section} is not correctly recognized 5") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized 5")) result.add(listOf(".CPY", ".COPY", ".cpy", ".copy","")) // val computed = loadProcessorGroupCopybookExtensionsConfig(item, cfg as List) // result.add(computed) } SETTINGS_SQL_BACKEND -> { - System.err.println("${item.section} is not correctly recognized yet 6") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 6")) result.add("DB2_SERVER") // val computed = loadProcessorGroupSqlBackendConfig(item, cfg as String) // result.add(computed) } SETTINGS_CPY_FILE_ENCODING -> { - System.err.println("${item.section} is not recognized yet 7") + logMessage(MessageParams(MessageType.Info, "${item.section} is not recognized yet 7")) // val computed = loadProcessorGroupCopybookEncodingConfig(item, cfg as String) // result.add(computed) } SETTINGS_COMPILE_OPTIONS -> { - System.err.println("${item.section} is not correctly recognized yet 8") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 8")) result.add(null) // val computed = loadProcessorGroupCompileOptionsConfig(item, cfg as String) // result.add(computed) } - "cobol-lsp.logging.level.root" -> { - System.err.println("${item.section} is not correctly recognized 11") + SETTINGS_CLIENT_LOGGING_LEVEL -> { + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized 11")) result.add("ERROR") } - "cobol-lsp.locale" -> { - System.err.println("${item.section} is not correctly recognized 12") + SETTINGS_LOCALE -> { + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized 12")) result.add("en") } - "cobol-lsp.cobol.program.layout is not recognized yet 13" -> { - System.err.println("${item.section} is not correctly recognized yet 12") + SETTINGS_COBOL_PROGRAM_LAYOUT -> { + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 12")) result.add(null) } - "cobol-lsp.subroutine-manager.paths-local" -> { - System.err.println("${item.section} is not correctly recognized yet 14") + SETTINGS_SUBROUTINE_LOCAL_PATH -> { + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 14")) result.add(emptyList()) } - "cobol-lsp.cics.translator" -> { - System.err.println("${item.section} is not correctly recognized yet 15") + SETTINGS_CICS_TRANSLATOR -> { + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 15")) result.add("true") } else -> { // result.add(cfg) - System.err.println("${item.section} is not recognized yet 9") + logMessage(MessageParams(MessageType.Info, "${item.section} is not recognized yet 9")) } } } else { - System.err.println("${item.section} is not correctly recognized yet 10") + logMessage(MessageParams(MessageType.Info, "${item.section} is not correctly recognized yet 10")) result.add(emptyList()) // result.add(vscode.workspace.getConfiguration().get(item.section)); } } catch (error: Throwable) { - System.err.println(error) + logMessage(MessageParams(MessageType.Error, "${error.message}\n${error.stackTrace}")) } } return CompletableFuture.completedFuture(result) diff --git a/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactory.kt b/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactory.kt index ff3907e..07f3a41 100644 --- a/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactory.kt +++ b/src/main/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactory.kt @@ -1,38 +1,56 @@ /* + * Copyright (c) 2024 IBA Group. + * * This program and the accompanying materials are made available under the terms of the * Eclipse Public License v2.0 which accompanies this distribution, and is available at * https://www.eclipse.org/legal/epl-v20.html * * SPDX-License-Identifier: EPL-2.0 * - * Copyright Contributors to the Zowe Project + * Contributors: + * IBA Group + * Zowe Community */ package org.zowe.cobol.lsp +import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.redhat.devtools.lsp4ij.LanguageServerFactory import com.redhat.devtools.lsp4ij.client.LanguageClientImpl import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider -import kotlinx.coroutines.runBlocking -import org.zowe.cobol.init.CobolPluginState -import org.zowe.cobol.init.InitializationOnly +import org.zowe.cobol.state.COBOL_PLUGIN_NOTIFICATION_ID +import org.zowe.cobol.state.CobolPluginState +import org.zowe.cobol.state.InitializationOnly +import org.zowe.cobol.state.LanguageSupportStateService -// TODO: doc -@OptIn(InitializationOnly::class) +/** COBOL language server factory to provide all the necessary functionalities for COBOL language support in the IDE */ class CobolLanguageServerFactory : LanguageServerFactory { override fun createConnectionProvider(project: Project): StreamConnectionProvider { - val pliPluginState = CobolPluginState.getPluginState(project) - runBlocking { - pliPluginState.unpackVSIX() + val lsStateService = LanguageSupportStateService.instance + val pluginState = lsStateService.getPluginState(project) { CobolPluginState(project) } + + @OptIn(InitializationOnly::class) + if (!pluginState.isLSPServerConnectionReady()) { + pluginState.prepareVSIX {} + pluginState.prepareLSPServerConnection {} } - return pliPluginState.loadLanguageServerDefinition(project) + + return pluginState.getReadyLSPServerConnection() as StreamConnectionProvider } override fun createLanguageClient(project: Project): LanguageClientImpl { - val pliPluginState = CobolPluginState.getPluginState(project) - return pliPluginState.loadLanguageClientDefinition(project) + val lsStateService = LanguageSupportStateService.instance + val pluginState = lsStateService.getPluginState(project) { CobolPluginState(project) } + + @OptIn(InitializationOnly::class) + if (!pluginState.isLSPClientReady()) { + pluginState.prepareLSPClient {} + pluginState.finishInitialization(COBOL_PLUGIN_NOTIFICATION_ID) {} + } + + return pluginState.getReadyLSPClient() as LanguageClientImpl } -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/zowe/cobol/state/CobolPluginState.kt b/src/main/kotlin/org/zowe/cobol/state/CobolPluginState.kt new file mode 100644 index 0000000..0827690 --- /dev/null +++ b/src/main/kotlin/org/zowe/cobol/state/CobolPluginState.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.state + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.project.Project +import com.intellij.util.io.ZipUtil +import com.jetbrains.rd.util.firstOrNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.plugins.textmate.TextMateService +import org.jetbrains.plugins.textmate.configuration.TextMateUserBundlesSettings +import com.intellij.openapi.util.io.FileUtil +import com.redhat.devtools.lsp4ij.client.LanguageClientImpl +import com.redhat.devtools.lsp4ij.server.JavaProcessCommandBuilder +import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider +import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider +import kotlinx.coroutines.runBlocking +import org.zowe.cobol.lsp.CobolLanguageClient +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.pathString + +const val COBOL_PLUGIN_NOTIFICATION_ID = "org.zowe.cobol.CobolNotificationId" + +// https://github.com/eclipse-che4z/che-che4z-lsp-for-cobol +private const val VSIX_NAME = "cobol-language-support" +private const val VSIX_VERSION = "2.1.2" +const val TEXTMATE_BUNDLE_NAME = "cobol" + +/** + * State of the COBOL plug-in. Provides initialization methods to set up all the things before the correct usage of + * the syntax highlighting and the LSP features + * @property project the project related to the plug-in's state + */ +@OptIn(InitializationOnly::class) +class CobolPluginState(private val project: Project) : LanguageSupportState() { + + private lateinit var vsixPlacingRootPath: Path + private lateinit var vsixUnpackedPath: Path + private lateinit var packageJsonPath: Path + private lateinit var lspServerPath: Path + private lateinit var lspServerConnection: StreamConnectionProvider + private lateinit var lspClient: LanguageClientImpl + + override fun isLSPServerConnectionReady(): Boolean { + return ::lspServerConnection.isInitialized + } + + override fun getReadyLSPServerConnection(): Any { + return if (isLSPServerConnectionReady()) lspServerConnection else throw IllegalStateException("LSP server connection is not ready") + } + + override fun isLSPClientReady(): Boolean { + return ::lspClient.isInitialized + } + + override fun getReadyLSPClient(): Any { + return if (isLSPClientReady()) lspClient else throw IllegalStateException("LSP client is not ready") + } + + /** + * Compute all the paths needed for the plug-in's setup + * @return boolean that indicates if the paths are already exist + */ + private fun computeVSIXPlacingPaths(): Boolean { + vsixPlacingRootPath = PathManager.getConfigDir().resolve(VSIX_NAME) + vsixUnpackedPath = vsixPlacingRootPath.resolve("extension") + packageJsonPath = vsixUnpackedPath.resolve("package.json") + lspServerPath = vsixUnpackedPath.resolve("server").resolve("jar").resolve("server.jar") + val syntaxesPath = vsixUnpackedPath.resolve("syntaxes") + return vsixUnpackedPath.exists() && packageJsonPath.exists() && lspServerPath.exists() && syntaxesPath.exists() + } + + /** Unpack VSIX package and place it under temp directory */ + private fun unpackVSIX() { + val activeClassLoader = this::class.java.classLoader + val vsixNameWithVersion = "$VSIX_NAME-$VSIX_VERSION" + val vsixWithExt = "$vsixNameWithVersion.vsix" + val vsixTempFile = FileUtil.createTempFile(VSIX_NAME, ".vsix") + val vsixResource = activeClassLoader + .getResourceAsStream(vsixWithExt) + ?: throw Exception("No $vsixWithExt found") + vsixTempFile.writeBytes(vsixResource.readAllBytes()) + ZipUtil.extract(vsixTempFile.toPath(), vsixPlacingRootPath, null) + } + + /** + * Unzip .vsix file in the 'resources' folder into the 'build' path, + * and later use the unzipped files to activate a TextMate bundle and an LSP server connection. + * If the paths of the unzipped .vsix are already exist, the processing is skipped + * @param prepFun the function for additional preparation steps after the VSIX package is prepared + * @see [LanguageSupportState.prepareVSIX] + */ + @InitializationOnly + override fun prepareVSIX(prepFun: () -> Unit) { + super.prepareVSIX { + runBlocking { + withContext(Dispatchers.IO) { + val doPathsAlreadyExist = computeVSIXPlacingPaths() + if (!doPathsAlreadyExist) { + unpackVSIX() + } + } + } + prepFun() + } + } + + /** Get instance of [JavaProcessCommandBuilder] for the project and language ID provided */ + private fun getJavaProcessCommandBuilder(): JavaProcessCommandBuilder { + return JavaProcessCommandBuilder(project, "cobol") + } + + /** + * Initialize language server definition. Will run the LSP server command + * @param prepFun the function for additional preparation steps after the LSP server connection instance is prepared + * @see [LanguageSupportState.prepareLSPServerConnection] + */ + @InitializationOnly + override fun prepareLSPServerConnection(prepFun: () -> Unit) { + return super.prepareLSPServerConnection { + val lspRunCommands = getJavaProcessCommandBuilder() + .setJar(lspServerPath.pathString) + .create() + lspRunCommands.add("pipeEnabled") + lspServerConnection = object : ProcessStreamConnectionProvider(lspRunCommands) {} + prepFun() + } + } + + /** + * Load a TextMate bundle from previously unzipped .vsix. The version of the bundle to activate is the same as the + * .vsix package has. If there is an already activated version of the bundle with the same name, it will be deleted + * if the version is less than the one it is trying to activate. If the versions are the same, or there are any + * troubles unzipping/using the provided bundle, the processing does not continue, and the bundle that is already + * loaded to the IDE stays there. As the finishing step, prepares the COBOL LSP client instance + * @param prepFun the function for additional preparation steps after the LSP client instance is prepared + * @see [LanguageSupportState.prepareLSPClient] + */ + @InitializationOnly + override fun prepareLSPClient(prepFun: () -> Unit) { + super.prepareLSPClient { + val emptyBundleName = "$TEXTMATE_BUNDLE_NAME-0.0.0" + val newBundleName = "$TEXTMATE_BUNDLE_NAME-$VSIX_VERSION" + val textMateUserBundlesSettings = TextMateUserBundlesSettings.instance + if (textMateUserBundlesSettings != null) { + var existingBundles = textMateUserBundlesSettings.bundles + val existingBundle = existingBundles + .filter { it.value.name.contains(TEXTMATE_BUNDLE_NAME) } + .firstOrNull() + val existingBundleName = existingBundle?.value?.name ?: emptyBundleName + if (existingBundleName < newBundleName) { + existingBundles = existingBundles.filter { it.value.name != existingBundleName } + textMateUserBundlesSettings.setBundlesConfig(existingBundles) + textMateUserBundlesSettings.addBundle(vsixUnpackedPath.toString(), newBundleName) + TextMateService.getInstance().reloadEnabledBundles() + } + } else { + Notification( + COBOL_PLUGIN_NOTIFICATION_ID, + "TextMate bundle is not initialized", + "TextMate user settings is failed to load, thus it is not possible to initialize the COBOL TextMate bundle", + NotificationType.WARNING + ).let { + Notifications.Bus.notify(it) + } + } + lspClient = CobolLanguageClient(project) + prepFun() + } + } + + /** + * Disable the COBOL plug-in TextMate bundle before the plug-in is unloaded + * @param unloadFun the function for additional unloading steps before the LSP client instance is unloaded + * @see [LanguageSupportState.unloadLSPClient] + */ + @InitializationOnly + override fun unloadLSPClient(unloadFun: () -> Unit) { + super.unloadLSPClient { + unloadFun() + val textMateUserBundlesSettings = TextMateUserBundlesSettings.instance + if (textMateUserBundlesSettings != null) { + var existingBundles = textMateUserBundlesSettings.bundles + existingBundles = existingBundles.filter { it.value.name.contains(TEXTMATE_BUNDLE_NAME) } + textMateUserBundlesSettings.setBundlesConfig(existingBundles) + TextMateService.getInstance().reloadEnabledBundles() + } else { + Notification( + COBOL_PLUGIN_NOTIFICATION_ID, + "TextMate bundle is not uninitialized", + "TextMate user settings is failed to load, thus it is not possible to remove the COBOL TextMate bundle", + NotificationType.WARNING + ).let { + Notifications.Bus.notify(it) + } + } + } + } + +} diff --git a/src/main/kotlin/org/zowe/cobol/init/InitStates.kt b/src/main/kotlin/org/zowe/cobol/state/InitStates.kt similarity index 53% rename from src/main/kotlin/org/zowe/cobol/init/InitStates.kt rename to src/main/kotlin/org/zowe/cobol/state/InitStates.kt index 0094dcd..f088c7b 100644 --- a/src/main/kotlin/org/zowe/cobol/init/InitStates.kt +++ b/src/main/kotlin/org/zowe/cobol/state/InitStates.kt @@ -1,27 +1,29 @@ /* + * Copyright (c) 2024 IBA Group. + * * This program and the accompanying materials are made available under the terms of the * Eclipse Public License v2.0 which accompanies this distribution, and is available at * https://www.eclipse.org/legal/epl-v20.html * * SPDX-License-Identifier: EPL-2.0 * - * Copyright Contributors to the Zowe Project + * Contributors: + * IBA Group + * Zowe Community */ -package org.zowe.cobol.init +package org.zowe.cobol.state /** Initialization states enum class to represent available plug-in's states */ enum class InitStates { DOWN, - LSP_UNLOADED, - LSP_UNLOAD_TRIGGERED, - TEXTMATE_BUNDLE_UNLOADED, - TEXTMATE_BUNDLE_UNLOAD_TRIGGERED, - VSIX_UNPACK_TRIGGERED, - VSIX_UNPACKED, - TEXTMATE_BUNDLE_LOAD_TRIGGERED, - TEXTMATE_BUNDLE_LOADED, - LSP_LOAD_TRIGGERED, - LSP_LOADED, + LSP_CLIENT_UNLOADED, + LSP_CLIENT_UNLOAD_TRIGGERED, + VSIX_PREPARE_TRIGGERED, + VSIX_PREPARED, + LSP_SERVER_CONNECTION_PREPARE_TRIGGERED, + LSP_SERVER_CONNECTION_PREPARED, + LSP_CLIENT_PREPARE_TRIGGERED, + LSP_CLIENT_PREPARED, UP } diff --git a/src/main/kotlin/org/zowe/cobol/init/InitializationOnly.kt b/src/main/kotlin/org/zowe/cobol/state/InitializationOnly.kt similarity index 84% rename from src/main/kotlin/org/zowe/cobol/init/InitializationOnly.kt rename to src/main/kotlin/org/zowe/cobol/state/InitializationOnly.kt index 6874718..4b91ba9 100644 --- a/src/main/kotlin/org/zowe/cobol/init/InitializationOnly.kt +++ b/src/main/kotlin/org/zowe/cobol/state/InitializationOnly.kt @@ -1,14 +1,18 @@ /* + * Copyright (c) 2024 IBA Group. + * * This program and the accompanying materials are made available under the terms of the * Eclipse Public License v2.0 which accompanies this distribution, and is available at * https://www.eclipse.org/legal/epl-v20.html * * SPDX-License-Identifier: EPL-2.0 * - * Copyright Contributors to the Zowe Project + * Contributors: + * IBA Group + * Zowe Community */ -package org.zowe.cobol.init +package org.zowe.cobol.state /** * Annotation for the restricted initialization methods. diff --git a/src/main/kotlin/org/zowe/cobol/state/LanguageSupportState.kt b/src/main/kotlin/org/zowe/cobol/state/LanguageSupportState.kt new file mode 100644 index 0000000..e3f1e45 --- /dev/null +++ b/src/main/kotlin/org/zowe/cobol/state/LanguageSupportState.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.state + +import com.intellij.notification.Notification +import com.intellij.notification.NotificationType +import com.intellij.notification.Notifications +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer + +/** + * Represents language support plug-in's state. Carries all the necessary instances + * to avoid double-initialization plus allows to initialize/de-initialize the plug-in's features. + * The main purpose is to provide a generic interface to handle the current state of the plug-in and manage + * it according to the previous and expected state + */ +abstract class LanguageSupportState : Disposable { + + /** The current state of the plug-in for a specific project */ + private var currState: InitStates = InitStates.DOWN + + /** Check if the LSP server connection instance is ready */ + abstract fun isLSPServerConnectionReady(): Boolean + + /** Get the LSP server connection instance */ + abstract fun getReadyLSPServerConnection(): Any + + /** Check if the LSP client instance is ready */ + abstract fun isLSPClientReady(): Boolean + + /** Get the LSP client instance */ + abstract fun getReadyLSPClient(): Any + + /** + * Prepare VSIX package before all the other preparations + * @param prepFun the function to prepare the VSIX package + */ + @InitializationOnly + open fun prepareVSIX(prepFun: () -> Unit) { + if (currState != InitStates.DOWN) + throw IllegalStateException("Invalid plug-in state. Expected: ${InitStates.DOWN}, current: $currState") + currState = InitStates.VSIX_PREPARE_TRIGGERED + prepFun() + currState = InitStates.VSIX_PREPARED + } + + /** + * Prepare LSP server connection instance. Expects that the VSIX is prepared + * @param prepFun the function to prepare LSP server connection instance + */ + @InitializationOnly + open fun prepareLSPServerConnection(prepFun: () -> Unit) { + if (currState < InitStates.VSIX_PREPARED) + throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.VSIX_PREPARED}, current: $currState") + currState = InitStates.LSP_SERVER_CONNECTION_PREPARE_TRIGGERED + prepFun() + currState = InitStates.LSP_SERVER_CONNECTION_PREPARED + } + + /** + * Prepare LSP client instance. Expects that the VSIX is prepared + * @param prepFun the function to prepare LSP client instance + */ + @InitializationOnly + open fun prepareLSPClient(prepFun: () -> Unit) { + if (currState < InitStates.VSIX_PREPARED) + throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.VSIX_PREPARED}, current: $currState") + currState = InitStates.LSP_CLIENT_PREPARE_TRIGGERED + prepFun() + currState = InitStates.LSP_CLIENT_PREPARED + } + + /** + * Initialization final step, puts the plug-in in [InitStates.UP] state. + * Will throw an error when both LSP client and LSP server connection instances are not prepared, + * shows notification when an LSP client is prepared and LSP server connection is not. + * @param notificationId the notification group ID to show the notification + * @param finishFun the function to finish initialization + */ + @InitializationOnly + open fun finishInitialization(notificationId: String, finishFun: () -> Unit) { + if (currState != InitStates.LSP_CLIENT_PREPARED) { + if (currState != InitStates.LSP_SERVER_CONNECTION_PREPARED) + throw IllegalStateException("Invalid plug-in state. Expected: at least ${InitStates.LSP_SERVER_CONNECTION_PREPARED}, current: $currState") + else + Notification( + notificationId, + "LSP client is not initialized", + "", + NotificationType.WARNING + ).let { + Notifications.Bus.notify(it) + } + } + finishFun() + currState = InitStates.UP + } + + /** + * Unload LSP client. It is the starting point of the plug-in's shutdown + * @param unloadFun the function to perform unloading + */ + @InitializationOnly + open fun unloadLSPClient(unloadFun: () -> Unit) { + if (currState != InitStates.UP) + throw IllegalStateException("Invalid plug-in state. Expected: ${InitStates.UP}, current: $currState") + currState = InitStates.LSP_CLIENT_UNLOAD_TRIGGERED + unloadFun() + currState = InitStates.LSP_CLIENT_UNLOADED + } + + /** + * Deinitialization final step. Disposing purposes + * @param unloadFinishFun the function to perform final unloading processes + */ + @InitializationOnly + open fun finishDeinitialization(unloadFinishFun: () -> Unit) { + if (currState > InitStates.LSP_CLIENT_UNLOADED) + throw IllegalStateException("Invalid plug-in state. Expected: at most ${InitStates.LSP_CLIENT_UNLOADED}, current: $currState") + unloadFinishFun() + this.dispose() + currState = InitStates.DOWN + } + + @InitializationOnly + override fun dispose() { + Disposer.dispose(this) + } +} \ No newline at end of file diff --git a/src/main/kotlin/org/zowe/cobol/state/LanguageSupportStateService.kt b/src/main/kotlin/org/zowe/cobol/state/LanguageSupportStateService.kt new file mode 100644 index 0000000..1af0a1e --- /dev/null +++ b/src/main/kotlin/org/zowe/cobol/state/LanguageSupportStateService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.state + +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project + +/** Service to provide language support states storage */ +@Service +class LanguageSupportStateService { + + companion object { + @JvmStatic + val instance + get() = service() + } + + private val projectToPluginState = mutableMapOf() + + /** + * Get initialized plug-in state by the project. If there is no plugin state initialized for the project, + * the new state is initialized + * @param project the project to get or initialize the plug-in's state + * @param defaultStateProvider the function that initializes the [LanguageSupportState] if it is not yet exists + * @return initialized plug-in's state + */ + fun getPluginState(project: Project, defaultStateProvider: () -> LanguageSupportState): LanguageSupportState { + return projectToPluginState.computeIfAbsent(project) { + defaultStateProvider() + } + } + +} \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index a9a9630..8d0df3c 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -23,6 +23,11 @@ com.redhat.devtools.lsp4ij org.jetbrains.plugins.textmate + + + + + + + + diff --git a/src/main/resources/META-INF/pluginIcon.svg b/src/main/resources/META-INF/pluginIcon.svg index ad741c9..f3aa8b6 100644 --- a/src/main/resources/META-INF/pluginIcon.svg +++ b/src/main/resources/META-INF/pluginIcon.svg @@ -1,10 +1,77 @@ - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/org/zowe/cobol/CobolProjectManagerListenerTestSpec.kt b/src/test/kotlin/org/zowe/cobol/CobolProjectManagerListenerTestSpec.kt new file mode 100644 index 0000000..f13285f --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/CobolProjectManagerListenerTestSpec.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.* +import org.zowe.cobol.state.InitializationOnly +import org.zowe.cobol.state.LanguageSupportState +import org.zowe.cobol.state.LanguageSupportStateService + +@OptIn(InitializationOnly::class) +class CobolProjectManagerListenerTestSpec : FunSpec({ + + context("CobolProjectManagerListenerTestSpec.projectClosing") { + lateinit var cobolProjectManagerListener: CobolProjectManagerListener + lateinit var lsStateMock: LanguageSupportState + + val projectMock = mockk() + var isUnloadLSPClientTriggered = false + var isFinishDeinitializationTriggered = false + + beforeTest { + isUnloadLSPClientTriggered = false + isFinishDeinitializationTriggered = false + + lsStateMock = mockk { + every { unloadLSPClient(any<() -> Unit>()) } answers { + firstArg<() -> Unit>().invoke() + isUnloadLSPClientTriggered = true + } + every { finishDeinitialization(any<() -> Unit>()) } answers { + firstArg<() -> Unit>().invoke() + isFinishDeinitializationTriggered = true + } + } + + cobolProjectManagerListener = spyk(CobolProjectManagerListener()) + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("check that the plugin is fully unloaded when the last project is being closed") { + mockkStatic(ProjectManager::getInstance) + every { ProjectManager.getInstance() } returns mockk { + every { openProjects } returns arrayOf(projectMock) + } + every { lsStateMock.isLSPClientReady() } returns true + + val lsStateServiceMock = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } returns lsStateMock + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateServiceMock + + cobolProjectManagerListener.projectClosing(projectMock) + + assertSoftly { isUnloadLSPClientTriggered shouldBe true } + assertSoftly { isFinishDeinitializationTriggered shouldBe true } + } + + test("check that the plugin is not unloaded when the project that is being closed is not the last one") { + mockkStatic(ProjectManager::getInstance) + every { ProjectManager.getInstance() } returns mockk { + every { openProjects } returns arrayOf(projectMock, mockk()) + } + + val lsStateServiceMock = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } answers { + secondArg<() -> LanguageSupportState>().invoke() + } + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateServiceMock + + cobolProjectManagerListener.projectClosing(projectMock) + + assertSoftly { isUnloadLSPClientTriggered shouldBe false } + assertSoftly { isFinishDeinitializationTriggered shouldBe false } + } + + test("check that the plugin is not unloaded when the last project is being closed but the LSP client and server are not initialized yet") { + mockkStatic(ProjectManager::getInstance) + every { ProjectManager.getInstance() } returns mockk { + every { openProjects } returns arrayOf(projectMock) + } + every { lsStateMock.isLSPClientReady() } returns false + every { lsStateMock.isLSPServerConnectionReady() } returns false + + val lsStateServiceMock = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } returns lsStateMock + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateServiceMock + + cobolProjectManagerListener.projectClosing(projectMock) + + assertSoftly { isUnloadLSPClientTriggered shouldBe false } + assertSoftly { isFinishDeinitializationTriggered shouldBe false } + } + } + +}) diff --git a/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageClientTestSpec.kt b/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageClientTestSpec.kt new file mode 100644 index 0000000..2cfd147 --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageClientTestSpec.kt @@ -0,0 +1,396 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.lsp + +import com.intellij.openapi.project.Project +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.shouldBe +import io.mockk.* +import org.eclipse.lsp4j.ConfigurationItem +import org.eclipse.lsp4j.ConfigurationParams +import org.eclipse.lsp4j.MessageParams + +class CobolLanguageClientTestSpec : FunSpec({ + + context("CobolLanguageClientTestSpec.configuration") { + afterTest { + unmockkAll() + clearAllMocks() + } + + test("process 'workspace/configuration' for dialect registry section request") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { section } returns DIALECT_REGISTRY_SECTION + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(DIALECT_REGISTRY_SECTION)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(emptyList()) } + } + + test("process 'workspace/configuration' for unrecognized request") { + var isLogMessageTriggeredCorrectly = false + + val someTestSection = "test-unrecognized-section" + val projectMock = mockk() + val configurationItemMock = mockk { + every { section } returns someTestSection + every { scopeUri } returns "" + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(someTestSection)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(emptyList()) } + } + + test("process 'workspace/configuration' for dialects request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_DIALECT + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_DIALECT)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(emptyList()) } + } + + test("process 'workspace/configuration' for cpy-manager paths-local request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_CPY_LOCAL_PATH + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_CPY_LOCAL_PATH)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf() } + } + + test("process 'workspace/configuration' for dialect libs request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns DIALECT_LIBS + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(DIALECT_LIBS)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf() } + } + + test("process 'workspace/configuration' for cpy-manager copybook-extensions request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_CPY_EXTENSIONS + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_CPY_EXTENSIONS)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(listOf(".CPY", ".COPY", ".cpy", ".copy", "")) } + } + + test("process 'workspace/configuration' for target-sql-backend request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_SQL_BACKEND + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_SQL_BACKEND)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf("DB2_SERVER") } + } + + test("process 'workspace/configuration' for cpy-manager copybook-file-encoding request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_CPY_FILE_ENCODING + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_CPY_FILE_ENCODING)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf() } + } + + test("process 'workspace/configuration' for cobol-lsp compiler options request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_COMPILE_OPTIONS + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_COMPILE_OPTIONS)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(null) } + } + + test("process 'workspace/configuration' for cobol-lsp logging level root request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_CLIENT_LOGGING_LEVEL + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_CLIENT_LOGGING_LEVEL)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf("ERROR") } + } + + test("process 'workspace/configuration' for cobol-lsp locale request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_LOCALE + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_LOCALE)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf("en") } + } + + test("process 'workspace/configuration' for cobol-lsp cobol program layout request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_COBOL_PROGRAM_LAYOUT + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_COBOL_PROGRAM_LAYOUT)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(null) } + } + + test("process 'workspace/configuration' for cobol-lsp subroutine-manager paths-local request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_SUBROUTINE_LOCAL_PATH + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_SUBROUTINE_LOCAL_PATH)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf(emptyList()) } + } + + test("process 'workspace/configuration' for cobol-lsp cics translator request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns SETTINGS_CICS_TRANSLATOR + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(SETTINGS_CICS_TRANSLATOR)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf("true") } + } + test("process 'workspace/configuration' for cobol-lsp unrecognized request with scope URI") { + var isLogMessageTriggeredCorrectly = false + + val someTestSection = "test-unrecognized-section" + val projectMock = mockk() + val configurationItemMock = mockk { + every { scopeUri } returns "test" + every { section } returns someTestSection + } + val configurationParamsMock = mockk { + every { items } returns listOf(configurationItemMock) + } + val cobolLanguageClient = spyk(CobolLanguageClient(projectMock)) + every { cobolLanguageClient.logMessage(any()) } answers { + if (firstArg().message.contains(someTestSection)) { + isLogMessageTriggeredCorrectly = true + } + } + + val result = cobolLanguageClient.configuration(configurationParamsMock).join() + + assertSoftly { isLogMessageTriggeredCorrectly shouldBe true } + assertSoftly { result shouldBeEqual listOf() } + } + } + +}) diff --git a/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactoryTestSpec.kt b/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactoryTestSpec.kt new file mode 100644 index 0000000..1cfaf0e --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/lsp/CobolLanguageServerFactoryTestSpec.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.lsp + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.redhat.devtools.lsp4ij.client.LanguageClientImpl +import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.* +import org.zowe.cobol.setPrivateFieldValue +import org.zowe.cobol.state.* + +@OptIn(InitializationOnly::class) +class CobolLanguageServerFactoryTestSpec : FunSpec({ + + context("CobolLanguageServerFactoryTestSpec.createConnectionProvider") { + afterTest { + unmockkAll() + clearAllMocks() + } + + test("create a connection provider, initializing all the elements") { + var isPrepareVSIXTriggered = false + var isPrepareLSPServerConnectionTriggered = false + + val projectMock = mockk() + val createdConnectionProvider = mockk() + + val lsStateMock = spyk(CobolPluginState(projectMock)) + every { lsStateMock.prepareVSIX(any<() -> Unit>()) } answers { + isPrepareVSIXTriggered = true + firstArg<() -> Unit>().invoke() + } + every { lsStateMock.prepareLSPServerConnection(any<() -> Unit>()) } answers { + isPrepareLSPServerConnectionTriggered = true + setPrivateFieldValue( + lsStateMock, + CobolPluginState::class.java, + "lspServerConnection", + createdConnectionProvider + ) + firstArg<() -> Unit>().invoke() + } + val lsStateService = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } returns lsStateMock + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + val cobolLanguageServerFactory = spyk(CobolLanguageServerFactory()) + + val result = cobolLanguageServerFactory.createConnectionProvider(projectMock) + + assertSoftly { isPrepareVSIXTriggered shouldBe true } + assertSoftly { isPrepareLSPServerConnectionTriggered shouldBe true } + assertSoftly { result shouldBe createdConnectionProvider } + } + + test("get a connection provider, that was already initialized") { + lateinit var defaultLSState: LanguageSupportState + var isPrepareVSIXTriggered = false + var isPrepareLSPServerConnectionTriggered = false + + val projectMock = mockk() + val createdConnectionProvider = mockk() + + val lsStateMock = spyk(CobolPluginState(projectMock)) + every { lsStateMock.prepareVSIX(any<() -> Unit>()) } answers { + isPrepareVSIXTriggered = true + } + every { lsStateMock.prepareLSPServerConnection(any<() -> Unit>()) } answers { + isPrepareLSPServerConnectionTriggered = true + } + setPrivateFieldValue( + lsStateMock, + CobolPluginState::class.java, + "lspServerConnection", + createdConnectionProvider + ) + val lsStateService = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } answers { + defaultLSState = secondArg<() -> LanguageSupportState>().invoke() + lsStateMock + } + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + val cobolLanguageServerFactory = spyk(CobolLanguageServerFactory()) + + val result = cobolLanguageServerFactory.createConnectionProvider(projectMock) + + assertSoftly { defaultLSState is CobolPluginState } + assertSoftly { isPrepareVSIXTriggered shouldBe false } + assertSoftly { isPrepareLSPServerConnectionTriggered shouldBe false } + assertSoftly { result shouldBe createdConnectionProvider } + } + } + + context("CobolLanguageServerFactoryTestSpec.createLanguageClient") { + afterTest { + unmockkAll() + clearAllMocks() + } + + test("create a language client, initializing all the elements") { + var isPrepareLSPClientTriggered = false + var isFinishInitializationTriggered = false + + val projectMock = mockk() + val createdLanguageClient = mockk() + + val lsStateMock = spyk(CobolPluginState(projectMock)) + every { lsStateMock.prepareLSPClient(any<() -> Unit>()) } answers { + isPrepareLSPClientTriggered = true + firstArg<() -> Unit>().invoke() + } + every { lsStateMock.finishInitialization(any(), any<() -> Unit>()) } answers { + isFinishInitializationTriggered = true + setPrivateFieldValue( + lsStateMock, + CobolPluginState::class.java, + "lspClient", + createdLanguageClient + ) + secondArg<() -> Unit>().invoke() + } + val lsStateService = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } returns lsStateMock + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + val cobolLanguageServerFactory = spyk(CobolLanguageServerFactory()) + + val result = cobolLanguageServerFactory.createLanguageClient(projectMock) + + assertSoftly { isPrepareLSPClientTriggered shouldBe true } + assertSoftly { isFinishInitializationTriggered shouldBe true } + assertSoftly { result shouldBe createdLanguageClient } + } + + test("get a language client, that was already initialized") { + lateinit var defaultLSState: LanguageSupportState + var isPrepareLSPClientTriggered = false + var isFinishInitializationTriggered = false + + val projectMock = mockk() + val createdLanguageClient = mockk() + + val lsStateMock = spyk(CobolPluginState(projectMock)) + every { lsStateMock.prepareLSPClient(any<() -> Unit>()) } answers { + isPrepareLSPClientTriggered = true + } + every { lsStateMock.finishInitialization(any(), any<() -> Unit>()) } answers { + isFinishInitializationTriggered = true + } + setPrivateFieldValue( + lsStateMock, + CobolPluginState::class.java, + "lspClient", + createdLanguageClient + ) + val lsStateService = mockk { + every { getPluginState(projectMock, any<() -> LanguageSupportState>()) } answers { + defaultLSState = secondArg<() -> LanguageSupportState>().invoke() + lsStateMock + } + } + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + val cobolLanguageServerFactory = spyk(CobolLanguageServerFactory()) + + val result = cobolLanguageServerFactory.createLanguageClient(projectMock) + + assertSoftly { defaultLSState is CobolPluginState } + assertSoftly { isPrepareLSPClientTriggered shouldBe false } + assertSoftly { isFinishInitializationTriggered shouldBe false } + assertSoftly { result shouldBe createdLanguageClient } + } + } + +}) diff --git a/src/test/kotlin/org/zowe/cobol/state/CobolPluginStateTestSpec.kt b/src/test/kotlin/org/zowe/cobol/state/CobolPluginStateTestSpec.kt new file mode 100644 index 0000000..e24f1bb --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/state/CobolPluginStateTestSpec.kt @@ -0,0 +1,573 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.state + +import com.intellij.notification.Notification +import com.intellij.notification.Notifications +import com.intellij.openapi.project.Project +import com.redhat.devtools.lsp4ij.server.JavaProcessCommandBuilder +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.* +import org.jetbrains.plugins.textmate.TextMateService +import org.jetbrains.plugins.textmate.configuration.TextMatePersistentBundle +import org.jetbrains.plugins.textmate.configuration.TextMateUserBundlesSettings +import org.junit.jupiter.api.assertThrows +import org.zowe.cobol.getPrivateFieldValue +import org.zowe.cobol.setPrivateFieldValue +import java.nio.file.Path +import kotlin.io.path.pathString +import kotlin.reflect.KFunction + +@OptIn(InitializationOnly::class) +class CobolPluginStateTestSpec : FunSpec({ + + context("CobolPluginStateTestSpec: lateinit vars") { + val cobolState = spyk(CobolPluginState(mockk())) + + test("make a try to get not yet initialized lateinit var") { + val exception = assertThrows { cobolState.getReadyLSPServerConnection() } + assertSoftly { exception.message shouldContain "LSP server connection is not ready" } + } + + test("make a try to get not yet initialized lateinit var") { + val exception = assertThrows { cobolState.getReadyLSPClient() } + assertSoftly { exception.message shouldContain "LSP client is not ready" } + } + } + + context("CobolPluginStateTestSpec.prepareVSIX") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalPrepFunctionCalled = false + var isUnpackVSIXCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + isUnpackVSIXCalled = false + + every { + cobolState["unpackVSIX"]() + } answers { + isUnpackVSIXCalled = true + Unit + } + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the VSIX unpacking process is finished") { + every { cobolState["computeVSIXPlacingPaths"]() } returns false + + cobolState.prepareVSIX { isFinalPrepFunctionCalled = true } + + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { isUnpackVSIXCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.VSIX_PREPARED } + } + + test("the state should change itself respectively without additional steps") { + every { cobolState["computeVSIXPlacingPaths"]() } returns true + + cobolState.prepareVSIX { isFinalPrepFunctionCalled = true } + + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { isUnpackVSIXCalled shouldBe false } + assertSoftly { currState shouldBe InitStates.VSIX_PREPARED} + } + + test("the state should throw error cause the state before VSIX unpack is not correct") { + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.VSIX_PREPARED + ) + val exception = assertThrows { cobolState.prepareVSIX { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + assertSoftly { isUnpackVSIXCalled shouldBe false } + } + } + + context("CobolPluginStateTestSpec.prepareLSPServerConnection") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the LSP server connection instance preparation is finished") { + val commandsListMock = mockk>() + every { commandsListMock.add(any()) } returns true + + val javaProcessCommandBuilderMock = mockk() + every { javaProcessCommandBuilderMock.setJar(any()) } returns javaProcessCommandBuilderMock + every { javaProcessCommandBuilderMock.create() } returns commandsListMock + + every { cobolState["getJavaProcessCommandBuilder"]() } returns javaProcessCommandBuilderMock + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.VSIX_PREPARED + ) + + val lspServerPathMock = mockk() + every { lspServerPathMock.pathString } returns "" + + setPrivateFieldValue( + cobolState, + CobolPluginState::class.java, + "lspServerPath", + lspServerPathMock + ) + + cobolState.prepareLSPServerConnection { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.LSP_SERVER_CONNECTION_PREPARED } + } + + test("the state should throw error cause the state before LSP server connection preparation is not correct") { + val exception = assertThrows { cobolState.prepareLSPServerConnection { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("CobolPluginStateTestSpec.prepareLSPClient") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the LSP client instance preparation is finished and a new bundle added") { + var isSetBundlesConfigTriggered = false + var isAddNewBundleTriggered = false + var isReloadEnabledBundlesTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_SERVER_CONNECTION_PREPARED + ) + + setPrivateFieldValue( + cobolState, + CobolPluginState::class.java, + "vsixUnpackedPath", + mockk() + ) + + val textMateBundle = mockk() + every { textMateBundle.name } returns "testBundle" + + mockkObject(TextMateUserBundlesSettings) + every { TextMateUserBundlesSettings.instance } returns mockk { + every { bundles } returns mapOf(textMateBundle.name to textMateBundle) + every { setBundlesConfig(any>()) } answers { + isSetBundlesConfigTriggered = true + } + every { addBundle(any(), any()) } answers { + isAddNewBundleTriggered = true + } + } + + mockkStatic(TextMateService::getInstance) + every { TextMateService.getInstance() } returns mockk { + every { reloadEnabledBundles() } answers { + isReloadEnabledBundlesTriggered = true + } + } + + cobolState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isSetBundlesConfigTriggered shouldBe true } + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { isReloadEnabledBundlesTriggered shouldBe true } + assertSoftly { currState shouldBe InitStates.LSP_CLIENT_PREPARED } + assertSoftly { isAddNewBundleTriggered shouldBe true } + } + + test("the state should change itself respectively after the LSP client instance preparation is finished and a new bundle is not added as TextMate settings instance is not yet initialized") { + var isReloadEnabledBundlesTriggered = false + var isNotificationTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_SERVER_CONNECTION_PREPARED + ) + + val notifyRef: (Notification) -> Unit = Notifications.Bus::notify + mockkStatic(notifyRef as KFunction<*>) + every { Notifications.Bus.notify(any()) } answers { + isNotificationTriggered = true + } + + mockkObject(TextMateUserBundlesSettings) + every { TextMateUserBundlesSettings.instance } returns null + + mockkStatic(TextMateService::getInstance) + every { TextMateService.getInstance() } returns mockk { + every { reloadEnabledBundles() } answers { + isReloadEnabledBundlesTriggered = true + } + } + + cobolState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { isNotificationTriggered shouldBe true } + assertSoftly { isReloadEnabledBundlesTriggered shouldBe false } + assertSoftly { currState shouldBe InitStates.LSP_CLIENT_PREPARED } + } + + test("the state should change itself respectively after the LSP client instance preparation is finished and a new bundle is not added as there is already a bundle with the version greater than the one to install") { + var isSetBundlesConfigTriggered = false + var isAddNewBundleTriggered = false + var isReloadEnabledBundlesTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_SERVER_CONNECTION_PREPARED + ) + + val textMateBundle = mockk() + every { textMateBundle.name } returns "$TEXTMATE_BUNDLE_NAME-999" + + mockkObject(TextMateUserBundlesSettings) + every { TextMateUserBundlesSettings.instance } returns mockk { + every { bundles } returns mapOf(textMateBundle.name to textMateBundle) + every { setBundlesConfig(any>()) } answers { + isSetBundlesConfigTriggered = true + } + every { addBundle(any(), any()) } answers { + isAddNewBundleTriggered = true + } + } + + mockkStatic(TextMateService::getInstance) + every { TextMateService.getInstance() } returns mockk { + every { reloadEnabledBundles() } answers { + isReloadEnabledBundlesTriggered = true + } + } + + cobolState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isSetBundlesConfigTriggered shouldBe false } + assertSoftly { isAddNewBundleTriggered shouldBe false } + assertSoftly { isReloadEnabledBundlesTriggered shouldBe false } + assertSoftly { currState shouldBe InitStates.LSP_CLIENT_PREPARED } + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + } + + test("the state should throw error cause the state before LSP client preparation is not correct") { + val exception = assertThrows { cobolState.prepareLSPClient { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("CobolPluginStateTestSpec.finishInitialization") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the initialization is finished") { + var isNotificationTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_CLIENT_PREPARED + ) + + val notifyRef: (Notification) -> Unit = Notifications.Bus::notify + mockkStatic(notifyRef as KFunction<*>) + every { Notifications.Bus.notify(any()) } answers { + isNotificationTriggered = true + } + + cobolState.finishInitialization("test") { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.UP } + assertSoftly { isNotificationTriggered shouldBe false } + } + + test("the state should change itself respectively after the initialization is finished together with notification as the TextMate bundle is not initialized") { + var isNotificationTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_SERVER_CONNECTION_PREPARED + ) + + val notifyRef: (Notification) -> Unit = Notifications.Bus::notify + mockkStatic(notifyRef as KFunction<*>) + every { Notifications.Bus.notify(any()) } answers { + isNotificationTriggered = true + } + + cobolState.finishInitialization("test") { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalPrepFunctionCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.UP } + assertSoftly { isNotificationTriggered shouldBe true } + } + + test("the state should throw error cause the state before initialization finish is not correct") { + val exception = assertThrows { cobolState.finishInitialization("test") { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("CobolPluginStateTestSpec.unloadLSPClient") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalUnloadFunctionCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalUnloadFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the LSP client is unloaded") { + var isSetBundlesConfigTriggered = false + var isReloadEnabledBundlesTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.UP + ) + + mockkObject(TextMateUserBundlesSettings) + every { TextMateUserBundlesSettings.instance } returns mockk { + every { bundles } returns emptyMap() + every { setBundlesConfig(any>()) } answers { + isSetBundlesConfigTriggered = true + } + } + + mockkStatic(TextMateService::getInstance) + every { TextMateService.getInstance() } returns mockk { + every { reloadEnabledBundles() } answers { + isReloadEnabledBundlesTriggered = true + } + } + + cobolState.unloadLSPClient { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isSetBundlesConfigTriggered shouldBe true } + assertSoftly { isReloadEnabledBundlesTriggered shouldBe true } + assertSoftly { isFinalUnloadFunctionCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.LSP_CLIENT_UNLOADED } + } + + test("the state should change itself respectively after the LSP client is unloaded but the notification for TextMate bundle unload failure is triggered") { + var isReloadEnabledBundlesTriggered = false + var isNotificationTriggered = false + + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.UP + ) + + val notifyRef: (Notification) -> Unit = Notifications.Bus::notify + mockkStatic(notifyRef as KFunction<*>) + every { Notifications.Bus.notify(any()) } answers { + isNotificationTriggered = true + } + + mockkObject(TextMateUserBundlesSettings) + every { TextMateUserBundlesSettings.instance } returns null + + mockkStatic(TextMateService::getInstance) + every { TextMateService.getInstance() } returns mockk { + every { reloadEnabledBundles() } answers { + isReloadEnabledBundlesTriggered = true + } + } + + cobolState.unloadLSPClient { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isReloadEnabledBundlesTriggered shouldBe false } + assertSoftly { isFinalUnloadFunctionCalled shouldBe true } + assertSoftly { isNotificationTriggered shouldBe true } + assertSoftly { currState shouldBe InitStates.LSP_CLIENT_UNLOADED } + } + + test("the state should throw error cause the state before LSP client unload is not correct") { + val exception = assertThrows { cobolState.unloadLSPClient { isFinalUnloadFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("CobolPluginStateTestSpec.finishDeinitialization") { + lateinit var projectMock: Project + lateinit var cobolState: CobolPluginState + + var isFinalUnloadFunctionCalled = false + + beforeTest { + projectMock = mockk() + cobolState = spyk(CobolPluginState(projectMock), recordPrivateCalls = true) + + isFinalUnloadFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the deinitialization process") { + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_CLIENT_UNLOADED + ) + + cobolState.finishDeinitialization { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState" + ) as InitStates + assertSoftly { isFinalUnloadFunctionCalled shouldBe true } + assertSoftly { currState shouldBe InitStates.DOWN } + } + + test("the state should throw error cause the state before deinitialization is not correct") { + setPrivateFieldValue( + cobolState, + LanguageSupportState::class.java, + "currState", + InitStates.UP + ) + val exception = assertThrows { cobolState.finishDeinitialization { isFinalUnloadFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + +}) diff --git a/src/test/kotlin/org/zowe/cobol/state/LanguageSupportStateServiceTestSpec.kt b/src/test/kotlin/org/zowe/cobol/state/LanguageSupportStateServiceTestSpec.kt new file mode 100644 index 0000000..235d3fa --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/state/LanguageSupportStateServiceTestSpec.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol.state + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.* +import org.zowe.cobol.setPrivateFieldValue +import org.zowe.cobol.state.InitializationOnly +import org.zowe.cobol.state.LanguageSupportState +import org.zowe.cobol.state.LanguageSupportStateService + +class LanguageSupportStateServiceTestSpec : FunSpec({ + + context("LanguageSupportStateServiceTestSpec.getPluginState") { + afterTest { + unmockkAll() + clearAllMocks() + } + + test("get already initialized plugin state") { + val projectMock = mockk() + val lsStateMock = mockk() + val defaultLSStateMock = mockk() + + val lsStateService = spyk(LanguageSupportStateService(), recordPrivateCalls = true) + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + setPrivateFieldValue( + lsStateService, + LanguageSupportStateService::class.java, + "projectToPluginState", + mutableMapOf(projectMock to lsStateMock) + ) + + val result = LanguageSupportStateService.instance.getPluginState(projectMock) { defaultLSStateMock } + + assertSoftly { result shouldBe lsStateMock } + } + + test("get newly initialized plugin state") { + val projectMock = mockk() + val lsStateMock = mockk() + val defaultLSStateMock = mockk() + + val lsStateService = spyk(LanguageSupportStateService(), recordPrivateCalls = true) + mockkObject(LanguageSupportStateService) + every { LanguageSupportStateService.instance } returns lsStateService + + setPrivateFieldValue( + lsStateService, + LanguageSupportStateService::class.java, + "projectToPluginState", + mutableMapOf(mockk() to lsStateMock) + ) + + val result = LanguageSupportStateService.instance.getPluginState(projectMock) { defaultLSStateMock } + + assertSoftly { result shouldBe defaultLSStateMock } + } + } + +}) diff --git a/src/test/kotlin/org/zowe/cobol/testUtils.kt b/src/test/kotlin/org/zowe/cobol/testUtils.kt new file mode 100644 index 0000000..c1a89b1 --- /dev/null +++ b/src/test/kotlin/org/zowe/cobol/testUtils.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 IBA Group. + * + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * IBA Group + * Zowe Community + */ + +package org.zowe.cobol + +import java.lang.reflect.Modifier + +/** + * Set private/protected field of the class for the object + * @param sourceObj the source object to mock the field for + * @param classWithTheField the class where the field is declared + * @param fieldName the field name to mock + * @param mockValue the mock value to set for the field + */ +fun setPrivateFieldValue(sourceObj: Any, classWithTheField: Class<*>, fieldName: String, mockValue: Any) { + return classWithTheField + .declaredFields + .filter { it.modifiers.and(Modifier.PRIVATE) > 0 || it.modifiers.and(Modifier.PROTECTED) > 0 } + .find { it.name == fieldName } + ?.also { it.isAccessible = true } + ?.set(sourceObj, mockValue) + ?: throw NoSuchFieldException("Field with name '$fieldName' is not found amongst private or protected fields") +} + +/** + * Get private/protected field of the class stored in the object + * @param sourceObj the source object to get the field from + * @param classWithTheField the class where the field is declared + * @param fieldName the field name to get value of + */ +fun getPrivateFieldValue(sourceObj: Any, classWithTheField: Class<*>, fieldName: String): Any { + val theField = classWithTheField + .declaredFields + .filter { it.modifiers.and(Modifier.PRIVATE) > 0 || it.modifiers.and(Modifier.PROTECTED) > 0 } + .find { it.name == fieldName } + ?.also { it.isAccessible = true } ?: throw NoSuchFieldException("Field with name '$fieldName' is not found amongst private or protected fields") + return theField.get(sourceObj) + ?: throw Exception("Field with name '$fieldName' is not accessible") +}