feat(r): add R language support with defineLanguage() pattern

Adds full R language support (39 integration tests + 14 unit tests passing).
R classes (S4/R5/R6) are expressed as function calls rather than
tree-sitter class syntax, so the worker can't always find enclosing
classes during AST walks. A new `r-post-parse.ts` resolves deferred
owner hints against the TypeRegistry and explicitly registers methods/
properties into MethodRegistry/FieldRegistry after parsing completes.

Changes:
- gitnexus-shared: add R enum + .r extension + Prism mapping
- languages/r.ts: defineLanguage() with wildcard-leaf imports, R6/R5/S4
  field+method extractors, roxygen2 type extraction, NAMESPACE-aware
  exports
- tree-sitter-queries: R_QUERIES for functions, classes, methods, fields,
  imports, heritage, roxygen2 comments (+ 246 lines)
- language-config: RPackageConfig + loadRPackageConfig() scanning
  DESCRIPTION/NAMESPACE for exports + exportPattern + S3method
- import-resolvers/types: add rPackageConfig to ImportConfigs
- parse-worker + parsing-processor: R ownerNameHint capture for
  setMethod("fn", "ClassName") + findRFieldOwnerNode fallback
- r-post-parse: attachDeferredROwners resolves ownerNameHint via
  TypeRegistry, mutates SymbolDefinition + registers into
  Method/FieldRegistry + emits HAS_METHOD/HAS_PROPERTY edges.
  refineRExportStatus applies NAMESPACE filtering per package.
- Integration tests cover <<- super-assignment, R6 private/active,
  S4 multi-parent EXTENDS, bare R6Class(), require(), dot-names,
  exportClasses/exportMethods, default-public without NAMESPACE,
  R6 field type inference from defaults, + heritage fix for S4
  contains= queries.
This commit is contained in:
taiseii 2026-04-15 15:21:22 +02:00
parent 32c9ddaf32
commit 04897ce709
48 changed files with 2498 additions and 31 deletions

View file

@ -42,6 +42,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.R]: ['.r'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
@ -100,6 +101,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.R]: 'r',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness

View file

@ -20,6 +20,7 @@ export enum SupportedLanguages {
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
R = 'r',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
}

View file

@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"@eagleoutice/tree-sitter-r": "^1.1.2",
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",
@ -135,6 +136,34 @@
"node": ">=18"
}
},
"node_modules/@eagleoutice/tree-sitter-r": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@eagleoutice/tree-sitter-r/-/tree-sitter-r-1.1.2.tgz",
"integrity": "sha512-HR0RyoI5uxcfsdZvTMqSM8GJyGo6DQTkfdgqygQ6N+D0KQObRH4RxYgBZ6ePsGq/36RBAqv8y0NeQvAFL2N4dQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
}
},
"node_modules/@eagleoutice/tree-sitter-r/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
@ -5132,7 +5161,7 @@
},
"node_modules/tree-sitter-dart": {
"version": "1.0.0",
"resolved": "git+https://github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"resolved": "git+ssh://git@github.com/UserNobody14/tree-sitter-dart.git#80e23c07b64494f7e21090bb3450223ef0b192f4",
"integrity": "sha512-Bs/1wAOIJ2akPEXlE/XVpuES19Oo3NqoSJRJ/0N2r38qAd9nTXdqmaGHQ44/JXnA6QHcbgD2YzCCc4wUc98cyQ==",
"hasInstallScript": true,
"license": "ISC",

View file

@ -51,6 +51,7 @@
"prepack": "node scripts/build.js"
},
"dependencies": {
"@eagleoutice/tree-sitter-r": "^1.1.2",
"@huggingface/transformers": "^3.0.0",
"@ladybugdb/core": "^0.15.2",
"@modelcontextprotocol/sdk": "^1.0.0",

View file

@ -227,6 +227,7 @@ export const ENTRY_POINT_PATTERNS = {
/^mapEventToState$/, // Legacy BLoC pattern
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
[SupportedLanguages.R]: [],
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
} satisfies Record<SupportedLanguages, RegExp[]>;

View file

@ -246,3 +246,27 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true;
/** Dart: public if no leading underscore (convention, same as Python). */
export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_');
/** Check for `#' @export` in preceding roxygen2 comment block */
const hasRoxygenExport = (node: SyntaxNode): boolean => {
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment' && sibling.text.startsWith("#'")) {
if (/#'\s*@export\b/.test(sibling.text)) return true;
} else if (sibling.type !== 'comment' && sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return false;
};
/**
* R: roxygen2 @export detection with default-public fallback.
* NAMESPACE-aware refinement runs as a post-processing step in parse-impl.ts
* (ExportChecker has no file-path context; NAMESPACE detection needs it).
*/
export const rExportChecker: ExportChecker = (node, _name) => {
if (hasRoxygenExport(node)) return true;
return true; // Default: public (refined by post-processing for packages)
};

View file

@ -0,0 +1,256 @@
// gitnexus/src/core/ingestion/field-extractors/r.ts
/**
* R field extractor — hand-written because R expresses classes as function
* calls (R6Class(), setClass(), setRefClass()) rather than dedicated class syntax, so the
* generic config-driven factory cannot navigate the AST.
*
* Handles:
* - R6 fields: non-function entries in public/private list()
* - R5 fields: setRefClass("Foo", fields = list(name = "character"))
* - S4 representation/slots: setClass("Foo", representation(name = "character"))
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { BaseFieldExtractor } from '../field-extractor.js';
import type {
FieldExtractorContext,
ExtractedFields,
FieldInfo,
FieldVisibility,
} from '../field-types.js';
export class RFieldExtractor extends BaseFieldExtractor {
language = SupportedLanguages.R;
isTypeDeclaration(node: SyntaxNode): boolean {
return isR6Class(node) || isS4Class(node);
}
protected extractVisibility(_node: SyntaxNode): FieldVisibility {
return 'public';
}
extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null {
if (isR6Class(node)) return this.extractR6Fields(node, context);
if (isS4Class(node)) return this.extractS4Fields(node, context);
return null;
}
// --------------------------------------------------------------------------
// R6 field extraction
// --------------------------------------------------------------------------
private extractR6Fields(
node: SyntaxNode,
context: FieldExtractorContext,
): ExtractedFields | null {
const ownerFqn = node.childForFieldName('lhs')?.text;
if (!ownerFqn) return null;
const call = node.childForFieldName('rhs');
if (!call) return null;
const args = call.childForFieldName('arguments');
if (!args) return null;
const fields: FieldInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const section = arg.childForFieldName('name')?.text;
if (section !== 'public' && section !== 'private') continue;
const visibility: FieldVisibility = section === 'private' ? 'private' : 'public';
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode) continue;
// Skip function entries (those are methods, not fields)
if (valueNode?.type === 'function_definition') continue;
fields.push({
name: nameNode.text,
type: inferR6FieldType(valueNode),
visibility,
isStatic: false,
isReadonly: false,
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
});
}
}
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
}
// --------------------------------------------------------------------------
// S4 field extraction (representation / slots / fields)
// --------------------------------------------------------------------------
private extractS4Fields(
node: SyntaxNode,
context: FieldExtractorContext,
): ExtractedFields | null {
const args = node.childForFieldName('arguments');
if (!args) return null;
// First string argument is the class name
let ownerFqn: string | undefined;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const val = arg.childForFieldName('value');
if (val?.type === 'string') {
const content = val.namedChildren.find((c) => c.type === 'string_content');
ownerFqn = content?.text ?? val.text.replace(/^["']|["']$/g, '');
break;
}
}
if (!ownerFqn) return null;
const fields: FieldInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const paramName = arg.childForFieldName('name')?.text;
if (paramName !== 'representation' && paramName !== 'slots' && paramName !== 'fields')
continue;
const slotCall = arg.childForFieldName('value');
if (!slotCall || slotCall.type !== 'call') continue;
const slotArgs = slotCall.childForFieldName('arguments');
if (!slotArgs) continue;
for (let j = 0; j < slotArgs.namedChildCount; j++) {
const slot = slotArgs.namedChild(j);
if (!slot || slot.type !== 'argument') continue;
const slotName = slot.childForFieldName('name');
const slotType = slot.childForFieldName('value');
if (!slotName) continue;
const typeName = extractQuotedTypeName(slotType);
fields.push({
name: slotName.text,
type: typeName,
visibility: 'public',
isStatic: false,
isReadonly: false,
sourceFile: context.filePath,
line: slot.startPosition.row + 1,
});
}
}
return fields.length > 0 ? { ownerFqn, fields, nestedTypes: [] } : null;
}
}
// ---------------------------------------------------------------------------
// AST helpers
// ---------------------------------------------------------------------------
export function findRFieldOwnerNode(node: SyntaxNode): SyntaxNode | null {
let current: SyntaxNode | null = node;
while (current) {
if (isR6Class(current) || isS4Class(current)) return current;
current = current.parent;
}
return null;
}
export function getRTopLevelPropertyOwnerName(node: SyntaxNode): string | null {
const ownerNode = findRFieldOwnerNode(node);
if (!ownerNode) return null;
if (ownerNode.type === 'binary_operator') {
return ownerNode.childForFieldName('lhs')?.text ?? null;
}
const args = ownerNode.childForFieldName('arguments');
if (!args) return null;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const value = arg.childForFieldName('value');
if (value?.type !== 'string') continue;
return extractQuotedTypeName(value);
}
return null;
}
/** Check if a binary_operator node is an R6 class assignment. */
function isR6Class(node: SyntaxNode): boolean {
if (node.type !== 'binary_operator') return false;
const rhs = node.childForFieldName('rhs');
if (!rhs || rhs.type !== 'call') return false;
const fn = rhs.childForFieldName('function');
if (!fn) return false;
// R6Class(...)
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
// R6::R6Class(...)
if (fn.type === 'namespace_operator') {
const rhsFn = fn.childForFieldName('rhs');
return rhsFn?.text === 'R6Class';
}
return false;
}
/** Check if a call node is setClass() or setRefClass(). */
function isS4Class(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && (fn.text === 'setClass' || fn.text === 'setRefClass');
}
/** Infer a basic type from an R6 field default value. */
function inferR6FieldType(valueNode: SyntaxNode | null): string | null {
if (!valueNode) return null;
switch (valueNode.type) {
case 'null':
return null;
case 'true':
case 'false':
return 'logical';
case 'float':
return 'numeric';
case 'integer':
return 'integer';
case 'string':
return 'character';
default:
// NA variants
if (valueNode.type === 'identifier') {
const text = valueNode.text;
if (text === 'NA_character_') return 'character';
if (text === 'NA_integer_') return 'integer';
if (text === 'NA_real_') return 'numeric';
if (text === 'NA_complex_') return 'complex';
}
return null;
}
}
function extractQuotedTypeName(node: SyntaxNode | null): string | null {
if (!node || node.type !== 'string') return null;
const content = node.namedChildren.find((c) => c.type === 'string_content');
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
}

View file

@ -892,6 +892,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
},
],
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
[SupportedLanguages.R]: [],
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;

View file

@ -0,0 +1,82 @@
/**
* R import resolution.
* Handles library(), require(), source(), and pkg::func() resolution.
*/
import path from 'path';
import type { SuffixIndex } from './utils.js';
import { suffixResolve } from './utils.js';
import type { RPackageConfig } from '../language-config.js';
import type { ImportResult, ResolveCtx } from './types.js';
/**
* Low-level R import resolver (internal helper).
* Resolves library/require/source paths to matching .R files.
*/
export function resolveRImportInternal(
filePath: string,
rawImportPath: string,
normalizedFileList: string[],
allFileList: string[],
rConfig: RPackageConfig | null,
index?: SuffixIndex,
): string | string[] | null {
const cleaned = rawImportPath.replace(/^["']|["']$/g, '');
// source() with file path
if (cleaned.endsWith('.R') || cleaned.endsWith('.r')) {
if (path.isAbsolute(cleaned)) {
const normalized = cleaned.replace(/\\/g, '/');
const idx = normalizedFileList.indexOf(normalized);
return idx >= 0 ? allFileList[idx] : null;
}
const dir = path.dirname(filePath);
const resolved = path.join(dir, cleaned).replace(/\\/g, '/');
const idx = normalizedFileList.indexOf(resolved);
if (idx >= 0) return allFileList[idx];
const pathParts = cleaned.split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
// library("pkg") / require("pkg") — resolve to ALL files in the local package
if (rConfig) {
const pkgDir = rConfig.packages.get(cleaned);
if (pkgDir) {
const rDirPrefix = (pkgDir ? pkgDir + '/' : '') + 'R/';
const files: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
if (
normalizedFileList[i].startsWith(rDirPrefix) &&
(normalizedFileList[i].endsWith('.R') || normalizedFileList[i].endsWith('.r'))
) {
files.push(allFileList[i]);
}
}
if (files.length > 0) return files;
}
}
// Fallback: suffix-based resolution
const pathParts = cleaned.split('/').filter(Boolean);
return suffixResolve(pathParts, normalizedFileList, allFileList, index);
}
/** R: library/require/source resolution via R package config. */
export function resolveRImport(
rawImportPath: string,
filePath: string,
ctx: ResolveCtx,
): ImportResult {
const resolved = resolveRImportInternal(
filePath,
rawImportPath,
ctx.normalizedFileList,
ctx.allFileList,
ctx.configs.rPackageConfig ?? null,
ctx.index,
);
if (!resolved) return null;
const files = Array.isArray(resolved) ? resolved : [resolved];
return { kind: 'files', files };
}

View file

@ -9,6 +9,7 @@ import type {
GoModuleConfig,
CSharpProjectConfig,
ComposerConfig,
RPackageConfig,
} from '../language-config.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { SuffixIndex } from './utils.js';
@ -31,6 +32,7 @@ export interface ImportConfigs {
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
rPackageConfig: RPackageConfig | null;
}
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */

View file

@ -224,6 +224,118 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
return null;
}
/** R package config parsed from DESCRIPTION files in a multi-package repo */
export interface RPackageConfig {
/** Map of package name to directory path relative to repo root */
packages: Map<string, string>;
/** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */
namespaceInfoByPackageDir: Map<string, RNamespaceInfo>;
}
export interface RNamespaceInfo {
/** True when the package has a readable NAMESPACE file. */
hasNamespaceFile: boolean;
/** Explicit named exports from export()/exportClasses()/exportMethods()/S3method(). */
namedExports: Set<string>;
/** Regex patterns from exportPattern("..."). */
exportPatterns: string[];
}
export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConfig | null> {
const packages = new Map<string, string>();
const namespaceInfoByPackageDir = new Map<string, RNamespaceInfo>();
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
const maxDepth = 3;
const maxDirs = 200;
let dirsScanned = 0;
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
const { dir, depth } = scanQueue.shift()!;
dirsScanned++;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && depth < maxDepth) {
if (
entry.name === 'node_modules' ||
entry.name === '.git' ||
entry.name === '.Rproj.user'
)
continue;
scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
}
if (entry.isFile() && entry.name === 'DESCRIPTION') {
try {
const descPath = path.join(dir, entry.name);
const content = await fs.readFile(descPath, 'utf-8');
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
if (pkgMatch) {
const pkgName = pkgMatch[1];
const pkgDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
packages.set(pkgName, pkgDir);
if (isDev) {
console.log(`Found R package: ${pkgName} at ${pkgDir}`);
}
const nsPath = path.join(dir, 'NAMESPACE');
try {
const nsContent = await fs.readFile(nsPath, 'utf-8');
const namedExports = new Set<string>();
const exportPatterns: string[] = [];
const lines = nsContent.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const exportArgsMatch = /^(export|exportClasses|exportMethods)\(([^)]*)\)$/.exec(
line,
);
if (exportArgsMatch) {
for (const token of exportArgsMatch[2].split(',')) {
const name = token.trim().replace(/^["']|["']$/g, '');
if (name) namedExports.add(name);
}
continue;
}
const s3MethodMatch = /^S3method\(\s*([^,\s]+)\s*,\s*([^)\s]+)\s*\)$/.exec(line);
if (s3MethodMatch) {
namedExports.add(`${s3MethodMatch[1]}.${s3MethodMatch[2]}`);
continue;
}
const exportPatternMatch = /^exportPattern\(\s*["'](.+?)["']\s*\)$/.exec(line);
if (exportPatternMatch) {
exportPatterns.push(exportPatternMatch[1]);
}
}
namespaceInfoByPackageDir.set(pkgDir, {
hasNamespaceFile: true,
namedExports,
exportPatterns,
});
} catch {
// No NAMESPACE file or can't read it
}
}
} catch {
// Can't read DESCRIPTION
}
}
}
} catch {
// Can't read directory
}
}
if (packages.size === 0) return null;
return { packages, namespaceInfoByPackageDir };
}
// ============================================================================
// BUNDLED CONFIG LOADER
// ============================================================================
@ -236,5 +348,6 @@ export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs
composerConfig: await loadComposerConfig(repoRoot),
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: await loadCSharpProjectConfig(repoRoot),
rPackageConfig: await loadRPackageConfig(repoRoot),
};
}

View file

@ -24,6 +24,7 @@ import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { rProvider } from './r.js';
import { cobolProvider } from './cobol.js';
export const providers = {
@ -42,6 +43,7 @@ export const providers = {
[SupportedLanguages.Swift]: swiftProvider,
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.R]: rProvider,
[SupportedLanguages.Cobol]: cobolProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;

View file

@ -0,0 +1,180 @@
/**
* R language provider.
*
* R uses wildcard import semantics — library() and source() bring
* everything into scope (no named imports). No special call routing
* is needed (unlike Ruby); R imports are parsed as standard import nodes.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as rTypeConfig } from '../type-extractors/r.js';
import { rExportChecker } from '../export-detection.js';
import { resolveRImport } from '../import-resolvers/r.js';
import { R_QUERIES } from '../tree-sitter-queries.js';
import { RFieldExtractor } from '../field-extractors/r.js';
import { rMethodExtractor } from '../method-extractors/r.js';
const R_BUILT_INS: ReadonlySet<string> = new Set([
// Base R
'c',
'list',
'vector',
'matrix',
'array',
'data.frame',
'length',
'nrow',
'ncol',
'dim',
'names',
'colnames',
'rownames',
'print',
'cat',
'paste',
'paste0',
'sprintf',
'message',
'warning',
'stop',
'tryCatch',
'withCallingHandlers',
'try',
'on.exit',
'sys.call',
'match.arg',
// Type checks / coercion
'is.null',
'is.na',
'is.numeric',
'is.character',
'is.logical',
'is.list',
'as.character',
'as.numeric',
'as.integer',
'as.logical',
'as.data.frame',
// Apply family
'lapply',
'sapply',
'vapply',
'mapply',
'tapply',
'apply',
'Map',
'Reduce',
'Filter',
// Functional
'do.call',
'Recall',
'match.fun',
// I/O
'readRDS',
'saveRDS',
'readLines',
'writeLines',
'read.csv',
'write.csv',
// Environment
'new.env',
'environment',
'parent.env',
'globalenv',
'baseenv',
'exists',
'get',
'assign',
'rm',
'ls',
// String
'nchar',
'substr',
'substring',
'gsub',
'sub',
'grep',
'grepl',
'regmatches',
'trimws',
'toupper',
'tolower',
'startsWith',
'endsWith',
'strsplit',
// Math
'sum',
'mean',
'median',
'min',
'max',
'abs',
'sqrt',
'log',
'exp',
'round',
'ceiling',
'floor',
'seq',
'seq_len',
'seq_along',
'rep',
'which',
'range',
'cumsum',
'diff',
// Logical
'any',
'all',
'xor',
'identical',
// Sort / order
'sort',
'order',
'rev',
'unique',
'duplicated',
'table',
// S4 / R6 internals
'setClass',
'setGeneric',
'setMethod',
'setRefClass',
'new',
'validObject',
'is',
'extends',
'isVirtualClass',
'R6Class',
// Package / namespace
'library',
'require',
'requireNamespace',
'loadNamespace',
'getNamespace',
'source',
'sys.source',
'attachNamespace',
// Control flow (builtins that appear as calls)
'return',
'invisible',
'missing',
'nargs',
'Sys.time',
'Sys.sleep',
'proc.time',
]);
export const rProvider = defineLanguage({
id: SupportedLanguages.R,
extensions: ['.R', '.r'],
treeSitterQueries: R_QUERIES,
typeConfig: rTypeConfig,
exportChecker: rExportChecker,
importResolver: resolveRImport,
importSemantics: 'wildcard-leaf',
fieldExtractor: new RFieldExtractor(),
methodExtractor: rMethodExtractor,
builtInNames: R_BUILT_INS,
});

View file

@ -0,0 +1,260 @@
// gitnexus/src/core/ingestion/method-extractors/r.ts
/**
* R method extractor — hand-written because R expresses classes and methods
* as function calls rather than dedicated syntax.
*
* Handles:
* - R6 methods: function entries in public/private/active list()
* - S4 setMethod(): owner hints for method implementations dispatched on signature
* - R5/setRefClass methods: methods = list(name = function(...) {...})
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import type {
MethodExtractor,
MethodExtractorContext,
ExtractedMethods,
MethodInfo,
MethodVisibility,
ParameterInfo,
} from '../method-types.js';
export const rMethodExtractor: MethodExtractor = {
language: SupportedLanguages.R,
isTypeDeclaration(node: SyntaxNode): boolean {
return isR6Class(node) || isRefClass(node);
},
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
if (isR6Class(node)) return extractR6Methods(node, context);
if (isRefClass(node)) return extractRefClassMethods(node, context);
return null;
},
};
/** Resolve the owning class name for a top-level R setMethod("foo", "Bar", ...) definition. */
export function getRTopLevelMethodOwnerName(node: SyntaxNode): string | null {
if (!isS4SetMethod(node)) return null;
const args = node.childForFieldName('arguments');
if (!args) return null;
let stringArgIndex = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const argName = arg.childForFieldName('name')?.text;
const value = arg.childForFieldName('value');
if (argName === 'signature') {
const namedSignature = extractQuotedString(value);
if (namedSignature) return namedSignature;
}
if (value?.type !== 'string') continue;
stringArgIndex++;
if (stringArgIndex === 2) {
return extractQuotedString(value);
}
}
return null;
}
// ---------------------------------------------------------------------------
// R6 method extraction
// ---------------------------------------------------------------------------
function extractR6Methods(
node: SyntaxNode,
context: MethodExtractorContext,
): ExtractedMethods | null {
const ownerName = node.childForFieldName('lhs')?.text;
if (!ownerName) return null;
const call = node.childForFieldName('rhs');
if (!call) return null;
const args = call.childForFieldName('arguments');
if (!args) return null;
const methods: MethodInfo[] = [];
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const section = arg.childForFieldName('name')?.text;
if (section !== 'public' && section !== 'private' && section !== 'active') continue;
const visibility: MethodVisibility = section === 'private' ? 'private' : 'public';
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode || valueNode?.type !== 'function_definition') continue;
methods.push({
name: nameNode.text,
receiverType: null,
returnType: null,
parameters: extractRParameters(valueNode),
visibility,
isStatic: false,
isAbstract: false,
isFinal: false,
annotations: section === 'active' ? ['@active'] : [],
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
});
}
}
return methods.length > 0 ? { ownerName, methods } : null;
}
// ---------------------------------------------------------------------------
// S4 / R5 (setRefClass) method extraction
// ---------------------------------------------------------------------------
function extractRefClassMethods(
node: SyntaxNode,
context: MethodExtractorContext,
): ExtractedMethods | null {
const args = node.childForFieldName('arguments');
if (!args) return null;
// First string argument is the class name
let ownerName: string | undefined;
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const val = arg.childForFieldName('value');
if (val?.type === 'string') {
const content = val.namedChildren.find((c) => c.type === 'string_content');
ownerName = content?.text ?? val.text.replace(/^["']|["']$/g, '');
break;
}
}
if (!ownerName) return null;
const methods: MethodInfo[] = [];
// setRefClass(... methods = list(name = function(...) { ... }))
for (let i = 0; i < args.namedChildCount; i++) {
const arg = args.namedChild(i);
if (!arg || arg.type !== 'argument') continue;
const paramName = arg.childForFieldName('name')?.text;
if (paramName !== 'methods') continue;
const listCall = arg.childForFieldName('value');
if (!listCall || listCall.type !== 'call') continue;
const listArgs = listCall.childForFieldName('arguments');
if (!listArgs) continue;
for (let j = 0; j < listArgs.namedChildCount; j++) {
const entry = listArgs.namedChild(j);
if (!entry || entry.type !== 'argument') continue;
const nameNode = entry.childForFieldName('name');
const valueNode = entry.childForFieldName('value');
if (!nameNode || valueNode?.type !== 'function_definition') continue;
methods.push({
name: nameNode.text,
receiverType: null,
returnType: null,
parameters: extractRParameters(valueNode),
visibility: 'public',
isStatic: false,
isAbstract: false,
isFinal: false,
annotations: [],
sourceFile: context.filePath,
line: entry.startPosition.row + 1,
});
}
}
return methods.length > 0 ? { ownerName, methods } : null;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isR6Class(node: SyntaxNode): boolean {
if (node.type !== 'binary_operator') return false;
const rhs = node.childForFieldName('rhs');
if (!rhs || rhs.type !== 'call') return false;
const fn = rhs.childForFieldName('function');
if (!fn) return false;
if (fn.type === 'identifier' && fn.text === 'R6Class') return true;
if (fn.type === 'namespace_operator') {
return fn.childForFieldName('rhs')?.text === 'R6Class';
}
return false;
}
function isRefClass(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && fn.text === 'setRefClass';
}
function isS4SetMethod(node: SyntaxNode): boolean {
if (node.type !== 'call') return false;
const fn = node.childForFieldName('function');
return fn?.type === 'identifier' && fn.text === 'setMethod';
}
function extractQuotedString(node: SyntaxNode | null): string | null {
if (!node || node.type !== 'string') return null;
const content = node.namedChildren.find((c) => c.type === 'string_content');
return content?.text ?? node.text.replace(/^["']|["']$/g, '');
}
/** Extract parameter names from an R function_definition node. */
function extractRParameters(fnNode: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
const paramList = fnNode.childForFieldName('parameters');
if (!paramList) return params;
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param) continue;
if (param.type === 'identifier') {
params.push({
name: param.text,
type: null,
isOptional: false,
isVariadic: param.text === '...',
});
} else if (param.type === 'argument') {
// Named argument with default value -> optional parameter
const nameNode = param.childForFieldName('name');
if (nameNode) {
params.push({
name: nameNode.text,
type: null,
isOptional: true,
isVariadic: false,
});
}
}
}
return params;
}

View file

@ -31,6 +31,8 @@ import {
buildCollisionGroups,
} from './utils/method-props.js';
import type { LanguageProvider } from './language-provider.js';
import { findRFieldOwnerNode, getRTopLevelPropertyOwnerName } from './field-extractors/r.js';
import { getRTopLevelMethodOwnerName } from './method-extractors/r.js';
import { WorkerPool } from './workers/worker-pool.js';
import type {
ParseWorkerResult,
@ -443,6 +445,19 @@ const processParsingSequential = async (
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
// R-specific deferred owner hints: setMethod("foo", "ClassName", fn) or
// R6/R5 class body members. ownerNameHint is resolved to ownerId after
// all Class symbols have been registered (post-parse pass).
const ownerNameHint =
language === SupportedLanguages.R && definitionNode && !enclosingClassId
? nodeLabel === 'Method'
? (getRTopLevelMethodOwnerName(definitionNode) ??
getRTopLevelPropertyOwnerName(definitionNode))
: nodeLabel === 'Property'
? getRTopLevelPropertyOwnerName(definitionNode)
: null
: null;
// Qualify method/property IDs with enclosing class name to avoid collisions
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
const qualifiedName = enclosingClassInfo
@ -467,7 +482,11 @@ const processParsingSequential = async (
// Try class-based extraction (method inside a class/struct/trait body).
// Raw lookup (no resolveEnclosingOwner) so the method extractor sees
// the actual container node (e.g. singleton_class) for static detection.
const methodOwnerNode = seqFindEnclosingOwnerNode(definitionNode);
// R6/R5 classes use function-call syntax (R6::R6Class(), setRefClass()),
// so fall back to findRFieldOwnerNode when the regular AST walk fails.
const methodOwnerNode =
seqFindEnclosingOwnerNode(definitionNode) ??
(language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null);
if (methodOwnerNode) {
// Cache extract() results per class node to avoid re-traversing the
// same class body for every method it contains (O(N) -> O(1) per hit).
@ -582,6 +601,8 @@ const processParsingSequential = async (
}
: {}),
...methodProps,
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
...(ownerNameHint ? { ownerNameHint } : {}),
},
};
@ -597,10 +618,9 @@ const processParsingSequential = async (
if (nodeLabel === 'Property' && definitionNode) {
// FieldExtractor is the single source of truth when available
if (provider.fieldExtractor && typeEnv) {
const classNode = seqFindEnclosingOwnerNode(
definitionNode,
provider.resolveEnclosingOwner,
);
const classNode =
seqFindEnclosingOwnerNode(definitionNode, provider.resolveEnclosingOwner) ??
(language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null);
if (classNode) {
const fieldMap = seqGetFieldInfo(classNode, provider, {
typeEnv,

View file

@ -42,6 +42,8 @@ import {
} from '../heritage-processor.js';
import { createResolutionContext } from '../model/resolution-context.js';
import { createASTCache } from '../ast-cache.js';
import { loadRPackageConfig } from '../language-config.js';
import { attachDeferredROwners, refineRExportStatus } from '../r-post-parse.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
@ -555,6 +557,16 @@ export async function runChunkedParseAndResolve(
}
}
// R post-parse: resolve deferred owner hints + NAMESPACE-based export refinement.
// R classes are defined via function calls (R6::R6Class, setClass, setRefClass) so
// the worker can't always find the enclosing class during AST walks. Methods/properties
// store an `ownerNameHint` (string) instead; we resolve it here against TypeRegistry
// (now fully populated) and register into MethodRegistry / FieldRegistry explicitly.
attachDeferredROwners(graph, ctx.model, 'Method', 'HAS_METHOD');
attachDeferredROwners(graph, ctx.model, 'Property', 'HAS_PROPERTY');
const rPackageConfig = await loadRPackageConfig(repoPath);
refineRExportStatus(graph, rPackageConfig);
// Worker-path enrichment: if exportedTypeMap is empty (e.g. the worker pool
// built TypeEnv inside workers without access to SymbolTable), reconstruct
// the map from graph nodes + SymbolTable here in the main thread before

View file

@ -0,0 +1,140 @@
/**
* R post-parse hooks.
*
* R classes are expressed as function calls (`R6::R6Class("Name", ...)`,
* `setClass("Name", ...)`, `setMethod("fn", "ClassName", ...)`) rather than
* tree-sitter class syntax, so the worker cannot always find the enclosing
* class node via a parent walk. When that happens, the worker stores the
* owner name as an `ownerNameHint` string on the symbol/graph node.
*
* After parsing is complete and the TypeRegistry is fully populated, we
* resolve every `ownerNameHint` to a real `ownerId` and:
* 1. Patch `node.properties.ownerId`
* 2. Mutate the SymbolDefinition's `ownerId` (shared ref across registries)
* 3. Register the symbol into MutableMethodRegistry / MutableFieldRegistry
* 4. Emit the HAS_METHOD / HAS_PROPERTY graph edge
*
* NAMESPACE-aware export refinement also runs here — ExportChecker doesn't have
* a file-path context, so it defaults all R symbols to public. Once we know
* which package directory each file lives in, we flip `isExported` to false
* for symbols that aren't in the NAMESPACE exports.
*/
import type { KnowledgeGraph } from '../graph/types.js';
import type { MutableSemanticModel } from './model/semantic-model.js';
import type { RPackageConfig } from './language-config.js';
import type { GraphNode } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { generateId } from '../../lib/utils.js';
type EdgeLabel = 'HAS_METHOD' | 'HAS_PROPERTY';
/**
* Resolve R deferred owner hints into ownerIds. Runs post-parse, after all
* Class symbols have been registered into the TypeRegistry.
*/
export const attachDeferredROwners = (
graph: KnowledgeGraph,
model: MutableSemanticModel,
nodeLabel: 'Method' | 'Property',
edgeType: EdgeLabel,
): void => {
graph.forEachNode((node: GraphNode) => {
if (node.label !== nodeLabel) return;
// If we already have a resolved ownerId, the hint is redundant — drop it.
if (typeof node.properties.ownerId === 'string') {
delete node.properties.ownerNameHint;
return;
}
const ownerNameHint =
typeof node.properties.ownerNameHint === 'string' ? node.properties.ownerNameHint : null;
if (!ownerNameHint) return;
const classDefs = model.types.lookupClassByName(ownerNameHint);
if (classDefs.length !== 1) return; // ambiguous or not found — leave hint for debugging
const ownerId = classDefs[0].nodeId;
// 1. Patch the graph node.
node.properties.ownerId = ownerId;
delete node.properties.ownerNameHint;
// 2. Mutate the SymbolDefinition in place (shared ref across indexes).
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
const defs = model.symbols.lookupExactAll(filePath, name);
const def = defs.find((d) => d.nodeId === node.id);
if (def) {
def.ownerId = ownerId;
// 3. Explicitly register into the owner-scoped registry so resolveMemberCall
// can find the symbol via MethodRegistry / FieldRegistry.
if (nodeLabel === 'Method') {
model.methods.register(ownerId, name, def);
} else {
model.fields.register(ownerId, name, def);
}
}
// 4. Emit the HAS_METHOD / HAS_PROPERTY edge.
graph.addRelationship({
id: generateId(edgeType, `${ownerId}->${node.id}`),
sourceId: ownerId,
targetId: node.id,
type: edgeType,
confidence: 1.0,
reason: '',
});
});
};
/**
* Refine R export status based on NAMESPACE files. Nodes whose package has a
* NAMESPACE file but whose symbol name is not in the exports list get flipped
* to `isExported: false`. Packages without a NAMESPACE keep the default
* public behavior.
*/
export const refineRExportStatus = (
graph: KnowledgeGraph,
rPackageConfig: RPackageConfig | null,
): void => {
if (!rPackageConfig || rPackageConfig.namespaceInfoByPackageDir.size === 0) return;
// Sort package directories by length descending so the most specific
// (deepest) match wins for nested packages.
const pkgDirs = [...rPackageConfig.namespaceInfoByPackageDir.keys()].sort(
(a, b) => b.length - a.length,
);
graph.forEachNode((node: GraphNode) => {
if (node.properties.language !== SupportedLanguages.R) return;
const filePath = typeof node.properties.filePath === 'string' ? node.properties.filePath : '';
if (!filePath) return;
const normalizedPath = filePath.replace(/\\/g, '/');
const pkgDir = pkgDirs.find(
(dir) => normalizedPath === dir || normalizedPath.startsWith(dir + '/'),
);
if (!pkgDir) return;
const nsInfo = rPackageConfig.namespaceInfoByPackageDir.get(pkgDir);
if (!nsInfo || !nsInfo.hasNamespaceFile) return;
const name = typeof node.properties.name === 'string' ? node.properties.name : '';
if (!name) return;
if (nsInfo.namedExports.has(name)) return; // explicit export — keep public
const matched = nsInfo.exportPatterns.some((pattern) => {
try {
return new RegExp(pattern).test(name);
} catch {
return false;
}
});
if (matched) return;
// Not in NAMESPACE → not exported.
node.properties.isExported = false;
});
};

View file

@ -1250,6 +1250,246 @@ export const DART_QUERIES = `
(mixins
(type_identifier) @heritage.trait))) @heritage
`;
export const R_QUERIES = `
; ── Functions (name <- function(...) or name = function(...)) ────────────────
(binary_operator
lhs: (identifier) @name
rhs: (function_definition)) @definition.function
; ── S4 Classes (setClass("ClassName", ...)) ──────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(setClass|setRefClass)$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.class
; ── R6 Classes via namespace (ClassName <- R6::R6Class(...)) ─────────────────
(binary_operator
lhs: (identifier) @name
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn
(#match? @_nsfn "^R6Class$")))) @definition.class
; ── R6 Classes via bare call (ClassName <- R6Class(...)) ─────────────────────
(binary_operator
lhs: (identifier) @name
rhs: (call
function: (identifier) @_r6fn
(#match? @_r6fn "^R6Class$"))) @definition.class
; ── R6 Methods via namespace (inside public/private/active = list(...)) ─────
(binary_operator
lhs: (identifier) @_class
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn2
(#match? @_nsfn2 "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_section
(#match? @_section "^(public|private|active)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── R6 Methods via bare call (inside public/private/active = list(...)) ─────
(binary_operator
lhs: (identifier) @_class2
rhs: (call
function: (identifier) @_r6fn2
(#match? @_r6fn2 "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_section2
(#match? @_section2 "^(public|private|active)$")
value: (call
function: (identifier) @_listfn2
(#match? @_listfn2 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── R5 Methods (inside setRefClass(... methods = list(...))) ─────────────────
(binary_operator
lhs: (identifier) @_class3
rhs: (call
function: (identifier) @_refFn
(#match? @_refFn "^setRefClass$")
arguments: (arguments
(argument
name: (identifier) @_methods
(#match? @_methods "^methods$")
value: (call
function: (identifier) @_listfn3
(#match? @_listfn3 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: (function_definition)) @definition.method))))))
; ── S4 setGeneric() (generic function declaration) ───────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^setGeneric$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.function
; ── S4 setMethod() (method implementation for a class) ──────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^setMethod$")
arguments: (arguments
(argument
value: (string
content: (string_content) @name)))) @definition.method
; ── All function calls ───────────────────────────────────────────────────────
(call
function: (identifier) @call.name) @call
; ── Namespaced calls (pkg::func()) ───────────────────────────────────────────
(call
function: (namespace_operator
lhs: (identifier) @call.namespace
rhs: (identifier) @call.name)) @call
; ── Member calls via $ (obj$method()) ───────────────────────────────────────
(call
function: (extract_operator
rhs: (identifier) @call.name)) @call
; ── Imports (library/require/source) ─────────────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(library|require|source)$")
arguments: (arguments
(argument
value: [(identifier) (string)] @import.source))) @import
; ── Heritage: S4 contains= (single parent) ───────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(setClass|setRefClass)$")
arguments: (arguments
. (argument
value: (string
content: (string_content) @heritage.class))
(argument
name: (identifier) @_arg
(#match? @_arg "^(contains|CONTAINS)$")
value: (string
content: (string_content) @heritage.extends)))) @heritage
; ── Heritage: S4 contains= (multiple parents via c()) ────────────────────────
(call
function: (identifier) @_fn2
(#match? @_fn2 "^(setClass|setRefClass)$")
arguments: (arguments
. (argument
value: (string
content: (string_content) @heritage.class))
(argument
name: (identifier) @_arg2
(#match? @_arg2 "^(contains|CONTAINS)$")
value: (call
function: (identifier) @_cfn
(#match? @_cfn "^c$")
arguments: (arguments
(argument
value: (string
content: (string_content) @heritage.extends))))))) @heritage
; ── Heritage: R6 inherit= via namespace ──────────────────────────────────────
(binary_operator
lhs: (identifier) @heritage.class
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn
(#match? @_nsfn "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_arg
(#match? @_arg "^inherit$")
value: (identifier) @heritage.extends)))) @heritage
; ── Heritage: R6 inherit= via bare call ──────────────────────────────────────
(binary_operator
lhs: (identifier) @heritage.class
rhs: (call
function: (identifier) @_r6fn
(#match? @_r6fn "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_arg
(#match? @_arg "^inherit$")
value: (identifier) @heritage.extends)))) @heritage
; ── R6 Fields via namespace (non-function entries in public/private list()) ──
(binary_operator
lhs: (identifier) @_class
rhs: (call
function: (namespace_operator
rhs: (identifier) @_nsfn
(#match? @_nsfn "^R6Class$"))
arguments: (arguments
(argument
name: (identifier) @_section
(#match? @_section "^(public|private)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: [(string) (integer) (float) (true) (false) (null) (identifier) (call) (unary_operator) (binary_operator)] @_val) @definition.property))))))
; ── R6 Fields via bare call ──────────────────────────────────────────────────
(binary_operator
lhs: (identifier) @_class2
rhs: (call
function: (identifier) @_r6fn
(#match? @_r6fn "^R6Class$")
arguments: (arguments
(argument
name: (identifier) @_section2
(#match? @_section2 "^(public|private)$")
value: (call
function: (identifier) @_listfn2
(#match? @_listfn2 "^list$")
arguments: (arguments
(argument
name: (identifier) @name
value: [(string) (integer) (float) (true) (false) (null) (identifier) (call) (unary_operator) (binary_operator)] @_val2) @definition.property))))))
; ── S4 slots / representation ────────────────────────────────────────────────
(call
function: (identifier) @_fn
(#match? @_fn "^(setClass|setRefClass)$")
arguments: (arguments
(argument
name: (identifier) @_slotArg
(#match? @_slotArg "^(representation|slots|fields)$")
value: (call
function: (identifier) @_listfn
(#match? @_listfn "^(list|representation)$")
arguments: (arguments
(argument
name: (identifier) @name) @definition.property)))))
; ── Roxygen2 doc comments ────────────────────────────────────────────────────
(comment) @comment
`;
import { SupportedLanguages } from 'gitnexus-shared';
@ -1269,5 +1509,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Swift]: SWIFT_QUERIES,
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.R]: R_QUERIES,
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
};

View file

@ -0,0 +1,208 @@
import type {
LanguageTypeConfig,
ParameterExtractor,
TypeBindingExtractor,
InitializerExtractor,
ConstructorBindingScanner,
} from './types.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
/**
* R type extractor — roxygen2 annotation parsing.
*
* R has no static type system, but the roxygen2 documentation convention
* provides de facto type annotations via comments:
*
* #' @param name Character the user's name
* #' @param repo UserRepo the repository
* #' @return User
* create <- function(name, repo) {
* repo$save()
* }
*
* This extractor parses `#' @param name Type` patterns from comment nodes
* preceding function definitions and binds parameter names to their types.
*
* Resolution tiers:
* - Tier 0: roxygen2 @param annotations (extractDeclaration pre-populates env)
* - Tier 1: Constructor inference via `obj <- ClassName$new()` (R6) or `obj <- new("ClassName")` (S4)
*/
/** Regex to extract @param annotations: `#' @param name Type description` */
const ROXYGEN_PARAM_RE = /#'\s*@param\s+(\w+)\s+(\S+)/g;
/** Regex to extract @return annotations: `#' @return Type` */
const ROXYGEN_RETURN_RE = /#'\s*@return\s+(\S+)/;
/**
* Walk backwards through preceding sibling nodes collecting consecutive
* roxygen2 comment lines (`#'`). Returns the joined comment block text.
*/
const collectRoxygenBlock = (node: SyntaxNode): string => {
const commentTexts: string[] = [];
let sibling = node.previousSibling;
while (sibling) {
if (sibling.type === 'comment' && sibling.text.startsWith("#'")) {
commentTexts.unshift(sibling.text);
} else if (sibling.type === 'comment') {
// Regular comment (not roxygen2) — skip, don't break
} else if (sibling.isNamed) {
break;
}
sibling = sibling.previousSibling;
}
return commentTexts.join('\n');
};
/**
* Collect roxygen2 @param annotations from comment nodes preceding a function definition.
* Returns a map of paramName → typeName.
*/
const collectRoxygenParams = (node: SyntaxNode): Map<string, string> => {
const params = new Map<string, string>();
const commentBlock = collectRoxygenBlock(node);
let match: RegExpExecArray | null;
ROXYGEN_PARAM_RE.lastIndex = 0;
while ((match = ROXYGEN_PARAM_RE.exec(commentBlock)) !== null) {
const paramName = match[1];
const typeName = match[2];
// Only accept types that start with uppercase (class/type names)
if (/^[A-Z]/.test(typeName)) {
params.set(paramName, typeName);
}
}
return params;
};
/**
* R node types that may carry type bindings.
* - `binary_operator`: function definitions use `name <- function(...)` which
* tree-sitter-r parses as binary_operator nodes. Also used for constructor
* assignments like `obj <- ClassName$new()`.
*/
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set(['binary_operator']);
/**
* Extract roxygen2 annotations from function definitions.
* Pre-populates the scope env with parameter types before the
* standard parameter walk (which won't find types since R has none).
*/
const extractDeclaration: TypeBindingExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
if (node.type !== 'binary_operator') return;
const rhs = node.childForFieldName('rhs');
if (!rhs || rhs.type !== 'function_definition') return;
const roxygenParams = collectRoxygenParams(node);
for (const [paramName, typeName] of roxygenParams) {
env.set(paramName, typeName);
}
};
/**
* R parameter extraction.
* R parameters have no inline type annotations. Roxygen2 types are
* already populated by extractDeclaration, so this is a no-op — the
* bindings are already in the env.
*
* We still register this to maintain the LanguageTypeConfig contract.
*/
const extractParameter: ParameterExtractor = (
_node: SyntaxNode,
_env: Map<string, string>,
): void => {
// R parameters have no type annotations.
// Roxygen2 types are pre-populated by extractDeclaration.
};
/**
* R constructor inference:
* - R6: `obj <- ClassName$new(...)` → type is ClassName
* - S4: `obj <- new("ClassName", ...)` → type is ClassName
*
* Resolves against locally-known class names.
*/
const extractInitializer: InitializerExtractor = (node, env, classNames): void => {
const result = scanConstructorBinding(node);
if (!result) return;
if (env.has(result.varName)) return;
if (classNames.has(result.calleeName)) {
env.set(result.varName, result.calleeName);
}
};
/**
* R constructor binding scanner: captures both R6 `obj <- ClassName$new()`
* and S4 `obj <- new("ClassName", ...)` patterns.
*/
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'binary_operator') return undefined;
const lhs = node.childForFieldName('lhs');
const rhs = node.childForFieldName('rhs');
if (!lhs || !rhs) return undefined;
if (lhs.type !== 'identifier') return undefined;
if (rhs.type !== 'call') return undefined;
const fn = rhs.childForFieldName('function');
if (!fn) return undefined;
// R6 pattern: obj <- ClassName$new(...)
// tree-sitter-r parses `ClassName$new` as an `extract_operator` node
if (fn.type === 'extract_operator') {
const children = fn.namedChildren;
if (children.length >= 2) {
const className = children[0];
const method = children[1];
if (className?.type === 'identifier' && method?.text === 'new') {
return { varName: lhs.text, calleeName: className.text };
}
}
}
// S4 pattern: obj <- new("ClassName", ...)
if (fn.type === 'identifier' && fn.text === 'new') {
const args = rhs.childForFieldName('arguments');
if (args) {
for (const child of args.children) {
if (child.type === 'argument') {
const val = child.childForFieldName('value');
if (val?.type === 'string') {
const content = val.children.find((c: SyntaxNode) => c.type === 'string_content');
if (content) {
return { varName: lhs.text, calleeName: content.text };
}
}
break;
}
}
}
}
return undefined;
};
/**
* Extract return type from roxygen2 `#' @return Type` annotation preceding
* a function definition. Walks backwards through preceding sibling comment nodes.
*/
export const extractReturnType = (node: SyntaxNode): string | undefined => {
const commentBlock = collectRoxygenBlock(node);
const match = ROXYGEN_RETURN_RE.exec(commentBlock);
if (match) {
const typeName = match[1];
if (/^[A-Z]/.test(typeName)) return typeName;
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
extractDeclaration,
extractParameter,
extractInitializer,
scanConstructorBinding,
};

View file

@ -9,6 +9,7 @@ import CPP from 'tree-sitter-cpp';
import CSharp from 'tree-sitter-c-sharp';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import R from '@eagleoutice/tree-sitter-r';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import { createRequire } from 'node:module';
@ -85,6 +86,8 @@ import {
buildCollisionGroups,
} from '../utils/method-props.js';
import type { LanguageProvider } from '../language-provider.js';
import { findRFieldOwnerNode, getRTopLevelPropertyOwnerName } from '../field-extractors/r.js';
import { getRTopLevelMethodOwnerName } from '../method-extractors/r.js';
// ============================================================================
// Types for serializable results
@ -129,6 +132,11 @@ interface ParsedSymbol {
returnType?: string;
declaredType?: string;
ownerId?: string;
/** R-specific: deferred owner name hint when enclosingClassId cannot be found
* via AST walk (e.g., setMethod("foo", "ClassName", fn) where ClassName is
* a string argument, not a syntactic parent). Resolved to ownerId in parse-impl.ts
* after all Class symbols are registered in the TypeRegistry. */
ownerNameHint?: string;
visibility?: string;
isStatic?: boolean;
isReadonly?: boolean;
@ -311,6 +319,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.R]: R,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
@ -1981,8 +1990,13 @@ const processFileGroup = (
// returnType, isAbstract/isFinal/annotations, visibility, and more.
let enrichedByMethodExtractor = false;
if (provider.methodExtractor && definitionNode) {
// R6/R5 classes are defined via function calls (R6::R6Class(), setRefClass()),
// not tree-sitter class syntax, so findEnclosingClassNode can't find them.
// findRFieldOwnerNode walks up looking for those function-call patterns.
const classNode =
findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(definitionNode);
findEnclosingClassNode(definitionNode) ??
findClassNodeByQualifiedName(definitionNode) ??
(language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null);
if (classNode) {
const methodMap = getMethodInfo(classNode, provider, {
filePath: file.path,
@ -2103,7 +2117,9 @@ const processFileGroup = (
if (nodeLabel === 'Property' && definitionNode) {
// FieldExtractor is the single source of truth when available
if (provider.fieldExtractor && typeEnv) {
const classNode = findEnclosingClassNode(definitionNode);
const classNode =
findEnclosingClassNode(definitionNode) ??
(language === SupportedLanguages.R ? findRFieldOwnerNode(definitionNode) : null);
if (classNode) {
const fieldMap = getFieldInfo(classNode, provider, {
typeEnv,
@ -2122,6 +2138,19 @@ const processFileGroup = (
}
}
// R-specific deferred owner hints for setMethod/property nodes whose parent
// AST is a function call (R6Class/setClass/setRefClass/setMethod).
// Resolved to an ownerId in parse-impl.ts after all Class symbols are registered.
const ownerNameHint =
language === SupportedLanguages.R && definitionNode && !enclosingClassId
? nodeLabel === 'Method'
? (getRTopLevelMethodOwnerName(definitionNode) ??
getRTopLevelPropertyOwnerName(definitionNode))
: nodeLabel === 'Property'
? getRTopLevelPropertyOwnerName(definitionNode)
: null
: null;
result.nodes.push({
id: nodeId,
label: nodeLabel,
@ -2145,6 +2174,8 @@ const processFileGroup = (
...(description !== undefined ? { description } : {}),
...methodProps,
...(declaredType !== undefined ? { declaredType } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
...(ownerNameHint ? { ownerNameHint } : {}),
},
});
@ -2162,6 +2193,7 @@ const processFileGroup = (
returnType: methodProps.returnType as string | undefined,
...(declaredType !== undefined ? { declaredType } : {}),
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
...(ownerNameHint ? { ownerNameHint } : {}),
visibility: methodProps.visibility as string | undefined,
isStatic: methodProps.isStatic as boolean | undefined,
isReadonly: methodProps.isReadonly as boolean | undefined,

View file

@ -10,6 +10,7 @@ import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import PHP from 'tree-sitter-php';
import Ruby from 'tree-sitter-ruby';
import R from '@eagleoutice/tree-sitter-r';
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
@ -46,6 +47,7 @@ const languageMap: Record<string, any> = {
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
[SupportedLanguages.PHP]: PHP.php_only,
[SupportedLanguages.Ruby]: Ruby,
[SupportedLanguages.R]: R,
[SupportedLanguages.Vue]: TypeScript.typescript,
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),

View file

@ -0,0 +1,4 @@
Package: pkgA
Title: Test Package A
Version: 0.1.0
Imports: pkgB

View file

@ -0,0 +1,6 @@
export(AddOutlierStatuses)
export(HelperFunc)
export(my.helper.func)
export(PackageScoped)
export(SuperAssignFunc)
importFrom(pkgB, CleanData)

View file

@ -0,0 +1,11 @@
#' Add outlier statuses to data
#'
#' @param data DataFrame the input data
#' @param outliers DataFrame outlier records
#' @return DataFrame with outlier statuses added
#' @export
AddOutlierStatuses <- function(data, outliers) {
clean <- pkgB::CleanData(data)
result <- merge(clean, outliers, by = "id", all.x = TRUE)
result
}

View file

@ -0,0 +1,4 @@
#' A dot-named function (legal R identifier — dots are valid in names)
my.helper.func <- function(x) {
x
}

View file

@ -0,0 +1,6 @@
#' Exported only from pkgA
#'
#' @export
PackageScoped <- function() {
TRUE
}

View file

@ -0,0 +1,29 @@
# S4 class definition
setClass("DataModel", contains = "VIRTUAL", slots = list(
name = "character",
value = "numeric"
))
# R5 reference class
DataProcessor <- setRefClass("DataProcessor",
fields = list(data = "data.frame"),
methods = list(
process = function() {
self$data
}
)
)
# R6 class
library(R6)
ResultSet <- R6::R6Class("ResultSet",
public = list(
items = NULL,
initialize = function(items = list()) {
self$items <- items
},
count = function() {
length(self$items)
}
)
)

View file

@ -0,0 +1,26 @@
library(R6)
AdvancedR6 <- R6::R6Class("AdvancedR6",
public = list(
name = "default",
active_count = 0L,
initialize = function(name) {
self$name <- name
},
get_name = function() {
self$name
}
),
private = list(
secret_key = NULL,
internal_flag = TRUE,
compute = function() {
private$secret_key
}
),
active = list(
display_name = function(value) {
if (missing(value)) return(self$name)
self$name <- value
}
)
)

View file

@ -0,0 +1,10 @@
# R6 class via bare R6Class() call (no R6:: namespace prefix)
BareR6 <- R6Class("BareR6",
public = list(
value = 42,
get_value = function() {
self$value
}
),
inherit = Parent
)

View file

@ -0,0 +1,13 @@
#' Generic description function
describe <- function(x) {
UseMethod("describe")
}
describe.FancyWidget <- function(x) {
x
}
#' @param widget FancyWidget widget under analysis
render_widget <- function(widget) {
describe(widget)
}

View file

@ -0,0 +1,41 @@
# S4 generic and method
setGeneric("validate", function(object, ...) standardGeneric("validate"))
setMethod("validate", "DataModel", function(object, ...) {
length(object@name) > 0
})
# R6 base class
Parent <- R6::R6Class("Parent",
public = list(
greet = function() "hi"
)
)
# R6 with inheritance
Child <- R6::R6Class("Child",
inherit = Parent,
public = list(
greet = function() "hello"
)
)
# Functions used by pipe-chain resolution tests
clean_native <- function(data) {
data
}
transform_native <- function(data) {
data
}
clean_magrittr <- function(data) {
data
}
transform_magrittr <- function(data) {
data
}
# Pipe usage
result <- data |> clean_native() |> transform_native()
result2 <- data %>% clean_magrittr() %>% transform_magrittr()

View file

@ -0,0 +1,3 @@
setClass("BaseA", slots = list(a_field = "numeric"))
setClass("BaseB", slots = list(b_field = "character"))
setClass("MultiChild", contains = c("BaseA", "BaseB"))

View file

@ -0,0 +1,7 @@
#' Function defined with <<- super-assignment
#'
#' @param x Numeric input
#' @return Numeric result
SuperAssignFunc <<- function(x) {
x + 1
}

View file

@ -0,0 +1,7 @@
#' A helper function using = assignment
#'
#' @param x Numeric input value
#' @return Numeric the transformed value
HelperFunc = function(x) {
x * 2
}

View file

@ -0,0 +1,3 @@
Package: pkgB
Title: Test Package B
Version: 0.1.0

View file

@ -0,0 +1,5 @@
export(CleanData)
exportPattern("^Pattern")
S3method(print, FancyWidget)
exportClasses(ExportedS4Class)
exportMethods(exportedMethod)

View file

@ -0,0 +1,8 @@
#' Clean input data
#'
#' @param raw_data DataFrame the raw data
#' @return DataFrame cleaned data
#' @export
CleanData <- function(raw_data) {
raw_data[complete.cases(raw_data), ]
}

View file

@ -0,0 +1,7 @@
PatternScoped <- function() {
TRUE
}
print.FancyWidget <- function(x) {
x
}

View file

@ -0,0 +1,3 @@
PackageScoped <- function() {
FALSE
}

View file

@ -0,0 +1,2 @@
setClass("ExportedS4Class", slots = list(data = "list"))
setGeneric("exportedMethod", function(object) standardGeneric("exportedMethod"))

View file

@ -0,0 +1,3 @@
Package: pkgC
Title: Test Package C (no NAMESPACE)
Version: 0.1.0

View file

@ -0,0 +1,10 @@
#' A function with roxygen @export tag
#'
#' @export
RoxygenExported <- function() {
TRUE
}
NoExportTag <- function() {
FALSE
}

View file

@ -0,0 +1,14 @@
# Standalone script using source() and library()
source("pkgA/R/utils.R")
library("pkgB")
result <- HelperFunc(42)
clean <- CleanData(result)
print(clean)
# R6 method call resolution
rs <- ResultSet$new(list(1, 2, 3))
n <- rs$count()
# require() should resolve identically to library()
require("pkgA")

View file

@ -0,0 +1,384 @@
/**
* R: function definitions (<- and = assignment), S4/R5/R6 classes,
* library/require imports, source() includes, pkg::func namespaced calls,
* roxygen2 doc parsing, cross-package resolution, heritage
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
describe('R function definitions and calls', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'r-packages'), () => {});
}, 60000);
// --- Function detection ---
it('detects functions defined with <- assignment', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('AddOutlierStatuses');
expect(functions).toContain('CleanData');
});
it('detects functions defined with = assignment', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('HelperFunc');
});
it('detects functions defined with <<- super-assignment', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('SuperAssignFunc');
});
it('detects functions with dot-separated names', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('my.helper.func');
});
// --- Class detection ---
it('detects S4 class defined with setClass', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('DataModel');
});
it('detects R5 class defined with setRefClass', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('DataProcessor');
});
it('detects R6 class defined with R6::R6Class', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('ResultSet');
});
it('detects R6 class defined with bare R6Class() call', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('BareR6');
});
// --- Import resolution ---
it('resolves source() to IMPORTS edge', () => {
const imports = getRelationships(result, 'IMPORTS');
const sourceEdge = imports.find(
(e) => e.sourceFilePath.includes('run_analysis.R') && e.targetFilePath.includes('utils.R'),
);
expect(sourceEdge).toBeDefined();
});
it('resolves library() to IMPORTS edges for all package files', () => {
const imports = getRelationships(result, 'IMPORTS');
const libEdges = imports.filter(
(e) => e.sourceFilePath.includes('run_analysis.R') && e.targetFilePath.includes('pkgB/'),
);
expect(libEdges.length).toBeGreaterThanOrEqual(1);
expect(libEdges.some((e) => e.targetFilePath.includes('clean_data.R'))).toBe(true);
});
it('resolves require() to IMPORTS edges for package files', () => {
const imports = getRelationships(result, 'IMPORTS');
const requireEdges = imports.filter(
(e) => e.sourceFilePath.includes('run_analysis.R') && e.targetFilePath.includes('pkgA/R/'),
);
// require("pkgA") should resolve to at least one file in pkgA/R/
expect(requireEdges.length).toBeGreaterThanOrEqual(1);
});
// --- Cross-package resolution ---
it('resolves cross-package pkgB::CleanData call', () => {
const calls = getRelationships(result, 'CALLS');
const crossPkg = calls.find(
(e) => e.target === 'CleanData' && e.targetFilePath.includes('clean_data.R'),
);
expect(crossPkg).toBeDefined();
});
// --- Call resolution ---
it('resolves source()-imported function calls', () => {
const calls = getRelationships(result, 'CALLS');
const helperCall = calls.find(
(e) => e.sourceFilePath.includes('run_analysis.R') && e.target === 'HelperFunc',
);
expect(helperCall).toBeDefined();
});
// --- R6 method detection ---
it('detects R6 methods inside public = list(...)', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('initialize');
expect(methods).toContain('count');
});
it('detects R5 methods inside setRefClass(... methods = list(...))', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('process');
});
it('emits HAS_METHOD edges from R6 class to its methods', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const r6Methods = hasMethod.filter((e) => e.source === 'ResultSet');
expect(r6Methods.length).toBeGreaterThanOrEqual(2);
expect(r6Methods.some((e) => e.target === 'initialize')).toBe(true);
expect(r6Methods.some((e) => e.target === 'count')).toBe(true);
});
it('emits HAS_METHOD edge from R5 class to its methods', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const r5Methods = hasMethod.filter((e) => e.source === 'DataProcessor');
expect(r5Methods.some((e) => e.target === 'process')).toBe(true);
});
// --- R6 private methods and fields ---
it('detects R6 private methods inside private = list(...)', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('compute');
});
it('emits HAS_METHOD edge from R6 class to private method', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const privateMethod = hasMethod.find(
(e) => e.source === 'AdvancedR6' && e.target === 'compute',
);
expect(privateMethod).toBeDefined();
});
it('detects R6 private fields with correct visibility', () => {
const properties = getNodesByLabelFull(result, 'Property');
const secretKey = properties.find(
(p) => p.name === 'secret_key' && p.properties.filePath.includes('r6_advanced.R'),
);
expect(secretKey).toBeDefined();
expect(secretKey!.properties.visibility).toBe('private');
});
// --- R6 active bindings ---
it('detects R6 active bindings as methods', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('display_name');
});
it('emits HAS_METHOD edge from R6 class to active binding', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const activeBinding = hasMethod.find(
(e) => e.source === 'AdvancedR6' && e.target === 'display_name',
);
expect(activeBinding).toBeDefined();
});
it('detects methods inside bare R6Class() call', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const bareMethod = hasMethod.find((e) => e.source === 'BareR6' && e.target === 'get_value');
expect(bareMethod).toBeDefined();
});
// --- R6 field type inference ---
it('infers R6 field types from default values', () => {
const properties = getNodesByLabelFull(result, 'Property');
const nameField = properties.find(
(p) => p.name === 'name' && p.properties.filePath.includes('r6_advanced.R'),
);
expect(nameField).toBeDefined();
expect(nameField!.properties.declaredType).toBe('character');
const countField = properties.find(
(p) => p.name === 'active_count' && p.properties.filePath.includes('r6_advanced.R'),
);
expect(countField).toBeDefined();
expect(countField!.properties.declaredType).toBe('integer');
const flagField = properties.find(
(p) => p.name === 'internal_flag' && p.properties.filePath.includes('r6_advanced.R'),
);
expect(flagField).toBeDefined();
expect(flagField!.properties.declaredType).toBe('logical');
});
it('resolves rs$count() call to ResultSet.count method', () => {
const calls = getRelationships(result, 'CALLS');
const countCall = calls.find(
(e) =>
e.sourceFilePath.includes('run_analysis.R') &&
e.target === 'count' &&
e.targetFilePath.includes('models.R'),
);
expect(countCall).toBeDefined();
});
// --- S4 setGeneric / setMethod detection ---
it('detects S4 setGeneric as a function definition', () => {
const functions = getNodesByLabel(result, 'Function');
expect(functions).toContain('validate');
});
it('detects S4 setMethod as a method definition', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toContain('validate');
});
it('emits HAS_METHOD edge from S4 class to setMethod implementation', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const s4Methods = hasMethod.filter((e) => e.source === 'DataModel');
expect(s4Methods.some((e) => e.target === 'validate')).toBe(true);
});
// --- R6 inherit= heritage ---
it('emits EXTENDS edge from R6 Child class via inherit= (namespace call)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const childEdge = extends_.find((e) => e.source === 'Child' && e.target === 'Parent');
expect(childEdge).toBeDefined();
});
it('detects S4 classes from multi-parent setClass with contains=c()', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toContain('MultiChild');
expect(classes).toContain('BaseA');
expect(classes).toContain('BaseB');
});
it('emits EXTENDS edges from S4 multi-parent setClass with contains=c()', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const toBaseA = extends_.find((e) => e.source === 'MultiChild' && e.target === 'BaseA');
const toBaseB = extends_.find((e) => e.source === 'MultiChild' && e.target === 'BaseB');
expect(toBaseA).toBeDefined();
expect(toBaseB).toBeDefined();
});
it('emits EXTENDS edge from S4 class with single-parent contains=', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const edge = extends_.find((e) => e.source === 'DataModel' && e.target === 'VIRTUAL');
expect(edge).toBeDefined();
});
it('emits EXTENDS edge from R6 class defined with bare R6Class() via inherit=', () => {
const extends_ = getRelationships(result, 'EXTENDS');
const bareEdge = extends_.find((e) => e.source === 'BareR6' && e.target === 'Parent');
expect(bareEdge).toBeDefined();
});
it('applies NAMESPACE exports per package instead of as a repo-wide name set', () => {
const functions = getNodesByLabelFull(result, 'Function').filter(
(n) => n.name === 'PackageScoped',
);
const pkgAFunction = functions.find((n) =>
n.properties.filePath.includes('pkgA/R/export_scope.R'),
);
const pkgBFunction = functions.find((n) =>
n.properties.filePath.includes('pkgB/R/export_scope.R'),
);
expect(pkgAFunction).toBeDefined();
expect(pkgBFunction).toBeDefined();
expect(pkgAFunction?.properties.isExported).toBe(true);
expect(pkgBFunction?.properties.isExported).toBe(false);
});
it('applies exportPattern and S3method directives from NAMESPACE', () => {
const functions = getNodesByLabelFull(result, 'Function');
const patternScoped = functions.find(
(n) =>
n.name === 'PatternScoped' &&
n.properties.filePath.includes('pkgB/R/export_namespace_variants.R'),
);
const s3Method = functions.find(
(n) =>
n.name === 'print.FancyWidget' &&
n.properties.filePath.includes('pkgB/R/export_namespace_variants.R'),
);
expect(patternScoped?.properties.isExported).toBe(true);
expect(s3Method?.properties.isExported).toBe(true);
});
it('applies exportClasses() and exportMethods() directives from NAMESPACE', () => {
const classes = getNodesByLabelFull(result, 'Class');
const exportedClass = classes.find(
(n) =>
n.name === 'ExportedS4Class' && n.properties.filePath.includes('pkgB/R/s4_export_test.R'),
);
expect(exportedClass).toBeDefined();
expect(exportedClass!.properties.isExported).toBe(true);
const functions = getNodesByLabelFull(result, 'Function');
const exportedMethod = functions.find(
(n) =>
n.name === 'exportedMethod' && n.properties.filePath.includes('pkgB/R/s4_export_test.R'),
);
expect(exportedMethod).toBeDefined();
expect(exportedMethod!.properties.isExported).toBe(true);
});
it('defaults all functions to exported when no NAMESPACE file exists', () => {
const functions = getNodesByLabelFull(result, 'Function');
const roxygenFunc = functions.find(
(n) =>
n.name === 'RoxygenExported' && n.properties.filePath.includes('pkgC/R/default_export.R'),
);
const noTagFunc = functions.find(
(n) => n.name === 'NoExportTag' && n.properties.filePath.includes('pkgC/R/default_export.R'),
);
expect(roxygenFunc).toBeDefined();
expect(noTagFunc).toBeDefined();
// Without NAMESPACE, rExportChecker defaults to true for all functions
expect(roxygenFunc!.properties.isExported).toBe(true);
expect(noTagFunc!.properties.isExported).toBe(true);
});
it('populates field metadata on R Property nodes', () => {
const properties = getNodesByLabelFull(result, 'Property');
const slotName = properties.find(
(p) => p.name === 'name' && p.properties.filePath.includes('pkgA/R/models.R'),
);
expect(slotName).toBeDefined();
expect(slotName!.properties.visibility).toBe('public');
expect(slotName!.properties.declaredType).toBe('character');
const dataField = properties.find(
(p) => p.name === 'data' && p.properties.filePath.includes('pkgA/R/models.R'),
);
expect(dataField).toBeDefined();
expect(dataField!.properties.visibility).toBe('public');
expect(dataField!.properties.declaredType).toBe('data.frame');
const itemsField = properties.find(
(p) => p.name === 'items' && p.properties.filePath.includes('pkgA/R/models.R'),
);
expect(itemsField).toBeDefined();
expect(itemsField!.properties.visibility).toBe('public');
});
// --- Negative tests ---
it('does not index .Rprofile or .Renviron files', () => {
const allNodes: string[] = [];
result.graph.forEachNode((n) => {
if (
n.properties.filePath?.includes('.Rprofile') ||
n.properties.filePath?.includes('.Renviron')
) {
allNodes.push(n.properties.name);
}
});
expect(allNodes).toHaveLength(0);
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import R from '@eagleoutice/tree-sitter-r';
import { countCallArguments } from '../../src/core/ingestion/utils/call-analysis.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../../src/core/ingestion/languages/index.js';
function extractRCall(
code: string,
calledName: string,
): { callNode: SyntaxNode; nameNode: SyntaxNode } | undefined {
const parser = new Parser();
parser.setLanguage(R);
const provider = getProvider(SupportedLanguages.R);
const tree = parser.parse(code);
const query = new Parser.Query(parser.getLanguage(), provider.treeSitterQueries);
for (const match of query.matches(tree.rootNode)) {
const captureMap: Record<string, SyntaxNode> = {};
for (const capture of match.captures) {
captureMap[capture.name] = capture.node;
}
if (captureMap['call'] && captureMap['call.name']?.text === calledName) {
return { callNode: captureMap['call'], nameNode: captureMap['call.name'] };
}
}
return undefined;
}
describe('countCallArguments (R)', () => {
it('returns 0 for empty-argument calls in pipe chains', () => {
const cleanCall = extractRCall('data |> clean_native() |> transform_native()', 'clean_native');
const transformCall = extractRCall(
'data |> clean_native() |> transform_native()',
'transform_native',
);
expect(cleanCall).toBeDefined();
expect(transformCall).toBeDefined();
// countCallArguments counts explicit arguments only — pipe LHS is not in the call node
expect(countCallArguments(cleanCall!.callNode)).toBe(0);
expect(countCallArguments(transformCall!.callNode)).toBe(0);
});
it('returns 0 for empty-argument magrittr pipe calls', () => {
const cleanCall = extractRCall(
'data %>% clean_magrittr() %>% transform_magrittr()',
'clean_magrittr',
);
const transformCall = extractRCall(
'data %>% clean_magrittr() %>% transform_magrittr()',
'transform_magrittr',
);
expect(cleanCall).toBeDefined();
expect(transformCall).toBeDefined();
expect(countCallArguments(cleanCall!.callNode)).toBe(0);
expect(countCallArguments(transformCall!.callNode)).toBe(0);
});
it('counts explicit arguments in ordinary R calls', () => {
const multiArgCall = extractRCall('transform_magrittr(first, second)', 'transform_magrittr');
expect(multiArgCall).toBeDefined();
// tree-sitter-r produces: argument("first"), comma(","), argument("second")
// countCallArguments counts all named non-comment children
const count = countCallArguments(multiArgCall!.callNode);
expect(count).toBeGreaterThanOrEqual(2);
});
it('counts explicit arguments when magrittr uses the dot placeholder', () => {
const placeholderCall = extractRCall(
'data %>% transform_magrittr(., extra)',
'transform_magrittr',
);
expect(placeholderCall).toBeDefined();
const count = countCallArguments(placeholderCall!.callNode);
expect(count).toBeGreaterThanOrEqual(2);
});
});

View file

@ -115,7 +115,7 @@ describe('getLanguageFromFilename', () => {
});
describe('unsupported', () => {
it.each(['.scala', '.r', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])(
it.each(['.scala', '.lua', '.zig', '.txt', '.md', '.json', '.yaml'])(
'returns null for %s files',
(ext) => {
expect(getLanguageFromFilename(`file${ext}`)).toBeNull();

View file

@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import { buildTypeEnv } from '../../src/core/ingestion/type-env.js';
import {
typeConfig as rTypeConfig,
extractReturnType as rExtractReturnType,
} from '../../src/core/ingestion/type-extractors/r.js';
import Parser from 'tree-sitter';
import R from '@eagleoutice/tree-sitter-r';
const parser = new Parser();
const parse = (code: string) => {
parser.setLanguage(R);
return parser.parse(code);
};
function flatGet(typeEnv: ReturnType<typeof buildTypeEnv>, varName: string): string | undefined {
for (const [, scopeMap] of typeEnv.allScopes()) {
const val = scopeMap.get(varName);
if (val) return val;
}
return undefined;
}
function flatSize(typeEnv: ReturnType<typeof buildTypeEnv>): number {
let count = 0;
for (const [, scopeMap] of typeEnv.allScopes()) count += scopeMap.size;
return count;
}
describe('buildTypeEnv', () => {
describe('R roxygen2 annotations', () => {
it('extracts @param type bindings from roxygen2 comments', () => {
const tree = parse(`
#' @param data DataFrame the input data
#' @param outliers DataFrame outlier records
AddOutlierStatuses <- function(data, outliers) {
data
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'data')).toBe('DataFrame');
expect(flatGet(typeEnv, 'outliers')).toBe('DataFrame');
});
it('skips lowercase type names (primitive types)', () => {
const tree = parse(`
#' @param x numeric input value
#' @param name Character the name
process <- function(x, name) {
x
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'x')).toBeUndefined();
expect(flatGet(typeEnv, 'name')).toBe('Character');
});
it('extracts no types when no roxygen2 comments present', () => {
const tree = parse(`
process <- function(x, y) {
x + y
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatSize(typeEnv)).toBe(0);
});
it('extracts types from = assignment function definitions', () => {
const tree = parse(`
#' @param repo UserRepo the repository
helper = function(repo) {
repo
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'repo')).toBe('UserRepo');
});
it('returns constructor binding for R6 obj <- ClassName$new()', () => {
const tree = parse(`
rs <- ResultSet$new(items)
`);
const { constructorBindings } = buildTypeEnv(tree, 'r');
const binding = constructorBindings.find((b) => b.varName === 'rs');
expect(binding).toBeDefined();
expect(binding!.calleeName).toBe('ResultSet');
});
it('extractReturnType extracts @return type from roxygen2 comment', () => {
const tree = parse(`
#' @param name Character the user name
#' @return User
create <- function(name) {
name
}
`);
let defNode: any = null;
for (let i = 0; i < tree.rootNode.childCount; i++) {
const child = tree.rootNode.child(i);
if (child?.type === 'binary_operator') {
defNode = child;
break;
}
}
expect(defNode).not.toBeNull();
const returnType = rExtractReturnType(defNode);
expect(returnType).toBe('User');
});
it('extractReturnType skips lowercase @return types', () => {
const tree = parse(`
#' @return numeric
compute <- function() { 42 }
`);
let defNode: any = null;
for (let i = 0; i < tree.rootNode.childCount; i++) {
const child = tree.rootNode.child(i);
if (child?.type === 'binary_operator') {
defNode = child;
break;
}
}
expect(defNode).not.toBeNull();
const returnType = rExtractReturnType(defNode);
expect(returnType).toBeUndefined();
});
it('returns constructor binding for S4 obj <- new("ClassName")', () => {
const tree = parse(`
model <- new("DataModel", name = "test")
`);
const { constructorBindings } = buildTypeEnv(tree, 'r');
const binding = constructorBindings.find((b) => b.varName === 'model');
expect(binding).toBeDefined();
expect(binding!.calleeName).toBe('DataModel');
});
it('extracts @param types when @examples block is present', () => {
const tree = parse(`
#' @param data DataFrame the input data
#' @param config Config configuration object
#' @return Result
#' @examples
#' result <- process(my_data, my_config)
#' print(result)
process <- function(data, config) {
data
}
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'data')).toBe('DataFrame');
expect(flatGet(typeEnv, 'config')).toBe('Config');
});
it('extracts @param types when regular comments appear before function', () => {
const tree = parse(`
#' @param x DataFrame the input
# TODO: refactor this later
compute <- function(x) { x }
`);
const typeEnv = buildTypeEnv(tree, 'r');
expect(flatGet(typeEnv, 'x')).toBe('DataFrame');
});
});
});

View file

@ -375,27 +375,27 @@ So return-type-aware receiver inference already exists in a constrained downstre
## Language Feature Matrix
| Feature | TS | JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C++ | C | Dart |
|---------|:--:|:--:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:---:|:-:|:----:|
| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes††† | Yes | Yes | Yes |
| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | Partial‡‡‡ | No | No | No |
| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes |
| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | No |
| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No |
| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No |
| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes |
| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | Yes††† | Yes | No | Yes |
| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes |
| Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No | Yes |
| Parameter types extracted | Yes** | No | Yes | Yes | Yes | Yes | Yes | Partial†† | No | No | No | Yes | No | No |
| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No | No |
| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No | Yes |
| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No | No |
| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** | Yes*** |
| Feature | TS | JS | Java | Kotlin | C# | Go | Rust | Python | PHP | Ruby | Swift | C++ | C | Dart | R |
|---------|:--:|:--:|:----:|:------:|:--:|:--:|:----:|:------:|:---:|:----:|:-----:|:---:|:-:|:----:|:-:|
| Declarations | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Parameters | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No‖‖‖ |
| Initializer / constructor inference | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| Constructor binding scan | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| For-loop element types | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes††† | Yes | Yes | Yes | No |
| Pattern binding | Yes | Yes | Yes | Yes | No | Yes | Yes | No | No | No | Partial‡‡‡ | No | No | No | No |
| Assignment chains | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | No |
| Field/property type resolution | Yes | No† | Yes | Yes | Yes | Yes | Yes | Yes* | Yes | YARD | No | Yes | No‡ | No | Partial‖‖‖ |
| Comment-based types | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | Roxygen2 |
| Return type extraction | JSDoc | JSDoc | No | No | No | No | No | No | PHPDoc | YARD | No | No | No | No | Roxygen2 |
| Call-result variable binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | Yes |
| Field access binding | Yes | No† | Yes | Yes | Yes | Yes | Yes | No‖ | Yes | N/A | Yes††† | Yes | No | Yes | No |
| Method-call-result binding | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes¶ | Yes††† | Yes | No | Yes | Yes |
| Write access (ACCESSES write) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes§ | Yes | Yes | Yes | No | Yes | No |
| Parameter types extracted | Yes** | No | Yes | Yes | Yes | Yes | Yes | Partial†† | No | No | No | Yes | No | No | Roxygen2 |
| Method overload disambiguation | Yes** | No | Yes | Yes | Yes | No | No | No | No | No | No | Yes | No | No | No |
| Constructor-visible virtual dispatch | Yes | No | Yes | Yes‡‡ | Yes | No | No | No | No | No | No | Yes§§ | No | Yes | No |
| Optional parameter arity resolution | Yes | No | No | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | No | No | No |
| Cross-file binding propagation | Yes | Yes | Yes‖‖ | Yes | Yes¶¶ | Yes*** | Yes | Yes | Partial | Yes*** | Yes*** | Yes*** | Yes*** | Yes*** | Yes*** |
\* Python class-level annotated attributes (`address: Address`) now resolve `declaredType` correctly. The `self.x` instance attribute pattern is not yet supported.
@ -427,7 +427,9 @@ So return-type-aware receiver inference already exists in a constrained downstre
‡‡‡ Swift: `if let` / `guard let` optional bindings supported. `while let`, `switch` / `case` pattern matching, and tuple destructuring not yet implemented.
\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction.
\*\*\* Whole-module-import languages (Go, Ruby, C/C++, Swift, R): namedImportMap entries synthesized from graph-exported symbols via `synthesizeWildcardImportBindings()`. Not from import AST node extraction.
‖‖‖ R has no inline type annotations. Parameter types are extracted from roxygen2 `#' @param name Type` comments. Field/property types for S4 slots come from `representation(name = "character")` string literals; R6 fields infer types from default values (`NULL`, `TRUE`, `0L`, `"str"`).
---