lsp-cli 0.1.0

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
use crate::cli::DetectArgs;
use crate::commands::common::analyze_path;
use crate::config::ConfigStore;
use crate::mason::resolve_detect_suggestions;
use crate::suggest::SuggestedLanguage;
use serde_json::json;
use std::collections::BTreeSet;

pub(super) fn run(args: &DetectArgs, config: &ConfigStore) -> Result<String, String> {
    let (detection, suggestions) = analyze_path(&args.path, config)?;
    let suggestions =
        filter_detect_suggestions(&suggestions, args.lang.as_deref(), args.lsp.as_deref())?;
    let suggestions = resolve_detect_suggestions(&suggestions, args.download)?;
    ensure_requested_suggestions_resolved(&suggestions, args.lang.as_deref(), args.lsp.as_deref())?;

    Ok(if args.json {
        render_json(&suggestions)
    } else if args.quiet {
        render_quiet(&suggestions)
    } else {
        render_text(&detection.filetypes, &suggestions)
    })
}

fn filter_detect_suggestions(
    suggestions: &[SuggestedLanguage],
    selected_language: Option<&str>,
    selected_server: Option<&str>,
) -> Result<Vec<SuggestedLanguage>, String> {
    let mut filtered = suggestions.to_vec();

    if let Some(selected_server) = selected_server {
        filtered.retain(|suggestion| suggestion.server == selected_server);
        if let Some(selected_language) = selected_language {
            filtered.retain(|suggestion| {
                suggestion
                    .languages
                    .iter()
                    .any(|language| language == selected_language)
            });
            if filtered.is_empty() {
                return Err(
                    if suggestions
                        .iter()
                        .any(|suggestion| suggestion.server == selected_server)
                    {
                        format!(
                            "requested LSP server {selected_server:?} is not available for language {selected_language:?}"
                        )
                    } else {
                        requested_server_not_detected_error(suggestions, selected_server)
                    },
                );
            }
        } else if filtered.is_empty() {
            return Err(requested_server_not_detected_error(
                suggestions,
                selected_server,
            ));
        }

        return Ok(filtered);
    }

    if let Some(selected_language) = selected_language {
        filtered.retain(|suggestion| {
            suggestion
                .languages
                .iter()
                .any(|language| language == selected_language)
        });
        if filtered.is_empty() {
            return Err(format!(
                "no LSP server was detected for language {selected_language:?}"
            ));
        }
    }

    Ok(filtered)
}

fn ensure_requested_suggestions_resolved(
    suggestions: &[SuggestedLanguage],
    selected_language: Option<&str>,
    selected_server: Option<&str>,
) -> Result<(), String> {
    if !suggestions.is_empty() {
        return Ok(());
    }

    match (selected_server, selected_language) {
        (Some(selected_server), Some(selected_language)) => Err(format!(
            "requested LSP server {selected_server:?} is not runnable for language {selected_language:?}"
        )),
        (Some(selected_server), None) => Err(format!(
            "requested LSP server {selected_server:?} is not runnable"
        )),
        (None, Some(selected_language)) => Err(format!(
            "no runnable LSP server was found for language {selected_language:?}"
        )),
        (None, None) => Ok(()),
    }
}

fn requested_server_not_detected_error(
    suggestions: &[SuggestedLanguage],
    selected_server: &str,
) -> String {
    let available = suggestions
        .iter()
        .map(|suggestion| suggestion.server.as_str())
        .collect::<Vec<_>>();
    if available.is_empty() {
        format!(
            "requested LSP server {selected_server:?} is not available because no matching servers were detected"
        )
    } else {
        format!(
            "requested LSP server {selected_server:?} is not in the detected server list: {}",
            available.join(", ")
        )
    }
}

pub(super) fn render_quiet(suggestions: &[SuggestedLanguage]) -> String {
    suggestions
        .iter()
        .map(|suggestion| suggestion.command.join(" "))
        .collect::<Vec<_>>()
        .join("\n")
}

pub(super) fn render_text(
    detected_filetypes: &BTreeSet<String>,
    suggestions: &[SuggestedLanguage],
) -> String {
    if suggestions.is_empty() {
        return if detected_filetypes.is_empty() {
            "No supported languages detected".to_string()
        } else {
            format!(
                "No runnable LSP server found for detected filetypes: {}",
                detected_filetypes
                    .iter()
                    .cloned()
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        };
    }

    suggestions
        .iter()
        .map(|suggestion| {
            let languages = suggestion.languages.join(", ");
            format!(
                "Language: {}\nSuggested command: {}",
                languages,
                suggestion.command.join(" ")
            )
        })
        .collect::<Vec<_>>()
        .join("\n\n")
}

pub(super) fn render_json(suggestions: &[SuggestedLanguage]) -> String {
    json!({
        "servers": suggestions
            .iter()
            .map(|suggestion| {
                json!({
                    "languages": suggestion.languages,
                    "server": suggestion.server,
                    "command": suggestion.command,
                })
            })
            .collect::<Vec<_>>(),
    })
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::{
        ensure_requested_suggestions_resolved, filter_detect_suggestions, render_json,
        render_quiet, render_text,
    };
    use crate::suggest::SuggestedLanguage;
    use std::collections::BTreeSet;
    use std::path::PathBuf;

    fn example_suggestion() -> SuggestedLanguage {
        SuggestedLanguage {
            config_id: "example_lsp".to_string(),
            languages: vec!["alpha".to_string(), "beta".to_string()],
            server: "example-lsp".to_string(),
            command: vec!["example-lsp".to_string(), "--stdio".to_string()],
            workspace_root: PathBuf::from("."),
            wait_for_index: false,
        }
    }

    fn secondary_suggestion() -> SuggestedLanguage {
        SuggestedLanguage {
            config_id: "secondary_lsp".to_string(),
            languages: vec!["gamma".to_string()],
            server: "secondary-lsp".to_string(),
            command: vec!["secondary-lsp".to_string(), "--stdio".to_string()],
            workspace_root: PathBuf::from("."),
            wait_for_index: false,
        }
    }

    #[test]
    fn filters_detect_suggestions_by_language() {
        assert_eq!(
            filter_detect_suggestions(
                &[example_suggestion(), secondary_suggestion()],
                Some("gamma"),
                None,
            )
            .expect("language filter should succeed"),
            vec![secondary_suggestion()]
        );
    }

    #[test]
    fn filters_detect_suggestions_by_server() {
        assert_eq!(
            filter_detect_suggestions(
                &[example_suggestion(), secondary_suggestion()],
                None,
                Some("example-lsp"),
            )
            .expect("server filter should succeed"),
            vec![example_suggestion()]
        );
    }

    #[test]
    fn filters_detect_suggestions_by_language_and_server() {
        assert_eq!(
            filter_detect_suggestions(&[example_suggestion()], Some("beta"), Some("example-lsp"))
                .expect("combined filter should succeed"),
            vec![example_suggestion()]
        );
    }

    #[test]
    fn errors_when_detect_language_has_no_matching_server() {
        assert_eq!(
            filter_detect_suggestions(&[example_suggestion()], Some("gamma"), None),
            Err("no LSP server was detected for language \"gamma\"".to_string())
        );
    }

    #[test]
    fn errors_when_detect_server_is_not_detected() {
        assert_eq!(
            filter_detect_suggestions(&[example_suggestion()], None, Some("missing-lsp")),
            Err(
                "requested LSP server \"missing-lsp\" is not in the detected server list: example-lsp"
                    .to_string()
            )
        );
    }

    #[test]
    fn errors_when_detect_server_is_not_detected_and_nothing_matches() {
        assert_eq!(
            filter_detect_suggestions(&[], None, Some("missing-lsp")),
            Err(
                "requested LSP server \"missing-lsp\" is not available because no matching servers were detected"
                    .to_string()
            )
        );
    }

    #[test]
    fn errors_when_detect_server_is_not_available_for_language() {
        assert_eq!(
            filter_detect_suggestions(&[example_suggestion()], Some("gamma"), Some("example-lsp")),
            Err(
                "requested LSP server \"example-lsp\" is not available for language \"gamma\""
                    .to_string()
            )
        );
    }

    #[test]
    fn errors_when_requested_detect_server_is_not_runnable() {
        assert_eq!(
            ensure_requested_suggestions_resolved(&[], None, Some("example-lsp")),
            Err("requested LSP server \"example-lsp\" is not runnable".to_string())
        );
    }

    #[test]
    fn errors_when_requested_detect_server_is_not_runnable_for_language() {
        assert_eq!(
            ensure_requested_suggestions_resolved(&[], Some("beta"), Some("example-lsp")),
            Err(
                "requested LSP server \"example-lsp\" is not runnable for language \"beta\""
                    .to_string()
            )
        );
    }

    #[test]
    fn errors_when_requested_detect_language_has_no_runnable_server() {
        assert_eq!(
            ensure_requested_suggestions_resolved(&[], Some("beta"), None),
            Err("no runnable LSP server was found for language \"beta\"".to_string())
        );
    }

    #[test]
    fn renders_empty_detect_text_output() {
        assert_eq!(
            render_text(&BTreeSet::new(), &[]),
            "No supported languages detected"
        );
    }

    #[test]
    fn renders_no_runnable_server_output() {
        assert_eq!(
            render_text(&BTreeSet::from(["python".to_string()]), &[]),
            "No runnable LSP server found for detected filetypes: python"
        );
    }

    #[test]
    fn renders_detect_text_output() {
        let detected = BTreeSet::from(["alpha".to_string(), "beta".to_string()]);

        assert_eq!(
            render_text(&detected, &[example_suggestion()]),
            "Language: alpha, beta\nSuggested command: example-lsp --stdio"
        );
    }

    #[test]
    fn renders_server_specific_languages_in_text_output() {
        let detected =
            BTreeSet::from(["alpha".to_string(), "beta".to_string(), "gamma".to_string()]);
        let suggestion = SuggestedLanguage {
            languages: vec!["beta".to_string()],
            ..example_suggestion()
        };

        assert_eq!(
            render_text(&detected, &[suggestion]),
            "Language: beta\nSuggested command: example-lsp --stdio"
        );
    }

    #[test]
    fn renders_detect_quiet_output() {
        assert_eq!(render_quiet(&[example_suggestion()]), "example-lsp --stdio");
    }

    #[test]
    fn renders_detect_json_output() {
        assert_eq!(
            render_json(&[example_suggestion()]),
            concat!(
                "{\"servers\":[",
                "{\"command\":[\"example-lsp\",\"--stdio\"],\"languages\":[\"alpha\",\"beta\"],\"server\":\"example-lsp\"}",
                "]}"
            )
        );
    }
}