diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b794d6..9c3f59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### next - support for nextest output & default nextest job, bound by default to the 'n' key - Fix #196 +- hit 'f' to have tests "scoped" to the failures - Fix #214 - new `exports` structure in configuration. New `analysis` export bound by default to `ctrl-e`. The old syntax defining locations export is still supported but won't appear in documentations anymore. - recognize panic location in test - Fix #208 - lines to ignore can be specified as a set of regular expressions in a `ignored_lines` field either in the job or at the top of the prefs or bacon.toml - Fix #223 diff --git a/README.md b/README.md index e35e8ba..fdf0ccf 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,14 @@ This will run against all targets like `check-all` does. bacon test +or `bacon nextest` if you're a nextest user. + ![bacon test](doc/test.png) + +When there's a failure, hit f to restrict the job to the failing test. +Hit esc to get back to all tests. + ## define your own jobs First create a `bacon.toml` file by running diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml index 84d8387..c8d5cdd 100644 --- a/defaults/default-prefs.toml +++ b/defaults/default-prefs.toml @@ -75,10 +75,9 @@ path = "bacon-analysis.json" # ctrl-q = "quit" # q = "quit" # F5 = "rerun" -# s = "toggle-summary" -# w = "toggle-wrap" -# b = "toggle-backtrace" -# f = "toggle-backtrace(full)" +# alt-s = "toggle-summary" +# alt-w = "toggle-wrap" +# alt-b = "toggle-backtrace" # Home = "scroll-to-top" # End = "scroll-to-bottom" # Up = "scroll-lines(-1)" diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs index d0cfdd4..5df3908 100644 --- a/src/analysis/analyzer.rs +++ b/src/analysis/analyzer.rs @@ -148,7 +148,7 @@ impl Analyzer { _ => {} } } - for (key, failure) in failures.drain() { + for (key, failure) in &failures { // if we know of a failure but there was no content, we add some if failure.has_title { continue; @@ -156,7 +156,7 @@ impl Analyzer { fails.push(Line { item_idx: 0, // will be filled later line_type: LineType::Title(Kind::TestFail), - content: TLine::failed(&key), + content: TLine::failed(key), }); fails.push(Line { item_idx: 0, @@ -181,11 +181,13 @@ impl Analyzer { let mut stats = Stats::from(&lines); stats.passed_tests = passed_tests; debug!("stats: {:#?}", &stats); + let failure_keys = failures.keys().cloned().collect(); let report = Report { lines, stats, suggest_backtrace, output: Default::default(), + failure_keys, }; Ok(report) } diff --git a/src/app.rs b/src/app.rs index 99ca0b0..b1ae9c1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -159,7 +159,6 @@ pub fn run( event_source.unblock(false); } } - info!("action: {action:?}"); if let Some(action) = action.take() { debug!("requested action: {action:?}"); match action { @@ -192,6 +191,15 @@ pub fn run( task_executor.die(); task_executor = state.start_computation(&mut executor)?; } + Internal::ScopeToFailures => { + if let Some(scope) = state.failures_scope() { + info!("scoping to failures: {scope:#?}"); + next_job = Some(JobRef::Scope(scope)); + break; + } else { + warn!("no available failures scope"); + } + } Internal::ToggleRawOutput => { state.toggle_raw_output(); } diff --git a/src/cli.rs b/src/cli.rs index 353834c..e2b5f4d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -129,7 +129,7 @@ pub fn run() -> anyhow::Result<()> { let mut result = Ok(()); #[allow(clippy::while_let_loop)] loop { - let (job_name, job) = match job_stack.pick_job(&next_job) { + let (concrete_job_ref, job) = match job_stack.pick_job(&next_job) { Err(e) => { result = Err(e); break; @@ -139,7 +139,7 @@ pub fn run() -> anyhow::Result<()> { break; } }; - let r = Mission::new(&location, job_name.to_string(), job, &settings) + let r = Mission::new(&location, concrete_job_ref, job, &settings) .and_then(|mission| app::run(&mut w, mission, &event_source)); match r { Ok(Some(job_ref)) => { diff --git a/src/help_line.rs b/src/help_line.rs index 4924677..f17bc73 100644 --- a/src/help_line.rs +++ b/src/help_line.rs @@ -10,6 +10,7 @@ pub struct HelpLine { close_help: Option, pause: Option, unpause: Option, + scope: Option, } impl HelpLine { @@ -48,6 +49,9 @@ impl HelpLine { .shortest_internal_key(Internal::Unpause) .or(kb.shortest_internal_key(Internal::TogglePause)) .map(|k| format!("*{k}* to unpause")); + let scope = kb + .shortest_internal_key(Internal::ScopeToFailures) + .map(|k| format!("*{k}* to scope to failures")); Self { quit, toggle_summary, @@ -58,6 +62,7 @@ impl HelpLine { close_help, pause, unpause, + scope, } } pub fn markdown( @@ -70,6 +75,11 @@ impl HelpLine { parts.push(s); } } else { + if state.can_be_scoped() { + if let Some(s) = &self.scope { + parts.push(s); + } + } if state.auto_refresh.is_paused() { if let Some(s) = &self.unpause { parts.push(s); diff --git a/src/internal.rs b/src/internal.rs index e818acf..cc1b9b0 100644 --- a/src/internal.rs +++ b/src/internal.rs @@ -12,6 +12,7 @@ pub enum Internal { Quit, Refresh, // clear and rerun ReRun, + ScopeToFailures, Scroll(ScrollCommand), ToggleBacktrace(&'static str), ToggleRawOutput, @@ -33,6 +34,7 @@ impl fmt::Display for Internal { Self::Quit => write!(f, "quit"), Self::Refresh => write!(f, "clear then run current job again"), Self::ReRun => write!(f, "run current job again"), + Self::ScopeToFailures => write!(f, "scope to failures"), Self::Scroll(scroll_command) => scroll_command.fmt(f), Self::ToggleBacktrace(level) => write!(f, "toggle backtrace ({level})"), Self::ToggleRawOutput => write!(f, "toggle raw output"), @@ -57,6 +59,7 @@ impl std::str::FromStr for Internal { "quit" => Ok(Self::Quit), "refresh" => Ok(Self::Refresh), "rerun" => Ok(Self::ReRun), + "scope-to-failures" => Ok(Self::ScopeToFailures), "toggle-raw-output" => Ok(Self::ToggleRawOutput), "toggle-backtrace" => Ok(Self::ToggleBacktrace("1")), "toggle-backtrace(1)" => Ok(Self::ToggleBacktrace("1")), diff --git a/src/job_ref.rs b/src/job_ref.rs index d6621c2..5d54094 100644 --- a/src/job_ref.rs +++ b/src/job_ref.rs @@ -1,4 +1,6 @@ use { + crate::*, + lazy_regex::*, serde::{ Deserialize, Deserializer, @@ -16,25 +18,66 @@ pub enum JobRef { Initial, Previous, Concrete(ConcreteJobRef), + Scope(Scope), } impl JobRef { pub fn from_job_name>(s: S) -> Self { - Self::Concrete(ConcreteJobRef::Name(s.into())) + Self::Concrete(ConcreteJobRef::from_job_name(s)) } } /// A "concrete" job ref is one which can be used from the start, without /// referring to the job stack #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ConcreteJobRef { +pub struct ConcreteJobRef { + pub name_or_alias: NameOrAlias, + pub scope: Scope, +} + +impl ConcreteJobRef { + pub fn from_job_name>(s: S) -> Self { + Self { + name_or_alias: NameOrAlias::Name(s.into()), + scope: Default::default(), + } + } + pub fn badge_label(&self) -> String { + let mut s = String::new(); + match &self.name_or_alias { + NameOrAlias::Name(name) => { + s.push_str(name); + } + NameOrAlias::Alias(alias) => { + s.push_str(alias); + } + } + if self.scope.has_tests() { + s.push_str(" (scoped)"); + } + s + } + pub fn with_scope( + mut self, + scope: Scope, + ) -> Self { + self.scope = scope; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum NameOrAlias { Name(String), Alias(String), } impl Default for ConcreteJobRef { fn default() -> Self { - Self::Name("check".to_string()) + Self { + name_or_alias: NameOrAlias::Name("check".to_string()), + scope: Default::default(), + } } } @@ -43,10 +86,14 @@ impl fmt::Display for ConcreteJobRef { &self, f: &mut fmt::Formatter, ) -> fmt::Result { - match self { - Self::Alias(alias) => write!(f, "alias:{alias}"), - Self::Name(name) => write!(f, "{name}"), + match &self.name_or_alias { + NameOrAlias::Alias(alias) => write!(f, "alias:{alias}")?, + NameOrAlias::Name(name) => write!(f, "{name}")?, + } + if self.scope.has_tests() { + write!(f, "({})", self.scope.tests.join(","))?; } + Ok(()) } } @@ -72,10 +119,27 @@ impl<'de> Deserialize<'de> for ConcreteJobRef { impl From<&str> for ConcreteJobRef { fn from(str_entry: &str) -> Self { - if let Some(alias) = str_entry.strip_prefix("alias:") { - ConcreteJobRef::Alias(alias.to_string()) + let Some((_, alias_prefix, name_or_alias, scope)) = + regex_captures!(r"^(alias:)?([^\(\)]+)(?:\(([^\)]+)\))?$", str_entry,) + else { + warn!("unexpected job ref: {:?}", str_entry); + return Self::from_job_name(str_entry.to_string()); + }; + let name_or_alias = if alias_prefix.is_empty() { + NameOrAlias::Name(name_or_alias.to_string()) } else { - ConcreteJobRef::Name(str_entry.to_string()) + NameOrAlias::Alias(name_or_alias.to_string()) + }; + let scope = Scope { + tests: scope + .split(',') + .filter(|t| !t.trim().is_empty()) + .map(|s| s.to_string()) + .collect(), + }; + Self { + name_or_alias, + scope, } } } @@ -89,18 +153,70 @@ impl fmt::Display for JobRef { Self::Default => write!(f, "default"), Self::Initial => write!(f, "initial"), Self::Previous => write!(f, "previous"), - Self::Concrete(concrete) => concrete.fmt(f), + Self::Scope(Scope { tests }) => write!(f, "scope:{}", tests.join(",")), + Self::Concrete(concrete) => write!(f, "{}", concrete), } } } impl From<&str> for JobRef { - fn from(name: &str) -> Self { - match name { - "default" => Self::Default, - "initial" => Self::Initial, - "previous" => Self::Previous, - _ => Self::Concrete(ConcreteJobRef::from(name)), - } + fn from(s: &str) -> Self { + regex_switch!(s, + "^default$"i => Self::Default, + "^default$"i => Self::Default, + "^initial$"i => Self::Initial, + "^previous$"i => Self::Previous, + "^scope:(?.+)$" => Self::Scope(Scope { + tests: tests + .split(',') + .filter(|t| !t.trim().is_empty()) + .map(|s| s.to_string()) + .collect(), + }), + ) + .unwrap_or_else(|| Self::Concrete(ConcreteJobRef::from(s))) + } +} + +#[test] +fn test_job_ref_string_round_trip() { + let job_refs = vec![ + JobRef::Default, + JobRef::Initial, + JobRef::Previous, + JobRef::Concrete(ConcreteJobRef { + name_or_alias: NameOrAlias::Name("run".to_string()), + scope: Scope::default(), + }), + JobRef::Concrete(ConcreteJobRef { + name_or_alias: NameOrAlias::Name("nextest".to_string()), + scope: Scope { + tests: vec!["first::test".to_string(), "second_test".to_string()], + }, + }), + JobRef::Concrete(ConcreteJobRef { + name_or_alias: NameOrAlias::Alias("my-check".to_string()), + scope: Scope::default(), + }), + JobRef::Concrete(ConcreteJobRef { + name_or_alias: NameOrAlias::Alias("my-test".to_string()), + scope: Scope { + tests: vec!["abc".to_string()], + }, + }), + JobRef::Concrete(ConcreteJobRef { + name_or_alias: NameOrAlias::Name("nextest".to_string()), + scope: Scope { + tests: vec!["abc".to_string()], + }, + }), + JobRef::Scope(Scope { + tests: vec!["first::test".to_string(), "second_test".to_string()], + }), + ]; + for job_ref in job_refs { + let s = job_ref.to_string(); + let job_ref2 = JobRef::from(s.as_str()); + assert_eq!(job_ref, job_ref2); } } diff --git a/src/job_stack.rs b/src/job_stack.rs index 9173d4f..b6e6fd1 100644 --- a/src/job_stack.rs +++ b/src/job_stack.rs @@ -6,8 +6,8 @@ use { }, }; -/// The stack of jobs that bacon ran, allowing -/// to get back to the previous one +/// The stack of jobs that bacon ran, allowing to get back to the previous one, +/// or to scope the current one pub struct JobStack<'c> { settings: &'c Settings, entries: Vec, @@ -28,6 +28,9 @@ impl<'c> JobStack<'c> { .unwrap_or(&self.settings.default_job) } + /// Apply the job ref instruction to determine the job to run, updating the stack. + /// + /// When no job is returned, the application is supposed to quit. pub fn pick_job( &mut self, job_ref: &JobRef, @@ -37,19 +40,38 @@ impl<'c> JobStack<'c> { JobRef::Default => self.settings.default_job.clone(), JobRef::Initial => self.initial_job().clone(), JobRef::Previous => { - self.entries.pop(); + let current = self.entries.pop(); match self.entries.pop() { Some(concrete) => concrete, + None if current + .as_ref() + .map_or(false, |current| current.scope.has_tests()) => + { + // rather than quitting, we assume the user wants to "unscope" + ConcreteJobRef { + name_or_alias: current.unwrap().name_or_alias, + scope: Scope::default(), + } + } None => { return Ok(None); } } } JobRef::Concrete(concrete) => concrete.clone(), + JobRef::Scope(scope) => match self.entries.last() { + Some(concrete) => ConcreteJobRef { + name_or_alias: concrete.name_or_alias.clone(), + scope: scope.clone(), + }, + None => { + return Ok(None); + } + }, }; - let job = match &concrete { - ConcreteJobRef::Alias(alias) => Job::from_alias(alias, self.settings), - ConcreteJobRef::Name(name) => self + let job = match &concrete.name_or_alias { + NameOrAlias::Alias(alias) => Job::from_alias(alias, self.settings), + NameOrAlias::Name(name) => self .settings .jobs .get(name) diff --git a/src/keybindings.rs b/src/keybindings.rs index 8db8ce9..281be70 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -38,6 +38,7 @@ impl Default for KeyBindings { bindings.set(key!(PageUp), Internal::Scroll(ScrollCommand::Pages(-1))); bindings.set(key!(PageDown), Internal::Scroll(ScrollCommand::Pages(1))); bindings.set(key!(Space), Internal::Scroll(ScrollCommand::Pages(1))); + bindings.set(key!(f), Internal::ScopeToFailures); bindings.set(key!(esc), Internal::Back); bindings.set(key!(ctrl - d), JobRef::Default); bindings.set(key!(i), JobRef::Initial); diff --git a/src/lib.rs b/src/lib.rs index ac34a38..e5c41ce 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ mod mission; mod mission_location; mod on_change_strategy; mod report; +mod scope; mod scroll; mod settings; mod state; @@ -71,6 +72,7 @@ pub use { mission_location::*, on_change_strategy::*, report::*, + scope::*, scroll::*, settings::*, state::*, diff --git a/src/mission.rs b/src/mission.rs index b1a90d7..89b49b9 100644 --- a/src/mission.rs +++ b/src/mission.rs @@ -21,7 +21,7 @@ static DEFAULT_WATCHES: &[&str] = &["src", "tests", "benches", "examples", "buil #[derive(Debug)] pub struct Mission<'s> { pub location_name: String, - pub job_name: String, + pub concrete_job_ref: ConcreteJobRef, pub cargo_execution_directory: PathBuf, pub workspace_root: PathBuf, pub job: Job, @@ -33,7 +33,7 @@ pub struct Mission<'s> { impl<'s> Mission<'s> { pub fn new( location: &MissionLocation, - job_name: String, + concrete_job_ref: ConcreteJobRef, job: Job, settings: &'s Settings, ) -> Result { @@ -69,7 +69,7 @@ impl<'s> Mission<'s> { files_to_watch.push(full_path.into()); } } else { - info!("missing {} : {:?}", dir, full_path); + debug!("missing {} : {:?}", dir, full_path); } } } @@ -84,7 +84,7 @@ impl<'s> Mission<'s> { let cargo_execution_directory = location.package_directory.clone(); Ok(Mission { location_name, - job_name, + concrete_job_ref, cargo_execution_directory, workspace_root: location.workspace_root.clone(), job, @@ -146,10 +146,8 @@ impl<'s> Mission<'s> { /// build (and doesn't call) the external cargo command pub fn get_command(&self) -> Command { - let expanded; - let command = if self.job.expand_env_vars { - expanded = self - .job + let mut command = if self.job.expand_env_vars { + self.job .command .iter() .map(|token| { @@ -164,11 +162,26 @@ impl<'s> Mission<'s> { }) .to_string() }) - .collect(); - &expanded + .collect() } else { - &self.job.command + self.job.command.clone() }; + + let scope = &self.concrete_job_ref.scope; + if scope.has_tests() && command.len() > 2 { + let tests = if command[0] == "cargo" && command[1] == "test" { + // Here we're going around a limitation of the vanilla cargo test: + // it can only be scoped to one test + &scope.tests[..1] + } else { + &scope.tests + }; + for test in tests { + command.push(test.to_string()); + } + } + + info!("command: {command:#?}"); let mut tokens = command.iter(); let mut command = Command::new( tokens.next().unwrap(), // implies a check in the job diff --git a/src/report.rs b/src/report.rs index f902fa6..d27a2ff 100644 --- a/src/report.rs +++ b/src/report.rs @@ -20,6 +20,7 @@ pub struct Report { pub stats: Stats, pub suggest_backtrace: bool, pub output: CommandOutput, + pub failure_keys: Vec, } impl Report { diff --git a/src/scope.rs b/src/scope.rs new file mode 100644 index 0000000..360ef8a --- /dev/null +++ b/src/scope.rs @@ -0,0 +1,11 @@ +/// A dynamic reduction of a job execution +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Scope { + pub tests: Vec, +} + +impl Scope { + pub fn has_tests(&self) -> bool { + !self.tests.is_empty() + } +} diff --git a/src/settings.rs b/src/settings.rs index 7636d8b..9ceb519 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -157,7 +157,7 @@ impl Settings { if self.jobs.is_empty() { bail!("Invalid configuration : no job found"); } - if let ConcreteJobRef::Name(name) = &self.default_job { + if let NameOrAlias::Name(name) = &self.default_job.name_or_alias { if !self.jobs.contains_key(name) { bail!("Invalid configuration : default job ({name:?}) not found in jobs"); } diff --git a/src/state.rs b/src/state.rs index 9c3cb3e..0a37968 100644 --- a/src/state.rs +++ b/src/state.rs @@ -144,6 +144,19 @@ impl<'s> AppState<'s> { pub fn has_report(&self) -> bool { matches!(self.cmd_result, CommandResult::Report(_)) } + pub fn can_be_scoped(&self) -> bool { + self.cmd_result + .report() + .map_or(false, |report| report.stats.can_scope_tests()) + } + pub fn failures_scope(&self) -> Option { + if !self.can_be_scoped() { + return None; + } + self.cmd_result.report().map(|report| Scope { + tests: report.failure_keys.clone(), + }) + } pub fn toggle_raw_output(&mut self) { self.raw_output ^= true; } @@ -155,11 +168,12 @@ impl<'s> AppState<'s> { cmd_result.reverse(); } match &cmd_result { - CommandResult::Report(_) => { - debug!("GOT REPORT"); + CommandResult::Report(report) => { + debug!("Got report"); + info!("Stats: {:#?}", report.stats); } CommandResult::Failure(_) => { - debug!("GOT FAILURE"); + debug!("Got failure"); } CommandResult::None => { debug!("GOT NONE ???"); @@ -412,7 +426,8 @@ impl<'s> AppState<'s> { let project_name = &self.mission.location_name; t_line.add_badge(TString::badge(project_name, 255, 240)); // black over pink - t_line.add_badge(TString::badge(&self.mission.job_name, 235, 204)); + let job_label = self.mission.concrete_job_ref.badge_label(); + t_line.add_badge(TString::badge(&job_label, 235, 204)); if let CommandResult::Report(report) = &self.cmd_result { let stats = &report.stats; if stats.errors > 0 { diff --git a/src/stats.rs b/src/stats.rs index c9b62a6..26b318e 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -44,4 +44,7 @@ impl Stats { pub fn items(&self) -> usize { self.warnings + self.errors + self.test_fails } + pub fn can_scope_tests(&self) -> bool { + self.passed_tests > 0 && self.test_fails > 0 + } } diff --git a/src/tty/mod.rs b/src/tty/mod.rs new file mode 100644 index 0000000..c7b877e --- /dev/null +++ b/src/tty/mod.rs @@ -0,0 +1,38 @@ +mod tline; +mod tstring; + +pub const CSI_RESET: &str = "\u{1b}[0m\u{1b}[0m"; +pub const CSI_BOLD: &str = "\u{1b}[1m"; +pub const CSI_ITALIC: &str = "\u{1b}[3m"; + +pub const CSI_GREEN: &str = "\u{1b}[32m"; + +pub const CSI_RED: &str = "\u{1b}[31m"; +pub const CSI_BOLD_RED: &str = "\u{1b}[1m\u{1b}[38;5;9m"; +pub const CSI_BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m"; + +/// Used for "Blocking" +pub const CSI_BLUE: &str = "\u{1b}[1m\u{1b}[36m"; + +#[cfg(windows)] +pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[38;5;11m"; +#[cfg(not(windows))] +pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; + +#[cfg(windows)] +pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;14m"; +#[cfg(not(windows))] +pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;12m"; + +#[cfg(windows)] +pub const CSI_BOLD_4BIT_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; + +#[cfg(windows)] +pub const CSI_BOLD_WHITE: &str = "\u{1b}[1m\u{1b}[38;5;15m"; + +static TAB_REPLACEMENT: &str = " "; + +pub use { + tline::*, + tstring::*, +}; diff --git a/src/tty.rs b/src/tty/tline.rs similarity index 58% rename from src/tty.rs rename to src/tty/tline.rs index 667bf5f..37d5a89 100644 --- a/src/tty.rs +++ b/src/tty/tline.rs @@ -1,157 +1,13 @@ use { + super::*, crate::*, anyhow::*, serde::{ Deserialize, Serialize, }, - std::{ - fmt::Write as _, - io::Write, - }, - termimad::StrFit, }; -pub const CSI_RESET: &str = "\u{1b}[0m\u{1b}[0m"; -pub const CSI_BOLD: &str = "\u{1b}[1m"; -pub const CSI_ITALIC: &str = "\u{1b}[3m"; - -pub const CSI_GREEN: &str = "\u{1b}[32m"; - -pub const CSI_RED: &str = "\u{1b}[31m"; -pub const CSI_BOLD_RED: &str = "\u{1b}[1m\u{1b}[38;5;9m"; -pub const CSI_BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m"; - -/// Used for "Blocking" -pub const CSI_BLUE: &str = "\u{1b}[1m\u{1b}[36m"; - -#[cfg(windows)] -pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[38;5;11m"; -#[cfg(not(windows))] -pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; - -#[cfg(windows)] -pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;14m"; -#[cfg(not(windows))] -pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;12m"; - -#[cfg(windows)] -pub const CSI_BOLD_4BIT_YELLOW: &str = "\u{1b}[1m\u{1b}[33m"; - -#[cfg(windows)] -pub const CSI_BOLD_WHITE: &str = "\u{1b}[1m\u{1b}[38;5;15m"; - -static TAB_REPLACEMENT: &str = " "; - -/// a simple representation of a colored and styled string. -/// -/// Note that this works because of a few properties of -/// cargo's output: -/// - styles and colors are always reset on changes -/// - they're always in the same order (bold then fg color) -/// -/// A more generic parsing would have to: -/// - parse the csi params (it's simple enough to map but takes code) -/// - use a simple state machine to keep style (bold, italic, etc.), -/// foreground color, and background color across tstrings -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct TString { - pub csi: String, - pub raw: String, -} -impl TString { - pub fn new>( - csi: S, - raw: S, - ) -> Self { - Self { - csi: csi.into(), - raw: raw.into(), - } - } - /// colors are 8bits ansi values - pub fn badge( - con: &str, - fg: u8, - bg: u8, - ) -> Self { - Self { - csi: format!("\u{1b}[1m\u{1b}[38;5;{}m\u{1b}[48;5;{}m", fg, bg), - raw: format!(" {} ", con), - } - } - pub fn num_badge( - num: usize, - cat: &str, - fg: u8, - bg: u8, - ) -> Self { - let raw = if num < 2 { - format!(" {} {} ", num, cat) - } else { - format!(" {} {}s ", num, cat) - }; - Self::badge(&raw, fg, bg) - } - pub fn push_csi( - &mut self, - params: &[i64], - action: char, - ) { - self.csi.push('\u{1b}'); - self.csi.push('['); - for (idx, p) in params.iter().enumerate() { - let _ = write!(self.csi, "{}", p); - if idx < params.len() - 1 { - self.csi.push(';'); - } - } - self.csi.push(action); - } - pub fn draw( - &self, - w: &mut W, - ) -> Result<()> { - if self.csi.is_empty() { - write!(w, "{}", &self.raw)?; - } else { - write!(w, "{}{}{}", &self.csi, &self.raw, CSI_RESET,)?; - } - Ok(()) - } - /// draw the string but without taking more than cols_max cols. - /// Return the number of cols written - pub fn draw_in( - &self, - w: &mut W, - cols_max: usize, - ) -> Result { - let fit = StrFit::make_cow(&self.raw, cols_max); - if self.csi.is_empty() { - write!(w, "{}", &fit.0)?; - } else { - write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?; - } - Ok(fit.1) - } - pub fn starts_with( - &self, - csi: &str, - raw: &str, - ) -> bool { - self.csi == csi && self.raw.starts_with(raw) - } - pub fn split_off( - &mut self, - at: usize, - ) -> Self { - Self { - csi: self.csi.clone(), - raw: self.raw.split_off(at), - } - } -} - /// a simple representation of a line made of homogeneous parts. /// /// Note that this only manages CSI and SGR components diff --git a/src/tty/tstring.rs b/src/tty/tstring.rs new file mode 100644 index 0000000..7686181 --- /dev/null +++ b/src/tty/tstring.rs @@ -0,0 +1,123 @@ +use { + super::*, + crate::*, + anyhow::*, + serde::{ + Deserialize, + Serialize, + }, + std::{ + fmt::Write as _, + io::Write, + }, + termimad::StrFit, +}; + +/// a simple representation of a colored and styled string. +/// +/// Note that this works because of a few properties of +/// cargo's output: +/// - styles and colors are always reset on changes +/// - they're always in the same order (bold then fg color) +/// +/// A more generic parsing would have to: +/// - parse the csi params (it's simple enough to map but takes code) +/// - use a simple state machine to keep style (bold, italic, etc.), +/// foreground color, and background color across tstrings +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TString { + pub csi: String, + pub raw: String, +} +impl TString { + pub fn new>( + csi: S, + raw: S, + ) -> Self { + Self { + csi: csi.into(), + raw: raw.into(), + } + } + /// colors are 8bits ansi values + pub fn badge( + con: &str, + fg: u8, + bg: u8, + ) -> Self { + Self { + csi: format!("\u{1b}[1m\u{1b}[38;5;{}m\u{1b}[48;5;{}m", fg, bg), + raw: format!(" {} ", con), + } + } + pub fn num_badge( + num: usize, + cat: &str, + fg: u8, + bg: u8, + ) -> Self { + let raw = if num < 2 { + format!(" {} {} ", num, cat) + } else { + format!(" {} {}s ", num, cat) + }; + Self::badge(&raw, fg, bg) + } + pub fn push_csi( + &mut self, + params: &[i64], + action: char, + ) { + self.csi.push('\u{1b}'); + self.csi.push('['); + for (idx, p) in params.iter().enumerate() { + let _ = write!(self.csi, "{}", p); + if idx < params.len() - 1 { + self.csi.push(';'); + } + } + self.csi.push(action); + } + pub fn draw( + &self, + w: &mut W, + ) -> Result<()> { + if self.csi.is_empty() { + write!(w, "{}", &self.raw)?; + } else { + write!(w, "{}{}{}", &self.csi, &self.raw, CSI_RESET,)?; + } + Ok(()) + } + /// draw the string but without taking more than cols_max cols. + /// Return the number of cols written + pub fn draw_in( + &self, + w: &mut W, + cols_max: usize, + ) -> Result { + let fit = StrFit::make_cow(&self.raw, cols_max); + if self.csi.is_empty() { + write!(w, "{}", &fit.0)?; + } else { + write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?; + } + Ok(fit.1) + } + pub fn starts_with( + &self, + csi: &str, + raw: &str, + ) -> bool { + self.csi == csi && self.raw.starts_with(raw) + } + pub fn split_off( + &mut self, + at: usize, + ) -> Self { + Self { + csi: self.csi.clone(), + raw: self.raw.split_off(at), + } + } +} diff --git a/website/docs/config.md b/website/docs/config.md index 7d3d845..2d5327a 100644 --- a/website/docs/config.md +++ b/website/docs/config.md @@ -176,6 +176,7 @@ toggle-raw-output | | display the untransformed command output toggle-backtrace(level) | b | enable rust backtrace, level is either `1` or `full` toggle-summary | s | display results as abstracts toggle-wrap | w | toggle line wrapping +scope-to-failures | f | restrict job to test failure(s) scroll-to-top | Home | scroll to top scroll-to-bottom | End | scroll to bottom scroll-lines(-1) | | move one line up diff --git a/website/docs/index.md b/website/docs/index.md index 15e7290..1ea87be 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -42,6 +42,9 @@ bacon test ![test](img/test.png) +When there's a failure, hit f to restrict the job to the failing test. +Hit esc to get back to all tests. + While in bacon, you can see Clippy warnings by hitting the c key. And you get back to your previous job with esc You may also open the `cargo doc` in your browser with the d key.