feat(find): add only matching output flag (#19150)

## Description

`find` can filter and highlight matches, but it did not have a way to
return only the matched text. This makes it harder to replace simple
`grep -o` style workflows with Nushell.

This change adds `find --only-matching` with short flag `-o`. It returns
each non-empty match as a separate value and supports the existing
matching modes, including regex, ignore case, multiline, and `--rfind`.
`--invert` is rejected with `--only-matching` because inverted output
has no clear matched substring to return.

## User-facing changes (Release notes)

Added `find --only-matching` and `find -o` to return only matched text,
similar to `grep -o`.

## Additional notes

Fixes #18982

Tests:
- `cargo test -p nu-command filters::find::tests::test_examples`
This commit is contained in:
Akshit Joshi authored and GitHub committed 2026-10-07 06:53:04 -05:00
1 parent 549e72d518
commit b14e65b77d
1 file changed
+308
+308
View File
@@ -56,6 +56,11 @@ impl Command for Find {
Some('n'),
)
.switch("invert", "Invert the match.", Some('v'))
.switch(
"only-matching",
"Print only the matched parts, with each match on a separate line.",
Some('o'),
)
.switch(
"rfind",
"Search from the end of the string and only return the first match.",
@@ -245,6 +250,34 @@ impl Command for Find {
"\u{1b}[39mhello world \u{1b}[0m\u{1b}[41;39mhello\u{1b}[0m\u{1b}[39m\u{1b}[0m",
)),
},
Example {
description: "Return only matching parts of a string.",
example: "'abc abc' | find --only-matching ab",
result: Some(Value::test_list(vec![
Value::test_string("ab"),
Value::test_string("ab"),
])),
},
Example {
description: "Return only the last matching part of a string.",
example: "'abc abc' | find --only-matching --rfind ab",
result: Some(Value::test_list(vec![Value::test_string("ab")])),
},
Example {
description: "Return only matching parts while preserving original case.",
example: "'ABC' | find --only-matching --ignore-case a",
result: Some(Value::test_list(vec![Value::test_string("A")])),
},
Example {
description: "Return only exact scalar matches for search terms.",
example: "[5 35] | find --only-matching 5",
result: Some(Value::test_list(vec![Value::test_string("5")])),
},
Example {
description: "Return only matches from selected columns of a table.",
example: "[[name desc]; [foo bar]] | find --only-matching --columns [name] bar",
result: Some(Value::test_list(vec![])),
},
]
}
@@ -302,6 +335,9 @@ struct MatchPattern {
/// return the values that aren't a match instead
invert: bool,
/// return only matched substrings
only_matching: bool,
/// search from the end (find last occurrence)
rfind: bool,
@@ -325,12 +361,24 @@ fn get_match_pattern_from_arguments(
let invert = call.has_flag(engine_state, stack, "invert")?;
let highlight = !call.has_flag(engine_state, stack, "no-highlight")?;
let only_matching = call.has_flag(engine_state, stack, "only-matching")?;
let rfind = call.has_flag(engine_state, stack, "rfind")?;
let ignore_case = call.has_flag(engine_state, stack, "ignore-case")?;
let dotall = call.has_flag(engine_state, stack, "dotall")?;
if invert && only_matching {
return Err(ShellError::IncompatibleParameters {
left_message: "inverts matches".into(),
left_span: call.get_flag_span(stack, "invert").expect("has flag"),
right_message: "returns matches".into(),
right_span: call
.get_flag_span(stack, "only-matching")
.expect("has flag"),
});
}
let style_computer = StyleComputer::from_config(engine_state, stack);
// Currently, search results all use the same style.
// Also note that this sample string is passed into user-written code (the closure that may or may not be
@@ -401,6 +449,7 @@ fn get_match_pattern_from_arguments(
search_terms,
ignore_case,
invert,
only_matching,
highlight,
rfind,
string_style,
@@ -524,6 +573,16 @@ fn find_in_pipelinedata(
) -> Result<PipelineData, ShellError> {
let config = stack.get_config(engine_state);
if pattern.only_matching {
return find_only_matching_in_pipelinedata(
pattern,
columns_to_search,
engine_state,
input,
&config,
);
}
let map_pattern = pattern.clone();
let map_columns_to_search = columns_to_search.clone();
@@ -575,6 +634,145 @@ fn find_in_pipelinedata(
}
}
fn find_only_matching_in_pipelinedata(
pattern: MatchPattern,
columns_to_search: Vec<String>,
engine_state: &EngineState,
input: PipelineData,
config: &Config,
) -> Result<PipelineData, ShellError> {
// Byte streams are split into lines so each line is matched like a string,
// mirroring the plain `find` byte-stream arm. Every other input shape is
// delegated to `PipelineData::flat_map`, which already walks lists, ranges,
// iterable custom values, preserves metadata and honors ctrl-c.
if let PipelineData::ByteStream(stream, ..) = input {
let span = stream.span();
let Some(lines) = stream.lines() else {
return Ok(PipelineData::empty());
};
let mut output = vec![];
for line in lines {
let line = line?;
output.extend(only_matching_matches_in_string(&pattern, &line, span));
}
return Ok(Value::list(output, span).into_pipeline_data());
}
// A top-level error should flow through unchanged, just like plain `find`,
// rather than being wrapped in a one-element list.
if let PipelineData::Value(value, _) = &input
&& value.is_error()
{
return Ok(input);
}
let config = config.clone();
input.flat_map(
move |value| only_matching_matches_in_value(&pattern, value, &columns_to_search, &config),
engine_state.signals(),
)
}
fn only_matching_matches_in_value(
pattern: &MatchPattern,
value: Value,
columns_to_search: &[String],
config: &Config,
) -> Vec<Value> {
let span = value.span();
match value {
Value::String { val, .. } => only_matching_matches_in_string(pattern, &val, span),
Value::List { vals, .. } => vals
.into_iter()
.flat_map(|item| only_matching_matches_in_value(pattern, item, &[], config))
.collect(),
Value::Record { val: record, .. } => {
let col_select = !columns_to_search.is_empty();
record
.into_owned()
.into_iter()
.filter(|(col, _)| !col_select || columns_to_search.contains(col))
.flat_map(|(_, val)| only_matching_matches_in_value(pattern, val, &[], config))
.collect()
}
Value::Bool { .. }
| Value::Int { .. }
| Value::Filesize { .. }
| Value::Duration { .. }
| Value::Date { .. }
| Value::Range { .. }
| Value::Float { .. }
| Value::Closure { .. }
| Value::Nothing { .. } => only_matching_matches_in_scalar(pattern, value, config),
Value::Binary { .. } => Vec::new(),
Value::Error { .. } => vec![value],
// `to_expanded_string` is only computed here (and in the scalar arm) so
// string/list/record values don't pay for a rendering they never use.
_ => only_matching_matches_in_string(pattern, &value.to_expanded_string("", config), span),
}
}
fn only_matching_matches_in_scalar(
pattern: &MatchPattern,
value: Value,
config: &Config,
) -> Vec<Value> {
let span = value.span();
let value_as_string = value.to_expanded_string("", config);
// Reuse `value_should_be_printed` for the exact-match rule so `find` and
// `find -o` agree on what counts as a scalar match.
if !pattern.search_terms.is_empty() {
return if value_should_be_printed(pattern, &value, &[], config) {
vec![Value::string(value_as_string, span)]
} else {
Vec::new()
};
}
only_matching_matches_in_string(pattern, &value_as_string, span)
}
fn only_matching_matches_in_string(pattern: &MatchPattern, text: &str, span: Span) -> Vec<Value> {
// Build the match iterator once; `rfind` keeps the last forward match
// (matching `highlight_last_match`), otherwise all non-empty matches.
let mut matches = Vec::new();
for found in pattern.regex.find_iter(text) {
match found {
Ok(m) if !m.as_str().is_empty() => matches.push(m.as_str().to_string()),
Ok(_) => {}
// Surface regex runtime errors (e.g. backtrack-limit exceeded) as an
// error value instead of silently returning a short or empty list.
Err(err) => {
return vec![Value::error(
ShellError::Generic(
nu_protocol::shell_error::generic::GenericError::new_internal(
"Regex error while matching",
err.to_string(),
),
),
span,
)];
}
}
}
if pattern.rfind {
matches
.pop()
.map(|m| vec![Value::string(m, span)])
.unwrap_or_default()
} else {
matches
.into_iter()
.map(|m| Value::string(m, span))
.collect()
}
}
// filter functions
fn string_should_be_printed(pattern: &MatchPattern, value: &str) -> bool {
@@ -679,6 +877,7 @@ pub fn find_internal(
ignore_case: true,
highlight,
invert: false,
only_matching: false,
rfind: false,
string_style,
highlight_style,
@@ -700,4 +899,113 @@ mod tests {
fn test_examples() -> nu_test_support::Result {
nu_test_support::test().examples(Find)
}
#[test]
fn only_matching_preserves_error_values() {
let pattern = MatchPattern {
regex: Regex::new("needle").expect("valid regex"),
search_terms: vec!["needle".to_string()],
ignore_case: false,
highlight: true,
invert: false,
only_matching: true,
rfind: false,
string_style: Style::new(),
highlight_style: Style::new(),
};
let error = Value::error(
ShellError::Generic(
nu_protocol::shell_error::generic::GenericError::new_internal(
"test error",
"test error",
),
),
Span::test_data(),
);
let matches = only_matching_matches_in_value(&pattern, error, &[], &Config::default());
assert!(matches[0].is_error());
}
#[test]
fn only_matching_preserves_original_case_for_globs() {
let pattern = MatchPattern {
regex: Regex::new("(?i)a").expect("valid regex"),
search_terms: vec!["a".to_string()],
ignore_case: true,
highlight: true,
invert: false,
only_matching: true,
rfind: false,
string_style: Style::new(),
highlight_style: Style::new(),
};
let matches = only_matching_matches_in_value(
&pattern,
Value::test_glob("ABC"),
&[],
&Config::default(),
);
assert_eq!(matches, vec![Value::test_string("A")]);
}
#[test]
fn only_matching_scalar_uses_exact_search_term_match() {
let pattern = MatchPattern {
regex: Regex::new("5").expect("valid regex"),
search_terms: vec!["5".to_string()],
ignore_case: false,
highlight: true,
invert: false,
only_matching: true,
rfind: false,
string_style: Style::new(),
highlight_style: Style::new(),
};
// An exact scalar match (e.g. an expanded range element) is returned.
assert_eq!(
only_matching_matches_in_value(&pattern, Value::test_int(5), &[], &Config::default()),
vec![Value::test_string("5")]
);
// A substring like 35 is not an exact scalar match, so nothing is returned.
assert!(
only_matching_matches_in_value(&pattern, Value::test_int(35), &[], &Config::default())
.is_empty()
);
}
#[test]
fn only_matching_respects_selected_columns_on_record() {
let pattern = MatchPattern {
regex: Regex::new("bar").expect("valid regex"),
search_terms: vec!["bar".to_string()],
ignore_case: false,
highlight: true,
invert: false,
only_matching: true,
rfind: false,
string_style: Style::new(),
highlight_style: Style::new(),
};
let record = Value::test_record(nu_protocol::record! {
"name" => Value::test_string("foo"),
"desc" => Value::test_string("bar"),
});
// Only the `name` column is searched, so the match in `desc` is skipped.
assert!(
only_matching_matches_in_value(
&pattern,
record,
&["name".to_string()],
&Config::default()
)
.is_empty()
);
}
}