From 04897ce709139955794d280737242e83b5af95e1 Mon Sep 17 00:00:00 2001 From: taiseii <37083727+taiseii@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:21:22 +0200 Subject: [PATCH] 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. --- gitnexus-shared/src/language-detection.ts | 2 + gitnexus-shared/src/languages.ts | 1 + gitnexus/package-lock.json | 31 +- gitnexus/package.json | 1 + .../src/core/ingestion/entry-point-scoring.ts | 1 + .../src/core/ingestion/export-detection.ts | 24 ++ .../src/core/ingestion/field-extractors/r.ts | 256 ++++++++++++ .../src/core/ingestion/framework-detection.ts | 1 + .../src/core/ingestion/import-resolvers/r.ts | 82 ++++ .../core/ingestion/import-resolvers/types.ts | 2 + .../src/core/ingestion/language-config.ts | 113 ++++++ .../src/core/ingestion/languages/index.ts | 2 + gitnexus/src/core/ingestion/languages/r.ts | 180 ++++++++ .../src/core/ingestion/method-extractors/r.ts | 260 ++++++++++++ .../src/core/ingestion/parsing-processor.ts | 30 +- .../ingestion/pipeline-phases/parse-impl.ts | 12 + gitnexus/src/core/ingestion/r-post-parse.ts | 140 +++++++ .../src/core/ingestion/tree-sitter-queries.ts | 241 +++++++++++ .../src/core/ingestion/type-extractors/r.ts | 208 ++++++++++ .../core/ingestion/workers/parse-worker.ts | 36 +- .../src/core/tree-sitter/parser-loader.ts | 2 + .../r-packages/pkgA/DESCRIPTION | 4 + .../lang-resolution/r-packages/pkgA/NAMESPACE | 6 + .../r-packages/pkgA/R/add_outliers.R | 11 + .../r-packages/pkgA/R/dot_names.R | 4 + .../r-packages/pkgA/R/export_scope.R | 6 + .../r-packages/pkgA/R/models.R | 29 ++ .../r-packages/pkgA/R/r6_advanced.R | 26 ++ .../r-packages/pkgA/R/r6_bare.R | 10 + .../lang-resolution/r-packages/pkgA/R/s3.R | 13 + .../r-packages/pkgA/R/s4_and_r6.R | 41 ++ .../r-packages/pkgA/R/s4_multi_parent.R | 3 + .../r-packages/pkgA/R/super_assign.R | 7 + .../lang-resolution/r-packages/pkgA/R/utils.R | 7 + .../r-packages/pkgB/DESCRIPTION | 3 + .../lang-resolution/r-packages/pkgB/NAMESPACE | 5 + .../r-packages/pkgB/R/clean_data.R | 8 + .../pkgB/R/export_namespace_variants.R | 7 + .../r-packages/pkgB/R/export_scope.R | 3 + .../r-packages/pkgB/R/s4_export_test.R | 2 + .../r-packages/pkgC/DESCRIPTION | 3 + .../r-packages/pkgC/R/default_export.R | 10 + .../r-packages/scripts/run_analysis.R | 14 + gitnexus/test/integration/resolvers/r.test.ts | 384 ++++++++++++++++++ gitnexus/test/unit/call-arguments-r.test.ts | 84 ++++ gitnexus/test/unit/ingestion-utils.test.ts | 2 +- gitnexus/test/unit/type-env-r.test.ts | 166 ++++++++ type-resolution-system.md | 46 ++- 48 files changed, 2498 insertions(+), 31 deletions(-) create mode 100644 gitnexus/src/core/ingestion/field-extractors/r.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/r.ts create mode 100644 gitnexus/src/core/ingestion/languages/r.ts create mode 100644 gitnexus/src/core/ingestion/method-extractors/r.ts create mode 100644 gitnexus/src/core/ingestion/r-post-parse.ts create mode 100644 gitnexus/src/core/ingestion/type-extractors/r.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/add_outliers.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/dot_names.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/export_scope.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/models.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_advanced.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/r6_bare.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s3.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_and_r6.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/s4_multi_parent.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/super_assign.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgA/R/utils.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/NAMESPACE create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/clean_data.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_namespace_variants.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/export_scope.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgB/R/s4_export_test.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/DESCRIPTION create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/pkgC/R/default_export.R create mode 100644 gitnexus/test/fixtures/lang-resolution/r-packages/scripts/run_analysis.R create mode 100644 gitnexus/test/integration/resolvers/r.test.ts create mode 100644 gitnexus/test/unit/call-arguments-r.test.ts create mode 100644 gitnexus/test/unit/type-env-r.test.ts diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index d31f9d58d..c1d8bb210 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -42,6 +42,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Swift]: ['.swift'], [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], + [SupportedLanguages.R]: ['.r'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], } satisfies Record; // Ensure exhaustiveness @@ -100,6 +101,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Swift]: 'swift', [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', + [SupportedLanguages.R]: 'r', [SupportedLanguages.Cobol]: 'cobol', } satisfies Record; // Ensure exhaustiveness diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index 29edb8d1e..95996e717 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -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', } diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index bf8b5bb35..85002efdd 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index 6448e716a..9a77ea13b 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/src/core/ingestion/entry-point-scoring.ts b/gitnexus/src/core/ingestion/entry-point-scoring.ts index f8d85ae48..fcfc46e4a 100644 --- a/gitnexus/src/core/ingestion/entry-point-scoring.ts +++ b/gitnexus/src/core/ingestion/entry-point-scoring.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 3eb1b4f07..8208cecbf 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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) +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/r.ts b/gitnexus/src/core/ingestion/field-extractors/r.ts new file mode 100644 index 000000000..6a3b9b100 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/r.ts @@ -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, ''); +} diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 9ea43800c..eebae4018 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/import-resolvers/r.ts b/gitnexus/src/core/ingestion/import-resolvers/r.ts new file mode 100644 index 000000000..3134db550 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/r.ts @@ -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 }; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 3e68c38b2..c42b397e5 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -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. */ diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 682d7b190..033f67879 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -224,6 +224,118 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise; + /** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */ + namespaceInfoByPackageDir: Map; +} + +export interface RNamespaceInfo { + /** True when the package has a readable NAMESPACE file. */ + hasNamespaceFile: boolean; + /** Explicit named exports from export()/exportClasses()/exportMethods()/S3method(). */ + namedExports: Set; + /** Regex patterns from exportPattern("..."). */ + exportPatterns: string[]; +} + +export async function loadRPackageConfig(repoRoot: string): Promise { + const packages = new Map(); + const namespaceInfoByPackageDir = new Map(); + 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(); + 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; diff --git a/gitnexus/src/core/ingestion/languages/r.ts b/gitnexus/src/core/ingestion/languages/r.ts new file mode 100644 index 000000000..8d1663534 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r.ts @@ -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 = 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, +}); diff --git a/gitnexus/src/core/ingestion/method-extractors/r.ts b/gitnexus/src/core/ingestion/method-extractors/r.ts new file mode 100644 index 000000000..b35eb9686 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/r.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index a13d66818..38426dc27 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 52c7a0d47..b6e833147 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/r-post-parse.ts b/gitnexus/src/core/ingestion/r-post-parse.ts new file mode 100644 index 000000000..69b15f23b --- /dev/null +++ b/gitnexus/src/core/ingestion/r-post-parse.ts @@ -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; + }); +}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 99fd3b21c..9bd09153c 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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.Swift]: SWIFT_QUERIES, [SupportedLanguages.Dart]: DART_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue