a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
1316 lines
46 KiB
Go
1316 lines
46 KiB
Go
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 (/// <summary>…) 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 <StaticClass>.<name>).
|
|
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 + ".<init>"
|
|
if seen[id] {
|
|
id = filePath + "::" + owner.name + ".<init>_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 + ".<init>",
|
|
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<T> → 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<int> 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<int> → 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<T> → 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 ""
|
|
}
|