codineer-lsp 0.6.4

Language Server Protocol client integration for Codineer
Documentation
use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};

use lsp_types::{Diagnostic, Range};
use serde_json::Value;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LspServerConfig {
    pub name: String,
    pub command: String,
    pub args: Vec<String>,
    pub env: BTreeMap<String, String>,
    pub workspace_root: PathBuf,
    pub initialization_options: Option<Value>,
    pub extension_to_language: BTreeMap<String, String>,
}

impl LspServerConfig {
    #[must_use]
    pub fn language_id_for(&self, path: &Path) -> Option<&str> {
        let extension = normalize_extension(path.extension()?.to_string_lossy().as_ref());
        self.extension_to_language
            .get(&extension)
            .map(String::as_str)
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct FileDiagnostics {
    pub path: PathBuf,
    pub uri: String,
    pub diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct WorkspaceDiagnostics {
    pub files: Vec<FileDiagnostics>,
}

impl WorkspaceDiagnostics {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.files.is_empty()
    }

    #[must_use]
    pub fn total_diagnostics(&self) -> usize {
        self.files.iter().map(|file| file.diagnostics.len()).sum()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolLocation {
    pub path: PathBuf,
    pub range: Range,
}

impl SymbolLocation {
    #[must_use]
    pub fn start_line(&self) -> u32 {
        self.range.start.line + 1
    }

    #[must_use]
    pub fn start_character(&self) -> u32 {
        self.range.start.character + 1
    }
}

impl Display for SymbolLocation {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}:{}:{}",
            self.path.display(),
            self.start_line(),
            self.start_character()
        )
    }
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct LspContextEnrichment {
    pub file_path: PathBuf,
    pub diagnostics: WorkspaceDiagnostics,
    pub definitions: Vec<SymbolLocation>,
    pub references: Vec<SymbolLocation>,
}

impl LspContextEnrichment {
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.diagnostics.is_empty() && self.definitions.is_empty() && self.references.is_empty()
    }

    #[must_use]
    pub fn render_prompt_section(&self) -> String {
        const MAX_RENDERED_DIAGNOSTICS: usize = 12;
        const MAX_RENDERED_LOCATIONS: usize = 12;

        let mut lines = vec!["# LSP context".to_string()];
        lines.push(format!(" - Focus file: {}", self.file_path.display()));
        lines.push(format!(
            " - Workspace diagnostics: {} across {} file(s)",
            self.diagnostics.total_diagnostics(),
            self.diagnostics.files.len()
        ));

        if !self.diagnostics.files.is_empty() {
            lines.push(String::new());
            lines.push("Diagnostics:".to_string());
            let mut rendered = 0usize;
            for file in &self.diagnostics.files {
                for diagnostic in &file.diagnostics {
                    if rendered == MAX_RENDERED_DIAGNOSTICS {
                        lines.push(" - Additional diagnostics omitted for brevity.".to_string());
                        break;
                    }
                    let severity = diagnostic_severity_label(diagnostic.severity);
                    lines.push(format!(
                        " - {}:{}:{} [{}] {}",
                        file.path.display(),
                        diagnostic.range.start.line + 1,
                        diagnostic.range.start.character + 1,
                        severity,
                        diagnostic.message.replace('\n', " ")
                    ));
                    rendered += 1;
                }
                if rendered == MAX_RENDERED_DIAGNOSTICS {
                    break;
                }
            }
        }

        if !self.definitions.is_empty() {
            lines.push(String::new());
            lines.push("Definitions:".to_string());
            lines.extend(
                self.definitions
                    .iter()
                    .take(MAX_RENDERED_LOCATIONS)
                    .map(|location| format!(" - {location}")),
            );
            if self.definitions.len() > MAX_RENDERED_LOCATIONS {
                lines.push(" - Additional definitions omitted for brevity.".to_string());
            }
        }

        if !self.references.is_empty() {
            lines.push(String::new());
            lines.push("References:".to_string());
            lines.extend(
                self.references
                    .iter()
                    .take(MAX_RENDERED_LOCATIONS)
                    .map(|location| format!(" - {location}")),
            );
            if self.references.len() > MAX_RENDERED_LOCATIONS {
                lines.push(" - Additional references omitted for brevity.".to_string());
            }
        }

        lines.join("\n")
    }
}

#[must_use]
pub(crate) fn normalize_extension(extension: &str) -> String {
    if extension.starts_with('.') {
        extension.to_ascii_lowercase()
    } else {
        format!(".{}", extension.to_ascii_lowercase())
    }
}

fn diagnostic_severity_label(severity: Option<lsp_types::DiagnosticSeverity>) -> &'static str {
    match severity {
        Some(lsp_types::DiagnosticSeverity::ERROR) => "error",
        Some(lsp_types::DiagnosticSeverity::WARNING) => "warning",
        Some(lsp_types::DiagnosticSeverity::INFORMATION) => "info",
        Some(lsp_types::DiagnosticSeverity::HINT) => "hint",
        _ => "unknown",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use lsp_types::{DiagnosticSeverity, Position, Range};
    use std::path::PathBuf;

    #[test]
    fn normalize_extension_handles_dot_prefix_and_casing() {
        assert_eq!(normalize_extension("rs"), ".rs");
        assert_eq!(normalize_extension(".rs"), ".rs");
        assert_eq!(normalize_extension("RS"), ".rs");
        assert_eq!(normalize_extension(".PY"), ".py");
        assert_eq!(normalize_extension("ts"), ".ts");
    }

    #[test]
    fn symbol_location_display_and_line_numbers() {
        let location = SymbolLocation {
            path: PathBuf::from("/src/main.rs"),
            range: Range {
                start: Position::new(9, 4),
                end: Position::new(9, 10),
            },
        };
        assert_eq!(location.start_line(), 10);
        assert_eq!(location.start_character(), 5);
        assert_eq!(format!("{location}"), "/src/main.rs:10:5");
    }

    #[test]
    fn workspace_diagnostics_counts_total() {
        let diag = WorkspaceDiagnostics {
            files: vec![
                FileDiagnostics {
                    path: PathBuf::from("/a.rs"),
                    uri: "file:///a.rs".to_string(),
                    diagnostics: vec![Diagnostic::default(), Diagnostic::default()],
                },
                FileDiagnostics {
                    path: PathBuf::from("/b.rs"),
                    uri: "file:///b.rs".to_string(),
                    diagnostics: vec![Diagnostic::default()],
                },
            ],
        };
        assert_eq!(diag.total_diagnostics(), 3);
        assert!(!diag.is_empty());
        assert!(WorkspaceDiagnostics::default().is_empty());
    }

    #[test]
    fn context_enrichment_renders_prompt_section_with_diagnostics() {
        let enrichment = LspContextEnrichment {
            file_path: PathBuf::from("/src/lib.rs"),
            diagnostics: WorkspaceDiagnostics {
                files: vec![FileDiagnostics {
                    path: PathBuf::from("/src/lib.rs"),
                    uri: "file:///src/lib.rs".to_string(),
                    diagnostics: vec![Diagnostic {
                        message: "unused variable".to_string(),
                        severity: Some(DiagnosticSeverity::WARNING),
                        range: Range {
                            start: Position::new(4, 0),
                            end: Position::new(4, 5),
                        },
                        ..Diagnostic::default()
                    }],
                }],
            },
            definitions: vec![SymbolLocation {
                path: PathBuf::from("/src/lib.rs"),
                range: Range {
                    start: Position::new(0, 0),
                    end: Position::new(0, 5),
                },
            }],
            references: vec![],
        };
        let rendered = enrichment.render_prompt_section();
        assert!(rendered.contains("# LSP context"));
        assert!(rendered.contains("[warning]"));
        assert!(rendered.contains("unused variable"));
        assert!(rendered.contains("Definitions:"));
        assert!(!rendered.contains("References:"));
    }

    #[test]
    fn empty_enrichment_reports_empty() {
        let empty = LspContextEnrichment::default();
        assert!(empty.is_empty());
    }

    #[test]
    fn severity_labels_are_correct() {
        assert_eq!(
            diagnostic_severity_label(Some(DiagnosticSeverity::ERROR)),
            "error"
        );
        assert_eq!(
            diagnostic_severity_label(Some(DiagnosticSeverity::WARNING)),
            "warning"
        );
        assert_eq!(
            diagnostic_severity_label(Some(DiagnosticSeverity::INFORMATION)),
            "info"
        );
        assert_eq!(
            diagnostic_severity_label(Some(DiagnosticSeverity::HINT)),
            "hint"
        );
        assert_eq!(diagnostic_severity_label(None), "unknown");
    }

    #[test]
    fn language_id_for_maps_configured_extensions() {
        let config = LspServerConfig {
            name: "test-server".to_string(),
            command: "echo".to_string(),
            args: vec![],
            env: BTreeMap::new(),
            workspace_root: PathBuf::from("/workspace"),
            initialization_options: None,
            extension_to_language: BTreeMap::from([
                (".rs".to_string(), "rust".to_string()),
                (".py".to_string(), "python".to_string()),
            ]),
        };
        assert_eq!(config.language_id_for(Path::new("main.rs")), Some("rust"));
        assert_eq!(
            config.language_id_for(Path::new("script.py")),
            Some("python")
        );
        assert_eq!(config.language_id_for(Path::new("style.css")), None);
        assert_eq!(config.language_id_for(Path::new("Makefile")), None);
    }
}