pytest-language-server 0.22.3

A blazingly fast Language Server Protocol implementation for pytest
Documentation
package com.github.bellini666.pytestlsp

import com.intellij.ide.plugins.cl.PluginAwareClassLoader
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.system.CpuArch
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption

@Service(Service.Level.PROJECT)
class PytestLanguageServerService(private val project: Project) {

    private val LOG = Logger.getInstance(PytestLanguageServerService::class.java)

    /**
     * Gets the path to the pytest-language-server executable.
     *
     * Priority order:
     * 1. Custom path via system property: -Dpytest.lsp.executable=/path/to/binary
     * 2. System PATH via system property: -Dpytest.lsp.useSystemPath=true
     * 3. Bundled binary (default)
     *
     * @return The path to the executable, or null if not found
     */
    fun getExecutablePath(): String? {
        // Check if user explicitly configured a custom path or wants to use PATH
        val customPath = System.getProperty("pytest.lsp.executable")
        val useSystemPath = System.getProperty("pytest.lsp.useSystemPath")?.toBoolean() ?: false

        if (customPath != null) {
            // User specified a custom path
            val file = File(customPath)
            if (file.exists()) {
                LOG.info("Using custom pytest-language-server from: $customPath")
                return customPath
            } else {
                LOG.error("Custom pytest-language-server path does not exist: $customPath")
                return null
            }
        }

        if (useSystemPath) {
            // User wants to use system PATH
            val pathExecutable = findInPath()
            if (pathExecutable != null) {
                LOG.info("Using pytest-language-server from PATH: $pathExecutable")
                return pathExecutable
            } else {
                LOG.error("pytest-language-server not found in PATH. Install via: pip install pytest-language-server")
                return null
            }
        }

        // Default: use bundled binary
        val bundledPath = getBundledBinaryPath()
        if (bundledPath != null) {
            LOG.info("Using bundled pytest-language-server: $bundledPath")
            return bundledPath
        }

        // This is an error - bundled binary should always be present in releases
        LOG.error("Bundled pytest-language-server binary not found. This is a packaging error. Please report at: https://github.com/bellini666/pytest-language-server/issues")
        return null
    }

    private fun findInPath(): String? {
        val pathEnv = System.getenv("PATH") ?: return null
        val pathSeparator = if (SystemInfo.isWindows) ";" else ":"
        val executable = if (SystemInfo.isWindows) "pytest-language-server.exe" else "pytest-language-server"

        pathEnv.split(pathSeparator).forEach { dir ->
            val file = File(dir, executable)
            if (file.exists() && file.canExecute()) {
                return file.absolutePath
            }
        }

        return null
    }

    private fun getBundledBinaryPath(): String? {
        val binaryName = when {
            SystemInfo.isWindows -> "pytest-language-server.exe"
            SystemInfo.isMac -> {
                if (CpuArch.isArm64()) {
                    "pytest-language-server-aarch64-apple-darwin"
                } else {
                    "pytest-language-server-x86_64-apple-darwin"
                }
            }
            SystemInfo.isLinux -> {
                if (CpuArch.isArm64()) {
                    "pytest-language-server-aarch64-unknown-linux-gnu"
                } else {
                    "pytest-language-server-x86_64-unknown-linux-gnu"
                }
            }
            else -> {
                LOG.error("Unsupported platform: ${SystemInfo.OS_NAME}")
                return null
            }
        }

        // Resolve the plugin directory via this plugin's own classloader, which exposes a
        // stable PluginDescriptor without the now-internal PluginManagerCore.getPlugin().
        val pluginDescriptor = (javaClass.classLoader as? PluginAwareClassLoader)?.pluginDescriptor
        if (pluginDescriptor == null) {
            LOG.error("Failed to find plugin descriptor")
            return null
        }

        val pluginPath = pluginDescriptor.pluginPath
        LOG.info("Plugin path: $pluginPath")

        // Try multiple possible locations for the binary
        val possibleLocations = listOf(
            pluginPath.resolve("lib/bin/$binaryName"),      // Inside plugin lib
            pluginPath.resolve("bin/$binaryName"),           // Direct bin directory
            pluginPath.resolve("pytest-language-server/lib/bin/$binaryName")  // Nested structure
        )

        for (location in possibleLocations) {
            val bundledBinary = location.toFile()
            LOG.info("Checking for binary at: ${bundledBinary.absolutePath}")
            if (bundledBinary.exists()) {
                // Ensure executable permissions on Unix-like systems
                if (!SystemInfo.isWindows) {
                    bundledBinary.setExecutable(true)
                }
                LOG.info("Found bundled binary at: ${bundledBinary.absolutePath}")
                return bundledBinary.absolutePath
            }
        }

        LOG.error("Bundled binary '$binaryName' not found in any of the expected locations: ${possibleLocations.map { it.toFile().absolutePath }}")
        return null
    }

    companion object {
        fun getInstance(project: Project): PytestLanguageServerService {
            return project.getService(PytestLanguageServerService::class.java)
        }
    }
}