Skip to main content

binocular/runtime/
headless.rs

1use crate::infra::channel::{self, Receiver, Sender};
2use crate::output::format_item_output;
3use crate::runtime::config::RunConfig;
4use crate::search::matcher::{spawn_exact_matcher, spawn_matcher, MatcherCommand, MatcherState};
5use crate::search::sources::{
6    spawn_git_searcher, spawn_searcher_with_config, spawn_stdin_searcher,
7};
8use crate::search::types::{SearchConfig, SearchItem, SearchResult};
9use std::io::{self, IsTerminal, Write};
10use std::sync::atomic::AtomicBool;
11use std::sync::Arc;
12
13pub fn run_with_configs(
14    run_config: RunConfig,
15    search_config: SearchConfig,
16    stdin_items: Option<Vec<String>>,
17) -> anyhow::Result<()> {
18    debug_assert!(run_config.headless);
19    run_with_search_config(search_config, stdin_items)
20}
21
22pub fn run_with_search_config(
23    search_config: SearchConfig,
24    stdin_items: Option<Vec<String>>,
25) -> anyhow::Result<()> {
26    let query = search_config
27        .query
28        .as_deref()
29        .unwrap_or("")
30        .trim()
31        .to_string();
32    if query.is_empty() {
33        stream_search_results(search_config, stdin_items)
34    } else {
35        stream_matched_results(search_config, stdin_items, query)
36    }
37}
38
39fn stream_search_results(
40    search_config: SearchConfig,
41    stdin_items: Option<Vec<String>>,
42) -> anyhow::Result<()> {
43    let (tx_items, rx_items) = channel::unbounded_default::<Vec<SearchItem>>();
44    let stop = Arc::new(AtomicBool::new(false));
45    if let Some(scope) = search_config.git_search_scope.clone() {
46        let _ = spawn_git_searcher(scope, stop, tx_items);
47    } else if let Some(items) = stdin_items {
48        let _ = spawn_stdin_searcher(items, stop, tx_items);
49    } else {
50        let _ = spawn_searcher_with_config(search_config, stop, tx_items);
51    }
52
53    let stdout = io::stdout();
54    let mut out = io::BufWriter::new(stdout.lock());
55    while let Ok(batch) = rx_items.recv() {
56        for item in batch {
57            writeln!(out, "{}", format_item_output(&item, None, false))?;
58        }
59    }
60
61    Ok(())
62}
63
64fn stream_matched_results(
65    search_config: SearchConfig,
66    stdin_items: Option<Vec<String>>,
67    query: String,
68) -> anyhow::Result<()> {
69    let (tx_items, rx_items) = channel::unbounded_default::<Vec<SearchItem>>();
70    let (tx_cmd, rx_cmd) = channel::unbounded_default::<MatcherCommand>();
71    let (tx_state, rx_state) = channel::unbounded_default::<MatcherState>();
72    let settings = search_config.settings;
73    let stop = Arc::new(AtomicBool::new(false));
74
75    if let Some(scope) = search_config.git_search_scope.clone() {
76        let _ = spawn_git_searcher(scope, stop.clone(), tx_items);
77    } else if let Some(items) = stdin_items {
78        let _ = spawn_stdin_searcher(items, stop.clone(), tx_items);
79    } else {
80        let _ = spawn_searcher_with_config(search_config, stop.clone(), tx_items);
81    }
82
83    if settings.matcher.is_exact() {
84        let _ = spawn_exact_matcher(
85            rx_items,
86            rx_cmd,
87            stop,
88            tx_state,
89            settings.mode.is_file_name_only(),
90            settings.mode.is_content(),
91            query.clone(),
92        );
93    } else {
94        let _ = spawn_matcher(
95            rx_items,
96            rx_cmd,
97            stop,
98            tx_state,
99            settings.mode.is_file_name_only(),
100            settings.mode.is_content(),
101        );
102    }
103    let _ = tx_cmd.send(MatcherCommand::Query(query));
104
105    loop {
106        match rx_state.recv() {
107            Ok(state) if !state.working => break,
108            Ok(_) => {}
109            Err(_) => return Ok(()),
110        }
111    }
112
113    while let Ok(Some(_)) = rx_state.try_recv() {}
114
115    let _ = tx_cmd.send(MatcherCommand::Resize(u32::MAX));
116    if let Ok(state) = rx_state.recv() {
117        write_match_results(&state.results, settings.mode.is_content())?;
118    }
119
120    Ok(())
121}
122
123fn write_match_results(results: &[SearchResult], is_content_mode: bool) -> anyhow::Result<()> {
124    let use_color = io::stdout().is_terminal();
125    let stdout = io::stdout();
126    let mut out = io::BufWriter::new(stdout.lock());
127
128    for result in results {
129        let line = if use_color && !result.indices.is_empty() {
130            format_colored_result(
131                &result.item,
132                &result.indices,
133                result.column,
134                is_content_mode,
135            )
136        } else {
137            format_item_output(&result.item, result.column, false)
138        };
139        writeln!(out, "{line}")?;
140    }
141
142    Ok(())
143}
144
145fn colorize_chars(text: &str, indices: &[u32]) -> String {
146    if indices.is_empty() {
147        return text.to_string();
148    }
149
150    let mut sorted: Vec<usize> = indices.iter().map(|&i| i as usize).collect();
151    sorted.sort_unstable();
152    sorted.dedup();
153
154    let mut result = String::with_capacity(text.len() + sorted.len() * 9);
155    let mut match_pos = 0;
156    let mut in_match = false;
157
158    for (char_idx, ch) in text.chars().enumerate() {
159        let is_match = match_pos < sorted.len() && sorted[match_pos] == char_idx;
160        if is_match {
161            match_pos += 1;
162        }
163        if is_match && !in_match {
164            result.push_str("\x1b[36m");
165            in_match = true;
166        } else if !is_match && in_match {
167            result.push_str("\x1b[0m");
168            in_match = false;
169        }
170        result.push(ch);
171    }
172
173    if in_match {
174        result.push_str("\x1b[0m");
175    }
176
177    result
178}
179
180fn format_colored_result(
181    item: &SearchItem,
182    indices: &[u32],
183    column: Option<usize>,
184    is_content_mode: bool,
185) -> String {
186    match item {
187        SearchItem::Stdin(text) | SearchItem::Message(text) => colorize_chars(text, indices),
188        SearchItem::Path(path) => colorize_chars(path, indices),
189        SearchItem::Grep { path, line, text } if is_content_mode => {
190            let line_num = line.to_string();
191            let content = text.trim_end_matches(['\n', '\r']);
192            let path_char_len = path.chars().count();
193            let prefix_char_len = path_char_len + 1 + line_num.chars().count() + 1;
194            let path_indices: Vec<u32> = indices
195                .iter()
196                .filter(|&&i| (i as usize) < path_char_len)
197                .copied()
198                .collect();
199            let content_indices: Vec<u32> = indices
200                .iter()
201                .filter_map(|&i| {
202                    let i = i as usize;
203                    if i >= prefix_char_len {
204                        Some((i - prefix_char_len) as u32)
205                    } else {
206                        None
207                    }
208                })
209                .collect();
210
211            let colored_path = colorize_chars(path, &path_indices);
212            let colored_content = colorize_chars(content, &content_indices);
213
214            if let Some(col) = column {
215                format!(
216                    "{}:{}:\x1b[2m{}\x1b[0m:{}",
217                    colored_path, line_num, col, colored_content
218                )
219            } else {
220                format!("{}:{}:{}", colored_path, line_num, colored_content)
221            }
222        }
223        SearchItem::GitHistory { .. }
224        | SearchItem::GitBranch { .. }
225        | SearchItem::GitCommit { .. } => format_item_output(item, column, false),
226        SearchItem::Grep { .. } => format_item_output(item, column, false),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn colorize_chars_groups_consecutive_matches() {
236        assert_eq!(
237            colorize_chars("abcd", &[1, 2]),
238            "a\x1b[36mbc\x1b[0md".to_string()
239        );
240    }
241
242    #[test]
243    fn format_colored_result_offsets_grep_content_indices() {
244        let item = SearchItem::grep("src/main.rs", 7, "hello world");
245        let rendered = format_colored_result(&item, &[0, 1, 14, 15, 16], None, true);
246
247        assert!(rendered.contains("\x1b[36msr\x1b[0mc/main.rs"));
248        assert!(rendered.contains("\x1b[36mhel\x1b[0mlo world"));
249    }
250}