Skip to main content

sqry_core/ast/
context.rs

1//! Context extraction from ASTs
2//!
3//! Extracts parent/ancestor information for graph nodes using tree-sitter.
4//!
5//! The `ContextExtractor` walks the AST to find each symbol's parent nodes,
6//! building a complete ancestry chain from the symbol to the root.
7
8use std::path::Path;
9use tree_sitter::Node;
10
11use super::error::{AstQueryError, Result};
12use super::types::{Context, ContextItem, ContextKind, ContextualMatch, ContextualMatchLocation};
13use crate::graph::unified::build::StagingGraph;
14use crate::graph::unified::concurrent::CodeGraph;
15use crate::plugin::PluginManager;
16
17/// Context extractor for extracting AST context from files
18///
19/// Uses tree-sitter to parse files and extract parent/ancestor information
20/// for each node.
21///
22/// # Refactoring (FT-A.1 + FT-A.2 Complete)
23///
24/// This struct has been refactored to use `PluginManager` directly, eliminating
25/// the dependency on the deprecated `SymbolExtractor`. The new implementation:
26///
27/// - Parses files only once using `plugin.parse_ast()`
28/// - Builds graph nodes using `plugin.graph_builder()` (no re-parse)
29/// - Builds context from the same tree (no re-parse)
30///
31/// This provides ~40-50% performance improvement for context extraction operations.
32pub struct ContextExtractor {
33    /// Plugin manager for language-specific operations
34    plugin_manager: PluginManager,
35}
36
37impl ContextExtractor {
38    /// Create a new context extractor with default plugin manager
39    ///
40    /// # Note
41    ///
42    /// This creates an empty `PluginManager`. Use `with_plugin_manager()` instead
43    /// to provide a properly configured plugin manager with registered plugins.
44    #[must_use]
45    pub fn new() -> Self {
46        Self::with_plugin_manager(PluginManager::new())
47    }
48
49    /// Create a new context extractor with a specific plugin manager
50    ///
51    /// This is the recommended way to create a `ContextExtractor`.
52    /// It uses `PluginManager` for all operations, eliminating double-parsing.
53    ///
54    /// # Arguments
55    ///
56    /// * `plugin_manager` - Configured plugin manager with registered language plugins
57    ///
58    /// # Example
59    ///
60    /// ```ignore
61    /// use sqry_core::ast::ContextExtractor;
62    /// use sqry_core::plugin::PluginManager;
63    ///
64    /// let mut manager = PluginManager::new();
65    /// // Register plugins...
66    /// let extractor = ContextExtractor::with_plugin_manager(manager);
67    /// ```
68    #[must_use]
69    pub fn with_plugin_manager(plugin_manager: PluginManager) -> Self {
70        Self { plugin_manager }
71    }
72
73    /// Extract contextual matches from a file
74    ///
75    /// # Modern Implementation (FT-A.1 + FT-A.2 Complete)
76    ///
77    /// This method uses `PluginManager` directly, parsing the file only ONCE.
78    /// The refactoring eliminates the double-parsing issue:
79    ///
80    /// **Before (v0.4.x and earlier)**:
81    /// 1. `SymbolExtractor.extract_from_file()` → parse #1
82    /// 2. `ContextExtractor` reads file and parses again → parse #2
83    ///
84    /// **After (v0.5.0+)**:
85    /// 1. `plugin.parse_ast()` → parse once
86    /// 2. `plugin.graph_builder().build_graph(tree, ...)` → reuse parse
87    /// 3. Build context from same tree → reuse parse
88    ///
89    /// **Performance Impact**: ~40-50% faster for context extraction operations.
90    ///
91    /// # Arguments
92    ///
93    /// * `path` - Path to the source file
94    ///
95    /// # Returns
96    ///
97    /// Vector of contextual matches (nodes with context)
98    ///
99    /// # Errors
100    ///
101    /// Returns error if:
102    /// - File cannot be read
103    /// - File contains invalid UTF-8
104    /// - Parser fails to parse the file
105    /// - No plugin supports the file path
106    // Extraction is a single linear pipeline; splitting would obscure error context.
107    #[allow(clippy::too_many_lines)]
108    pub fn extract_from_file(&self, path: &Path) -> Result<Vec<ContextualMatch>> {
109        // Get plugin for this path (extension + special filename routing).
110        let plugin = self.plugin_manager.plugin_for_path(path).ok_or_else(|| {
111            AstQueryError::ContextExtraction(format!(
112                "No plugin found for path: {}",
113                path.display()
114            ))
115        })?;
116
117        // Read file content once
118        let raw_content = std::fs::read(path)?;
119
120        // Get language metadata
121        let lang_name = plugin.metadata().id;
122
123        // Prepare parse-aligned bytes and parse once using the plugin contract.
124        let (prepared_content, tree) = plugin
125            .prepare_ast(&raw_content)
126            .map_err(|e| AstQueryError::ContextExtraction(format!("Failed to parse AST: {e:?}")))?;
127        let parse_content = prepared_content.as_ref();
128
129        let builder = plugin.graph_builder().ok_or_else(|| {
130            AstQueryError::ContextExtraction(format!("No graph builder registered for {lang_name}"))
131        })?;
132
133        // Build nodes via graph builder using the pre-parsed tree.
134        let mut staging = StagingGraph::new();
135        builder
136            .build_graph(&tree, parse_content, path, &mut staging)
137            .map_err(|e| {
138                AstQueryError::ContextExtraction(format!(
139                    "Failed to build graph for {}: {e}",
140                    path.display()
141                ))
142            })?;
143
144        staging.attach_body_hashes(&raw_content);
145
146        let mut graph = CodeGraph::new();
147        let file_id = graph
148            .files_mut()
149            .register_with_language(path, Some(builder.language()))
150            .map_err(|e| {
151                AstQueryError::ContextExtraction(format!(
152                    "Failed to register file {}: {e}",
153                    path.display()
154                ))
155            })?;
156        staging.apply_file_id(file_id);
157
158        let string_remap = staging.commit_strings(graph.strings_mut()).map_err(|e| {
159            AstQueryError::ContextExtraction(format!(
160                "Failed to commit strings for {}: {e}",
161                path.display()
162            ))
163        })?;
164        staging.apply_string_remap(&string_remap).map_err(|e| {
165            AstQueryError::ContextExtraction(format!(
166                "Failed to remap strings for {}: {e}",
167                path.display()
168            ))
169        })?;
170        let _node_id_map = staging.commit_nodes(graph.nodes_mut()).map_err(|e| {
171            AstQueryError::ContextExtraction(format!(
172                "Failed to commit nodes for {}: {e}",
173                path.display()
174            ))
175        })?;
176
177        // Convert content to string for context building
178        let content_str = String::from_utf8_lossy(&raw_content);
179        let root_node = tree.root_node();
180
181        // Extract context for each node using our tree
182        let mut contextual_matches = Vec::new();
183        for (_, entry) in graph.nodes().iter() {
184            if ContextKind::from_node_kind(entry.kind).is_none() {
185                continue;
186            }
187            if entry.start_line == 0 {
188                continue;
189            }
190            let start_line = entry.start_line;
191            let start_column = entry.start_column;
192            let mut node = Self::find_defining_node(root_node, start_line, start_column, lang_name);
193
194            if node.is_none()
195                && Self::looks_like_byte_span(
196                    entry.start_line,
197                    entry.end_line,
198                    entry.start_column,
199                    entry.end_column,
200                    &content_str,
201                )
202            {
203                node = Self::find_defining_node_by_bytes(
204                    root_node,
205                    entry.start_column as usize,
206                    entry.end_column as usize,
207                    lang_name,
208                );
209            }
210
211            if let Some(node) = node {
212                // Build context from node (parent and ancestors)
213                let semantic_context = Self::build_context(&node, &content_str, lang_name);
214                let match_name = semantic_context.immediate.name.clone();
215
216                let location = ContextualMatchLocation::new(
217                    path.to_path_buf(),
218                    entry.start_line,
219                    entry.start_column,
220                    entry.end_line,
221                    entry.end_column,
222                );
223                contextual_matches.push(ContextualMatch::new(
224                    match_name,
225                    location,
226                    semantic_context,
227                    lang_name.to_string(),
228                ));
229            }
230        }
231
232        Ok(contextual_matches)
233    }
234
235    /// Find the defining AST node for a symbol at the given position
236    ///
237    /// Searches for the named scope node (function, class, etc.) that starts at this position.
238    fn find_defining_node<'a>(
239        root: Node<'a>,
240        line: u32,
241        column: u32,
242        lang_name: &str,
243    ) -> Option<Node<'a>> {
244        let mut cursor = root.walk();
245        Self::find_defining_node_recursive(root, line, column, lang_name, &mut cursor)
246    }
247
248    fn find_defining_node_by_bytes<'a>(
249        root: Node<'a>,
250        start: usize,
251        end: usize,
252        lang_name: &str,
253    ) -> Option<Node<'a>> {
254        let target = root.descendant_for_byte_range(start, end)?;
255        let mut current = Some(target);
256
257        while let Some(node) = current {
258            if Self::is_named_scope(&node, lang_name) {
259                return Some(node);
260            }
261            current = node.parent();
262        }
263
264        None
265    }
266
267    fn looks_like_byte_span(
268        start_line: u32,
269        end_line: u32,
270        start_column: u32,
271        end_column: u32,
272        source: &str,
273    ) -> bool {
274        if start_line != 1 || end_line != 1 {
275            return false;
276        }
277        let first_line_len = source.lines().next().map_or(0, str::len);
278        let start = start_column as usize;
279        let end = end_column as usize;
280        start > first_line_len || end > first_line_len
281    }
282
283    /// Recursively search for the defining node
284    fn find_defining_node_recursive<'a>(
285        node: Node<'a>,
286        line: u32,
287        column: u32,
288        lang_name: &str,
289        cursor: &mut tree_sitter::TreeCursor<'a>,
290    ) -> Option<Node<'a>> {
291        // Check if this node contains the given position
292        // Using range-based matching instead of exact position matching for robustness
293        let start_pos = node.start_position();
294        let end_pos = node.end_position();
295
296        // Convert to 1-based line numbers for comparison (clamped to u32::MAX)
297        let node_start_line = start_pos
298            .row
299            .try_into()
300            .unwrap_or(u32::MAX)
301            .saturating_add(1);
302        let node_end_line = end_pos.row.try_into().unwrap_or(u32::MAX).saturating_add(1);
303
304        // Check if line is within node's line range
305        let line_in_range = line >= node_start_line && line <= node_end_line;
306
307        // Convert columns safely (clamped to u32::MAX)
308        let start_col: u32 = start_pos.column.try_into().unwrap_or(u32::MAX);
309        let end_col: u32 = end_pos.column.try_into().unwrap_or(u32::MAX);
310
311        // Check if column is within node's column range for the given line
312        let col_in_range = if line == node_start_line && line == node_end_line {
313            // Single-line node: column must be between start and end
314            column >= start_col && column <= end_col
315        } else if line == node_start_line {
316            // First line: column must be >= start column
317            column >= start_col
318        } else if line == node_end_line {
319            // Last line: column must be <= end column
320            column <= end_col
321        } else {
322            // Middle lines: any column is valid
323            true
324        };
325
326        // Early exit if position not in range
327        if !line_in_range || !col_in_range {
328            return None;
329        }
330
331        // Collect children first to avoid borrow issues
332        let children: Vec<Node<'a>> = node.children(cursor).collect();
333
334        // Search children first to find the innermost matching node
335        for child in children {
336            let child_end = child.end_position();
337            // Only search if the position could be within this child (clamped to u32::MAX)
338            let child_end_line: u32 = child_end
339                .row
340                .try_into()
341                .unwrap_or(u32::MAX)
342                .saturating_add(1);
343            if child_end_line >= line
344                && let Some(found) =
345                    Self::find_defining_node_recursive(child, line, column, lang_name, cursor)
346            {
347                return Some(found);
348            }
349        }
350
351        // No matching child found, check if this node is a named scope
352        if Self::is_named_scope(&node, lang_name) {
353            return Some(node);
354        }
355
356        None
357    }
358
359    /// Build context from an AST node
360    ///
361    /// Walks up the tree from the node to build the full context chain.
362    fn build_context(node: &Node, source: &str, lang_name: &str) -> Context {
363        let source_bytes = source.as_bytes();
364
365        // Build immediate context item from this node
366        let immediate = Self::node_to_context_item(node, source_bytes, lang_name);
367
368        // Walk up to find parent and ancestors
369        let mut parent = None;
370        let mut ancestors = Vec::new();
371        let mut current = node.parent();
372
373        while let Some(node) = current {
374            // Check if this node represents a named scope
375            if Self::is_named_scope(&node, lang_name) {
376                let item = Self::node_to_context_item(&node, source_bytes, lang_name);
377
378                if parent.is_none() {
379                    parent = Some(item);
380                } else {
381                    ancestors.push(item);
382                }
383            }
384
385            current = node.parent();
386        }
387
388        Context::new(immediate, parent, ancestors)
389    }
390
391    /// Convert an AST node to a `ContextItem`
392    fn node_to_context_item(node: &Node, source_bytes: &[u8], lang_name: &str) -> ContextItem {
393        // Extract name from node
394        let name = Self::extract_name(node, source_bytes, lang_name)
395            .unwrap_or_else(|| "<anonymous>".to_string());
396
397        // Determine kind from node type (with parent-sensitive handling)
398        let kind = Self::node_to_context_kind(node, lang_name);
399
400        // Extract position
401        let start_pos = node.start_position();
402        let end_pos = node.end_position();
403
404        // Convert to 1-based line numbers (clamped to u32::MAX)
405        let start_line = start_pos
406            .row
407            .try_into()
408            .unwrap_or(u32::MAX)
409            .saturating_add(1);
410        let end_line = end_pos.row.try_into().unwrap_or(u32::MAX).saturating_add(1);
411
412        ContextItem::new(
413            name,
414            kind,
415            start_line,
416            end_line,
417            node.start_byte(),
418            node.end_byte(),
419        )
420    }
421
422    /// Check if a node represents a named scope (function, class, etc.)
423    fn is_named_scope(node: &Node, lang_name: &str) -> bool {
424        let kind = node.kind();
425
426        // Exclude root-level nodes (source_file, program, module)
427        if matches!(kind, "source_file" | "program" | "module") {
428            return false;
429        }
430
431        match lang_name {
432            "rust" => matches!(
433                kind,
434                "function_item"
435                    | "impl_item"
436                    | "trait_item"
437                    | "struct_item"
438                    | "enum_item"
439                    | "mod_item"
440            ),
441            "javascript" | "typescript" => matches!(
442                kind,
443                "function_declaration"
444                    | "method_definition"
445                    | "class_declaration"
446                    | "lexical_declaration"
447            ),
448            "python" => matches!(kind, "function_definition" | "class_definition"),
449            "go" => matches!(
450                kind,
451                "function_declaration" | "method_declaration" | "type_declaration"
452            ),
453            _ => false,
454        }
455    }
456
457    /// Get the identifier node kinds to search for in a given language.
458    fn identifier_kinds(lang_name: &str) -> &'static [&'static str] {
459        match lang_name {
460            "rust" => &["identifier", "type_identifier"],
461            "javascript" | "typescript" => &["identifier", "property_identifier"],
462            "python" | "go" => &["identifier"],
463            _ => &[],
464        }
465    }
466
467    /// Extract name from an AST node
468    fn extract_name(node: &Node, source_bytes: &[u8], lang_name: &str) -> Option<String> {
469        let kinds = Self::identifier_kinds(lang_name);
470        if kinds.is_empty() {
471            return None;
472        }
473
474        let mut cursor = node.walk();
475        node.children(&mut cursor)
476            .find(|child| kinds.contains(&child.kind()))
477            .and_then(|child| child.utf8_text(source_bytes).ok())
478            .map(std::string::ToString::to_string)
479    }
480
481    /// Map tree-sitter node kind to `ContextKind`
482    fn node_to_context_kind(node: &Node, lang_name: &str) -> ContextKind {
483        if lang_name == "rust" && node.kind() == "function_item" {
484            let mut current = node.parent();
485            while let Some(parent) = current {
486                if matches!(parent.kind(), "impl_item" | "trait_item") {
487                    return ContextKind::Method;
488                }
489                current = parent.parent();
490            }
491        }
492
493        Self::node_kind_to_context_kind(node.kind(), lang_name)
494    }
495
496    fn node_kind_to_context_kind(node_kind: &str, lang_name: &str) -> ContextKind {
497        match lang_name {
498            "rust" => match node_kind {
499                "impl_item" => ContextKind::Impl,
500                "trait_item" => ContextKind::Trait,
501                "struct_item" => ContextKind::Struct,
502                "enum_item" => ContextKind::Enum,
503                "mod_item" => ContextKind::Module,
504                "const_item" => ContextKind::Constant,
505                "static_item" => ContextKind::Variable,
506                "type_item" => ContextKind::TypeAlias,
507                _ => ContextKind::Function,
508            },
509            "javascript" | "typescript" => match node_kind {
510                "method_definition" => ContextKind::Method,
511                "class_declaration" => ContextKind::Class,
512                "lexical_declaration" | "variable_declaration" => ContextKind::Variable,
513                _ => ContextKind::Function,
514            },
515            "python" => match node_kind {
516                "class_definition" => ContextKind::Class,
517                _ => ContextKind::Function,
518            },
519            "go" => match node_kind {
520                "method_declaration" => ContextKind::Method,
521                "type_declaration" => ContextKind::Struct,
522                _ => ContextKind::Function,
523            },
524            _ => ContextKind::Function,
525        }
526    }
527
528    /// Extract context from a directory recursively
529    ///
530    /// # Errors
531    ///
532    /// Returns [`anyhow::Error`] when traversal fails (e.g., directory unreadable) or when
533    /// `extract_from_file` propagates extraction errors.
534    pub fn extract_from_directory(&self, root: &Path) -> Result<Vec<ContextualMatch>> {
535        let mut all_matches = Vec::new();
536
537        for entry in walkdir::WalkDir::new(root)
538            .follow_links(false)
539            .into_iter()
540            .filter_map(std::result::Result::ok)
541        {
542            let path = entry.path();
543            if path.is_file() {
544                // Try to extract context (ignore unsupported files)
545                if let Ok(mut matches) = self.extract_from_file(path) {
546                    all_matches.append(&mut matches);
547                }
548            }
549        }
550
551        Ok(all_matches)
552    }
553}
554
555impl Default for ContextExtractor {
556    fn default() -> Self {
557        Self::new()
558    }
559}
560
561// ============================================================================
562// TESTS
563// ============================================================================
564//
565// NOTE: These tests are gated behind `cfg(feature = "context-tests")`
566// to avoid circular dependencies during `cargo test -p sqry-core`.
567//
568// The circular dependency occurs because:
569// 1. sqry-core has dev-dependencies on language plugins (sqry-lang-rust, etc.)
570// 2. Those plugins depend on sqry-core
571// 3. Tests that register plugins cause trait version mismatches (E0277)
572//
573// To run these tests, use workspace-level tests which don't have this issue:
574//   cargo test --workspace
575//
576// Or explicitly enable the feature:
577//   cargo test -p sqry-core --features context-tests
578//
579#[cfg(all(test, feature = "context-tests"))]
580mod tests {
581    use super::*;
582    use std::fs;
583    use tempfile::TempDir;
584
585    /// Creates a `PluginManager` pre-loaded with builtin plugins for testing.
586    ///
587    /// Other plugins are intentionally excluded to avoid trait version mismatches
588    /// between test dependencies. Returns an empty manager in unit test context
589    /// since language plugin crates are dev-dependencies only available in
590    /// integration tests.
591    fn create_test_plugin_manager() -> crate::plugin::PluginManager {
592        // Returns empty PluginManager in unit test context
593        // Language plugin crates are dev-dependencies, only available in integration tests
594        // Unit tests that need plugins should be moved to tests/ directory
595        crate::test_support::plugin_factory::with_builtin_plugins()
596    }
597
598    #[test]
599    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
600    fn test_extract_rust_function_context() {
601        let dir = TempDir::new().unwrap();
602        let file_path = dir.path().join("test.rs");
603        fs::write(
604            &file_path,
605            r#"
606fn top_level() {
607    println!("hello");
608}
609
610struct MyStruct {
611    value: i32,
612}
613
614impl MyStruct {
615    fn method(&self) -> i32 {
616        self.value
617    }
618}
619"#,
620        )
621        .unwrap();
622
623        let manager = create_test_plugin_manager();
624        let extractor = ContextExtractor::with_plugin_manager(manager);
625        let matches = extractor.extract_from_file(&file_path).unwrap();
626
627        // Should find: top_level function, MyStruct struct, method
628        assert!(
629            matches.len() >= 2,
630            "Expected at least 2 matches, found {}",
631            matches.len()
632        );
633
634        // Find top_level function
635        let top_level = matches.iter().find(|m| m.name == "top_level");
636        assert!(top_level.is_some(), "Should find top_level function");
637        if let Some(m) = top_level {
638            assert_eq!(m.context.depth(), 1, "top_level should be at depth 1");
639            assert_eq!(m.context.path(), "top_level");
640        }
641
642        // Find method
643        let method = matches.iter().find(|m| m.name == "method");
644        if let Some(m) = method {
645            assert!(m.context.depth() >= 1, "method should have depth >= 1");
646            assert!(m.context.parent.is_some(), "method should have a parent");
647        }
648    }
649
650    #[test]
651    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
652    fn test_extract_nested_context() {
653        let dir = TempDir::new().unwrap();
654        let file_path = dir.path().join("test.rs");
655        fs::write(
656            &file_path,
657            r"
658mod outer {
659    struct Inner {
660        value: i32,
661    }
662
663    impl Inner {
664        fn deeply_nested(&self) {
665            // nested function
666        }
667    }
668}
669",
670        )
671        .unwrap();
672
673        let manager = create_test_plugin_manager();
674        let extractor = ContextExtractor::with_plugin_manager(manager);
675        let matches = extractor.extract_from_file(&file_path).unwrap();
676
677        // Find deeply_nested method
678        let method = matches.iter().find(|m| m.name == "deeply_nested");
679        if let Some(m) = method {
680            // Should have parent (impl or struct)
681            assert!(m.context.parent.is_some(), "Should have parent");
682            // Depth depends on whether we capture mod, impl, etc.
683            assert!(m.context.depth() >= 1, "Should have depth >= 1");
684        }
685    }
686
687    // Note: JavaScript and Python tests disabled to avoid trait version mismatches
688    // The PluginManager test helper only registers RustPlugin
689    // These tests can be re-enabled when all plugins are rebuilt with the new trait
690
691    #[test]
692    #[ignore = "JavaScript plugin not registered in test helper"]
693    fn test_extract_javascript_class() {
694        let dir = TempDir::new().unwrap();
695        let file_path = dir.path().join("test.js");
696        fs::write(
697            &file_path,
698            r#"
699function topLevel() {
700    console.log("hello");
701}
702
703class MyClass {
704    constructor(name) {
705        this.name = name;
706    }
707
708    greet() {
709        console.log("Hello " + this.name);
710    }
711}
712"#,
713        )
714        .unwrap();
715
716        let manager = create_test_plugin_manager();
717        let extractor = ContextExtractor::with_plugin_manager(manager);
718        let matches = extractor.extract_from_file(&file_path).unwrap();
719
720        assert!(matches.len() >= 2, "Should find at least 2 matches");
721
722        // Find topLevel function
723        let top_fn = matches.iter().find(|m| m.name == "topLevel");
724        if let Some(m) = top_fn {
725            assert_eq!(m.context.depth(), 1, "topLevel should be at depth 1");
726        }
727
728        // Find class
729        let class = matches.iter().find(|m| m.name == "MyClass");
730        assert!(class.is_some(), "Should find MyClass");
731    }
732
733    #[test]
734    #[ignore = "Python plugin not registered in test helper"]
735    fn test_extract_python_context() {
736        let dir = TempDir::new().unwrap();
737        let file_path = dir.path().join("test.py");
738        fs::write(
739            &file_path,
740            r#"
741def top_level():
742    print("hello")
743
744class MyClass:
745    def method(self):
746        return 42
747"#,
748        )
749        .unwrap();
750
751        let manager = create_test_plugin_manager();
752        let extractor = ContextExtractor::with_plugin_manager(manager);
753        let matches = extractor.extract_from_file(&file_path).unwrap();
754
755        assert!(matches.len() >= 2, "Should find at least 2 matches");
756
757        // Find top_level function
758        let top_fn = matches.iter().find(|m| m.name == "top_level");
759        if let Some(m) = top_fn {
760            assert_eq!(m.context.depth(), 1);
761        }
762
763        // Find method
764        let method = matches.iter().find(|m| m.name == "method");
765        if let Some(m) = method {
766            assert!(m.context.depth() >= 2);
767            assert!(m.context.parent.is_some());
768        }
769    }
770
771    #[test]
772    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
773    fn test_empty_file() {
774        let dir = TempDir::new().unwrap();
775        let file_path = dir.path().join("empty.rs");
776        fs::write(&file_path, "").unwrap();
777
778        let manager = create_test_plugin_manager();
779        let extractor = ContextExtractor::with_plugin_manager(manager);
780        let matches = extractor.extract_from_file(&file_path).unwrap();
781
782        assert_eq!(matches.len(), 0);
783    }
784
785    // H2: Position matching robustness tests
786
787    #[test]
788    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
789    fn test_position_matching_single_line_function() {
790        // Test that position matching works for single-line functions
791        let dir = TempDir::new().unwrap();
792        let file_path = dir.path().join("test.rs");
793        fs::write(
794            &file_path,
795            r#"
796fn single_line() { println!("hello"); }
797"#,
798        )
799        .unwrap();
800
801        let manager = create_test_plugin_manager();
802        let extractor = ContextExtractor::with_plugin_manager(manager);
803        let matches = extractor.extract_from_file(&file_path).unwrap();
804
805        // Should find the function
806        let func = matches.iter().find(|m| m.name == "single_line");
807        assert!(func.is_some(), "Should find single-line function");
808
809        if let Some(m) = func {
810            assert_eq!(m.context.depth(), 1);
811            assert_eq!(m.context.path(), "single_line");
812        }
813    }
814
815    #[test]
816    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
817    fn test_position_matching_multiline_function() {
818        // Test that position matching works for multi-line functions
819        let dir = TempDir::new().unwrap();
820        let file_path = dir.path().join("test.rs");
821        fs::write(
822            &file_path,
823            r"
824fn multiline() {
825    let x = 1;
826    let y = 2;
827    x + y
828}
829",
830        )
831        .unwrap();
832
833        let manager = create_test_plugin_manager();
834        let extractor = ContextExtractor::with_plugin_manager(manager);
835        let matches = extractor.extract_from_file(&file_path).unwrap();
836
837        // Should find the function
838        let func = matches.iter().find(|m| m.name == "multiline");
839        assert!(func.is_some(), "Should find multi-line function");
840
841        if let Some(m) = func {
842            assert_eq!(m.context.depth(), 1);
843            // Verify context spans multiple lines
844            assert!(m.end_line > m.start_line + 1);
845        }
846    }
847
848    #[test]
849    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
850    fn test_position_matching_nested_structures() {
851        // Test position matching with nested structures
852        let dir = TempDir::new().unwrap();
853        let file_path = dir.path().join("test.rs");
854        fs::write(
855            &file_path,
856            r"
857mod outer {
858    struct Inner {
859        field: i32,
860    }
861
862    impl Inner {
863        fn method(&self) -> i32 {
864            self.field
865        }
866    }
867}
868",
869        )
870        .unwrap();
871
872        let manager = create_test_plugin_manager();
873        let extractor = ContextExtractor::with_plugin_manager(manager);
874        let matches = extractor.extract_from_file(&file_path).unwrap();
875
876        // Should find the method
877        let method = matches.iter().find(|m| m.name == "method");
878        assert!(method.is_some(), "Should find nested method");
879
880        if let Some(m) = method {
881            // Method is inside impl block inside mod
882            // Structure: outer (mod) -> Inner (struct) -> impl Inner -> method
883            // Depth: 1 (self) + 1 (parent=impl) + 1 (ancestor=outer) = 3
884            assert_eq!(m.context.depth(), 3, "Method should have depth 3");
885            assert!(m.context.parent.is_some(), "Method should have parent");
886            if let Some(parent) = &m.context.parent {
887                // Parent is the impl block, named after the type it implements
888                assert_eq!(parent.name, "Inner", "Method parent should be Inner impl");
889            }
890        }
891    }
892
893    #[test]
894    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
895    fn test_position_matching_with_comments() {
896        // Test that position matching works correctly with comments
897        let dir = TempDir::new().unwrap();
898        let file_path = dir.path().join("test.rs");
899        fs::write(
900            &file_path,
901            r#"
902// This is a comment
903/// Documentation comment
904fn documented_function() {
905    // Internal comment
906    println!("test");
907}
908"#,
909        )
910        .unwrap();
911
912        let manager = create_test_plugin_manager();
913        let extractor = ContextExtractor::with_plugin_manager(manager);
914        let matches = extractor.extract_from_file(&file_path).unwrap();
915
916        // Should find the function despite comments
917        let func = matches.iter().find(|m| m.name == "documented_function");
918        assert!(func.is_some(), "Should find function with comments");
919
920        if let Some(m) = func {
921            assert_eq!(m.context.depth(), 1);
922        }
923    }
924
925    #[test]
926    #[ignore = "Plugins not available in unit tests (dev-dependencies). Move to integration tests if needed."]
927    fn test_position_matching_edge_positions() {
928        // Test position matching at various positions within a function
929        let dir = TempDir::new().unwrap();
930        let file_path = dir.path().join("test.rs");
931        fs::write(
932            &file_path,
933            r"
934struct Container {
935    value: i32,
936}
937
938impl Container {
939    fn new(val: i32) -> Self {
940        Self { value: val }
941    }
942}
943",
944        )
945        .unwrap();
946
947        let manager = create_test_plugin_manager();
948        let extractor = ContextExtractor::with_plugin_manager(manager);
949        let matches = extractor.extract_from_file(&file_path).unwrap();
950
951        // Should find both struct and method
952        let container = matches.iter().find(|m| m.name == "Container");
953        let new_method = matches.iter().find(|m| m.name == "new");
954
955        assert!(container.is_some(), "Should find Container struct");
956        assert!(new_method.is_some(), "Should find new method");
957
958        if let Some(m) = new_method {
959            // Method inside impl block
960            // Structure: impl Container -> new
961            // Depth: 1 (self) + 1 (parent=impl) = 2
962            assert_eq!(m.context.depth(), 2, "Method should have depth 2");
963            if let Some(parent) = &m.context.parent {
964                assert_eq!(
965                    parent.name, "Container",
966                    "Method parent should be Container impl"
967                );
968            }
969        }
970    }
971}