-
Notifications
You must be signed in to change notification settings - Fork 43
Fix C# --deps: add .csproj/packages.config parsing, fix namespace resolution, add directory-based import matching #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -774,3 +774,128 @@ func TestDepsDetectLanguage(t *testing.T) { | |
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestParseCsproj(t *testing.T) { | ||
| csproj := `<Project Sdk="Microsoft.NET.Sdk"> | ||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFramework>net8.0</TargetFramework> | ||
| </PropertyGroup> | ||
| <ItemGroup> | ||
| <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> | ||
| <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" /> | ||
| <PackageReference Include='Serilog' Version='3.0.0' /> | ||
| </ItemGroup> | ||
| </Project>` | ||
|
|
||
| deps := parseCsproj(csproj) | ||
|
|
||
| expected := []string{"Newtonsoft.Json", "Microsoft.Extensions.Logging", "Serilog"} | ||
|
|
||
| if len(deps) != len(expected) { | ||
| t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) | ||
| } | ||
|
|
||
| depsMap := make(map[string]bool) | ||
| for _, d := range deps { | ||
| depsMap[d] = true | ||
| } | ||
| for _, exp := range expected { | ||
| if !depsMap[exp] { | ||
| t.Errorf("Expected dep %q not found in %v", exp, deps) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestParseCsprojEmpty(t *testing.T) { | ||
| csproj := `<Project Sdk="Microsoft.NET.Sdk"> | ||
| <PropertyGroup> | ||
| <TargetFramework>net8.0</TargetFramework> | ||
| </PropertyGroup> | ||
| </Project>` | ||
|
|
||
| deps := parseCsproj(csproj) | ||
| if len(deps) != 0 { | ||
| t.Errorf("Expected no deps, got %v", deps) | ||
| } | ||
| } | ||
|
|
||
| func TestParsePackagesConfig(t *testing.T) { | ||
| config := `<?xml version="1.0" encoding="utf-8"?> | ||
| <packages> | ||
| <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /> | ||
| <package id="NUnit" version="3.14.0" targetFramework="net48" /> | ||
| <package id='log4net' version='2.0.15' /> | ||
| </packages>` | ||
|
|
||
| deps := parsePackagesConfig(config) | ||
|
|
||
| expected := []string{"Newtonsoft.Json", "NUnit", "log4net"} | ||
|
|
||
| if len(deps) != len(expected) { | ||
| t.Errorf("Expected %d deps, got %d: %v", len(expected), len(deps), deps) | ||
| } | ||
|
|
||
| depsMap := make(map[string]bool) | ||
| for _, d := range deps { | ||
| depsMap[d] = true | ||
| } | ||
| for _, exp := range expected { | ||
| if !depsMap[exp] { | ||
| t.Errorf("Expected dep %q not found in %v", exp, deps) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestReadExternalDepsCsharp(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
|
|
||
| csproj := `<Project Sdk="Microsoft.NET.Sdk"> | ||
| <ItemGroup> | ||
| <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> | ||
| <PackageReference Include="Serilog" Version="3.0.0" /> | ||
| </ItemGroup> | ||
| </Project>` | ||
| if err := os.WriteFile(filepath.Join(tmpDir, "MyApp.csproj"), []byte(csproj), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
|
||
| deps := ReadExternalDeps(tmpDir) | ||
|
|
||
| csDeps, ok := deps["csharp"] | ||
| if !ok { | ||
| t.Fatal("Expected csharp deps") | ||
| } | ||
| sort.Strings(csDeps) | ||
| expected := []string{"Newtonsoft.Json", "Serilog"} | ||
| sort.Strings(expected) | ||
| if !reflect.DeepEqual(csDeps, expected) { | ||
| t.Errorf("Expected csharp deps %v, got %v", expected, csDeps) | ||
| } | ||
| } | ||
|
|
||
| func TestReadExternalDepsPackagesConfig(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
|
|
||
| config := `<?xml version="1.0" encoding="utf-8"?> | ||
| <packages> | ||
| <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" /> | ||
| <package id="NUnit" version="3.14.0" targetFramework="net48" /> | ||
| </packages>` | ||
| if err := os.WriteFile(filepath.Join(tmpDir, "packages.config"), []byte(config), 0644); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
|
Comment on lines
+885
to
+887
|
||
|
|
||
| deps := ReadExternalDeps(tmpDir) | ||
|
|
||
| csDeps, ok := deps["csharp"] | ||
| if !ok { | ||
| t.Fatal("Expected csharp deps from packages.config") | ||
| } | ||
| sort.Strings(csDeps) | ||
| expected := []string{"Newtonsoft.Json", "NUnit"} | ||
| sort.Strings(expected) | ||
| if !reflect.DeepEqual(csDeps, expected) { | ||
| t.Errorf("Expected csharp deps %v, got %v", expected, csDeps) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,12 +72,14 @@ func BuildFileGraph(root string) (*FileGraph, error) { | |
|
|
||
| for _, imp := range a.Imports { | ||
| resolved := fuzzyResolve(imp, a.Path, idx, fg.Module, fg.PathAliases, fg.BaseURL) | ||
| // Only count imports that resolve to exactly one file. | ||
| // If an import resolves to multiple files, it's a package/module | ||
| // import (Go, Python, Rust, etc.) not a file-level import. | ||
| // This ensures hub detection works correctly across all languages. | ||
| if len(resolved) == 1 { | ||
| resolvedImports = append(resolvedImports, resolved[0]) | ||
| // Exclude multi-file Go package imports to avoid inflating hub counts. | ||
| // Go package imports start with the module prefix and resolve to all | ||
| // files in that package. For all other imports (e.g., C# namespace | ||
| // imports that resolve via directory matching), allow multi-file | ||
| // resolution so inter-namespace dependencies are tracked. | ||
| isGoPkg := fg.Module != "" && strings.HasPrefix(imp, fg.Module) && len(resolved) > 1 | ||
| if !isGoPkg && len(resolved) > 0 { | ||
| resolvedImports = append(resolvedImports, resolved...) | ||
|
Comment on lines
+81
to
+82
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
|
Comment on lines
+75
to
83
|
||
| } | ||
|
|
||
|
|
@@ -185,6 +187,12 @@ func fuzzyResolve(imp, fromFile string, idx *fileIndex, goModule string, pathAli | |
| return files | ||
| } | ||
|
|
||
| // Strategy 6: Directory match (for namespace-level imports like C# "using Foo.Bar;" | ||
| // where Foo.Bar normalizes to Foo/Bar and maps to the Foo/Bar/ directory). | ||
| if files := tryDirMatch(normalized, idx); len(files) > 0 { | ||
| return files | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
|
|
@@ -280,6 +288,35 @@ func trySuffixMatch(normalized string, idx *fileIndex) []string { | |
| return nil | ||
| } | ||
|
|
||
| // tryDirMatch returns all files whose parent directory matches the given path. | ||
| // This resolves namespace-level imports (e.g. C# "using Foo.Bar;" -> "Foo/Bar/") | ||
| // where an import refers to a whole directory rather than a single file. | ||
| // It also tries progressively shorter suffixes to handle namespace prefixes | ||
| // (e.g. "MyApp/Models" tries "MyApp/Models" first, then "Models"). | ||
| func tryDirMatch(path string, idx *fileIndex) []string { | ||
| // Try exact match first | ||
| if files, ok := idx.byDir[path]; ok { | ||
| return files | ||
| } | ||
| // Normalize path separators for cross-platform compatibility | ||
| nativePath := filepath.FromSlash(path) | ||
| if nativePath != path { | ||
| if files, ok := idx.byDir[nativePath]; ok { | ||
| return files | ||
| } | ||
| } | ||
| // Try suffix match: strip leading segments progressively | ||
| // This handles namespace prefixes like MyApp.Models -> Models | ||
| parts := strings.Split(filepath.ToSlash(path), "/") | ||
| for i := 1; i < len(parts); i++ { | ||
| suffix := filepath.Join(parts[i:]...) | ||
| if files, ok := idx.byDir[suffix]; ok { | ||
|
Comment on lines
+311
to
+313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| return files | ||
| } | ||
| } | ||
| return nil | ||
|
Comment on lines
+291
to
+317
|
||
| } | ||
|
|
||
| // detectModule reads go.mod to find the module name | ||
| func detectModule(root string) string { | ||
| modFile := filepath.Join(root, "go.mod") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -338,3 +338,94 @@ func TestFileGraphHubAndConnectedFiles(t *testing.T) { | |
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestTryDirMatch(t *testing.T) { | ||
| files := []FileInfo{ | ||
| {Path: filepath.Join("MyApp", "Models", "User.cs")}, | ||
| {Path: filepath.Join("MyApp", "Models", "Product.cs")}, | ||
| {Path: filepath.Join("MyApp", "Services", "UserService.cs")}, | ||
| } | ||
| idx := buildFileIndex(files, "") | ||
|
|
||
| t.Run("matches directory with multiple files", func(t *testing.T) { | ||
| got := tryDirMatch(filepath.Join("MyApp", "Models"), idx) | ||
| sort.Strings(got) | ||
| want := []string{ | ||
| filepath.Join("MyApp", "Models", "Product.cs"), | ||
| filepath.Join("MyApp", "Models", "User.cs"), | ||
| } | ||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("tryDirMatch = %v, want %v", got, want) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("matches directory with single file", func(t *testing.T) { | ||
| got := tryDirMatch(filepath.Join("MyApp", "Services"), idx) | ||
| want := []string{filepath.Join("MyApp", "Services", "UserService.cs")} | ||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("tryDirMatch = %v, want %v", got, want) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("suffix match strips namespace prefix", func(t *testing.T) { | ||
| // Files are in Models/ but namespace is ProjectName/Models | ||
| files2 := []FileInfo{ | ||
| {Path: filepath.Join("Models", "User.cs")}, | ||
| {Path: filepath.Join("Models", "Product.cs")}, | ||
| } | ||
| idx2 := buildFileIndex(files2, "") | ||
| got := tryDirMatch(filepath.Join("ProjectName", "Models"), idx2) | ||
| sort.Strings(got) | ||
| want := []string{ | ||
| filepath.Join("Models", "Product.cs"), | ||
| filepath.Join("Models", "User.cs"), | ||
| } | ||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("tryDirMatch suffix = %v, want %v", got, want) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("no match for missing directory", func(t *testing.T) { | ||
| got := tryDirMatch(filepath.Join("MyApp", "Missing"), idx) | ||
| if got != nil { | ||
| t.Errorf("expected nil, got %v", got) | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| func TestFuzzyResolveCsharpNamespace(t *testing.T) { | ||
| userCS := filepath.Join("MyApp", "Models", "User.cs") | ||
| productCS := filepath.Join("MyApp", "Models", "Product.cs") | ||
| serviceCS := filepath.Join("MyApp", "Services", "UserService.cs") | ||
|
|
||
| files := []FileInfo{ | ||
| {Path: userCS}, | ||
| {Path: productCS}, | ||
| {Path: serviceCS}, | ||
| } | ||
| idx := buildFileIndex(files, "") | ||
|
|
||
| t.Run("namespace with multiple files resolves via directory", func(t *testing.T) { | ||
| got := fuzzyResolve("MyApp.Models", "MyApp/Services/UserService.cs", idx, "", nil, "") | ||
| sort.Strings(got) | ||
|
Comment on lines
+408
to
+410
|
||
| want := []string{productCS, userCS} | ||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("fuzzyResolve(MyApp.Models) = %v, want %v", got, want) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("namespace with single file resolves via directory", func(t *testing.T) { | ||
| got := fuzzyResolve("MyApp.Services", "MyApp/Program.cs", idx, "", nil, "") | ||
| want := []string{serviceCS} | ||
|
Comment on lines
+417
to
+419
|
||
| if !reflect.DeepEqual(got, want) { | ||
| t.Errorf("fuzzyResolve(MyApp.Services) = %v, want %v", got, want) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("external namespace does not resolve", func(t *testing.T) { | ||
| got := fuzzyResolve("System.Collections.Generic", "MyApp/Program.cs", idx, "", nil, "") | ||
| if got != nil { | ||
|
Comment on lines
+425
to
+427
|
||
| t.Errorf("expected nil for external namespace, got %v", got) | ||
| } | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
File writes in this test use mode
0644, but other tests in this file use the Go 1.13+0o644literal (e.g., earlieros.WriteFile(..., 0o644)). Consider using0o644here as well for consistency.