use crate::scroll_buffer::ScrollBuffer;
use crate::theme;
use crate::tui_output::{self, DIM};
use ratatui::text::{Line, Span};
pub(crate) fn render_list_line(buffer: &mut ScrollBuffer, line: &str) {
let is_dir = line.starts_with("d ");
let path_str = if is_dir {
&line[2..]
} else {
line.trim_start()
};
let style = if is_dir {
theme::DIRECTORY
} else {
theme::style_for_extension(path_extension(path_str))
};
let prefix = if is_dir { "\u{1f4c1} " } else { " " };
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(prefix),
Span::styled(path_str.to_string(), style),
]),
);
}
pub(crate) fn render_glob_line(buffer: &mut ScrollBuffer, line: &str) {
let trimmed = line.trim();
let is_meta = trimmed.is_empty()
|| trimmed.ends_with("matched:")
|| trimmed.starts_with("No files matched")
|| trimmed.starts_with("[Capped");
if is_meta {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::styled(line.to_string(), DIM),
]),
);
return;
}
let style = theme::style_for_extension(path_extension(trimmed));
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(" "),
Span::styled(trimmed.to_string(), style),
]),
);
}
pub(crate) fn render_grep_line(buffer: &mut ScrollBuffer, line: &str, pattern: Option<&str>) {
let parsed = line.split_once(':').and_then(|(file, rest)| {
rest.split_once(':')
.map(|(lineno, content)| (file.to_string(), lineno.to_string(), content.to_string()))
});
let (file, lineno, content) = match parsed {
Some(t) => t,
None => {
tui_output::emit_line(
buffer,
Line::from(vec![
Span::styled(" \u{2502} ", DIM),
Span::raw(line.to_string()),
]),
);
return;
}
};
let mut spans: Vec<Span<'static>> = Vec::with_capacity(8);
spans.push(Span::styled(" \u{2502} ", DIM));
spans.push(Span::styled(file, theme::PATH));
spans.push(Span::styled(":", DIM));
spans.push(Span::styled(lineno, theme::LINENO));
spans.push(Span::styled(":", DIM));
extend_with_match_highlights(&mut spans, &content, pattern);
tui_output::emit_line(buffer, Line::from(spans));
}
fn extend_with_match_highlights(
spans: &mut Vec<Span<'static>>,
content: &str,
pattern: Option<&str>,
) {
const MAX_HITS_PER_LINE: usize = 32;
let pattern = match pattern {
Some(p) if !p.is_empty() => p,
_ => {
spans.push(Span::raw(content.to_string()));
return;
}
};
let ranges: Vec<(usize, usize)> = match regex::RegexBuilder::new(pattern)
.case_insensitive(false)
.build()
{
Ok(re) => re
.find_iter(content)
.take(MAX_HITS_PER_LINE)
.map(|m: regex::Match<'_>| (m.start(), m.end()))
.collect(),
Err(_) => content
.match_indices(pattern)
.take(MAX_HITS_PER_LINE)
.map(|(i, s)| (i, i + s.len()))
.collect(),
};
if ranges.is_empty() {
spans.push(Span::raw(content.to_string()));
return;
}
let mut cursor = 0usize;
for (start, end) in ranges {
if start < cursor || end > content.len() {
continue;
}
if start > cursor {
spans.push(Span::raw(content[cursor..start].to_string()));
}
spans.push(Span::styled(
content[start..end].to_string(),
theme::MATCH_HIT,
));
cursor = end;
}
if cursor < content.len() {
spans.push(Span::raw(content[cursor..].to_string()));
}
}
fn path_extension(path: &str) -> &str {
std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
}
#[cfg(test)]
mod tests {
use super::*;
fn match_hits<'a>(spans: &'a [Span<'static>]) -> Vec<&'a str> {
spans
.iter()
.filter(|s| s.style == theme::MATCH_HIT)
.map(|s| s.content.as_ref())
.collect()
}
fn drain_lines(buf: &ScrollBuffer) -> Vec<&Line<'static>> {
buf.all_lines().collect()
}
#[test]
fn grep_line_highlights_literal_pattern() {
let mut buf = ScrollBuffer::new(100);
render_grep_line(&mut buf, "src/foo.rs:42:let foo = bar;", Some("foo"));
let lines = drain_lines(&buf);
let hits = match_hits(&lines.last().unwrap().spans);
assert_eq!(hits, vec!["foo"], "literal pattern not highlighted");
}
#[test]
fn grep_line_highlights_multiple_hits() {
let mut buf = ScrollBuffer::new(100);
render_grep_line(&mut buf, "a.rs:1:foo and foo again", Some("foo"));
let lines = drain_lines(&buf);
let hits = match_hits(&lines.last().unwrap().spans);
assert_eq!(hits.len(), 2, "expected 2 highlighted matches");
}
#[test]
fn grep_line_no_pattern_no_highlights() {
let mut buf = ScrollBuffer::new(100);
render_grep_line(&mut buf, "a.rs:1:hello", None);
let lines = drain_lines(&buf);
let hits = match_hits(&lines.last().unwrap().spans);
assert!(hits.is_empty());
}
#[test]
fn grep_line_invalid_regex_falls_back_to_literal() {
let mut buf = ScrollBuffer::new(100);
render_grep_line(&mut buf, "a.rs:1:has [broken regex inside", Some("[broke"));
let lines = drain_lines(&buf);
let hits = match_hits(&lines.last().unwrap().spans);
assert_eq!(hits, vec!["[broke"], "literal fallback failed");
}
#[test]
fn grep_line_unparseable_format_passes_through() {
let mut buf = ScrollBuffer::new(100);
render_grep_line(&mut buf, "no colons here", Some("x"));
let lines = drain_lines(&buf);
assert_eq!(lines.len(), 1);
}
#[test]
fn glob_line_dims_header() {
let mut buf = ScrollBuffer::new(100);
render_glob_line(&mut buf, "3 files matched:");
let lines = drain_lines(&buf);
let body_styles: Vec<_> = lines
.last()
.unwrap()
.spans
.iter()
.map(|s| s.style)
.collect();
assert!(body_styles.iter().all(|s| *s == DIM));
}
#[test]
fn glob_line_colors_path_by_extension() {
let mut buf = ScrollBuffer::new(100);
render_glob_line(&mut buf, "src/main.rs");
let lines = drain_lines(&buf);
let expected = theme::style_for_extension("rs");
let found = lines
.last()
.unwrap()
.spans
.iter()
.any(|s| s.style.fg == expected.fg && s.content.as_ref() == "src/main.rs");
assert!(found, "glob path not colored by extension");
}
#[test]
fn list_line_bolds_directories() {
let mut buf = ScrollBuffer::new(100);
render_list_line(&mut buf, "d src/tools");
let lines = drain_lines(&buf);
let found = lines
.last()
.unwrap()
.spans
.iter()
.any(|s| s.style == theme::DIRECTORY && s.content.as_ref() == "src/tools");
assert!(found, "directory not bolded");
}
}