mirror of
https://github.com/nushell/nushell.git
synced 2026-10-28 17:05:03 +08:00
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:
1 parent
549e72d518
commit
b14e65b77d
1 file changed
+308
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user