package languages import ( "bytes" "fmt" "regexp" "strings" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/parser" sitter "github.com/zzet/gortex/internal/parser/tsitter" "github.com/zzet/gortex/internal/parser/tsitter/csharp" ) // csharpInterfaceNamePattern encodes the C# `I`-prefix convention // (IService, IRepository, IList): an interface name conventionally // starts with a capital `I` followed by another uppercase letter. The // base-list heuristic falls back to this when a base type is defined in // another compilation unit and so cannot be matched against the file's // own interface declarations. var csharpInterfaceNamePattern = regexp.MustCompile(`^I[A-Z]`) // qCSharpAll is a single tree-sitter query alternating over every // pattern the C# extractor needs. One tree walk per file replaces the // 13 `parser.RunQuery` calls the previous design made (each of which // recompiled its query and ran an independent cursor over the whole // tree). Capture names are disjoint across patterns so the dispatch in // Extract can branch on which name is set. Class / struct / interface // membership for methods, constructors, fields, and properties is // resolved via a parent walk on the captured node — the legacy nested // queries duplicated each member pattern across class_declaration and // struct_declaration; the parent walk collapses them into a single // pattern per member kind. const qCSharpAll = ` [ (namespace_declaration name: (_) @ns.name) @ns.def (class_declaration name: (identifier) @class.name) @class.def (interface_declaration name: (identifier) @iface.name) @iface.def (struct_declaration name: (identifier) @struct.name) @struct.def (record_declaration name: (identifier) @record.name) @record.def (enum_declaration name: (identifier) @enum.name) @enum.def (anonymous_object_creation_expression) @anon.def (method_declaration name: (identifier) @method.name) @method.def (constructor_declaration name: (identifier) @ctor.name) @ctor.def (field_declaration (variable_declaration (variable_declarator name: (identifier) @field.name))) @field.def (property_declaration name: (identifier) @prop.name) @prop.def (using_directive (_) @using.path) @using.def (invocation_expression function: (identifier) @call.name) @call.expr (invocation_expression function: (member_access_expression expression: (_) @callm.receiver name: (identifier) @callm.method)) @callm.expr (local_declaration_statement (variable_declaration type: (_) @lvar.type (variable_declarator (identifier) @lvar.name))) @lvar.def ] ` // CSharpExtractor extracts C# source files into graph nodes and edges. type CSharpExtractor struct { lang *sitter.Language qAll *parser.PreparedQuery } func NewCSharpExtractor() *CSharpExtractor { lang := csharp.GetLanguage() return &CSharpExtractor{ lang: lang, qAll: parser.MustPreparedQuery(qCSharpAll, lang), } } func (e *CSharpExtractor) Language() string { return "csharp" } func (e *CSharpExtractor) Extensions() []string { return []string{".cs"} } // --- Deferred match buffers ---------------------------------------- type csharpDeferredCall struct { name string receiver string line int isMember bool // returnUsage is how the call site consumes the return value // (graph.ReturnUsage* label), classified at capture time and // stamped as edge Meta on the EdgeCalls emitted for this site. returnUsage string } // csharpDeferredLocal buffers a local variable declaration for the // post-pass type-env build. Matches the legacy two-stage pass: Tier 0 // records explicit types (`Foo svc = ...`); Tier 1 walks the def node // for `var svc = new Foo()` to recover the type when Tier 0 left a // "var" key without a real annotation. type csharpDeferredLocal struct { name string rawType string defNode *sitter.Node } // csharpTypeUse buffers a type referenced only in a local-variable // annotation (`HttpResponse resp = Get();`) so the post-pass can emit an // EdgeTypedAs from the enclosing function once funcRanges are built. // Field / property annotations emit their edge inline from the member // node, so they don't ride this buffer. type csharpTypeUse struct { typeText string line int } // Extract parses the C# source, adaptively recovering symbols that tree-sitter // silently drops inside conditional-compilation branches. The grammar parses a // #if/#elif/#else block without raising any error, yet omits every declaration // inside its branches from the tree — so a method guarded by #if vanishes with // no signal. When the source uses conditional compilation, Extract therefore // also extracts from a directive-blanked copy (offset-preserving) and keeps // whichever variant yields more symbols; native wins ties, so a file the // grammar already handles cleanly is never perturbed. This beats an always-blank // rewrite, which would discard the grammar's handling on files that don't need // it and can unbalance braces when both branches are forced live. func (e *CSharpExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) { res, _, err := e.extractCSharp(filePath, src) if err != nil { return nil, err } if hasCSharpConditional(src) { if alt, _, altErr := e.extractCSharp(filePath, blankConditionalDirectives(src)); altErr == nil && csharpSymbolCount(alt) > csharpSymbolCount(res) { return alt, nil } } return res, nil } // hasCSharpConditional reports whether src contains a conditional-compilation // directive — the cheap gate that decides whether the directive-blanked // re-parse is worth attempting. A false positive (the token in a string or // comment) only costs one extra parse whose result loses the symbol-count tie. func hasCSharpConditional(src []byte) bool { return bytes.Contains(src, []byte("#if")) } // csharpSymbolCount counts the non-file symbol nodes in a result — the metric // the adaptive re-parse maximises when deciding whether the directive-blanked // variant recovered more of the file than the native parse. func csharpSymbolCount(r *parser.ExtractionResult) int { if r == nil { return 0 } n := 0 for _, nd := range r.Nodes { if nd != nil && nd.Kind != graph.KindFile { n++ } } return n } func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.ExtractionResult, bool, error) { tree, err := parser.ParseFile(src, e.lang) if err != nil { return nil, false, err } defer tree.Close() root := tree.RootNode() result := &parser.ExtractionResult{} hadError := root.HasError() fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1, Language: "csharp", } // Parse-health signal: a file the grammar could not fully parse (and that // the blanked re-parse did not improve) is flagged so a consumer knows its // C# member surface may be incomplete — codegraph parses silently with no // such signal. if hadError { fileNode.Meta = map[string]any{"parse_health": "partial"} } fileID := fileNode.ID result.Nodes = append(result.Nodes, fileNode) seen := make(map[string]bool) annotationSeen := make(map[string]bool) ifaceMethods := make(map[string][]string) // interface name → method names // Pre-scan the file's own interface declarations. A base type that // names one of these is definitively an interface, even when its name // doesn't follow the `I`-prefix convention — the base-list heuristic // (emitCSharpBaseList) checks this set before falling back to name // shape so a locally-known interface always wins. localInterfaces := collectCSharpInterfaceNames(root, src) var calls []csharpDeferredCall var locals []csharpDeferredLocal var typeUses []csharpTypeUse parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) { switch { case m.Captures["ns.def"] != nil: e.emitNamespace(m, filePath, fileID, result, seen) case m.Captures["class.def"] != nil: e.emitContainer(m, "class", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) case m.Captures["iface.def"] != nil: e.emitContainer(m, "iface", graph.KindInterface, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) case m.Captures["struct.def"] != nil: e.emitContainer(m, "struct", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) case m.Captures["record.def"] != nil: e.emitContainer(m, "record", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) case m.Captures["enum.def"] != nil: e.emitContainer(m, "enum", graph.KindType, filePath, fileID, src, result, seen, annotationSeen, localInterfaces) case m.Captures["anon.def"] != nil: e.emitAnonymousType(m, filePath, fileID, result, seen) case m.Captures["method.def"] != nil: e.emitMethod(m, filePath, fileID, src, result, seen, annotationSeen, ifaceMethods) case m.Captures["ctor.def"] != nil: e.emitConstructor(m, filePath, fileID, src, result, seen) case m.Captures["field.def"] != nil: e.emitField(m, filePath, fileID, src, result, seen) case m.Captures["prop.def"] != nil: e.emitProperty(m, filePath, fileID, src, result, seen) case m.Captures["using.def"] != nil: e.emitUsing(m, filePath, fileID, result) case m.Captures["callm.expr"] != nil: expr := m.Captures["callm.expr"] calls = append(calls, csharpDeferredCall{ name: m.Captures["callm.method"].Text, receiver: m.Captures["callm.receiver"].Text, line: expr.StartLine + 1, isMember: true, returnUsage: classifyReturnUsage(expr.Node, src, csharpReturnUsageSpec), }) case m.Captures["call.expr"] != nil: expr := m.Captures["call.expr"] calls = append(calls, csharpDeferredCall{ name: m.Captures["call.name"].Text, line: expr.StartLine + 1, returnUsage: classifyReturnUsage(expr.Node, src, csharpReturnUsageSpec), }) case m.Captures["lvar.def"] != nil: locals = append(locals, csharpDeferredLocal{ name: m.Captures["lvar.name"].Text, rawType: m.Captures["lvar.type"].Text, defNode: m.Captures["lvar.def"].Node, }) // Buffer the annotated type so the post-pass (once // funcRanges exist) can attribute an EdgeTypedAs to the // enclosing function — a type used only in a local // annotation seeds tenv but otherwise emits no reference, // so find_usages would miss it without an LSP. typeUses = append(typeUses, csharpTypeUse{ typeText: m.Captures["lvar.type"].Text, line: m.Captures["lvar.type"].StartLine + 1, }) } }) // Stamp interface method names onto interface nodes' Meta["methods"]. for _, n := range result.Nodes { if n.Kind != graph.KindInterface { continue } if methods, ok := ifaceMethods[n.Name]; ok { if n.Meta == nil { n.Meta = make(map[string]any) } n.Meta["methods"] = methods } } // Build type environment in legacy precedence: // Tier 0 — explicit type annotations (skip "var" placeholder) // Tier 1 — `var x = new Foo()` walk for `var`-keyed locals only tenv := make(typeEnv) for _, l := range locals { typeName := normalizeCSharpTypeName(l.rawType) if typeName != "" && typeName != "var" { tenv[l.name] = typeName } } for _, l := range locals { if _, exists := tenv[l.name]; exists { continue } if l.rawType != "var" { continue } if l.defNode == nil { continue } walkNodes(l.defNode, func(n *sitter.Node) { if n.Type() == "object_creation_expression" { typeName := inferTypeFromCSharpNew(n, src) if typeName != "" { tenv[l.name] = typeName } } }) } // Resolve calls against funcRanges + tenv. funcRanges := buildFuncRanges(result) // Local-variable type annotations → EdgeTypedAs from the enclosing // function (file node as fallback). Mirrors the parameter/return // type-use emission so a type referenced only in a local body // declaration is still a navigable reference without an LSP. for _, tu := range typeUses { ownerID := findEnclosingFunc(funcRanges, tu.line) if ownerID == "" { ownerID = fileID } emitCSharpTypeUseEdges(ownerID, tu.typeText, filePath, tu.line, result) } // Expression-site type references the symbol/annotation walk misses: // instantiation (`new Foo()`), casts / type-tests (`(Foo)x`, `x is Foo`, // `x as Foo`), static / const access (`Foo.Empty`, `typeof(Foo)`, // `nameof(Foo)`), and attribute type names (`[Foo]`). Inheritance is // already covered by emitCSharpBaseList, so it is not re-emitted here. emitCSharpReferenceForms(root, src, filePath, fileID, result) for _, c := range calls { callerID := findEnclosingFunc(funcRanges, c.line) if callerID == "" { continue } if c.isMember { edge := &graph.Edge{ From: callerID, To: "unresolved::*." + c.name, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } if recvType, ok := tenv[c.receiver]; ok { edge.Meta = map[string]any{"receiver_type": recvType} } else if strings.Contains(c.receiver, ".") || strings.Contains(c.receiver, "(") { stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, tenv, result)) } stampReturnUsage(edge, c.returnUsage) result.Edges = append(result.Edges, edge) continue } edge := &graph.Edge{ From: callerID, To: "unresolved::" + c.name, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } stampReturnUsage(edge, c.returnUsage) result.Edges = append(result.Edges, edge) } // .NET surfaces a symbol walk misses: DI registrations + COM // interop. Stamped onto the file node. detectDotNetSurfaces(src, result) // Same-file constant/variable value references → impact-radius reads. captureValueRefCandidates(result, root, filePath, src) captureFnValueCandidates(result, root, filePath, src) captureMediatRDispatch(result, root, filePath, src) return result, hadError, nil } // --- Per-match emit helpers ----------------------------------------- func (e *CSharpExtractor) emitNamespace(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool) { name := m.Captures["ns.name"].Text def := m.Captures["ns.def"] id := filePath + "::" + name if seen[id] { return } seen[id] = true result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindPackage, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "csharp", }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) } // emitContainer collapses the per-kind class/interface/struct/enum // node emission. The capture-name prefix selects which capture set to // read from (the legacy code repeated this body four times). func (e *CSharpExtractor) emitContainer(m parser.QueryResult, kind string, nodeKind graph.NodeKind, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, localInterfaces map[string]bool) { name := m.Captures[kind+".name"].Text def := m.Captures[kind+".def"] id := filePath + "::" + name if seen[id] { return } seen[id] = true meta := map[string]any{"visibility": csharpVisibility(def.Node, src, VisibilityInternal)} // A struct is a value type; record struct too. Surfacing it lets a // consumer reason about copy-vs-reference semantics. if kind == "struct" { meta["value_type"] = true } // Structural flavor, keyed off the capture that funnelled in here. switch kind { case "iface": meta["type_flavor"] = "interface" case "struct": meta["type_flavor"] = "struct" case "enum": meta["type_flavor"] = "enum" case "record": meta["type_flavor"] = "record" default: meta["type_flavor"] = "class" } // Namespace scope so a type in `namespace App.Core` is attributable // without re-deriving its enclosing namespace from source. if ns := csharpEnclosingNamespace(def.Node, src); ns != "" { meta["scope_ns"] = ns } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: nodeKind, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "csharp", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) emitCSharpAnnotationEdges(csharpCollectAttributes(def.Node, src), id, filePath, result, annotationSeen) emitCSharpGenericParamNodes(id, def.Node, src, filePath, def.StartLine+1, result) // Only classes, structs, and records carry a base class / interface // list that splits into EdgeExtends + EdgeImplements. Structs and // `record struct` declarations have no base class — every base is an // interface — which emitCSharpBaseList infers from the declaration. switch kind { case "class", "struct", "record": emitCSharpBaseList(id, def.Node, src, filePath, localInterfaces, result) case "enum": e.emitCSharpEnumMembers(def.Node, src, filePath, id, name, result, seen) } } // emitCSharpEnumMembers emits one KindEnumMember per `enum_member_declaration` // in an enum body, with its explicit value (when given) and a MemberOf edge to // the enum — so an enum's members are navigable symbols, not lost in the type. func (e *CSharpExtractor) emitCSharpEnumMembers(enumNode *sitter.Node, src []byte, filePath, enumID, enumName string, result *parser.ExtractionResult, seen map[string]bool) { var list *sitter.Node for i, _nc := 0, int(enumNode.ChildCount()); i < _nc; i++ { if c := enumNode.Child(i); c != nil && c.Type() == "enum_member_declaration_list" { list = c break } } if list == nil { return } for i, _nc := 0, int(list.NamedChildCount()); i < _nc; i++ { mem := list.NamedChild(i) if mem.Type() != "enum_member_declaration" { continue } var nameNode, valNode *sitter.Node if nn := mem.ChildByFieldName("name"); nn != nil { nameNode = nn } for j, _nc := 0, int(mem.NamedChildCount()); j < _nc; j++ { c := mem.NamedChild(j) if c.Type() == "identifier" && nameNode == nil { nameNode = c } else if c != mem.ChildByFieldName("name") && c.Type() != "identifier" { valNode = c } } if nameNode == nil { continue } mname := nameNode.Content(src) line := int(mem.StartPoint().Row) + 1 id, ok := disambiguateID(seen, filePath+"::"+enumName+"."+mname, line) if !ok { continue } emeta := map[string]any{"enum": enumID, "receiver": enumName} if valNode != nil { emeta["value"] = strings.TrimSpace(valNode.Content(src)) } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindEnumMember, Name: mname, FilePath: filePath, StartLine: line, EndLine: line, Language: "csharp", Meta: emeta, }) result.Edges = append(result.Edges, &graph.Edge{ From: id, To: enumID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line, }) } } // csharpHasModifier reports whether a declaration carries the given modifier // keyword (const / static / async / readonly / …). func csharpHasModifier(decl *sitter.Node, src []byte, mod string) bool { if decl == nil { return false } for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c != nil && c.Type() == "modifier" && strings.TrimSpace(c.Content(src)) == mod { return true } } return false } // csharpExtensionReceiverType returns the generics- and namespace-stripped // type of a method's first parameter when that parameter carries the `this` // modifier — the receiver type of a C# extension method (`static int Foo(this // string s)` → "string"). Returns "" for a non-extension method. Unlike // normalizeCSharpTypeName it keeps primitive receivers (string / int), since // extension methods commonly extend them. func csharpExtensionReceiverType(methodNode *sitter.Node, src []byte) string { if methodNode == nil { return "" } params := methodNode.ChildByFieldName("parameters") if params == nil { return "" } var first *sitter.Node for i, _nc := 0, int(params.NamedChildCount()); i < _nc; i++ { c := params.NamedChild(i) if c != nil && c.Type() == "parameter" { first = c break } } if first == nil { return "" } hasThis := false for i, _nc := 0, int(first.ChildCount()); i < _nc; i++ { c := first.Child(i) if c == nil { continue } // The `this` marker is a `modifier` child (grammar revisions may also // spell it `parameter_modifier` or a bare `this` keyword node). ct := c.Type() if (ct == "modifier" || ct == "parameter_modifier") && strings.TrimSpace(c.Content(src)) == "this" { hasThis = true break } if ct == "this" { hasThis = true break } } if !hasThis { return "" } t := first.ChildByFieldName("type") if t == nil { return "" } return normalizeCSharpBaseName(t.Content(src)) } // csharpEnclosingNamespace returns the dotted name of the nearest enclosing // namespace declaration (block or file-scoped), or "". func csharpEnclosingNamespace(node *sitter.Node, src []byte) string { 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)) } } } } return "" } // emitAnonymousType indexes a C# anonymous type — `new { Name = ..., Age = ... }` // — as a synthetic KindType node with an EdgeExtends to object, its implicit // base. C# anonymous types are nameless compiler-generated classes that derive // directly from System.Object; surfacing each instantiation as a distinct type // keeps the graph's type set complete and gives the projection a node to anchor // to, rather than vanishing into the expression that produced it. func (e *CSharpExtractor) emitAnonymousType(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, seen map[string]bool) { def := m.Captures["anon.def"] line := def.StartLine + 1 name := fmt.Sprintf("anon@%d", line) id := filePath + "::" + name if seen[id] { return } seen[id] = true result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindType, Name: name, FilePath: filePath, StartLine: line, EndLine: def.EndLine + 1, Language: "csharp", Meta: map[string]any{"anonymous": true, "type_flavor": "anonymous_class"}, }) result.Edges = append(result.Edges, &graph.Edge{From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line}, &graph.Edge{From: id, To: "unresolved::object", Kind: graph.EdgeExtends, FilePath: filePath, Line: line, Origin: graph.OriginASTInferred}, ) } // csharpVisibility scans a declaration's modifier children for an // access modifier. C# defaults are container-dependent — defaultVis is // "internal" for top-level types and "private" for class members. func csharpVisibility(decl *sitter.Node, src []byte, defaultVis string) string { if decl == nil { return defaultVis } for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c == nil { continue } if c.Type() != "modifier" { continue } switch strings.TrimSpace(c.Content(src)) { case "public": return VisibilityPublic case "private": return VisibilityPrivate case "protected": return VisibilityProtected case "internal": return VisibilityInternal } } return defaultVis } // csharpCollectAttributes walks a declaration's children for // `attribute_list` nodes ([Attr1, Attr2(...)]) and returns each // attribute's bare name plus verbatim args. Multiple attributes can // appear inside one bracket pair, and multiple bracket pairs can // stack on the same declaration. func csharpCollectAttributes(decl *sitter.Node, src []byte) []javaAnnotation { if decl == nil { return nil } var out []javaAnnotation for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c == nil || c.Type() != "attribute_list" { continue } for j, _nc := 0, int(c.ChildCount()); j < _nc; j++ { a := c.Child(j) if a == nil || a.Type() != "attribute" { continue } var name, args string line := int(a.StartPoint().Row) + 1 if nm := a.ChildByFieldName("name"); nm != nil { name = nm.Content(src) } for k, _nc := 0, int(a.ChildCount()); k < _nc; k++ { inner := a.Child(k) if inner == nil { continue } if inner.Type() == "attribute_argument_list" { txt := inner.Content(src) if len(txt) >= 2 && txt[0] == '(' && txt[len(txt)-1] == ')' { txt = txt[1 : len(txt)-1] } args = txt } } if name != "" { out = append(out, javaAnnotation{name: name, args: args, line: line}) } } } return out } func emitCSharpAnnotationEdges(anns []javaAnnotation, fromID, filePath string, result *parser.ExtractionResult, seen map[string]bool) { for _, a := range anns { if a.name == "" { continue } EmitAnnotationEdge(fromID, "csharp", a.name, a.args, filePath, a.line, result, seen) } } // extractCSharpDoc tries the XML-doc form first (/// …) and // falls back to /** … */ block comments (less common in C# but valid). func extractCSharpDoc(src []byte, startRow int) string { if d := ExtractDocAbove(src, startRow, DocLangCSharpXML); d != "" { return d } return ExtractDocAbove(src, startRow, DocLangBlockStar) } func (e *CSharpExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen, annotationSeen map[string]bool, ifaceMethods map[string][]string) { name := m.Captures["method.name"].Text def := m.Captures["method.def"] startLine1 := def.StartLine + 1 owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration", "interface_declaration") if owner.kind == "" { // Method outside a recognised container — legacy didn't emit // these (its nested queries required class/struct/interface // parentage), so skip. return } // Interface members feed the interface node's method-name list (read // back where interface nodes are stamped). Beyond that list they also // need their own member-level node: a through-interface call site binds // to the interface member, so find_usages can only answer — and the // member-level dispatch synthesis can only fan out to implementations — // when that node exists. A C# 8 default interface method carries a body // and otherwise flows through the concrete-method path unchanged. isIface := owner.kind == "interface_declaration" if isIface { ifaceMethods[owner.name] = append(ifaceMethods[owner.name], name) } id := filePath + "::" + owner.name + "." + name if seen[id] { id = filePath + "::" + owner.name + "." + name + "_L" + fmt.Sprint(startLine1) } if seen[id] { return } seen[id] = true // Interface members are implicitly public; an explicit modifier (a C# 8 // default member marked private/protected) still wins via csharpVisibility. defaultVis := VisibilityPrivate if isIface { defaultVis = VisibilityPublic } meta := map[string]any{ "receiver": owner.name, "visibility": csharpVisibility(def.Node, src, defaultVis), } // Distinguish a bodyless interface declaration from a concrete method so // dispatch synthesis and analyzers can tell them apart; a bodyless // member must never be treated as a dead-code candidate just for lacking // a body. if isIface { meta["iface_member"] = true } if rt := extractCSharpMethodReturnType(def.Node, src, name); rt != "" { meta["return_type"] = rt } if csharpHasModifier(def.Node, src, "async") { meta["async"] = true } if csharpHasModifier(def.Node, src, "static") { meta["static"] = true } // Extension method: a static method whose first parameter carries the // `this` modifier. Record the receiver type it extends so member-call // resolution can bind `x.Foo()` to it (the id stays .). if extType := csharpExtensionReceiverType(def.Node, src); extType != "" { meta["extension"] = true meta["this_param_type"] = extType } if ns := csharpEnclosingNamespace(def.Node, src); ns != "" { meta["scope_ns"] = ns } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindMethod, Name: name, FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1, Language: "csharp", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1, }) ownerID := filePath + "::" + owner.name result.Edges = append(result.Edges, &graph.Edge{ From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1, }) emitCSharpAnnotationEdges(csharpCollectAttributes(def.Node, src), id, filePath, result, annotationSeen) if body := csharpFunctionBody(def.Node); body != nil { emitCSharpAsyncSpawns(id, body, src, filePath, result) } emitCSharpFunctionShape(id, def.Node, src, filePath, startLine1, result) } func (e *CSharpExtractor) emitConstructor(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { def := m.Captures["ctor.def"] startLine1 := def.StartLine + 1 owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration") if owner.kind == "" { return } id := filePath + "::" + owner.name + "." if seen[id] { id = filePath + "::" + owner.name + "._L" + fmt.Sprint(startLine1) } if seen[id] { return } seen[id] = true result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindMethod, Name: owner.name + ".", FilePath: filePath, StartLine: startLine1, EndLine: def.EndLine + 1, Language: "csharp", Meta: map[string]any{"receiver": owner.name}, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine1, }) ownerID := filePath + "::" + owner.name result.Edges = append(result.Edges, &graph.Edge{ From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine1, }) // Constructor params: same shape as methods so DI containers and // codegen tooling see the dependencies they need. if body := csharpFunctionBody(def.Node); body != nil { emitCSharpAsyncSpawns(id, body, src, filePath, result) } emitCSharpFunctionShape(id, def.Node, src, filePath, startLine1, result) } func (e *CSharpExtractor) emitField(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { def := m.Captures["field.def"] owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration", "interface_declaration") if owner.kind == "" { return } name := m.Captures["field.name"].Text id := filePath + "::" + owner.name + "." + name if seen[id] { return } seen[id] = true // Interface fields (C# 8+ static/const members) are implicitly public. isIface := owner.kind == "interface_declaration" defaultVis := VisibilityPrivate if isIface { defaultVis = VisibilityPublic } meta := map[string]any{ "receiver": owner.name, "visibility": csharpVisibility(def.Node, src, defaultVis), } if isIface { meta["iface_member"] = true } // A field_declaration's type lives on its nested variable_declaration // (`field_declaration → variable_declaration[type] → variable_declarator`), // not as a direct `type` field of the field_declaration itself. fieldTypeRaw := csharpFieldDeclType(def.Node, src) if fieldTypeRaw != "" { meta["field_type"] = fieldTypeRaw } // A `const` field is a compile-time constant, not a mutable field — // classify it as KindConstant so it joins the value-reference impact // surface. `static` / `readonly` are stamped for completeness. fieldKind := graph.KindField if csharpHasModifier(def.Node, src, "const") { fieldKind = graph.KindConstant } if csharpHasModifier(def.Node, src, "static") { meta["static"] = true } if csharpHasModifier(def.Node, src, "readonly") { meta["readonly"] = true } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: fieldKind, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "csharp", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) ownerID := filePath + "::" + owner.name result.Edges = append(result.Edges, &graph.Edge{ From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1, }) // Field type annotation → EdgeTypedAs from the field node, so a type // used only as a field's declared type (`private Session _s;`) is a // navigable reference without an LSP. emitCSharpTypeUseEdges(id, fieldTypeRaw, filePath, def.StartLine+1, result) } func (e *CSharpExtractor) emitProperty(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { def := m.Captures["prop.def"] owner := csharpDirectMemberOwner(def.Node, src, "class_declaration", "struct_declaration", "interface_declaration") if owner.kind == "" { return } name := m.Captures["prop.name"].Text id := filePath + "::" + owner.name + "." + name if seen[id] { return } seen[id] = true // Interface properties are implicitly public; explicit modifiers still win. isIface := owner.kind == "interface_declaration" defaultVis := VisibilityPrivate if isIface { defaultVis = VisibilityPublic } meta := map[string]any{ "receiver": owner.name, "visibility": csharpVisibility(def.Node, src, defaultVis), "kind": "property", } if isIface { meta["iface_member"] = true } var propTypeRaw string if t := def.Node.ChildByFieldName("type"); t != nil { propTypeRaw = strings.TrimSpace(t.Content(src)) meta["field_type"] = propTypeRaw } if doc := extractCSharpDoc(src, def.StartLine); doc != "" { meta["doc"] = doc } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindField, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "csharp", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) ownerID := filePath + "::" + owner.name result.Edges = append(result.Edges, &graph.Edge{ From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1, }) // Property type annotation → EdgeTypedAs from the property node. emitCSharpTypeUseEdges(id, propTypeRaw, filePath, def.StartLine+1, result) } func (e *CSharpExtractor) emitUsing(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) { path := m.Captures["using.path"] importPath := strings.ReplaceAll(path.Text, ".", "/") result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: "unresolved::import::" + importPath, Kind: graph.EdgeImports, FilePath: filePath, Line: path.StartLine + 1, }) } // --- Helpers -------------------------------------------------------- type csharpOwner struct { kind string // class_declaration / struct_declaration / interface_declaration name string } // csharpDirectMemberOwner mirrors the legacy nested queries: the // member must be a direct child of the container's declaration_list. // Returns kind == "" when the member isn't directly inside one of the // allowed container kinds (skipping nested types, top-level statements, // etc. — none of which the legacy extractor handled). func csharpDirectMemberOwner(member *sitter.Node, src []byte, allowed ...string) csharpOwner { if member == nil { return csharpOwner{} } parent := member.Parent() if parent == nil || parent.Type() != "declaration_list" { return csharpOwner{} } grand := parent.Parent() if grand == nil { return csharpOwner{} } gtype := grand.Type() for _, a := range allowed { if gtype == a { nameNode := grand.ChildByFieldName("name") if nameNode == nil { return csharpOwner{} } return csharpOwner{kind: gtype, name: nameNode.Content(src)} } } return csharpOwner{} } // collectCSharpInterfaceNames walks the tree for every // interface_declaration and records its bare name. The base-list // heuristic consults this set first: a base type that names a // locally-declared interface is unambiguously an interface, regardless // of whether its name follows the `I`-prefix convention. func collectCSharpInterfaceNames(root *sitter.Node, src []byte) map[string]bool { names := make(map[string]bool) walkNodes(root, func(n *sitter.Node) { if n.Type() != "interface_declaration" { return } if nameNode := n.ChildByFieldName("name"); nameNode != nil { names[nameNode.Content(src)] = true } }) return names } // emitCSharpBaseList splits a class/struct/record base list into // EdgeExtends (the superclass) and EdgeImplements (the interfaces). // // C# lists the optional base class and any implemented interfaces in a // single comma-separated base_list, and — unlike Go or Java — the // grammar does not tag which entry is the class. When a base type is // defined elsewhere (another compilation unit) the extractor cannot // resolve its kind, so it discriminates with a heuristic: // // 1. A base whose name matches a locally-declared interface (the // prescan set) is definitively an interface → EdgeImplements. // 2. Otherwise a base whose name matches the `I`-prefix convention // (^I[A-Z], generics stripped first so IList → IList) is treated // as an interface → EdgeImplements. // 3. The first base that is neither is the superclass → EdgeExtends. // C# allows at most one base class and it must come first; every // base after it is an interface. Structs and `record struct` // declarations have no base class, so all of their bases are // interfaces regardless of position. // // All edges ride at OriginASTInferred: the discrimination is a // heuristic, not a type-checked fact. Targets are left unresolved so // the resolver binds them like every other C# reference. A base that // resolves to a same-file class still flows through unchanged — it is // neither a known interface nor I-prefixed, so it lands as EdgeExtends. func emitCSharpBaseList(typeID string, decl *sitter.Node, src []byte, filePath string, localInterfaces map[string]bool, result *parser.ExtractionResult) { if decl == nil { return } baseList := decl.ChildByFieldName("bases") if baseList == nil { // `bases` is not a named field in every grammar revision; fall // back to a direct child scan for the base_list node. for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c != nil && c.Type() == "base_list" { baseList = c break } } } if baseList == nil { return } // Structs and `record struct` cannot derive from a base class — the // CLR forbids it — so every entry in their base list is an interface // and the "first non-interface is the superclass" branch never runs. allowsBaseClass := csharpDeclAllowsBaseClass(decl) extendsTaken := false for i, _nc := 0, int(baseList.NamedChildCount()); i < _nc; i++ { entry := baseList.NamedChild(i) if entry == nil { continue } name, isCtorBase := csharpBaseTypeName(entry, src) if name == "" { continue } line := int(entry.StartPoint().Row) + 1 // A primary_constructor_base_type (`: Base(args)`) invokes a base // constructor, which is only valid for a base class — it is never // an interface. isInterface := !isCtorBase && (localInterfaces[name] || csharpInterfaceNamePattern.MatchString(name)) kind := graph.EdgeImplements if !isInterface && allowsBaseClass && !extendsTaken { kind = graph.EdgeExtends extendsTaken = true } result.Edges = append(result.Edges, &graph.Edge{ From: typeID, To: "unresolved::" + name, Kind: kind, FilePath: filePath, Line: line, Origin: graph.OriginASTInferred, }) } } // csharpDeclAllowsBaseClass reports whether a class/struct/record // declaration can have a base class. Structs never can; a record is a // struct when its declaration carries the `struct` keyword // (`record struct`), otherwise it is a reference type that can extend a // base record/class. func csharpDeclAllowsBaseClass(decl *sitter.Node) bool { switch decl.Type() { case "struct_declaration": return false case "record_declaration": for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { if c := decl.Child(i); c != nil && c.Type() == "struct" { return false } } return true default: return true } } // csharpBaseTypeName extracts the bare type name from a single base_list // entry, stripping generic arguments and namespace qualification so the // `I`-prefix test sees IList rather than IList or System.IList. The // bool return reports whether the entry is a primary_constructor_base_type // (`Base(args)`), which can only ever be a base class. func csharpBaseTypeName(entry *sitter.Node, src []byte) (string, bool) { switch entry.Type() { case "identifier": return entry.Content(src), false case "generic_name": // First child is the base identifier; the type_argument_list // follows. IList → IList. if id := entry.ChildByFieldName("name"); id != nil { return id.Content(src), false } for i, _nc := 0, int(entry.ChildCount()); i < _nc; i++ { if c := entry.Child(i); c != nil && c.Type() == "identifier" { return c.Content(src), false } } case "qualified_name": // System.Object → Object (the last identifier). var last string for i, _nc := 0, int(entry.ChildCount()); i < _nc; i++ { if c := entry.Child(i); c != nil && c.Type() == "identifier" { last = c.Content(src) } } return last, false case "primary_constructor_base_type": // `: Base(args)` — record base-constructor call; always a class. if id := entry.ChildByFieldName("type"); id != nil { return normalizeCSharpBaseName(id.Content(src)), true } for i, _nc := 0, int(entry.ChildCount()); i < _nc; i++ { c := entry.Child(i) if c == nil { continue } if c.Type() == "identifier" || c.Type() == "generic_name" || c.Type() == "qualified_name" { return normalizeCSharpBaseName(c.Content(src)), true } } } return "", false } // normalizeCSharpBaseName reduces a raw base-type spelling to its bare // simple name: drops generic arguments (Foo → Foo) and namespace // qualification (A.B.Foo → Foo). func normalizeCSharpBaseName(raw string) string { raw = strings.TrimSpace(raw) if idx := strings.Index(raw, "<"); idx > 0 { raw = raw[:idx] } if idx := strings.LastIndex(raw, "."); idx >= 0 { raw = raw[idx+1:] } return strings.TrimSpace(raw) } // extractCSharpMethodReturnType walks a method_declaration node for // the type child preceding the method name. func extractCSharpMethodReturnType(methodNode *sitter.Node, src []byte, methodName string) string { if methodNode == nil { return "" } for i, _nc := 0, int(methodNode.ChildCount()); i < _nc; i++ { child := methodNode.Child(i) if child.Type() == "identifier" && string(src[child.StartByte():child.EndByte()]) == methodName { break } switch child.Type() { case "predefined_type", "identifier", "qualified_name", "generic_name", "nullable_type", "array_type", "tuple_type": rawType := string(src[child.StartByte():child.EndByte()]) if rt := normalizeCSharpTypeName(rawType); rt != "" && rt != "var" { return rt } } } return "" } // normalizeCSharpTypeName strips generics and nullable markers from a C# type name. func normalizeCSharpTypeName(t string) string { t = strings.TrimSpace(t) // Remove nullable suffix. t = strings.TrimSuffix(t, "?") // Remove array suffix. if idx := strings.Index(t, "["); idx > 0 { t = t[:idx] } // Remove generics. if idx := strings.Index(t, "<"); idx > 0 { t = t[:idx] } // Skip C# primitives and keywords. switch t { case "var", "int", "long", "short", "byte", "float", "double", "decimal", "bool", "char", "string", "object", "void", "dynamic": if t == "var" { return "var" // caller handles this specially } return "" } if t == "" || (t[0] >= 'a' && t[0] <= 'z') { return "" } return t } // csharpFieldDeclType returns the verbatim declared type of a // field_declaration. The type is a field of the nested // variable_declaration node, not of the field_declaration itself, so a // direct ChildByFieldName("type") on the field_declaration is always nil. func csharpFieldDeclType(fieldDecl *sitter.Node, src []byte) string { if fieldDecl == nil { return "" } for i, _nc := 0, int(fieldDecl.NamedChildCount()); i < _nc; i++ { c := fieldDecl.NamedChild(i) if c == nil || c.Type() != "variable_declaration" { continue } if t := c.ChildByFieldName("type"); t != nil { return strings.TrimSpace(t.Content(src)) } // Fallback: first named child of the variable_declaration is the // type in grammar revisions that don't tag the field. if c.NamedChildCount() > 0 { if first := c.NamedChild(0); first != nil && first.Type() != "variable_declarator" { return strings.TrimSpace(first.Content(src)) } } } return "" } // inferTypeFromCSharpNew extracts the type name from a C# object_creation_expression. // new UserService(...) -> "UserService" func inferTypeFromCSharpNew(node *sitter.Node, src []byte) string { for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ { child := node.NamedChild(i) if child.Type() == "identifier" || child.Type() == "type_identifier" || child.Type() == "generic_name" || child.Type() == "qualified_name" { name := child.Content(src) // Strip generics from generic_name. if idx := strings.Index(name, "<"); idx > 0 { name = name[:idx] } if len(name) > 0 && name[0] >= 'A' && name[0] <= 'Z' { return name } } } return "" }