feat(cli): Colored output

This supports
- Basic capability detection
- NO_COLOR env variable
- tty detection
- CLI overrides

This does not yet support CLICOLOR.  I'll be trying to upstream all of
this into `yansi` and get it taken care of there.

This only supports Windows Anniversary edition and later which I think
is a fine compromise due to the ergonomic difference between `yansi` and
`termcolor`.

Fixes #30
This commit is contained in:
Ed Page 2021-05-03 18:16:39 -05:00
parent 943ae7f490
commit 60950d02bb
6 changed files with 268 additions and 60 deletions

35
Cargo.lock generated
View file

@ -122,9 +122,9 @@ dependencies = [
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "0.2.15" version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40b47ad93e1a5404e6c18dec46b628214fee441c70f4ab5d6942142cc268a3d" checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"memchr", "memchr",
@ -521,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
dependencies = [ dependencies = [
"atty", "atty",
"humantime 1.3.0", "humantime",
"log", "log",
"regex", "regex",
"termcolor", "termcolor",
@ -533,10 +533,7 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f"
dependencies = [ dependencies = [
"atty",
"humantime 2.1.0",
"log", "log",
"regex",
"termcolor", "termcolor",
] ]
@ -661,12 +658,6 @@ dependencies = [
"quick-error", "quick-error",
] ]
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@ -1174,9 +1165,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.5.2" version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efb2352a0f4d4b128f734b5c44c79ff80117351138733f12f982fe3e2b13343" checksum = "ce5f1ceb7f74abbce32601642fcf8e8508a8a8991e0621c7d750295b9095702b"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -1194,9 +1185,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.6.24" version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00efb87459ba4f6fb2169d20f68565555688e1250ee6825cdf6254f8b48fafb2" checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]] [[package]]
name = "remove_dir_all" name = "remove_dir_all"
@ -1209,9 +1200,9 @@ dependencies = [
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.18" version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232" checksum = "410f7acf3cb3a44527c5d9546bad4bf4e6c460915d5f9f2fc524498bfe8f70ce"
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
@ -1485,6 +1476,7 @@ dependencies = [
"ahash", "ahash",
"anyhow", "anyhow",
"assert_fs", "assert_fs",
"atty",
"bstr", "bstr",
"clap", "clap",
"clap-verbosity-flag", "clap-verbosity-flag",
@ -1516,6 +1508,7 @@ dependencies = [
"unicase", "unicase",
"unicode-segmentation", "unicode-segmentation",
"varcon-core", "varcon-core",
"yansi",
] ]
[[package]] [[package]]
@ -1797,3 +1790,9 @@ name = "wyz"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
[[package]]
name = "yansi"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71"

View file

@ -62,7 +62,9 @@ ignore = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "0.5" toml = "0.5"
log = "0.4" log = "0.4"
env_logger = "0.8" env_logger = { version = "0.8", default-features = false, features = ["termcolor"] }
atty = "0.2.14"
yansi = "0.5.0"
bstr = "0.2" bstr = "0.2"
once_cell = "1.2.0" once_cell = "1.2.0"
ahash = "0.7" ahash = "0.7"

View file

@ -12,18 +12,23 @@ arg_enum! {
} }
} }
pub const PRINT_SILENT: crate::report::PrintSilent = crate::report::PrintSilent;
pub const PRINT_BRIEF: crate::report::PrintBrief = crate::report::PrintBrief;
pub const PRINT_LONG: crate::report::PrintLong = crate::report::PrintLong;
pub const PRINT_JSON: crate::report::PrintJson = crate::report::PrintJson;
impl Format { impl Format {
pub(crate) fn reporter(self) -> &'static dyn typos_cli::report::Report { pub(crate) fn reporter(
self,
stdout_palette: crate::report::Palette,
stderr_palette: crate::report::Palette,
) -> Box<dyn typos_cli::report::Report> {
match self { match self {
Format::Silent => &PRINT_SILENT, Format::Silent => Box::new(crate::report::PrintSilent),
Format::Brief => &PRINT_BRIEF, Format::Brief => Box::new(crate::report::PrintBrief {
Format::Long => &PRINT_LONG, stdout_palette,
Format::Json => &PRINT_JSON, stderr_palette,
}),
Format::Long => Box::new(crate::report::PrintLong {
stdout_palette,
stderr_palette,
}),
Format::Json => Box::new(crate::report::PrintJson),
} }
} }
} }
@ -98,6 +103,9 @@ pub(crate) struct Args {
#[structopt(flatten)] #[structopt(flatten)]
pub(crate) config: ConfigArgs, pub(crate) config: ConfigArgs,
#[structopt(flatten)]
pub(crate) color: crate::color::ColorArgs,
#[structopt(flatten)] #[structopt(flatten)]
pub(crate) verbose: clap_verbosity_flag::Verbosity, pub(crate) verbose: clap_verbosity_flag::Verbosity,
} }

100
src/bin/typos-cli/color.rs Normal file
View file

@ -0,0 +1,100 @@
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
#[structopt(rename_all = "kebab-case")]
pub struct ColorArgs {
/// "Specify when to use colored output. The automatic mode
/// only enables colors if an interactive terminal is detected -
/// colors are automatically disabled if the output goes to a pipe.
///
/// Possible values: *auto*, never, always.
#[structopt(
long,
value_name="when",
possible_values(&ColorValue::variants()),
case_insensitive(true),
default_value("auto"),
hide_possible_values(true),
hide_default_value(true),
help="When to use colors (*auto*, never, always).")]
color: ColorValue,
}
impl ColorArgs {
pub fn colored(&self) -> Option<bool> {
self.color.colored()
}
}
arg_enum! {
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum ColorValue {
Always,
Never,
Auto,
}
}
impl ColorValue {
fn colored(self) -> Option<bool> {
match self {
ColorValue::Always => Some(true),
ColorValue::Never => Some(false),
ColorValue::Auto => None,
}
}
}
impl Default for ColorValue {
fn default() -> Self {
ColorValue::Auto
}
}
pub fn colored_stdout() -> Option<bool> {
if atty::is(atty::Stream::Stdout) {
None
} else {
Some(false)
}
}
pub fn colored_stderr() -> Option<bool> {
if atty::is(atty::Stream::Stderr) {
None
} else {
Some(false)
}
}
pub fn colored_env() -> Option<bool> {
match std::env::var_os("TERM") {
None => noterm_colored(),
Some(k) => {
if k == "dumb" {
Some(false)
} else {
None
}
}
}
.or_else(|| {
// See https://no-color.org/
std::env::var_os("NO_COLOR").map(|_| true)
})
}
#[cfg(not(windows))]
fn noterm_colored() -> Option<bool> {
// If TERM isn't set, then we are in a weird environment that
// probably doesn't support colors.
Some(false)
}
#[cfg(windows)]
fn noterm_colored() -> Option<bool> {
// On Windows, if TERM isn't set, then we shouldn't automatically
// assume that colors aren't allowed. This is unlike Unix environments
// where TERM is more rigorously set.
None
}

View file

@ -7,6 +7,7 @@ use std::io::Write;
use structopt::StructOpt; use structopt::StructOpt;
mod args; mod args;
mod color;
mod report; mod report;
use proc_exit::WithCodeResultExt; use proc_exit::WithCodeResultExt;
@ -30,14 +31,33 @@ fn run() -> proc_exit::ExitResult {
} }
}; };
init_logging(args.verbose.log_level()); let colored = args.color.colored().or_else(color::colored_env);
let mut colored_stdout = colored.or_else(color::colored_stdout).unwrap_or(true);
let mut colored_stderr = colored.or_else(color::colored_stderr).unwrap_or(true);
if (colored_stdout || colored_stderr) && !yansi::Paint::enable_windows_ascii() {
colored_stdout = false;
colored_stderr = false;
}
init_logging(args.verbose.log_level(), colored_stderr);
let stdout_palette = if colored_stdout {
report::Palette::colored()
} else {
report::Palette::plain()
};
let stderr_palette = if colored_stderr {
report::Palette::colored()
} else {
report::Palette::plain()
};
if let Some(output_path) = args.dump_config.as_ref() { if let Some(output_path) = args.dump_config.as_ref() {
run_dump_config(&args, output_path) run_dump_config(&args, output_path)
} else if args.type_list { } else if args.type_list {
run_type_list(&args) run_type_list(&args)
} else { } else {
run_checks(&args) run_checks(&args, stdout_palette, stderr_palette)
} }
} }
@ -136,7 +156,11 @@ fn run_type_list(args: &args::Args) -> proc_exit::ExitResult {
Ok(()) Ok(())
} }
fn run_checks(args: &args::Args) -> proc_exit::ExitResult { fn run_checks(
args: &args::Args,
stdout_palette: report::Palette,
stderr_palette: report::Palette,
) -> proc_exit::ExitResult {
let global_cwd = std::env::current_dir()?; let global_cwd = std::env::current_dir()?;
let storage = typos_cli::policy::ConfigStorage::new(); let storage = typos_cli::policy::ConfigStorage::new();
@ -187,11 +211,11 @@ fn run_checks(args: &args::Args) -> proc_exit::ExitResult {
// HACK: Diff doesn't handle mixing content // HACK: Diff doesn't handle mixing content
let output_reporter = if args.diff { let output_reporter = if args.diff {
&args::PRINT_SILENT Box::new(crate::report::PrintSilent)
} else { } else {
args.format.reporter() args.format.reporter(stdout_palette, stderr_palette)
}; };
let status_reporter = report::MessageStatus::new(output_reporter); let status_reporter = report::MessageStatus::new(output_reporter.as_ref());
let reporter: &dyn typos_cli::report::Report = &status_reporter; let reporter: &dyn typos_cli::report::Report = &status_reporter;
let selected_checks: &dyn typos_cli::file::FileChecker = if args.files { let selected_checks: &dyn typos_cli::file::FileChecker = if args.files {
@ -245,9 +269,14 @@ fn run_checks(args: &args::Args) -> proc_exit::ExitResult {
} }
} }
fn init_logging(level: Option<log::Level>) { fn init_logging(level: Option<log::Level>, colored: bool) {
if let Some(level) = level { if let Some(level) = level {
let mut builder = env_logger::Builder::new(); let mut builder = env_logger::Builder::new();
builder.write_style(if colored {
env_logger::WriteStyle::Always
} else {
env_logger::WriteStyle::Never
});
builder.filter(None, level.to_level_filter()); builder.filter(None, level.to_level_filter());

View file

@ -5,6 +5,34 @@ use std::sync::atomic;
use typos_cli::report::{Context, Message, Report, Typo}; use typos_cli::report::{Context, Message, Report, Typo};
#[derive(Copy, Clone, Debug)]
pub struct Palette {
error: yansi::Style,
warn: yansi::Style,
info: yansi::Style,
strong: yansi::Style,
}
impl Palette {
pub fn colored() -> Self {
Self {
error: yansi::Style::new(yansi::Color::Red),
warn: yansi::Style::new(yansi::Color::Yellow),
info: yansi::Style::new(yansi::Color::Blue),
strong: yansi::Style::default().bold(),
}
}
pub fn plain() -> Self {
Self {
error: yansi::Style::default(),
warn: yansi::Style::default(),
info: yansi::Style::default(),
strong: yansi::Style::default(),
}
}
}
pub struct MessageStatus<'r> { pub struct MessageStatus<'r> {
typos_found: atomic::AtomicBool, typos_found: atomic::AtomicBool,
errors_found: atomic::AtomicBool, errors_found: atomic::AtomicBool,
@ -59,8 +87,10 @@ impl Report for PrintSilent {
} }
} }
#[derive(Copy, Clone, Debug)] pub struct PrintBrief {
pub struct PrintBrief; pub stdout_palette: Palette,
pub stderr_palette: Palette,
}
impl Report for PrintBrief { impl Report for PrintBrief {
fn report(&self, msg: Message) -> Result<(), std::io::Error> { fn report(&self, msg: Message) -> Result<(), std::io::Error> {
@ -68,7 +98,7 @@ impl Report for PrintBrief {
Message::BinaryFile(msg) => { Message::BinaryFile(msg) => {
log::info!("{}", msg); log::info!("{}", msg);
} }
Message::Typo(msg) => print_brief_correction(msg)?, Message::Typo(msg) => print_brief_correction(msg, self.stdout_palette)?,
Message::File(msg) => { Message::File(msg) => {
writeln!(io::stdout(), "{}", msg.path.display())?; writeln!(io::stdout(), "{}", msg.path.display())?;
} }
@ -84,8 +114,10 @@ impl Report for PrintBrief {
} }
} }
#[derive(Copy, Clone, Debug)] pub struct PrintLong {
pub struct PrintLong; pub stdout_palette: Palette,
pub stderr_palette: Palette,
}
impl Report for PrintLong { impl Report for PrintLong {
fn report(&self, msg: Message) -> Result<(), std::io::Error> { fn report(&self, msg: Message) -> Result<(), std::io::Error> {
@ -93,7 +125,7 @@ impl Report for PrintLong {
Message::BinaryFile(msg) => { Message::BinaryFile(msg) => {
log::info!("{}", msg); log::info!("{}", msg);
} }
Message::Typo(msg) => print_long_correction(msg)?, Message::Typo(msg) => print_long_correction(msg, self.stdout_palette)?,
Message::File(msg) => { Message::File(msg) => {
writeln!(io::stdout(), "{}", msg.path.display())?; writeln!(io::stdout(), "{}", msg.path.display())?;
} }
@ -109,7 +141,7 @@ impl Report for PrintLong {
} }
} }
fn print_brief_correction(msg: &Typo) -> Result<(), std::io::Error> { fn print_brief_correction(msg: &Typo, palette: Palette) -> Result<(), std::io::Error> {
let line = String::from_utf8_lossy(msg.buffer.as_ref()); let line = String::from_utf8_lossy(msg.buffer.as_ref());
let line = line.replace("\t", " "); let line = line.replace("\t", " ");
let column = unicode_segmentation::UnicodeSegmentation::graphemes( let column = unicode_segmentation::UnicodeSegmentation::graphemes(
@ -120,22 +152,31 @@ fn print_brief_correction(msg: &Typo) -> Result<(), std::io::Error> {
match &msg.corrections { match &msg.corrections {
typos::Status::Valid => {} typos::Status::Valid => {}
typos::Status::Invalid => { typos::Status::Invalid => {
let divider = ":";
writeln!( writeln!(
io::stdout(), io::stdout(),
"{}:{}: `{}` is disallowed", "{}{}{}: {}",
context_display(&msg.context), palette.info.paint(context_display(&msg.context)),
column, palette.info.paint(divider),
msg.typo, palette.info.paint(column),
palette
.strong
.paint(format_args!("`{}` is disallowed:", msg.typo)),
)?; )?;
} }
typos::Status::Corrections(corrections) => { typos::Status::Corrections(corrections) => {
let divider = ":";
writeln!( writeln!(
io::stdout(), io::stdout(),
"{}:{}: `{}` -> {}", "{}{}{}: {}",
context_display(&msg.context), palette.info.paint(context_display(&msg.context)),
column, palette.info.paint(divider),
palette.info.paint(column),
palette.strong.paint(format_args!(
"`{}` -> {}",
msg.typo, msg.typo,
itertools::join(corrections.iter().map(|s| format!("`{}`", s)), ", ") itertools::join(corrections.iter().map(|s| format!("`{}`", s)), ", ")
)),
)?; )?;
} }
} }
@ -143,7 +184,7 @@ fn print_brief_correction(msg: &Typo) -> Result<(), std::io::Error> {
Ok(()) Ok(())
} }
fn print_long_correction(msg: &Typo) -> Result<(), std::io::Error> { fn print_long_correction(msg: &Typo, palette: Palette) -> Result<(), std::io::Error> {
let stdout = io::stdout(); let stdout = io::stdout();
let mut handle = stdout.lock(); let mut handle = stdout.lock();
@ -157,18 +198,36 @@ fn print_long_correction(msg: &Typo) -> Result<(), std::io::Error> {
match &msg.corrections { match &msg.corrections {
typos::Status::Valid => {} typos::Status::Valid => {}
typos::Status::Invalid => { typos::Status::Invalid => {
writeln!(handle, "error: `{}` is disallowed`", msg.typo,)?; writeln!(
handle,
"{}: {}",
palette.error.paint("error"),
palette
.strong
.paint(format_args!("`{}` is disallowed`", msg.typo))
)?;
} }
typos::Status::Corrections(corrections) => { typos::Status::Corrections(corrections) => {
writeln!( writeln!(
handle, handle,
"error: `{}` should be {}", "{}: {}",
palette.error.paint("error"),
palette.strong.paint(format_args!(
"`{}` should be {}",
msg.typo, msg.typo,
itertools::join(corrections.iter().map(|s| format!("`{}`", s)), ", ") itertools::join(corrections.iter().map(|s| format!("`{}`", s)), ", ")
))
)?; )?;
} }
} }
writeln!(handle, " --> {}:{}", context_display(&msg.context), column)?; let divider = ":";
writeln!(
handle,
" --> {}{}{}",
palette.info.paint(context_display(&msg.context)),
palette.info.paint(divider),
palette.info.paint(column)
)?;
if let Some(Context::File(context)) = &msg.context { if let Some(Context::File(context)) = &msg.context {
let line_num = context.line_num.to_string(); let line_num = context.line_num.to_string();
@ -178,8 +237,19 @@ fn print_long_correction(msg: &Typo) -> Result<(), std::io::Error> {
let hl: String = itertools::repeat_n("^", msg.typo.len()).collect(); let hl: String = itertools::repeat_n("^", msg.typo.len()).collect();
writeln!(handle, "{} |", line_indent)?; writeln!(handle, "{} |", line_indent)?;
writeln!(handle, "{} | {}", line_num, line.trim_end())?; writeln!(
writeln!(handle, "{} | {}{}", line_indent, hl_indent, hl)?; handle,
"{} | {}",
palette.info.paint(line_num),
line.trim_end()
)?;
writeln!(
handle,
"{} | {}{}",
line_indent,
hl_indent,
palette.error.paint(hl)
)?;
writeln!(handle, "{} |", line_indent)?; writeln!(handle, "{} |", line_indent)?;
} }