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 == Some(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 parts = if args.paths.is_empty() {
vec![workspace.display().to_string()]
} else {
resolved
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
};
writer.set_targets(parts, 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,
submatches,
..
} => {
*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);
}
let column = submatches.first().map(|sub| sub.start as u64 + 1);
text.write_line(writer, args, &path, (line_number, column), &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, None), 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,
position: (u64, Option<u64>),
bytes: &[u8],
separator: u8,
) -> Result<()> {
let (line_number, column) = position;
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);
if let Some(column) = column {
line.extend_from_slice(column.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 search_patterns(args: &SearchArgs) -> Vec<String> {
let mut patterns = Vec::with_capacity(args.regex.len() + 1);
if let Some(positional) = args.pattern.as_deref().filter(|p| !p.is_empty()) {
patterns.push(positional.to_string());
}
patterns.extend(args.regex.iter().filter(|p| !p.is_empty()).cloned());
patterns
}
fn validate_patterns(args: &SearchArgs) -> Result<()> {
if search_patterns(args).is_empty() {
return Err(crate::error::AtomwriteError::MissingFlag {
flag: "-e/--regex".to_string(),
reason: "search needs at least one pattern: pass it positionally or with -e/--regex PATTERN"
.to_string(),
}
.into());
}
Ok(())
}
fn build_matcher(args: &SearchArgs) -> Result<grep_regex::RegexMatcher> {
let patterns = search_patterns(args);
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_many(&patterns).map_err(|e| {
crate::error::AtomwriteError::invalid(
&format!("invalid search pattern '{}'", patterns.join("', '")),
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 = matched_fragment(line, m.start(), m.end());
subs.push(Submatch {
r#match: matched_text,
start: m.start(),
end: m.end(),
});
true
}),
"collect submatch offsets",
);
subs
}
fn matched_fragment(line: &str, start: usize, end: usize) -> String {
String::from_utf8_lossy(line.as_bytes().get(start..end).unwrap_or(&[])).into_owned()
}
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 let Some(hint) = &summary.base.hint {
crate::best_effort::best_effort(
writeln!(std::io::stderr().lock(), "atomwrite: {hint}"),
"report the --limit cut 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()))
}