cargo-sonar 1.6.0

Helper to transform reports from Rust tooling for code quality, into valid Sonar report
Documentation
use std::{
    io::{BufRead as _, BufReader},
    path::PathBuf,
};

use dyn_iter::{DynIter, IntoDynIterator as _};
use md5::Digest;

use crate::{Category, Location, Severity, TextRange};

const TYPOS_ENGINE: &str = "typos";

pub struct Typos {
    issues: DynIter<'static, Issue>,
}

impl Iterator for Typos {
    type Item = Issue;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.issues.next()
    }
}

impl Typos {
    /// Create a typos parser for issues
    ///
    /// # Errors
    /// May fail reading and parsing the file (IO errors).
    #[inline]
    pub fn try_new<R>(json_read: R) -> eyre::Result<Self>
    where
        R: std::io::Read + 'static,
    {
        let reader = BufReader::new(json_read);
        let issues = reader
            .lines()
            .map_while(Result::ok)
            .flat_map(|line| serde_json::from_str::<Issue>(&line))
            .into_dyn_iter();
        let typos = Self { issues };
        Ok(typos)
    }
}

#[derive(serde::Deserialize)]
pub struct Issue {
    pub path: PathBuf,
    pub line_num: usize,
    pub byte_offset: usize,
    pub typo: String,
    pub corrections: Vec<String>,
}

impl crate::Issue for Issue {
    #[inline]
    fn analyzer_id(&self) -> String {
        TYPOS_ENGINE.to_owned()
    }
    #[inline]
    fn issue_id(&self) -> String {
        "typo".to_owned()
    }
    #[inline]
    fn fingerprint(&self) -> Digest {
        md5::compute(format!(
            "{}::{}::{}",
            self.path.to_string_lossy(),
            self.line_num,
            self.byte_offset
        ))
    }
    #[inline]
    fn category(&self) -> Category {
        Category::Style
    }
    #[inline]
    fn severity(&self) -> Severity {
        Severity::Info
    }
    #[inline]
    fn location(&self) -> Option<Location> {
        let message = format!(
            "‘{}’ might be misspelled. Did you mean: {}",
            self.typo,
            self.corrections.join(", "),
        );
        let path = self.path.clone();
        let range = TextRange::new(
            (self.line_num, self.byte_offset),
            (
                self.line_num,
                self.byte_offset.saturating_add(self.typo.len()),
            ),
        );
        let location = Location {
            path,
            range,
            message,
        };
        Some(location)
    }
}

#[cfg(test)]
mod tests {
    use crate::{typos::Typos, Category, Issue as _, Severity};
    use std::{io::Write as _, path::Path};
    use test_log::test;

    #[test]
    fn single_issue() {
        let json = r#"{
            "type":"typo",
            "path":"./CHANGELOG.md",
            "line_num":89,
            "byte_offset":32,
            "typo":"ba",
            "corrections":["be", "by"]
        }"#;
        let json = json.to_owned().replace('\n', "");
        let mut typos_json = tempfile::NamedTempFile::new().unwrap();
        write!(typos_json, "{}", json).unwrap();
        let typos_json = typos_json.reopen().unwrap();

        let mut typos = Typos::try_new(typos_json).unwrap();
        let issue = typos.next().unwrap();
        assert_eq!(issue.analyzer_id(), "typos");
        assert_eq!(issue.issue_uid(), "typos::typo");
        assert!(matches!(issue.severity(), Severity::Info));
        assert!(matches!(issue.category(), Category::Style));
        let location = issue.location().unwrap();
        assert_eq!(location.path, Path::new("./CHANGELOG.md"));
        assert_eq!(
            location.message,
            "‘ba’ might be misspelled. Did you mean: be, by",
        );
        assert_eq!(location.range.start.line, 89);
        assert_eq!(location.range.end.line, 89);
        assert_eq!(location.range.start.column, 32);
        assert_eq!(location.range.end.column, 34);
    }
}