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]]
name = "bstr"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40b47ad93e1a5404e6c18dec46b628214fee441c70f4ab5d6942142cc268a3d"
checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
dependencies = [
"lazy_static",
"memchr",
@ -521,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
dependencies = [
"atty",
"humantime 1.3.0",
"humantime",
"log",
"regex",
"termcolor",
@ -533,10 +533,7 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f"
dependencies = [
"atty",
"humantime 2.1.0",
"log",
"regex",
"termcolor",
]
@ -661,12 +658,6 @@ dependencies = [
"quick-error",
]
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "ident_case"
version = "1.0.1"
@ -1174,9 +1165,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.5.2"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efb2352a0f4d4b128f734b5c44c79ff80117351138733f12f982fe3e2b13343"
checksum = "ce5f1ceb7f74abbce32601642fcf8e8508a8a8991e0621c7d750295b9095702b"
dependencies = [
"aho-corasick",
"memchr",
@ -1194,9 +1185,9 @@ dependencies = [
[[package]]
name = "regex-syntax"
version = "0.6.24"
version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00efb87459ba4f6fb2169d20f68565555688e1250ee6825cdf6254f8b48fafb2"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
@ -1209,9 +1200,9 @@ dependencies = [
[[package]]
name = "rustc-demangle"
version = "0.1.18"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e3bad0ee36814ca07d7968269dd4b7ec89ec2da10c4bb613928d3077083c232"
checksum = "410f7acf3cb3a44527c5d9546bad4bf4e6c460915d5f9f2fc524498bfe8f70ce"
[[package]]
name = "rustc_version"
@ -1485,6 +1476,7 @@ dependencies = [
"ahash",
"anyhow",
"assert_fs",
"atty",
"bstr",
"clap",
"clap-verbosity-flag",
@ -1516,6 +1508,7 @@ dependencies = [
"unicase",
"unicode-segmentation",
"varcon-core",
"yansi",
]
[[package]]
@ -1797,3 +1790,9 @@ name = "wyz"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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"] }
toml = "0.5"
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"
once_cell = "1.2.0"
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 {
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 {
Format::Silent => &PRINT_SILENT,
Format::Brief => &PRINT_BRIEF,
Format::Long => &PRINT_LONG,
Format::Json => &PRINT_JSON,
Format::Silent => Box::new(crate::report::PrintSilent),
Format::Brief => Box::new(crate::report::PrintBrief {
stdout_palette,
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)]
pub(crate) config: ConfigArgs,
#[structopt(flatten)]
pub(crate) color: crate::color::ColorArgs,
#[structopt(flatten)]
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;
mod args;
mod color;
mod report;
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() {
run_dump_config(&args, output_path)
} else if args.type_list {
run_type_list(&args)
} 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(())
}
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 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
let output_reporter = if args.diff {
&args::PRINT_SILENT
Box::new(crate::report::PrintSilent)
} 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 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 {
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());

View file

@ -5,6 +5,34 @@ use std::sync::atomic;
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> {
typos_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 {
fn report(&self, msg: Message) -> Result<(), std::io::Error> {
@ -68,7 +98,7 @@ impl Report for PrintBrief {
Message::BinaryFile(msg) => {
log::info!("{}", msg);
}
Message::Typo(msg) => print_brief_correction(msg)?,
Message::Typo(msg) => print_brief_correction(msg, self.stdout_palette)?,
Message::File(msg) => {
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 {
fn report(&self, msg: Message) -> Result<(), std::io::Error> {
@ -93,7 +125,7 @@ impl Report for PrintLong {
Message::BinaryFile(msg) => {
log::info!("{}", msg);
}
Message::Typo(msg) => print_long_correction(msg)?,
Message::Typo(msg) => print_long_correction(msg, self.stdout_palette)?,
Message::File(msg) => {
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 = line.replace("\t", " ");
let column = unicode_segmentation::UnicodeSegmentation::graphemes(
@ -120,22 +152,31 @@ fn print_brief_correction(msg: &Typo) -> Result<(), std::io::Error> {
match &msg.corrections {
typos::Status::Valid => {}
typos::Status::Invalid => {
let divider = ":";
writeln!(
io::stdout(),
"{}:{}: `{}` is disallowed",
context_display(&msg.context),
column,
msg.typo,
"{}{}{}: {}",
palette.info.paint(context_display(&msg.context)),
palette.info.paint(divider),
palette.info.paint(column),
palette
.strong
.paint(format_args!("`{}` is disallowed:", msg.typo)),
)?;
}
typos::Status::Corrections(corrections) => {
let divider = ":";
writeln!(
io::stdout(),
"{}:{}: `{}` -> {}",
context_display(&msg.context),
column,
"{}{}{}: {}",
palette.info.paint(context_display(&msg.context)),
palette.info.paint(divider),
palette.info.paint(column),
palette.strong.paint(format_args!(
"`{}` -> {}",
msg.typo,
itertools::join(corrections.iter().map(|s| format!("`{}`", s)), ", ")
)),
)?;
}
}
@ -143,7 +184,7 @@ fn print_brief_correction(msg: &Typo) -> Result<(), std::io::Error> {
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 mut handle = stdout.lock();
@ -157,18 +198,36 @@ fn print_long_correction(msg: &Typo) -> Result<(), std::io::Error> {
match &msg.corrections {
typos::Status::Valid => {}
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) => {
writeln!(
handle,
"error: `{}` should be {}",
"{}: {}",
palette.error.paint("error"),
palette.strong.paint(format_args!(
"`{}` should be {}",
msg.typo,
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 {
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();
writeln!(handle, "{} |", line_indent)?;
writeln!(handle, "{} | {}", line_num, line.trim_end())?;
writeln!(handle, "{} | {}{}", line_indent, hl_indent, hl)?;
writeln!(
handle,
"{} | {}",
palette.info.paint(line_num),
line.trim_end()
)?;
writeln!(
handle,
"{} | {}{}",
line_indent,
hl_indent,
palette.error.paint(hl)
)?;
writeln!(handle, "{} |", line_indent)?;
}