diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1742c408..4f9365db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Short version: Drift installs as a Claude Code plugin and works inside the agent
- guard Go
- guard Rust, and stop conflating packages in a workspace
- guard Java and Kotlin
+- guard C#
### Fixed
diff --git a/README.md b/README.md
index 1562d968..205fe0a6 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,9 @@ plugin that fires in every project you open has no business leaving a directory
in each of them. A project that wants the index alongside its source opts in by
creating a `.drift/` directory.
-**Seven file types in one index** — Python, TypeScript, JavaScript, Go, Rust,
-Java and Kotlin (`.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts`),
+**Eight file types in one index** — Python, TypeScript, JavaScript, Go, Rust,
+Java, Kotlin and C#
+(`.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts .cs`),
in one index — a name defined in Python is found from TypeScript, because
`validate_token`, `validateToken` and `ValidateToken` all normalise to the same
thing. Other languages pass through untouched.
diff --git a/docs-site/claude-code-plugin.md b/docs-site/claude-code-plugin.md
index a1a1c3d5..b172eaf5 100644
--- a/docs-site/claude-code-plugin.md
+++ b/docs-site/claude-code-plugin.md
@@ -17,8 +17,8 @@ business leaving a directory in each of them. A project that wants the index
alongside its source opts in by creating a `.drift/` directory; `DRIFT_CACHE_HOME`
overrides both.
-!!! note "Python, TypeScript, JavaScript, Go, Rust, Java and Kotlin"
- `.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts`, all in one index — a name defined in
+!!! note "Python, TypeScript, JavaScript, Go, Rust, Java, Kotlin and C#"
+ `.py .ts .tsx .js .jsx .mjs .cjs .go .rs .java .kt .kts .cs`, all in one index — a name defined in
Python is found from Go, because `validate_token`, `validateToken` and
`ValidateToken` all normalise to the same thing. Python is parsed with `ast`;
the others are matched against top-level declarations,
diff --git a/src/drift/guard/build.py b/src/drift/guard/build.py
index c93d700e..0327dd00 100644
--- a/src/drift/guard/build.py
+++ b/src/drift/guard/build.py
@@ -114,7 +114,13 @@ def import_to_dir(
"""
if specifier.startswith("."):
return relative_to_dir(specifier, src_dir, known_dirs)
- if suffix in extract.GO_SUFFIXES | extract.RUST_SUFFIXES | extract.JVM_SUFFIXES:
+ if (
+ suffix
+ in extract.GO_SUFFIXES
+ | extract.RUST_SUFFIXES
+ | extract.JVM_SUFFIXES
+ | extract.CSHARP_SUFFIXES
+ ):
return suffix_to_dir(specifier, known_dirs)
if "/" in specifier:
return None
@@ -150,6 +156,10 @@ def _sha256(path: pathlib.Path) -> str:
"build.gradle.kts",
)
+#: Markers whose name varies. A .NET project is `Whatever.csproj`, so the
+#: directory has to be searched rather than probed.
+PACKAGE_MARKER_GLOBS = ("*.csproj", "*.fsproj")
+
def package_root_of(repo_root: pathlib.Path, rel_path: str, cache: dict[str, str]) -> str:
"""The package a file belongs to, as a repo-relative directory.
@@ -171,7 +181,9 @@ def package_root_of(repo_root: pathlib.Path, rel_path: str, cache: dict[str, str
for depth in range(len(parts), -1, -1):
candidate = "/".join(parts[:depth]) if depth else "."
base = repo_root if candidate == "." else repo_root / candidate
- if any((base / marker).exists() for marker in PACKAGE_MARKERS):
+ if any((base / marker).exists() for marker in PACKAGE_MARKERS) or any(
+ next(base.glob(pattern), None) is not None for pattern in PACKAGE_MARKER_GLOBS
+ ):
found = candidate
break
diff --git a/src/drift/guard/extract.py b/src/drift/guard/extract.py
index 64d4c875..08a232f6 100644
--- a/src/drift/guard/extract.py
+++ b/src/drift/guard/extract.py
@@ -27,7 +27,10 @@
GO_SUFFIXES = frozenset({".go"})
RUST_SUFFIXES = frozenset({".rs"})
JVM_SUFFIXES = frozenset({".java", ".kt", ".kts"})
-GUARDED_SUFFIXES = PYTHON_SUFFIXES | TS_JS_SUFFIXES | GO_SUFFIXES | RUST_SUFFIXES | JVM_SUFFIXES
+CSHARP_SUFFIXES = frozenset({".cs"})
+GUARDED_SUFFIXES = (
+ PYTHON_SUFFIXES | TS_JS_SUFFIXES | GO_SUFFIXES | RUST_SUFFIXES | JVM_SUFFIXES | CSHARP_SUFFIXES
+)
#: Normalized names too common to ever be a meaningful duplicate.
STOPWORDS = frozenset(
@@ -196,6 +199,28 @@
_JVM_IMPORT = re.compile(r"^import\s+(?:static\s+)?([\w.]+(?:\*)?)", re.MULTILINE)
+#: C# declarations. Members are indented, so the column-zero anchor excludes
+#: them — except that C# nests types inside a namespace block, which indents
+#: everything by one level. Both indents are therefore allowed for types, and
+#: the file-scoped `namespace X;` form needs no allowance at all.
+_CSHARP_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
+ (
+ "type",
+ re.compile(
+ r"^[ \t]{0,4}(?:\[[^\]]+\]\s*)*"
+ r"(?:public\s+|internal\s+|private\s+|protected\s+|sealed\s+|abstract\s+"
+ r"|static\s+|partial\s+|readonly\s+|record\s+)*"
+ r"(?:class|interface|struct|enum|record)\s+([A-Za-z_][\w]*)",
+ re.MULTILINE,
+ ),
+ ),
+)
+
+#: `using A.B.C;` names a namespace, which C# projects mirror as directories.
+#: `using X = A.B;` is an alias rather than a dependency on a place.
+_CSHARP_USING = re.compile(r"^using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;", re.MULTILINE)
+
+
class Symbol(NamedTuple):
name: str
norm_name: str
@@ -239,9 +264,42 @@ def extract(source: str, suffix: str = ".py") -> tuple[list[Symbol], list[str]]:
return extract_rust(source)
if suffix in JVM_SUFFIXES:
return extract_jvm(source)
+ if suffix in CSHARP_SUFFIXES:
+ return extract_csharp(source)
return ([], [])
+def extract_csharp(source: str) -> tuple[list[Symbol], list[str]]:
+ """C#, matched rather than parsed.
+
+ `using A.B.C;` becomes `A/B/C`, because a C# project mirrors its namespace
+ in the directory tree — the same assumption Java relies on, and the same
+ trailing-segment resolver finds it.
+ """
+ symbols: list[Symbol] = []
+ seen: set[str] = set()
+
+ for kind, pattern in _CSHARP_PATTERNS:
+ for match in pattern.finditer(source):
+ name = match.group(1)
+ if name in seen:
+ continue
+ seen.add(name)
+ symbols.append(
+ Symbol(
+ name=name,
+ norm_name=normalize(name),
+ kind=kind,
+ sig_hash=signature_hash(kind, []),
+ line=source.count("\n", 0, match.start()) + 1,
+ )
+ )
+
+ imports = ["/".join(m.group(1).split(".")) for m in _CSHARP_USING.finditer(source)]
+ symbols.sort(key=lambda s: s.line)
+ return (symbols, imports)
+
+
def extract_jvm(source: str) -> tuple[list[Symbol], list[str]]:
"""Java and Kotlin, matched rather than parsed.
diff --git a/src/drift/guard/lookup.py b/src/drift/guard/lookup.py
index a1dc65a5..c3f2dea2 100644
--- a/src/drift/guard/lookup.py
+++ b/src/drift/guard/lookup.py
@@ -85,13 +85,19 @@ def repeats_by_design(rel_path: str) -> bool:
return True
if parts and parts[0] in _REPEATING_ROOT_DIRS:
return True
+ # `Newtonsoft.Json.Tests` — .NET names a test project after the project it
+ # covers, so the segment is never exactly "tests".
+ if any(part.lower().endswith((".tests", ".test", ".specs", ".spec")) for part in parts[:-1]):
+ return True
stem = parts[-1].rsplit(".", 1)[0]
- return (
- stem in ("test", "spec")
- or stem.startswith(("test_", "test-"))
- or stem.endswith(("_test", "-test", ".test", ".spec", "_spec"))
- )
+ if stem in ("test", "spec") or stem.startswith(("test_", "test-")):
+ return True
+ if stem.endswith(("_test", "-test", ".test", ".spec", "_spec")):
+ return True
+ # Generated code restates whatever it was generated from, and no one edits
+ # it: `Model.designer.cs`, `Api.g.cs`, `Schema.generated.ts`.
+ return stem.lower().endswith((".designer", ".g", ".generated", "_pb2", "_pb"))
def find_duplicates(
diff --git a/tests/guard/test_extract_csharp.py b/tests/guard/test_extract_csharp.py
new file mode 100644
index 00000000..9faab433
--- /dev/null
+++ b/tests/guard/test_extract_csharp.py
@@ -0,0 +1,143 @@
+"""C#: what the matcher finds, and what it refuses to invent."""
+
+from __future__ import annotations
+
+from drift.guard import build, extract, lookup, schema
+
+BLOCK_SCOPED = """\
+using System.Text;
+using MyApp.Db;
+using static MyApp.Util.Helpers;
+using Alias = MyApp.Legacy;
+
+namespace MyApp.Auth
+{
+ [Service]
+ public sealed class TokenStore : IValidator
+ {
+ public bool Validate(string token)
+ {
+ return true;
+ }
+
+ private void InternalDetail() { }
+ }
+
+ public interface IValidator
+ {
+ bool Validate(string token);
+ }
+
+ public enum Outcome
+ {
+ Accepted
+ }
+}
+"""
+
+FILE_SCOPED = """\
+using MyApp.Db;
+
+namespace MyApp.Auth;
+
+public record TokenPair(string Left, string Right);
+
+public struct Marker { }
+"""
+
+
+def _names(source: str) -> list[str]:
+ symbols, _ = extract.extract(source, ".cs")
+ return [s.name for s in symbols]
+
+
+def test_types_nested_in_a_namespace_block_are_found():
+ """C# indents everything by one level, which no other language here does."""
+ names = _names(BLOCK_SCOPED)
+
+ assert "TokenStore" in names
+ assert "IValidator" in names
+ assert "Outcome" in names
+
+
+def test_the_file_scoped_namespace_form_is_found():
+ names = _names(FILE_SCOPED)
+
+ assert "TokenPair" in names
+ assert "Marker" in names
+
+
+def test_members_are_not_indexed():
+ names = _names(BLOCK_SCOPED)
+
+ assert "Validate" not in names
+ assert "InternalDetail" not in names
+
+
+def test_usings_become_directory_paths():
+ _, imports = extract.extract(BLOCK_SCOPED, ".cs")
+
+ assert "MyApp/Db" in imports
+ assert "System/Text" in imports
+ assert "MyApp/Util/Helpers" in imports, "a static using ends at the class"
+
+
+def test_an_alias_is_not_a_dependency_on_a_place():
+ """`using Alias = MyApp.Legacy;` renames rather than reaches."""
+ _, imports = extract.extract("using Alias = MyApp.Legacy;\n", ".cs")
+
+ assert imports == []
+
+
+def test_a_word_inside_a_string_is_not_a_declaration():
+ source = 'namespace A;\n\npublic class Real { string s = "public class NotReal {}"; }\n'
+
+ assert _names(source) == ["Real"]
+
+
+def _write_csharp_repo(root):
+ (root / "src" / "MyApp" / "Auth").mkdir(parents=True)
+ (root / "src" / "MyApp" / "Db").mkdir(parents=True)
+ (root / "src" / "MyApp" / "Api").mkdir(parents=True)
+ (root / "src" / "MyApp" / "MyApp.csproj").write_text("\n", encoding="utf-8")
+ (root / "src" / "MyApp" / "Auth" / "TokenStore.cs").write_text(
+ "namespace MyApp.Auth;\n\npublic class TokenStore { }\n", encoding="utf-8"
+ )
+ (root / "src" / "MyApp" / "Db" / "Client.cs").write_text(
+ "namespace MyApp.Db;\n\npublic class Client { }\n", encoding="utf-8"
+ )
+ (root / "src" / "MyApp" / "Api" / "Routes.cs").write_text(
+ "using MyApp.Auth;\n\nnamespace MyApp.Api;\n\npublic class Routes { }\n",
+ encoding="utf-8",
+ )
+
+
+def test_a_csharp_repository_reports_a_duplicate(tmp_path):
+ _write_csharp_repo(tmp_path)
+ build.build_full(tmp_path)
+ conn = schema.connect(tmp_path)
+
+ symbols, _ = extract.extract("namespace MyApp.Api;\n\npublic class TokenStore { }\n", ".cs")
+ hits = lookup.find_duplicates(conn, "src/MyApp/Api/TokenStore.cs", symbols)
+
+ assert hits
+ assert "Auth/TokenStore.cs" in hits[0].message
+
+
+def test_a_csharp_repository_reports_a_first_ever_boundary(tmp_path):
+ _write_csharp_repo(tmp_path)
+ build.build_full(tmp_path)
+ conn = schema.connect(tmp_path)
+
+ hits = lookup.find_novel_edges(conn, "src/MyApp/Api/Routes.cs", ["MyApp/Db"])
+
+ assert hits, "Api has never used Db"
+ assert "Api" in hits[0].message and "Db" in hits[0].message
+
+
+def test_a_csproj_marks_a_package(tmp_path):
+ """The marker name varies per project, so the directory has to be searched."""
+ (tmp_path / "src" / "Thing").mkdir(parents=True)
+ (tmp_path / "src" / "Thing" / "Thing.csproj").write_text("\n", encoding="utf-8")
+
+ assert build.package_root_of(tmp_path, "src/Thing/Model.cs", {}) == "src/Thing"
diff --git a/tests/guard/test_lookup.py b/tests/guard/test_lookup.py
index 53507eeb..bc4d9be5 100644
--- a/tests/guard/test_lookup.py
+++ b/tests/guard/test_lookup.py
@@ -259,3 +259,17 @@ def test_a_top_level_example_directory_still_repeats_by_design():
assert lookup.repeats_by_design("example/main.go")
assert lookup.repeats_by_design("examples/celery/app.py")
assert lookup.repeats_by_design("pkgs/core/examples/cjs/test.cjs")
+
+
+def test_dotnet_style_test_projects_repeat_by_design():
+ """.NET names a test project after the project it covers."""
+ assert lookup.repeats_by_design("Src/Newtonsoft.Json.Tests/Schema/JsonSchemaTests.cs")
+ assert lookup.repeats_by_design("src/MyApp.Test/Thing.cs")
+ assert not lookup.repeats_by_design("src/MyApp.Core/Thing.cs")
+
+
+def test_generated_files_repeat_whatever_generated_them():
+ assert lookup.repeats_by_design("src/Model.designer.cs")
+ assert lookup.repeats_by_design("src/Api.g.cs")
+ assert lookup.repeats_by_design("src/schema_pb2.py")
+ assert not lookup.repeats_by_design("src/designer.py")