Change the behavior of --ignore-case and --multiline options for find (#16323)

# Description

Changes the behavior of `--ignore-case` and `--multiline` options for
`find`, to make them more consistent between regex mode and search term
mode, and to enable more options for using find.

# User-Facing Changes

Search term mode is now case-sensitive by default.

`--ignore-case` will make the search case-insensitive in search term
mode. In regex mode, the previous behavior of adding a (?i) flag to the
regex is preserved.

`--multiline` will no longer add a (?m) flag in regex mode. Instead, it
will make the search not split multi-line strings into lists of lines.

closes #16317
closes #16022
This commit is contained in:
new-years-eve
2025-08-04 16:27:00 +02:00
committed by GitHub
parent d528bb713b
commit 4f9c0775d9
3 changed files with 109 additions and 51 deletions

View File

@ -31,12 +31,12 @@ impl Command for Find {
) )
.switch( .switch(
"ignore-case", "ignore-case",
"case-insensitive regex mode; equivalent to (?i)", "case-insensitive; when in regex mode, this is equivalent to (?i)",
Some('i'), Some('i'),
) )
.switch( .switch(
"multiline", "multiline",
"multi-line regex mode: ^ and $ match begin/end of line; equivalent to (?m)", "don't split multi-line strings into lists of lines. you should use this option when using the (?m) or (?s) flags in regex mode",
Some('m'), Some('m'),
) )
.switch( .switch(
@ -72,8 +72,8 @@ impl Command for Find {
result: None, result: None,
}, },
Example { Example {
description: "Search and highlight text for a term in a string. Note that regular search is case insensitive", description: "Search and highlight text for a term in a string.",
example: r#"'Cargo.toml' | find cargo"#, example: r#"'Cargo.toml' | find Cargo"#,
result: Some(Value::test_string( result: Some(Value::test_string(
"\u{1b}[37m\u{1b}[0m\u{1b}[41;37mCargo\u{1b}[0m\u{1b}[37m.toml\u{1b}[0m" "\u{1b}[37m\u{1b}[0m\u{1b}[41;37mCargo\u{1b}[0m\u{1b}[37m.toml\u{1b}[0m"
.to_owned(), .to_owned(),
@ -81,7 +81,7 @@ impl Command for Find {
}, },
Example { Example {
description: "Search a number or a file size in a list of numbers", description: "Search a number or a file size in a list of numbers",
example: r#"[1 5 3kb 4 3Mb] | find 5 3kb"#, example: r#"[1 5 3kb 4 35 3Mb] | find 5 3kb"#,
result: Some(Value::list( result: Some(Value::list(
vec![Value::test_int(5), Value::test_filesize(3000)], vec![Value::test_int(5), Value::test_filesize(3000)],
Span::test_data(), Span::test_data(),
@ -103,16 +103,16 @@ impl Command for Find {
)), )),
}, },
Example { Example {
description: "Find using regex", description: "Search using regex",
example: r#"[abc bde arc abf] | find --regex "ab""#, example: r#"[abc odb arc abf] | find --regex "b.""#,
result: Some(Value::list( result: Some(Value::list(
vec![ vec![
Value::test_string( Value::test_string(
"\u{1b}[37m\u{1b}[0m\u{1b}[41;37mab\u{1b}[0m\u{1b}[37mc\u{1b}[0m" "\u{1b}[37ma\u{1b}[0m\u{1b}[41;37mbc\u{1b}[0m\u{1b}[37m\u{1b}[0m"
.to_string(), .to_string(),
), ),
Value::test_string( Value::test_string(
"\u{1b}[37m\u{1b}[0m\u{1b}[41;37mab\u{1b}[0m\u{1b}[37mf\u{1b}[0m" "\u{1b}[37ma\u{1b}[0m\u{1b}[41;37mbf\u{1b}[0m\u{1b}[37m\u{1b}[0m"
.to_string(), .to_string(),
), ),
], ],
@ -120,8 +120,8 @@ impl Command for Find {
)), )),
}, },
Example { Example {
description: "Find using regex case insensitive", description: "Case insensitive search",
example: r#"[aBc bde Arc abf] | find --regex "ab" -i"#, example: r#"[aBc bde Arc abf] | find "ab" -i"#,
result: Some(Value::list( result: Some(Value::list(
vec![ vec![
Value::test_string( Value::test_string(
@ -211,11 +211,33 @@ impl Command for Find {
Span::test_data(), Span::test_data(),
)), )),
}, },
Example {
description: "Find in a multi-line string",
example: r#""Violets are red\nAnd roses are blue\nWhen metamaterials\nAlter their hue" | find "ue""#,
result: Some(Value::list(
vec![
Value::test_string(
"\u{1b}[37mAnd roses are bl\u{1b}[0m\u{1b}[41;37mue\u{1b}[0m\u{1b}[37m\u{1b}[0m",
),
Value::test_string(
"\u{1b}[37mAlter their h\u{1b}[0m\u{1b}[41;37mue\u{1b}[0m\u{1b}[37m\u{1b}[0m",
),
],
Span::test_data(),
)),
},
Example {
description: "Find in a multi-line string without splitting the input into a list of lines",
example: r#""Violets are red\nAnd roses are blue\nWhen metamaterials\nAlter their hue" | find --multiline "ue""#,
result: Some(Value::test_string(
"\u{1b}[37mViolets are red\nAnd roses are bl\u{1b}[0m\u{1b}[41;37mue\u{1b}[0m\u{1b}[37m\nWhen metamaterials\nAlter their h\u{1b}[0m\u{1b}[41;37mue\u{1b}[0m\u{1b}[37m\u{1b}[0m",
)),
},
] ]
} }
fn search_terms(&self) -> Vec<&str> { fn search_terms(&self) -> Vec<&str> {
vec!["filter", "regex", "search", "condition"] vec!["filter", "regex", "search", "condition", "grep"]
} }
fn run( fn run(
@ -227,11 +249,25 @@ impl Command for Find {
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let pattern = get_match_pattern_from_arguments(engine_state, stack, call)?; let pattern = get_match_pattern_from_arguments(engine_state, stack, call)?;
let multiline = call.has_flag(engine_state, stack, "multiline")?;
let columns_to_search: Vec<_> = call let columns_to_search: Vec<_> = call
.get_flag(engine_state, stack, "columns")? .get_flag(engine_state, stack, "columns")?
.unwrap_or_default(); .unwrap_or_default();
let input = split_string_if_multiline(input, call.head); let input = if multiline {
if let PipelineData::ByteStream(..) = input {
// ByteStream inputs are processed by iterating over the lines, which necessarily
// breaks the multi-line text being streamed into a list of lines.
return Err(ShellError::IncompatibleParametersSingle {
msg: "Flag `--multiline` currently doesn't work for byte stream inputs. Consider using `collect`".into(),
span: call.get_flag_span(stack, "multiline").expect("has flag"),
});
};
input
} else {
split_string_if_multiline(input, call.head)
};
find_in_pipelinedata(pattern, columns_to_search, engine_state, stack, input) find_in_pipelinedata(pattern, columns_to_search, engine_state, stack, input)
} }
@ -242,8 +278,11 @@ struct MatchPattern {
/// the regex to be used for matching in text /// the regex to be used for matching in text
regex: Regex, regex: Regex,
/// the list of match terms converted to lowercase strings, or empty if a regex was provided /// the list of match terms (converted to lowercase if needed), or empty if a regex was provided
lower_terms: Vec<String>, search_terms: Vec<String>,
/// case-insensitive match
ignore_case: bool,
/// return a modified version of the value where matching parts are highlighted /// return a modified version of the value where matching parts are highlighted
highlight: bool, highlight: bool,
@ -272,6 +311,10 @@ fn get_match_pattern_from_arguments(
let invert = call.has_flag(engine_state, stack, "invert")?; let invert = call.has_flag(engine_state, stack, "invert")?;
let highlight = !call.has_flag(engine_state, stack, "no-highlight")?; let highlight = !call.has_flag(engine_state, stack, "no-highlight")?;
let ignore_case = call.has_flag(engine_state, stack, "ignore-case")?;
let dotall = call.has_flag(engine_state, stack, "dotall")?;
let style_computer = StyleComputer::from_config(engine_state, stack); let style_computer = StyleComputer::from_config(engine_state, stack);
// Currently, search results all use the same style. // 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 // Also note that this sample string is passed into user-written code (the closure that may or may not be
@ -280,7 +323,7 @@ fn get_match_pattern_from_arguments(
let highlight_style = let highlight_style =
style_computer.compute("search_result", &Value::string("search result", span)); style_computer.compute("search_result", &Value::string("search result", span));
let (regex_str, lower_terms) = if let Some(regex) = regex { let (regex_str, search_terms) = if let Some(regex) = regex {
if !terms.is_empty() { if !terms.is_empty() {
return Err(ShellError::IncompatibleParametersSingle { return Err(ShellError::IncompatibleParametersSingle {
msg: "Cannot use a `--regex` parameter with additional search terms".into(), msg: "Cannot use a `--regex` parameter with additional search terms".into(),
@ -288,47 +331,54 @@ fn get_match_pattern_from_arguments(
}); });
} }
let insensitive = call.has_flag(engine_state, stack, "ignore-case")?; let flags = match (ignore_case, dotall) {
let multiline = call.has_flag(engine_state, stack, "multiline")?; (false, false) => "",
let dotall = call.has_flag(engine_state, stack, "dotall")?; (true, false) => "(?i)", // case insensitive
(false, true) => "(?s)", // allow . to match \n
let flags = match (insensitive, multiline, dotall) { (true, true) => "(?is)", // case insensitive and allow . to match \n
(false, false, false) => "",
(true, false, false) => "(?i)", // case insensitive
(false, true, false) => "(?m)", // multi-line mode
(false, false, true) => "(?s)", // allow . to match \n
(true, true, false) => "(?im)", // case insensitive and multi-line mode
(true, false, true) => "(?is)", // case insensitive and allow . to match \n
(false, true, true) => "(?ms)", // multi-line mode and allow . to match \n
(true, true, true) => "(?ims)", // case insensitive, multi-line mode and allow . to match \n
}; };
(flags.to_string() + regex.as_str(), Vec::new()) (flags.to_string() + regex.as_str(), Vec::new())
} else { } else {
if dotall {
return Err(ShellError::IncompatibleParametersSingle {
msg: "Flag --dotall only works for regex search".into(),
span: call.get_flag_span(stack, "dotall").expect("has flag"),
});
}
let mut regex = String::new(); let mut regex = String::new();
regex += "(?i)"; if ignore_case {
regex += "(?i)";
}
let lower_terms = terms let search_terms = terms
.iter() .iter()
.map(|v| escape(&v.to_expanded_string("", &config).to_lowercase()).into()) .map(|v| {
if ignore_case {
v.to_expanded_string("", &config).to_lowercase()
} else {
v.to_expanded_string("", &config)
}
})
.collect::<Vec<String>>(); .collect::<Vec<String>>();
if let Some(term) = lower_terms.first() { let escaped_terms = search_terms
.iter()
.map(|v| escape(v).into())
.collect::<Vec<String>>();
if let Some(term) = escaped_terms.first() {
regex += term; regex += term;
} }
for term in lower_terms.iter().skip(1) { for term in escaped_terms.iter().skip(1) {
regex += "|"; regex += "|";
regex += term; regex += term;
} }
let lower_terms = terms (regex, search_terms)
.iter()
.map(|v| v.to_expanded_string("", &config).to_lowercase())
.collect::<Vec<String>>();
(regex, lower_terms)
}; };
let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch { let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch {
@ -338,7 +388,8 @@ fn get_match_pattern_from_arguments(
Ok(MatchPattern { Ok(MatchPattern {
regex, regex,
lower_terms, search_terms,
ignore_case,
invert, invert,
highlight, highlight,
string_style, string_style,
@ -507,7 +558,11 @@ fn value_should_be_printed(
columns_to_search: &[String], columns_to_search: &[String],
config: &Config, config: &Config,
) -> bool { ) -> bool {
let lower_value = value.to_expanded_string("", config).to_lowercase(); let value_as_string = if pattern.ignore_case {
value.to_expanded_string("", config).to_lowercase()
} else {
value.to_expanded_string("", config)
};
match value { match value {
Value::Bool { .. } Value::Bool { .. }
@ -519,18 +574,18 @@ fn value_should_be_printed(
| Value::Float { .. } | Value::Float { .. }
| Value::Closure { .. } | Value::Closure { .. }
| Value::Nothing { .. } => { | Value::Nothing { .. } => {
if !pattern.lower_terms.is_empty() { if !pattern.search_terms.is_empty() {
// look for exact match when searching with terms // look for exact match when searching with terms
pattern pattern
.lower_terms .search_terms
.iter() .iter()
.any(|term: &String| term == &lower_value) .any(|term: &String| term == &value_as_string)
} else { } else {
string_should_be_printed(pattern, &lower_value) string_should_be_printed(pattern, &value_as_string)
} }
} }
Value::Glob { .. } | Value::CellPath { .. } | Value::Custom { .. } => { Value::Glob { .. } | Value::CellPath { .. } | Value::Custom { .. } => {
string_should_be_printed(pattern, &lower_value) string_should_be_printed(pattern, &value_as_string)
} }
Value::String { val, .. } => string_should_be_printed(pattern, val), Value::String { val, .. } => string_should_be_printed(pattern, val),
Value::List { vals, .. } => vals Value::List { vals, .. } => vals
@ -597,7 +652,8 @@ pub fn find_internal(
let pattern = MatchPattern { let pattern = MatchPattern {
regex, regex,
lower_terms: vec![search_term.to_lowercase()], search_terms: vec![search_term.to_lowercase()],
ignore_case: true,
highlight, highlight,
invert: false, invert: false,
string_style, string_style,

View File

@ -26,10 +26,11 @@ fn find_with_list_search_with_char() {
#[test] #[test]
fn find_with_bytestream_search_with_char() { fn find_with_bytestream_search_with_char() {
let actual = let actual = nu!(
nu!("\"ABC\" | save foo.txt; let res = open foo.txt | find abc; rm foo.txt; $res | get 0"); "\"ABC\" | save foo.txt; let res = open foo.txt | find -i abc; rm foo.txt; $res | get 0"
);
let actual_no_highlight = nu!( let actual_no_highlight = nu!(
"\"ABC\" | save foo.txt; let res = open foo.txt | find --no-highlight abc; rm foo.txt; $res | get 0" "\"ABC\" | save foo.txt; let res = open foo.txt | find -i --no-highlight abc; rm foo.txt; $res | get 0"
); );
assert_eq!( assert_eq!(

View File

@ -19,6 +19,7 @@ extend-ignore-re = [
"0x\\[ba be\\]", "0x\\[ba be\\]",
"\\)BaR'", "\\)BaR'",
"fo<66>.txt", "fo<66>.txt",
"ue",
] ]
[type.rust.extend-words] [type.rust.extend-words]