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};
use std::sync::{Arc, Mutex};
use std::thread;
const TRUNCATE_PREFIX_RESERVE: usize = 18;
const TRUNCATE_SUFFIX_LEN: usize = 15;
const TRUNCATE_SEPARATOR: &str = " .. ";
const COLS_ADJUSTMENT: usize = 19;
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)]
show_make_version: 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::create_disabled();
}
Self::create_enabled()
}
const fn create_disabled() -> Self {
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(),
}
}
fn create_enabled() -> Self {
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 get_default_prefix(&self) -> String {
format!("{}{}", self.scheme.norm, self.scheme.default)
}
#[inline]
fn get_reset(&self) -> &str {
&self.scheme.norm
}
#[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);
text = self.apply_truncation_if_needed(&text);
if self.matchers.make.is_match(&text) {
return self.process_make_line(&text, context);
}
if self.matchers.gcc.is_match(&text) {
return self.process_gcc_command_line(&text, original_line, context, lines_to_skip);
}
if *context == Context::Gcc {
return self.process_gcc_line(&text, original_line);
}
text
}
fn apply_truncation_if_needed(&self, text: &str) -> String {
if self.cols == 0 {
return text.to_string();
}
if text.len() <= self.cols {
return text.to_string();
}
truncate(text, self.cols)
}
fn process_make_line(&self, text: &str, context: &mut Context) -> String {
let result = self
.matchers
.make
.replace(text, format!("{}$1", self.scheme.make))
.into_owned();
*context = Context::Make;
result
}
fn process_gcc_command_line(
&self,
text: &str,
original_line: &str,
context: &mut Context,
lines_to_skip: &mut usize,
) -> String {
let colored_text = format!("{}{}{}", self.scheme.gcc, text, self.scheme.norm);
*context = Context::Gcc;
if colored_text.contains(" -MF ") {
*lines_to_skip = LINES_TO_SKIP_AFTER_MF;
}
self.process_gcc_line(&colored_text, original_line)
}
#[inline]
fn process_gcc_line(&self, text: &str, original_line: &str) -> String {
if self.matchers.error.is_match(text) {
return self.process_error_line(text, original_line);
}
if self.matchers.warning.is_match(text) {
return self.process_warning_line(text);
}
if self.matchers.note.is_match(text) {
return self.process_note_line(text);
}
self.apply_filename_coloring(text)
}
fn process_error_line(&self, _text: &str, original_line: &str) -> String {
let mut error_text: Cow<str> = original_line.into();
error_text = self.apply_truncation_to_error_text(error_text);
error_text = Cow::Owned(self.apply_line_number_coloring(&error_text));
let highlighted = format!("{}{}{}", self.scheme.error_hl, error_text, self.scheme.norm);
self.apply_filename_coloring(&highlighted)
}
fn apply_truncation_to_error_text<'a>(&self, error_text: Cow<'a, str>) -> Cow<'a, str> {
if self.cols == 0 {
return error_text;
}
if error_text.len() <= self.cols {
return error_text;
}
Cow::Owned(truncate(&error_text, self.cols))
}
fn apply_line_number_coloring(&self, text: &str) -> String {
self.matchers
.line
.replace_all(
text,
format!(":{}$1{}$2", self.scheme.linenum, self.scheme.default),
)
.into_owned()
}
fn process_warning_line(&self, text: &str) -> String {
let with_warning_color = self
.matchers
.warning
.replace(text, format!("warning:{}$0", self.scheme.warning))
.into_owned();
self.apply_filename_coloring(&with_warning_color)
}
fn process_note_line(&self, text: &str) -> String {
let with_note_color = self
.matchers
.note
.replace(text, format!("note:{}$0", self.scheme.trace))
.into_owned();
self.apply_filename_coloring(&with_note_color)
}
fn apply_filename_coloring(&self, text: &str) -> String {
self.matchers
.file
.replace(
text,
format!("{}$1{}", self.scheme.filename, self.scheme.default),
)
.into_owned()
}
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}")?;
writer.flush()?;
}
}
write!(writer, "{}", self.scheme.norm)?;
writer.flush()?;
Ok(has_output)
}
}
const STDIN_BUFFER_SIZE: usize = 8192;
fn process_stdin(processor: &Processor) -> io::Result<()> {
let mut writer = BufWriter::with_capacity(STDIN_BUFFER_SIZE, io::stdout());
let stdin = io::stdin();
let has_output = processor.process_all(stdin.lock(), &mut writer)?;
if !has_output {
writeln!(writer)?;
}
Ok(())
}
const STREAM_BUFFER_SIZE: usize = 1024;
fn create_stream_processor<R: io::Read + Send + 'static>(
reader: io::BufReader<R>,
processor: Arc<Processor>,
writer: Arc<Mutex<BufWriter<io::Stdout>>>,
has_output: Arc<Mutex<bool>>,
) -> thread::JoinHandle<io::Result<()>> {
thread::spawn(move || -> io::Result<()> {
let mut context = Context::Unknown;
let mut lines_to_skip = 0;
let mut local_has_output = false;
for line in reader.lines() {
let line = line?;
let processed_text = processor.process_line(&line, &mut context, &mut lines_to_skip);
if processed_text.is_empty() {
continue;
}
local_has_output = true;
write_processed_line(&writer, &processor, &processed_text)?;
}
reset_color_codes(&writer, &processor)?;
update_has_output_flag(&has_output, local_has_output);
Ok(())
})
}
fn write_processed_line(
writer: &Arc<Mutex<BufWriter<io::Stdout>>>,
processor: &Processor,
processed_text: &str,
) -> io::Result<()> {
let mut writer_guard = writer.lock().unwrap();
if !processed_text.starts_with(' ') {
write!(writer_guard, "{}", processor.get_default_prefix())?;
}
writeln!(writer_guard, "{processed_text}")?;
writer_guard.flush()?;
drop(writer_guard);
Ok(())
}
fn reset_color_codes(
writer: &Arc<Mutex<BufWriter<io::Stdout>>>,
processor: &Processor,
) -> io::Result<()> {
let mut writer_guard = writer.lock().unwrap();
write!(writer_guard, "{}", processor.get_reset())?;
writer_guard.flush()?;
drop(writer_guard);
Ok(())
}
fn update_has_output_flag(has_output: &Arc<Mutex<bool>>, local_has_output: bool) {
*has_output.lock().unwrap() |= local_has_output;
}
const SUBPROCESS_BUFFER_SIZE: usize = 8192;
fn run_subprocess(processor: Processor, mut child: std::process::Child) -> io::Result<()> {
let writer = Arc::new(Mutex::new(BufWriter::with_capacity(
SUBPROCESS_BUFFER_SIZE,
io::stdout(),
)));
let processor = Arc::new(processor);
let has_output = Arc::new(Mutex::new(false));
let handles = spawn_stream_processors(&mut child, &processor, &writer, &has_output);
wait_for_threads(handles)?;
finalize_output(&writer, &has_output)?;
let status = child.wait()?;
std::process::exit(status.code().unwrap_or(1));
}
fn spawn_stream_processors(
child: &mut std::process::Child,
processor: &Arc<Processor>,
writer: &Arc<Mutex<BufWriter<io::Stdout>>>,
has_output: &Arc<Mutex<bool>>,
) -> Vec<thread::JoinHandle<io::Result<()>>> {
let mut handles = Vec::new();
if let Some(stdout) = child.stdout.take() {
let reader = io::BufReader::with_capacity(STREAM_BUFFER_SIZE, stdout);
handles.push(create_stream_processor(
reader,
Arc::clone(processor),
Arc::clone(writer),
Arc::clone(has_output),
));
}
if let Some(stderr) = child.stderr.take() {
let reader = io::BufReader::with_capacity(STREAM_BUFFER_SIZE, stderr);
handles.push(create_stream_processor(
reader,
Arc::clone(processor),
Arc::clone(writer),
Arc::clone(has_output),
));
}
handles
}
fn wait_for_threads(handles: Vec<thread::JoinHandle<io::Result<()>>>) -> io::Result<()> {
for handle in handles {
handle.join().unwrap()?;
}
Ok(())
}
fn finalize_output(
writer: &Arc<Mutex<BufWriter<io::Stdout>>>,
has_output: &Arc<Mutex<bool>>,
) -> io::Result<()> {
let has_output_val = *has_output.lock().unwrap();
let mut writer_guard = writer.lock().unwrap();
if !has_output_val {
writeln!(writer_guard)?;
}
writer_guard.flush()?;
drop(writer_guard);
Ok(())
}
fn main() -> io::Result<()> {
let args = Args::parse();
let has_color = should_enable_colors(&args);
let scheme = ColorScheme::new(has_color);
let matchers = create_matchers()?;
if args.show_config {
scheme.show_config();
return Ok(());
}
if args.show_make_version {
return show_make_version(&args.make_cmd);
}
let cols = calculate_column_limit(args.cols);
let processor = Processor::new(matchers, scheme, cols);
if !io::stdin().is_terminal() {
return process_stdin(&processor);
}
let child = spawn_make_command(&args);
run_subprocess(processor, child)
}
fn spawn_make_command(args: &Args) -> std::process::Child {
let mut cmd = Command::new(&args.make_cmd);
cmd.args(&args.arguments);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
match cmd.spawn() {
Ok(child) => child,
Err(e) => {
eprintln!(
"colormake: error: failed to execute '{}': {}",
args.make_cmd, e
);
std::process::exit(127);
}
}
}
fn should_enable_colors(args: &Args) -> bool {
if args.no_color {
return false;
}
if !io::stdout().is_terminal() {
return false;
}
#[allow(clippy::manual_let_else)]
let term = match env::var("TERM") {
Ok(t) => t,
Err(_) => return false,
};
!matches!(term.as_str(), "dumb" | "unknown")
}
fn create_matchers() -> Result<Matchers, io::Error> {
Matchers::new().map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("Failed to compile regex patterns: {e}"),
)
})
}
fn show_make_version(make_cmd: &str) -> io::Result<()> {
let output = Command::new(make_cmd)
.arg("--version")
.output()
.map_err(|e| {
io::Error::new(
io::ErrorKind::NotFound,
format!("Failed to execute '{make_cmd} --version': {e}"),
)
})?;
io::stdout().write_all(&output.stdout)?;
io::stderr().write_all(&output.stderr)?;
Ok(())
}
fn calculate_column_limit(cols: Option<usize>) -> usize {
cols.map_or(0, |c| c.saturating_sub(COLS_ADJUSTMENT).max(0))
}
#[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);
}
}