Skip to content

Commit 3169ff7

Browse files
fcouryclaude
andcommitted
feat: add ANSI color support for test runner and error messages
- Add colored output for test results (green=passed, red=failed, yellow=ignored) - Add colored error messages with red error prefix and underlines - Add automatic TTY detection to disable colors when output is piped - Add global --no-color flag to disable all colored output - Add --no-color flag specifically for test command - Use termcolor crate for cross-platform color support - Use atty crate for TTY detection - Fix test script compatibility by ensuring proper output formatting Colors are automatically disabled when: - Output is piped or redirected to a file - The --no-color flag is used - Not running in a TTY environment This makes error messages and test results much easier to read while maintaining compatibility with scripts and CI environments. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent ee02951 commit 3169ff7

File tree

6 files changed

+263
-32
lines changed

6 files changed

+263
-32
lines changed

Cargo.lock

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ edition = "2021"
55

66
[dependencies]
77
anyhow = "1.0.86"
8+
atty = "0.2"
89
clap = { version = "4.5.8", features = ["derive"] }
910
indexmap = "2.2.6"
1011
rustyline = "14.0.0"
1112
serde = { version = "1.0", features = ["derive"] }
1213
serde_json = "1.0"
1314
simple-home-dir = "0.3.5"
15+
termcolor = "1.4"
1416
thiserror = "1.0.61"
1517
toml = "0.8"
1618

src/error.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::fmt;
2+
use std::io::Write;
23

4+
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
35
use thiserror::Error;
46

57
use crate::span::Span;
@@ -45,6 +47,85 @@ impl Error {
4547
}
4648
}
4749

50+
pub fn pretty_print_colored(&self, code: impl Into<String>, no_color: bool) -> String {
51+
// Use a string buffer to build the colored output
52+
let mut buffer = Vec::new();
53+
self.write_colored(&mut buffer, code, no_color).unwrap();
54+
String::from_utf8(buffer).unwrap()
55+
}
56+
57+
pub fn write_colored<W: Write>(
58+
&self,
59+
_writer: &mut W,
60+
code: impl Into<String>,
61+
no_color: bool,
62+
) -> std::io::Result<()> {
63+
let code = code.into();
64+
65+
// Determine color choice
66+
let color_choice = if no_color {
67+
ColorChoice::Never
68+
} else if atty::is(atty::Stream::Stderr) {
69+
ColorChoice::Auto
70+
} else {
71+
ColorChoice::Never
72+
};
73+
74+
// Only use color if we're writing to a TTY
75+
if color_choice == ColorChoice::Auto {
76+
let mut stderr = StandardStream::stderr(color_choice);
77+
78+
// Write error type in red
79+
stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))?;
80+
write!(&mut stderr, "error")?;
81+
stderr.reset()?;
82+
83+
// Write location info
84+
if let Some(span) = self.span() {
85+
write!(&mut stderr, ": ")?;
86+
stderr.set_color(ColorSpec::new().set_bold(true))?;
87+
write!(
88+
&mut stderr,
89+
"{}:{}",
90+
span.line_number(&code),
91+
span.column_number(&code)
92+
)?;
93+
stderr.reset()?;
94+
}
95+
96+
// Write message
97+
write!(&mut stderr, " - {}", self.message())?;
98+
99+
// Write code snippet with underline
100+
if let Some(span) = self.span() {
101+
writeln!(&mut stderr)?;
102+
let snippet = span.pretty_print(&code);
103+
104+
// Split the snippet into lines
105+
let lines: Vec<&str> = snippet.lines().collect();
106+
107+
// Write the code line
108+
if !lines.is_empty() {
109+
writeln!(&mut stderr, "{}", lines[0])?;
110+
}
111+
112+
// Write the underline in red
113+
if lines.len() > 1 {
114+
stderr.set_color(ColorSpec::new().set_fg(Some(Color::Red)).set_bold(true))?;
115+
writeln!(&mut stderr, "{}", lines[1])?;
116+
stderr.reset()?;
117+
}
118+
} else {
119+
writeln!(&mut stderr)?;
120+
}
121+
} else {
122+
// No color - just print plain text
123+
eprintln!("{}", self.pretty_print(&code));
124+
}
125+
126+
Ok(())
127+
}
128+
48129
fn span(&self) -> Option<&Span> {
49130
match self {
50131
Error::Semantic(_, span) => Some(span),

src/main.rs

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ struct Cli {
1212

1313
/// Run a hash script
1414
file: Option<std::path::PathBuf>,
15+
16+
/// Disable colored output
17+
#[clap(long, global = true)]
18+
no_color: bool,
1519
}
1620

1721
#[derive(Parser, Debug)]
@@ -77,6 +81,10 @@ struct Test {
7781
/// Number of test threads (interpreter mode only)
7882
#[clap(long, default_value = "1")]
7983
test_threads: usize,
84+
85+
/// Disable colored output
86+
#[clap(long)]
87+
no_color: bool,
8088
}
8189

8290
#[derive(Subcommand, Debug)]
@@ -108,23 +116,24 @@ struct Compile {
108116

109117
fn main() -> anyhow::Result<()> {
110118
let cli = Cli::parse();
119+
let no_color = cli.no_color;
111120

112121
if let Some(file) = cli.file {
113-
return run_command(file);
122+
return run_command(file, no_color);
114123
}
115124

116125
match cli.cmd {
117-
Some(Command::Run(run)) => run_command(run.file)?,
118-
Some(Command::Compile(compile)) => compile_command(compile)?,
119-
Some(Command::Build(build)) => build_command(build)?,
126+
Some(Command::Run(run)) => run_command(run.file, no_color)?,
127+
Some(Command::Compile(compile)) => compile_command(compile, no_color)?,
128+
Some(Command::Build(build)) => build_command(build, no_color)?,
120129
Some(Command::New(new)) => new_command(new)?,
121-
Some(Command::Test(test)) => test_command(test)?,
130+
Some(Command::Test(test)) => test_command(test, no_color)?,
122131
Some(Command::Repl) | None => repl()?,
123132
};
124133
Ok(())
125134
}
126135

127-
fn run_command(file: PathBuf) -> anyhow::Result<()> {
136+
fn run_command(file: PathBuf, no_color: bool) -> anyhow::Result<()> {
128137
let code = std::fs::read_to_string(&file)?;
129138

130139
// Determine project root (for now, use parent of src directory if it exists)
@@ -143,15 +152,16 @@ fn run_command(file: PathBuf) -> anyhow::Result<()> {
143152
match husk::execute_script_with_context(&code, Some(file), project_root) {
144153
Ok(_) => {}
145154
Err(e) => {
146-
eprintln!("{}", e.pretty_print(code));
155+
let mut stderr = Vec::new();
156+
e.write_colored(&mut stderr, &code, no_color).unwrap();
147157
std::process::exit(1);
148158
}
149159
}
150160

151161
Ok(())
152162
}
153163

154-
fn build_command(cli: Build) -> anyhow::Result<()> {
164+
fn build_command(cli: Build, _no_color: bool) -> anyhow::Result<()> {
155165
use husk::HuskConfig;
156166
use std::fs;
157167

@@ -268,12 +278,13 @@ fn find_husk_files(dir: &std::path::Path) -> anyhow::Result<Vec<std::path::PathB
268278
Ok(husk_files)
269279
}
270280

271-
fn compile_command(cli: Compile) -> anyhow::Result<()> {
281+
fn compile_command(cli: Compile, no_color: bool) -> anyhow::Result<()> {
272282
let code = std::fs::read_to_string(cli.file)?;
273283
match husk::transpile_to_js_with_packages(&code) {
274284
Ok(js) => println!("{}", js),
275285
Err(e) => {
276-
eprintln!("{}", e.pretty_print(code));
286+
let mut stderr = Vec::new();
287+
e.write_colored(&mut stderr, &code, no_color).unwrap();
277288
std::process::exit(1);
278289
}
279290
}
@@ -351,7 +362,7 @@ module = "esm"
351362
Ok(())
352363
}
353364

354-
fn test_command(cli: Test) -> anyhow::Result<()> {
365+
fn test_command(cli: Test, no_color: bool) -> anyhow::Result<()> {
355366
use husk::test_runner::{TestConfig, TestRunner};
356367
use husk::{Lexer, Parser, SemanticVisitor};
357368
use std::fs;
@@ -389,19 +400,19 @@ fn test_command(cli: Test) -> anyhow::Result<()> {
389400
let ast = match parser.parse() {
390401
Ok(ast) => ast,
391402
Err(e) => {
392-
eprintln!("Parse error in {:?}: {}", file_path, e.pretty_print(code));
403+
eprint!("Parse error in {:?}: ", file_path);
404+
let mut stderr = Vec::new();
405+
e.write_colored(&mut stderr, &code, no_color).unwrap();
393406
continue;
394407
}
395408
};
396409

397410
// Run semantic analysis with test discovery
398411
let mut analyzer = SemanticVisitor::new();
399412
if let Err(e) = analyzer.analyze(&ast) {
400-
eprintln!(
401-
"Semantic error in {:?}: {}",
402-
file_path,
403-
e.pretty_print(code)
404-
);
413+
eprint!("Semantic error in {:?}: ", file_path);
414+
let mut stderr = Vec::new();
415+
e.write_colored(&mut stderr, &code, no_color).unwrap();
405416
continue;
406417
}
407418

@@ -429,6 +440,7 @@ fn test_command(cli: Test) -> anyhow::Result<()> {
429440
filter: cli.filter.clone(),
430441
test_threads: cli.test_threads,
431442
show_timing: cli.show_timing,
443+
no_color: cli.no_color || no_color,
432444
};
433445

434446
let runner = TestRunner::new(ast, config);

0 commit comments

Comments
 (0)