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

#[derive(Debug, Deserialize)]
struct StaticcheckDiagnostic {
    #[serde(rename = "code")]
    check: String,
    location: StaticcheckLocation,
    message: String,
}

#[derive(Debug, Deserialize)]
struct StaticcheckLocation {
    #[allow(dead_code)]
    file: String,
    line: usize,
    column: usize,
}

pub(crate) struct StaticcheckParser;

impl ExternalLinterParser for StaticcheckParser {
    const NAME: &'static str = "staticcheck";

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

        let mut output = Vec::new();
        for diag in diagnostics {
            let line = diag.location.line;
            let column = diag.location.column;
            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, line, column.saturating_add(1))
                .unwrap_or(ctx.original_input.len());

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

            output.push(
                Diagnostic::warning(location, diag.check, diag.message)
                    .with_origin(DiagnosticOrigin::External),
            );
        }

        Ok(output)
    }
}

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

    if trimmed.starts_with('[') {
        return serde_json::from_str(trimmed);
    }

    let mut diagnostics = Vec::new();
    for line in trimmed.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        diagnostics.push(serde_json::from_str::<StaticcheckDiagnostic>(line)?);
    }
    Ok(diagnostics)
}

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

    #[test]
    fn parses_staticcheck_array_json() {
        let json =
            r#"[{"code":"SA1006","location":{"file":"x.go","line":2,"column":5},"message":"msg"}]"#;
        let parsed = parse_staticcheck_output(json).unwrap();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].check, "SA1006");
    }

    #[test]
    fn parses_staticcheck_ndjson() {
        let json =
            r#"{"code":"SA1006","location":{"file":"x.go","line":2,"column":5},"message":"msg"}"#;
        let parsed = parse_staticcheck_output(json).unwrap();
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].location.line, 2);
    }

    #[test]
    fn maps_staticcheck_diagnostic_to_panache() {
        let ctx = ParseContext {
            output: r#"[{"code":"SA1006","location":{"file":"x.go","line":1,"column":1},"message":"msg"}]"#,
            linted_input: "x := 1\n",
            original_input: "x := 1\n",
            mappings: None,
        };
        let diagnostics = StaticcheckParser::parse(&ctx).unwrap();
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "SA1006");
    }
}