panache 2.43.0

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};
use crate::linter::diagnostics::{Diagnostic, DiagnosticNoteKind, DiagnosticOrigin, Location};

#[derive(Debug, Deserialize)]
struct ClippyMessage {
    #[serde(rename = "$message_type")]
    message_type: String,
    #[serde(default)]
    message: String,
    #[serde(default)]
    level: String,
    #[serde(default)]
    code: Option<ClippyCode>,
    #[serde(default)]
    spans: Vec<ClippySpan>,
    #[serde(default)]
    children: Vec<ClippyChildMessage>,
}

#[derive(Debug, Deserialize)]
struct ClippyCode {
    code: String,
}

#[derive(Debug, Deserialize)]
struct ClippySpan {
    line_start: usize,
    column_start: usize,
    line_end: usize,
    column_end: usize,
    is_primary: bool,
}

#[derive(Debug, Deserialize)]
struct ClippyChildMessage {
    #[serde(default)]
    level: String,
    #[serde(default)]
    message: String,
}

pub(crate) struct ClippyParser;

impl ExternalLinterParser for ClippyParser {
    const NAME: &'static str = "clippy";

    fn parse(ctx: &ParseContext<'_>) -> Result<Vec<Diagnostic>, LinterError> {
        let messages = parse_clippy_messages(ctx.output)
            .map_err(|e| LinterError::ParseError(format!("invalid clippy JSON: {}", e)))?;

        let mut diagnostics = Vec::new();
        for msg in messages {
            if msg.message_type != "diagnostic" {
                continue;
            }

            let Some(primary_span) = msg
                .spans
                .iter()
                .find(|s| s.is_primary)
                .or(msg.spans.first())
            else {
                continue;
            };

            let line = primary_span.line_start;
            let column = primary_span.column_start;
            let start_offset = line_col_to_offset(ctx.original_input, line, column)
                .unwrap_or(ctx.original_input.len());
            let end_offset = line_col_to_offset(
                ctx.original_input,
                primary_span.line_end,
                primary_span.column_end,
            )
            .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
                .code
                .map(|c| c.code)
                .unwrap_or_else(|| "clippy".to_string());
            let mut diagnostic = match msg.level.as_str() {
                "error" => Diagnostic::error(location, code, msg.message),
                "warning" => Diagnostic::warning(location, code, msg.message),
                _ => Diagnostic::info(location, code, msg.message),
            }
            .with_origin(DiagnosticOrigin::External);
            for child in msg.children {
                if child.message.trim().is_empty() {
                    continue;
                }
                let kind = match child.level.as_str() {
                    "help" => DiagnosticNoteKind::Help,
                    _ => DiagnosticNoteKind::Note,
                };
                diagnostic = diagnostic.with_note(kind, child.message);
            }
            diagnostics.push(diagnostic);
        }

        Ok(diagnostics)
    }
}

fn parse_clippy_messages(output: &str) -> Result<Vec<ClippyMessage>, serde_json::Error> {
    let trimmed = output.trim();
    if trimmed.is_empty() {
        return Ok(Vec::new());
    }

    let mut messages = Vec::new();
    for line in trimmed.lines() {
        let line = line.trim();
        if !line.starts_with('{') {
            continue;
        }
        messages.push(serde_json::from_str::<ClippyMessage>(line)?);
    }
    Ok(messages)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::linter::external_linters::ParseContext;

    #[test]
    fn parses_clippy_diagnostic_line() {
        let line = r#"{"$message_type":"diagnostic","message":"useless use of vec!","code":{"code":"clippy::useless_vec"},"level":"warning","spans":[{"line_start":1,"column_start":13,"line_end":1,"column_end":24,"is_primary":true}]}"#;
        let parsed = parse_clippy_messages(line).unwrap();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].level, "warning");
    }

    #[test]
    fn maps_clippy_diagnostic_to_panache() {
        let ctx = ParseContext {
            output: r#"{"$message_type":"diagnostic","message":"useless use of vec!","code":{"code":"clippy::useless_vec"},"level":"warning","spans":[{"line_start":1,"column_start":13,"line_end":1,"column_end":24,"is_primary":true}]}"#,
            linted_input: "fn main(){ let x = vec![1,2]; }\n",
            original_input: "fn main(){ let x = vec![1,2]; }\n",
            mappings: None,
        };
        let diagnostics = ClippyParser::parse(&ctx).unwrap();
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "clippy::useless_vec");
    }

    #[test]
    fn maps_clippy_children_to_notes() {
        let ctx = ParseContext {
            output: r#"{"$message_type":"diagnostic","message":"useless use of vec!","code":{"code":"clippy::useless_vec"},"level":"warning","spans":[{"line_start":1,"column_start":13,"line_end":1,"column_end":24,"is_primary":true}],"children":[{"level":"help","message":"use an array directly"},{"level":"note","message":"`Vec` allocates on the heap"}]}"#,
            linted_input: "fn main(){ let x = vec![1,2]; }\n",
            original_input: "fn main(){ let x = vec![1,2]; }\n",
            mappings: None,
        };
        let diagnostics = ClippyParser::parse(&ctx).unwrap();
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].notes.len(), 2);
        assert_eq!(
            diagnostics[0].notes[0].kind,
            crate::linter::DiagnosticNoteKind::Help
        );
    }
}