lsp-cli 0.1.6

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
use crate::error::{Error, Result};
use crate::mason::install::{resolve_cached_program, resolve_or_install_program};
use crate::mason::link::{is_command_runnable, rewrite_program};
use crate::mason::registry::MasonRegistry;
use crate::runtime_state::{RuntimeState, default_runtime_state_root};
use crate::suggest::SuggestedLanguage;

pub fn resolve_detect_suggestions(
    suggestions: &[SuggestedLanguage],
    download: bool,
) -> Result<Vec<SuggestedLanguage>> {
    let state = default_runtime_state_root().ok().map(RuntimeState::new);
    let cached_registry = state.as_ref().and_then(MasonRegistry::load_cached);
    let mut install_registry = None;
    let mut resolved = Vec::new();
    let mut errors = Vec::new();

    for suggestion in suggestions {
        if let Some(suggestion) = resolve_suggestion_from_path_or_cache(
            suggestion,
            state.as_ref(),
            cached_registry.as_ref(),
        )? {
            resolved.push(suggestion);
            continue;
        }

        if !download {
            continue;
        }

        match install_suggestion(suggestion, state.as_ref(), &mut install_registry) {
            Ok(suggestion) => resolved.push(suggestion),
            Err(error) => errors.push(error),
        }
    }

    if !resolved.is_empty() {
        for error in errors {
            eprintln!("warning: {error}");
        }
        return Ok(resolved);
    }

    match errors.len() {
        0 => Ok(Vec::new()),
        1 => Err(errors.remove(0)),
        _ => Err(Error::unexpected(
            errors
                .into_iter()
                .map(|error| error.to_string())
                .collect::<Vec<_>>()
                .join("\n"),
        )),
    }
}

fn resolve_suggestion_from_path_or_cache(
    suggestion: &SuggestedLanguage,
    state: Option<&RuntimeState>,
    registry: Option<&MasonRegistry>,
) -> Result<Option<SuggestedLanguage>> {
    let Some(program) = suggestion.command.first() else {
        return Err(Error::unexpected(format!(
            "selected LSP server {} has an empty command",
            suggestion.server
        )));
    };

    if is_command_runnable(program) {
        return Ok(Some(suggestion.clone()));
    }

    if program.contains(std::path::MAIN_SEPARATOR) {
        return Ok(None);
    }

    let Some(state) = state else {
        return Ok(None);
    };
    let Some(registry) = registry else {
        return Ok(None);
    };
    let Some(package) =
        registry.package_for_detected(&suggestion.config_id, &suggestion.server, program)
    else {
        return Ok(None);
    };

    let Ok(Some(executable_path)) = resolve_cached_program(state, package, program) else {
        return Ok(None);
    };

    Ok(Some(rewrite_program(suggestion, &executable_path)))
}

fn install_suggestion(
    suggestion: &SuggestedLanguage,
    state: Option<&RuntimeState>,
    registry: &mut Option<MasonRegistry>,
) -> Result<SuggestedLanguage> {
    let Some(program) = suggestion.command.first() else {
        return Err(Error::unexpected(format!(
            "selected LSP server {} has an empty command",
            suggestion.server
        )));
    };
    if program.contains(std::path::MAIN_SEPARATOR) {
        return Err(Error::missing_executable(format!(
            "configured LSP server executable `{program}` was not found"
        )));
    }

    let Some(state) = state else {
        return Err(Error::unexpected(
            "cannot install LSP servers automatically because $HOME is not set",
        ));
    };
    let registry = registry.get_or_insert(MasonRegistry::load(state)?);
    let Some(package) =
        registry.package_for_detected(&suggestion.config_id, &suggestion.server, program)
    else {
        return Err(Error::unexpected(format!(
            "no Mason install recipe is available for detected server {}",
            suggestion.server
        )));
    };
    let executable_path = resolve_or_install_program(state, package, program)?;

    Ok(rewrite_program(suggestion, &executable_path))
}

#[cfg(test)]
mod tests {
    use super::resolve_detect_suggestions;
    use crate::test_support::{
        TestDir, env_var, jdtls_package, make_executable, pyright_package, runtime_state_in_home,
        suggested_language, with_env_vars, write_registry,
    };
    use std::fs;

    fn prepare_registry_test_home(
        package_name: &str,
        packages: &[crate::mason::registry::MasonPackage],
    ) -> (
        TestDir,
        std::path::PathBuf,
        crate::runtime_state::RuntimeState,
    ) {
        let dir = TestDir::new("mason-resolve");
        let home = dir.path().join("home");
        let state = runtime_state_in_home(&home);
        state.ensure_dirs().expect("state dirs should be created");
        write_registry(&state, packages);
        let package_dir = state.package_dir(package_name);
        (dir, package_dir, state)
    }

    #[cfg(unix)]
    #[test]
    fn prefers_cached_direct_binary_when_path_misses() {
        let (dir, package_dir, _state) =
            prepare_registry_test_home("pyright", &[pyright_package()]);
        let home = dir.path().join("home");
        let cached = package_dir.join("node_modules/.bin/pyright-langserver");
        fs::create_dir_all(cached.parent().expect("parent should exist"))
            .expect("parent dirs should be created");
        fs::write(&cached, b"stub\n").expect("cached binary should be written");
        make_executable(&cached);

        let resolved = with_env_vars(
            &[env_var("HOME", &home), env_var("PATH", "/nonexistent")],
            || {
                resolve_detect_suggestions(
                    &[suggested_language(
                        "pyright-langserver",
                        "pyright",
                        "pyright",
                        "python",
                    )],
                    false,
                )
                .expect("resolution should succeed")
            },
        );

        assert_eq!(resolved.len(), 1);
        assert_eq!(resolved[0].command[0], cached.display().to_string());
    }

    #[cfg(unix)]
    #[test]
    fn prefers_cached_wrapper_when_path_misses() {
        let (dir, package_dir, state) = prepare_registry_test_home("jdtls", &[jdtls_package()]);
        let home = dir.path().join("home");
        let target = package_dir.join("bin/jdtls");
        fs::create_dir_all(target.parent().expect("parent should exist"))
            .expect("parent dirs should be created");
        fs::write(&target, b"print('ok')\n").expect("target should be written");
        let launcher = state.bin_dir().join("jdtls");
        fs::write(&launcher, b"stub\n").expect("launcher should be written");
        make_executable(&launcher);
        let runtime_dir = dir.path().join("bin");
        fs::create_dir_all(&runtime_dir).expect("runtime dir should be created");
        let python = runtime_dir.join("python3");
        fs::write(&python, b"stub\n").expect("runtime should be written");
        make_executable(&python);

        let resolved = with_env_vars(
            &[
                env_var("HOME", &home),
                env_var("PATH", runtime_dir.display().to_string()),
            ],
            || {
                resolve_detect_suggestions(
                    &[suggested_language("jdtls", "jdtls", "jdtls", "python")],
                    false,
                )
                .expect("resolution should succeed")
            },
        );

        assert_eq!(resolved.len(), 1);
        assert_eq!(resolved[0].command[0], launcher.display().to_string());
    }

    #[cfg(unix)]
    #[test]
    fn skips_server_when_not_in_path_or_cache() {
        let dir = TestDir::new("mason-resolve");
        let home = dir.path().join("home");
        fs::create_dir_all(&home).expect("home dir should be created");

        let resolved = with_env_vars(
            &[env_var("HOME", &home), env_var("PATH", "/nonexistent")],
            || {
                resolve_detect_suggestions(
                    &[suggested_language(
                        "pyright-langserver",
                        "pyright",
                        "pyright",
                        "python",
                    )],
                    false,
                )
                .expect("resolution should succeed")
            },
        );

        assert!(resolved.is_empty());
    }

    #[cfg(unix)]
    #[test]
    fn treats_corrupted_cache_as_missing() {
        let dir = TestDir::new("mason-resolve");
        let home = dir.path().join("home");
        let state = runtime_state_in_home(&home);
        state.ensure_dirs().expect("state dirs should be created");
        fs::write(state.registry_json_path(), b"not json")
            .expect("corrupted registry should be written");

        let resolved = with_env_vars(
            &[env_var("HOME", &home), env_var("PATH", "/nonexistent")],
            || {
                resolve_detect_suggestions(
                    &[suggested_language(
                        "pyright-langserver",
                        "pyright",
                        "pyright",
                        "python",
                    )],
                    false,
                )
                .expect("resolution should succeed")
            },
        );

        assert!(resolved.is_empty());
    }
}