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

491 lines
15 KiB
Go

package languages
import (
"regexp"
"strings"
rforest "github.com/alexaandru/go-sitter-forest/r"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
"github.com/zzet/gortex/internal/parser/forest"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
)
// R extractor uses forest's tree-sitter grammar (with bundled
// tags.scm) for definitions and call edges, then layers regex passes
// for the R-specific idioms tags.scm doesn't categorize: `library()`
// / `require()` / `source()` calls become EdgeImports rather than
// EdgeCalls. Top-level assignments are also rescued by regex —
// tags.scm captures functions but doesn't always tag plain value
// bindings as variables.
var (
rLibraryRe = regexp.MustCompile(`(?m)\blibrary\(\s*"?'?(\w+)"?'?\s*\)`)
rRequireRe = regexp.MustCompile(`(?m)\brequire\(\s*"?'?(\w+)"?'?\s*\)`)
rSourceRe = regexp.MustCompile(`(?m)\bsource\(\s*["']([^"']+)["']\s*\)`)
rVarAssignRe = regexp.MustCompile(`(?m)^(\w[\w.]*)\s*(?:<-|=)\s*(?:[^f]|f[^u]|fu[^n])`)
)
// RExtractor extracts R source via forest + regex idiom layer.
type RExtractor struct {
forest *forest.Extractor
lang *sitter.Language
}
func NewRExtractor() *RExtractor {
return &RExtractor{
forest: forest.New("r", []string{".R", ".r", ".Rmd"}, rforest.GetLanguage, rforest.GetQuery),
lang: sitter.NewLanguage(rforest.GetLanguage()),
}
}
// rS3Generics are the common base-R S3 generic functions; a function named
// `<generic>.<class>` is treated as an S3 method dispatched from one of these.
var rS3Generics = map[string]bool{
"print": true, "format": true, "summary": true, "plot": true, "predict": true,
"as.character": true, "as.data.frame": true, "as.list": true, "as.numeric": true,
"length": true, "names": true, "str": true, "c": true, "mean": true, "median": true,
"toString": true, "update": true, "coef": true, "residuals": true, "fitted": true,
}
func (e *RExtractor) Language() string { return "r" }
func (e *RExtractor) Extensions() []string { return []string{".R", ".r", ".Rmd"} }
func (e *RExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) {
res, err := e.forest.Extract(filePath, src)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
for _, n := range res.Nodes {
seen[n.ID] = true
}
// R class systems (S4 setClass/setGeneric/setMethod, R6Class /
// setRefClass, S3 generic.class methods) and generic→method dispatch.
// Runs before the var-assign rescue so a class binding is typed as a
// class, not a plain variable.
e.extractRClassSystems(src, filePath, res, seen)
// Namespace-qualified (`dplyr::filter`) and `$`-dispatch (`obj$method`)
// calls: preserve the package qualifier / receiver the tag pass strips.
e.extractRNamespaceCalls(src, filePath, res)
// Idiom imports: library(X) / require(X) / source("X.R").
for _, re := range []*regexp.Regexp{rLibraryRe, rRequireRe} {
for _, m := range re.FindAllSubmatchIndex(src, -1) {
mod := string(src[m[2]:m[3]])
line := lineAt(src, m[0])
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: "unresolved::import::" + mod,
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
})
}
}
for _, m := range rSourceRe.FindAllSubmatchIndex(src, -1) {
path := string(src[m[2]:m[3]])
line := lineAt(src, m[0])
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: "unresolved::import::" + path,
Kind: graph.EdgeImports, FilePath: filePath, Line: line,
})
}
// Top-level value bindings (`name <- value` / `name = value`)
// that aren't function assignments — tags.scm typically only
// captures the function-binding shape.
for _, m := range rVarAssignRe.FindAllSubmatchIndex(src, -1) {
name := string(src[m[2]:m[3]])
if isRKeyword(name) {
continue
}
line := lineAt(src, m[0])
id := filePath + "::" + name
if seen[id] {
continue
}
seen[id] = true
res.Nodes = append(res.Nodes, &graph.Node{
ID: id, Kind: graph.KindVariable, Name: name,
FilePath: filePath, StartLine: line, EndLine: line,
Language: "r",
})
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: id, Kind: graph.EdgeDefines,
FilePath: filePath, Line: line,
})
}
// Databricks source-format `.R` / `.r` notebooks: emit cell-level
// nodes alongside the regular R symbol nodes. No-op for ordinary
// R scripts.
MaybeEnrichDatabricks(filePath, filePath, src, res)
return res, nil
}
// extractRClassSystems parses the R tree and models the three class systems and
// their dispatch: S4 (setClass + `contains` inheritance, setGeneric, setMethod
// → a generic→method dispatch edge), R6/Reference classes (the bound name typed
// as a class), and S3 (`generic.class` methods dispatched from a base generic).
// The dispatch edges are the win: a call to a generic reaches its methods, which
// plain symbol extraction leaves disconnected.
func (e *RExtractor) extractRClassSystems(src []byte, filePath string, res *parser.ExtractionResult, seen map[string]bool) {
tree, err := parser.ParseFile(src, e.lang)
if err != nil {
return
}
defer tree.Close()
emitType := func(name string, line int, system string) string {
if name == "" {
return ""
}
id := filePath + "::" + name
if seen[id] {
return id
}
seen[id] = true
res.Nodes = append(res.Nodes, &graph.Node{
ID: id, Kind: graph.KindType, Name: name,
FilePath: filePath, StartLine: line, EndLine: line, Language: "r",
Meta: map[string]any{"class_system": system, "type_flavor": "class"},
})
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
})
return id
}
emitGeneric := func(name string, line int) string {
id := filePath + "::" + name
if !seen[id] {
seen[id] = true
res.Nodes = append(res.Nodes, &graph.Node{
ID: id, Kind: graph.KindFunction, Name: name,
FilePath: filePath, StartLine: line, EndLine: line, Language: "r",
Meta: map[string]any{"r_generic": true},
})
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
})
}
return id
}
dispatch := func(genericID, methodID string, line int, via string) {
res.Edges = append(res.Edges, &graph.Edge{
From: genericID, To: methodID, Kind: graph.EdgeCalls, FilePath: filePath, Line: line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{"via": via, "dispatch": true},
})
}
var walk func(n *sitter.Node)
walk = func(n *sitter.Node) {
switch n.Type() {
case "call":
callee := rCallee(n, src)
args := rCallArgs(n, src)
line := int(n.StartPoint().Row) + 1
switch callee {
case "setClass":
if cls := rFirstPositional(args); cls != "" {
id := emitType(cls, line, "S4")
if base := rNamedArg(args, "contains"); base != "" && id != "" {
res.Edges = append(res.Edges, &graph.Edge{
From: id, To: "unresolved::" + base, Kind: graph.EdgeExtends,
FilePath: filePath, Line: line, Meta: map[string]any{"class_system": "S4"},
})
}
}
case "setGeneric":
if g := rFirstPositional(args); g != "" {
emitGeneric(g, line)
}
case "setMethod":
pos := rPositionals(args)
if len(pos) >= 2 && pos[0] != "" && pos[1] != "" {
generic, cls := pos[0], pos[1]
mid := filePath + "::" + generic + "." + cls
if !seen[mid] {
seen[mid] = true
res.Nodes = append(res.Nodes, &graph.Node{
ID: mid, Kind: graph.KindMethod, Name: generic,
FilePath: filePath, StartLine: line, EndLine: int(n.EndPoint().Row) + 1, Language: "r",
Meta: map[string]any{"receiver": cls, "class_system": "S4", "dispatch_class": cls},
})
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: mid, Kind: graph.EdgeDefines, FilePath: filePath, Line: line,
})
res.Edges = append(res.Edges, &graph.Edge{
From: mid, To: "unresolved::" + cls, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: line,
})
}
dispatch(emitGeneric(generic, line), mid, line, "s4_dispatch")
}
case "R6Class", "setRefClass":
cls := rFirstPositional(args)
if cls == "" {
cls = rBindingName(n, src) // fall back to the LHS binding
}
emitType(cls, line, rClassSystemName(callee))
}
case "binary_operator":
// S3 method: `generic.class <- function(...)`.
if lhs := rBindingName(n, src); lhs != "" {
if rhs := rBindingValueType(n); rhs == "function_definition" {
if dot := strings.IndexByte(lhs, '.'); dot > 0 {
generic := lhs[:dot]
if rS3Generics[generic] {
mid := filePath + "::" + lhs
line := int(n.StartPoint().Row) + 1
dispatch(emitGeneric(generic, line), mid, line, "s3_dispatch")
}
}
}
}
}
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
walk(n.Child(i))
}
}
walk(tree.RootNode())
}
// extractRNamespaceCalls upgrades the calls the tag pass records with a
// stripped, bare callee. A `pkg::fn(...)` namespace call is rewritten from the
// bare `unresolved::fn` to `unresolved::pkg::fn` (carrying r_namespace) so the
// package qualifier is preserved — a `dplyr::filter` call stays distinguishable
// from a base-R `filter`. An `obj$method(...)` extract-dispatch call, which the
// tag pass drops entirely, gets its own edge carrying the receiver. This is the
// call provenance a tags.scm-only path discards.
func (e *RExtractor) extractRNamespaceCalls(src []byte, filePath string, res *parser.ExtractionResult) {
tree, err := parser.ParseFile(src, e.lang)
if err != nil {
return
}
defer tree.Close()
// Index the tag pass's bare call edges by (callee, line) so a namespace
// call can claim and rewrite the matching edge in place rather than
// emitting a duplicate.
type callKey struct {
name string
line int
}
bare := map[callKey]*graph.Edge{}
for _, ed := range res.Edges {
if ed.Kind == graph.EdgeCalls && strings.HasPrefix(ed.To, "unresolved::") {
name := strings.TrimPrefix(ed.To, "unresolved::")
bare[callKey{name, ed.Line}] = ed
}
}
var walk func(n *sitter.Node)
walk = func(n *sitter.Node) {
if n.Type() == "call" {
fn := n.ChildByFieldName("function")
if fn == nil && n.ChildCount() > 0 {
fn = n.Child(0)
}
line := int(n.StartPoint().Row) + 1
if fn != nil {
switch fn.Type() {
case "namespace_operator":
if pkg, name := rNamespaceParts(fn, src); pkg != "" && name != "" {
qualified := "unresolved::" + pkg + "::" + name
if ed := bare[callKey{name, line}]; ed != nil {
ed.To = qualified
if ed.Meta == nil {
ed.Meta = map[string]any{}
}
ed.Meta["r_namespace"] = pkg
} else {
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: qualified, Kind: graph.EdgeCalls,
FilePath: filePath, Line: line, Origin: graph.OriginASTResolved,
Meta: map[string]any{"r_namespace": pkg},
})
}
}
case "extract_operator":
if recv, name := rNamespaceParts(fn, src); name != "" {
res.Edges = append(res.Edges, &graph.Edge{
From: filePath, To: "unresolved::" + name, Kind: graph.EdgeCalls,
FilePath: filePath, Line: line, Origin: graph.OriginASTInferred,
Meta: map[string]any{"via": "dollar_dispatch", "r_receiver": recv},
})
}
}
}
}
for i, _nc := 0, int(n.ChildCount()); i < _nc; i++ {
walk(n.Child(i))
}
}
walk(tree.RootNode())
}
// rNamespaceParts returns the (lhs, rhs) identifier text of an R
// namespace_operator (`pkg::fn`, `pkg:::fn`) or extract_operator (`obj$field`)
// node — the package + function, or the receiver + member.
func rNamespaceParts(op *sitter.Node, src []byte) (string, string) {
var ids []string
for i, _nc := 0, int(op.ChildCount()); i < _nc; i++ {
if c := op.Child(i); c != nil && c.Type() == "identifier" {
ids = append(ids, c.Content(src))
}
}
if len(ids) >= 2 {
return ids[0], ids[1]
}
return "", ""
}
// rCallee returns the function name of an R call (its leading identifier).
func rCallee(call *sitter.Node, src []byte) string {
for i, _nc := 0, int(call.ChildCount()); i < _nc; i++ {
c := call.Child(i)
if c.Type() == "identifier" {
return c.Content(src)
}
if c.Type() == "arguments" {
break
}
}
return ""
}
// rArg is one parsed call argument: Name is "" for positional; Str is the
// string-literal value when the argument is a string; ValueType is the value
// node's type.
type rArg struct {
Name string
Str string
ValueType string
}
// rCallArgs parses an R call's argument list into name/value pairs.
func rCallArgs(call *sitter.Node, src []byte) []rArg {
var argsNode *sitter.Node
for i, _nc := 0, int(call.ChildCount()); i < _nc; i++ {
if call.Child(i).Type() == "arguments" {
argsNode = call.Child(i)
break
}
}
if argsNode == nil {
return nil
}
var out []rArg
for i, _nc := 0, int(argsNode.NamedChildCount()); i < _nc; i++ {
arg := argsNode.NamedChild(i)
if arg.Type() != "argument" {
continue
}
var kids []*sitter.Node
for j, _nc := 0, int(arg.NamedChildCount()); j < _nc; j++ {
kids = append(kids, arg.NamedChild(j))
}
var a rArg
var value *sitter.Node
if len(kids) == 2 && kids[0].Type() == "identifier" {
a.Name = kids[0].Content(src)
value = kids[1]
} else if len(kids) == 1 {
value = kids[0]
}
if value != nil {
a.ValueType = value.Type()
if value.Type() == "string" {
a.Str = rStringContent(value, src)
}
}
out = append(out, a)
}
return out
}
func rStringContent(strNode *sitter.Node, src []byte) string {
for i, _nc := 0, int(strNode.NamedChildCount()); i < _nc; i++ {
if c := strNode.NamedChild(i); c.Type() == "string_content" {
return c.Content(src)
}
}
return strings.Trim(strNode.Content(src), `"'`)
}
// rFirstPositional returns the first positional string argument.
func rFirstPositional(args []rArg) string {
for _, a := range args {
if a.Name == "" && a.Str != "" {
return a.Str
}
}
return ""
}
// rPositionals returns the string values of the positional arguments in order.
func rPositionals(args []rArg) []string {
var out []string
for _, a := range args {
if a.Name == "" {
out = append(out, a.Str)
}
}
return out
}
// rNamedArg returns the string value of the named argument, or "".
func rNamedArg(args []rArg, name string) string {
for _, a := range args {
if a.Name == name {
return a.Str
}
}
return ""
}
// rBindingName returns the LHS identifier of an `x <- ...` / `x = ...` /
// `... -> x` assignment expressed as a binary_operator, or "".
func rBindingName(bin *sitter.Node, src []byte) string {
if bin.NamedChildCount() < 2 {
return ""
}
lhs := bin.NamedChild(0)
if lhs != nil && lhs.Type() == "identifier" {
return lhs.Content(src)
}
return ""
}
// rBindingValueType returns the type of the RHS value of an assignment.
func rBindingValueType(bin *sitter.Node) string {
if bin.NamedChildCount() < 2 {
return ""
}
if rhs := bin.NamedChild(int(bin.NamedChildCount()) - 1); rhs != nil {
return rhs.Type()
}
return ""
}
func rClassSystemName(callee string) string {
if callee == "R6Class" {
return "R6"
}
return "R5"
}
func isRKeyword(s string) bool {
switch s {
case "if", "else", "for", "while", "repeat", "in", "next", "break",
"return", "function", "TRUE", "FALSE", "NULL", "NA", "Inf", "NaN",
"library", "require", "source":
return true
}
return false
}
var _ parser.Extractor = (*RExtractor)(nil)