From e8f1940f1c1ca1c20d3d7df387e5d3dd6641ef5b Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 18:36:24 +0100 Subject: [PATCH 1/2] feat(search): --json JSON Lines output (ripgrep-compatible) Add SearchOutputFormat Text|Json and wire grep_printer::JSON for standard/ only-matching search. Emit begin/match/context/end per file plus final summary; quiet JSON discards match lines but keeps summary. CLI: --json/ --no-json, conflicts with count/file-list modes, implicit stderr stats. Integration tests and rg compat matrix updated. --- crates/cli/src/main.rs | 63 ++++- crates/cli/tests/integration_json.rs | 87 ++++++ crates/core/benches/search.rs | 6 +- crates/core/src/bin/sift_profile/scenarios.rs | 1 + crates/core/src/lib.rs | 10 +- crates/core/src/search/execute.rs | 263 +++++++++++++++++- crates/core/src/search/mod.rs | 3 +- crates/core/src/search/types.rs | 12 + docs/rg-compat-matrix.md | 3 +- 9 files changed, 437 insertions(+), 11 deletions(-) create mode 100644 crates/cli/tests/integration_json.rs diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 351ad56..eef123e 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -8,7 +8,7 @@ use sift_core::{ CaseMode, ColorChoice, CompiledSearch, Error as SiftError, FilenameMode, GlobConfig, HiddenMode, IgnoreConfig, IgnoreSources, Index, IndexBuilder, OutputEmission, SearchFilter, SearchFilterConfig, SearchLineStyle, SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, - SearchRecordStyle, SearchStats, VisibilityConfig, + SearchOutputFormat, SearchRecordStyle, SearchStats, VisibilityConfig, }; #[derive(Parser)] @@ -52,6 +52,17 @@ struct Cli { paths: PathArgs, #[command(flatten)] stats_decl: StatsDecl, + #[command(flatten)] + json_decl: JsonDecl, +} + +/// Declares `--json` / `--no-json` for clap; effective value uses [`resolve_json_from_args`]. +#[derive(Args)] +struct JsonDecl { + #[arg(long = "json", action = ArgAction::SetTrue)] + _json: bool, + #[arg(long = "no-json", action = ArgAction::SetTrue)] + _no_json: bool, } /// Declares `--stats` for clap; effective value uses [`resolve_stats_from_args`]. @@ -363,6 +374,23 @@ fn resolve_stats_from_args(args: &[String]) -> bool { result } +/// `--json` / `--no-json`; later flag wins (ripgrep-style). +fn resolve_json_from_args(args: &[String]) -> bool { + let mut last_idx = 0usize; + let mut result = false; + for (i, arg) in args.iter().enumerate() { + if arg == "--json" && i >= last_idx { + last_idx = i; + result = true; + } + if arg == "--no-json" && i >= last_idx { + last_idx = i; + result = false; + } + } + result +} + fn resolve_color_from_args(args: &[String]) -> ColorChoice { let mut result = ColorChoice::Auto; let mut i = 0usize; @@ -897,12 +925,14 @@ fn excluded_search_paths(search_root: &Path, sift_dir: &Path) -> Vec { } const fn search_output( + format: SearchOutputFormat, effective_mode: SearchMode, quiet: bool, lines: SearchLineStyle, records: SearchRecordStyle, ) -> SearchOutput { SearchOutput { + format, mode: effective_mode, emission: if quiet { OutputEmission::Quiet @@ -954,6 +984,7 @@ struct SearchOutputCtx { mode: SearchModeCtx, lines: SearchLineResolveCtx, format: SearchFormatCtx, + output_format: SearchOutputFormat, } /// Resolved visibility, ignore sources, and glob case (from argv order + clap) for [`SearchFilterConfig`]. @@ -1028,12 +1059,14 @@ fn run_search_with_index( corpus_is_single_file, ); let output = search_output( + out.output_format, out.mode.effective_mode, out.mode.quiet, SearchLineStyle { filename_mode, heading: out.lines.heading, - line_number: cli.out1.line_number, + line_number: cli.out1.line_number + || matches!(out.output_format, SearchOutputFormat::Json), }, SearchRecordStyle { null_data: out.format.null_data, @@ -1066,12 +1099,14 @@ fn run_search_walk( let filename_mode = effective_filename_mode(out.lines.with_filename, out.lines.is_path_mode, false); let output = search_output( + out.output_format, out.mode.effective_mode, out.mode.quiet, SearchLineStyle { filename_mode, heading: out.lines.heading, - line_number: cli.out1.line_number, + line_number: cli.out1.line_number + || matches!(out.output_format, SearchOutputFormat::Json), }, SearchRecordStyle { null_data: out.format.null_data, @@ -1099,7 +1134,6 @@ fn run_search(cli: &Cli) -> anyhow::Result { )?; let args: Vec = std::env::args().collect(); - let print_stats = resolve_stats_from_args(&args); 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); @@ -1138,6 +1172,22 @@ fn run_search(cli: &Cli) -> anyhow::Result { mode }; + let use_json = resolve_json_from_args(&args); + if use_json { + match effective_mode { + SearchMode::Count + | SearchMode::CountMatches + | SearchMode::FilesWithMatches + | SearchMode::FilesWithoutMatch => { + anyhow::bail!( + "sift: --json cannot be used with --count, --count-matches, --files-with-matches, or --files-without-match" + ); + } + SearchMode::Standard | SearchMode::OnlyMatching => {} + } + } + let print_stats = resolve_stats_from_args(&args) || use_json; + let query = CompiledSearch::new(&patterns, opts).map_err(|e| anyhow::anyhow!("{e}"))?; let cwd = std::env::current_dir()?; @@ -1157,6 +1207,11 @@ fn run_search(cli: &Cli) -> anyhow::Result { is_path_mode, }, format: SearchFormatCtx { null_data, color }, + output_format: if use_json { + SearchOutputFormat::Json + } else { + SearchOutputFormat::Text + }, }; let filter = SearchFilterCtx { hidden, diff --git a/crates/cli/tests/integration_json.rs b/crates/cli/tests/integration_json.rs new file mode 100644 index 0000000..ce2bc66 --- /dev/null +++ b/crates/cli/tests/integration_json.rs @@ -0,0 +1,87 @@ +//! `--json` JSON Lines output (ripgrep-compatible wire format via `grep-printer`). + +mod common; + +use std::ffi::OsString; +use std::fs; + +use common::{BuildIndexOptions, assert_success, fresh_dir}; + +#[test] +fn json_emits_begin_match_end_and_summary() { + let root = fresh_dir("json-basic"); + fs::write(root.join("a.txt"), "hello world\n").unwrap(); + let idx = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &idx, std::path::Path::new(".")); + + let mut cmd = common::command(Some(&root)); + cmd.arg("--sift-dir").arg(&idx); + cmd.args([OsString::from("hello"), OsString::from("--json")]); + let output = cmd.output().unwrap(); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + assert!( + lines.len() >= 4, + "expected begin, match, end, summary lines; got {}: {stdout:?}", + lines.len() + ); + assert!( + lines[0].contains("\"type\":\"begin\""), + "begin: {}", + lines[0] + ); + assert!( + lines[1].contains("\"type\":\"match\""), + "match: {}", + lines[1] + ); + assert!(lines[2].contains("\"type\":\"end\""), "end: {}", lines[2]); + assert!( + lines[lines.len() - 1].contains("\"type\":\"summary\""), + "summary: {}", + lines[lines.len() - 1] + ); +} + +#[test] +fn json_implies_stats_on_stderr() { + let root = fresh_dir("json-stats-stderr"); + fs::write(root.join("a.txt"), "x\n").unwrap(); + let idx = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &idx, std::path::Path::new(".")); + + let mut cmd = common::command(Some(&root)); + cmd.arg("--sift-dir").arg(&idx); + cmd.args([OsString::from("x"), OsString::from("--json")]); + let output = cmd.output().unwrap(); + assert_success(&output); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("matches") && stderr.contains("bytes searched"), + "expected --stats-style lines on stderr: {stderr:?}" + ); +} + +#[test] +fn json_with_count_exits_error() { + let root = fresh_dir("json-bad-count"); + fs::write(root.join("a.txt"), "a\n").unwrap(); + let idx = root.join(".sift"); + BuildIndexOptions::default().run(Some(&root), &idx, std::path::Path::new(".")); + + let mut cmd = common::command(Some(&root)); + cmd.arg("--sift-dir").arg(&idx); + cmd.args([ + OsString::from("a"), + OsString::from("--json"), + OsString::from("--count"), + ]); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--json") && stderr.contains("count"), + "expected conflict message: {stderr:?}" + ); +} diff --git a/crates/core/benches/search.rs b/crates/core/benches/search.rs index a98b5fa..1057af3 100644 --- a/crates/core/benches/search.rs +++ b/crates/core/benches/search.rs @@ -41,8 +41,8 @@ use std::hint::black_box; use sift_core::{ CaseMode, ColorChoice, CompiledSearch, FilenameMode, GlobConfig, HiddenMode, IgnoreConfig, IgnoreSources, Index, IndexBuilder, OutputEmission, SearchFilter, SearchFilterConfig, - SearchLineStyle, SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchRecordStyle, - VisibilityConfig, + SearchLineStyle, SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchOutputFormat, + SearchRecordStyle, VisibilityConfig, }; fn make_parity_corpus(root: &Path) { @@ -176,6 +176,7 @@ fn default_filter() -> SearchFilterConfig { const fn output_std() -> SearchOutput { SearchOutput { + format: SearchOutputFormat::Text, mode: SearchMode::Standard, emission: OutputEmission::Normal, lines: SearchLineStyle { @@ -192,6 +193,7 @@ const fn output_std() -> SearchOutput { const fn output_quiet(mode: SearchMode) -> SearchOutput { SearchOutput { + format: SearchOutputFormat::Text, mode, emission: OutputEmission::Quiet, lines: SearchLineStyle { diff --git a/crates/core/src/bin/sift_profile/scenarios.rs b/crates/core/src/bin/sift_profile/scenarios.rs index a243adb..2c5998e 100644 --- a/crates/core/src/bin/sift_profile/scenarios.rs +++ b/crates/core/src/bin/sift_profile/scenarios.rs @@ -54,6 +54,7 @@ impl Scenario { const fn make_output(mode: SearchMode, emission: sift_core::OutputEmission) -> SearchOutput { SearchOutput { + format: sift_core::SearchOutputFormat::Text, mode, emission, lines: SearchLineStyle { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 9769e79..30c85ec 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -16,8 +16,8 @@ pub use planner::TrigramPlan; pub use search::{ CandidateInfo, CaseMode, ColorChoice, CompiledSearch, FilenameMode, GlobConfig, HiddenMode, IgnoreConfig, IgnoreSources, Match, OutputEmission, SearchFilter, SearchFilterConfig, - SearchLineStyle, SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchRecordStyle, - SearchStats, VisibilityConfig, walk_file_paths, + SearchLineStyle, SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchOutputFormat, + SearchRecordStyle, SearchStats, VisibilityConfig, walk_file_paths, }; pub use ignore::{Walk, WalkBuilder}; @@ -55,6 +55,12 @@ pub enum Error { #[error("invalid max-count: 0 matches requested")] InvalidMaxCount, + #[error("JSON output is only supported for standard search (not count or file-list modes)")] + JsonOutputIncompatibleMode, + + #[error("JSON serialization error: {0}")] + JsonSerialize(#[from] serde_json::Error), + #[error("invalid index metadata: {0}")] InvalidMeta(PathBuf), diff --git a/crates/core/src/search/execute.rs b/crates/core/src/search/execute.rs index e8f8cbb..0e8b68c 100644 --- a/crates/core/src/search/execute.rs +++ b/crates/core/src/search/execute.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::Instant; use grep_matcher::Matcher; +use grep_printer::{JSON, Stats as JsonStats}; use grep_regex::RegexMatcher; use grep_searcher::{Searcher, Sink, SinkContext, SinkMatch}; use rayon::prelude::*; @@ -15,7 +16,7 @@ use crate::planner::TrigramPlan; use super::{ CandidateInfo, ColorChoice, CompiledSearch, FilenameMode, OutputEmission, SearchFilter, - SearchMode, SearchOutput, SearchRecordStyle, SearchStats, + SearchMode, SearchOutput, SearchOutputFormat, SearchRecordStyle, SearchStats, }; #[cfg(test)] @@ -58,6 +59,54 @@ struct StatsCollection<'a> { bytes_printed: Option<&'a AtomicU64>, } +/// Discards JSON bytes (for `--json` + quiet: ripgrep emits summary only). +struct NullWriter; + +impl io::Write for NullWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[inline] +fn fill_json_search_stats( + s: &mut SearchStats, + merged: &JsonStats, + candidates_len: usize, + bytes_searched_sum: u64, + elapsed: std::time::Duration, + summary_line_bytes: u64, +) { + s.matches = usize::try_from(merged.matches()).unwrap_or(usize::MAX); + s.files_with_matches = + usize::try_from(merged.searches_with_match()).unwrap_or(usize::MAX); + s.files_searched = candidates_len; + s.bytes_printed = merged.bytes_printed() + summary_line_bytes; + s.bytes_searched = bytes_searched_sum; + s.elapsed = elapsed; +} + +fn format_json_summary_line(wall: std::time::Duration, agg: &JsonStats) -> crate::Result { + let stats_val = serde_json::to_value(agg)?; + let wall_secs = f64::from(wall.subsec_nanos()).mul_add(1e-9, wall.as_secs_f64()); + let v = serde_json::json!({ + "type": "summary", + "data": { + "elapsed_total": { + "secs": wall.as_secs(), + "nanos": wall.subsec_nanos(), + "human": format!("{wall_secs:0.6}s"), + }, + "stats": stats_val, + } + }); + Ok(serde_json::to_string(&v)?) +} + impl CompiledSearch { /// Returns raw candidate file IDs from index (trigram or full scan). /// Does NOT apply `SearchFilter` - filtering happens in `prepare_candidates`. @@ -148,6 +197,21 @@ impl CompiledSearch { let matcher = self.matcher.get_or_try_init(|| self.build_matcher())?; let parallel = candidates.len() >= threshold; + if matches!(output.format, SearchOutputFormat::Json) { + return match output.mode { + SearchMode::Standard | SearchMode::OnlyMatching => self + .run_json_standard_with_info( + &candidates, + matcher, + output, + parallel, + search_start, + stats, + ), + _ => Err(crate::Error::JsonOutputIncompatibleMode), + }; + } + let match_counter = AtomicUsize::new(0); let counter_ref = stats.is_some().then_some(&match_counter); let files_with_matches = AtomicUsize::new(0); @@ -270,6 +334,21 @@ impl CompiledSearch { let matcher = self.matcher.get_or_try_init(|| self.build_matcher())?; let parallel = candidates.len() >= threshold; + if matches!(output.format, SearchOutputFormat::Json) { + return match output.mode { + SearchMode::Standard | SearchMode::OnlyMatching => self + .run_json_standard_with_info( + &candidates, + matcher, + output, + parallel, + search_start, + stats, + ), + _ => Err(crate::Error::JsonOutputIncompatibleMode), + }; + } + let match_counter = AtomicUsize::new(0); let counter_ref = stats.is_some().then_some(&match_counter); let files_with_matches = AtomicUsize::new(0); @@ -378,6 +457,71 @@ impl CompiledSearch { } } + fn run_json_standard_with_info( + &self, + candidates: &[CandidateInfo], + matcher: &RegexMatcher, + output: SearchOutput, + parallel: bool, + wall_start: Instant, + stats: Option<&mut SearchStats>, + ) -> crate::Result { + let bytes_searched_sum = sum_candidate_file_bytes(candidates); + if parallel { + let stop = AtomicBool::new(false); + let n = candidates.len(); + let mut files = Vec::with_capacity(n); + candidates + .par_iter() + .enumerate() + .map_init( + || JsonWorker::new(self, matcher, output), + |worker: &mut JsonWorker<'_>, + (result_index, candidate): (usize, &CandidateInfo)| { + worker.search_candidate(candidate, result_index, &stop) + }, + ) + .collect_into_vec(&mut files); + files.sort_by_key(|file| file.index); + return finish_json_run( + files, + wall_start, + stats, + candidates.len(), + bytes_searched_sum, + ); + } + + self.run_json_capped_with_info(candidates, matcher, output, wall_start, stats) + } + + fn run_json_capped_with_info( + &self, + candidates: &[CandidateInfo], + matcher: &RegexMatcher, + output: SearchOutput, + wall_start: Instant, + stats: Option<&mut SearchStats>, + ) -> crate::Result { + let bytes_searched_sum = sum_candidate_file_bytes(candidates); + self.with_cached_searcher(true, self.opts.max_results, |searcher| { + let stop = AtomicBool::new(false); + let mut files = Vec::with_capacity(candidates.len()); + for (i, candidate) in candidates.iter().enumerate() { + files.push(json_search_one( + searcher, matcher, output, candidate, i, &stop, + )); + } + finish_json_run( + files, + wall_start, + stats, + candidates.len(), + bytes_searched_sum, + ) + }) + } + fn run_standard_with_info( &self, candidates: &[CandidateInfo], @@ -669,6 +813,38 @@ impl CompiledSearch { } } +struct JsonWorker<'a> { + searcher: Searcher, + matcher: &'a RegexMatcher, + output: SearchOutput, +} + +impl<'a> JsonWorker<'a> { + fn new(search: &'a CompiledSearch, matcher: &'a RegexMatcher, output: SearchOutput) -> Self { + Self { + searcher: search.build_searcher(true, search.opts.max_results, true), + matcher, + output, + } + } + + fn search_candidate( + &mut self, + candidate: &CandidateInfo, + result_index: usize, + stop: &AtomicBool, + ) -> FileResult { + json_search_one( + &mut self.searcher, + self.matcher, + self.output, + candidate, + result_index, + stop, + ) + } +} + struct StandardWorker<'a> { /// Shared across Rayon threads; [`Matcher`] is implemented for `&RegexMatcher`. matcher: &'a RegexMatcher, @@ -718,6 +894,7 @@ impl<'a> StandardWorker<'a> { return FileResult { index: result_index, output: ChunkOutput::empty(), + json_stats: None, }; } @@ -783,6 +960,7 @@ impl<'a> StandardWorker<'a> { && self.output.lines.filename_mode != FilenameMode::Never && self.output.emission != OutputEmission::Quiet, }, + json_stats: None, } } } @@ -954,6 +1132,7 @@ impl<'a> SummaryWorker<'a> { return FileResult { index: result_index, output: ChunkOutput::empty(), + json_stats: None, }; } @@ -980,6 +1159,7 @@ impl<'a> SummaryWorker<'a> { matched, heading: false, }, + json_stats: None, } } } @@ -987,6 +1167,8 @@ impl<'a> SummaryWorker<'a> { struct FileResult { index: usize, output: ChunkOutput, + /// Per-file [`JsonStats`] when running JSON output mode; unused for text printers. + json_stats: Option, } struct ChunkOutput { @@ -1033,6 +1215,85 @@ fn flush_chunk_output( Ok(any_match) } +fn finish_json_run( + files: Vec, + wall_start: Instant, + stats: Option<&mut SearchStats>, + candidates_len: usize, + bytes_searched_sum: u64, +) -> crate::Result { + let mut merged = JsonStats::new(); + let mut outputs = Vec::with_capacity(files.len()); + for f in files { + if let Some(st) = f.json_stats { + merged += &st; + } + outputs.push(f.output); + } + let any_match = flush_chunk_output(outputs, None)?; + let summary_line = format_json_summary_line(wall_start.elapsed(), &merged)?; + let summary_bytes = summary_line.len() as u64 + 1; + let mut stdout = io::stdout().lock(); + stdout.write_all(summary_line.as_bytes())?; + stdout.write_all(b"\n")?; + if let Some(s) = stats { + fill_json_search_stats( + s, + &merged, + candidates_len, + bytes_searched_sum, + wall_start.elapsed(), + summary_bytes, + ); + } + Ok(any_match) +} + +fn json_search_one( + searcher: &mut Searcher, + matcher: &RegexMatcher, + output: SearchOutput, + candidate: &CandidateInfo, + result_index: usize, + stop: &AtomicBool, +) -> FileResult { + if stop.load(Ordering::SeqCst) { + return FileResult { + index: result_index, + output: ChunkOutput::empty(), + json_stats: None, + }; + } + let quiet = output.emission == OutputEmission::Quiet; + let (bytes, file_stats) = if quiet { + let mut json = JSON::new(NullWriter); + let mut sink = json.sink_with_path(matcher, &candidate.abs_path); + let _ = searcher.search_path(matcher, &candidate.abs_path, &mut sink); + (Vec::new(), sink.stats().clone()) + } else { + let mut json = JSON::new(Vec::new()); + let file_stats = { + let mut sink = json.sink_with_path(matcher, &candidate.abs_path); + let _ = searcher.search_path(matcher, &candidate.abs_path, &mut sink); + sink.stats().clone() + }; + (json.into_inner(), file_stats) + }; + let had_match = file_stats.matches() > 0; + if output.emission == OutputEmission::Quiet && had_match { + stop.store(true, Ordering::SeqCst); + } + FileResult { + index: result_index, + output: ChunkOutput { + bytes, + matched: had_match, + heading: false, + }, + json_stats: Some(file_stats), + } +} + #[derive(Clone, Copy)] struct FileSummary { matched: bool, diff --git a/crates/core/src/search/mod.rs b/crates/core/src/search/mod.rs index 5d48b11..e89d81e 100644 --- a/crates/core/src/search/mod.rs +++ b/crates/core/src/search/mod.rs @@ -12,5 +12,6 @@ pub use filter::{ }; pub use types::{ CaseMode, ColorChoice, CompiledSearch, FilenameMode, Match, OutputEmission, SearchLineStyle, - SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchRecordStyle, SearchStats, + SearchMatchFlags, SearchMode, SearchOptions, SearchOutput, SearchOutputFormat, + SearchRecordStyle, SearchStats, }; diff --git a/crates/core/src/search/types.rs b/crates/core/src/search/types.rs index 663f4e0..77f4504 100644 --- a/crates/core/src/search/types.rs +++ b/crates/core/src/search/types.rs @@ -162,8 +162,19 @@ impl Default for SearchRecordStyle { } } +/// Stdout encoding for search results (`--json` uses [JSON Lines](https://jsonlines.org/) like ripgrep). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SearchOutputFormat { + /// Human-readable lines (default). + #[default] + Text, + /// Machine-readable JSON Lines (`grep_printer::JSON`, ripgrep-compatible wire format). + Json, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SearchOutput { + pub format: SearchOutputFormat, pub mode: SearchMode, pub emission: OutputEmission, pub lines: SearchLineStyle, @@ -173,6 +184,7 @@ pub struct SearchOutput { impl Default for SearchOutput { fn default() -> Self { Self { + format: SearchOutputFormat::Text, mode: SearchMode::Standard, emission: OutputEmission::Normal, lines: SearchLineStyle::default(), diff --git a/docs/rg-compat-matrix.md b/docs/rg-compat-matrix.md index 2f89233..7993f66 100644 --- a/docs/rg-compat-matrix.md +++ b/docs/rg-compat-matrix.md @@ -19,5 +19,6 @@ back each implemented row with Rust tests that run `rg` and `sift` side by side. | Color / separators / null | `ripgrep/crates/printer/src/standard.rs`, `ripgrep/crates/printer/src/summary.rs` | Implemented | `--color`, `-0` / `--null`; see `integration_null_color.rs`. | | `--stats` | `ripgrep/crates/printer/stats.rs` (approx.) | Partial | Match tally, files contained matches, files searched, bytes printed, bytes searched (metadata sum), elapsed on stderr; no “matched lines” / split timing like rg; parity vs rg totals not guaranteed. See `integration_stats.rs`. | | Encoding / multiline | `ripgrep/crates/core/flags/defs.rs`, `ripgrep/tests/json.rs`, `ripgrep/tests/multiline.rs` | Missing | In scope, but after basic output parity lands. | -| `--json`, `--vimgrep`, `--debug` | `ripgrep/tests/json.rs`, `ripgrep/tests/misc.rs` (`vimgrep`) | Missing | In scope for non-engine parity. | +| `--json` | `ripgrep/tests/json.rs`, `grep-printer` JSON | Implemented | JSON Lines (`begin` / `match` / `context` / `end` / `summary`); `--json` implies stderr stats like `rg`. See `integration_json.rs`. | +| `--vimgrep`, `--debug` | `ripgrep/tests/misc.rs` (`vimgrep`) | Missing | In scope for non-engine parity. | | `-P` / PCRE2 | `ripgrep/crates/pcre2`, `ripgrep/crates/core/flags/defs.rs` | Deferred | Explicitly out of scope for the current parity phase. | From 2e87fa7bcfcd91e4d3a9089d48dda48c8ab7da55 Mon Sep 17 00:00:00 2001 From: Botir Khaltaev Date: Fri, 3 Apr 2026 18:41:59 +0100 Subject: [PATCH 2/2] style: rustfmt execute.rs --- crates/core/src/search/execute.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/core/src/search/execute.rs b/crates/core/src/search/execute.rs index 0e8b68c..967272e 100644 --- a/crates/core/src/search/execute.rs +++ b/crates/core/src/search/execute.rs @@ -82,8 +82,7 @@ fn fill_json_search_stats( summary_line_bytes: u64, ) { s.matches = usize::try_from(merged.matches()).unwrap_or(usize::MAX); - s.files_with_matches = - usize::try_from(merged.searches_with_match()).unwrap_or(usize::MAX); + s.files_with_matches = usize::try_from(merged.searches_with_match()).unwrap_or(usize::MAX); s.files_searched = candidates_len; s.bytes_printed = merged.bytes_printed() + summary_line_bytes; s.bytes_searched = bytes_searched_sum;