Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 59 additions & 4 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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`].
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -897,12 +925,14 @@ fn excluded_search_paths(search_root: &Path, sift_dir: &Path) -> Vec<PathBuf> {
}

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
Expand Down Expand Up @@ -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`].
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1099,7 +1134,6 @@ fn run_search(cli: &Cli) -> anyhow::Result<bool> {
)?;

let args: Vec<String> = 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);
Expand Down Expand Up @@ -1138,6 +1172,22 @@ fn run_search(cli: &Cli) -> anyhow::Result<bool> {
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()?;

Expand All @@ -1157,6 +1207,11 @@ fn run_search(cli: &Cli) -> anyhow::Result<bool> {
is_path_mode,
},
format: SearchFormatCtx { null_data, color },
output_format: if use_json {
SearchOutputFormat::Json
} else {
SearchOutputFormat::Text
},
};
let filter = SearchFilterCtx {
hidden,
Expand Down
87 changes: 87 additions & 0 deletions crates/cli/tests/integration_json.rs
Original file line number Diff line number Diff line change
@@ -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:?}"
);
}
6 changes: 4 additions & 2 deletions crates/core/benches/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions crates/core/src/bin/sift_profile/scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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),

Expand Down
Loading
Loading