diff --git a/intellij-plugin/.github/workflows/build.yml b/.github/workflows/build.yml similarity index 65% rename from intellij-plugin/.github/workflows/build.yml rename to .github/workflows/build.yml index 48d4c49..7d0d89d 100644 --- a/intellij-plugin/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,9 +3,24 @@ name: Build on: [push, workflow_dispatch] jobs: - build: + build-intellij: runs-on: ubuntu-latest + # Changing WORKING_DIRECTORY, don't forget to change defaults.run.working-directory + env: + WORKING_DIRECTORY: intellij-plugin + defaults: + run: + working-directory: ./intellij-plugin steps: + + - name: Small check + shell: bash + run: ls -la + + - name: Small check 2 + shell: bash + run: cd ${{ github.workspace }}/${{ env.WORKING_DIRECTORY }} + - name: Checkout the plugin GitHub repository uses: actions/checkout@v4 @@ -24,6 +39,10 @@ jobs: shell: bash run: pwd && ls -la + - name: Test plugin + shell: bash + run: ./gradlew test + - name: Build plugin shell: bash run: ./gradlew buildPlugin @@ -32,7 +51,7 @@ jobs: id: artifact shell: bash run: | - cd ${{ github.workspace }}/build/distributions + cd ${{ github.workspace }}/${{ env.WORKING_DIRECTORY }}/build/distributions FILENAME=`ls *.zip` unzip "$FILENAME" -d content echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT diff --git a/intellij-plugin/build.gradle.kts b/intellij-plugin/build.gradle.kts index fac9c4c..005b6c0 100644 --- a/intellij-plugin/build.gradle.kts +++ b/intellij-plugin/build.gradle.kts @@ -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 */ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile @@ -16,10 +20,15 @@ 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 lsp4ijVersion = "0.3.0" +val kotestVersion = "5.9.1" +val mockkVersion = "1.13.11" +val junitVersion = "1.10.2" repositories { mavenCentral() @@ -29,7 +38,22 @@ repositories { // Read more: https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html intellij { version.set(properties("platformVersion").get()) - plugins.set(listOf("org.jetbrains.plugins.textmate", "com.redhat.devtools.lsp4ij:0.0.1")) + // pluginsRepositories { + // custom("https://plugins.jetbrains.com/plugins/nightly/23257") + // } + plugins.set(listOf("org.jetbrains.plugins.textmate", "com.redhat.devtools.lsp4ij:$lsp4ijVersion")) +} + +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:$junitVersion") + // ========================== } tasks { @@ -43,8 +67,16 @@ tasks { } patchPluginXml { - version.set(properties("pluginVersion").get()) - sinceBuild.set(properties("pluginSinceBuild").get()) - untilBuild.set(properties("pluginUntilBuild").get()) + version = properties("pluginVersion") + sinceBuild = properties("pluginSinceBuild") + untilBuild = properties("pluginUntilBuild") + } + + test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + finalizedBy("koverHtmlReport") } } diff --git a/intellij-plugin/gradle.properties b/intellij-plugin/gradle.properties index 4e310e4..25e2b50 100644 --- a/intellij-plugin/gradle.properties +++ b/intellij-plugin/gradle.properties @@ -16,4 +16,3 @@ pluginGroup = org.zowe # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 232 -pluginUntilBuild = 241.* diff --git a/intellij-plugin/gradlew b/intellij-plugin/gradlew old mode 100644 new mode 100755 diff --git a/intellij-plugin/src/main/kotlin/org/zowe/pli/PliProjectManagerListener.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/PliProjectManagerListener.kt new file mode 100644 index 0000000..63376ed --- /dev/null +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/PliProjectManagerListener.kt @@ -0,0 +1,47 @@ +/* + * 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.pli + +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.project.ProjectManagerListener +import org.zowe.pli.state.PliPluginState +import org.zowe.pli.state.InitializationOnly +import org.zowe.pli.state.LanguageSupportStateService + +/** PL/I project manager listener. Listens to projects changes and react to them respectively */ +class PliProjectManagerListener : 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) { PliPluginState(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/intellij-plugin/src/main/kotlin/org/zowe/pli/init/PliPluginState.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/init/PliPluginState.kt deleted file mode 100644 index 23ec70e..0000000 --- a/intellij-plugin/src/main/kotlin/org/zowe/pli/init/PliPluginState.kt +++ /dev/null @@ -1,233 +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.pli.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.openapi.util.io.FileUtil -import com.intellij.util.io.ZipUtil -import com.jetbrains.rd.util.firstOrNull -import com.redhat.devtools.lsp4ij.client.LanguageClientImpl -import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider -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 java.nio.file.Path -import kotlin.io.path.exists -import kotlin.io.path.pathString -import kotlin.io.path.readText - -private const val VSIX_NAME = "pli-language-support" -private const val VSIX_VERSION = "0.0.1" -private const val TEXTMATE_BUNDLE_NAME = "pli" - -/** - * State of the PL/I plug-in. Provides initialization methods to set up all the things before the correct usage of - * the syntax highlighting and the LSP features - */ -class PliPluginState 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): PliPluginState { - val pluginState = projectToPluginState[project] ?: PliPluginState() - 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("out").resolve("language").resolve("main.cjs") - 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 LanguageClientImpl(project) - } - - /** Extract PL/I 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 == "pl-one") { - 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(): 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 = listOf("node", lspServerPathString, "--stdio") - 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 plug-in's 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/intellij-plugin/src/main/kotlin/org/zowe/pli/lsp/PliLanguageServerFactory.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/lsp/PliLanguageServerFactory.kt index 5c4e1e9..a392d05 100644 --- a/intellij-plugin/src/main/kotlin/org/zowe/pli/lsp/PliLanguageServerFactory.kt +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/lsp/PliLanguageServerFactory.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.pli.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.pli.init.InitializationOnly -import org.zowe.pli.init.PliPluginState +import org.zowe.pli.state.PLI_PLUGIN_NOTIFICATION_ID +import org.zowe.pli.state.PliPluginState +import org.zowe.pli.state.InitializationOnly +import org.zowe.pli.state.LanguageSupportStateService -// TODO: doc -@OptIn(InitializationOnly::class) +/** PL/I language server factory to provide all the necessary functionalities for PL/I language support in the IDE */ class PliLanguageServerFactory : LanguageServerFactory { override fun createConnectionProvider(project: Project): StreamConnectionProvider { - val pliPluginState = PliPluginState.getPluginState(project) - runBlocking { - pliPluginState.unpackVSIX() + val lsStateService = LanguageSupportStateService.instance + val pluginState = lsStateService.getPluginState(project) { PliPluginState(project) } + + @OptIn(InitializationOnly::class) + if (!pluginState.isLSPServerConnectionReady()) { + pluginState.prepareVSIX {} + pluginState.prepareLSPServerConnection {} } - return pliPluginState.loadLanguageServerDefinition() + + return pluginState.getReadyLSPServerConnection() as StreamConnectionProvider } override fun createLanguageClient(project: Project): LanguageClientImpl { - val pliPluginState = PliPluginState.getPluginState(project) - return pliPluginState.loadLanguageClientDefinition(project) + val lsStateService = LanguageSupportStateService.instance + val pluginState = lsStateService.getPluginState(project) { PliPluginState(project) } + + @OptIn(InitializationOnly::class) + if (!pluginState.isLSPClientReady()) { + pluginState.prepareLSPClient {} + pluginState.finishInitialization(PLI_PLUGIN_NOTIFICATION_ID) {} + } + + return pluginState.getReadyLSPClient() as LanguageClientImpl } -} \ No newline at end of file +} diff --git a/intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitStates.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/InitStates.kt similarity index 53% rename from intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitStates.kt rename to intellij-plugin/src/main/kotlin/org/zowe/pli/state/InitStates.kt index 6757bfa..68f6a0f 100644 --- a/intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitStates.kt +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/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.pli.init +package org.zowe.pli.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 -} \ No newline at end of file +} diff --git a/intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitializationOnly.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/InitializationOnly.kt similarity index 84% rename from intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitializationOnly.kt rename to intellij-plugin/src/main/kotlin/org/zowe/pli/state/InitializationOnly.kt index d8fd785..4626e0f 100644 --- a/intellij-plugin/src/main/kotlin/org/zowe/pli/init/InitializationOnly.kt +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/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.pli.init +package org.zowe.pli.state /** * Annotation for the restricted initialization methods. diff --git a/intellij-plugin/src/main/kotlin/org/zowe/pli/state/LanguageSupportState.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/LanguageSupportState.kt new file mode 100644 index 0000000..0d55862 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/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.pli.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/intellij-plugin/src/main/kotlin/org/zowe/pli/state/LanguageSupportStateService.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/LanguageSupportStateService.kt new file mode 100644 index 0000000..96e1b4d --- /dev/null +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/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.pli.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/intellij-plugin/src/main/kotlin/org/zowe/pli/state/PliPluginState.kt b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/PliPluginState.kt new file mode 100644 index 0000000..e0b05d9 --- /dev/null +++ b/intellij-plugin/src/main/kotlin/org/zowe/pli/state/PliPluginState.kt @@ -0,0 +1,208 @@ +/* + * 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.pli.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 java.nio.file.Path +import kotlin.io.path.exists +import kotlin.io.path.pathString + +const val PLI_PLUGIN_NOTIFICATION_ID = "org.zowe.pli.PliNotificationId" + +private const val VSIX_NAME = "pli-language-support" +private const val VSIX_VERSION = "0.0.1" +const val TEXTMATE_BUNDLE_NAME = "pli" + +/** + * State of the PL/I 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 PliPluginState(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("out").resolve("language").resolve("main.cjs") + 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() + } + } + + /** + * 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 lspServerPathString = lspServerPath.pathString + val commands = listOf("node", lspServerPathString, "--stdio") + lspServerConnection = object : ProcessStreamConnectionProvider(commands) {} + 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 PL/I 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( + PLI_PLUGIN_NOTIFICATION_ID, + "TextMate bundle is not initialized", + "TextMate user settings is failed to load, thus it is not possible to initialize the PL/I TextMate bundle", + NotificationType.WARNING + ).let { + Notifications.Bus.notify(it) + } + } + lspClient = LanguageClientImpl(project) + prepFun() + } + } + + /** + * Disable the PL/I 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( + PLI_PLUGIN_NOTIFICATION_ID, + "TextMate bundle is not uninitialized", + "TextMate user settings is failed to load, thus it is not possible to remove the PL/I TextMate bundle", + NotificationType.WARNING + ).let { + Notifications.Bus.notify(it) + } + } + } + } + +} diff --git a/intellij-plugin/src/main/resources/META-INF/plugin.xml b/intellij-plugin/src/main/resources/META-INF/plugin.xml index 81cc228..6ad494f 100644 --- a/intellij-plugin/src/main/resources/META-INF/plugin.xml +++ b/intellij-plugin/src/main/resources/META-INF/plugin.xml @@ -23,6 +23,11 @@ com.redhat.devtools.lsp4ij org.jetbrains.plugins.textmate + + + + + + + + diff --git a/intellij-plugin/src/test/kotlin/org/zowe/pli/PliProjectManagerListenerTestSpec.kt b/intellij-plugin/src/test/kotlin/org/zowe/pli/PliProjectManagerListenerTestSpec.kt new file mode 100644 index 0000000..e464092 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/org/zowe/pli/PliProjectManagerListenerTestSpec.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.pli + +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.pli.state.InitializationOnly +import org.zowe.pli.state.LanguageSupportState +import org.zowe.pli.state.LanguageSupportStateService + +@OptIn(InitializationOnly::class) +class PliProjectManagerListenerTestSpec : FunSpec({ + + context("PliProjectManagerListenerTestSpec.projectClosing") { + lateinit var pliProjectManagerListener: PliProjectManagerListener + 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 + } + } + + pliProjectManagerListener = spyk(PliProjectManagerListener()) + } + + 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 + + pliProjectManagerListener.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 + + pliProjectManagerListener.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 + + pliProjectManagerListener.projectClosing(projectMock) + + assertSoftly { isUnloadLSPClientTriggered shouldBe false } + assertSoftly { isFinishDeinitializationTriggered shouldBe false } + } + } + +}) diff --git a/intellij-plugin/src/test/kotlin/org/zowe/pli/lsp/PliLanguageServerFactoryTestSpec.kt b/intellij-plugin/src/test/kotlin/org/zowe/pli/lsp/PliLanguageServerFactoryTestSpec.kt new file mode 100644 index 0000000..c13c452 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/org/zowe/pli/lsp/PliLanguageServerFactoryTestSpec.kt @@ -0,0 +1,198 @@ +/* + * 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.pli.lsp + +import com.intellij.openapi.project.Project +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.pli.setPrivateFieldValue +import org.zowe.pli.state.* + +@OptIn(InitializationOnly::class) +class PliLanguageServerFactoryTestSpec : FunSpec({ + + context("PliLanguageServerFactoryTestSpec.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(PliPluginState(projectMock)) + every { lsStateMock.prepareVSIX(any<() -> Unit>()) } answers { + isPrepareVSIXTriggered = true + firstArg<() -> Unit>().invoke() + } + every { lsStateMock.prepareLSPServerConnection(any<() -> Unit>()) } answers { + isPrepareLSPServerConnectionTriggered = true + setPrivateFieldValue( + lsStateMock, + PliPluginState::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 pliLanguageServerFactory = spyk(PliLanguageServerFactory()) + + val result = pliLanguageServerFactory.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(PliPluginState(projectMock)) + every { lsStateMock.prepareVSIX(any<() -> Unit>()) } answers { + isPrepareVSIXTriggered = true + } + every { lsStateMock.prepareLSPServerConnection(any<() -> Unit>()) } answers { + isPrepareLSPServerConnectionTriggered = true + } + setPrivateFieldValue( + lsStateMock, + PliPluginState::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 pliLanguageServerFactory = spyk(PliLanguageServerFactory()) + + val result = pliLanguageServerFactory.createConnectionProvider(projectMock) + + assertSoftly { defaultLSState is PliPluginState } + assertSoftly { isPrepareVSIXTriggered shouldBe false } + assertSoftly { isPrepareLSPServerConnectionTriggered shouldBe false } + assertSoftly { result shouldBe createdConnectionProvider } + } + } + + context("PliLanguageServerFactoryTestSpec.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(PliPluginState(projectMock)) + every { lsStateMock.prepareLSPClient(any<() -> Unit>()) } answers { + isPrepareLSPClientTriggered = true + firstArg<() -> Unit>().invoke() + } + every { lsStateMock.finishInitialization(any(), any<() -> Unit>()) } answers { + isFinishInitializationTriggered = true + setPrivateFieldValue( + lsStateMock, + PliPluginState::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 pliLanguageServerFactory = spyk(PliLanguageServerFactory()) + + val result = pliLanguageServerFactory.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(PliPluginState(projectMock)) + every { lsStateMock.prepareLSPClient(any<() -> Unit>()) } answers { + isPrepareLSPClientTriggered = true + } + every { lsStateMock.finishInitialization(any(), any<() -> Unit>()) } answers { + isFinishInitializationTriggered = true + } + setPrivateFieldValue( + lsStateMock, + PliPluginState::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 pliLanguageServerFactory = spyk(PliLanguageServerFactory()) + + val result = pliLanguageServerFactory.createLanguageClient(projectMock) + + assertSoftly { defaultLSState is PliPluginState } + assertSoftly { isPrepareLSPClientTriggered shouldBe false } + assertSoftly { isFinishInitializationTriggered shouldBe false } + assertSoftly { result shouldBe createdLanguageClient } + } + } + +}) diff --git a/intellij-plugin/src/test/kotlin/org/zowe/pli/state/LanguageSupportStateServiceTestSpec.kt b/intellij-plugin/src/test/kotlin/org/zowe/pli/state/LanguageSupportStateServiceTestSpec.kt new file mode 100644 index 0000000..e1ef547 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/org/zowe/pli/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.pli.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.pli.setPrivateFieldValue +import org.zowe.pli.state.InitializationOnly +import org.zowe.pli.state.LanguageSupportState +import org.zowe.pli.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/intellij-plugin/src/test/kotlin/org/zowe/pli/state/PliPluginStateTestSpec.kt b/intellij-plugin/src/test/kotlin/org/zowe/pli/state/PliPluginStateTestSpec.kt new file mode 100644 index 0000000..8af4641 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/org/zowe/pli/state/PliPluginStateTestSpec.kt @@ -0,0 +1,567 @@ +/* + * 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.pli.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.pli.getPrivateFieldValue +import org.zowe.pli.setPrivateFieldValue +import java.nio.file.Path +import kotlin.io.path.pathString +import kotlin.reflect.KFunction + +@OptIn(InitializationOnly::class) +class PliPluginStateTestSpec : FunSpec({ + + context("PliPluginStateTestSpec: lateinit vars") { + val pliState = spyk(PliPluginState(mockk())) + + test("make a try to get not yet initialized lateinit var") { + val exception = assertThrows { pliState.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 { pliState.getReadyLSPClient() } + assertSoftly { exception.message shouldContain "LSP client is not ready" } + } + } + + context("PliPluginStateTestSpec.prepareVSIX") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalPrepFunctionCalled = false + var isUnpackVSIXCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + isUnpackVSIXCalled = false + + every { + pliState["unpackVSIX"]() + } answers { + isUnpackVSIXCalled = true + Unit + } + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the VSIX unpacking process is finished") { + every { pliState["computeVSIXPlacingPaths"]() } returns false + + pliState.prepareVSIX { isFinalPrepFunctionCalled = true } + + val currState = getPrivateFieldValue( + pliState, + 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 { pliState["computeVSIXPlacingPaths"]() } returns true + + pliState.prepareVSIX { isFinalPrepFunctionCalled = true } + + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + LanguageSupportState::class.java, + "currState", + InitStates.VSIX_PREPARED + ) + val exception = assertThrows { pliState.prepareVSIX { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + assertSoftly { isUnpackVSIXCalled shouldBe false } + } + } + + context("PliPluginStateTestSpec.prepareLSPServerConnection") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(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 + + setPrivateFieldValue( + pliState, + LanguageSupportState::class.java, + "currState", + InitStates.VSIX_PREPARED + ) + + val lspServerPathMock = mockk() + every { lspServerPathMock.pathString } returns "" + + setPrivateFieldValue( + pliState, + PliPluginState::class.java, + "lspServerPath", + lspServerPathMock + ) + + pliState.prepareLSPServerConnection { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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 { pliState.prepareLSPServerConnection { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("PliPluginStateTestSpec.prepareLSPClient") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(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( + pliState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_SERVER_CONNECTION_PREPARED + ) + + setPrivateFieldValue( + pliState, + PliPluginState::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 + } + } + + pliState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + 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 + } + } + + pliState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + 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 + } + } + + pliState.prepareLSPClient { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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 { pliState.prepareLSPClient { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("PliPluginStateTestSpec.finishInitialization") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalPrepFunctionCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(projectMock), recordPrivateCalls = true) + + isFinalPrepFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the initialization is finished") { + var isNotificationTriggered = false + + setPrivateFieldValue( + pliState, + 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 + } + + pliState.finishInitialization("test") { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + 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 + } + + pliState.finishInitialization("test") { isFinalPrepFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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 { pliState.finishInitialization("test") { isFinalPrepFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("PliPluginStateTestSpec.unloadLSPClient") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalUnloadFunctionCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(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( + pliState, + 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 + } + } + + pliState.unloadLSPClient { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + 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 + } + } + + pliState.unloadLSPClient { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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 { pliState.unloadLSPClient { isFinalUnloadFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + + context("PliPluginStateTestSpec.finishDeinitialization") { + lateinit var projectMock: Project + lateinit var pliState: PliPluginState + + var isFinalUnloadFunctionCalled = false + + beforeTest { + projectMock = mockk() + pliState = spyk(PliPluginState(projectMock), recordPrivateCalls = true) + + isFinalUnloadFunctionCalled = false + } + + afterTest { + unmockkAll() + clearAllMocks() + } + + test("the state should change itself respectively after the deinitialization process") { + setPrivateFieldValue( + pliState, + LanguageSupportState::class.java, + "currState", + InitStates.LSP_CLIENT_UNLOADED + ) + + pliState.finishDeinitialization { isFinalUnloadFunctionCalled = true } + val currState = getPrivateFieldValue( + pliState, + 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( + pliState, + LanguageSupportState::class.java, + "currState", + InitStates.UP + ) + val exception = assertThrows { pliState.finishDeinitialization { isFinalUnloadFunctionCalled = true } } + assertSoftly { exception.message shouldContain "Invalid plug-in state" } + } + } + +}) diff --git a/intellij-plugin/src/test/kotlin/org/zowe/pli/testUtils.kt b/intellij-plugin/src/test/kotlin/org/zowe/pli/testUtils.kt new file mode 100644 index 0000000..a2b4bb8 --- /dev/null +++ b/intellij-plugin/src/test/kotlin/org/zowe/pli/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.pli + +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") +}