lean_ctx/core/patterns/
ninja.rs1macro_rules! static_regex {
2 ($pattern:expr) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10fn progress_re() -> &'static regex::Regex {
11 static_regex!(r"^\[(\d+)/(\d+)\]\s+")
12}
13
14pub fn compress(command: &str, output: &str) -> Option<String> {
15 let trimmed = output.trim();
16 if trimmed.is_empty() {
17 return Some("ninja: ok".to_string());
18 }
19
20 if command.contains("-t targets") || command.contains("-t rules") {
21 return Some(compress_query(trimmed));
22 }
23
24 Some(compress_build(trimmed))
25}
26
27fn compress_build(output: &str) -> String {
28 let mut total_steps = 0u32;
29 let mut max_total = 0u32;
30 let mut errors = Vec::new();
31 let mut warnings = Vec::new();
32 let mut warning_seen = std::collections::HashSet::new();
33
34 for line in output.lines() {
35 let trimmed = line.trim();
36
37 if let Some(caps) = progress_re().captures(trimmed) {
38 if let (Ok(current), Ok(total)) = (caps[1].parse::<u32>(), caps[2].parse::<u32>()) {
39 total_steps = current;
40 max_total = total;
41 }
42 continue;
43 }
44
45 if is_error_line(trimmed) {
46 if errors.len() < 20 {
47 errors.push(trimmed.to_string());
48 }
49 continue;
50 }
51
52 if is_warning_line(trimmed) {
53 let key = normalize_warning(trimmed);
54 if warning_seen.insert(key) {
55 warnings.push(trimmed.to_string());
56 }
57 }
58 }
59
60 if !errors.is_empty() {
61 let mut result = format!("ninja: FAILED ({} errors", errors.len());
62 if !warnings.is_empty() {
63 result.push_str(&format!(", {} unique warnings", warnings.len()));
64 }
65 result.push_str(&format!(", {total_steps}/{max_total} steps)"));
66 for e in errors.iter().take(10) {
67 result.push_str(&format!("\n {e}"));
68 }
69 if errors.len() > 10 {
70 result.push_str(&format!("\n ... +{} more errors", errors.len() - 10));
71 }
72 return result;
73 }
74
75 let mut result = format!("ninja: ok ({total_steps}/{max_total} steps)");
76 if !warnings.is_empty() {
77 result.push_str(&format!("\n{} unique warnings:", warnings.len()));
78 for w in warnings.iter().take(10) {
79 result.push_str(&format!("\n {w}"));
80 }
81 if warnings.len() > 10 {
82 result.push_str(&format!("\n ... +{} more", warnings.len() - 10));
83 }
84 }
85 result
86}
87
88fn compress_query(output: &str) -> String {
89 let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
90 if lines.len() <= 20 {
91 return format!("{} entries:\n{}", lines.len(), lines.join("\n"));
92 }
93 format!(
94 "{} entries:\n{}\n... +{} more",
95 lines.len(),
96 lines[..20].join("\n"),
97 lines.len() - 20
98 )
99}
100
101fn is_error_line(line: &str) -> bool {
102 let l = line.to_ascii_lowercase();
103 l.contains("error:") || l.contains("fatal error") || l.contains("ninja: error")
104}
105
106fn is_warning_line(line: &str) -> bool {
107 let l = line.to_ascii_lowercase();
108 l.contains("warning:")
109}
110
111fn normalize_warning(line: &str) -> String {
112 let re = static_regex!(r"[^\s:]+:\d+:\d+:\s*");
113 let without_location = re.replace_all(line, "");
114 without_location.to_string()
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn compresses_successful_build() {
123 let output = "[1/10] Compiling foo.c\n[2/10] Compiling bar.c\n[10/10] Linking app\n";
124 let result = compress("ninja", output).unwrap();
125 assert!(result.contains("10/10"), "should show final progress");
126 assert!(result.contains("ok"), "should indicate success");
127 }
128
129 #[test]
130 fn keeps_errors() {
131 let output =
132 "[1/5] Compiling foo.c\n[2/5] Compiling bar.c\nerror: undefined reference to `main`\n";
133 let result = compress("ninja", output).unwrap();
134 assert!(result.contains("FAILED"), "should indicate failure");
135 assert!(result.contains("undefined reference"), "should keep errors");
136 }
137
138 #[test]
139 fn deduplicates_warnings() {
140 let output = "[1/3] Compiling a.c\nsrc/a.c:10:5: warning: unused variable\nsrc/b.c:20:5: warning: unused variable\n[3/3] Linking\n";
141 let result = compress("ninja", output).unwrap();
142 assert!(
143 result.contains("1 unique warning"),
144 "should deduplicate same warning at different locations: {result}"
145 );
146 }
147
148 #[test]
149 fn empty_output() {
150 let result = compress("ninja", "").unwrap();
151 assert_eq!(result, "ninja: ok");
152 }
153
154 #[test]
155 fn compresses_target_query() {
156 let output = "target1: phony\ntarget2: cc\ntarget3: link\n";
157 let result = compress("ninja -t targets", output).unwrap();
158 assert!(result.contains("3 entries"), "should count targets");
159 }
160}