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
98 changes: 95 additions & 3 deletions graphify/extractors/csharp.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,35 @@ def _metadata(value: object) -> dict:
return value if isinstance(value, dict) else {}


def _razor_imports_dir(source_file: object) -> str | None:
"""The directory prefix an ``_Imports.razor``'s directives apply to.

Returns the file's parent directory with normalized separators ("" for the
corpus root), or None when the file is not an ``_Imports.razor``.
"""
if not isinstance(source_file, str) or not source_file:
return None
norm = source_file.replace("\\", "/")
parts = norm.rstrip("/").rsplit("/", 1)
name = parts[-1]
if name.lower() != "_imports.razor":
return None
return parts[0] if len(parts) == 2 else ""


def _razor_dir_applies(imports_dir: str, source_file: str) -> bool:
"""True when an ``_Imports.razor`` in ``imports_dir`` governs ``source_file``.

The Razor compiler applies ``_Imports.razor`` directives to every Razor
file in the same directory and every subdirectory below it.
"""
norm = source_file.replace("\\", "/")
file_dir = norm.rsplit("/", 1)[0] if "/" in norm else ""
if not imports_dir:
return True # root _Imports.razor governs the whole tree
return file_dir == imports_dir or file_dir.startswith(imports_dir + "/")


class CsharpNameResolver:
"""Namespace/using/alias-aware C# simple-name resolution.

Expand Down Expand Up @@ -227,6 +256,26 @@ def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
if entry not in bucket:
bucket.append(entry)

# Blazor ``_Imports.razor`` (#3187): the Razor compiler applies its
# ``@using``/alias directives to every Razor file in the same directory
# and below, and the standard Blazor template keeps the app's
# namespaces there — so without this a bare ``@inject WidgetService``
# in a page dangles even though the canonical definition is in the
# graph. Index each _Imports.razor's directives by the directory they
# govern; razor-family lookups fold them in.
self._razor_dir_usings: list[tuple[str, list[tuple[str, str, str | None]]]] = []
self._razor_dir_aliases: list[
tuple[str, dict[str, list[tuple[str, str, str | None]]]]
] = []
for sf, entries in self.namespace_usings_by_file.items():
d = _razor_imports_dir(sf)
if d is not None:
self._razor_dir_usings.append((d, entries))
for sf, alias_map in self.aliases_by_file.items():
d = _razor_imports_dir(sf)
if d is not None:
self._razor_dir_aliases.append((d, alias_map))

@staticmethod
def _namespace(node: dict | None) -> str:
metadata = _metadata((node or {}).get("metadata"))
Expand All @@ -243,6 +292,46 @@ def _using_in_scope(self, scope_kind: str, scope_id: str | None, source_node: di
return True
return scope_id is not None and scope_id in self._scope_chain(source_node)

@staticmethod
def _is_razor_family(source_file: str) -> bool:
return isinstance(source_file, str) and source_file.lower().endswith(
(".razor", ".cshtml")
)

def _inherited_razor_usings(
self, source_file: str
) -> list[tuple[str, str, str | None]]:
if not self._razor_dir_usings or not self._is_razor_family(source_file):
return []
out: list[tuple[str, str, str | None]] = []
for d, entries in self._razor_dir_usings:
if _razor_dir_applies(d, source_file):
for entry in entries:
if entry not in out:
out.append(entry)
return out

def _aliases_for(
self, source_file: str
) -> dict[str, list[tuple[str, str, str | None]]]:
own = self.aliases_by_file.get(source_file, {})
if not self._razor_dir_aliases or not self._is_razor_family(source_file):
return own
inherited = [
alias_map for d, alias_map in self._razor_dir_aliases
if _razor_dir_applies(d, source_file)
]
if not inherited:
return own
merged = {alias: list(entries) for alias, entries in own.items()}
for alias_map in inherited:
for alias, entries in alias_map.items():
bucket = merged.setdefault(alias, [])
for entry in entries:
if entry not in bucket:
bucket.append(entry)
return merged

def _scopes_for(self, source_node: dict, source_file: str) -> list[str]:
def _append_unique(items: list[str], value: str) -> None:
if value not in items:
Expand All @@ -254,11 +343,14 @@ def _append_unique(items: list[str], value: str) -> None:
for namespace, scope_kind, scope_id in self.namespace_usings_by_file.get(source_file, []):
if self._using_in_scope(scope_kind, scope_id, source_node):
_append_unique(scopes, namespace)
for namespace, scope_kind, scope_id in self._inherited_razor_usings(source_file):
if self._using_in_scope(scope_kind, scope_id, source_node):
_append_unique(scopes, namespace)
return scopes

def _resolve_alias(self, label: str, source_node: dict, source_file: str) -> str | None:
hits = set()
for target_fqn, scope_kind, scope_id in self.aliases_by_file.get(source_file, {}).get(label, []):
for target_fqn, scope_kind, scope_id in self._aliases_for(source_file).get(label, []):
if not self._using_in_scope(scope_kind, scope_id, source_node):
continue
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
Expand Down Expand Up @@ -286,7 +378,7 @@ def resolve_type_name(
* ``(None, False)`` — scoping knows nothing about the name; a caller
may fall back (e.g. to the corpus-wide unique bare-name match).
"""
if label in self.aliases_by_file.get(source_file, {}):
if label in self._aliases_for(source_file):
return self._resolve_alias(label, source_node, source_file), True
candidates: list[str] = []
for namespace in self._scopes_for(source_node, source_file):
Expand All @@ -310,7 +402,7 @@ def resolve_qualified(
if not isinstance(qualifier, str) or not qualifier:
return None
in_scope = [
entry for entry in self.aliases_by_file.get(source_file, {}).get(qualifier, [])
entry for entry in self._aliases_for(source_file).get(qualifier, [])
if self._using_in_scope(entry[1], entry[2], source_node)
]
if in_scope:
Expand Down
161 changes: 161 additions & 0 deletions tests/test_razor_imports_usings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Blazor ``_Imports.razor`` global usings reach the C# resolver (#3187).

#3188 routed a Razor ``@inject`` through the C# cross-file type resolver, but
only directives written in the page itself counted. The Razor compiler applies
``_Imports.razor`` ``@using``/alias directives to every Razor file in the same
directory and below — and the standard Blazor template keeps the app's
namespaces there, so a bare ``@inject WidgetService _w`` in a page dangled on
a sourceless stub even though the canonical definition was in the graph.
"""

from graphify.extract import collect_files, extract

SERVICE_CS = (
"namespace Demo.Services;\n\n"
"public class WidgetService\n{\n"
' public string GetName() => "widget";\n'
"}\n"
)

ALPHA_RAZOR = (
'@page "/alpha"\n'
"@inject WidgetService _widgets\n\n"
"<p>@_widgets.GetName()</p>\n"
)


def _extract(td, files):
for rel, body in files.items():
p = td / rel
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(body, encoding="utf-8")
return extract(collect_files(td), cache_root=td, parallel=False)


def _canonical_id(r):
return next(
n["id"] for n in r["nodes"]
if n.get("label") == "WidgetService"
and str(n.get("source_file", "")).endswith(".cs")
and (n.get("metadata") or {}).get("namespace") == "Demo.Services"
)


def _razor_ref_targets(r, page_suffix=".razor"):
return {
e["target"] for e in r["edges"]
if e.get("relation") == "references"
and str(e.get("source_file", "")).endswith(page_suffix)
}


def test_same_directory_imports_razor_resolves_inject(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/AlphaPage.razor": ALPHA_RAZOR,
"Pages/_Imports.razor": "@using Demo.Services\n",
})
assert _canonical_id(r) in _razor_ref_targets(r)
stubs = [n for n in r["nodes"]
if n.get("label") == "WidgetService" and not n.get("source_file")]
assert stubs == [], f"sourceless stub survived: {stubs}"


def test_root_imports_razor_governs_nested_pages(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/Admin/AlphaPage.razor": ALPHA_RAZOR,
"_Imports.razor": "@using Demo.Services\n",
})
assert _canonical_id(r) in _razor_ref_targets(r)


def test_sibling_directory_imports_razor_does_not_apply(tmp_path, monkeypatch):
"""An _Imports.razor in an unrelated sibling directory must not leak in."""
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/AlphaPage.razor": ALPHA_RAZOR,
"Components/_Imports.razor": "@using Demo.Services\n",
})
assert _canonical_id(r) not in _razor_ref_targets(r), (
"a sibling directory's _Imports.razor must not govern this page"
)


def test_cs_files_do_not_inherit_imports_razor(tmp_path, monkeypatch):
"""The Razor compiler's rule is Razor-only: a .cs file in the same
directory must not gain the _Imports.razor usings."""
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/_Imports.razor": "@using Demo.Services\n",
"Pages/Helper.cs": (
"namespace Demo.Pages;\n\n"
"public class Helper\n{\n"
" private readonly WidgetService _w;\n"
" public Helper(WidgetService w) => _w = w;\n"
"}\n"
),
})
cs_targets = {
e["target"] for e in r["edges"]
if e.get("relation") == "references"
and str(e.get("source_file", "")).endswith("Helper.cs")
}
assert _canonical_id(r) not in cs_targets, (
"a .cs file must not inherit _Imports.razor usings"
)


def test_ambiguous_inherited_using_still_dangles(tmp_path, monkeypatch):
"""Two same-named types both brought in scope via _Imports.razor usings:
no arbitrary winner."""
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"A/WidgetService.cs": (
"namespace Demo.A;\n\npublic class WidgetService { }\n"
),
"B/WidgetService.cs": (
"namespace Demo.B;\n\npublic class WidgetService { }\n"
),
"Pages/AlphaPage.razor": ALPHA_RAZOR,
"Pages/_Imports.razor": "@using Demo.A\n@using Demo.B\n",
})
razor_targets = _razor_ref_targets(r)
defined = {
n["id"] for n in r["nodes"]
if n.get("label") == "WidgetService" and n.get("source_file")
}
assert not (razor_targets & defined), (
"ambiguous inherited using must not pick a winner"
)


def test_no_using_anywhere_still_dangles(tmp_path, monkeypatch):
"""Control for the intentional behavior: with no using in scope the inject
dangles, matching a bare C# cross-namespace reference (that Razor file
would not compile either)."""
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/AlphaPage.razor": ALPHA_RAZOR,
})
assert _canonical_id(r) not in _razor_ref_targets(r)


def test_imports_razor_alias_resolves(tmp_path, monkeypatch):
"""An alias directive in _Imports.razor works like a page-local one."""
monkeypatch.chdir(tmp_path)
r = _extract(tmp_path, {
"Services/WidgetService.cs": SERVICE_CS,
"Pages/AlphaPage.razor": (
'@page "/alpha"\n'
"@inject Widgets _widgets\n\n"
"<p>@_widgets.GetName()</p>\n"
),
"Pages/_Imports.razor": "@using Widgets = Demo.Services.WidgetService\n",
})
assert _canonical_id(r) in _razor_ref_targets(r)
Loading