lsp-cli 0.1.6

Command-line tool for talking to Language Server Protocol (LSP) servers from the terminal.
use super::render::{render_symbol_matches_text_full, render_workspace_symbol_json_full};
use super::{
    ListSymbolsTarget, dedupe_symbol_matches, ensure_document_symbol_support, list_symbols_target,
    preferred_function_name_matches, preferred_name_matches, render_paths_text,
    render_symbol_match_paths_text, render_symbol_matches_text, render_symbol_names_text,
    truncate_items,
};
use crate::lsp::SymbolMatch;
use crate::suggest::SuggestedLanguage;
use crate::test_support::TestDir;
use lsp_types::SymbolKind;
use serde_json::json;
use std::collections::BTreeSet;
use std::path::PathBuf;

fn matched(
    path: &str,
    line: u32,
    col: u32,
    name: &str,
    kind: SymbolKind,
    line_content: &str,
) -> SymbolMatch {
    SymbolMatch {
        name: name.to_string(),
        kind,
        path: PathBuf::from(path),
        line,
        col,
        line_content: line_content.to_string(),
        full_content: None,
    }
}

fn render_server() -> SuggestedLanguage {
    SuggestedLanguage {
        config_id: "pyright".to_string(),
        languages: vec!["python".to_string()],
        server: "pyright-langserver".to_string(),
        command: vec!["pyright-langserver".to_string(), "--stdio".to_string()],
        workspace_root: PathBuf::from("."),
        wait_for_index: false,
    }
}

#[test]
fn renders_grep_text_output() {
    assert_eq!(
        render_symbol_matches_text(&[matched(
            "src/main.rs",
            3,
            14,
            "main",
            SymbolKind::FUNCTION,
            "fn main() {",
        )]),
        "src/main.rs:3:14:fn main() {"
    );
}

#[test]
fn renders_empty_grep_text_output() {
    assert_eq!(render_symbol_matches_text(&[]), "");
}

#[test]
fn renders_symbol_names_text_output() {
    assert_eq!(
        render_symbol_names_text(&[
            matched(
                "src/main.rs",
                3,
                14,
                "main",
                SymbolKind::FUNCTION,
                "fn main() {"
            ),
            matched(
                "src/lib.rs",
                8,
                1,
                "helper",
                SymbolKind::METHOD,
                "fn helper() {}"
            ),
        ]),
        "main\nhelper"
    );
}

#[test]
fn renders_full_definition_text_output() {
    let mut matched = matched(
        "src/main.rs",
        3,
        14,
        "main",
        SymbolKind::FUNCTION,
        "fn main() {",
    );
    matched.full_content = Some("fn main() {\n    helper();\n}".to_string());

    assert_eq!(
        render_symbol_matches_text_full(&[matched]),
        "src/main.rs:3:14:\nfn main() {\n    helper();\n}"
    );
}

#[test]
fn renders_files_with_matches_text_output() {
    assert_eq!(
        render_symbol_match_paths_text(&[
            matched(
                "src/main.rs",
                3,
                14,
                "main",
                SymbolKind::FUNCTION,
                "fn main() {"
            ),
            matched(
                "src/main.rs",
                8,
                1,
                "helper",
                SymbolKind::METHOD,
                "fn helper() {}"
            ),
            matched(
                "src/lib.rs",
                2,
                5,
                "lib",
                SymbolKind::FUNCTION,
                "fn lib() {}"
            ),
        ]),
        "src/main.rs\nsrc/lib.rs"
    );
}

#[test]
fn renders_full_definition_json_output() {
    let mut matched = matched(
        "app/models.py",
        5,
        1,
        "Order",
        SymbolKind::CLASS,
        "class Order:",
    );
    matched.full_content =
        Some("class Order:\n    customer: str\n    items: list[OrderItem]".to_string());

    let rendered = render_workspace_symbol_json_full(
        "Order",
        std::path::Path::new("playground/python"),
        &BTreeSet::from(["python".to_string()]),
        &render_server(),
        &[matched],
    );

    assert_eq!(
        serde_json::from_str::<serde_json::Value>(&rendered).expect("json should parse"),
        json!({
            "query": "Order",
            "directory": "playground/python",
            "detected": ["python"],
            "server": {
                "name": "pyright-langserver",
                "languages": ["python"],
                "command": ["pyright-langserver", "--stdio"],
                "workspace_root": "."
            },
            "matches": [{
                "name": "Order",
                "kind": SymbolKind::CLASS,
                "path": "app/models.py",
                "line": 5,
                "col": 1,
                "line_content": "class Order:",
                "full_content": "class Order:\n    customer: str\n    items: list[OrderItem]"
            }]
        })
    );
}

#[test]
fn renders_paths_text_output() {
    assert_eq!(
        render_paths_text(&[PathBuf::from("src/lib.rs"), PathBuf::from("src/main.rs")]),
        "src/lib.rs\nsrc/main.rs"
    );
}

#[test]
fn truncates_items_to_limit() {
    let items = truncate_items(vec![1, 2, 3], 2, "lines");

    assert_eq!(items, vec![1, 2]);
}

#[test]
fn dedupes_symbol_matches_by_location_and_name() {
    let matched = matched(
        "src/main.rs",
        1,
        1,
        "main",
        SymbolKind::FUNCTION,
        "fn main() {}",
    );

    assert_eq!(
        dedupe_symbol_matches(vec![matched.clone(), matched.clone()]),
        vec![matched]
    );
}

#[test]
fn prefers_exact_name_matches_over_fuzzy_matches() {
    let exact = matched(
        "src/main.rs",
        1,
        1,
        "main",
        SymbolKind::FUNCTION,
        "fn main() {}",
    );
    let fuzzy = matched(
        "src/lsp/symbols.rs",
        1,
        1,
        "SymbolInformationItem",
        SymbolKind::STRUCT,
        "struct SymbolInformationItem {}",
    );

    assert_eq!(
        preferred_name_matches(vec![fuzzy, exact.clone()], "main"),
        vec![exact]
    );
}

#[test]
fn prefers_function_matches_for_function_queries() {
    let function = matched(
        "src/main.rs",
        1,
        1,
        "main",
        SymbolKind::FUNCTION,
        "fn main() {}",
    );
    let non_function = matched(
        "src/lib.rs",
        1,
        1,
        "main",
        SymbolKind::STRUCT,
        "struct main;",
    );

    assert_eq!(
        preferred_function_name_matches(vec![non_function, function.clone()], "main"),
        vec![function]
    );
}

#[test]
fn classifies_directory_for_list_symbols_query() {
    let dir = TestDir::new("list-symbols-dir");

    assert_eq!(
        list_symbols_target(dir.path()),
        Ok(ListSymbolsTarget::Directory)
    );
}

#[test]
fn classifies_file_for_list_symbols_query() {
    let dir = TestDir::new("list-symbols-file");
    let file = dir.write_file("src/main.rs", "fn main() {}\n");

    assert_eq!(list_symbols_target(&file), Ok(ListSymbolsTarget::File));
}

#[test]
fn rejects_missing_list_symbols_path() {
    let dir = TestDir::new("list-symbols-missing");
    let error =
        list_symbols_target(&dir.path().join("missing.rs")).expect_err("missing input should fail");

    assert!(error.contains("expected a file or directory path"));
    assert!(error.contains("does not exist"));
}

fn initialize_response(
    document_symbol_provider: Option<&serde_json::Value>,
) -> crate::lsp::InitializeResponse {
    crate::lsp::InitializeResponse::from_raw_value(json!({
        "capabilities": {
            "documentSymbolProvider": document_symbol_provider,
        }
    }))
    .expect("initialize response should decode")
}

#[test]
fn formats_list_functions_support_error_for_missing_document_symbol() {
    let error =
        ensure_document_symbol_support(&initialize_response(None), "harper-ls", "list-functions")
            .expect_err("missing document symbol support should fail");

    assert_eq!(
        error,
        "harper-ls does not support list-functions because it does not advertise textDocument/documentSymbol"
    );
}

#[test]
fn formats_list_symbols_support_error_for_missing_document_symbol() {
    let error =
        ensure_document_symbol_support(&initialize_response(None), "harper-ls", "list-symbols")
            .expect_err("missing document symbol support should fail");

    assert_eq!(
        error,
        "harper-ls does not support list-symbols because it does not advertise textDocument/documentSymbol"
    );
}