├── website ├── .gitignore ├── src │ ├── img │ │ ├── test.png │ │ ├── bacon-ls.png │ │ ├── cochon.jpg │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── bacon-cpp.png │ │ ├── bacon-ruff.png │ │ ├── bacon-biome.png │ │ ├── bacon-eslint.png │ │ ├── bacon-pytest.png │ │ ├── github-mark.png │ │ ├── vi-and-bacon.png │ │ ├── bacon-broot-clippy.png │ │ ├── github-mark-white.png │ │ ├── ddoc-search.svg │ │ ├── ddoc-left-arrow.svg │ │ ├── ddoc-right-arrow.svg │ │ ├── github-mark-white.svg │ │ ├── github-mark.svg │ │ ├── logo-white.svg │ │ └── logo.svg │ ├── community │ │ ├── nvim-bacon.md │ │ ├── FAQ.md │ │ └── bacon-dev.md │ └── index.md ├── README.md ├── deploy.sh └── ddoc.hjson ├── fmt.sh ├── .github └── FUNDING.yml ├── doc ├── test.png └── screenshot.png ├── .cargo └── config.toml ├── img ├── logo-text.png ├── logo-icon.svg └── logo-text.svg ├── src ├── analysis │ ├── python │ │ ├── mod.rs │ │ ├── unittest.rs │ │ ├── ruff.rs │ │ └── pytest.rs │ ├── line_analyzer.rs │ ├── mod.rs │ ├── swift │ │ ├── mod.rs │ │ └── build.rs │ ├── nextest │ │ └── mod.rs │ ├── standard │ │ └── mod.rs │ ├── line_analysis.rs │ ├── stats.rs │ ├── cargo_json │ │ └── cargo_json_export.rs │ ├── item_accumulator.rs │ ├── analyzer.rs │ ├── eslint │ │ └── mod.rs │ ├── line_type.rs │ └── biome │ │ └── mod.rs ├── main.rs ├── tui │ ├── md.rs │ ├── app_state.rs │ ├── menu │ │ ├── inform.rs │ │ ├── mod.rs │ │ ├── action_menu.rs │ │ └── menu_state.rs │ ├── dialog.rs │ ├── mod.rs │ ├── messages.rs │ ├── drawing.rs │ ├── focus_file.rs │ └── wrap.rs ├── conf │ ├── defaults.rs │ ├── auto_refresh.rs │ ├── mod.rs │ ├── cargo_wrapped_config.rs │ └── config.rs ├── help │ ├── mod.rs │ ├── examples.rs │ ├── list_jobs.rs │ └── help_page.rs ├── context_nature.rs ├── tty │ ├── trange.rs │ ├── mod.rs │ ├── tline_builder.rs │ └── tstring.rs ├── export │ ├── mod.rs │ ├── exporter.rs │ ├── export_config.rs │ └── export_settings.rs ├── jobs │ ├── mod.rs │ ├── scope.rs │ ├── job_stack.rs │ └── job_ref.rs ├── exec │ ├── task.rs │ ├── mod.rs │ ├── on_change_strategy.rs │ ├── period.rs │ └── command_builder.rs ├── result │ ├── failure.rs │ ├── mod.rs │ ├── wrapped_report.rs │ ├── location.rs │ ├── wrapped_command_output.rs │ ├── items.rs │ ├── report_maker.rs │ ├── command_output.rs │ ├── filter.rs │ ├── command_result.rs │ └── line.rs ├── sound │ ├── no_sound.rs │ ├── mod.rs │ ├── sound_config.rs │ ├── volume.rs │ └── sound_player.rs ├── socket │ ├── mod.rs │ └── server.rs ├── cli │ ├── completions.rs │ └── mod.rs ├── search │ ├── goto_idx.rs │ ├── mod.rs │ ├── line_pattern.rs │ └── search_pattern.rs ├── lib.rs ├── ignorer │ ├── glob_ignorer.rs │ ├── mod.rs │ └── git_ignorer.rs ├── burp │ └── mod.rs └── watcher.rs ├── resources ├── 2-100419.mp3 ├── beep-6-96243.mp3 ├── beep-beep-6151.mp3 ├── slash1-94367.mp3 ├── success-48018.mp3 ├── beep-warning-6387.mp3 ├── bell-chord1-83260.mp3 ├── cow_bells_01-98236.mp3 ├── pickup-sound-46472.mp3 ├── 90s-game-ui-6-185099.mp3 ├── positive_beeps-85504.mp3 ├── short-beep-tone-47916.mp3 ├── car-horn-beepsmp3-14659.mp3 ├── store-scanner-beep-90395.mp3 ├── conveniencestorering-96090.mp3 └── README.md ├── .gitignore ├── rustfmt.toml ├── CONTRIBUTING.md ├── flake.nix ├── Cargo.toml ├── flake.lock ├── bacon.toml ├── README.md └── defaults ├── default-prefs.toml └── default-bacon.toml /website/.gitignore: -------------------------------------------------------------------------------- 1 | /site 2 | -------------------------------------------------------------------------------- /fmt.sh: -------------------------------------------------------------------------------- 1 | cargo +nightly fmt 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Canop] 2 | -------------------------------------------------------------------------------- /doc/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/doc/test.png -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | 2 | [resolver] 3 | incompatible-rust-versions = "fallback" 4 | -------------------------------------------------------------------------------- /doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/doc/screenshot.png -------------------------------------------------------------------------------- /img/logo-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/img/logo-text.png -------------------------------------------------------------------------------- /src/analysis/python/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod pytest; 2 | pub mod ruff; 3 | pub mod unittest; 4 | -------------------------------------------------------------------------------- /resources/2-100419.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/2-100419.mp3 -------------------------------------------------------------------------------- /website/src/img/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/test.png -------------------------------------------------------------------------------- /resources/beep-6-96243.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/beep-6-96243.mp3 -------------------------------------------------------------------------------- /resources/beep-beep-6151.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/beep-beep-6151.mp3 -------------------------------------------------------------------------------- /resources/slash1-94367.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/slash1-94367.mp3 -------------------------------------------------------------------------------- /resources/success-48018.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/success-48018.mp3 -------------------------------------------------------------------------------- /website/src/img/bacon-ls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-ls.png -------------------------------------------------------------------------------- /website/src/img/cochon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/cochon.jpg -------------------------------------------------------------------------------- /website/src/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/favicon.ico -------------------------------------------------------------------------------- /website/src/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/favicon.png -------------------------------------------------------------------------------- /website/src/img/bacon-cpp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-cpp.png -------------------------------------------------------------------------------- /website/src/img/bacon-ruff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-ruff.png -------------------------------------------------------------------------------- /resources/beep-warning-6387.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/beep-warning-6387.mp3 -------------------------------------------------------------------------------- /resources/bell-chord1-83260.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/bell-chord1-83260.mp3 -------------------------------------------------------------------------------- /resources/cow_bells_01-98236.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/cow_bells_01-98236.mp3 -------------------------------------------------------------------------------- /resources/pickup-sound-46472.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/pickup-sound-46472.mp3 -------------------------------------------------------------------------------- /website/src/img/bacon-biome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-biome.png -------------------------------------------------------------------------------- /website/src/img/bacon-eslint.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-eslint.png -------------------------------------------------------------------------------- /website/src/img/bacon-pytest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-pytest.png -------------------------------------------------------------------------------- /website/src/img/github-mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/github-mark.png -------------------------------------------------------------------------------- /website/src/img/vi-and-bacon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/vi-and-bacon.png -------------------------------------------------------------------------------- /resources/90s-game-ui-6-185099.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/90s-game-ui-6-185099.mp3 -------------------------------------------------------------------------------- /resources/positive_beeps-85504.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/positive_beeps-85504.mp3 -------------------------------------------------------------------------------- /resources/short-beep-tone-47916.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/short-beep-tone-47916.mp3 -------------------------------------------------------------------------------- /resources/car-horn-beepsmp3-14659.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/car-horn-beepsmp3-14659.mp3 -------------------------------------------------------------------------------- /resources/store-scanner-beep-90395.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/store-scanner-beep-90395.mp3 -------------------------------------------------------------------------------- /website/src/img/bacon-broot-clippy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/bacon-broot-clippy.png -------------------------------------------------------------------------------- /website/src/img/github-mark-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/website/src/img/github-mark-white.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | bacon.log 3 | /*deploy.sh 4 | /website/site 5 | .bacon-locations 6 | bacon-analysis.json 7 | /result 8 | -------------------------------------------------------------------------------- /resources/conveniencestorering-96090.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/bacon/HEAD/resources/conveniencestorering-96090.mp3 -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | style_edition = "2024" 3 | imports_granularity = "One" 4 | imports_layout = "Vertical" 5 | fn_params_layout = "Vertical" 6 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | /// Knowledge is power 2 | fn main() -> anyhow::Result<()> { 3 | cli_log::init_cli_log!(); 4 | bacon::run()?; 5 | cli_log::info!("bye"); 6 | Ok(()) 7 | } 8 | -------------------------------------------------------------------------------- /src/tui/md.rs: -------------------------------------------------------------------------------- 1 | pub trait Md { 2 | fn md(&self) -> String; 3 | } 4 | 5 | impl Md for &str { 6 | fn md(&self) -> String { 7 | self.to_string() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/tui/app_state.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | #[derive(Default)] 4 | pub struct AppState { 5 | pub headless: bool, 6 | /// Dimissals and filtering state 7 | pub filter: Filter, 8 | } 9 | -------------------------------------------------------------------------------- /src/conf/defaults.rs: -------------------------------------------------------------------------------- 1 | pub static DEFAULT_PREFS: &str = include_str!("../../defaults/default-prefs.toml"); 2 | 3 | pub static DEFAULT_PACKAGE_CONFIG: &str = include_str!("../../defaults/default-bacon.toml"); 4 | -------------------------------------------------------------------------------- /src/help/mod.rs: -------------------------------------------------------------------------------- 1 | mod examples; 2 | mod help_line; 3 | mod help_page; 4 | mod list_jobs; 5 | 6 | pub use { 7 | examples::*, 8 | help_line::*, 9 | help_page::*, 10 | list_jobs::*, 11 | }; 12 | -------------------------------------------------------------------------------- /src/context_nature.rs: -------------------------------------------------------------------------------- 1 | /// The kind of projec/context, as it impacts computing features, 2 | /// files to watch, etc. 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 4 | pub enum ContextNature { 5 | Cargo, 6 | Other, 7 | } 8 | -------------------------------------------------------------------------------- /src/tty/trange.rs: -------------------------------------------------------------------------------- 1 | /// A position in a tline 2 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 3 | pub struct TRange { 4 | pub string_idx: usize, 5 | pub start_byte_in_string: usize, 6 | pub end_byte_in_string: usize, 7 | } 8 | -------------------------------------------------------------------------------- /src/export/mod.rs: -------------------------------------------------------------------------------- 1 | mod export_config; 2 | mod export_settings; 3 | mod exporter; 4 | mod exports_settings; 5 | 6 | pub use { 7 | export_config::*, 8 | export_settings::*, 9 | exporter::*, 10 | exports_settings::*, 11 | }; 12 | -------------------------------------------------------------------------------- /src/jobs/mod.rs: -------------------------------------------------------------------------------- 1 | mod concrete_job_ref; 2 | mod job; 3 | mod job_ref; 4 | mod job_stack; 5 | mod scope; 6 | 7 | pub use { 8 | concrete_job_ref::*, 9 | job::*, 10 | job_ref::*, 11 | job_stack::*, 12 | scope::*, 13 | }; 14 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | 2 | Bacon's website is live at https://dystroy.org/bacon 3 | 4 | It's built using ddoc 5 | 6 | To test it locally, cd to the website directory then 7 | 8 | ddoc --serve 9 | 10 | To build it, do 11 | 12 | ddoc 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/exec/task.rs: -------------------------------------------------------------------------------- 1 | use crate::Period; 2 | 3 | /// Settings for one execution of a job's command 4 | #[derive(Debug, Clone, Copy, PartialEq)] 5 | pub struct Task { 6 | pub backtrace: Option<&'static str>, // ("0", "1", "2", or "full") 7 | pub grace_period: Period, 8 | } 9 | -------------------------------------------------------------------------------- /src/exec/mod.rs: -------------------------------------------------------------------------------- 1 | mod command_builder; 2 | mod executor; 3 | mod on_change_strategy; 4 | mod period; 5 | mod task; 6 | 7 | pub use { 8 | command_builder::CommandBuilder, 9 | executor::*, 10 | on_change_strategy::*, 11 | period::*, 12 | task::Task, 13 | }; 14 | -------------------------------------------------------------------------------- /src/jobs/scope.rs: -------------------------------------------------------------------------------- 1 | /// A dynamic reduction of a job execution 2 | #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] 3 | pub struct Scope { 4 | pub tests: Vec, 5 | } 6 | 7 | impl Scope { 8 | pub fn has_tests(&self) -> bool { 9 | !self.tests.is_empty() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/analysis/line_analyzer.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | // an utility for those analyzers that can work with the LineAnalysis struct 4 | pub trait LineAnalyzer { 5 | /// this function will disappear 6 | fn analyze_line( 7 | &mut self, 8 | line: &CommandOutputLine, 9 | ) -> LineAnalysis; 10 | } 11 | -------------------------------------------------------------------------------- /src/tui/menu/inform.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | static OK: &str = "OK"; 4 | 5 | pub fn inform>(txt: S) -> Menu<&'static str> { 6 | let mut menu = Menu::new(); 7 | menu.add_item(OK, None); 8 | menu.state.set_intro(txt); 9 | menu 10 | } 11 | 12 | pub type InformMenu = Menu<&'static str>; 13 | -------------------------------------------------------------------------------- /src/result/failure.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | }; 8 | 9 | /// data of a failed command 10 | #[derive(Debug, Clone, Serialize, Deserialize)] 11 | pub struct Failure { 12 | pub error_code: i32, 13 | pub output: CommandOutput, 14 | pub suggest_backtrace: bool, 15 | } 16 | -------------------------------------------------------------------------------- /src/tui/dialog.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | /// the dialog that may be displayed over the rest of the UI 4 | #[allow(clippy::large_enum_variant)] 5 | pub enum Dialog { 6 | None, 7 | Menu(ActionMenu), 8 | } 9 | 10 | impl Dialog { 11 | pub fn is_none(&self) -> bool { 12 | matches!(self, Self::None) 13 | } 14 | pub fn is_some(&self) -> bool { 15 | !self.is_none() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /website/deploy.sh: -------------------------------------------------------------------------------- 1 | # This script is dedicated to the official documentation site at https://dystroy.org/bacon 2 | 3 | # build the documentation site 4 | ddoc 5 | 6 | # copy the site to the deployement stage 7 | cp -r site/* ~/dev/www/dystroy/bacon/ 8 | 9 | # build the config schema 10 | bacon --generate-config-schema > ~/dev/www/dystroy/bacon/.bacon.schema.json 11 | 12 | # deploy on dystroy.org 13 | ~/dev/www/dystroy/deploy.sh 14 | -------------------------------------------------------------------------------- /src/analysis/mod.rs: -------------------------------------------------------------------------------- 1 | mod analyzer; 2 | mod biome; 3 | mod cargo_json; 4 | mod cpp; 5 | mod eslint; 6 | mod item_accumulator; 7 | mod line_analysis; 8 | mod line_analyzer; 9 | mod line_type; 10 | mod nextest; 11 | mod python; 12 | mod standard; 13 | mod stats; 14 | mod swift; 15 | 16 | pub use { 17 | analyzer::*, 18 | item_accumulator::*, 19 | line_analysis::*, 20 | line_analyzer::*, 21 | line_type::*, 22 | stats::*, 23 | }; 24 | -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | Resources here are embedded in bacon, depending on features enabled at compilation. 2 | 3 | All mp3 files in this directory come from https://pixabay.com/ 4 | Their name in this directory is the name in pixabay, to ease retrieval. 5 | They're free to use. 6 | 7 | 8 | You may propose other sounds for inclusion in bacon, provided that their usage isn't limited and that they're light enough. 9 | See src/sound/play_sound.rs for more information. 10 | -------------------------------------------------------------------------------- /src/conf/auto_refresh.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy, PartialEq)] 2 | pub enum AutoRefresh { 3 | /// Don't rerun the job on file changes. 4 | Paused, 5 | /// Run the job on file changes. 6 | Enabled, 7 | } 8 | 9 | impl AutoRefresh { 10 | pub fn is_enabled(self) -> bool { 11 | matches!(self, AutoRefresh::Enabled) 12 | } 13 | 14 | pub fn is_paused(self) -> bool { 15 | matches!(self, AutoRefresh::Paused) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | mod app_state; 3 | mod dialog; 4 | mod drawing; 5 | mod focus_file; 6 | mod md; 7 | mod menu; 8 | mod messages; 9 | mod mission_state; 10 | mod scroll; 11 | mod search_state; 12 | mod wrap; 13 | 14 | pub use { 15 | app_state::*, 16 | dialog::*, 17 | drawing::*, 18 | focus_file::*, 19 | md::*, 20 | menu::*, 21 | messages::*, 22 | mission_state::*, 23 | scroll::*, 24 | search_state::*, 25 | wrap::*, 26 | }; 27 | -------------------------------------------------------------------------------- /src/exec/on_change_strategy.rs: -------------------------------------------------------------------------------- 1 | use { 2 | schemars::JsonSchema, 3 | serde::Deserialize, 4 | }; 5 | 6 | /// Strategy to apply when changes are detected while a job is running. 7 | #[derive(Debug, Clone, Copy, Deserialize, PartialEq, JsonSchema)] 8 | #[serde(rename_all = "snake_case")] 9 | pub enum OnChangeStrategy { 10 | /// Stop the running job immediately before starting a new one. 11 | KillThenRestart, 12 | /// Let the running job finish before starting again. 13 | WaitThenRestart, 14 | } 15 | -------------------------------------------------------------------------------- /src/analysis/swift/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod build; 2 | pub mod lint; 3 | 4 | fn parse_swift_location(location_str: &str) -> Option<(&str, &str, &str)> { 5 | // splitting from the right to avoid colons in path 6 | let parts: Vec<&str> = location_str.rsplitn(3, ':').collect(); 7 | if parts.len() == 3 { 8 | let path = parts[2]; 9 | let line = parts[1]; 10 | let column = parts[0]; 11 | Some((path, line, column)) // path, line, column 12 | } else { 13 | None 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/sound/no_sound.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A dummy sound player which does nothing 4 | pub struct SoundPlayer {} 5 | impl SoundPlayer { 6 | pub fn new(_base_volume: Volume) -> anyhow::Result { 7 | Err(anyhow::anyhow!( 8 | "Bacon is compiled without the sound feature" 9 | )) 10 | } 11 | pub fn play( 12 | &self, 13 | _beep: PlaySoundCommand, 14 | ) { 15 | // should never be called as the sound player is not instantiated 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/result/mod.rs: -------------------------------------------------------------------------------- 1 | mod command_output; 2 | mod command_result; 3 | mod failure; 4 | mod filter; 5 | mod items; 6 | mod line; 7 | mod location; 8 | mod report; 9 | mod report_maker; 10 | mod wrapped_command_output; 11 | mod wrapped_report; 12 | 13 | pub use { 14 | command_output::*, 15 | command_result::*, 16 | failure::*, 17 | filter::*, 18 | items::*, 19 | line::*, 20 | location::*, 21 | report::*, 22 | report_maker::*, 23 | wrapped_command_output::*, 24 | wrapped_report::*, 25 | }; 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | Before you start, unless you're fixing a typo or proposing a trivial change, please 3 | 4 | - discuss the need and technical design first, either in an issue or on [miaou](https://miaou.dystroy.org/3768) 5 | - keep it simple and focused 6 | - don't touch more files or lines than necessary 7 | - apply the standard formatting of the project 8 | - check tests 9 | 10 | And remember: there's no problem in asking when not sure, even if somebody else may have asked before, we're humans. 11 | 12 | More info at https://dystroy.org/blog/contributing/ 13 | -------------------------------------------------------------------------------- /website/src/img/ddoc-search.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/sound/mod.rs: -------------------------------------------------------------------------------- 1 | #[cfg(not(feature = "sound"))] 2 | mod no_sound; 3 | #[cfg(feature = "sound")] 4 | mod play_sound; 5 | mod sound_config; 6 | #[cfg(feature = "sound")] 7 | mod sound_player; 8 | mod volume; 9 | 10 | #[cfg(not(feature = "sound"))] 11 | pub use no_sound::*; 12 | #[cfg(feature = "sound")] 13 | pub use { 14 | play_sound::*, 15 | sound_player::*, 16 | }; 17 | pub use { 18 | sound_config::*, 19 | volume::*, 20 | }; 21 | 22 | /// A command to play a sound 23 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] 24 | pub struct PlaySoundCommand { 25 | pub name: Option, 26 | pub volume: Volume, 27 | } 28 | -------------------------------------------------------------------------------- /website/src/img/ddoc-left-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /website/src/img/ddoc-right-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/socket/mod.rs: -------------------------------------------------------------------------------- 1 | mod server; 2 | 3 | use { 4 | crate::*, 5 | anyhow::{ 6 | Context as _, 7 | Result, 8 | }, 9 | std::{ 10 | io::Write, 11 | os::unix::net::UnixStream, 12 | }, 13 | }; 14 | 15 | pub use server::Server; 16 | 17 | pub fn send_action( 18 | context: &Context, 19 | action: &str, 20 | ) -> Result<()> { 21 | let path = context.unix_socket_path(); 22 | let mut stream = UnixStream::connect(&path) 23 | .with_context(|| format!("Failed to connect to socket: {}", path.display()))?; 24 | stream.write_all(action.as_bytes())?; 25 | stream.flush()?; 26 | Ok(()) 27 | } 28 | -------------------------------------------------------------------------------- /src/export/exporter.rs: -------------------------------------------------------------------------------- 1 | use { 2 | schemars::JsonSchema, 3 | serde::Deserialize, 4 | }; 5 | 6 | /// Export backend. 7 | #[derive(Debug, Clone, Copy, PartialEq, Deserialize, JsonSchema)] 8 | #[serde(rename_all = "snake_case")] 9 | pub enum Exporter { 10 | /// The analyzer is tasked with doing an export while analyzing the 11 | /// command output 12 | #[serde(alias = "analyzer")] 13 | Analyser, 14 | /// This exporter doesn't exist at the moment 15 | #[serde(alias = "analyzis")] 16 | Analysis, 17 | /// Emit a machine-readable JSON report for the mission. 18 | JsonReport, 19 | /// Produce a list of file locations for editors or other tools. 20 | Locations, 21 | } 22 | -------------------------------------------------------------------------------- /src/cli/completions.rs: -------------------------------------------------------------------------------- 1 | use { 2 | clap_complete::CompletionCandidate, 3 | std::process::Command, 4 | }; 5 | 6 | fn with_self_command( 7 | f: impl FnOnce(Command) -> Option> 8 | ) -> Vec { 9 | std::env::current_exe() 10 | .ok() 11 | .and_then(|command| f(Command::new(command))) 12 | .unwrap_or_default() 13 | } 14 | 15 | pub fn list_jobs() -> Vec { 16 | with_self_command(|mut c| { 17 | let output = c.arg("--completion-list-jobs").output().ok()?; 18 | let output: String = String::from_utf8(output.stdout).ok()?; 19 | Some(output.split('\0').map(CompletionCandidate::new).collect()) 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/search/goto_idx.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | pub fn search_item_idx<'i, I>( 4 | idx: usize, 5 | lines: I, 6 | ) -> Vec 7 | where 8 | I: IntoIterator, 9 | { 10 | for (line_idx, line) in lines.into_iter().enumerate() { 11 | if line.item_idx == idx && !line.content.strings.is_empty() { 12 | let end_byte_in_string = line.content.strings[0].raw.len(); 13 | return vec![Found { 14 | line_idx, 15 | trange: TRange { 16 | string_idx: 0, 17 | start_byte_in_string: 0, 18 | end_byte_in_string, 19 | }, 20 | continued: None, 21 | }]; 22 | } 23 | } 24 | vec![] 25 | } 26 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod analysis; 2 | pub mod burp; 3 | mod cli; 4 | mod conf; 5 | mod context; 6 | mod context_nature; 7 | mod exec; 8 | mod export; 9 | mod help; 10 | mod ignorer; 11 | mod jobs; 12 | mod mission; 13 | mod result; 14 | mod search; 15 | #[cfg(unix)] 16 | mod socket; 17 | mod sound; 18 | mod tty; 19 | mod tui; 20 | mod watcher; 21 | 22 | pub use { 23 | analysis::*, 24 | cli::*, 25 | conf::*, 26 | context::*, 27 | context_nature::*, 28 | exec::*, 29 | export::*, 30 | help::*, 31 | ignorer::*, 32 | jobs::*, 33 | mission::*, 34 | result::*, 35 | search::*, 36 | sound::*, 37 | tty::*, 38 | tui::*, 39 | watcher::*, 40 | }; 41 | 42 | #[cfg(unix)] 43 | pub use socket::*; 44 | 45 | #[macro_use] 46 | extern crate cli_log; 47 | -------------------------------------------------------------------------------- /src/tui/messages.rs: -------------------------------------------------------------------------------- 1 | use std::time::{ 2 | Duration, 3 | Instant, 4 | }; 5 | 6 | /// A message to be displayed to the user, one line max 7 | pub struct Message { 8 | pub markdown: String, 9 | /// when the message was first displayed 10 | pub display_start: Option, 11 | /// minimal duration to display the message 12 | pub display_duration: Duration, 13 | } 14 | 15 | impl Message { 16 | /// build a short message, typically to answer to a user action 17 | /// (thus when the user is looking at bacon) 18 | pub fn short>(markdown: S) -> Self { 19 | Self { 20 | markdown: markdown.into(), 21 | display_start: None, 22 | display_duration: Duration::from_secs(5), 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/export/export_config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | schemars::JsonSchema, 4 | serde::Deserialize, 5 | std::path::PathBuf, 6 | }; 7 | 8 | /// A generic configuration for all exports, whatever the exporter. 9 | #[derive(Debug, Clone, Deserialize, JsonSchema)] 10 | pub struct ExportConfig { 11 | /// Exporter backend that should produce the output. 12 | pub exporter: Option, 13 | 14 | /// Whether the export should run automatically after each mission. 15 | #[serde(alias = "enabled")] 16 | pub auto: Option, 17 | 18 | /// Destination path where the exporter writes its output. 19 | pub path: Option, 20 | 21 | /// Optional format string used by exporters that write line-based data. 22 | pub line_format: Option, 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/tui/drawing.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::Result, 4 | termimad::crossterm::{ 5 | cursor, 6 | execute, 7 | terminal, 8 | }, 9 | }; 10 | 11 | /// Move the curstor to the x, y position 12 | pub fn goto( 13 | w: &mut W, 14 | x: u16, 15 | y: u16, 16 | ) -> Result<()> { 17 | execute!(w, cursor::MoveTo(x, y))?; 18 | Ok(()) 19 | } 20 | 21 | /// Move the curstor to the start of the provided line 22 | pub fn goto_line( 23 | w: &mut W, 24 | y: u16, 25 | ) -> Result<()> { 26 | execute!(w, cursor::MoveTo(0, y))?; 27 | Ok(()) 28 | } 29 | 30 | /// Clear from the current position to the end of the line 31 | pub fn clear_line(w: &mut W) -> Result<()> { 32 | execute!(w, terminal::Clear(terminal::ClearType::UntilNewLine))?; 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /src/result/wrapped_report.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | pub struct WrappedReport { 4 | pub sub_lines: Vec, 5 | /// number of summary lines after wrapping 6 | pub summary_height: usize, 7 | } 8 | 9 | impl WrappedReport { 10 | /// compute a new wrapped report for a width and report. 11 | /// 12 | /// width is the total area width, including the scrollbar. 13 | pub fn new( 14 | report: &Report, 15 | width: u16, 16 | ) -> Self { 17 | debug!("wrapping report"); 18 | let sub_lines = wrap(&report.lines, width); 19 | let summary_height = sub_lines 20 | .iter() 21 | .filter(|sl| sl.line_type.is_summary()) 22 | .count(); 23 | Self { 24 | sub_lines, 25 | summary_height, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/result/location.rs: -------------------------------------------------------------------------------- 1 | use { 2 | lazy_regex::*, 3 | std::{ 4 | path::PathBuf, 5 | str::FromStr, 6 | }, 7 | }; 8 | 9 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 10 | pub struct Location { 11 | pub path: PathBuf, 12 | pub line: usize, 13 | pub column: Option, 14 | } 15 | 16 | impl FromStr for Location { 17 | type Err = &'static str; 18 | fn from_str(s: &str) -> Result { 19 | let Some((_, path, line, column)) = regex_captures!(r#"^([^:\s]+):(\d+)(?:\:(\d+))?$"#, s,) 20 | else { 21 | return Err("invalid location format"); 22 | }; 23 | let line = line.parse().map_err(|_| "invalid line number")?; // too many digits 24 | let column = column.parse().ok(); 25 | Ok(Self { 26 | path: PathBuf::from(path), 27 | line, 28 | column, 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/analysis/nextest/mod.rs: -------------------------------------------------------------------------------- 1 | mod nextest_line_analyser; 2 | 3 | use { 4 | crate::*, 5 | anyhow::Result, 6 | nextest_line_analyser::NextestLineAnalyzer, 7 | }; 8 | 9 | #[derive(Debug, Default)] 10 | pub struct NextestAnalyzer { 11 | lines: Vec, 12 | } 13 | 14 | impl Analyzer for NextestAnalyzer { 15 | fn start( 16 | &mut self, 17 | _mission: &Mission, 18 | ) { 19 | self.lines.clear(); 20 | } 21 | 22 | fn receive_line( 23 | &mut self, 24 | line: CommandOutputLine, 25 | command_output: &mut CommandOutput, 26 | ) { 27 | self.lines.push(line.clone()); 28 | command_output.push(line); 29 | } 30 | 31 | fn build_report(&mut self) -> Result { 32 | let line_analyzer = NextestLineAnalyzer::default(); 33 | crate::analysis::standard::build_report(&self.lines, line_analyzer) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/tui/focus_file.rs: -------------------------------------------------------------------------------- 1 | use lazy_regex::*; 2 | 3 | /// A command to focus on the diagnostics related 4 | /// to a specific file 5 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 6 | pub struct FocusFileCommand { 7 | pub file: String, 8 | } 9 | 10 | impl FocusFileCommand { 11 | pub fn new(s: &str) -> Self { 12 | Self { 13 | file: s.trim().to_string(), 14 | } 15 | } 16 | /// Return the action description to show in doc/help 17 | pub fn doc(&self) -> String { 18 | format!("focus file {}", self.file) 19 | } 20 | /// Tell whether the location should be focused 21 | pub fn matches( 22 | &self, 23 | location: &str, 24 | ) -> bool { 25 | let Some((_, file, _line, _col)) = regex_captures!(r"^([^:]+)(:\d+)?(:\d+)?$", location) 26 | else { 27 | return false; 28 | }; 29 | file.ends_with(&self.file) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /website/src/img/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/src/img/github-mark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/analysis/standard/mod.rs: -------------------------------------------------------------------------------- 1 | mod standard_line_analyser; 2 | mod standard_report_building; 3 | 4 | pub use { 5 | standard_line_analyser::StandardLineAnalyzer, 6 | standard_report_building::build_report, 7 | }; 8 | 9 | use { 10 | crate::*, 11 | anyhow::Result, 12 | }; 13 | 14 | #[derive(Debug, Default)] 15 | pub struct StandardAnalyzer { 16 | lines: Vec, 17 | } 18 | 19 | impl Analyzer for StandardAnalyzer { 20 | fn start( 21 | &mut self, 22 | _mission: &Mission, 23 | ) { 24 | self.lines.clear(); 25 | } 26 | 27 | fn receive_line( 28 | &mut self, 29 | line: CommandOutputLine, 30 | command_output: &mut CommandOutput, 31 | ) { 32 | self.lines.push(line.clone()); 33 | command_output.push(line); 34 | } 35 | 36 | fn build_report(&mut self) -> Result { 37 | let line_analyzer = StandardLineAnalyzer {}; 38 | build_report(&self.lines, line_analyzer) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/help/examples.rs: -------------------------------------------------------------------------------- 1 | /// A bacon launch example to display in the --help message 2 | pub struct Example { 3 | pub title: &'static str, 4 | pub cmd: &'static str, 5 | } 6 | 7 | pub static EXAMPLES_TEMPLATE: &str = " 8 | **Examples:** 9 | 10 | ${examples 11 | *${example-number})* ${example-title}: `${example-cmd}` 12 | } 13 | "; 14 | 15 | /// Examples to display in the --help message 16 | pub static EXAMPLES: &[Example] = &[ 17 | Example { 18 | title: "Start with the default job", 19 | cmd: "bacon", 20 | }, 21 | Example { 22 | title: "Start with a specific job", 23 | cmd: "bacon clippy", 24 | }, 25 | Example { 26 | title: "Start with features", 27 | cmd: "bacon --features clipboard", 28 | }, 29 | Example { 30 | title: "Start a specific job on another path", 31 | cmd: "bacon ../broot test", 32 | }, 33 | Example { 34 | title: "Start in summary mode", 35 | cmd: "bacon -s", 36 | }, 37 | ]; 38 | -------------------------------------------------------------------------------- /src/sound/sound_config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | schemars::JsonSchema, 4 | serde::Deserialize, 5 | }; 6 | 7 | /// Sound configuration. 8 | #[derive(Debug, Clone, Default, Deserialize, PartialEq, JsonSchema)] 9 | pub struct SoundConfig { 10 | /// Whether sound notifications should be played. 11 | pub enabled: Option, 12 | 13 | /// Base volume, acting as a multiplier for the volume of specific sounds. 14 | pub base_volume: Option, 15 | } 16 | 17 | impl SoundConfig { 18 | pub fn apply( 19 | &mut self, 20 | sc: &SoundConfig, 21 | ) { 22 | if let Some(b) = sc.enabled { 23 | self.enabled = Some(b); 24 | } 25 | if let Some(bv) = sc.base_volume { 26 | self.base_volume = Some(bv); 27 | } 28 | } 29 | pub fn is_enabled(&self) -> bool { 30 | self.enabled.unwrap_or(false) 31 | } 32 | pub fn get_base_volume(&self) -> Volume { 33 | self.base_volume.unwrap_or_default() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/help/list_jobs.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | termimad::{ 4 | MadSkin, 5 | minimad::{ 6 | OwningTemplateExpander, 7 | TextTemplate, 8 | }, 9 | }, 10 | }; 11 | 12 | pub fn print_jobs(settings: &Settings) { 13 | static MD: &str = r" 14 | |:-:|:-| 15 | |**job**|**command**| 16 | |:-:|:-| 17 | ${jobs 18 | |${job_name}|${job_command}| 19 | } 20 | |-|-| 21 | default job: ${default_job} 22 | "; 23 | let mut expander = OwningTemplateExpander::new(); 24 | let mut jobs: Vec<_> = settings.jobs.iter().collect(); 25 | jobs.sort_by_key(|(name, _)| (*name).to_string()); 26 | for (name, job) in &jobs { 27 | expander 28 | .sub("jobs") 29 | .set("job_name", name) 30 | .set("job_command", job.command.join(" ")); 31 | } 32 | expander.set("default_job", &settings.default_job); 33 | let skin = MadSkin::default(); 34 | skin.print_owning_expander(&expander, &TextTemplate::from(MD)); 35 | } 36 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | flake-utils.url = "github:numtide/flake-utils"; 4 | naersk.url = "github:nix-community/naersk"; 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | }; 7 | 8 | outputs = 9 | { 10 | self, 11 | flake-utils, 12 | naersk, 13 | nixpkgs, 14 | }: 15 | flake-utils.lib.eachDefaultSystem ( 16 | system: 17 | let 18 | pkgs = (import nixpkgs) { 19 | inherit system; 20 | }; 21 | 22 | naersk' = pkgs.callPackage naersk { }; 23 | bacon = naersk'.buildPackage { 24 | buildInputs = if pkgs.stdenv.isLinux then [ pkgs.alsa-lib pkgs.pkg-config ] else []; 25 | src = ./.; 26 | }; 27 | in 28 | { 29 | # For `nix build` & `nix run`: 30 | defaultPackage = bacon; 31 | 32 | # For `nix develop`: 33 | devShell = pkgs.mkShell { 34 | nativeBuildInputs = with pkgs; [ 35 | rustc 36 | cargo 37 | ]; 38 | }; 39 | 40 | # Overlay for package usage in other Nix configurations 41 | overlay = final: prev: { 42 | bacon = bacon; 43 | }; 44 | } 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/search/mod.rs: -------------------------------------------------------------------------------- 1 | mod goto_idx; 2 | mod line_pattern; 3 | mod search_pattern; 4 | 5 | pub use { 6 | goto_idx::*, 7 | line_pattern::*, 8 | search_pattern::*, 9 | }; 10 | 11 | use crate::*; 12 | 13 | #[derive(Debug, Clone, Copy, PartialEq)] 14 | pub enum SearchMode { 15 | Pattern, 16 | ItemIdx, 17 | } 18 | 19 | /// position in a `[TLine]` of a found pattern 20 | #[derive(Debug, PartialEq, Eq)] 21 | pub struct Found { 22 | /// The index of the first line containing the pattern 23 | pub line_idx: usize, 24 | /// The range of the pattern in the line 25 | pub trange: TRange, 26 | /// If the pattern goes over a line wrap, the range of the pattern in the next line 27 | pub continued: Option, 28 | } 29 | 30 | pub enum Search { 31 | Pattern(Pattern), 32 | ItemIdx(usize), 33 | } 34 | 35 | impl Search { 36 | pub fn search_lines<'i, I>( 37 | &self, 38 | lines: I, 39 | ) -> Vec 40 | where 41 | I: IntoIterator, 42 | { 43 | match self { 44 | Self::Pattern(pattern) => pattern.search_lines(lines), 45 | Self::ItemIdx(idx) => search_item_idx(*idx, lines), 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ignorer/glob_ignorer.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::Result, 4 | std::path::Path, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct GlobIgnorer { 9 | globs: Vec, 10 | } 11 | 12 | impl GlobIgnorer { 13 | pub fn add( 14 | &mut self, 15 | pattern: &str, 16 | root: &Path, 17 | ) -> Result<()> { 18 | if pattern.starts_with('/') { 19 | self.globs.push(glob::Pattern::new(pattern)?); 20 | // it's probably a path relative to the root of the package 21 | let pattern = root.join(pattern); 22 | let pattern = pattern.to_string_lossy(); 23 | self.globs.push(glob::Pattern::new(&pattern)?); 24 | } else { 25 | // as glob doesn't work with non absolute paths, we make it absolute 26 | self.globs 27 | .push(glob::Pattern::new(&format!("/**/{pattern}"))?); 28 | } 29 | Ok(()) 30 | } 31 | } 32 | 33 | impl Ignorer for GlobIgnorer { 34 | fn excludes( 35 | &mut self, 36 | paths: &Path, 37 | ) -> Result { 38 | for glob in &self.globs { 39 | if glob.matches_path(paths) { 40 | return Ok(true); 41 | } 42 | } 43 | Ok(false) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tui/menu/mod.rs: -------------------------------------------------------------------------------- 1 | mod action_menu; 2 | mod inform; 3 | mod menu_state; 4 | mod menu_view; 5 | 6 | pub use { 7 | action_menu::*, 8 | inform::*, 9 | menu_state::*, 10 | menu_view::*, 11 | }; 12 | 13 | use { 14 | crate::*, 15 | anyhow::Result, 16 | crokey::KeyCombination, 17 | termimad::Area, 18 | }; 19 | 20 | pub struct Menu { 21 | pub state: MenuState, 22 | view: MenuView, 23 | } 24 | 25 | impl Menu { 26 | pub fn new() -> Self { 27 | Self { 28 | state: Default::default(), 29 | view: Default::default(), 30 | } 31 | } 32 | pub fn draw( 33 | &mut self, 34 | w: &mut W, 35 | skin: &BaconSkin, 36 | ) -> Result<()> { 37 | self.view.draw(w, &mut self.state, skin) 38 | } 39 | pub fn set_available_area( 40 | &mut self, 41 | area: Area, 42 | ) { 43 | self.view.set_available_area(area); 44 | } 45 | pub fn set_intro>( 46 | &mut self, 47 | intro: S, 48 | ) { 49 | self.state.set_intro(intro); 50 | } 51 | pub fn add_item( 52 | &mut self, 53 | action: I, 54 | key: Option, 55 | ) { 56 | self.state.add_item(action, key); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /website/src/community/nvim-bacon.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Purpose 4 | 5 | [nvim-bacon](https://github.com/Canop/nvim-bacon) is a neovim plugin, which, combined with bacon, lets you navigate between errors and warnings without leaving your editor, just hitting a key. 6 | 7 | # How it works 8 | 9 | At every job end, bacon writes a `.bacon-locations` file with all items (errors, warnings, test failures, etc.) and for each of them its label, file path, line and column. 10 | 11 | In nvim, on predefined shortcuts, the nvim-bacon plugin may jump to the next item's position, or display all items to let you choose one. The plugin reads the `.bacon-locations` file every time you hit one of its shortcuts. 12 | 13 | Nothing in this design is Rust related. 14 | This plugin can thus be used whatever the ecosystem(s) you program in. 15 | 16 | # Bacon configuration 17 | 18 | The configuration instructing bacon to export the locations at every job should be defined in your global `bacon/prefs.toml`: 19 | 20 | ```TOML 21 | [exports.locations] 22 | auto = true 23 | path = ".bacon-locations" 24 | line_format = "{kind} {path}:{line}:{column} {message}" 25 | ``` 26 | 27 | # Installation & Usage 28 | 29 | How to install nvim-bacon, how to configure it, and how to use it, are described in its own page: [https://github.com/Canop/nvim-bacon](https://github.com/Canop/nvim-bacon). 30 | -------------------------------------------------------------------------------- /src/conf/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod auto_refresh; 3 | mod cargo_wrapped_config; 4 | mod config; 5 | mod defaults; 6 | mod keybindings; 7 | mod settings; 8 | mod skin; 9 | 10 | pub use { 11 | action::*, 12 | auto_refresh::*, 13 | cargo_wrapped_config::*, 14 | config::*, 15 | defaults::*, 16 | keybindings::*, 17 | settings::*, 18 | skin::*, 19 | }; 20 | 21 | use std::path::{ 22 | Path, 23 | PathBuf, 24 | }; 25 | 26 | /// If the system can manage application preferences, return the 27 | /// canonical path to the bacon preferences file 28 | pub fn bacon_prefs_path() -> Option { 29 | directories_next::ProjectDirs::from("org", "dystroy", "bacon") 30 | .map(|project_dir| project_dir.config_dir().join("prefs.toml")) 31 | } 32 | 33 | /// Return the path given by the env var, if it exists (doesn't check whether 34 | /// it's a correct configuration file) 35 | pub fn config_path_from_env(env_var_name: &str) -> Option { 36 | let path = std::env::var_os(env_var_name)?; 37 | let path = Path::new(&path); 38 | if path.exists() { 39 | Some(path.to_path_buf()) 40 | } else { 41 | // some users may want to use an env var to point to a file that may not always exist 42 | // so we don't throw an error here 43 | warn!("Env var {env_var_name:?} points to file {path:?} which does not exist"); 44 | None 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/conf/cargo_wrapped_config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::Config, 3 | anyhow::*, 4 | serde::Deserialize, 5 | std::path::Path, 6 | }; 7 | 8 | #[derive(Deserialize)] 9 | struct CargoWrappedConfig { 10 | workspace: Option, 11 | package: Option, 12 | } 13 | #[derive(Deserialize)] 14 | struct WrappedMetadata { 15 | metadata: Option, 16 | } 17 | #[derive(Deserialize)] 18 | struct WrappedConfig { 19 | bacon: Option, 20 | } 21 | 22 | pub fn load_config_from_cargo_toml(cargo_file_path: &Path) -> Result> { 23 | if !cargo_file_path.exists() { 24 | return Ok(Vec::default()); 25 | } 26 | let cargo_toml = std::fs::read_to_string(cargo_file_path)?; 27 | let mut cargo: CargoWrappedConfig = toml::from_str(&cargo_toml)?; 28 | let mut configs = Vec::new(); 29 | let worskpace_config = cargo 30 | .workspace 31 | .take() 32 | .and_then(|workspace| workspace.metadata) 33 | .and_then(|metadata| metadata.bacon); 34 | if let Some(config) = worskpace_config { 35 | configs.push(config); 36 | } 37 | let worskpace_config = cargo 38 | .package 39 | .take() 40 | .and_then(|package| package.metadata) 41 | .and_then(|metadata| metadata.bacon); 42 | if let Some(config) = worskpace_config { 43 | configs.push(config); 44 | } 45 | Ok(configs) 46 | } 47 | -------------------------------------------------------------------------------- /src/analysis/line_analysis.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | }; 8 | 9 | /// result of the "parsing" of the line 10 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 11 | pub struct LineAnalysis { 12 | pub line_type: LineType, 13 | #[serde(default, skip_serializing_if = "Option::is_none")] 14 | pub key: Option, 15 | } 16 | 17 | impl LineAnalysis { 18 | pub fn of_type(line_type: LineType) -> Self { 19 | Self { 20 | line_type, 21 | key: None, 22 | } 23 | } 24 | pub fn normal() -> Self { 25 | Self::of_type(LineType::Normal) 26 | } 27 | pub fn garbage() -> Self { 28 | Self::of_type(LineType::Garbage) 29 | } 30 | pub fn title_key( 31 | kind: Kind, 32 | key: String, 33 | ) -> Self { 34 | Self { 35 | line_type: LineType::Title(kind), 36 | key: Some(key), 37 | } 38 | } 39 | pub fn fail>(key: S) -> Self { 40 | Self { 41 | line_type: LineType::Title(Kind::TestFail), 42 | key: Some(key.into()), 43 | } 44 | } 45 | pub fn test_result( 46 | key: String, 47 | pass: bool, 48 | ) -> Self { 49 | Self { 50 | line_type: LineType::TestResult(pass), 51 | key: Some(key), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/result/wrapped_command_output.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | /// A wrapped cmd_output, only valid for the `cmd_output` it was computed for, 4 | /// contains references to the start and end of lines wrapped for a 5 | /// given width 6 | pub struct WrappedCommandOutput { 7 | pub sub_lines: Vec, 8 | 9 | /// in order to allow partial wrapping, and assuming the wrapped part 10 | /// didn't change, we store the count of lines which were wrapped so 11 | /// that we may update starting from there 12 | pub wrapped_lines_count: usize, 13 | } 14 | 15 | impl WrappedCommandOutput { 16 | /// compute a new wrapped `cmd_output` for a `width` and `cmd_output`. 17 | /// 18 | /// width is the total area width, including the scrollbar. 19 | pub fn new( 20 | cmd_output: &CommandOutput, 21 | width: u16, 22 | ) -> Self { 23 | let sub_lines = wrap(&cmd_output.lines, width); 24 | Self { 25 | sub_lines, 26 | wrapped_lines_count: cmd_output.len(), 27 | } 28 | } 29 | 30 | /// Assuming the width is the same and the lines already handled 31 | /// didn't change, wrap and add the lines which weren't. 32 | pub fn update( 33 | &mut self, 34 | cmd_output: &CommandOutput, 35 | width: u16, 36 | ) { 37 | self.sub_lines 38 | .extend(wrap(&cmd_output.lines[self.wrapped_lines_count..], width)); 39 | self.wrapped_lines_count = cmd_output.lines.len(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/analysis/stats.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | }; 8 | 9 | /// number of lines per type in a report 10 | #[derive(Debug, Default, Clone, Serialize, Deserialize)] 11 | pub struct Stats { 12 | pub warnings: usize, 13 | pub errors: usize, 14 | pub test_fails: usize, 15 | pub location_lines: usize, 16 | pub normal_lines: usize, 17 | } 18 | impl From<&Vec> for Stats { 19 | fn from(lines: &Vec) -> Self { 20 | lines.iter().fold(Stats::default(), |mut stats, line| { 21 | match line.line_type { 22 | LineType::Title(Kind::Error) => stats.errors += 1, 23 | LineType::Title(Kind::Warning) => stats.warnings += 1, 24 | LineType::Title(Kind::TestFail) => stats.test_fails += 1, 25 | LineType::Title(Kind::TestOutput) => stats.test_fails += 1, 26 | LineType::Location => stats.location_lines += 1, 27 | _ => stats.normal_lines += 1, 28 | } 29 | stats 30 | }) 31 | } 32 | } 33 | impl Stats { 34 | pub fn lines( 35 | &self, 36 | summary: bool, 37 | ) -> usize { 38 | let mut sum = self.warnings + self.errors + self.test_fails + self.location_lines; 39 | if !summary { 40 | sum += self.normal_lines; 41 | } 42 | sum 43 | } 44 | pub fn items(&self) -> usize { 45 | self.warnings + self.errors + self.test_fails 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/tui/menu/action_menu.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | pub type ActionMenu = Menu; 4 | 5 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 6 | pub struct ActionMenuDefinition { 7 | pub intro: Option, 8 | pub actions: Vec, 9 | } 10 | 11 | impl ActionMenu { 12 | pub fn add_action( 13 | &mut self, 14 | action: Action, 15 | ) { 16 | self.add_item(action, None); // TODO look for key combination in settings 17 | } 18 | pub fn with_all_jobs(mission: &Mission) -> Self { 19 | let mut menu = Self::new(); 20 | let mut job_names = mission.settings.jobs.keys().collect::>(); 21 | job_names.sort(); 22 | let actions = job_names 23 | .into_iter() 24 | .map(|job_name| Action::Job(ConcreteJobRef::from_job_name(job_name).into())); 25 | for action in actions { 26 | let key = mission.settings.keybindings.shortest_key_for(&action); 27 | menu.add_item(action, key); 28 | } 29 | menu 30 | } 31 | pub fn from_definition( 32 | ActionMenuDefinition { intro, actions }: ActionMenuDefinition, 33 | settings: &Settings, 34 | ) -> Self { 35 | let mut menu = Self::new(); 36 | if let Some(intro) = intro { 37 | menu.set_intro(intro); 38 | } 39 | for action in actions { 40 | let key = settings.keybindings.shortest_key_for(&action); 41 | menu.add_item(action, key); 42 | } 43 | menu 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ignorer/mod.rs: -------------------------------------------------------------------------------- 1 | use { 2 | anyhow::Result, 3 | std::path::{ 4 | Path, 5 | PathBuf, 6 | }, 7 | }; 8 | 9 | mod git_ignorer; 10 | mod glob_ignorer; 11 | 12 | pub use { 13 | git_ignorer::GitIgnorer, 14 | glob_ignorer::GlobIgnorer, 15 | }; 16 | 17 | pub trait Ignorer { 18 | /// Tell whether all given paths are excluded according to 19 | /// either the global gitignore rules or the ones of the repository. 20 | /// 21 | /// Return Ok(false) when at least one file is included (i.e. we should 22 | /// execute the job) 23 | fn excludes( 24 | &mut self, 25 | paths: &Path, 26 | ) -> Result; 27 | } 28 | 29 | /// A set of ignorers 30 | #[derive(Default)] 31 | pub struct IgnorerSet { 32 | ignorers: Vec>, 33 | } 34 | impl IgnorerSet { 35 | pub fn add( 36 | &mut self, 37 | ignorer: Box, 38 | ) { 39 | self.ignorers.push(ignorer); 40 | } 41 | pub fn excludes_all_pathbufs( 42 | &mut self, 43 | paths: &[PathBuf], 44 | ) -> Result { 45 | if self.ignorers.is_empty() { 46 | return Ok(false); 47 | } 48 | for path in paths { 49 | let mut excluded = false; 50 | for ignorer in &mut self.ignorers { 51 | if ignorer.excludes(path)? { 52 | excluded = true; 53 | break; 54 | } 55 | } 56 | if !excluded { 57 | return Ok(false); 58 | } 59 | } 60 | Ok(true) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/result/items.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | #[derive(Clone, Copy)] 4 | pub struct Item<'l> { 5 | /// non empty slice of lines with the same `item_idx` 6 | lines: &'l [Line], 7 | } 8 | 9 | impl<'l> Item<'l> { 10 | /// return a vector of slices of lines, each slice pointing to the 11 | /// consecutive lines having the same `item_idx` 12 | pub fn items_of(lines: &'l [Line]) -> Vec { 13 | let mut items: Vec = Vec::new(); 14 | let mut start = 0; 15 | for i in 1..lines.len() { 16 | if lines[i].item_idx != lines[start].item_idx { 17 | items.push(Item { 18 | lines: &lines[start..i], 19 | }); 20 | start = i; 21 | } 22 | } 23 | if start < lines.len() { 24 | items.push(Item { 25 | lines: &lines[start..lines.len()], 26 | }); 27 | } 28 | items 29 | } 30 | 31 | pub fn item_idx(&self) -> usize { 32 | self.lines[0].item_idx 33 | } 34 | 35 | pub fn lines(&self) -> &'l [Line] { 36 | self.lines 37 | } 38 | 39 | pub fn location(&self) -> Option<&str> { 40 | for line in self.lines { 41 | if let Some(location) = line.location() { 42 | return Some(location); 43 | } 44 | } 45 | None 46 | } 47 | pub fn diag_type(&self) -> Option<&str> { 48 | for line in self.lines { 49 | if let Some(diag_type) = line.diag_type() { 50 | return Some(diag_type); 51 | } 52 | } 53 | None 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /website/ddoc.hjson: -------------------------------------------------------------------------------- 1 | # A configuration file for ddoc, see https://dystroy.org/ddoc 2 | 3 | title: Bacon 4 | description: "Bacon, a background compiler" 5 | favicon: img/favicon.ico 6 | 7 | pages: { 8 | Overview: index.md 9 | Config: config.md 10 | Analyzers: analyzers.md 11 | Cookbook: cookbook.md 12 | Community: { 13 | "Bacon Dev": community/bacon-dev.md 14 | FAQ: community/FAQ.md 15 | bacon-ls: community/bacon-ls.md 16 | nvim-bacon: community/nvim-bacon.md 17 | } 18 | } 19 | 20 | // Nav links can have { img, href, class, label, alt }, all fields optional. 21 | header: { 22 | before-menu: [ 23 | { 24 | img: img/dystroy-rust-white.svg 25 | url: https://dystroy.org 26 | alt: dystroy.org homepage 27 | class: external-nav-link 28 | } 29 | { 30 | img: img/logo-white.svg 31 | url: /index.md 32 | alt: Bacon homepage 33 | class: bacon-nav-logo 34 | } 35 | ] 36 | middle: menu 37 | after-menu: [ 38 | { 39 | img: img/ddoc-search.svg 40 | href: --search 41 | class: search-opener 42 | alt: Search 43 | } 44 | { 45 | img: img/github-mark-white.svg 46 | class: external-nav-link 47 | alt: GitHub 48 | href: https://github.com/Canop/bacon 49 | } 50 | ] 51 | } 52 | 53 | // UI options 54 | ui: { 55 | // if true, the generated HTML includes a checkbox which 56 | // can be styled into a hamburger menu for small screens 57 | hamburger_checkbox: true 58 | } 59 | 60 | -------------------------------------------------------------------------------- /img/logo-icon.svg: -------------------------------------------------------------------------------- 1 | 6 | 12 | 18 | 27 | 32 | 37 | 42 | 43 | -------------------------------------------------------------------------------- /src/analysis/cargo_json/cargo_json_export.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | cargo_metadata::diagnostic::{ 4 | Diagnostic, 5 | DiagnosticSpan, 6 | }, 7 | serde::Serialize, 8 | }; 9 | 10 | /// An export in progress for the `cargo_json` analyzer 11 | pub struct CargoJsonExport { 12 | pub name: String, 13 | /// The written data to write to the file 14 | pub export: String, 15 | pub line_template: iq::Template, 16 | } 17 | 18 | /// The data provided to the template, once per span 19 | #[derive(Debug, Clone, Serialize)] 20 | struct OnSpanData<'d> { 21 | diagnostic: &'d Diagnostic, 22 | span: &'d DiagnosticSpan, 23 | } 24 | 25 | impl CargoJsonExport { 26 | pub fn new( 27 | name: String, 28 | settings: &ExportSettings, 29 | ) -> Self { 30 | Self { 31 | name, 32 | export: String::new(), 33 | line_template: iq::Template::new(&settings.line_format), 34 | } 35 | } 36 | pub fn receive_diagnostic( 37 | &mut self, 38 | diagnostic: &Diagnostic, 39 | ) { 40 | for span in &diagnostic.spans { 41 | let data = { 42 | // This is a diagnostic that originates from a proc-macro. 43 | if let Some(expansion) = &span.expansion { 44 | OnSpanData { 45 | diagnostic, 46 | span: &expansion.span, 47 | } 48 | } else { 49 | OnSpanData { diagnostic, span } 50 | } 51 | }; 52 | let line = self.line_template.render(&data); 53 | if !line.is_empty() { 54 | self.export.push_str(&line); 55 | self.export.push('\n'); 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/export/export_settings.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | std::{ 4 | fs::File, 5 | path::PathBuf, 6 | }, 7 | }; 8 | 9 | /// Settings for one export 10 | #[derive(Debug, Clone)] 11 | pub struct ExportSettings { 12 | pub exporter: Exporter, 13 | pub auto: bool, 14 | pub path: PathBuf, 15 | pub line_format: String, 16 | } 17 | impl ExportSettings { 18 | pub fn do_export( 19 | &self, 20 | name: &str, 21 | state: &MissionState<'_, '_>, 22 | ) -> anyhow::Result<()> { 23 | let path = if self.path.is_relative() { 24 | state.mission.package_directory.join(&self.path) 25 | } else { 26 | self.path.clone() 27 | }; 28 | info!("exporting to {path:?}"); 29 | let Some(report) = state.cmd_result.report() else { 30 | info!("No report to export"); 31 | return Ok(()); 32 | }; 33 | match self.exporter { 34 | Exporter::Analyser => { 35 | if let Some(export) = report.analyzer_exports.get(name) { 36 | std::fs::write(&path, export)?; 37 | } else { 38 | info!("Analyzer didn't build export {name:?}"); 39 | } 40 | } 41 | Exporter::Analysis => { 42 | error!("Aanlysis export not currently implemented"); 43 | } 44 | Exporter::JsonReport => { 45 | let json = serde_json::to_string_pretty(&report)?; 46 | std::fs::write(&path, json)?; 47 | } 48 | Exporter::Locations => { 49 | let mut file = File::create(path)?; 50 | report.write_locations(&mut file, &state.mission, &self.line_format)?; 51 | } 52 | } 53 | Ok(()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bacon" 3 | version = "3.20.3" 4 | authors = ["dystroy "] 5 | repository = "https://github.com/Canop/bacon" 6 | description = "background rust compiler" 7 | edition = "2021" 8 | keywords = ["rust", "background", "compiler", "watch", "inotify"] 9 | license = "AGPL-3.0" 10 | categories = ["command-line-utilities", "development-tools"] 11 | readme = "README.md" 12 | rust-version = "1.77" 13 | 14 | [features] 15 | default = [] 16 | clipboard = ["arboard"] 17 | sound = ["rodio"] 18 | 19 | [dependencies] 20 | anyhow = "1.0" 21 | arboard = { version = "3.4", default-features = false, optional = true } 22 | cargo_metadata = "0.19" 23 | clap = { version = "4.5", features = ["derive", "cargo"] } 24 | clap-help = "1.5" 25 | clap_complete = { version = "4.5.44", features = ["unstable-dynamic"] } 26 | cli-log = "2.1" 27 | crokey = "1.3" 28 | ctrlc = "3.4" 29 | directories-next = "2.0.0" 30 | gix = { version = "0.73", default-features = false, features = [ "index", "excludes", "parallel" ] } 31 | glob = "0.3" 32 | iq = { version = "0.4", features = ["template"] } 33 | lazy-regex = "3.4.1" 34 | notify = "8.2.0" 35 | paste = "1.0" 36 | pretty_assertions = "1.4" 37 | rodio = { version = "0.21", optional = true, default-features = false, features = [ "mp3", "playback" ] } 38 | rustc-hash = "2" 39 | serde = { version = "1.0", features = ["derive"] } 40 | serde_json = "1.0" 41 | schemars = "1" 42 | termimad = "0.34" 43 | toml = "0.9" 44 | unicode-width = "0.2" 45 | vte = "0.15" 46 | 47 | [profile.release] 48 | debug = false 49 | lto = "fat" 50 | strip = "symbols" 51 | codegen-units = 1 52 | 53 | [patch.crates-io] 54 | # clap-help = { path = "../clap-help" } 55 | # termimad = { path = "../termimad" } 56 | # crokey = { path = "../crokey" } 57 | # coolor = { path = "../coolor" } 58 | # iq = { path = "../iq" } 59 | # lazy-regex = { path = "../lazy-regex" } 60 | -------------------------------------------------------------------------------- /src/burp/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utilities related to BURP 2 | //! See 3 | use crate::*; 4 | 5 | /// Make a BURP compliant location line 6 | pub fn location_line>(location: S) -> TLine { 7 | let mut line = TLine::default(); 8 | line.strings 9 | .push(TString::new("\u{1b}[1m\u{1b}[38;5;12m", " --> ")); 10 | line.strings.push(TString::new("", location)); 11 | line 12 | } 13 | 14 | /// Make a BURP compliant error line (title) 15 | pub fn error_line(error: &str) -> TLine { 16 | let mut line = TLine::default(); 17 | line.strings.push(TString::new(CSI_BOLD_RED, "error")); 18 | line.strings.push(TString::new("", ": ")); 19 | line.strings.push(TString::new("", error.to_string())); 20 | line 21 | } 22 | /// Make a BURP compliant error line (title) from a `[TString]` 23 | pub fn error_line_ts(error: &[TString]) -> TLine { 24 | let mut line = TLine::default(); 25 | line.strings.push(TString::new(CSI_BOLD_RED, "error")); 26 | line.strings.push(TString::new("", ": ")); 27 | line.strings.extend(error.iter().cloned()); 28 | line 29 | } 30 | /// Make a BURP compliant warning line (title) from a `[TString]` 31 | pub fn warning_line_ts(warning: &[TString]) -> TLine { 32 | let mut line = TLine::default(); 33 | line.strings.push(TString::new(CSI_BOLD_YELLOW, "warning")); 34 | line.strings.push(TString::new("", ": ")); 35 | line.strings.extend(warning.iter().cloned()); 36 | line 37 | } 38 | /// Make a BURP compliant test failure line (title) 39 | /// (this one isn't based on cargo) 40 | pub fn failure_line(error: &str) -> TLine { 41 | let mut line = TLine::default(); 42 | line.strings.push(TString::new(CSI_BOLD_YELLOW, "failure")); 43 | line.strings.push(TString::new("", ": ")); 44 | line.strings.push(TString::new("", error.to_string())); 45 | line 46 | } 47 | -------------------------------------------------------------------------------- /src/tty/mod.rs: -------------------------------------------------------------------------------- 1 | mod tline; 2 | mod tline_builder; 3 | mod trange; 4 | mod tstring; 5 | 6 | pub const CSI_RESET: &str = "\u{1b}[0m"; 7 | pub const CSI_BOLD: &str = "\u{1b}[1m"; 8 | pub const CSI_ITALIC: &str = "\u{1b}[3m"; 9 | 10 | pub const CSI_GREEN: &str = "\u{1b}[32m"; 11 | 12 | pub const CSI_RED: &str = "\u{1b}[31m"; 13 | pub const CSI_BOLD_RED: &str = "\u{1b}[1m\u{1b}[38;5;9m"; 14 | pub const CSI_BOLD_4BIT_RED: &str = "\u{1b}[1m\u{1b}[91m"; 15 | pub const CSI_BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m"; 16 | pub const CSI_BOLD_GREEN: &str = "\u{1b}[1m\u{1b}[38;5;34m"; 17 | 18 | /// Used for "Blocking" 19 | pub const CSI_BLUE: &str = "\u{1b}[1m\u{1b}[36m"; 20 | 21 | #[cfg(windows)] 22 | pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[38;5;11m"; 23 | #[cfg(not(windows))] 24 | pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; 25 | 26 | #[cfg(windows)] 27 | pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;14m"; 28 | #[cfg(not(windows))] 29 | pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;12m"; 30 | pub const CSI_BOLD_4BIT_BLUE: &str = "\u{1b}[1m\u{1b}[94m"; 31 | 32 | #[cfg(windows)] 33 | pub const CSI_BOLD_4BIT_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; 34 | 35 | #[cfg(windows)] 36 | pub const CSI_BOLD_WHITE: &str = "\u{1b}[1m\u{1b}[38;5;15m"; 37 | 38 | static TAB_REPLACEMENT: &str = " "; 39 | 40 | use { 41 | crate::W, 42 | anyhow::Result, 43 | std::io::Write, 44 | }; 45 | 46 | pub use { 47 | tline::*, 48 | tline_builder::*, 49 | trange::*, 50 | tstring::*, 51 | }; 52 | 53 | pub fn draw( 54 | w: &mut W, 55 | csi: &str, 56 | raw: &str, 57 | ) -> Result<()> { 58 | if csi.is_empty() { 59 | write!(w, "{raw}")?; 60 | } else { 61 | write!(w, "{csi}{raw}{CSI_RESET}")?; 62 | } 63 | Ok(()) 64 | } 65 | pub fn csi( 66 | fg: u8, 67 | bg: u8, 68 | ) -> String { 69 | format!("\u{1b}[1m\u{1b}[38;5;{fg}m\u{1b}[48;5;{bg}m") 70 | } 71 | -------------------------------------------------------------------------------- /src/result/report_maker.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::*, 4 | std::process::ExitStatus, 5 | }; 6 | 7 | /// Dedicated to a mission, the report maker receives the command 8 | /// output lines and builds a report at end, complete with starts. 9 | pub struct ReportMaker { 10 | ignored_lines_patterns: Option>, 11 | analyzer: Box, 12 | } 13 | 14 | impl ReportMaker { 15 | pub fn new(mission: &Mission) -> Self { 16 | let ignored_lines_patterns = mission.ignored_lines_patterns().cloned(); 17 | let analyzer_ref = mission.analyzer(); 18 | let analyzer = analyzer_ref.create_analyzer(); 19 | Self { 20 | ignored_lines_patterns, 21 | analyzer, 22 | } 23 | } 24 | 25 | pub fn start( 26 | &mut self, 27 | mission: &Mission, 28 | ) { 29 | self.analyzer.start(mission); 30 | } 31 | 32 | pub fn receive_line( 33 | &mut self, 34 | cmd_line: CommandOutputLine, 35 | command_output: &mut CommandOutput, 36 | ) { 37 | if let Some(patterns) = self.ignored_lines_patterns.as_ref() { 38 | let raw_line = cmd_line.content.to_raw(); // FIXME could be made more efficient 39 | if patterns.iter().any(|p| p.raw_line_is_match(&raw_line)) { 40 | debug!("ignoring line: {}", &raw_line); 41 | return; 42 | } 43 | } 44 | self.analyzer.receive_line(cmd_line, command_output); 45 | } 46 | 47 | pub fn build_report(&mut self) -> Result { 48 | self.analyzer.build_report() 49 | } 50 | 51 | pub fn build_result( 52 | &mut self, 53 | output: CommandOutput, 54 | exit_status: ExitStatus, 55 | ) -> Result { 56 | let report = self.analyzer.build_report()?; 57 | let result = CommandResult::build(output, exit_status, report)?; 58 | Ok(result) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/result/command_output.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | std::process::ExitStatus, 8 | }; 9 | 10 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Eq)] 11 | pub enum CommandStream { 12 | StdOut, 13 | StdErr, 14 | } 15 | 16 | /// a line coming either from stdout or from stderr, before TTY parsing 17 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 18 | pub struct RawCommandOutputLine { 19 | pub content: String, 20 | pub origin: CommandStream, 21 | } 22 | 23 | /// a line coming either from stdout or from stderr 24 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 25 | pub struct CommandOutputLine { 26 | pub content: TLine, 27 | pub origin: CommandStream, 28 | } 29 | 30 | /// some output lines 31 | #[derive(Debug, Clone, Default, Serialize, Deserialize)] 32 | pub struct CommandOutput { 33 | pub lines: Vec, 34 | } 35 | 36 | /// a piece of information about the execution of a command 37 | #[derive(Debug)] 38 | pub enum CommandExecInfo { 39 | /// Command ended 40 | End { status: ExitStatus }, 41 | 42 | /// Bacon killed the command 43 | Interruption, 44 | 45 | /// Execution failed 46 | Error(anyhow::Error), 47 | 48 | /// Here's a line of output (coming from stderr or stdout) 49 | Line(RawCommandOutputLine), 50 | } 51 | 52 | impl CommandOutput { 53 | pub fn reverse(&mut self) { 54 | self.lines.reverse(); 55 | } 56 | pub fn push>( 57 | &mut self, 58 | line: L, 59 | ) { 60 | self.lines.push(line.into()); 61 | } 62 | pub fn len(&self) -> usize { 63 | self.lines.len() 64 | } 65 | } 66 | 67 | impl From for CommandOutputLine { 68 | fn from(raw: RawCommandOutputLine) -> Self { 69 | CommandOutputLine { 70 | content: TLine::from_tty(&raw.content), 71 | origin: raw.origin, 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/search/line_pattern.rs: -------------------------------------------------------------------------------- 1 | use { 2 | lazy_regex::regex::Regex, 3 | schemars::{ 4 | JsonSchema, 5 | Schema, 6 | SchemaGenerator, 7 | json_schema, 8 | }, 9 | serde::{ 10 | Deserialize, 11 | Deserializer, 12 | de, 13 | }, 14 | std::{ 15 | borrow::Cow, 16 | str::FromStr, 17 | }, 18 | }; 19 | 20 | /// A pattern dedicated to line matching. 21 | /// 22 | /// In the future, this may become more complex (eg filtering by style or origin) 23 | #[derive(Debug, Clone)] 24 | pub struct LinePattern { 25 | pub regex: Regex, 26 | } 27 | 28 | impl LinePattern { 29 | pub fn raw_line_is_match( 30 | &self, 31 | line: &str, 32 | ) -> bool { 33 | self.regex.is_match(line) 34 | } 35 | } 36 | 37 | impl FromStr for LinePattern { 38 | type Err = String; 39 | fn from_str(s: &str) -> Result { 40 | let regex = Regex::new(s).map_err(|e| format!("invalid regex: {e}"))?; 41 | Ok(Self { regex }) 42 | } 43 | } 44 | 45 | impl<'de> Deserialize<'de> for LinePattern { 46 | fn deserialize(deserializer: D) -> Result 47 | where 48 | D: Deserializer<'de>, 49 | { 50 | let s = String::deserialize(deserializer)?; 51 | FromStr::from_str(&s).map_err(de::Error::custom) 52 | } 53 | } 54 | 55 | impl PartialEq for LinePattern { 56 | fn eq( 57 | &self, 58 | other: &Self, 59 | ) -> bool { 60 | self.regex.as_str() == other.regex.as_str() 61 | } 62 | } 63 | impl JsonSchema for LinePattern { 64 | fn schema_name() -> Cow<'static, str> { 65 | "LinePattern".into() 66 | } 67 | fn schema_id() -> Cow<'static, str> { 68 | concat!(module_path!(), "::LinePattern").into() 69 | } 70 | fn json_schema(_gen: &mut SchemaGenerator) -> Schema { 71 | json_schema!({ 72 | "type": "string", 73 | }) 74 | } 75 | fn inline_schema() -> bool { 76 | true 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/analysis/item_accumulator.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | /// Receives lines and accumulates them into items, used 4 | /// at end to build a sorted list of lines. 5 | /// 6 | /// This is a small optional utility for report makers 7 | #[derive(Default)] 8 | pub struct ItemAccumulator { 9 | curr_kind: Option, 10 | errors: Vec, 11 | test_fails: Vec, 12 | warnings: Vec, 13 | } 14 | 15 | impl ItemAccumulator { 16 | pub fn start_item( 17 | &mut self, 18 | kind: Kind, 19 | ) { 20 | self.curr_kind = Some(kind); 21 | } 22 | pub fn close_item(&mut self) { 23 | self.curr_kind = None; 24 | } 25 | pub fn push_line( 26 | &mut self, 27 | line_type: LineType, 28 | content: TLine, 29 | ) { 30 | let line = Line { 31 | item_idx: 0, // will be filled later 32 | line_type, 33 | content, 34 | }; 35 | match self.curr_kind { 36 | Some(Kind::Warning) => self.warnings.push(line), 37 | Some(Kind::Error) => self.errors.push(line), 38 | Some(Kind::TestFail) => self.test_fails.push(line), 39 | _ => {} // before warnings and errors, or in a sum, or test output 40 | } 41 | } 42 | pub fn push_error_title( 43 | &mut self, 44 | content: TLine, 45 | ) { 46 | self.curr_kind = Some(Kind::Error); 47 | self.push_line(LineType::Title(Kind::Error), content); 48 | } 49 | pub fn push_failure_title( 50 | &mut self, 51 | content: TLine, 52 | ) { 53 | self.curr_kind = Some(Kind::TestFail); 54 | self.push_line(LineType::Title(Kind::TestFail), content); 55 | } 56 | pub fn lines(mut self) -> Vec { 57 | let mut lines = self.errors; 58 | lines.append(&mut self.test_fails); 59 | lines.append(&mut self.warnings); 60 | let mut item_idx = 0; 61 | for line in &mut lines { 62 | if matches!(line.line_type, LineType::Title(_)) { 63 | item_idx += 1; 64 | } 65 | line.item_idx = item_idx; 66 | } 67 | lines 68 | } 69 | pub fn report(self) -> Report { 70 | let lines = self.lines(); 71 | Report::new(lines) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /website/src/community/FAQ.md: -------------------------------------------------------------------------------- 1 | 2 | # What does it exactly do ? 3 | 4 | Bacon watches the content of your source directories and launches `cargo check` or other commands on changes. 5 | 6 | Watching and computations are done on background threads to prevent any blocking. 7 | 8 | The screen isn't cleaned until the compilation is finished to prevent useful information from being replaced by the lines of an unfinished computation. 9 | 10 | Errors and test failures are displayed before warnings because you usually want to fix them first. 11 | 12 | Rendering is adapted to the dimensions of the terminal to ensure you get a proper usable report. And bacon manages rewrapping on resize. 13 | 14 | # Parallel bacon ? 15 | 16 | It's perfectly OK to have several bacon running in parallel, and can be useful to check several compilation targets. 17 | 18 | Similarly you don't have to stop bacon when you want to use cargo to build the application, or when you're just working on something else. You may have a dozen bacon running without problem. 19 | 20 | Bacon is efficient and doesn't work when there's no notification. 21 | 22 | # Supported platforms 23 | 24 | It works on all decent terminals on Linux, Max OSX and Windows. 25 | 26 | # Vim & Neovim support 27 | 28 | (Neo)Vim is perfectly supported but you may have had a problem, depending on your installation, with bacon sometimes not recomputing on file changes. 29 | 30 | The default write strategy of vim makes successive savings of the same file not always detectable by inotify. 31 | 32 | A solution is to add this to your init.vim file: 33 | 34 | set nowritebackup 35 | 36 | This doesn't prevent vim from keeping copies during editions, it just changes the behavior of the write operation and has no practical downside. 37 | 38 | # Licences 39 | 40 | Bacon is licenced under [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.en.html). 41 | You're free to use it to compile the Rust projects of your choice, even commercial. 42 | 43 | The logo is licensed under a [Creative Commons Attribution-ShareAlike 4.0 International License](https://creativecommons.org/licenses/by-sa/4.0). 44 | 45 | # Why "bacon" ? 46 | 47 | * It's a **bac**kground **con**piler. 48 | * It comes from France and, as you know, France is bacon. 49 | 50 | It's just a name. You don't have to eat meat to use the software. 51 | -------------------------------------------------------------------------------- /src/analysis/analyzer.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::{ 3 | biome, 4 | cargo_json, 5 | cpp, 6 | eslint, 7 | nextest, 8 | python, 9 | standard, 10 | swift, 11 | }, 12 | crate::*, 13 | schemars::JsonSchema, 14 | serde::{ 15 | Deserialize, 16 | Serialize, 17 | }, 18 | }; 19 | 20 | /// A stateless operator building a report from a list of command output lines. 21 | /// 22 | /// Implementation routing will probably change at some point 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)] 24 | #[serde(rename_all = "snake_case")] 25 | pub enum AnalyzerRef { 26 | #[default] 27 | Standard, 28 | CargoJson, 29 | Nextest, 30 | Eslint, 31 | Biome, 32 | PythonPytest, 33 | PythonRuff, 34 | PythonUnittest, 35 | Cpp, 36 | CppDoctest, 37 | SwiftBuild, 38 | SwiftLint, 39 | } 40 | 41 | impl AnalyzerRef { 42 | pub fn create_analyzer(self) -> Box { 43 | match self { 44 | Self::Standard => Box::new(standard::StandardAnalyzer::default()), 45 | Self::Nextest => Box::new(nextest::NextestAnalyzer::default()), 46 | Self::Eslint => Box::new(eslint::EslintAnalyzer::default()), 47 | Self::Biome => Box::new(biome::BiomeAnalyzer::default()), 48 | Self::PythonUnittest => Box::new(python::unittest::UnittestAnalyzer::default()), 49 | Self::PythonPytest => Box::new(python::pytest::PytestAnalyzer::default()), 50 | Self::PythonRuff => Box::new(python::ruff::RuffAnalyzer::default()), 51 | Self::CargoJson => Box::new(cargo_json::CargoJsonAnalyzer::default()), 52 | Self::Cpp => Box::new(cpp::CppAnalyzer::default()), 53 | Self::CppDoctest => Box::new(cpp::CppDoctestAnalyzer::default()), 54 | Self::SwiftBuild => Box::new(swift::build::SwiftBuildAnalyzer::default()), 55 | Self::SwiftLint => Box::new(swift::lint::SwiftLintAnalyzer::default()), 56 | } 57 | } 58 | } 59 | 60 | pub trait Analyzer { 61 | fn start( 62 | &mut self, 63 | mission: &Mission, 64 | ); 65 | 66 | fn receive_line( 67 | &mut self, 68 | line: CommandOutputLine, 69 | command_output: &mut CommandOutput, 70 | ); 71 | 72 | fn build_report(&mut self) -> anyhow::Result; 73 | } 74 | -------------------------------------------------------------------------------- /src/exec/period.rs: -------------------------------------------------------------------------------- 1 | use { 2 | anyhow::anyhow, 3 | lazy_regex::*, 4 | schemars::{ 5 | JsonSchema, 6 | Schema, 7 | SchemaGenerator, 8 | json_schema, 9 | }, 10 | serde::{ 11 | Deserialize, 12 | Deserializer, 13 | de, 14 | }, 15 | std::{ 16 | borrow::Cow, 17 | str::FromStr, 18 | time::Duration, 19 | }, 20 | }; 21 | 22 | /// A small wrapper over `time::Duration`, to allow reading from a string in 23 | /// config. There's no symmetric serialization and the input format is 24 | /// quite crude (eg "25ms" or "254ns" or "none") 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 26 | pub struct Period { 27 | pub duration: Duration, 28 | } 29 | 30 | impl Period { 31 | pub const fn is_zero(&self) -> bool { 32 | self.duration.is_zero() 33 | } 34 | pub fn sleep(&self) { 35 | std::thread::sleep(self.duration); 36 | } 37 | } 38 | 39 | impl From for Period { 40 | fn from(duration: Duration) -> Self { 41 | Self { duration } 42 | } 43 | } 44 | 45 | impl FromStr for Period { 46 | type Err = anyhow::Error; 47 | fn from_str(s: &str) -> Result { 48 | let duration = regex_switch!(s, 49 | r"^(?\d+)\s*ns$" => Duration::from_nanos(n.parse()?), 50 | r"^(?\d+)\s*ms$" => Duration::from_millis(n.parse()?), 51 | r"^(?\d+)\s*s$" => Duration::from_secs(n.parse()?), 52 | r"^[^1-9]*$" => Duration::new(0, 0), // eg "none", "0", "off" 53 | ) 54 | .ok_or_else(|| anyhow!("Invalid period: {s}"))?; 55 | Ok(Self { duration }) 56 | } 57 | } 58 | 59 | impl<'de> Deserialize<'de> for Period { 60 | fn deserialize(deserializer: D) -> Result 61 | where 62 | D: Deserializer<'de>, 63 | { 64 | let s = String::deserialize(deserializer)?; 65 | FromStr::from_str(&s).map_err(de::Error::custom) 66 | } 67 | } 68 | impl JsonSchema for Period { 69 | fn schema_name() -> Cow<'static, str> { 70 | "Period".into() 71 | } 72 | fn schema_id() -> Cow<'static, str> { 73 | concat!(module_path!(), "::Period").into() 74 | } 75 | fn json_schema(_gen: &mut SchemaGenerator) -> Schema { 76 | json_schema!({ 77 | "type": "string", 78 | "description": "Duration expressed as a human-readable string such as \"15ms\" or \"2s\".", 79 | }) 80 | } 81 | fn inline_schema() -> bool { 82 | true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/tui/wrap.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | unicode_width::UnicodeWidthChar, 4 | }; 5 | 6 | /// Wrap lines into sublines containing positions in the original lines 7 | pub fn wrap( 8 | lines: &[Line], 9 | width: u16, 10 | ) -> Vec { 11 | let cols = width as usize - 1; // -1 for the probable scrollbar 12 | let mut sub_lines = Vec::new(); 13 | for line in lines.iter() { 14 | let summary = line.line_type.is_summary(); 15 | sub_lines.push(Line { 16 | item_idx: line.item_idx, 17 | content: TLine::default(), 18 | line_type: line.line_type, 19 | }); 20 | let mut sub_cols = line.line_type.cols(); 21 | let mut wrap_idx = 0; // 1 for first continuation, etc. 22 | for string in &line.content.strings { 23 | sub_lines 24 | .last_mut() 25 | .unwrap() 26 | .content 27 | .strings 28 | .push(string.clone()); // might be truncated later 29 | let mut byte_offset = 0; 30 | for (byte_idx, c) in string.raw.char_indices() { 31 | let char_cols = c.width().unwrap_or(0); 32 | if sub_cols + char_cols > cols && sub_cols > 0 { 33 | let last_string = sub_lines 34 | .last_mut() 35 | .unwrap() 36 | .content 37 | .strings 38 | .last_mut() 39 | .unwrap(); 40 | let after_cut = TString::new( 41 | last_string.csi.clone(), 42 | last_string.raw[byte_idx - byte_offset..].to_string(), 43 | ); 44 | last_string.raw.truncate(byte_idx - byte_offset); 45 | byte_offset = byte_idx; 46 | sub_lines.push(Line { 47 | item_idx: line.item_idx, 48 | content: TLine { 49 | strings: vec![after_cut], 50 | }, 51 | line_type: LineType::Continuation { 52 | offset: wrap_idx, 53 | summary, 54 | }, 55 | }); 56 | wrap_idx += 1; 57 | sub_cols = char_cols; 58 | } else { 59 | sub_cols += char_cols; 60 | } 61 | } 62 | } 63 | } 64 | sub_lines 65 | } 66 | -------------------------------------------------------------------------------- /website/src/community/bacon-dev.md: -------------------------------------------------------------------------------- 1 | 2 | **Bacon** is developed by **Denys Séguret**, also known as [Canop](https://github.com/Canop) or [dystroy](https://dystroy.org). 3 | 4 | Major updates are announced on Mastodon : [@dystroy@mastodon.dystroy.org](https://mastodon.dystroy.org/@dystroy) and BlueSky: [@dystroy.bsky.social](https://bsky.app/profile/dystroy.bsky.social) 5 | 6 | The logo has been designed by [Peter Varo](https://petervaro.com). 7 | 8 | # Sponsorship 9 | 10 | **Bacon** is free for all uses. 11 | 12 | If it helps your company make money, consider helping me find time to add features and to develop new free open-source software. 13 | 14 |
15 | 19 | 20 |
21 | 22 | I'm also available for consulting or custom development. Head to [https://dystroy.org](https://dystroy.org) for references. 23 | 24 | # Chat 25 | 26 | The best place to chat about bacon, to talk about features or bugs, is the Miaou chat. 27 | 28 | [Bacon room on Miaou](https://miaou.dystroy.org/4683?bacon) 29 | 30 | # Issues 31 | 32 | We use [GitHub's issue manager](https://github.com/Canop/bacon/issues). 33 | 34 | Before posting a new issue, check your problem hasn't already been raised and in case of doubt **please come first discuss it on the chat**. 35 | 36 | Looking into an issue may require bacon to be launched with log enabled (eg `BACON_LOG=DEBUG bacon`) which produces a `bacon.log` file. 37 | 38 | If bacon didn't understand correctly the output of a cargo tool, it may also be useful to have a look at the analysis export, which you normally find in a `bacon-analysis.json` file on hitting `ctrl-e`. 39 | 40 | # Contribute 41 | 42 | If you think you might help, as a tester or coder, you're welcome, but please read [Contributing to my FOSS projects](https://dystroy.org/blog/contributing/) before starting a PR. 43 | 44 | 45 | **Don't open a PR without discussing the design before**, either in the chat or in an issue, unless you're just fixing a typo. Coding is the easy part. Determining the exact requirement and how we want it to be done is the hard part. This is especially important if you plan to add a dependency or to change the visible parts, eg the launch arguments. 46 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "naersk": { 22 | "inputs": { 23 | "nixpkgs": "nixpkgs" 24 | }, 25 | "locked": { 26 | "lastModified": 1736429655, 27 | "narHash": "sha256-BwMekRuVlSB9C0QgwKMICiJ5EVbLGjfe4qyueyNQyGI=", 28 | "owner": "nix-community", 29 | "repo": "naersk", 30 | "rev": "0621e47bd95542b8e1ce2ee2d65d6a1f887a13ce", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "naersk", 36 | "type": "github" 37 | } 38 | }, 39 | "nixpkgs": { 40 | "locked": { 41 | "lastModified": 1736320768, 42 | "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", 43 | "owner": "NixOS", 44 | "repo": "nixpkgs", 45 | "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", 46 | "type": "github" 47 | }, 48 | "original": { 49 | "id": "nixpkgs", 50 | "type": "indirect" 51 | } 52 | }, 53 | "nixpkgs_2": { 54 | "locked": { 55 | "lastModified": 1736320768, 56 | "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", 57 | "owner": "NixOS", 58 | "repo": "nixpkgs", 59 | "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", 60 | "type": "github" 61 | }, 62 | "original": { 63 | "owner": "NixOS", 64 | "ref": "nixpkgs-unstable", 65 | "repo": "nixpkgs", 66 | "type": "github" 67 | } 68 | }, 69 | "root": { 70 | "inputs": { 71 | "flake-utils": "flake-utils", 72 | "naersk": "naersk", 73 | "nixpkgs": "nixpkgs_2" 74 | } 75 | }, 76 | "systems": { 77 | "locked": { 78 | "lastModified": 1681028828, 79 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 80 | "owner": "nix-systems", 81 | "repo": "default", 82 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 83 | "type": "github" 84 | }, 85 | "original": { 86 | "owner": "nix-systems", 87 | "repo": "default", 88 | "type": "github" 89 | } 90 | } 91 | }, 92 | "root": "root", 93 | "version": 7 94 | } 95 | -------------------------------------------------------------------------------- /src/ignorer/git_ignorer.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::{ 4 | Context, 5 | Result, 6 | }, 7 | gix::{ 8 | self as git, 9 | Repository, 10 | }, 11 | std::path::Path, 12 | }; 13 | 14 | /// An object able to tell whether a file is excluded 15 | /// by gitignore rules 16 | pub struct GitIgnorer { 17 | repo: Repository, 18 | } 19 | 20 | impl GitIgnorer { 21 | /// Create an Ignorer from any directory path: the closest 22 | /// surrounding git repository will be found (if there's one) 23 | /// and its gitignore rules used. 24 | /// 25 | /// `root_path` is assumed to exist and be a directory 26 | pub(crate) fn new(root_path: &Path) -> Result { 27 | let repo = git::discover(root_path)?; 28 | Ok(Self { repo }) 29 | } 30 | } 31 | 32 | impl Ignorer for GitIgnorer { 33 | fn excludes( 34 | &mut self, 35 | paths: &Path, 36 | ) -> Result { 37 | self.excludes_all_paths(&[paths]) 38 | } 39 | } 40 | impl GitIgnorer { 41 | fn excludes_all_paths( 42 | &mut self, 43 | paths: &[&Path], 44 | ) -> Result { 45 | let worktree = self.repo.worktree().context("a worktree should exist")?; 46 | 47 | // The "Cache" is the structure allowing checking exclusion. 48 | // Building it is the most expensive operation, and we could store it 49 | // in the Ignorer instead of the repo (by having the repo in the mission), 50 | // but it's still about just 1ms and I'm not sure we know if it always 51 | // stays valid. 52 | let mut cache = time!(Debug, worktree.excludes(None)?); 53 | 54 | for path in paths { 55 | // cache.at_path panics if not provided a path relative 56 | // to the work directory, so we compute the relative path 57 | let Some(work_dir) = self.repo.workdir() else { 58 | return Ok(false); 59 | }; 60 | let Ok(relative_path) = path.strip_prefix(work_dir) else { 61 | return Ok(false); 62 | }; 63 | 64 | // cache.at_path panics if the relative path is empty, so 65 | // we must check that 66 | if relative_path.as_os_str().is_empty() { 67 | return Ok(true); 68 | } 69 | 70 | if path.is_dir() { 71 | // we're not interested in directories (we should not receive them anyway) 72 | return Ok(false); 73 | } 74 | 75 | let platform = cache.at_path(relative_path, Some(gix::index::entry::Mode::FILE))?; 76 | 77 | if !platform.is_excluded() { 78 | return Ok(false); 79 | } 80 | } 81 | Ok(true) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/socket/server.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::{ 4 | Context as _, 5 | Result, 6 | }, 7 | std::{ 8 | fs, 9 | io::{ 10 | BufRead, 11 | BufReader, 12 | }, 13 | os::unix::net::UnixListener, 14 | path::PathBuf, 15 | thread, 16 | }, 17 | termimad::crossbeam::channel::Sender, 18 | }; 19 | 20 | pub struct Server { 21 | path: PathBuf, 22 | } 23 | 24 | impl Server { 25 | pub fn new( 26 | context: &Context, 27 | tx: Sender, 28 | ) -> Result { 29 | let path = context.unix_socket_path(); 30 | if fs::metadata(&path).is_ok() { 31 | fs::remove_file(&path) 32 | .with_context(|| format!("Failed to remove socket file {}", &path.display()))?; 33 | } 34 | let listener = UnixListener::bind(&path)?; 35 | info!("listening on {}", path.display()); 36 | thread::spawn(move || { 37 | for stream in listener.incoming() { 38 | let Ok(stream) = stream else { 39 | warn!("error while accepting connection"); 40 | continue; 41 | }; 42 | let tx = tx.clone(); 43 | thread::spawn(move || { 44 | debug!("new connection"); 45 | let mut br = BufReader::new(&stream); 46 | let mut line = String::new(); 47 | while br.read_line(&mut line).is_ok() { 48 | while line.ends_with('\n') || line.ends_with('\r') { 49 | line.pop(); 50 | } 51 | debug!("line => {:?}", &line); 52 | if line.is_empty() { 53 | debug!("empty line, closing connection"); 54 | break; 55 | } 56 | match line.parse() { 57 | Ok(action) => { 58 | if tx.send(action).is_err() { 59 | error!("failed to send action"); 60 | } 61 | } 62 | Err(e) => { 63 | warn!("failed to parse action: {e}"); 64 | } 65 | } 66 | line.clear(); 67 | } 68 | debug!("closed connection"); 69 | }); 70 | } 71 | }); 72 | Ok(Self { path }) 73 | } 74 | } 75 | 76 | impl Drop for Server { 77 | fn drop(&mut self) { 78 | debug!("removing socket file"); 79 | let _ = fs::remove_file(&self.path); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/tty/tline_builder.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// A builder consuming a string assumed to contain TTY sequences and building a `TLine`. 4 | #[derive(Debug, Default)] 5 | pub struct TLineBuilder { 6 | cur: Option, 7 | strings: Vec, 8 | } 9 | impl TLineBuilder { 10 | pub fn read( 11 | &mut self, 12 | s: &str, 13 | ) { 14 | let mut parser = vte::Parser::new(); 15 | parser.advance(self, s.as_bytes()); 16 | } 17 | pub fn build(mut self) -> TLine { 18 | self.take_tstring(); 19 | TLine { 20 | strings: self.strings, 21 | } 22 | } 23 | fn take_tstring(&mut self) { 24 | if let Some(cur) = self.cur.take() { 25 | self.push_tstring(cur); 26 | } 27 | } 28 | fn push_tstring( 29 | &mut self, 30 | tstring: TString, 31 | ) { 32 | if let Some(last) = self.strings.last_mut() { 33 | if last.csi == tstring.csi { 34 | last.raw.push_str(&tstring.raw); 35 | return; 36 | } 37 | } 38 | self.strings.push(tstring); 39 | } 40 | } 41 | impl vte::Perform for TLineBuilder { 42 | fn print( 43 | &mut self, 44 | c: char, 45 | ) { 46 | self.cur.get_or_insert_with(TString::default).raw.push(c); 47 | } 48 | fn csi_dispatch( 49 | &mut self, 50 | params: &vte::Params, 51 | _intermediates: &[u8], 52 | _ignore: bool, 53 | action: char, 54 | ) { 55 | if params.len() == 1 && params.iter().next() == Some(&[0]) { 56 | self.take_tstring(); 57 | return; 58 | } 59 | if let Some(cur) = self.cur.as_mut() { 60 | if cur.raw.is_empty() { 61 | cur.push_csi(params, action); 62 | return; 63 | } 64 | } 65 | self.take_tstring(); 66 | let mut cur = TString::default(); 67 | cur.push_csi(params, action); 68 | self.cur = Some(cur); 69 | } 70 | fn execute( 71 | &mut self, 72 | _byte: u8, 73 | ) { 74 | } 75 | fn hook( 76 | &mut self, 77 | _params: &vte::Params, 78 | _intermediates: &[u8], 79 | _ignore: bool, 80 | _action: char, 81 | ) { 82 | } 83 | fn put( 84 | &mut self, 85 | _byte: u8, 86 | ) { 87 | } 88 | fn unhook(&mut self) {} 89 | fn osc_dispatch( 90 | &mut self, 91 | _params: &[&[u8]], 92 | _bell_terminated: bool, 93 | ) { 94 | } 95 | fn esc_dispatch( 96 | &mut self, 97 | _intermediates: &[u8], 98 | _ignore: bool, 99 | _byte: u8, 100 | ) { 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/analysis/eslint/mod.rs: -------------------------------------------------------------------------------- 1 | //! An analyzer for eslint ( ) 2 | mod eslint_line_analyzer; 3 | 4 | use { 5 | super::*, 6 | crate::*, 7 | anyhow::Result, 8 | eslint_line_analyzer::*, 9 | }; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct EslintAnalyzer { 13 | lines: Vec, 14 | } 15 | 16 | impl Analyzer for EslintAnalyzer { 17 | fn start( 18 | &mut self, 19 | _mission: &Mission, 20 | ) { 21 | self.lines.clear(); 22 | } 23 | 24 | fn receive_line( 25 | &mut self, 26 | line: CommandOutputLine, 27 | command_output: &mut CommandOutput, 28 | ) { 29 | self.lines.push(line.clone()); 30 | command_output.push(line); 31 | } 32 | 33 | fn build_report(&mut self) -> Result { 34 | build_report(&self.lines) 35 | } 36 | } 37 | 38 | /// Build a report from the output of eslint 39 | /// 40 | /// The main specificity of eslint is that the path of a file with error is given 41 | /// before the errors of the file, each error coming with the line and column 42 | /// in the file. 43 | pub fn build_report(cmd_lines: &[CommandOutputLine]) -> anyhow::Result { 44 | let mut line_analyzer = EslintLineAnalyzer {}; 45 | let mut items = ItemAccumulator::default(); 46 | let mut last_location_path = None; 47 | for cmd_line in cmd_lines { 48 | let line_analysis = line_analyzer.analyze_line(cmd_line); 49 | let line_type = line_analysis.line_type; 50 | match line_type { 51 | LineType::Garbage => { 52 | continue; 53 | } 54 | LineType::Title(kind) => { 55 | items.start_item(kind); 56 | } 57 | LineType::Normal => {} 58 | LineType::Location => { 59 | let path = get_location_path(&cmd_line.content); 60 | if let Some(path) = path { 61 | last_location_path = Some(path); 62 | continue; 63 | } 64 | warn!("inconsistent line parsing"); 65 | } 66 | _ => {} 67 | } 68 | items.push_line(line_type, cleaned_tline(&cmd_line.content)); 69 | if matches!(line_type, LineType::Title(_)) { 70 | // We just added the title, we must now add the location 71 | // As it's something which isn't present in eslint output, we're free 72 | // to choose the format we want so we're choosing the BURP one 73 | let line_col: Option<&str> = cmd_line.content.strings.get(1).map(|s| s.raw.as_ref()); 74 | let Some(line_col) = line_col else { 75 | warn!("inconsistent line parsing"); 76 | continue; 77 | }; 78 | let Some(location_path) = last_location_path.as_ref() else { 79 | warn!("no location given before error"); 80 | continue; 81 | }; 82 | items.push_line( 83 | LineType::Location, 84 | burp::location_line(format!("{location_path}:{line_col}")), 85 | ); 86 | } 87 | } 88 | Ok(items.report()) 89 | } 90 | -------------------------------------------------------------------------------- /src/exec/command_builder.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | ffi::{ 4 | OsStr, 5 | OsString, 6 | }, 7 | path::{ 8 | Path, 9 | PathBuf, 10 | }, 11 | process::{ 12 | Command, 13 | Stdio, 14 | }, 15 | }; 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct CommandBuilder { 19 | exe: String, 20 | current_dir: Option, 21 | args: Vec, 22 | with_stdout: bool, 23 | envs: HashMap, 24 | } 25 | 26 | impl CommandBuilder { 27 | pub fn new(exe: &str) -> Self { 28 | Self { 29 | exe: exe.to_string(), 30 | current_dir: None, 31 | args: Vec::new(), 32 | with_stdout: false, 33 | envs: Default::default(), 34 | } 35 | } 36 | pub fn build(&self) -> Command { 37 | let mut command = Command::new(&self.exe); 38 | if let Some(dir) = &self.current_dir { 39 | command.current_dir(dir); 40 | } 41 | command.args(&self.args); 42 | command.envs(&self.envs); 43 | command 44 | .envs(&self.envs) 45 | .stdin(Stdio::null()) 46 | .stderr(Stdio::piped()) 47 | .stdout(if self.with_stdout { 48 | Stdio::piped() 49 | } else { 50 | Stdio::null() 51 | }); 52 | command 53 | } 54 | pub fn with_stdout( 55 | &mut self, 56 | b: bool, 57 | ) -> &mut Self { 58 | self.with_stdout = b; 59 | self 60 | } 61 | pub fn is_with_stdout(&self) -> bool { 62 | self.with_stdout 63 | } 64 | pub fn current_dir>( 65 | &mut self, 66 | dir: P, 67 | ) -> &mut Self { 68 | self.current_dir = Some(dir.as_ref().to_path_buf()); 69 | self 70 | } 71 | pub fn arg>( 72 | &mut self, 73 | arg: S, 74 | ) -> &mut Self { 75 | self.args.push(arg.as_ref().to_os_string()); 76 | self 77 | } 78 | pub fn args( 79 | &mut self, 80 | args: I, 81 | ) -> &mut Self 82 | where 83 | I: IntoIterator, 84 | S: AsRef, 85 | { 86 | for arg in args { 87 | self.args.push(arg.as_ref().to_os_string()); 88 | } 89 | self 90 | } 91 | pub fn env( 92 | &mut self, 93 | key: K, 94 | val: V, 95 | ) -> &mut Self 96 | where 97 | K: AsRef, 98 | V: AsRef, 99 | { 100 | self.envs 101 | .insert(key.as_ref().to_os_string(), val.as_ref().to_os_string()); 102 | self 103 | } 104 | pub fn envs( 105 | &mut self, 106 | vars: I, 107 | ) -> &mut Self 108 | where 109 | I: IntoIterator, 110 | K: AsRef, 111 | V: AsRef, 112 | { 113 | for (k, v) in vars { 114 | self.envs 115 | .insert(k.as_ref().to_os_string(), v.as_ref().to_os_string()); 116 | } 117 | self 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /website/src/index.md: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | **bacon** is a background code checker. 7 | 8 | It's designed for minimal interaction so that you can just let it run, alongside your editor, and be notified of warnings, errors, or test failures in your code. 9 | 10 | It conveys the information you need even in a small terminal so that you can keep more screen estate for your other tasks. 11 | 12 | It shows you errors before warnings, and the first errors before the last ones, so you don't have to scroll up to find what's relevant. 13 | 14 | ![vi-and-bacon](img/vi-and-bacon.png) 15 | 16 | You don't have to remember commands: the essential ones are listed at the bottom and the few other ones are shown when you hit the h key. 17 | 18 | # Installation 19 | 20 | Run 21 | 22 | ```bash 23 | cargo install --locked bacon 24 | ``` 25 | 26 | Run this command too if you want to update bacon. Configuration has always been backward compatible so you won't lose anything. 27 | 28 | Some features are disabled by default. You may enable them with 29 | 30 | cargo install --locked bacon --features "clipboard sound" 31 | 32 | # Usage 33 | 34 | Launch bacon in a terminal you'll keep visible 35 | 36 | ```bash 37 | bacon 38 | ``` 39 | 40 | This launches the default job, usually based on `cargo check`: 41 | Bacon will watch the source directories and show you the errors and warnings found by the cargo command. 42 | 43 | You may decide to launch and watch tests by either hitting the t key, or by launching bacon with 44 | 45 | ```bash 46 | bacon test 47 | ``` 48 | 49 | or `bacon nextest` if you're a nextest user. 50 | 51 | ![test](img/test.png) 52 | 53 | When there's a failure, hit f to restrict the job to the failing test. 54 | Hit esc to get back to all tests. 55 | 56 | While in bacon, you can see Clippy warnings by hitting the c key. And you get back to your previous job with esc 57 | 58 | You may also open the `cargo doc` in your browser with the d key. 59 | 60 | You can configure and launch the jobs of your choice: tests, specific target compilations, examples, etc. and look at the results while you code. 61 | 62 | Hit ctrlj to see all jobs. 63 | 64 | Run `bacon --help` to see all launch arguments, and read the [cookbook](cookbook.md). 65 | 66 | # Configuration 67 | 68 | See [config](config.md) for details, but here's the crust: 69 | 70 | ## Global Preferences 71 | 72 | The `prefs.toml` file lets you define key bindings, or always start in summary mode or with lines wrapped. 73 | 74 | To create a default preferences file, use `bacon --prefs`. 75 | 76 | Shortcut: 77 | 78 | $EDITOR "$(bacon --prefs)" 79 | 80 | ## Project Settings 81 | 82 | You'll define in the `bacon.toml` file the jobs you need, perhaps an example to check, a run with special parameters, or the settings of clippy, as well as shortcuts to run those jobs. 83 | 84 | Create a `bacon.toml` file by running 85 | 86 | bacon --init 87 | 88 | This file already contains some standard jobs. Add your own, for example 89 | 90 | ```toml 91 | [jobs.check-win] 92 | command = ["cargo", "check", "--target", "x86_64-pc-windows-gnu"] 93 | ``` 94 | -------------------------------------------------------------------------------- /src/jobs/job_stack.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::{ 4 | Result, 5 | anyhow, 6 | }, 7 | }; 8 | 9 | /// The stack of jobs that bacon ran, allowing to get back to the previous one, 10 | /// or to scope the current one 11 | #[derive(Default)] 12 | pub struct JobStack { 13 | entries: Vec, 14 | } 15 | 16 | impl JobStack { 17 | /// Apply the job ref instruction to determine the job to run, updating the stack. 18 | /// 19 | /// When no job is returned, the application is supposed to quit. 20 | pub fn pick_job( 21 | &mut self, 22 | job_ref: &JobRef, 23 | settings: &Settings, 24 | ) -> Result> { 25 | debug!("picking job {job_ref:?}"); 26 | let concrete = match job_ref { 27 | JobRef::Default => settings.default_job.clone(), 28 | JobRef::Initial => settings 29 | .arg_job 30 | .as_ref() 31 | .unwrap_or(&settings.default_job) 32 | .clone(), 33 | JobRef::Previous | JobRef::PreviousOrQuit => { 34 | let current = self.entries.pop(); 35 | match self.entries.pop() { 36 | Some(concrete) => concrete, 37 | None if current 38 | .as_ref() 39 | .is_some_and(|current| current.scope.has_tests()) => 40 | { 41 | // rather than quitting, we assume the user wants to "unscope" 42 | ConcreteJobRef { 43 | name_or_alias: current.unwrap().name_or_alias, 44 | scope: Scope::default(), 45 | } 46 | } 47 | None if *job_ref == JobRef::PreviousOrQuit => { 48 | return Ok(None); 49 | } 50 | None => { 51 | let Some(current) = current else { 52 | error!("no current job"); // job stack was misused 53 | return Ok(None); 54 | }; 55 | current 56 | } 57 | } 58 | } 59 | JobRef::Concrete(concrete) => concrete.clone(), 60 | JobRef::Scope(scope) => match self.entries.last() { 61 | Some(concrete) => ConcreteJobRef { 62 | name_or_alias: concrete.name_or_alias.clone(), 63 | scope: scope.clone(), 64 | }, 65 | None => { 66 | return Ok(None); 67 | } 68 | }, 69 | }; 70 | let job = match &concrete.name_or_alias { 71 | NameOrAlias::Alias(alias) => Job::from_alias(alias, settings), 72 | NameOrAlias::Name(name) => settings 73 | .jobs 74 | .get(name) 75 | .ok_or_else(|| anyhow!("job not found: {name:?}"))? 76 | .clone(), 77 | }; 78 | if self.entries.last() != Some(&concrete) { 79 | self.entries.push(concrete.clone()); 80 | } 81 | Ok(Some((concrete, job))) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/analysis/python/unittest.rs: -------------------------------------------------------------------------------- 1 | //! An analyzer for Python unittest 2 | use { 3 | crate::*, 4 | anyhow::Result, 5 | lazy_regex::*, 6 | }; 7 | 8 | #[derive(Debug, Default)] 9 | pub struct UnittestAnalyzer { 10 | lines: Vec, 11 | } 12 | 13 | impl Analyzer for UnittestAnalyzer { 14 | fn start( 15 | &mut self, 16 | _mission: &Mission, 17 | ) { 18 | self.lines.clear(); 19 | } 20 | 21 | fn receive_line( 22 | &mut self, 23 | line: CommandOutputLine, 24 | command_output: &mut CommandOutput, 25 | ) { 26 | self.lines.push(line.clone()); 27 | command_output.push(line); 28 | } 29 | 30 | fn build_report(&mut self) -> Result { 31 | build_report(&self.lines) 32 | } 33 | } 34 | 35 | pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis { 36 | // we're not expecting styled output for unittest (it's probable 37 | // some users use decorators, but I don't know those today) 38 | let Some(content) = cmd_line.content.if_unstyled() else { 39 | return LineAnalysis::normal(); 40 | }; 41 | regex_switch!(content, 42 | r"^FAIL:\s+\S+\s+\((?.+)\)" => LineAnalysis::fail(key), 43 | r#"^\s+File ".+", line \d+"# => LineAnalysis::of_type(LineType::Location), 44 | "^={50,}$" => LineAnalysis::garbage(), 45 | "^-{50,}$" => LineAnalysis::garbage(), 46 | r"^Traceback \(most recent call last\)" => LineAnalysis::garbage(), 47 | ) 48 | .unwrap_or_else(LineAnalysis::normal) 49 | } 50 | 51 | /// Build a report from the output of Python unittest 52 | /// 53 | /// The main special thing here is transforming the location line in 54 | /// a BURP location line. 55 | pub fn build_report(cmd_lines: &[CommandOutputLine]) -> anyhow::Result { 56 | let mut items = ItemAccumulator::default(); 57 | let mut item_location_written = false; 58 | for cmd_line in cmd_lines { 59 | let line_analysis = analyze_line(cmd_line); 60 | let line_type = line_analysis.line_type; 61 | match line_type { 62 | LineType::Garbage => { 63 | continue; 64 | } 65 | LineType::Title(kind) => { 66 | items.start_item(kind); 67 | item_location_written = false; 68 | } 69 | LineType::Normal => {} 70 | LineType::Location => { 71 | if !item_location_written { 72 | if let Some(content) = cmd_line.content.if_unstyled() { 73 | // we rewrite the location as a BURP location 74 | if let Some((_, path, line)) = 75 | regex_captures!(r#"\s+File "(.+)", line (\d+)"#, content,) 76 | { 77 | items.push_line( 78 | LineType::Location, 79 | burp::location_line(format!("{path}:{line}")), 80 | ); 81 | item_location_written = true; 82 | } else { 83 | warn!("inconsistent line parsing"); 84 | } 85 | continue; 86 | } 87 | } 88 | } 89 | _ => {} 90 | } 91 | items.push_line(line_type, cmd_line.content.clone()); 92 | } 93 | Ok(items.report()) 94 | } 95 | -------------------------------------------------------------------------------- /src/analysis/python/ruff.rs: -------------------------------------------------------------------------------- 1 | //! An analyzer for ruff ( ) 2 | 3 | use { 4 | crate::*, 5 | anyhow::Result, 6 | lazy_regex::*, 7 | }; 8 | 9 | #[derive(Debug, Default)] 10 | pub struct RuffAnalyzer { 11 | lines: Vec, 12 | } 13 | 14 | #[derive(Debug)] 15 | struct LocationTitle<'l> { 16 | path: &'l str, 17 | line: &'l str, 18 | column: &'l str, 19 | message: &'l [TString], 20 | } 21 | 22 | #[derive(Debug)] 23 | enum RuffLine<'l> { 24 | LocationTitle(LocationTitle<'l>), 25 | Other, 26 | } 27 | 28 | impl Analyzer for RuffAnalyzer { 29 | fn start( 30 | &mut self, 31 | _mission: &Mission, 32 | ) { 33 | self.lines.clear(); 34 | } 35 | 36 | fn receive_line( 37 | &mut self, 38 | line: CommandOutputLine, 39 | command_output: &mut CommandOutput, 40 | ) { 41 | self.lines.push(line.clone()); 42 | command_output.push(line); 43 | } 44 | 45 | fn build_report(&mut self) -> Result { 46 | build_report(&self.lines) 47 | } 48 | } 49 | 50 | fn recognize_line(tline: &TLine) -> RuffLine<'_> { 51 | if let Some(lt) = recognize_location_message(tline) { 52 | return RuffLine::LocationTitle(lt); 53 | } 54 | RuffLine::Other 55 | } 56 | 57 | fn recognize_location_message(tline: &TLine) -> Option> { 58 | let tstrings = &tline.strings; 59 | if tstrings.len() < 8 { 60 | return None; 61 | } 62 | if tstrings[0].csi != "\u{1b}[1m" || tstrings[1].raw.is_empty() // path 63 | || tstrings[1].csi != "\u{1b}[36m" || tstrings[1].raw != ":" 64 | || tstrings[2].is_styled() || !regex_is_match!(r"^\d+$", &tstrings[2].raw) // line 65 | || tstrings[3].csi != "\u{1b}[36m" || tstrings[3].raw != ":" 66 | || tstrings[4].is_styled() || !regex_is_match!(r"^\d+$", &tstrings[4].raw) // column 67 | || tstrings[5].csi != "\u{1b}[36m" || tstrings[5].raw != ":" 68 | || tstrings[6].is_styled() || tstrings[6].raw != " " 69 | { 70 | return None; 71 | } 72 | Some(LocationTitle { 73 | path: &tstrings[0].raw, 74 | line: &tstrings[2].raw, 75 | column: &tstrings[4].raw, 76 | message: &tstrings[7..], 77 | }) 78 | } 79 | 80 | /// Build a report from the output of biome 81 | pub fn build_report(cmd_lines: &[CommandOutputLine]) -> anyhow::Result { 82 | let mut items = ItemAccumulator::default(); 83 | let mut last_is_blank = true; 84 | let mut i = 0; 85 | for cmd_line in cmd_lines { 86 | if i < 5 { 87 | info!("cmd_line: {:#?}", &cmd_line); 88 | i += 1; 89 | } 90 | let bline = recognize_line(&cmd_line.content); 91 | if let RuffLine::LocationTitle(LocationTitle { 92 | path, 93 | line, 94 | column, 95 | message, 96 | }) = bline 97 | { 98 | items.push_error_title(burp::error_line_ts(message)); 99 | items.push_line( 100 | LineType::Location, 101 | burp::location_line(format!("{path}:{line}:{column}")), 102 | ); 103 | last_is_blank = false; 104 | } else { 105 | let is_blank = cmd_line.content.is_blank(); 106 | if !(is_blank && last_is_blank) { 107 | items.push_line(LineType::Normal, cmd_line.content.clone()); 108 | } 109 | last_is_blank = is_blank; 110 | } 111 | } 112 | Ok(items.report()) 113 | } 114 | -------------------------------------------------------------------------------- /src/result/filter.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | rustc_hash::FxHashSet, 4 | std::fmt, 5 | }; 6 | 7 | #[derive(Default, Debug)] 8 | pub struct Filter { 9 | dismissals: Vec, 10 | } 11 | 12 | #[derive(Debug, PartialEq, Eq, Hash, Clone)] 13 | pub enum Dismissal { 14 | Location(String), 15 | DiagType(String), 16 | } 17 | 18 | impl Dismissal { 19 | /// Return true if the item must be dismissed 20 | pub fn applies_to( 21 | &self, 22 | item: Item<'_>, 23 | ) -> bool { 24 | match self { 25 | Self::Location(v) => item.location().is_some_and(|loc| loc == v), 26 | Self::DiagType(v) => item.diag_type().is_some_and(|loc| loc == v), 27 | } 28 | } 29 | pub fn undo_action(&self) -> Action { 30 | match self { 31 | Self::Location(v) => Action::UndismissLocation(v.clone()), 32 | Self::DiagType(v) => Action::UndismissDiagType(v.clone()), 33 | } 34 | } 35 | } 36 | 37 | impl Filter { 38 | pub fn add( 39 | &mut self, 40 | dismissal: Dismissal, 41 | ) { 42 | if !self.dismissals.contains(&dismissal) { 43 | self.dismissals.push(dismissal); 44 | } 45 | } 46 | pub fn remove( 47 | &mut self, 48 | dismissal: &Dismissal, 49 | ) { 50 | self.dismissals.retain(|d| d != dismissal); 51 | } 52 | pub fn remove_dismissed_lines( 53 | &self, 54 | report: &mut Report, 55 | ) -> bool { 56 | let mut dismissed_item_idxs = FxHashSet::default(); 57 | for item in Item::items_of(&report.lines) { 58 | for dismissal in &self.dismissals { 59 | if dismissal.applies_to(item) { 60 | dismissed_item_idxs.insert(item.item_idx()); 61 | break; 62 | } 63 | } 64 | } 65 | let mut kept_lines = Vec::new(); 66 | for line in report.lines.drain(..) { 67 | if dismissed_item_idxs.contains(&line.item_idx) { 68 | report.dismissed_lines.push(line); 69 | } else { 70 | kept_lines.push(line); 71 | } 72 | } 73 | report.lines = kept_lines; 74 | report.dismissed_items += dismissed_item_idxs.len(); 75 | report.lines_changed(); 76 | info!( 77 | "FILTERING, dismissed {} items: {:?}", 78 | dismissed_item_idxs.len(), 79 | dismissed_item_idxs 80 | ); 81 | !dismissed_item_idxs.is_empty() 82 | } 83 | pub fn restore_dismissed_lines( 84 | &self, 85 | report: &mut Report, 86 | ) { 87 | if !report.dismissed_lines.is_empty() { 88 | report.lines.append(&mut report.dismissed_lines); 89 | report.lines.sort_by_key(|line| line.item_idx); 90 | report.dismissed_items = 0; 91 | report.lines_changed(); 92 | } 93 | } 94 | pub fn undismiss_menu(&self) -> ActionMenu { 95 | let mut menu = ActionMenu::new(); 96 | menu.add_action(Action::UndismissAll); 97 | for dismissal in &self.dismissals { 98 | menu.add_action(dismissal.undo_action()); 99 | } 100 | menu 101 | } 102 | } 103 | 104 | impl fmt::Display for Dismissal { 105 | fn fmt( 106 | &self, 107 | f: &mut fmt::Formatter<'_>, 108 | ) -> fmt::Result { 109 | match self { 110 | Self::Location(v) => write!(f, "location: {v}"), 111 | Self::DiagType(v) => write!(f, "diag_type: {v}"), 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/search/search_pattern.rs: -------------------------------------------------------------------------------- 1 | use crate::*; 2 | 3 | pub struct Pattern { 4 | pub pattern: String, // might change later 5 | } 6 | 7 | impl Pattern { 8 | // Current limitations: 9 | // - a match can't span over more than 2 lines. This is probably fine. 10 | pub fn search_lines<'i, I>( 11 | &self, 12 | lines: I, 13 | ) -> Vec 14 | where 15 | I: IntoIterator, 16 | { 17 | let lines = lines.into_iter(); 18 | let pattern = &self.pattern; 19 | let len = pattern.len(); 20 | let mut founds = Vec::new(); 21 | let mut previous_line: Option<&Line> = None; 22 | for (line_idx, line) in lines.enumerate() { 23 | if line.is_continuation() { 24 | if let Some(previous_line) = previous_line { 25 | // we check for a match broken by wrapping 26 | if !previous_line.content.strings.is_empty() && !line.content.strings.is_empty() 27 | { 28 | let previous_line_string_idx = previous_line.content.strings.len() - 1; 29 | let previous_last_raw = 30 | &previous_line.content.strings[previous_line_string_idx].raw; 31 | if let Some(cut) = find_cut_pattern( 32 | pattern, 33 | previous_last_raw, 34 | &line.content.strings[0].raw, 35 | ) { 36 | let found = Found { 37 | line_idx: line_idx - 1, 38 | trange: TRange { 39 | string_idx: previous_line_string_idx, 40 | start_byte_in_string: previous_last_raw.len() - cut, 41 | end_byte_in_string: previous_last_raw.len(), 42 | }, 43 | continued: Some(TRange { 44 | string_idx: 0, 45 | start_byte_in_string: 0, 46 | end_byte_in_string: len - cut, 47 | }), 48 | }; 49 | founds.push(found); 50 | } 51 | } 52 | } 53 | } 54 | previous_line = Some(line); 55 | for (string_idx, tstring) in line.content.strings.iter().enumerate() { 56 | let mut offset = 0; 57 | while offset + len < tstring.raw.len() { 58 | let haystack = &tstring.raw[offset..]; 59 | let Some(pos) = haystack.find(pattern) else { 60 | break; 61 | }; 62 | let found = Found { 63 | line_idx, 64 | trange: TRange { 65 | string_idx, 66 | start_byte_in_string: pos + offset, 67 | end_byte_in_string: pos + offset + pattern.len(), 68 | }, 69 | continued: None, 70 | }; 71 | founds.push(found); 72 | offset += pos + pattern.len(); 73 | } 74 | } 75 | } 76 | founds 77 | } 78 | } 79 | 80 | fn find_cut_pattern( 81 | pattern: &str, 82 | a: &str, 83 | b: &str, 84 | ) -> Option { 85 | let len = pattern.len(); 86 | (1..len).find(|&i| a.ends_with(&pattern[..i]) && b.starts_with(&pattern[i..])) 87 | } 88 | -------------------------------------------------------------------------------- /src/watcher.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::Result, 4 | notify::{ 5 | RecommendedWatcher, 6 | RecursiveMode, 7 | Watcher as NotifyWatcher, 8 | event::{ 9 | AccessKind, 10 | AccessMode, 11 | DataChange, 12 | EventKind, 13 | ModifyKind, 14 | }, 15 | }, 16 | std::path::PathBuf, 17 | termimad::crossbeam::channel::{ 18 | Receiver, 19 | bounded, 20 | }, 21 | }; 22 | 23 | /// A file watcher, providing a channel to receive notifications 24 | pub struct Watcher { 25 | pub receiver: Receiver<()>, 26 | _notify_watcher: RecommendedWatcher, 27 | } 28 | 29 | impl Watcher { 30 | pub fn new( 31 | paths_to_watch: &[PathBuf], 32 | mut ignorer: IgnorerSet, 33 | ) -> Result { 34 | info!("watcher on {paths_to_watch:#?}"); 35 | let (sender, receiver) = bounded(0); 36 | let mut notify_watcher = 37 | notify::recommended_watcher(move |res: notify::Result| match res { 38 | Ok(we) => { 39 | match we.kind { 40 | EventKind::Modify(ModifyKind::Metadata(_)) => { 41 | //debug!("ignoring metadata change"); 42 | return; // useless event 43 | } 44 | EventKind::Modify(ModifyKind::Data(DataChange::Any)) => { 45 | //debug!("ignoring 'any' data change"); 46 | return; // probably useless event with no real change 47 | } 48 | EventKind::Access(AccessKind::Close(AccessMode::Write)) => { 49 | debug!("close write event: {we:?}"); 50 | } 51 | EventKind::Access(_) => { 52 | //debug!("ignoring access event: {we:?}"); 53 | return; // probably useless event 54 | } 55 | _ => { 56 | info!("notify event: {we:?}"); 57 | } 58 | } 59 | match time!(Info, ignorer.excludes_all_pathbufs(&we.paths)) { 60 | Ok(true) => { 61 | debug!("all excluded"); 62 | return; 63 | } 64 | Ok(false) => { 65 | debug!("at least one is included"); 66 | } 67 | Err(e) => { 68 | warn!("exclusion check failed: {e}"); 69 | } 70 | } 71 | if let Err(e) = sender.send(()) { 72 | debug!("error when notifying on notify event: {e}"); 73 | } 74 | } 75 | Err(e) => warn!("watch error: {e:?}"), 76 | })?; 77 | for path in paths_to_watch { 78 | if !path.exists() { 79 | warn!("watch path doesn't exist: {path:?}"); 80 | continue; 81 | } 82 | if path.is_dir() { 83 | debug!("add watch dir {path:?}"); 84 | notify_watcher.watch(path, RecursiveMode::Recursive)?; 85 | } else if path.is_file() { 86 | debug!("add watch file {path:?}"); 87 | notify_watcher.watch(path, RecursiveMode::NonRecursive)?; 88 | } 89 | } 90 | Ok(Self { 91 | receiver, 92 | _notify_watcher: notify_watcher, 93 | }) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/analysis/line_type.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::*, 4 | serde::{ 5 | Deserialize, 6 | Serialize, 7 | }, 8 | std::io::Write, 9 | termimad::crossterm::style::Stylize, 10 | }; 11 | 12 | /// a kind of section 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 14 | pub enum Kind { 15 | /// a warning 16 | Warning, 17 | /// an error 18 | Error, 19 | /// a test failure 20 | TestFail, 21 | /// a test output (may be a failure, or just --show-output) 22 | TestOutput, 23 | /// a sum of errors and/or warnings, typically occurring 24 | /// at the end of the compilation of a package 25 | Sum, 26 | } 27 | 28 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 29 | pub enum LineType { 30 | /// the start of a section 31 | Title(Kind), 32 | 33 | /// the end of a section (not part of the section) 34 | SectionEnd, 35 | 36 | /// a line locating the problem 37 | Location, 38 | 39 | /// the line saying if a test was passed 40 | TestResult(bool), 41 | 42 | /// a suggestion to try with backtrace 43 | BacktraceSuggestion, 44 | 45 | /// a line we know is useless noise 46 | Garbage, 47 | 48 | /// Raw line, unclassified 49 | Raw(CommandStream), 50 | 51 | /// Continuation of a previous line 52 | Continuation { 53 | /// offset to count back to get to first (starting at 1) 54 | offset: usize, 55 | /// whether the line is a summary 56 | summary: bool, 57 | }, 58 | 59 | /// any other line 60 | Normal, 61 | } 62 | 63 | impl LineType { 64 | /// Width on screen for the specific prefix of line of this type 65 | pub fn cols(self) -> usize { 66 | match self { 67 | Self::Title(_) => 3, 68 | _ => 0, 69 | } 70 | } 71 | pub fn at_index_in( 72 | idx: usize, 73 | lines: &[Line], 74 | ) -> Option { 75 | let line = lines.get(idx)?; 76 | match line.line_type { 77 | Self::Continuation { offset, .. } => { 78 | if offset > idx { 79 | error!("inconsistent offset in continuation line"); 80 | return None; 81 | } 82 | let idx = idx - offset; 83 | let line = lines.get(idx)?; 84 | Some(line.line_type) 85 | } 86 | line_type => Some(line_type), 87 | } 88 | } 89 | pub fn is_summary(self) -> bool { 90 | match self { 91 | Self::Normal | Self::Raw(_) => false, 92 | Self::Continuation { summary, .. } => summary, 93 | _ => true, 94 | } 95 | } 96 | pub fn matches( 97 | self, 98 | summary: bool, 99 | ) -> bool { 100 | !summary || self.is_summary() 101 | } 102 | pub fn draw( 103 | self, 104 | w: &mut W, 105 | item_idx: usize, 106 | ) -> Result<()> { 107 | match self { 108 | Self::Title(Kind::Error) => { 109 | write!(w, "{}", format!("{item_idx:^3}").black().bold().on_red())?; 110 | } 111 | Self::Title(Kind::TestFail | Kind::TestOutput) => { 112 | write!( 113 | w, 114 | "\u{1b}[1m\u{1b}[38;5;235m\u{1b}[48;5;208m{item_idx:^3}\u{1b}[0m\u{1b}[0m" 115 | )?; 116 | } 117 | Self::Title(Kind::Warning) => { 118 | write!(w, "{}", format!("{item_idx:^3}").black().bold().on_yellow())?; 119 | } 120 | _ => {} 121 | } 122 | Ok(()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/tty/tstring.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::*, 4 | anyhow::*, 5 | serde::{ 6 | Deserialize, 7 | Serialize, 8 | }, 9 | std::{ 10 | fmt::Write as _, 11 | io::Write, 12 | }, 13 | termimad::StrFit, 14 | }; 15 | 16 | /// a simple representation of a colored and styled string. 17 | /// 18 | /// Note that this works because of a few properties of 19 | /// cargo's output: 20 | /// - styles and colors are always reset on changes 21 | /// - they're always in the same order (bold then fg color) 22 | /// 23 | /// A more generic parsing would have to: 24 | /// - parse the csi params (it's simple enough to map but takes code) 25 | /// - use a simple state machine to keep style (bold, italic, etc.), 26 | /// foreground color, and background color across tstrings 27 | #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] 28 | pub struct TString { 29 | pub csi: String, 30 | pub raw: String, 31 | } 32 | impl TString { 33 | pub fn new, S2: Into>( 34 | csi: S1, 35 | raw: S2, 36 | ) -> Self { 37 | Self { 38 | csi: csi.into(), 39 | raw: raw.into(), 40 | } 41 | } 42 | /// colors are 8bits ansi values 43 | pub fn badge( 44 | con: &str, 45 | fg: u8, 46 | bg: u8, 47 | ) -> Self { 48 | Self { 49 | csi: csi(fg, bg), 50 | raw: format!(" {con} "), 51 | } 52 | } 53 | pub fn num_badge( 54 | num: usize, 55 | cat: &str, 56 | fg: u8, 57 | bg: u8, 58 | ) -> Self { 59 | let raw = if num < 2 { 60 | format!(" {num} {cat} ") 61 | } else { 62 | format!(" {num} {cat}s ") 63 | }; 64 | Self::badge(&raw, fg, bg) 65 | } 66 | pub fn push_csi( 67 | &mut self, 68 | params: &vte::Params, 69 | action: char, 70 | ) { 71 | self.csi.push('\u{1b}'); 72 | self.csi.push('['); 73 | for (idx, param) in params.iter().enumerate() { 74 | for p in param { 75 | let _ = write!(self.csi, "{p}"); 76 | } 77 | if idx < params.len() - 1 { 78 | self.csi.push(';'); 79 | } 80 | } 81 | self.csi.push(action); 82 | } 83 | pub fn draw( 84 | &self, 85 | w: &mut W, 86 | ) -> Result<()> { 87 | draw(w, &self.csi, &self.raw) 88 | } 89 | /// draw the string but without taking more than `cols_max` cols. 90 | /// Return the number of cols written 91 | pub fn draw_in( 92 | &self, 93 | w: &mut W, 94 | cols_max: usize, 95 | ) -> Result { 96 | let fit = StrFit::make_cow(&self.raw, cols_max); 97 | if self.csi.is_empty() { 98 | write!(w, "{}", &fit.0)?; 99 | } else { 100 | write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?; 101 | } 102 | Ok(fit.1) 103 | } 104 | pub fn starts_with( 105 | &self, 106 | csi: &str, 107 | raw: &str, 108 | ) -> bool { 109 | self.csi == csi && self.raw.starts_with(raw) 110 | } 111 | pub fn split_off( 112 | &mut self, 113 | at: usize, 114 | ) -> Self { 115 | Self { 116 | csi: self.csi.clone(), 117 | raw: self.raw.split_off(at), 118 | } 119 | } 120 | pub fn is_blank(&self) -> bool { 121 | self.raw.chars().all(char::is_whitespace) 122 | } 123 | pub fn is_styled(&self) -> bool { 124 | !self.csi.is_empty() 125 | } 126 | pub fn is_unstyled(&self) -> bool { 127 | self.csi.is_empty() 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/analysis/biome/mod.rs: -------------------------------------------------------------------------------- 1 | //! An analyzer for biome ( / ) 2 | 3 | use { 4 | super::*, 5 | crate::*, 6 | anyhow::Result, 7 | lazy_regex::*, 8 | }; 9 | 10 | #[derive(Debug, Default)] 11 | pub struct BiomeAnalyzer { 12 | lines: Vec, 13 | } 14 | 15 | #[derive(Debug)] 16 | struct LocationCode<'l> { 17 | location: &'l str, // path with line and column 18 | code: &'l str, // eg "lint/complexity/noForEach" 19 | tag: Option<&'l TString>, // eg "FIXABLE" 20 | } 21 | 22 | #[derive(Debug)] 23 | enum BiomeLine<'l> { 24 | LocationCode(LocationCode<'l>), 25 | Other, 26 | } 27 | 28 | impl Analyzer for BiomeAnalyzer { 29 | fn start( 30 | &mut self, 31 | _mission: &Mission, 32 | ) { 33 | self.lines.clear(); 34 | } 35 | 36 | fn receive_line( 37 | &mut self, 38 | line: CommandOutputLine, 39 | command_output: &mut CommandOutput, 40 | ) { 41 | self.lines.push(line.clone()); 42 | command_output.push(line); 43 | } 44 | 45 | fn build_report(&mut self) -> Result { 46 | build_report(&self.lines) 47 | } 48 | } 49 | 50 | fn recognize_line(tline: &TLine) -> BiomeLine<'_> { 51 | if let Some(lc) = recognize_location_code(tline) { 52 | return BiomeLine::LocationCode(lc); 53 | } 54 | BiomeLine::Other 55 | } 56 | 57 | fn recognize_location_code(tline: &TLine) -> Option> { 58 | if let Some(s) = tline.if_unstyled() { 59 | // untagged 60 | if let Some((_, location, code)) = regex_captures!(r"([^\s:]+:\d+:\d+) (\S+) ━+$", s) { 61 | let tag = None; 62 | return Some(LocationCode { 63 | location, 64 | code, 65 | tag, 66 | }); 67 | } 68 | } 69 | let mut strings = tline.strings.iter(); 70 | let a = strings.next(); 71 | let b = strings.next(); 72 | let c = strings.next(); 73 | if let (Some(a), Some(b), Some(c)) = (a, b, c) { 74 | if a.is_unstyled() && c.is_unstyled() && regex_is_match!("^ ━+$", &c.raw) { 75 | if let Some((_, location, code)) = regex_captures!(r"([^\s:]+:\d+:\d+) (\S+) ", &a.raw) 76 | { 77 | let tag = Some(b); 78 | return Some(LocationCode { 79 | location, 80 | code, 81 | tag, 82 | }); 83 | } 84 | } 85 | } 86 | None 87 | } 88 | 89 | /// Build a report from the output of biome 90 | pub fn build_report(cmd_lines: &[CommandOutputLine]) -> anyhow::Result { 91 | let mut items = ItemAccumulator::default(); 92 | let mut last_is_blank = true; 93 | for cmd_line in cmd_lines { 94 | let bline = recognize_line(&cmd_line.content); 95 | if let BiomeLine::LocationCode(lc) = bline { 96 | let mut error_line = burp::error_line(lc.code); 97 | if let Some(tag) = lc.tag { 98 | error_line.strings.push(TString::new("", " ")); 99 | error_line.strings.push(tag.clone()); 100 | } 101 | items.push_error_title(error_line); 102 | items.push_line( 103 | LineType::Location, 104 | burp::location_line(lc.location.to_string()), 105 | ); 106 | last_is_blank = false; 107 | } else { 108 | let is_blank = cmd_line.content.is_blank(); 109 | if !(is_blank && last_is_blank) { 110 | items.push_line(LineType::Normal, cmd_line.content.clone()); 111 | } 112 | last_is_blank = is_blank; 113 | } 114 | } 115 | Ok(items.report()) 116 | } 117 | -------------------------------------------------------------------------------- /src/sound/volume.rs: -------------------------------------------------------------------------------- 1 | use { 2 | schemars::{ 3 | JsonSchema, 4 | Schema, 5 | SchemaGenerator, 6 | json_schema, 7 | }, 8 | serde::{ 9 | Deserialize, 10 | Deserializer, 11 | Serialize, 12 | Serializer, 13 | de, 14 | }, 15 | std::{ 16 | borrow::Cow, 17 | fmt, 18 | ops::Mul, 19 | str::FromStr, 20 | }, 21 | }; 22 | 23 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 24 | pub struct Volume { 25 | /// Volume in [0, 100] 26 | percent: u16, // u16 ensures we can multiply it without overflow 27 | } 28 | impl Default for Volume { 29 | fn default() -> Self { 30 | Self { percent: 100 } 31 | } 32 | } 33 | impl Mul for Volume { 34 | type Output = Self; 35 | fn mul( 36 | self, 37 | rhs: Self, 38 | ) -> Self { 39 | Self { 40 | percent: self.percent * rhs.percent / 100, 41 | } 42 | } 43 | } 44 | impl Volume { 45 | pub fn new(percent: u16) -> Self { 46 | Self { 47 | percent: percent.clamp(0, 100), 48 | } 49 | } 50 | /// Return the volume in [0, 100] 51 | pub fn as_percent(self) -> u16 { 52 | self.percent 53 | } 54 | /// Return the volume in [0, 1] 55 | pub fn as_part(self) -> f32 { 56 | f32::from(self.percent) / 100f32 57 | } 58 | } 59 | #[derive(Debug, Clone, PartialEq)] 60 | pub enum ParseVolumeError { 61 | ValueOutOfRange, 62 | NotU16(String), 63 | } 64 | impl fmt::Display for ParseVolumeError { 65 | fn fmt( 66 | &self, 67 | f: &mut fmt::Formatter<'_>, 68 | ) -> fmt::Result { 69 | match self { 70 | Self::ValueOutOfRange => write!(f, "value out of [0-100] range"), 71 | Self::NotU16(s) => write!(f, "value '{s}' is not a valid integer"), 72 | } 73 | } 74 | } 75 | impl std::error::Error for ParseVolumeError {} 76 | 77 | impl FromStr for Volume { 78 | type Err = ParseVolumeError; 79 | fn from_str(s: &str) -> Result { 80 | let s = s.trim_end_matches('%'); 81 | let percent: u16 = s 82 | .parse() 83 | .map_err(|_| ParseVolumeError::NotU16(s.to_string()))?; 84 | if percent > 100 { 85 | return Err(ParseVolumeError::ValueOutOfRange); 86 | } 87 | Ok(Self { percent }) 88 | } 89 | } 90 | impl std::fmt::Display for Volume { 91 | fn fmt( 92 | &self, 93 | f: &mut std::fmt::Formatter<'_>, 94 | ) -> std::fmt::Result { 95 | write!(f, "{}%", self.percent) 96 | } 97 | } 98 | impl Serialize for Volume { 99 | fn serialize( 100 | &self, 101 | serializer: S, 102 | ) -> Result 103 | where 104 | S: Serializer, 105 | { 106 | serializer.collect_str(self) 107 | } 108 | } 109 | impl<'de> Deserialize<'de> for Volume { 110 | fn deserialize(deserializer: D) -> Result 111 | where 112 | D: Deserializer<'de>, 113 | { 114 | let s = String::deserialize(deserializer)?; 115 | Self::from_str(&s).map_err(de::Error::custom) 116 | } 117 | } 118 | impl JsonSchema for Volume { 119 | fn schema_name() -> Cow<'static, str> { 120 | "Volume".into() 121 | } 122 | fn schema_id() -> Cow<'static, str> { 123 | concat!(module_path!(), "::Volume").into() 124 | } 125 | fn json_schema(_gen: &mut SchemaGenerator) -> Schema { 126 | json_schema!({ 127 | "type": "string", 128 | "description": "Volume percentage written as an integer between 0 and 100, optionally suffixed with '%'.", 129 | }) 130 | } 131 | fn inline_schema() -> bool { 132 | true 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/result/command_result.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::*, 4 | serde::{ 5 | Deserialize, 6 | Serialize, 7 | }, 8 | std::process::ExitStatus, 9 | }; 10 | 11 | /// what we get from the execution of a command 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub enum CommandResult { 14 | /// a trustable report with errors and warnings computed 15 | Report(Report), 16 | /// we don't have a proper report 17 | Failure(Failure), 18 | /// not yet computed 19 | None, 20 | } 21 | 22 | impl CommandResult { 23 | pub fn build( 24 | output: CommandOutput, 25 | exit_status: ExitStatus, 26 | mut report: Report, 27 | ) -> Result { 28 | let error_code = exit_status.code().filter(|&c| c != 0); 29 | debug!("report stats: {:?}", &report.stats); 30 | if let Some(error_code) = error_code { 31 | let stats = &report.stats; 32 | if stats.errors + stats.test_fails + stats.warnings == 0 { 33 | // Report shows no error while the command exe reported 34 | // an error, so the report can't be trusted. 35 | // Note that some tools return an error on warnings (eg 36 | // miri), some don't. 37 | let suggest_backtrace = report.suggest_backtrace; 38 | return Ok(Self::Failure(Failure { 39 | error_code, 40 | output, 41 | suggest_backtrace, 42 | })); 43 | } 44 | } 45 | report.output = output; 46 | // report looks valid 47 | Ok(Self::Report(report)) 48 | } 49 | 50 | pub fn output(&self) -> Option<&CommandOutput> { 51 | match self { 52 | Self::Report(report) => Some(&report.output), 53 | Self::Failure(failure) => Some(&failure.output), 54 | Self::None => None, 55 | } 56 | } 57 | 58 | pub fn report(&self) -> Option<&Report> { 59 | match self { 60 | Self::Report(report) => Some(report), 61 | _ => None, 62 | } 63 | } 64 | 65 | pub fn report_mut(&mut self) -> Option<&mut Report> { 66 | match self { 67 | Self::Report(report) => Some(report), 68 | _ => None, 69 | } 70 | } 71 | 72 | pub fn suggest_backtrace(&self) -> bool { 73 | match self { 74 | Self::Report(report) => report.suggest_backtrace, 75 | Self::Failure(failure) => failure.suggest_backtrace, 76 | Self::None => false, 77 | } 78 | } 79 | 80 | /// return true when the report has been computed and there's been no 81 | /// error, warning, or test failures 82 | /// 83 | /// This is different from the `is_success` that a mission can compute 84 | /// from a report using its own settings (eg `allow_warnings`) 85 | pub fn is_success(&self) -> bool { 86 | match self { 87 | Self::Report(report) => { 88 | report.stats.errors + report.stats.warnings + report.stats.test_fails == 0 89 | } 90 | _ => false, 91 | } 92 | } 93 | 94 | pub fn reverse(&mut self) { 95 | match self { 96 | Self::Report(report) => { 97 | report.reverse(); 98 | } 99 | Self::Failure(failure) => { 100 | failure.output.reverse(); 101 | } 102 | Self::None => {} 103 | } 104 | } 105 | pub fn lines_len(&self) -> usize { 106 | match self { 107 | Self::Report(report) => report.lines.len(), 108 | Self::Failure(failure) => failure.output.lines.len(), 109 | Self::None => 0, 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/jobs/job_ref.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | lazy_regex::*, 4 | std::fmt, 5 | }; 6 | 7 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 8 | pub enum JobRef { 9 | Default, 10 | Initial, 11 | Previous, 12 | PreviousOrQuit, 13 | Concrete(ConcreteJobRef), 14 | Scope(Scope), 15 | } 16 | 17 | impl JobRef { 18 | pub fn from_job_name>(s: S) -> Self { 19 | Self::Concrete(ConcreteJobRef::from_job_name(s)) 20 | } 21 | } 22 | 23 | impl From for JobRef { 24 | fn from(scope: Scope) -> Self { 25 | Self::Scope(scope) 26 | } 27 | } 28 | 29 | impl From for JobRef { 30 | fn from(concrete: ConcreteJobRef) -> Self { 31 | Self::Concrete(concrete) 32 | } 33 | } 34 | 35 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 36 | pub enum NameOrAlias { 37 | Name(String), 38 | Alias(String), 39 | } 40 | 41 | impl fmt::Display for JobRef { 42 | fn fmt( 43 | &self, 44 | f: &mut fmt::Formatter, 45 | ) -> fmt::Result { 46 | match self { 47 | Self::Default => write!(f, "default"), 48 | Self::Initial => write!(f, "initial"), 49 | Self::Previous => write!(f, "previous"), 50 | Self::PreviousOrQuit => write!(f, "previous-or-quit"), 51 | Self::Scope(Scope { tests }) => write!(f, "scope:{}", tests.join(",")), 52 | Self::Concrete(concrete) => write!(f, "{concrete}"), 53 | } 54 | } 55 | } 56 | 57 | impl From<&str> for JobRef { 58 | fn from(s: &str) -> Self { 59 | regex_switch!(s, 60 | "^default$"i => Self::Default, 61 | "^initial$"i => Self::Initial, 62 | "^previous$"i => Self::Previous, 63 | "^previous-or-quit$"i => Self::PreviousOrQuit, 64 | "^scope:(?.+)$"i => Self::Scope(Scope { 65 | tests: tests 66 | .split(',') 67 | .filter(|t| !t.trim().is_empty()) 68 | .map(|s| s.to_string()) 69 | .collect(), 70 | }), 71 | ) 72 | .unwrap_or_else(|| Self::Concrete(ConcreteJobRef::from(s))) 73 | } 74 | } 75 | 76 | #[test] 77 | fn test_job_ref_string_round_trip() { 78 | let job_refs = vec![ 79 | JobRef::Default, 80 | JobRef::Initial, 81 | JobRef::Previous, 82 | JobRef::PreviousOrQuit, 83 | JobRef::Concrete(ConcreteJobRef { 84 | name_or_alias: NameOrAlias::Name("run".to_string()), 85 | scope: Scope::default(), 86 | }), 87 | JobRef::Concrete(ConcreteJobRef { 88 | name_or_alias: NameOrAlias::Name("nextest".to_string()), 89 | scope: Scope { 90 | tests: vec!["first::test".to_string(), "second_test".to_string()], 91 | }, 92 | }), 93 | JobRef::Concrete(ConcreteJobRef { 94 | name_or_alias: NameOrAlias::Alias("my-check".to_string()), 95 | scope: Scope::default(), 96 | }), 97 | JobRef::Concrete(ConcreteJobRef { 98 | name_or_alias: NameOrAlias::Alias("my-test".to_string()), 99 | scope: Scope { 100 | tests: vec!["abc".to_string()], 101 | }, 102 | }), 103 | JobRef::Concrete(ConcreteJobRef { 104 | name_or_alias: NameOrAlias::Name("nextest".to_string()), 105 | scope: Scope { 106 | tests: vec!["abc".to_string()], 107 | }, 108 | }), 109 | JobRef::Scope(Scope { 110 | tests: vec!["first::test".to_string(), "second_test".to_string()], 111 | }), 112 | ]; 113 | for job_ref in job_refs { 114 | let s = job_ref.to_string(); 115 | let job_ref2 = JobRef::from(s.as_str()); 116 | assert_eq!(job_ref, job_ref2); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/sound/sound_player.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | std::thread, 4 | termimad::crossbeam::channel::{ 5 | self, 6 | Sender, 7 | select, 8 | }, 9 | }; 10 | 11 | /// The maximum number of sounds that can be queued, as we don't want 12 | /// a long list of sounds for events that are triggered in a loop 13 | /// (on quit, the queue will be interrupted anyway). 14 | const MAX_QUEUED_SOUNDS: usize = 2; 15 | 16 | /// Manage a thread to play sounds without blocking bacon 17 | pub struct SoundPlayer { 18 | thread: Option>, 19 | s_die: Option>, 20 | s_sound: Sender, 21 | } 22 | impl SoundPlayer { 23 | pub fn new(base_volume: Volume) -> anyhow::Result { 24 | let (s_sound, r_sound) = channel::bounded::(MAX_QUEUED_SOUNDS); 25 | let (s_die, r_die) = channel::bounded(1); 26 | let thread = thread::spawn(move || { 27 | loop { 28 | select! { 29 | recv(r_die) -> _ => { 30 | info!("sound player thread is stopping"); 31 | break; 32 | } 33 | recv(r_sound) -> ps => { 34 | match ps { 35 | Ok(mut ps) => { 36 | if !r_die.is_empty() { 37 | continue; 38 | } 39 | ps.volume = ps.volume * base_volume; 40 | match play_sound(&ps, r_die.clone()) { 41 | Ok(()) => { 42 | debug!("sound played"); 43 | } 44 | Err(SoundError::Interrupted) => { 45 | // only reason is sound player is dying 46 | info!("sound interrupted"); 47 | break; 48 | } 49 | Err(e) => { 50 | error!("sound error: {}", e); 51 | } 52 | } 53 | } 54 | Err(e) => { 55 | error!("sound player channel error: {}", e); 56 | break; 57 | } 58 | } 59 | } 60 | } 61 | } 62 | }); 63 | Ok(Self { 64 | thread: Some(thread), 65 | s_die: Some(s_die), 66 | s_sound, 67 | }) 68 | } 69 | /// Requests a sound, unless too many of them are already queued 70 | pub fn play( 71 | &self, 72 | sound_command: PlaySoundCommand, 73 | ) { 74 | if self.s_sound.try_send(sound_command).is_err() { 75 | warn!("Too many sounds in the queue, dropping one"); 76 | } 77 | } 78 | /// Make the beeper thread synchronously stop 79 | /// (interrupting the current sound if any) 80 | pub fn die(&mut self) { 81 | if let Some(sender) = self.s_die.take() { 82 | if let Err(e) = sender.send(()) { 83 | warn!("failed to send 'kill' signal: {e}"); 84 | } 85 | } 86 | if let Some(thread) = self.thread.take() { 87 | if thread.join().is_err() { 88 | warn!("child_thread.join() failed"); // should not happen 89 | } else { 90 | info!("SoundPlayer gracefully stopped"); 91 | } 92 | } 93 | } 94 | } 95 | impl Drop for SoundPlayer { 96 | fn drop(&mut self) { 97 | self.die(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/analysis/python/pytest.rs: -------------------------------------------------------------------------------- 1 | //! An analyzer for Python's Pytest test framework. 2 | use { 3 | crate::*, 4 | anyhow::Result, 5 | lazy_regex::*, 6 | }; 7 | 8 | #[derive(Debug, Default)] 9 | pub struct PytestAnalyzer { 10 | lines: Vec, 11 | } 12 | 13 | enum PytLineFormat<'l> { 14 | H1(&'l str), // big title with `=` 15 | H2(&'l str), // smaller title with `_` 16 | Location { path: &'l str, line: &'l str }, 17 | Other, 18 | } 19 | enum Section { 20 | Errors, 21 | Failures, 22 | Other, 23 | } 24 | 25 | impl Analyzer for PytestAnalyzer { 26 | fn start( 27 | &mut self, 28 | _mission: &Mission, 29 | ) { 30 | self.lines.clear(); 31 | } 32 | 33 | fn receive_line( 34 | &mut self, 35 | line: CommandOutputLine, 36 | command_output: &mut CommandOutput, 37 | ) { 38 | self.lines.push(line.clone()); 39 | command_output.push(line); 40 | } 41 | 42 | fn build_report(&mut self) -> Result { 43 | build_report(&self.lines) 44 | } 45 | } 46 | 47 | fn recognize_format(content: &str) -> PytLineFormat<'_> { 48 | regex_switch!(content, 49 | r"^(?:={2,39}) (?.+) (?:={2,39})$" => PytLineFormat::H1(title), 50 | r"^(?:_{2,39}) (?<title>.+) (?:_{2,39})$" => PytLineFormat::H2(title), 51 | r"^file (?<path>\S+\.py), line (?<line>\d{1,8})$" => PytLineFormat::Location { path, line }, 52 | r"^(?<path>\S+\.py):(?<line>\d{1,8})" => PytLineFormat::Location { path, line }, 53 | ) 54 | .unwrap_or(PytLineFormat::Other) 55 | } 56 | 57 | /// Build a report from the output of Python unittest 58 | /// 59 | /// The main special thing here is transforming the location line in 60 | /// a BURP location line. 61 | pub fn build_report(cmd_lines: &[CommandOutputLine]) -> anyhow::Result<Report> { 62 | let mut current_section = Section::Other; 63 | let mut items = ItemAccumulator::default(); 64 | let mut last_location_in_item = None; // to deduplicate locations 65 | for cmd_line in cmd_lines { 66 | let Some(content) = cmd_line.content.if_unstyled() else { 67 | continue; // right now we're not expecting styled output 68 | }; 69 | let format = recognize_format(content); 70 | match format { 71 | PytLineFormat::H1(title) => { 72 | current_section = match title { 73 | "ERRORS" => Section::Errors, 74 | "FAILURES" => Section::Failures, 75 | _ => Section::Other, 76 | }; 77 | items.close_item(); 78 | } 79 | PytLineFormat::H2(title) => match current_section { 80 | Section::Errors => { 81 | items.push_error_title(burp::error_line(title)); 82 | last_location_in_item = None; 83 | } 84 | Section::Failures => { 85 | items.push_failure_title(burp::failure_line(title)); 86 | last_location_in_item = None; 87 | } 88 | Section::Other => {} 89 | }, 90 | PytLineFormat::Location { path, line } => { 91 | if let Some(last_location) = last_location_in_item { 92 | if last_location == (path, line) { 93 | continue; 94 | } 95 | } 96 | last_location_in_item = Some((path, line)); 97 | items.push_line( 98 | LineType::Location, 99 | burp::location_line(format!("{path}:{line}")), 100 | ); 101 | } 102 | PytLineFormat::Other => { 103 | items.push_line(LineType::Normal, cmd_line.content.clone()); 104 | } 105 | } 106 | } 107 | Ok(items.report()) 108 | } 109 | -------------------------------------------------------------------------------- /bacon.toml: -------------------------------------------------------------------------------- 1 | #:schema https://dystroy.org/bacon/.bacon.schema.json 2 | 3 | # A bacon.toml file dedicated to the bacon tool 4 | # (if you're looking for the current default bacon.toml file, 5 | # generate it with `bacon --init` or download it from 6 | # https://github.com/Canop/bacon/tree/main/defaults) 7 | 8 | default_job = "check-all" 9 | env.CARGO_TERM_COLOR = "always" 10 | 11 | # uncomment for sound 12 | #sound.enabled = true 13 | #sound.base_volume = "20%" 14 | 15 | [jobs] 16 | 17 | # This alternative to 'check' demonstrates the use of the JSON output 18 | # of cargo check - see https://github.com/Canop/bacon/issues/249 19 | [jobs.json-check] 20 | command = [ 21 | "cargo", "check", 22 | "--message-format", "json-diagnostic-rendered-ansi", 23 | ] 24 | need_stdout = true 25 | analyzer = "cargo_json" 26 | 27 | [jobs.check] 28 | command = ["cargo", "check"] 29 | need_stdout = false 30 | 31 | [jobs.check-windows] 32 | command = [ 33 | "cross", "build", 34 | "--target", "x86_64-pc-windows-gnu", 35 | ] 36 | 37 | [jobs.check-nightly] 38 | command = [ 39 | "cargo", "+nightly", 40 | "check", 41 | "--all-targets", 42 | ] 43 | need_stdout = false 44 | 45 | [jobs.check-all] 46 | command = [ 47 | "cargo", "check", 48 | "--all-targets", 49 | ] 50 | need_stdout = false 51 | 52 | [jobs.fmt] 53 | command = ["cargo", "+nightly", "fmt"] 54 | 55 | [jobs.nightly] 56 | command = [ 57 | "cargo", 58 | "+nightly", 59 | "check", 60 | "--all-targets", 61 | ] 62 | need_stdout = false 63 | 64 | [jobs.win] 65 | command = [ 66 | "cross", "build", 67 | "--target", "x86_64-pc-windows-gnu", 68 | ] 69 | 70 | [jobs.test] 71 | command = ["cargo", "test"] 72 | need_stdout = true 73 | on_success = "play-sound(name=90s-game-ui-6,volume=50)" 74 | on_failure = "play-sound(name=beep-warning,volume=100)" 75 | 76 | [jobs.doc] 77 | command = ["cargo", "doc", "--no-deps"] 78 | need_stdout = false 79 | 80 | # If the doc compiles, then it opens in your browser and bacon switches 81 | # to the previous job 82 | [jobs.doc-open] 83 | command = ["cargo", "doc", "--no-deps", "--open"] 84 | need_stdout = false 85 | on_success = "back" # so that we don't open the browser at each change 86 | 87 | [jobs.clippy-all] 88 | command = [ 89 | "cargo", "clippy", 90 | "--", 91 | "-A", "clippy::bool_to_int_with_if", 92 | "-A", "clippy::collapsible_else_if", 93 | "-A", "clippy::collapsible_if", 94 | "-A", "clippy::derive_partial_eq_without_eq", 95 | "-A", "clippy::get_first", 96 | "-A", "clippy::if_same_then_else", 97 | "-A", "clippy::len_without_is_empty", 98 | "-A", "clippy::map_entry", 99 | "-A", "clippy::while_let_on_iterator", 100 | "-A", "clippy::new_without_default", 101 | ] 102 | need_stdout = false 103 | 104 | # Pedantic job refined for bacon's source code. 105 | # 106 | # The goal of the Allow here isn't to have a warning free code 107 | # (a pedantic session can still dismiss items) but to exclude 108 | # the lints which are, for this project, considered as bad. 109 | [jobs.pedantic] 110 | command = [ 111 | "cargo", "clippy", 112 | "--", 113 | "-W", "clippy::pedantic", 114 | "-A", "clippy::struct_excessive_bools", 115 | "-A", "clippy::default_trait_access", 116 | "-A", "clippy::must_use_candidate", 117 | "-A", "clippy::return_self_not_must_use", 118 | "-A", "clippy::get_first", 119 | "-A", "clippy::len_without_is_empty", 120 | "-A", "clippy::collapsible_else_if", 121 | "-A", "clippy::collapsible_if", 122 | "-A", "clippy::new_without_default", 123 | "-A", "clippy::if_not_else", 124 | ] 125 | need_stdout = false 126 | 127 | [skin] 128 | menu_bg = 3 129 | change_badge_bg = 5 130 | 131 | [keybindings] 132 | c = "job:clippy-all" 133 | ctrl-c = "copy-unstyled-output" 134 | #n = "next-match" 135 | #shift-n = "previous-match" 136 | #h = "play-sound(name=car-horn)" 137 | #b = "play-sound(name=beep-6)" 138 | f = "job:fmt" 139 | w = "job:win" 140 | alt-b = "toggle-backtrace(0)" # disable backtrace when set with env var 141 | cmd-e = "play-sound" 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![bacon][logo] 2 | 3 | [logo]: img/logo-text.png?raw=true "bacon" 4 | 5 | [![Latest Version][s1]][l1] [![site][s4]][l4] [![Chat on Miaou][s2]][l2] [![License: AGPL v3][s3]][l3] 6 | 7 | [s1]: https://img.shields.io/crates/v/bacon.svg 8 | [l1]: https://crates.io/crates/bacon 9 | 10 | [s2]: https://dystroy.org/chat-shield.svg 11 | [l2]: https://miaou.dystroy.org/4683?bacon 12 | 13 | [s3]: https://img.shields.io/badge/License-AGPL_v3-blue.svg 14 | [l3]: https://www.gnu.org/licenses/agpl-3.0 15 | 16 | [s4]: https://dystroy.org/dystroy-doc-pink-shield.svg 17 | [l4]: https://dystroy.org/bacon 18 | 19 | **bacon** is a background code checker. 20 | 21 | It's designed for minimal interaction so that you can just let it run, alongside your editor, and be notified of warnings, errors, or test failures in your Rust code. 22 | 23 | ![screenshot](doc/screenshot.png) 24 | 25 | # Documentation 26 | 27 | The **[bacon website](https://dystroy.org/bacon)** is a complete guide. 28 | 29 | Below is only a short overview. 30 | 31 | ## install 32 | 33 | cargo install --locked bacon 34 | 35 | Run this command too if you want to update bacon. Configuration has always been backward compatible so you won't lose anything. 36 | 37 | Some features are disabled by default. You may enable them with 38 | 39 | cargo install --features "clipboard sound" 40 | 41 | ## check the current project 42 | 43 | bacon 44 | 45 | That's how you'll usually launch bacon, because other jobs like `test`, `clippy`, `doc`, your own ones, are just a key away: You'll hit <kbd>c</kbd> to see Clippy warnings, <kbd>t</kbd> for the tests, <kbd>d</kbd> to open the documentation, etc. 46 | 47 | 48 | ## check another project 49 | 50 | bacon --path ../broot 51 | 52 | or 53 | 54 | bacon ../broot 55 | 56 | ## check all targets (tests, examples, benches, etc) 57 | 58 | bacon --job check-all 59 | 60 | When there's no ambiguity, you may omit the `--job` part: 61 | 62 | bacon check-all 63 | 64 | ## run clippy instead of cargo check 65 | 66 | bacon clippy 67 | 68 | This will run against all targets like `check-all` does. 69 | 70 | ## run tests 71 | 72 | bacon test 73 | 74 | or `bacon nextest` if you're a nextest user. 75 | 76 | ![bacon test](doc/test.png) 77 | 78 | 79 | When there's a failure, hit <kbd>f</kbd> to restrict the job to the failing test. 80 | Hit <kbd>esc</kbd> to get back to all tests. 81 | 82 | ## define your own jobs 83 | 84 | First create a `bacon.toml` file by running 85 | 86 | bacon --init 87 | 88 | This file already contains some standard jobs. Add your own, for example 89 | 90 | ```toml 91 | [jobs.check-win] 92 | command = ["cargo", "check", "--target", "x86_64-pc-windows-gnu"] 93 | ``` 94 | 95 | or 96 | 97 | ```toml 98 | [jobs.check-examples] 99 | command = ["cargo", "check", "--examples"] 100 | watch = ["examples"] # src is implicitly included 101 | ``` 102 | 103 | and run 104 | 105 | bacon check-win 106 | 107 | or 108 | 109 | bacon check-examples 110 | 111 | The `bacon.toml` file may evolve with the features and settings of your project and should be added to source control. 112 | 113 | ## Optional features 114 | 115 | Some bacon features can be disabled or enabled at compilation: 116 | 117 | * `"clipboard"` - disabled by default : necessary for the `copy-unstyled-output` action 118 | * `"sound"` - disabled by default : necessary for the `play-sound` action 119 | 120 | ## Licences 121 | 122 | Bacon is licenced under [AGPL-3.0](https://www.gnu.org/licenses/agpl-3.0.en.html). 123 | You're free to use it to compile the Rust projects of your choice, even commercial. 124 | 125 | The logo is designed by [Peter Varo][pv] and licensed under a 126 | [Creative Commons Attribution-ShareAlike 4.0 International License][cc-lic]. 127 | [![license][cc-img]][cc-lic] 128 | 129 | [pv]: https://petervaro.com 130 | [cc-lic]: https://creativecommons.org/licenses/by-sa/4.0 131 | [cc-img]: https://i.creativecommons.org/l/by-sa/4.0/80x15.png 132 | -------------------------------------------------------------------------------- /defaults/default-prefs.toml: -------------------------------------------------------------------------------- 1 | #:schema https://dystroy.org/bacon/.bacon.schema.json 2 | 3 | # This is a preferences file for the bacon tool 4 | # More info at https://github.com/Canop/bacon 5 | 6 | # Uncomment to have bacon listen for commands on 7 | # a 'bacon.socket' unix socket (on unix) 8 | # listen = true 9 | 10 | # Uncomment and change the value (true/false) to 11 | # specify whether bacon should start in summary mode 12 | # 13 | # summary = true 14 | 15 | 16 | # Uncomment and change the value (true/false) to 17 | # specify whether bacon should start with lines wrapped 18 | # 19 | # wrap = false 20 | 21 | 22 | # In "reverse" mode, the focus is at the bottom, item 23 | # order is reversed, and the status bar is on top 24 | # 25 | # reverse = true 26 | 27 | 28 | # The grace period is a delay after a file event before the real 29 | # task is launched and during which other events will be ignored. 30 | # This is mostly useful when your editor does several operations 31 | # when saving a file and the state is temporarily wrong (eg it 32 | # moves the file to a backup name before recreating the right one) 33 | # You can set it to "none" if it's useless for you. 34 | # 35 | # grace_period = "15ms" 36 | 37 | 38 | # Uncomment and change the value (true/false) to 39 | # specify whether bacon should show a help line. 40 | # 41 | # help_line = false 42 | 43 | 44 | # Uncomment and change the value (true/false) to 45 | # set whether to display the count of changes since last job start 46 | # 47 | # show_changes_count = false 48 | 49 | 50 | # Uncomment one of those lines if you don't want the default 51 | # behavior triggered by a file change. This property can also 52 | # be set directly in a specific job. 53 | # 54 | # on_change_strategy = "kill_then_restart" 55 | # on_change_strategy = "wait_then_restart" 56 | 57 | 58 | # Exporting "locations" (by setting its 'auto' to true) lets you use 59 | # them in an external tool, for example as a list of jump locations 60 | # in an IDE or in a language server. 61 | # (See https://dystroy.org/bacon/config/#export-locations), 62 | # 63 | # Possible line_format parts: 64 | # - kind: warning|error|test 65 | # - path: complete absolute path to the file 66 | # - line: 1-based line number 67 | # - column: 1-based column 68 | # - message: description of the item 69 | # - context: unstyled lines of output, separated with escaped newlines (`\\n`) 70 | [exports.locations] 71 | auto = false 72 | exporter = "locations" 73 | path = ".bacon-locations" 74 | line_format = "{kind} {path}:{line}:{column} {message}" 75 | 76 | 77 | # If you want some job to emit a beep on success or on failure, 78 | # you need to globally enable sound, and you may set up the max volume here 79 | # 80 | # With sound enabled, you may set up sound on a job with eg 81 | # on_success = "play-sound(name=90s-game-ui-6,volume=50)" 82 | # on_failure = "play-sound(name=beep-warning,volume=100)" 83 | [sound] 84 | enabled = false # set true to allow sound 85 | base_volume = "100%" # global volume multiplier 86 | 87 | # Uncomment and change the key-bindings you want to define 88 | # (some of those ones are the defaults and are just here for illustration) 89 | [keybindings] 90 | # esc = "back" 91 | # g = "scroll-to-top" 92 | # shift-g = "scroll-to-bottom" 93 | # k = "scroll-lines(-1)" 94 | # j = "scroll-lines(1)" 95 | # ctrl-c = "quit" 96 | # ctrl-c = "copy-unstyled-output" 97 | # ctrl-q = "quit" 98 | # q = "quit" 99 | # F5 = "rerun" 100 | # alt-s = "toggle-summary" 101 | # alt-w = "toggle-wrap" 102 | # alt-b = "toggle-backtrace" 103 | # Home = "scroll-to-top" 104 | # End = "scroll-to-bottom" 105 | # Up = "scroll-lines(-1)" 106 | # Down = "scroll-lines(1)" 107 | # PageUp = "scroll-pages(-1)" 108 | # PageDown = "scroll-pages(1)" 109 | # Space = "scroll-pages(1)" 110 | # a = "job:check-all" 111 | # i = "job:initial" 112 | # c = "job:clippy" 113 | # c = "job:clippy-all" 114 | # d = "job:doc-open" 115 | # t = "job:test" 116 | # r = "job:run" 117 | # ctrl-e = "export:analysis" 118 | 119 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | mod completions; 3 | 4 | pub use args::*; 5 | 6 | use { 7 | crate::*, 8 | anyhow::anyhow, 9 | clap::{ 10 | CommandFactory, 11 | Parser, 12 | }, 13 | schemars::schema_for, 14 | std::{ 15 | fs, 16 | io::Write, 17 | }, 18 | termimad::crossterm::{ 19 | QueueableCommand, 20 | cursor, 21 | terminal::{ 22 | EnterAlternateScreen, 23 | LeaveAlternateScreen, 24 | }, 25 | }, 26 | }; 27 | 28 | #[cfg(windows)] 29 | use termimad::crossterm::event::{ 30 | DisableMouseCapture, 31 | EnableMouseCapture, 32 | }; 33 | 34 | /// The Write type used by all GUI writing functions 35 | pub type W = std::io::BufWriter<std::io::Stdout>; 36 | 37 | /// return the writer used by the application 38 | pub fn writer() -> W { 39 | std::io::BufWriter::new(std::io::stdout()) 40 | } 41 | 42 | pub fn run() -> anyhow::Result<()> { 43 | if std::env::var_os("COMPLETE").is_some() { 44 | clap_complete::CompleteEnv::with_factory(Args::command).complete(); 45 | } 46 | 47 | let mut args: Args = Args::parse(); 48 | args.fix()?; 49 | info!("args: {:#?}", &args); 50 | let headless = args.headless; 51 | 52 | if args.help { 53 | args.print_help(); 54 | return Ok(()); 55 | } 56 | 57 | if args.version { 58 | println!("bacon {}", env!("CARGO_PKG_VERSION")); 59 | return Ok(()); 60 | } 61 | 62 | if args.generate_config_schema { 63 | let schema = schema_for!(Config); 64 | let json = serde_json::to_string_pretty(&schema)?; 65 | println!("{json}"); 66 | return Ok(()); 67 | } 68 | 69 | if args.prefs { 70 | let prefs_path = 71 | bacon_prefs_path().ok_or(anyhow!("No preferences location known for this system."))?; 72 | if !prefs_path.exists() { 73 | fs::create_dir_all(prefs_path.parent().unwrap())?; 74 | fs::write(&prefs_path, DEFAULT_PREFS.trim_start())?; 75 | // written to stderr to allow initialization with commands like 76 | // $EDITOR "$(bacon --prefs)" 77 | eprintln!("Preferences file written."); 78 | } 79 | println!("{}", prefs_path.to_string_lossy()); 80 | return Ok(()); 81 | } 82 | 83 | let context = Context::new(&args)?; 84 | debug!("mission context: {:#?}", &context); 85 | 86 | if args.init { 87 | let package_config_path = context.package_config_path(); 88 | if !package_config_path.exists() { 89 | fs::write(&package_config_path, DEFAULT_PACKAGE_CONFIG.trim_start())?; 90 | eprintln!("bacon project configuration file written."); 91 | } else { 92 | eprintln!("bacon configuration file already exists."); 93 | } 94 | println!("{}", package_config_path.to_string_lossy()); 95 | return Ok(()); 96 | } 97 | 98 | #[cfg(unix)] 99 | if let Some(action) = &args.send { 100 | socket::send_action(&context, action)?; 101 | return Ok(()); 102 | } 103 | 104 | let settings = Settings::read(&args, &context)?; 105 | 106 | if args.list_jobs { 107 | print_jobs(&settings); 108 | return Ok(()); 109 | } 110 | if args.completion_list_jobs { 111 | let mut keys = settings.jobs.keys().cloned().collect::<Vec<_>>(); 112 | keys.sort(); 113 | for job in keys { 114 | print!("{job}\0"); 115 | } 116 | return Ok(()); 117 | } 118 | 119 | let mut w = writer(); 120 | if !headless { 121 | w.queue(EnterAlternateScreen)?; 122 | w.queue(cursor::Hide)?; 123 | #[cfg(windows)] 124 | w.queue(EnableMouseCapture)?; 125 | w.flush()?; 126 | } 127 | let result = tui::app::run(&mut w, settings, &args, &context, headless); 128 | if !headless { 129 | #[cfg(windows)] 130 | w.queue(DisableMouseCapture)?; 131 | w.queue(cursor::Show)?; 132 | w.queue(LeaveAlternateScreen)?; 133 | } 134 | w.flush()?; 135 | result 136 | } 137 | -------------------------------------------------------------------------------- /src/analysis/swift/build.rs: -------------------------------------------------------------------------------- 1 | //! Analyzer for swift build. 2 | //! 3 | //! # Config 4 | //! 5 | //! A sample bacon.toml: 6 | //! 7 | //! ```toml 8 | //! default_job = "swift_build" 9 | //! 10 | //! [keybindings] 11 | //! b = "job:swift_build" 12 | //! 13 | //! [jobs.swift_build] 14 | //! command = ["swift", "build"] 15 | //! watch = ["Sources"] 16 | //! need_stdout = true 17 | //! analyzer = "swift_build" 18 | //! ``` 19 | //! 20 | //! # Caveats 21 | //! 22 | //! This analyzer processes warnings, but `swift build` is incremental by default, and so warnings 23 | //! will not be consistent. Consider treating warnings as errors if this is a problem. 24 | 25 | use crate::{ 26 | Analyzer, 27 | CommandOutputLine, 28 | ItemAccumulator, 29 | Kind, 30 | LineType, 31 | TLine, 32 | TString, 33 | burp, 34 | }; 35 | 36 | use super::parse_swift_location; 37 | 38 | #[derive(Debug, Default)] 39 | pub struct SwiftBuildAnalyzer { 40 | lines: Vec<CommandOutputLine>, 41 | } 42 | 43 | impl Analyzer for SwiftBuildAnalyzer { 44 | fn start( 45 | &mut self, 46 | _: &crate::Mission, 47 | ) { 48 | self.lines.clear(); 49 | } 50 | 51 | fn receive_line( 52 | &mut self, 53 | line: CommandOutputLine, 54 | command_output: &mut crate::CommandOutput, 55 | ) { 56 | self.lines.push(line.clone()); 57 | command_output.push(line); 58 | } 59 | 60 | fn build_report(&mut self) -> anyhow::Result<crate::Report> { 61 | let mut items = ItemAccumulator::default(); 62 | 63 | for line in &self.lines { 64 | if let Some(diagnostic) = recognize_swift_diagnostic(&line.content) { 65 | let content = match diagnostic.level { 66 | Kind::Error => burp::error_line_ts(&diagnostic.message), 67 | Kind::Warning => burp::warning_line_ts(&diagnostic.message), 68 | _ => unreachable!(), 69 | }; 70 | 71 | items.start_item(diagnostic.level); 72 | items.push_line(LineType::Title(diagnostic.level), content); 73 | items.push_line( 74 | LineType::Location, 75 | burp::location_line(format!( 76 | "{}:{}:{}", 77 | diagnostic.path, diagnostic.line, diagnostic.column 78 | )), 79 | ); 80 | } else { 81 | items.push_line(LineType::Normal, line.content.clone()); 82 | } 83 | } 84 | 85 | Ok(items.report()) 86 | } 87 | } 88 | 89 | struct SwiftDiagnostic<'a> { 90 | level: Kind, 91 | path: &'a str, 92 | line: &'a str, 93 | column: &'a str, 94 | message: Vec<TString>, 95 | } 96 | 97 | fn recognize_swift_diagnostic(line: &TLine) -> Option<SwiftDiagnostic<'_>> { 98 | // Look for Swift format: path:line:column: (error|warning): message 99 | let content = line.if_unstyled()?; 100 | 101 | if let Some(error_pos) = content.find(": error: ") { 102 | let location_part = &content[..error_pos]; 103 | let message_part = &content[error_pos + ": error: ".len()..]; 104 | 105 | if let Some((path, line_num, column)) = parse_swift_location(location_part) { 106 | return Some(SwiftDiagnostic { 107 | level: Kind::Error, 108 | path, 109 | line: line_num, 110 | column, 111 | message: vec![TString::new("", message_part)], 112 | }); 113 | } 114 | } else if let Some(warning_pos) = content.find(": warning: ") { 115 | let location_part = &content[..warning_pos]; 116 | let message_part = &content[warning_pos + ": warning: ".len()..]; 117 | 118 | if let Some((path, line_num, column)) = parse_swift_location(location_part) { 119 | return Some(SwiftDiagnostic { 120 | level: Kind::Warning, 121 | path, 122 | line: line_num, 123 | column, 124 | message: vec![TString::new("", message_part)], 125 | }); 126 | } 127 | } 128 | 129 | None 130 | } 131 | -------------------------------------------------------------------------------- /img/logo-text.svg: -------------------------------------------------------------------------------- 1 | <?xml 2 | version="1.0" 3 | encoding="UTF-8" 4 | standalone="no" 5 | ?> 6 | <!-- 7 | Copyright (C) 2020 Peter Varo <hello@petervaro.com> 8 | This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 9 | International License. (CC-BY-SA) For more information about the license 10 | visit <https://creativecommons.org/licenses/by-sa/4.0> 11 | --> 12 | <!DOCTYPE 13 | svg 14 | PUBLIC 15 | "-//W3C//DTD SVG 1.1//EN" 16 | "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" 17 | > 18 | <svg 19 | version="1.1" 20 | xmlns="http://www.w3.org/2000/svg" 21 | xmlns:xlink="http://www.w3.org/1999/xlink" 22 | xml:space="preserve" 23 | width="100%" 24 | height="100%" 25 | viewBox="0 0 143 43" 26 | > 27 | <g id="logo"> 28 | <path 29 | id="letter-b" 30 | style="fill:#000000" 31 | d="M 5 5 L 5 36.816406 L 10.248047 36.816406 L 10.248047 33.759766 C 12.699859 36.385988 14.97869 37.375 18.505859 37.375 C 25.216082 37.375 30.121094 31.908574 30.121094 24.71875 C 30.121094 17.528926 24.958407 12.103516 18.119141 12.103516 C 15.022114 12.103516 12.914429 12.964461 10.720703 15.160156 L 10.720703 5 L 5 5 z M 17.302734 17.355469 C 21.389088 17.355469 24.355469 20.499488 24.355469 24.761719 C 24.355469 29.110056 21.517995 32.123047 17.431641 32.123047 C 13.302274 32.123047 10.376953 29.067087 10.376953 24.71875 C 10.376953 20.413466 13.302409 17.355469 17.302734 17.355469 z " 32 | /> 33 | <path 34 | id="letter-a" 35 | style="fill:#000000" 36 | d="M 44.632812 12.404297 C 37.578476 12.404297 32.503906 17.700632 32.503906 24.976562 C 32.503906 32.166387 37.535596 37.375 44.503906 37.375 C 47.859016 37.375 50.138213 36.299968 52.417969 33.716797 L 52.417969 36.816406 L 57.666016 36.816406 L 57.666016 12.964844 L 52.417969 12.964844 L 52.417969 16.150391 C 50.439313 13.524168 48.073952 12.404297 44.632812 12.404297 z M 45.234375 17.658203 C 49.320727 17.658203 52.246094 20.714164 52.246094 25.0625 C 52.246094 29.023361 49.666705 32.123047 45.322266 32.123047 C 41.14988 32.123047 38.267578 29.281594 38.267578 25.105469 C 38.267578 20.757132 41.148019 17.658203 45.234375 17.658203 z " 37 | /> 38 | <path 39 | id="letter-c" 40 | style="fill:#000000" 41 | d="m 60.746459,24.890412 c 0,6.931507 5.677879,12.485321 12.732216,12.485321 5.548839,0 9.850265,-3.05675 11.828921,-8.395302 h -6.452138 c -1.419471,2.195695 -3.054015,3.142857 -5.419796,3.142857 -3.957313,0 -6.925294,-3.099804 -6.925294,-7.275929 0,-4.176126 2.838938,-7.189824 6.79625,-7.189824 2.580856,0 4.387456,1.076321 5.54884,3.272015 h 6.452138 c -1.849613,-5.46771 -6.581181,-8.524462 -12.000978,-8.524462 -7.054336,0 -12.560159,5.467711 -12.560159,12.485324 z" 42 | /> 43 | <path 44 | id="letter-o" 45 | style="fill:#ff8080" 46 | d="M 87.642164,24.890412 C 87.642164,32.123288 93.603942,38 100.92066,38 c 7.27156,0 13.32367,-5.876712 13.32367,-12.928766 0,-7.458905 -5.82628,-13.290412 -13.36883,-13.290412 -7.271558,0 -13.233336,5.921918 -13.233336,13.10959 z" 47 | /> 48 | <path 49 | id="left-nostril" 50 | style="fill:#000000" 51 | d="m 98.990804,24.890409 a 2.1980491,3.5639496 0 0 1 -2.198049,3.56395 2.1980491,3.5639496 0 0 1 -2.198049,-3.56395 2.1980491,3.5639496 0 0 1 2.198049,-3.563949 2.1980491,3.5639496 0 0 1 2.198049,3.563949 z" 52 | /> 53 | <path 54 | id="right-nostril" 55 | style="fill:#000000;stroke-width:0.999998" 56 | d="m 107.29178,24.890409 a 2.1980491,3.5639496 0 0 1 -2.19805,3.56395 2.1980491,3.5639496 0 0 1 -2.19805,-3.56395 2.1980491,3.5639496 0 0 1 2.19805,-3.563949 2.1980491,3.5639496 0 0 1 2.19805,3.563949 z" 57 | /> 58 | <path 59 | id="letter-n" 60 | style="fill:#000000" 61 | d="m 116.83699,36.816046 h 5.72089 V 25.579257 c 0,-3.18591 0.21509,-4.563601 0.94632,-5.726028 0.9033,-1.420743 2.4088,-2.195694 4.21539,-2.195694 2.83894,0 4.55951,1.119373 4.55951,7.491194 V 36.816046 H 138 V 24.029355 c 0,-4.262231 -0.43014,-6.328768 -1.72057,-8.18004 -1.54851,-2.238747 -4.12937,-3.444227 -7.44147,-3.444227 -2.70989,0 -4.60252,0.818004 -6.71022,2.841487 v -2.2818 h -5.29075 z" 62 | /> 63 | </g> 64 | </svg> 65 | -------------------------------------------------------------------------------- /website/src/img/logo-white.svg: -------------------------------------------------------------------------------- 1 | <?xml 2 | version="1.0" 3 | encoding="UTF-8" 4 | standalone="no" 5 | ?> 6 | <!-- 7 | derived from 2020 Peter Varo <hello@petervaro.com> 8 | This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 9 | International License. (CC-BY-SA) For more information about the license 10 | visit <https://creativecommons.org/licenses/by-sa/4.0> 11 | --> 12 | <!DOCTYPE 13 | svg 14 | PUBLIC 15 | "-//W3C//DTD SVG 1.1//EN" 16 | "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" 17 | > 18 | <svg 19 | version="1.1" 20 | xmlns="http://www.w3.org/2000/svg" 21 | xmlns:xlink="http://www.w3.org/1999/xlink" 22 | xml:space="preserve" 23 | width="100%" 24 | height="100%" 25 | viewBox="0 0 143 43" 26 | > 27 | <g id="logo"> 28 | <path 29 | id="letter-b" 30 | style="fill:#ffffff" 31 | d="M 5 5 L 5 36.816406 L 10.248047 36.816406 L 10.248047 33.759766 C 12.699859 36.385988 14.97869 37.375 18.505859 37.375 C 25.216082 37.375 30.121094 31.908574 30.121094 24.71875 C 30.121094 17.528926 24.958407 12.103516 18.119141 12.103516 C 15.022114 12.103516 12.914429 12.964461 10.720703 15.160156 L 10.720703 5 L 5 5 z M 17.302734 17.355469 C 21.389088 17.355469 24.355469 20.499488 24.355469 24.761719 C 24.355469 29.110056 21.517995 32.123047 17.431641 32.123047 C 13.302274 32.123047 10.376953 29.067087 10.376953 24.71875 C 10.376953 20.413466 13.302409 17.355469 17.302734 17.355469 z " 32 | /> 33 | <path 34 | id="letter-a" 35 | style="fill:#ffffff" 36 | d="M 44.632812 12.404297 C 37.578476 12.404297 32.503906 17.700632 32.503906 24.976562 C 32.503906 32.166387 37.535596 37.375 44.503906 37.375 C 47.859016 37.375 50.138213 36.299968 52.417969 33.716797 L 52.417969 36.816406 L 57.666016 36.816406 L 57.666016 12.964844 L 52.417969 12.964844 L 52.417969 16.150391 C 50.439313 13.524168 48.073952 12.404297 44.632812 12.404297 z M 45.234375 17.658203 C 49.320727 17.658203 52.246094 20.714164 52.246094 25.0625 C 52.246094 29.023361 49.666705 32.123047 45.322266 32.123047 C 41.14988 32.123047 38.267578 29.281594 38.267578 25.105469 C 38.267578 20.757132 41.148019 17.658203 45.234375 17.658203 z " 37 | /> 38 | <path 39 | id="letter-c" 40 | style="fill:#ffffff" 41 | d="m 60.746459,24.890412 c 0,6.931507 5.677879,12.485321 12.732216,12.485321 5.548839,0 9.850265,-3.05675 11.828921,-8.395302 h -6.452138 c -1.419471,2.195695 -3.054015,3.142857 -5.419796,3.142857 -3.957313,0 -6.925294,-3.099804 -6.925294,-7.275929 0,-4.176126 2.838938,-7.189824 6.79625,-7.189824 2.580856,0 4.387456,1.076321 5.54884,3.272015 h 6.452138 c -1.849613,-5.46771 -6.581181,-8.524462 -12.000978,-8.524462 -7.054336,0 -12.560159,5.467711 -12.560159,12.485324 z" 42 | /> 43 | <path 44 | id="letter-o" 45 | style="fill:#ff8080" 46 | d="M 87.642164,24.890412 C 87.642164,32.123288 93.603942,38 100.92066,38 c 7.27156,0 13.32367,-5.876712 13.32367,-12.928766 0,-7.458905 -5.82628,-13.290412 -13.36883,-13.290412 -7.271558,0 -13.233336,5.921918 -13.233336,13.10959 z" 47 | /> 48 | <path 49 | id="left-nostril" 50 | style="fill:#000" 51 | d="m 98.990804,24.890409 a 2.1980491,3.5639496 0 0 1 -2.198049,3.56395 2.1980491,3.5639496 0 0 1 -2.198049,-3.56395 2.1980491,3.5639496 0 0 1 2.198049,-3.563949 2.1980491,3.5639496 0 0 1 2.198049,3.563949 z" 52 | /> 53 | <path 54 | id="right-nostril" 55 | style="fill:#000;stroke-width:0.999998" 56 | d="m 107.29178,24.890409 a 2.1980491,3.5639496 0 0 1 -2.19805,3.56395 2.1980491,3.5639496 0 0 1 -2.19805,-3.56395 2.1980491,3.5639496 0 0 1 2.19805,-3.563949 2.1980491,3.5639496 0 0 1 2.19805,3.563949 z" 57 | /> 58 | <path 59 | id="letter-n" 60 | style="fill:#ffffff" 61 | d="m 116.83699,36.816046 h 5.72089 V 25.579257 c 0,-3.18591 0.21509,-4.563601 0.94632,-5.726028 0.9033,-1.420743 2.4088,-2.195694 4.21539,-2.195694 2.83894,0 4.55951,1.119373 4.55951,7.491194 V 36.816046 H 138 V 24.029355 c 0,-4.262231 -0.43014,-6.328768 -1.72057,-8.18004 -1.54851,-2.238747 -4.12937,-3.444227 -7.44147,-3.444227 -2.70989,0 -4.60252,0.818004 -6.71022,2.841487 v -2.2818 h -5.29075 z" 62 | /> 63 | </g> 64 | </svg> 65 | -------------------------------------------------------------------------------- /website/src/img/logo.svg: -------------------------------------------------------------------------------- 1 | <?xml 2 | version="1.0" 3 | encoding="UTF-8" 4 | standalone="no" 5 | ?> 6 | <!-- 7 | Copyright (C) 2020 Peter Varo <hello@petervaro.com> 8 | This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 9 | International License. (CC-BY-SA) For more information about the license 10 | visit <https://creativecommons.org/licenses/by-sa/4.0> 11 | --> 12 | <!DOCTYPE 13 | svg 14 | PUBLIC 15 | "-//W3C//DTD SVG 1.1//EN" 16 | "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" 17 | > 18 | <svg 19 | version="1.1" 20 | xmlns="http://www.w3.org/2000/svg" 21 | xmlns:xlink="http://www.w3.org/1999/xlink" 22 | xml:space="preserve" 23 | width="100%" 24 | height="100%" 25 | viewBox="0 0 143 43" 26 | > 27 | <g id="logo"> 28 | <path 29 | id="letter-b" 30 | style="fill:#000000" 31 | d="M 5 5 L 5 36.816406 L 10.248047 36.816406 L 10.248047 33.759766 C 12.699859 36.385988 14.97869 37.375 18.505859 37.375 C 25.216082 37.375 30.121094 31.908574 30.121094 24.71875 C 30.121094 17.528926 24.958407 12.103516 18.119141 12.103516 C 15.022114 12.103516 12.914429 12.964461 10.720703 15.160156 L 10.720703 5 L 5 5 z M 17.302734 17.355469 C 21.389088 17.355469 24.355469 20.499488 24.355469 24.761719 C 24.355469 29.110056 21.517995 32.123047 17.431641 32.123047 C 13.302274 32.123047 10.376953 29.067087 10.376953 24.71875 C 10.376953 20.413466 13.302409 17.355469 17.302734 17.355469 z " 32 | /> 33 | <path 34 | id="letter-a" 35 | style="fill:#000000" 36 | d="M 44.632812 12.404297 C 37.578476 12.404297 32.503906 17.700632 32.503906 24.976562 C 32.503906 32.166387 37.535596 37.375 44.503906 37.375 C 47.859016 37.375 50.138213 36.299968 52.417969 33.716797 L 52.417969 36.816406 L 57.666016 36.816406 L 57.666016 12.964844 L 52.417969 12.964844 L 52.417969 16.150391 C 50.439313 13.524168 48.073952 12.404297 44.632812 12.404297 z M 45.234375 17.658203 C 49.320727 17.658203 52.246094 20.714164 52.246094 25.0625 C 52.246094 29.023361 49.666705 32.123047 45.322266 32.123047 C 41.14988 32.123047 38.267578 29.281594 38.267578 25.105469 C 38.267578 20.757132 41.148019 17.658203 45.234375 17.658203 z " 37 | /> 38 | <path 39 | id="letter-c" 40 | style="fill:#000000" 41 | d="m 60.746459,24.890412 c 0,6.931507 5.677879,12.485321 12.732216,12.485321 5.548839,0 9.850265,-3.05675 11.828921,-8.395302 h -6.452138 c -1.419471,2.195695 -3.054015,3.142857 -5.419796,3.142857 -3.957313,0 -6.925294,-3.099804 -6.925294,-7.275929 0,-4.176126 2.838938,-7.189824 6.79625,-7.189824 2.580856,0 4.387456,1.076321 5.54884,3.272015 h 6.452138 c -1.849613,-5.46771 -6.581181,-8.524462 -12.000978,-8.524462 -7.054336,0 -12.560159,5.467711 -12.560159,12.485324 z" 42 | /> 43 | <path 44 | id="letter-o" 45 | style="fill:#ff8080" 46 | d="M 87.642164,24.890412 C 87.642164,32.123288 93.603942,38 100.92066,38 c 7.27156,0 13.32367,-5.876712 13.32367,-12.928766 0,-7.458905 -5.82628,-13.290412 -13.36883,-13.290412 -7.271558,0 -13.233336,5.921918 -13.233336,13.10959 z" 47 | /> 48 | <path 49 | id="left-nostril" 50 | style="fill:#000000" 51 | d="m 98.990804,24.890409 a 2.1980491,3.5639496 0 0 1 -2.198049,3.56395 2.1980491,3.5639496 0 0 1 -2.198049,-3.56395 2.1980491,3.5639496 0 0 1 2.198049,-3.563949 2.1980491,3.5639496 0 0 1 2.198049,3.563949 z" 52 | /> 53 | <path 54 | id="right-nostril" 55 | style="fill:#000000;stroke-width:0.999998" 56 | d="m 107.29178,24.890409 a 2.1980491,3.5639496 0 0 1 -2.19805,3.56395 2.1980491,3.5639496 0 0 1 -2.19805,-3.56395 2.1980491,3.5639496 0 0 1 2.19805,-3.563949 2.1980491,3.5639496 0 0 1 2.19805,3.563949 z" 57 | /> 58 | <path 59 | id="letter-n" 60 | style="fill:#000000" 61 | d="m 116.83699,36.816046 h 5.72089 V 25.579257 c 0,-3.18591 0.21509,-4.563601 0.94632,-5.726028 0.9033,-1.420743 2.4088,-2.195694 4.21539,-2.195694 2.83894,0 4.55951,1.119373 4.55951,7.491194 V 36.816046 H 138 V 24.029355 c 0,-4.262231 -0.43014,-6.328768 -1.72057,-8.18004 -1.54851,-2.238747 -4.12937,-3.444227 -7.44147,-3.444227 -2.70989,0 -4.60252,0.818004 -6.71022,2.841487 v -2.2818 h -5.29075 z" 62 | /> 63 | </g> 64 | </svg> 65 | -------------------------------------------------------------------------------- /src/result/line.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | lazy_regex::regex_captures, 4 | serde::{ 5 | Deserialize, 6 | Serialize, 7 | }, 8 | std::path::PathBuf, 9 | }; 10 | 11 | /// A report line 12 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 13 | pub struct Line { 14 | /// the index among items 15 | /// (all lines having the same index belong to the same error, warning, or test item, 16 | /// or just the same unclassified soup) 17 | pub item_idx: usize, 18 | 19 | pub line_type: LineType, 20 | 21 | pub content: TLine, 22 | } 23 | 24 | impl Line { 25 | /// If the line is a title, get its message 26 | pub fn title_message(&self) -> Option<&str> { 27 | let title = match self.line_type { 28 | LineType::Title(_) => { 29 | if let Some(content) = self.content.if_unstyled() { 30 | Some(content) 31 | } else { 32 | self.content 33 | .strings 34 | .get(1) 35 | .map(|ts| ts.raw.as_str()) 36 | .map(|s| s.trim_start_matches(|c: char| c.is_whitespace() || c == ':')) 37 | } 38 | } 39 | _ => None, 40 | }; 41 | title 42 | } 43 | 44 | pub fn matches( 45 | &self, 46 | summary: bool, 47 | ) -> bool { 48 | !summary || self.line_type.is_summary() 49 | } 50 | 51 | /// Return the location as given by the command 52 | /// 53 | /// It's usually relative and may contain the line and column 54 | pub fn location(&self) -> Option<&str> { 55 | match self.line_type { 56 | LineType::Location => { 57 | // the location part is a string at end like src/truc:15:3 58 | // or src/truc 59 | self.content 60 | .strings 61 | .last() 62 | .and_then(|ts| regex_captures!(r"(\S+)$", ts.raw.as_str())) 63 | .map(|(_, path)| path) 64 | } 65 | _ => None, 66 | } 67 | } 68 | 69 | /// Try to guess the kind of diagnostic (eg "unused_variable") 70 | /// 71 | /// Might be moved to the Analyzer trait in the future to support 72 | /// tools expressing their diagnostics in a different way. 73 | pub fn diag_type(&self) -> Option<&str> { 74 | match self.line_type { 75 | LineType::Title(_) => { 76 | // first string is usually something like "warning" 77 | let second = self.content.strings.get(1)?; 78 | let (_, dt) = regex_captures!(r#"^:?\s*([^:]+)"#, second.raw.as_str(),)?; 79 | Some(dt.trim_end()) 80 | } 81 | LineType::Normal => { 82 | // quite ad-hoc detection for cargo, might be removed 83 | let third = self.content.strings.get(2)?; 84 | if third.raw == "note" { 85 | let fourth = self.content.strings.get(3)?; 86 | let (_, dt) = 87 | regex_captures!(r"^[` :]*#\[warn\((\w+)\)\]", fourth.raw.as_str(),)?; 88 | return Some(dt.trim_end()); 89 | } 90 | None 91 | } 92 | _ => None, 93 | } 94 | } 95 | 96 | /// Return the absolute path to the error/warning/test location 97 | pub fn location_path( 98 | &self, 99 | mission: &Mission, 100 | ) -> Option<PathBuf> { 101 | let location_path = self.location()?; 102 | let mut location_path = PathBuf::from(location_path); 103 | if !location_path.is_absolute() { 104 | location_path = mission.package_directory.join(location_path); 105 | } 106 | Some(location_path) 107 | } 108 | 109 | pub fn is_continuation(&self) -> bool { 110 | matches!(self.line_type, LineType::Continuation { .. }) 111 | } 112 | } 113 | 114 | impl From<CommandOutputLine> for Line { 115 | fn from(col: CommandOutputLine) -> Self { 116 | Line { 117 | item_idx: 0, 118 | content: col.content, 119 | line_type: LineType::Raw(col.origin), 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /defaults/default-bacon.toml: -------------------------------------------------------------------------------- 1 | #:schema https://dystroy.org/bacon/.bacon.schema.json 2 | 3 | # This is a configuration file for the bacon tool 4 | # 5 | # Complete help on configuration: https://dystroy.org/bacon/config/ 6 | # 7 | # You may check the current default at 8 | # https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml 9 | 10 | default_job = "check" 11 | env.CARGO_TERM_COLOR = "always" 12 | 13 | [jobs.check] 14 | command = ["cargo", "check"] 15 | need_stdout = false 16 | 17 | [jobs.check-all] 18 | command = ["cargo", "check", "--all-targets"] 19 | need_stdout = false 20 | 21 | # Run clippy on the default target 22 | [jobs.clippy] 23 | command = ["cargo", "clippy"] 24 | need_stdout = false 25 | 26 | # Run clippy on all targets 27 | # To disable some lints, you may change the job this way: 28 | # [jobs.clippy-all] 29 | # command = [ 30 | # "cargo", "clippy", 31 | # "--all-targets", 32 | # "--", 33 | # "-A", "clippy::bool_to_int_with_if", 34 | # "-A", "clippy::collapsible_if", 35 | # "-A", "clippy::derive_partial_eq_without_eq", 36 | # ] 37 | # need_stdout = false 38 | [jobs.clippy-all] 39 | command = ["cargo", "clippy", "--all-targets"] 40 | need_stdout = false 41 | 42 | # Run clippy in pedantic mode 43 | # The 'dismiss' feature may come handy 44 | [jobs.pedantic] 45 | command = [ 46 | "cargo", "clippy", 47 | "--", 48 | "-W", "clippy::pedantic", 49 | ] 50 | need_stdout = false 51 | 52 | # This job lets you run 53 | # - all tests: bacon test 54 | # - a specific test: bacon test -- config::test_default_files 55 | # - the tests of a package: bacon test -- -- -p config 56 | [jobs.test] 57 | command = ["cargo", "test"] 58 | need_stdout = true 59 | 60 | [jobs.nextest] 61 | command = [ 62 | "cargo", "nextest", "run", 63 | "--hide-progress-bar", "--failure-output", "final" 64 | ] 65 | need_stdout = true 66 | analyzer = "nextest" 67 | 68 | [jobs.doc] 69 | command = ["cargo", "doc", "--no-deps"] 70 | need_stdout = false 71 | 72 | # If the doc compiles, then it opens in your browser and bacon switches 73 | # to the previous job 74 | [jobs.doc-open] 75 | command = ["cargo", "doc", "--no-deps", "--open"] 76 | need_stdout = false 77 | on_success = "back" # so that we don't open the browser at each change 78 | 79 | # You can run your application and have the result displayed in bacon, 80 | # if it makes sense for this crate. 81 | [jobs.run] 82 | command = [ 83 | "cargo", "run", 84 | # put launch parameters for your program behind a `--` separator 85 | ] 86 | need_stdout = true 87 | allow_warnings = true 88 | background = true 89 | 90 | # Run your long-running application (eg server) and have the result displayed in bacon. 91 | # For programs that never stop (eg a server), `background` is set to false 92 | # to have the cargo run output immediately displayed instead of waiting for 93 | # program's end. 94 | # 'on_change_strategy' is set to `kill_then_restart` to have your program restart 95 | # on every change (an alternative would be to use the 'F5' key manually in bacon). 96 | # If you often use this job, it makes sense to override the 'r' key by adding 97 | # a binding `r = job:run-long` at the end of this file . 98 | # A custom kill command such as the one suggested below is frequently needed to kill 99 | # long running programs (uncomment it if you need it) 100 | [jobs.run-long] 101 | command = [ 102 | "cargo", "run", 103 | # put launch parameters for your program behind a `--` separator 104 | ] 105 | need_stdout = true 106 | allow_warnings = true 107 | background = false 108 | on_change_strategy = "kill_then_restart" 109 | # kill = ["pkill", "-TERM", "-P"] 110 | 111 | # This parameterized job runs the example of your choice, as soon 112 | # as the code compiles. 113 | # Call it as 114 | # bacon ex -- my-example 115 | [jobs.ex] 116 | command = ["cargo", "run", "--example"] 117 | need_stdout = true 118 | allow_warnings = true 119 | 120 | # You may define here keybindings that would be specific to 121 | # a project, for example a shortcut to launch a specific job. 122 | # Shortcuts to internal functions (scrolling, toggling, etc.) 123 | # should go in your personal global prefs.toml file instead. 124 | [keybindings] 125 | # alt-m = "job:my-job" 126 | c = "job:clippy-all" # comment this to have 'c' run clippy on only the default target 127 | p = "job:pedantic" 128 | -------------------------------------------------------------------------------- /src/tui/menu/menu_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::Md, 3 | crokey::{ 4 | KeyCombination, 5 | crossterm::event::{ 6 | MouseButton, 7 | MouseEvent, 8 | MouseEventKind, 9 | }, 10 | key, 11 | }, 12 | termimad::Area, 13 | }; 14 | 15 | pub struct MenuItem<I> { 16 | pub action: I, 17 | pub area: Option<Area>, 18 | pub key: Option<KeyCombination>, 19 | } 20 | 21 | pub struct MenuState<I> { 22 | pub intro: Option<String>, 23 | pub items: Vec<MenuItem<I>>, 24 | pub selection: usize, 25 | pub scroll: usize, 26 | } 27 | 28 | impl<I> Default for MenuState<I> { 29 | fn default() -> Self { 30 | Self { 31 | intro: None, 32 | items: Vec::new(), 33 | selection: 0, 34 | scroll: 0, 35 | } 36 | } 37 | } 38 | 39 | impl<I: Md + Clone> MenuState<I> { 40 | pub fn set_intro<S: Into<String>>( 41 | &mut self, 42 | intro: S, 43 | ) { 44 | self.intro = Some(intro.into()); 45 | } 46 | pub fn add_item( 47 | &mut self, 48 | action: I, 49 | key: Option<KeyCombination>, 50 | ) { 51 | self.items.push(MenuItem { 52 | action, 53 | area: None, 54 | key, 55 | }); 56 | } 57 | pub fn clear_item_areas(&mut self) { 58 | for item in self.items.iter_mut() { 59 | item.area = None; 60 | } 61 | } 62 | pub fn select( 63 | &mut self, 64 | selection: usize, 65 | ) { 66 | self.selection = selection.min(self.items.len()); 67 | } 68 | pub(crate) fn fix_scroll( 69 | &mut self, 70 | page_height: usize, 71 | ) { 72 | let len = self.items.len(); 73 | let sel = self.selection; 74 | if len <= page_height || sel < 3 || sel <= page_height / 2 { 75 | self.scroll = 0; 76 | } else if sel + 3 >= len { 77 | self.scroll = len - page_height; 78 | } else { 79 | self.scroll = (sel - 2).min(len - page_height); 80 | } 81 | } 82 | /// Handle a key event (not triggering the actions on their keys, only apply 83 | /// the menu mechanics). 84 | /// 85 | /// Return an optional action and a bool telling whether the event was 86 | /// consumed by the menu. 87 | pub fn on_key( 88 | &mut self, 89 | key: KeyCombination, 90 | ) -> (Option<I>, bool) { 91 | let items = &self.items; 92 | if key == key!(down) { 93 | self.selection = (self.selection + 1) % items.len(); 94 | return (None, true); 95 | } else if key == key!(up) { 96 | self.selection = (self.selection + items.len() - 1) % items.len(); 97 | return (None, true); 98 | } else if key == key!(enter) { 99 | return (Some(items[self.selection].action.clone()), true); 100 | } 101 | for item in &self.items { 102 | if item.key == Some(key) { 103 | return (Some(item.action.clone()), true); 104 | } 105 | } 106 | (None, false) 107 | } 108 | pub fn item_idx_at( 109 | &self, 110 | x: u16, 111 | y: u16, 112 | ) -> Option<usize> { 113 | for (idx, item) in self.items.iter().enumerate() { 114 | if let Some(area) = &item.area { 115 | if area.contains(x, y) { 116 | return Some(idx); 117 | } 118 | } 119 | } 120 | None 121 | } 122 | /// handle a mouse event, returning the triggered action if any (on 123 | /// double click only) 124 | pub fn on_mouse_event( 125 | &mut self, 126 | mouse_event: MouseEvent, 127 | double_click: bool, 128 | ) -> Option<I> { 129 | let is_click = matches!( 130 | mouse_event.kind, 131 | MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left), 132 | ); 133 | if is_click { 134 | if let Some(selection) = self.item_idx_at(mouse_event.column, mouse_event.row) { 135 | self.selection = selection; 136 | if double_click { 137 | return Some(self.items[self.selection].action.clone()); 138 | } 139 | } 140 | } 141 | None 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/conf/config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::*, 4 | lazy_regex::regex_is_match, 5 | schemars::JsonSchema, 6 | serde::Deserialize, 7 | std::{ 8 | collections::HashMap, 9 | fs, 10 | path::Path, 11 | }, 12 | }; 13 | 14 | /// A configuration item which may be stored in various places, eg as `bacon.toml` 15 | /// along a `Cargo.toml` file or as `prefs.toml` in the xdg config directory. 16 | /// 17 | /// Leaf values are options (and not Default) so that they don't 18 | /// override previously set values when applied to settings. 19 | #[derive(Debug, Clone, Deserialize, JsonSchema)] 20 | pub struct Config { 21 | /// Extra arguments appended when a job runs a cargo alias. 22 | pub additional_alias_args: Option<Vec<String>>, 23 | 24 | /// Name of the job to run when no job was requested explicitly. 25 | pub default_job: Option<ConcreteJobRef>, 26 | 27 | /// Default config for a job 28 | #[serde(flatten)] 29 | pub all_jobs: Job, 30 | 31 | /// Deprecated single export configuration; use `exports.locations` instead. 32 | #[deprecated(since = "2.22.0", note = "use exports.locations")] 33 | pub export: Option<ExportConfig>, 34 | 35 | /// Deprecated toggle for the legacy locations export; use `exports.locations.auto` 36 | #[deprecated(since = "2.9.0", note = "use exports.locations.auto")] 37 | pub export_locations: Option<bool>, 38 | 39 | /// Export configurations keyed by their name. 40 | #[serde(default)] 41 | pub exports: HashMap<String, ExportConfig>, 42 | 43 | /// Whether to display the contextual help line 44 | pub help_line: Option<bool>, 45 | 46 | /// Job definitions keyed by their identifier 47 | #[serde(default)] 48 | pub jobs: HashMap<String, Job>, 49 | 50 | /// Custom keybindings layered on top of the defaults 51 | pub keybindings: Option<KeyBindings>, 52 | 53 | /// Whether to display the mission output in reverse order. 54 | pub reverse: Option<bool>, 55 | 56 | /// Whether to show diagnostics summarized instead of full 57 | pub summary: Option<bool>, 58 | 59 | /// Deprecated toggle that enables a built-in set of Vim-style keybindings. Use `keybindings` instead 60 | #[deprecated(since = "2.0.0", note = "use keybindings")] 61 | pub vim_keys: Option<bool>, 62 | 63 | /// Whether to listen for actions on a unix socket (if on unix) 64 | pub listen: Option<bool>, 65 | 66 | /// Whether to wrap long lines 67 | pub wrap: Option<bool>, 68 | } 69 | 70 | impl Config { 71 | /// Load from zero to two configuration items from the provided path which 72 | /// must be in TOML format but may not exist. 73 | /// 74 | /// Expected structures are either bacon config or a cargo.toml file (which 75 | /// may contain a workspace.metadata.bacon key and a package.metadata.bacon key). 76 | pub fn from_path_detect(path: &Path) -> Result<Vec<Self>> { 77 | if !path.exists() { 78 | return Ok(Vec::default()); 79 | } 80 | let file_name = path.file_name().and_then(|f| f.to_str()); 81 | if file_name == Some("Cargo.toml") { 82 | load_config_from_cargo_toml(path) 83 | } else { 84 | Ok(vec![Self::from_path(path)?]) 85 | } 86 | } 87 | /// Load a configuration item filling the provided path in TOML 88 | pub fn from_path(path: &Path) -> Result<Self> { 89 | let conf = toml::from_str::<Self>(&fs::read_to_string(path)?) 90 | .with_context(|| format!("Failed to parse configuration file at {path:?}"))?; 91 | for (name, job) in &conf.jobs { 92 | if !regex_is_match!(r#"^[\w-]+$"#, name) { 93 | bail!("Invalid configuration : Illegal job name : {name:?}"); 94 | } 95 | if job.command.is_empty() { 96 | bail!("Invalid configuration : empty command for job {name:?}"); 97 | } 98 | } 99 | Ok(conf) 100 | } 101 | pub fn default_package_config() -> Self { 102 | toml::from_str(DEFAULT_PACKAGE_CONFIG).unwrap() 103 | } 104 | pub fn default_prefs() -> Self { 105 | toml::from_str(DEFAULT_PREFS).unwrap() 106 | } 107 | } 108 | 109 | #[test] 110 | fn test_default_files() { 111 | let mut settings = Settings::default(); 112 | settings.apply_config(&Config::default_prefs()); 113 | settings.apply_config(&Config::default_package_config()); 114 | settings.check().unwrap(); 115 | } 116 | -------------------------------------------------------------------------------- /src/help/help_page.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::*, 3 | anyhow::Result, 4 | termimad::{ 5 | Area, 6 | CompoundStyle, 7 | FmtText, 8 | MadSkin, 9 | TextView, 10 | crossterm::style::Attribute, 11 | minimad::{ 12 | Alignment, 13 | OwningTemplateExpander, 14 | TextTemplate, 15 | }, 16 | }, 17 | }; 18 | 19 | static TEMPLATE: &str = r" 20 | 21 | # bacon ${version} 22 | 23 | **bac*o*n** is a background compiler, watching your sources and executing your cargo jobs on change. 24 | 25 | See *https://dystroy.org/bacon* for a complete guide. 26 | 27 | |:-:|:-: 28 | |**action**|**shortcut** 29 | |:-|:-: 30 | ${keybindings 31 | |${action}|${keys} 32 | } 33 | |-: 34 | 35 | Key bindings, jobs, and other preferences were loaded from these files: 36 | * internal default configuration files 37 | ${config_files 38 | * ${config_file_path} 39 | } 40 | 41 | 42 | "; 43 | 44 | pub struct HelpPage { 45 | area: Area, 46 | skin: MadSkin, 47 | expander: OwningTemplateExpander<'static>, 48 | template: TextTemplate<'static>, 49 | scroll: usize, 50 | } 51 | 52 | impl HelpPage { 53 | pub fn new(settings: &Settings) -> Self { 54 | let mut skin = MadSkin::default(); 55 | skin.paragraph.align = Alignment::Center; 56 | let key_color = settings.all_jobs.skin.key_fg.color(); 57 | skin.italic = CompoundStyle::new(Some(key_color), None, Attribute::Bold.into()); 58 | skin.table.align = Alignment::Center; 59 | skin.bullet.set_fg(key_color); 60 | let mut expander = OwningTemplateExpander::new(); 61 | expander.set("version", env!("CARGO_PKG_VERSION")); 62 | let mut bindings: Vec<(String, String)> = settings 63 | .keybindings 64 | .build_reverse_map() 65 | .into_iter() 66 | .filter(|(action, _)| **action != Action::NoOp) 67 | .map(|(action, cks)| { 68 | let cks: Vec<String> = cks.iter().map(|ck| format!("*{ck}*")).collect(); 69 | let cks = cks.join(" or "); 70 | (action.md(), cks) 71 | }) 72 | .collect(); 73 | bindings.sort_by(|a, b| a.0.cmp(&b.0)); 74 | for (action, key) in bindings.drain(..) { 75 | expander 76 | .sub("keybindings") 77 | .set_md("keys", key) 78 | .set_md("action", action); 79 | } 80 | for config_file in &settings.config_files { 81 | expander 82 | .sub("config_files") 83 | .set_md("config_file_path", config_file.to_string_lossy()); 84 | } 85 | let template = TextTemplate::from(TEMPLATE); 86 | Self { 87 | area: Area::default(), 88 | skin, 89 | expander, 90 | template, 91 | scroll: 0, 92 | } 93 | } 94 | 95 | /// draw the state on the whole terminal 96 | pub fn draw( 97 | &mut self, 98 | w: &mut W, 99 | area: Area, 100 | ) -> Result<()> { 101 | self.area = area; 102 | let text = self.expander.expand(&self.template); 103 | let fmt_text = FmtText::from_text(&self.skin, text, Some((self.area.width - 1) as usize)); 104 | let mut text_view = TextView::from(&self.area, &fmt_text); 105 | self.scroll = text_view.set_scroll(self.scroll); 106 | Ok(text_view.write_on(w)?) 107 | } 108 | 109 | pub fn apply_scroll_command( 110 | &mut self, 111 | cmd: ScrollCommand, 112 | ) { 113 | let text = self.expander.expand(&self.template); 114 | let fmt_text = FmtText::from_text(&self.skin, text, Some((self.area.width - 1) as usize)); 115 | let mut text_view = TextView::from(&self.area, &fmt_text); 116 | text_view.set_scroll(self.scroll); 117 | match cmd { 118 | ScrollCommand::Top => { 119 | text_view.scroll = 0; 120 | } 121 | ScrollCommand::Bottom => { 122 | text_view.set_scroll(text_view.content_height()); 123 | } 124 | ScrollCommand::Lines(lines) => { 125 | text_view.try_scroll_lines(lines); 126 | } 127 | ScrollCommand::MilliPages(milli_pages) => { 128 | text_view.try_scroll_pages(f64::from(milli_pages) / 1000f64); 129 | } 130 | } 131 | self.scroll = text_view.scroll; 132 | } 133 | } 134 | --------------------------------------------------------------------------------