lsp-cli 0.1.0

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use crate::config::LspConfig;
use crate::detect::DetectionResult;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SuggestedLanguage {
    pub config_id: String,
    pub languages: Vec<String>,
    pub server: String,
    pub command: Vec<String>,
    pub workspace_root: PathBuf,
    pub wait_for_index: bool,
}

pub fn suggestions_for(
    lsps: &[LspConfig],
    detection: &DetectionResult,
    workspace: &Path,
) -> Result<Vec<SuggestedLanguage>, String> {
    lsps.iter()
        .filter(|lsp| matches_lsp(lsp, detection))
        .map(|lsp| build_suggestion(lsp, detection, workspace))
        .collect()
}

pub fn sort_suggestions(
    suggestions: &mut [SuggestedLanguage],
    preferences: &BTreeMap<String, Vec<String>>,
    language: Option<&str>,
) {
    suggestions.sort_by_key(|suggestion| preference_rank(suggestion, preferences, language));
}

fn preference_rank(
    suggestion: &SuggestedLanguage,
    preferences: &BTreeMap<String, Vec<String>>,
    language: Option<&str>,
) -> (usize, usize) {
    let matched_rank = match language {
        Some(language) => preference_index(language, &suggestion.server, preferences),
        None => suggestion
            .languages
            .iter()
            .filter_map(|language| preference_index(language, &suggestion.server, preferences))
            .min(),
    };

    matched_rank.map_or((1, usize::MAX), |rank| (0, rank))
}

fn preference_index(
    language: &str,
    server: &str,
    preferences: &BTreeMap<String, Vec<String>>,
) -> Option<usize> {
    preferences
        .get(language)
        .and_then(|servers| servers.iter().position(|candidate| candidate == server))
}

fn matches_lsp(lsp: &LspConfig, detection: &DetectionResult) -> bool {
    lsp.filetypes
        .iter()
        .any(|filetype| detection.filetypes.contains(filetype))
}

fn build_suggestion(
    lsp: &LspConfig,
    detection: &DetectionResult,
    workspace: &Path,
) -> Result<SuggestedLanguage, String> {
    let workspace_root = resolve_workspace_root(lsp, workspace)
        .map_err(|error| format!("failed to resolve workspace for {}: {error}", lsp.name))?;
    let workspace_root = absolute_path(&workspace_root)
        .map_err(|error| format!("failed to resolve workspace for {}: {error}", lsp.name))?;
    let workspace = workspace_root.to_string_lossy();
    let template = shlex::split(&lsp.cmdline)
        .ok_or_else(|| format!("invalid cmdline for {}: {}", lsp.name, lsp.cmdline))?;
    let command = template
        .into_iter()
        .map(|token| token.replace("$WORKSPACE", &workspace))
        .collect::<Vec<_>>();

    if command.is_empty() {
        return Err(format!("empty cmdline for {}", lsp.name));
    }

    Ok(SuggestedLanguage {
        config_id: lsp.id.clone(),
        languages: matching_languages(lsp, detection),
        server: lsp.name.clone(),
        command,
        workspace_root,
        wait_for_index: lsp.wait_for_index,
    })
}

fn matching_languages(lsp: &LspConfig, detection: &DetectionResult) -> Vec<String> {
    lsp.filetypes
        .iter()
        .filter(|filetype| detection.filetypes.contains(*filetype))
        .cloned()
        .collect()
}

fn resolve_workspace_root(lsp: &LspConfig, workspace: &Path) -> std::io::Result<PathBuf> {
    let start = match std::fs::metadata(workspace) {
        Ok(metadata) if metadata.is_file() => workspace
            .parent()
            .map_or_else(|| workspace.to_path_buf(), Path::to_path_buf),
        Ok(_) => workspace.to_path_buf(),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => workspace.to_path_buf(),
        Err(error) => return Err(error),
    };

    for directory in start.ancestors() {
        if has_any_root_marker(directory, &lsp.root_markers) {
            return Ok(directory.to_path_buf());
        }
    }

    Ok(start)
}

fn absolute_path(path: &Path) -> std::io::Result<PathBuf> {
    absolute_path_from(path, &std::env::current_dir()?)
}

fn absolute_path_from(path: &Path, current_dir: &Path) -> std::io::Result<PathBuf> {
    let candidate = if path.is_relative() {
        current_dir.join(path)
    } else {
        path.to_path_buf()
    };

    match std::fs::canonicalize(&candidate) {
        Ok(path) => Ok(path),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(candidate),
        Err(error) => Err(error),
    }
}

fn has_any_root_marker(directory: &Path, root_markers: &[String]) -> bool {
    if root_markers.is_empty() {
        return false;
    }

    if !directory.is_dir() {
        return false;
    }

    root_markers
        .iter()
        .any(|marker| directory.join(marker).exists())
}

#[cfg(test)]
mod tests {
    use super::{SuggestedLanguage, absolute_path_from, sort_suggestions, suggestions_for};
    use crate::config::LspConfig;
    use crate::test_support::{TestDir, detection_result};
    use std::collections::BTreeMap;
    use std::path::Path;

    fn example_lsp() -> LspConfig {
        LspConfig {
            id: "example_lsp".to_string(),
            filetypes: vec!["alpha".to_string(), "beta".to_string()],
            root_markers: vec![".workspace-root".to_string(), ".git".to_string()],
            name: "example-lsp".to_string(),
            cmdline: "example-lsp --stdio $WORKSPACE".to_string(),
            wait_for_index: false,
        }
    }

    fn suggestion(server: &str) -> SuggestedLanguage {
        SuggestedLanguage {
            config_id: server.to_string(),
            languages: vec!["python".to_string()],
            server: server.to_string(),
            command: vec![server.to_string()],
            workspace_root: Path::new(".").to_path_buf(),
            wait_for_index: false,
        }
    }

    #[test]
    fn suggests_lsp_from_detected_filetype() {
        let dir = TestDir::new("suggest");
        let workspace = dir.path().join("workspace");
        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["beta"], &[]),
            &workspace,
        )
        .expect("suggestions should succeed");

        assert_eq!(
            suggestions,
            vec![SuggestedLanguage {
                config_id: "example_lsp".to_string(),
                languages: vec!["beta".to_string()],
                server: "example-lsp".to_string(),
                command: vec![
                    "example-lsp".to_string(),
                    "--stdio".to_string(),
                    workspace.display().to_string()
                ],
                workspace_root: workspace,
                wait_for_index: false,
            }]
        );
    }

    #[test]
    fn carries_wait_for_index_from_config() {
        let mut lsp = example_lsp();
        lsp.wait_for_index = true;
        let dir = TestDir::new("suggest");
        let workspace = dir.path().join("workspace");

        let suggestions = suggestions_for(&[lsp], &detection_result(&["beta"], &[]), &workspace)
            .expect("suggestions should succeed");

        assert!(suggestions[0].wait_for_index);
    }

    #[test]
    fn does_not_suggest_lsp_from_root_marker_alone() {
        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&[], &[".workspace-root"]),
            Path::new("workspace"),
        )
        .expect("suggestions should succeed");

        assert!(suggestions.is_empty());
    }

    #[test]
    fn includes_all_matching_languages() {
        let dir = TestDir::new("suggest");
        let workspace = dir.path().join("workspace");
        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["alpha", "beta"], &[]),
            &workspace,
        )
        .expect("suggestions should succeed");

        assert_eq!(
            suggestions[0].languages,
            vec!["alpha".to_string(), "beta".to_string()]
        );
    }

    #[test]
    fn returns_no_suggestions_when_nothing_matches() {
        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["gamma"], &["workspace.lock"]),
            Path::new("."),
        )
        .expect("suggestions should succeed");

        assert!(suggestions.is_empty());
    }

    #[test]
    fn substitutes_workspace_without_splitting_spaces() {
        let dir = TestDir::new("suggest");
        let workspace = dir.path().join("with spaces");
        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["alpha"], &[]),
            &workspace,
        )
        .expect("suggestions should succeed");

        assert_eq!(
            suggestions[0].command,
            vec![
                "example-lsp".to_string(),
                "--stdio".to_string(),
                workspace.display().to_string()
            ]
        );
    }

    #[test]
    fn resolves_missing_relative_workspace_against_provided_current_dir() {
        let dir = TestDir::new("suggest");
        let workspace = absolute_path_from(Path::new("playground/c"), dir.path())
            .expect("workspace should resolve");

        assert_eq!(workspace, dir.path().join("playground/c"));
    }

    #[test]
    fn resolves_workspace_from_root_marker() {
        let dir = TestDir::new("suggest");
        dir.write_file(".workspace-root", "test");
        let nested = dir.write_file("src/main.foo", "test");

        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["beta"], &[".workspace-root", "main.foo"]),
            &nested,
        )
        .expect("suggestions should succeed");

        assert_eq!(
            suggestions[0].command,
            vec![
                "example-lsp".to_string(),
                "--stdio".to_string(),
                dir.path().display().to_string()
            ]
        );
        assert_eq!(suggestions[0].workspace_root, dir.path().to_path_buf());
    }

    #[test]
    fn falls_back_to_input_directory_when_no_marker_exists() {
        let dir = TestDir::new("suggest");
        let nested = dir.write_file("src/main.foo", "test");

        let suggestions = suggestions_for(
            &[example_lsp()],
            &detection_result(&["beta"], &["main.foo"]),
            &nested,
        )
        .expect("suggestions should succeed");

        assert_eq!(
            suggestions[0].command,
            vec![
                "example-lsp".to_string(),
                "--stdio".to_string(),
                dir.path().join("src").display().to_string()
            ]
        );
        assert_eq!(suggestions[0].workspace_root, dir.path().join("src"));
    }

    #[test]
    fn sorts_suggestions_by_language_preference() {
        let mut suggestions = vec![suggestion("pyright"), suggestion("ty")];

        sort_suggestions(
            &mut suggestions,
            &BTreeMap::from([(
                "python".to_string(),
                vec!["ty".to_string(), "pyright".to_string()],
            )]),
            Some("python"),
        );

        assert_eq!(
            suggestions
                .iter()
                .map(|suggestion| suggestion.server.as_str())
                .collect::<Vec<_>>(),
            vec!["ty", "pyright"]
        );
    }

    #[test]
    fn keeps_unlisted_servers_after_listed_ones() {
        let mut suggestions = vec![suggestion("jedi-language-server"), suggestion("pyright")];

        sort_suggestions(
            &mut suggestions,
            &BTreeMap::from([("python".to_string(), vec!["pyright".to_string()])]),
            Some("python"),
        );

        assert_eq!(
            suggestions
                .iter()
                .map(|suggestion| suggestion.server.as_str())
                .collect::<Vec<_>>(),
            vec!["pyright", "jedi-language-server"]
        );
    }
}