Skip to main content

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