Skip to main content

scope_engine/
selector.rs

1use std::path::{Path, PathBuf};
2
3use regex::Regex;
4
5type OptionalLineRangeSuffix<'a> = (&'a str, Option<(usize, usize)>);
6
7/// A parsed SCOPE selector. The selector is a positioning DSL: it locates
8/// targets/ranges, but it does not encode operation semantics.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ParsedSelector {
11    /// File path relative to project root.
12    pub file_path: PathBuf,
13    /// Parsed selector target.
14    pub target: SelectorTarget,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum SelectorTarget {
19    Symbol(SymbolSelector),
20    LineRange {
21        start_line: usize,
22        end_line: usize,
23    },
24    AroundLine {
25        line: usize,
26        context: usize,
27    },
28    Match {
29        pattern: String,
30        around: Option<usize>,
31    },
32    BeforeLine {
33        line: usize,
34    },
35    AfterLine {
36        line: usize,
37    },
38    Enclosing {
39        line: usize,
40    },
41    Outline,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SymbolSelector {
46    /// The kind of symbol (function, struct, etc.) or Unknown for bare names.
47    pub kind: SymbolKind,
48    /// The symbol name to match against AST nodes.
49    pub name: String,
50    /// Optional 1-based line range disambiguator: `#Lstart-Lend`.
51    pub line_range: Option<(usize, usize)>,
52}
53
54impl ParsedSelector {
55    #[must_use]
56    pub const fn as_symbol(&self) -> Option<&SymbolSelector> {
57        match &self.target {
58            SelectorTarget::Symbol(symbol) => Some(symbol),
59            _ => None,
60        }
61    }
62
63    /// Legacy accessor for callers/tests that still operate on symbol selectors.
64    #[must_use]
65    pub fn kind(&self) -> Option<&SymbolKind> {
66        self.as_symbol().map(|symbol| &symbol.kind)
67    }
68
69    /// Legacy accessor for callers/tests that still operate on symbol selectors.
70    #[must_use]
71    pub fn name(&self) -> Option<&str> {
72        self.as_symbol().map(|symbol| symbol.name.as_str())
73    }
74
75    /// Legacy accessor for callers/tests that still operate on symbol selectors.
76    #[must_use]
77    pub fn line_range(&self) -> Option<(usize, usize)> {
78        self.as_symbol().and_then(|symbol| symbol.line_range)
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum SymbolKind {
84    Function,
85    Struct,
86    Enum,
87    Trait,
88    Impl,
89    Class,
90    /// Mod, const, static, type alias, or bare name (fuzzy match all).
91    Unknown,
92}
93
94impl SymbolKind {
95    /// Parse a symbol-kind prefix like "fn", "struct", "enum", "trait", "impl".
96    fn from_prefix(prefix: &str) -> Self {
97        match prefix {
98            // Rust
99            "struct" | "type" => Self::Struct,
100            "enum" => Self::Enum,
101            "trait" | "interface" => Self::Trait,
102            "impl" => Self::Impl,
103            // Go
104            // Java/C++/C#/Ruby/PHP
105            "class" => Self::Class,
106            "fn" | "func" | "method" | "constructor" | "def" => Self::Function,
107            _ => Self::Unknown,
108        }
109    }
110
111    /// Heuristic: guess the kind from a tree-sitter node kind string.
112    /// Used to map tree-sitter parse results back to `SymbolKind`.
113    #[must_use]
114    pub fn from_ts_node_kind(kind: &str) -> Self {
115        match kind {
116            "struct_item"
117            | "type_alias_declaration"
118            | "type_declaration"
119            | "type_identifier"
120            | "struct_specifier" => Self::Struct,
121            "enum_item" | "enum_declaration" => Self::Enum,
122            "trait_item" | "interface_declaration" => Self::Trait,
123            "impl_item" => Self::Impl,
124            // Python tree-sitter node types
125            "class_definition" | "class_declaration" | "class_specifier" => Self::Class,
126            // TypeScript/JavaScript node types
127            // Go tree-sitter node types
128            // Java tree-sitter node types
129            // C/C++ tree-sitter node types
130            // Ruby tree-sitter node types
131            "function_item"
132            | "function_definition"
133            | "decorated_definition"
134            | "function_declaration"
135            | "method_definition"
136            | "arrow_function"
137            | "variable_declarator"
138            | "method_declaration"
139            | "constructor_declaration"
140            | "singleton_method" => Self::Function,
141            _ => Self::Unknown,
142        }
143    }
144}
145
146impl std::fmt::Display for SymbolKind {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::Function => write!(f, "fn"),
150            Self::Struct => write!(f, "struct"),
151            Self::Enum => write!(f, "enum"),
152            Self::Trait => write!(f, "trait"),
153            Self::Impl => write!(f, "impl"),
154            Self::Class => write!(f, "class"),
155            Self::Unknown => write!(f, "symbol"),
156        }
157    }
158}
159
160/// Parse a selector string of the form `file_path::[kind ]name`.
161///
162/// The `::` separates the file path from the symbol expression.
163/// After `::`, an optional kind prefix (`fn`, `struct`, etc.) may appear,
164/// followed by the symbol name.  Trailing `()` is stripped from function names.
165///
166/// # Errors
167/// Returns an error string if the input is missing `::`, has an empty file path,
168/// or has an empty symbol name.
169pub fn parse_selector(input: &str) -> Result<ParsedSelector, String> {
170    if !input.contains("::")
171        && let Some((file_part, suffix)) = input.split_once('#')
172    {
173        return parse_hash_selector(file_part, suffix, input);
174    }
175
176    // Split on the first `::`
177    let (file_part, symbol_part) = input.split_once("::").ok_or_else(|| {
178        format!("selector must contain '::' separating file path from symbol: '{input}'")
179    })?;
180
181    let file_path = parse_file_path(file_part)?;
182
183    let symbol_part = symbol_part.trim();
184    if symbol_part.is_empty() {
185        return Err("selector symbol part is empty after '::'".to_string());
186    }
187
188    let (symbol_part, line_range) = parse_line_range_suffix(symbol_part)?;
189
190    // Parse the symbol part: optional kind prefix + name
191    let (kind, name) = parse_symbol_expr(symbol_part);
192
193    if name.is_empty() {
194        return Err(format!("selector symbol name is empty: '{symbol_part}'"));
195    }
196
197    Ok(ParsedSelector {
198        file_path,
199        target: SelectorTarget::Symbol(SymbolSelector {
200            kind,
201            name,
202            line_range,
203        }),
204    })
205}
206
207fn parse_file_path(file_part: &str) -> Result<PathBuf, String> {
208    let file_path = PathBuf::from(file_part.trim());
209    if file_path.as_os_str().is_empty() {
210        return Err("selector file path is empty".to_string());
211    }
212    Ok(file_path)
213}
214
215fn parse_hash_selector(
216    file_part: &str,
217    suffix: &str,
218    input: &str,
219) -> Result<ParsedSelector, String> {
220    let file_path = parse_file_path(file_part)?;
221    let target = if let Some(range_expr) = suffix.strip_prefix('L') {
222        let (start_line, end_line) = parse_line_range_expr(range_expr)?;
223        SelectorTarget::LineRange {
224            start_line,
225            end_line,
226        }
227    } else if let Some(expr) = suffix.strip_prefix("around:L") {
228        let (line, context) = parse_around_line_expr(expr)?;
229        SelectorTarget::AroundLine { line, context }
230    } else if let Some(expr) = suffix.strip_prefix("match:/") {
231        parse_match_target(expr)?
232    } else if let Some(expr) = suffix.strip_prefix("before:L") {
233        let line = parse_positive_usize(expr, "before line")?;
234        SelectorTarget::BeforeLine { line }
235    } else if let Some(expr) = suffix.strip_prefix("after:L") {
236        let line = parse_positive_usize(expr, "after line")?;
237        SelectorTarget::AfterLine { line }
238    } else if let Some(expr) = suffix.strip_prefix("enclosing:L") {
239        let line = parse_positive_usize(expr, "enclosing line")?;
240        SelectorTarget::Enclosing { line }
241    } else if suffix == "outline" {
242        SelectorTarget::Outline
243    } else {
244        return Err(format!("unsupported selector suffix in '{input}'"));
245    };
246
247    Ok(ParsedSelector { file_path, target })
248}
249
250/// Parse an optional `#Lstart-Lend` line-range disambiguator suffix.
251fn parse_line_range_suffix(expr: &str) -> Result<OptionalLineRangeSuffix<'_>, String> {
252    let Some((symbol_expr, range_expr)) = expr.rsplit_once("#L") else {
253        return Ok((expr, None));
254    };
255
256    let symbol_expr = symbol_expr.trim_end();
257    if symbol_expr.is_empty() {
258        return Err(format!(
259            "selector symbol name is empty before line range: '{expr}'"
260        ));
261    }
262
263    let (start_str, end_str) = range_expr
264        .split_once("-L")
265        .or_else(|| range_expr.split_once('-'))
266        .ok_or_else(|| format!("bad selector line range '#L{range_expr}'"))?;
267    let start = start_str
268        .parse::<usize>()
269        .map_err(|_| format!("bad selector line range start: '#L{range_expr}'"))?;
270    let end = end_str
271        .parse::<usize>()
272        .map_err(|_| format!("bad selector line range end: '#L{range_expr}'"))?;
273    if start == 0 || end == 0 || start > end {
274        return Err(format!("bad selector line range '#L{range_expr}'"));
275    }
276
277    Ok((symbol_expr, Some((start, end))))
278}
279
280fn parse_line_range_expr(expr: &str) -> Result<(usize, usize), String> {
281    let (start_str, end_str) = expr
282        .split_once("-L")
283        .or_else(|| expr.split_once('-'))
284        .ok_or_else(|| format!("bad selector line range '#L{expr}'"))?;
285    let start = parse_positive_usize(start_str, "line range start")?;
286    let end = parse_positive_usize(end_str, "line range end")?;
287    if start > end {
288        return Err(format!("bad selector line range '#L{expr}'"));
289    }
290    Ok((start, end))
291}
292
293fn parse_around_line_expr(expr: &str) -> Result<(usize, usize), String> {
294    let (line_str, context_str) = expr
295        .split_once('±')
296        .or_else(|| expr.split_once("+-"))
297        .or_else(|| expr.split_once("+/-"))
298        .ok_or_else(|| format!("bad around selector '#around:L{expr}'"))?;
299    Ok((
300        parse_positive_usize(line_str, "around line")?,
301        parse_positive_usize(context_str, "around context")?,
302    ))
303}
304
305fn parse_match_target(expr: &str) -> Result<SelectorTarget, String> {
306    let (pattern, rest) = expr
307        .split_once('/')
308        .ok_or_else(|| "bad match selector; expected #match:/pattern/".to_string())?;
309    if pattern.is_empty() {
310        return Err("match selector pattern is empty".to_string());
311    }
312    Regex::new(pattern).map_err(|e| format!("bad match selector regex: {e}"))?;
313    let around = if rest.is_empty() {
314        None
315    } else if let Some(around_expr) = rest.strip_prefix("#around:") {
316        Some(parse_positive_usize(around_expr, "match around context")?)
317    } else {
318        return Err(format!("unsupported match selector suffix: '{rest}'"));
319    };
320    Ok(SelectorTarget::Match {
321        pattern: pattern.to_string(),
322        around,
323    })
324}
325
326fn parse_positive_usize(input: &str, label: &str) -> Result<usize, String> {
327    let value = input
328        .parse::<usize>()
329        .map_err(|_| format!("bad {label}: '{input}'"))?;
330    if value == 0 {
331        return Err(format!("bad {label}: '{input}'"));
332    }
333    Ok(value)
334}
335
336/// Parse the symbol expression (everything after `::`).
337///
338/// Recognised forms:
339/// - `fn name` or `fn name()` → (Function, "name")
340/// - `struct Name`            → (Struct, "Name")
341/// - `enum Name`              → (Enum, "Name")
342/// - `trait Name`             → (Trait, "Name")
343/// - `impl Type`              → (Impl, "Type")
344/// - `impl Trait for Type`    → (Impl, "Trait")  — we take the trait name
345/// - bare name                → (Unknown, "name")
346fn parse_symbol_expr(expr: &str) -> (SymbolKind, String) {
347    let expr = expr.trim();
348
349    // Strip trailing `()` from function-like names
350    let expr = expr.strip_suffix("()").unwrap_or(expr);
351
352    // Check for kind prefixes
353    let parts: Vec<&str> = expr.splitn(2, char::is_whitespace).collect();
354    if parts.len() == 2 {
355        let prefix = parts[0];
356        let remainder = parts[1].trim();
357        let kind = SymbolKind::from_prefix(prefix);
358        if !matches!(kind, SymbolKind::Unknown) {
359            // For `impl Trait for Type`, take the trait name
360            if matches!(kind, SymbolKind::Impl)
361                && let Some(trait_name) = remainder.split_whitespace().next()
362            {
363                return (kind, trait_name.to_string());
364            }
365            // Strip trailing parens from remainder too (e.g. "fn old()")
366            let name = remainder.strip_suffix("()").unwrap_or(remainder);
367            return (kind, name.to_string());
368        }
369    }
370
371    // Bare name — fuzzy match all kinds
372    (SymbolKind::Unknown, expr.to_string())
373}
374
375/// Resolve a selector's file path against a project root, and return the absolute path
376/// along with the file extension (for language detection).
377///
378/// # Errors
379///
380/// Returns an error if the selected file does not exist or has no extension.
381pub fn resolve_file(
382    selector: &ParsedSelector,
383    project_root: &Path,
384) -> Result<(PathBuf, String), String> {
385    let full_path = project_root.join(&selector.file_path);
386    if !full_path.exists() {
387        return Err(format!("file not found: {}", full_path.display()));
388    }
389    let ext = full_path
390        .extension()
391        .and_then(|e| e.to_str())
392        .unwrap_or("")
393        .to_string();
394    if ext.is_empty() {
395        return Err(format!(
396            "cannot determine language from file: {}",
397            full_path.display()
398        ));
399    }
400    Ok((full_path, ext))
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn parse_fn_selector() {
409        let sel = parse_selector("src/foo.rs::fn authenticate").unwrap();
410        assert_eq!(sel.file_path, PathBuf::from("src/foo.rs"));
411        assert_eq!(sel.kind(), Some(&SymbolKind::Function));
412        assert_eq!(sel.name(), Some("authenticate"));
413    }
414
415    #[test]
416    fn parse_fn_with_parens() {
417        let sel = parse_selector("src/foo.rs::fn authenticate()").unwrap();
418        assert_eq!(sel.name(), Some("authenticate"));
419    }
420
421    #[test]
422    fn parse_line_range_disambiguator() {
423        let sel = parse_selector("src/foo.rs::fn authenticate #L10-L20").unwrap();
424        assert_eq!(sel.name(), Some("authenticate"));
425        assert_eq!(sel.line_range(), Some((10, 20)));
426    }
427
428    #[test]
429    fn invalid_line_range_disambiguator_is_error() {
430        assert!(parse_selector("src/foo.rs::fn authenticate #L20-L10").is_err());
431        assert!(parse_selector("src/foo.rs::fn authenticate #Labc-L20").is_err());
432    }
433
434    #[test]
435    fn parse_struct_selector() {
436        let sel = parse_selector("src/lib.rs::struct Config").unwrap();
437        assert_eq!(sel.kind(), Some(&SymbolKind::Struct));
438        assert_eq!(sel.name(), Some("Config"));
439    }
440
441    #[test]
442    fn parse_enum_selector() {
443        let sel = parse_selector("src/types.rs::enum Color").unwrap();
444        assert_eq!(sel.kind(), Some(&SymbolKind::Enum));
445        assert_eq!(sel.name(), Some("Color"));
446    }
447
448    #[test]
449    fn parse_trait_selector() {
450        let sel = parse_selector("src/lib.rs::trait Serialize").unwrap();
451        assert_eq!(sel.kind(), Some(&SymbolKind::Trait));
452        assert_eq!(sel.name(), Some("Serialize"));
453    }
454
455    #[test]
456    fn parse_impl_selector() {
457        let sel = parse_selector("src/foo.rs::impl MyStruct").unwrap();
458        assert_eq!(sel.kind(), Some(&SymbolKind::Impl));
459        assert_eq!(sel.name(), Some("MyStruct"));
460    }
461
462    #[test]
463    fn parse_impl_for_selector() {
464        let sel = parse_selector("src/foo.rs::impl Display for MyStruct").unwrap();
465        assert_eq!(sel.kind(), Some(&SymbolKind::Impl));
466        assert_eq!(sel.name(), Some("Display"));
467    }
468
469    #[test]
470    fn parse_bare_name() {
471        let sel = parse_selector("src/foo.rs::authenticate").unwrap();
472        assert_eq!(sel.kind(), Some(&SymbolKind::Unknown));
473        assert_eq!(sel.name(), Some("authenticate"));
474    }
475
476    #[test]
477    fn parse_with_spaces_in_path() {
478        // File paths with spaces are unusual but valid
479        let sel = parse_selector("some dir/file.rs::fn hello").unwrap();
480        assert_eq!(sel.file_path, PathBuf::from("some dir/file.rs"));
481        assert_eq!(sel.name(), Some("hello"));
482    }
483
484    #[test]
485    fn missing_double_colon_is_error() {
486        assert!(parse_selector("src/foo.rs").is_err());
487    }
488
489    #[test]
490    fn empty_file_path_is_error() {
491        assert!(parse_selector("::fn foo").is_err());
492    }
493
494    #[test]
495    fn empty_symbol_is_error() {
496        assert!(parse_selector("src/foo.rs::").is_err());
497    }
498    #[test]
499    fn parse_scope_range_and_context_selectors() {
500        let sel = parse_selector("src/foo.rs#L120-L180").unwrap();
501        assert_eq!(sel.file_path, PathBuf::from("src/foo.rs"));
502        assert_eq!(
503            sel.target,
504            SelectorTarget::LineRange {
505                start_line: 120,
506                end_line: 180
507            }
508        );
509
510        let sel = parse_selector("src/foo.rs#around:L150±40").unwrap();
511        assert_eq!(
512            sel.target,
513            SelectorTarget::AroundLine {
514                line: 150,
515                context: 40
516            }
517        );
518
519        let sel = parse_selector("src/foo.rs#enclosing:L150").unwrap();
520        assert_eq!(sel.target, SelectorTarget::Enclosing { line: 150 });
521
522        let sel = parse_selector("src/foo.rs#before:L150").unwrap();
523        assert_eq!(sel.target, SelectorTarget::BeforeLine { line: 150 });
524
525        let sel = parse_selector("src/foo.rs#after:L150").unwrap();
526        assert_eq!(sel.target, SelectorTarget::AfterLine { line: 150 });
527
528        let sel = parse_selector("src/foo.rs#outline").unwrap();
529        assert_eq!(sel.target, SelectorTarget::Outline);
530    }
531
532    #[test]
533    fn parse_scope_match_selectors() {
534        let sel = parse_selector("src/foo.rs#match:/ProjectInstructions/").unwrap();
535        assert_eq!(
536            sel.target,
537            SelectorTarget::Match {
538                pattern: "ProjectInstructions".to_string(),
539                around: None
540            }
541        );
542
543        let sel = parse_selector("src/foo.rs#match:/ProjectInstructions/#around:40").unwrap();
544        assert_eq!(
545            sel.target,
546            SelectorTarget::Match {
547                pattern: "ProjectInstructions".to_string(),
548                around: Some(40)
549            }
550        );
551    }
552}