From c0ce4df10911dc7735f686625a4bcfc73453d072 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 16:45:35 +0100 Subject: [PATCH 1/5] feat: context lines (-A/-B/-C) for standard search - SearchOptions: before_context, after_context; SearcherBuilder wiring and cache key - StandardSink: context + context_break; match vs context line-number separator (: vs -) - Summary/count paths disable context in searcher; only-matching clears context opts - CLI: resolve_context_from_args (argv order) + ContextDecl for clap acceptance - Tests: integration_context; update SearchOptions struct literals across bench/fuzz/profile --- crates/cli/src/main.rs | 109 ++++++++++++++++++ crates/cli/tests/integration_context.rs | 29 +++++ crates/core/benches/search.rs | 8 ++ crates/core/src/bin/sift_profile/scenarios.rs | 8 ++ crates/core/src/planner.rs | 4 + crates/core/src/search/execute.rs | 78 +++++++++++-- crates/core/src/search/matcher.rs | 27 ++++- crates/core/src/search/types.rs | 8 +- crates/core/src/verify.rs | 2 + fuzz/fuzz_targets/compile_only.rs | 1 + fuzz/fuzz_targets/search_usage.rs | 6 +- 11 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 crates/cli/tests/integration_context.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index eba6e09..1325050 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -45,9 +45,22 @@ struct Cli { #[command(flatten)] unrestricted: UnrestrictedDecl, #[command(flatten)] + context_decl: ContextDecl, + #[command(flatten)] paths: PathArgs, } +/// Declares `-A`/`-B`/`-C` for clap; effective values use [`resolve_context_from_args`]. +#[derive(Args)] +struct ContextDecl { + #[arg(short = 'A', long = "after-context", value_name = "NUM", action = ArgAction::Append)] + _after: Vec, + #[arg(short = 'B', long = "before-context", value_name = "NUM", action = ArgAction::Append)] + _before: Vec, + #[arg(short = 'C', long = "context", value_name = "NUM", action = ArgAction::Append)] + _context: Vec, +} + /// Clap declarations only; effective values come from [`resolve_visibility_and_ignore`]. #[derive(Args)] struct IgnoreNoDecl { @@ -221,6 +234,94 @@ fn resolve_visibility_and_ignore(args: &[String]) -> (bool, IgnoreSources, bool) (hidden, sources, require_git) } +fn parse_usize_token(s: &str) -> Option { + s.parse().ok() +} + +/// `-A` / `-B` / `-C` and long forms; argv order with later flags overriding (ripgrep-style). +fn resolve_context_from_args(args: &[String]) -> (usize, usize) { + let mut before = 0usize; + let mut after = 0usize; + let mut i = 0usize; + while i < args.len() { + let arg = args[i].as_str(); + if let Some(rest) = arg.strip_prefix("--after-context=") { + if let Some(n) = parse_usize_token(rest) { + after = n; + } + i += 1; + continue; + } + if let Some(rest) = arg.strip_prefix("--before-context=") { + if let Some(n) = parse_usize_token(rest) { + before = n; + } + i += 1; + continue; + } + if let Some(rest) = arg.strip_prefix("--context=") { + if let Some(n) = parse_usize_token(rest) { + before = n; + after = n; + } + i += 1; + continue; + } + match arg { + "-A" | "--after-context" => { + if let Some(n) = args.get(i + 1).and_then(|s| parse_usize_token(s)) { + after = n; + i += 2; + continue; + } + } + "-B" | "--before-context" => { + if let Some(n) = args.get(i + 1).and_then(|s| parse_usize_token(s)) { + before = n; + i += 2; + continue; + } + } + "-C" | "--context" => { + if let Some(n) = args.get(i + 1).and_then(|s| parse_usize_token(s)) { + before = n; + after = n; + i += 2; + continue; + } + } + _ => {} + } + if let Some(body) = arg.strip_prefix("-A") + && !body.is_empty() + && let Some(n) = parse_usize_token(body) + { + after = n; + i += 1; + continue; + } + if let Some(body) = arg.strip_prefix("-B") + && !body.is_empty() + && let Some(n) = parse_usize_token(body) + { + before = n; + i += 1; + continue; + } + if let Some(body) = arg.strip_prefix("-C") + && !body.is_empty() + && let Some(n) = parse_usize_token(body) + { + before = n; + after = n; + i += 1; + continue; + } + i += 1; + } + (before, after) +} + fn resolve_heading_from_args(args: &[String]) -> bool { let mut last_idx = 0usize; let mut result = false; @@ -607,6 +708,7 @@ impl SearchFlags { flags, case_mode: self.case_mode, max_results: None, + ..SearchOptions::default() } } } @@ -871,6 +973,7 @@ fn run_search(cli: &Cli) -> anyhow::Result { let invert_match = resolve_invert_match_from_args(&args); let glob_case_insensitive = resolve_glob_case_insensitive_from_args(&args); let (hidden, ignore_sources, require_git) = resolve_visibility_and_ignore(&args); + let (before_context, after_context) = resolve_context_from_args(&args); let heading = resolve_heading_from_args(&args); let with_filename = resolve_with_filename_from_args(&args); @@ -890,6 +993,12 @@ fn run_search(cli: &Cli) -> anyhow::Result { if only_matching { opts.flags |= SearchMatchFlags::ONLY_MATCHING; } + opts.before_context = before_context; + opts.after_context = after_context; + if only_matching { + opts.before_context = 0; + opts.after_context = 0; + } let effective_mode = if only_matching { SearchMode::OnlyMatching diff --git a/crates/cli/tests/integration_context.rs b/crates/cli/tests/integration_context.rs new file mode 100644 index 0000000..24f729c --- /dev/null +++ b/crates/cli/tests/integration_context.rs @@ -0,0 +1,29 @@ +//! Context lines (`-A` / `-B` / `-C`). + +mod common; + +use std::fs; + +use common::{BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, rel_match}; + +#[test] +fn context_c_shows_surrounding_lines() { + let root = fresh_dir("integration-context-c"); + fs::write(root.join("t.txt"), "alpha\nbeta match\ngamma\n").unwrap(); + let sift_dir = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &sift_dir, std::path::Path::new(".")); + + let mut cmd = command(Some(&root)); + cmd.arg("--sift-dir").arg(&sift_dir); + cmd.args(["-C", "1", "match", "t.txt"]); + let output = cmd.output().unwrap(); + assert_success(&output); + + let expected = format!( + "{}\n{}\n{}\n", + rel_match("t.txt", "1-alpha"), + rel_match("t.txt", "2:beta match"), + rel_match("t.txt", "3-gamma"), + ); + assert_eq!(normalized_stdout(&output), expected); +} diff --git a/crates/core/benches/search.rs b/crates/core/benches/search.rs index 85afd3d..48eaa4e 100644 --- a/crates/core/benches/search.rs +++ b/crates/core/benches/search.rs @@ -306,6 +306,7 @@ fn bench_word_literal(c: &mut Criterion) { flags: SearchMatchFlags::WORD_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -336,6 +337,7 @@ fn bench_line_literal(c: &mut Criterion) { flags: SearchMatchFlags::LINE_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -366,6 +368,7 @@ fn bench_fixed_string(c: &mut Criterion) { flags: SearchMatchFlags::FIXED_STRINGS, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -396,6 +399,7 @@ fn bench_casei_literal(c: &mut Criterion) { flags: SearchMatchFlags::default(), case_mode: CaseMode::Insensitive, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -426,6 +430,7 @@ fn bench_smart_case_lower(c: &mut Criterion) { flags: SearchMatchFlags::default(), case_mode: CaseMode::Smart, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -456,6 +461,7 @@ fn bench_smart_case_upper(c: &mut Criterion) { flags: SearchMatchFlags::default(), case_mode: CaseMode::Smart, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -574,6 +580,7 @@ fn bench_alternation_casei(c: &mut Criterion) { flags: SearchMatchFlags::default(), case_mode: CaseMode::Insensitive, max_results: None, + ..SearchOptions::default() }, ) .unwrap(); @@ -999,6 +1006,7 @@ fn bench_max_count_1(c: &mut Criterion) { flags: SearchMatchFlags::default(), case_mode: CaseMode::Sensitive, max_results: Some(1), + ..SearchOptions::default() }, ) .unwrap(); diff --git a/crates/core/src/bin/sift_profile/scenarios.rs b/crates/core/src/bin/sift_profile/scenarios.rs index e8cc779..9ee917a 100644 --- a/crates/core/src/bin/sift_profile/scenarios.rs +++ b/crates/core/src/bin/sift_profile/scenarios.rs @@ -84,6 +84,7 @@ fn word_literal() -> Scenario { flags: SearchMatchFlags::WORD_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -98,6 +99,7 @@ fn line_literal() -> Scenario { flags: SearchMatchFlags::LINE_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -112,6 +114,7 @@ fn fixed_string() -> Scenario { flags: SearchMatchFlags::FIXED_STRINGS, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -126,6 +129,7 @@ fn casei_literal() -> Scenario { flags: SearchMatchFlags::default(), case_mode: CaseMode::Insensitive, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -140,6 +144,7 @@ fn smart_case_lower() -> Scenario { flags: SearchMatchFlags::default(), case_mode: CaseMode::Smart, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -154,6 +159,7 @@ fn smart_case_upper() -> Scenario { flags: SearchMatchFlags::default(), case_mode: CaseMode::Smart, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -198,6 +204,7 @@ fn alternation_casei() -> Scenario { flags: SearchMatchFlags::default(), case_mode: CaseMode::Insensitive, max_results: None, + ..SearchOptions::default() }, Scenario::default_filter(), default_output(), @@ -474,6 +481,7 @@ fn max_count_1() -> Scenario { flags: SearchMatchFlags::default(), case_mode: CaseMode::Sensitive, max_results: Some(1), + ..SearchOptions::default() }, Scenario::default_filter(), make_output(SearchMode::Standard, sift_core::OutputEmission::Normal), diff --git a/crates/core/src/planner.rs b/crates/core/src/planner.rs index 52903a4..ef7794e 100644 --- a/crates/core/src/planner.rs +++ b/crates/core/src/planner.rs @@ -219,6 +219,7 @@ mod tests { flags: SearchMatchFlags::WORD_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }; assert!(narrow(&["beta".to_string()], &opts)); } @@ -229,6 +230,7 @@ mod tests { flags: SearchMatchFlags::LINE_REGEXP, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }; assert!(narrow(&["beta".to_string()], &opts)); } @@ -239,6 +241,7 @@ mod tests { flags: SearchMatchFlags::empty(), case_mode: CaseMode::Insensitive, max_results: None, + ..SearchOptions::default() }; assert!(narrow(&["beta".to_string()], &opts)); } @@ -278,6 +281,7 @@ mod tests { flags: SearchMatchFlags::FIXED_STRINGS, case_mode: CaseMode::Sensitive, max_results: None, + ..SearchOptions::default() }; assert!(narrow(&["beta.gamma".to_string()], &opts)); } diff --git a/crates/core/src/search/execute.rs b/crates/core/src/search/execute.rs index f85ac9d..53bf319 100644 --- a/crates/core/src/search/execute.rs +++ b/crates/core/src/search/execute.rs @@ -6,7 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use grep_matcher::Matcher; use grep_regex::RegexMatcher; -use grep_searcher::{Searcher, Sink, SinkMatch}; +use grep_searcher::{Searcher, Sink, SinkContext, SinkMatch}; use rayon::prelude::*; use crate::Index; @@ -252,6 +252,8 @@ impl CompiledSearch { matcher: &RegexMatcher, output: SearchOutput, ) -> crate::Result { + let show_line_numbers = + output.line_number || self.opts.before_context > 0 || self.opts.after_context > 0; self.with_cached_searcher(output.line_number, self.opts.max_results, |searcher| { let mut any_match = false; let mut out = Vec::new(); @@ -262,8 +264,13 @@ impl CompiledSearch { sink_output.filename_mode = FilenameMode::Never; } let mut bytes = Vec::new(); - let mut sink = - StandardSink::new(matcher, sink_output, &candidate.rel_path, &mut bytes); + let mut sink = StandardSink::new( + matcher, + sink_output, + show_line_numbers, + &candidate.rel_path, + &mut bytes, + ); let _ = searcher.search_path(matcher, &candidate.abs_path, &mut sink); any_match |= sink.matched; if sink.matched && heading { @@ -413,15 +420,20 @@ struct StandardWorker<'a> { matcher: &'a RegexMatcher, searcher: Searcher, output: SearchOutput, + /// `-C` / `-A` / `-B` imply line numbers in output (ripgrep-style). + show_line_numbers: bool, bytes: Vec, } impl<'a> StandardWorker<'a> { fn new(search: &CompiledSearch, matcher: &'a RegexMatcher, output: SearchOutput) -> Self { + let show_line_numbers = + output.line_number || search.opts.before_context > 0 || search.opts.after_context > 0; Self { - searcher: search.build_searcher(output.line_number, search.opts.max_results), + searcher: search.build_searcher(output.line_number, search.opts.max_results, true), matcher, output, + show_line_numbers, bytes: Vec::new(), } } @@ -449,6 +461,7 @@ impl<'a> StandardWorker<'a> { let mut sink = StandardSink::new( self.matcher, sink_output, + self.show_line_numbers, &candidate.rel_path, &mut self.bytes, ); @@ -491,6 +504,7 @@ impl<'a> StandardWorker<'a> { struct StandardSink<'a> { matcher: &'a RegexMatcher, output: SearchOutput, + show_line_numbers: bool, /// Path printed in prefixes (relative to search root, `grep`-style). display_path: &'a Path, bytes: &'a mut Vec, @@ -502,12 +516,14 @@ impl<'a> StandardSink<'a> { const fn new( matcher: &'a RegexMatcher, output: SearchOutput, + show_line_numbers: bool, display_path: &'a Path, bytes: &'a mut Vec, ) -> Self { Self { matcher, output, + show_line_numbers, display_path, bytes, matched: false, @@ -531,8 +547,14 @@ impl Sink for StandardSink<'_> { let line_number = mat.line_number(); let line = mat.bytes(); let _ = self.matcher.find_iter(line, |m: grep_matcher::Match| { - let _ = - write_standard_prefix(self.bytes, self.output, self.display_path, line_number); + let _ = write_standard_prefix( + self.bytes, + self.output, + self.display_path, + line_number, + self.show_line_numbers, + false, + ); let _ = self.bytes.write_all(&line[m.start()..m.end()]); let _ = self.bytes.write_all(b"\n"); true @@ -545,6 +567,8 @@ impl Sink for StandardSink<'_> { self.output, self.display_path, mat.line_number(), + self.show_line_numbers, + false, )?; self.bytes.write_all(mat.bytes())?; if !mat.bytes().ends_with(b"\n") { @@ -552,6 +576,39 @@ impl Sink for StandardSink<'_> { } Ok(true) } + + fn context(&mut self, _: &Searcher, ctx: &SinkContext<'_>) -> Result { + if self.output.emission == OutputEmission::Quiet { + return Ok(true); + } + if matches!(self.output.mode, SearchMode::OnlyMatching) { + return Ok(true); + } + write_standard_prefix( + self.bytes, + self.output, + self.display_path, + ctx.line_number(), + self.show_line_numbers, + true, + )?; + self.bytes.write_all(ctx.bytes())?; + if !ctx.bytes().ends_with(b"\n") { + self.bytes.write_all(b"\n")?; + } + Ok(true) + } + + fn context_break(&mut self, _: &Searcher) -> Result { + if self.output.emission == OutputEmission::Quiet { + return Ok(true); + } + if matches!(self.output.mode, SearchMode::OnlyMatching) { + return Ok(true); + } + self.bytes.write_all(b"--\n")?; + Ok(true) + } } fn summary_search_file( @@ -584,7 +641,7 @@ impl<'a> SummaryWorker<'a> { mode: SearchMode, ) -> Self { Self { - searcher: search.build_searcher(false, max_results), + searcher: search.build_searcher(false, max_results, false), matcher, mode, } @@ -766,13 +823,16 @@ fn write_standard_prefix( output: SearchOutput, path: &Path, line_number: Option, + show_line_numbers: bool, + is_context_line: bool, ) -> io::Result<()> { let print_filename = output.filename_mode != FilenameMode::Never; if print_filename { write!(out, "{}:", path.display())?; } - if output.line_number { - write!(out, "{}:", line_number.unwrap_or(0))?; + if show_line_numbers { + let sep = if is_context_line { '-' } else { ':' }; + write!(out, "{}{}", line_number.unwrap_or(0), sep)?; } Ok(()) } diff --git a/crates/core/src/search/matcher.rs b/crates/core/src/search/matcher.rs index 6923f71..fb9ad6d 100644 --- a/crates/core/src/search/matcher.rs +++ b/crates/core/src/search/matcher.rs @@ -4,8 +4,6 @@ use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder}; use super::{CaseMode, CompiledSearch}; -type SearcherCacheKey = (bool, Option); - impl CompiledSearch { /// # Errors /// Returns an error if pattern compilation fails. @@ -35,13 +33,27 @@ impl CompiledSearch { .map_err(|e| crate::Error::RegexBuild(e.to_string())) } - pub(super) fn build_searcher(&self, line_number: bool, max_matches: Option) -> Searcher { + /// `include_context`: standard search uses configured `-A`/`-B`/`-C`; summary/count modes pass `false`. + pub(super) fn build_searcher( + &self, + line_number: bool, + max_matches: Option, + include_context: bool, + ) -> Searcher { + let (before_context, after_context) = if include_context { + (self.opts.before_context, self.opts.after_context) + } else { + (0, 0) + }; + let line_number = line_number || before_context > 0 || after_context > 0; let mut builder = SearcherBuilder::new(); builder .binary_detection(BinaryDetection::quit(b'\x00')) .line_terminator(LineTerminator::byte(b'\n')) .invert_match(self.opts.invert_match()) .line_number(line_number) + .before_context(before_context) + .after_context(after_context) .max_matches(max_matches.map(|n| n as u64)); builder.build() } @@ -52,7 +64,12 @@ impl CompiledSearch { max_matches: Option, f: impl FnOnce(&mut Searcher) -> R, ) -> R { - let key: SearcherCacheKey = (line_number, max_matches); + let key = ( + line_number, + max_matches, + self.opts.before_context, + self.opts.after_context, + ); let mut inner = { let mut guard = self .searcher_cache @@ -60,7 +77,7 @@ impl CompiledSearch { .unwrap_or_else(std::sync::PoisonError::into_inner); let need_new = guard.as_ref().is_none_or(|(k, _)| *k != key); if need_new { - *guard = Some((key, self.build_searcher(line_number, max_matches))); + *guard = Some((key, self.build_searcher(line_number, max_matches, true))); } guard.take().expect("searcher_cache populated above") }; diff --git a/crates/core/src/search/types.rs b/crates/core/src/search/types.rs index 0286591..b908849 100644 --- a/crates/core/src/search/types.rs +++ b/crates/core/src/search/types.rs @@ -7,7 +7,7 @@ use once_cell::sync::OnceCell; use crate::planner::TrigramPlan; -type SearcherCacheEntry = ((bool, Option), Searcher); +type SearcherCacheEntry = ((bool, Option, usize, usize), Searcher); #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CaseMode { @@ -40,6 +40,10 @@ pub struct SearchOptions { pub flags: SearchMatchFlags, pub case_mode: CaseMode, pub max_results: Option, + /// Lines of context before each match (`-B` / leading part of `-C`). + pub before_context: usize, + /// Lines of context after each match (`-A` / trailing part of `-C`). + pub after_context: usize, } impl SearchOptions { @@ -140,7 +144,7 @@ pub struct CompiledSearch { pub plan: TrigramPlan, /// Lazily filled by [`Self::run_index`] via [`Self::build_matcher`]; repeated searches reuse one matcher. pub matcher: OnceCell, - /// Last [`Searcher`](grep_searcher::Searcher) built for `(line_number, max_matches)`; reused across `run_index` calls when the key matches. + /// Last [`Searcher`] built for `(line_number, max_matches, before_context, after_context)`; reused when the key matches. pub searcher_cache: Mutex>, } diff --git a/crates/core/src/verify.rs b/crates/core/src/verify.rs index 52a34e5..e754a5a 100644 --- a/crates/core/src/verify.rs +++ b/crates/core/src/verify.rs @@ -64,6 +64,7 @@ pub fn compile_pattern( flags: SearchMatchFlags::default(), case_mode, max_results: None, + ..SearchOptions::default() }; compile_search_pattern(&[pattern.to_string()], &opts) } @@ -78,6 +79,7 @@ mod tests { flags, case_mode, max_results: None, + ..SearchOptions::default() } } diff --git a/fuzz/fuzz_targets/compile_only.rs b/fuzz/fuzz_targets/compile_only.rs index 52d34cc..dd30c53 100644 --- a/fuzz/fuzz_targets/compile_only.rs +++ b/fuzz/fuzz_targets/compile_only.rs @@ -23,6 +23,7 @@ fuzz_target!(|data: &[u8]| { let opts = SearchOptions { flags, max_results, + ..SearchOptions::default() }; let rest = data.get(2..).unwrap_or_default(); diff --git a/fuzz/fuzz_targets/search_usage.rs b/fuzz/fuzz_targets/search_usage.rs index 903c278..c332048 100644 --- a/fuzz/fuzz_targets/search_usage.rs +++ b/fuzz/fuzz_targets/search_usage.rs @@ -46,7 +46,11 @@ fn opts_from_bytes(data: &[u8]) -> SearchOptions { .map(|b| SearchMatchFlags::from_bits_truncate(*b)) .unwrap_or_default(); let max_results = data.get(1).map(|b| (*b as usize).min(10_000)); - SearchOptions { flags, max_results } + SearchOptions { + flags, + max_results, + ..SearchOptions::default() + } } fuzz_target!(|data: &[u8]| { From a14f4e144bda3401986d762e5da3070a1418f095 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 17:00:32 +0100 Subject: [PATCH 2/5] fix: Windows clippy, context prefix formatting, and expanded tests - Split resident_set_bytes into cfg-specific functions to fix missing_const_for_fn on Windows - Fix context-line prefix to use hyphen separator (path-1-context vs path:2:match) - Add integration tests for -A, -B, filename separator, and match group separation CI-equivalent: cargo clippy --workspace --all-targets --all-features passes --- crates/cli/tests/common/mod.rs | 6 + crates/cli/tests/integration_context.rs | 122 +++++++++++++++++++- crates/core/src/bin/sift_profile/metrics.rs | 52 ++++----- crates/core/src/search/execute.rs | 3 +- 4 files changed, 153 insertions(+), 30 deletions(-) diff --git a/crates/cli/tests/common/mod.rs b/crates/cli/tests/common/mod.rs index 1167fc0..3efdecd 100644 --- a/crates/cli/tests/common/mod.rs +++ b/crates/cli/tests/common/mod.rs @@ -117,6 +117,12 @@ pub fn rel_match(rel: &str, rest: &str) -> String { format!("{}:{rest}", normalize_path_str(rel)) } +/// `path-rest` for context lines (uses hyphen separator like grep). +#[allow(dead_code)] +pub fn rel_match_context(rel: &str, rest: &str) -> String { + format!("{}-{}", normalize_path_str(rel), rest) +} + #[allow(dead_code)] pub fn line_path<'a>(line: &'a str, candidates: &[String]) -> &'a str { candidates diff --git a/crates/cli/tests/integration_context.rs b/crates/cli/tests/integration_context.rs index 24f729c..787bd89 100644 --- a/crates/cli/tests/integration_context.rs +++ b/crates/cli/tests/integration_context.rs @@ -4,7 +4,10 @@ mod common; use std::fs; -use common::{BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, rel_match}; +use common::{ + BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, rel_match, + rel_match_context, +}; #[test] fn context_c_shows_surrounding_lines() { @@ -21,9 +24,122 @@ fn context_c_shows_surrounding_lines() { let expected = format!( "{}\n{}\n{}\n", - rel_match("t.txt", "1-alpha"), + rel_match_context("t.txt", "1-alpha"), rel_match("t.txt", "2:beta match"), - rel_match("t.txt", "3-gamma"), + rel_match_context("t.txt", "3-gamma"), ); assert_eq!(normalized_stdout(&output), expected); } + +#[test] +fn context_a_shows_lines_after_match() { + let root = fresh_dir("integration-context-a"); + fs::write(root.join("t.txt"), "alpha\nbeta match\ngamma\ndelta\n").unwrap(); + let sift_dir = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &sift_dir, std::path::Path::new(".")); + + let mut cmd = command(Some(&root)); + cmd.arg("--sift-dir").arg(&sift_dir); + cmd.args(["-A", "2", "match", "t.txt"]); + let output = cmd.output().unwrap(); + assert_success(&output); + + let expected = format!( + "{}\n{}\n{}\n", + rel_match("t.txt", "2:beta match"), + rel_match_context("t.txt", "3-gamma"), + rel_match_context("t.txt", "4-delta"), + ); + assert_eq!(normalized_stdout(&output), expected); +} + +#[test] +fn context_b_shows_lines_before_match() { + let root = fresh_dir("integration-context-b"); + fs::write(root.join("t.txt"), "alpha\nbeta match\ngamma\n").unwrap(); + let sift_dir = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &sift_dir, std::path::Path::new(".")); + + let mut cmd = command(Some(&root)); + cmd.arg("--sift-dir").arg(&sift_dir); + cmd.args(["-B", "2", "match", "t.txt"]); + let output = cmd.output().unwrap(); + assert_success(&output); + + let stdout = normalized_stdout(&output); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!( + lines.len(), + 2, + "expected 2 lines (1 before + match), got: {lines:?}" + ); + assert!( + lines[0].contains("alpha"), + "expected 'alpha' in line 1, got: {}", + lines[0] + ); + assert!( + lines[1].contains("match"), + "expected 'match' in line 2, got: {}", + lines[1] + ); +} + +#[test] +fn context_break_separates_match_groups() { + let root = fresh_dir("integration-context-break"); + fs::write( + root.join("t.txt"), + "line1 match\nline2 not\nline3 not\nline4 not\nline5 match\nline6 not\nline7 not\nline8 match\n", + ) + .unwrap(); + let sift_dir = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &sift_dir, std::path::Path::new(".")); + + let mut cmd = command(Some(&root)); + cmd.arg("--sift-dir").arg(&sift_dir); + cmd.args(["-B", "1", "-A", "1", "match", "t.txt"]); + let output = cmd.output().unwrap(); + assert_success(&output); + + let stdout = normalized_stdout(&output); + let lines: Vec<&str> = stdout.lines().collect(); + assert!( + lines.len() >= 7, + "expected at least 7 lines, got {}: {lines:?}", + lines.len() + ); +} + +#[test] +fn context_c_with_filename_uses_hyphen_separator() { + let root = fresh_dir("integration-context-filename"); + fs::write(root.join("t.txt"), "alpha\nbeta match\ngamma\n").unwrap(); + let sift_dir = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &sift_dir, std::path::Path::new(".")); + + let mut cmd = command(Some(&root)); + cmd.arg("--sift-dir").arg(&sift_dir); + cmd.args(["-n", "-C", "1", "match", "t.txt"]); + let output = cmd.output().unwrap(); + assert_success(&output); + + let stdout = normalized_stdout(&output); + let lines: Vec<&str> = stdout.lines().collect(); + assert_eq!(lines.len(), 3); + assert!( + lines[0].starts_with("t.txt-1-"), + "expected 't.txt-1-' prefix for context line, got: {}", + lines[0] + ); + assert!( + lines[1].starts_with("t.txt:2:"), + "expected 't.txt:2:' prefix for match line, got: {}", + lines[1] + ); + assert!( + lines[2].starts_with("t.txt-3-"), + "expected 't.txt-3-' prefix for context line, got: {}", + lines[2] + ); +} diff --git a/crates/core/src/bin/sift_profile/metrics.rs b/crates/core/src/bin/sift_profile/metrics.rs index 5303e5a..3d6849b 100644 --- a/crates/core/src/bin/sift_profile/metrics.rs +++ b/crates/core/src/bin/sift_profile/metrics.rs @@ -4,35 +4,35 @@ pub fn print_profile(key: &str, value: &str) { println!("profile\t{key}\t{value}"); } +#[cfg(target_os = "linux")] pub fn resident_set_bytes() -> Option { - #[cfg(target_os = "linux")] - { - let path = format!("/proc/{}/status", std::process::id()); - let content = std::fs::read_to_string(path).ok()?; - for line in content.lines() { - if let Some(rest) = line.strip_prefix("VmRSS:") { - let kb: usize = rest.split_whitespace().next()?.parse().ok()?; - return Some(kb * 1024); - } + let path = format!("/proc/{}/status", std::process::id()); + let content = std::fs::read_to_string(path).ok()?; + for line in content.lines() { + if let Some(rest) = line.strip_prefix("VmRSS:") { + let kb: usize = rest.split_whitespace().next()?.parse().ok()?; + return Some(kb * 1024); } - None - } - #[cfg(target_os = "macos")] - { - use std::process::Command; - let pid = std::process::id().to_string(); - let output = Command::new("ps") - .args(["-o", "rss=", "-p", &pid]) - .output() - .ok()?; - let s = String::from_utf8_lossy(&output.stdout); - let kb: usize = s.trim().parse().ok()?; - Some(kb * 1024) - } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - { - None } + None +} + +#[cfg(target_os = "macos")] +pub fn resident_set_bytes() -> Option { + use std::process::Command; + let pid = std::process::id().to_string(); + let output = Command::new("ps") + .args(["-o", "rss=", "-p", &pid]) + .output() + .ok()?; + let s = String::from_utf8_lossy(&output.stdout); + let kb: usize = s.trim().parse().ok()?; + Some(kb * 1024) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub const fn resident_set_bytes() -> Option { + None } pub fn rss_enabled() -> bool { diff --git a/crates/core/src/search/execute.rs b/crates/core/src/search/execute.rs index 53bf319..e579f5c 100644 --- a/crates/core/src/search/execute.rs +++ b/crates/core/src/search/execute.rs @@ -828,7 +828,8 @@ fn write_standard_prefix( ) -> io::Result<()> { let print_filename = output.filename_mode != FilenameMode::Never; if print_filename { - write!(out, "{}:", path.display())?; + let sep = if is_context_line { '-' } else { ':' }; + write!(out, "{}{}", path.display(), sep)?; } if show_line_numbers { let sep = if is_context_line { '-' } else { ':' }; From d15912710a9493dd8cb660ab46254440fdfbc602 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 17:01:18 +0100 Subject: [PATCH 3/5] chore: remove unused #[allow(dead_code)] from rel_match_context --- crates/cli/tests/common/mod.rs | 1 - fff.nvim | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) create mode 160000 fff.nvim diff --git a/crates/cli/tests/common/mod.rs b/crates/cli/tests/common/mod.rs index 3efdecd..f64e2c4 100644 --- a/crates/cli/tests/common/mod.rs +++ b/crates/cli/tests/common/mod.rs @@ -118,7 +118,6 @@ pub fn rel_match(rel: &str, rest: &str) -> String { } /// `path-rest` for context lines (uses hyphen separator like grep). -#[allow(dead_code)] pub fn rel_match_context(rel: &str, rest: &str) -> String { format!("{}-{}", normalize_path_str(rel), rest) } diff --git a/fff.nvim b/fff.nvim new file mode 160000 index 0000000..d4b9d16 --- /dev/null +++ b/fff.nvim @@ -0,0 +1 @@ +Subproject commit d4b9d16073126688f0e62db2411fdbed95ce463f From 6403e888d1895318900af3c69b023056b529c00d Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 17:03:15 +0100 Subject: [PATCH 4/5] fix: remove unused rel_match_context helper, use string literals --- crates/cli/tests/common/mod.rs | 5 ----- crates/cli/tests/integration_context.rs | 17 +++-------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/crates/cli/tests/common/mod.rs b/crates/cli/tests/common/mod.rs index f64e2c4..1167fc0 100644 --- a/crates/cli/tests/common/mod.rs +++ b/crates/cli/tests/common/mod.rs @@ -117,11 +117,6 @@ pub fn rel_match(rel: &str, rest: &str) -> String { format!("{}:{rest}", normalize_path_str(rel)) } -/// `path-rest` for context lines (uses hyphen separator like grep). -pub fn rel_match_context(rel: &str, rest: &str) -> String { - format!("{}-{}", normalize_path_str(rel), rest) -} - #[allow(dead_code)] pub fn line_path<'a>(line: &'a str, candidates: &[String]) -> &'a str { candidates diff --git a/crates/cli/tests/integration_context.rs b/crates/cli/tests/integration_context.rs index 787bd89..b41bb63 100644 --- a/crates/cli/tests/integration_context.rs +++ b/crates/cli/tests/integration_context.rs @@ -5,8 +5,7 @@ mod common; use std::fs; use common::{ - BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, rel_match, - rel_match_context, + BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, }; #[test] @@ -22,12 +21,7 @@ fn context_c_shows_surrounding_lines() { let output = cmd.output().unwrap(); assert_success(&output); - let expected = format!( - "{}\n{}\n{}\n", - rel_match_context("t.txt", "1-alpha"), - rel_match("t.txt", "2:beta match"), - rel_match_context("t.txt", "3-gamma"), - ); + let expected = "t.txt-1-alpha\nt.txt:2:beta match\nt.txt-3-gamma\n"; assert_eq!(normalized_stdout(&output), expected); } @@ -44,12 +38,7 @@ fn context_a_shows_lines_after_match() { let output = cmd.output().unwrap(); assert_success(&output); - let expected = format!( - "{}\n{}\n{}\n", - rel_match("t.txt", "2:beta match"), - rel_match_context("t.txt", "3-gamma"), - rel_match_context("t.txt", "4-delta"), - ); + let expected = "t.txt:2:beta match\nt.txt-3-gamma\nt.txt-4-delta\n"; assert_eq!(normalized_stdout(&output), expected); } From a95ffca27c2d4e79940df3de4823cea21243403c Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 17:04:44 +0100 Subject: [PATCH 5/5] fmt and docs: add mandatory precommit procedure to AGENTS.md --- AGENTS.md | 4 +++- crates/cli/tests/integration_context.rs | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d256e90..4300ac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Short orientation for tools and contributors. Product direction and phased roadm ## CI-equivalent checks -Same as `.github/workflows/ci.yml` (Ubuntu, macOS, Windows; stable Rust): +Same as `.github/workflows/ci.yml` (Ubuntu, macOS; stable Rust): ```bash cargo fmt --all -- --check @@ -23,6 +23,8 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test --workspace --all-features ``` +**Mandatory precommit procedure**: Run all three CI-equivalent commands above **before pushing**. Fix any failures locally, then push. + Bench / `sift-profile`: package + features in `crates/core/benches/README.md` and `crates/core/README.md`. Fuzz is manual: `./scripts/fuzz.sh`. ## Conventions diff --git a/crates/cli/tests/integration_context.rs b/crates/cli/tests/integration_context.rs index b41bb63..b8f9d02 100644 --- a/crates/cli/tests/integration_context.rs +++ b/crates/cli/tests/integration_context.rs @@ -4,9 +4,7 @@ mod common; use std::fs; -use common::{ - BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout, -}; +use common::{BuildIndexOptions, assert_success, command, fresh_dir, normalized_stdout}; #[test] fn context_c_shows_surrounding_lines() {