use std::process::ExitCode;
use std::sync::LazyLock;
use regex::Regex;
use super::run_parsed_command;
use crate::output::canonical::BuildResult;
use crate::output::ParseResult;
use crate::runner::CommandOutput;
pub(crate) fn run(args: &[String], show_stats: bool) -> anyhow::Result<ExitCode> {
run_parsed_command(
"tsc",
args,
&[],
"npm install -g typescript",
show_stats,
parse_tsc,
)
}
fn parse_tsc(output: &CommandOutput) -> ParseResult<BuildResult> {
if let Some(result) = try_tier1_regex(&output.stderr) {
return result;
}
let combined = format!("{}\n{}", output.stdout, output.stderr);
if let Some(result) = try_tier2_combined(&combined) {
return result;
}
if output.stdout.trim().is_empty() && output.stderr.trim().is_empty() {
let result = BuildResult::new(true, 0, 0, None, vec![]);
return ParseResult::Full(result);
}
let passthrough = if output.stderr.is_empty() {
output.stdout.clone()
} else if output.stdout.is_empty() {
output.stderr.clone()
} else {
combined
};
ParseResult::Passthrough(passthrough)
}
static TSC_ERROR_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(.+)\((\d+),(\d+)\): error (TS\d+): (.+)$").expect("valid regex")
});
fn try_tier1_regex(stderr: &str) -> Option<ParseResult<BuildResult>> {
if stderr.trim().is_empty() {
return None;
}
let mut error_count: usize = 0;
let mut error_messages: Vec<String> = Vec::new();
let mut any_match = false;
for line in stderr.lines() {
if let Some(caps) = TSC_ERROR_RE.captures(line) {
any_match = true;
error_count += 1;
let file = caps.get(1).map_or("", |m| m.as_str());
let line_num = caps.get(2).map_or("", |m| m.as_str());
let ts_code = caps.get(4).map_or("", |m| m.as_str());
let message = caps.get(5).map_or("", |m| m.as_str());
error_messages.push(format!("{ts_code}: {message} ({file}:{line_num})"));
}
}
if !any_match {
return None;
}
let result = BuildResult::new(false, 0, error_count, None, error_messages);
Some(ParseResult::Full(result))
}
fn try_tier2_combined(combined: &str) -> Option<ParseResult<BuildResult>> {
let mut error_count: usize = 0;
let mut error_messages: Vec<String> = Vec::new();
for line in combined.lines() {
if let Some(caps) = TSC_ERROR_RE.captures(line) {
error_count += 1;
let file = caps.get(1).map_or("", |m| m.as_str());
let line_num = caps.get(2).map_or("", |m| m.as_str());
let ts_code = caps.get(4).map_or("", |m| m.as_str());
let message = caps.get(5).map_or("", |m| m.as_str());
error_messages.push(format!("{ts_code}: {message} ({file}:{line_num})"));
}
}
if error_count == 0 {
return None;
}
let result = BuildResult::new(false, 0, error_count, None, error_messages);
Some(ParseResult::Degraded(
result,
vec!["tsc: structured parse failed, using combined stdout+stderr".to_string()],
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cmd/build")
}
fn load_fixture(name: &str) -> String {
std::fs::read_to_string(fixtures_dir().join(name))
.unwrap_or_else(|e| panic!("Failed to load fixture {name}: {e}"))
}
fn make_output(stdout: &str, stderr: &str, exit_code: Option<i32>) -> CommandOutput {
CommandOutput {
stdout: stdout.to_string(),
stderr: stderr.to_string(),
exit_code,
duration: Duration::from_millis(100),
}
}
#[test]
fn test_tier1_tsc_errors() {
let stderr = load_fixture("tsc_errors.txt");
let output = make_output("", &stderr, Some(2));
let result = parse_tsc(&output);
assert!(
result.is_full(),
"expected Full, got {:?}",
result.tier_name()
);
if let ParseResult::Full(build_result) = &result {
assert_eq!(build_result.errors, 3, "expected 3 errors");
assert!(!build_result.success, "expected failure");
}
}
#[test]
fn test_tier1_tsc_success() {
let output = make_output("", "", Some(0));
let result = parse_tsc(&output);
assert!(
result.is_full(),
"expected Full for empty output, got {:?}",
result.tier_name()
);
if let ParseResult::Full(build_result) = &result {
assert!(build_result.success, "expected success");
assert_eq!(build_result.errors, 0, "expected 0 errors");
}
}
#[test]
fn test_tsc_group_by_file() {
let stderr = load_fixture("tsc_errors.txt");
let output = make_output("", &stderr, Some(2));
let result = parse_tsc(&output);
if let ParseResult::Full(build_result) = &result {
let src_index_errors: Vec<_> = build_result
.error_messages
.iter()
.filter(|m| m.contains("src/index.ts"))
.collect();
let src_utils_errors: Vec<_> = build_result
.error_messages
.iter()
.filter(|m| m.contains("src/utils.ts"))
.collect();
assert_eq!(
src_index_errors.len(),
2,
"expected 2 errors from src/index.ts"
);
assert_eq!(
src_utils_errors.len(),
1,
"expected 1 error from src/utils.ts"
);
} else {
panic!("expected Full result");
}
}
#[test]
fn test_tier2_tsc_errors_on_stdout() {
let tsc_output = "src/main.ts(5,1): error TS2304: Cannot find name 'x'.\n";
let output = make_output(tsc_output, "", Some(2));
let result = parse_tsc(&output);
assert!(
result.is_degraded(),
"expected Degraded for stdout-only tsc errors, got {:?}",
result.tier_name()
);
if let ParseResult::Degraded(build_result, markers) = &result {
assert_eq!(build_result.errors, 1, "expected 1 error");
assert!(markers.iter().any(|m| m.contains("combined")));
}
}
#[test]
fn test_tier3_passthrough() {
let output = make_output("", "some random error text\n", Some(1));
let result = parse_tsc(&output);
assert!(
result.is_passthrough(),
"expected Passthrough, got {:?}",
result.tier_name()
);
}
}