nu_command/strings/str_/
replace.rs

1use fancy_regex::{Captures, NoExpand, Regex};
2use nu_cmd_base::input_handler::{CmdArgument, operate};
3use nu_engine::{ClosureEval, command_prelude::*};
4use std::sync::Arc;
5
6enum ReplacementValue {
7    String(Arc<Spanned<String>>),
8    Closure(Box<Spanned<ClosureEval>>),
9}
10
11struct Arguments {
12    all: bool,
13    find: Spanned<String>,
14    replace: ReplacementValue,
15    cell_paths: Option<Vec<CellPath>>,
16    literal_replace: bool,
17    no_regex: bool,
18    multiline: bool,
19}
20
21impl CmdArgument for Arguments {
22    fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
23        self.cell_paths.take()
24    }
25}
26
27#[derive(Clone)]
28pub struct StrReplace;
29
30impl Command for StrReplace {
31    fn name(&self) -> &str {
32        "str replace"
33    }
34
35    fn signature(&self) -> Signature {
36        Signature::build("str replace")
37            .input_output_types(vec![
38                (Type::String, Type::String),
39                // TODO: clarify behavior with cell-path-rest argument
40                (Type::table(), Type::table()),
41                (Type::record(), Type::record()),
42                (
43                    Type::List(Box::new(Type::String)),
44                    Type::List(Box::new(Type::String)),
45                ),
46            ])
47            .required("find", SyntaxShape::String, "The pattern to find.")
48            .required("replace",
49                SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::Closure(None)]),
50                "The replacement string, or a closure that generates it."
51            )
52            .rest(
53                "rest",
54                SyntaxShape::CellPath,
55                "For a data structure input, operate on strings at the given cell paths.",
56            )
57            .switch("all", "replace all occurrences of the pattern", Some('a'))
58            .switch(
59                "no-expand",
60                "do not expand capture groups (like $name) in the replacement string",
61                Some('n'),
62            )
63            .switch(
64                "regex",
65                "match the pattern as a regular expression in the input, instead of a substring",
66                Some('r'),
67            )
68            .switch(
69                "multiline",
70                "multi-line regex mode (implies --regex): ^ and $ match begin/end of line; equivalent to (?m)",
71                Some('m'),
72            )
73            .allow_variants_without_examples(true)
74            .category(Category::Strings)
75    }
76
77    fn description(&self) -> &str {
78        "Find and replace text."
79    }
80
81    fn extra_description(&self) -> &str {
82        r#"The pattern to find can be a substring (default) or a regular expression (with `--regex`).
83
84The replacement can be a a string, possibly containing references to numbered (`$1` etc) or
85named capture groups (`$name`), or it can be closure that is invoked for each match.
86In the latter case, the closure is invoked with the entire match as its input and any capture
87groups as its argument. It must return a string that will be used as a replacement for the match.
88"#
89    }
90
91    fn search_terms(&self) -> Vec<&str> {
92        vec!["search", "shift", "switch", "regex"]
93    }
94
95    fn is_const(&self) -> bool {
96        true
97    }
98
99    fn run(
100        &self,
101        engine_state: &EngineState,
102        stack: &mut Stack,
103        call: &Call,
104        input: PipelineData,
105    ) -> Result<PipelineData, ShellError> {
106        let find: Spanned<String> = call.req(engine_state, stack, 0)?;
107        let replace = match call.req(engine_state, stack, 1)? {
108            Value::Closure {
109                val, internal_span, ..
110            } => Ok(ReplacementValue::Closure(Box::new(
111                ClosureEval::new(engine_state, stack, *val).into_spanned(internal_span),
112            ))),
113            Value::String {
114                val, internal_span, ..
115            } => Ok(ReplacementValue::String(Arc::new(
116                val.into_spanned(internal_span),
117            ))),
118            val => Err(ShellError::TypeMismatch {
119                err_message: "unsupported replacement value type".to_string(),
120                span: val.span(),
121            }),
122        }?;
123        let cell_paths: Vec<CellPath> = call.rest(engine_state, stack, 2)?;
124        let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
125        let literal_replace = call.has_flag(engine_state, stack, "no-expand")?;
126        let no_regex = !call.has_flag(engine_state, stack, "regex")?
127            && !call.has_flag(engine_state, stack, "multiline")?;
128        let multiline = call.has_flag(engine_state, stack, "multiline")?;
129
130        let args = Arguments {
131            all: call.has_flag(engine_state, stack, "all")?,
132            find,
133            replace,
134            cell_paths,
135            literal_replace,
136            no_regex,
137            multiline,
138        };
139        operate(action, args, input, call.head, engine_state.signals())
140    }
141
142    fn run_const(
143        &self,
144        working_set: &StateWorkingSet,
145        call: &Call,
146        input: PipelineData,
147    ) -> Result<PipelineData, ShellError> {
148        let find: Spanned<String> = call.req_const(working_set, 0)?;
149        let replace: Spanned<String> = call.req_const(working_set, 1)?;
150        let cell_paths: Vec<CellPath> = call.rest_const(working_set, 2)?;
151        let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
152        let literal_replace = call.has_flag_const(working_set, "no-expand")?;
153        let no_regex = !call.has_flag_const(working_set, "regex")?
154            && !call.has_flag_const(working_set, "multiline")?;
155        let multiline = call.has_flag_const(working_set, "multiline")?;
156
157        let args = Arguments {
158            all: call.has_flag_const(working_set, "all")?,
159            find,
160            replace: ReplacementValue::String(Arc::new(replace)),
161            cell_paths,
162            literal_replace,
163            no_regex,
164            multiline,
165        };
166        operate(
167            action,
168            args,
169            input,
170            call.head,
171            working_set.permanent().signals(),
172        )
173    }
174
175    fn examples(&self) -> Vec<Example<'_>> {
176        vec![
177            Example {
178                description: "Find and replace the first occurrence of a substring",
179                example: r"'c:\some\cool\path' | str replace 'c:\some\cool' '~'",
180                result: Some(Value::test_string("~\\path")),
181            },
182            Example {
183                description: "Find and replace all occurrences of a substring",
184                example: r#"'abc abc abc' | str replace --all 'b' 'z'"#,
185                result: Some(Value::test_string("azc azc azc")),
186            },
187            Example {
188                description: "Find and replace contents with capture group using regular expression",
189                example: "'my_library.rb' | str replace -r '(.+).rb' '$1.nu'",
190                result: Some(Value::test_string("my_library.nu")),
191            },
192            Example {
193                description: "Find and replace contents with capture group using regular expression, with escapes",
194                example: "'hello=world' | str replace -r '\\$?(?<varname>.*)=(?<value>.*)' '$$$varname = $value'",
195                result: Some(Value::test_string("$hello = world")),
196            },
197            Example {
198                description: "Find and replace all occurrences of found string using regular expression",
199                example: "'abc abc abc' | str replace --all --regex 'b' 'z'",
200                result: Some(Value::test_string("azc azc azc")),
201            },
202            Example {
203                description: "Find and replace all occurrences of found string in table using regular expression",
204                example: "[[ColA ColB ColC]; [abc abc ads]] | str replace --all --regex 'b' 'z' ColA ColC",
205                result: Some(Value::test_list(vec![Value::test_record(record! {
206                    "ColA" => Value::test_string("azc"),
207                    "ColB" => Value::test_string("abc"),
208                    "ColC" => Value::test_string("ads"),
209                })])),
210            },
211            Example {
212                description: "Find and replace all occurrences of found string in record using regular expression",
213                example: "{ KeyA: abc, KeyB: abc, KeyC: ads } | str replace --all --regex 'b' 'z' KeyA KeyC",
214                result: Some(Value::test_record(record! {
215                    "KeyA" => Value::test_string("azc"),
216                    "KeyB" => Value::test_string("abc"),
217                    "KeyC" => Value::test_string("ads"),
218                })),
219            },
220            Example {
221                description: "Find and replace contents without using the replace parameter as a regular expression",
222                example: r"'dogs_$1_cats' | str replace -r '\$1' '$2' -n",
223                result: Some(Value::test_string("dogs_$2_cats")),
224            },
225            Example {
226                description: "Use captures to manipulate the input text using regular expression",
227                example: r#""abc-def" | str replace -r "(.+)-(.+)" "${2}_${1}""#,
228                result: Some(Value::test_string("def_abc")),
229            },
230            Example {
231                description: "Find and replace with fancy-regex using regular expression",
232                example: r"'a successful b' | str replace -r '\b([sS])uc(?:cs|s?)e(ed(?:ed|ing|s?)|ss(?:es|ful(?:ly)?|i(?:ons?|ve(?:ly)?)|ors?)?)\b' '${1}ucce$2'",
233                result: Some(Value::test_string("a successful b")),
234            },
235            Example {
236                description: "Find and replace with fancy-regex using regular expression",
237                example: r#"'GHIKK-9+*' | str replace -r '[*[:xdigit:]+]' 'z'"#,
238                result: Some(Value::test_string("GHIKK-z+*")),
239            },
240            Example {
241                description: "Find and replace on individual lines using multiline regular expression",
242                example: r#""non-matching line\n123. one line\n124. another line\n" | str replace --all --multiline '^[0-9]+\. ' ''"#,
243                result: Some(Value::test_string(
244                    "non-matching line\none line\nanother line\n",
245                )),
246            },
247            Example {
248                description: "Find and replace backslash escape sequences using a closure",
249                example: r#"'string: \"abc\" backslash: \\ newline:\nend' | str replace -a -r '\\(.)' {|char| if $char == "n" { "\n" } else { $char } }"#,
250                result: Some(Value::test_string(
251                    "string: \"abc\" backslash: \\ newline:\nend",
252                )),
253            },
254        ]
255    }
256}
257
258fn action(
259    input: &Value,
260    Arguments {
261        find,
262        replace,
263        all,
264        literal_replace,
265        no_regex,
266        multiline,
267        ..
268    }: &Arguments,
269    head: Span,
270) -> Value {
271    match input {
272        Value::String { val, .. } => {
273            let find_str: &str = &find.item;
274            if *no_regex {
275                // just use regular string replacement vs regular expressions
276                let replace_str: Result<Arc<Spanned<String>>, (ShellError, Span)> = match replace {
277                    ReplacementValue::String(replace_str) => Ok(replace_str.clone()),
278                    ReplacementValue::Closure(closure) => {
279                        // find_str is fixed, so we need to run the closure only once
280                        let mut closure_eval = closure.item.clone();
281                        let span = closure.span;
282                        let result: Result<Value, ShellError> = closure_eval
283                            .run_with_value(Value::string(find.item.clone(), find.span))
284                            .and_then(|result| result.into_value(span));
285                        match result {
286                            Ok(Value::String { val, .. }) => Ok(Arc::new(val.into_spanned(span))),
287                            Ok(res) => Err((
288                                ShellError::RuntimeTypeMismatch {
289                                    expected: Type::String,
290                                    actual: res.get_type(),
291                                    span: res.span(),
292                                },
293                                span,
294                            )),
295                            Err(error) => Err((error, span)),
296                        }
297                    }
298                };
299                match replace_str {
300                    Ok(replace_str) => {
301                        if *all {
302                            Value::string(val.replace(find_str, &replace_str.item), head)
303                        } else {
304                            Value::string(val.replacen(find_str, &replace_str.item, 1), head)
305                        }
306                    }
307                    Err((error, span)) => Value::error(error, span),
308                }
309            } else {
310                // use regular expressions to replace strings
311                let flags = match multiline {
312                    true => "(?m)",
313                    false => "",
314                };
315                let regex_string = flags.to_string() + find_str;
316                let regex = Regex::new(&regex_string);
317
318                match (regex, replace) {
319                    (Ok(re), ReplacementValue::String(replace_str)) => {
320                        if *all {
321                            Value::string(
322                                {
323                                    if *literal_replace {
324                                        re.replace_all(val, NoExpand(&replace_str.item)).to_string()
325                                    } else {
326                                        re.replace_all(val, &replace_str.item).to_string()
327                                    }
328                                },
329                                head,
330                            )
331                        } else {
332                            Value::string(
333                                {
334                                    if *literal_replace {
335                                        re.replace(val, NoExpand(&replace_str.item)).to_string()
336                                    } else {
337                                        re.replace(val, &replace_str.item).to_string()
338                                    }
339                                },
340                                head,
341                            )
342                        }
343                    }
344                    (Ok(re), ReplacementValue::Closure(closure)) => {
345                        let span = closure.span;
346                        // TODO: We only need to clone the evaluator here because
347                        //       operate() doesn't allow us to have a mutable reference
348                        //       to Arguments. Would it be worth the effort to change operate()
349                        //       and all commands that use it?
350                        let mut closure_eval = closure.item.clone();
351                        let mut first_error: Option<ShellError> = None;
352                        let replacer = |caps: &Captures| {
353                            for capture in caps.iter().skip(1) {
354                                let arg = match capture {
355                                    Some(m) => Value::string(m.as_str().to_string(), head),
356                                    None => Value::nothing(head),
357                                };
358                                closure_eval.add_arg(arg);
359                            }
360                            let value = match caps.get(0) {
361                                Some(m) => Value::string(m.as_str().to_string(), head),
362                                None => Value::nothing(head),
363                            };
364                            let result: Result<Value, ShellError> = closure_eval
365                                .run_with_input(PipelineData::value(value, None))
366                                .and_then(|result| result.into_value(span));
367                            match result {
368                                Ok(Value::String { val, .. }) => val.to_string(),
369                                Ok(res) => {
370                                    first_error = Some(ShellError::RuntimeTypeMismatch {
371                                        expected: Type::String,
372                                        actual: res.get_type(),
373                                        span: res.span(),
374                                    });
375                                    "".to_string()
376                                }
377                                Err(e) => {
378                                    first_error = Some(e);
379                                    "".to_string()
380                                }
381                            }
382                        };
383                        let result = if *all {
384                            Value::string(re.replace_all(val, replacer).to_string(), head)
385                        } else {
386                            Value::string(re.replace(val, replacer).to_string(), head)
387                        };
388                        match first_error {
389                            None => result,
390                            Some(error) => Value::error(error, span),
391                        }
392                    }
393                    (Err(e), _) => Value::error(
394                        ShellError::IncorrectValue {
395                            msg: format!("Regex error: {e}"),
396                            val_span: find.span,
397                            call_span: head,
398                        },
399                        find.span,
400                    ),
401                }
402            }
403        }
404        Value::Error { .. } => input.clone(),
405        _ => Value::error(
406            ShellError::OnlySupportsThisInputType {
407                exp_input_type: "string".into(),
408                wrong_type: input.get_type().to_string(),
409                dst_span: head,
410                src_span: input.span(),
411            },
412            head,
413        ),
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use super::{Arguments, StrReplace, action};
421
422    fn test_spanned_string(val: &str) -> Spanned<String> {
423        Spanned {
424            item: String::from(val),
425            span: Span::test_data(),
426        }
427    }
428
429    #[test]
430    fn test_examples() {
431        use crate::test_examples;
432
433        test_examples(StrReplace {})
434    }
435
436    #[test]
437    fn can_have_capture_groups() {
438        let word = Value::test_string("Cargo.toml");
439
440        let options = Arguments {
441            find: test_spanned_string("Cargo.(.+)"),
442            replace: ReplacementValue::String(Arc::new(test_spanned_string("Carga.$1"))),
443            cell_paths: None,
444            literal_replace: false,
445            all: false,
446            no_regex: false,
447            multiline: false,
448        };
449
450        let actual = action(&word, &options, Span::test_data());
451        assert_eq!(actual, Value::test_string("Carga.toml"));
452    }
453}