1use 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
22pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}