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
384 lines
13 KiB
Go
384 lines
13 KiB
Go
package languages
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
)
|
|
|
|
var (
|
|
// razorBlockRe matches a C# block opener: @code{ / @functions{ (group 1
|
|
// names the keyword) or a bare @{ statement block (group 1 empty).
|
|
razorBlockRe = regexp.MustCompile(`@(code|functions)\s*\{|@\{`)
|
|
razorModelRe = regexp.MustCompile(`(?m)^\s*@(?:model|inherits)\s+([A-Za-z_][\w.]*(?:<[^>]*>)?)`)
|
|
razorInjectRe = regexp.MustCompile(`(?m)^\s*@inject\s+([A-Za-z_][\w.]*(?:<[^>]*>)?)\s+\w+`)
|
|
razorTypeofRe = regexp.MustCompile(`@typeof\(\s*([A-Za-z_][\w.]*)`)
|
|
// razorUsingRe matches an `@using Some.Namespace` directive (optionally
|
|
// `@using static`, whose member-import we skip). The captured namespace
|
|
// feeds the resolver's import cascade.
|
|
razorUsingRe = regexp.MustCompile(`(?m)^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)`)
|
|
)
|
|
|
|
// RazorExtractor extracts Razor / Blazor files (.razor, .cshtml). It carves
|
|
// every @code{...} / @functions{...} block and delegates it to the C# extractor
|
|
// (rebased into host-file coordinates), and emits type references for the
|
|
// @model / @inherits / @inject directives.
|
|
type RazorExtractor struct {
|
|
cs *CSharpExtractor
|
|
}
|
|
|
|
// NewRazorExtractor constructs a Razor extractor.
|
|
func NewRazorExtractor() *RazorExtractor {
|
|
return &RazorExtractor{cs: NewCSharpExtractor()}
|
|
}
|
|
|
|
func (e *RazorExtractor) Language() string { return "razor" }
|
|
func (e *RazorExtractor) Extensions() []string { return []string{".razor", ".cshtml"} }
|
|
|
|
func (e *RazorExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
|
|
result := &parser.ExtractionResult{}
|
|
lineCount := 1 + strings.Count(string(src), "\n")
|
|
fileNode := &graph.Node{
|
|
ID: filePath, Kind: graph.KindFile, Name: filePath,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount, Language: "razor",
|
|
}
|
|
result.Nodes = append(result.Nodes, fileNode)
|
|
|
|
// One navigable component node per .razor file: a Blazor component IS the
|
|
// file, so emitting it as a real KindType node (not just a reference) lets
|
|
// it participate in find_usages / renders_child as a first-class symbol —
|
|
// where codegraph only ever emits a reference.
|
|
componentID := ""
|
|
if name := razorComponentName(filePath); name != "" {
|
|
componentID = filePath + "::" + name
|
|
compMeta := map[string]any{"component": true, "ui_component": "razor", "type_flavor": "component"}
|
|
// The component's Blazor namespace is its directory path dotted
|
|
// (`App/Widgets/Counter.razor` → `App.Widgets`), so an `@using`
|
|
// import of that namespace can bind a `<Counter/>` reference.
|
|
if ns := razorComponentNamespace(filePath); ns != "" {
|
|
compMeta["scope_ns"] = ns
|
|
}
|
|
result.Nodes = append(result.Nodes, &graph.Node{
|
|
ID: componentID, Kind: graph.KindType, Name: name,
|
|
FilePath: filePath, StartLine: 1, EndLine: lineCount, Language: "razor",
|
|
Meta: compMeta,
|
|
})
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileNode.ID, To: componentID, Kind: graph.EdgeDefines, FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
|
|
// @code{...} / @functions{...} blocks hold C# class members; a bare @{...}
|
|
// holds statements. Each is wrapped in a synthetic class (or class+method
|
|
// for bare blocks) so tree-sitter parses it, then delegated;
|
|
// delegateRazorCode strips the wrapper and rebases into host coordinates.
|
|
for _, span := range razorCodeSpans(src) {
|
|
lineOffset := strings.Count(string(src[:span.start]), "\n")
|
|
wrapPrefix, wrapSuffix := razorCodeWrapPrefix+"\n", "\n}"
|
|
if span.bare {
|
|
wrapPrefix, wrapSuffix = razorCodeWrapPrefix+"\nvoid __Body() {\n", "\n}\n}"
|
|
}
|
|
e.delegateRazorCode(src[span.start:span.end], lineOffset, wrapPrefix, wrapSuffix, filePath, fileNode.ID, result)
|
|
}
|
|
|
|
// Directive type references: @model / @inherits name the view-model or base
|
|
// type; @inject names the injected service type; @typeof(X) references X.
|
|
for _, m := range razorModelRe.FindAllSubmatch(src, -1) {
|
|
emitRazorTypeRef(result, fileNode.ID, filePath, string(m[1]))
|
|
}
|
|
for _, m := range razorInjectRe.FindAllSubmatch(src, -1) {
|
|
emitRazorTypeRef(result, fileNode.ID, filePath, string(m[1]))
|
|
}
|
|
for _, m := range razorTypeofRe.FindAllSubmatch(src, -1) {
|
|
emitRazorTypeRef(result, fileNode.ID, filePath, string(m[1]))
|
|
}
|
|
|
|
// `@using Some.Namespace` directives (and the cascading _Imports.razor)
|
|
// feed the resolver's namespace-scoped simple-type binding. Emitted as a
|
|
// per-file marker the resolver consumes and removes; no new node kind.
|
|
for _, m := range razorUsingRe.FindAllSubmatch(src, -1) {
|
|
ns := strings.TrimSpace(string(m[1]))
|
|
if ns == "" {
|
|
continue
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fileNode.ID, To: "unresolved::razor_using::" + ns,
|
|
Kind: graph.EdgeImports, FilePath: filePath, Line: 1,
|
|
})
|
|
}
|
|
|
|
// Markup component tags (`<Child />`) and Blazor generic type-arg
|
|
// references (`<Grid TItem="CatalogItem" />`), scanned with @code /
|
|
// @functions / @{} bodies blanked so the C# inside them is never parsed
|
|
// for tags.
|
|
if componentID != "" {
|
|
blanked := razorBlankCode(src)
|
|
mineTemplateComponentUsages(blanked, filePath, componentID, "razor", result)
|
|
for _, m := range razorGenericArgRE.FindAllSubmatch(blanked, -1) {
|
|
emitRazorTypeRef(result, fileNode.ID, filePath, string(m[1]))
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// razorComponentName derives the Blazor component name from a .razor file path
|
|
// (the PascalCase base name). Returns "" for .cshtml views, which are not
|
|
// components.
|
|
func razorComponentName(filePath string) string {
|
|
if !strings.HasSuffix(filePath, ".razor") {
|
|
return ""
|
|
}
|
|
base := filePath
|
|
if i := strings.LastIndexAny(base, "/\\"); i >= 0 {
|
|
base = base[i+1:]
|
|
}
|
|
base = strings.TrimSuffix(base, ".razor")
|
|
if base == "" || base[0] < 'A' || base[0] > 'Z' {
|
|
return ""
|
|
}
|
|
return base
|
|
}
|
|
|
|
// razorComponentNamespace derives a Blazor component's namespace from its
|
|
// repo-relative directory path, dotted (`App/Widgets/Counter.razor` →
|
|
// `App.Widgets`). Returns "" for a root-level component. Path-derived (not
|
|
// RootNamespace-prefixed) so it matches the `@using` namespaces the resolver
|
|
// compares against, which are likewise path-relative in practice.
|
|
func razorComponentNamespace(filePath string) string {
|
|
filePath = strings.ReplaceAll(filePath, "\\", "/")
|
|
dir := ""
|
|
if i := strings.LastIndex(filePath, "/"); i >= 0 {
|
|
dir = filePath[:i]
|
|
}
|
|
if dir == "" {
|
|
return ""
|
|
}
|
|
return strings.ReplaceAll(dir, "/", ".")
|
|
}
|
|
|
|
// razorCodeWrapPrefix is a single line so the wrap shifts content by exactly one
|
|
// line (compensated when rebasing).
|
|
const razorCodeWrapPrefix = "class __RazorCode {"
|
|
|
|
// delegateRazorCode wraps a @code block body in a synthetic class, runs the C#
|
|
// extractor over it, and merges the result rebased into host coordinates,
|
|
// dropping the synthetic file and wrapper-class nodes.
|
|
func (e *RazorExtractor) delegateRazorCode(content []byte, lineOffset int, wrapPrefix, wrapSuffix, filePath, fileID string, result *parser.ExtractionResult) {
|
|
if strings.TrimSpace(string(content)) == "" {
|
|
return
|
|
}
|
|
virtual := filePath + "#code"
|
|
wrapped := []byte(wrapPrefix + string(content) + wrapSuffix)
|
|
sub, err := e.cs.Extract(virtual, wrapped)
|
|
if err != nil || sub == nil {
|
|
return
|
|
}
|
|
// The wrapper prefix occupies one line per newline it carries, so a wrapped
|
|
// line W is content line W-(prefix lines); combined with the block's host
|
|
// offset that is lineOffset-(prefix lines).
|
|
shift := lineOffset - strings.Count(wrapPrefix, "\n")
|
|
wrapperID := ""
|
|
for _, n := range sub.Nodes {
|
|
if n == nil || n.ID == virtual {
|
|
continue
|
|
}
|
|
if n.Kind == graph.KindType && n.Name == "__RazorCode" {
|
|
wrapperID = n.ID
|
|
continue
|
|
}
|
|
// Drop the synthetic method that wraps a bare @{ } statement block.
|
|
if n.Kind == graph.KindMethod && n.Name == "__Body" {
|
|
continue
|
|
}
|
|
n.FilePath = filePath
|
|
n.Language = "razor"
|
|
if n.StartLine > 0 {
|
|
n.StartLine += shift
|
|
}
|
|
if n.EndLine > 0 {
|
|
n.EndLine += shift
|
|
}
|
|
if n.Meta == nil {
|
|
n.Meta = map[string]any{}
|
|
}
|
|
n.Meta["inline_script"] = true
|
|
result.Nodes = append(result.Nodes, n)
|
|
}
|
|
for _, ed := range sub.Edges {
|
|
if ed == nil || ed.From == wrapperID || ed.To == wrapperID {
|
|
continue // drop edges to/from the synthetic wrapper class
|
|
}
|
|
if ed.From == virtual {
|
|
ed.From = fileID
|
|
}
|
|
ed.FilePath = filePath
|
|
if ed.Line > 0 {
|
|
ed.Line += shift
|
|
}
|
|
result.Edges = append(result.Edges, ed)
|
|
}
|
|
}
|
|
|
|
type razorSpan struct {
|
|
start, end int
|
|
// bare is true for a @{ } statement block (delegated wrapped in a method
|
|
// body), false for a @code / @functions member block (wrapped in a class).
|
|
bare bool
|
|
}
|
|
|
|
// razorCodeSpans returns the inner content span of every @code{...} /
|
|
// @functions{...} member block and bare @{...} statement block. Brace matching
|
|
// is string-, char-, and comment-aware (matchRazorBrace), so a `}` inside a C#
|
|
// string literal or comment cannot end the block early — which would otherwise
|
|
// truncate the delegated C# and silently drop every member after it.
|
|
// razorGenericArgRE matches a Blazor generic type-parameter attribute on a
|
|
// component tag — `TItem="CatalogItem"`, `TValue="int"` — whose value is a type
|
|
// reference. The `T[A-Z]` shape is the Blazor type-param convention, so it does
|
|
// not match ordinary attributes like `Title=` / `Text=`.
|
|
var razorGenericArgRE = regexp.MustCompile(`\bT[A-Z]\w*\s*=\s*"([A-Za-z_][\w.]*)"`)
|
|
|
|
// razorBlankCode returns a copy of src with every @code / @functions / @{}
|
|
// body blanked (newlines preserved), so the markup tag/type scan never reads
|
|
// the C# inside those blocks.
|
|
func razorBlankCode(src []byte) []byte {
|
|
out := make([]byte, len(src))
|
|
copy(out, src)
|
|
for _, span := range razorCodeSpans(src) {
|
|
if span.start <= span.end && span.end <= len(out) {
|
|
copy(out[span.start:span.end], blankPreservingNewlines(out[span.start:span.end]))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func razorCodeSpans(src []byte) []razorSpan {
|
|
var spans []razorSpan
|
|
for _, loc := range razorBlockRe.FindAllSubmatchIndex(src, -1) {
|
|
open := loc[1] - 1 // position of the opening '{'
|
|
bare := loc[2] < 0 // group 1 absent → bare @{ block
|
|
end := matchRazorBrace(src, open)
|
|
if end < 0 {
|
|
continue
|
|
}
|
|
spans = append(spans, razorSpan{start: open + 1, end: end, bare: bare})
|
|
}
|
|
return spans
|
|
}
|
|
|
|
// matchRazorBrace returns the index of the '}' closing the '{' at open, scanning
|
|
// the C# body with awareness of string ("..."), verbatim (@"..."), and char
|
|
// ('...') literals and // and /* */ comments, so a brace inside any of them
|
|
// never shifts the depth. Returns -1 when the brace is unbalanced.
|
|
func matchRazorBrace(src []byte, open int) int {
|
|
depth := 0
|
|
for i := open; i < len(src); i++ {
|
|
switch src[i] {
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
case '"':
|
|
i = skipCSharpQuoted(src, i, '"', false)
|
|
case '\'':
|
|
i = skipCSharpQuoted(src, i, '\'', false)
|
|
case '@':
|
|
if i+1 < len(src) && src[i+1] == '"' {
|
|
i = skipCSharpQuoted(src, i+1, '"', true)
|
|
}
|
|
case '/':
|
|
if i+1 < len(src) && src[i+1] == '/' {
|
|
i += 2
|
|
for i < len(src) && src[i] != '\n' {
|
|
i++
|
|
}
|
|
} else if i+1 < len(src) && src[i+1] == '*' {
|
|
i += 2
|
|
for i < len(src) {
|
|
if src[i] == '*' && i+1 < len(src) && src[i+1] == '/' {
|
|
i++
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// skipCSharpQuoted returns the index of the closing quote of a literal that
|
|
// opens at i. For a regular literal a backslash escapes the next byte; for a
|
|
// verbatim (@"...") literal backslashes are literal and the quote is escaped by
|
|
// doubling ("") instead. On an unterminated literal it returns len-1 so the
|
|
// caller advances to EOF.
|
|
func skipCSharpQuoted(src []byte, i int, quote byte, verbatim bool) int {
|
|
for j := i + 1; j < len(src); j++ {
|
|
switch {
|
|
case !verbatim && src[j] == '\\':
|
|
j++ // skip the escaped byte
|
|
case src[j] == quote:
|
|
if verbatim && j+1 < len(src) && src[j+1] == quote {
|
|
j++ // doubled quote inside a verbatim string
|
|
continue
|
|
}
|
|
return j
|
|
}
|
|
}
|
|
return len(src) - 1
|
|
}
|
|
|
|
func emitRazorTypeRef(result *parser.ExtractionResult, fromID, filePath, typeName string) {
|
|
typeName = strings.TrimSpace(typeName)
|
|
// Split generic type arguments into their own references: a `List<Foo>`
|
|
// directive references both List and Foo, so the type graph reaches every
|
|
// type the generic names rather than only the outer container.
|
|
if i := strings.IndexByte(typeName, '<'); i >= 0 {
|
|
if j := strings.LastIndexByte(typeName, '>'); j > i {
|
|
for _, arg := range splitRazorTypeArgs(typeName[i+1 : j]) {
|
|
emitRazorTypeRef(result, fromID, filePath, arg)
|
|
}
|
|
}
|
|
typeName = typeName[:i]
|
|
}
|
|
if i := strings.LastIndexByte(typeName, '.'); i >= 0 {
|
|
typeName = typeName[i+1:]
|
|
}
|
|
if typeName = strings.TrimSpace(typeName); typeName == "" {
|
|
return
|
|
}
|
|
result.Edges = append(result.Edges, &graph.Edge{
|
|
From: fromID, To: "unresolved::" + typeName, Kind: graph.EdgeReferences, FilePath: filePath,
|
|
})
|
|
}
|
|
|
|
// splitRazorTypeArgs splits a comma-separated generic argument list at the top
|
|
// nesting level, so `string, List<int>` yields "string" and "List<int>".
|
|
func splitRazorTypeArgs(s string) []string {
|
|
var args []string
|
|
depth, start := 0, 0
|
|
for i := 0; i < len(s); i++ {
|
|
switch s[i] {
|
|
case '<':
|
|
depth++
|
|
case '>':
|
|
depth--
|
|
case ',':
|
|
if depth == 0 {
|
|
if a := strings.TrimSpace(s[start:i]); a != "" {
|
|
args = append(args, a)
|
|
}
|
|
start = i + 1
|
|
}
|
|
}
|
|
}
|
|
if a := strings.TrimSpace(s[start:]); a != "" {
|
|
args = append(args, a)
|
|
}
|
|
return args
|
|
}
|