nu_command/filters/
find.rs

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                    // TODO: This is too permissive; if we could express this
20                    // using a type parameter it would be List<T> -> List<T>.
21                    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        "Searches terms in the input."
70    }
71
72    fn examples(&self) -> Vec<Example<'_>> {
73        vec![
74            Example {
75                description: "Search for multiple terms in a command output",
76                example: r#"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: r#"'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: r#"[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: r#"[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: r#"'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                // ByteStream inputs are processed by iterating over the lines, which necessarily
273                // breaks the multi-line text being streamed into a list of lines.
274                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    /// the regex to be used for matching in text
291    regex: Regex,
292
293    /// the list of match terms (converted to lowercase if needed), or empty if a regex was provided
294    search_terms: Vec<String>,
295
296    /// case-insensitive match
297    ignore_case: bool,
298
299    /// return a modified version of the value where matching parts are highlighted
300    highlight: bool,
301
302    /// return the values that aren't a match instead
303    invert: bool,
304
305    /// search from the end (find last occurrence)
306    rfind: bool,
307
308    /// style of the non-highlighted string sections
309    string_style: Style,
310
311    /// style of the highlighted string sections
312    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    // Currently, search results all use the same style.
336    // Also note that this sample string is passed into user-written code (the closure that may or may not be
337    // defined for "string").
338    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) = if let Some(regex) = regex {
343        if !terms.is_empty() {
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
350        let flags = match (ignore_case, dotall) {
351            (false, false) => "",
352            (true, false) => "(?i)", // case insensitive
353            (false, true) => "(?s)", // allow . to match \n
354            (true, true) => "(?is)", // case insensitive and allow . to match \n
355        };
356
357        (flags.to_string() + regex.as_str(), Vec::new())
358    } else {
359        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
366        let mut regex = String::new();
367
368        if ignore_case {
369            regex += "(?i)";
370        }
371
372        let search_terms = terms
373            .iter()
374            .map(|v| {
375                if ignore_case {
376                    v.to_expanded_string("", &config).to_lowercase()
377                } else {
378                    v.to_expanded_string("", &config)
379                }
380            })
381            .collect::<Vec<String>>();
382
383        let escaped_terms = search_terms
384            .iter()
385            .map(|v| escape(v).into())
386            .collect::<Vec<String>>();
387
388        if let Some(term) = escaped_terms.first() {
389            regex += term;
390        }
391
392        for term in escaped_terms.iter().skip(1) {
393            regex += "|";
394            regex += term;
395        }
396
397        (regex, search_terms)
398    };
399
400    let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch {
401        err_message: format!("invalid regex: {e}"),
402        span,
403    })?;
404
405    Ok(MatchPattern {
406        regex,
407        search_terms,
408        ignore_case,
409        invert,
410        highlight,
411        rfind,
412        string_style,
413        highlight_style,
414    })
415}
416
417// map functions
418
419fn highlight_matches_in_string(pattern: &MatchPattern, val: String) -> String {
420    if !pattern.regex.is_match(&val).unwrap_or(false) {
421        return val;
422    }
423
424    let stripped_val = nu_utils::strip_ansi_string_unlikely(val);
425
426    if pattern.rfind {
427        highlight_last_match(pattern, &stripped_val)
428    } else {
429        highlight_all_matches(pattern, &stripped_val)
430    }
431}
432
433fn highlight_last_match(pattern: &MatchPattern, text: &str) -> String {
434    // Find the last match using fold to avoid collecting all matches
435    let last_match = pattern.regex.find_iter(text).fold(None, |_, m| m.ok());
436
437    match last_match {
438        Some(m) => {
439            let start = m.start();
440            let end = m.end();
441            format!(
442                "{}{}{}",
443                pattern.string_style.paint(&text[..start]),
444                pattern.highlight_style.paint(&text[start..end]),
445                pattern.string_style.paint(&text[end..])
446            )
447        }
448        None => pattern.string_style.paint(text).to_string(),
449    }
450}
451
452fn highlight_all_matches(pattern: &MatchPattern, text: &str) -> String {
453    let mut last_match_end = 0;
454    let mut highlighted = String::new();
455
456    for cap in pattern.regex.captures_iter(text) {
457        let capture = match cap {
458            Ok(capture) => capture,
459            Err(_) => return pattern.string_style.paint(text).to_string(),
460        };
461
462        let m = match capture.get(0) {
463            Some(m) => m,
464            None => continue,
465        };
466
467        highlighted.push_str(
468            &pattern
469                .string_style
470                .paint(&text[last_match_end..m.start()])
471                .to_string(),
472        );
473        highlighted.push_str(
474            &pattern
475                .highlight_style
476                .paint(&text[m.start()..m.end()])
477                .to_string(),
478        );
479        last_match_end = m.end();
480    }
481
482    highlighted.push_str(
483        &pattern
484            .string_style
485            .paint(&text[last_match_end..])
486            .to_string(),
487    );
488    highlighted
489}
490
491fn highlight_matches_in_value(
492    pattern: &MatchPattern,
493    value: Value,
494    columns_to_search: &[String],
495) -> Value {
496    if !pattern.highlight || pattern.invert {
497        return value;
498    }
499    let span = value.span();
500
501    match value {
502        Value::Record { val: record, .. } => {
503            let col_select = !columns_to_search.is_empty();
504
505            // TODO: change API to mutate in place
506            let mut record = record.into_owned();
507
508            for (col, val) in record.iter_mut() {
509                if col_select && !columns_to_search.contains(col) {
510                    continue;
511                }
512
513                *val = highlight_matches_in_value(pattern, std::mem::take(val), &[]);
514            }
515
516            Value::record(record, span)
517        }
518        Value::List { vals, .. } => vals
519            .into_iter()
520            .map(|item| highlight_matches_in_value(pattern, item, &[]))
521            .collect::<Vec<Value>>()
522            .into_value(span),
523        Value::String { val, .. } => highlight_matches_in_string(pattern, val).into_value(span),
524        _ => value,
525    }
526}
527
528fn find_in_pipelinedata(
529    pattern: MatchPattern,
530    columns_to_search: Vec<String>,
531    engine_state: &EngineState,
532    stack: &mut Stack,
533    input: PipelineData,
534) -> Result<PipelineData, ShellError> {
535    let config = stack.get_config(engine_state);
536
537    let map_pattern = pattern.clone();
538    let map_columns_to_search = columns_to_search.clone();
539
540    match input {
541        PipelineData::Empty => Ok(PipelineData::empty()),
542        PipelineData::Value(_, _) => input
543            .filter(
544                move |value| {
545                    value_should_be_printed(&pattern, value, &columns_to_search, &config)
546                        != pattern.invert
547                },
548                engine_state.signals(),
549            )?
550            .map(
551                move |x| highlight_matches_in_value(&map_pattern, x, &map_columns_to_search),
552                engine_state.signals(),
553            ),
554        PipelineData::ListStream(stream, metadata) => {
555            let stream = stream.modify(|iter| {
556                iter.filter(move |value| {
557                    value_should_be_printed(&pattern, value, &columns_to_search, &config)
558                        != pattern.invert
559                })
560                .map(move |x| highlight_matches_in_value(&map_pattern, x, &map_columns_to_search))
561            });
562
563            Ok(PipelineData::list_stream(stream, metadata))
564        }
565        PipelineData::ByteStream(stream, ..) => {
566            let span = stream.span();
567            if let Some(lines) = stream.lines() {
568                let mut output: Vec<Value> = vec![];
569                for line in lines {
570                    let line = line?;
571                    if string_should_be_printed(&pattern, &line) != pattern.invert {
572                        if pattern.highlight && !pattern.invert {
573                            output
574                                .push(highlight_matches_in_string(&pattern, line).into_value(span))
575                        } else {
576                            output.push(line.into_value(span))
577                        }
578                    }
579                }
580                Ok(Value::list(output, span).into_pipeline_data())
581            } else {
582                Ok(PipelineData::empty())
583            }
584        }
585    }
586}
587
588// filter functions
589
590fn string_should_be_printed(pattern: &MatchPattern, value: &str) -> bool {
591    pattern.regex.is_match(value).unwrap_or(false)
592}
593
594fn value_should_be_printed(
595    pattern: &MatchPattern,
596    value: &Value,
597    columns_to_search: &[String],
598    config: &Config,
599) -> bool {
600    let value_as_string = if pattern.ignore_case {
601        value.to_expanded_string("", config).to_lowercase()
602    } else {
603        value.to_expanded_string("", config)
604    };
605
606    match value {
607        Value::Bool { .. }
608        | Value::Int { .. }
609        | Value::Filesize { .. }
610        | Value::Duration { .. }
611        | Value::Date { .. }
612        | Value::Range { .. }
613        | Value::Float { .. }
614        | Value::Closure { .. }
615        | Value::Nothing { .. } => {
616            if !pattern.search_terms.is_empty() {
617                // look for exact match when searching with terms
618                pattern
619                    .search_terms
620                    .iter()
621                    .any(|term: &String| term == &value_as_string)
622            } else {
623                string_should_be_printed(pattern, &value_as_string)
624            }
625        }
626        Value::Glob { .. } | Value::CellPath { .. } | Value::Custom { .. } => {
627            string_should_be_printed(pattern, &value_as_string)
628        }
629        Value::String { val, .. } => string_should_be_printed(pattern, val),
630        Value::List { vals, .. } => vals
631            .iter()
632            .any(|item| value_should_be_printed(pattern, item, &[], config)),
633        Value::Record { val: record, .. } => {
634            let col_select = !columns_to_search.is_empty();
635            record.iter().any(|(col, val)| {
636                if col_select && !columns_to_search.contains(col) {
637                    return false;
638                }
639                value_should_be_printed(pattern, val, &[], config)
640            })
641        }
642        Value::Binary { .. } => false,
643        Value::Error { .. } => true,
644    }
645}
646
647// utility
648
649fn split_string_if_multiline(input: PipelineData, head_span: Span) -> PipelineData {
650    let span = input.span().unwrap_or(head_span);
651    match input {
652        PipelineData::Value(Value::String { ref val, .. }, _) => {
653            if val.contains('\n') {
654                Value::list(
655                    val.lines()
656                        .map(|s| Value::string(s.to_string(), span))
657                        .collect(),
658                    span,
659                )
660                .into_pipeline_data_with_metadata(input.metadata())
661            } else {
662                input
663            }
664        }
665        _ => input,
666    }
667}
668
669/// function for using find from other commands
670pub fn find_internal(
671    input: PipelineData,
672    engine_state: &EngineState,
673    stack: &mut Stack,
674    search_term: &str,
675    columns_to_search: &[&str],
676    highlight: bool,
677) -> Result<PipelineData, ShellError> {
678    let span = input.span().unwrap_or(Span::unknown());
679
680    let style_computer = StyleComputer::from_config(engine_state, stack);
681    let string_style = style_computer.compute("string", &Value::string("search result", span));
682    let highlight_style =
683        style_computer.compute("search_result", &Value::string("search result", span));
684
685    let regex_str = format!("(?i){}", escape(search_term));
686
687    let regex = Regex::new(regex_str.as_str()).map_err(|e| ShellError::TypeMismatch {
688        err_message: format!("invalid regex: {e}"),
689        span: Span::unknown(),
690    })?;
691
692    let pattern = MatchPattern {
693        regex,
694        search_terms: vec![search_term.to_lowercase()],
695        ignore_case: true,
696        highlight,
697        invert: false,
698        rfind: false,
699        string_style,
700        highlight_style,
701    };
702
703    let columns_to_search = columns_to_search
704        .iter()
705        .map(|str| String::from(*str))
706        .collect();
707
708    find_in_pipelinedata(pattern, columns_to_search, engine_state, stack, input)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_examples() {
717        use crate::test_examples;
718
719        test_examples(Find)
720    }
721}