From d781da9a3429c2bbb80b0c10f2a7bc35fbefe147 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Sat, 1 Nov 2025 14:21:44 +0000 Subject: [PATCH 1/4] Add utility to get line number and byte range for given position Introduce a new function `get_line_range` in the `teamsearch_utils` crate for parsing line numbers and byte ranges from a string, given a byte position. --- crates/teamsearch_utils/src/lib.rs | 1 + crates/teamsearch_utils/src/lines.rs | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 crates/teamsearch_utils/src/lines.rs diff --git a/crates/teamsearch_utils/src/lib.rs b/crates/teamsearch_utils/src/lib.rs index cafa7f2..b2790af 100644 --- a/crates/teamsearch_utils/src/lib.rs +++ b/crates/teamsearch_utils/src/lib.rs @@ -1,6 +1,7 @@ //! Various `teamsearch` utilities. pub mod fs; pub mod highlight; +pub mod lines; pub mod logging; pub mod stream; pub mod thread_pool; diff --git a/crates/teamsearch_utils/src/lines.rs b/crates/teamsearch_utils/src/lines.rs new file mode 100644 index 0000000..5d8b9f1 --- /dev/null +++ b/crates/teamsearch_utils/src/lines.rs @@ -0,0 +1,21 @@ +/// Get the line number (1-indexed) and byte range for a given byte position. +/// +/// Returns a tuple of (line_number, line_start_byte, line_end_byte) where +/// line_number is 1-indexed. +pub fn get_line_range(contents: &str, byte_pos: usize) -> (usize, usize, usize) { + // Count newlines before the position to get the line number. + // Line numbers are 1-indexed, so we add 1. + let line_num = contents[..byte_pos].bytes().filter(|&b| b == b'\n').count() + 1; + + // Find the start and end of the line containing this byte position. + let line_start = if line_num == 1 { + 0 + } else { + contents[..byte_pos].rfind('\n').map(|pos| pos + 1).unwrap_or(0) + }; + + let line_end = + contents[byte_pos..].find('\n').map(|offset| byte_pos + offset).unwrap_or(contents.len()); + + (line_num, line_start, line_end) +} From a1dc0a07f4110c79154f1d0024510c1994f806d1 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Fri, 31 Oct 2025 14:39:33 +0000 Subject: [PATCH 2/4] Refactor find output to ripgrep-style format grouped by file --- crates/teamsearch/src/lib.rs | 47 +++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/teamsearch/src/lib.rs b/crates/teamsearch/src/lib.rs index 12f296d..2aca615 100644 --- a/crates/teamsearch/src/lib.rs +++ b/crates/teamsearch/src/lib.rs @@ -8,18 +8,19 @@ mod crash; pub(crate) mod version; use std::{ + collections::BTreeMap, panic, path::{Path, PathBuf}, process::ExitCode, }; -use annotate_snippets::{Level, Renderer, Snippet}; use anyhow::{Ok, Result, anyhow}; use cli::{FindCommand, LookupCommand, OrphanCommand}; use commands::{find::FindResult, lookup::LookupEntry}; use crash::crash_handler; use log::info; -use teamsearch_utils::{logging::ToolLogger, stream::CompilerOutputStream}; +use teamsearch_matcher::Match; +use teamsearch_utils::{lines::get_line_range, logging::ToolLogger, stream::CompilerOutputStream}; use teamsearch_workspace::settings::Settings; #[derive(Copy, Clone)] @@ -116,28 +117,34 @@ fn find(args: FindCommand) -> Result { // Print out the results in JSON format. println!("{}", serde_json::to_string_pretty(&file_matches)?); } else { - // Create a new `annotate-snippets` renderer, and then use it to render the - // produced results. - let renderer = Renderer::styled(); - - for result in &file_matches { + for (idx, result) in file_matches.iter().enumerate() { if args.count { info!("{}: {}", result.path.display(), result.len()); } else { - let mut message = Level::Info.title("match found"); - - // Now, construct the reports so that we can emit them to the user. - for m in &result.matches { - let level = Level::Info; - message = message.snippet( - Snippet::source(result.contents.as_str()) - .origin(result.path.as_os_str().to_str().unwrap()) - .fold(true) - .annotation(level.span(m.start..m.end).label("")), - ); + // Group matches by line number to avoid printing duplicate lines. + // BTreeMap automatically keeps lines sorted by line number. + let line_matches: BTreeMap = result + .matches + .iter() + .map(|m| { + let (line_num, line_content) = get_line_info(&result.contents, m.start); + (line_num, line_content) + }) + .collect(); + + // Print file path followed by all matching lines. + if !line_matches.is_empty() { + println!("{}", result.path.display()); + + for (line_num, line_content) in &line_matches { + println!("{}:{}", line_num, line_content); + } + + // Only print blank line between files, not after the last one. + if idx < file_matches.len() - 1 { + println!(); + } } - - println!("{}", renderer.render(message)) } } From 916a695528a54d3b84a3f65a5a8251c5976ef72f Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Fri, 31 Oct 2025 14:53:14 +0000 Subject: [PATCH 3/4] Add colored output: magenta file paths, green line numbers, red matches --- crates/teamsearch/src/lib.rs | 89 +++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 16 deletions(-) diff --git a/crates/teamsearch/src/lib.rs b/crates/teamsearch/src/lib.rs index 2aca615..e7e70f7 100644 --- a/crates/teamsearch/src/lib.rs +++ b/crates/teamsearch/src/lib.rs @@ -20,7 +20,12 @@ use commands::{find::FindResult, lookup::LookupEntry}; use crash::crash_handler; use log::info; use teamsearch_matcher::Match; -use teamsearch_utils::{lines::get_line_range, logging::ToolLogger, stream::CompilerOutputStream}; +use teamsearch_utils::{ + highlight::{Colour, highlight}, + lines::get_line_range, + logging::ToolLogger, + stream::CompilerOutputStream, +}; use teamsearch_workspace::settings::Settings; #[derive(Copy, Clone)] @@ -92,6 +97,40 @@ fn resolve_default_files(files: Vec, is_stdin: bool) -> Vec { } } +/// Highlight matches in a line of text. +fn highlight_line_matches(line_content: &str, matches: &[Match]) -> String { + if matches.is_empty() { + return line_content.to_string(); + } + + // Sort matches by start position + let mut sorted_matches: Vec<_> = matches.iter().collect(); + sorted_matches.sort_by_key(|m| m.start); + + let mut result = String::new(); + let mut last_end = 0; + + for m in sorted_matches { + // Add text before match + if m.start > last_end { + result.push_str(&line_content[last_end..m.start]); + } + + // Add highlighted match + let matched_text = &line_content[m.start..m.end.min(line_content.len())]; + result.push_str(&highlight(Colour::Red, matched_text)); + + last_end = m.end.min(line_content.len()); + } + + // Add remaining text after last match + if last_end < line_content.len() { + result.push_str(&line_content[last_end..]); + } + + result +} + fn find(args: FindCommand) -> Result { let files = resolve_default_files(args.files, false); @@ -121,23 +160,41 @@ fn find(args: FindCommand) -> Result { if args.count { info!("{}: {}", result.path.display(), result.len()); } else { - // Group matches by line number to avoid printing duplicate lines. - // BTreeMap automatically keeps lines sorted by line number. - let line_matches: BTreeMap = result - .matches - .iter() - .map(|m| { - let (line_num, line_content) = get_line_info(&result.contents, m.start); - (line_num, line_content) - }) - .collect(); - + // Group matches by line number, keeping track of all matches on each line. + let mut line_matches: BTreeMap)> = BTreeMap::new(); + + for m in &result.matches { + let (line_num, line_start, line_end) = + get_line_range(&result.contents, m.start); + + // Get the line content + let line_content = result.contents[line_start..line_end].trim_end().to_string(); + + // Adjust match positions relative to line start + let adjusted_match = Match { + start: m.start.saturating_sub(line_start), + end: m.end.saturating_sub(line_start), + }; + + line_matches + .entry(line_num) + .and_modify(|(_, matches)| { + matches.push(adjusted_match); + }) + .or_insert_with(|| (line_content, vec![adjusted_match])); + } + // Print file path followed by all matching lines. if !line_matches.is_empty() { - println!("{}", result.path.display()); - - for (line_num, line_content) in &line_matches { - println!("{}:{}", line_num, line_content); + // File path in magenta/pink + println!("{}", highlight(Colour::Magenta, result.path.display())); + + for (line_num, (line_content, matches)) in &line_matches { + // Highlight matches in the line + let highlighted_line = highlight_line_matches(line_content, matches); + + // Line number in bright green, then the highlighted line + println!("{}:{}", highlight(Colour::Green, line_num), highlighted_line); } // Only print blank line between files, not after the last one. From f7cc23340652b0f108d599c5086717d1df459cc1 Mon Sep 17 00:00:00 2001 From: Ester Beltrami Date: Fri, 31 Oct 2025 15:16:19 +0000 Subject: [PATCH 4/4] Update README.md to reflect new ouput format --- README.md | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 18b28ff..8af8e54 100644 --- a/README.md +++ b/README.md @@ -49,28 +49,13 @@ The `find` command is useful when you want to search for code based on a specifi teamsearch find . -c .github/CODEOWNERS -t "my-team" -p "c(o)+de" ``` -```py -info: match found - --> repo/sub/item3.html:2:11 - | - 2 | const code = { - | ---- - | - ::: repo/sub/item3.html:3:15 - | - 3 | "some-cooode-pattern": "some-value", - | ------ - | - ::: repo/sub/item3.html:4:18 - | - 4 | "another-code-pattern": "some-value", - | ---- - | - ::: repo/sub/item3.html:10:40 - | -10 |

Hello world, a fast way to find code owned by teams

- | ---- - | +``` +repo/sub/item3.html +2: const code = { +3: "some-cooode-pattern": "some-value", +4: "another-code-pattern": "some-value", +10:

Hello world, a fast way to find code owned by teams

+ info: found 4 matches in 7.918375ms ```