diff --git a/src/trailmark/parsers/csharp/parser.py b/src/trailmark/parsers/csharp/parser.py index a9c9dba..fa8ed19 100644 --- a/src/trailmark/parsers/csharp/parser.py +++ b/src/trailmark/parsers/csharp/parser.py @@ -91,10 +91,23 @@ def _visit_module( module_id: str, graph: CodeGraph, ) -> None: - """Walk the top-level of a module, extracting nodes and edges.""" + """Walk the top-level of a module, extracting nodes and edges. + + A file-scoped namespace declaration (``namespace X;``, C# 10+) has no + ``body`` field: every top-level declaration after it, to the end of + the compilation unit, belongs to that namespace. Track it while + walking and route subsequent children through ``_visit_ns_child``, + mirroring what ``_extract_namespace`` does with a braced body. + """ add_module_node(root, file_path, module_id, graph) + ns_id = None for child in root.children: - _visit_top_level(child, file_path, module_id, graph) + if child.type == "file_scoped_namespace_declaration": + ns_id = _create_namespace_unit(child, file_path, module_id, graph) + elif ns_id is not None: + _visit_ns_child(child, file_path, module_id, ns_id, graph) + else: + _visit_top_level(child, file_path, module_id, graph) def _visit_top_level( @@ -120,16 +133,16 @@ def _visit_top_level( _extract_import(child, graph) -def _extract_namespace( +def _create_namespace_unit( node: Node, file_path: str, module_id: str, graph: CodeGraph, -) -> None: - """Extract a namespace declaration and its children.""" +) -> str | None: + """Create a NAMESPACE CodeUnit and its CONTAINS edge; return its id.""" name_node = node.child_by_field_name("name") if name_node is None: - return + return None ns_name = node_text(name_node) ns_id = f"{module_id}:{ns_name}" location = make_location(node, file_path) @@ -142,7 +155,19 @@ def _extract_namespace( ) graph.nodes[ns_id] = ns_unit add_contains_edge(graph, module_id, ns_id) + return ns_id + +def _extract_namespace( + node: Node, + file_path: str, + module_id: str, + graph: CodeGraph, +) -> None: + """Extract a block-scoped namespace declaration and its children.""" + ns_id = _create_namespace_unit(node, file_path, module_id, graph) + if ns_id is None: + return body = node.child_by_field_name("body") if body is None: return diff --git a/tests/test_csharp_parser.py b/tests/test_csharp_parser.py index 796d1d5..914024f 100644 --- a/tests/test_csharp_parser.py +++ b/tests/test_csharp_parser.py @@ -257,6 +257,95 @@ def test_generic_return_type(self) -> None: assert len(get_items.return_type.generic_args) == 1 +FILE_SCOPED_NAMESPACE_CODE = """\ +using System; + +namespace Animals; + +/// Defines animal behavior. +public interface IAnimal +{ + string Speak(); +} + +/// A dog that implements IAnimal. +public class Dog : IAnimal +{ + public string Speak() + { + return "woof"; + } +} +""" + + +def _parse_file_scoped_namespace() -> tuple[str, CodeGraph]: + parser = CSharpParser() + with tempfile.NamedTemporaryFile( + suffix=".cs", + mode="w", + delete=False, + ) as f: + f.write(FILE_SCOPED_NAMESPACE_CODE) + f.flush() + graph = parser.parse_file(f.name) + os.unlink(f.name) + return f.name, graph + + +def _contains_count(graph: CodeGraph, source_id: str, target_id: str) -> int: + return sum( + 1 + for e in graph.edges + if e.kind == EdgeKind.CONTAINS and e.source_id == source_id and e.target_id == target_id + ) + + +class TestCSharpFileScopedNamespace: + def test_finds_namespace(self) -> None: + _, graph = _parse_file_scoped_namespace() + namespaces = [n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE] + names = {n.name for n in namespaces} + assert "Animals" in names + + def test_module_contains_namespace(self) -> None: + path, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + assert namespace.id == f"{module.id}:Animals" + assert namespace.location.file_path == path + assert _contains_count(graph, module.id, namespace.id) == 1 + + def test_class_attributed_to_namespace(self) -> None: + path, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + dog = next(n for n in graph.nodes.values() if n.name == "Dog") + assert dog.id == f"{module.id}:Dog" + assert dog.location.file_path == path + assert _contains_count(graph, namespace.id, dog.id) == 1 + assert _contains_count(graph, module.id, dog.id) == 0 + + def test_interface_attributed_to_namespace(self) -> None: + _, graph = _parse_file_scoped_namespace() + module = next(n for n in graph.nodes.values() if n.kind == NodeKind.MODULE) + namespace = next(n for n in graph.nodes.values() if n.kind == NodeKind.NAMESPACE) + animal = next(n for n in graph.nodes.values() if n.name == "IAnimal") + assert animal.id == f"{module.id}:IAnimal" + assert _contains_count(graph, namespace.id, animal.id) == 1 + assert _contains_count(graph, module.id, animal.id) == 0 + + def test_using_before_namespace_still_imported(self) -> None: + _, graph = _parse_file_scoped_namespace() + assert "System" in graph.dependencies + + def test_method_still_found(self) -> None: + _, graph = _parse_file_scoped_namespace() + methods = [n for n in graph.nodes.values() if n.kind == NodeKind.METHOD] + names = {m.name for m in methods} + assert "Speak" in names + + class TestCSharpParseDirectory: def test_parses_multiple_files(self) -> None: parser = CSharpParser()