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()