use clap::Parser;
use regex_lite::Regex;
use std::borrow::Cow;
use std::env;
use std::io::{self, BufRead, BufWriter, IsTerminal, Write};
use std::process::{Command, Stdio};
const TRUNCATE_PREFIX_RESERVE: usize = 18;
const TRUNCATE_SUFFIX_LEN: usize = 15;
const TRUNCATE_SEPARATOR: &str = " .. ";
const COLS_ADJUSTMENT: usize = 19;
const BUFFER_SIZE: usize = 64 * 1024;
const LINES_TO_SKIP_AFTER_MF: usize = 2;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(long)]
lines: Option<usize>,
#[arg(long)]
cols: Option<usize>,
#[arg(long)]
no_color: bool,
#[arg(long)]
show_config: bool,
#[arg(long, default_value = "make")]
make_cmd: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
arguments: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Context {
Unknown,
Make,
Gcc,
}
struct Matchers {
make: Regex,
gcc: Regex,
error: Regex,
warning: Regex,
note: Regex,
file: Regex,
line: Regex,
}
impl Matchers {
fn new() -> Result<Self, regex_lite::Error> {
Ok(Self {
make: Regex::new(r"^((p|g)?make\[)")?,
gcc: Regex::new(
r"^\s*(libtool:\s*)?((compile|link):\s*)?(([\x00-\x7F]+-)?g?(cc|\+\+)|(g|c)\+\+|clang|rustc|cargo|CC|CXX)\b",
)?,
error: Regex::new(r"error:")?,
warning: Regex::new(r"warning:")?,
note: Regex::new(r"note:")?,
file: Regex::new(r"^([^:]+)")?,
line: Regex::new(r":(\d+)([:,])")?,
})
}
}
struct ColorScheme {
norm: String,
default: String,
gcc: String,
make: String,
filename: String,
linenum: String,
trace: String,
warning: String,
comment: String,
error: String,
error_hl: String,
}
impl ColorScheme {
fn new(enabled: bool) -> Self {
if !enabled {
return Self {
norm: String::new(),
default: String::new(),
gcc: String::new(),
make: String::new(),
filename: String::new(),
linenum: String::new(),
trace: String::new(),
warning: String::new(),
comment: String::new(),
error: String::new(),
error_hl: String::new(),
};
}
let norm = "\x1b[0m".to_string();
let default = "\x1b[38;2;216;222;233m".to_string();
let brighten = "\x1b[1m".to_string();
let magenta = "\x1b[38;2;180;142;173m".to_string();
let cyan = "\x1b[38;2;136;192;208m".to_string();
let yellow = "\x1b[38;2;235;203;139m".to_string();
let green = "\x1b[38;2;163;190;140m".to_string();
let drkgray = "\x1b[38;2;59;66;82m".to_string();
Self {
norm,
default,
gcc: format!("{magenta}{brighten}"),
make: cyan.clone(),
filename: yellow.clone(),
linenum: cyan,
trace: yellow.clone(),
warning: green,
comment: drkgray,
error: format!("{yellow}{brighten}"),
error_hl: brighten,
}
}
fn show_config(&self) {
println!("Color Scheme Configuration:");
println!(" default: {}\"[sample]\"{}", self.default, self.norm);
println!(" gcc: {}\"[sample]\"{}", self.gcc, self.norm);
println!(" make: {}\"[sample]\"{}", self.make, self.norm);
println!(" filename: {}\"[sample]\"{}", self.filename, self.norm);
println!(" linenum: {}\"[sample]\"{}", self.linenum, self.norm);
println!(" trace: {}\"[sample]\"{}", self.trace, self.norm);
println!(" warning: {}\"[sample]\"{}", self.warning, self.norm);
println!(" comment: {}\"[sample]\"{}", self.comment, self.norm);
println!(" error: {}\"[sample]\"{}", self.error, self.norm);
println!(" error_hl: {}\"[sample]\"{}", self.error_hl, self.norm);
}
}
struct Processor {
matchers: Matchers,
scheme: ColorScheme,
cols: usize,
}
impl Processor {
const fn new(matchers: Matchers, scheme: ColorScheme, cols: usize) -> Self {
Self {
matchers,
scheme,
cols,
}
}
#[inline]
fn process_line(&self, line: &str, context: &mut Context, lines_to_skip: &mut usize) -> String {
if *lines_to_skip > 0 {
*lines_to_skip -= 1;
return String::new();
}
let original_line = line;
let mut text = normalize_ws(line);
if self.cols > 0 && text.len() > self.cols {
text = truncate(&text, self.cols);
}
if self.matchers.make.is_match(&text) {
text = self
.matchers
.make
.replace(&text, format!("{}$1", self.scheme.make))
.into_owned();
*context = Context::Make;
return text;
}
if self.matchers.gcc.is_match(&text) {
text = format!("{}{}{}", self.scheme.gcc, text, self.scheme.norm);
*context = Context::Gcc;
if text.contains(" -MF ") {
*lines_to_skip = LINES_TO_SKIP_AFTER_MF;
}
text = self.process_gcc_line(&text, original_line);
return text;
}
if *context == Context::Gcc {
text = self.process_gcc_line(&text, original_line);
}
text
}
#[inline]
fn process_gcc_line(&self, text: &str, original_line: &str) -> String {
let mut result = text.to_string();
if self.matchers.error.is_match(&result) {
let mut error_text: Cow<str> = original_line.into();
if self.cols > 0 && error_text.len() > self.cols {
error_text = Cow::Owned(truncate(&error_text, self.cols));
}
error_text = Cow::Owned(
self.matchers
.line
.replace_all(
&error_text,
format!(":{}$1{}$2", self.scheme.linenum, self.scheme.default),
)
.into_owned(),
);
result = format!("{}{}{}", self.scheme.error_hl, error_text, self.scheme.norm);
result = self
.matchers
.file
.replace(
&result,
format!("{}$1{}", self.scheme.filename, self.scheme.default),
)
.into_owned();
return result;
}
if self.matchers.warning.is_match(&result) {
result = self
.matchers
.warning
.replace(&result, format!("warning:{}$0", self.scheme.warning))
.into_owned();
result = self
.matchers
.file
.replace(
&result,
format!("{}$1{}", self.scheme.filename, self.scheme.default),
)
.into_owned();
return result;
}
if self.matchers.note.is_match(&result) {
result = self
.matchers
.note
.replace(&result, format!("note:{}$0", self.scheme.trace))
.into_owned();
result = self
.matchers
.file
.replace(
&result,
format!("{}$1{}", self.scheme.filename, self.scheme.default),
)
.into_owned();
return result;
}
result = self
.matchers
.file
.replace(
&result,
format!("{}$1{}", self.scheme.filename, self.scheme.default),
)
.into_owned();
result
}
fn process_all<R: BufRead, W: Write>(&self, reader: R, writer: &mut W) -> io::Result<bool> {
let mut context = Context::Unknown;
let mut lines_to_skip = 0;
let mut has_output = false;
for line in reader.lines() {
let line = line?;
let processed_text = self.process_line(&line, &mut context, &mut lines_to_skip);
if !processed_text.is_empty() {
has_output = true;
if !processed_text.starts_with(' ') {
write!(writer, "{}{}", self.scheme.norm, self.scheme.default)?;
}
writeln!(writer, "{processed_text}")?;
}
}
write!(writer, "{}", self.scheme.norm)?;
writer.flush()?;
Ok(has_output)
}
}
fn main() -> io::Result<()> {
let args = Args::parse();
let has_color = io::stdout().is_terminal()
&& env::var("TERM")
.map(|t| !matches!(t.as_str(), "dumb" | "unknown"))
.unwrap_or(false)
&& !args.no_color;
let scheme = ColorScheme::new(has_color);
let matchers = Matchers::new().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to compile regex patterns: {e}"),
)
})?;
if args.show_config {
scheme.show_config();
return Ok(());
}
let cols = args
.cols
.map_or(0, |c| c.saturating_sub(COLS_ADJUSTMENT).max(0));
let processor = Processor::new(matchers, scheme, cols);
let mut writer = BufWriter::with_capacity(BUFFER_SIZE, io::stdout());
if !io::stdin().is_terminal() {
let stdin = io::stdin();
let has_output = processor.process_all(stdin.lock(), &mut writer)?;
if !has_output {
writeln!(writer)?;
}
return Ok(());
}
let mut cmd = Command::new(&args.make_cmd);
cmd.args(&args.arguments);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
eprintln!(
"colormake: error: failed to execute '{}': {}",
args.make_cmd, e
);
std::process::exit(127);
}
};
let mut has_output = false;
if let Some(stdout) = child.stdout.take() {
let reader = io::BufReader::new(stdout);
has_output |= processor.process_all(reader, &mut writer)?;
}
if let Some(stderr) = child.stderr.take() {
let reader = io::BufReader::new(stderr);
has_output |= processor.process_all(reader, &mut writer)?;
}
if !has_output {
writeln!(writer)?;
}
writer.flush()?;
let status = child.wait()?;
std::process::exit(status.code().unwrap_or(1));
}
#[inline]
fn normalize_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[inline]
fn truncate(s: &str, cols: usize) -> String {
if s.len() <= cols {
return s.to_string();
}
let prefix_len = cols.saturating_sub(TRUNCATE_PREFIX_RESERVE);
let suffix_start = s.len().saturating_sub(TRUNCATE_SUFFIX_LEN);
format!(
"{}{}{}",
&s[..prefix_len],
TRUNCATE_SEPARATOR,
&s[suffix_start..]
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_ws() {
assert_eq!(normalize_ws(" hello world "), "hello world");
assert_eq!(normalize_ws("single"), "single");
assert_eq!(normalize_ws(""), "");
}
#[test]
fn test_truncate() {
let s = "a".repeat(100);
let result = truncate(&s, 50);
assert!(result.len() <= 50 + 5);
assert!(result.contains(".."));
let short = "short";
assert_eq!(truncate(short, 100), short);
}
#[test]
fn test_truncate_edge_cases() {
let s = "test";
let result = truncate(s, 10);
assert!(!result.is_empty());
assert_eq!(truncate("", 10), "");
}
#[test]
fn test_color_scheme_disabled() {
let scheme = ColorScheme::new(false);
assert_eq!(scheme.norm, "");
assert_eq!(scheme.default, "");
}
#[test]
fn test_color_scheme_enabled() {
let scheme = ColorScheme::new(true);
assert!(!scheme.norm.is_empty());
assert!(!scheme.default.is_empty());
}
#[test]
fn test_matchers_creation() {
let matchers = Matchers::new();
assert!(matchers.is_ok());
}
#[test]
fn test_context_enum() {
let mut ctx = Context::Unknown;
assert_eq!(ctx, Context::Unknown);
ctx = Context::Make;
assert_eq!(ctx, Context::Make);
ctx = Context::Gcc;
assert_eq!(ctx, Context::Gcc);
}
#[test]
fn test_process_line_make() {
let matchers = Matchers::new().unwrap();
let scheme = ColorScheme::new(true);
let processor = Processor::new(matchers, scheme, 0);
let mut context = Context::Unknown;
let mut lines_to_skip = 0;
processor.process_line(
"make[1]: Entering directory",
&mut context,
&mut lines_to_skip,
);
assert_eq!(context, Context::Make);
}
#[test]
fn test_process_line_gcc() {
let matchers = Matchers::new().unwrap();
let scheme = ColorScheme::new(true);
let processor = Processor::new(matchers, scheme, 0);
let mut context = Context::Unknown;
let mut lines_to_skip = 0;
processor.process_line("gcc -c file.c", &mut context, &mut lines_to_skip);
assert_eq!(context, Context::Gcc);
}
#[test]
fn test_process_line_error() {
let matchers = Matchers::new().unwrap();
let scheme = ColorScheme::new(true);
let processor = Processor::new(matchers, scheme, 0);
let mut context = Context::Gcc;
let mut lines_to_skip = 0;
let text = processor.process_line(
"file.c:10:5: error: something",
&mut context,
&mut lines_to_skip,
);
assert!(text.contains("error"));
}
#[test]
fn test_process_line_warning() {
let matchers = Matchers::new().unwrap();
let scheme = ColorScheme::new(true);
let processor = Processor::new(matchers, scheme, 0);
let mut context = Context::Gcc;
let mut lines_to_skip = 0;
let text = processor.process_line(
"file.c:10:5: warning: something",
&mut context,
&mut lines_to_skip,
);
assert!(text.contains("warning"));
}
#[test]
fn test_cols_overflow_protection() {
let cols = 10usize;
let result = cols.saturating_sub(COLS_ADJUSTMENT);
assert_eq!(result, 0);
}
}