panache 2.53.0

An LSP, formatter, and linter for Markdown, Quarto, and R Markdown
//! `textDocument/formatting` and `rangeFormatting`.
//!
//! Runs on a [`TaskPool`](crate::lsp::task_pool) worker over a
//! [`StateSnapshot`]; formatting itself is the synchronous [`crate::format`],
//! which routes through the synchronous external-formatter path.

use lsp_types::{
    DocumentFormattingParams, DocumentRangeFormattingParams, Position, Range, TextEdit,
};

use super::super::conversions::{offset_to_position, position_to_offset};
use super::super::helpers::is_uri_excluded;
use crate::lsp::global_state::StateSnapshot;
use crate::{parser, range_utils};

/// Handle `textDocument/formatting`.
pub(crate) fn format_document(
    snap: &StateSnapshot,
    params: DocumentFormattingParams,
) -> Option<Vec<TextEdit>> {
    let uri = params.text_document.uri;
    log::debug!("format_document uri={}", uri.as_str());

    let (text, config, source, workspace_root) = snap.document_config_and_source(&uri)?;

    if is_uri_excluded(&uri, &config, &source, workspace_root.as_deref()) {
        log::info!(
            "Skipping formatting (matched exclude pattern): {}",
            uri.as_str()
        );
        return None;
    }

    // Reuse the salsa-cached parse (the one hover/symbols read) instead of
    // parsing afresh, saving a parse per format request. Falls back to a fresh
    // parse only if the document somehow isn't open.
    let formatted = match snap.parsed_tree(&uri) {
        Some(tree) => crate::format_with_tree(&text, &tree, &config, None),
        None => crate::format(&text, Some(config), None),
    };

    if formatted == text {
        return None;
    }

    // Replace the entire document; use text.len() to include trailing newlines.
    let end_position = offset_to_position(&text, text.len());
    let range = Range {
        start: Position {
            line: 0,
            character: 0,
        },
        end: end_position,
    };

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

/// Handle `textDocument/rangeFormatting`.
///
/// Range formatting intentionally bypasses `exclude`/`extend-exclude`: it only
/// fires when the user explicitly selects text and asks to format it, mirroring
/// the CLI's "explicit file target bypasses excludes" rule.
pub(crate) fn format_range(
    snap: &StateSnapshot,
    params: DocumentRangeFormattingParams,
) -> Option<Vec<TextEdit>> {
    let uri = params.text_document.uri;
    let range = params.range;
    log::debug!(
        "format_range uri={} start={:?} end={:?}",
        uri.as_str(),
        range.start,
        range.end
    );

    let (text, config) = snap.document_and_config(&uri)?;

    // 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 _ = (
        position_to_offset(&text, range.start),
        position_to_offset(&text, range.end),
    );

    // Reuse the salsa-cached parse for both range expansion and formatting,
    // rather than parsing twice (once here, once inside `format`).
    let tree = snap
        .parsed_tree(&uri)
        .unwrap_or_else(|| parser::parse(&text, Some(config.clone())));
    let expanded_range =
        range_utils::expand_line_range_to_blocks(&tree, &text, start_line, end_line);
    let formatted = crate::format_with_tree(&text, &tree, &config, Some((start_line, end_line)));

    if formatted.is_empty() || formatted == text {
        return None;
    }

    let (start_offset, end_offset) = expanded_range?;

    let edit_range = Range {
        start: offset_to_position(&text, start_offset),
        end: offset_to_position(&text, end_offset.min(text.len())),
    };

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