Skip to content

Commit

Permalink
Add response formatter; refactor stats formatter (#1398)
Browse files Browse the repository at this point in the history
This adds support for formatting responses in different ways.

For now, the options are:

* `plain`: No color, basic formatting
* `color`: Color, indented formatting (default)
* `emoji`: Fancy mode with emoji icons

Fixes #546
Related to #271
  • Loading branch information
mre committed Jun 14, 2024
1 parent cc7acfb commit dedc554
Show file tree
Hide file tree
Showing 24 changed files with 713 additions and 238 deletions.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,10 +458,17 @@ Options:
-o, --output <OUTPUT>
Output file of status report
--mode <MODE>
Set the output display mode. Determines how results are presented in the terminal
[default: color]
[possible values: plain, color, emoji]
-f, --format <FORMAT>
Output format of final status report (compact, detailed, json, markdown)
Output format of final status report
[default: compact]
[possible values: compact, detailed, json, markdown, raw]
--require-https
When HTTPS is available, treat HTTP links as errors
Expand All @@ -474,7 +481,6 @@ Options:
-V, --version
Print version
```

### Exit codes
Expand Down
61 changes: 35 additions & 26 deletions lychee-bin/src/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use lychee_lib::{InputSource, Result};
use lychee_lib::{ResponseBody, Status};

use crate::archive::{Archive, Suggestion};
use crate::formatters::get_response_formatter;
use crate::formatters::response::ResponseFormatter;
use crate::verbosity::Verbosity;
use crate::{cache::Cache, stats::ResponseStats, ExitCode};
Expand Down Expand Up @@ -62,11 +63,13 @@ where
accept,
));

let formatter = get_response_formatter(&params.cfg.mode);

let show_results_task = tokio::spawn(progress_bar_task(
recv_resp,
params.cfg.verbose,
pb.clone(),
Arc::new(params.formatter),
formatter,
stats,
));

Expand Down Expand Up @@ -178,11 +181,17 @@ async fn progress_bar_task(
mut recv_resp: mpsc::Receiver<Response>,
verbose: Verbosity,
pb: Option<ProgressBar>,
formatter: Arc<Box<dyn ResponseFormatter>>,
formatter: Box<dyn ResponseFormatter>,
mut stats: ResponseStats,
) -> Result<(Option<ProgressBar>, ResponseStats)> {
while let Some(response) = recv_resp.recv().await {
show_progress(&mut io::stderr(), &pb, &response, &formatter, &verbose)?;
show_progress(
&mut io::stderr(),
&pb,
&response,
formatter.as_ref(),
&verbose,
)?;
stats.add(response);
}
Ok((pb, stats))
Expand Down Expand Up @@ -289,10 +298,11 @@ fn show_progress(
output: &mut dyn Write,
progress_bar: &Option<ProgressBar>,
response: &Response,
formatter: &Arc<Box<dyn ResponseFormatter>>,
formatter: &dyn ResponseFormatter,
verbose: &Verbosity,
) -> Result<()> {
let out = formatter.write_response(response)?;
let out = formatter.format_response(response.body());

if let Some(pb) = progress_bar {
pb.inc(1);
pb.set_message(out.clone());
Expand Down Expand Up @@ -330,31 +340,26 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {

#[cfg(test)]
mod tests {
use crate::{formatters::get_response_formatter, options};
use log::info;

use lychee_lib::{CacheStatus, ClientBuilder, InputSource, ResponseBody, Uri};

use crate::formatters;
use lychee_lib::{CacheStatus, ClientBuilder, InputSource, Uri};

use super::*;

#[test]
fn test_skip_cached_responses_in_progress_output() {
let mut buf = Vec::new();
let response = Response(
let response = Response::new(
Uri::try_from("http://127.0.0.1").unwrap(),
Status::Cached(CacheStatus::Ok(200)),
InputSource::Stdin,
ResponseBody {
uri: Uri::try_from("http://127.0.0.1").unwrap(),
status: Status::Cached(CacheStatus::Ok(200)),
},
);
let formatter: Arc<Box<dyn ResponseFormatter>> =
Arc::new(Box::new(formatters::response::Raw::new()));
let formatter = get_response_formatter(&options::OutputMode::Plain);
show_progress(
&mut buf,
&None,
&response,
&formatter,
formatter.as_ref(),
&Verbosity::default(),
)
.unwrap();
Expand All @@ -366,20 +371,24 @@ mod tests {
#[test]
fn test_show_cached_responses_in_progress_debug_output() {
let mut buf = Vec::new();
let response = Response(
let response = Response::new(
Uri::try_from("http://127.0.0.1").unwrap(),
Status::Cached(CacheStatus::Ok(200)),
InputSource::Stdin,
ResponseBody {
uri: Uri::try_from("http://127.0.0.1").unwrap(),
status: Status::Cached(CacheStatus::Ok(200)),
},
);
let formatter: Arc<Box<dyn ResponseFormatter>> =
Arc::new(Box::new(formatters::response::Raw::new()));
show_progress(&mut buf, &None, &response, &formatter, &Verbosity::debug()).unwrap();
let formatter = get_response_formatter(&options::OutputMode::Plain);
show_progress(
&mut buf,
&None,
&response,
formatter.as_ref(),
&Verbosity::debug(),
)
.unwrap();

assert!(!buf.is_empty());
let buf = String::from_utf8_lossy(&buf);
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
}

#[tokio::test]
Expand Down
2 changes: 0 additions & 2 deletions lychee-bin/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ pub(crate) use dump::dump_inputs;
use std::sync::Arc;

use crate::cache::Cache;
use crate::formatters::response::ResponseFormatter;
use crate::options::Config;
use lychee_lib::Result;
use lychee_lib::{Client, Request};
Expand All @@ -18,6 +17,5 @@ pub(crate) struct CommandParams<S: futures::Stream<Item = Result<Request>>> {
pub(crate) client: Client,
pub(crate) cache: Arc<Cache>,
pub(crate) requests: S,
pub(crate) formatter: Box<dyn ResponseFormatter>,
pub(crate) cfg: Config,
}
18 changes: 17 additions & 1 deletion lychee-bin/src/color.rs → lychee-bin/src/formatters/color.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
//! Defines the colors used in the output of the CLI.

use console::Style;
use log::Level;
use once_cell::sync::Lazy;

pub(crate) static NORMAL: Lazy<Style> = Lazy::new(Style::new);
pub(crate) static DIM: Lazy<Style> = Lazy::new(|| Style::new().dim());

pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bright());
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(2).bold().bright());
pub(crate) static BOLD_GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bold().bright());
pub(crate) static YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bright());
pub(crate) static BOLD_YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bold().bright());
pub(crate) static PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197));
pub(crate) static BOLD_PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bold());

// Used for debug log messages
pub(crate) static BLUE: Lazy<Style> = Lazy::new(|| Style::new().blue().bright());

// Write output using predefined colors
macro_rules! color {
($f:ident, $color:ident, $text:tt, $($tts:tt)*) => {
write!($f, "{}", $color.apply_to(format!($text, $($tts)*)))
};
}

/// Returns the appropriate color for a given log level.
pub(crate) fn color_for_level(level: Level) -> &'static Style {
match level {
Level::Error => &BOLD_PINK,
Level::Warn => &BOLD_YELLOW,
Level::Info | Level::Debug => &BLUE,
Level::Trace => &DIM,
}
}

pub(crate) use color;
83 changes: 83 additions & 0 deletions lychee-bin/src/formatters/log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use env_logger::{Builder, Env};
use log::LevelFilter;
use std::io::Write;

use crate::{
formatters::{self, response::MAX_RESPONSE_OUTPUT_WIDTH},
options::OutputMode,
verbosity::Verbosity,
};

/// Initialize the logging system with the given verbosity level.
pub(crate) fn init_logging(verbose: &Verbosity, mode: &OutputMode) {
// Set a base level for all modules to `warn`, which is a reasonable default.
// It will be overridden by RUST_LOG if it's set.
let env = Env::default().filter_or("RUST_LOG", "warn");

let mut builder = Builder::from_env(env);
builder
.format_timestamp(None)
.format_module_path(false)
.format_target(false);

if std::env::var("RUST_LOG").is_err() {
// Adjust the base log level filter based on the verbosity from CLI.
// This applies to all modules not explicitly mentioned in RUST_LOG.
let level_filter = verbose.log_level_filter();

// Apply a global filter. This ensures that, by default, other modules don't log at the debug level.
builder.filter_level(LevelFilter::Info);

// Apply more specific filters to your own crates, enabling more verbose logging as per `-vv`.
builder
.filter_module("lychee", level_filter)
.filter_module("lychee_lib", level_filter);
}

// Calculate the longest log level text, including brackets.
let max_level_text_width = log::LevelFilter::iter()
.map(|level| level.as_str().len() + 2)
.max()
.unwrap_or(0);

// Customize the log message format according to the output mode
if mode.is_plain() {
// Explicitly disable colors for plain output
builder.format(move |buf, record| writeln!(buf, "[{}] {}", record.level(), record.args()));
} else if mode.is_emoji() {
// Disable padding, keep colors
builder.format(move |buf, record| {
let level = record.level();
let color = formatters::color::color_for_level(level);
writeln!(
buf,
"{} {}",
color.apply_to(format!("[{level}]")),
record.args()
)
});
} else {
builder.format(move |buf, record| {
let level = record.level();
let level_text = format!("{level:5}");
let padding = (MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(max_level_text_width)).max(0);
let prefix = format!(
"{:<width$}",
format!("[{}]", level_text),
width = max_level_text_width
);
let color = formatters::color::color_for_level(level);
let colored_level = color.apply_to(&prefix);
writeln!(
buf,
"{:<padding$}{} {}",
"",
colored_level,
record.args(),
padding = padding
)
});
}

builder.init();
}
52 changes: 23 additions & 29 deletions lychee-bin/src/formatters/mod.rs
Original file line number Diff line number Diff line change
@@ -1,47 +1,41 @@
pub(crate) mod color;
pub(crate) mod duration;
pub(crate) mod log;
pub(crate) mod response;
pub(crate) mod stats;

use lychee_lib::{CacheStatus, ResponseBody, Status};
use self::{response::ResponseFormatter, stats::StatsFormatter};
use crate::options::{OutputMode, StatsFormat};
use supports_color::Stream;

use crate::{
color::{DIM, GREEN, NORMAL, PINK, YELLOW},
options::{self, Format},
};

use self::response::ResponseFormatter;

/// Detects whether a terminal supports color, and gives details about that
/// support. It takes into account the `NO_COLOR` environment variable.
fn supports_color() -> bool {
supports_color::on(Stream::Stdout).is_some()
}

/// Color the response body for TTYs that support it
pub(crate) fn color_response(body: &ResponseBody) -> String {
if supports_color() {
let out = match body.status {
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => GREEN.apply_to(body),
Status::Excluded
| Status::Unsupported(_)
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => {
DIM.apply_to(body)
}
Status::Redirected(_) => NORMAL.apply_to(body),
Status::UnknownStatusCode(_) | Status::Timeout(_) => YELLOW.apply_to(body),
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => PINK.apply_to(body),
};
out.to_string()
} else {
body.to_string()
/// Create a stats formatter based on the given format option
pub(crate) fn get_stats_formatter(
format: &StatsFormat,
mode: &OutputMode,
) -> Box<dyn StatsFormatter> {
match format {
StatsFormat::Compact => Box::new(stats::Compact::new(mode.clone())),
StatsFormat::Detailed => Box::new(stats::Detailed::new(mode.clone())),
StatsFormat::Json => Box::new(stats::Json::new()),
StatsFormat::Markdown => Box::new(stats::Markdown::new()),
StatsFormat::Raw => Box::new(stats::Raw::new()),
}
}

/// Create a response formatter based on the given format option
pub(crate) fn get_formatter(format: &options::Format) -> Box<dyn ResponseFormatter> {
if matches!(format, Format::Raw) || !supports_color() {
return Box::new(response::Raw::new());
pub(crate) fn get_response_formatter(mode: &OutputMode) -> Box<dyn ResponseFormatter> {
if !supports_color() {
return Box::new(response::PlainFormatter);
}
match mode {
OutputMode::Plain => Box::new(response::PlainFormatter),
OutputMode::Color => Box::new(response::ColorFormatter),
OutputMode::Emoji => Box::new(response::EmojiFormatter),
}
Box::new(response::Color::new())
}
Loading

0 comments on commit dedc554

Please sign in to comment.