mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
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:
parent
32c9ddaf32
commit
04897ce709
48 changed files with 2498 additions and 31 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
31
gitnexus/package-lock.json
generated
31
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
256
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal file
256
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal 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, '');
|
||||
}
|
||||
|
|
@ -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[]>;
|
||||
|
||||
|
|
|
|||
82
gitnexus/src/core/ingestion/import-resolvers/r.ts
Normal file
82
gitnexus/src/core/ingestion/import-resolvers/r.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
180
gitnexus/src/core/ingestion/languages/r.ts
Normal file
180
gitnexus/src/core/ingestion/languages/r.ts
Normal 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,
|
||||
});
|
||||
260
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal file
260
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
140
gitnexus/src/core/ingestion/r-post-parse.ts
Normal file
140
gitnexus/src/core/ingestion/r-post-parse.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
208
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal file
208
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
4
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/DESCRIPTION
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Package: pkgA
|
||||
Title: Test Package A
|
||||
Version: 0.1.0
|
||||
Imports: pkgB
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/NAMESPACE
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export(AddOutlierStatuses)
|
||||
export(HelperFunc)
|
||||
export(my.helper.func)
|
||||
export(PackageScoped)
|
||||
export(SuperAssignFunc)
|
||||
importFrom(pkgB, CleanData)
|
||||
11
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/add_outliers.R
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/add_outliers.R
vendored
Normal 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
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/dot_names.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/dot_names.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#' A dot-named function (legal R identifier — dots are valid in names)
|
||||
my.helper.func <- function(x) {
|
||||
x
|
||||
}
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/export_scope.R
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/export_scope.R
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#' Exported only from pkgA
|
||||
#'
|
||||
#' @export
|
||||
PackageScoped <- function() {
|
||||
TRUE
|
||||
}
|
||||
29
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/models.R
vendored
Normal file
29
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/models.R
vendored
Normal 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)
|
||||
}
|
||||
)
|
||||
)
|
||||
26
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_advanced.R
vendored
Normal file
26
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_advanced.R
vendored
Normal 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
|
||||
}
|
||||
)
|
||||
)
|
||||
10
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_bare.R
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_bare.R
vendored
Normal 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
|
||||
)
|
||||
13
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s3.R
vendored
Normal file
13
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s3.R
vendored
Normal 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)
|
||||
}
|
||||
41
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_and_r6.R
vendored
Normal file
41
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_and_r6.R
vendored
Normal 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()
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_multi_parent.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_multi_parent.R
vendored
Normal 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"))
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/super_assign.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/super_assign.R
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#' Function defined with <<- super-assignment
|
||||
#'
|
||||
#' @param x Numeric input
|
||||
#' @return Numeric result
|
||||
SuperAssignFunc <<- function(x) {
|
||||
x + 1
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/utils.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/utils.R
vendored
Normal 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
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: pkgB
|
||||
Title: Test Package B
|
||||
Version: 0.1.0
|
||||
5
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/NAMESPACE
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export(CleanData)
|
||||
exportPattern("^Pattern")
|
||||
S3method(print, FancyWidget)
|
||||
exportClasses(ExportedS4Class)
|
||||
exportMethods(exportedMethod)
|
||||
8
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/clean_data.R
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/clean_data.R
vendored
Normal 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), ]
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_namespace_variants.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_namespace_variants.R
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
PatternScoped <- function() {
|
||||
TRUE
|
||||
}
|
||||
|
||||
print.FancyWidget <- function(x) {
|
||||
x
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_scope.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_scope.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
PackageScoped <- function() {
|
||||
FALSE
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/s4_export_test.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/s4_export_test.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
setClass("ExportedS4Class", slots = list(data = "list"))
|
||||
setGeneric("exportedMethod", function(object) standardGeneric("exportedMethod"))
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: pkgC
|
||||
Title: Test Package C (no NAMESPACE)
|
||||
Version: 0.1.0
|
||||
10
gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/R/default_export.R
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/R/default_export.R
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#' A function with roxygen @export tag
|
||||
#'
|
||||
#' @export
|
||||
RoxygenExported <- function() {
|
||||
TRUE
|
||||
}
|
||||
|
||||
NoExportTag <- function() {
|
||||
FALSE
|
||||
}
|
||||
14
gitnexus/test/fixtures/lang-resolution/r-packages/scripts/run_analysis.R
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/r-packages/scripts/run_analysis.R
vendored
Normal 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")
|
||||
384
gitnexus/test/integration/resolvers/r.test.ts
Normal file
384
gitnexus/test/integration/resolvers/r.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
gitnexus/test/unit/call-arguments-r.test.ts
Normal file
84
gitnexus/test/unit/call-arguments-r.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
166
gitnexus/test/unit/type-env-r.test.ts
Normal file
166
gitnexus/test/unit/type-env-r.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue