lsp-cli 0.1.6

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
pub(crate) mod lsp_peer;
mod temp_root;

use crate::detect::DetectionResult;
use crate::mason::registry::{MasonDownload, MasonNeovim, MasonPackage, MasonSource, OneOrMany};
use crate::runtime_state::RuntimeState;
use crate::suggest::SuggestedLanguage;
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::TempDir;

use self::temp_root::test_temp_root;

pub(crate) const LOCAL_SHARE_LSP_CLI: &str = ".local/share/lsp-cli";
#[cfg(test)]
pub(crate) const SUBPROCESS_HELPER_MODE_ENV: &str = "LSP_CLI_TEST_HELPER_MODE";
#[cfg(test)]
pub(crate) const SUBPROCESS_HELPER_OUTPUT_PATH_ENV: &str = "LSP_CLI_TEST_HELPER_OUTPUT_PATH";
#[cfg(test)]
pub(crate) const SUBPROCESS_HELPER_STDERR_ENV: &str = "LSP_CLI_TEST_HELPER_STDERR";
#[cfg(test)]
pub(crate) const SUBPROCESS_HELPER_EXIT_CODE_ENV: &str = "LSP_CLI_TEST_HELPER_EXIT_CODE";
#[cfg(test)]
pub(crate) const SUBPROCESS_HELPER_TEST_NAME: &str = "test_support::tests::subprocess_helper";

pub(crate) struct TestDir {
    dir: TempDir,
}

impl TestDir {
    pub(crate) fn new(prefix: &str) -> Self {
        let test_temp_root = test_temp_root().expect("secure test temp root should be selected");
        fs::create_dir_all(&test_temp_root).expect("test temp root should be created");
        let dir = tempfile::Builder::new()
            .prefix(&format!("lsp-cli-{prefix}-test-"))
            .tempdir_in(test_temp_root)
            .expect("temp dir should be created");
        Self { dir }
    }

    pub(crate) fn path(&self) -> &Path {
        self.dir.path()
    }

    pub(crate) fn write_file(&self, relative: &str, contents: impl AsRef<[u8]>) -> PathBuf {
        let path = self.path().join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).expect("parent dirs should be created");
        }

        fs::write(&path, contents).expect("file should be written");
        path
    }
}

struct EnvGuard {
    saved: Vec<(String, Option<OsString>)>,
}

impl EnvGuard {
    fn new(vars: &[(&str, OsString)]) -> Self {
        Self::with_removed(vars, &[])
    }

    fn with_removed(vars: &[(&str, OsString)], removed: &[&str]) -> Self {
        let saved = vars
            .iter()
            .map(|(name, _)| ((*name).to_string(), std::env::var_os(name)))
            .chain(
                removed
                    .iter()
                    .map(|name| ((*name).to_string(), std::env::var_os(name))),
            )
            .collect::<Vec<_>>();

        for (name, value) in vars {
            // Tests serialize env changes with a global mutex because process env is shared.
            unsafe { std::env::set_var(name, value) };
        }

        for name in removed {
            // Tests serialize env changes with a global mutex because process env is shared.
            unsafe { std::env::remove_var(name) };
        }

        Self { saved }
    }
}

impl Drop for EnvGuard {
    fn drop(&mut self) {
        for (name, value) in &self.saved {
            match value {
                Some(value) => {
                    // Tests serialize env changes with a global mutex because process env is shared.
                    unsafe { std::env::set_var(name, value) };
                }
                None => {
                    // Tests serialize env changes with a global mutex because process env is shared.
                    unsafe { std::env::remove_var(name) };
                }
            }
        }
    }
}

fn env_lock() -> &'static Mutex<()> {
    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| Mutex::new(()))
}

pub(crate) fn env_var(name: &'static str, value: impl AsRef<OsStr>) -> (&'static str, OsString) {
    (name, value.as_ref().to_os_string())
}

pub(crate) fn with_env_vars<T>(vars: &[(&str, OsString)], run: impl FnOnce() -> T) -> T {
    let _lock = env_lock().lock().expect("env lock should be available");
    let _guard = EnvGuard::new(vars);
    run()
}

pub(crate) fn without_env_vars<T>(vars: &[&str], run: impl FnOnce() -> T) -> T {
    let _lock = env_lock().lock().expect("env lock should be available");
    let _guard = EnvGuard::with_removed(&[], vars);
    run()
}

pub(crate) fn runtime_state_in_home(home: &Path) -> RuntimeState {
    RuntimeState::new(home.join(LOCAL_SHARE_LSP_CLI))
}

pub(crate) fn write_registry(state: &RuntimeState, packages: &[MasonPackage]) {
    fs::create_dir_all(state.registry_dir()).expect("registry dir should be created");
    let bytes = serde_json::to_vec(packages).expect("registry should serialize");
    fs::write(state.registry_json_path(), bytes).expect("registry should be written");

    // Seed fresh metadata too so tests use the local cache immediately instead of
    // attempting a network refresh before falling back to the registry file.
    let refreshed_at_epoch_seconds = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock should be after unix epoch")
        .as_secs();
    let metadata = serde_json::json!({
        "release_tag": "test",
        "refreshed_at_epoch_seconds": refreshed_at_epoch_seconds,
        "digest": null,
    });
    fs::write(
        state.registry_metadata_path(),
        serde_json::to_vec(&metadata).expect("registry metadata should serialize"),
    )
    .expect("registry metadata should be written");
}

pub(crate) fn pyright_package() -> MasonPackage {
    MasonPackage {
        name: "pyright".to_string(),
        categories: vec!["LSP".to_string()],
        source: MasonSource {
            id: "pkg:npm/pyright@1.1.409".to_string(),
            extra_packages: Vec::new(),
            asset: None,
            download: None,
            version_overrides: Vec::new(),
        },
        bin: BTreeMap::from([(
            "pyright-langserver".to_string(),
            "npm:pyright-langserver".to_string(),
        )]),
        share: BTreeMap::new(),
        neovim: MasonNeovim {
            lspconfig: Some("pyright".to_string()),
        },
    }
}

pub(crate) fn roslyn_package() -> MasonPackage {
    MasonPackage {
        name: "roslyn-language-server".to_string(),
        categories: vec!["LSP".to_string()],
        source: MasonSource {
            id: "pkg:nuget/roslyn-language-server@5.11.0-1.26380.4".to_string(),
            extra_packages: Vec::new(),
            asset: None,
            download: None,
            version_overrides: Vec::new(),
        },
        bin: BTreeMap::from([(
            "roslyn-language-server".to_string(),
            "nuget:roslyn-language-server".to_string(),
        )]),
        share: BTreeMap::new(),
        neovim: MasonNeovim {
            lspconfig: Some("roslyn_ls".to_string()),
        },
    }
}

pub(crate) fn jdtls_package() -> MasonPackage {
    MasonPackage {
        name: "jdtls".to_string(),
        categories: vec!["LSP".to_string()],
        source: MasonSource {
            id: "pkg:generic/eclipse/eclipse.jdt.ls@v1.0.0".to_string(),
            extra_packages: Vec::new(),
            asset: None,
            download: Some(OneOrMany::Many(vec![MasonDownload {
                target: Some(OneOrMany::One("linux".to_string())),
                files: BTreeMap::from([(
                    "jdtls.tar.gz".to_string(),
                    "https://example.invalid/jdtls.tar.gz".to_string(),
                )]),
                bin: None,
                config: Some("config_linux/".to_string()),
                man: None,
            }])),
            version_overrides: Vec::new(),
        },
        bin: BTreeMap::from([("jdtls".to_string(), "python:bin/jdtls".to_string())]),
        share: BTreeMap::from([
            ("jdtls/plugins/".to_string(), "plugins/".to_string()),
            (
                "jdtls/config/".to_string(),
                "{{source.download.config}}".to_string(),
            ),
        ]),
        neovim: MasonNeovim {
            lspconfig: Some("jdtls".to_string()),
        },
    }
}

pub(crate) fn suggested_language(
    program: &str,
    config_id: &str,
    server: &str,
    language: &str,
) -> SuggestedLanguage {
    SuggestedLanguage {
        config_id: config_id.to_string(),
        languages: vec![language.to_string()],
        server: server.to_string(),
        command: vec![program.to_string(), "--stdio".to_string()],
        workspace_root: PathBuf::from("."),
        wait_for_index: false,
    }
}

pub(crate) fn detection_result(filetypes: &[&str], filenames: &[&str]) -> DetectionResult {
    DetectionResult {
        filetypes: filetypes
            .iter()
            .map(|filetype| (*filetype).to_string())
            .collect(),
        filenames: filenames
            .iter()
            .map(|filename| (*filename).to_string())
            .collect(),
    }
}

#[cfg(test)]
pub(crate) fn current_test_executable() -> PathBuf {
    std::env::current_exe().expect("current test executable should resolve")
}

#[cfg(test)]
pub(crate) fn subprocess_helper_command() -> Vec<String> {
    vec![
        current_test_executable().display().to_string(),
        "--quiet".to_string(),
        "--ignored".to_string(),
        "--exact".to_string(),
        SUBPROCESS_HELPER_TEST_NAME.to_string(),
        "--nocapture".to_string(),
    ]
}

#[cfg(test)]
pub(crate) fn subprocess_helper_env(
    mode: &str,
    extra: &[(&'static str, OsString)],
) -> Vec<(&'static str, OsString)> {
    let mut vars = Vec::with_capacity(extra.len() + 1);
    vars.push(env_var(SUBPROCESS_HELPER_MODE_ENV, mode));
    vars.extend(extra.iter().cloned());
    vars
}

#[cfg(unix)]
pub(crate) fn make_executable(path: &Path) {
    use std::os::unix::fs::PermissionsExt;

    let mut permissions = fs::metadata(path)
        .expect("metadata should be available")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).expect("permissions should be updated");
}

#[cfg(test)]
mod tests {
    use super::{
        SUBPROCESS_HELPER_EXIT_CODE_ENV, SUBPROCESS_HELPER_MODE_ENV,
        SUBPROCESS_HELPER_OUTPUT_PATH_ENV, SUBPROCESS_HELPER_STDERR_ENV, TestDir, test_temp_root,
    };
    use std::fs;
    use std::io::Write as _;

    #[test]
    fn creates_test_directories_in_secure_selected_root() {
        let dir = TestDir::new("secure-root");

        assert!(
            dir.path()
                .starts_with(test_temp_root().expect("secure test temp root should be selected"))
        );
        assert!(!dir.path().starts_with("/tmp"));
    }

    #[test]
    #[ignore = "subprocess helper"]
    fn subprocess_helper() {
        let Some(mode) = std::env::var_os(SUBPROCESS_HELPER_MODE_ENV) else {
            return;
        };

        match mode.to_string_lossy().as_ref() {
            "write-cwd" => {
                let output = std::env::var_os(SUBPROCESS_HELPER_OUTPUT_PATH_ENV)
                    .expect("helper output path should be configured");
                let cwd = std::env::current_dir().expect("helper cwd should resolve");
                fs::write(output, cwd.display().to_string()).expect("helper cwd should be written");
                std::process::exit(0);
            }
            "stderr-and-exit" => {
                let stderr = std::env::var(SUBPROCESS_HELPER_STDERR_ENV).unwrap_or_default();
                let code = std::env::var(SUBPROCESS_HELPER_EXIT_CODE_ENV)
                    .ok()
                    .and_then(|value| value.parse::<i32>().ok())
                    .unwrap_or(0);
                let mut handle = std::io::stderr().lock();
                handle
                    .write_all(stderr.as_bytes())
                    .expect("helper stderr should write");
                handle.flush().expect("helper stderr should flush");
                std::process::exit(code);
            }
            other => panic!("unknown subprocess helper mode {other:?}"),
        }
    }
}