From 0bfc70e516afc82c314ef0e790d1ee378aa40039 Mon Sep 17 00:00:00 2001 From: Sheldon Young Date: Fri, 6 Feb 2026 14:28:04 -0800 Subject: [PATCH 1/2] Support NO_COLOR to disable output coloring, disable coloring in insta snapshots --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 6 +- README.md.in | 6 +- crates/figue/Cargo.toml | 1 + crates/figue/src/color.rs | 14 +++++ crates/figue/src/driver.rs | 4 +- crates/figue/src/dump.rs | 102 +++++++++++++++++++++++-------- crates/figue/src/error.rs | 6 +- crates/figue/src/extract.rs | 24 +++++--- crates/figue/src/lib.rs | 1 + crates/figue/src/missing.rs | 37 ++++++++--- crates/figue/src/schema/error.rs | 8 ++- 13 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 crates/figue/src/color.rs diff --git a/Cargo.lock b/Cargo.lock index 7174e49..3c0ed77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,7 @@ dependencies = [ "owo-colors", "strip-ansi-escapes", "strsim", + "supports-color 3.0.2", "tempfile", "tracing", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index 85f877a..9694b64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ unicode-width = "0.2.2" strip-ansi-escapes = "0.2.1" strsim = "0.11" insta = "1" +supports-color = "3" tempfile = "3" # Uncomment for local development with facet diff --git a/README.md b/README.md index d35584d..f9d424a 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,13 @@ Ok(()) The entry point of figue is [`builder`] — let yourself be guided from there. +## Color + +figue uses [facet-color](https://docs.rs/facet-color) for coloring output. + ## Contributing -Run `install/hooks.sh` to install pre-commit and pre-push hooks. +Run `hooks/install.sh` to install pre-commit and pre-push hooks. ## Sponsors diff --git a/README.md.in b/README.md.in index d35584d..f9d424a 100644 --- a/README.md.in +++ b/README.md.in @@ -35,9 +35,13 @@ Ok(()) The entry point of figue is [`builder`] — let yourself be guided from there. +## Color + +figue uses [facet-color](https://docs.rs/facet-color) for coloring output. + ## Contributing -Run `install/hooks.sh` to install pre-commit and pre-push hooks. +Run `hooks/install.sh` to install pre-commit and pre-push hooks. ## Sponsors diff --git a/crates/figue/Cargo.toml b/crates/figue/Cargo.toml index fdbb204..5a7dfc2 100644 --- a/crates/figue/Cargo.toml +++ b/crates/figue/Cargo.toml @@ -35,6 +35,7 @@ ariadne.workspace = true unicode-width.workspace = true strip-ansi-escapes.workspace = true strsim.workspace = true +supports-color.workspace = true [dev-dependencies] facet-format = { workspace = true, features = ["tracing"] } diff --git a/crates/figue/src/color.rs b/crates/figue/src/color.rs new file mode 100644 index 0000000..3977839 --- /dev/null +++ b/crates/figue/src/color.rs @@ -0,0 +1,14 @@ +use std::env::var_os; +use supports_color::Stream; + +/// Determine if output should be colored. +/// +/// This respects the [`NO_COLOR`](https://no-color.org) and [`FORCE_COLOR`] environment variables. +pub fn should_use_color() -> bool { + // Don't use color when creating snapshots for `insta`. Color isn't disabled for testing in general + // to allow coloring to be tested. + var_os("INSTA_UPDATE").is_none() + && var_os("INSTA_WORKSPACE").is_none() + && var_os("INSTA_SNAPSHOT_UPDATE").is_none() + && supports_color::on(Stream::Stdout).is_some() +} diff --git a/crates/figue/src/driver.rs b/crates/figue/src/driver.rs index c7ce54a..b03ae51 100644 --- a/crates/figue/src/driver.rs +++ b/crates/figue/src/driver.rs @@ -23,6 +23,7 @@ use std::string::String; use std::vec::Vec; use crate::builder::Config; +use crate::color::should_use_color; use crate::completions::{Shell, generate_completions_for_shape}; use crate::config_value::ConfigValue; use crate::config_value_parser::{fill_defaults_from_schema, from_config_value}; @@ -969,7 +970,7 @@ impl ariadne::Cache<()> for NamedSource { impl DriverReport { /// Render the report using Ariadne for pretty error display. pub fn render_pretty(&self) -> String { - use ariadne::{Color, Label, Report, ReportKind, Source}; + use ariadne::{Color, Config, Label, Report, ReportKind, Source}; if self.diagnostics.is_empty() { return String::new(); @@ -1027,6 +1028,7 @@ impl DriverReport { let label_message = diagnostic.label.as_deref().unwrap_or(&diagnostic.message); let report = Report::build(report_kind, span.clone()) + .with_config(Config::default().with_color(should_use_color())) .with_message(&diagnostic.message) .with_label( Label::new(span) diff --git a/crates/figue/src/dump.rs b/crates/figue/src/dump.rs index fc4e961..08c85bb 100644 --- a/crates/figue/src/dump.rs +++ b/crates/figue/src/dump.rs @@ -8,6 +8,7 @@ use crate::{ schema::{ConfigValueSchema, Schema}, }; use owo_colors::OwoColorize; +use owo_colors::Stream::Stdout; use std::collections::HashMap; use std::io::Write; use unicode_width::UnicodeWidthStr; @@ -118,7 +119,7 @@ pub(crate) fn dump_config_with_schema( writeln!( w, "Some values were truncated. To show full values, rerun with {}=1", - "FACET_ARGS_BLAST_IT".yellow() + "FACET_ARGS_BLAST_IT".if_supports_color(Stdout, |text| text.yellow()) ) .ok(); } @@ -181,12 +182,19 @@ fn write_sources_header(w: &mut impl Write, file_resolution: &FileResolution, sc }; let colored_path = match path_info.status { - FilePathStatus::Picked => path_str.magenta().to_string(), - _ => path_str.dimmed().to_string(), + FilePathStatus::Picked => path_str + .if_supports_color(Stdout, |text| text.magenta()) + .to_string(), + _ => path_str + .if_supports_color(Stdout, |text| text.dimmed()) + .to_string(), }; + let colored_status = match path_info.status { FilePathStatus::Picked => status_label.to_string(), - _ => status_label.dimmed().to_string(), + _ => status_label + .if_supports_color(Stdout, |text| text.dimmed()) + .to_string(), }; writeln!( @@ -207,7 +215,13 @@ fn write_sources_header(w: &mut impl Write, file_resolution: &FileResolution, sc current_source += 1; let is_last_source = current_source == sources_count; let branch = if is_last_source { "└─ " } else { "├─ " }; - writeln!(w, "{}env {}", branch, format!("${}__*", prefix).yellow()).ok(); + writeln!( + w, + "{}env {}", + branch, + format!("${}__*", prefix).if_supports_color(Stdout, |text| text.yellow()) + ) + .ok(); } { @@ -218,7 +232,7 @@ fn write_sources_header(w: &mut impl Write, file_resolution: &FileResolution, sc w, "{}cli {}", branch, - format!("--{}.*", config_field_name).cyan() + format!("--{}.*", config_field_name).if_supports_color(Stdout, |text| text.cyan()) ) .ok(); } @@ -450,37 +464,51 @@ fn build_entry_from_schema( (ConfigValue::String(sourced), ConfigValueSchema::Leaf(_)) => { let formatted = if is_sensitive { format!("🔒 [REDACTED ({} bytes)]", sourced.value.len()) - .bright_magenta() + .if_supports_color(Stdout, |text| text.bright_magenta()) .to_string() } else { let escaped = sourced.value.replace('\n', "↵"); let (truncated, _) = truncate_middle(&escaped, opts.max_string_length); - truncated.green().to_string() + truncated + .if_supports_color(Stdout, |text| text.green()) + .to_string() }; DumpEntry::leaf(key, formatted, format_provenance(&sourced.provenance)) } (ConfigValue::Integer(sourced), ConfigValueSchema::Leaf(_)) => DumpEntry::leaf( key, - sourced.value.to_string().blue().to_string(), + sourced + .value + .if_supports_color(Stdout, |value| value.blue()) + .to_string(), format_provenance(&sourced.provenance), ), (ConfigValue::Float(sourced), ConfigValueSchema::Leaf(_)) => DumpEntry::leaf( key, - sourced.value.to_string().bright_blue().to_string(), + sourced + .value + .if_supports_color(Stdout, |value| value.bright_blue()) + .to_string(), format_provenance(&sourced.provenance), ), (ConfigValue::Bool(sourced), ConfigValueSchema::Leaf(_)) => DumpEntry::leaf( key, if sourced.value { - "true".green().to_string() + "true" + .if_supports_color(Stdout, |text| text.green()) + .to_string() } else { - "false".red().to_string() + "false" + .if_supports_color(Stdout, |text| text.red()) + .to_string() }, format_provenance(&sourced.provenance), ), (ConfigValue::Null(sourced), ConfigValueSchema::Leaf(_)) => DumpEntry::leaf( key, - "null".bright_black().to_string(), + "null" + .if_supports_color(Stdout, |text| text.bright_black()) + .to_string(), format_provenance(&sourced.provenance), ), @@ -511,37 +539,51 @@ fn build_leaf_entry( ConfigValue::String(sourced) => { let formatted = if is_sensitive { format!("🔒 [REDACTED ({} bytes)]", sourced.value.len()) - .bright_magenta() + .if_supports_color(Stdout, |text| text.bright_magenta()) .to_string() } else { let escaped = sourced.value.replace('\n', "↵"); let (truncated, _) = truncate_middle(&escaped, opts.max_string_length); - truncated.green().to_string() + truncated + .if_supports_color(Stdout, |text| text.green()) + .to_string() }; DumpEntry::leaf(key, formatted, format_provenance(&sourced.provenance)) } ConfigValue::Integer(sourced) => DumpEntry::leaf( key, - sourced.value.to_string().blue().to_string(), + sourced + .value + .if_supports_color(Stdout, |text| text.blue()) + .to_string(), format_provenance(&sourced.provenance), ), ConfigValue::Float(sourced) => DumpEntry::leaf( key, - sourced.value.to_string().bright_blue().to_string(), + sourced + .value + .if_supports_color(Stdout, |text| text.bright_blue()) + .to_string(), format_provenance(&sourced.provenance), ), ConfigValue::Bool(sourced) => DumpEntry::leaf( key, if sourced.value { - "true".green().to_string() + "true" + .if_supports_color(Stdout, |text| text.green()) + .to_string() } else { - "false".red().to_string() + "false" + .if_supports_color(Stdout, |text| text.red()) + .to_string() }, format_provenance(&sourced.provenance), ), ConfigValue::Null(sourced) => DumpEntry::leaf( key, - "null".bright_black().to_string(), + "null" + .if_supports_color(Stdout, |text| text.bright_black()) + .to_string(), format_provenance(&sourced.provenance), ), ConfigValue::Object(sourced) => { @@ -669,9 +711,9 @@ fn render_entries_with_prefix( "{}{}{} {}{} {}", full_prefix, entry.key, - key_pad.bright_black(), + key_pad.if_supports_color(Stdout, |text| text.bright_black()), line, - val_pad.bright_black(), + val_pad.if_supports_color(Stdout, |text| text.bright_black()), entry.provenance, ) .ok(); @@ -718,17 +760,25 @@ fn render_entries_with_prefix( fn format_provenance(prov: &Option) -> String { match prov { - Some(Provenance::Cli { arg, .. }) => arg.cyan().to_string(), - Some(Provenance::Env { var, .. }) => format!("${}", var).yellow().to_string(), + Some(Provenance::Cli { arg, .. }) => arg + .if_supports_color(Stdout, |text| text.cyan()) + .to_string(), + Some(Provenance::Env { var, .. }) => format!("${}", var) + .if_supports_color(Stdout, |text| text.yellow()) + .to_string(), Some(Provenance::File { file, offset, .. }) => { let line_num = calculate_line_number(&file.contents, *offset); let filename = std::path::Path::new(file.path.as_str()) .file_name() .and_then(|n| n.to_str()) .unwrap_or(file.path.as_str()); - format!("{}:{}", filename, line_num).magenta().to_string() + format!("{}:{}", filename, line_num) + .if_supports_color(Stdout, |text| text.magenta()) + .to_string() } - Some(Provenance::Default) => "DEFAULT".bright_black().to_string(), + Some(Provenance::Default) => "DEFAULT" + .if_supports_color(Stdout, |text| text.bright_black()) + .to_string(), None => String::new(), } } diff --git a/crates/figue/src/error.rs b/crates/figue/src/error.rs index 915ecc9..172892c 100644 --- a/crates/figue/src/error.rs +++ b/crates/figue/src/error.rs @@ -606,7 +606,8 @@ impl fmt::Display for ArgsError { mod ariadne_impl { use super::*; - use ariadne::{Color, Label, Report, ReportKind, Source}; + use crate::color::should_use_color; + use ariadne::{Color, Config, Label, Report, ReportKind, Source}; use facet_pretty::{PathSegment, format_shape_with_spans}; use std::borrow::Cow; @@ -619,6 +620,7 @@ mod ariadne_impl { // Skip help requests - they're not real errors if self.is_help_request() { return Report::build(ReportKind::Custom("Help", Color::Cyan), 0..0) + .with_config(Config::default().with_color(should_use_color())) .with_message(self.help_text().unwrap_or("")) .finish(); } @@ -631,6 +633,7 @@ mod ariadne_impl { let span = field_span.key.0..field_span.value.1; let mut builder = Report::build(ReportKind::Error, span.clone()) + .with_config(Config::default().with_color(should_use_color())) .with_code(self.inner.kind.code()) .with_message(self.inner.kind.label()); @@ -677,6 +680,7 @@ mod ariadne_impl { let range = span.start..(span.start + span.len); let mut builder = Report::build(ReportKind::Error, range.clone()) + .with_config(Config::default().with_color(should_use_color())) .with_code(self.inner.kind.code()) .with_message(self.inner.kind.label()); diff --git a/crates/figue/src/extract.rs b/crates/figue/src/extract.rs index d7d87c5..3075e39 100644 --- a/crates/figue/src/extract.rs +++ b/crates/figue/src/extract.rs @@ -3,13 +3,13 @@ //! This module provides the ability to validate and extract subcommand-specific //! required fields from a successfully parsed configuration. +use crate::config_value::{ConfigValue, ObjectMap, Sourced}; +use crate::schema::Schema; use facet::{Def, Facet, Field, Type, UserType}; use heck::{ToKebabCase, ToShoutySnakeCase}; use indexmap::IndexMap; use owo_colors::OwoColorize; - -use crate::config_value::{ConfigValue, ObjectMap, Sourced}; -use crate::schema::Schema; +use owo_colors::Stream::Stdout; /// Information about a missing required field during extraction. #[derive(Debug, Clone)] @@ -40,17 +40,27 @@ impl std::fmt::Display for ExtractError { write!( f, " {} <{}> at {}", - field.field_name.bold(), - field.type_name.cyan(), + field + .field_name + .if_supports_color(Stdout, |text| text.bold()), + field + .type_name + .if_supports_color(Stdout, |text| text.cyan()), field.origin_path )?; let mut hints = Vec::new(); if let Some(cli) = &field.cli_hint { - hints.push(cli.green().to_string()); + hints.push( + cli.if_supports_color(Stdout, |text| text.green()) + .to_string(), + ); } if let Some(env) = &field.env_hint { - hints.push(env.yellow().to_string()); + hints.push( + env.if_supports_color(Stdout, |text| text.yellow()) + .to_string(), + ); } if !hints.is_empty() { write!(f, "\n Set via: {}", hints.join(" or "))?; diff --git a/crates/figue/src/lib.rs b/crates/figue/src/lib.rs index 2034893..ec866ac 100644 --- a/crates/figue/src/lib.rs +++ b/crates/figue/src/lib.rs @@ -163,6 +163,7 @@ use figue_attrs as args; mod macros; pub(crate) mod builder; +pub(crate) mod color; pub(crate) mod completions; pub(crate) mod config_format; pub(crate) mod config_value; diff --git a/crates/figue/src/missing.rs b/crates/figue/src/missing.rs index b485c13..8291e50 100644 --- a/crates/figue/src/missing.rs +++ b/crates/figue/src/missing.rs @@ -7,6 +7,7 @@ use crate::schema::{ }; use heck::ToKebabCase; use heck::ToShoutySnakeCase; +use owo_colors::Stream::Stdout; /// Normalize a program name for display in error messages and help text. /// @@ -536,25 +537,40 @@ pub fn format_missing_fields_summary(missing: &[MissingFieldInfo]) -> String { write!( output, " {} <{}>", - field.field_path.bold(), - field.type_name.cyan() + field + .field_path + .if_supports_color(Stdout, |text| text.bold()), + field + .type_name + .if_supports_color(Stdout, |text| text.cyan()), ) .unwrap(); // Show how to set the field let mut hints = Vec::new(); if let Some(cli) = &field.cli_flag { - hints.push(cli.green().to_string()); + hints.push( + cli.if_supports_color(Stdout, |text| text.green()) + .to_string(), + ); } if let Some(env) = &field.env_var { - hints.push(format!("${}", env).yellow().to_string()); + hints.push( + format!("${}", env) + .if_supports_color(Stdout, |text| text.yellow()) + .to_string(), + ); } // Show env aliases for alias in &field.env_aliases { hints.push( - format!("${} {}", alias, "(alias)".dimmed()) - .yellow() - .to_string(), + format!( + "${} {}", + alias, + "(alias)".if_supports_color(Stdout, |text| text.dimmed()) + ) + .if_supports_color(Stdout, |text| text.yellow()) + .to_string(), ); } if !hints.is_empty() { @@ -563,7 +579,12 @@ pub fn format_missing_fields_summary(missing: &[MissingFieldInfo]) -> String { // Add doc comment on a new line if available if let Some(doc) = &field.doc_comment { - write!(output, "\n {}", doc.dimmed()).unwrap(); + write!( + output, + "\n {}", + doc.if_supports_color(Stdout, |text| text.dimmed()) + ) + .unwrap(); } output.push('\n'); diff --git a/crates/figue/src/schema/error.rs b/crates/figue/src/schema/error.rs index 9d170cb..f4d0b8f 100644 --- a/crates/figue/src/schema/error.rs +++ b/crates/figue/src/schema/error.rs @@ -1,9 +1,10 @@ use std::borrow::Cow; -use ariadne::{Color, Label, Report, ReportKind, Source}; +use ariadne::{Color, Config, Label, Report, ReportKind, Source}; use facet_core::Shape; use facet_pretty::{PathSegment, format_shape_with_spans}; +use crate::color::should_use_color; use crate::{ diagnostics::{ColorHint, Diagnostic, LabelSpec, SourceBundle, SourceId}, path::Path, @@ -230,8 +231,9 @@ impl SchemaError { .map(|label| label.span.clone()) .unwrap_or(0..0); - let mut builder = - Report::build(ReportKind::Error, primary_span.clone()).with_message(self.label()); + let mut builder = Report::build(ReportKind::Error, primary_span.clone()) + .with_config(Config::default().with_color(should_use_color())) + .with_message(self.label()); for label in labels { if label.source != SourceId::Schema { From f1a1ce80b8adff1f46629a8c20667911fe1039a8 Mon Sep 17 00:00:00 2001 From: Sheldon Young Date: Fri, 6 Feb 2026 14:33:04 -0800 Subject: [PATCH 2/2] Better README message about color --- crates/figue/README.md | 5 +++++ crates/figue/README.md.in | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/crates/figue/README.md b/crates/figue/README.md index d9f1ab1..84a3298 100644 --- a/crates/figue/README.md +++ b/crates/figue/README.md @@ -35,6 +35,11 @@ Ok(()) The entry point of figue is [`builder`] — let yourself be guided from there. +## Color + +Color is enabled by default if the terminal supports it. It is disabled when the +[`NO_COLOR`](https://no-color.org) environment variable is set. + ## Sponsors Thanks to all individual sponsors: diff --git a/crates/figue/README.md.in b/crates/figue/README.md.in index d9f1ab1..84a3298 100644 --- a/crates/figue/README.md.in +++ b/crates/figue/README.md.in @@ -35,6 +35,11 @@ Ok(()) The entry point of figue is [`builder`] — let yourself be guided from there. +## Color + +Color is enabled by default if the terminal supports it. It is disabled when the +[`NO_COLOR`](https://no-color.org) environment variable is set. + ## Sponsors Thanks to all individual sponsors: