Skip to main content

aptu_coder_core/
parser.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Tree-sitter-based parser for extracting semantic structure from source code.
4//!
5//! This module provides language-agnostic parsing using tree-sitter queries to extract
6//! functions, classes, imports, references, and other semantic elements from source files.
7//! Two main extractors handle different use cases:
8//!
9//! - [`ElementExtractor`]: Quick extraction of function and class counts.
10//! - [`SemanticExtractor`]: Detailed semantic analysis with calls, imports, and references.
11
12use crate::languages::get_language_info;
13use crate::types::{ImplTraitInfo, SemanticAnalysis};
14use std::cell::RefCell;
15use std::collections::HashMap;
16use std::path::Path;
17use std::sync::LazyLock;
18use thiserror::Error;
19use tracing::instrument;
20use tree_sitter::{Parser, Query, QueryCursor, StreamingIterator};
21
22// Import extracted element handlers from parser_elements module
23use crate::parser_elements::{
24    extract_calls, extract_def_use, extract_elements, extract_impl_methods,
25    extract_impl_traits_from_tree, extract_imports, extract_references,
26};
27
28#[derive(Debug, Error)]
29#[non_exhaustive]
30pub enum ParserError {
31    #[error("Unsupported language: {0}")]
32    UnsupportedLanguage(String),
33    #[error("Failed to parse file: {0}")]
34    ParseError(String),
35    #[error("Invalid UTF-8 in file")]
36    InvalidUtf8,
37    #[error("Query error: {0}")]
38    QueryError(String),
39    #[error("Parse timeout exceeded: {0} microseconds")]
40    Timeout(u64),
41}
42
43/// Groups a query deadline with the configured timeout duration for use in private extract helpers.
44/// Avoids threading two separate values through every helper signature.
45#[derive(Clone, Copy)]
46pub(crate) struct TimeoutConfig {
47    /// Absolute deadline; `None` means no timeout.
48    pub deadline: Option<std::time::Instant>,
49    /// The configured timeout in microseconds (used in `ParserError::Timeout`).
50    pub micros: u64,
51}
52
53impl TimeoutConfig {
54    fn new(timeout_micros: Option<u64>) -> Self {
55        let deadline = timeout_micros
56            .map(|us| std::time::Instant::now() + std::time::Duration::from_micros(us));
57        Self {
58            deadline,
59            micros: timeout_micros.unwrap_or(0),
60        }
61    }
62
63    /// Returns `true` if the deadline has been reached.
64    pub(crate) fn is_exceeded(self) -> bool {
65        self.deadline
66            .is_some_and(|d| std::time::Instant::now() >= d)
67    }
68}
69
70/// Compiled tree-sitter queries for a language.
71/// Stores all query types: mandatory (element, call) and optional (import, impl, reference).
72pub(crate) struct CompiledQueries {
73    pub element: Query,
74    pub call: Query,
75    pub import: Option<Query>,
76    pub impl_block: Option<Query>,
77    pub reference: Option<Query>,
78    pub impl_trait: Option<Query>,
79    pub defuse: Option<Query>,
80}
81
82/// Build compiled queries for a given language.
83///
84/// Compiles all tree-sitter queries for a language, including mandatory queries
85/// (element, call) and optional queries (import, impl, reference, impl_trait, defuse).
86/// Returns an error if any query fails to compile.
87fn build_compiled_queries(
88    lang_info: &crate::languages::LanguageInfo,
89) -> Result<CompiledQueries, ParserError> {
90    let element = Query::new(&lang_info.language, lang_info.element_query).map_err(|e| {
91        ParserError::QueryError(format!(
92            "Failed to compile element query for {}: {}",
93            lang_info.name, e
94        ))
95    })?;
96
97    let call = Query::new(&lang_info.language, lang_info.call_query).map_err(|e| {
98        ParserError::QueryError(format!(
99            "Failed to compile call query for {}: {}",
100            lang_info.name, e
101        ))
102    })?;
103
104    let import = if let Some(import_query_str) = lang_info.import_query {
105        Some(
106            Query::new(&lang_info.language, import_query_str).map_err(|e| {
107                ParserError::QueryError(format!(
108                    "Failed to compile import query for {}: {}",
109                    lang_info.name, e
110                ))
111            })?,
112        )
113    } else {
114        None
115    };
116
117    let impl_block = if let Some(impl_query_str) = lang_info.impl_query {
118        Some(
119            Query::new(&lang_info.language, impl_query_str).map_err(|e| {
120                ParserError::QueryError(format!(
121                    "Failed to compile impl query for {}: {}",
122                    lang_info.name, e
123                ))
124            })?,
125        )
126    } else {
127        None
128    };
129
130    let reference = if let Some(reference_query_str) = lang_info.reference_query {
131        Some(
132            Query::new(&lang_info.language, reference_query_str).map_err(|e| {
133                ParserError::QueryError(format!(
134                    "Failed to compile reference query for {}: {}",
135                    lang_info.name, e
136                ))
137            })?,
138        )
139    } else {
140        None
141    };
142
143    let impl_trait = if let Some(impl_trait_query_str) = lang_info.impl_trait_query {
144        Some(
145            Query::new(&lang_info.language, impl_trait_query_str).map_err(|e| {
146                ParserError::QueryError(format!(
147                    "Failed to compile impl_trait query for {}: {}",
148                    lang_info.name, e
149                ))
150            })?,
151        )
152    } else {
153        None
154    };
155
156    let defuse = if let Some(defuse_query_str) = lang_info.defuse_query {
157        Some(
158            Query::new(&lang_info.language, defuse_query_str).map_err(|e| {
159                ParserError::QueryError(format!(
160                    "Failed to compile defuse query for {}: {}",
161                    lang_info.name, e
162                ))
163            })?,
164        )
165    } else {
166        None
167    };
168
169    Ok(CompiledQueries {
170        element,
171        call,
172        import,
173        impl_block,
174        reference,
175        impl_trait,
176        defuse,
177    })
178}
179
180/// Initialize the query cache with compiled queries for all supported languages.
181///
182/// Excluded from coverage: the `Err` arm is unreachable because `build_compiled_queries`
183/// only fails on invalid hardcoded query strings.
184#[cfg_attr(coverage_nightly, coverage(off))]
185fn init_query_cache() -> HashMap<&'static str, CompiledQueries> {
186    let mut cache = HashMap::new();
187
188    for lang_name in crate::lang::supported_languages() {
189        if let Some(lang_info) = get_language_info(lang_name) {
190            match build_compiled_queries(&lang_info) {
191                Ok(compiled) => {
192                    cache.insert(*lang_name, compiled);
193                }
194                Err(e) => {
195                    tracing::error!(
196                        "Failed to compile queries for language {}: {}",
197                        lang_name,
198                        e
199                    );
200                }
201            }
202        }
203    }
204
205    cache
206}
207
208/// Lazily initialized cache of compiled queries per language.
209static QUERY_CACHE: LazyLock<HashMap<&'static str, CompiledQueries>> =
210    LazyLock::new(init_query_cache);
211
212/// Get compiled queries for a language from the cache.
213fn get_compiled_queries(language: &str) -> Result<&'static CompiledQueries, ParserError> {
214    QUERY_CACHE
215        .get(language)
216        .ok_or_else(|| ParserError::UnsupportedLanguage(language.to_string()))
217}
218
219thread_local! {
220    pub(crate) static PARSER: RefCell<Parser> = RefCell::new(Parser::new());
221    pub(crate) static QUERY_CURSOR: RefCell<QueryCursor> = RefCell::new(QueryCursor::new());
222}
223
224/// Canonical API for extracting element counts from source code.
225pub struct ElementExtractor;
226
227impl ElementExtractor {
228    /// Extract function and class counts from source code.
229    ///
230    /// # Errors
231    ///
232    /// Returns `ParserError::UnsupportedLanguage` if the language is not recognized.
233    /// Returns `ParserError::ParseError` if the source code cannot be parsed.
234    /// Returns `ParserError::QueryError` if the tree-sitter query fails.
235    #[instrument(skip_all, fields(language))]
236    pub fn extract_with_depth(source: &str, language: &str) -> Result<(usize, usize), ParserError> {
237        let lang_info = get_language_info(language)
238            .ok_or_else(|| ParserError::UnsupportedLanguage(language.to_string()))?;
239
240        let tree = PARSER.with(|p| {
241            let mut parser = p.borrow_mut();
242            parser
243                .set_language(&lang_info.language)
244                .map_err(|e| ParserError::ParseError(format!("Failed to set language: {e}")))?;
245            parser
246                .parse(source, None)
247                .ok_or_else(|| ParserError::ParseError("Failed to parse".to_string()))
248        })?;
249
250        let compiled = get_compiled_queries(language)?;
251
252        let (function_count, class_count) = QUERY_CURSOR.with(|c| {
253            let mut cursor = c.borrow_mut();
254            cursor.set_max_start_depth(None);
255            let mut function_count = 0;
256            let mut class_count = 0;
257
258            let mut matches =
259                cursor.matches(&compiled.element, tree.root_node(), source.as_bytes());
260            while let Some(mat) = matches.next() {
261                for capture in mat.captures {
262                    let capture_name = compiled.element.capture_names()[capture.index as usize];
263                    match capture_name {
264                        "function" => function_count += 1,
265                        "class" => class_count += 1,
266                        _ => {}
267                    }
268                }
269            }
270            (function_count, class_count)
271        });
272
273        tracing::debug!(language = %language, functions = function_count, classes = class_count, "parse complete");
274
275        Ok((function_count, class_count))
276    }
277}
278
279/// Canonical API for detailed semantic analysis of source code.
280pub struct SemanticExtractor;
281
282impl SemanticExtractor {
283    /// Extract detailed semantic information from source code.
284    ///
285    /// This is the main entry point for comprehensive semantic analysis. It extracts:
286    /// - Function and class definitions
287    /// - Function calls and call frequency
288    /// - Import statements
289    /// - Type references
290    /// - Impl trait blocks (Rust only)
291    ///
292    /// # Arguments
293    ///
294    /// * `source` - The source code as a string
295    /// * `language` - The programming language (e.g., "rust", "python")
296    /// * `ast_recursion_limit` - Optional AST recursion depth limit (0 = unlimited)
297    /// * `timeout_micros` - Optional timeout in microseconds
298    ///
299    /// # Returns
300    ///
301    /// A `SemanticAnalysis` containing all extracted semantic information.
302    ///
303    /// # Errors
304    ///
305    /// Returns a `ParserError` if:
306    /// * `ParserError::Timeout` - The operation exceeds the specified timeout
307    /// * `ParserError::UnsupportedLanguage` - The language is not supported
308    /// * `ParserError::ParseError` - Tree-sitter parsing fails
309    #[instrument(skip_all, fields(language))]
310    pub fn extract(
311        source: &str,
312        language: &str,
313        ast_recursion_limit: Option<usize>,
314        timeout_micros: Option<u64>,
315    ) -> Result<SemanticAnalysis, ParserError> {
316        let tc = TimeoutConfig::new(timeout_micros);
317
318        // Check deadline at the start before any parsing work.
319        if tc.is_exceeded() {
320            return Err(ParserError::Timeout(tc.micros));
321        }
322
323        let lang_info = get_language_info(language)
324            .ok_or_else(|| ParserError::UnsupportedLanguage(language.to_string()))?;
325
326        let tree = PARSER.with(|p| {
327            let mut parser = p.borrow_mut();
328            parser
329                .set_language(&lang_info.language)
330                .map_err(|e| ParserError::ParseError(format!("Failed to set language: {e}")))?;
331            parser
332                .parse(source, None)
333                .ok_or_else(|| ParserError::ParseError("Failed to parse".to_string()))
334        })?;
335
336        // Check deadline after parsing
337        if tc.is_exceeded() {
338            return Err(ParserError::Timeout(tc.micros));
339        }
340
341        let compiled = get_compiled_queries(language)?;
342        let root = tree.root_node();
343
344        // Convert ast_recursion_limit: 0 means unlimited (None); positive values become Some(u32).
345        let max_depth: Option<u32> = ast_recursion_limit
346            .filter(|&limit| limit > 0)
347            .and_then(|limit| u32::try_from(limit).ok());
348
349        let mut functions = Vec::new();
350        let mut classes = Vec::new();
351        let mut imports = Vec::new();
352        let mut references = Vec::new();
353        let mut calls = Vec::new();
354        let mut call_frequency = HashMap::new();
355
356        // Extract functions and classes
357        extract_elements(
358            source,
359            compiled,
360            root,
361            max_depth,
362            &mut functions,
363            &mut classes,
364            tc,
365            &lang_info,
366        )?;
367
368        // Check deadline after extract_elements
369        if tc.is_exceeded() {
370            return Err(ParserError::Timeout(tc.micros));
371        }
372
373        extract_calls(
374            source,
375            compiled,
376            root,
377            max_depth,
378            &mut calls,
379            &mut call_frequency,
380            tc,
381        )?;
382        extract_imports(source, compiled, root, max_depth, &mut imports, tc)?;
383        extract_impl_methods(source, compiled, root, max_depth, &mut classes, tc)?;
384        extract_references(source, compiled, root, max_depth, &mut references, tc)?;
385
386        // Extract impl-trait blocks for Rust files (empty for other languages)
387        let impl_traits = if language == "rust" {
388            extract_impl_traits_from_tree(source, compiled, root, tc)?
389        } else {
390            vec![]
391        };
392
393        tracing::debug!(language = %language, functions = functions.len(), classes = classes.len(), imports = imports.len(), references = references.len(), calls = calls.len(), impl_traits = impl_traits.len(), "extraction complete");
394
395        Ok(SemanticAnalysis {
396            functions,
397            classes,
398            imports,
399            references,
400            call_frequency,
401            calls,
402            impl_traits,
403            def_use_sites: Vec::new(),
404        })
405    }
406
407    /// Fast path for extracting module metadata: functions and imports only.
408    ///
409    /// This method is optimized for the `analyze_module` tool, which only needs function
410    /// definitions and import statements. It skips the more expensive extractors (calls,
411    /// references, impl traits) and returns a lightweight `ModuleInfo` directly.
412    ///
413    /// # Arguments
414    ///
415    /// * `source` - The source code as a string
416    /// * `language` - The programming language (e.g., "rust", "python")
417    /// * `timeout` - Optional timeout configuration in microseconds
418    ///
419    /// # Returns
420    ///
421    /// A `ModuleInfo` containing the file name, line count, language, functions, and imports.
422    ///
423    /// # Errors
424    ///
425    /// Returns a `ParserError` if:
426    /// * `ParserError::Timeout` - The operation exceeds the specified timeout
427    /// * `ParserError::UnsupportedLanguage` - The language is not supported
428    /// * `ParserError::ParseError` - Tree-sitter parsing fails
429    #[instrument(skip_all, fields(language))]
430    pub fn extract_module_info(
431        source: &str,
432        language: &str,
433        timeout_micros: Option<u64>,
434    ) -> Result<crate::types::ModuleInfo, ParserError> {
435        let tc = TimeoutConfig::new(timeout_micros);
436
437        // Check deadline at the start before any parsing work.
438        if tc.is_exceeded() {
439            return Err(ParserError::Timeout(tc.micros));
440        }
441
442        let lang_info = get_language_info(language)
443            .ok_or_else(|| ParserError::UnsupportedLanguage(language.to_string()))?;
444
445        let tree = PARSER.with(|p| {
446            let mut parser = p.borrow_mut();
447            parser
448                .set_language(&lang_info.language)
449                .map_err(|e| ParserError::ParseError(format!("Failed to set language: {e}")))?;
450            parser
451                .parse(source, None)
452                .ok_or_else(|| ParserError::ParseError("Failed to parse".to_string()))
453        })?;
454
455        // Check deadline after parsing
456        if tc.is_exceeded() {
457            return Err(ParserError::Timeout(tc.micros));
458        }
459
460        let compiled = get_compiled_queries(language)?;
461        let root = tree.root_node();
462
463        let mut functions = Vec::new();
464        let mut classes = Vec::new();
465        let mut imports = Vec::new();
466
467        // Extract functions and classes
468        extract_elements(
469            source,
470            compiled,
471            root,
472            None,
473            &mut functions,
474            &mut classes,
475            tc,
476            &lang_info,
477        )?;
478
479        // Check deadline after extract_elements
480        if tc.is_exceeded() {
481            return Err(ParserError::Timeout(tc.micros));
482        }
483
484        // Extract imports
485        extract_imports(source, compiled, root, None, &mut imports, tc)?;
486
487        // Check deadline after extract_imports
488        if tc.is_exceeded() {
489            return Err(ParserError::Timeout(tc.micros));
490        }
491
492        // Map to ModuleInfo
493        let module_functions = functions
494            .into_iter()
495            .map(|f| crate::types::ModuleFunctionInfo {
496                name: f.name,
497                line: f.line,
498            })
499            .collect();
500
501        let module_imports = imports
502            .into_iter()
503            .map(|i| crate::types::ModuleImportInfo {
504                module: i.module,
505                items: i.items,
506            })
507            .collect();
508
509        let line_count = source.lines().count();
510
511        Ok(crate::types::ModuleInfo::new(
512            String::new(), // Will be set by caller
513            line_count,
514            language.to_string(),
515            module_functions,
516            module_imports,
517        ))
518    }
519
520    /// Parse `source` in `language`, run the defuse query for `symbol`, and return all sites.
521    /// Returns an empty vec if the language has no defuse query or parsing fails.
522    pub(crate) fn extract_def_use_for_file(
523        source: &str,
524        language: &str,
525        symbol: &str,
526        file_path: &str,
527        ast_recursion_limit: Option<usize>,
528    ) -> Vec<crate::types::DefUseSite> {
529        let Some(lang_info) = get_language_info(language) else {
530            return vec![];
531        };
532        let Ok(compiled) = get_compiled_queries(language) else {
533            return vec![];
534        };
535        if compiled.defuse.is_none() {
536            return vec![];
537        }
538
539        let tree = match PARSER.with(|p| {
540            let mut parser = p.borrow_mut();
541            if parser.set_language(&lang_info.language).is_err() {
542                return None;
543            }
544            parser.parse(source, None)
545        }) {
546            Some(t) => t,
547            None => return vec![],
548        };
549
550        let root = tree.root_node();
551
552        // Convert ast_recursion_limit the same way extract() does:
553        // 0 means unlimited (None); positive values become Some(u32).
554        let max_depth: Option<u32> = ast_recursion_limit
555            .filter(|&limit| limit > 0)
556            .and_then(|limit| u32::try_from(limit).ok());
557
558        extract_def_use(source, compiled, root, symbol, file_path, max_depth)
559    }
560}
561
562/// Extract `impl Trait for Type` blocks from Rust source.
563///
564/// Runs independently of `extract_references` to avoid shared deduplication state.
565/// Returns an empty vec for non-Rust source (no error; caller decides).
566#[must_use]
567pub fn extract_impl_traits(source: &str, path: &Path) -> Vec<ImplTraitInfo> {
568    let Some(lang_info) = get_language_info("rust") else {
569        return vec![];
570    };
571
572    let Ok(compiled) = get_compiled_queries("rust") else {
573        return vec![];
574    };
575
576    let Some(query) = &compiled.impl_trait else {
577        return vec![];
578    };
579
580    let Some(tree) = PARSER.with(|p| {
581        let mut parser = p.borrow_mut();
582        let _ = parser.set_language(&lang_info.language);
583        parser.parse(source, None)
584    }) else {
585        return vec![];
586    };
587
588    let root = tree.root_node();
589    let mut results = Vec::new();
590
591    QUERY_CURSOR.with(|c| {
592        let mut cursor = c.borrow_mut();
593        cursor.set_max_start_depth(None);
594        let mut matches = cursor.matches(query, root, source.as_bytes());
595
596        while let Some(mat) = matches.next() {
597            let mut trait_name = String::new();
598            let mut impl_type = String::new();
599            let mut line = 0usize;
600
601            for capture in mat.captures {
602                let capture_name = query.capture_names()[capture.index as usize];
603                let node = capture.node;
604                let text = source[node.start_byte()..node.end_byte()].to_string();
605                match capture_name {
606                    "trait_name" => {
607                        trait_name = text;
608                        line = node.start_position().row + 1;
609                    }
610                    "impl_type" => {
611                        impl_type = text;
612                    }
613                    _ => {}
614                }
615            }
616
617            if !trait_name.is_empty() && !impl_type.is_empty() {
618                results.push(ImplTraitInfo {
619                    trait_name,
620                    impl_type,
621                    path: path.to_path_buf(),
622                    line,
623                });
624            }
625        }
626    });
627
628    results
629}
630
631/// Execute a custom tree-sitter query against source code.
632///
633/// This is the internal implementation of the public `execute_query` function.
634pub(crate) fn execute_query_impl(
635    language: &str,
636    source: &str,
637    query_str: &str,
638) -> Result<Vec<crate::QueryCapture>, ParserError> {
639    // Get the tree-sitter language from the language name
640    let ts_language = crate::languages::get_ts_language(language)
641        .ok_or_else(|| ParserError::UnsupportedLanguage(language.to_string()))?;
642
643    let mut parser = Parser::new();
644    parser
645        .set_language(&ts_language)
646        .map_err(|e| ParserError::QueryError(e.to_string()))?;
647
648    let tree = parser
649        .parse(source.as_bytes(), None)
650        .ok_or_else(|| ParserError::QueryError("failed to parse source".to_string()))?;
651
652    let query =
653        Query::new(&ts_language, query_str).map_err(|e| ParserError::QueryError(e.to_string()))?;
654
655    let source_bytes = source.as_bytes();
656
657    let mut captures = Vec::new();
658    QUERY_CURSOR.with(|c| {
659        let mut cursor = c.borrow_mut();
660        cursor.set_max_start_depth(None);
661        let mut matches = cursor.matches(&query, tree.root_node(), source_bytes);
662        while let Some(m) = matches.next() {
663            for cap in m.captures {
664                let node = cap.node;
665                let capture_name = query.capture_names()[cap.index as usize].to_string();
666                let text = node.utf8_text(source_bytes).unwrap_or("").to_string();
667                captures.push(crate::QueryCapture {
668                    capture_name,
669                    text,
670                    start_line: node.start_position().row,
671                    end_line: node.end_position().row,
672                    start_byte: node.start_byte(),
673                    end_byte: node.end_byte(),
674                });
675            }
676        }
677    });
678    Ok(captures)
679}
680
681#[cfg(test)]
682// Tests for Rust language parsing
683mod tests_rust {
684    use super::*;
685    use crate::types::CallInfo;
686
687    #[test]
688    fn test_ast_recursion_limit_zero_is_unlimited() {
689        // Arrange: simple Rust source
690        let source = r#"fn hello() -> u32 { 42 }"#;
691        // Act: extract with ast_recursion_limit=0 (unlimited)
692        let result = SemanticExtractor::extract(source, "rust", Some(0), None);
693        // Assert: should succeed and find the function
694        assert!(result.is_ok(), "extract with limit=0 should succeed");
695        let analysis = result.unwrap();
696        assert_eq!(
697            analysis.functions.len(),
698            1,
699            "should find exactly one function"
700        );
701    }
702
703    #[test]
704    fn test_rust_use_as_imports() {
705        // Arrange: Rust use-as import
706        let source = "use std::io as stdio;\n";
707        // Act
708        let result = SemanticExtractor::extract(source, "rust", None, None).unwrap();
709        // Assert: should capture the alias "stdio"
710        let stdio_import = result
711            .imports
712            .iter()
713            .find(|imp| imp.items.iter().any(|i| i == "stdio"));
714        assert!(
715            stdio_import.is_some(),
716            "expected import with alias 'stdio' in {:?}",
717            result.imports
718        );
719    }
720
721    #[test]
722    fn test_rust_use_as_clause_plain_identifier() {
723        // Arrange: plain identifier with alias
724        let source = "use io as stdio;\n";
725        // Act
726        let result = SemanticExtractor::extract(source, "rust", None, None).unwrap();
727        // Assert: should capture the alias
728        let alias_import = result
729            .imports
730            .iter()
731            .find(|imp| imp.items.iter().any(|i| i == "stdio"));
732        assert!(
733            alias_import.is_some(),
734            "expected import with alias 'stdio' in {:?}",
735            result.imports
736        );
737    }
738
739    #[test]
740    fn test_rust_scoped_use_with_prefix() {
741        // Arrange: scoped use with prefix
742        let source = "use std::{io, fs};\n";
743        // Act
744        let result = SemanticExtractor::extract(source, "rust", None, None).unwrap();
745        // Assert: should capture both io and fs
746        let has_io = result
747            .imports
748            .iter()
749            .any(|imp| imp.items.iter().any(|i| i == "io"));
750        let has_fs = result
751            .imports
752            .iter()
753            .any(|imp| imp.items.iter().any(|i| i == "fs"));
754        assert!(has_io, "expected import 'io' in {:?}", result.imports);
755        assert!(has_fs, "expected import 'fs' in {:?}", result.imports);
756    }
757
758    #[test]
759    fn test_rust_scoped_use_imports() {
760        // Arrange: scoped use imports
761        let source = "use std::{io, fs};\n";
762        // Act
763        let result = SemanticExtractor::extract(source, "rust", None, None).unwrap();
764        // Assert: should capture both imports
765        assert!(
766            !result.imports.is_empty(),
767            "expected imports in {:?}",
768            result.imports
769        );
770    }
771
772    #[test]
773    fn test_rust_wildcard_imports() {
774        // Arrange: wildcard import
775        let source = "use std::*;\n";
776        // Act
777        let result = SemanticExtractor::extract(source, "rust", None, None).unwrap();
778        // Assert: should capture wildcard
779        let wildcard = result
780            .imports
781            .iter()
782            .find(|imp| imp.items.iter().any(|i| i == "*"));
783        assert!(
784            wildcard.is_some(),
785            "expected wildcard import in {:?}",
786            result.imports
787        );
788    }
789
790    #[test]
791    fn test_extract_impl_traits_standalone() {
792        // Arrange: Rust impl trait block
793        let source = r#"
794            trait MyTrait {
795                fn method(&self);
796            }
797            impl MyTrait for MyType {
798                fn method(&self) {}
799            }
800        "#;
801        // Act
802        let result = extract_impl_traits(source, Path::new("test.rs"));
803        // Assert: should find the impl trait
804        assert!(
805            !result.is_empty(),
806            "expected impl trait in result, got {:?}",
807            result
808        );
809    }
810
811    #[test]
812    fn test_ast_recursion_limit_overflow() {
813        // Arrange: simple Rust source with very large recursion limit
814        let source = r#"fn hello() -> u32 { 42 }"#;
815        // Act: extract with ast_recursion_limit=usize::MAX (will overflow to None)
816        let result = SemanticExtractor::extract(source, "rust", Some(usize::MAX), None);
817        // Assert: should still succeed (overflow is handled gracefully)
818        assert!(
819            result.is_ok(),
820            "extract with limit=usize::MAX should succeed"
821        );
822    }
823
824    #[test]
825    fn test_ast_recursion_limit_some() {
826        // Arrange: simple Rust source
827        let source = r#"fn hello() -> u32 { 42 }"#;
828        // Act: extract with ast_recursion_limit=10
829        let result = SemanticExtractor::extract(source, "rust", Some(10), None);
830        // Assert: should succeed and find the function
831        assert!(result.is_ok(), "extract with limit=10 should succeed");
832        let analysis = result.unwrap();
833        assert_eq!(
834            analysis.functions.len(),
835            1,
836            "should find exactly one function"
837        );
838    }
839
840    #[test]
841    fn test_extract_def_use_for_file_finds_write_and_read() {
842        // Arrange: Rust source with variable write and read
843        let source = r#"
844            fn test() {
845                let mut x = 5;
846                x = 10;
847                let y = x;
848            }
849        "#;
850        // Act
851        let result =
852            SemanticExtractor::extract_def_use_for_file(source, "rust", "x", "test.rs", None);
853        // Assert: should find both write and read sites
854        let has_write = result
855            .iter()
856            .any(|s| s.kind == crate::types::DefUseKind::Write);
857        let has_read = result
858            .iter()
859            .any(|s| s.kind == crate::types::DefUseKind::Read);
860        assert!(has_write, "expected write site for 'x'");
861        assert!(has_read, "expected read site for 'x'");
862    }
863
864    #[test]
865    fn test_extract_def_use_for_file_no_match_returns_empty() {
866        // Arrange: Rust source without the target symbol
867        let source = r#"
868            fn test() {
869                let x = 5;
870            }
871        "#;
872        // Act
873        let result = SemanticExtractor::extract_def_use_for_file(
874            source,
875            "rust",
876            "nonexistent",
877            "test.rs",
878            None,
879        );
880        // Assert: should return empty vec
881        assert!(
882            result.is_empty(),
883            "expected empty result for nonexistent symbol"
884        );
885    }
886
887    #[test]
888    fn extract_calls_does_not_panic_on_function_calls() {
889        // Arrange: Rust source with function calls
890        let src = r#"
891            fn foo() {}
892            fn bar() {
893                foo();
894            }
895        "#;
896        // Act
897        let result = SemanticExtractor::extract(src, "rust", None, None);
898        // Assert: should succeed and extract calls
899        assert!(
900            result.is_ok(),
901            "extract must succeed on source with function calls"
902        );
903        let output = result.unwrap();
904        assert!(
905            !output.calls.is_empty(),
906            "extract must return call entries for source with function calls"
907        );
908    }
909
910    #[test]
911    fn extract_calls_caps_arg_count_at_sixteen_hops() {
912        // Regression test for #1251: verify deeply nested parenthesized arguments
913        // (20 levels) do not cause a panic. The inner call `g()` is always at 1 hop
914        // from its enclosing call_expression (function identifier is a direct child),
915        // so arg_count is Some(0) -- the cap only fires when the captured node is
916        // deeper in the AST than the direct function child of a call_expression.
917        let src = r#"fn main() { f((((((((((((((((((((g())))))))))))))))))))); }"#;
918        let result = SemanticExtractor::extract(src, "rust", None, None);
919        assert!(
920            result.is_ok(),
921            "extract must succeed even with deeply nested parenthesized arguments"
922        );
923        let output = result.unwrap();
924        let g_calls: Vec<&CallInfo> = output.calls.iter().filter(|c| c.callee == "g").collect();
925        assert_eq!(
926            g_calls.len(),
927            1,
928            "expected exactly one CallInfo with callee 'g', got {}",
929            g_calls.len()
930        );
931        // arg_count is Some(0) because g() has 0 arguments; the >16 hop cap is
932        // a parent-traversal guard on the captured node, not on the call nesting.
933        assert_eq!(
934            g_calls[0].arg_count,
935            Some(0),
936            "g() has 0 arguments, expected Some(0)"
937        );
938    }
939}
940
941#[cfg(test)]
942// Tests for Python language parsing
943mod tests_python {
944    use super::*;
945
946    #[test]
947    fn test_python_relative_import() {
948        // Arrange: relative import (from . import foo)
949        let source = "from . import foo\n";
950        // Act
951        let result = SemanticExtractor::extract(source, "python", None, None).unwrap();
952        // Assert: relative import should be captured
953        let relative = result.imports.iter().find(|imp| imp.module.contains("."));
954        assert!(
955            relative.is_some(),
956            "expected relative import in {:?}",
957            result.imports
958        );
959    }
960
961    #[test]
962    fn test_python_aliased_import() {
963        // Arrange: aliased import (from os import path as p)
964        // Note: tree-sitter-python extracts "path" (the original name), not the alias "p"
965        let source = "from os import path as p\n";
966        // Act
967        let result = SemanticExtractor::extract(source, "python", None, None).unwrap();
968        // Assert: "path" should be in items (alias is captured separately by aliased_import node)
969        let path_import = result
970            .imports
971            .iter()
972            .find(|imp| imp.module == "os" && imp.items.iter().any(|i| i == "path"));
973        assert!(
974            path_import.is_some(),
975            "expected import 'path' from module 'os' in {:?}",
976            result.imports
977        );
978    }
979
980    #[test]
981    fn test_parse_no_timeout_when_none() {
982        // Arrange: simple Rust source with no deadline
983        let source = r#"fn hello() -> u32 { 42 }"#;
984        // Act: extract with deadline=None (no timeout)
985        let result = SemanticExtractor::extract(source, "rust", None, None);
986        // Assert: should succeed normally
987        assert!(result.is_ok(), "extract with deadline=None should succeed");
988        let analysis = result.unwrap();
989        assert!(
990            analysis.functions.len() >= 1,
991            "should find at least one function"
992        );
993    }
994
995    #[test]
996    fn test_parse_timeout_triggers_error() {
997        // Arrange: simple Rust source with a very short timeout (1 microsecond)
998        let source = r#"fn hello() -> u32 { 42 }"#;
999        // Act: extract with a very short timeout that will expire immediately
1000        let result = SemanticExtractor::extract(source, "rust", None, Some(1u64));
1001        // Assert: should return a Timeout error
1002        assert!(
1003            matches!(result, Err(ParserError::Timeout(_))),
1004            "expected Timeout error, got {:?}",
1005            result
1006        );
1007    }
1008}
1009
1010// Tests that do not require any language feature gate
1011#[cfg(test)]
1012mod tests_unsupported {
1013    use super::*;
1014
1015    #[test]
1016    fn test_element_extractor_unsupported_language() {
1017        // Arrange + Act
1018        let result = ElementExtractor::extract_with_depth("x = 1", "cobol");
1019        // Assert
1020        assert!(
1021            matches!(result, Err(ParserError::UnsupportedLanguage(ref lang)) if lang == "cobol"),
1022            "expected UnsupportedLanguage error, got {:?}",
1023            result
1024        );
1025    }
1026
1027    #[test]
1028    fn test_semantic_extractor_unsupported_language() {
1029        // Arrange + Act
1030        let result = SemanticExtractor::extract("x = 1", "cobol", None, None);
1031        // Assert
1032        assert!(
1033            matches!(result, Err(ParserError::UnsupportedLanguage(ref lang)) if lang == "cobol"),
1034            "expected UnsupportedLanguage error, got {:?}",
1035            result
1036        );
1037    }
1038}