lsp-mcp 0.1.0

MCP server providing unified access to Language Server Protocol features
Documentation
use crate::error::Result;
use crate::lsp::LanguageServerManager;
use crate::tools::{ensure_document_open, make_position};
use lsp_types::{TextEdit, WorkspaceEdit};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

/// Text edit information
#[derive(Debug, Serialize, Deserialize)]
pub struct TextEditInfo {
    pub start_line: u32,
    pub start_character: u32,
    pub end_line: u32,
    pub end_character: u32,
    pub new_text: String,
}

/// File edits
#[derive(Debug, Serialize, Deserialize)]
pub struct FileEdits {
    pub file: String,
    pub edits: Vec<TextEditInfo>,
}

/// Result of prepare rename
#[derive(Debug, Serialize, Deserialize)]
pub struct PrepareRenameResult {
    pub can_rename: bool,
    pub placeholder: Option<String>,
    pub start_line: Option<u32>,
    pub start_character: Option<u32>,
    pub end_line: Option<u32>,
    pub end_character: Option<u32>,
}

/// Result of rename
#[derive(Debug, Serialize, Deserialize)]
pub struct RenameResult {
    pub files_affected: usize,
    pub total_edits: usize,
    pub edits: Vec<FileEdits>,
}

/// Prepare rename operation
pub fn prepare_rename(
    manager: &LanguageServerManager,
    file_path: &str,
    line: u32,
    character: u32,
) -> Result<PrepareRenameResult> {
    let path = PathBuf::from(file_path);
    let (client, uri) = ensure_document_open(manager, &path)?;

    let position = make_position(line, character);
    let response = client.prepare_rename(&uri, position)?;

    match response {
        Some(lsp_types::PrepareRenameResponse::Range(range)) => Ok(PrepareRenameResult {
            can_rename: true,
            placeholder: None,
            start_line: Some(range.start.line),
            start_character: Some(range.start.character),
            end_line: Some(range.end.line),
            end_character: Some(range.end.character),
        }),
        Some(lsp_types::PrepareRenameResponse::RangeWithPlaceholder { range, placeholder }) => {
            Ok(PrepareRenameResult {
                can_rename: true,
                placeholder: Some(placeholder),
                start_line: Some(range.start.line),
                start_character: Some(range.start.character),
                end_line: Some(range.end.line),
                end_character: Some(range.end.character),
            })
        }
        Some(lsp_types::PrepareRenameResponse::DefaultBehavior { default_behavior: _ }) => {
            Ok(PrepareRenameResult {
                can_rename: true,
                placeholder: None,
                start_line: None,
                start_character: None,
                end_line: None,
                end_character: None,
            })
        }
        None => Ok(PrepareRenameResult {
            can_rename: false,
            placeholder: None,
            start_line: None,
            start_character: None,
            end_line: None,
            end_character: None,
        }),
    }
}

/// Perform rename
pub fn rename_symbol(
    manager: &LanguageServerManager,
    file_path: &str,
    line: u32,
    character: u32,
    new_name: &str,
) -> Result<RenameResult> {
    let path = PathBuf::from(file_path);
    let (client, uri) = ensure_document_open(manager, &path)?;

    let position = make_position(line, character);
    let workspace_edit = client.rename(&uri, position, new_name)?;

    match workspace_edit {
        Some(edit) => Ok(convert_workspace_edit(edit)),
        None => Ok(RenameResult {
            files_affected: 0,
            total_edits: 0,
            edits: vec![],
        }),
    }
}

fn convert_workspace_edit(edit: WorkspaceEdit) -> RenameResult {
    let mut file_edits: HashMap<String, Vec<TextEditInfo>> = HashMap::new();

    // Process changes (simple text edits)
    if let Some(changes) = edit.changes {
        for (uri, edits) in changes {
            let file_path = uri
                .to_file_path()
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_else(|_| uri.to_string());

            let edit_infos: Vec<TextEditInfo> = edits.into_iter().map(convert_text_edit).collect();

            file_edits
                .entry(file_path)
                .or_default()
                .extend(edit_infos);
        }
    }

    // Process document changes (versioned edits)
    if let Some(doc_changes) = edit.document_changes {
        match doc_changes {
            lsp_types::DocumentChanges::Edits(edits) => {
                for versioned in edits {
                    let file_path = versioned
                        .text_document
                        .uri
                        .to_file_path()
                        .map(|p| p.to_string_lossy().to_string())
                        .unwrap_or_else(|_| versioned.text_document.uri.to_string());

                    let edit_infos: Vec<TextEditInfo> = versioned
                        .edits
                        .into_iter()
                        .filter_map(|e| match e {
                            lsp_types::OneOf::Left(edit) => Some(convert_text_edit(edit)),
                            lsp_types::OneOf::Right(_annotated) => None,
                        })
                        .collect();

                    file_edits
                        .entry(file_path)
                        .or_default()
                        .extend(edit_infos);
                }
            }
            lsp_types::DocumentChanges::Operations(operations) => {
                for op in operations {
                    match op {
                        lsp_types::DocumentChangeOperation::Edit(versioned) => {
                            let file_path = versioned
                                .text_document
                                .uri
                                .to_file_path()
                                .map(|p| p.to_string_lossy().to_string())
                                .unwrap_or_else(|_| versioned.text_document.uri.to_string());

                            let edit_infos: Vec<TextEditInfo> = versioned
                                .edits
                                .into_iter()
                                .filter_map(|e| match e {
                                    lsp_types::OneOf::Left(edit) => Some(convert_text_edit(edit)),
                                    lsp_types::OneOf::Right(_annotated) => None,
                                })
                                .collect();

                            file_edits
                                .entry(file_path)
                                .or_default()
                                .extend(edit_infos);
                        }
                        lsp_types::DocumentChangeOperation::Op(_) => {
                            // Skip file operations (create/rename/delete) for now
                        }
                    }
                }
            }
        }
    }

    let total_edits: usize = file_edits.values().map(|e| e.len()).sum();
    let files_affected = file_edits.len();

    let edits: Vec<FileEdits> = file_edits
        .into_iter()
        .map(|(file, edits)| FileEdits { file, edits })
        .collect();

    RenameResult {
        files_affected,
        total_edits,
        edits,
    }
}

fn convert_text_edit(edit: TextEdit) -> TextEditInfo {
    TextEditInfo {
        start_line: edit.range.start.line,
        start_character: edit.range.start.character,
        end_line: edit.range.end.line,
        end_character: edit.range.end.character,
        new_text: edit.new_text,
    }
}