panache 2.60.0

An LSP, formatter, and linter for Markdown, Quarto, and R Markdown
use rowan::TextRange;

use crate::linter::diagnostics::{Diagnostic, Location};
use crate::linter::offsets::line_col_to_byte_offset_1based;
use crate::metadata::{
    DocumentMetadata, InlineBibConflict, InlineReferenceDuplicate, YamlError,
    bibliography_range_map, format_bibliography_load_error, inline_bib_conflicts,
    inline_reference_duplicates,
};

pub fn yaml_error_diagnostic(error: &YamlError, text: &str) -> Option<Diagnostic> {
    match error {
        YamlError::ParseError {
            message,
            line,
            column,
            byte_offset,
        } => {
            let offset = (*byte_offset)
                .unwrap_or_else(|| line_col_to_offset(text, *line as usize, *column as usize));
            let range = TextRange::new((offset as u32).into(), (offset as u32).into());
            Some(Diagnostic::warning(
                Location::from_range(range, text),
                "yaml-parse-error",
                format!("YAML parse error: {}", message),
            ))
        }
        YamlError::StructureError(msg) => Some(Diagnostic::warning(
            Location::from_range(TextRange::default(), text),
            "yaml-structure-error",
            format!("YAML structure error: {}", msg),
        )),
        YamlError::NotFound(_) => None,
    }
}

pub(crate) fn yaml_parse_error_at_offset_diagnostic(
    text: &str,
    offset: usize,
    message: Option<&str>,
) -> Diagnostic {
    let range = TextRange::new((offset as u32).into(), (offset as u32).into());
    Diagnostic::warning(
        Location::from_range(range, text),
        "yaml-parse-error",
        format!(
            "YAML parse error: {}",
            message.unwrap_or("invalid YAML content")
        ),
    )
}

pub fn metadata_diagnostics(metadata: &DocumentMetadata, text: &str) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    diagnostics.extend(check_bibliography_parse(metadata, text));
    diagnostics.extend(inline_reference_diagnostics(metadata, text));
    diagnostics
}

/// Duplicate-bibliography-key diagnostics for the bibliographies a project
/// manifest declares, anchored to the manifest's own `bibliography:` value(s) in
/// `manifest_text`.
///
/// A duplicate confined to a project-level bibliography (declared in
/// `_quarto.yml`, inherited by every document) is a defect of that shared file,
/// not of any one document. The document-level `citation-keys` rule deliberately
/// skips it; this reports it once, against the manifest, so it is still caught.
pub fn manifest_bibliography_diagnostics(
    manifest_path: &std::path::Path,
    manifest_text: &str,
) -> Vec<Diagnostic> {
    let bibs = match crate::metadata::manifest_declared_bibliographies(manifest_path, manifest_text)
    {
        Ok(bibs) => bibs,
        // YAML parse errors are surfaced by the manifest's own schema/parse pass.
        Err(_) => return Vec::new(),
    };
    if bibs.is_empty() {
        return Vec::new();
    }

    let paths: Vec<std::path::PathBuf> = bibs.iter().map(|b| b.resolved_path.clone()).collect();
    let index = crate::bib::load_bibliography(&paths);

    let mut diagnostics = Vec::new();
    for duplicate in &index.duplicates {
        // Anchor at the manifest declaration of the file the duplicate entry
        // lives in, falling back to the first occurrence's file. Both point at
        // the same `assets/bibliography.bib` value for a self-duplicate.
        let Some(anchor) = bibs
            .iter()
            .find(|b| b.resolved_path == duplicate.duplicate.file)
            .or_else(|| {
                bibs.iter()
                    .find(|b| b.resolved_path == duplicate.first.file)
            })
        else {
            continue;
        };
        let range = TextRange::new(
            (anchor.value_range.start as u32).into(),
            (anchor.value_range.end as u32).into(),
        );
        diagnostics.push(Diagnostic::warning(
            Location::from_range(range, manifest_text),
            "duplicate-bibliography-key",
            format!(
                "Duplicate bibliography key '{}' in {} and {}",
                duplicate.key,
                duplicate.first.file.display(),
                duplicate.duplicate.file.display()
            ),
        ));
    }
    diagnostics
}

fn check_bibliography_parse(metadata: &DocumentMetadata, text: &str) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    let Some(parse) = metadata.bibliography_parse.as_ref() else {
        return diagnostics;
    };
    let range_by_path = bibliography_range_map(metadata);
    let source_ranges = metadata
        .bibliography
        .as_ref()
        .map(|info| info.source_ranges.as_slice())
        .unwrap_or_default();
    let fallback_range = source_ranges.first().cloned().unwrap_or_default();

    for error in &parse.index.load_errors {
        let range = range_by_path
            .get(&error.path)
            .copied()
            .unwrap_or(fallback_range);
        let message = format_bibliography_load_error(&error.message);
        diagnostics.push(Diagnostic::error(
            Location::from_range(range, text),
            "bibliography-load-error",
            format!(
                "Failed to load bibliography {}: {}",
                error.path.display(),
                message
            ),
        ));
    }

    for duplicate in &parse.index.duplicates {
        let range = range_by_path
            .get(&duplicate.first.file)
            .or_else(|| range_by_path.get(&duplicate.duplicate.file))
            .copied()
            .unwrap_or(fallback_range);
        diagnostics.push(Diagnostic::warning(
            Location::from_range(range, text),
            "duplicate-bibliography-key",
            format!(
                "Duplicate bibliography key '{}' in {} and {}",
                duplicate.key,
                duplicate.first.file.display(),
                duplicate.duplicate.file.display()
            ),
        ));
    }

    for message in &parse.parse_errors {
        diagnostics.push(Diagnostic::error(
            Location::from_range(fallback_range, text),
            "bibliography-parse-error",
            format!("Invalid bibliography entry: {}", message),
        ));
    }

    diagnostics
}

fn inline_reference_diagnostics(metadata: &DocumentMetadata, text: &str) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    if metadata.inline_references.is_empty() {
        return diagnostics;
    }
    for duplicate in inline_reference_duplicates(&metadata.inline_references) {
        diagnostics.push(inline_reference_duplicate_diagnostic(&duplicate, text));
    }
    if let Some(parse) = metadata.bibliography_parse.as_ref() {
        for conflict in inline_bib_conflicts(&metadata.inline_references, &parse.index) {
            diagnostics.push(inline_reference_conflict_diagnostic(&conflict, text));
        }
    }
    diagnostics
}

fn inline_reference_duplicate_diagnostic(
    duplicate: &InlineReferenceDuplicate,
    text: &str,
) -> Diagnostic {
    Diagnostic::warning(
        Location::from_range(duplicate.duplicate.range, text),
        "duplicate-inline-reference-id",
        format!("Duplicate inline reference id '{}'", duplicate.key),
    )
}

fn inline_reference_conflict_diagnostic(conflict: &InlineBibConflict, text: &str) -> Diagnostic {
    Diagnostic::warning(
        Location::from_range(conflict.inline.range, text),
        "duplicate-inline-reference-id",
        format!(
            "Duplicate inline reference id '{}' in {} and {}",
            conflict.key,
            conflict.inline.path.display(),
            conflict.bib.source_file.display()
        ),
    )
}

fn line_col_to_offset(input: &str, line: usize, column: usize) -> usize {
    line_col_to_byte_offset_1based(input, line, column).unwrap_or(input.len())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bib::{BibIndex, BibLoadError};
    use crate::metadata::{BibliographyInfo, BibliographyParse, CitationInfo};
    use std::collections::HashMap;
    use std::path::PathBuf;

    #[test]
    fn bibliography_load_error_uses_source_range() {
        let text = "---\nbibliography: test.bib\n---\n\nText\n";
        let start = text.find("test.bib").unwrap();
        let end = start + "test.bib".len();
        let range = TextRange::new((start as u32).into(), (end as u32).into());
        let path = PathBuf::from("/tmp/test.bib");

        let metadata = DocumentMetadata {
            source_path: PathBuf::from("/tmp/test.qmd"),
            bibliography: Some(BibliographyInfo {
                paths: vec![path.clone()],
                source_ranges: vec![range],
            }),
            metadata_files: Vec::new(),
            bibliography_parse: Some(BibliographyParse {
                index: BibIndex {
                    entries: HashMap::new(),
                    duplicates: Vec::new(),
                    errors: Vec::new(),
                    load_errors: vec![BibLoadError {
                        path: path.clone(),
                        message: "No such file or directory (os error 2)".to_string(),
                    }],
                },
                parse_errors: Vec::new(),
            }),
            inline_references: Vec::new(),
            citations: CitationInfo { keys: Vec::new() },
            title: None,
            raw_yaml: String::new(),
        };

        let diagnostics = metadata_diagnostics(&metadata, text);
        assert_eq!(diagnostics.len(), 1);
        let diag = &diagnostics[0];
        assert_eq!(diag.location.range, range);
        assert_eq!(
            diag.message,
            "Failed to load bibliography /tmp/test.bib: File not found"
        );
    }

    #[test]
    fn manifest_bibliography_diagnostic_anchors_to_manifest_value() {
        // A self-duplicate inside a manifest-declared bibliography is reported
        // once, anchored to the `bibliography:` value in the manifest text.
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join("assets")).unwrap();
        std::fs::write(
            dir.path().join("assets/bibliography.bib"),
            "@article{dup,\n  title = {One},\n  year = {2020}\n}\n\
             @article{dup,\n  title = {Two},\n  year = {2021}\n}\n",
        )
        .unwrap();
        let manifest_path = dir.path().join("_quarto.yml");
        let manifest_text = "project:\n  type: website\nbibliography: assets/bibliography.bib\n";

        let diagnostics = manifest_bibliography_diagnostics(&manifest_path, manifest_text);
        assert_eq!(diagnostics.len(), 1);
        let diag = &diagnostics[0];
        assert_eq!(diag.code, "duplicate-bibliography-key");
        assert!(diag.message.contains("'dup'"));
        // Anchored to the `assets/bibliography.bib` value in the manifest.
        let start: usize = diag.location.range.start().into();
        let end: usize = diag.location.range.end().into();
        assert_eq!(&manifest_text[start..end], "assets/bibliography.bib");
    }

    #[test]
    fn manifest_without_bibliography_yields_no_diagnostics() {
        let dir = tempfile::tempdir().unwrap();
        let manifest_path = dir.path().join("_quarto.yml");
        let manifest_text = "project:\n  type: website\n";
        assert!(manifest_bibliography_diagnostics(&manifest_path, manifest_text).is_empty());
    }

    #[test]
    fn line_col_to_offset_handles_unicode_columns() {
        let text = "éx\n";
        assert_eq!(line_col_to_offset(text, 1, 1), 0);
        assert_eq!(line_col_to_offset(text, 1, 2), 2);
        assert_eq!(line_col_to_offset(text, 1, 3), 3);
    }

    #[test]
    fn yaml_error_diagnostic_prefers_byte_offset_mapping() {
        let text = "---\ntitle: [\n---\n";
        let diag = yaml_error_diagnostic(
            &YamlError::ParseError {
                message: "bad yaml".to_string(),
                line: 1,
                column: 1,
                byte_offset: Some(8),
            },
            text,
        )
        .expect("diagnostic");
        let start: usize = diag.location.range.start().into();
        assert_eq!(start, 8);
    }

    #[test]
    fn yaml_parse_error_at_offset_diagnostic_uses_host_error_offset() {
        let text = "---\ntitle: [\n---\n";
        let diag = yaml_parse_error_at_offset_diagnostic(text, 11, Some("expected ]"));
        let start: usize = diag.location.range.start().into();
        assert_eq!(start, 11);
        assert_eq!(diag.code, "yaml-parse-error");
    }
}