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 (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 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 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}