feat: add R language support (in five phases) - #70
Open
kapelner wants to merge 6 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-ron npm itself is an unrelated squatted placeholder) parses.R/.rfiles.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(...) {} -> namebecomes a flatfunctionnode — the same altitude Python support already operates at for module-leveldefs.function_definitioncarries 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 ownbodyfield instead of the function sitting inside an outerbinary_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, andself$/private$calls resolving directly to the enclosing class the same way Python'sself/TS'sthisalready do. S4 (setClass()/setMethod()) become a class and an owned method respectively, withcontains =heritage;setGeneric()isn't specially extracted. S3 (generic.Class <- function() {}) only becomes a method whengenericis a known generic (registered locally viaUseMethod(), or a small curated set of common base-R generics) —read.csv/data.frameare correctly NOT treated as S3 dispatch.Phase 3 — roxygen
@exportvisibility + R6super$dispatch. A#' @exporttag marks a definition exported regardless of naming convention; a roxygen block with no@exporttag 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-extractedinherit =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 dynamicdo.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 inR6::R6Class(...)at all, is a real, deliberate convention for splicing a shared method bundle into multiple classes (public = c(Foo$public, list(...))) rather than usinginherit =. Now recognized as a class-like container whenever the list has apublic =/private =entry — precise enough that ordinary data/config lists are never mistaken for one.Known limitations (documented in code + CHANGELOG)
setMethod()only handles single-class dispatch, notsignature()-based multiple dispatch.Test plan
tsc --noEmitcleannpm run buildcleantest/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--no-reuseforced 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 internalcorrectly driving visibility, and — after Phase 5 — zero unexplained empty files remaining across the corpus🤖 Generated with Claude Code