use tree_sitter::Node;
use crate::model::{Edge, EdgeType, Language, Node as ModelNode, NodeLabel};
use crate::resolve::FqnGenerator;
use super::dedupe_qn;
use super::error::{ParseError, Result};
use super::extractor::{CallInfo, ExtractResult, Extractor, ImportInfo};
use super::parser_factory::ParserFactory;
pub struct CSharpExtractor {
_priv: (),
}
impl CSharpExtractor {
#[must_use]
pub const fn new() -> Self {
Self { _priv: () }
}
}
impl Default for CSharpExtractor {
fn default() -> Self {
Self::new()
}
}
impl Extractor for CSharpExtractor {
fn language(&self) -> Language {
Language::CSharp
}
fn extract(&self, source: &str, file_path: &str, project: &str) -> Result<ExtractResult> {
let mut result = ExtractResult::new(file_path, Language::CSharp);
let mut parser = ParserFactory::create_parser(Language::CSharp)?;
let tree = parser
.parse(source, None)
.ok_or_else(|| ParseError::ParseFailed {
file_path: file_path.to_string(),
})?;
let root = tree.root_node();
let ctx = VisitContext {
file_path,
project,
current_func: None,
};
for i in 0..root.named_child_count() as u32 {
if let Some(child) = root.named_child(i) {
visit_node(child, source, &ctx, &mut result);
}
}
Ok(result)
}
}
struct VisitContext<'a> {
file_path: &'a str,
project: &'a str,
current_func: Option<&'a str>,
}
fn visit_node(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
match node.kind() {
"class_declaration" => {
extract_named_type(node, source, ctx, result, NodeLabel::Class);
visit_children(node, source, ctx, result);
}
"interface_declaration" => {
extract_named_type(node, source, ctx, result, NodeLabel::Interface);
visit_children(node, source, ctx, result);
}
"struct_declaration" => {
extract_named_type(node, source, ctx, result, NodeLabel::Struct);
visit_children(node, source, ctx, result);
}
"enum_declaration" => {
extract_named_type(node, source, ctx, result, NodeLabel::Enum);
visit_children(node, source, ctx, result);
}
"namespace_declaration" => {
extract_named_type(node, source, ctx, result, NodeLabel::Namespace);
visit_children(node, source, ctx, result);
}
"method_declaration" => {
extract_method(node, source, ctx, result);
let name = method_name(node, source);
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: name.as_deref(),
};
visit_children(node, source, &child_ctx, result);
}
"using_directive" => {
extract_import(node, source, result);
}
"invocation_expression" => {
extract_call(node, source, ctx, result);
visit_children(node, source, ctx, result);
}
_ => {
visit_children(node, source, ctx, result);
}
}
}
fn visit_children(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
visit_node(child, source, ctx, result);
}
}
}
fn is_exported_name(name: &str) -> bool {
name.chars().next().is_some_and(|c| c.is_ascii_uppercase())
}
fn extract_named_type(
node: Node,
source: &str,
ctx: &VisitContext<'_>,
result: &mut ExtractResult,
label: NodeLabel,
) {
let Some(name) = name_field(node, source) else {
return;
};
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project),
node.start_position().row as u32 + 1,
result,
);
let is_exported = is_exported_name(&name);
let model_node = ModelNode::builder(label, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::CSharp)
.project(ctx.project)
.is_global(true)
.is_exported(is_exported)
.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_method(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(name) = method_name(node, source) else {
return;
};
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project),
node.start_position().row as u32 + 1,
result,
);
let signature = node_text(node, source)
.map(signature_first_line)
.map(String::from);
let is_exported = is_exported_name(&name);
let mut builder = ModelNode::builder(NodeLabel::Method, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::CSharp)
.project(ctx.project)
.is_global(false)
.is_exported(is_exported);
if let Some(sig) = signature {
builder = builder.signature(sig);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_import(node: Node, source: &str, result: &mut ExtractResult) {
let Some(name_node) = node.named_child(0) else {
return;
};
let Some(name) = node_text(name_node, source).map(String::from) else {
return;
};
result.imports.push(ImportInfo {
source_file: name,
imported_names: Vec::new(),
line: node.start_position().row as u32 + 1,
});
}
fn extract_call(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(func_node) = node.child_by_field_name("function") else {
return;
};
let Some(callee) = callee_name(func_node, source) else {
return;
};
let args = call_arguments(node, source);
let caller_qn = ctx
.current_func
.map(|name| make_qn(ctx.file_path, name, ctx.project));
result.calls.push(CallInfo {
caller_qn,
callee_name: callee,
line: node.start_position().row as u32 + 1,
args,
});
}
fn name_field(node: Node, source: &str) -> Option<String> {
let name_node = node.child_by_field_name("name")?;
node_text(name_node, source).map(String::from)
}
fn method_name(node: Node, source: &str) -> Option<String> {
name_field(node, source)
}
fn callee_name(node: Node, source: &str) -> Option<String> {
match node.kind() {
"identifier" => node_text(node, source).map(String::from),
"qualified_name" => {
let mut current = node;
loop {
let right = current.child_by_field_name("right");
let left = current.child_by_field_name("left");
match (right, left) {
(Some(r), _) => {
if r.kind() == "identifier" {
return node_text(r, source).map(String::from);
}
current = r;
}
_ => return None,
}
}
}
"member_access_expression" => {
let name = node.child_by_field_name("name")?;
node_text(name, source).map(String::from)
}
"invocation_expression" => {
let func = node.child_by_field_name("function")?;
callee_name(func, source)
}
"parenthesized_expression" => {
let inner = node.named_child(0)?;
callee_name(inner, source)
}
_ => None,
}
}
fn call_arguments(node: Node, source: &str) -> Vec<String> {
let Some(args_node) = node.child_by_field_name("arguments") else {
return Vec::new();
};
let mut args = Vec::new();
for i in 0..args_node.named_child_count() as u32 {
if let Some(arg) = args_node.named_child(i) {
if let Ok(text) = arg.utf8_text(source.as_bytes()) {
args.push(text.to_string());
}
}
}
args
}
fn node_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
node.utf8_text(source.as_bytes()).ok()
}
fn signature_first_line(text: &str) -> &str {
text.lines().next().unwrap_or(text)
}
fn make_qn(file_path: &str, name: &str, project: &str) -> String {
FqnGenerator::generate(project, file_path, name, Language::CSharp, None)
}
fn add_definition_edges(
file_path: &str,
project: &str,
node: &ModelNode,
result: &mut ExtractResult,
) {
result.edges.push(Edge::new(
file_path.to_string(),
node.id.clone(),
EdgeType::Defines,
project,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeLabel;
fn extract(source: &str) -> ExtractResult {
let ext = CSharpExtractor::new();
ext.extract(source, "test.cs", "proj")
.expect("extraction should succeed")
}
#[test]
fn language_returns_csharp() {
assert_eq!(CSharpExtractor::new().language(), Language::CSharp);
}
#[test]
fn default_creates_extractor() {
let ext = CSharpExtractor::default();
assert_eq!(ext.language(), Language::CSharp);
}
#[test]
fn extracts_class_declaration() {
let result = extract("public class Foo { }\n");
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(
classes.len(),
1,
"should extract 1 class: {:?}",
result.nodes
);
assert_eq!(classes[0].name, "Foo");
assert_eq!(classes[0].language, Some(Language::CSharp));
assert_eq!(classes[0].project, "proj");
assert_eq!(classes[0].file_path.as_deref(), Some("test.cs"));
assert!(classes[0].is_global, "top-level class should be global");
assert!(
classes[0].is_exported,
"PascalCase class should be exported"
);
}
#[test]
fn extracts_interface_declaration() {
let result = extract("public interface IReader { void Read(); }\n");
let ifaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(
ifaces.len(),
1,
"should extract 1 interface: {:?}",
result.nodes
);
assert_eq!(ifaces[0].name, "IReader");
}
#[test]
fn extracts_struct_declaration() {
let result = extract("public struct Point { public int X; public int Y; }\n");
let structs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Struct)
.collect();
assert_eq!(
structs.len(),
1,
"should extract 1 struct: {:?}",
result.nodes
);
assert_eq!(structs[0].name, "Point");
}
#[test]
fn extracts_enum_declaration() {
let result = extract("public enum Color { Red, Green, Blue }\n");
let enums: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Enum)
.collect();
assert_eq!(enums.len(), 1, "should extract 1 enum: {:?}", result.nodes);
assert_eq!(enums[0].name, "Color");
}
#[test]
fn extracts_method_declaration() {
let result = extract("public class Foo { public void Bar() { } }\n");
let methods: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Method)
.collect();
assert_eq!(
methods.len(),
1,
"should extract 1 method: {:?}",
result.nodes
);
assert_eq!(methods[0].name, "Bar");
assert!(!methods[0].is_global, "method should not be global");
assert!(
methods[0].is_exported,
"PascalCase method should be exported"
);
}
#[test]
fn extracts_namespace_declaration() {
let result = extract("namespace MyApp { class Foo { } }\n");
let namespaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Namespace)
.collect();
assert_eq!(
namespaces.len(),
1,
"should extract 1 namespace: {:?}",
result.nodes
);
assert_eq!(namespaces[0].name, "MyApp");
}
#[test]
fn extracts_using_directive_simple() {
let result = extract("using System;\n");
assert_eq!(result.imports.len(), 1, "should extract 1 import");
assert_eq!(result.imports[0].source_file, "System");
assert_eq!(result.imports[0].line, 1);
}
#[test]
fn extracts_using_directive_qualified() {
let result = extract("using System.IO;\n");
assert_eq!(result.imports.len(), 1);
assert_eq!(result.imports[0].source_file, "System.IO");
}
#[test]
fn extracts_multiple_using_directives() {
let result =
extract("using System;\nusing System.IO;\nusing System.Collections.Generic;\n");
assert_eq!(
result.imports.len(),
3,
"should extract 3 imports: {:?}",
result.imports
);
let paths: Vec<_> = result
.imports
.iter()
.map(|i| i.source_file.as_str())
.collect();
assert!(paths.contains(&"System"));
assert!(paths.contains(&"System.IO"));
assert!(paths.contains(&"System.Collections.Generic"));
}
#[test]
fn extracts_invocation_expression() {
let src = "class Foo { void Bar() { Baz(); } }\n";
let result = extract(src);
let callees: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
callees.contains(&"Baz"),
"should extract call to Baz: {:?}",
callees
);
}
#[test]
fn extracts_qualified_invocation() {
let src = "using System; class Foo { void Bar() { Console.WriteLine(); } }\n";
let result = extract(src);
let callees: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
callees.contains(&"WriteLine"),
"should extract member call to WriteLine: {:?}",
callees
);
}
#[test]
fn call_has_line_and_args() {
let src = "class Foo { void Bar() { Baz(1, 2); } }\n";
let result = extract(src);
let call = result
.calls
.iter()
.find(|c| c.callee_name == "Baz")
.expect("should find call to Baz");
assert_eq!(call.args.len(), 2, "Baz(1, 2) should have 2 args");
assert!(call.line >= 1);
}
#[test]
fn call_in_method_has_dotted_fqn_caller_qn() {
let src = "class Foo { void Caller() { Callee(); } }\n";
let ext = CSharpExtractor::new();
let result = ext
.extract(src, "/tmp/demo/test.cs", "proj")
.expect("extraction should succeed");
let call = result
.calls
.iter()
.find(|c| c.callee_name == "Callee")
.expect("should find call to Callee");
assert_eq!(
call.caller_qn.as_deref(),
Some("proj.tmp.demo.test.cs.Caller"),
"caller_qn should be the dotted FQN of the enclosing method"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "Caller")
.expect("should find Caller method node");
assert_eq!(
call.caller_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"caller_qn must match the caller method node id"
);
}
#[test]
fn top_level_call_has_none_caller_qn() {
let result = extract("");
assert!(result.calls.is_empty());
}
#[test]
fn empty_source_returns_empty_result() {
let result = extract("");
assert!(result.is_empty());
}
#[test]
fn result_language_is_csharp() {
let result = extract("class Foo { }\n");
assert_eq!(result.language, Language::CSharp);
assert_eq!(result.file_path, "test.cs");
}
#[test]
fn creates_defines_edges() {
let result = extract("class Foo { }\n");
let defines_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Defines)
.count();
let node_count = result.nodes.len();
assert_eq!(defines_count, node_count, "one DEFINES edge per node");
}
#[test]
fn qualified_name_uses_file_path_and_name() {
let result = extract("class Foo { }\n");
let foo = result.nodes.iter().find(|n| n.name == "Foo").unwrap();
assert_eq!(foo.qualified_name, "proj.test.cs.Foo");
}
#[test]
fn method_has_signature() {
let src = "class Foo { public int Add(int a, int b) { return a + b; } }\n";
let result = extract(src);
let add = result.nodes.iter().find(|n| n.name == "Add").unwrap();
assert!(add.signature.is_some(), "method should have a signature");
let sig = add.signature.as_deref().unwrap();
assert!(sig.contains("Add"), "signature should contain name: {sig}");
}
#[test]
fn nested_classes_extracted() {
let src = "class Outer { class Inner { } }\n";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(classes.len(), 2, "should extract outer and inner class");
let names: Vec<_> = classes.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&"Outer"));
assert!(names.contains(&"Inner"));
}
#[test]
fn generic_class_declaration() {
let src = "public class List<T> { }\n";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "List");
}
#[test]
fn generic_method_declaration() {
let src = "public class Foo { public T Get<T>() { return default(T); } }\n";
let result = extract(src);
let methods: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Method)
.collect();
assert_eq!(methods.len(), 1);
assert_eq!(methods[0].name, "Get");
}
#[test]
fn property_declaration_does_not_break_extraction() {
let src = "public class Foo { public int Bar { get; set; } }\n";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(
classes.len(),
1,
"class should still be extracted with property"
);
assert_eq!(classes[0].name, "Foo");
}
#[test]
fn event_declaration_does_not_break_extraction() {
let src = "public class Foo { public event EventHandler Click; }\n";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(
classes.len(),
1,
"class should still be extracted with event"
);
}
#[test]
fn nested_namespace_extracted() {
let src = "namespace Outer { namespace Inner { class Foo { } } }\n";
let result = extract(src);
let namespaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Namespace)
.collect();
assert_eq!(namespaces.len(), 2, "should extract both namespaces");
let names: Vec<_> = namespaces.iter().map(|n| n.name.as_str()).collect();
assert!(names.contains(&"Outer"));
assert!(names.contains(&"Inner"));
}
#[test]
fn class_with_constructor_does_not_break_extraction() {
let src = "public class Foo { public Foo() { } }\n";
let result = extract(src);
let classes: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Class)
.collect();
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name, "Foo");
}
#[test]
fn comment_only_source_returns_empty_result() {
let result = extract("// just a comment\n");
assert!(
result.is_empty(),
"comment-only file should produce no nodes"
);
}
#[test]
fn multiple_namespaces_extracted() {
let src = "namespace A { } namespace B { }\n";
let result = extract(src);
let namespaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Namespace)
.collect();
assert_eq!(namespaces.len(), 2);
}
#[test]
fn interface_with_methods_extracted() {
let src = "public interface IReader { void Read(); void Write(); }\n";
let result = extract(src);
let ifaces: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Interface)
.collect();
assert_eq!(ifaces.len(), 1);
assert_eq!(ifaces[0].name, "IReader");
let methods: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Method)
.collect();
assert_eq!(methods.len(), 2, "should extract 2 interface methods");
}
#[test]
fn enum_with_values_extracted() {
let src = "public enum HttpStatus { Ok = 200, NotFound = 404, Error = 500 }\n";
let result = extract(src);
let enums: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Enum)
.collect();
assert_eq!(enums.len(), 1);
assert_eq!(enums[0].name, "HttpStatus");
}
#[test]
fn method_call_with_arguments() {
let src = "class Foo { void Bar() { Calc(1, 2, 3); } }\n";
let result = extract(src);
let call = result
.calls
.iter()
.find(|c| c.callee_name == "Calc")
.expect("should find call to Calc");
assert_eq!(call.args.len(), 3, "Calc(1, 2, 3) should have 3 args");
}
#[test]
fn lowercase_class_not_marked_exported() {
let src = "class foo { }\n";
let result = extract(src);
let c = result
.nodes
.iter()
.find(|n| n.name == "foo")
.expect("should extract class foo");
assert!(
!c.is_exported,
"lowercase class name should not be exported"
);
}
fn parse_source(source: &str) -> tree_sitter::Tree {
let mut parser =
crate::parse::parser_factory::ParserFactory::create_parser(Language::CSharp)
.expect("parser");
parser.parse(source, None).expect("parse")
}
fn find_first_by_kind<'a>(
node: tree_sitter::Node<'a>,
kind: &str,
) -> Option<tree_sitter::Node<'a>> {
if node.kind() == kind {
return Some(node);
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if let Some(found) = find_first_by_kind(child, kind) {
return Some(found);
}
}
}
None
}
#[test]
fn callee_name_on_qualified_name_returns_rightmost() {
let src = "using System.IO;\n";
let tree = parse_source(src);
let root = tree.root_node();
let qn = find_first_by_kind(root, "qualified_name").expect("should find qualified_name");
let name = callee_name(qn, src);
if let Some(ref n) = name {
assert!(
n == "IO" || n == "System",
"callee_name on qualified_name should return part of System.IO: {n}"
);
}
}
#[test]
fn callee_name_on_invocation_expression_recurses() {
let src = "class C { void M() { Foo()(); } }\n";
let tree = parse_source(src);
let root = tree.root_node();
fn find_chained_call<'a>(node: tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
if func.kind() == "invocation_expression" {
return Some(func);
}
}
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if let Some(found) = find_chained_call(child) {
return Some(found);
}
}
}
None
}
if let Some(inner_invocation) = find_chained_call(root) {
let name = callee_name(inner_invocation, src);
assert_eq!(
name,
Some("Foo".to_string()),
"callee_name on invocation_expression should recurse to Foo"
);
}
}
#[test]
fn callee_name_on_parenthesized_expression_recurses() {
let src = "class C { void M() { (Foo)(); } }\n";
let tree = parse_source(src);
let root = tree.root_node();
fn find_paren_call<'a>(node: tree_sitter::Node<'a>) -> Option<tree_sitter::Node<'a>> {
if node.kind() == "invocation_expression" {
if let Some(func) = node.child_by_field_name("function") {
if func.kind() == "parenthesized_expression" {
return Some(func);
}
}
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if let Some(found) = find_paren_call(child) {
return Some(found);
}
}
}
None
}
if let Some(paren_expr) = find_paren_call(root) {
let name = callee_name(paren_expr, src);
assert_eq!(
name,
Some("Foo".to_string()),
"callee_name on parenthesized_expression should recurse to Foo"
);
}
}
#[test]
fn callee_name_returns_none_for_unknown_kind() {
let src = "class Foo { }\n";
let tree = parse_source(src);
let root = tree.root_node();
let class_decl =
find_first_by_kind(root, "class_declaration").expect("should find class_declaration");
assert!(
callee_name(class_decl, src).is_none(),
"callee_name on class_declaration should return None"
);
}
#[test]
fn call_arguments_returns_empty_when_no_arguments_field() {
let src = "class Foo { }\n";
let tree = parse_source(src);
let root = tree.root_node();
let class_decl =
find_first_by_kind(root, "class_declaration").expect("should find class_declaration");
let args = call_arguments(class_decl, src);
assert!(
args.is_empty(),
"call_arguments on class_declaration should return empty"
);
}
}