fn emits_anchor_events(args: &crate::cli::SearchArgs) -> bool {
!args.count && !args.files && !args.no_begin_end
}
#[derive(Debug, Default)]
struct SearchTally {
files_matched: u64,
total_matches: u64,
offset_skipped: u64,
}
fn emit_search_event(
event: SearchEvent,
writer: &mut NdjsonWriter<impl Write>,
args: &crate::cli::SearchArgs,
has_matches: &mut bool,
tally: &mut SearchTally,
text: &mut TextLayout,
) -> Result<u64> {
if args.format == crate::cli::SearchFormat::Text {
return emit_text_event(event, writer, args, has_matches, tally, text);
}
match event {
SearchEvent::Begin(path) => {
if emits_anchor_events(args) {
writer.write_framing_event(&SearchBegin {
r#type: "begin",
path: path.display().to_string(),
})?;
}
Ok(0)
}
SearchEvent::Match {
path,
line_number,
lines,
byte_offset,
submatches,
raw: _,
} => {
*has_matches = true;
if args.count || args.files || crate::output::limit_reached() {
return Ok(0);
}
if tally.offset_skipped < args.offset {
tally.offset_skipped = tally.offset_skipped.saturating_add(1);
return Ok(0);
}
writer.write_event(&SearchMatch {
r#type: "match",
path: path.display().to_string(),
line_number,
lines,
byte_offset,
submatches,
})?;
tally.total_matches = tally.total_matches.saturating_add(1);
Ok(1)
}
SearchEvent::Context {
path,
line_number,
lines,
raw_block: _,
} => {
let paged_out = tally.offset_skipped < args.offset;
if !args.count && !args.files && !paged_out && !crate::output::limit_reached() {
writer.write_event(&SearchContext {
r#type: "context",
path: path.display().to_string(),
line_number,
lines,
})?;
}
Ok(0)
}
SearchEvent::End {
path,
matches,
lines_searched,
} => {
*has_matches |= matches > 0;
let path_str = path.display().to_string();
let mut answered = 0;
if args.files && matches > 0 && !crate::output::limit_reached() {
writer.write_event(&SearchFile {
r#type: "file",
path: path_str.clone(),
})?;
tally.total_matches = tally.total_matches.saturating_add(matches);
answered = 1;
}
if args.count && matches > 0 && !crate::output::limit_reached() {
writer.write_event(&SearchCount {
r#type: "count",
path: path_str.clone(),
count: matches,
})?;
tally.total_matches = tally.total_matches.saturating_add(matches);
answered = 1;
}
if emits_anchor_events(args) {
writer.write_framing_event(&SearchEnd {
r#type: "end",
path: path_str,
stats: FileStats {
matches,
lines_searched,
},
})?;
}
Ok(answered)
}
}
}
#[derive(Debug, Default)]
struct TextLayout {
current_path: Option<std::path::PathBuf>,
last_line: Option<u64>,
in_group: bool,
workspace: std::path::PathBuf,
targets: Vec<std::path::PathBuf>,
show_path: bool,
}
pub(crate) fn search_roots(args: &SearchArgs) -> Vec<std::path::PathBuf> {
if args.paths.is_empty() {
vec![std::path::PathBuf::from(".")]
} else {
args.paths.clone()
}
}
pub(crate) fn stamp_search_target<W: std::io::Write>(
writer: &mut crate::output::NdjsonWriter<W>,
args: &SearchArgs,
resolved: &[std::path::PathBuf],
workspace: &std::path::Path,
) {
let source = if args.paths.is_empty() {
crate::commands::path_resolution::TargetSource::Workspace
} else {
crate::commands::path_resolution::TargetSource::Argv
};
let joined = if args.paths.is_empty() {
workspace.display().to_string()
} else {
resolved
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(",")
};
writer.set_target(joined, source.as_str());
}
impl TextLayout {
fn new(args: &SearchArgs, workspace: &std::path::Path) -> Self {
Self {
workspace: workspace.to_path_buf(),
targets: search_roots(args),
show_path: text_needs_path(args, workspace),
..Self::default()
}
}
fn order_key(&self, path: &std::path::Path) -> (usize, String) {
let relative = path.strip_prefix(&self.workspace).unwrap_or(path);
let relative = relative.to_string_lossy().replace('\\', "/");
let index = self
.targets
.iter()
.enumerate()
.filter(|(_, target)| !target.is_file())
.find_map(|(index, target)| {
let base = target.to_string_lossy().replace('\\', "/");
let base = base.trim_end_matches('/').to_string();
(base.is_empty() || base == "." || relative.starts_with(&format!("{base}/")))
.then_some(index)
})
.unwrap_or(usize::MAX);
(index, relative)
}
fn path_label(&self, path: &std::path::Path) -> String {
let relative = path.strip_prefix(&self.workspace).unwrap_or(path);
let relative = relative.to_string_lossy().replace('\\', "/");
for target in &self.targets {
if target.is_file() {
continue;
}
let base = target.to_string_lossy().replace('\\', "/");
let base = base.trim_end_matches('/');
if base.is_empty() || base == "." {
return format!("./{relative}");
}
if let Some(rest) = relative.strip_prefix(&format!("{base}/")) {
return format!("{base}/{rest}");
}
}
relative
}
}
fn emit_text_event(
event: SearchEvent,
writer: &mut NdjsonWriter<impl Write>,
args: &SearchArgs,
has_matches: &mut bool,
tally: &mut SearchTally,
text: &mut TextLayout,
) -> Result<u64> {
match event {
SearchEvent::Begin(_) => Ok(0),
SearchEvent::Match {
path,
line_number,
raw,
..
} => {
*has_matches = true;
if args.count || args.files || crate::output::limit_reached() {
return Ok(0);
}
if tally.offset_skipped < args.offset {
tally.offset_skipped = tally.offset_skipped.saturating_add(1);
return Ok(0);
}
text.write_line(writer, args, &path, line_number, &raw, b':')?;
tally.total_matches = tally.total_matches.saturating_add(1);
Ok(1)
}
SearchEvent::Context {
path,
line_number,
raw_block,
..
} => {
let paged_out = tally.offset_skipped < args.offset;
if args.count || args.files || paged_out || crate::output::limit_reached() {
return Ok(0);
}
let mut number = line_number;
for line in raw_block.split(|b| *b == b'\n') {
if line.is_empty() && number > line_number {
continue;
}
text.write_line(writer, args, &path, number, line, b'-')?;
number = number.saturating_add(1);
}
Ok(0)
}
SearchEvent::End { path, matches, .. } => {
*has_matches |= matches > 0;
if args.files && matches > 0 && !crate::output::limit_reached() {
let line = format!("{}\n", text.path_label(&path));
writer.write_text_bytes(line.as_bytes())?;
tally.total_matches = tally.total_matches.saturating_add(matches);
return Ok(1);
}
if args.count && matches > 0 && !crate::output::limit_reached() {
let line = if text.show_path {
format!("{}:{matches}\n", text.path_label(&path))
} else {
format!("{matches}\n")
};
writer.write_text_bytes(line.as_bytes())?;
tally.total_matches = tally.total_matches.saturating_add(matches);
return Ok(1);
}
Ok(0)
}
}
}
impl TextLayout {
fn write_line(
&mut self,
writer: &mut NdjsonWriter<impl Write>,
args: &SearchArgs,
path: &std::path::Path,
line_number: u64,
bytes: &[u8],
separator: u8,
) -> Result<()> {
let new_path = self.current_path.as_deref() != Some(path);
let skipped = self
.last_line
.is_none_or(|last| line_number > last.saturating_add(1));
if args.context > 0 && self.in_group && (new_path || skipped) {
writer.write_text_bytes(b"--\n")?;
}
let mut line: Vec<u8> = Vec::with_capacity(bytes.len() + 32);
if self.show_path {
line.extend_from_slice(self.path_label(path).as_bytes());
line.push(separator);
}
line.extend_from_slice(line_number.to_string().as_bytes());
line.push(separator);
line.extend_from_slice(bytes);
line.push(b'\n');
writer.write_text_bytes(&line)?;
self.current_path = Some(path.to_path_buf());
self.last_line = Some(line_number);
self.in_group = true;
Ok(())
}
}
fn text_needs_path(args: &SearchArgs, workspace: &std::path::Path) -> bool {
if args.paths.len() != 1 {
return true;
}
let only = args.paths[0].clone();
let resolved = if only.is_absolute() {
only
} else {
workspace.join(only)
};
!resolved.is_file()
}
fn build_matcher(args: &SearchArgs) -> Result<grep_regex::RegexMatcher> {
let mut builder = RegexMatcherBuilder::new();
if args.case_insensitive {
builder.case_insensitive(true);
}
if args.smart_case {
builder.case_smart(true);
}
if args.word {
builder.word(true);
}
if args.multiline {
builder.multi_line(true);
}
if args.fixed {
builder.fixed_strings(true);
}
builder.build(&args.pattern).map_err(|e| {
crate::error::AtomwriteError::invalid(
&format!("invalid search pattern '{}'", args.pattern),
e,
)
.into()
})
}
fn build_walker(
args: &SearchArgs,
canonical_paths: &[std::path::PathBuf],
global: &GlobalArgs,
) -> Result<ignore::WalkBuilder> {
let mut builder =
crate::commands::build_scoped_walker(canonical_paths, global, &args.include, &args.exclude)?;
if let Some(SortBy::Path) = &args.sort {
builder.sort_by_file_path(|a, b| a.cmp(b));
}
Ok(builder)
}
fn extract_submatches(matcher: &grep_regex::RegexMatcher, line: &str) -> Vec<Submatch> {
let mut subs = Vec::with_capacity(crate::constants::SEARCH_SUBMATCHES_CAPACITY);
crate::best_effort::best_effort(
matcher.find_iter(line.as_bytes(), |m| {
let matched_text = &line[m.start()..m.end()];
subs.push(Submatch {
r#match: matched_text.to_owned(),
start: m.start(),
end: m.end(),
});
true
}),
"collect submatch offsets",
);
subs
}
fn root_failures(missing: &[std::path::PathBuf]) -> FailureTally {
let mut tally = FailureTally::default();
tally.record_missing_roots(missing);
tally
}
fn finish_search_run(
writer: &mut NdjsonWriter<impl Write>,
base: Summary,
failures: FailureTally,
roots_missing: u64,
answered: bool,
text_mode: bool,
) -> Result<()> {
let (summary, first_failure) = failures.into_summary(base);
if text_mode {
if let Some(errors) = &summary.errors {
for failure in errors {
crate::best_effort::best_effort(
writeln!(
std::io::stderr().lock(),
"atomwrite: {}: {}",
failure.path,
failure.message
),
"report search failure on stderr",
);
}
}
if roots_missing > 0
&& let Some(err) = first_failure
{
return Err(err.context(crate::output::VerdictPublished));
}
if answered {
return Ok(());
}
if let Some(err) = first_failure {
return Err(err.context(crate::output::VerdictPublished));
}
return Err(crate::error::AtomwriteError::NoMatches.into());
}
if roots_missing > 0
&& let Some(err) = first_failure
{
return Err(writer.publish_failure(&summary, err));
}
if answered {
writer.write_terminal_if_accepted(&summary)?;
return Ok(());
}
if let Some(err) = first_failure {
return Err(writer.publish_failure(&summary, err));
}
Err(writer.publish_failure(&summary, crate::error::AtomwriteError::NoMatches.into()))
}