Repository: Canop/bacon (language: rust)

Issue:
Python unit test analyzer
Make it possible to test python with bacon


Discussion / hints:

Fix patch:
diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs
index 107b7bb..53ad5a0 100644
--- a/src/analysis/analyzer.rs
+++ b/src/analysis/analyzer.rs
@@ -2,6 +2,7 @@ use {
     super::{
         eslint,
         nextest,
+        python,
         standard,
     },
     crate::*,
@@ -21,6 +22,7 @@ pub enum Analyzer {
     Standard,
     Nextest,
     Eslint,
+    PythonUnittest,
 }
 
 impl Analyzer {
@@ -32,6 +34,7 @@ impl Analyzer {
             Self::Eslint => eslint::analyze_line(line),
             Self::Standard => standard::analyze_line(line),
             Self::Nextest => nextest::analyze_line(line),
+            Self::PythonUnittest => python::unittest::analyze_line(line),
         }
     }
     pub fn build_report(
@@ -41,6 +44,7 @@ impl Analyzer {
     ) -> anyhow::Result<Report> {
         match self {
             Self::Eslint => eslint::build_report(cmd_lines, *self, mission),
+            Self::PythonUnittest => python::unittest::build_report(cmd_lines, *self, mission),
             Self::Standard | Self::Nextest => {
                 // nextest analyzis simply uses the standard report building
                 standard::build_report(cmd_lines, *self, mission)
diff --git a/src/analysis/eslint/mod.rs b/src/analysis/eslint/mod.rs
index 85e804a..b5f91cd 100644
--- a/src/analysis/eslint/mod.rs
+++ b/src/analysis/eslint/mod.rs
@@ -42,7 +42,7 @@ pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
 
 /// Build a report from the output of eslint
 ///
-/// The main specificity of eslint is that the path of file with error is given
+/// The main specificity of eslint is that the path of a file with error is given
 /// before the errors of the file, each error coming with the line and column
 /// in the file.
 pub fn build_report(
@@ -50,12 +50,7 @@ pub fn build_report(
     line_analyzer: Analyzer,
     mission: &Mission,
 ) -> anyhow::Result<Report> {
-    let ignore_patterns = mission
-        .job
-        .ignored_lines
-        .as_ref()
-        .or(mission.settings.ignored_lines.as_ref())
-        .filter(|p| !p.is_empty());
+    let ignore_patterns = mission.ignored_lines_patterns();
     let mut items = ItemAccumulator::default();
     let mut last_location_path = None;
     for cmd_line in cmd_lines {
@@ -101,7 +96,10 @@ pub fn build_report(
                 warn!("no location given before error");
                 continue;
             };
-            items.push_line(LineType::Location, burp_location(location_path, line_col));
+            items.push_line(
+                LineType::Location,
+                burp::location_line(location_path, line_col),
+            );
         }
     }
     let lines = items.lines();
@@ -117,53 +115,6 @@ pub fn build_report(
     Ok(report)
 }
 
-// this might be made generic when needed in newer analyzers
-#[derive(Default)]
-struct ItemAccumulator {
-    curr_err_kind: Option<Kind>,
-    errors: Vec<Line>,
-    test_fails: Vec<Line>,
-    warnings: Vec<Line>,
-}
-impl ItemAccumulator {
-    fn start_item(
-        &mut self,
-        kind: Kind,
-    ) {
-        self.curr_err_kind = Some(kind);
-    }
-    fn push_line(
-        &mut self,
-        line_type: LineType,
-        content: TLine,
-    ) {
-        let line = Line {
-            item_idx: 0, // will be filled later
-            line_type,
-            content,
-        };
-        match self.curr_err_kind {
-            Some(Kind::Warning) => self.warnings.push(line),
-            Some(Kind::Error) => self.errors.push(line),
-            Some(Kind::TestFail) => self.test_fails.push(line),
-            _ => {} // before warnings and errors, or in a sum
-        }
-    }
-    fn lines(mut self) -> Vec<Line> {
-        let mut lines = self.errors;
-        lines.append(&mut self.test_fails);
-        lines.append(&mut self.warnings);
-        let mut item_idx = 0;
-        for line in &mut lines {
-            if matches!(line.line_type, LineType::Title(_)) {
-                item_idx += 1;
-            }
-            line.item_idx = item_idx;
-        }
-        lines
-    }
-}
-
 /// Return true when the line is like
 ///    "67:52  error  Unnecessary escape character: \/  no-useless-escape"
 fn is_line_col_error_line(content: &TLine) -> bool {
@@ -235,19 +186,6 @@ fn get_location_path(content: &TLine) -> Option<String> {
     Some(first.raw.to_string())
 }
 
-/// Make a BURP compliant location line
-fn burp_location(
-    location_path: &str,
-    line_col: &str,
-) -> TLine {
-    let mut line = TLine::default();
-    line.strings
-        .push(TString::new("\u{1b}[1m\u{1b}[38;5;12m", "   --> "));
-    line.strings
-        .push(TString::new("", format!("{}:{}", location_path, line_col)));
-    line
-}
-
 fn cleaned_tline(content: &TLine) -> TLine {
     let mut tline = TLine::default();
     let mut last_is_blank = true;
diff --git a/src/analysis/item_accumulator.rs b/src/analysis/item_accumulator.rs
new file mode 100644
index 0000000..9e51a8c
--- /dev/null
+++ b/src/analysis/item_accumulator.rs
@@ -0,0 +1,52 @@
+use crate::*;
+
+/// Receives lines and accumulates them into items, used
+/// at end to build a sorted list of lines.
+///
+/// This is a small optional utility for report makers
+#[derive(Default)]
+pub struct ItemAccumulator {
+    curr_kind: Option<Kind>,
+    errors: Vec<Line>,
+    test_fails: Vec<Line>,
+    warnings: Vec<Line>,
+}
+
+impl ItemAccumulator {
+    pub fn start_item(
+        &mut self,
+        kind: Kind,
+    ) {
+        self.curr_kind = Some(kind);
+    }
+    pub fn push_line(
+        &mut self,
+        line_type: LineType,
+        content: TLine,
+    ) {
+        let line = Line {
+            item_idx: 0, // will be filled later
+            line_type,
+            content,
+        };
+        match self.curr_kind {
+            Some(Kind::Warning) => self.warnings.push(line),
+            Some(Kind::Error) => self.errors.push(line),
+            Some(Kind::TestFail) => self.test_fails.push(line),
+            _ => {} // before warnings and errors, or in a sum
+        }
+    }
+    pub fn lines(mut self) -> Vec<Line> {
+        let mut lines = self.errors;
+        lines.append(&mut self.test_fails);
+        lines.append(&mut self.warnings);
+        let mut item_idx = 0;
+        for line in &mut lines {
+            if matches!(line.line_type, LineType::Title(_)) {
+                item_idx += 1;
+            }
+            line.item_idx = item_idx;
+        }
+        lines
+    }
+}
diff --git a/src/analysis/line_analysis.rs b/src/analysis/line_analysis.rs
index 9b165a9..385e7df 100644
--- a/src/analysis/line_analysis.rs
+++ b/src/analysis/line_analysis.rs
@@ -24,6 +24,9 @@ impl LineAnalysis {
     pub fn normal() -> Self {
         Self::of_type(LineType::Normal)
     }
+    pub fn garbage() -> Self {
+        Self::of_type(LineType::Garbage)
+    }
     pub fn title_key(
         kind: Kind,
         key: String,
@@ -33,6 +36,12 @@ impl LineAnalysis {
             key: Some(key),
         }
     }
+    pub fn fail<S: Into<String>>(key: S) -> Self {
+        Self {
+            line_type: LineType::Title(Kind::TestFail),
+            key: Some(key.into()),
+        }
+    }
     pub fn test_result(
         key: String,
         pass: bool,
diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs
index a688ddd..42ba1ab 100644
--- a/src/analysis/mod.rs
+++ b/src/analysis/mod.rs
@@ -1,14 +1,17 @@
 mod analyzer;
 mod eslint;
+mod item_accumulator;
 mod line_analysis;
 mod line_pattern;
 mod line_type;
 mod nextest;
+mod python;
 mod standard;
 mod stats;
 
 pub use {
     analyzer::*,
+    item_accumulator::*,
     line_analysis::*,
     line_pattern::*,
     line_type::*,
diff --git a/src/analysis/python/mod.rs b/src/analysis/python/mod.rs
new file mode 100644
index 0000000..ad205c3
--- /dev/null
+++ b/src/analysis/python/mod.rs
@@ -0,0 +1,1 @@
+pub mod unittest;
diff --git a/src/analysis/standard/standard_report_building.rs b/src/analysis/standard/standard_report_building.rs
index d84801a..78937ab 100644
--- a/src/analysis/standard/standard_report_building.rs
+++ b/src/analysis/standard/standard_report_building.rs
@@ -21,12 +21,7 @@ pub fn build_report(
     let mut cur_err_kind = None; // the current kind among stderr lines
     let mut is_in_out_fail = false;
     let mut suggest_backtrace = false;
-    let ignore_patterns = mission
-        .job
-        .ignored_lines
-        .as_ref()
-        .or(mission.settings.ignored_lines.as_ref())
-        .filter(|p| !p.is_empty());
+    let ignore_patterns = mission.ignored_lines_patterns();
     for cmd_line in cmd_lines {
         if let Some(patterns) = ignore_patterns {
             let raw_line = cmd_line.content.to_raw();
diff --git a/src/burp/mod.rs b/src/burp/mod.rs
new file mode 100644
index 0000000..72f04fc
--- /dev/null
+++ b/src/burp/mod.rs
@@ -0,0 +1,16 @@
+//! Utilities related to BURP
+//! See https://dystroy.org/blog/bacon-everything-roadmap/#introduce-burp
+use crate::*;
+
+/// Make a BURP compliant location line
+pub fn location_line(
+    location_path: &str,
+    line_col: &str,
+) -> TLine {
+    let mut line = TLine::default();
+    line.strings
+        .push(TString::new("\u{1b}[1m\u{1b}[38;5;12m", "   --> "));
+    line.strings
+        .push(TString::new("", format!("{}:{}", location_path, line_col)));
+    line
+}
diff --git a/src/lib.rs b/src/lib.rs
index ac4563d..94b6bc7 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,7 @@
 mod analysis;
 mod app;
 mod auto_refresh;
+pub mod burp;
 mod cli;
 mod conf;
 mod context;
diff --git a/src/mission.rs b/src/mission.rs
index d1369a9..8e39ffc 100644
--- a/src/mission.rs
+++ b/src/mission.rs
@@ -201,6 +201,14 @@ impl<'s> Mission<'s> {
     pub fn analyzer(&self) -> Analyzer {
         self.job.analyzer.unwrap_or_default()
     }
+
+    pub fn ignored_lines_patterns(&self) -> Option<&Vec<LinePattern>> {
+        self.job
+            .ignored_lines
+            .as_ref()
+            .or(self.settings.ignored_lines.as_ref())
+            .filter(|p| !p.is_empty())
+    }
 }
 
 fn merge_features(
diff --git a/src/result/line.rs b/src/result/line.rs
index f9fdaf5..c5fd880 100644
--- a/src/result/line.rs
+++ b/src/result/line.rs
@@ -25,12 +25,17 @@ impl Line {
     /// If the line is a title, get its message
     pub fn title_message(&self) -> Option<&str> {
         let title = match self.line_type {
-            LineType::Title(_) => self
-                .content
-                .strings
-                .get(1)
-                .map(|ts| ts.raw.as_str())
-                .map(|s| s.trim_start_matches(|c: char| c.is_whitespace() || c == ':')),
+            LineType::Title(_) => {
+                if let Some(content) = self.content.if_unstyled() {
+                    Some(content)
+                } else {
+                    self.content
+                        .strings
+                        .get(1)
+                        .map(|ts| ts.raw.as_str())
+                        .map(|s| s.trim_start_matches(|c: char| c.is_whitespace() || c == ':'))
+                }
+            }
             _ => None,
         };
         title
diff --git a/src/result/report.rs b/src/result/report.rs
index a4b7141..31792da 100644
--- a/src/result/report.rs
+++ b/src/result/report.rs
@@ -86,8 +86,8 @@ impl Report {
             let Some(location) = line.location() else {
                 continue;
             };
-            let (_, mut path, file_line, file_column) =
-                regex_captures!(r#"^([^:\s]+):(\d+):(\d+)$"#, location)
+            let (_, mut path, file_line, mut file_column) =
+                regex_captures!(r#"^([^:\s]+):(\d+)(?:\:(\d+))?$"#, location)
                     .unwrap_or(("", location, "", ""));
             // we need to make sure the path is absolute
             let path_buf = PathBuf::from(path);
@@ -107,6 +107,9 @@ impl Report {
             } else {
                 ""
             };
+            if file_column.is_empty() {
+                file_column = "1"; // by default, first column in file
+            }
             let exported = regex_replace_all!(r#"\{([^\s}]+)\}"#, line_format, |_, key| {
                 match key {
                     "column" => file_column,


Test patch:
diff --git a/src/analysis/python/unittest.rs b/src/analysis/python/unittest.rs
new file mode 100644
index 0000000..1b6cfb3
--- /dev/null
+++ b/src/analysis/python/unittest.rs
@@ -0,0 +1,85 @@
+//! An analyzer for Python unittest
+use {
+    crate::*,
+    lazy_regex::*,
+};
+
+pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
+    // we're not expecting styled output for unittest (it's probable
+    // some users use decorators, but I don't know those today)
+    let Some(content) = cmd_line.content.if_unstyled() else {
+        return LineAnalysis::normal();
+    };
+    regex_switch!(content,
+        r"^FAIL:\s+\S+\s+\((?<key>.+)\)" => LineAnalysis::fail(key),
+        r#"^\s+File ".+", line \d+"# => LineAnalysis::of_type(LineType::Location),
+        "^={50,}$" => LineAnalysis::garbage(),
+        "^-{50,}$" => LineAnalysis::garbage(),
+        r"^Traceback \(most recent call last\)" => LineAnalysis::garbage(),
+    )
+    .unwrap_or_else(LineAnalysis::normal)
+}
+
+/// Build a report from the output of eslint
+///
+/// The main special thing here is transforming the location line in
+/// a BURP location line.
+pub fn build_report(
+    cmd_lines: &[CommandOutputLine],
+    line_analyzer: Analyzer,
+    mission: &Mission,
+) -> anyhow::Result<Report> {
+    let ignore_patterns = mission.ignored_lines_patterns();
+    let mut items = ItemAccumulator::default();
+    let mut item_location_written = false;
+    for cmd_line in cmd_lines {
+        if let Some(patterns) = ignore_patterns {
+            let raw_line = cmd_line.content.to_raw();
+            if patterns.iter().any(|p| p.raw_line_is_match(&raw_line)) {
+                debug!("ignoring line: {}", &raw_line);
+                continue;
+            }
+        }
+        let line_analysis = line_analyzer.analyze_line(cmd_line);
+        let line_type = line_analysis.line_type;
+        match line_type {
+            LineType::Garbage => {
+                continue;
+            }
+            LineType::Title(kind) => {
+                items.start_item(kind);
+                item_location_written = false;
+            }
+            LineType::Normal => {}
+            LineType::Location => {
+                if !item_location_written {
+                    if let Some(content) = cmd_line.content.if_unstyled() {
+                        // we rewrite the location as a BURP location
+                        if let Some((_, path, line)) =
+                            regex_captures!(r#"\s+File "(.+)", line (\d+)"#, content,)
+                        {
+                            items.push_line(LineType::Location, burp::location_line(path, line));
+                            item_location_written = true;
+                        } else {
+                            warn!("unconsistent line parsing");
+                        }
+                        continue;
+                    }
+                }
+            }
+            _ => {}
+        }
+        items.push_line(line_type, cmd_line.content.clone());
+    }
+    let lines = items.lines();
+    let stats = Stats::from(&lines);
+    debug!("stats: {:#?}", &stats);
+    let report = Report {
+        lines,
+        stats,
+        suggest_backtrace: false,
+        output: Default::default(),
+        failure_keys: Vec::new(),
+    };
+    Ok(report)
+}