panache 2.33.1

An LSP, formatter, and linter for Pandoc markdown, Quarto, and RMarkdown
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;

use rowan::GreenNode;
use tokio::sync::Mutex;
use tower_lsp_server::Client;
use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::{
    Location, OneOf, Position, Range, SymbolKind, Uri, WorkspaceSymbol, WorkspaceSymbolParams,
    WorkspaceSymbolResponse,
};

use crate::lsp::DocumentState;
use crate::lsp::conversions::offset_to_position;
use crate::salsa::{Db, HeadingOutlineEntry};
use crate::syntax::{AstNode, Document, Heading, SyntaxNode};

pub(crate) async fn workspace_symbol(
    _client: &Client,
    document_map: Arc<Mutex<HashMap<String, DocumentState>>>,
    salsa_db: Arc<Mutex<crate::salsa::SalsaDb>>,
    _workspace_root: Arc<Mutex<Option<PathBuf>>>,
    params: WorkspaceSymbolParams,
) -> Result<Option<WorkspaceSymbolResponse>> {
    let open_documents: Vec<(String, DocumentState)> = {
        let map = document_map.lock().await;
        map.iter()
            .map(|(uri, state)| (uri.clone(), state.clone()))
            .collect()
    };

    if open_documents.is_empty() {
        return Ok(None);
    }

    let mut candidate_paths: HashSet<PathBuf> = HashSet::new();
    let mut path_configs: HashMap<PathBuf, crate::salsa::FileConfig> = HashMap::new();
    let mut path_uris: HashMap<PathBuf, Uri> = HashMap::new();
    let mut memory_states: Vec<(Uri, crate::salsa::FileText, GreenNode)> = Vec::new();
    let mut memory_docs: Vec<(Uri, String, GreenNode)> = Vec::new();

    for (uri_str, state) in &open_documents {
        if let Some(path) = &state.path {
            candidate_paths.insert(path.clone());
            path_configs
                .entry(path.clone())
                .or_insert(state.salsa_config);
            if let Ok(uri) = uri_str.parse::<Uri>() {
                path_uris.entry(path.clone()).or_insert(uri);
            }

            let graph_paths = crate::lsp::navigation::project_document_paths(
                &salsa_db,
                state.salsa_file,
                state.salsa_config,
                path,
            )
            .await;

            for graph_path in graph_paths {
                candidate_paths.insert(graph_path.clone());
                path_configs.entry(graph_path).or_insert(state.salsa_config);
            }
        } else if let Ok(uri) = uri_str.parse::<Uri>() {
            memory_states.push((uri, state.salsa_file, state.tree.clone()));
        }
    }

    if !memory_states.is_empty() {
        let db = salsa_db.lock().await;
        for (uri, file, tree) in memory_states {
            let content = file.text(&*db).clone();
            memory_docs.push((uri, content, tree));
        }
    }

    let query = params.query.trim().to_lowercase();
    let mut symbols = Vec::new();

    {
        let db = salsa_db.lock().await;
        for path in candidate_paths {
            let Some(file) = db.file_text(path.clone()) else {
                continue;
            };
            let Some(config) = path_configs.get(&path).copied() else {
                continue;
            };
            let uri = Uri::from_file_path(&path).or_else(|| path_uris.get(&path).cloned());
            let Some(uri) = uri else {
                continue;
            };

            let content = file.text(&*db).clone();
            let outline = crate::salsa::heading_outline(&*db, file, config, path).clone();
            symbols.extend(symbols_for_document(&uri, &content, &outline, &query));
        }
    }

    for (uri, content, green) in memory_docs {
        let root = SyntaxNode::new_root(green);
        let outline = heading_outline_from_root(&root);
        symbols.extend(symbols_for_document(&uri, &content, &outline, &query));
    }

    symbols.sort_by(compare_workspace_symbol);
    symbols.dedup_by(|a, b| {
        a.name == b.name
            && a.kind == b.kind
            && workspace_symbol_uri(a) == workspace_symbol_uri(b)
            && workspace_symbol_range(a) == workspace_symbol_range(b)
    });

    if symbols.is_empty() {
        Ok(None)
    } else {
        Ok(Some(WorkspaceSymbolResponse::Nested(symbols)))
    }
}

fn heading_outline_from_root(root: &SyntaxNode) -> Vec<HeadingOutlineEntry> {
    let Some(document) = Document::cast(root.clone()) else {
        return Vec::new();
    };

    document
        .blocks()
        .filter_map(Heading::cast)
        .filter(|heading| crate::salsa::is_structural_heading_node(heading.syntax()))
        .filter_map(|heading| {
            let level = heading.level();
            if level == 0 {
                return None;
            }

            let title = heading.title_or("(empty)");
            Some(HeadingOutlineEntry {
                title,
                level,
                range: heading.text_range(),
            })
        })
        .collect()
}

fn symbols_for_document(
    uri: &Uri,
    content: &str,
    outline: &[HeadingOutlineEntry],
    query: &str,
) -> Vec<WorkspaceSymbol> {
    let mut symbols = Vec::new();
    let mut heading_stack: Vec<(usize, String)> = Vec::new();

    for entry in outline {
        while let Some((stack_level, _)) = heading_stack.last() {
            if *stack_level < entry.level {
                break;
            }
            heading_stack.pop();
        }

        let container_name = heading_stack.last().map(|(_, title)| title.clone());
        heading_stack.push((entry.level, entry.title.clone()));

        if !query.is_empty() && !entry.title.to_lowercase().contains(query) {
            continue;
        }

        symbols.push(make_workspace_symbol(
            entry.title.clone(),
            SymbolKind::NAMESPACE,
            Location {
                uri: uri.clone(),
                range: range_from_text_range(content, entry.range),
            },
            container_name,
        ));
    }

    symbols
}

fn make_workspace_symbol(
    name: String,
    kind: SymbolKind,
    location: Location,
    container_name: Option<String>,
) -> WorkspaceSymbol {
    WorkspaceSymbol {
        name,
        kind,
        tags: None,
        container_name,
        location: OneOf::Left(location),
        data: None,
    }
}

fn range_from_text_range(content: &str, range: rowan::TextRange) -> Range {
    Range {
        start: offset_to_position(content, range.start().into()),
        end: offset_to_position(content, range.end().into()),
    }
}

fn compare_workspace_symbol(a: &WorkspaceSymbol, b: &WorkspaceSymbol) -> std::cmp::Ordering {
    compare_locations(workspace_symbol_location(a), workspace_symbol_location(b))
        .then(a.name.cmp(&b.name))
        .then(a.container_name.cmp(&b.container_name))
}

fn workspace_symbol_location(symbol: &WorkspaceSymbol) -> Option<&Location> {
    match &symbol.location {
        OneOf::Left(location) => Some(location),
        OneOf::Right(_) => None,
    }
}

fn workspace_symbol_uri(symbol: &WorkspaceSymbol) -> &Uri {
    match &symbol.location {
        OneOf::Left(location) => &location.uri,
        OneOf::Right(location) => &location.uri,
    }
}

fn workspace_symbol_range(symbol: &WorkspaceSymbol) -> Option<&Range> {
    workspace_symbol_location(symbol).map(|location| &location.range)
}

fn compare_locations(a: Option<&Location>, b: Option<&Location>) -> std::cmp::Ordering {
    let a_uri = a.map(|location| location.uri.as_str()).unwrap_or("");
    let b_uri = b.map(|location| location.uri.as_str()).unwrap_or("");

    a_uri
        .cmp(b_uri)
        .then_with(|| {
            compare_positions(
                a.map(|location| &location.range.start),
                b.map(|location| &location.range.start),
            )
        })
        .then_with(|| {
            compare_positions(
                a.map(|location| &location.range.end),
                b.map(|location| &location.range.end),
            )
        })
}

fn compare_positions(a: Option<&Position>, b: Option<&Position>) -> std::cmp::Ordering {
    match (a, b) {
        (Some(a), Some(b)) => a.line.cmp(&b.line).then(a.character.cmp(&b.character)),
        (None, Some(_)) => std::cmp::Ordering::Less,
        (Some(_), None) => std::cmp::Ordering::Greater,
        (None, None) => std::cmp::Ordering::Equal,
    }
}

#[cfg(test)]
mod tests {
    use super::{heading_outline_from_root, symbols_for_document};
    use std::env;
    use tower_lsp_server::ls_types::Uri;

    #[test]
    fn extracts_heading_symbols_with_container_names() {
        let content = "# Top\n\n## Child\n\n### Grandchild\n\n## Sibling\n";
        let uri = Uri::from_file_path(env::temp_dir().join("test.qmd")).expect("path uri");
        let root = crate::parse(content, None);
        let outline = heading_outline_from_root(&root);
        let symbols = symbols_for_document(&uri, content, &outline, "");

        assert_eq!(symbols.len(), 4);
        assert_eq!(symbols[0].name, "Top");
        assert_eq!(symbols[1].container_name.as_deref(), Some("Top"));
        assert_eq!(symbols[2].container_name.as_deref(), Some("Child"));
        assert_eq!(symbols[3].container_name.as_deref(), Some("Top"));
    }

    #[test]
    fn filters_heading_symbols_by_query() {
        let content = "# Intro\n\n## Methods\n\n## Results\n";
        let uri = Uri::from_file_path(env::temp_dir().join("test.qmd")).expect("path uri");
        let root = crate::parse(content, None);
        let outline = heading_outline_from_root(&root);
        let symbols = symbols_for_document(&uri, content, &outline, "intro");

        assert_eq!(symbols.len(), 1);
        assert_eq!(symbols[0].name, "Intro");
    }

    #[test]
    fn excludes_container_headings_from_outline() {
        let content = "# Top\n\n- # Item Heading\n\nTerm\n: # Definition Heading\n\n> # Quote Heading\n\n## Child\n";
        let uri = Uri::from_file_path(env::temp_dir().join("test.qmd")).expect("path uri");
        let root = crate::parse(content, None);
        let outline = heading_outline_from_root(&root);
        let symbols = symbols_for_document(&uri, content, &outline, "");

        assert_eq!(symbols.len(), 2);
        assert_eq!(symbols[0].name, "Top");
        assert_eq!(symbols[1].name, "Child");
        assert_eq!(symbols[1].container_name.as_deref(), Some("Top"));
    }
}