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