Skip to content
Merged
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
102 changes: 90 additions & 12 deletions internal/parser/languages/csharp.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex
}
fileID := fileNode.ID
result.Nodes = append(result.Nodes, fileNode)
stampCSharpUsings(root, src, fileNode)

seen := make(map[string]bool)
annotationSeen := make(map[string]bool)
Expand Down Expand Up @@ -613,22 +614,51 @@ func csharpExtensionReceiverType(methodNode *sitter.Node, src []byte) string {
return normalizeCSharpBaseName(t.Content(src))
}

// csharpEnclosingNamespace returns the dotted name of the nearest enclosing
// namespace declaration (block or file-scoped), or "".
// csharpEnclosingNamespace returns the dotted name of the enclosing
// namespace scope, or "". Nested block declarations join outer-to-inner
// (`namespace A { namespace B {` → "A.B").
func csharpEnclosingNamespace(node *sitter.Node, src []byte) string {
var parts []string
root := node
for n := node; n != nil; n = n.Parent() {
t := n.Type()
if t == "namespace_declaration" || t == "file_scoped_namespace_declaration" {
if nm := n.ChildByFieldName("name"); nm != nil {
return strings.TrimSpace(nm.Content(src))
}
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
c := n.NamedChild(i)
if c.Type() == "identifier" || c.Type() == "qualified_name" {
return strings.TrimSpace(c.Content(src))
}
if nm := csharpNamespaceName(n, src); nm != "" {
parts = append(parts, nm)
}
}
root = n
}
if len(parts) > 0 {
// Collected innermost-first — reverse into source order.
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
parts[i], parts[j] = parts[j], parts[i]
}
return strings.Join(parts, ".")
}
// File-scoped form: `namespace X;` spans only its own statement in the
// AST — the declarations it governs are later siblings under the
// compilation unit, so the ancestor walk above never sees it.
for i, _nc := 0, int(root.NamedChildCount()); i < _nc; i++ {
c := root.NamedChild(i)
if c.Type() == "file_scoped_namespace_declaration" && c.StartByte() <= node.StartByte() {
return csharpNamespaceName(c, src)
}
}
return ""
}

// csharpNamespaceName extracts the dotted name from a namespace_declaration
// or file_scoped_namespace_declaration node.
func csharpNamespaceName(n *sitter.Node, src []byte) string {
if nm := n.ChildByFieldName("name"); nm != nil {
return strings.TrimSpace(nm.Content(src))
}
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
c := n.NamedChild(i)
if c.Type() == "identifier" || c.Type() == "qualified_name" {
return strings.TrimSpace(c.Content(src))
}
}
return ""
}
Expand Down Expand Up @@ -999,6 +1029,42 @@ func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID st
emitCSharpTypeUseEdges(id, propTypeRaw, filePath, def.StartLine+1, result)
}

// stampCSharpUsings records the file's plain namespace usings (global
// ones included) on the file node as Meta["usings"]. Aliases and
// using-static grant no bare-name namespace visibility and are skipped.
// Resolution rewrites the per-directive import edges, so the resolver's
// namespace narrowing reads this shape, which nothing mutates.
func stampCSharpUsings(root *sitter.Node, src []byte, fileNode *graph.Node) {
var usings []string
seen := map[string]bool{}
walkNodes(root, func(n *sitter.Node) {
if n.Type() != "using_directive" {
return
}
var name string
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
c := n.Child(i)
switch c.Type() {
case "static", "name_equals", "=":
return
case "identifier", "qualified_name":
name = strings.TrimSpace(c.Content(src))
}
}
if name != "" && !seen[name] {
seen[name] = true
usings = append(usings, name)
}
})
if len(usings) == 0 {
return
}
if fileNode.Meta == nil {
fileNode.Meta = map[string]any{}
}
fileNode.Meta["usings"] = usings
}

func (e *CSharpExtractor) emitUsing(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) {
path := m.Captures["using.path"]
importPath := strings.ReplaceAll(path.Text, ".", "/")
Expand Down Expand Up @@ -1132,11 +1198,23 @@ func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath s
kind = graph.EdgeExtends
extendsTaken = true
}
result.Edges = append(result.Edges, &graph.Edge{
edge := &graph.Edge{
From: typeID, To: "unresolved::" + name,
Kind: kind, FilePath: filePath, Line: line,
Origin: graph.OriginASTInferred,
})
}
// A qualified base spelling names its namespace — keep it for the
// resolver's namespace narrowing (same stamp as reference forms).
raw := entry.Content(src)
if isCtorBase {
if tn := entry.ChildByFieldName("type"); tn != nil {
raw = tn.Content(src)
}
}
if fqn := csharpQualifiedTypeRef(raw); fqn != "" {
edge.Meta = map[string]any{"target_fqn": fqn}
}
result.Edges = append(result.Edges, edge)
}
}

Expand Down
59 changes: 59 additions & 0 deletions internal/parser/languages/csharp_filescoped_ns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package languages

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestCSharpExtractor_NestedNamespaceBlocksJoin: nested block
// namespaces join outer-to-inner — `B` alone would collide with any
// other `B` in the graph and never match a `using A.B;`.
func TestCSharpExtractor_NestedNamespaceBlocksJoin(t *testing.T) {
src := []byte(`namespace App {
namespace Core {
public class Widget {
public void Render() {}
}
}
}
`)
res, err := NewCSharpExtractor().Extract("w.cs", src)
require.NoError(t, err)

for _, n := range res.Nodes {
if n.Name == "Widget" || n.Name == "Render" {
assert.Equal(t, "App.Core", n.Meta["scope_ns"], "%s must carry the joined namespace", n.Name)
}
}
}

// TestCSharpExtractor_FileScopedNamespaceScopeNS: a C#10 file-scoped
// namespace's declarations are AST siblings, not children — an
// ancestor walk alone loses every such file's scope_ns.
func TestCSharpExtractor_FileScopedNamespaceScopeNS(t *testing.T) {
src := []byte(`namespace App.Core.Metrics;

public class MetricsCollector
{
public void Configure() {}
}

public interface IMetrics {}
`)
res, err := NewCSharpExtractor().Extract("a.cs", src)
require.NoError(t, err)

for _, name := range []string{"MetricsCollector", "Configure", "IMetrics"} {
found := false
for _, n := range res.Nodes {
if n.Name == name {
found = true
assert.Equal(t, "App.Core.Metrics", n.Meta["scope_ns"],
"%s must carry the file-scoped namespace", name)
}
}
require.True(t, found, "node %s missing", name)
}
}
37 changes: 32 additions & 5 deletions internal/parser/languages/csharp_function_shape.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@ func emitCSharpGenericParamNodes(ownerID string, methodNode *sitter.Node, src []
}
}

// csharpContainerGenerics are wrapper types whose single type argument is
// the type a reference is really about; canonicalisation unwraps them.
var csharpContainerGenerics = []string{
"Task", "ValueTask", "List", "IList", "IEnumerable",
"ICollection", "IReadOnlyList", "IReadOnlyCollection",
"IAsyncEnumerable", "Nullable", "Span", "ReadOnlySpan",
}

// csharpQualifiedTypeRef mirrors canonicalizeCSharpTypeRef but keeps the
// dotted qualifier ("Common.Lookup.Foo?" -> "Common.Lookup.Foo").
// Returns "" when the spelling carries no qualifier.
func csharpQualifiedTypeRef(t string) string {
t = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), "?"))
for strings.HasSuffix(t, "[]") {
t = strings.TrimSpace(strings.TrimSuffix(t, "[]"))
}
for _, wrapper := range csharpContainerGenerics {
prefix := wrapper + "<"
if strings.HasPrefix(t, prefix) && strings.HasSuffix(t, ">") {
return csharpQualifiedTypeRef(t[len(prefix) : len(t)-1])
}
}
if idx := strings.Index(t, "<"); idx > 0 {
t = t[:idx]
}
if !strings.Contains(t, ".") {
return ""
}
return t
}

func canonicalizeCSharpTypeRef(t string) string {
t = strings.TrimSpace(t)
if t == "" {
Expand All @@ -200,11 +231,7 @@ func canonicalizeCSharpTypeRef(t string) string {
t = strings.TrimSpace(t)
}
// Unwrap container generics.
for _, wrapper := range []string{
"Task", "ValueTask", "List", "IList", "IEnumerable",
"ICollection", "IReadOnlyList", "IReadOnlyCollection",
"IAsyncEnumerable", "Nullable", "Span", "ReadOnlySpan",
} {
for _, wrapper := range csharpContainerGenerics {
prefix := wrapper + "<"
if strings.HasPrefix(t, prefix) && strings.HasSuffix(t, ">") {
inner := t[len(prefix) : len(t)-1]
Expand Down
79 changes: 79 additions & 0 deletions internal/parser/languages/csharp_qualified_ref_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package languages

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/zzet/gortex/internal/graph"
)

// TestCSharpExtractor_QualifiedTypeRefKeepsFQN: a qualified spelling
// names its namespace in the source. The bare name stays the target;
// the dotted spelling rides Meta["target_fqn"] for the resolver.
func TestCSharpExtractor_QualifiedTypeRefKeepsFQN(t *testing.T) {
src := []byte(`public class Wiring {
public void Configure(MapperConfig cfg) {
cfg.AddProfile<Shared.Reporting.SalesProfile>();
cfg.AddProfile<LocalProfile>();
var x = new Data.Models.Order();
}
}
`)
res, err := NewCSharpExtractor().Extract("w.cs", src)
require.NoError(t, err)

var qualified, bare, created *graph.Edge
for _, e := range res.Edges {
switch e.To {
case "unresolved::SalesProfile":
if e.Kind == graph.EdgeReferences {
qualified = e
}
case "unresolved::LocalProfile":
if e.Kind == graph.EdgeReferences {
bare = e
}
case "unresolved::Order":
if e.Kind == graph.EdgeInstantiates {
created = e
}
}
}

require.NotNil(t, qualified, "qualified generic arg must emit a reference")
assert.Equal(t, "Shared.Reporting.SalesProfile", qualified.Meta["target_fqn"])

require.NotNil(t, bare, "bare generic arg must emit a reference")
assert.Nil(t, bare.Meta["target_fqn"], "bare spellings carry no qualifier")

require.NotNil(t, created, "qualified new must emit an instantiates edge")
assert.Equal(t, "Data.Models.Order", created.Meta["target_fqn"])
}

// TestCSharpExtractor_QualifiedBaseListKeepsFQN: base-list spellings
// resolve through resolveTypeRef, which consumes the same qualifier
// evidence — extends and implements must carry it too.
func TestCSharpExtractor_QualifiedBaseListKeepsFQN(t *testing.T) {
src := []byte(`public class Repo : Data.Access.RepoBase, Core.Abstractions.IStore
{
}
`)
res, err := NewCSharpExtractor().Extract("r.cs", src)
require.NoError(t, err)

var ext, impl *graph.Edge
for _, e := range res.Edges {
switch {
case e.Kind == graph.EdgeExtends && e.To == "unresolved::RepoBase":
ext = e
case e.Kind == graph.EdgeImplements && e.To == "unresolved::IStore":
impl = e
}
}
require.NotNil(t, ext, "qualified base class must emit extends")
assert.Equal(t, "Data.Access.RepoBase", ext.Meta["target_fqn"])
require.NotNil(t, impl, "qualified interface must emit implements")
assert.Equal(t, "Core.Abstractions.IStore", impl.Meta["target_fqn"])
}
8 changes: 8 additions & 0 deletions internal/parser/languages/csharp_typeuse.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ func emitCSharpReferenceForms(root *sitter.Node, src []byte, filePath, fileID st
if refContext != "" {
edge.Meta = map[string]any{"ref_context": refContext}
}
// A qualified spelling names its namespace in the source — keep it
// as evidence for the resolver's namespace narrowing.
if fqn := csharpQualifiedTypeRef(rawType); fqn != "" {
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["target_fqn"] = fqn
}
result.Edges = append(result.Edges, edge)
}

Expand Down
Loading
Loading