Skip to main content

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: "A match that falls inside a grapheme cluster is reported as not found.",
137                example: "'๐Ÿ‡ฏ๐Ÿ‡ต' | str index-of --grapheme-clusters '๐Ÿ‡ต'",
138                result: Some(Value::test_int(-1)),
139            },
140            Example {
141                description: "Returns index of string in input within a`rhs open range`.",
142                example: " '.rb.rb' | str index-of '.rb' --range 1..",
143                result: Some(Value::test_int(3)),
144            },
145            Example {
146                description: "Returns index of string in input within a lhs open range.",
147                example: " '123456' | str index-of '6' --range ..4",
148                result: Some(Value::test_int(-1)),
149            },
150            Example {
151                description: "Returns index of string in input within a range.",
152                example: " '123456' | str index-of '3' --range 1..4",
153                result: Some(Value::test_int(2)),
154            },
155            Example {
156                description: "Returns index of string in input.",
157                example: " '/this/is/some/path/file.txt' | str index-of '/' -e",
158                result: Some(Value::test_int(18)),
159            },
160        ]
161    }
162}
163
164fn action(
165    input: &Value,
166    Arguments {
167        substring,
168        range,
169        end,
170        graphemes,
171        ..
172    }: &Arguments,
173    head: Span,
174) -> Value {
175    match input {
176        Value::String { val: s, .. } => {
177            let (search_str, start_index) = if let Some(spanned_range) = range {
178                let range_span = spanned_range.span;
179                let range = &spanned_range.item;
180
181                let (start, end) = range.absolute_bounds(s.len());
182                let s = match end {
183                    Bound::Excluded(end) => s.get(start..end),
184                    Bound::Included(end) => s.get(start..=end),
185                    Bound::Unbounded => s.get(start..),
186                };
187
188                let s = match s {
189                    Some(s) => s,
190                    None => {
191                        return Value::error(
192                            ShellError::OutOfBounds {
193                                left_flank: start.to_string(),
194                                right_flank: match range.end() {
195                                    Bound::Unbounded => "".to_string(),
196                                    Bound::Included(end) => format!("={end}"),
197                                    Bound::Excluded(end) => format!("<{end}"),
198                                },
199                                span: range_span,
200                            },
201                            head,
202                        );
203                    }
204                };
205                (s, start)
206            } else {
207                (s.as_str(), 0)
208            };
209
210            // When the -e flag is present, search using rfind instead of find.s
211            if let Some(result) = if *end {
212                search_str.rfind(&**substring)
213            } else {
214                search_str.find(&**substring)
215            } {
216                let result = result + start_index;
217                Value::int(
218                    if *graphemes {
219                        // Having found the substring's byte index, convert to grapheme index.
220                        // grapheme_indices iterates graphemes alongside their UTF-8 byte indices, so .enumerate()
221                        // is used to get the grapheme index alongside it.
222                        s.grapheme_indices(true)
223                            .enumerate()
224                            .find(|e| e.1.0 >= result)
225                            .map_or(-1, |e| e.0 as i64)
226                    } else {
227                        result as i64
228                    },
229                    head,
230                )
231            } else {
232                Value::int(-1, head)
233            }
234        }
235        Value::Error { .. } => input.clone(),
236        _ => Value::error(
237            ShellError::OnlySupportsThisInputType {
238                exp_input_type: "string".into(),
239                wrong_type: input.get_type().to_string(),
240                dst_span: head,
241                src_span: input.span(),
242            },
243            head,
244        ),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use nu_protocol::ast::RangeInclusion;
251
252    use super::*;
253    use super::{Arguments, StrIndexOf, action};
254
255    #[test]
256    fn test_examples() -> nu_test_support::Result {
257        nu_test_support::test().examples(StrIndexOf)
258    }
259
260    #[test]
261    fn returns_index_of_substring() {
262        let word = Value::test_string("Cargo.tomL");
263
264        let options = Arguments {
265            substring: String::from(".tomL"),
266            range: None,
267            cell_paths: None,
268            end: false,
269            graphemes: false,
270        };
271
272        let actual = action(&word, &options, Span::test_data());
273
274        assert_eq!(actual, Value::test_int(5));
275    }
276    #[test]
277    fn index_of_does_not_exist_in_string() {
278        let word = Value::test_string("Cargo.tomL");
279
280        let options = Arguments {
281            substring: String::from("Lm"),
282            range: None,
283            cell_paths: None,
284            end: false,
285            graphemes: false,
286        };
287
288        let actual = action(&word, &options, Span::test_data());
289
290        assert_eq!(actual, Value::test_int(-1));
291    }
292
293    #[test]
294    fn returns_index_of_next_substring() {
295        let word = Value::test_string("Cargo.Cargo");
296        let range = IntRange::new(
297            Value::int(1, Span::test_data()),
298            Value::nothing(Span::test_data()),
299            Value::nothing(Span::test_data()),
300            RangeInclusion::Inclusive,
301            Span::test_data(),
302        )
303        .expect("valid range");
304
305        let spanned_range = Spanned {
306            item: range,
307            span: Span::test_data(),
308        };
309
310        let options = Arguments {
311            substring: String::from("Cargo"),
312
313            range: Some(spanned_range),
314            cell_paths: None,
315            end: false,
316            graphemes: false,
317        };
318
319        let actual = action(&word, &options, Span::test_data());
320        assert_eq!(actual, Value::test_int(6));
321    }
322
323    #[test]
324    fn index_does_not_exist_due_to_end_index() {
325        let word = Value::test_string("Cargo.Banana");
326        let range = IntRange::new(
327            Value::nothing(Span::test_data()),
328            Value::nothing(Span::test_data()),
329            Value::int(5, Span::test_data()),
330            RangeInclusion::Inclusive,
331            Span::test_data(),
332        )
333        .expect("valid range");
334
335        let spanned_range = Spanned {
336            item: range,
337            span: Span::test_data(),
338        };
339
340        let options = Arguments {
341            substring: String::from("Banana"),
342
343            range: Some(spanned_range),
344            cell_paths: None,
345            end: false,
346            graphemes: false,
347        };
348
349        let actual = action(&word, &options, Span::test_data());
350        assert_eq!(actual, Value::test_int(-1));
351    }
352
353    #[test]
354    fn returns_index_of_nums_in_middle_due_to_index_limit_from_both_ends() {
355        let word = Value::test_string("123123123");
356        let range = IntRange::new(
357            Value::int(2, Span::test_data()),
358            Value::nothing(Span::test_data()),
359            Value::int(6, Span::test_data()),
360            RangeInclusion::Inclusive,
361            Span::test_data(),
362        )
363        .expect("valid range");
364
365        let spanned_range = Spanned {
366            item: range,
367            span: Span::test_data(),
368        };
369
370        let options = Arguments {
371            substring: String::from("123"),
372
373            range: Some(spanned_range),
374            cell_paths: None,
375            end: false,
376            graphemes: false,
377        };
378
379        let actual = action(&word, &options, Span::test_data());
380        assert_eq!(actual, Value::test_int(3));
381    }
382
383    #[test]
384    fn index_does_not_exists_due_to_strict_bounds() {
385        let word = Value::test_string("123456");
386        let range = IntRange::new(
387            Value::int(2, Span::test_data()),
388            Value::nothing(Span::test_data()),
389            Value::int(5, Span::test_data()),
390            RangeInclusion::RightExclusive,
391            Span::test_data(),
392        )
393        .expect("valid range");
394
395        let spanned_range = Spanned {
396            item: range,
397            span: Span::test_data(),
398        };
399
400        let options = Arguments {
401            substring: String::from("1"),
402
403            range: Some(spanned_range),
404            cell_paths: None,
405            end: false,
406            graphemes: false,
407        };
408
409        let actual = action(&word, &options, Span::test_data());
410        assert_eq!(actual, Value::test_int(-1));
411    }
412
413    #[test]
414    fn use_utf8_bytes() {
415        let word = Value::string(String::from("๐Ÿ‡ฏ๐Ÿ‡ตใปใ’ ใตใŒ ใดใ‚ˆ"), Span::test_data());
416
417        let options = Arguments {
418            substring: String::from("ใตใŒ"),
419            range: None,
420            cell_paths: None,
421            end: false,
422            graphemes: false,
423        };
424
425        let actual = action(&word, &options, Span::test_data());
426        assert_eq!(actual, Value::test_int(15));
427    }
428
429    #[test]
430    fn index_is_not_a_char_boundary() {
431        let word = Value::string(String::from("๐Ÿ’›"), Span::test_data());
432
433        let range = IntRange::new(
434            Value::int(0, Span::test_data()),
435            Value::int(1, Span::test_data()),
436            Value::int(2, Span::test_data()),
437            RangeInclusion::Inclusive,
438            Span::test_data(),
439        )
440        .expect("valid range");
441
442        let spanned_range = Spanned {
443            item: range,
444            span: Span::test_data(),
445        };
446
447        let options = Arguments {
448            substring: String::new(),
449
450            range: Some(spanned_range),
451            cell_paths: None,
452            end: false,
453            graphemes: false,
454        };
455
456        let actual = action(&word, &options, Span::test_data());
457        assert!(actual.is_error());
458    }
459
460    #[test]
461    fn index_is_out_of_bounds() {
462        let word = Value::string(String::from("hello"), Span::test_data());
463
464        let range = IntRange::new(
465            Value::int(-1, Span::test_data()),
466            Value::int(1, Span::test_data()),
467            Value::int(3, Span::test_data()),
468            RangeInclusion::Inclusive,
469            Span::test_data(),
470        )
471        .expect("valid range");
472
473        let spanned_range = Spanned {
474            item: range,
475            span: Span::test_data(),
476        };
477
478        let options = Arguments {
479            substring: String::from("h"),
480
481            range: Some(spanned_range),
482            cell_paths: None,
483            end: false,
484            graphemes: false,
485        };
486
487        let actual = action(&word, &options, Span::test_data());
488        assert_eq!(actual, Value::test_int(-1));
489    }
490}