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 (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 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 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 let flags = match multiline {
312 true => "(?m)",
313 false => "",
314 };
315 let regex_string = flags.to_string() + find_str;
316 let regex = Regex::new(®ex_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 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}