1use fancy_regex::{Regex, escape};
2use nu_ansi_term::Style;
3use nu_color_config::StyleComputer;
4use nu_engine::command_prelude::*;
5use nu_protocol::Config;
6
7#[derive(Clone)]
8pub struct Find;
9
10impl Command for Find {
11 fn name(&self) -> &str {
12 "find"
13 }
14
15 fn signature(&self) -> Signature {
16 Signature::build(self.name())
17 .input_output_types(vec![
18 (
19 Type::List(Box::new(Type::Any)),
22 Type::List(Box::new(Type::Any)),
23 ),
24 (Type::String, Type::Any),
25 ])
26 .named(
27 "regex",
28 SyntaxShape::String,
29 "Regex to match with.",
30 Some('r'),
31 )
32 .switch(
33 "ignore-case",
34 "Case-insensitive; when in regex mode, this is equivalent to (?i).",
35 Some('i'),
36 )
37 .switch(
38 "multiline",
39 "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.",
40 Some('m'),
41 )
42 .switch(
43 "dotall",
44 "Dotall regex mode: allow a dot . to match newlines \\n; equivalent to (?s).",
45 Some('s'),
46 )
47 .named(
48 "columns",
49 SyntaxShape::List(Box::new(SyntaxShape::String)),
50 "Column names to be searched.",
51 Some('c'),
52 )
53 .switch(
54 "no-highlight",
55 "No-highlight mode: find without marking with ansi code.",
56 Some('n'),
57 )
58 .switch("invert", "Invert the match.", Some('v'))
59 .switch(
60 "rfind",
61 "Search from the end of the string and only return the first match.",
62 Some('R'),
63 )
64 .rest("rest", SyntaxShape::Any, "Terms to search.")
65 .category(Category::Filters)
66 }
67
68 fn description(&self) -> &str {
69 "Search for terms in the input data."
70 }
71
72 fn examples(&self) -> Vec<Example<'_>> {
73 vec![
74 Example {
75 description: "Search for multiple terms in a command output.",
76 example: "ls | find toml md sh",
77 result: None,
78 },
79 Example {
80 description: "Search and highlight text for a term in a string.",
81 example: "'Cargo.toml' | find Cargo",
82 result: Some(Value::test_string(
83 "\u{1b}[39m\u{1b}[0m\u{1b}[41;39mCargo\u{1b}[0m\u{1b}[39m.toml\u{1b}[0m"
84 .to_owned(),
85 )),
86 },
87 Example {
88 description: "Search a number or a file size in a list of numbers.",
89 example: "[1 5 3kb 4 35 3Mb] | find 5 3kb",
90 result: Some(Value::list(
91 vec![Value::test_int(5), Value::test_filesize(3000)],
92 Span::test_data(),
93 )),
94 },
95 Example {
96 description: "Search a char in a list of string.",
97 example: "[moe larry curly] | find l",
98 result: Some(Value::list(
99 vec![
100 Value::test_string(
101 "\u{1b}[39m\u{1b}[0m\u{1b}[41;39ml\u{1b}[0m\u{1b}[39marry\u{1b}[0m",
102 ),
103 Value::test_string(
104 "\u{1b}[39mcur\u{1b}[0m\u{1b}[41;39ml\u{1b}[0m\u{1b}[39my\u{1b}[0m",
105 ),
106 ],
107 Span::test_data(),
108 )),
109 },
110 Example {
111 description: "Search using regex.",
112 example: r#"[abc odb arc abf] | find --regex "b.""#,
113 result: Some(Value::list(
114 vec![
115 Value::test_string(
116 "\u{1b}[39ma\u{1b}[0m\u{1b}[41;39mbc\u{1b}[0m\u{1b}[39m\u{1b}[0m"
117 .to_string(),
118 ),
119 Value::test_string(
120 "\u{1b}[39ma\u{1b}[0m\u{1b}[41;39mbf\u{1b}[0m\u{1b}[39m\u{1b}[0m"
121 .to_string(),
122 ),
123 ],
124 Span::test_data(),
125 )),
126 },
127 Example {
128 description: "Case insensitive search.",
129 example: r#"[aBc bde Arc abf] | find "ab" -i"#,
130 result: Some(Value::list(
131 vec![
132 Value::test_string(
133 "\u{1b}[39m\u{1b}[0m\u{1b}[41;39maB\u{1b}[0m\u{1b}[39mc\u{1b}[0m"
134 .to_string(),
135 ),
136 Value::test_string(
137 "\u{1b}[39m\u{1b}[0m\u{1b}[41;39mab\u{1b}[0m\u{1b}[39mf\u{1b}[0m"
138 .to_string(),
139 ),
140 ],
141 Span::test_data(),
142 )),
143 },
144 Example {
145 description: "Find value in records using regex.",
146 example: r#"[[version name]; ['0.1.0' nushell] ['0.1.1' fish] ['0.2.0' zsh]] | find --regex "nu""#,
147 result: Some(Value::test_list(vec![Value::test_record(record! {
148 "version" => Value::test_string("0.1.0"),
149 "name" => Value::test_string("\u{1b}[39m\u{1b}[0m\u{1b}[41;39mnu\u{1b}[0m\u{1b}[39mshell\u{1b}[0m".to_string()),
150 })])),
151 },
152 Example {
153 description: "Find inverted values in records using regex.",
154 example: r#"[[version name]; ['0.1.0' nushell] ['0.1.1' fish] ['0.2.0' zsh]] | find --regex "nu" --invert"#,
155 result: Some(Value::test_list(vec![
156 Value::test_record(record! {
157 "version" => Value::test_string("0.1.1"),
158 "name" => Value::test_string("fish".to_string()),
159 }),
160 Value::test_record(record! {
161 "version" => Value::test_string("0.2.0"),
162 "name" =>Value::test_string("zsh".to_string()),
163 }),
164 ])),
165 },
166 Example {
167 description: "Find value in list using regex.",
168 example: r#"[["Larry", "Moe"], ["Victor", "Marina"]] | find --regex "rr""#,
169 result: Some(Value::list(
170 vec![Value::list(
171 vec![
172 Value::test_string(
173 "\u{1b}[39mLa\u{1b}[0m\u{1b}[41;39mrr\u{1b}[0m\u{1b}[39my\u{1b}[0m",
174 ),
175 Value::test_string("Moe"),
176 ],
177 Span::test_data(),
178 )],
179 Span::test_data(),
180 )),
181 },
182 Example {
183 description: "Find inverted values in records using regex.",
184 example: r#"[["Larry", "Moe"], ["Victor", "Marina"]] | find --regex "rr" --invert"#,
185 result: Some(Value::list(
186 vec![Value::list(
187 vec![Value::test_string("Victor"), Value::test_string("Marina")],
188 Span::test_data(),
189 )],
190 Span::test_data(),
191 )),
192 },
193 Example {
194 description: "Remove ANSI sequences from result.",
195 example: "[[foo bar]; [abc 123] [def 456]] | find --no-highlight 123",
196 result: Some(Value::list(
197 vec![Value::test_record(record! {
198 "foo" => Value::test_string("abc"),
199 "bar" => Value::test_int(123)
200 })],
201 Span::test_data(),
202 )),
203 },
204 Example {
205 description: "Find and highlight text in specific columns.",
206 example: "[[col1 col2 col3]; [moe larry curly] [larry curly moe]] | find moe --columns [col1]",
207 result: Some(Value::list(
208 vec![Value::test_record(record! {
209 "col1" => Value::test_string(
210 "\u{1b}[39m\u{1b}[0m\u{1b}[41;39mmoe\u{1b}[0m\u{1b}[39m\u{1b}[0m"
211 .to_string(),
212 ),
213 "col2" => Value::test_string("larry".to_string()),
214 "col3" => Value::test_string("curly".to_string()),
215 })],
216 Span::test_data(),
217 )),
218 },
219 Example {
220 description: "Find in a multi-line string.",
221 example: "'Violets are red\nAnd roses are blue\nWhen metamaterials\nAlter their hue' | find ue",
222 result: Some(Value::list(
223 vec![
224 Value::test_string(
225 "\u{1b}[39mAnd roses are bl\u{1b}[0m\u{1b}[41;39mue\u{1b}[0m\u{1b}[39m\u{1b}[0m",
226 ),
227 Value::test_string(
228 "\u{1b}[39mAlter their h\u{1b}[0m\u{1b}[41;39mue\u{1b}[0m\u{1b}[39m\u{1b}[0m",
229 ),
230 ],
231 Span::test_data(),
232 )),
233 },
234 Example {
235 description: "Find in a multi-line string without splitting the input into a list of lines.",
236 example: "'Violets are red\nAnd roses are blue\nWhen metamaterials\nAlter their hue' | find --multiline ue",
237 result: Some(Value::test_string(
238 "\u{1b}[39mViolets are red\nAnd roses are bl\u{1b}[0m\u{1b}[41;39mue\u{1b}[0m\u{1b}[39m\nWhen metamaterials\nAlter their h\u{1b}[0m\u{1b}[41;39mue\u{1b}[0m\u{1b}[39m\u{1b}[0m",
239 )),
240 },
241 Example {
242 description: "Find and highlight the last occurrence in a string.",
243 example: "'hello world hello' | find --rfind hello",
244 result: Some(Value::test_string(
245 "\u{1b}[39mhello world \u{1b}[0m\u{1b}[41;39mhello\u{1b}[0m\u{1b}[39m\u{1b}[0m",
246 )),
247 },
248 ]
249 }
250
251 fn search_terms(&self) -> Vec<&str> {
252 vec!["filter", "regex", "search", "condition", "grep"]
253 }
254
255 fn run(
256 &self,
257 engine_state: &EngineState,
258 stack: &mut Stack,
259 call: &Call,
260 input: PipelineData,
261 ) -> Result<PipelineData, ShellError> {
262 let pattern = get_match_pattern_from_arguments(engine_state, stack, call)?;
263
264 let multiline = call.has_flag(engine_state, stack, "multiline")?;
265
266 let columns_to_search: Vec<_> = call
267 .get_flag(engine_state, stack, "columns")?
268 .unwrap_or_default();
269
270 let input = if multiline {
271 if let PipelineData::ByteStream(..) = input {
272 return Err(ShellError::IncompatibleParametersSingle {
275 msg: "Flag `--multiline` currently doesn't work for byte stream inputs. Consider using `collect`".into(),
276 span: call.get_flag_span(stack, "multiline").expect("has flag"),
277 });
278 };
279 input
280 } else {
281 split_string_if_multiline(input, call.head)
282 };
283
284 find_in_pipelinedata(pattern, columns_to_search, engine_state, stack, input)
285 }
286}
287
288#[derive(Clone)]
289struct MatchPattern {
290 regex: Regex,
292
293 search_terms: Vec<String>,
295
296 ignore_case: bool,
298
299 highlight: bool,
301
302 invert: bool,
304
305 rfind: bool,
307
308 string_style: Style,
310
311 highlight_style: Style,
313}
314
315fn get_match_pattern_from_arguments(
316 engine_state: &EngineState,
317 stack: &mut Stack,
318 call: &Call,
319) -> Result<MatchPattern, ShellError> {
320 let config = stack.get_config(engine_state);
321
322 let span = call.head;
323 let regex = call.get_flag::<String>(engine_state, stack, "regex")?;
324 let terms = call.rest::<Value>(engine_state, stack, 0)?;
325
326 let invert = call.has_flag(engine_state, stack, "invert")?;
327 let highlight = !call.has_flag(engine_state, stack, "no-highlight")?;
328 let rfind = call.has_flag(engine_state, stack, "rfind")?;
329
330 let ignore_case = call.has_flag(engine_state, stack, "ignore-case")?;
331
332 let dotall = call.has_flag(engine_state, stack, "dotall")?;
333
334 let style_computer = StyleComputer::from_config(engine_state, stack);
335 let string_style = style_computer.compute("string", &Value::string("search result", span));
339 let highlight_style =
340 style_computer.compute("search_result", &Value::string("search result", span));
341
342 let (regex_str, search_terms) = match (regex, terms.as_slice()) {
343 (Some(_), [_, ..]) => {
344 return Err(ShellError::IncompatibleParametersSingle {
345 msg: "Cannot use a `--regex` parameter with additional search terms".into(),
346 span: call.get_flag_span(stack, "regex").expect("has flag"),
347 });
348 }
349 (Some(regex), []) => {
350 let flags = match (ignore_case, dotall) {
351 (false, false) => "",
352 (true, false) => "(?i)", (false, true) => "(?s)", (true, true) => "(?is)", };
356
357 (flags.to_string() + regex.as_str(), Vec::new())
358 }
359 (None, _) if dotall => {
360 return Err(ShellError::IncompatibleParametersSingle {
361 msg: "Flag --dotall only works for regex search".into(),
362 span: call.get_flag_span(stack, "dotall").expect("has flag"),
363 });
364 }
365 (None, terms) => {
368 let mut regex = String::new();
369
370 if ignore_case {
371 regex += "(?i)";
372 }
373
374 let search_terms = terms
375 .iter()
376 .map(|v| {
377 if ignore_case {
378 v.to_expanded_string("", &config).to_lowercase()
379 } else {
380 v.to_expanded_string("", &config)
381 }
382 })
383 .collect::<Vec<String>>();
384
385 if let [first, rest @ ..] = search_terms.as_slice() {
386 regex.push_str(escape(first).as_ref());
387 for term in rest {
388 regex.push('|');
389 regex.push_str(escape(term).as_ref());
390 }
391 }
392
393 (regex, search_terms)
394 }
395 };
396
397 let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch {
399 err_message: format!("invalid regex: {e}"),
400 span,
401 })?;
402
403 Ok(MatchPattern {
404 regex,
405 search_terms,
406 ignore_case,
407 invert,
408 highlight,
409 rfind,
410 string_style,
411 highlight_style,
412 })
413}
414
415fn highlight_matches_in_string(pattern: &MatchPattern, val: String) -> String {
418 if !pattern.regex.is_match(&val).unwrap_or(false) {
419 return val;
420 }
421
422 let stripped_val = nu_utils::strip_ansi_string_unlikely(val);
423
424 if pattern.rfind {
425 highlight_last_match(pattern, &stripped_val)
426 } else {
427 highlight_all_matches(pattern, &stripped_val)
428 }
429}
430
431fn highlight_last_match(pattern: &MatchPattern, text: &str) -> String {
432 let last_match = pattern.regex.find_iter(text).fold(None, |_, m| m.ok());
434
435 match last_match {
436 Some(m) => {
437 let start = m.start();
438 let end = m.end();
439 format!(
440 "{}{}{}",
441 pattern.string_style.paint(&text[..start]),
442 pattern.highlight_style.paint(&text[start..end]),
443 pattern.string_style.paint(&text[end..])
444 )
445 }
446 None => pattern.string_style.paint(text).to_string(),
447 }
448}
449
450fn highlight_all_matches(pattern: &MatchPattern, text: &str) -> String {
451 let mut last_match_end = 0;
452 let mut highlighted = String::new();
453
454 for cap in pattern.regex.captures_iter(text) {
455 let Ok(capture) = cap else {
456 return pattern.string_style.paint(text).to_string();
457 };
458
459 let Some(m) = capture.get(0) else { continue };
460
461 highlighted.push_str(
462 &pattern
463 .string_style
464 .paint(&text[last_match_end..m.start()])
465 .to_string(),
466 );
467 highlighted.push_str(
468 &pattern
469 .highlight_style
470 .paint(&text[m.start()..m.end()])
471 .to_string(),
472 );
473 last_match_end = m.end();
474 }
475
476 highlighted.push_str(
477 &pattern
478 .string_style
479 .paint(&text[last_match_end..])
480 .to_string(),
481 );
482 highlighted
483}
484
485fn highlight_matches_in_value(
486 pattern: &MatchPattern,
487 value: Value,
488 columns_to_search: &[String],
489) -> Value {
490 if !pattern.highlight || pattern.invert {
491 return value;
492 }
493 let span = value.span();
494
495 match value {
496 Value::Record { val: record, .. } => {
497 let col_select = !columns_to_search.is_empty();
498
499 let mut record = record.into_owned();
501
502 for (col, val) in record.iter_mut() {
503 if col_select && !columns_to_search.contains(col) {
504 continue;
505 }
506
507 *val = highlight_matches_in_value(pattern, std::mem::take(val), &[]);
508 }
509
510 Value::record(record, span)
511 }
512 Value::List { vals, .. } => vals
513 .into_iter()
514 .map(|item| highlight_matches_in_value(pattern, item, &[]))
515 .collect::<Vec<Value>>()
516 .into_value(span),
517 Value::String { val, .. } => highlight_matches_in_string(pattern, val).into_value(span),
518 _ => value,
519 }
520}
521
522fn find_in_pipelinedata(
523 pattern: MatchPattern,
524 columns_to_search: Vec<String>,
525 engine_state: &EngineState,
526 stack: &mut Stack,
527 input: PipelineData,
528) -> Result<PipelineData, ShellError> {
529 let config = stack.get_config(engine_state);
530
531 let map_pattern = pattern.clone();
532 let map_columns_to_search = columns_to_search.clone();
533
534 match input {
535 PipelineData::Empty => Ok(PipelineData::empty()),
536 PipelineData::Value(_, _) => input
537 .filter(
538 move |value| {
539 value_should_be_printed(&pattern, value, &columns_to_search, &config)
540 != pattern.invert
541 },
542 engine_state.signals(),
543 )?
544 .map(
545 move |x| highlight_matches_in_value(&map_pattern, x, &map_columns_to_search),
546 engine_state.signals(),
547 ),
548 PipelineData::ListStream(stream, metadata) => {
549 let stream = stream.modify(|iter| {
550 iter.filter(move |value| {
551 value_should_be_printed(&pattern, value, &columns_to_search, &config)
552 != pattern.invert
553 })
554 .map(move |x| highlight_matches_in_value(&map_pattern, x, &map_columns_to_search))
555 });
556
557 Ok(PipelineData::list_stream(stream, metadata))
558 }
559 PipelineData::ByteStream(stream, ..) => {
560 let span = stream.span();
561 if let Some(lines) = stream.lines() {
562 let mut output: Vec<Value> = vec![];
563 for line in lines {
564 let line = line?;
565 if string_should_be_printed(&pattern, &line) != pattern.invert {
566 if pattern.highlight && !pattern.invert {
567 output
568 .push(highlight_matches_in_string(&pattern, line).into_value(span))
569 } else {
570 output.push(line.into_value(span))
571 }
572 }
573 }
574 Ok(Value::list(output, span).into_pipeline_data())
575 } else {
576 Ok(PipelineData::empty())
577 }
578 }
579 }
580}
581
582fn string_should_be_printed(pattern: &MatchPattern, value: &str) -> bool {
585 pattern.regex.is_match(value).unwrap_or(false)
586}
587
588fn value_should_be_printed(
589 pattern: &MatchPattern,
590 value: &Value,
591 columns_to_search: &[String],
592 config: &Config,
593) -> bool {
594 let value_as_string = if pattern.ignore_case {
595 value.to_expanded_string("", config).to_lowercase()
596 } else {
597 value.to_expanded_string("", config)
598 };
599
600 match value {
601 Value::Bool { .. }
602 | Value::Int { .. }
603 | Value::Filesize { .. }
604 | Value::Duration { .. }
605 | Value::Date { .. }
606 | Value::Range { .. }
607 | Value::Float { .. }
608 | Value::Closure { .. }
609 | Value::Nothing { .. } => {
610 if !pattern.search_terms.is_empty() {
611 pattern
613 .search_terms
614 .iter()
615 .any(|term: &String| term == &value_as_string)
616 } else {
617 string_should_be_printed(pattern, &value_as_string)
618 }
619 }
620 Value::Glob { .. } | Value::CellPath { .. } | Value::Custom { .. } => {
621 string_should_be_printed(pattern, &value_as_string)
622 }
623 Value::String { val, .. } => string_should_be_printed(pattern, val),
624 Value::List { vals, .. } => vals
625 .iter()
626 .any(|item| value_should_be_printed(pattern, item, &[], config)),
627 Value::Record { val: record, .. } => {
628 let col_select = !columns_to_search.is_empty();
629 record.iter().any(|(col, val)| {
630 if col_select && !columns_to_search.contains(col) {
631 return false;
632 }
633 value_should_be_printed(pattern, val, &[], config)
634 })
635 }
636 Value::Binary { .. } => false,
637 Value::Error { .. } => true,
638 }
639}
640
641fn split_string_if_multiline(input: PipelineData, head_span: Span) -> PipelineData {
644 let span = input.span().unwrap_or(head_span);
645 match input {
646 PipelineData::Value(Value::String { ref val, .. }, metadata) if val.contains('\n') => {
647 Value::list(
648 val.lines()
649 .map(|s| Value::string(s.to_string(), span))
650 .collect(),
651 span,
652 )
653 .into_pipeline_data_with_metadata(metadata)
654 }
655 _ => input,
656 }
657}
658
659pub fn find_internal(
661 input: PipelineData,
662 engine_state: &EngineState,
663 stack: &mut Stack,
664 search_term: &str,
665 columns_to_search: &[&str],
666 highlight: bool,
667 head: Span,
668) -> Result<PipelineData, ShellError> {
669 let span = input.span().unwrap_or(head);
670
671 let style_computer = StyleComputer::from_config(engine_state, stack);
672 let string_style = style_computer.compute("string", &Value::string("search result", span));
673 let highlight_style =
674 style_computer.compute("search_result", &Value::string("search result", span));
675
676 let regex_str = format!("(?i){}", escape(search_term));
677
678 let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch {
679 err_message: format!("invalid regex: {e}"),
680 span: head,
681 })?;
682
683 let pattern = MatchPattern {
684 regex,
685 search_terms: vec![search_term.to_lowercase()],
686 ignore_case: true,
687 highlight,
688 invert: false,
689 rfind: false,
690 string_style,
691 highlight_style,
692 };
693
694 let columns_to_search = columns_to_search
695 .iter()
696 .map(|str| String::from(*str))
697 .collect();
698
699 find_in_pipelinedata(pattern, columns_to_search, engine_state, stack, input)
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705
706 #[test]
707 fn test_examples() -> nu_test_support::Result {
708 nu_test_support::test().examples(Find)
709 }
710}