Skip to main content

sqry_lang_csharp/
lib.rs

1//! C# language plugin for sqry
2//!
3//! Implements the `LanguagePlugin` trait for C#, providing:
4//! - AST parsing with tree-sitter
5//! - Scope extraction
6//! - Relation extraction (call graph, using directives, exports, return types)
7//!
8//! This plugin enables semantic code search for C# codebases, the #4 priority
9//! language for Microsoft ecosystem and Unity game development.
10
11use sqry_core::ast::{Scope, ScopeId, link_nested_scopes};
12use sqry_core::plugin::{
13    LanguageMetadata, LanguagePlugin,
14    error::{ParseError, ScopeError},
15};
16use std::path::Path;
17use streaming_iterator::StreamingIterator;
18use tree_sitter::{Language, Parser, Query, QueryCursor, Tree};
19
20pub mod relations;
21
22/// C# language plugin
23///
24/// Provides language support for C# source files (.cs, .csx).
25///
26/// # Example
27///
28/// ```
29/// use sqry_lang_csharp::CSharpPlugin;
30/// use sqry_core::plugin::LanguagePlugin;
31///
32/// let plugin = CSharpPlugin::default();
33/// let metadata = plugin.metadata();
34/// assert_eq!(metadata.id, "csharp");
35/// assert_eq!(metadata.name, "C#");
36/// ```
37pub struct CSharpPlugin {
38    graph_builder: relations::CSharpGraphBuilder,
39}
40
41impl CSharpPlugin {
42    #[must_use]
43    pub fn new() -> Self {
44        Self {
45            graph_builder: relations::CSharpGraphBuilder::default(),
46        }
47    }
48}
49
50impl Default for CSharpPlugin {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl LanguagePlugin for CSharpPlugin {
57    fn metadata(&self) -> LanguageMetadata {
58        LanguageMetadata {
59            id: "csharp",
60            name: "C#",
61            version: env!("CARGO_PKG_VERSION"),
62            author: "Verivus Pty Ltd",
63            description: "C# language support for sqry - .NET and Unity code search",
64            tree_sitter_version: "0.24",
65        }
66    }
67
68    fn extensions(&self) -> &'static [&'static str] {
69        &["cs", "csx"]
70    }
71
72    fn language(&self) -> Language {
73        tree_sitter_c_sharp::LANGUAGE.into()
74    }
75
76    fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
77        let mut parser = Parser::new();
78        let language = self.language();
79
80        parser.set_language(&language).map_err(|e| {
81            ParseError::LanguageSetFailed(format!("Failed to set C# language: {e}"))
82        })?;
83
84        parser
85            .parse(content, None)
86            .ok_or(ParseError::TreeSitterFailed)
87    }
88
89    fn extract_scopes(
90        &self,
91        tree: &Tree,
92        content: &[u8],
93        file_path: &Path,
94    ) -> Result<Vec<Scope>, ScopeError> {
95        Self::extract_csharp_scopes(tree, content, file_path)
96    }
97
98    fn graph_builder(&self) -> Option<&dyn sqry_core::graph::GraphBuilder> {
99        Some(&self.graph_builder)
100    }
101}
102
103impl CSharpPlugin {
104    /// Tree-sitter query for C# scope extraction
105    fn scope_query_source() -> &'static str {
106        r"
107; Class declarations with body
108(class_declaration
109    name: (identifier) @class.name
110    body: (declaration_list)) @class.type
111
112; Interface declarations with body
113(interface_declaration
114    name: (identifier) @interface.name
115    body: (declaration_list)) @interface.type
116
117; Struct declarations with body
118(struct_declaration
119    name: (identifier) @struct.name
120    body: (declaration_list)) @struct.type
121
122; Record declarations with body (C# 9+)
123(record_declaration
124    name: (identifier) @record.name
125    body: (declaration_list)) @record.type
126
127; Method declarations with block body
128(method_declaration
129    name: (identifier) @method.name
130    body: (block)) @method.type
131
132; Method declarations with expression body (=> expr;)
133(method_declaration
134    name: (identifier) @method.name
135    body: (arrow_expression_clause)) @method.type
136
137; Abstract/interface method declarations (no body, ends with semicolon)
138(method_declaration
139    name: (identifier) @abstract_method.name) @abstract_method.type
140
141; Constructor declarations with block body
142(constructor_declaration
143    name: (identifier) @constructor.name
144    body: (block)) @constructor.type
145
146; Constructor declarations with expression body
147(constructor_declaration
148    name: (identifier) @constructor.name
149    body: (arrow_expression_clause)) @constructor.type
150
151; Property declarations with accessors (create scope for property block)
152(property_declaration
153    name: (identifier) @property.name
154    accessors: (accessor_list)) @property.type
155
156; Namespace declarations with body (simple name)
157(namespace_declaration
158    name: (identifier) @namespace.name
159    body: (declaration_list)) @namespace.type
160
161; Namespace declarations with body (qualified name)
162(namespace_declaration
163    name: (qualified_name) @namespace.name
164    body: (declaration_list)) @namespace.type
165
166; File-scoped namespace declarations (C# 10+)
167(file_scoped_namespace_declaration
168    name: (identifier) @namespace.name) @namespace.type
169
170; File-scoped namespace with qualified name (C# 10+)
171(file_scoped_namespace_declaration
172    name: (qualified_name) @namespace.name) @namespace.type
173
174; Enum declarations
175(enum_declaration
176    name: (identifier) @enum.name
177    body: (enum_member_declaration_list)) @enum.type
178
179; Local functions (nested functions inside methods)
180(local_function_statement
181    name: (identifier) @function.name
182    body: (block)) @function.type
183
184; Local functions with expression body
185(local_function_statement
186    name: (identifier) @function.name
187    body: (arrow_expression_clause)) @function.type
188"
189    }
190
191    /// Extract scopes from C# source using tree-sitter queries
192    fn extract_csharp_scopes(
193        tree: &Tree,
194        content: &[u8],
195        file_path: &Path,
196    ) -> Result<Vec<Scope>, ScopeError> {
197        let root_node = tree.root_node();
198        let language: Language = tree_sitter_c_sharp::LANGUAGE.into();
199        let scope_query = Self::scope_query_source();
200
201        let query = Query::new(&language, scope_query)
202            .map_err(|e| ScopeError::QueryCompilationFailed(e.to_string()))?;
203
204        let mut scopes = Vec::new();
205        let mut cursor = QueryCursor::new();
206        let mut query_matches = cursor.matches(&query, root_node, content);
207
208        while let Some(m) = query_matches.next() {
209            let mut scope_type: Option<&str> = None;
210            let mut scope_name: Option<String> = None;
211            let mut type_node: Option<tree_sitter::Node> = None;
212
213            for capture in m.captures {
214                let capture_name = query.capture_names()[capture.index as usize];
215                match capture_name {
216                    "class.type"
217                    | "interface.type"
218                    | "struct.type"
219                    | "method.type"
220                    | "namespace.type"
221                    | "record.type"
222                    | "constructor.type"
223                    | "property.type"
224                    | "enum.type"
225                    | "function.type"
226                    | "abstract_method.type" => {
227                        // abstract_method maps to method scope type
228                        let type_name = if capture_name == "abstract_method.type" {
229                            "method"
230                        } else {
231                            capture_name.split('.').next().unwrap_or("unknown")
232                        };
233                        scope_type = Some(type_name);
234                        type_node = Some(capture.node);
235                    }
236                    "class.name"
237                    | "interface.name"
238                    | "struct.name"
239                    | "method.name"
240                    | "namespace.name"
241                    | "record.name"
242                    | "constructor.name"
243                    | "property.name"
244                    | "enum.name"
245                    | "function.name"
246                    | "abstract_method.name" => {
247                        scope_name = capture.node.utf8_text(content).ok().map(String::from);
248                    }
249                    _ => {}
250                }
251            }
252
253            if let (Some(scope_type_str), Some(name), Some(node)) =
254                (scope_type, scope_name, type_node)
255            {
256                let start_pos = node.start_position();
257                let end_pos = node.end_position();
258
259                scopes.push(Scope {
260                    id: ScopeId::new(0),
261                    name,
262                    scope_type: scope_type_str.to_string(),
263                    file_path: file_path.to_path_buf(),
264                    start_line: start_pos.row + 1,
265                    start_column: start_pos.column,
266                    end_line: end_pos.row + 1,
267                    end_column: end_pos.column,
268                    parent_id: None,
269                });
270            }
271        }
272
273        // Deduplicate by (name, scope_type, start_line, start_column)
274        // Include scope_type in key to prevent merging distinct scope types at same position
275        // abstract_method pattern may match methods with bodies, but dedup handles this safely
276        scopes.sort_by_key(|s| {
277            (
278                s.name.clone(),
279                s.scope_type.clone(),
280                s.start_line,
281                s.start_column,
282            )
283        });
284        scopes.dedup_by(|a, b| {
285            a.name == b.name
286                && a.scope_type == b.scope_type
287                && a.start_line == b.start_line
288                && a.start_column == b.start_column
289        });
290
291        // Sort by position and link nested scopes
292        scopes.sort_by_key(|s| (s.start_line, s.start_column));
293        link_nested_scopes(&mut scopes);
294
295        Ok(scopes)
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_metadata() {
305        let plugin = CSharpPlugin::default();
306        let metadata = plugin.metadata();
307
308        assert_eq!(metadata.id, "csharp");
309        assert_eq!(metadata.name, "C#");
310        assert_eq!(metadata.version, env!("CARGO_PKG_VERSION"));
311        assert_eq!(metadata.author, "Verivus Pty Ltd");
312        assert_eq!(metadata.tree_sitter_version, "0.24");
313    }
314
315    #[test]
316    fn test_extensions() {
317        let plugin = CSharpPlugin::default();
318        let extensions = plugin.extensions();
319
320        assert_eq!(extensions.len(), 2);
321        assert!(extensions.contains(&"cs"));
322        assert!(extensions.contains(&"csx"));
323    }
324
325    #[test]
326    fn test_language() {
327        let plugin = CSharpPlugin::default();
328        let language = plugin.language();
329
330        // Just verify we can get a language (ABI version should be non-zero)
331        assert!(language.abi_version() > 0);
332    }
333
334    #[test]
335    fn test_parse_ast_simple() {
336        let plugin = CSharpPlugin::default();
337        let source = b"class MyClass { }";
338
339        let tree = plugin.parse_ast(source).unwrap();
340        assert!(!tree.root_node().has_error());
341    }
342
343    #[test]
344    fn test_plugin_is_send_sync() {
345        fn assert_send_sync<T: Send + Sync>() {}
346        assert_send_sync::<CSharpPlugin>();
347    }
348
349    #[test]
350    fn test_extract_scopes_class() {
351        let plugin = CSharpPlugin::default();
352        let source = br"
353public class MyClass
354{
355    public void Method()
356    {
357        // method body
358    }
359}
360";
361        let path = std::path::Path::new("test.cs");
362        let tree = plugin.parse_ast(source).unwrap();
363        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
364
365        // Should have 2 scopes: MyClass (class) and Method (method)
366        assert_eq!(scopes.len(), 2, "Expected 2 scopes, got {}", scopes.len());
367
368        let class_scope = scopes.iter().find(|s| s.name == "MyClass");
369        let method_scope = scopes.iter().find(|s| s.name == "Method");
370
371        assert!(class_scope.is_some(), "Missing 'MyClass' class scope");
372        assert!(method_scope.is_some(), "Missing 'Method' method scope");
373
374        assert_eq!(class_scope.unwrap().scope_type, "class");
375        assert_eq!(method_scope.unwrap().scope_type, "method");
376    }
377
378    #[test]
379    fn test_extract_scopes_namespace() {
380        let plugin = CSharpPlugin::default();
381        let source = br"
382namespace MyApp
383{
384    public class Service
385    {
386    }
387}
388";
389        let path = std::path::Path::new("test.cs");
390        let tree = plugin.parse_ast(source).unwrap();
391        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
392
393        // Should have 2 scopes: MyApp (namespace) and Service (class)
394        assert_eq!(scopes.len(), 2, "Expected 2 scopes, got {}", scopes.len());
395
396        let ns_scope = scopes.iter().find(|s| s.name == "MyApp");
397        let class_scope = scopes.iter().find(|s| s.name == "Service");
398
399        assert!(ns_scope.is_some(), "Missing 'MyApp' namespace scope");
400        assert!(class_scope.is_some(), "Missing 'Service' class scope");
401
402        assert_eq!(ns_scope.unwrap().scope_type, "namespace");
403        assert_eq!(class_scope.unwrap().scope_type, "class");
404
405        // Service should be nested inside MyApp namespace
406        let myapp = ns_scope.unwrap();
407        let service = class_scope.unwrap();
408        assert!(
409            service.start_line > myapp.start_line,
410            "Service should be inside MyApp"
411        );
412        assert!(
413            service.end_line < myapp.end_line,
414            "Service should be inside MyApp"
415        );
416    }
417
418    #[test]
419    fn test_extract_scopes_interface() {
420        let plugin = CSharpPlugin::default();
421        let source = br"
422public interface IService
423{
424    void Execute();
425}
426";
427        let path = std::path::Path::new("test.cs");
428        let tree = plugin.parse_ast(source).unwrap();
429        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
430
431        // Should have 1 interface scope: IService
432        // Note: interface methods without body don't create method scopes
433        assert!(
434            !scopes.is_empty(),
435            "Expected at least 1 scope, got {}",
436            scopes.len()
437        );
438
439        let iface_scope = scopes.iter().find(|s| s.name == "IService");
440        assert!(iface_scope.is_some(), "Missing 'IService' interface scope");
441        assert_eq!(iface_scope.unwrap().scope_type, "interface");
442    }
443
444    #[test]
445    fn test_extract_scopes_struct() {
446        let plugin = CSharpPlugin::default();
447        let source = br"
448public struct Point
449{
450    public int X;
451    public int Y;
452}
453";
454        let path = std::path::Path::new("test.cs");
455        let tree = plugin.parse_ast(source).unwrap();
456        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
457
458        // Should have 1 struct scope: Point
459        assert_eq!(
460            scopes.len(),
461            1,
462            "Expected 1 struct scope, got {}",
463            scopes.len()
464        );
465        assert_eq!(scopes[0].name, "Point");
466        assert_eq!(scopes[0].scope_type, "struct");
467    }
468
469    #[test]
470    fn test_extract_scopes_file_scoped_namespace() {
471        let plugin = CSharpPlugin::default();
472        let source = br"
473namespace MyApp.Services;
474
475public class UserService
476{
477    public void GetUser()
478    {
479    }
480}
481";
482        let path = std::path::Path::new("test.cs");
483        let tree = plugin.parse_ast(source).unwrap();
484        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
485
486        // Should have 3 scopes: namespace, class, and method
487        assert!(
488            scopes.len() >= 2,
489            "Expected at least 2 scopes, got {}",
490            scopes.len()
491        );
492
493        let ns_scope = scopes.iter().find(|s| s.name == "MyApp.Services");
494        let class_scope = scopes.iter().find(|s| s.name == "UserService");
495
496        assert!(ns_scope.is_some(), "Missing file-scoped namespace scope");
497        assert!(class_scope.is_some(), "Missing 'UserService' class scope");
498
499        assert_eq!(ns_scope.unwrap().scope_type, "namespace");
500        assert_eq!(class_scope.unwrap().scope_type, "class");
501    }
502
503    #[test]
504    fn test_extract_scopes_constructor() {
505        let plugin = CSharpPlugin::default();
506        let source = br"
507public class Person
508{
509    public Person(string name)
510    {
511        Name = name;
512    }
513
514    public string Name { get; }
515}
516";
517        let path = std::path::Path::new("test.cs");
518        let tree = plugin.parse_ast(source).unwrap();
519        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
520
521        let class_scope = scopes
522            .iter()
523            .find(|s| s.name == "Person" && s.scope_type == "class");
524        let ctor_scope = scopes
525            .iter()
526            .find(|s| s.name == "Person" && s.scope_type == "constructor");
527        let prop_scope = scopes.iter().find(|s| s.name == "Name");
528
529        assert!(class_scope.is_some(), "Missing 'Person' class scope");
530        assert!(ctor_scope.is_some(), "Missing 'Person' constructor scope");
531        assert!(prop_scope.is_some(), "Missing 'Name' property scope");
532
533        assert_eq!(prop_scope.unwrap().scope_type, "property");
534    }
535
536    #[test]
537    fn test_extract_scopes_record() {
538        let plugin = CSharpPlugin::default();
539        let source = br#"
540public record Person(string FirstName, string LastName)
541{
542    public string FullName => $"{FirstName} {LastName}";
543}
544"#;
545        let path = std::path::Path::new("test.cs");
546        let tree = plugin.parse_ast(source).unwrap();
547        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
548
549        // Note: record_declaration may or may not be available depending on tree-sitter-c-sharp version
550        // At minimum, if the grammar supports it, we should find a record scope
551        // For now, check that parsing doesn't fail
552
553        // If records are supported by the grammar:
554        if let Some(record_scope) = scopes.iter().find(|s| s.name == "Person") {
555            assert!(
556                record_scope.scope_type == "record" || record_scope.scope_type == "class",
557                "Person should be record or class type"
558            );
559        }
560    }
561
562    #[test]
563    fn test_extract_scopes_enum() {
564        let plugin = CSharpPlugin::default();
565        let source = br"
566public enum Status
567{
568    Active,
569    Inactive,
570    Pending
571}
572";
573        let path = std::path::Path::new("test.cs");
574        let tree = plugin.parse_ast(source).unwrap();
575        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
576
577        let enum_scope = scopes.iter().find(|s| s.name == "Status");
578        assert!(enum_scope.is_some(), "Missing 'Status' enum scope");
579        assert_eq!(enum_scope.unwrap().scope_type, "enum");
580    }
581
582    #[test]
583    fn test_extract_scopes_expression_bodied() {
584        let plugin = CSharpPlugin::default();
585        let source = br"
586public class Calculator
587{
588    public int Add(int a, int b) => a + b;
589
590    public int Multiply(int a, int b)
591    {
592        return a * b;
593    }
594}
595";
596        let path = std::path::Path::new("test.cs");
597        let tree = plugin.parse_ast(source).unwrap();
598        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
599
600        let class_scope = scopes.iter().find(|s| s.name == "Calculator");
601        let add_scope = scopes.iter().find(|s| s.name == "Add");
602        let multiply_scope = scopes.iter().find(|s| s.name == "Multiply");
603
604        assert!(class_scope.is_some(), "Missing 'Calculator' class scope");
605        assert!(
606            add_scope.is_some(),
607            "Missing 'Add' expression-bodied method scope"
608        );
609        assert!(
610            multiply_scope.is_some(),
611            "Missing 'Multiply' block-bodied method scope"
612        );
613
614        assert_eq!(add_scope.unwrap().scope_type, "method");
615        assert_eq!(multiply_scope.unwrap().scope_type, "method");
616    }
617
618    #[test]
619    fn test_extract_scopes_local_function() {
620        let plugin = CSharpPlugin::default();
621        let source = br"
622public class Example
623{
624    public void Outer()
625    {
626        void Inner()
627        {
628            // local function
629        }
630        Inner();
631    }
632}
633";
634        let path = std::path::Path::new("test.cs");
635        let tree = plugin.parse_ast(source).unwrap();
636        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
637
638        let outer_scope = scopes.iter().find(|s| s.name == "Outer");
639        let inner_scope = scopes.iter().find(|s| s.name == "Inner");
640
641        assert!(outer_scope.is_some(), "Missing 'Outer' method scope");
642        assert!(
643            inner_scope.is_some(),
644            "Missing 'Inner' local function scope"
645        );
646
647        assert_eq!(inner_scope.unwrap().scope_type, "function");
648    }
649
650    #[test]
651    fn test_extract_scopes_abstract_interface_methods() {
652        let plugin = CSharpPlugin::default();
653        let source = br"
654public interface IService
655{
656    void Execute();
657    string GetName();
658}
659
660public abstract class BaseService
661{
662    public abstract void Initialize();
663    public abstract int GetPriority();
664}
665";
666        let path = std::path::Path::new("test.cs");
667        let tree = plugin.parse_ast(source).unwrap();
668        let scopes = plugin.extract_scopes(&tree, source, path).unwrap();
669
670        // Should have 6 scopes: IService, Execute, GetName, BaseService, Initialize, GetPriority
671        assert_eq!(
672            scopes.len(),
673            6,
674            "Expected 6 scopes, got {}: {:?}",
675            scopes.len(),
676            scopes
677                .iter()
678                .map(|s| (&s.name, &s.scope_type))
679                .collect::<Vec<_>>()
680        );
681
682        let interface_scope = scopes.iter().find(|s| s.name == "IService");
683        let execute_scope = scopes.iter().find(|s| s.name == "Execute");
684        let get_name_scope = scopes.iter().find(|s| s.name == "GetName");
685        let base_class_scope = scopes.iter().find(|s| s.name == "BaseService");
686        let initialize_scope = scopes.iter().find(|s| s.name == "Initialize");
687        let get_priority_scope = scopes.iter().find(|s| s.name == "GetPriority");
688
689        assert!(
690            interface_scope.is_some(),
691            "Missing 'IService' interface scope"
692        );
693        assert!(
694            execute_scope.is_some(),
695            "Missing 'Execute' interface method scope"
696        );
697        assert!(
698            get_name_scope.is_some(),
699            "Missing 'GetName' interface method scope"
700        );
701        assert!(
702            base_class_scope.is_some(),
703            "Missing 'BaseService' abstract class scope"
704        );
705        assert!(
706            initialize_scope.is_some(),
707            "Missing 'Initialize' abstract method scope"
708        );
709        assert!(
710            get_priority_scope.is_some(),
711            "Missing 'GetPriority' abstract method scope"
712        );
713
714        assert_eq!(interface_scope.unwrap().scope_type, "interface");
715        assert_eq!(execute_scope.unwrap().scope_type, "method");
716        assert_eq!(get_name_scope.unwrap().scope_type, "method");
717        assert_eq!(base_class_scope.unwrap().scope_type, "class");
718        assert_eq!(initialize_scope.unwrap().scope_type, "method");
719        assert_eq!(get_priority_scope.unwrap().scope_type, "method");
720
721        // Verify parent_id nesting: top-level types have no parent, methods have parent
722        // Now verify ACTUAL parent_id targets, not just is_some()
723        let interface_scope = interface_scope.unwrap();
724        let base_class_scope = base_class_scope.unwrap();
725        let execute_scope = execute_scope.unwrap();
726        let get_name_scope = get_name_scope.unwrap();
727        let initialize_scope = initialize_scope.unwrap();
728        let get_priority_scope = get_priority_scope.unwrap();
729
730        // Top-level types should have no parent
731        assert!(
732            interface_scope.parent_id.is_none(),
733            "Top-level interface IService should have no parent"
734        );
735        assert!(
736            base_class_scope.parent_id.is_none(),
737            "Top-level class BaseService should have no parent"
738        );
739
740        // Interface methods should have interface as parent
741        assert_eq!(
742            execute_scope.parent_id,
743            Some(interface_scope.id),
744            "Execute parent_id should match IService id ({:?})",
745            interface_scope.id
746        );
747        assert_eq!(
748            get_name_scope.parent_id,
749            Some(interface_scope.id),
750            "GetName parent_id should match IService id ({:?})",
751            interface_scope.id
752        );
753
754        // Abstract class methods should have class as parent
755        assert_eq!(
756            initialize_scope.parent_id,
757            Some(base_class_scope.id),
758            "Initialize parent_id should match BaseService id ({:?})",
759            base_class_scope.id
760        );
761        assert_eq!(
762            get_priority_scope.parent_id,
763            Some(base_class_scope.id),
764            "GetPriority parent_id should match BaseService id ({:?})",
765            base_class_scope.id
766        );
767    }
768
769    #[test]
770    fn test_exports_public_class() {
771        use sqry_core::graph::{GraphBuilder, unified::StagingGraph};
772        use std::path::PathBuf;
773
774        let plugin = CSharpPlugin::default();
775        let source = br"
776namespace MyApp
777{
778    public class User
779    {
780        private string name;
781    }
782}
783";
784        let path = PathBuf::from("User.cs");
785        let tree = plugin.parse_ast(source).unwrap();
786        let mut staging = StagingGraph::new();
787
788        plugin
789            .graph_builder
790            .build_graph(&tree, source, &path, &mut staging)
791            .unwrap();
792
793        // Should have at least 1 class node + 1 module node + 1 export edge
794        let stats = staging.stats();
795        assert!(
796            stats.nodes_staged >= 2,
797            "Expected at least 2 nodes (class + module), got {}",
798            stats.nodes_staged
799        );
800        assert!(
801            stats.edges_staged >= 1,
802            "Expected at least 1 export edge, got {}",
803            stats.edges_staged
804        );
805    }
806
807    #[test]
808    fn test_exports_public_methods() {
809        use sqry_core::graph::{GraphBuilder, unified::StagingGraph};
810        use std::path::PathBuf;
811
812        let plugin = CSharpPlugin::default();
813        let source = br"
814namespace MyApp
815{
816    public class Service
817    {
818        public void Execute() { }
819        private void Internal() { }
820    }
821}
822";
823        let path = PathBuf::from("Service.cs");
824        let tree = plugin.parse_ast(source).unwrap();
825        let mut staging = StagingGraph::new();
826
827        plugin
828            .graph_builder
829            .build_graph(&tree, source, &path, &mut staging)
830            .unwrap();
831
832        // Should have: 1 class + 2 methods + 1 module
833        // Export edges: class + Execute method (not Internal)
834        let stats = staging.stats();
835        assert!(
836            stats.nodes_staged >= 4,
837            "Expected at least 4 nodes (class + 2 methods + module), got {}",
838            stats.nodes_staged
839        );
840        assert!(
841            stats.edges_staged >= 2,
842            "Expected at least 2 export edges (class + Execute method), got {}",
843            stats.edges_staged
844        );
845    }
846
847    #[test]
848    fn test_exports_interfaces() {
849        use sqry_core::graph::{GraphBuilder, unified::StagingGraph};
850        use std::path::PathBuf;
851
852        let plugin = CSharpPlugin::default();
853        let source = br"
854namespace MyApp
855{
856    public interface IRepository
857    {
858        void Save();
859        void Delete();
860    }
861}
862";
863        let path = PathBuf::from("IRepository.cs");
864        let tree = plugin.parse_ast(source).unwrap();
865        let mut staging = StagingGraph::new();
866
867        plugin
868            .graph_builder
869            .build_graph(&tree, source, &path, &mut staging)
870            .unwrap();
871
872        // Should have: 1 interface + 2 methods + 1 module
873        // Export edges: interface + 2 methods
874        let stats = staging.stats();
875        assert!(
876            stats.nodes_staged >= 4,
877            "Expected at least 4 nodes (interface + 2 methods + module), got {}",
878            stats.nodes_staged
879        );
880        assert!(
881            stats.edges_staged >= 3,
882            "Expected at least 3 export edges (interface + 2 methods), got {}",
883            stats.edges_staged
884        );
885    }
886
887    #[test]
888    fn test_exports_internal_members() {
889        use sqry_core::graph::{GraphBuilder, unified::StagingGraph};
890        use std::path::PathBuf;
891
892        let plugin = CSharpPlugin::default();
893        let source = br"
894namespace MyApp
895{
896    internal class InternalClass
897    {
898        internal void InternalMethod() { }
899    }
900}
901";
902        let path = PathBuf::from("Internal.cs");
903        let tree = plugin.parse_ast(source).unwrap();
904        let mut staging = StagingGraph::new();
905
906        plugin
907            .graph_builder
908            .build_graph(&tree, source, &path, &mut staging)
909            .unwrap();
910
911        // Should have: 1 class + 1 method + 1 module
912        // Export edges: class + method (internal is exported in C#)
913        let stats = staging.stats();
914        assert!(
915            stats.nodes_staged >= 3,
916            "Expected at least 3 nodes (class + method + module), got {}",
917            stats.nodes_staged
918        );
919        assert!(
920            stats.edges_staged >= 2,
921            "Expected at least 2 export edges (class + method), got {}",
922            stats.edges_staged
923        );
924    }
925}