deepseek_rust_cli/tui/colorizer/
mod.rs1pub mod highlighter;
4pub mod types;
5pub mod utils;
6
7use std::fmt::Write as FmtWrite;
8
9pub use highlighter::CodeColorizer;
10pub use types::CodeLang;
11pub use utils::truncate_result;
12
13use crate::tui::colorizer::types::State;
14
15pub struct StreamColorizer {
16 state: State,
17 pending: String,
19 dimmed: bool,
20 first_feed: bool,
21}
22
23impl Default for StreamColorizer {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl StreamColorizer {
30 pub fn new() -> Self {
31 Self {
32 state: State::Normal,
33 pending: String::new(),
34 dimmed: false,
35 first_feed: true,
36 }
37 }
38
39 pub fn set_dimmed(&mut self, dimmed: bool) {
40 self.dimmed = dimmed;
41 }
42
43 fn reset_code(&self) -> String {
44 if self.dimmed {
45 "\x1b[0m\x1b[2m".to_string()
46 } else {
47 "\x1b[0m".to_string()
48 }
49 }
50
51 pub fn feed(&mut self, chunk: &str) -> String {
54 let input = format!("{}{}", self.pending, chunk);
55 self.pending.clear();
56
57 let mut out = String::new();
58 if self.first_feed && self.dimmed {
59 out.push_str("\x1b[2m");
60 self.first_feed = false;
61 }
62 let chars: Vec<char> = input.chars().collect();
63 let len = chars.len();
64 let mut i = 0;
65
66 while i < len {
67 match self.state {
68 State::Normal => {
69 if chars[i] == '`' {
71 if i + 2 < len && chars[i + 1] == '`' && chars[i + 2] == '`' {
73 let _ = write!(out, "\x1b[33m```{}", self.reset_code()); i += 3;
76 let mut lang = String::new();
78 while i < len && chars[i] != '\n' && chars[i] != '\r' {
79 lang.push(chars[i]);
80 i += 1;
81 }
82 if !lang.is_empty() {
83 let _ = write!(out, "\x1b[36m{}{}", lang, self.reset_code());
84 }
86 if i < len && chars[i] == '\r' {
88 i += 1;
89 }
90 if i < len && chars[i] == '\n' {
91 out.push('\n');
92 i += 1;
93 }
94 self.state = State::FencedBlock {
95 lang: lang.trim().to_string(),
96 };
97 } else {
98 let _ = write!(out, "\x1b[32m`{}", self.reset_code()); i += 1;
101 self.state = State::InlineCode;
102 }
103 } else if self.is_path_boundary(&chars, i, len) {
104 let path_end = self.match_path(&chars, i, len);
106 if path_end > i {
107 let path: String = chars[i..path_end].iter().collect();
108 let _ = write!(out, "\x1b[34m{}{}", path, self.reset_code()); i = path_end;
110 } else {
111 out.push(chars[i]);
112 i += 1;
113 }
114 } else {
115 out.push(chars[i]);
116 i += 1;
117 }
118 }
119 State::InlineCode => {
120 if chars[i] == '`' {
121 let _ = write!(out, "\x1b[32m`{}", self.reset_code()); i += 1;
123 self.state = State::Normal;
124 } else {
125 out.push_str("\x1b[32m");
127 while i < len && chars[i] != '`' {
128 out.push(chars[i]);
129 i += 1;
130 }
131 out.push_str(&self.reset_code());
132 }
133 }
134 State::FencedBlock { ref lang } => {
135 if chars[i] == '`' && i + 2 < len && chars[i + 1] == '`' && chars[i + 2] == '`'
137 {
138 let _ = write!(out, "\x1b[33m```{}", self.reset_code()); i += 3;
140 self.state = State::Normal;
141 } else {
142 let lang_clone = lang.clone();
144 out.push_str("\x1b[37m");
145 while i < len {
146 if chars[i] == '`'
147 && i + 2 < len
148 && chars[i + 1] == '`'
149 && chars[i + 2] == '`'
150 {
151 break;
152 }
153 out.push(chars[i]);
154 i += 1;
155 }
156 out.push_str(&self.reset_code());
157 self.state = State::FencedBlock { lang: lang_clone };
158 }
159 }
160 }
161 }
162
163 out
164 }
165
166 pub fn finish(&mut self) -> String {
168 let mut out = String::new();
169
170 match self.state {
172 State::InlineCode => {
173 let _ = write!(out, "\x1b[32m`{}", self.reset_code()); }
175 State::FencedBlock { .. } => {
176 let _ = write!(out, "\x1b[33m```{}", self.reset_code()); }
178 _ => {}
179 }
180
181 if !self.pending.is_empty() {
182 out.push_str(&self.pending);
183 self.pending.clear();
184 }
185
186 if self.dimmed {
187 out.push_str("\x1b[0m");
188 }
189
190 self.state = State::Normal;
191 self.first_feed = true;
192 out
193 }
194
195 fn is_path_boundary(&self, chars: &[char], i: usize, _len: usize) -> bool {
197 let c = chars[i];
198 if c == '.' || c == '/' || c == '~' {
200 return true;
201 }
202 if c.is_alphanumeric() {
203 if i == 0 || chars[i - 1].is_whitespace() || chars[i - 1] == '(' || chars[i - 1] == '['
205 {
206 return true;
208 }
209 }
210 false
211 }
212
213 fn match_path(&self, chars: &[char], start: usize, len: usize) -> usize {
215 let mut end = start;
216
217 if start < len {
219 match chars[start] {
220 '/' => {
221 end += 1;
222 }
223 '~' if start + 1 < len && chars[start + 1] == '/' => {
224 end += 2;
225 }
226 '.' if start + 1 < len && chars[start + 1] == '/' => {
227 end += 2;
228 }
229 c if c.is_alphanumeric() => {
230 }
232 _ => return start,
233 }
234 }
235
236 while end < len {
238 let c = chars[end];
239 if c.is_alphanumeric()
240 || c == '/'
241 || c == '.'
242 || c == '-'
243 || c == '_'
244 || c == ' '
245 || c == '~'
246 || c == '+'
247 || c == '@'
248 || c == '#'
249 || c == ':'
250 {
251 if c == '.' && end + 1 < len {
253 let remaining = &chars[end + 1..];
255 let remaining_str: String = remaining.iter().collect();
256 let ext_candidates = [
257 "rs",
258 "py",
259 "js",
260 "ts",
261 "go",
262 "java",
263 "c",
264 "cpp",
265 "h",
266 "hpp",
267 "rb",
268 "php",
269 "swift",
270 "kt",
271 "scala",
272 "sh",
273 "bash",
274 "zsh",
275 "fish",
276 "ps1",
277 "toml",
278 "yaml",
279 "yml",
280 "json",
281 "xml",
282 "html",
283 "css",
284 "scss",
285 "md",
286 "txt",
287 "log",
288 "csv",
289 "env",
290 "cfg",
291 "conf",
292 "lock",
293 "gitignore",
294 "dockerfile",
295 "nix",
296 "lua",
297 "vim",
298 "el",
299 "ex",
300 "exs",
301 "erl",
302 "hrl",
303 "sql",
304 "graphql",
305 "proto",
306 "vue",
307 "svelte",
308 "tsx",
309 "jsx",
310 "mjs",
311 "wasm",
312 "wat",
313 "bc",
314 "dc",
315 "awk",
316 "sed",
317 ];
318 let matched = ext_candidates.iter().any(|ext| {
319 remaining_str.len() >= ext.len()
320 && remaining_str[..ext.len()].eq_ignore_ascii_case(ext)
321 && (remaining_str.len() == ext.len()
322 || remaining_str
323 .as_bytes()
324 .get(ext.len())
325 .is_none_or(|&b| !b.is_ascii_alphanumeric() && b != b'_'))
326 });
327 if matched {
328 end += 1;
330 while end < len && chars[end].is_alphanumeric() {
332 end += 1;
333 }
334 if end < len && chars[end] == '/' {
336 end += 1;
337 continue;
338 }
339 break;
340 }
341 }
342
343 if c.is_whitespace() {
345 break;
346 }
347
348 if c == ','
350 || c == ';'
351 || c == ')'
352 || c == ']'
353 || c == '}'
354 || c == '"'
355 || c == '\''
356 || c == '>'
357 || c == '`'
358 {
359 break;
360 }
361
362 end += 1;
363 } else {
364 break;
365 }
366 }
367
368 if end - start >= 3 {
370 let segment: String = chars[start..end].iter().collect();
371 if segment.contains('/')
372 || segment.contains('.')
373 || segment.ends_with("rc")
374 || segment.ends_with("file")
375 {
376 return end;
377 }
378 }
379
380 start }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_inline_code() {
390 let mut c = StreamColorizer::new();
391 let out = c.feed("Use `cargo build` to compile.");
392 assert!(out.contains("\x1b[32m"));
393 }
394
395 #[test]
396 fn test_file_path() {
397 let mut c = StreamColorizer::new();
398 let out = c.feed("Edit src/main.rs and Cargo.toml");
399 assert!(out.contains("\x1b[34m"));
400 }
401
402 #[test]
403 fn test_fenced_block() {
404 let mut c = StreamColorizer::new();
405 let out = c.feed("```rust\nlet x = 1;\n```");
406 assert!(out.contains("\x1b[33m"));
407 assert!(out.contains("\x1b[36mrust\x1b[0m"));
408 }
409
410 #[test]
411 fn test_code_rust_keywords() {
412 let code = "fn main() {\n let x = 42;\n}";
413 let colored = CodeColorizer::highlight(code, CodeLang::Rust, None);
414 assert!(colored.contains("\x1b[34mfn\x1b[0m"));
415 assert!(colored.contains("\x1b[34mlet\x1b[0m"));
416 assert!(colored.contains("\x1b[35m42\x1b[0m"));
417 }
418
419 #[test]
420 fn test_lang_from_path() {
421 assert_eq!(CodeLang::from_path("src/main.rs"), CodeLang::Rust);
422 assert_eq!(CodeLang::from_path("app.py"), CodeLang::Python);
423 assert_eq!(CodeLang::from_path("unknown.xyz"), CodeLang::Generic);
424 }
425
426 #[test]
427 fn test_truncate_result() {
428 let short = "hello";
429 assert_eq!(truncate_result(short, 100), "hello");
430
431 let long = "a".repeat(200);
432 let truncated = truncate_result(&long, 50);
433 assert!(truncated.len() <= 120);
434 }
435}