Skip to main content

ctx/parser/
mod.rs

1//! Code parsing module using tree-sitter.
2//!
3//! This module extracts symbols and relationships from source code files
4//! using tree-sitter grammars.
5
6mod go;
7mod python;
8mod rust;
9mod solidity;
10mod typescript;
11
12use std::path::Path;
13
14use tree_sitter::{Node, Query, QueryCursor};
15
16use crate::db::{Edge, EdgeKind, ParseResult, Symbol, SymbolKind};
17
18/// Supported programming languages.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Language {
21    Rust,
22    TypeScript,
23    Tsx,
24    JavaScript,
25    Jsx,
26    Python,
27    Go,
28    Solidity,
29    Yaml,
30    Unknown,
31}
32
33impl Language {
34    /// Detect language from file extension.
35    pub fn from_path(path: &Path) -> Self {
36        match path.extension().and_then(|e| e.to_str()) {
37            Some("rs") => Language::Rust,
38            Some("ts") => Language::TypeScript,
39            Some("tsx") => Language::Tsx,
40            Some("js") | Some("mjs") | Some("cjs") => Language::JavaScript,
41            Some("jsx") => Language::Jsx,
42            Some("py") | Some("pyi") => Language::Python,
43            Some("go") => Language::Go,
44            Some("sol") => Language::Solidity,
45            Some("yaml") | Some("yml") => Language::Yaml,
46            _ => Language::Unknown,
47        }
48    }
49
50    /// Get the language name as a string.
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            Language::Rust => "rust",
54            Language::TypeScript => "typescript",
55            Language::Tsx => "tsx",
56            Language::JavaScript => "javascript",
57            Language::Jsx => "jsx",
58            Language::Python => "python",
59            Language::Go => "go",
60            Language::Solidity => "solidity",
61            Language::Yaml => "yaml",
62            Language::Unknown => "unknown",
63        }
64    }
65}
66
67/// Code parser that extracts symbols and relationships.
68pub struct CodeParser {
69    go_parser: go::GoParser,
70    python_parser: python::PythonParser,
71    rust_parser: rust::RustParser,
72    solidity_parser: solidity::SolidityParser,
73    typescript_parser: typescript::TypeScriptParser,
74}
75
76impl CodeParser {
77    /// Create a new code parser.
78    pub fn new() -> Self {
79        Self {
80            go_parser: go::GoParser::new(),
81            python_parser: python::PythonParser::new(),
82            rust_parser: rust::RustParser::new(),
83            solidity_parser: solidity::SolidityParser::new(),
84            typescript_parser: typescript::TypeScriptParser::new(),
85        }
86    }
87
88    /// Parse a source file and extract symbols/edges.
89    pub fn parse(&mut self, path: &Path, source: &str) -> Option<ParseResult> {
90        let language = Language::from_path(path);
91        let file_path = path.to_string_lossy().to_string();
92
93        match language {
94            Language::Rust => self.rust_parser.parse(&file_path, source),
95            Language::Solidity => self.solidity_parser.parse(&file_path, source),
96            Language::TypeScript => {
97                self.typescript_parser
98                    .parse(&file_path, source, typescript::JsVariant::TypeScript)
99            }
100            Language::Tsx => {
101                self.typescript_parser
102                    .parse(&file_path, source, typescript::JsVariant::Tsx)
103            }
104            Language::JavaScript => {
105                self.typescript_parser
106                    .parse(&file_path, source, typescript::JsVariant::JavaScript)
107            }
108            Language::Jsx => {
109                self.typescript_parser
110                    .parse(&file_path, source, typescript::JsVariant::Jsx)
111            }
112            Language::Python => self.python_parser.parse(&file_path, source),
113            Language::Go => self.go_parser.parse(&file_path, source),
114            _ => {
115                // Return a minimal result for unsupported languages
116                Some(ParseResult {
117                    file_path,
118                    language: language.as_str().to_string(),
119                    symbols: Vec::new(),
120                    edges: Vec::new(),
121                    module: None,
122                })
123            }
124        }
125    }
126
127    /// Check if a language is supported for full parsing.
128    /// Note: YAML is detected but not parsed (no tree-sitter grammar available).
129    pub fn is_supported(&self, path: &Path) -> bool {
130        Self::is_supported_static(path)
131    }
132
133    /// Static version of is_supported (for parallel indexing).
134    pub fn is_supported_static(path: &Path) -> bool {
135        matches!(
136            Language::from_path(path),
137            Language::Rust
138                | Language::Solidity
139                | Language::TypeScript
140                | Language::Tsx
141                | Language::JavaScript
142                | Language::Jsx
143                | Language::Python
144                | Language::Go // Language::Yaml excluded - detected but not parsed
145        )
146    }
147}
148
149impl Default for CodeParser {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155/// Extract a brief description from a docstring.
156pub fn extract_brief(docstring: &str) -> Option<String> {
157    let trimmed = docstring.trim();
158    if trimmed.is_empty() {
159        return None;
160    }
161
162    // Take the first line or sentence
163    let first_line = trimmed.lines().next()?;
164    let brief = first_line.trim();
165
166    // If it ends with a period, take it as-is
167    if brief.ends_with('.') {
168        return Some(brief.to_string());
169    }
170
171    // Otherwise, try to find the first sentence
172    if let Some(idx) = brief.find(". ") {
173        return Some(brief[..=idx].to_string());
174    }
175
176    Some(brief.to_string())
177}
178
179/// Truncate a string to a maximum length, adding "..." if truncated.
180pub fn truncate_context(s: &str, max_len: usize) -> String {
181    let s = s.trim();
182    if s.len() <= max_len {
183        s.to_string()
184    } else {
185        // Find a valid UTF-8 char boundary for truncation
186        let target = max_len.saturating_sub(3);
187        let mut end = target;
188        while end > 0 && !s.is_char_boundary(end) {
189            end -= 1;
190        }
191        format!("{}...", &s[..end])
192    }
193}
194
195/// Get a snippet of context around a line.
196#[allow(dead_code)]
197pub fn get_context_snippet(source: &str, line: usize, col: usize) -> Option<String> {
198    let lines: Vec<&str> = source.lines().collect();
199    if line == 0 || line > lines.len() {
200        return None;
201    }
202
203    let target_line = lines[line - 1];
204
205    // Get a reasonable snippet (up to 80 chars)
206    // Find valid UTF-8 char boundaries
207    let mut start = col.saturating_sub(20);
208    while start > 0 && !target_line.is_char_boundary(start) {
209        start -= 1;
210    }
211    let mut end = (col + 60).min(target_line.len());
212    while end < target_line.len() && !target_line.is_char_boundary(end) {
213        end += 1;
214    }
215
216    let snippet = &target_line[start..end];
217    Some(snippet.trim().to_string())
218}
219
220/// Maps a capture name prefix (e.g., "func", "class") to a SymbolKind.
221///
222/// This is used to reduce the complexity of extract_symbols functions.
223/// Capture names follow the pattern "prefix.name" or "prefix.def".
224pub struct SymbolKindMapping {
225    /// The prefix to match (e.g., "func", "class", "method")
226    pub prefix: &'static str,
227    /// The SymbolKind to assign when this prefix is matched
228    pub kind: SymbolKind,
229}
230
231impl SymbolKindMapping {
232    /// Create a new mapping from prefix to kind.
233    pub const fn new(prefix: &'static str, kind: SymbolKind) -> Self {
234        Self { prefix, kind }
235    }
236}
237
238/// Find the SymbolKind for a capture name based on a list of mappings.
239///
240/// Returns Some(kind) if the capture name matches "prefix.name" for any mapping,
241/// or None if no match is found.
242pub fn find_symbol_kind(capture_name: &str, mappings: &[SymbolKindMapping]) -> Option<SymbolKind> {
243    if !capture_name.ends_with(".name") {
244        return None;
245    }
246    let prefix = capture_name.trim_end_matches(".name");
247    mappings.iter().find(|m| m.prefix == prefix).map(|m| m.kind)
248}
249
250/// Check if a capture name is a definition capture (ends with ".def").
251pub fn is_def_capture(capture_name: &str) -> bool {
252    capture_name.ends_with(".def")
253}
254
255/// Capture name patterns for call extraction.
256/// Each tuple contains (name_patterns, expr_patterns) that the query captures should match.
257pub struct CallCapturePatterns {
258    /// Patterns that match the function/method name being called
259    pub name_patterns: &'static [&'static str],
260    /// Patterns that match the call expression node
261    pub expr_patterns: &'static [&'static str],
262}
263
264impl CallCapturePatterns {
265    /// Standard patterns for Python (call.name, method_call.name)
266    pub const STANDARD: CallCapturePatterns = CallCapturePatterns {
267        name_patterns: &["call.name", "method_call.name"],
268        expr_patterns: &["call.expr", "method_call.expr"],
269    };
270
271    /// Rust patterns include scoped calls (e.g., module::function())
272    pub const RUST: CallCapturePatterns = CallCapturePatterns {
273        name_patterns: &["call.name", "method_call.name", "scoped_call.name"],
274        expr_patterns: &["call.expr", "method_call.expr", "scoped_call.expr"],
275    };
276
277    /// TypeScript/JavaScript patterns include new expressions (e.g., new Class())
278    pub const TYPESCRIPT: CallCapturePatterns = CallCapturePatterns {
279        name_patterns: &["call.name", "method_call.name", "new.name"],
280        expr_patterns: &["call.expr", "method_call.expr", "new.expr"],
281    };
282}
283
284/// Extract call edges from an AST using a tree-sitter query.
285///
286/// This is a shared helper for all language parsers. The query should capture:
287/// - Call/method names with patterns like "call.name", "method_call.name"
288/// - Call expressions with patterns like "call.expr", "method_call.expr"
289pub fn extract_call_edges(
290    query: &Query,
291    root: &Node,
292    source: &str,
293    symbols: &[Symbol],
294    edges: &mut Vec<Edge>,
295    patterns: &CallCapturePatterns,
296) {
297    // Build a map of function ranges to their symbol IDs
298    let func_ranges: Vec<_> = symbols
299        .iter()
300        .filter(|s| matches!(s.kind, SymbolKind::Function | SymbolKind::Method))
301        .map(|s| (s.line_start, s.line_end, s.id.clone()))
302        .collect();
303
304    let mut cursor = QueryCursor::new();
305    let matches = cursor.matches(query, *root, source.as_bytes());
306
307    for m in matches {
308        let mut call_name: Option<&str> = None;
309        let mut call_node: Option<Node> = None;
310
311        for capture in m.captures {
312            let capture_name = &query.capture_names()[capture.index as usize];
313            let node = capture.node;
314            let text = node.utf8_text(source.as_bytes()).unwrap_or("");
315
316            if patterns.name_patterns.contains(&capture_name.as_str()) {
317                call_name = Some(text);
318            } else if patterns.expr_patterns.contains(&capture_name.as_str()) {
319                call_node = Some(node);
320            }
321        }
322
323        if let (Some(name), Some(node)) = (call_name, call_node) {
324            let line = node.start_position().row as u32 + 1;
325            let col = node.start_position().column as u32;
326
327            // Find which function this call is in
328            let source_id = func_ranges
329                .iter()
330                .find(|(start, end, _)| line >= *start && line <= *end)
331                .map(|(_, _, id)| id.clone());
332
333            if let Some(source_id) = source_id {
334                let context = node
335                    .utf8_text(source.as_bytes())
336                    .ok()
337                    .map(|s| truncate_context(s, 80));
338
339                // Try to resolve the target only if the context contains the qualified name
340                // (e.g., "TypeScriptParser::new()").
341                //
342                // We don't resolve based on "unique name in file" because:
343                // 1. Calls like "Vec::new()" or "Parser::new()" would match local "new" functions
344                // 2. Cross-file resolution in post-indexing phase is more accurate
345                //
346                // Common names that are almost always external are left unresolved here.
347                let target_id = if let Some(ctx) = &context {
348                    symbols
349                        .iter()
350                        .find(|s| {
351                            s.name == name
352                                && s.qualified_name
353                                    .as_ref()
354                                    .map(|qn| ctx.contains(qn))
355                                    .unwrap_or(false)
356                        })
357                        .map(|s| s.id.clone())
358                } else {
359                    None
360                };
361
362                edges.push(Edge {
363                    source_id,
364                    target_id,
365                    target_name: name.to_string(),
366                    kind: EdgeKind::Calls,
367                    line: Some(line),
368                    col: Some(col),
369                    context,
370                });
371            }
372        }
373    }
374}
375
376/// Extract module name from a file path.
377///
378/// This is a shared helper for all language parsers. The `index_names` parameter
379/// specifies which file stems should use the parent directory name instead
380/// (e.g., "index" for TypeScript, "__init__" for Python, "mod" for Rust).
381pub fn extract_module_name(file_path: &str, index_names: &[&str]) -> Option<String> {
382    let path = std::path::Path::new(file_path);
383    let stem = path.file_stem()?.to_str()?;
384
385    if index_names.contains(&stem) {
386        // Use parent directory name for index/entry files
387        path.parent()?.file_name()?.to_str().map(String::from)
388    } else {
389        Some(stem.to_string())
390    }
391}
392
393/// Parse a block doc comment (/** ... */ or /*! ... */) into clean content.
394///
395/// This is a shared helper for extracting doc comments in JSDoc, NatSpec, and Rust doc styles.
396/// It strips the comment delimiters and leading asterisks from each line.
397pub fn parse_block_doc_comment(text: &str) -> String {
398    // Strip the opening delimiter (/** or /*!)
399    let content = text
400        .trim_start_matches("/**")
401        .trim_start_matches("/*!")
402        .trim_end_matches("*/");
403
404    // Process each line: strip leading whitespace and asterisks
405    content
406        .lines()
407        .map(|l| l.trim().trim_start_matches('*').trim())
408        .filter(|l| !l.is_empty())
409        .collect::<Vec<_>>()
410        .join("\n")
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_language_detection() {
419        assert_eq!(Language::from_path(Path::new("main.rs")), Language::Rust);
420        assert_eq!(
421            Language::from_path(Path::new("app.ts")),
422            Language::TypeScript
423        );
424        assert_eq!(Language::from_path(Path::new("App.tsx")), Language::Tsx);
425        assert_eq!(
426            Language::from_path(Path::new("script.js")),
427            Language::JavaScript
428        );
429        assert_eq!(Language::from_path(Path::new("Button.jsx")), Language::Jsx);
430        assert_eq!(Language::from_path(Path::new("main.py")), Language::Python);
431        assert_eq!(Language::from_path(Path::new("main.go")), Language::Go);
432        assert_eq!(
433            Language::from_path(Path::new("Token.sol")),
434            Language::Solidity
435        );
436        assert_eq!(
437            Language::from_path(Path::new("config.yaml")),
438            Language::Yaml
439        );
440        assert_eq!(Language::from_path(Path::new("ci.yml")), Language::Yaml);
441        assert_eq!(
442            Language::from_path(Path::new("data.json")),
443            Language::Unknown
444        );
445    }
446
447    #[test]
448    fn test_extract_brief() {
449        // Multi-line extracts first line
450        assert_eq!(
451            extract_brief("This is a brief.\nMore details here."),
452            Some("This is a brief.".to_string())
453        );
454        assert_eq!(
455            extract_brief("Single line"),
456            Some("Single line".to_string())
457        );
458        assert_eq!(extract_brief(""), None);
459    }
460
461    #[test]
462    fn test_truncate_context_ascii() {
463        // Short string - no truncation
464        assert_eq!(truncate_context("hello", 10), "hello");
465
466        // Exact length - no truncation
467        assert_eq!(truncate_context("hello", 5), "hello");
468
469        // Needs truncation
470        assert_eq!(truncate_context("hello world", 8), "hello...");
471
472        // With leading/trailing whitespace
473        assert_eq!(truncate_context("  hello world  ", 8), "hello...");
474    }
475
476    #[test]
477    fn test_truncate_context_unicode() {
478        // Box drawing characters (3 bytes each: ─ is \xe2\x94\x80)
479        let box_line = "┌────────────────────────────────────────────────────────┐";
480
481        // Should not panic on Unicode
482        let result = truncate_context(box_line, 20);
483        assert!(result.ends_with("..."));
484        assert!(result.len() <= 20);
485
486        // Emoji (4 bytes each)
487        let emoji_str = "Hello 🎉🎊🎁 World";
488        let result = truncate_context(emoji_str, 12);
489        assert!(result.ends_with("..."));
490
491        // Mixed content
492        let mixed = "console.log(\"├──────┤\")";
493        let result = truncate_context(mixed, 15);
494        assert!(result.ends_with("..."));
495
496        // Chinese characters (3 bytes each)
497        let chinese = "你好世界这是一个测试";
498        let result = truncate_context(chinese, 10);
499        assert!(result.ends_with("..."));
500    }
501
502    #[test]
503    fn test_truncate_context_edge_cases() {
504        // Very short max_len
505        assert_eq!(truncate_context("hello", 3), "...");
506        assert_eq!(truncate_context("hi", 3), "hi");
507
508        // Empty string
509        assert_eq!(truncate_context("", 10), "");
510
511        // Only whitespace
512        assert_eq!(truncate_context("   ", 10), "");
513    }
514
515    #[test]
516    fn test_get_context_snippet_unicode() {
517        // Source with Unicode box drawing
518        let source = "line1\nconsole.log(\"┌────────────────────┐\")\nline3";
519
520        // Should not panic when getting snippet from line with Unicode
521        let result = get_context_snippet(source, 2, 15);
522        assert!(result.is_some());
523    }
524}