Skip to main content

nu_command/filesystem/idx/
search.rs

1use super::state::stream_grep;
2use fff_search::GrepMode;
3use nu_engine::command_prelude::*;
4use nu_protocol::Range;
5use std::ops::Bound;
6
7#[derive(Clone)]
8pub struct IdxSearch;
9
10impl Command for IdxSearch {
11    fn name(&self) -> &str {
12        "idx search"
13    }
14
15    fn signature(&self) -> Signature {
16        Signature::build(self.name())
17            .rest(
18                "pattern",
19                SyntaxShape::String,
20                "One or more search patterns.",
21            )
22            .switch("regex", "Use regular-expression matching mode.", Some('r'))
23            .switch("fuzzy", "Use fuzzy line-matching mode.", Some('f'))
24            .named(
25                "limit",
26                SyntaxShape::Int,
27                "Maximum number of matches to collect.",
28                Some('l'),
29            )
30            .named(
31                "context",
32                SyntaxShape::OneOf(vec![SyntaxShape::Range, SyntaxShape::Int]),
33                "The number of context lines to include before and after each match can be specified as an integer or a range. An integer sets both the before and after context to that number, while a range uses a negative value for lines before and a positive value for lines after (e.g., -3..5).",
34                Some('c'),
35            )
36            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::record())))])
37            .category(Category::FileSystem)
38    }
39
40    fn description(&self) -> &str {
41        "Search indexed file contents."
42    }
43
44    fn extra_description(&self) -> &str {
45        "Mode selection: plain text is the default and treats each pattern literally, `--regex` evaluates the patterns as regular expressions, and `--fuzzy` performs approximate line matching."
46    }
47
48    fn examples(&self) -> Vec<Example<'_>> {
49        vec![
50            Example {
51                description: "Search indexed file contents for a plain text pattern.",
52                example: "idx search hello",
53                result: None,
54            },
55            Example {
56                description: "Search using a regular expression.",
57                example: "idx search --regex 'fn \\w+'",
58                result: None,
59            },
60            Example {
61                description: "Search with multiple patterns simultaneously.",
62                example: "idx search TODO FIXME HACK",
63                result: None,
64            },
65            Example {
66                description: "Include 2 lines of context before and 5 lines after each match.",
67                example: "idx search error -2..5",
68                result: None,
69            },
70            Example {
71                description: "Brackets and question marks are treated as literal text, not glob patterns.",
72                example: "idx search 'arr[0]'",
73                result: None,
74            },
75            Example {
76                description: "Glob patterns with a path separator filter which files to search.",
77                example: "idx search pattern tests/*",
78                result: None,
79            },
80            Example {
81                description: "Brace expansion globs also filter which files to search.",
82                example: "idx search pattern *.{rs,js}",
83                result: None,
84            },
85        ]
86    }
87
88    fn run(
89        &self,
90        engine_state: &EngineState,
91        stack: &mut Stack,
92        call: &Call,
93        _input: PipelineData,
94    ) -> Result<PipelineData, ShellError> {
95        let patterns: Vec<String> = call.rest(engine_state, stack, 0)?;
96        if patterns.is_empty() {
97            return Err(ShellError::MissingParameter {
98                param_name: "pattern".to_string(),
99                span: call.head,
100            });
101        }
102
103        let regex = call.has_flag(engine_state, stack, "regex")?;
104        let fuzzy = call.has_flag(engine_state, stack, "fuzzy")?;
105
106        if regex && fuzzy {
107            return Err(ShellError::IncompatibleParameters {
108                left_message: "--regex cannot be used with --fuzzy".to_string(),
109                left_span: call.get_flag_span(stack, "regex").unwrap_or(call.head),
110                right_message: "--fuzzy cannot be used with --regex".to_string(),
111                right_span: call.get_flag_span(stack, "fuzzy").unwrap_or(call.head),
112            });
113        }
114
115        let limit = call
116            .get_flag::<i64>(engine_state, stack, "limit")?
117            .and_then(|v| usize::try_from(v).ok())
118            .unwrap_or(50);
119
120        let mode = if fuzzy {
121            GrepMode::Fuzzy
122        } else if regex {
123            GrepMode::Regex
124        } else {
125            GrepMode::PlainText
126        };
127
128        let context_param: Option<Value> = call.get_flag(engine_state, stack, "context")?;
129        let (before_context, after_context) = match context_param {
130            Some(Value::Int { val: i, .. }) if i < 0 => {
131                return Err(ShellError::UnsupportedInput {
132                    msg: "Context must be specified as an or a range (e.g. -3..5). Negative value for before-context, positive value for after-context.".into(),
133                    input: "value originates from here".into(),
134                    msg_span: call.head,
135                    input_span: call.head,
136                });
137            }
138            Some(Value::Int { val: n, .. }) => (n as usize, n as usize),
139            Some(Value::Range { val: range, .. }) => {
140                // Valid cases
141                match *range {
142                    Range::IntRange(r) => {
143                        // Reject three-part ranges like -3..1..5 (explicit step != 1)
144                        if r.step() != 1 {
145                            return Err(ShellError::UnsupportedInput {
146                                msg: "Context range must not have an explicit step (e.g. use -3..5, not -3..1..5)".into(),
147                                input: "value originates from here".into(),
148                                msg_span: call.head,
149                                input_span: call.head,
150                            });
151                        }
152
153                        let start = r.start();
154                        if start > 0 {
155                            return Err(ShellError::UnsupportedInput {
156                                msg: "Context range start must be <= 0 (use a negative value for before-context, e.g. -3..5)".into(),
157                                input: "value originates from here".into(),
158                                msg_span: call.head,
159                                input_span: call.head,
160                            });
161                        }
162
163                        let end_val = match r.end() {
164                            Bound::Included(e) | Bound::Excluded(e) => e,
165                            Bound::Unbounded => {
166                                return Err(ShellError::UnsupportedInput {
167                                    msg: "Context range must have a bounded end (use a positive value for after-context, e.g. -3..5)".into(),
168                                    input: "value originates from here".into(),
169                                    msg_span: call.head,
170                                    input_span: call.head,
171                                });
172                            }
173                        };
174
175                        if end_val < 0 {
176                            return Err(ShellError::UnsupportedInput {
177                                msg: "Context range end must be >= 0 (use a positive value for after-context, e.g. -3..5)".into(),
178                                input: "value originates from here".into(),
179                                msg_span: call.head,
180                                input_span: call.head,
181                            });
182                        }
183
184                        let before = start.unsigned_abs() as usize;
185                        let after = end_val as usize;
186                        (before, after)
187                    }
188                    Range::FloatRange(_) => {
189                        return Err(ShellError::UnsupportedInput {
190                            msg: "Float ranges are not supported for context".into(),
191                            input: "value originates from here".into(),
192                            msg_span: call.head,
193                            input_span: call.head,
194                        });
195                    }
196                }
197            }
198            Some(other) => {
199                return Err(ShellError::UnsupportedInput {
200                    msg: format!(
201                        "Context must be an integer or range, but got {}",
202                        other.get_type()
203                    ),
204                    input: "value originates from here".into(),
205                    msg_span: call.head,
206                    input_span: call.head,
207                });
208            }
209            None => (0usize, 0usize),
210        };
211
212        let signals = engine_state.signals();
213        let cwd = engine_state.cwd(Some(stack))?.into_std_path_buf();
214        let ctx = super::state::GrepSearchContext {
215            patterns: &patterns,
216            mode,
217            page_limit: limit,
218            span: call.head,
219            before_context,
220            after_context,
221            cwd: Some(cwd.as_path()),
222            signals,
223        };
224        stream_grep(ctx)
225    }
226}