R has no declaration syntax — everything is an expression — so the extractor works through the visitNode hook: functions in every assignment form (incl. nested, attributed to their enclosing scope), top-level variables/constants, library()/require() imports and source() file references (claimed, Lua-style), S4/RefClass/R6/ggproto classes with their methods and extends edges, setGeneric/setMethod. Grammar vendored from r-lib/tree-sitter-r v1.2.0 (ABI 14; npm package is a security placeholder, tree-sitter-wasms has no R). Benchmarked on AnomalyDetection (8/8 named defs), dplyr (1027 fns), ggplot2 (150 ggproto classes / 597 methods / 128 extends edges — adding ggproto mid-bench flipped the large-repo A/B from a regression to 2.4x faster than the no-codegraph arm). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2c7bbd5387
commit
06a410e9b4
@@ -36,6 +36,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
|
||||
pascal: 'tree-sitter-pascal.wasm',
|
||||
scala: 'tree-sitter-scala.wasm',
|
||||
lua: 'tree-sitter-lua.wasm',
|
||||
r: 'tree-sitter-r.wasm',
|
||||
luau: 'tree-sitter-luau.wasm',
|
||||
objc: 'tree-sitter-objc.wasm',
|
||||
};
|
||||
@@ -94,6 +95,7 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
'.svelte': 'svelte',
|
||||
'.vue': 'vue',
|
||||
'.astro': 'astro',
|
||||
'.r': 'r',
|
||||
'.pas': 'pascal',
|
||||
'.dpr': 'pascal',
|
||||
'.dpk': 'pascal',
|
||||
@@ -214,7 +216,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
|
||||
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
|
||||
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
|
||||
// primary constructors natively.
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp')
|
||||
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r')
|
||||
? path.join(__dirname, 'wasm', wasmFile)
|
||||
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
|
||||
const language = await WasmLanguage.load(wasmPath);
|
||||
@@ -401,6 +403,7 @@ export function getLanguageDisplayName(language: Language): string {
|
||||
python: 'Python',
|
||||
go: 'Go',
|
||||
rust: 'Rust',
|
||||
r: 'R',
|
||||
java: 'Java',
|
||||
c: 'C',
|
||||
cpp: 'C++',
|
||||
|
||||
@@ -24,6 +24,7 @@ import { dartExtractor } from './dart';
|
||||
import { pascalExtractor } from './pascal';
|
||||
import { scalaExtractor } from './scala';
|
||||
import { luaExtractor } from './lua';
|
||||
import { rExtractor } from './r';
|
||||
import { luauExtractor } from './luau';
|
||||
import { objcExtractor } from './objc';
|
||||
|
||||
@@ -47,6 +48,7 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
|
||||
pascal: pascalExtractor,
|
||||
scala: scalaExtractor,
|
||||
lua: luaExtractor,
|
||||
r: rExtractor,
|
||||
luau: luauExtractor,
|
||||
objc: objcExtractor,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
||||
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
|
||||
import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
|
||||
|
||||
/**
|
||||
* R language extractor (#828).
|
||||
*
|
||||
* R has no declaration syntax — everything is an expression, so every symbol
|
||||
* the graph needs arrives through the visitNode hook rather than node-type
|
||||
* lists:
|
||||
*
|
||||
* - functions: `name <- function(x) …` / `name = function(x) …` parse as
|
||||
* binary_operator(lhs: identifier, rhs: function_definition).
|
||||
* (`function(x) … -> name` right-assign of a function does
|
||||
* not survive the grammar's precedence — the `->` binds inside
|
||||
* the body — and the style is rare; deliberate gap.)
|
||||
* - variables: top-level assignments only (locals would bloat the graph);
|
||||
* ALL_CAPS / dotted-caps names extract as constants.
|
||||
* - imports: `library(x)` / `require(x)` / `requireNamespace("x")` are
|
||||
* ordinary calls; `source("file.R")` references another file.
|
||||
* All are claimed so no noise call-edge to `library` remains
|
||||
* (same pattern as Lua's `require`).
|
||||
* - classes: S4 `setClass("Name", …)`, R5 `setRefClass("Name", …)`, and
|
||||
* R6 `R6Class("Name", public = list(m = function() …))` are
|
||||
* calls too; the class node is named by the first string
|
||||
* argument and `name = function` entries in its list() args
|
||||
* extract as methods inside the class scope.
|
||||
* - S4 generics: `setGeneric("name", …)` / `setMethod("name", "Class", fn)`
|
||||
* extract as functions named by the first string argument.
|
||||
*
|
||||
* Calls themselves go through the generic call extraction (`call` nodes with a
|
||||
* `function` field). Namespaced `pkg::fn(…)` keeps its qualified text;
|
||||
* `obj$method(…)` extracts under its full text (resolution of `$`-dispatch is
|
||||
* a known gap — R's S3 dispatch is runtime by design).
|
||||
*/
|
||||
|
||||
const ASSIGN_LEFT = new Set(['<-', '<<-', '=']);
|
||||
const ASSIGN_RIGHT = new Set(['->', '->>']);
|
||||
const IMPORT_FNS = new Set(['library', 'require', 'requireNamespace', 'loadNamespace']);
|
||||
const CLASS_FNS = new Set(['setClass', 'setRefClass', 'R6Class', 'ggproto']);
|
||||
const GENERIC_FNS = new Set(['setGeneric', 'setMethod']);
|
||||
/** ALL_CAPS or DOTTED.CAPS top-level assignment → constant. */
|
||||
const CONSTANT_NAME = /^[A-Z][A-Z0-9._]*$/;
|
||||
|
||||
/** The call's callee name when it is a bare identifier or `pkg::fn` (→ `fn`). */
|
||||
function calleeName(call: SyntaxNode, source: string): string | null {
|
||||
const fn = getChildByField(call, 'function');
|
||||
if (!fn) return null;
|
||||
if (fn.type === 'identifier') return getNodeText(fn, source);
|
||||
if (fn.type === 'namespace_operator') {
|
||||
const rhs = getChildByField(fn, 'rhs');
|
||||
if (rhs) return getNodeText(rhs, source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First positional argument's value node of a call. */
|
||||
function firstArgValue(call: SyntaxNode): SyntaxNode | null {
|
||||
const args = getChildByField(call, 'arguments');
|
||||
if (!args) return null;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'argument') continue;
|
||||
return getChildByField(arg, 'value');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Text of a string node's content, or an identifier's text. */
|
||||
function literalOrIdentifier(node: SyntaxNode | null, source: string): string | null {
|
||||
if (!node) return null;
|
||||
if (node.type === 'identifier') return getNodeText(node, source);
|
||||
if (node.type === 'string') {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'string_content') return getNodeText(c, source);
|
||||
}
|
||||
return ''; // empty string literal
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Emit one `name = function(…)` argument entry as a method in the current scope. */
|
||||
function emitMethodArg(entry: SyntaxNode, ctx: ExtractorContext): void {
|
||||
const entryName = getChildByField(entry, 'name');
|
||||
const entryValue = getChildByField(entry, 'value');
|
||||
if (!entryName || entryValue?.type !== 'function_definition') return;
|
||||
const params = getChildByField(entryValue, 'parameters');
|
||||
const method = ctx.createNode('method', getNodeText(entryName, ctx.source), entry, {
|
||||
signature: params ? getNodeText(params, ctx.source) : undefined,
|
||||
});
|
||||
const body = getChildByField(entryValue, 'body');
|
||||
if (method && body) {
|
||||
ctx.pushScope(method.id);
|
||||
ctx.visitNode(body); // hook-aware walk — see the function-assignment note below
|
||||
ctx.popScope();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a class call's methods. Two shapes:
|
||||
* - inside list() arguments — R5 `methods = list(deposit = function(x) …)`,
|
||||
* R6 `public = list(…)` / `private = list(…)`;
|
||||
* - DIRECT named function arguments — ggproto's style:
|
||||
* `ggproto("GeomPoint", Geom, draw_panel = function(…) …)`.
|
||||
* Also records the parent class as an `extends` reference: ggproto's second
|
||||
* positional identifier argument, R6's `inherit = Parent`, S4's
|
||||
* `contains = "Parent"`.
|
||||
*/
|
||||
function extractClassMembers(classCall: SyntaxNode, classId: string, ctx: ExtractorContext): void {
|
||||
const args = getChildByField(classCall, 'arguments');
|
||||
if (!args) return;
|
||||
let positional = 0;
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (arg?.type !== 'argument') continue;
|
||||
const argName = getChildByField(arg, 'name');
|
||||
const value = getChildByField(arg, 'value');
|
||||
if (!argName) {
|
||||
positional++;
|
||||
// ggproto("Name", Parent, …) — the 2nd positional identifier is the parent.
|
||||
if (positional === 2 && value?.type === 'identifier') {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId: classId,
|
||||
referenceName: getNodeText(value, ctx.source),
|
||||
referenceKind: 'extends',
|
||||
line: value.startPosition.row + 1,
|
||||
column: value.startPosition.column,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const argNameText = getNodeText(argName, ctx.source);
|
||||
// R6 `inherit = Parent` / S4 `contains = "Parent"`.
|
||||
if ((argNameText === 'inherit' || argNameText === 'contains') && value) {
|
||||
const parent = literalOrIdentifier(value, ctx.source);
|
||||
if (parent) {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId: classId,
|
||||
referenceName: parent,
|
||||
referenceKind: 'extends',
|
||||
line: value.startPosition.row + 1,
|
||||
column: value.startPosition.column,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Direct named function argument (ggproto methods).
|
||||
if (value?.type === 'function_definition') {
|
||||
emitMethodArg(arg, ctx);
|
||||
continue;
|
||||
}
|
||||
// list(…) of named function arguments (R5/R6 methods).
|
||||
if (value?.type === 'call' && calleeName(value, ctx.source) === 'list') {
|
||||
const listArgs = getChildByField(value, 'arguments');
|
||||
if (!listArgs) continue;
|
||||
for (let j = 0; j < listArgs.namedChildCount; j++) {
|
||||
const entry = listArgs.namedChild(j);
|
||||
if (entry?.type === 'argument') emitMethodArg(entry, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const rExtractor: LanguageExtractor = {
|
||||
functionTypes: [], // named functions are assignments — handled in visitNode
|
||||
classTypes: [],
|
||||
methodTypes: [],
|
||||
interfaceTypes: [],
|
||||
structTypes: [],
|
||||
enumTypes: [],
|
||||
typeAliasTypes: [],
|
||||
importTypes: [], // library()/require()/source() are calls — handled in visitNode
|
||||
callTypes: ['call'],
|
||||
variableTypes: [], // top-level assignments — handled in visitNode
|
||||
nameField: 'name',
|
||||
bodyField: 'body',
|
||||
paramsField: 'parameters',
|
||||
|
||||
visitNode: (node, ctx) => {
|
||||
const source = ctx.source;
|
||||
|
||||
if (node.type === 'call') {
|
||||
const fname = calleeName(node, source);
|
||||
if (!fname) return false;
|
||||
|
||||
// library(dplyr) / require(stats) / requireNamespace("jsonlite") —
|
||||
// and source("helpers.R"), which references another file in the project.
|
||||
if (IMPORT_FNS.has(fname) || fname === 'source') {
|
||||
const mod = literalOrIdentifier(firstArgValue(node), source);
|
||||
if (!mod) return true; // dynamic argument — nothing to record, still not a call edge
|
||||
const imp = ctx.createNode('import', mod, node, {
|
||||
signature: getNodeText(node, source).trim().slice(0, 100),
|
||||
});
|
||||
if (imp && ctx.nodeStack.length > 0) {
|
||||
const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
|
||||
if (parentId) {
|
||||
ctx.addUnresolvedReference({
|
||||
fromNodeId: parentId,
|
||||
referenceName: mod,
|
||||
referenceKind: 'imports',
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// setClass("Patient", …) / setRefClass("Account", …) / R6Class("Stack", …)
|
||||
if (CLASS_FNS.has(fname)) {
|
||||
const name = literalOrIdentifier(firstArgValue(node), source);
|
||||
if (!name) return false;
|
||||
const cls = ctx.createNode('class', name, node, {});
|
||||
if (cls) {
|
||||
ctx.pushScope(cls.id);
|
||||
extractClassMembers(node, cls.id, ctx);
|
||||
ctx.popScope();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// setGeneric("describe", …) / setMethod("describe", "Patient", function(obj) …)
|
||||
if (GENERIC_FNS.has(fname)) {
|
||||
const name = literalOrIdentifier(firstArgValue(node), source);
|
||||
if (!name) return false;
|
||||
// The implementing function_definition, when present (setMethod always,
|
||||
// setGeneric usually via the def= argument).
|
||||
const args = getChildByField(node, 'arguments');
|
||||
let impl: SyntaxNode | null = null;
|
||||
if (args) {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const v = args.namedChild(i)?.type === 'argument'
|
||||
? getChildByField(args.namedChild(i)!, 'value') : null;
|
||||
if (v?.type === 'function_definition') { impl = v; break; }
|
||||
}
|
||||
}
|
||||
const params = impl ? getChildByField(impl, 'parameters') : null;
|
||||
const fn = ctx.createNode('function', name, node, {
|
||||
signature: params ? getNodeText(params, source) : undefined,
|
||||
});
|
||||
const body = impl ? getChildByField(impl, 'body') : null;
|
||||
if (fn && body) {
|
||||
ctx.pushScope(fn.id);
|
||||
ctx.visitNode(body); // hook-aware walk — see the function-assignment note below
|
||||
ctx.popScope();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // ordinary call — generic extraction records the edge
|
||||
}
|
||||
|
||||
if (node.type === 'binary_operator') {
|
||||
const op = node.childForFieldName('operator')?.text;
|
||||
if (!op) return false;
|
||||
const lhs = getChildByField(node, 'lhs');
|
||||
const rhs = getChildByField(node, 'rhs');
|
||||
|
||||
// name <- function(…) / name = function(…) (any scope — nested
|
||||
// functions extract inside their enclosing function's scope). The body
|
||||
// is walked through ctx.visitNode, NOT ctx.visitFunctionBody: the body
|
||||
// walker doesn't consult this hook, and in R every nested definition is
|
||||
// an assignment expression that only this hook can recognize. visitNode
|
||||
// dispatches calls and the hook alike, with the function on the scope
|
||||
// stack so attribution is right.
|
||||
if (ASSIGN_LEFT.has(op) && lhs?.type === 'identifier' && rhs?.type === 'function_definition') {
|
||||
const params = getChildByField(rhs, 'parameters');
|
||||
const fn = ctx.createNode('function', getNodeText(lhs, source), node, {
|
||||
signature: params ? getNodeText(params, source) : undefined,
|
||||
});
|
||||
const body = getChildByField(rhs, 'body');
|
||||
if (fn && body) {
|
||||
ctx.pushScope(fn.id);
|
||||
ctx.visitNode(body);
|
||||
ctx.popScope();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Top-level value assignments → variable/constant. Locals are skipped
|
||||
// deliberately (graph bloat); the initializer is still visited so its
|
||||
// calls and nested definitions extract.
|
||||
const topLevel = node.parent?.type === 'program';
|
||||
if (topLevel && ASSIGN_LEFT.has(op) && lhs?.type === 'identifier' && rhs) {
|
||||
// `Account <- setRefClass("Account", …)` is the CLASS definition idiom
|
||||
// (same for R6Class / setClass / setGeneric) — the call hook makes the
|
||||
// class/function node; a twin variable node would just be noise.
|
||||
const rhsCallee = rhs.type === 'call' ? calleeName(rhs, source) : null;
|
||||
if (!rhsCallee || (!CLASS_FNS.has(rhsCallee) && !GENERIC_FNS.has(rhsCallee))) {
|
||||
const name = getNodeText(lhs, source);
|
||||
ctx.createNode(CONSTANT_NAME.test(name) ? 'constant' : 'variable', name, node, {});
|
||||
}
|
||||
ctx.visitNode(rhs);
|
||||
return true;
|
||||
}
|
||||
// value -> name / value ->> name (right assign)
|
||||
if (topLevel && ASSIGN_RIGHT.has(op) && rhs?.type === 'identifier' && lhs) {
|
||||
const name = getNodeText(rhs, source);
|
||||
ctx.createNode(CONSTANT_NAME.test(name) ? 'constant' : 'variable', name, node, {});
|
||||
ctx.visitNode(lhs);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -90,6 +90,7 @@ export const LANGUAGES = [
|
||||
'lua',
|
||||
'luau',
|
||||
'objc',
|
||||
'r',
|
||||
'yaml',
|
||||
'twig',
|
||||
'xml',
|
||||
|
||||
Reference in New Issue
Block a user