Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions src/trailmark/parsers/csharp/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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
Expand Down
89 changes: 89 additions & 0 deletions tests/test_csharp_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>Defines animal behavior.</summary>
public interface IAnimal
{
string Speak();
}

/// <summary>A dog that implements IAnimal.</summary>
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()
Expand Down