Skip to content

Commit

Permalink
Add response formatter; refactor stats formatter
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 Apr 4, 2024
1 parent 13f4339 commit 0a68ae9
Show file tree
Hide file tree
Showing 21 changed files with 393 additions and 231 deletions.
60 changes: 35 additions & 25 deletions lychee-bin/src/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ use lychee_lib::{InputSource, Result};
use lychee_lib::{ResponseBody, Status};

use crate::archive::{Archive, Suggestion};
use crate::formatters::response::ResponseFormatter;
use crate::formatters::get_response_formatter;
use crate::formatters::response::ResponseBodyFormatter;
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 ResponseBodyFormatter>,
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 @@ -275,10 +284,11 @@ fn show_progress(
output: &mut dyn Write,
progress_bar: &Option<ProgressBar>,
response: &Response,
formatter: &Arc<Box<dyn ResponseFormatter>>,
formatter: &dyn ResponseBodyFormatter,
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 @@ -317,30 +327,26 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {
#[cfg(test)]
mod tests {
use log::info;
use lychee_lib::{CacheStatus, InputSource, Uri};

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

use crate::formatters;
use crate::{formatters::get_response_formatter, options};

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::ResponseFormat::Plain);
show_progress(
&mut buf,
&None,
&response,
&formatter,
formatter.as_ref(),
&Verbosity::default(),
)
.unwrap();
Expand All @@ -352,16 +358,20 @@ 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::ResponseFormat::Plain);
show_progress(
&mut buf,
&None,
&response,
formatter.as_ref(),
&Verbosity::debug(),
)
.unwrap();

assert!(!buf.is_empty());
let buf = String::from_utf8_lossy(&buf);
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,
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
//! Defines the colors used in the output of the CLI.

use console::Style;
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)*) => {
Expand Down
53 changes: 53 additions & 0 deletions lychee-bin/src/formatters/log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use log::Level;
use std::io::Write;

use crate::{formatters, options::ResponseFormat, verbosity::Verbosity};

/// Initialize the logging system with the given verbosity level
pub(crate) fn init_logging(verbose: &Verbosity, mode: &ResponseFormat) {
let mut builder = env_logger::Builder::new();

builder
.format_timestamp(None) // Disable timestamps
.format_module_path(false) // Disable module path to reduce clutter
.format_target(false) // Disable target
.filter_module("lychee", verbose.log_level_filter()) // Re-add module filtering
.filter_module("lychee_lib", verbose.log_level_filter()); // Re-add module filtering

// Enable color unless the user has disabled it
if !matches!(mode, ResponseFormat::Plain) {
builder.format(|buf, record| {
let level = record.level();
let level_text = match level {
Level::Error => "ERROR",
Level::Warn => " WARN",
Level::Info => " INFO",
Level::Debug => "DEBUG",
Level::Trace => "TRACE",
};

// Desired total width including brackets
let numeric_padding: usize = 10;
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
let effective_padding = numeric_padding.saturating_sub(level_text.len() + 2); // +2 for brackets

// Construct the log prefix with the log level.
// The spaces added before "WARN" and "INFO" are to visually align them with "ERROR", "DEBUG", and "TRACE"
let level_label = format!("[{level_text}]");
let c = match level {
Level::Error => &formatters::color::BOLD_PINK,
Level::Warn => &formatters::color::BOLD_YELLOW,
Level::Info | Level::Debug => &formatters::color::BLUE,
Level::Trace => &formatters::color::DIM,
};
let colored_level = c.apply_to(level_label);

let prefix = format!("{}{}", " ".repeat(effective_padding), colored_level);

// Write formatted log message with aligned level and original log message.
writeln!(buf, "{} {}", prefix, record.args())
});
}

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::ResponseBodyFormatter, stats::StatsFormatter};
use crate::options::{ResponseFormat, 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()
pub(crate) fn get_stats_formatter(
format: &StatsFormat,
response_format: &ResponseFormat,
) -> Box<dyn StatsFormatter> {
match format {
StatsFormat::Compact => Box::new(stats::Compact::new(response_format.clone())),
StatsFormat::Detailed => Box::new(stats::Detailed::new(response_format.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(format: &ResponseFormat) -> Box<dyn ResponseBodyFormatter> {
if !supports_color() {
return Box::new(response::PlainFormatter);
}
match format {
ResponseFormat::Plain => Box::new(response::PlainFormatter),
ResponseFormat::Color => Box::new(response::ColorFormatter),
ResponseFormat::Emoji => Box::new(response::EmojiFormatter),
}
Box::new(response::Color::new())
}
105 changes: 105 additions & 0 deletions lychee-bin/src/formatters/response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use lychee_lib::{CacheStatus, ResponseBody, Status};

use super::color::{DIM, GREEN, NORMAL, PINK, YELLOW};

/// A trait for formatting a response body
///
/// This trait is used to format a response body into a string.
/// It can be implemented for different formatting styles such as
/// colorized output or plain text.
pub(crate) trait ResponseBodyFormatter: Send + Sync {
fn format_response(&self, body: &ResponseBody) -> String;
}

/// A basic formatter that just returns the response body as a string
/// without any color codes or other formatting.
///
/// Under the hood, it calls the `Display` implementation of the `ResponseBody`
/// type.
///
/// This formatter is used when the user has requested raw output
/// or when the terminal does not support color.
pub(crate) struct PlainFormatter;

impl ResponseBodyFormatter for PlainFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
body.to_string()
}
}

/// A colorized formatter for the response body
///
/// This formatter is used when the terminal supports color and the user
/// has not explicitly requested raw, uncolored output.
pub(crate) struct ColorFormatter;

impl ResponseBodyFormatter for ColorFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
// Determine the color based on the status.
let status_color = match body.status {
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => &GREEN,
Status::Excluded
| Status::Unsupported(_)
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => &DIM,
Status::Redirected(_) => &NORMAL,
Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW,
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK,
};

let status_formatted = format_status(&body.status);

let colored_status = status_color.apply_to(status_formatted);

// Construct the output.
format!("{} {}", colored_status, body.uri)
}
}

/// Desired total width of formatted string for color formatter
///
/// The longest string, which needs to be formatted, is currently `[Excluded]`
/// which is 10 characters long (including brackets).
///
/// Keep in sync with `Status::code_as_string`, which converts status codes to
/// strings.
const STATUS_CODE_PADDING: usize = 10;

/// Format the status code or text for the color formatter.
///
/// Numeric status codes are right-aligned.
/// Textual statuses are left-aligned.
/// Padding is taken into account.
fn format_status(status: &Status) -> String {
let status_code_or_text = status.code_as_string();

// Calculate the effective padding. Ensure it's non-negative to avoid panic.
let padding = STATUS_CODE_PADDING.saturating_sub(status_code_or_text.len() + 2); // +2 for brackets

format!(
"{}[{:>width$}]",
" ".repeat(padding),
status_code_or_text,
width = status_code_or_text.len()
)
}

/// An emoji formatter for the response body
///
/// This formatter replaces certain textual elements with emojis for a more
/// visual output.
pub(crate) struct EmojiFormatter;

impl ResponseBodyFormatter for EmojiFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
let emoji = match body.status {
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => "✅",
Status::Excluded
| Status::Unsupported(_)
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => "🚫",
Status::Redirected(_) => "↪️",
Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️",
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "❌",
};
format!("{} {}", emoji, body.uri)
}
}

0 comments on commit 0a68ae9

Please sign in to comment.