nu_command/strings/str_/
index_of.rs

1use std::ops::Bound;
2
3use crate::{grapheme_flags, grapheme_flags_const};
4use nu_cmd_base::input_handler::{CmdArgument, operate};
5use nu_engine::command_prelude::*;
6use nu_protocol::{IntRange, engine::StateWorkingSet};
7use unicode_segmentation::UnicodeSegmentation;
8
9struct Arguments {
10    end: bool,
11    substring: String,
12    range: Option<Spanned<IntRange>>,
13    cell_paths: Option<Vec<CellPath>>,
14    graphemes: bool,
15}
16
17impl CmdArgument for Arguments {
18    fn take_cell_paths(&mut self) -> Option<Vec<CellPath>> {
19        self.cell_paths.take()
20    }
21}
22
23#[derive(Clone)]
24pub struct StrIndexOf;
25
26impl Command for StrIndexOf {
27    fn name(&self) -> &str {
28        "str index-of"
29    }
30
31    fn signature(&self) -> Signature {
32        Signature::build("str index-of")
33            .input_output_types(vec![
34                (Type::String, Type::Int),
35                (Type::List(Box::new(Type::String)), Type::List(Box::new(Type::Int))),
36                (Type::table(), Type::table()),
37                (Type::record(), Type::record()),
38            ])
39            .allow_variants_without_examples(true)
40            .required("string", SyntaxShape::String, "The string to find in the input.")
41            .switch(
42                "grapheme-clusters",
43                "count indexes using grapheme clusters (all visible chars have length 1)",
44                Some('g'),
45            )
46            .switch(
47                "utf-8-bytes",
48                "count indexes using UTF-8 bytes (default; non-ASCII chars have length 2+)",
49                Some('b'),
50            )
51            .rest(
52                "rest",
53                SyntaxShape::CellPath,
54                "For a data structure input, search strings at the given cell paths, and replace with result.",
55            )
56            .named(
57                "range",
58                SyntaxShape::Range,
59                "optional start and/or end index",
60                Some('r'),
61            )
62            .switch("end", "search from the end of the input", Some('e'))
63            .category(Category::Strings)
64    }
65
66    fn description(&self) -> &str {
67        "Returns start index of first occurrence of string in input, or -1 if no match."
68    }
69
70    fn search_terms(&self) -> Vec<&str> {
71        vec!["match", "find", "search"]
72    }
73
74    fn is_const(&self) -> bool {
75        true
76    }
77
78    fn run(
79        &self,
80        engine_state: &EngineState,
81        stack: &mut Stack,
82        call: &Call,
83        input: PipelineData,
84    ) -> Result<PipelineData, ShellError> {
85        let substring: Spanned<String> = call.req(engine_state, stack, 0)?;
86        let cell_paths: Vec<CellPath> = call.rest(engine_state, stack, 1)?;
87        let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
88        let args = Arguments {
89            substring: substring.item,
90            range: call.get_flag(engine_state, stack, "range")?,
91            end: call.has_flag(engine_state, stack, "end")?,
92            cell_paths,
93            graphemes: grapheme_flags(engine_state, stack, call)?,
94        };
95        operate(action, args, input, call.head, engine_state.signals())
96    }
97
98    fn run_const(
99        &self,
100        working_set: &StateWorkingSet,
101        call: &Call,
102        input: PipelineData,
103    ) -> Result<PipelineData, ShellError> {
104        let substring: Spanned<String> = call.req_const(working_set, 0)?;
105        let cell_paths: Vec<CellPath> = call.rest_const(working_set, 1)?;
106        let cell_paths = (!cell_paths.is_empty()).then_some(cell_paths);
107        let args = Arguments {
108            substring: substring.item,
109            range: call.get_flag_const(working_set, "range")?,
110            end: call.has_flag_const(working_set, "end")?,
111            cell_paths,
112            graphemes: grapheme_flags_const(working_set, call)?,
113        };
114        operate(
115            action,
116            args,
117            input,
118            call.head,
119            working_set.permanent().signals(),
120        )
121    }
122
123    fn examples(&self) -> Vec<Example<'_>> {
124        vec![
125            Example {
126                description: "Returns index of string in input",
127                example: " 'my_library.rb' | str index-of '.rb'",
128                result: Some(Value::test_int(10)),
129            },
130            Example {
131                description: "Count length using grapheme clusters",
132                example: "'๐Ÿ‡ฏ๐Ÿ‡ตใปใ’ ใตใŒ ใดใ‚ˆ' | str index-of --grapheme-clusters 'ใตใŒ'",
133                result: Some(Value::test_int(4)),
134            },
135            Example {
136                description: "Returns index of string in input within a`rhs open range`",
137                example: " '.rb.rb' | str index-of '.rb' --range 1..",
138                result: Some(Value::test_int(3)),
139            },
140            Example {
141                description: "Returns index of string in input within a lhs open range",
142                example: " '123456' | str index-of '6' --range ..4",
143                result: Some(Value::test_int(-1)),
144            },
145            Example {
146                description: "Returns index of string in input within a range",
147                example: " '123456' | str index-of '3' --range 1..4",
148                result: Some(Value::test_int(2)),
149            },
150            Example {
151                description: "Returns index of string in input",
152                example: " '/this/is/some/path/file.txt' | str index-of '/' -e",
153                result: Some(Value::test_int(18)),
154            },
155        ]
156    }
157}
158
159fn action(
160    input: &Value,
161    Arguments {
162        substring,
163        range,
164        end,
165        graphemes,
166        ..
167    }: &Arguments,
168    head: Span,
169) -> Value {
170    match input {
171        Value::String { val: s, .. } => {
172            let (search_str, start_index) = if let Some(spanned_range) = range {
173                let range_span = spanned_range.span;
174                let range = &spanned_range.item;
175
176                let (start, end) = range.absolute_bounds(s.len());
177                let s = match end {
178                    Bound::Excluded(end) => s.get(start..end),
179                    Bound::Included(end) => s.get(start..=end),
180                    Bound::Unbounded => s.get(start..),
181                };
182
183                let s = match s {
184                    Some(s) => s,
185                    None => {
186                        return Value::error(
187                            ShellError::OutOfBounds {
188                                left_flank: start.to_string(),
189                                right_flank: match range.end() {
190                                    Bound::Unbounded => "".to_string(),
191                                    Bound::Included(end) => format!("={end}"),
192                                    Bound::Excluded(end) => format!("<{end}"),
193                                },
194                                span: range_span,
195                            },
196                            head,
197                        );
198                    }
199                };
200                (s, start)
201            } else {
202                (s.as_str(), 0)
203            };
204
205            // When the -e flag is present, search using rfind instead of find.s
206            if let Some(result) = if *end {
207                search_str.rfind(&**substring)
208            } else {
209                search_str.find(&**substring)
210            } {
211                let result = result + start_index;
212                Value::int(
213                    if *graphemes {
214                        // Having found the substring's byte index, convert to grapheme index.
215                        // grapheme_indices iterates graphemes alongside their UTF-8 byte indices, so .enumerate()
216                        // is used to get the grapheme index alongside it.
217                        s.grapheme_indices(true)
218                            .enumerate()
219                            .find(|e| e.1.0 >= result)
220                            .expect("No grapheme index for substring")
221                            .0
222                    } else {
223                        result
224                    } as i64,
225                    head,
226                )
227            } else {
228                Value::int(-1, head)
229            }
230        }
231        Value::Error { .. } => input.clone(),
232        _ => Value::error(
233            ShellError::OnlySupportsThisInputType {
234                exp_input_type: "string".into(),
235                wrong_type: input.get_type().to_string(),
236                dst_span: head,
237                src_span: input.span(),
238            },
239            head,
240        ),
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use nu_protocol::ast::RangeInclusion;
247
248    use super::*;
249    use super::{Arguments, StrIndexOf, action};
250
251    #[test]
252    fn test_examples() {
253        use crate::test_examples;
254
255        test_examples(StrIndexOf {})
256    }
257
258    #[test]
259    fn returns_index_of_substring() {
260        let word = Value::test_string("Cargo.tomL");
261
262        let options = Arguments {
263            substring: String::from(".tomL"),
264            range: None,
265            cell_paths: None,
266            end: false,
267            graphemes: false,
268        };
269
270        let actual = action(&word, &options, Span::test_data());
271
272        assert_eq!(actual, Value::test_int(5));
273    }
274    #[test]
275    fn index_of_does_not_exist_in_string() {
276        let word = Value::test_string("Cargo.tomL");
277
278        let options = Arguments {
279            substring: String::from("Lm"),
280            range: None,
281            cell_paths: None,
282            end: false,
283            graphemes: false,
284        };
285
286        let actual = action(&word, &options, Span::test_data());
287
288        assert_eq!(actual, Value::test_int(-1));
289    }
290
291    #[test]
292    fn returns_index_of_next_substring() {
293        let word = Value::test_string("Cargo.Cargo");
294        let range = IntRange::new(
295            Value::int(1, Span::test_data()),
296            Value::nothing(Span::test_data()),
297            Value::nothing(Span::test_data()),
298            RangeInclusion::Inclusive,
299            Span::test_data(),
300        )
301        .expect("valid range");
302
303        let spanned_range = Spanned {
304            item: range,
305            span: Span::test_data(),
306        };
307
308        let options = Arguments {
309            substring: String::from("Cargo"),
310
311            range: Some(spanned_range),
312            cell_paths: None,
313            end: false,
314            graphemes: false,
315        };
316
317        let actual = action(&word, &options, Span::test_data());
318        assert_eq!(actual, Value::test_int(6));
319    }
320
321    #[test]
322    fn index_does_not_exist_due_to_end_index() {
323        let word = Value::test_string("Cargo.Banana");
324        let range = IntRange::new(
325            Value::nothing(Span::test_data()),
326            Value::nothing(Span::test_data()),
327            Value::int(5, Span::test_data()),
328            RangeInclusion::Inclusive,
329            Span::test_data(),
330        )
331        .expect("valid range");
332
333        let spanned_range = Spanned {
334            item: range,
335            span: Span::test_data(),
336        };
337
338        let options = Arguments {
339            substring: String::from("Banana"),
340
341            range: Some(spanned_range),
342            cell_paths: None,
343            end: false,
344            graphemes: false,
345        };
346
347        let actual = action(&word, &options, Span::test_data());
348        assert_eq!(actual, Value::test_int(-1));
349    }
350
351    #[test]
352    fn returns_index_of_nums_in_middle_due_to_index_limit_from_both_ends() {
353        let word = Value::test_string("123123123");
354        let range = IntRange::new(
355            Value::int(2, Span::test_data()),
356            Value::nothing(Span::test_data()),
357            Value::int(6, Span::test_data()),
358            RangeInclusion::Inclusive,
359            Span::test_data(),
360        )
361        .expect("valid range");
362
363        let spanned_range = Spanned {
364            item: range,
365            span: Span::test_data(),
366        };
367
368        let options = Arguments {
369            substring: String::from("123"),
370
371            range: Some(spanned_range),
372            cell_paths: None,
373            end: false,
374            graphemes: false,
375        };
376
377        let actual = action(&word, &options, Span::test_data());
378        assert_eq!(actual, Value::test_int(3));
379    }
380
381    #[test]
382    fn index_does_not_exists_due_to_strict_bounds() {
383        let word = Value::test_string("123456");
384        let range = IntRange::new(
385            Value::int(2, Span::test_data()),
386            Value::nothing(Span::test_data()),
387            Value::int(5, Span::test_data()),
388            RangeInclusion::RightExclusive,
389            Span::test_data(),
390        )
391        .expect("valid range");
392
393        let spanned_range = Spanned {
394            item: range,
395            span: Span::test_data(),
396        };
397
398        let options = Arguments {
399            substring: String::from("1"),
400
401            range: Some(spanned_range),
402            cell_paths: None,
403            end: false,
404            graphemes: false,
405        };
406
407        let actual = action(&word, &options, Span::test_data());
408        assert_eq!(actual, Value::test_int(-1));
409    }
410
411    #[test]
412    fn use_utf8_bytes() {
413        let word = Value::string(String::from("๐Ÿ‡ฏ๐Ÿ‡ตใปใ’ ใตใŒ ใดใ‚ˆ"), Span::test_data());
414
415        let options = Arguments {
416            substring: String::from("ใตใŒ"),
417            range: None,
418            cell_paths: None,
419            end: false,
420            graphemes: false,
421        };
422
423        let actual = action(&word, &options, Span::test_data());
424        assert_eq!(actual, Value::test_int(15));
425    }
426
427    #[test]
428    fn index_is_not_a_char_boundary() {
429        let word = Value::string(String::from("๐Ÿ’›"), Span::test_data());
430
431        let range = IntRange::new(
432            Value::int(0, Span::test_data()),
433            Value::int(1, Span::test_data()),
434            Value::int(2, Span::test_data()),
435            RangeInclusion::Inclusive,
436            Span::test_data(),
437        )
438        .expect("valid range");
439
440        let spanned_range = Spanned {
441            item: range,
442            span: Span::test_data(),
443        };
444
445        let options = Arguments {
446            substring: String::new(),
447
448            range: Some(spanned_range),
449            cell_paths: None,
450            end: false,
451            graphemes: false,
452        };
453
454        let actual = action(&word, &options, Span::test_data());
455        assert!(actual.is_error());
456    }
457
458    #[test]
459    fn index_is_out_of_bounds() {
460        let word = Value::string(String::from("hello"), Span::test_data());
461
462        let range = IntRange::new(
463            Value::int(-1, Span::test_data()),
464            Value::int(1, Span::test_data()),
465            Value::int(3, Span::test_data()),
466            RangeInclusion::Inclusive,
467            Span::test_data(),
468        )
469        .expect("valid range");
470
471        let spanned_range = Spanned {
472            item: range,
473            span: Span::test_data(),
474        };
475
476        let options = Arguments {
477            substring: String::from("h"),
478
479            range: Some(spanned_range),
480            cell_paths: None,
481            end: false,
482            graphemes: false,
483        };
484
485        let actual = action(&word, &options, Span::test_data());
486        assert_eq!(actual, Value::test_int(-1));
487    }
488}