1use std::path::{Path, PathBuf};
2
3use regex::Regex;
4
5type OptionalLineRangeSuffix<'a> = (&'a str, Option<(usize, usize)>);
6
7#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ParsedSelector {
11 pub file_path: PathBuf,
13 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 pub kind: SymbolKind,
48 pub name: String,
50 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 #[must_use]
65 pub fn kind(&self) -> Option<&SymbolKind> {
66 self.as_symbol().map(|symbol| &symbol.kind)
67 }
68
69 #[must_use]
71 pub fn name(&self) -> Option<&str> {
72 self.as_symbol().map(|symbol| symbol.name.as_str())
73 }
74
75 #[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 Unknown,
92}
93
94impl SymbolKind {
95 fn from_prefix(prefix: &str) -> Self {
97 match prefix {
98 "struct" | "type" => Self::Struct,
100 "enum" => Self::Enum,
101 "trait" | "interface" => Self::Trait,
102 "impl" => Self::Impl,
103 "class" => Self::Class,
106 "fn" | "func" | "method" | "constructor" | "def" => Self::Function,
107 _ => Self::Unknown,
108 }
109 }
110
111 #[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 "class_definition" | "class_declaration" | "class_specifier" => Self::Class,
126 "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
160pub 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 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 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
250fn 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
336fn parse_symbol_expr(expr: &str) -> (SymbolKind, String) {
347 let expr = expr.trim();
348
349 let expr = expr.strip_suffix("()").unwrap_or(expr);
351
352 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 if matches!(kind, SymbolKind::Impl)
361 && let Some(trait_name) = remainder.split_whitespace().next()
362 {
363 return (kind, trait_name.to_string());
364 }
365 let name = remainder.strip_suffix("()").unwrap_or(remainder);
367 return (kind, name.to_string());
368 }
369 }
370
371 (SymbolKind::Unknown, expr.to_string())
373}
374
375pub 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 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}