Skip to content

feat: add R language support (in five phases) - #70

Open
kapelner wants to merge 6 commits into
NanoNets:mainfrom
kapelner:feat/r-support
Open

feat: add R language support (in five phases)#70
kapelner wants to merge 6 commits into
NanoNets:mainfrom
kapelner:feat/r-support

Conversation

@kapelner

@kapelner kapelner commented Aug 9, 2026

Copy link
Copy Markdown

Summary

Adds R language support to graft's Tier-1 extraction. tree-sitter-r (npm:@davisvaughan/tree-sitter-r — the real, maintained package; tree-sitter-r on npm itself is an unrelated squatted placeholder) parses .R/.r files.

R's class systems (S3/S4/R6) are library convention, not grammar syntax, unlike every other language graft supports — this is the first "pattern-match known call idioms → sometimes a class/method" language here, rather than "one grammar construct → one kind." Shipped in five incremental phases, each independently tested and (from Phase 2 on) verified against a real R6-heavy production package found while dogfooding:

Phase 1 — flat function extraction. Every name <- function(...) {} / name = function(...) {} / function(...) {} -> name becomes a flat function node — the same altitude Python support already operates at for module-level defs. function_definition carries no name field at all in this grammar, so the identifier always comes from an enclosing assignment. Right-assign (->/->>) needed its own logic rather than mirroring left-assign: its low operator precedence means it's absorbed into the function definition's own body field instead of the function sitting inside an outer binary_operator — confirmed by dumping the real AST, not assumed. library()/require()/source() calls are recognized as imports by pattern-matching the callee name (R has no import statement at the grammar level).

Phase 2 — S3/S4/R6 class awareness. R6 (Foo <- R6::R6Class("Foo", public = list(...), private = list(...))) gets full support: the class node, public =/private =/active = list entries as methods (private ones unexported), inherit = heritage, and self$/private$ calls resolving directly to the enclosing class the same way Python's self/TS's this already do. S4 (setClass()/setMethod()) become a class and an owned method respectively, with contains = heritage; setGeneric() isn't specially extracted. S3 (generic.Class <- function() {}) only becomes a method when generic is a known generic (registered locally via UseMethod(), or a small curated set of common base-R generics) — read.csv/data.frame are correctly NOT treated as S3 dispatch.

Phase 3 — roxygen @export visibility + R6 super$ dispatch. A #' @export tag marks a definition exported regardless of naming convention; a roxygen block with no @export tag is instead treated as explicit "not exported" (matching roxygen's own NAMESPACE-generation semantics). super$method() now resolves directly to the parent class's method via the already-extracted inherit = heritage, rather than risking a match on the current class's own same-named override.

Phase 4 — untyped R6 composition calls. private$other_obj$method() (one class holding another as a field) is a common real pattern that a field-type-binding table (the "field <- SomeClass$new()" pattern other languages use) turns out not to help with in practice — real-world field assignment is usually constructor-parameter pass-through or dynamic do.call() dispatch, neither of which names a class anywhere the AST can see. The actual fix: bare-name call resolution was silently restricted to "function"-kind nodes only, so since R6 methods are always kind "method", every such call was unconditionally unresolvable. Now allows a "method" match too, using the same "unique match resolves, ambiguous match safely drops" logic already used everywhere else.

Phase 5 — plain-list mixin/extension bundles. Found dogfooding a full rebuild of a real R6-heavy corpus: Foo <- list(public = list(...), private = list(...)), NOT wrapped in R6::R6Class(...) at all, is a real, deliberate convention for splicing a shared method bundle into multiple classes (public = c(Foo$public, list(...))) rather than using inherit =. Now recognized as a class-like container whenever the list has a public =/private = entry — precise enough that ordinary data/config lists are never mistaken for one.

Known limitations (documented in code + CHANGELOG)

  • S3 generics registered in a different file than their methods aren't recognized (no whole-repo pass exists — same per-file limitation Go/C++ bindings already accept).
  • S4's setMethod() only handles single-class dispatch, not signature()-based multiple dispatch.
  • R6 active bindings are treated as ordinary (exported) methods, no distinction from regular ones.
  • No general R6 field-type-binding table (Phase 4 addresses the highest-value case of this without one).

Test plan

  • tsc --noEmit clean
  • npm run build clean
  • 5 dedicated test files (test/graph-r*.test.ts) covering all phases — Phase 1 flat extraction, Phase 2 S3/S4/R6, Phase 3 roxygen/super$, Phase 4 composition-call resolution, Phase 5 mixin bundles
  • Full existing test suite green, no regressions
  • Verified against a real ~180-file R6-heavy production package (--no-reuse forced full rebuild, not cache-replayed): confirmed real composition calls resolving (e.g. private$des_obj$get_n() → 13 real resolved call sites), roxygen @export/@keywords internal correctly driving visibility, and — after Phase 5 — zero unexplained empty files remaining across the corpus

🤖 Generated with Claude Code

kapelner and others added 6 commits August 7, 2026 12:21
tree-sitter-r (npm:@davisvaughan/tree-sitter-r) parses .R/.r files.
Every name <- function(){}, name = function(){}, and function(){} -> name
becomes a flat function node -- no S3/S4/R6 class awareness, deliberately
scoped as a separate Phase 2.

R's function_definition carries no name field at all -- the identifier
always comes from an enclosing assignment via the one generic
binary_operator node shared by every binary op, so a dedicated describeR
does the filtering. Right-assign needed its own branch since its AST
shape does not mirror left-assign's the way it looks like it should
(confirmed by dumping the real AST, not assumed): -> gets absorbed into
the function definition's own body field rather than wrapping it from
outside.

library()/require()/source() are recognized as imports by pattern-
matching the callee name (no import statement exists in R's grammar).
pkg::fn() and obj$method() calls resolve by bare name, since Phase 1 has
no type-binding table for a typed member-call match. Visibility is the
leading-dot naming convention only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Needed to install tree-sitter-r and run ad-hoc grammar-inspection
scripts while building R support. This branch forked before the
equivalent cpp-branch commit, so it needed re-adding here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
R's class systems are library convention, not grammar syntax, unlike
every other language graft supports -- this is the first "pattern-match
known call idioms -> sometimes a class/method" language rather than
"one grammar construct -> one kind."

R6 (Foo <- R6::R6Class("Foo", public = list(...), private = list(...)))
is the highest-value target -- this repo's own dominant R OOP style --
and gets full support: the class node, public=/private=/active= list
entries as methods (private ones unexported), inherit= heritage, and
self$/private$ calls resolving directly to the enclosing class the same
way self/this already do for Python/TS. Implemented as a walk()-level
interception of the public=/private=/active= argument since R6's
"class body" is several levels of ordinary call/argument nodes, not a
dedicated grammar construct.

S4 (setClass()/setMethod(), both call nodes with side effects, almost
never assigned) become a class and an owned method respectively, with
contains= (single or c(...)-vector) heritage. setGeneric() isn't
specially extracted -- no natural class/method mapping.

S3 (generic.Class <- function() {}) is the genuinely ambiguous case the
plan flags: read.csv/data.frame are not S3 dispatch, and nothing in the
grammar distinguishes them from print.MyClass. A name.Class assignment
only becomes an S3 method when name is a generic registered locally via
a UseMethod() call in the same file, or is one of a small curated set of
common base-R generics -- erring toward false negatives over false
positives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scoped for an R6-plus-roxygen setup specifically (no S3/S4 involved).

A #' @export roxygen tag now marks its definition exported regardless
of the leading-dot naming convention. A definition with some roxygen
doc block but no @export tag is treated as an explicit "not exported"
(roxygen's own NAMESPACE-generation convention -- only @export-tagged
items are exported, so documented-but-untagged is a real signal, not
an absence of evidence); the naming-convention fallback only applies
when there's no roxygen block at all. comment is a grammar extra
(floats as an ordinary sibling, not attached via a field), so this
walks backward through previousNamedSibling collecting a contiguous
roxygen (#') comment run.

R6's super$method() now resolves directly to the parent class's
method via the inherit= heritage already extracted in Phase 2, rather
than falling back to a plain bare-name match that could just as
easily match the current class's own same-named override.

Also fixed in passing: the R6Class(...) call itself no longer
generates a spurious (harmless -- always unresolved and dropped, but
wasted) calls-edge intent to a function literally named "R6Class".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
private$other_obj$method() (one class holding another as a field) was
investigated against a real R6-heavy corpus and found common (40+
occurrences), but the field-type-binding table other languages have
wouldn't have helped: the dominant real-world field-assignment shape
there is constructor-parameter pass-through and dynamic do.call(...)
dispatch, neither of which names a class anywhere a static
pattern-matcher could read.

The narrower, real fix: these calls were already marked
viaMember:false (a plain bare-name match), but bare-name resolution
only ever matched "function"-kind nodes, never "method" -- so since R6
methods are always kind "method", every such call was unconditionally
unresolvable, not just occasionally imprecise.

Bare-name resolution for this one shape (an untyped $ call, not
self/private/super, which already resolve precisely) now also
considers "method"-kind nodes, via a new optional RawEdge.kinds field
threaded from calleeName through to resolve.ts's resolveName call --
using the exact same "unique match resolves, ambiguous match safely
drops" logic already used everywhere else. pkg::fun() qualified calls
are untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…lasses

Found dogfooding a full --no-reuse rebuild of a real R6-heavy corpus:
12 files with substantial content produced zero extracted symbols. 11
shared one cause -- a real convention that codebase calls "Pattern-1
mixin/extension": Foo <- list(public = list(...), private = list(...)),
sharing a method bundle across classes by splicing
(public = c(Foo$public, list(...))) rather than inherit=-based
inheritance, never wrapped in R6::R6Class(...) at all. 25 files use
this convention.

Name <- list(...) is now recognized as a class-like container
specifically when the list has a public= or private= entry whose own
value is itself a list(...) call -- precise enough that an ordinary
data/config list is never mistaken for one. Nothing else needed to
change: every downstream mechanism (the list-walk, method visibility,
self$/private$ resolution) already worked purely off
ctx.enclosingKind === "class", indifferent to how the class was
spelled. No heritage edge is emitted (splicing isn't inherit=).

Verified against the real corpus: all 11 previously-empty files now
extract correctly, classes 256->277, methods 1764->1916, edges
9089->9415.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kapelner kapelner changed the title feat: add R language support (Phases 1-5) feat: add R language support (in five phases) Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant