Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

339 lines
11 KiB
Go

package languages
import (
"encoding/xml"
"path/filepath"
"regexp"
"strings"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/modules"
"github.com/zzet/gortex/internal/parser"
)
// DotNetProjectExtractor ingests .NET solution (.sln) and project
// (.csproj/.fsproj/.vbproj) files into the graph. It does NOT parse C#
// source — that is the tree-sitter C# extractor's job (with
// detectDotNetSurfaces stamping the .cs metadata). This extractor
// builds the *build graph*: which projects a solution groups, how
// projects depend on each other (ProjectReference / solution
// ProjectDependencies), and which NuGet packages a project pulls in
// (PackageReference → KindModule, ecosystem "nuget").
//
// File nodes are keyed by their repo-relative path so a ProjectReference
// emitted here lines up with the file node a later .csproj index pass
// produces — matching IDs is what stitches the cross-project edges
// together; no stub bookkeeping is required.
type DotNetProjectExtractor struct{}
// NewDotNetProjectExtractor constructs a DotNetProjectExtractor.
func NewDotNetProjectExtractor() *DotNetProjectExtractor { return &DotNetProjectExtractor{} }
func (e *DotNetProjectExtractor) Language() string { return "dotnet" }
func (e *DotNetProjectExtractor) Extensions() []string {
return []string{".sln", ".csproj", ".fsproj", ".vbproj"}
}
func (e *DotNetProjectExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
switch strings.ToLower(filepath.Ext(filePath)) {
case ".sln":
return e.extractSolution(filePath, src), nil
default:
// .csproj / .fsproj / .vbproj — all share the MSBuild XML shape.
return e.extractProject(filePath, src), nil
}
}
// slnProjectRe matches a Visual Studio solution Project declaration:
//
// Project("{FAE04EC0-...}") = "MyApp", "src\MyApp\MyApp.csproj", "{GUID}"
//
// Capture groups: 1=project type GUID, 2=project name, 3=relative path,
// 4=project GUID.
var slnProjectRe = regexp.MustCompile(
`(?i)^\s*Project\("\{([^}]*)\}"\)\s*=\s*"([^"]*)",\s*"([^"]*)",\s*"\{([^}]*)\}"`)
// slnDependencyRe matches a single dependency entry inside a
// ProjectSection(ProjectDependencies) block: `{GUID1} = {GUID2}`.
var slnDependencyRe = regexp.MustCompile(
`^\s*\{([^}]*)\}\s*=\s*\{([^}]*)\}`)
// extractSolution parses a .sln file line by line. The .sln is an
// INI/text format; we capture each Project(...) declaration as a file
// node for the referenced project and wire ProjectDependencies blocks
// into EdgeDependsOn edges between project file nodes.
func (e *DotNetProjectExtractor) extractSolution(filePath string, src []byte) *parser.ExtractionResult {
result := &parser.ExtractionResult{}
slnDir := filepath.Dir(filePath)
lines := strings.Split(string(src), "\n")
slnNode := &graph.Node{
ID: filePath,
Kind: graph.KindFile,
Name: filepath.Base(filePath),
FilePath: filePath,
Language: "dotnet",
StartLine: 1,
EndLine: len(lines),
Meta: map[string]any{"kind": "dotnet_solution"},
}
result.Nodes = append(result.Nodes, slnNode)
// guid (upper-cased) -> resolved repo-relative project file path.
guidToPath := make(map[string]string)
// projects we have already emitted a node for, keyed by resolved path.
seenProject := make(map[string]bool)
// First pass: collect every Project(...) declaration so the GUID map
// is complete before we resolve ProjectDependencies (which may
// reference a project declared later in the file).
type pendingDep struct{ owner, dep string } // owner/dep are GUIDs (upper-cased)
var deps []pendingDep
var currentOwnerGUID string // GUID of the Project whose section we're in
inDepSection := false
for i, raw := range lines {
line := strings.TrimSpace(raw)
switch {
case strings.HasPrefix(line, "Project("):
m := slnProjectRe.FindStringSubmatch(line)
if m == nil {
currentOwnerGUID = ""
continue
}
typeGUID := m[1]
name := m[2]
rel := m[3]
projGUID := strings.ToUpper(m[4])
currentOwnerGUID = projGUID
// Solution folders use the well-known type GUID
// {2150E333-8FDC-42A3-9474-1A3956D46DE8} and a virtual path
// that is not a real file. Skip anything whose path doesn't
// look like a project file.
if !looksLikeProjectPath(rel) {
continue
}
resolved := resolveDotNetPath(slnDir, rel)
guidToPath[projGUID] = resolved
if !seenProject[resolved] {
seenProject[resolved] = true
result.Nodes = append(result.Nodes, &graph.Node{
ID: resolved,
Kind: graph.KindFile,
Name: name,
FilePath: resolved,
Language: "dotnet",
StartLine: i + 1,
EndLine: i + 1,
Meta: map[string]any{
"project_guid": projGUID,
"project_type": typeGUID,
"kind": "dotnet_project",
},
})
result.Edges = append(result.Edges, &graph.Edge{
From: slnNode.ID,
To: resolved,
Kind: graph.EdgeDefines,
FilePath: filePath,
Line: i + 1,
Origin: graph.OriginASTResolved,
})
}
case strings.HasPrefix(line, "EndProject"):
currentOwnerGUID = ""
inDepSection = false
case strings.HasPrefix(line, "ProjectSection(ProjectDependencies)"):
inDepSection = true
case strings.HasPrefix(line, "EndProjectSection"):
inDepSection = false
default:
if inDepSection && currentOwnerGUID != "" {
dm := slnDependencyRe.FindStringSubmatch(line)
if dm == nil {
continue
}
// Inside a ProjectDependencies section the line
// `{GUID1} = {GUID2}` means the owning project depends on
// GUID2 (GUID1 is conventionally the same as the owner).
deps = append(deps, pendingDep{
owner: currentOwnerGUID,
dep: strings.ToUpper(dm[2]),
})
}
}
}
// Second pass: resolve GUID dependencies into file-node edges now
// that guidToPath is complete.
for _, d := range deps {
from, ok1 := guidToPath[d.owner]
to, ok2 := guidToPath[d.dep]
if !ok1 || !ok2 || from == to {
continue
}
result.Edges = append(result.Edges, &graph.Edge{
From: from,
To: to,
Kind: graph.EdgeDependsOn,
FilePath: filePath,
Origin: graph.OriginASTResolved,
})
}
return result
}
// msbuildProject is the minimal MSBuild project shape we read. Both
// SDK-style and legacy .csproj/.fsproj/.vbproj files share these
// element names; we ignore everything we don't model.
type msbuildProject struct {
XMLName xml.Name `xml:"Project"`
PropertyGroups []struct {
TargetFramework string `xml:"TargetFramework"`
TargetFrameworks string `xml:"TargetFrameworks"`
} `xml:"PropertyGroup"`
ItemGroups []struct {
ProjectReferences []struct {
Include string `xml:"Include,attr"`
} `xml:"ProjectReference"`
PackageReferences []struct {
Include string `xml:"Include,attr"`
Version string `xml:"Version,attr"`
} `xml:"PackageReference"`
} `xml:"ItemGroup"`
}
// extractProject parses an MSBuild project file. It emits the project's
// own file node (with target-framework meta), EdgeDependsOn edges to
// referenced sibling projects, and KindModule nodes + EdgeDependsOnModule
// edges for NuGet PackageReferences.
func (e *DotNetProjectExtractor) extractProject(filePath string, src []byte) *parser.ExtractionResult {
result := &parser.ExtractionResult{}
projDir := filepath.Dir(filePath)
var proj msbuildProject
// Malformed project XML is best-effort: emit the file node so the
// project still appears in the graph, but skip references.
parseErr := xml.Unmarshal(src, &proj)
// Collect the target framework(s) for meta.
var frameworks []string
if parseErr == nil {
for _, pg := range proj.PropertyGroups {
if tf := strings.TrimSpace(pg.TargetFramework); tf != "" {
frameworks = append(frameworks, tf)
}
if tfs := strings.TrimSpace(pg.TargetFrameworks); tfs != "" {
for _, f := range strings.Split(tfs, ";") {
if f = strings.TrimSpace(f); f != "" {
frameworks = append(frameworks, f)
}
}
}
}
}
fileMeta := map[string]any{"kind": "dotnet_project"}
if len(frameworks) > 0 {
fileMeta["target_framework"] = strings.Join(frameworks, ";")
}
fileNode := &graph.Node{
ID: filePath,
Kind: graph.KindFile,
Name: filepath.Base(filePath),
FilePath: filePath,
Language: "dotnet",
StartLine: 1,
EndLine: 1,
Meta: fileMeta,
}
result.Nodes = append(result.Nodes, fileNode)
if parseErr != nil {
return result
}
seenRef := make(map[string]bool)
seenModule := make(map[string]bool)
for _, ig := range proj.ItemGroups {
for _, pr := range ig.ProjectReferences {
rel := strings.TrimSpace(pr.Include)
if rel == "" {
continue
}
target := resolveDotNetPath(projDir, rel)
if target == filePath || seenRef[target] {
continue
}
seenRef[target] = true
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID,
To: target,
Kind: graph.EdgeDependsOn,
FilePath: filePath,
Origin: graph.OriginASTResolved,
})
}
for _, pkg := range ig.PackageReferences {
name := strings.TrimSpace(pkg.Include)
if name == "" {
continue
}
version := strings.TrimSpace(pkg.Version)
id := modules.ModuleNodeID("nuget", name, version)
if !seenModule[id] {
seenModule[id] = true
meta := map[string]any{
"ecosystem": "nuget",
"path": name,
"version": version,
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: id,
Kind: graph.KindModule,
Name: name,
FilePath: filePath,
Language: "dotnet",
Meta: meta,
})
}
result.Edges = append(result.Edges, &graph.Edge{
From: fileNode.ID,
To: id,
Kind: graph.EdgeDependsOnModule,
FilePath: filePath,
Origin: graph.OriginASTResolved,
})
}
}
return result
}
// resolveDotNetPath converts an MSBuild/solution-relative path (which
// uses Windows backslashes) into a clean, forward-slashed path relative
// to baseDir. The result keys file nodes so a ProjectReference here and
// the referenced project's own index pass agree on the ID.
func resolveDotNetPath(baseDir, rel string) string {
rel = strings.ReplaceAll(rel, `\`, "/")
joined := filepath.ToSlash(filepath.Join(baseDir, rel))
return filepath.ToSlash(filepath.Clean(joined))
}
// looksLikeProjectPath reports whether a solution Project(...) path
// points at a real project file (vs. a solution-folder virtual path,
// which is typically just the folder name with no extension).
func looksLikeProjectPath(rel string) bool {
switch strings.ToLower(filepath.Ext(rel)) {
case ".csproj", ".fsproj", ".vbproj", ".proj", ".sqlproj", ".vcxproj", ".shproj":
return true
default:
return false
}
}
var _ parser.Extractor = (*DotNetProjectExtractor)(nil)