panache 2.43.1

An LSP, formatter, and linter for Markdown, Quarto, and R Markdown
//! Handler for textDocument/completion LSP requests.

use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_lsp_server::jsonrpc::Result;
use tower_lsp_server::ls_types::*;

use crate::lsp::DocumentState;
use crate::utils::normalize_anchor_label;

use super::super::helpers;
use crate::metadata::inline_reference_map;

pub(crate) async fn completion(
    client: &tower_lsp_server::Client,
    document_map: Arc<Mutex<HashMap<String, DocumentState>>>,
    salsa_db: Arc<Mutex<crate::salsa::SalsaDb>>,
    workspace_root: Arc<Mutex<Option<PathBuf>>>,
    params: CompletionParams,
) -> Result<Option<CompletionResponse>> {
    let uri = &params.text_document_position.text_document.uri;
    let position = params.text_document_position.position;
    let config = helpers::get_config(client, &workspace_root, uri).await;

    let Some(text) = helpers::get_document_content(&document_map, &salsa_db, uri).await else {
        return Ok(None);
    };

    let Some(offset) = super::super::conversions::position_to_offset(&text, position) else {
        return Ok(None);
    };

    let Some(query) = citation_query_prefix(&text, offset) else {
        return Ok(None);
    };

    let (salsa_file, salsa_config, doc_path, parsed_yaml_regions) = {
        let map = document_map.lock().await;
        match map.get(&uri.to_string()) {
            Some(state) => (
                state.salsa_file,
                state.salsa_config,
                state.path.clone(),
                state.parsed_yaml_regions.clone(),
            ),
            None => return Ok(None),
        }
    };

    let offset_in_frontmatter =
        helpers::is_offset_in_yaml_frontmatter(&parsed_yaml_regions, offset);
    if offset_in_frontmatter {
        return Ok(None);
    }

    let Some(doc_path) = doc_path else {
        return Ok(None);
    };
    let yaml_ok = helpers::is_yaml_frontmatter_valid(&parsed_yaml_regions);
    if !yaml_ok {
        return Ok(None);
    }

    let metadata = {
        let db = salsa_db.lock().await;
        crate::salsa::metadata(&*db, salsa_file, salsa_config, doc_path.clone()).clone()
    };
    let parse = metadata.bibliography_parse.as_ref();
    let symbol_index = {
        let db = salsa_db.lock().await;
        crate::salsa::symbol_usage_index(&*db, salsa_file, salsa_config, doc_path).clone()
    };

    let has_crossref_candidates = symbol_index
        .crossref_declaration_entries()
        .any(|(key, _)| is_supported_crossref_completion_key(key));
    if parse.is_none() && metadata.inline_references.is_empty() && !has_crossref_candidates {
        return Ok(None);
    }

    let mut seen = std::collections::HashSet::new();
    let mut items = Vec::new();
    if let Some(parse) = parse {
        for entry in parse.index.entries() {
            if !seen.insert(entry.key.to_lowercase()) {
                continue;
            }
            if !matches_query(&entry.key, &query) {
                continue;
            }
            items.push(CompletionItem {
                label: entry.key.clone(),
                kind: Some(CompletionItemKind::REFERENCE),
                insert_text: Some(entry.key.clone()),
                insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
                ..Default::default()
            });
        }
    }
    for (key, entries) in inline_reference_map(&metadata.inline_references) {
        if entries.is_empty() || !seen.insert(key.clone()) {
            continue;
        }
        let label = entries[0].id.clone();
        if !matches_query(&label, &query) {
            continue;
        }
        items.push(CompletionItem {
            label: label.clone(),
            kind: Some(CompletionItemKind::REFERENCE),
            insert_text: Some(label),
            insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
            ..Default::default()
        });
    }

    if config.extensions.quarto_crossrefs || config.extensions.bookdown_references {
        for (label, _) in symbol_index.crossref_declaration_entries() {
            if !is_supported_crossref_completion_key(label) {
                continue;
            }
            let display = normalize_anchor_label(label);
            if display.is_empty() || !seen.insert(display.to_lowercase()) {
                continue;
            }
            if !matches_query(&display, &query) {
                continue;
            }
            items.push(CompletionItem {
                label: display.clone(),
                kind: Some(CompletionItemKind::REFERENCE),
                insert_text: Some(display),
                insert_text_format: Some(InsertTextFormat::PLAIN_TEXT),
                ..Default::default()
            });
        }
    }

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

    Ok(Some(CompletionResponse::Array(items)))
}

fn citation_query_prefix(text: &str, offset: usize) -> Option<String> {
    let start = offset.saturating_sub(8);
    let snippet = &text[start..offset];
    let at = snippet.rfind('@')?;
    let query = &snippet[at + 1..];
    if query
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':' | '.'))
    {
        Some(query.to_string())
    } else {
        None
    }
}

fn matches_query(candidate: &str, query: &str) -> bool {
    if query.is_empty() {
        return true;
    }
    candidate
        .to_ascii_lowercase()
        .starts_with(&query.to_ascii_lowercase())
}

fn is_supported_crossref_completion_key(key: &str) -> bool {
    panache_parser::parser::inlines::citations::is_quarto_crossref_key(key)
        || panache_parser::parser::inlines::citations::has_bookdown_prefix(key)
}