panache 2.37.0

An LSP, formatter, and linter for Pandoc markdown, Quarto, and RMarkdown
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_lsp_server::Client;
use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::*;

use super::super::conversions::offset_to_position;
use super::super::helpers::get_document_and_config;
use crate::lsp::DocumentState;
use crate::{parser, range_utils};

/// Handle textDocument/formatting request
pub(crate) async fn format_document(
    client: &Client,
    document_map: Arc<Mutex<HashMap<String, DocumentState>>>,
    salsa_db: Arc<Mutex<crate::salsa::SalsaDb>>,
    workspace_root: Arc<Mutex<Option<PathBuf>>>,
    params: DocumentFormattingParams,
) -> Result<Option<Vec<TextEdit>>> {
    let uri = params.text_document.uri;
    log::debug!("format_document uri={}", *uri);

    client
        .log_message(
            MessageType::INFO,
            format!("Formatting request for {}", *uri),
        )
        .await;

    // Use helper to get document and config
    let (text, config) = match get_document_and_config(
        client,
        &document_map,
        &salsa_db,
        &workspace_root,
        &uri,
    )
    .await
    {
        Some(result) => result,
        None => {
            client
                .log_message(MessageType::ERROR, format!("Document not found: {}", *uri))
                .await;
            return Ok(None);
        }
    };

    // Run formatting in a blocking task (because rowan::SyntaxNode isn't Send)
    // but use format_async inside to support external formatters
    let text_clone = text.clone();
    let formatted = tokio::task::spawn_blocking(move || {
        // Create a new tokio runtime for async external formatters
        tokio::runtime::Runtime::new()
            .expect("Failed to create runtime")
            .block_on(crate::format_async(&text_clone, Some(config), None))
    })
    .await
    .map_err(|_| tower_lsp_server::jsonrpc::Error::internal_error())?;

    // If the content didn't change, return None
    if formatted == text {
        return Ok(None);
    }

    // Calculate the range to replace (entire document)
    // Use text.len() to ensure we include any trailing newlines
    let end_position = offset_to_position(&text, text.len());

    let range = Range {
        start: Position {
            line: 0,
            character: 0,
        },
        end: end_position,
    };

    Ok(Some(vec![TextEdit {
        range,
        new_text: formatted,
    }]))
}

/// Handle textDocument/rangeFormatting request
pub(crate) async fn format_range(
    client: &Client,
    document_map: Arc<Mutex<HashMap<String, DocumentState>>>,
    salsa_db: Arc<Mutex<crate::salsa::SalsaDb>>,
    workspace_root: Arc<Mutex<Option<PathBuf>>>,
    params: DocumentRangeFormattingParams,
) -> Result<Option<Vec<TextEdit>>> {
    let uri = params.text_document.uri;
    let range = params.range;
    log::debug!(
        "format_range uri={} start={:?} end={:?}",
        *uri,
        range.start,
        range.end
    );

    client
        .log_message(
            MessageType::INFO,
            format!(
                "Range formatting request for {} (lines {}-{})",
                *uri,
                range.start.line + 1,
                range.end.line + 1
            ),
        )
        .await;

    // Use helper to get document and config
    let (text, config) = match get_document_and_config(
        client,
        &document_map,
        &salsa_db,
        &workspace_root,
        &uri,
    )
    .await
    {
        Some(result) => result,
        None => {
            client
                .log_message(MessageType::ERROR, format!("Document not found: {}", *uri))
                .await;
            return Ok(None);
        }
    };

    // Convert LSP range (0-indexed lines, end-exclusive) to panache range (1-indexed, inclusive)
    let start_line = (range.start.line + 1) as usize;
    let mut end_line = (range.end.line + 1) as usize;
    if range.end.character == 0 && range.end.line > range.start.line {
        end_line = range.end.line as usize;
    }

    let start_offset = super::super::conversions::position_to_offset(&text, range.start);
    let end_offset = super::super::conversions::position_to_offset(&text, range.end);
    client
        .log_message(
            MessageType::INFO,
            format!(
                "Range formatting selection bytes {:?}..{:?} (start {:?}, end {:?})",
                start_offset, end_offset, range.start, range.end
            ),
        )
        .await;

    // Run range formatting in a blocking task
    let text_clone = text.clone();
    let config_clone = config.clone();
    let formatted = tokio::task::spawn_blocking(move || {
        let tree = parser::parse(&text_clone, Some(config_clone.clone()));
        let expanded_range =
            range_utils::expand_line_range_to_blocks(&tree, &text_clone, start_line, end_line);

        let output = tokio::runtime::Runtime::new()
            .expect("Failed to create runtime")
            .block_on(crate::format_async(
                &text_clone,
                Some(config_clone),
                Some((start_line, end_line)),
            ));
        (output, expanded_range)
    })
    .await
    .map_err(|_| tower_lsp_server::jsonrpc::Error::internal_error())?;

    let (formatted, expanded_range) = formatted;

    // If the formatted range is empty or unchanged, return None
    if formatted.is_empty() || formatted == text {
        return Ok(None);
    }

    if let Some((start_offset, end_offset)) = expanded_range {
        client
            .log_message(
                MessageType::INFO,
                format!(
                    "Range formatting expanded to byte range {}..{}",
                    start_offset, end_offset
                ),
            )
            .await;
    }

    // Calculate the actual range that was formatted (expanded to block boundaries)
    // For simplicity, we'll replace the entire selected range with the formatted output
    // The range expansion is already handled by panache's range_utils

    // Find where the formatted text should be placed
    // Since range formatting returns only the formatted blocks, we need to determine
    // the byte offsets in the original text to replace

    let Some((start_offset, end_offset)) = expanded_range else {
        return Ok(None);
    };

    // Create the edit range
    let edit_range = Range {
        start: offset_to_position(&text, start_offset),
        end: offset_to_position(&text, end_offset.min(text.len())),
    };
    client
        .log_message(
            MessageType::INFO,
            format!(
                "Range formatting edit range {:?}..{:?}",
                edit_range.start, edit_range.end
            ),
        )
        .await;

    Ok(Some(vec![TextEdit {
        range: edit_range,
        new_text: formatted,
    }]))
}