panache 2.41.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)]
struct EslintFileReport {
    messages: Vec<EslintMessage>,
}

#[derive(Debug, Deserialize)]
struct EslintMessage {
    #[serde(default, rename = "ruleId")]
    rule_id: Option<String>,
    severity: i64,
    message: String,
    line: usize,
    column: usize,
    #[serde(default, rename = "endLine")]
    end_line: Option<usize>,
    #[serde(default, rename = "endColumn")]
    end_column: Option<usize>,
    #[serde(default)]
    fix: Option<EslintFix>,
    #[serde(default)]
    suggestions: Vec<EslintSuggestion>,
}

#[derive(Debug, Deserialize)]
struct EslintSuggestion {
    fix: EslintFix,
}

#[derive(Debug, Deserialize)]
struct EslintFix {
    range: [usize; 2],
    text: String,
}

pub(crate) struct EslintParser;

impl ExternalLinterParser for EslintParser {
    const NAME: &'static str = "eslint";

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

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

        let mut diagnostics = Vec::new();
        for report in reports {
            for msg in report.messages {
                let line = msg.line;
                let column = msg.column;
                let start_offset = line_col_to_offset(ctx.original_input, line, column)
                    .unwrap_or(ctx.original_input.len());
                let end_line = msg.end_line.unwrap_or(line);
                let end_column = msg.end_column.unwrap_or(column.saturating_add(1));
                let end_offset = line_col_to_offset(ctx.original_input, end_line, end_column)
                    .unwrap_or(ctx.original_input.len());

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

                let code = msg.rule_id.unwrap_or_else(|| "eslint".to_string());
                let diagnostic = match msg.severity {
                    2 => Diagnostic::error(location, code, msg.message),
                    1 => Diagnostic::info(location, code, msg.message),
                    _ => Diagnostic::warning(location, code, msg.message),
                }
                .with_origin(DiagnosticOrigin::External);
                let selected_fix = msg
                    .fix
                    .or_else(|| msg.suggestions.into_iter().next().map(|s| s.fix));
                let mapped_fix =
                    if let (Some(mappings), Some(eslint_fix)) = (ctx.mappings, selected_fix) {
                        let fix_start = map_concatenated_offset_to_original_with_end_boundary(
                            eslint_fix.range[0],
                            mappings,
                        );
                        let fix_end = map_concatenated_offset_to_original_with_end_boundary(
                            eslint_fix.range[1],
                            mappings,
                        );
                        if let (Some(fix_start), Some(fix_end)) = (fix_start, fix_end) {
                            Some(Fix {
                                message: "Apply ESLint fix".to_string(),
                                edits: vec![Edit {
                                    range: TextRange::new(
                                        (fix_start as u32).into(),
                                        (fix_end as u32).into(),
                                    ),
                                    replacement: eslint_fix.text,
                                }],
                            })
                        } else {
                            None
                        }
                    } else {
                        None
                    };

                diagnostics.push(if let Some(fix) = mapped_fix {
                    diagnostic.with_fix(fix)
                } else {
                    diagnostic
                });
            }
        }

        Ok(diagnostics)
    }
}