Repository: Canop/bacon (language: rust)

Issue:
Consider parsing JSON output from `check` and `clippy`
This issue is motivated by crisidev/bacon-ls#2.

bacon's [locations export](https://dystroy.org/bacon/config/#exports) doesn't surface enough information to fully describe spans in an editor. From my quick investigation, I believe this is because bacon simply parses the direct output of `check` and `clippy`, but do correct me if I'm wrong.

However, both tools can provide much more information with their JSON message formats [as described here](https://doc.rust-lang.org/cargo/commands/cargo-check.html#display-options). As far as I can tell, `json-diagnostic-rendered-ansi` provides the best of both worlds by providing detailed information along with fully rendered messages.

I don't know what the scope of such a change would be, so it's possible this is impractical. However, while it may seem a little roundabout, I've found using bacon in this way very refreshing and more reliable than rust-analyzer for my larger projects. It would be amazing if we could get accurate spans to boot!


Discussion / hints:
> it's possible this is impractical

This would require some work but is doable. Biggest problem for me today is finding time.
I am working on bacon-ls 0.4.0, but when I am done with the release, I have some time to start the MR and try to work on it.
@crisidev I'm working on designing a new version of the analyzer framework, in accordance with [this roadmap](https://dystroy.org/blog/bacon-everything-roadmap/). Please come [chat](https://miaou.dystroy.org/4683) about what you intend to do exactly before starting a PR.
Nice, I'll join the chat to see if I can help.

After reading the roadmap, I think I can help updating the analyzers for cargo using json output, to provide precise locations. 
Hum... The `--message-format` thing doesn't exist for `cargo test`, right ?
AFAIK only in nightly.. I am not sure it has been stabilized yet..
@CorvusPrudens @crisidev  I've made an analyzer which reads the output of  `cargo check --message-format json-diagnostic-rendered-ansi`.

Do you think the information you'd like to see in a location export can be found ?

Analyzer reading the message: https://github.com/Canop/bacon/pull/269/files#diff-8649e54cb521dcb0d2d7aeaf0129acfa65feb8f57116e0020eb3c39ca521fee8R73-R83

Cargo message: https://docs.rs/cargo_metadata/0.19.0/cargo_metadata/enum.Message.html

If you tell me precisely what data you'd want, I can add it to the locations export.
@Canop I can't speak for @crisidev as far as the implementation goes, but the main information I'd like as a user is `line_start`, `line_end`, `column_start`, and `column_end` from the [diagnostic span](https://docs.rs/cargo_metadata/0.19.0/cargo_metadata/diagnostic/struct.DiagnosticSpan.html).

I'm not sure what the ideal behavior is when there are multiple spans for one message

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 2890c6a..52e7649 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -136,6 +136,7 @@ dependencies = [
  "directories-next",
  "gix",
  "glob",
+ "iq",
  "lazy-regex",
  "notify",
  "rustc-hash",
@@ -1209,6 +1210,17 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "iq"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef6f26340d47024663bf0180a65c9ca5e25776f606913695442d5219730f9da1"
+dependencies = [
+ "lazy-regex",
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.1"
diff --git a/Cargo.toml b/Cargo.toml
index 03d63f4..f944ae4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,6 +21,7 @@ crokey = "1.1"
 directories-next = "2.0.0"
 gix = { version = "0.67", default-features = false, features = ["index", "excludes", "parallel"] }
 glob = "0.3"
+iq = { version = "0.2", features = ["template"] }
 lazy-regex = "3.3"
 notify = "7.0"
 rustc-hash = "2"
@@ -42,4 +43,5 @@ codegen-units = 1
 # termimad = { path = "../termimad" }
 # crokey = { path = "../crokey" }
 # coolor = { path = "../coolor" }
+# iq = { path = "../iq" }
 # lazy-regex = { path = "../lazy-regex" }
diff --git a/bacon.toml b/bacon.toml
index 15ef10a..34b7930 100644
--- a/bacon.toml
+++ b/bacon.toml
@@ -76,3 +76,11 @@ need_stdout = false
 
 [keybindings]
 c = "job:clippy-all"
+
+# an example of export based on the JSON export, active when
+# the analyzer is "cargo_json"
+[exports.cargo-json-spans]
+auto = false
+exporter = "analyzer"
+path = "bacon-analyser-export.json"
+line_format = "{span.file_name} {span.line_start}-{span.line_end} | {diagnostic.message} [{diagnostic.code}] level={diagnostic.level}"
diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml
index c76aff1..811080d 100644
--- a/defaults/default-prefs.toml
+++ b/defaults/default-prefs.toml
@@ -1,21 +1,25 @@
 # This is a preferences file for the bacon tool
 # More info at https://github.com/Canop/bacon
 
+
 # Uncomment and change the value (true/false) to
 # specify whether bacon should start in summary mode
 #
 # summary = true
 
+
 # Uncomment and change the value (true/false) to
 # specify whether bacon should start with lines wrapped
 #
 # wrap = false
 
+
 # In "reverse" mode, the focus is at the bottom, item
 # order is reversed, and the status bar is on top
 #
 # reverse = true
 
+
 # The grace period is a delay after a file event before the real
 # task is launched and during which other events will be ignored.
 # This is useful if several events are often sent quasi-simultaneously
@@ -24,16 +28,19 @@
 #
 # grace_period = "5ms"
 
+
 # Uncomment and change the value (true/false) to
 # specify whether bacon should show a help line.
 #
 # help_line = false
 
+
 # Uncomment and change the value (true/false) to
 # set whether to display the count of changes since last job start
 #
 # show_changes_count = false
 
+
 # Uncomment one of those lines if you don't want the default
 # behavior triggered by a file change. This property can also
 # be set directly in a specific job.
@@ -41,13 +48,7 @@
 # on_change_strategy = "kill_then_restart"
 # on_change_strategy = "wait_then_restart"
 
-# Exports can be executed either
-# - on each job completion (if auto is true)
-# - or called on a key (eg `ctrl-e = "export:analysis"`)
-#
-# They can be used for communication with external tools
-# or for inquiries.
-#
+
 # Exporting "locations" (by setting its 'auto' to true) lets you use
 # them in an external tool, for example as a list of jump locations
 # in an IDE or in a language server.
@@ -62,14 +63,10 @@
 #  - context: unstyled lines of output, separated with escaped newlines (`\\n`)
 [exports.locations]
 auto = false
+exporter = "locations"
 path = ".bacon-locations"
 line_format = "{kind} {path}:{line}:{column} {message}"
-[exports.json-report]
-auto = false
-path = "bacon-report.json"
-[exports.analysis]
-auto = false
-path = "bacon-analysis.json"
+
 
 # Uncomment and change the key-bindings you want to define
 # (some of those ones are the defaults and are just here for illustration)
diff --git a/src/analysis/cargo_json/cargo_json_export.rs b/src/analysis/cargo_json/cargo_json_export.rs
new file mode 100644
index 0000000..93e40c2
--- /dev/null
+++ b/src/analysis/cargo_json/cargo_json_export.rs
@@ -0,0 +1,49 @@
+use {
+    crate::*,
+    cargo_metadata::diagnostic::{
+        Diagnostic,
+        DiagnosticSpan,
+    },
+    serde::Serialize,
+};
+
+/// An export in progress for the cargo_json analyzer
+pub struct CargoJsonExport {
+    pub name: String,
+    /// The written data to write to the file
+    pub export: String,
+    pub line_template: iq::Template,
+}
+
+/// The data provided to the template, once per span
+#[derive(Debug, Clone, Serialize)]
+struct OnSpanData<'d> {
+    diagnostic: &'d Diagnostic,
+    span: &'d DiagnosticSpan,
+}
+
+impl CargoJsonExport {
+    pub fn new(
+        name: String,
+        settings: &ExportSettings,
+    ) -> Self {
+        Self {
+            name,
+            export: String::new(),
+            line_template: iq::Template::new(&settings.line_format),
+        }
+    }
+    pub fn receive_diagnostic(
+        &mut self,
+        diagnostic: &Diagnostic,
+    ) {
+        for span in &diagnostic.spans {
+            let data = OnSpanData { diagnostic, span };
+            let line = self.line_template.render(&data);
+            if !line.is_empty() {
+                self.export.push_str(&line);
+                self.export.push('\n');
+            }
+        }
+    }
+}
diff --git a/src/analysis/cargo_json/mod.rs b/src/analysis/cargo_json/mod.rs
index 1c51a53..677228c 100644
--- a/src/analysis/cargo_json/mod.rs
+++ b/src/analysis/cargo_json/mod.rs
@@ -1,3 +1,5 @@
+mod cargo_json_export;
+
 use {
     super::*,
     crate::{
@@ -5,6 +7,7 @@ use {
         *,
     },
     anyhow::Result,
+    cargo_json_export::*,
     cargo_metadata::{
         Message,
         diagnostic::Diagnostic,
@@ -21,17 +24,25 @@ use {
 ///
 /// There are so many problems with this approach, though, that I'm
 /// not sure this is worth it.
-#[derive(Debug, Default)]
+#[derive(Default)]
 pub struct CargoJsonAnalyzer {
     lines: Vec<CommandOutputLine>,
+    exports: Vec<CargoJsonExport>,
 }
 
 impl Analyzer for CargoJsonAnalyzer {
     fn start(
         &mut self,
-        _mission: &Mission,
+        mission: &Mission,
     ) {
         self.lines.clear();
+        self.exports.clear();
+        for (name, export_settings) in &mission.settings.exports.exports {
+            if export_settings.exporter == Exporter::Analyser {
+                let export = CargoJsonExport::new(name.clone(), export_settings);
+                self.exports.push(export);
+            }
+        }
     }
 
     fn receive_line(
@@ -59,7 +70,11 @@ impl Analyzer for CargoJsonAnalyzer {
 
     fn build_report(&mut self) -> Result<Report> {
         let line_analyzer = standard::StandardLineAnalyzer {};
-        standard::build_report(&self.lines, line_analyzer)
+        let mut report = standard::build_report(&self.lines, line_analyzer)?;
+        for export in self.exports.drain(..) {
+            report.analyzer_exports.insert(export.name, export.export);
+        }
+        Ok(report)
     }
 }
 
@@ -89,6 +104,9 @@ impl CargoJsonAnalyzer {
         origin: CommandStream,
         command_output: &mut CommandOutput,
     ) {
+        for export in &mut self.exports {
+            export.receive_diagnostic(&diagnostic);
+        }
         let Diagnostic {
             //message,
             //code,
diff --git a/src/analysis/item_accumulator.rs b/src/analysis/item_accumulator.rs
index 7998ff2..c723000 100644
--- a/src/analysis/item_accumulator.rs
+++ b/src/analysis/item_accumulator.rs
@@ -76,6 +76,7 @@ impl ItemAccumulator {
             suggest_backtrace: false,
             output: Default::default(),
             failure_keys: Vec::new(),
+            analyzer_exports: Default::default(),
         }
     }
 }
diff --git a/src/analysis/standard/standard_line_analyser.rs b/src/analysis/standard/standard_line_analyser.rs
index fb19a53..4a8c8aa 100644
--- a/src/analysis/standard/standard_line_analyser.rs
+++ b/src/analysis/standard/standard_line_analyser.rs
@@ -91,6 +91,9 @@ fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
                 ("", title_raw, CSI_BOLD_BLUE, "--> ") if is_spaces(title_raw) => {
                     LineType::Location
                 }
+                ("", title_raw, CSI_BOLD_BLUE, "::: ") if is_spaces(title_raw) => {
+                    LineType::Location
+                }
                 ("", k, CSI_BOLD_RED | CSI_RED, "FAILED") if content.strings.len() == 2 => {
                     if let Some(k) = as_test_name(k) {
                         key = Some(k.to_string());
diff --git a/src/analysis/standard/standard_report_building.rs b/src/analysis/standard/standard_report_building.rs
index 3986f4d..ebc4903 100644
--- a/src/analysis/standard/standard_report_building.rs
+++ b/src/analysis/standard/standard_report_building.rs
@@ -148,6 +148,7 @@ pub fn build_report<L: LineAnalyzer>(
         suggest_backtrace,
         output: Default::default(),
         failure_keys,
+        analyzer_exports: Default::default(),
     };
     Ok(report)
 }
diff --git a/src/export/export_settings.rs b/src/export/export_settings.rs
index 08fc51d..c1dd62d 100644
--- a/src/export/export_settings.rs
+++ b/src/export/export_settings.rs
@@ -17,6 +17,7 @@ pub struct ExportSettings {
 impl ExportSettings {
     pub fn do_export(
         &self,
+        name: &str,
         state: &AppState<'_>,
     ) -> anyhow::Result<()> {
         let path = if self.path.is_relative() {
@@ -25,23 +26,26 @@ impl ExportSettings {
             self.path.to_path_buf()
         };
         info!("exporting to {:?}", path);
+        let Some(report) = state.cmd_result.report() else {
+            info!("No report to export");
+            return Ok(());
+        };
         match self.exporter {
+            Exporter::Analyser => {
+                if let Some(export) = report.analyzer_exports.get(name) {
+                    std::fs::write(&path, export)?;
+                } else {
+                    info!("Analyzer didn't build export {:?}", name);
+                }
+            }
             Exporter::Analysis => {
                 error!("Aanlysis export not currently implemented");
             }
             Exporter::JsonReport => {
-                let Some(report) = state.cmd_result.report() else {
-                    info!("No report to export");
-                    return Ok(());
-                };
                 let json = serde_json::to_string_pretty(&report)?;
                 std::fs::write(&path, json)?;
             }
             Exporter::Locations => {
-                let Some(report) = state.cmd_result.report() else {
-                    info!("No report to export");
-                    return Ok(());
-                };
                 let mut file = File::create(path)?;
                 report.write_locations(&mut file, &state.mission, &self.line_format)?;
             }
diff --git a/src/export/exporter.rs b/src/export/exporter.rs
index 405e2d6..f3c65b6 100644
--- a/src/export/exporter.rs
+++ b/src/export/exporter.rs
@@ -1,7 +1,14 @@
 use serde::Deserialize;
 
-#[derive(Debug, Clone, Copy, Deserialize)]
+#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
+#[serde(rename_all = "snake_case")]
 pub enum Exporter {
+    /// The analyzer is tasked with doing an export while analyzing the
+    /// command output
+    #[serde(alias = "analyzer")]
+    Analyser,
+    /// This exporter doesn't exist at the moment
+    #[serde(alias = "analyzis")]
     Analysis,
     JsonReport,
     Locations,
diff --git a/src/export/exports_settings.rs b/src/export/exports_settings.rs
index 6797b89..e8e9b01 100644
--- a/src/export/exports_settings.rs
+++ b/src/export/exports_settings.rs
@@ -31,7 +31,7 @@ impl ExportsSettings {
         for (name, export) in &self.exports {
             if export.auto {
                 info!("doing auto export {:?}", name);
-                if let Err(e) = export.do_export(state) {
+                if let Err(e) = export.do_export(name, state) {
                     error!("error while exporting {:?}: {:?}", name, e);
                 }
             }
@@ -44,7 +44,7 @@ impl ExportsSettings {
         state: &AppState<'_>,
     ) {
         if let Some(export) = self.exports.get(requested_name) {
-            if let Err(e) = export.do_export(state) {
+            if let Err(e) = export.do_export(requested_name, state) {
                 error!("error while exporting {:?}: {:?}", requested_name, e);
             }
         } else {
@@ -99,6 +99,7 @@ impl ExportsSettings {
             };
             let auto = ec.auto.unwrap_or(true);
             let path = ec.path.clone().unwrap_or_else(|| match exporter {
+                Exporter::Analyser => default_analyser_path(),
                 Exporter::Analysis => default_analysis_path(),
                 Exporter::Locations => default_locations_path(),
                 Exporter::JsonReport => default_json_report_path(),
@@ -201,6 +202,9 @@ pub fn default_locations_line_format() -> &'static str {
     "{kind} {path}:{line}:{column} {message}"
 }
 
+pub fn default_analyser_path() -> PathBuf {
+    PathBuf::from("bacon-analyser.json")
+}
 pub fn default_analysis_path() -> PathBuf {
     PathBuf::from("bacon-analysis.json")
 }
diff --git a/src/result/report.rs b/src/result/report.rs
index 42843e6..9ce7872 100644
--- a/src/result/report.rs
+++ b/src/result/report.rs
@@ -7,6 +7,7 @@ use {
         Serialize,
     },
     std::{
+        collections::HashMap,
         io,
         path::PathBuf,
     },
@@ -21,6 +22,8 @@ pub struct Report {
     pub suggest_backtrace: bool,
     pub output: CommandOutput,
     pub failure_keys: Vec<String>,
+    /// the exports that the analyzers have done, by name
+    pub analyzer_exports: HashMap<String, String>,
 }
 
 impl Report {


Test patch: