panache 2.43.1

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

use super::{
    ExternalLinterParser, LinterError, ParseContext, line_col_to_offset,
    map_concatenated_offset_to_original_with_end_boundary,
};
use crate::linter::diagnostics::{Diagnostic, DiagnosticOrigin, Location};

#[derive(Debug, Deserialize)]
pub(crate) struct RuffDiagnostic {
    code: String,
    message: String,
    location: RuffPosition,
    end_location: RuffPosition,
    #[allow(dead_code)]
    filename: String,
    #[serde(default)]
    severity: Option<String>,
    #[serde(default)]
    fix: Option<RuffFix>,
}

#[derive(Debug, Deserialize)]
struct RuffPosition {
    row: usize,
    column: usize,
}

#[derive(Debug, Deserialize)]
struct RuffFix {
    message: String,
    edits: Vec<RuffEdit>,
}

#[derive(Debug, Deserialize)]
struct RuffEdit {
    content: String,
    location: RuffPosition,
    end_location: RuffPosition,
}

pub(crate) struct RuffParser;

impl ExternalLinterParser for RuffParser {
    const NAME: &'static str = "ruff";

    fn parse(ctx: &ParseContext<'_>) -> Result<Vec<Diagnostic>, LinterError> {
        use crate::linter::diagnostics::{Edit, Fix};

        let output: Vec<RuffDiagnostic> = serde_json::from_str(ctx.output)
            .map_err(|e| LinterError::ParseError(format!("invalid ruff JSON: {}", e)))?;

        let mut diagnostics = Vec::new();
        for ruff_diag in output {
            let line = ruff_diag.location.row;
            let column = ruff_diag.location.column;
            let start_offset = line_col_to_offset(ctx.original_input, line, column)
                .unwrap_or(ctx.original_input.len());

            let end_line = ruff_diag.end_location.row;
            let end_column = ruff_diag.end_location.column;
            let end_offset = line_col_to_offset(ctx.original_input, end_line, end_column)
                .unwrap_or(ctx.original_input.len());

            let range = TextRange::new((start_offset as u32).into(), (end_offset as u32).into());
            let location = Location {
                line,
                column,
                range,
            };

            let fix = if let (Some(mappings), Some(fix)) = (ctx.mappings, ruff_diag.fix.as_ref()) {
                let mut edits = Vec::new();
                for edit in &fix.edits {
                    let start = line_col_to_offset(
                        ctx.linted_input,
                        edit.location.row,
                        edit.location.column,
                    );
                    let end = line_col_to_offset(
                        ctx.linted_input,
                        edit.end_location.row,
                        edit.end_location.column,
                    );

                    let Some(start) = start else {
                        edits.clear();
                        break;
                    };
                    let Some(end) = end else {
                        edits.clear();
                        break;
                    };

                    let Some(mapped_start) =
                        map_concatenated_offset_to_original_with_end_boundary(start, mappings)
                    else {
                        edits.clear();
                        break;
                    };
                    let Some(mapped_end) =
                        map_concatenated_offset_to_original_with_end_boundary(end, mappings)
                    else {
                        edits.clear();
                        break;
                    };

                    edits.push(Edit {
                        range: TextRange::new(
                            (mapped_start as u32).into(),
                            (mapped_end as u32).into(),
                        ),
                        replacement: edit.content.clone(),
                    });
                }

                if edits.is_empty() {
                    None
                } else {
                    Some(Fix {
                        message: fix.message.clone(),
                        edits,
                    })
                }
            } else {
                None
            };

            let diagnostic = match ruff_diag.severity.as_deref() {
                Some("error") => Diagnostic::error(location, ruff_diag.code, ruff_diag.message),
                Some("warning") => Diagnostic::warning(location, ruff_diag.code, ruff_diag.message),
                _ => Diagnostic::info(location, ruff_diag.code, ruff_diag.message),
            }
            .with_origin(DiagnosticOrigin::External);
            diagnostics.push(if let Some(fix) = fix {
                diagnostic.with_fix(fix)
            } else {
                diagnostic
            });
        }
        Ok(diagnostics)
    }
}