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