mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-13 03:58:33 +00:00
Merge b191eb5dd4 into 61c9a894a4
This commit is contained in:
commit
fca26eec09
271 changed files with 17163 additions and 70 deletions
|
|
@ -69,6 +69,8 @@ GRAMMARS: dict[str, tuple[str, str, str]] = {
|
|||
"tree-sitter-python": ("tree-sitter/tree-sitter-python", "master", "src/parser.c"),
|
||||
"tree-sitter-ruby": ("tree-sitter/tree-sitter-ruby", "master", "src/parser.c"),
|
||||
"tree-sitter-rust": ("tree-sitter/tree-sitter-rust", "master", "src/parser.c"),
|
||||
# Scoped npm package: the only grammar whose name does not start with "tree-sitter-".
|
||||
"@eagleoutice/tree-sitter-r": ("r-lib/tree-sitter-r", "main", "src/parser.c"),
|
||||
"tree-sitter-swift": ("alex-pinkus/tree-sitter-swift", "main", "src/parser.c"),
|
||||
"tree-sitter-typescript": ("tree-sitter/tree-sitter-typescript", "master", "typescript/src/parser.c"),
|
||||
# Vendored parsers — kept here so the upstream coords for drift
|
||||
|
|
@ -162,13 +164,15 @@ def read_pinned_grammar_versions() -> dict[str, str]:
|
|||
|
||||
Looks at both runtime and optional dependencies. Returns the raw range
|
||||
string (e.g. '0.21.4', '^0.23.0', 'file:./vendor/...') so the report can
|
||||
expose how flexible each pin is.
|
||||
expose how flexible each pin is. A grammar is recognised by its package
|
||||
basename, so a scoped package (``@scope/tree-sitter-x``) is included, keyed
|
||||
by its full name.
|
||||
"""
|
||||
pkg = _load_package_json()
|
||||
pinned: dict[str, str] = {}
|
||||
for section in ("dependencies", "optionalDependencies"):
|
||||
for name, spec in (pkg.get(section) or {}).items():
|
||||
if name.startswith("tree-sitter-"):
|
||||
if name.rsplit("/", 1)[-1].startswith("tree-sitter-"):
|
||||
pinned[name] = spec
|
||||
return pinned
|
||||
|
||||
|
|
@ -182,7 +186,7 @@ def read_patched_peers() -> list[tuple[str, str, str]]:
|
|||
"""
|
||||
script = GITNEXUS_DIR / "scripts" / "prepare-tree-sitter-bundle.cjs"
|
||||
return re.findall(
|
||||
r"\['(tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
|
||||
r"\['((?:@[\w.-]+/)?tree-sitter[\w-]*)@([\d.]+)',\s*'([^']+)'\]",
|
||||
script.read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,8 +38,10 @@ _spec.loader.exec_module(readiness) # type: ignore[union-attr]
|
|||
# The exact row-diff regex the workflow's change-detection bot uses
|
||||
# (.github/workflows/tree-sitter-upgrade-readiness.yml) — byte-identical so a matrix
|
||||
# format change that would silently break change-detection fails here. Group 2 is
|
||||
# ONLY the Status cell ([^|]+? before the final `|$`).
|
||||
_ROW_DIFF_RE = re.compile(r"\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
||||
# ONLY the Status cell ([^|]+? before the final `|$`). Group 1 is the grammar
|
||||
# name, optionally scoped (`@eagleoutice/tree-sitter-r`).
|
||||
_R_GRAMMAR = "@eagleoutice/tree-sitter-r"
|
||||
_ROW_DIFF_RE = re.compile(r"\| `((?:@[^/`]+/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$", re.M)
|
||||
# Mirrors the scheduled issue-update summary extraction in
|
||||
# tree-sitter-upgrade-readiness.yml. If the report prose changes again, the issue
|
||||
# comment should not silently degrade to "?/? ready. ? blocker(s)".
|
||||
|
|
@ -216,6 +218,93 @@ class AssertCurrent(TestCase):
|
|||
self.assertIn("outside current runtime range", buf.getvalue())
|
||||
|
||||
|
||||
class AssertCurrentCoversR(TestCase):
|
||||
"""R is an npm grammar under a scoped name; --assert-current must check its
|
||||
installed ABI against the current runtime's range like any other grammar."""
|
||||
|
||||
def test_out_of_range_r_abi_fails_the_gate_and_names_r(self):
|
||||
real = readiness.extract_language_version
|
||||
|
||||
def fake(parser_c):
|
||||
return 99 if "tree-sitter-r" in parser_c.parts else real(parser_c)
|
||||
|
||||
import urllib.request
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(readiness, "extract_language_version", side_effect=fake), \
|
||||
mock.patch.object(urllib.request, "urlopen", side_effect=AssertionError("network")), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
code = readiness.assert_current()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(f"{_R_GRAMMAR}: installed ABI 99 outside current runtime range", buf.getvalue())
|
||||
|
||||
|
||||
class ScopedGrammarName(TestCase):
|
||||
"""The R grammar's package name is scoped (`@eagleoutice/tree-sitter-r`); every
|
||||
place that recognises a grammar by a `tree-sitter-` prefix must cope with it."""
|
||||
|
||||
def test_pinned_versions_include_the_scoped_grammar(self):
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
|
||||
pinned = readiness.read_pinned_grammar_versions()
|
||||
self.assertEqual(pinned.get(_R_GRAMMAR), pkg["dependencies"][_R_GRAMMAR])
|
||||
# Selection is by grammar basename: the runtime and non-grammar scoped
|
||||
# dependencies are still excluded.
|
||||
self.assertNotIn("tree-sitter", pinned)
|
||||
self.assertNotIn("@ladybugdb/core", pinned)
|
||||
|
||||
def test_r_row_shows_its_pin_and_is_a_blocker_not_a_bump_candidate(self):
|
||||
# Production-faithful R metadata: npm latest 1.1.2 (what package.json pins) with peer ^0.21.0 — blocks 0.25 and is NOT behind latest.
|
||||
def fake_npm(pkg: str):
|
||||
if pkg == _R_GRAMMAR:
|
||||
return {"version": "1.1.2", "peerDependencies": {"tree-sitter": "^0.21.0"}}
|
||||
return {"version": "9.9.9", "peerDependencies": {"tree-sitter": "^0.25.0"}}
|
||||
|
||||
def fake_fetch(url: str, timeout: int = 8):
|
||||
if "parser.c" in url and "alex-pinkus" not in url:
|
||||
return "#define LANGUAGE_VERSION 14\n"
|
||||
if "/commits/" in url:
|
||||
return json.dumps({"sha": "0123456789abcdef"})
|
||||
return None
|
||||
|
||||
buf = io.StringIO()
|
||||
with mock.patch.object(readiness, "npm_view_json", side_effect=fake_npm), \
|
||||
mock.patch.object(readiness, "fetch_text", side_effect=fake_fetch), \
|
||||
contextlib.redirect_stdout(buf):
|
||||
code = readiness.main()
|
||||
report = buf.getvalue()
|
||||
row = next(l for l in report.splitlines() if l.startswith(f"| `{_R_GRAMMAR}` |"))
|
||||
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
||||
self.assertEqual(cells[1], "1.1.2") # Pinned
|
||||
self.assertEqual(cells[-1], "Peer patch required")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn(f"`{_R_GRAMMAR}@1.1.2` — peer `^0.21.0`", report) # Blocked bucket
|
||||
# On latest already: not a bump candidate (pre-fix the lost pin made it
|
||||
# one). Other grammars are mocked at 9.9.9 and so do appear in that list;
|
||||
# only R's own line is asserted absent.
|
||||
self.assertNotIn(f"- `{_R_GRAMMAR}`: ", report)
|
||||
self.assertNotIn(f"`{_R_GRAMMAR}` — pinned", report)
|
||||
|
||||
def test_row_diff_regex_captures_scoped_and_unscoped_names(self):
|
||||
rows = (
|
||||
"| `@eagleoutice/tree-sitter-r` | ^1.1.2 | 1.1.2 | ^0.21.0 | **No** | 14 | 14 | Peer patch required |\n"
|
||||
"| `tree-sitter-go` | ^0.23.0 | 0.25.0 | ^0.25.0 | Yes | 14 | 14 | Ready |\n"
|
||||
)
|
||||
self.assertEqual(
|
||||
dict(_ROW_DIFF_RE.findall(rows)),
|
||||
{"@eagleoutice/tree-sitter-r": "Peer patch required", "tree-sitter-go": "Ready"},
|
||||
)
|
||||
|
||||
def test_row_diff_regex_mirror_matches_the_workflow(self):
|
||||
# The workflow's change-detection regex is the one that matters; the
|
||||
# mirror above is only worth anything while it is the same expression.
|
||||
workflow = (
|
||||
_REPO_ROOT / ".github" / "workflows" / "tree-sitter-upgrade-readiness.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
match = re.search(r"md\.matchAll\(/(.+)/gm\)", workflow)
|
||||
self.assertIsNotNone(match, "row-diff regex not found in the workflow")
|
||||
# JS regex literals escape `/`; Python patterns do not.
|
||||
self.assertEqual(match.group(1).replace("\\/", "/"), _ROW_DIFF_RE.pattern)
|
||||
|
||||
|
||||
class FetchHelperReadPhaseErrors(TestCase):
|
||||
"""Read-phase transport failures — raised by resp.read() AFTER urlopen has
|
||||
returned (ConnectionResetError, ssl.SSLError, socket.timeout,
|
||||
|
|
@ -326,6 +415,17 @@ class ReportRendering(TestCase):
|
|||
self.assertNotIn("Could not check", self.report)
|
||||
self.assertNotIn("fetch failed", self.report)
|
||||
|
||||
def test_r_grammar_is_listed_npm_queried_and_diffable(self):
|
||||
# R is an npm dependency, so it takes the npm path (queried, not vendored)
|
||||
# and its row must be captured by the change-detection regex despite the
|
||||
# scoped package name.
|
||||
self.assertEqual(
|
||||
readiness.GRAMMARS.get(_R_GRAMMAR),
|
||||
("r-lib/tree-sitter-r", "main", "src/parser.c"),
|
||||
)
|
||||
self.assertIn(_R_GRAMMAR, _render_report.last_npm_calls)
|
||||
self.assertIn(_R_GRAMMAR, self.rows)
|
||||
|
||||
def test_held_swift_renders_held_and_keeps_exit_nonzero(self):
|
||||
# Status is the last matrix cell (the row-diff regex captures the whole
|
||||
# tail, not just status, so read the cell directly).
|
||||
|
|
@ -361,17 +461,19 @@ class ReportRendering(TestCase):
|
|||
self.assertIsNotNone(ready)
|
||||
self.assertIsNotNone(blockers)
|
||||
# Counts are derived from _render_report()'s mock corpus (all npm peer
|
||||
# deps mocked permissive): of the 10 npm-installed grammars, 9 render
|
||||
# deps mocked permissive): of the 11 npm-installed grammars, 10 render
|
||||
# Ready and 1 — tree-sitter-cpp — is the intentional pin (#1242), so it is
|
||||
# not counted ready. The remaining workarounds are that intentional pin
|
||||
# and Swift's declaration-recovery hold.
|
||||
self.assertEqual(ready.groups(), ("9", "10"))
|
||||
self.assertEqual(ready.groups(), ("10", "11"))
|
||||
self.assertEqual(blockers.group(1), "2")
|
||||
|
||||
def test_lists_every_pack_time_peer_patch_with_its_drop_condition(self):
|
||||
patched = readiness.read_patched_peers()
|
||||
# Guards the AUDITED_PEERS regex: the bundle script patches 8 manifests today.
|
||||
self.assertGreaterEqual(len(patched), 8)
|
||||
# Guards the AUDITED_PEERS regex: the bundle script patches 9 manifests
|
||||
# today, including the scoped R grammar.
|
||||
self.assertGreaterEqual(len(patched), 9)
|
||||
self.assertIn((_R_GRAMMAR, "1.1.2", "^0.21.0"), patched)
|
||||
self.assertIn(f"## Pack-time peer patches ({len(patched)})", self.report)
|
||||
for name, version, _ in patched:
|
||||
self.assertIn(f"- `{name}@{version}`", self.report)
|
||||
|
|
@ -382,6 +484,8 @@ class ReportRendering(TestCase):
|
|||
self.assertIn("required while intentionally pinned", line("tree-sitter-cpp@0.23.4"))
|
||||
self.assertIn("transitive", line("tree-sitter-javascript@0.23.1"))
|
||||
self.assertIn("transitive", line("tree-sitter-c@0.23.6"))
|
||||
# The scoped R grammar is a direct pin, so its patch is listed like any other.
|
||||
self.assertIn("can drop: bump to `9.9.9`", line(f"{_R_GRAMMAR}@1.1.2"))
|
||||
|
||||
def test_target_runtime_is_the_bundled_pin(self):
|
||||
pkg = json.loads((_REPO_ROOT / "gitnexus" / "package.json").read_text())
|
||||
|
|
|
|||
|
|
@ -176,7 +176,8 @@ jobs:
|
|||
// `|$`), so change-detection fires on status transitions, not on
|
||||
// unrelated cell drift (e.g. an upstream-ABI bump). Mirror this in
|
||||
// _ROW_DIFF_RE in test_check_tree_sitter_upgrade_readiness.py.
|
||||
for (const m of md.matchAll(/\| `(tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
|
||||
// The grammar name may be scoped (`@eagleoutice/tree-sitter-r`).
|
||||
for (const m of md.matchAll(/\| `((?:@[^\/`]+\/)?tree-sitter-[^`]+)` \|.*\| ([^|]+?) \|$/gm)) {
|
||||
map[m[1]] = m[2].trim();
|
||||
}
|
||||
return map;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<!-- version: 1.17.0 -->
|
||||
<!-- Last updated: 2026-09-24 -->
|
||||
<!-- version: 1.18.0 -->
|
||||
<!-- Last updated: 2026-10-07 -->
|
||||
|
||||
Last reviewed: 2026-09-24
|
||||
Last reviewed: 2026-10-07
|
||||
|
||||
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
|
||||
|
||||
|
|
@ -40,6 +40,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Objective-C provider work:** read **[docs/languages/objective-c-provider.md](docs/languages/objective-c-provider.md)** before changing Objective-C parsing or resolution.
|
||||
- **R provider work:** read **[docs/languages/r-provider.md](docs/languages/r-provider.md)** before changing R parsing or resolution.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
|
||||
|
|
@ -91,6 +92,7 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-10-07 | 1.18.0 | Added the R provider guide as the required reference before changing R parsing or resolution. |
|
||||
| 2026-09-24 | 1.17.0 | Clones with the same `origin` URL now share a store automatically; `--no-share` records a lasting opt-out (#3352). |
|
||||
| 2026-09-24 | 1.16.0 | Documented the shared worktree index store (`<GITNEXUS_HOME>/stores/`, `analyze --share-with`, `GITNEXUS_SHARED_STORE=off`) in the storage notes (#3352). |
|
||||
| 2026-09-07 | 1.15.0 | Added the Objective-C provider guide as the required reference before changing Objective-C parsing or resolution. |
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ CI auto-discovers the set via `tsx`. No workflow edit required.
|
|||
|
||||
## Language-agnostic graph feeding
|
||||
|
||||
18 languages → single unified graph. Four abstraction layers:
|
||||
19 languages → single unified graph. Four abstraction layers:
|
||||
|
||||
```
|
||||
Unified Graph Schema (44 node types, 21 relationship types)
|
||||
|
|
@ -404,8 +404,10 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
|
||||
| `postParse` | Optional. Whole-graph pass a language runs once after every chunk is merged and before scope resolution (deferred owner resolution, manifest-driven export refinement). Called once per analyze, awaited, only when the language has parsed files; a throw aborts the parse phase. R: deferred `setMethod`/R6 owners + NAMESPACE exports. |
|
||||
| `resolveMemberOwnerNode` | Optional. For languages whose members are owned by a call/assignment rather than an enclosing container node (R: R6/RefClass): returns the node the method and field extractors read members from, or null. Consulted by the enclosing-owner slot after the container walk and `resolveFileTypeOwner` (same slot; Zig file-owner is the precedent). Not an S4 mechanism: R's `setMethod(...)` returns null (its owner is a string-literal class name, not a node); S4 ownership arrives only through the `ownerNameHint` → `postParse` path. |
|
||||
|
||||
18 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
19 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
### Unified capture tags
|
||||
|
||||
|
|
|
|||
|
|
@ -722,9 +722,12 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
| Objective-C | ✓ | — | ✓ | ✓ | ✓ | — | — | — | — |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
|
||||
| R (experimental) | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — |
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions, S4 classes, R6 classes and RefClass (`setRefClass`) classes with their methods and fields, and resolves `library()` / `require()` / `source()` imports to local packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4 `contains=` heritage and NAMESPACE-driven export status. Calls through function values, `do.call()`, infix operators and S3 dispatch are not linked, and `.Rmd` files, `box::use()` and external (CRAN) packages are not supported. See [docs/languages/r-provider.md](docs/languages/r-provider.md) for the full coverage and limits.
|
||||
|
||||
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
|
||||
|
||||
### Multi-Repo Architecture
|
||||
|
|
|
|||
121
docs/languages/r-provider.md
Normal file
121
docs/languages/r-provider.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# R Language Provider
|
||||
|
||||
Status: implemented (experimental)
|
||||
|
||||
The provider is covered by unit and integration tests, listed under [Tests](#tests). It is classified as experimental in the language support table. This status describes what is implemented today; it does not promise R's dynamic dispatch.
|
||||
|
||||
## Scope
|
||||
|
||||
GitNexus indexes `.r` and `.R` files with the `@eagleoutice/tree-sitter-r` grammar and resolves them through the scope-resolution pipeline (see ARCHITECTURE.md, Scope-Resolution Pipeline). R-specific logic lives in `gitnexus/src/core/ingestion/languages/r/`, with the per-language extractor and import-resolver files beside those of the other languages. Shared ingestion code does not name R; the provider plugs in through `LanguageProvider` and `ScopeResolver` hooks.
|
||||
|
||||
R has no class syntax: classes are function calls (`R6Class(...)`, `setClass(...)`, `setRefClass(...)`) and package exports live in a NAMESPACE file rather than in the source. That is why the provider uses two optional hooks, `postParse` (attach each member to its owner and refine export status once every file is parsed) and `resolveMemberOwnerNode` (name the owner of a member from a call rather than an enclosing node).
|
||||
|
||||
## Supported constructs
|
||||
|
||||
Definitions:
|
||||
|
||||
- Functions defined by assignment (`<-`, `=`, `<<-`). S3 methods appear as ordinary functions with dotted names such as `print.foo`.
|
||||
- S4 classes (`setClass`), generics (`setGeneric`), methods (`setMethod`) and slots (`representation`, `slots`).
|
||||
- R6 classes with their methods, fields and active bindings.
|
||||
- RefClass (`setRefClass`) classes with their methods and fields.
|
||||
- The argument that names an S4 or RefClass class, a generic or a method is the argument spelled with the first formal (`Class=`, `name=`, `f=`) wherever it sits, else the first unnamed argument (so a later `Class=` takes the formal from an earlier unnamed argument, as in R). Other strings (`contains=`, `valueClass=`, a `setMethod` signature) create no node.
|
||||
|
||||
Types:
|
||||
|
||||
- `x <- Type$new()` (R6, RefClass) and `x <- new("Class")` (S4) type `x`. Resolution of `x$method()` is asserted by tests for R6 only; the S4 `new()` binding is unit-tested only.
|
||||
- roxygen2 `@param` types in a restricted form: the description is the type alone (`#' @param name Type`) or starts with it in braces (`#' @param name {Type} prose`), the type is an R6, S4 or RefClass class defined in the same file, and the name is a formal of the function the block documents. The type is bound inside that function only. Prose that begins with a capitalised word binds nothing. Other roxygen2 tags (`@return`, `@field`, `@inheritParams`, `@rdname`, `@noRd`, `@export`) are not read.
|
||||
|
||||
Imports:
|
||||
|
||||
- `library()` and `require()` resolve to local packages, and `source()` to local files. The argument is `package=` / `file=` wherever it sits, else the first unnamed argument after any named one; named-first arguments such as `lib.loc=` and `local=` are not imports.
|
||||
- NAMESPACE `importFrom()` names bind to definitions in local packages. A name that can never bind (not exported, not defined at top level, or dotted) creates no import, so its calls keep the `global-name-fallback` guess.
|
||||
|
||||
Calls:
|
||||
|
||||
- Free calls, `obj$method()` calls (receiver typed as above), R6 `self$method()` calls, and calls inside native pipe (`|>`) chains.
|
||||
- Named nested functions (closures) are indexed, and calls inside attribute to the nested function, which can shadow a top-level name. Top-level script calls outside any function are attributed to the File node.
|
||||
- `pkg::fn()` and `pkg:::fn()`, decided by the qualifier (see [Resolution model](#resolution-model)).
|
||||
|
||||
Heritage:
|
||||
|
||||
- R6 `inherit=` and S4 `contains=` (a single class or `c(...)`). `contains = "VIRTUAL"` links to one placeholder class named `VIRTUAL`.
|
||||
|
||||
Exports:
|
||||
|
||||
- Read from each local package's NAMESPACE: `export()`, `exportClasses()`, `exportMethods()`, `S3method()` and `exportPattern()`. `S3method(generic, class, method)` exports `method`, the function R registers, else `generic.class`. A `DESCRIPTION` file with a `Package:` line marks a package root.
|
||||
|
||||
## Resolution model
|
||||
|
||||
The scope query (`languages/r/query.ts`) captures three call shapes only: `f(x)`, `pkg::f(x)` and `obj$f(x)`.
|
||||
|
||||
- A bare call binds lexically first. `library()` and `require()` of a local package make every top-level name of that package visible, unfiltered by NAMESPACE. `source()` binds no name; a call into a sourced file, or into another file of the same package, binds through a workspace-wide unique-name fallback at confidence 0.5 (`global-name-fallback`). An ambiguous name yields no edge.
|
||||
- A bare call never binds to a dotted definition, so `foo(x)` cannot reach `print.foo`. Dotted (S3-style) names are never import-resolved; a call that spells the full dotted name binds by qualified-name lookup. Dotted names are not bound through `importFrom()`.
|
||||
- `pkg::name()` is decided by its qualifier:
|
||||
- `pkg` is a package of the repository and defines `name` exactly once: the call binds to that definition at confidence 0.85 (`import-resolved`).
|
||||
- `pkg` is certainly not a repository package (discovery completed and found no such package, including a repository with no `DESCRIPTION`): no edge.
|
||||
- `pkg` defines `name` more than once: no edge.
|
||||
- Discovery may have missed a package (its limits were reached): the call keeps the name-based behaviour.
|
||||
- `::` and `:::` are not distinguished, so `pkg::name` binds to a definition whether or not the package exports it.
|
||||
- Package discovery is bounded to depth 3 and 200 directories (when the bound may have hidden a package this is flagged internally and nothing is logged), reads directories in code-unit order, ranks duplicate package names by shallowest then by whole path, and never searches directories named exactly `renv`, `packrat`, `revdep` or `<name>.Rcheck`. The shared file scanner has no such ignore entries, so `.R` files inside those trees are still parsed even though discovery skips them.
|
||||
- Export status: the language-level export check treats every symbol as public. The NAMESPACE pass then marks the remaining `R/` symbols of a NAMESPACE-bearing package non-exported. Symbols outside `R/` (`tests/`, `vignettes/`, `inst/`) are not package members and stay public; files in `R/` subdirectories are treated as `R/` files although R itself does not load them. A package without a NAMESPACE stays fully public, and a nested package without one does not inherit its enclosing package's. roxygen2 `@export` tags are not consulted.
|
||||
- `exportPattern()` text comes from the analysed repository, so patterns are compiled to a linear-time automaton (`linear-regex.ts`) with a work budget. Constructs with no linear-time algorithm (back-references, look-around) and oversized patterns are dropped with one logged warning per pattern per package. Character classes use ASCII semantics.
|
||||
|
||||
## Limits and known gaps
|
||||
|
||||
Not edges or nodes:
|
||||
|
||||
- A function passed as a value (`lapply(x, f)`, `Map`, `lapply(x, pkg::f)`), dynamic calls (`do.call("f", ...)`, `match.fun()`, `get()`) and dispatch through a registry (`handlers[["a"]]()`) create no CALLS edge, because only the three call shapes above are captured; the cross-language callable-value analysis classifies R as not applicable.
|
||||
- R6 `super$method()` and `private$method()` calls, S4 slot access (`obj@slot`), and `UseMethod()` / `NextMethod()` create no edge.
|
||||
- Anonymous functions (`function(x)`, `\(x)`) passed as arguments are not indexed as definitions; calls inside are attributed to the enclosing function, or to the File at top level.
|
||||
- Infix operators (`a %op% b`) are not linked to their definitions.
|
||||
- Constants and other top-level non-function assignments, and environments (`new.env()`, `local()`, `assign()` / `get()`), are not nodes; the only value-like nodes are R6, RefClass and S4 fields and slots. `setValidity()`, `setClassUnion()`, `setReplaceMethod()` and `Class$methods(...)` create no definition or edge.
|
||||
- No framework or entry-point detection (Shiny, plumber, targets, testthat). Plumber `#*` annotations are not read and no route nodes are created, and no route or fetch edges link R to other languages.
|
||||
|
||||
Not supported:
|
||||
|
||||
- The magrittr pipe `%>%` has no specific support and none is claimed; the native pipe `|>` is supported. tidyverse non-standard evaluation (bare column names, `.data$x`) is not modelled.
|
||||
- `.Rmd` and `.qmd` files are not indexed (the provider handles `.r` and `.R` only; `.Rprofile` and `.Renviron` are not indexed either), and `box::use()`, `import::from()`, `loadNamespace()`, `attachNamespace()` and `sys.source()` imports are not read.
|
||||
- S3 dispatch is not resolved: S3 methods are indexed as functions only. S4 and RefClass dispatch is not resolved either; there are definitions and heritage, not dispatch edges.
|
||||
- NAMESPACE `import()`, `importClassesFrom()` and `importMethodsFrom()` are not read. Imports of external (CRAN) packages produce no edges.
|
||||
- S4 `contains=` links only to classes defined in the repository or the `VIRTUAL` marker; basic-type bases such as `"numeric"` add no edge.
|
||||
- Package discovery is bounded (depth 3, 200 directories), so a package beyond the bound is treated like an unknown one. A package found only under `renv`, `packrat`, `revdep` or `<name>.Rcheck` is not a repository package.
|
||||
- A `pkg::name` whose package does not itself define `name` (for example a re-export) can still bind to a same-file or `importFrom` candidate, a `::` to a name the package does not export still binds to its definition, and `pkg::name` used as a value is not qualifier-resolved. Call sites dropped for certainly-external packages or duplicate definitions leave no recorded outcome.
|
||||
- When discovery may have missed a package, a `pkg::name` call keeps the name-based behaviour even if the candidate sits in a different package.
|
||||
- Files in `R/` subdirectories are treated as package-owned, although R itself does not load them.
|
||||
- NAMESPACE is read from disk during analysis, but neither it nor `DESCRIPTION` is a parsed source file. After an edit to either alone, an incremental analyse (no `--force`) keeps stale manifest-derived data: the persisted `isExported` of symbols in unchanged `R/` files, a `CALLS` edge bound through an `importFrom()` whose target changed, and a `pkg::fn` edge after a `Package:` rename. A full analyse is correct and `analyze --force` recomputes it. This is a limitation of the shared incremental write path (only changed files are re-processed), not of R resolution; other language manifests that feed resolution can be affected in the same way. It is to be addressed in a follow-up.
|
||||
- Roxygen `@param` types live in the per-file type environment; no graph edge consumes them.
|
||||
- Argument matching follows R for named and reordered arguments but does not model partial argument names: `setClass(Cla = "D")`, `new(Cla = "E")`, `setRefClass(Cla = "A")` and `setMethod("show", sig = "Foo")` are not recognised, so no definition or owner is attached.
|
||||
- Members are read only from the named forms. The positional second argument of `setClass("A", representation(x = "numeric"))`, `R6Class("X", list(...))` and `setRefClass("A", list(...))` creates no field or slot nodes; use `slots=` / `representation=`, `public =` / `private =` / `active =` and `fields =`.
|
||||
- For `S3method(generic, class, method)` the positional third argument names the exported method. A named third argument such as `S3method(print, foo, method = impl)` is not read: R registers `impl`, but the export is recorded for `print.foo`.
|
||||
- A leading `]` in a bracket expression of a NAMESPACE `exportPattern()` (for example `[]a]` or `[^]a]`) is read with JavaScript RegExp semantics, not POSIX/TRE, unless the pattern also contains a POSIX class such as `[:digit:]`; R would treat that `]` as a literal member.
|
||||
|
||||
### Bounds on NAMESPACE exportPattern matching
|
||||
|
||||
- Matching work and NFA states are counted in deterministic work units, never wall-clock time, so the outcome is the same on every machine. A NAMESPACE file is still read in full.
|
||||
- Caps, in `linear-regex.ts`: `MAX_TOTAL_WORK` (40,000,000 state visits per matcher), `MAX_SHARED_WORK` (100,000,000 matching visits per load), `MAX_PACKAGE_WORK` (30,000,000 per package), `MAX_SHARED_STATES` (500,000 NFA states per load) and `MAX_PACKAGE_STATES` (100,000 per package); a single pattern is also capped by `MAX_PATTERN_LENGTH` (16,384 characters) and `MAX_STATES` (16,384).
|
||||
- A pattern that does not fit is dropped, exports nothing and is warned about: at most 50 patterns are named per package (`MAX_LISTED_DROPPED_PATTERNS`), followed by a summary line counting the rest.
|
||||
- The first pattern refused for lack of states closes that package's allowance, so later patterns in the package are refused without being parsed.
|
||||
- One hostile package cannot exhaust the load budget alone (it is limited to its own share), but several can (three, for matching work); packages processed after that degrade, with warnings. Which package degrades follows graph node order.
|
||||
- Explicit `export()` names are unaffected by these bounds.
|
||||
|
||||
## Type annotations
|
||||
|
||||
R has no inline type annotations, so the "Type Annotations" column of the language support table is not ticked for R. The roxygen2 `@param` extraction above is a comment-based fallback, in the same class as the JSDoc, PHPDoc and YARD fallbacks listed in type-resolution-system.md (Ruby's YARD support is likewise not ticked).
|
||||
|
||||
## Extending the provider
|
||||
|
||||
- Definition captures are in `R_QUERIES` (`tree-sitter-queries.ts`); scope, import, type-binding, heritage and reference captures are in `languages/r/query.ts`. Keep each declaration pattern and its matching `@scope.function` pattern in lockstep, as the comments in `query.ts` explain.
|
||||
- The captures for `setClass`, `setRefClass`, `setGeneric`, `setMethod` and for `library` / `require` / `source` are deliberately loose. The argument that names the definition or the import is chosen afterwards in `languages/r/naming-argument.ts`, because a query-level prefix that skips leading comments and named arguments is quadratic in the argument count.
|
||||
- Resolution wiring is in `languages/r/scope-resolver.ts`, registered in `SCOPE_RESOLVERS`. Package discovery and NAMESPACE parsing are in `package-config.ts` and `namespace-imports.ts`; qualified calls in `qualified-call.ts`; owner and export post-processing in `post-parse.ts` and `owner-hooks.ts`.
|
||||
- Changing what the scope query captures changes what cached parsed files contain, so raise `SCHEMA_BUMP`.
|
||||
- Shared code under `gitnexus/src/core/ingestion/` must not name languages; add behaviour through the provider hooks.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests are under `gitnexus/test/unit/` (`r-*.test.ts`, `type-env-r.test.ts` and `scope-resolution/r/`) and integration tests under `gitnexus/test/integration/resolvers/` (`r*.test.ts`), with `r-*` fixture projects in `gitnexus/test/fixtures/lang-resolution/`. They cover:
|
||||
|
||||
- definitions, owners and the naming-argument rules (`r-definition-anchors`, `r-owner-hooks`, `r-post-parse-owners`, `r-scope-naming-argument`, `r-s4-anchors`, `r-s4-formals`, `r-scope-args`);
|
||||
- NAMESPACE parsing, exports and `exportPattern()` safety and scale (`r-namespace-parser`, `r-namespace-exports`, `r-export-pattern*`, `r-export-status-*`);
|
||||
- package discovery and root-package imports (`r-package-discovery-*`, `r-import-root-package`, `r-namespace-imports`);
|
||||
- qualified calls, dotted S3 names and native pipes (`r-qualified-*`, `r-dotted-s3-collision`, `r-native-pipes` fixture in `r.test.ts`);
|
||||
- roxygen2 `@param` types (`type-env-r`, `r-roxygen-param`).
|
||||
|
|
@ -53,6 +53,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'],
|
||||
[SupportedLanguages.Zig]: ['.zig'],
|
||||
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
|
||||
|
|
@ -127,6 +128,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
|
|||
[SupportedLanguages.Swift]: 'swift',
|
||||
[SupportedLanguages.Dart]: 'dart',
|
||||
[SupportedLanguages.Vue]: 'typescript',
|
||||
[SupportedLanguages.R]: 'r',
|
||||
[SupportedLanguages.Cobol]: 'cobol',
|
||||
[SupportedLanguages.Zig]: 'zig',
|
||||
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export enum SupportedLanguages {
|
|||
Swift = 'swift',
|
||||
Dart = 'dart',
|
||||
Vue = 'vue',
|
||||
R = 'r',
|
||||
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
|
||||
Cobol = 'cobol',
|
||||
Zig = 'zig',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*
|
||||
* Added after Ring 1: zig enters as `experimental` (new language
|
||||
* integration; promotion to `production` is a separate governance PR).
|
||||
* Same for r, added later.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from '../languages.js';
|
||||
|
|
@ -47,6 +48,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
|
|||
[SupportedLanguages.Vue]: 'experimental',
|
||||
[SupportedLanguages.Cobol]: 'experimental',
|
||||
[SupportedLanguages.Zig]: 'experimental',
|
||||
[SupportedLanguages.R]: 'experimental',
|
||||
};
|
||||
|
||||
/** Convenience predicate: is this language gating Ring 4 retirement? */
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
|
|||
|
||||
## Supported Languages
|
||||
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig
|
||||
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig, R
|
||||
|
||||
### Language Feature Matrix
|
||||
|
||||
|
|
@ -482,9 +482,12 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
|
|||
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
|
||||
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
|
||||
| R (experimental) | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | — |
|
||||
|
||||
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
|
||||
|
||||
**R (experimental)** — `.r` / `.R` files, parsed with `@eagleoutice/tree-sitter-r`. Extracts functions, S4 classes, R6 classes and RefClass (`setRefClass`) classes with their methods and fields, and resolves `library()` / `require()` / `source()` imports to local packages and files, `obj$method()` and `pkg::fn()` calls, R6 `inherit=` / S4 `contains=` heritage and NAMESPACE-driven export status. Calls through function values, `do.call()`, infix operators and S3 dispatch are not linked, and `.Rmd` files, `box::use()` and external (CRAN) packages are not supported. See [docs/languages/r-provider.md](../docs/languages/r-provider.md) for the full coverage and limits.
|
||||
|
||||
## Agent Skills
|
||||
|
||||
GitNexus ships with skill files that teach AI agents how to use the tools effectively:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,8 +5,8 @@
|
|||
* — over ONE shared corpus so the arms are directly comparable. One arm per
|
||||
* registered language, plus a second `csharp` arm carrying csproj configs
|
||||
* (#2902), so there is one more arm than there are languages. The newest row
|
||||
* is `zig` (PR #1432), added the day its resolver registered — the inventory
|
||||
* arm below is what noticed it missing, which is the arm doing its job.
|
||||
* is `r`, which registered without a row and was reported by the inventory arm
|
||||
* below — the arm doing its job — after `zig` (PR #1432), which did the same.
|
||||
*
|
||||
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
|
||||
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
|
||||
|
|
@ -127,6 +127,19 @@
|
|||
* path-walking resolver alone and does not move when that config parsing
|
||||
* changes.
|
||||
*
|
||||
* - r: one resolver behind three import forms, and the arm builds all three
|
||||
* (see `uniqueTarget`): `library(pkg)` (a wildcard that resolves to every
|
||||
* file of the package's `R/`), `source("R/file.R")` (a side-effect import
|
||||
* answered by the suffix index after an exact-join miss) and a NAMESPACE
|
||||
* `importFrom(pkg, name)` (a named import that reads the parsed workspace,
|
||||
* spelled `pkg::name` in the corpus). It passes the package map a
|
||||
* `DESCRIPTION` scan yields and is a `CONTEXT_LANGS` member. Two of its
|
||||
* legs are O(files) per import rather than keyed (a local `library()` scans
|
||||
* the file list for the `R/` prefix; every `source()` path runs
|
||||
* `normalizedFileList.indexOf` before the index), which is why R reads the
|
||||
* highest scaling of the linear-budget languages; `_r_arm` in
|
||||
* baselines.json has the per-leg numbers.
|
||||
*
|
||||
* Two properties of the corpus are load-bearing and must not be "simplified":
|
||||
*
|
||||
* 1. **Most imports are unresolvable.** In real source the majority of imports
|
||||
|
|
@ -495,6 +508,7 @@ import { typescriptScopeResolver } from '../../src/core/ingestion/languages/type
|
|||
import { cScopeResolver } from '../../src/core/ingestion/languages/c/scope-resolver.ts';
|
||||
import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.ts';
|
||||
import { objectiveCScopeResolver } from '../../src/core/ingestion/languages/objective-c/scope-resolver.ts';
|
||||
import { rScopeResolver } from '../../src/core/ingestion/languages/r/scope-resolver.ts';
|
||||
// `SCOPE_RESOLVERS` is NOT imported here — see the inventory arm at the bottom,
|
||||
// which loads it dynamically. Statically it costs 6-10 s of module load
|
||||
// depending on the box (measured both ways there), because reaching the
|
||||
|
|
@ -606,6 +620,9 @@ const HEAP_BUDGETED = [
|
|||
'objc',
|
||||
// Lexical module lookup retains a workspace index over parsed Rust files.
|
||||
'rust',
|
||||
// R retains the shared workspace file index (57.26 MiB at 32000 files, ratio
|
||||
// 0.970 against the 1.25 budget), so it takes the full ceiling + floor + ratio.
|
||||
'r',
|
||||
];
|
||||
// javascript, typescript and vue were budgeted here until #2953 and are now
|
||||
// BOUNDED, which is a demotion in gate strength and a promotion in what the
|
||||
|
|
@ -634,7 +651,7 @@ const HEAP_BUDGETED = [
|
|||
* parameters cannot observe a context and are not listed, so their timed
|
||||
* numbers stay on the three-argument shape.
|
||||
*/
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp'];
|
||||
const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'rust', 'c', 'cpp', 'r'];
|
||||
|
||||
/**
|
||||
* Needs `node --expose-gc` to force collection for a clean delta; without it
|
||||
|
|
@ -767,6 +784,7 @@ const EXTENSION = {
|
|||
cpp: '.cpp',
|
||||
objc: '.m',
|
||||
zig: '.zig',
|
||||
r: '.R',
|
||||
};
|
||||
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
|
||||
* through `resolutionConfig` rather than through `allFilePaths` — see
|
||||
|
|
@ -899,6 +917,12 @@ function uniqueDir(lang, d, i) {
|
|||
// share the other unique arms take from a nested directory comes from the
|
||||
// target instead (see `uniqueTarget`).
|
||||
if (lang === 'zig') return `src/mod${d}`;
|
||||
// An R package keeps its sources in `<pkg>/R/` and nothing else in the
|
||||
// namespace, so one package per index with its files flat under `R/` is the
|
||||
// layout `library()` (every file of `<pkg>/R/`) and `importFrom()` (the file
|
||||
// defining one name) both address. Flat on purpose: R has no nested source
|
||||
// directories inside `R/`, so there is no `d % 7` nested slice to mint.
|
||||
if (lang === 'r') return `pkg${d}/R`;
|
||||
throw unwiredLanguage('uniqueDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -994,6 +1018,11 @@ function collideDir(lang, d, i) {
|
|||
// axis that CAN grow — `collideTarget` spells its imports up through the
|
||||
// tree and back down, ~4x the components of the unique arm.
|
||||
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
|
||||
// `svc{d}/R/mod{k}.R` in every package: the conventional flat `R/` directory
|
||||
// with a repeated basename per package, so the `source("R/mod{k}.R")` spelling
|
||||
// below is ambiguous across every package and its suffix lookup answers from
|
||||
// a bucket that grows with the corpus.
|
||||
if (lang === 'r') return `svc${d}/R`;
|
||||
throw unwiredLanguage('collideDir', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1048,7 +1077,8 @@ function buildFiles(lang, fileCount, pad, shape) {
|
|||
layout === 'vue' ||
|
||||
layout === 'python' ||
|
||||
layout === 'c' ||
|
||||
layout === 'cpp';
|
||||
layout === 'cpp' ||
|
||||
layout === 'r';
|
||||
const [fileStem, modStem] = PASCAL_CASE_FILES.has(layout) ? ['File', 'Mod'] : ['file', 'mod'];
|
||||
let stem =
|
||||
shape === 'collide' && collideStem ? `${modStem}${Math.floor(i / dirs)}` : `${fileStem}${i}`;
|
||||
|
|
@ -1169,6 +1199,58 @@ const kotlinProbeFile = (filePath, packageName, exportName) => {
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* An R file whose Module scope binds each of `names` to a top-level function,
|
||||
* which is exactly what `rFileTopLevel` reads — and ONLY that, because
|
||||
* `localDefs` also lists nested functions and R6 members and cannot answer "is
|
||||
* this defined at top level". The resolver reads the bindings, never the def's
|
||||
* other fields, so the rest of the shape is the inert `probeFile` one.
|
||||
*/
|
||||
const rProbeFile = (filePath, names) => {
|
||||
const base = probeFile(
|
||||
filePath,
|
||||
names.map((name) => ['Function', name]),
|
||||
);
|
||||
const moduleScope = `module:${filePath}`;
|
||||
return {
|
||||
...base,
|
||||
moduleScope,
|
||||
scopes: [
|
||||
{
|
||||
id: moduleScope,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
|
||||
filePath,
|
||||
bindings: new Map(
|
||||
base.localDefs.map((def) => [def.qualifiedName, [{ def, origin: 'local' }]]),
|
||||
),
|
||||
ownedDefs: base.localDefs,
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* What `loadRPackageConfig` reads from disk (one `DESCRIPTION` per package),
|
||||
* rebuilt from the corpus paths: every `<dir>/R/` is the `R/` directory of the
|
||||
* package named after its last component. `namespaceInfoByPackageDir` is empty
|
||||
* — no NAMESPACE file — so `importFrom()` exports are not filtered, which is
|
||||
* the leg that reads `parsedFiles` and is the one this arm exists to measure.
|
||||
*/
|
||||
const rPackageConfigFor = (files) => {
|
||||
const packages = new Map();
|
||||
for (const file of files) {
|
||||
const at = file.indexOf('/R/');
|
||||
if (at < 0) continue;
|
||||
const dir = file.slice(0, at);
|
||||
packages.set(dir.slice(dir.lastIndexOf('/') + 1), dir);
|
||||
}
|
||||
return { packages, namespaceInfoByPackageDir: new Map() };
|
||||
};
|
||||
|
||||
function javaBenchmarkPackage(filePath) {
|
||||
const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(`/${filePath}`)?.[1];
|
||||
if (uniquePackage !== undefined) return `com.example.${uniquePackage}`;
|
||||
|
|
@ -1217,6 +1299,17 @@ function buildParsedFiles(lang, files) {
|
|||
parsedFiles.push(kotlinProbeFile(filePath, kotlinBenchmarkPackage(filePath), stem));
|
||||
continue;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// One top-level function named for the file, plus an S3 method on it
|
||||
// (`file7.print`), which is a dotted top-level name: the resolver's
|
||||
// `dottedTails` guard reads it, and its tail (`print`) never equals a
|
||||
// name an import asks for, so the guard runs on every lookup and refuses
|
||||
// none.
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
|
||||
parsedFiles.push(rProbeFile(filePath, [stem, `${stem}.print`]));
|
||||
continue;
|
||||
}
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
|
||||
const parent = slash < 0 ? '' : filePath.slice(0, slash);
|
||||
|
|
@ -1446,6 +1539,32 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
|
|||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `../vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// Three import forms, one per leg of the resolver, in the shares R code
|
||||
// actually has them:
|
||||
// - `library(pkg)` — `pkg{d}` is a LOCAL package and resolves to every
|
||||
// file of its `R/` directory (a list); an external one (`dplyr`) is
|
||||
// refused by the wildcard guard before any lookup;
|
||||
// - `source("R/file{j}.R")` — the project-root spelling. The importer's
|
||||
// own directory does not hold `R/R/…`, so the exact-join probe misses
|
||||
// and the suffix index answers; an external one is a vendored path in
|
||||
// no package, which runs the whole cascade to null;
|
||||
// - `importFrom(pkg, name)` — spelled `pkg::name` in this corpus because
|
||||
// the import list is `[from, target]` pairs and `resolveOne` splits it
|
||||
// back (`::` is not legal in a package name, so the split is exact).
|
||||
// Resolves through the parsed workspace's top-level definitions; an
|
||||
// external package is not in the config and answers null.
|
||||
if (local) {
|
||||
const leg = (r >>> 3) % 3;
|
||||
if (leg === 0) return `pkg${d}`;
|
||||
if (leg === 1) return `R/file${j}.R`;
|
||||
return `pkg${j % dirs}::file${j}`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
|
||||
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
|
||||
return `ext${(r >>> 4) % 97}::tidy`;
|
||||
}
|
||||
throw unwiredLanguage('uniqueTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1679,6 +1798,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
|
||||
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
|
||||
}
|
||||
if (lang === 'r') {
|
||||
// The same three forms in the same proportions as the unique arm, so the
|
||||
// resolved count is identical by construction (asserted). The source
|
||||
// spelling is the one that collides: `R/mod{k}.R` matches the file of that
|
||||
// name in EVERY package, so the suffix lookup is ambiguous across `dirs`
|
||||
// packages — the project-root `source("R/utils.R")` shape a multi-package
|
||||
// repo really has. The external spellings are unchanged and deliberately
|
||||
// share no stem with a real file, or they would suffix-match and stop
|
||||
// being misses.
|
||||
const k = Math.floor(j / dirs);
|
||||
if (local) {
|
||||
const leg = (r >>> 3) % 3;
|
||||
if (leg === 0) return `svc${d}`;
|
||||
if (leg === 1) return `R/mod${k}.R`;
|
||||
return `svc${j % dirs}::mod${k}`;
|
||||
}
|
||||
const miss = (r >>> 3) % 3;
|
||||
if (miss === 0) return ['dplyr', 'ggplot2', 'data.table', 'stringr'][(r >>> 4) % 4];
|
||||
if (miss === 1) return `vendor${(r >>> 4) % 97}/R/missing.R`;
|
||||
return `ext${(r >>> 4) % 97}::tidy`;
|
||||
}
|
||||
throw unwiredLanguage('collideTarget', lang);
|
||||
}
|
||||
|
||||
|
|
@ -1780,7 +1920,12 @@ function newPass(lang, files, pad = 0) {
|
|||
restoreBenchmarkSideChannels(lang, parsedFiles);
|
||||
return {
|
||||
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
|
||||
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
|
||||
config:
|
||||
lang === 'php'
|
||||
? phpComposerConfigFor(pad)
|
||||
: lang === 'r'
|
||||
? rPackageConfigFor(files)
|
||||
: undefined,
|
||||
parsedFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -1966,6 +2111,33 @@ function resolveOne(lang, from, target, pass) {
|
|||
if (lang === 'objc') {
|
||||
return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
|
||||
}
|
||||
// The registered hook, because R's adapter is where its per-pass file index,
|
||||
// the wildcard guard and the `importFrom` branch all live. `parsedImport` is
|
||||
// rebuilt from the target's spelling exactly the way `interpretRImport` and
|
||||
// `populateRNamespaceImports` mint it: `library()` is a `wildcard`, `source()`
|
||||
// a `side-effect`, and a NAMESPACE `importFrom()` a `named` import whose
|
||||
// `targetRaw` is the package. Only the named form reads `parsedFiles`.
|
||||
if (lang === 'r') {
|
||||
const sep = target.indexOf('::');
|
||||
const parsedImport =
|
||||
sep >= 0
|
||||
? {
|
||||
kind: 'named',
|
||||
localName: target.slice(sep + 2),
|
||||
importedName: target.slice(sep + 2),
|
||||
targetRaw: target.slice(0, sep),
|
||||
}
|
||||
: target.endsWith('.R')
|
||||
? { kind: 'side-effect', targetRaw: target }
|
||||
: { kind: 'wildcard', targetRaw: target };
|
||||
return rScopeResolver.resolveImportTarget(
|
||||
parsedImport.targetRaw,
|
||||
from,
|
||||
allFilePaths,
|
||||
pass.config,
|
||||
contextFor(pass, parsedImport),
|
||||
);
|
||||
}
|
||||
if (lang === 'csharp' || lang === 'csharp_csproj') {
|
||||
return resolveCsharpImportTarget(
|
||||
{ kind: 'namespace', localName: '_', importedName: '_', targetRaw: target },
|
||||
|
|
@ -2223,6 +2395,9 @@ const HEAP_PROBE_TARGET = {
|
|||
typescript: 'vendor0/lib/missing',
|
||||
vue: 'vendor0/lib/Missing.vue',
|
||||
cpp: 'vendor0/missing.hpp',
|
||||
// A vendored `source()` path in no package: the exact-join probe misses and
|
||||
// the suffix cascade runs to the end, forcing the workspace file index.
|
||||
r: 'vendor0/R/missing.R',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -2447,6 +2622,24 @@ const CONTEXT_PROBE = {
|
|||
probeFile('include/util.hpp', []),
|
||||
],
|
||||
},
|
||||
/**
|
||||
* `importFrom(pkgA, f)` where `pkgA` holds `a.R` (defining `f`) and `b.R`
|
||||
* (defining `g`). With the parsed workspace the named import resolves to the
|
||||
* one file that defines `f`; without it the call is the context-free one,
|
||||
* which cannot tell a named import from `library(pkgA)` and answers every
|
||||
* file of the package's `R/`. Two distinct non-null answers, so a dropped
|
||||
* `parsedFiles` cannot look like a miss. `f` is spelled with the `pkg::name`
|
||||
* convention `resolveOne` splits.
|
||||
*/
|
||||
r: {
|
||||
from: 'app/R/main.R',
|
||||
target: 'pkgA::f',
|
||||
parsedFiles: [
|
||||
rProbeFile('app/R/main.R', ['main']),
|
||||
rProbeFile('pkgA/R/a.R', ['f']),
|
||||
rProbeFile('pkgA/R/b.R', ['g']),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Resolve the probe twice through `resolveOne` — once with the pass's parsed
|
||||
|
|
@ -2455,7 +2648,12 @@ const CONTEXT_PROBE = {
|
|||
function measureContext(lang) {
|
||||
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
|
||||
const config =
|
||||
lang === 'php'
|
||||
? phpComposerConfigFor(0)
|
||||
: lang === 'r'
|
||||
? rPackageConfigFor(parsedFiles.map((f) => f.filePath))
|
||||
: undefined;
|
||||
const answer = (files) => {
|
||||
restoreBenchmarkSideChannels(lang, files ?? []);
|
||||
const pass = { allFilePaths, config, parsedFiles: files };
|
||||
|
|
@ -2616,6 +2814,7 @@ const LANG_REGISTRY = {
|
|||
cpp: SupportedLanguages.CPlusPlus,
|
||||
objc: SupportedLanguages.ObjectiveC,
|
||||
zig: SupportedLanguages.Zig,
|
||||
r: SupportedLanguages.R,
|
||||
};
|
||||
const LANGS = Object.keys(LANG_REGISTRY);
|
||||
/**
|
||||
|
|
|
|||
22
gitnexus/package-lock.json
generated
22
gitnexus/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "gitnexus",
|
||||
"version": "1.6.12",
|
||||
"bundleDependencies": [
|
||||
"@eagleoutice/tree-sitter-r",
|
||||
"tree-sitter",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cpp",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@eagleoutice/tree-sitter-r": "1.1.2",
|
||||
"@ladybugdb/core": "0.21.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
|
|
@ -231,6 +233,26 @@
|
|||
"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,
|
||||
"inBundle": 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/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
"version": "node scripts/sync-plugin-manifests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eagleoutice/tree-sitter-r": "1.1.2",
|
||||
"@ladybugdb/core": "0.21.1",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
|
|
@ -143,6 +144,7 @@
|
|||
"node": "^22.18.0 || >=24.11.0"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@eagleoutice/tree-sitter-r",
|
||||
"tree-sitter",
|
||||
"tree-sitter-c-sharp",
|
||||
"tree-sitter-cpp",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const SAMPLES = {
|
|||
dart: 'void main() { print(1); }\n',
|
||||
kotlin: 'class A {\n fun m() {}\n}\n',
|
||||
zig: 'pub fn main() void {}\n',
|
||||
r: 'f <- function(x) x\n',
|
||||
};
|
||||
|
||||
async function check(installedDir) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const path = require('node:path');
|
|||
const RUNTIME = '0.25.1';
|
||||
// Upstream metadata is an input to validate, never the peer range we ship.
|
||||
const AUDITED_PEERS = new Map([
|
||||
['@eagleoutice/tree-sitter-r@1.1.2', '^0.21.0'],
|
||||
['tree-sitter-c@0.23.6', '^0.22.1'],
|
||||
['tree-sitter-cpp@0.23.4', '^0.21.1'],
|
||||
['tree-sitter-java@0.23.5', '^0.21.1'],
|
||||
|
|
@ -30,8 +31,10 @@ function prepareTreeSitterBundle(packageRoot = path.resolve(__dirname, '..')) {
|
|||
if (manifest.dependencies['tree-sitter'] !== RUNTIME) {
|
||||
throw new Error(`Tree-sitter bundle is only validated for runtime ${RUNTIME}`);
|
||||
}
|
||||
// A grammar is recognised by its package basename, so a scoped package such as
|
||||
// @eagleoutice/tree-sitter-r is held to the same bundling and pinning rules.
|
||||
const grammars = Object.keys(manifest.dependencies).filter(
|
||||
(name) => name === 'tree-sitter' || name.startsWith('tree-sitter-'),
|
||||
(name) => name === 'tree-sitter' || name.split('/').pop().startsWith('tree-sitter-'),
|
||||
);
|
||||
for (const name of grammars) {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -303,3 +303,10 @@ export function hasZigPubKeyword(declNode: SyntaxNode): boolean {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* R: every definition is exported by default. NAMESPACE-driven export
|
||||
* refinement lives in R's `postParse` hook (ExportChecker has no file-path
|
||||
* context; NAMESPACE detection needs it).
|
||||
*/
|
||||
export const rExportChecker: ExportChecker = (_node, _name) => true;
|
||||
|
|
|
|||
259
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal file
259
gitnexus/src/core/ingestion/field-extractors/r.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// 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 { isRNamingArgument } from '../languages/r/naming-argument.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;
|
||||
|
||||
// The class name is the `Class` argument (by name, else the first unnamed one).
|
||||
const ownerFqn = getRClassNameArgument(args);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The class name of a `setClass` / `setRefClass` argument list: the string value of
|
||||
* the argument that takes the `Class` formal. Uses the same selection as the scope
|
||||
* query and definition hook (`isRNamingArgument`), so `contains = "Base"` or
|
||||
* `representation(...)` ahead of `Class = "A"` never names the owner.
|
||||
*/
|
||||
export function getRClassNameArgument(args: SyntaxNode): string | null {
|
||||
for (let i = 0; i < args.namedChildCount; i++) {
|
||||
const arg = args.namedChild(i);
|
||||
if (!arg || arg.type !== 'argument') continue;
|
||||
if (!isRNamingArgument(arg, 'Class')) continue;
|
||||
const value = arg.childForFieldName('value');
|
||||
if (value?.type !== 'string') continue;
|
||||
const name = extractQuotedTypeName(value);
|
||||
if (name) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
return getRClassNameArgument(args);
|
||||
}
|
||||
|
||||
/** 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, '');
|
||||
}
|
||||
249
gitnexus/src/core/ingestion/import-resolvers/r.ts
Normal file
249
gitnexus/src/core/ingestion/import-resolvers/r.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* 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 { ParsedFile } from 'gitnexus-shared';
|
||||
import { rExportPatternMatches, type RPackageConfig } from '../languages/r/package-config.js';
|
||||
import {
|
||||
rFileTopLevel,
|
||||
rPackageDirForFile,
|
||||
type RFileTopLevel,
|
||||
} from '../languages/r/namespace-imports.js';
|
||||
import type { ImportResult, ResolveCtx } from './types.js';
|
||||
import type { ImportResolutionContext } from '../scope-resolution/contract/scope-resolver.js';
|
||||
import { getWorkspaceFileIndex } from './workspace-file-index.js';
|
||||
|
||||
/**
|
||||
* Low-level R import resolver (internal helper).
|
||||
* Resolves library/require/source paths to matching .R files.
|
||||
*
|
||||
* `normToRaw` (normalized path → first raw path with that spelling, built once per pass by
|
||||
* `getWorkspaceFileIndex`) answers the exact-path lookups of `source()` in O(1). Without it
|
||||
* they fall back to `indexOf` over `normalizedFileList`, which picks the same file (the first
|
||||
* in list order) but costs O(files) per `source()` import.
|
||||
*/
|
||||
export function resolveRImportInternal(
|
||||
filePath: string,
|
||||
rawImportPath: string,
|
||||
normalizedFileList: readonly string[],
|
||||
allFileList: readonly string[],
|
||||
rConfig: RPackageConfig | null,
|
||||
index?: SuffixIndex,
|
||||
normToRaw?: ReadonlyMap<string, string>,
|
||||
): string | string[] | null {
|
||||
const exactFile = (normalizedPath: string): string | undefined => {
|
||||
if (normToRaw !== undefined) return normToRaw.get(normalizedPath);
|
||||
const idx = normalizedFileList.indexOf(normalizedPath);
|
||||
return idx >= 0 ? allFileList[idx] : undefined;
|
||||
};
|
||||
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, '/');
|
||||
return exactFile(normalized) ?? null;
|
||||
}
|
||||
const dir = path.dirname(filePath);
|
||||
const resolved = path.join(dir, cleaned).replace(/\\/g, '/');
|
||||
const direct = exactFile(resolved);
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
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);
|
||||
// A root-level package has the directory '' (falsy), so test for absence, not truthiness.
|
||||
if (pkgDir !== undefined) {
|
||||
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,
|
||||
// The legacy `ResolveCtx` bundle never carried R's config; the live path is
|
||||
// `resolveRImportTarget` + `loadResolutionConfig`.
|
||||
null,
|
||||
ctx.index,
|
||||
);
|
||||
if (!resolved) return null;
|
||||
const files = Array.isArray(resolved) ? resolved : [resolved];
|
||||
return { kind: 'files', files };
|
||||
}
|
||||
|
||||
/** Per-package index of top-level definitions, built once per `parsedFiles` array. */
|
||||
interface RPackageDefIndex {
|
||||
/** Files of the package's `R/` directory, with what each defines at top level. */
|
||||
readonly files: ReadonlyMap<string, RFileTopLevel>;
|
||||
/** Top-level name → files (in `parsedFiles` order) that define it. */
|
||||
readonly definingFiles: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
const packageDefIndexes = new WeakMap<
|
||||
readonly ParsedFile[],
|
||||
WeakMap<RPackageConfig, ReadonlyMap<string, RPackageDefIndex>>
|
||||
>();
|
||||
|
||||
function packageDefIndex(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
rConfig: RPackageConfig,
|
||||
pkg: string,
|
||||
): RPackageDefIndex | undefined {
|
||||
let byConfig = packageDefIndexes.get(parsedFiles);
|
||||
if (byConfig === undefined) packageDefIndexes.set(parsedFiles, (byConfig = new WeakMap()));
|
||||
let byPackage = byConfig.get(rConfig);
|
||||
if (byPackage === undefined) {
|
||||
const files = new Map<string, Map<string, RFileTopLevel>>();
|
||||
const defining = new Map<string, Map<string, string[]>>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const location = rPackageDirForFile(parsed.filePath, rConfig);
|
||||
if (location === undefined) continue;
|
||||
const topLevel = rFileTopLevel(parsed);
|
||||
let pkgFiles = files.get(location.name);
|
||||
let pkgDefining = defining.get(location.name);
|
||||
if (pkgFiles === undefined || pkgDefining === undefined) {
|
||||
files.set(location.name, (pkgFiles = new Map()));
|
||||
defining.set(location.name, (pkgDefining = new Map()));
|
||||
}
|
||||
pkgFiles.set(parsed.filePath, topLevel);
|
||||
for (const name of topLevel.names) {
|
||||
const list = pkgDefining.get(name);
|
||||
if (list === undefined) pkgDefining.set(name, [parsed.filePath]);
|
||||
else list.push(parsed.filePath);
|
||||
}
|
||||
}
|
||||
const built = new Map<string, RPackageDefIndex>();
|
||||
for (const [name, pkgFiles] of files) {
|
||||
built.set(name, { files: pkgFiles, definingFiles: defining.get(name) ?? new Map() });
|
||||
}
|
||||
byConfig.set(rConfig, (byPackage = built));
|
||||
}
|
||||
return byPackage.get(pkg);
|
||||
}
|
||||
|
||||
const stripBackticks = (s: string): string => s.replace(/^`+|`+$/g, '');
|
||||
|
||||
/**
|
||||
* Resolve a NAMESPACE `importFrom(pkg, importedName)` to the `R/` file(s) of the
|
||||
* local package `pkg` that define `importedName` at top level, or `null`.
|
||||
*
|
||||
* `null` (no binding, no IMPORTS edge) when: `pkg` is not a local package
|
||||
* (external packages never suffix-match anything); `importedName` is dotted
|
||||
* (finalize keys defs by the text after the last `.`, so it can never bind);
|
||||
* the provider has a NAMESPACE that does not export the name; no file defines
|
||||
* it at top level; or a defining file also holds a dotted top-level def whose
|
||||
* tail equals `importedName` (finalize would index `print.tidy` under `tidy`
|
||||
* and bind the WRONG def — the guard is order-blind, so it also refuses the
|
||||
* layout where the bare def comes first). `null` degrades the call site to the
|
||||
* baseline `global-name-fallback` guess. Never builds a workspace file index.
|
||||
*/
|
||||
export function resolveRNamedImport(
|
||||
rConfig: RPackageConfig | null,
|
||||
targetRaw: string,
|
||||
importedName: string,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): readonly string[] | null {
|
||||
const pkg = targetRaw.replace(/^["']|["']$/g, '');
|
||||
const pkgDir = rConfig?.packages.get(pkg);
|
||||
if (rConfig === null || pkgDir === undefined) return null;
|
||||
|
||||
if (importedName.includes('.')) return null;
|
||||
|
||||
const info = rConfig.namespaceInfoByPackageDir.get(pkgDir);
|
||||
if (info !== undefined && info.hasNamespaceFile) {
|
||||
const exported =
|
||||
info.namedExports.has(importedName) ||
|
||||
[...info.namedExports].some((entry) => stripBackticks(entry) === importedName) ||
|
||||
rExportPatternMatches(info, importedName);
|
||||
if (!exported) return null;
|
||||
}
|
||||
|
||||
const index = packageDefIndex(parsedFiles, rConfig, pkg);
|
||||
const defining = index?.definingFiles.get(importedName);
|
||||
if (index === undefined || defining === undefined || defining.length === 0) return null;
|
||||
if (defining.some((file) => index.files.get(file)?.dottedTails.has(importedName) === true)) {
|
||||
return null;
|
||||
}
|
||||
return defining;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveImportTarget`-shaped adapter over
|
||||
* `resolveRImportInternal`. Two responsibilities live only here, not in the
|
||||
* internal resolver:
|
||||
*
|
||||
* - Memoization: `getWorkspaceFileIndex` derives `{ normalized, all, index }`
|
||||
* once per `allFilePaths` Set identity (see its own doc comment) and this
|
||||
* adapter calls it on every invocation instead of materializing fresh
|
||||
* arrays per import — the O(imports × files) regression PR #1918/#2911
|
||||
* exist to catch (`import-target-index-reuse.contract.test.ts`).
|
||||
* - Cross-language false-positive guard: a `library()`/`require()` package
|
||||
* name not found in `rConfig.packages` would otherwise fall through to
|
||||
* `resolveRImportInternal`'s generic suffix fallback — and `EXTENSIONS`
|
||||
* (`./utils.js`) has no `.R`/`.r` entry, so a bare R package reference can
|
||||
* suffix-match an unrelated same-named file in another language.
|
||||
* `context.parsedImport.kind === 'wildcard'` is exactly the
|
||||
* `library()`/`require()` shape (see `interpretRImport`), so the guard is
|
||||
* scoped to that kind only — `source()`'s own suffix-based file-path
|
||||
* fallback is unaffected.
|
||||
* - NAMESPACE `importFrom()` names (`kind: 'named'`, synthesised by
|
||||
* `populateRNamespaceImports`): resolved by {@link resolveRNamedImport}
|
||||
* before `getWorkspaceFileIndex`, so they build no file index.
|
||||
*/
|
||||
export function resolveRImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
resolutionConfig?: unknown,
|
||||
context?: ImportResolutionContext,
|
||||
): string | readonly string[] | null {
|
||||
const rConfig = (resolutionConfig as RPackageConfig | null | undefined) ?? null;
|
||||
|
||||
if (context?.parsedImport?.kind === 'wildcard') {
|
||||
const cleaned = targetRaw.replace(/^["']|["']$/g, '');
|
||||
if (rConfig === null || !rConfig.packages.has(cleaned)) return null;
|
||||
}
|
||||
|
||||
if (context?.parsedImport?.kind === 'named') {
|
||||
return resolveRNamedImport(
|
||||
rConfig,
|
||||
targetRaw,
|
||||
context.parsedImport.importedName,
|
||||
context.parsedFiles,
|
||||
);
|
||||
}
|
||||
|
||||
const { normalized, all, index, normToRaw } = getWorkspaceFileIndex(allFilePaths);
|
||||
return resolveRImportInternal(fromFile, targetRaw, normalized, all, rConfig, index, normToRaw);
|
||||
}
|
||||
|
|
@ -38,6 +38,8 @@ import type { ImportResolverFn } from './import-resolvers/types.js';
|
|||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import type { CfgVisitor } from './cfg/types.js';
|
||||
import type { GraphNode, NodeLabel, ParameterTypeClass, RelationshipType } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import type { MutableSemanticModel } from './model/semantic-model.js';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type {
|
||||
|
|
@ -233,6 +235,16 @@ export function shouldHarvestModuleConstants(
|
|||
return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a {@link LanguageProviderConfig.postParse} hook may read or mutate: the merged graph, the
|
||||
* populated semantic model, and the repository root. Hooks mutate `graph`/`model` in place.
|
||||
*/
|
||||
export interface PostParseContext {
|
||||
readonly graph: KnowledgeGraph;
|
||||
readonly model: MutableSemanticModel;
|
||||
readonly repoPath: string;
|
||||
}
|
||||
|
||||
interface LanguageProviderConfig {
|
||||
// ── Identity ──────────────────────────────────────────────────────
|
||||
readonly id: SupportedLanguages;
|
||||
|
|
@ -376,6 +388,16 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => { readonly name: string; readonly label: NodeLabel } | null;
|
||||
|
||||
/**
|
||||
* For languages whose member owner is not a syntactic ancestor container
|
||||
* (R: `R6Class(...)` / `setRefClass(...)` calls): return the node the
|
||||
* method/field extractors read members from, or `null`.
|
||||
*
|
||||
* Consulted by the enclosing-owner slot after the container walk and
|
||||
* `resolveFileTypeOwner` (same slot as the Zig file-owner).
|
||||
* Default: undefined. */
|
||||
readonly resolveMemberOwnerNode?: (definitionNode: SyntaxNode) => SyntaxNode | null;
|
||||
|
||||
/**
|
||||
* The type a CONTAINER node declares, when the language names it from its
|
||||
* context rather than from a name child of the node — a binding wrapper,
|
||||
|
|
@ -732,6 +754,20 @@ interface LanguageProviderConfig {
|
|||
*/
|
||||
readonly prepareRouteConstants?: (repo: RepoConstants) => void;
|
||||
|
||||
/**
|
||||
* Whole-graph work that can only run once every chunk is merged and before scope resolution
|
||||
* (deferred owner resolution, package-manifest-driven export refinement). "Post-parse" means after
|
||||
* all chunks are merged but still inside the parse phase — not the later `crossFile` phase.
|
||||
*
|
||||
* Called once per analyze, only if this language has parsed files, awaited, on the main thread.
|
||||
* Mutates `ctx.graph` / `ctx.model` in place. A throw aborts the parse phase (it is not isolated,
|
||||
* because the hook mutates shared state and a swallowed mid-mutation throw could leave the graph
|
||||
* half-updated).
|
||||
*
|
||||
* Default: undefined (nothing to do after the merge).
|
||||
*/
|
||||
readonly postParse?: (ctx: PostParseContext) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Spring async messaging facts captured for one file — the listener
|
||||
* annotations that subscribe to a broker destination and the template calls
|
||||
|
|
@ -1237,6 +1273,22 @@ export function prepareRouteConstantsByProvider(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run each present language's {@link LanguageProviderConfig.postParse} hook, sequentially and in
|
||||
* language-id order (independent of scan order), awaiting each before the next: the hooks share one
|
||||
* mutable graph/model. The provider lookup is a callback so this file needs no registry import.
|
||||
* A rejection propagates and later hooks do not run.
|
||||
*/
|
||||
export async function runPostParseHooks(
|
||||
presentLanguages: ReadonlySet<SupportedLanguages>,
|
||||
providerFor: (language: SupportedLanguages) => Pick<LanguageProvider, 'postParse'> | undefined,
|
||||
ctx: PostParseContext,
|
||||
): Promise<void> {
|
||||
for (const language of [...presentLanguages].sort()) {
|
||||
await providerFor(language)?.postParse?.(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULTS: Pick<LanguageProvider, 'mroStrategy'> = {
|
||||
mroStrategy: 'first-wins',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,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';
|
||||
import { zigProvider } from './zig.js';
|
||||
|
||||
|
|
@ -49,6 +50,7 @@ export const providers = {
|
|||
[SupportedLanguages.Swift]: swiftProvider,
|
||||
[SupportedLanguages.Dart]: dartProvider,
|
||||
[SupportedLanguages.Vue]: vueProvider,
|
||||
[SupportedLanguages.R]: rProvider,
|
||||
[SupportedLanguages.Cobol]: cobolProvider,
|
||||
[SupportedLanguages.Zig]: zigProvider,
|
||||
} satisfies Record<SupportedLanguages, LanguageProvider>;
|
||||
|
|
|
|||
195
gitnexus/src/core/ingestion/languages/r.ts
Normal file
195
gitnexus/src/core/ingestion/languages/r.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* 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';
|
||||
import { emitRScopeCaptures } from './r/captures.js';
|
||||
import { interpretRImport, interpretRTypeBinding } from './r/interpret.js';
|
||||
import { rPostParse } from './r/post-parse.js';
|
||||
import { rDefinitionProperties, rResolveMemberOwnerNode } from './r/owner-hooks.js';
|
||||
import { isRNonNamingArgumentCapture } from './r/naming-argument.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'],
|
||||
entryPointPatterns: [],
|
||||
astFrameworkPatterns: [],
|
||||
treeSitterQueries: R_QUERIES,
|
||||
typeConfig: rTypeConfig,
|
||||
exportChecker: rExportChecker,
|
||||
importResolver: resolveRImport,
|
||||
fieldExtractor: new RFieldExtractor(),
|
||||
methodExtractor: rMethodExtractor,
|
||||
builtInNames: R_BUILT_INS,
|
||||
emitScopeCaptures: emitRScopeCaptures,
|
||||
interpretImport: interpretRImport,
|
||||
interpretTypeBinding: interpretRTypeBinding,
|
||||
postParse: rPostParse,
|
||||
resolveMemberOwnerNode: rResolveMemberOwnerNode,
|
||||
definitionPropertiesExtractor: rDefinitionProperties,
|
||||
// R_QUERIES captures every string argument of setClass/setGeneric/setMethod;
|
||||
// only the one that names the definition is kept.
|
||||
shouldSkipDefinitionCapture: isRNonNamingArgumentCapture,
|
||||
});
|
||||
48
gitnexus/src/core/ingestion/languages/r/captures.ts
Normal file
48
gitnexus/src/core/ingestion/languages/r/captures.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture } from '../../utils/ast-helpers.js';
|
||||
import { getRParser, getRScopeQuery } from './query.js';
|
||||
import { isRNonNamingScopeMatch } from './naming-argument.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
|
||||
/**
|
||||
* Emit R scope captures — a single tree-sitter query run against the R
|
||||
* grammar (see `query.ts` for the capture shapes and the rationale for each
|
||||
* one). No programmatic enrichment is needed: unlike C/Zig/Rust, R's
|
||||
* declarations carry no synthesized arity/parameter metadata, and heritage
|
||||
* sites resolve directly by name (no marker encoding, unlike Ruby's
|
||||
* `include`/`extend`/`prepend`).
|
||||
*/
|
||||
export function emitRScopeCaptures(
|
||||
sourceText: string,
|
||||
filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getRParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getRParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
}
|
||||
|
||||
const rawMatches = getRScopeQuery().matches(tree.rootNode);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// The scope query captures every candidate argument of setClass/library/require/
|
||||
// source; keep only the one that names the class or import.
|
||||
if (isRNonNamingScopeMatch(m.captures)) continue;
|
||||
const grouped: Record<string, Capture> = {};
|
||||
let hasRealCapture = false;
|
||||
for (const c of m.captures) {
|
||||
const tag = '@' + c.name;
|
||||
if (tag.startsWith('@_')) continue;
|
||||
grouped[tag] = nodeToCapture(tag, c.node);
|
||||
hasRealCapture = true;
|
||||
}
|
||||
if (!hasRealCapture) continue;
|
||||
out.push(grouped);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
234
gitnexus/src/core/ingestion/languages/r/export-pattern.ts
Normal file
234
gitnexus/src/core/ingestion/languages/r/export-pattern.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { compileLinearRegexDetailed, type SharedWorkBudget } from './linear-regex.js';
|
||||
|
||||
/**
|
||||
* `exportPattern("...")` compilation for R NAMESPACE files.
|
||||
*
|
||||
* R evaluates `exportPattern()` arguments as POSIX (TRE) extended regular
|
||||
* expressions, whose bracket expressions support named classes such as
|
||||
* `[[:alpha:]]`. JavaScript's `RegExp` has no such classes: it reads
|
||||
* `[[:alpha:]]` as the set `[:alph` followed by a literal `]`. The RStudio
|
||||
* new-package template writes `exportPattern("^[[:alpha:]]+")`, so without a
|
||||
* translation step that pattern never matches anything.
|
||||
*
|
||||
* {@link compileRExportPattern} rewrites POSIX classes inside bracket
|
||||
* expressions to ASCII JS equivalents (the C-locale meaning of each class) and
|
||||
* compiles the result once. Patterns without `[:` are compiled verbatim, so
|
||||
* their behaviour is unchanged. It never throws.
|
||||
*
|
||||
* The compiled form is a linear-time matcher, not a `RegExp`: the pattern text comes
|
||||
* from the repository being analysed, and a backtracking engine lets a hostile
|
||||
* pattern such as `^(a+)+$` hang the analyzer (R itself uses the non-backtracking
|
||||
* TRE). See `linear-regex.ts` for the supported subset; a pattern outside it
|
||||
* compiles to null and therefore matches nothing. Callers that care why use
|
||||
* {@link compileRExportPatternDetailed}, so that a dropped pattern is reported
|
||||
* rather than silently treated as an empty export list.
|
||||
*
|
||||
* The argument reaches {@link compileRExportPattern} as an R string *value*:
|
||||
* {@link unescapeRString} first turns the NAMESPACE source text `"\\."` (the
|
||||
* R string value `\.`) into the regex `\.`. Callers that read a pattern out
|
||||
* of NAMESPACE text must unescape before compiling.
|
||||
*/
|
||||
|
||||
const POSIX_CLASSES: Readonly<Record<string, string>> = {
|
||||
alpha: 'A-Za-z',
|
||||
alnum: 'A-Za-z0-9',
|
||||
digit: '0-9',
|
||||
upper: 'A-Z',
|
||||
lower: 'a-z',
|
||||
punct: '!-\\/:-@\\[-`{-~',
|
||||
space: ' \\t\\n\\r\\f\\v',
|
||||
blank: ' \\t',
|
||||
cntrl: '\\x00-\\x1f\\x7f',
|
||||
print: '\\x20-\\x7e',
|
||||
graph: '\\x21-\\x7e',
|
||||
xdigit: '0-9A-Fa-f',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate one bracket expression starting at `src[start] === '['`.
|
||||
* Returns the JS equivalent and the index just past its closing `]`, or null
|
||||
* when the expression is unterminated or names an unknown class.
|
||||
*/
|
||||
function translateBracket(src: string, start: number): { text: string; end: number } | null {
|
||||
let i = start + 1;
|
||||
let out = '[';
|
||||
if (src[i] === '^') {
|
||||
out += '^';
|
||||
i++;
|
||||
}
|
||||
// A `]` in first position is a literal member, not the terminator.
|
||||
if (src[i] === ']') {
|
||||
out += '\\]';
|
||||
i++;
|
||||
}
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === ']') return { text: out + ']', end: i + 1 };
|
||||
if (ch === '[' && src[i + 1] === ':') {
|
||||
const close = src.indexOf(':]', i + 2);
|
||||
if (close === -1) return null;
|
||||
const expansion = POSIX_CLASSES[src.slice(i + 2, close)];
|
||||
if (expansion === undefined) return null;
|
||||
out += expansion;
|
||||
i = close + 2;
|
||||
} else if (ch === '\\' && i + 1 < src.length) {
|
||||
out += ch + src[i + 1];
|
||||
i += 2;
|
||||
} else if (ch === '[' || ch === '^') {
|
||||
out += '\\' + ch;
|
||||
i++;
|
||||
} else {
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function translatePosixClasses(src: string): string | null {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === '\\' && i + 1 < src.length) {
|
||||
out += ch + src[i + 1];
|
||||
i += 2;
|
||||
} else if (ch === '[') {
|
||||
const bracket = translateBracket(src, i);
|
||||
if (bracket === null) return null;
|
||||
out += bracket.text;
|
||||
i = bracket.end;
|
||||
} else {
|
||||
out += ch;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A compiled `exportPattern()`; `test` answers whether the pattern matches anywhere in `name`. */
|
||||
export interface RExportMatcher {
|
||||
/** The pattern text that was compiled (after POSIX-class translation). */
|
||||
readonly source: string;
|
||||
test(name: string): boolean;
|
||||
/**
|
||||
* True once the matcher stopped answering because it spent its work budget (its own, or
|
||||
* the one shared by a package-config load); names it had not yet decided were then
|
||||
* treated as not matching. Absent on matchers that
|
||||
* cannot run out (a plain `RegExp`).
|
||||
*/
|
||||
readonly exhausted?: boolean;
|
||||
}
|
||||
|
||||
/** The outcome of {@link compileRExportPatternDetailed}: a matcher, or why there is none. */
|
||||
export type RExportPatternResult =
|
||||
| { readonly matcher: RExportMatcher }
|
||||
| { readonly matcher: null; readonly reason: string };
|
||||
|
||||
/**
|
||||
* Compile an `exportPattern()` argument, saying why when it cannot be compiled safely.
|
||||
* Matchers compiled with the same `shared` budget stop together once it is spent (see
|
||||
* `linear-regex.ts`); a package-config load passes one so that the number of patterns a
|
||||
* repository carries cannot multiply the per-matcher cost.
|
||||
*/
|
||||
export function compileRExportPatternDetailed(
|
||||
source: string,
|
||||
shared?: SharedWorkBudget,
|
||||
): RExportPatternResult {
|
||||
try {
|
||||
let translated = source;
|
||||
if (source.includes('[:')) {
|
||||
const posix = translatePosixClasses(source);
|
||||
if (posix === null) {
|
||||
return {
|
||||
matcher: null,
|
||||
reason: 'unterminated bracket expression or unknown POSIX class',
|
||||
};
|
||||
}
|
||||
translated = posix;
|
||||
}
|
||||
const result = compileLinearRegexDetailed(translated, shared);
|
||||
return 'reason' in result
|
||||
? { matcher: null, reason: result.reason }
|
||||
: { matcher: result.regex };
|
||||
} catch {
|
||||
return { matcher: null, reason: 'pattern could not be compiled' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile an `exportPattern()` argument; null when it cannot be compiled safely. */
|
||||
export function compileRExportPattern(
|
||||
source: string,
|
||||
shared?: SharedWorkBudget,
|
||||
): RExportMatcher | null {
|
||||
return compileRExportPatternDetailed(source, shared).matcher;
|
||||
}
|
||||
|
||||
const SIMPLE_R_ESCAPES: Readonly<Record<string, string>> = {
|
||||
n: '\n',
|
||||
t: '\t',
|
||||
r: '\r',
|
||||
a: '\x07',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
v: '\v',
|
||||
'\\': '\\',
|
||||
'"': '"',
|
||||
"'": "'",
|
||||
'`': '`',
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
/**
|
||||
* Turn the body of an R string literal (the text between the quotes) into the
|
||||
* string value R would hold: `\\` -> `\`, `\"` -> `"`, `\n` -> newline, and
|
||||
* likewise `\t \r \a \b \f \v \' \``, backslash-space, `\xHH`,
|
||||
* `\uXXXX` / `\u{X..}` and `\UXXXXXXXX` / `\U{X..}` (code points). R
|
||||
* rejects any other backslash sequence; this leniently keeps it verbatim, so a
|
||||
* mistaken `"\."` still reaches the regex compiler as `\.`. Never throws.
|
||||
*/
|
||||
export function unescapeRString(body: string): string {
|
||||
if (!body.includes('\\')) return body;
|
||||
let out = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if (ch !== '\\' || i + 1 >= body.length) {
|
||||
out += ch;
|
||||
continue;
|
||||
}
|
||||
const next = body[i + 1];
|
||||
const simple = SIMPLE_R_ESCAPES[next];
|
||||
if (simple !== undefined) {
|
||||
out += simple;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const hex = readHexEscape(body, i + 1);
|
||||
if (hex !== null) {
|
||||
out += hex.text;
|
||||
i = hex.end - 1;
|
||||
continue;
|
||||
}
|
||||
out += ch; // Unknown escape: keep the backslash; the next char is emitted normally.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `x`/`u`/`U` escape whose introducer is at `body[at]`; null when malformed. */
|
||||
function readHexEscape(body: string, at: number): { text: string; end: number } | null {
|
||||
const kind = body[at];
|
||||
const maxDigits = kind === 'x' ? 2 : kind === 'u' ? 4 : kind === 'U' ? 8 : 0;
|
||||
if (maxDigits === 0) return null;
|
||||
let i = at + 1;
|
||||
const braced = kind !== 'x' && body[i] === '{';
|
||||
if (braced) i++;
|
||||
const start = i;
|
||||
while (i < body.length && i - start < maxDigits && /[0-9A-Fa-f]/.test(body[i])) i++;
|
||||
if (i === start) return null;
|
||||
if (braced) {
|
||||
if (body[i] !== '}') return null;
|
||||
}
|
||||
const code = parseInt(body.slice(start, i), 16);
|
||||
if (code > 0x10ffff) return null;
|
||||
return { text: String.fromCodePoint(code), end: braced ? i + 1 : i };
|
||||
}
|
||||
40
gitnexus/src/core/ingestion/languages/r/interpret.ts
Normal file
40
gitnexus/src/core/ingestion/languages/r/interpret.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* `source()` is a side-effect import — it establishes a file dependency but
|
||||
* binds no name (cross-file free calls into a `source()`d file resolve via
|
||||
* `rScopeResolver.allowGlobalFreeCallFallback` instead).
|
||||
*
|
||||
* `library()`/`require()` are wildcard imports — every top-level name of the
|
||||
* named local package becomes visible (`rProvider.expandsWildcardTo`).
|
||||
*
|
||||
* `resolveRImportTarget` (`import-resolvers/r.ts`) guards on
|
||||
* `context.parsedImport.kind === 'wildcard'` specifically to refuse a
|
||||
* `library()`/`require()` package name that isn't a known local package
|
||||
* (rather than falling through to a cross-language suffix-match false
|
||||
* positive) — the two kinds set here are what that guard keys on.
|
||||
*/
|
||||
export function interpretRImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const source = captures['@import.source']?.text;
|
||||
if (source === undefined || source.length === 0) return null;
|
||||
|
||||
if (captures['@import.wildcard'] !== undefined) {
|
||||
return { kind: 'wildcard', targetRaw: source };
|
||||
}
|
||||
return { kind: 'side-effect', targetRaw: source };
|
||||
}
|
||||
|
||||
/**
|
||||
* `x <- Type$new(...)` — R6's constructor spelling. `@type-binding.type` is
|
||||
* the `$` receiver (the class name), captured only when the extract
|
||||
* operator's rhs is literally `new` (see `query.ts`), so this always
|
||||
* resolves via the shared `constructor-inferred` sub-kind — the same one
|
||||
* Zig's `interpretZigTypeBinding` uses for `Counter.init()`.
|
||||
*/
|
||||
export function interpretRTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const name = captures['@type-binding.name']?.text;
|
||||
const type = captures['@type-binding.type']?.text;
|
||||
if (name === undefined || type === undefined) return null;
|
||||
|
||||
return { boundName: name, rawTypeName: type, source: 'constructor-inferred' };
|
||||
}
|
||||
1041
gitnexus/src/core/ingestion/languages/r/linear-regex.ts
Normal file
1041
gitnexus/src/core/ingestion/languages/r/linear-regex.ts
Normal file
File diff suppressed because it is too large
Load diff
798
gitnexus/src/core/ingestion/languages/r/namespace-imports.ts
Normal file
798
gitnexus/src/core/ingestion/languages/r/namespace-imports.ts
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
/**
|
||||
* NAMESPACE `importFrom()` parsing for R packages.
|
||||
*
|
||||
* `parseRNamespaceImportFrom` is a pure, package-blind tokenizer: it reads the
|
||||
* text of one NAMESPACE file and returns every `importFrom(pkg, name, ...)`
|
||||
* pair in file order. It deliberately returns *all* entries — including a
|
||||
* package importing from itself and duplicate names from different packages —
|
||||
* because those policy decisions (own-package drop, last-wins, external-package
|
||||
* skip) belong to the consumer that knows the workspace, not to the parser.
|
||||
*
|
||||
* The tokenizer ({@link scanRNamespaceDirectives}) is generic (directive head +
|
||||
* balanced argument list across newlines, honouring `"…"`, `'…'` and backtick
|
||||
* quoting, `#` comments stripped outside quotes). Two consumers sit on it:
|
||||
* {@link parseRNamespaceImportFrom} (`importFrom` only; `import`,
|
||||
* `importClassesFrom` and `importMethodsFrom` are deferred) and
|
||||
* {@link parseRNamespaceExports} (`export`, `exportClasses`, `exportMethods`,
|
||||
* `S3method`, `exportPattern`). `useDynLib` and anything under an
|
||||
* `if (…)` / `else` conditional are ignored. Unbalanced or garbled input never
|
||||
* throws: a malformed directive is discarded and scanning RESYNCHRONISES at the
|
||||
* next line that begins, at column 0, with `identifier(` (see
|
||||
* {@link resyncAfterMalformed}); everything parsed before and after is kept.
|
||||
* Quoted strings never span lines, and a column-0 `identifier(` line ends any
|
||||
* argument list still open, so a broken directive cannot swallow those after it.
|
||||
*
|
||||
* Runtime import direction is `language-config.ts` → this file; any type from
|
||||
* `language-config.ts` must be taken with `import type` only, so no runtime
|
||||
* import cycle forms.
|
||||
*
|
||||
* The second half of the file binds those entries to workspace definitions:
|
||||
* {@link populateRNamespaceImports} synthesises `named` imports for
|
||||
* `importFrom()` names that a *local* package provides, and
|
||||
* {@link resolveRImportTarget}'s `named` branch (`import-resolvers/r.ts`)
|
||||
* resolves them using {@link rFileTopLevel} / {@link rPackageDirForFile}.
|
||||
*
|
||||
* The file also hosts the pieces of `pkg::name()` qualifier handling that the
|
||||
* global-name-fallback veto ({@link isRGlobalNameFallbackPlausible}) shares
|
||||
* with `qualified-call.ts`: {@link parseRQualifier} and the per-package
|
||||
* definition-count registry. The qualified-call decisions themselves live in
|
||||
* `qualified-call.ts`.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ParsedImport } from 'gitnexus-shared';
|
||||
import type { RPackageConfig } from './package-config.js';
|
||||
import { unescapeRString } from './export-pattern.js';
|
||||
import { resolveRNamedImport } from '../../import-resolvers/r.js';
|
||||
|
||||
/** One `importFrom(pkg, name)` pair, in NAMESPACE file order. */
|
||||
export interface RNamespaceImportFromEntry {
|
||||
readonly pkg: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const IMPORT_FROM_DIRECTIVES: ReadonlySet<string> = new Set(['importFrom']);
|
||||
|
||||
/** Directives whose arguments name exported symbols (`S3method` is handled separately). */
|
||||
const EXPORT_NAME_DIRECTIVES: ReadonlySet<string> = new Set([
|
||||
'export',
|
||||
'exportClasses',
|
||||
'exportMethods',
|
||||
]);
|
||||
|
||||
const EXPORT_DIRECTIVES: ReadonlySet<string> = new Set([
|
||||
...EXPORT_NAME_DIRECTIVES,
|
||||
'S3method',
|
||||
'exportPattern',
|
||||
]);
|
||||
|
||||
/** Directives whose following statement is conditional and therefore ignored. */
|
||||
const CONDITIONAL_HEADS: ReadonlySet<string> = new Set(['if', 'else']);
|
||||
|
||||
const isIdentStart = (ch: string): boolean => /[A-Za-z_.]/.test(ch);
|
||||
const isIdentPart = (ch: string): boolean => /[A-Za-z0-9_.]/.test(ch);
|
||||
const isQuote = (ch: string): boolean => ch === '"' || ch === "'" || ch === '`';
|
||||
|
||||
/**
|
||||
* Advance past a quoted region starting at `start` (which holds the opening
|
||||
* quote). Returns the index just after the closing quote, or `-1` when the
|
||||
* quote does not close on the same line. Backslash escapes the next character
|
||||
* (R string rules; harmless inside backticks). Quotes never span lines: a
|
||||
* NAMESPACE directive argument is a one-line token, and letting a stray quote
|
||||
* run on would pair with a later one and swallow the directives between them.
|
||||
*/
|
||||
function skipQuoted(text: string, start: number): number {
|
||||
const quote = text[start];
|
||||
for (let i = start + 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '\n') return -1;
|
||||
if (ch === '\\') {
|
||||
i++;
|
||||
if (text[i] === '\n') return -1;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) return i + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function skipLineComment(text: string, start: number): number {
|
||||
const nl = text.indexOf('\n', start);
|
||||
return nl === -1 ? text.length : nl + 1;
|
||||
}
|
||||
|
||||
/** A directive-looking line start: `identifier(` (no leading whitespace, no space before `(`). */
|
||||
const DIRECTIVE_LINE_START = /[A-Za-z_.][A-Za-z0-9_.]*\(/y;
|
||||
|
||||
/** True when `i` is the start of a line (column 0) that begins with `identifier(`. */
|
||||
function isDirectiveLineStart(text: string, i: number): boolean {
|
||||
if (i > 0 && text[i - 1] !== '\n') return false;
|
||||
DIRECTIVE_LINE_START.lastIndex = i;
|
||||
return DIRECTIVE_LINE_START.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resynchronisation point after a malformed directive that began at `from`: the
|
||||
* start of the first line AFTER the line containing `from` that begins, at
|
||||
* column 0, with `identifier(`; `text.length` when there is none. The result
|
||||
* is always greater than `from` (or the end of text), so callers make forward
|
||||
* progress and each failure costs at most one pass over the text it skips.
|
||||
*/
|
||||
function resyncAfterMalformed(text: string, from: number): number {
|
||||
let nl = text.indexOf('\n', from);
|
||||
while (nl !== -1) {
|
||||
const lineStart = nl + 1;
|
||||
if (isDirectiveLineStart(text, lineStart)) return lineStart;
|
||||
nl = text.indexOf('\n', lineStart);
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a balanced `( … )` group whose opening paren is at `openIdx`. Returns
|
||||
* the comma-separated top-level arguments (raw, comment-stripped, unquoted only
|
||||
* at the outer layer) and the index just after the closing paren, or `null`
|
||||
* when the group is unbalanced, holds an unterminated quote, or is still open
|
||||
* when a line starting `identifier(` at column 0 begins (which bounds the scan,
|
||||
* so an unbalanced group cannot run to the end of the file).
|
||||
*/
|
||||
function parseArgs(text: string, openIdx: number): { args: string[]; end: number } | null {
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let depth = 0;
|
||||
for (let i = openIdx; i < text.length; ) {
|
||||
if (i > openIdx && isDirectiveLineStart(text, i)) return null;
|
||||
const ch = text[i];
|
||||
if (ch === '#') {
|
||||
i = skipLineComment(text, i);
|
||||
continue;
|
||||
}
|
||||
if (isQuote(ch)) {
|
||||
const end = skipQuoted(text, i);
|
||||
if (end === -1) return null;
|
||||
current += text.slice(i, end);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
depth++;
|
||||
if (depth > 1) current += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
args.push(current);
|
||||
return { args, end: i + 1 };
|
||||
}
|
||||
current += ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && depth === 1) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip whitespace and one layer of surrounding quotes/backticks from a raw
|
||||
* argument. Returns `''` for empty or non-simple arguments (unterminated quote,
|
||||
* or anything that is not a single bare/quoted token) so callers can skip them.
|
||||
*/
|
||||
function normaliseArg(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
const first = trimmed[0];
|
||||
if (isQuote(first)) {
|
||||
if (trimmed.length < 2 || trimmed[trimmed.length - 1] !== first) return '';
|
||||
const end = skipQuoted(trimmed, 0);
|
||||
if (end !== trimmed.length) return '';
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
// Bare token: reject anything containing whitespace, quotes, or parens (e.g. `x = y`, nested calls).
|
||||
if (/[\s"'`()=]/.test(trimmed)) return '';
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip one statement following an `if (…)` / `else`: either a `{ … }` block or a
|
||||
* single directive call. Returns the index to resume scanning from.
|
||||
*/
|
||||
function skipConditionalBody(text: string, from: number): number {
|
||||
let i = from;
|
||||
while (i < text.length && /\s/.test(text[i])) i++;
|
||||
if (i >= text.length) return i;
|
||||
if (text[i] === '{') {
|
||||
const blockStart = i;
|
||||
let depth = 0;
|
||||
for (; i < text.length; ) {
|
||||
const ch = text[i];
|
||||
if (ch === '#') {
|
||||
i = skipLineComment(text, i);
|
||||
continue;
|
||||
}
|
||||
if (isQuote(ch)) {
|
||||
const end = skipQuoted(text, i);
|
||||
if (end === -1) return resyncAfterMalformed(text, blockStart);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return resyncAfterMalformed(text, blockStart); // unterminated `{ … }`
|
||||
}
|
||||
if (isIdentStart(text[i])) {
|
||||
let j = i + 1;
|
||||
while (j < text.length && isIdentPart(text[j])) j++;
|
||||
const head = text.slice(i, j);
|
||||
if (CONDITIONAL_HEADS.has(head)) return j; // `else if (…)` — let the main loop handle the chain.
|
||||
while (j < text.length && /\s/.test(text[j])) j++;
|
||||
if (text[j] === '(') {
|
||||
const group = parseArgs(text, j);
|
||||
return group ? group.end : resyncAfterMalformed(text, i);
|
||||
}
|
||||
return j;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** One top-level NAMESPACE directive: its head and its raw (comment-stripped) arguments. */
|
||||
interface RawDirective {
|
||||
readonly head: string;
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise NAMESPACE text into the top-level directives whose head is in
|
||||
* `heads`, in file order. Conditional (`if` / `else`) bodies are skipped, and
|
||||
* directives nested inside another directive's arguments are never emitted.
|
||||
* Never throws. A malformed directive (unbalanced group, unterminated quote,
|
||||
* unterminated `{` block) is discarded and scanning resumes at the next line
|
||||
* starting `identifier(` at column 0 ({@link resyncAfterMalformed}); everything
|
||||
* before and after is kept. Directives indented after a malformed one are
|
||||
* dropped with it.
|
||||
*/
|
||||
function scanRNamespaceDirectives(text: string, heads: ReadonlySet<string>): RawDirective[] {
|
||||
const directives: RawDirective[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i];
|
||||
if (ch === '#') {
|
||||
i = skipLineComment(text, i);
|
||||
continue;
|
||||
}
|
||||
if (isQuote(ch)) {
|
||||
const end = skipQuoted(text, i);
|
||||
if (end === -1) {
|
||||
i = resyncAfterMalformed(text, i); // stray unterminated quote
|
||||
continue;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (!isIdentStart(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let j = i + 1;
|
||||
while (j < text.length && isIdentPart(text[j])) j++;
|
||||
const head = text.slice(i, j);
|
||||
let k = j;
|
||||
while (k < text.length && /\s/.test(text[k])) k++;
|
||||
|
||||
if (text[k] !== '(') {
|
||||
// `else` may be followed directly by a directive with no parens of its own.
|
||||
if (head === 'else') {
|
||||
i = skipConditionalBody(text, k);
|
||||
continue;
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = parseArgs(text, k);
|
||||
if (!group) {
|
||||
i = resyncAfterMalformed(text, i); // discard this directive, resume at the next one
|
||||
continue;
|
||||
}
|
||||
i = group.end;
|
||||
|
||||
if (CONDITIONAL_HEADS.has(head)) {
|
||||
i = skipConditionalBody(text, i);
|
||||
continue;
|
||||
}
|
||||
if (heads.has(head)) directives.push({ head, args: group.args });
|
||||
}
|
||||
return directives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every `importFrom(pkg, name, ...)` pair from NAMESPACE text, in file
|
||||
* order. Never throws; returns `[]` for empty, garbled or unbalanced input.
|
||||
*/
|
||||
export function parseRNamespaceImportFrom(text: string): RNamespaceImportFromEntry[] {
|
||||
const entries: RNamespaceImportFromEntry[] = [];
|
||||
for (const { args } of scanRNamespaceDirectives(text, IMPORT_FROM_DIRECTIVES)) {
|
||||
const values = args.map(normaliseArg);
|
||||
const pkg = values[0];
|
||||
if (!pkg) continue;
|
||||
for (let n = 1; n < values.length; n++) {
|
||||
if (values[n]) entries.push({ pkg, name: values[n] });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** What a NAMESPACE file exports, as read by {@link parseRNamespaceExports}. */
|
||||
export interface RNamespaceExports {
|
||||
/** Names from `export()`, `exportClasses()`, `exportMethods()` and `S3method()` (the third argument, else `generic.class`), in file order. */
|
||||
readonly namedExports: readonly string[];
|
||||
/** `exportPattern()` arguments, R-unescaped (an R string `"\\."` yields the regex source `\.`), in file order. */
|
||||
readonly exportPatterns: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-token string argument of `exportPattern()`, R-unescaped, or `''`
|
||||
* when the argument is empty, not a quoted string, or unterminated. Only `"`
|
||||
* and `'` quote a string in R (a backticked name is a symbol, not a pattern).
|
||||
*/
|
||||
function exportPatternArg(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const quote = trimmed[0];
|
||||
if (quote !== '"' && quote !== "'") return '';
|
||||
if (skipQuoted(trimmed, 0) !== trimmed.length) return '';
|
||||
return unescapeRString(trimmed.slice(1, -1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the export directives from NAMESPACE text: `export`, `exportClasses`
|
||||
* and `exportMethods` names, `S3method(generic, class[, method])` as `method`
|
||||
* (else `generic.class`), and `exportPattern("…")` sources. Multi-line, quoted,
|
||||
* backticked, commented and CRLF input is handled like {@link
|
||||
* parseRNamespaceImportFrom}. Non-simple arguments (named, nested calls) are
|
||||
* skipped. Never throws; returns empty lists for empty or garbled input.
|
||||
*/
|
||||
export function parseRNamespaceExports(text: string): RNamespaceExports {
|
||||
const namedExports: string[] = [];
|
||||
const exportPatterns: string[] = [];
|
||||
for (const { head, args } of scanRNamespaceDirectives(text, EXPORT_DIRECTIVES)) {
|
||||
if (head === 'exportPattern') {
|
||||
for (const raw of args) {
|
||||
const pattern = exportPatternArg(raw);
|
||||
if (pattern) exportPatterns.push(pattern);
|
||||
}
|
||||
} else if (head === 'S3method') {
|
||||
const generic = normaliseArg(args[0] ?? '');
|
||||
const cls = normaliseArg(args[1] ?? '');
|
||||
if (generic && cls) {
|
||||
// The optional third argument names the function registered as the method;
|
||||
// without it the method is `generic.class`.
|
||||
const method = normaliseArg(args[2] ?? '');
|
||||
namedExports.push(method || `${generic}.${cls}`);
|
||||
}
|
||||
} else {
|
||||
for (const raw of args) {
|
||||
const name = normaliseArg(raw);
|
||||
if (name) namedExports.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { namedExports, exportPatterns };
|
||||
}
|
||||
|
||||
// ─── Package lookup ─────────────────────────────────────────────────────────
|
||||
|
||||
/** A local R package located by {@link rPackageDirForFile}. */
|
||||
export interface RPackageLocation {
|
||||
/** `Package:` name from `DESCRIPTION`. */
|
||||
readonly name: string;
|
||||
/** Package directory relative to the repo root; `''` for a root-level package. */
|
||||
readonly dir: string;
|
||||
}
|
||||
|
||||
/** `R/` directory prefix of a package dir (`'R/'` for a root-level package). */
|
||||
export function rRDirPrefix(pkgDir: string): string {
|
||||
return pkgDir === '' ? 'R/' : `${pkgDir}/R/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local package whose `<pkgDir>/R/**` contains `filePath`, or `undefined`.
|
||||
*
|
||||
* NAMESPACE effects apply only to a package's `R/` sources (R loads nothing
|
||||
* else into the namespace), so files under `tests/`, `scripts/`, `inst/`, a
|
||||
* root-level `plumber.R`, … belong to no package here. When package dirs nest,
|
||||
* the longest matching dir wins. A root-level package (`dir === ''`) is
|
||||
* handled without ever building a `'' + '/'` prefix.
|
||||
*/
|
||||
export function rPackageDirForFile(
|
||||
filePath: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): RPackageLocation | undefined {
|
||||
if (cfg === null || cfg === undefined) return undefined;
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
let best: RPackageLocation | undefined;
|
||||
for (const [name, dir] of cfg.packages) {
|
||||
if (!normalized.startsWith(rRDirPrefix(dir))) continue;
|
||||
if (best === undefined || dir.length > best.dir.length) best = { name, dir };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory of the package whose `R/` sources contain `filePath`, or `undefined`.
|
||||
*
|
||||
* Unlike {@link rPackageDirForFile} this considers every directory that has a NAMESPACE as
|
||||
* well as every registered package, so the nearest enclosing package owns a file and a nested
|
||||
* package without a NAMESPACE never inherits the enclosing package's. The longest matching
|
||||
* directory wins; the root package (`''`) is matched without building a `'' + '/'` prefix.
|
||||
*/
|
||||
export function rOwningPackageDir(
|
||||
filePath: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): string | undefined {
|
||||
if (cfg === null || cfg === undefined) return undefined;
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
let best: string | undefined;
|
||||
const consider = (dir: string): void => {
|
||||
if (!normalized.startsWith(rRDirPrefix(dir))) return;
|
||||
if (best === undefined || dir.length > best.length) best = dir;
|
||||
};
|
||||
for (const dir of cfg.packages.values()) consider(dir);
|
||||
for (const dir of cfg.namespaceInfoByPackageDir.keys()) consider(dir);
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `filePath` sits under the `R/` directory of any package discovery read a
|
||||
* `DESCRIPTION` for: a registered package, a directory with a NAMESPACE, or a same-name copy
|
||||
* recorded only in {@link RPackageConfig.packageDirs}. Such a file belongs to the package its
|
||||
* `DESCRIPTION` names, whatever its directory is called.
|
||||
*/
|
||||
export function rFileBelongsToDiscoveredPackage(
|
||||
filePath: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): boolean {
|
||||
if (cfg === null || cfg === undefined) return false;
|
||||
if (rOwningPackageDir(filePath, cfg) !== undefined) return true;
|
||||
if (cfg.packageDirs === undefined) return false;
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
for (const dir of cfg.packageDirs) if (normalized.startsWith(rRDirPrefix(dir))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Top-level definitions of a parsed file ────────────────────────────────
|
||||
|
||||
/** Def kinds an R `importFrom()` (or a `pkg::name()` call) can bind to (functions and R6/R5/S4 classes). */
|
||||
export const TOP_LEVEL_BINDABLE_TYPES: ReadonlySet<string> = new Set(['Function', 'Class']);
|
||||
|
||||
/** What one R file defines at top level, read from its Module scope. */
|
||||
export interface RFileTopLevel {
|
||||
/** Names of top-level function/class definitions, exactly as bound (backticks kept). */
|
||||
readonly names: ReadonlySet<string>;
|
||||
/** For each dotted name in {@link names}, the text after its last `.`. */
|
||||
readonly dottedTails: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const EMPTY_TOP_LEVEL: RFileTopLevel = { names: new Set(), dottedTails: new Set() };
|
||||
const topLevelCache = new WeakMap<ParsedFile, RFileTopLevel>();
|
||||
|
||||
/**
|
||||
* Top-level definitions of `parsed`, read from the Module scope's hoisted
|
||||
* bindings.
|
||||
*
|
||||
* Since R scope queries anchor a `@scope.function` on every named function, a top-level function def is
|
||||
* owned by its *own* Function scope and only its binding is hoisted into the
|
||||
* Module scope, so neither the Module scope's `ownedDefs` nor the flat
|
||||
* `localDefs` (which also lists nested functions and R6/R5 members) answers
|
||||
* "is `name` defined at top level?". A `local`-origin Module binding to a
|
||||
* function/class def does. Bindings are read at extraction time, before
|
||||
* `finalizeScopeModel` adds import/wildcard bindings.
|
||||
*/
|
||||
export function rFileTopLevel(parsed: ParsedFile): RFileTopLevel {
|
||||
const cached = topLevelCache.get(parsed);
|
||||
if (cached !== undefined) return cached;
|
||||
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
|
||||
let result = EMPTY_TOP_LEVEL;
|
||||
if (moduleScope !== undefined) {
|
||||
const names = new Set<string>();
|
||||
const dottedTails = new Set<string>();
|
||||
for (const [name, refs] of moduleScope.bindings) {
|
||||
if (!refs.some((r) => r.origin === 'local' && TOP_LEVEL_BINDABLE_TYPES.has(r.def.type))) {
|
||||
continue;
|
||||
}
|
||||
names.add(name);
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot >= 0) dottedTails.add(name.slice(dot + 1));
|
||||
}
|
||||
result = { names, dottedTails };
|
||||
}
|
||||
topLevelCache.set(parsed, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Import synthesis (populateWorkspaceReferences) ─────────────────────────
|
||||
|
||||
/**
|
||||
* Per local package, the set of names it defines at top level in its `R/`
|
||||
* files, recorded by {@link populateRNamespaceImports} for the packages that
|
||||
* take part in an `importFrom()` (as caller or as provider). Keyed by the
|
||||
* `resolutionConfig` object, which reaches both this hook and the
|
||||
* free-call-fallback veto. A missing entry means "cannot decide".
|
||||
*/
|
||||
const packageTopLevelNames = new WeakMap<object, ReadonlyMap<string, ReadonlySet<string>>>();
|
||||
|
||||
/** Recorded top-level names per package for `resolutionConfig`, if the hook ran with imports. */
|
||||
export function rRecordedPackageTopLevelNames(
|
||||
resolutionConfig: unknown,
|
||||
): ReadonlyMap<string, ReadonlySet<string>> | undefined {
|
||||
if (typeof resolutionConfig !== 'object' || resolutionConfig === null) return undefined;
|
||||
return packageTopLevelNames.get(resolutionConfig);
|
||||
}
|
||||
|
||||
interface CallerPlan {
|
||||
readonly dir: string;
|
||||
/** Surviving `name → provider package` after last-wins / self-drop / external-skip. */
|
||||
readonly bindable: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesise `named` imports for NAMESPACE `importFrom(pkg, name)` entries
|
||||
* whose `pkg` is a local package, into every `R/` file of the caller package.
|
||||
*
|
||||
* NOTE: `populateWorkspaceReferences` is documented as reference-site
|
||||
* enrichment; it is reused here (same call site and ctx as the Zig static
|
||||
* gating precedent) because it runs before `finalizeScopeModel`, receives the
|
||||
* mutable `ParsedFile[]`, and also runs over warm-cache ParsedFiles, so the
|
||||
* synthesised imports are never persisted stale. If this is ever upstreamed,
|
||||
* propose widening that contract's doc string.
|
||||
*
|
||||
* Order of operations per caller package:
|
||||
* 1. last-wins over ALL entries for a name, in file order, external
|
||||
* packages included (R replaces the earlier `importFrom` binding);
|
||||
* 2. drop the surviving entry when its `pkg` is the caller itself;
|
||||
* 3. skip it when its `pkg` is not a local package (an earlier local entry
|
||||
* never resurrects);
|
||||
* 4. skip it when the caller's own package defines `name` at top level in
|
||||
* any `R/` file (the own namespace masks its imports; `rMergeBindings`
|
||||
* only shadows within a single scope);
|
||||
* 5. append a `named` import to every `R/` file of the caller package.
|
||||
*/
|
||||
export function populateRNamespaceImports(
|
||||
parsedFiles: ParsedFile[],
|
||||
ctx: { readonly resolutionConfig?: unknown },
|
||||
): void {
|
||||
const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null;
|
||||
if (cfg === null || cfg.packages.size === 0) return;
|
||||
|
||||
// Steps 1-3: collapse each caller's entries; note which packages we need names for.
|
||||
const plans = new Map<string, CallerPlan>();
|
||||
const needNamesFor = new Set<string>();
|
||||
for (const [callerName, dir] of cfg.packages) {
|
||||
const info = cfg.namespaceInfoByPackageDir.get(dir);
|
||||
if (info === undefined || info.importFrom.length === 0) continue;
|
||||
const last = new Map<string, string>();
|
||||
for (const entry of info.importFrom) last.set(entry.name, entry.pkg);
|
||||
const bindable = new Map<string, string>();
|
||||
for (const [name, pkg] of last) {
|
||||
if (cfg.packages.has(pkg)) needNamesFor.add(pkg); // the fallback veto reads the provider's names too
|
||||
if (pkg === callerName || !cfg.packages.has(pkg)) continue;
|
||||
bindable.set(name, pkg);
|
||||
}
|
||||
if (bindable.size === 0) continue;
|
||||
plans.set(callerName, { dir, bindable });
|
||||
needNamesFor.add(callerName);
|
||||
}
|
||||
if (plans.size === 0) return;
|
||||
|
||||
// One pass over the parsed files: group caller files, collect top-level names.
|
||||
const namesByPackage = new Map<string, Set<string>>();
|
||||
const callerFiles = new Map<string, number[]>();
|
||||
for (let i = 0; i < parsedFiles.length; i++) {
|
||||
const location = rPackageDirForFile(parsedFiles[i].filePath, cfg);
|
||||
if (location === undefined) continue;
|
||||
if (needNamesFor.has(location.name)) {
|
||||
let names = namesByPackage.get(location.name);
|
||||
if (names === undefined) namesByPackage.set(location.name, (names = new Set()));
|
||||
for (const name of rFileTopLevel(parsedFiles[i]).names) names.add(name);
|
||||
}
|
||||
if (plans.has(location.name)) {
|
||||
const indices = callerFiles.get(location.name);
|
||||
if (indices === undefined) callerFiles.set(location.name, [i]);
|
||||
else indices.push(i);
|
||||
}
|
||||
}
|
||||
for (const pkg of needNamesFor) if (!namesByPackage.has(pkg)) namesByPackage.set(pkg, new Set());
|
||||
packageTopLevelNames.set(cfg, namesByPackage);
|
||||
|
||||
// Steps 4-5.
|
||||
for (const [callerName, plan] of plans) {
|
||||
const ownNames = namesByPackage.get(callerName);
|
||||
const synthesised: Extract<ParsedImport, { kind: 'named' }>[] = [];
|
||||
for (const [name, pkg] of plan.bindable) {
|
||||
if (ownNames?.has(name) === true) continue;
|
||||
// `null` means the name can never bind. A synthesised import for it would stay unresolved
|
||||
// yet count as an explicit name claim, suppressing the documented `global-name-fallback`.
|
||||
// (Importing the resolver forms a cycle with import-resolvers/r.ts; every use is deferred
|
||||
// to call time, so it is safe at runtime.)
|
||||
if (resolveRNamedImport(cfg, pkg, name, parsedFiles) === null) continue;
|
||||
synthesised.push({ kind: 'named', localName: name, importedName: name, targetRaw: pkg });
|
||||
}
|
||||
if (synthesised.length === 0) continue;
|
||||
for (const index of callerFiles.get(callerName) ?? []) {
|
||||
const parsed = parsedFiles[index];
|
||||
const fresh = synthesised.filter(
|
||||
(imp) =>
|
||||
!parsed.parsedImports.some(
|
||||
(existing) =>
|
||||
existing.kind === 'named' &&
|
||||
existing.localName === imp.localName &&
|
||||
existing.targetRaw === imp.targetRaw,
|
||||
),
|
||||
);
|
||||
if (fresh.length === 0) continue;
|
||||
parsedFiles[index] = Object.freeze({
|
||||
...parsed,
|
||||
parsedImports: Object.freeze([...parsed.parsedImports, ...fresh]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── `pkg::name()` qualifier helpers ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The package named by the text of an `@reference.qualified-name` capture (the
|
||||
* whole `namespace_operator`, e.g. `pkg::f`, `pkg:::f`, `pkg ::: f`,
|
||||
* `"pkg"::f`, `` `pkg`::f ``): the text before the first `::` / `:::`,
|
||||
* trimmed, with one pair of surrounding `"`, `'` or backticks removed.
|
||||
* `undefined` when there is no `::` or the package part is empty. The
|
||||
* function name is not parsed here: it is the site's own `name`.
|
||||
*
|
||||
* Lives here (not in `qualified-call.ts`, which re-exports it) because the
|
||||
* veto below needs it and `qualified-call.ts` imports this file.
|
||||
*/
|
||||
export function parseRQualifier(raw: string): string | undefined {
|
||||
const match = /^([\s\S]*?)\s*:::?/.exec(raw);
|
||||
if (match === null) return undefined;
|
||||
let pkg = match[1].trim();
|
||||
if (pkg.length >= 2 && /^(["'`])[\s\S]*\1$/.test(pkg)) pkg = pkg.slice(1, -1);
|
||||
return pkg.length === 0 ? undefined : pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per local package, how many top-level definitions of each name it has in its
|
||||
* `R/` files, recorded by `populateRQualifiedCalls` for the packages that some
|
||||
* `pkg::name()` call in the workspace names. Keyed by the `resolutionConfig`
|
||||
* object (like {@link packageTopLevelNames}), which reaches both that hook and
|
||||
* the free-call-fallback veto. A missing entry means "cannot decide".
|
||||
*/
|
||||
const qualifiedDefinitionCounts = new WeakMap<
|
||||
object,
|
||||
ReadonlyMap<string, ReadonlyMap<string, number>>
|
||||
>();
|
||||
|
||||
/** Record (replacing any earlier record) the per-package definition counts for `resolutionConfig`. */
|
||||
export function rRecordQualifiedDefinitionCounts(
|
||||
resolutionConfig: object,
|
||||
counts: ReadonlyMap<string, ReadonlyMap<string, number>>,
|
||||
): void {
|
||||
qualifiedDefinitionCounts.set(resolutionConfig, counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of top-level definitions of `name` recorded for local package `pkg`,
|
||||
* or `undefined` when nothing was recorded (hook did not run for that package).
|
||||
*/
|
||||
export function rQualifiedDefinitionCount(
|
||||
resolutionConfig: object,
|
||||
pkg: string,
|
||||
name: string,
|
||||
): number | undefined {
|
||||
const byName = qualifiedDefinitionCounts.get(resolutionConfig)?.get(pkg);
|
||||
return byName === undefined ? undefined : (byName.get(name) ?? 0);
|
||||
}
|
||||
|
||||
// ─── Global-name fallback veto (isGlobalNameFallbackPlausible) ──────────────
|
||||
|
||||
/**
|
||||
* Structural subset of the hook context `isRGlobalNameFallbackPlausible`
|
||||
* reads; the shared contract's full context is assignable to it.
|
||||
*/
|
||||
export interface RGlobalNameFallbackContext {
|
||||
readonly callerParsed: { readonly filePath: string };
|
||||
readonly candidate: { readonly filePath: string };
|
||||
/** Opaque `loadResolutionConfig` result; an {@link RPackageConfig} for R. */
|
||||
readonly resolutionConfig?: unknown;
|
||||
readonly site: { readonly name: string; readonly rawQualifiedName?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a `global-name-fallback` guess that the caller package's NAMESPACE
|
||||
* contradicts. Returns `false` (veto) only when ALL of these hold:
|
||||
*
|
||||
* - the caller file lives under `<pkgDir>/R/**` of a package with a NAMESPACE
|
||||
* that has an `importFrom(P, <site.name>)` entry (the LAST such entry over
|
||||
* all entries, external packages included — the same last-wins rule the synthesised imports follow);
|
||||
* - `P` is not the caller's own package (a self-import binds nothing);
|
||||
* - the candidate lives under some package's `R/**`, and that package is
|
||||
* neither the caller's own (own namespace masks; a plain 0.5 fallback edge
|
||||
* until own-package binding exists) nor `P`;
|
||||
* - if `P` is local, it defines `site.name` at top level; otherwise `P`
|
||||
* re-exports the name from a third package and the candidate may be the
|
||||
* real origin (unknown per-package names = cannot decide = allow).
|
||||
*
|
||||
* Everything else — no config, caller outside a package `R/` (`tests/`,
|
||||
* `scripts/`, root `plumber.R`), no entry, candidate outside any package
|
||||
* `R/` — answers `true`, so `source()`/`library()` script flows are untouched.
|
||||
*
|
||||
* A qualified call `pkg::name()` (site carries `rawQualifiedName`) is judged
|
||||
* by its qualifier first: with `pkg` a local package, a candidate
|
||||
* inside `pkg` is plausible whatever the NAMESPACE says, and a candidate
|
||||
* elsewhere is vetoed when `pkg` itself defines `name`. A `pkg` that defines
|
||||
* nothing of that name (re-export), or that is not a local package, falls
|
||||
* through to the NAMESPACE rules above.
|
||||
*
|
||||
* Known unsound shape, pinned by a characterisation test: `import(pkg)` is not
|
||||
* tokenised, so a later `import(pkg)` that R would prefer is invisible.
|
||||
*/
|
||||
export function isRGlobalNameFallbackPlausible(ctx: RGlobalNameFallbackContext): boolean {
|
||||
const cfg = ctx.resolutionConfig as RPackageConfig | null | undefined;
|
||||
if (
|
||||
typeof cfg !== 'object' ||
|
||||
cfg === null ||
|
||||
!(cfg.packages instanceof Map) ||
|
||||
!(cfg.namespaceInfoByPackageDir instanceof Map)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Qualified call `pkg::name()`: the source named the package, so the package
|
||||
// outranks the caller's NAMESPACE. A candidate inside a local `pkg` is right;
|
||||
// a candidate elsewhere contradicts the qualifier when `pkg` defines `name`
|
||||
// itself. A `pkg` that defines nothing of that name (a re-export) or that has
|
||||
// no recorded counts cannot be judged from here: fall through to the rules
|
||||
// below, i.e. today's behaviour. An unknown or external `pkg` falls through
|
||||
// too (external sites are dropped upstream, before any fallback tier).
|
||||
const qualifier =
|
||||
ctx.site.rawQualifiedName === undefined
|
||||
? undefined
|
||||
: parseRQualifier(ctx.site.rawQualifiedName);
|
||||
if (qualifier !== undefined && cfg.packages.has(qualifier)) {
|
||||
if (rPackageDirForFile(ctx.candidate.filePath, cfg)?.name === qualifier) return true;
|
||||
if ((rQualifiedDefinitionCount(cfg, qualifier, ctx.site.name) ?? 0) > 0) return false;
|
||||
}
|
||||
|
||||
const caller = rPackageDirForFile(ctx.callerParsed.filePath, cfg);
|
||||
if (caller === undefined) return true;
|
||||
const importFrom = cfg.namespaceInfoByPackageDir.get(caller.dir)?.importFrom;
|
||||
if (importFrom === undefined) return true;
|
||||
|
||||
let lastEntry: RNamespaceImportFromEntry | undefined;
|
||||
for (const entry of importFrom) if (entry.name === ctx.site.name) lastEntry = entry;
|
||||
if (lastEntry === undefined) return true;
|
||||
const importedFrom = lastEntry.pkg;
|
||||
if (importedFrom === caller.name) return true;
|
||||
|
||||
const candidatePkg = rPackageDirForFile(ctx.candidate.filePath, cfg);
|
||||
if (candidatePkg === undefined) return true;
|
||||
if (candidatePkg.name === caller.name || candidatePkg.name === importedFrom) return true;
|
||||
|
||||
if (cfg.packages.has(importedFrom)) {
|
||||
const providerNames = rRecordedPackageTopLevelNames(cfg)?.get(importedFrom);
|
||||
if (providerNames === undefined || !providerNames.has(ctx.site.name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
187
gitnexus/src/core/ingestion/languages/r/naming-argument.ts
Normal file
187
gitnexus/src/core/ingestion/languages/r/naming-argument.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Which argument of an R call names what the call defines or imports.
|
||||
*
|
||||
* `R_QUERIES` captures every string-valued argument of `setClass`,
|
||||
* `setRefClass`, `setGeneric` and `setMethod`, and the scope query captures every
|
||||
* identifier- or string-valued argument of `library`, `require` and `source` and
|
||||
* every string argument of `setClass`/`setRefClass`. Only one of them is the
|
||||
* definition's name or the import's source; the rest are `contains = "VIRTUAL"`,
|
||||
* `valueClass = "numeric"`, the class slot of `setMethod("show", "Foo", ...)`,
|
||||
* `lib.loc = libO`, and so on. The choice is made here, in code, rather than in the
|
||||
* query: a tree-sitter query can only skip an unbounded run of leading
|
||||
* comments and named arguments with a repeated sibling group, and that costs
|
||||
* time quadratic in the argument count on every call in the file (a single
|
||||
* `list(a0 = 0, ...)` with 16000 arguments took 18s). This check is linear: it
|
||||
* reads each argument list once and caches the answer.
|
||||
*
|
||||
* R matches arguments by name first, then positionally. The naming argument is
|
||||
* therefore the argument spelled with the call's first formal (`Class = "A"`),
|
||||
* or, when no argument anywhere in the list is spelled that way, the first
|
||||
* unnamed argument. Comments, commas and other named arguments may precede it,
|
||||
* and a `Class = ...` after an unnamed argument takes the formal from it, so
|
||||
* `setClass(a = "Q", "B", Class = "A")` names `A` only.
|
||||
*
|
||||
* A formal may be spelled bare, in backticks or in quotes (`Class`, `` `Class` ``,
|
||||
* `"Class"`); all name the same argument. Partial matching of an abbreviated
|
||||
* name (`Cl = "A"`) is not modelled.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { CaptureMap } from '../../language-provider.js';
|
||||
|
||||
/** The first formal of each call whose first argument names what it defines or imports. */
|
||||
const NAMING_FORMAL: Readonly<Record<string, string>> = {
|
||||
setClass: 'Class',
|
||||
setRefClass: 'Class',
|
||||
setGeneric: 'name',
|
||||
setMethod: 'f',
|
||||
library: 'package',
|
||||
require: 'package',
|
||||
source: 'file',
|
||||
};
|
||||
|
||||
/** An argument's name with one pair of surrounding backticks or quotes removed. */
|
||||
function argumentNameText(name: SyntaxNode): string {
|
||||
const text = name.text;
|
||||
if (text.length >= 2) {
|
||||
const first = text[0];
|
||||
if ((first === '`' || first === '"' || first === "'") && text[text.length - 1] === first) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per tree, per (argument list, formal): the id of the unnamed argument that takes
|
||||
* the formal, or null when none does. One pass over the argument list answers every
|
||||
* later match in that list in constant time. Looking backwards from each match
|
||||
* instead is quadratic in the number of preceding comments (sibling lookup is
|
||||
* linear in the sibling's position), so the list is read forwards, once.
|
||||
*/
|
||||
const namingArgumentIds = new WeakMap<object, Map<string, number | null>>();
|
||||
|
||||
function namingUnnamedArgumentId(argumentList: SyntaxNode, formal: string): number | null {
|
||||
let perTree = namingArgumentIds.get(argumentList.tree);
|
||||
if (perTree === undefined) {
|
||||
perTree = new Map();
|
||||
namingArgumentIds.set(argumentList.tree, perTree);
|
||||
}
|
||||
const key = `${argumentList.id}:${formal}`;
|
||||
const cached = perTree.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
// The first unnamed argument takes the formal, unless an argument anywhere in the
|
||||
// list is spelled with it (R matches by name first), which retracts that choice.
|
||||
let found: number | null = null;
|
||||
for (const child of argumentList.namedChildren) {
|
||||
if (child.type !== 'argument') continue; // comment, comma
|
||||
const name = child.childForFieldName('name');
|
||||
if (name === null) {
|
||||
found ??= child.id;
|
||||
} else if (argumentNameText(name) === formal) {
|
||||
found = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
perTree.set(key, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* The argument R would match to `target` in a call to a function whose formals are
|
||||
* `formals`, in order. R matches named arguments first (a name that spells a formal
|
||||
* binds it) and then fills the formals that remain from the unnamed arguments, in
|
||||
* order. Partial matching of abbreviated names (`sig =`) is not modelled. Returns
|
||||
* null when no argument reaches `target`. Linear in the argument count; meant for
|
||||
* the one call a definition hook is looking at, not for every call in a file.
|
||||
*/
|
||||
export function getRMatchedArgument(
|
||||
argumentList: SyntaxNode,
|
||||
formals: readonly string[],
|
||||
target: string,
|
||||
): SyntaxNode | null {
|
||||
const unnamed: SyntaxNode[] = [];
|
||||
const named = new Set<string>();
|
||||
for (const child of argumentList.namedChildren) {
|
||||
if (child.type !== 'argument') continue; // comment, comma
|
||||
const name = child.childForFieldName('name');
|
||||
if (name === null) {
|
||||
unnamed.push(child);
|
||||
continue;
|
||||
}
|
||||
const text = argumentNameText(name);
|
||||
if (text === target) return child;
|
||||
named.add(text);
|
||||
}
|
||||
const remaining = formals.filter((formal) => !named.has(formal));
|
||||
const index = remaining.indexOf(target);
|
||||
return index === -1 ? null : (unnamed[index] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `argument` (an `argument` node) is the one that names the call's
|
||||
* definition, given the call's first `formal`.
|
||||
*/
|
||||
export function isRNamingArgument(argument: SyntaxNode, formal: string): boolean {
|
||||
const ownName = argument.childForFieldName('name');
|
||||
if (ownName !== null) return argumentNameText(ownName) === formal;
|
||||
const argumentList = argument.parent;
|
||||
if (argumentList === null) return false;
|
||||
return namingUnnamedArgumentId(argumentList, formal) === argument.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a `R_QUERIES` match captured an argument of one of the calls above
|
||||
* that is NOT the naming argument, so the definition it would mint is spurious.
|
||||
* Matches of any other shape (R6, functions, slots, calls, imports) are never
|
||||
* skipped. The legacy `@import` matches are discarded by the parse worker, so
|
||||
* they are not considered here.
|
||||
*/
|
||||
export function isRNonNamingArgumentCapture(captureMap: CaptureMap): boolean {
|
||||
const fn = captureMap['_fn'];
|
||||
if (fn === undefined) return false;
|
||||
const formal = NAMING_FORMAL[fn.text];
|
||||
if (formal === undefined) return false;
|
||||
|
||||
// Definitions capture the string's content. Slot matches capture a named
|
||||
// argument's identifier and are not ours.
|
||||
const captured = captureMap['name'];
|
||||
if (captured === undefined || captured.type !== 'string_content') return false;
|
||||
const argument = captured.parent?.parent;
|
||||
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
|
||||
|
||||
return !isRNamingArgument(argument, formal);
|
||||
}
|
||||
|
||||
/** The captures of a raw tree-sitter match, as the scope emitter sees them. */
|
||||
interface RawCapture {
|
||||
readonly name: string;
|
||||
readonly node: SyntaxNode;
|
||||
}
|
||||
|
||||
/** Callee captures of the scope query's naming-argument patterns (`r/query.ts`). */
|
||||
const SCOPE_CALLEE_CAPTURES: ReadonlySet<string> = new Set(['_s4_decl', '_srcfn', '_libfn']);
|
||||
|
||||
/**
|
||||
* True when a scope-query match captured an argument of `setClass` /
|
||||
* `setRefClass` (`@declaration.name`), `library` / `require` or `source`
|
||||
* (`@import.source`) that is NOT the naming argument. The scope query captures
|
||||
* every such argument; the emitter drops these matches.
|
||||
*/
|
||||
export function isRNonNamingScopeMatch(captures: readonly RawCapture[]): boolean {
|
||||
const fn = captures.find((c) => SCOPE_CALLEE_CAPTURES.has(c.name));
|
||||
if (fn === undefined) return false;
|
||||
const formal = NAMING_FORMAL[fn.node.text];
|
||||
if (formal === undefined) return false;
|
||||
|
||||
const captured = captures.find(
|
||||
(c) => c.name === 'declaration.name' || c.name === 'import.source',
|
||||
);
|
||||
if (captured === undefined) return false;
|
||||
const argument =
|
||||
captured.node.type === 'string_content' ? captured.node.parent?.parent : captured.node.parent;
|
||||
if (argument === undefined || argument === null || argument.type !== 'argument') return false;
|
||||
|
||||
return !isRNamingArgument(argument, formal);
|
||||
}
|
||||
39
gitnexus/src/core/ingestion/languages/r/owner-hooks.ts
Normal file
39
gitnexus/src/core/ingestion/languages/r/owner-hooks.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* R owner hooks for the shared ingestion pipeline.
|
||||
*
|
||||
* R has no class syntax: R6 / RefClass members belong to a call or assignment
|
||||
* (`Foo <- R6::R6Class(...)`, `setRefClass(...)`), not to an enclosing
|
||||
* container node. `resolveMemberOwnerNode` hands the member extractors that
|
||||
* owner node. Members whose owner is a string (`setMethod("area", "Circle",
|
||||
* ...)`) or a top-level R6/RefClass call are instead tied to their class after
|
||||
* all chunks merge: `definitionPropertiesExtractor` records the owner name as
|
||||
* an `ownerNameHint`, resolved by R's `postParse` hook.
|
||||
*/
|
||||
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { DefinitionPropertiesContext } from '../../language-provider.js';
|
||||
import { findRFieldOwnerNode, getRTopLevelPropertyOwnerName } from '../../field-extractors/r.js';
|
||||
import { getRTopLevelMethodOwnerName } from '../../method-extractors/r.js';
|
||||
|
||||
/** `LanguageProvider.resolveMemberOwnerNode` for R. */
|
||||
export const rResolveMemberOwnerNode = (definitionNode: SyntaxNode): SyntaxNode | null =>
|
||||
findRFieldOwnerNode(definitionNode);
|
||||
|
||||
/**
|
||||
* `LanguageProvider.definitionPropertiesExtractor` for R: the deferred owner
|
||||
* name (`ownerNameHint`) of a `Method`/`Property` whose owning class is named by
|
||||
* a string argument or a top-level R6/RefClass call rather than a syntactic
|
||||
* ancestor.
|
||||
*/
|
||||
export const rDefinitionProperties = (
|
||||
ctx: DefinitionPropertiesContext,
|
||||
): Readonly<Record<string, unknown>> | undefined => {
|
||||
const ownerNameHint =
|
||||
ctx.nodeLabel === 'Method'
|
||||
? (getRTopLevelMethodOwnerName(ctx.definitionNode) ??
|
||||
getRTopLevelPropertyOwnerName(ctx.definitionNode))
|
||||
: ctx.nodeLabel === 'Property'
|
||||
? getRTopLevelPropertyOwnerName(ctx.definitionNode)
|
||||
: null;
|
||||
return ownerNameHint ? { ownerNameHint } : undefined;
|
||||
};
|
||||
407
gitnexus/src/core/ingestion/languages/r/package-config.ts
Normal file
407
gitnexus/src/core/ingestion/languages/r/package-config.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
import type { Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {
|
||||
parseRNamespaceExports,
|
||||
parseRNamespaceImportFrom,
|
||||
type RNamespaceImportFromEntry,
|
||||
} from './namespace-imports.js';
|
||||
import { compileRExportPatternDetailed, type RExportMatcher } from './export-pattern.js';
|
||||
import { SharedWorkBudget } from './linear-regex.js';
|
||||
import { isDev } from '../../utils/env.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
|
||||
/** An `exportPattern()` argument that was not compiled, and why. */
|
||||
export interface RDroppedExportPattern {
|
||||
/** The pattern text as R would see it (the string value, after unescaping). */
|
||||
readonly pattern: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** 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. When several
|
||||
* directories declare the same `Package:` name the shallowest directory wins
|
||||
* and, among equal depths, the one whose whole path string sorts first by
|
||||
* code unit (so `a-b/x` beats `a/x`: `-` sorts before `/`); the others are
|
||||
* not entries here (their NAMESPACE is still recorded in
|
||||
* {@link namespaceInfoByPackageDir}, which is keyed by directory). Installed
|
||||
* libraries and check output (`renv`, `packrat`, `revdep`, `<name>.Rcheck`) are
|
||||
* not searched, so their packages are not entries here.
|
||||
*/
|
||||
packages: Map<string, string>;
|
||||
/** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */
|
||||
namespaceInfoByPackageDir: Map<string, RNamespaceInfo>;
|
||||
/**
|
||||
* Every directory (relative to the repo root, `''` for the root) in which discovery read a
|
||||
* `DESCRIPTION` with a `Package:` line: the directories of {@link packages} plus the
|
||||
* directories of same-name copies that lost to them, with or without a NAMESPACE. The
|
||||
* qualifier-locality decision reads it, so that a file under such a directory's `R/` is not
|
||||
* taken as evidence for a package named after its directory. Optional so that hand-built
|
||||
* configs read as "the directories of {@link packages} and {@link namespaceInfoByPackageDir}".
|
||||
*/
|
||||
packageDirs?: ReadonlySet<string>;
|
||||
/**
|
||||
* True when discovery cannot rule out an undiscovered package: a directory
|
||||
* of the main walk could not be read, or a subtree the walk skipped (below
|
||||
* the depth limit, or still queued when the directory cap stopped it) may
|
||||
* hide a package. Skipped subtrees are scanned once more for `DESCRIPTION`
|
||||
* files (see {@link hidesUndiscoveredPackage}); the flag is set only when
|
||||
* that scan finds a `Package:` name absent from {@link packages}, exceeds
|
||||
* its directory budget, or hits an unreadable path. A skipped subtree with
|
||||
* no package inside (`tests/testthat/fixtures/...`, `inst/extdata/...`) or
|
||||
* only a same-name copy of an already-discovered package does NOT truncate.
|
||||
* A package missing from {@link packages} is then not proven absent from the
|
||||
* repo, so `rQualifierLocality` answers `unknown` rather than `external`.
|
||||
* This holds for an empty {@link packages} map too: a complete scan that found
|
||||
* no package at all proves every qualifier external.
|
||||
* Optional so that hand-built configs (tests, callers that never scan) read
|
||||
* as "complete".
|
||||
*/
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
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>;
|
||||
/** Precompiled matchers from exportPattern("..."), compiled once here so
|
||||
* `refineRExportStatus` doesn't recompile per node it checks. POSIX bracket
|
||||
* classes such as `[[:alpha:]]` are translated to JS equivalents first
|
||||
* (see `compileRExportPattern`). The matchers run in linear time, so a hostile
|
||||
* pattern cannot stall analysis. Patterns that fail to compile, or that the
|
||||
* linear-time matcher cannot handle, are dropped at this stage (they never match)
|
||||
* and listed in {@link droppedExportPatterns}. */
|
||||
exportPatterns: RExportMatcher[];
|
||||
/**
|
||||
* Non-empty `exportPattern()` arguments that could not be compiled, with the reason.
|
||||
* A dropped pattern exports nothing, so every name the pattern was meant to cover
|
||||
* reads as unexported; `reportDroppedRExportPatterns` turns these into warnings.
|
||||
* Optional so that hand-built configs read as "nothing dropped".
|
||||
*/
|
||||
droppedExportPatterns?: readonly RDroppedExportPattern[];
|
||||
/**
|
||||
* How many further non-empty `exportPattern()` arguments were dropped but not listed in
|
||||
* {@link droppedExportPatterns}, which holds at most {@link MAX_LISTED_DROPPED_PATTERNS}
|
||||
* per package so that a NAMESPACE of hundreds of thousands of refused patterns cannot
|
||||
* produce hundreds of thousands of warnings. Reported as one line.
|
||||
*/
|
||||
omittedDroppedExportPatterns?: number;
|
||||
/**
|
||||
* The work budget this package's matchers draw on: a sub-budget of the load's. Absent on
|
||||
* hand-built configs. {@link rExportPatternMatches} stops consulting the matchers once it
|
||||
* is spent.
|
||||
*/
|
||||
exportPatternBudget?: SharedWorkBudget;
|
||||
/** `importFrom(pkg, name)` pairs in NAMESPACE file order (all entries, incl. self-imports and duplicates). */
|
||||
importFrom: readonly RNamespaceImportFromEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Most patterns named in warnings per package, for dropped patterns (the rest are counted,
|
||||
* see `omittedDroppedExportPatterns`) and for patterns cut short by the budget alike, so
|
||||
* that a NAMESPACE of tens of thousands of patterns cannot produce as many warnings.
|
||||
*/
|
||||
export const MAX_LISTED_DROPPED_PATTERNS = 50;
|
||||
|
||||
/**
|
||||
* True when one of the package's `exportPattern()` matchers matches `name`. Once the
|
||||
* package's work budget (or the load's) is spent it stops asking the matchers at all and
|
||||
* answers false, so the cost no longer grows as patterns x names; the patterns it did not
|
||||
* get to run are then reported by {@link reportRExportPatternProblems}.
|
||||
*/
|
||||
export function rExportPatternMatches(
|
||||
info: Pick<RNamespaceInfo, 'exportPatterns' | 'exportPatternBudget'>,
|
||||
name: string,
|
||||
): boolean {
|
||||
const budget = info.exportPatternBudget;
|
||||
for (const pattern of info.exportPatterns) {
|
||||
if (budget?.isSpent === true) {
|
||||
budget.cutShort = true;
|
||||
return false;
|
||||
}
|
||||
if (pattern.test(name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Longest pattern prefix quoted in a warning. */
|
||||
const QUOTED_PATTERN_LENGTH = 60;
|
||||
|
||||
const quotePattern = (pattern: string): string =>
|
||||
JSON.stringify(
|
||||
pattern.length > QUOTED_PATTERN_LENGTH
|
||||
? `${pattern.slice(0, QUOTED_PATTERN_LENGTH)}...`
|
||||
: pattern,
|
||||
);
|
||||
|
||||
/**
|
||||
* Warn, once per pattern per package, about every `exportPattern()` that is not in
|
||||
* effect: one dropped at load ({@link RNamespaceInfo.droppedExportPatterns}) or one
|
||||
* whose matcher ran out of work budget since (names it had not yet decided were read as
|
||||
* unexported). Call after the matchers have been used. A dropped pattern exports
|
||||
* nothing, which would otherwise look exactly like a package that exports few names.
|
||||
*/
|
||||
export function reportRExportPatternProblems(config: RPackageConfig): void {
|
||||
for (const [pkgDir, info] of config.namespaceInfoByPackageDir) {
|
||||
const where = pkgDir === '' ? '.' : pkgDir;
|
||||
for (const { pattern, reason } of info.droppedExportPatterns ?? []) {
|
||||
logger.warn(
|
||||
`R package ${where}: exportPattern(${quotePattern(pattern)}) was ignored (${reason}); ` +
|
||||
`names it would export are treated as not exported`,
|
||||
);
|
||||
}
|
||||
if ((info.omittedDroppedExportPatterns ?? 0) > 0) {
|
||||
logger.warn(
|
||||
`R package ${where}: ${info.omittedDroppedExportPatterns} further exportPattern() ` +
|
||||
`arguments were ignored as well and are not listed; names they would export are ` +
|
||||
`treated as not exported`,
|
||||
);
|
||||
}
|
||||
const cutShort = info.exportPatternBudget?.cutShort === true;
|
||||
let listed = 0;
|
||||
let unlisted = 0;
|
||||
for (const matcher of info.exportPatterns) {
|
||||
if (matcher.exhausted !== true && !cutShort) continue;
|
||||
if (listed >= MAX_LISTED_DROPPED_PATTERNS) {
|
||||
unlisted++;
|
||||
continue;
|
||||
}
|
||||
listed++;
|
||||
logger.warn(
|
||||
`R package ${where}: exportPattern(${quotePattern(matcher.source)}) exceeded its work ` +
|
||||
`budget (its own, its package's or the repository's); ` +
|
||||
`names it had not yet been tested against are treated as not exported`,
|
||||
);
|
||||
}
|
||||
if (unlisted > 0) {
|
||||
logger.warn(
|
||||
`R package ${where}: ${unlisted} further exportPattern() arguments exceeded their work ` +
|
||||
`budget as well and are not listed; names they had not yet been tested against are ` +
|
||||
`treated as not exported`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Directories never descended into, by the main walk and the hidden-package scan alike. */
|
||||
const SKIPPED_DIR_NAMES: ReadonlySet<string> = new Set(['node_modules', '.git', '.Rproj.user']);
|
||||
|
||||
/**
|
||||
* Directories R tooling fills with installed packages or check output: the renv project
|
||||
* library, the packrat private library and the revdepcheck workspace. Each holds one
|
||||
* `DESCRIPTION` per third-party package, none of which is part of the repository.
|
||||
*/
|
||||
const VENDORED_LIBRARY_DIR_NAMES: ReadonlySet<string> = new Set(['renv', 'packrat', 'revdep']);
|
||||
|
||||
/** Suffix of the `<pkg>.Rcheck` directories `R CMD check` writes. */
|
||||
const RCHECK_SUFFIX = '.Rcheck';
|
||||
|
||||
/**
|
||||
* True for a directory that discovery must not enter. The match is on the directory's own
|
||||
* name, exact and case-sensitive: `renv`, `packrat` or `revdep`, or `<something>.Rcheck`
|
||||
* (a name that is only the suffix is not one). Look-alikes such as `renv-tools`, `Renv`,
|
||||
* `revdeps` or `pkg.rcheck` are ordinary directories and may hold a real package.
|
||||
*/
|
||||
function isSkippedDirName(name: string): boolean {
|
||||
return (
|
||||
SKIPPED_DIR_NAMES.has(name) ||
|
||||
VENDORED_LIBRARY_DIR_NAMES.has(name) ||
|
||||
(name.length > RCHECK_SUFFIX.length && name.endsWith(RCHECK_SUFFIX))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entries in code-unit name order. `readdir` order is filesystem-dependent
|
||||
* (and `localeCompare` is locale-dependent), so every walk below reads through this to
|
||||
* make what it visits, and what it finds first, the same on every machine.
|
||||
*/
|
||||
async function readDirSorted(dir: string): Promise<Dirent[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key that ranks the directories declaring one package name: fewer path segments
|
||||
* first, then the whole `/`-separated relative path compared by code unit. Comparing keys
|
||||
* with `<` therefore needs no help from the walk order. The depth is zero-padded so that
|
||||
* it compares as a number; `''` (the repo root) has depth 0.
|
||||
*/
|
||||
function preferredPackageDir(pkgDir: string): string {
|
||||
const depth = pkgDir === '' ? 0 : pkgDir.split('/').length;
|
||||
return `${String(depth).padStart(6, '0')}\0${pkgDir}`;
|
||||
}
|
||||
|
||||
/** Global directory budget for {@link hidesUndiscoveredPackage} across all its roots. */
|
||||
const HIDDEN_SCAN_BUDGET = 5000;
|
||||
|
||||
/**
|
||||
* Scan the subtrees the main walk skipped for a package it never discovered.
|
||||
*
|
||||
* Iterative DFS over `roots` under one global `budget` of directories (skipping
|
||||
* the directories {@link isSkippedDirName} names, as the main walk does). Returns
|
||||
* `true` (cannot rule a hidden package out) when the budget is exceeded, when a
|
||||
* directory or `DESCRIPTION` cannot be read, or when a `DESCRIPTION` declares a
|
||||
* `Package:` name that is not in `knownPackageNames`. Returns `false` when every
|
||||
* subtree was scanned and holds no such package. Deterministic and read-only.
|
||||
* The answer is a property of the set of directories, not of the order they are
|
||||
* visited in (every `true` condition is order-independent and a `false` means the
|
||||
* whole forest was read), but the walk still reads sorted so that it is reproducible.
|
||||
*/
|
||||
export async function hidesUndiscoveredPackage(
|
||||
roots: readonly string[],
|
||||
knownPackageNames: ReadonlySet<string>,
|
||||
budget: number = HIDDEN_SCAN_BUDGET,
|
||||
): Promise<boolean> {
|
||||
const stack = [...roots];
|
||||
let remaining = budget;
|
||||
for (let dir = stack.pop(); dir !== undefined; dir = stack.pop()) {
|
||||
if (--remaining < 0) return true;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readDirSorted(dir);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!isSkippedDirName(entry.name)) stack.push(path.join(dir, entry.name));
|
||||
} else if (entry.isFile() && entry.name === 'DESCRIPTION') {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(dir, entry.name), 'utf-8');
|
||||
const pkgMatch = content.match(/^Package:\s*(\S+)/m);
|
||||
if (pkgMatch && !knownPackageNames.has(pkgMatch[1])) return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the R packages of a repo. Always returns a config: a scan that finds
|
||||
* no package yields an empty {@link RPackageConfig.packages} map, and its
|
||||
* {@link RPackageConfig.truncated} flag says whether that result is complete
|
||||
* (no `DESCRIPTION` anywhere, so no `pkg::fn()` qualifier names a local package)
|
||||
* or merely what the bounded scan managed to see.
|
||||
*/
|
||||
export async function loadRPackageConfig(repoRoot: string): Promise<RPackageConfig> {
|
||||
const packages = new Map<string, string>();
|
||||
const namespaceInfoByPackageDir = new Map<string, RNamespaceInfo>();
|
||||
const packageDirs = new Set<string>();
|
||||
// One allowance (matching work and NFA states) for every exportPattern of every package
|
||||
// found by this load, split into a sub-budget per package: the number of patterns a
|
||||
// repository carries must not multiply the per-matcher cost.
|
||||
const loadBudget = new SharedWorkBudget();
|
||||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
const maxDepth = 3;
|
||||
const maxDirs = 200;
|
||||
let dirsScanned = 0;
|
||||
let truncated = false;
|
||||
// Directories the walk never visits (depth limit, cap leftovers): scanned at the end.
|
||||
const unvisited: string[] = [];
|
||||
|
||||
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
|
||||
const { dir, depth } = scanQueue.shift()!;
|
||||
dirsScanned++;
|
||||
try {
|
||||
const entries = await readDirSorted(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (isSkippedDirName(entry.name)) continue;
|
||||
const child = path.join(dir, entry.name);
|
||||
if (depth < maxDepth) {
|
||||
scanQueue.push({ dir: child, depth: depth + 1 });
|
||||
} else {
|
||||
// Below the depth limit: not visited by the walk; scanned below.
|
||||
unvisited.push(child);
|
||||
}
|
||||
}
|
||||
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, '/');
|
||||
packageDirs.add(pkgDir);
|
||||
// Same name in several directories: keep the preferred one, whatever the
|
||||
// order the walk met them in (see preferredPackageDir).
|
||||
const current = packages.get(pkgName);
|
||||
if (
|
||||
current === undefined ||
|
||||
preferredPackageDir(pkgDir) < preferredPackageDir(current)
|
||||
) {
|
||||
packages.set(pkgName, pkgDir);
|
||||
if (isDev) {
|
||||
logger.info(`📦 Found R package: ${pkgName} at ${pkgDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
const nsPath = path.join(dir, 'NAMESPACE');
|
||||
try {
|
||||
const nsContent = await fs.readFile(nsPath, 'utf-8');
|
||||
const parsedExports = parseRNamespaceExports(nsContent);
|
||||
const namedExports = new Set<string>(parsedExports.namedExports);
|
||||
// POSIX classes are translated; an uncompilable pattern is skipped (never
|
||||
// matches) and recorded so the caller can say so.
|
||||
const exportPatterns: RExportMatcher[] = [];
|
||||
const droppedExportPatterns: RDroppedExportPattern[] = [];
|
||||
let omittedDropped = 0;
|
||||
// This package's share of the load's budget: one hostile package cannot
|
||||
// take all of it and leave the packages after it without their patterns.
|
||||
const packageBudget = loadBudget.child();
|
||||
for (const pattern of new Set(parsedExports.exportPatterns)) {
|
||||
const compiled = compileRExportPatternDetailed(pattern, packageBudget);
|
||||
if ('reason' in compiled) {
|
||||
if (droppedExportPatterns.length < MAX_LISTED_DROPPED_PATTERNS) {
|
||||
droppedExportPatterns.push({ pattern, reason: compiled.reason });
|
||||
} else {
|
||||
omittedDropped++;
|
||||
}
|
||||
} else {
|
||||
exportPatterns.push(compiled.matcher);
|
||||
}
|
||||
}
|
||||
|
||||
namespaceInfoByPackageDir.set(pkgDir, {
|
||||
hasNamespaceFile: true,
|
||||
namedExports,
|
||||
exportPatterns,
|
||||
droppedExportPatterns,
|
||||
omittedDroppedExportPatterns: omittedDropped,
|
||||
exportPatternBudget: packageBudget,
|
||||
importFrom: parseRNamespaceImportFrom(nsContent),
|
||||
});
|
||||
} catch {
|
||||
// No NAMESPACE file or can't read it
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read DESCRIPTION: the package it declares was not discovered.
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read directory: whatever it holds was not discovered.
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Directories still queued when the cap stopped the walk were never visited either.
|
||||
for (const { dir } of scanQueue) unvisited.push(dir);
|
||||
if (
|
||||
!truncated &&
|
||||
unvisited.length > 0 &&
|
||||
(await hidesUndiscoveredPackage(unvisited, new Set(packages.keys())))
|
||||
) {
|
||||
truncated = true;
|
||||
}
|
||||
return { packages, namespaceInfoByPackageDir, packageDirs, truncated };
|
||||
}
|
||||
161
gitnexus/src/core/ingestion/languages/r/post-parse.ts
Normal file
161
gitnexus/src/core/ingestion/languages/r/post-parse.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* 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's `R/` 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 { PostParseContext } from '../../language-provider.js';
|
||||
import {
|
||||
loadRPackageConfig,
|
||||
reportRExportPatternProblems,
|
||||
rExportPatternMatches,
|
||||
type RNamespaceInfo,
|
||||
type RPackageConfig,
|
||||
} from './package-config.js';
|
||||
import { rOwningPackageDir } from './namespace-imports.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) => {
|
||||
// Skip immediately for non-R nodes — avoids doing any further work per node
|
||||
// in this full-graph traversal for repos with no R files at all.
|
||||
if (node.properties.language !== SupportedLanguages.R) return;
|
||||
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. A file belongs to the package whose
|
||||
* `R/` directory contains it (the nearest enclosing discovered package; R loads nothing else
|
||||
* into a package namespace), so only `R/` symbols are tested against that package's NAMESPACE:
|
||||
* a symbol that package does not export is flipped to `isExported: false`. Files outside every
|
||||
* package's `R/` (`tests/`, `vignettes/`, `inst/`, scripts, ...) and packages without a
|
||||
* NAMESPACE file keep the default public behaviour; a nested package without a NAMESPACE does
|
||||
* not inherit its enclosing package's.
|
||||
*/
|
||||
export const refineRExportStatus = (
|
||||
graph: KnowledgeGraph,
|
||||
rPackageConfig: RPackageConfig | null,
|
||||
): void => {
|
||||
if (!rPackageConfig || rPackageConfig.namespaceInfoByPackageDir.size === 0) return;
|
||||
|
||||
// Nodes of one file share an owner; resolve it once per file.
|
||||
const ownerByFile = new Map<string, RNamespaceInfo | undefined>();
|
||||
const namespaceInfoFor = (filePath: string): RNamespaceInfo | undefined => {
|
||||
if (ownerByFile.has(filePath)) return ownerByFile.get(filePath);
|
||||
const pkgDir = rOwningPackageDir(filePath, rPackageConfig);
|
||||
const info =
|
||||
pkgDir === undefined ? undefined : rPackageConfig.namespaceInfoByPackageDir.get(pkgDir);
|
||||
ownerByFile.set(filePath, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
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 nsInfo = namespaceInfoFor(filePath);
|
||||
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
|
||||
// exportPatterns are precompiled linear-time matchers (see loadRPackageConfig) — no per-node recompile.
|
||||
const matched = rExportPatternMatches(nsInfo, name);
|
||||
if (matched) return;
|
||||
|
||||
// Not in NAMESPACE → not exported.
|
||||
node.properties.isExported = false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* R's `LanguageProvider.postParse` hook: deferred owner attach for methods and
|
||||
* properties, then NAMESPACE-driven export refinement. Runs once per analyze,
|
||||
* after every chunk is merged and before scope resolution.
|
||||
*/
|
||||
export const rPostParse = async (ctx: PostParseContext): Promise<void> => {
|
||||
attachDeferredROwners(ctx.graph, ctx.model, 'Method', 'HAS_METHOD');
|
||||
attachDeferredROwners(ctx.graph, ctx.model, 'Property', 'HAS_PROPERTY');
|
||||
const config = await loadRPackageConfig(ctx.repoPath);
|
||||
refineRExportStatus(ctx.graph, config);
|
||||
reportRExportPatternProblems(config);
|
||||
};
|
||||
243
gitnexus/src/core/ingestion/languages/r/qualified-call.ts
Normal file
243
gitnexus/src/core/ingestion/languages/r/qualified-call.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* R `pkg::name()` / `pkg:::name()` qualified calls.
|
||||
*
|
||||
* The scope query captures the whole `namespace_operator` as
|
||||
* `@reference.qualified-name`, so a call site carries `rawQualifiedName`
|
||||
* (`pkg::f`). Without it every tier discards the qualifier and guesses by
|
||||
* name: `dplyr::filter()` binds to an unrelated local `filter`, and
|
||||
* `mypkg::tidy()` inside a wrapper named `tidy` binds to the wrapper itself.
|
||||
*
|
||||
* {@link populateRQualifiedCalls} runs at `populateWorkspaceReferences` time
|
||||
* (before `finalizeScopeModel`, on cold and warm-cache runs alike, with the
|
||||
* workspace's `RPackageConfig`) and decides each qualified site by the
|
||||
* {@link rQualifierLocality} of its package:
|
||||
*
|
||||
* - `external` (not local, and package discovery completed): the site is
|
||||
* dropped from `referenceSites`, so no tier can guess an edge for it;
|
||||
* - `local` with exactly one top-level definition of the name in the
|
||||
* package's `R/` files: that definition is recorded per call site and
|
||||
* returned by {@link resolveRQualifiedFreeCall}, which the free-call
|
||||
* fallback consults before every lexical tier (a precise 0.85 edge);
|
||||
* - `local` with two or more such definitions (same file or across files):
|
||||
* the site is dropped (every valid target is inside the named package, so
|
||||
* any edge would be provably wrong);
|
||||
* - `local` with no definition (a re-export, or a name defined some other
|
||||
* way) and `unknown` locality: the site is left untouched, i.e. today's
|
||||
* behaviour.
|
||||
*
|
||||
* The per-package definition counts are also recorded for the
|
||||
* global-name-fallback veto (`isRGlobalNameFallbackPlausible`).
|
||||
*
|
||||
* Import direction: this file imports `namespace-imports.ts`, never the other
|
||||
* way round, so `parseRQualifier` and the definition-count registry live there
|
||||
* and are re-exported here.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ReferenceSite, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { RPackageConfig } from './package-config.js';
|
||||
import {
|
||||
parseRQualifier,
|
||||
rFileBelongsToDiscoveredPackage,
|
||||
rPackageDirForFile,
|
||||
rRecordQualifiedDefinitionCounts,
|
||||
TOP_LEVEL_BINDABLE_TYPES,
|
||||
} from './namespace-imports.js';
|
||||
|
||||
export { parseRQualifier } from './namespace-imports.js';
|
||||
|
||||
/** Whether a qualifier's package is a package of the analysed repo. */
|
||||
export type RQualifierLocality = 'local' | 'external' | 'unknown';
|
||||
|
||||
/**
|
||||
* True when `filePath` sits under a `<pkg>/R/` directory: a `/`-delimited path
|
||||
* segment equal to `pkg` immediately followed by `R/` (`pkg/R/a.R`,
|
||||
* `deep/x/pkg/R/a.R`; not `mypkg/R/a.R`, not `pkg/scripts/a.R`).
|
||||
*
|
||||
* The directory name stands in for the package name only for a file that no discovered
|
||||
* package owns (the fallback for a package whose `DESCRIPTION` discovery missed or that has
|
||||
* none). A file under a discovered package's `R/` belongs to the package its `DESCRIPTION`
|
||||
* names, whatever its directory is called, so it is never evidence for another name.
|
||||
*/
|
||||
function isUnderPackageRDir(
|
||||
pkg: string,
|
||||
filePath: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
if (!normalized.startsWith(`${pkg}/R/`) && !normalized.includes(`/${pkg}/R/`)) return false;
|
||||
return !rFileBelongsToDiscoveredPackage(normalized, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locality of the package named by a qualifier.
|
||||
*
|
||||
* - `local`: `pkg` is in `cfg.packages`, or a parsed file sits under a
|
||||
* `<pkg>/R/` directory (a package whose `DESCRIPTION` discovery missed or
|
||||
* that has none; root-level packages are found through `cfg` only);
|
||||
* - `external`: not local, and `cfg` exists and its discovery was complete
|
||||
* (`!cfg.truncated`), so `pkg` provably is not part of the repo. This
|
||||
* includes a complete scan that found no package at all (no `DESCRIPTION`
|
||||
* anywhere): the config is then empty, not absent;
|
||||
* - `unknown`: not found, but discovery was truncated or there is no config
|
||||
* (hand-built callers), so `pkg` may still be a local package that was not seen.
|
||||
*
|
||||
* One predicate, so the populate step and the veto cannot disagree on "local".
|
||||
*/
|
||||
export function rQualifierLocality(
|
||||
pkg: string,
|
||||
cfg: RPackageConfig | null | undefined,
|
||||
parsedFilePaths: ReadonlySet<string>,
|
||||
): RQualifierLocality {
|
||||
if (cfg?.packages.has(pkg) === true) return 'local';
|
||||
for (const filePath of parsedFilePaths) {
|
||||
if (isUnderPackageRDir(pkg, filePath, cfg)) return 'local';
|
||||
}
|
||||
if (cfg === null || cfg === undefined) return 'unknown';
|
||||
return cfg.truncated === true ? 'unknown' : 'external';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call site → the single definition its `pkg::name()` qualifier names, for the
|
||||
* current run. Module state, replaced wholesale by every
|
||||
* {@link populateRQualifiedCalls} call. Keyed by file path and start position
|
||||
* (the key `free-call-fallback.ts` uses for its handled sites), not by object
|
||||
* identity, so it survives the frozen `ParsedFile` copies made after populate.
|
||||
*/
|
||||
let preciseBySite: Map<string, SymbolDefinition> = new Map();
|
||||
|
||||
const siteKey = (filePath: string, atRange: { startLine: number; startCol: number }): string =>
|
||||
`${filePath}:${atRange.startLine}:${atRange.startCol}`;
|
||||
|
||||
function isQualifiedCall(site: ReferenceSite): site is ReferenceSite & {
|
||||
readonly rawQualifiedName: string;
|
||||
} {
|
||||
return site.kind === 'call' && site.rawQualifiedName !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per name, every top-level function/class definition (one entry per binding
|
||||
* ref, so a same-file redefinition counts twice) of the given package files.
|
||||
* Read from Module-scope `local` bindings at extraction time, like
|
||||
* `rFileTopLevel`, but keeping the definitions rather than the names.
|
||||
*/
|
||||
function collectTopLevelDefinitions(files: readonly ParsedFile[]): Map<string, SymbolDefinition[]> {
|
||||
const byName = new Map<string, SymbolDefinition[]>();
|
||||
for (const parsed of files) {
|
||||
const moduleScope = parsed.scopes.find((s) => s.id === parsed.moduleScope);
|
||||
if (moduleScope === undefined) continue;
|
||||
for (const [name, refs] of moduleScope.bindings) {
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'local' || !TOP_LEVEL_BINDABLE_TYPES.has(ref.def.type)) continue;
|
||||
const list = byName.get(name);
|
||||
if (list === undefined) byName.set(name, [ref.def]);
|
||||
else list.push(ref.def);
|
||||
}
|
||||
}
|
||||
}
|
||||
return byName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide every qualified call site of `parsedFiles` as described in the file
|
||||
* header: drop external and duplicate-definition sites (in place, through
|
||||
* frozen `ParsedFile` copies), record the precise target of single-definition
|
||||
* sites, leave every other site untouched. Resets all module state first, so a
|
||||
* run never sees the previous run's answers.
|
||||
*/
|
||||
export function populateRQualifiedCalls(
|
||||
parsedFiles: ParsedFile[],
|
||||
ctx: { readonly resolutionConfig?: unknown },
|
||||
): void {
|
||||
preciseBySite = new Map();
|
||||
const cfg = (ctx.resolutionConfig as RPackageConfig | null | undefined) ?? null;
|
||||
if (cfg !== null) rRecordQualifiedDefinitionCounts(cfg, new Map());
|
||||
|
||||
// Packages named by some qualified call, and how local each one is.
|
||||
let filePaths: Set<string> | undefined;
|
||||
const localityByPackage = new Map<string, RQualifierLocality>();
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (!isQualifiedCall(site)) continue;
|
||||
const pkg = parseRQualifier(site.rawQualifiedName);
|
||||
if (pkg === undefined || localityByPackage.has(pkg)) continue;
|
||||
filePaths ??= new Set(parsedFiles.map((p) => p.filePath));
|
||||
localityByPackage.set(pkg, rQualifierLocality(pkg, cfg, filePaths));
|
||||
}
|
||||
}
|
||||
if (localityByPackage.size === 0) return;
|
||||
|
||||
// Top-level definitions of each named local package, by name.
|
||||
const localPackages = [...localityByPackage].filter(([, l]) => l === 'local').map(([p]) => p);
|
||||
const definitions = new Map<string, Map<string, SymbolDefinition[]>>();
|
||||
if (localPackages.length > 0) {
|
||||
const filesByPackage = localPackages.map((pkg) => ({ pkg, files: [] as ParsedFile[] }));
|
||||
for (const parsed of parsedFiles) {
|
||||
const owner = rPackageDirForFile(parsed.filePath, cfg)?.name;
|
||||
for (const { pkg, files } of filesByPackage) {
|
||||
// In the config a package owns its `R/` files (longest-dir wins); otherwise
|
||||
// it is local only through the `<pkg>/R/` path, as `rQualifierLocality` decided.
|
||||
const inPackage =
|
||||
cfg?.packages.has(pkg) === true
|
||||
? owner === pkg
|
||||
: isUnderPackageRDir(pkg, parsed.filePath, cfg);
|
||||
if (inPackage) files.push(parsed);
|
||||
}
|
||||
}
|
||||
for (const { pkg, files } of filesByPackage) {
|
||||
definitions.set(pkg, collectTopLevelDefinitions(files));
|
||||
}
|
||||
if (cfg !== null) {
|
||||
rRecordQualifiedDefinitionCounts(
|
||||
cfg,
|
||||
new Map(
|
||||
[...definitions].map(([pkg, byName]) => [
|
||||
pkg,
|
||||
new Map([...byName].map(([name, defs]) => [name, defs.length])),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Decide each site.
|
||||
for (let i = 0; i < parsedFiles.length; i++) {
|
||||
const parsed = parsedFiles[i];
|
||||
const kept: ReferenceSite[] = [];
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (!isQualifiedCall(site)) {
|
||||
kept.push(site);
|
||||
continue;
|
||||
}
|
||||
const pkg = parseRQualifier(site.rawQualifiedName);
|
||||
const locality = pkg === undefined ? 'unknown' : localityByPackage.get(pkg);
|
||||
if (locality === 'external') continue;
|
||||
if (locality === 'local' && pkg !== undefined) {
|
||||
const found = definitions.get(pkg)?.get(site.name) ?? [];
|
||||
if (found.length >= 2) continue;
|
||||
if (found.length === 1) preciseBySite.set(siteKey(parsed.filePath, site.atRange), found[0]);
|
||||
}
|
||||
kept.push(site);
|
||||
}
|
||||
if (kept.length !== parsed.referenceSites.length) {
|
||||
parsedFiles[i] = Object.freeze({ ...parsed, referenceSites: Object.freeze(kept) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveQualifiedFreeCall` for R: the definition
|
||||
* {@link populateRQualifiedCalls} recorded for this call site, if any.
|
||||
*
|
||||
* The contract types `site` without its position; the runtime object is the
|
||||
* `ReferenceSite`, which has `atRange`. That is read through a narrow cast
|
||||
* here instead of widening the shared contract.
|
||||
*/
|
||||
export function resolveRQualifiedFreeCall(
|
||||
site: object,
|
||||
callerParsed: ParsedFile,
|
||||
): SymbolDefinition | undefined {
|
||||
const atRange = (site as { readonly atRange?: { startLine: number; startCol: number } }).atRange;
|
||||
return atRange === undefined
|
||||
? undefined
|
||||
: preciseBySite.get(siteKey(callerParsed.filePath, atRange));
|
||||
}
|
||||
348
gitnexus/src/core/ingestion/languages/r/query.ts
Normal file
348
gitnexus/src/core/ingestion/languages/r/query.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
/**
|
||||
* Tree-sitter query for R scope captures (RFC #909 Ring 3).
|
||||
*
|
||||
* R has no dedicated class syntax — R6 classes are `R6Class(...)` calls, S4/R5
|
||||
* classes are `setClass(...)`/`setRefClass(...)` calls — so this query mirrors
|
||||
* the shapes the legacy `R_QUERIES` (`tree-sitter-queries.ts`) already proved
|
||||
* queryable for structural extraction (the heritage shapes below have no
|
||||
* counterpart there), renamed onto the scope-resolution capture vocabulary
|
||||
* (`@scope.*`, `@declaration.*`, `@reference.*`, `@import.*`,
|
||||
* `@type-binding.*`).
|
||||
*
|
||||
* R specifics encoded here:
|
||||
*
|
||||
* - A Class scope spans the ENTIRE `R6Class(...)`/`setClass(...)` call
|
||||
* (the whole `binary_operator` for the `name <- R6Class(...)` shape),
|
||||
* not just `public = list(...)` — the `inherit=`/`contains=` argument
|
||||
* must fall inside the scope's range so the `@reference.inherits` sites
|
||||
* resolve against the deriving class's own scope.
|
||||
*
|
||||
* - `@declaration.function`/`@declaration.class` anchor on the WHOLE
|
||||
* `name <- <rhs>` assignment (matching the legacy `@definition.*`
|
||||
* anchors exactly), so `resolveDefGraphId`'s position-based (Function)
|
||||
* and qualifiedName-keyed (Class) reconciliation lands on the SAME
|
||||
* graph node the legacy extractor already created.
|
||||
*
|
||||
* - `@declaration.method` anchors on the `name = function(...)` ARGUMENT
|
||||
* inside `public =`/`private =`/`active =`/`methods = list(...)`.
|
||||
*
|
||||
* - A def is owned by the innermost scope whose range EQUALS its anchor
|
||||
* range (`scope-extractor.ts` Pass 2), and `resolveCallerGraphId` walks
|
||||
* from a call site up to the first scope that owns a callable def. The
|
||||
* blanket `(function_definition) @scope.function` is strictly narrower
|
||||
* than the assignment/argument anchors, so on its own it never owns a
|
||||
* def: every named function/method would be owned by the module or
|
||||
* Class scope and every call in the file would be credited to the
|
||||
* FIRST callable in it. So each named-callable anchor also gets a
|
||||
* `@scope.function` on the SAME node, placed directly beside its
|
||||
* `@declaration.*` pattern below (the two must stay in lockstep — a
|
||||
* declaration anchor without a matching scope silently reverts that
|
||||
* shape to first-callable attribution). The def's own binding still
|
||||
* hoists to the PARENT scope (anchor range == scope range), so name
|
||||
* lookup is unchanged. The blanket rule is kept: it still covers
|
||||
* anonymous functions (`lapply(x, function(z) ...)`), which own nothing.
|
||||
* Calls in a Class body outside any method (an R6 field default, a
|
||||
* `setClass(validity = function...)` body) are credited to the Class.
|
||||
*
|
||||
* - No `@declaration.qualified_name` is emitted for `@declaration.class`
|
||||
* — R's legacy extractor has no `classExtractor`, so a Class node's
|
||||
* `qualifiedName` today is the bare class-name text. Omitting the
|
||||
* capture lets `buildDefFromDeclarationMatch` default `qualifiedName`
|
||||
* to the bare name, matching the legacy node so `resolveDefGraphId`'s
|
||||
* name-keyed fallback finds it.
|
||||
*
|
||||
* - S4/setGeneric/setMethod declarations are deliberately NOT captured
|
||||
* here — they are already produced by the legacy extractor via
|
||||
* R_QUERIES, independent of scope-resolution, and none of the target
|
||||
* scenarios needs a NEW declaration for them. Their inner
|
||||
* `function_definition` bodies still get a `@scope.function` from the
|
||||
* blanket rule below (an unnamed scope, harmless).
|
||||
*
|
||||
* - `library()`/`require()` are wildcard imports (`@import.wildcard`) —
|
||||
* every top-level name of the named local package becomes visible,
|
||||
* unfiltered by NAMESPACE (see `rProvider.expandsWildcardTo`).
|
||||
* `source()` is a side-effect import (`@import.side-effect`) — it
|
||||
* establishes a file dependency but binds no name; cross-file free
|
||||
* calls into a `source()`d file resolve via `allowGlobalFreeCallFallback`
|
||||
* instead (`rScopeResolver`).
|
||||
*
|
||||
* - `obj$method()` (the `extract_operator`, R's `$`) is a member call with
|
||||
* an explicit receiver — the same shape as Ruby's `obj.method`.
|
||||
* `Type$new(...)` is R6's constructor spelling: captured separately as
|
||||
* `@type-binding.constructor` so `x <- Type$new(...)` types `x`.
|
||||
*
|
||||
* Exposes lazy `Parser` and `Query` singletons so callers don't pay
|
||||
* tree-sitter init cost per file.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
import R from '@eagleoutice/tree-sitter-r';
|
||||
|
||||
/**
|
||||
* Exported for `value-ref-dispatchability.test.ts`-style whole-query readers.
|
||||
* Not part of the provider surface — nothing else should import it.
|
||||
*/
|
||||
export const R_SCOPE_QUERY = `
|
||||
;; ── Scopes ───────────────────────────────────────────────────────────────
|
||||
|
||||
(program) @scope.module
|
||||
|
||||
(function_definition) @scope.function
|
||||
|
||||
;; R6 class via namespace call (ClassName <- R6::R6Class(...)) — the scope
|
||||
;; spans the WHOLE assignment, including inherit=, so heritage sites resolve
|
||||
;; inside the deriving class's own scope.
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_scope
|
||||
(#match? @_r6ns_scope "^R6Class$")))) @scope.class
|
||||
|
||||
;; R6 class via bare call (ClassName <- R6Class(...))
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_scope
|
||||
(#match? @_r6bare_scope "^R6Class$"))) @scope.class
|
||||
|
||||
;; S4/R5 class (setClass(...) / setRefClass(...)) — scope spans the whole call.
|
||||
(call
|
||||
function: (identifier) @_s4_scope
|
||||
(#match? @_s4_scope "^(setClass|setRefClass)$")) @scope.class
|
||||
|
||||
;; ── Declarations — functions ──────────────────────────────────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
rhs: (function_definition)) @declaration.function
|
||||
|
||||
;; Function scope on the SAME assignment node as the declaration above, so the
|
||||
;; def is owned by its own scope (and calls inside the body attribute to it).
|
||||
;; This pattern and the \`@declaration.function\` pattern above must stay in
|
||||
;; lockstep — identical structure, identical anchor node (see file header).
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
rhs: (function_definition)) @scope.function
|
||||
|
||||
;; ── Declarations — classes (name only; no qualified_name, see file header) ─
|
||||
;;
|
||||
;; The setClass/setRefClass declaration and the source/library/require imports
|
||||
;; below capture EVERY string (imports: identifier or string) argument of the call.
|
||||
;; \`emitRScopeCaptures\` keeps only the one that names the class or import
|
||||
;; (\`isRNonNamingScopeMatch\`): the argument spelled with the first formal, else the
|
||||
;; first unnamed one, so a leading comment or a named \`lib.loc =\` / \`local =\` does
|
||||
;; not change the answer. A query-level anchor cannot skip an unbounded run of
|
||||
;; leading comments and named arguments without a repeated sibling group, which
|
||||
;; is quadratic in the argument count on every call.
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_decl
|
||||
(#match? @_r6ns_decl "^R6Class$")))) @declaration.class
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @declaration.name
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_decl
|
||||
(#match? @_r6bare_decl "^R6Class$"))) @declaration.class
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4_decl
|
||||
(#match? @_s4_decl "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @declaration.name)))) @declaration.class
|
||||
|
||||
;; ── Declarations — R6/R5 methods ──────────────────────────────────────────
|
||||
|
||||
;; R6 methods via namespace call (inside public/private/active = list(...))
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class1
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_m
|
||||
(#match? @_r6ns_m "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_section1
|
||||
(#match? @_section1 "^(public|private|active)$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn1
|
||||
(#match? @_listfn1 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_definition)) @declaration.method))))))
|
||||
|
||||
;; R6 methods via bare call (inside public/private/active = list(...))
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class2
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_m
|
||||
(#match? @_r6bare_m "^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) @declaration.name
|
||||
value: (function_definition)) @declaration.method))))))
|
||||
|
||||
;; R5 methods (inside setRefClass(... methods = list(...)))
|
||||
(binary_operator
|
||||
lhs: (identifier) @_class3
|
||||
rhs: (call
|
||||
function: (identifier) @_refFn_m
|
||||
(#match? @_refFn_m "^setRefClass$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_methods3
|
||||
(#match? @_methods3 "^methods$")
|
||||
value: (call
|
||||
function: (identifier) @_listfn3
|
||||
(#match? @_listfn3 "^list$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @declaration.name
|
||||
value: (function_definition)) @declaration.method))))))
|
||||
|
||||
;; Function scope on the SAME \`name = function(...)\` argument node that the
|
||||
;; three \`@declaration.method\` patterns above anchor on, so each R6/R5 method
|
||||
;; def is owned by its own Function scope (a child of the Class scope) rather
|
||||
;; than by the Class scope. Deliberately generic — any named function-valued
|
||||
;; argument — so it cannot drift from the three long nesting patterns above; it
|
||||
;; is inert for non-method arguments (\`FUN = function...\`, \`error = function(e)\`
|
||||
;; own no defs, and calls inside them walk up to the enclosing callable). The
|
||||
;; declaration patterns above and this pattern must stay in lockstep.
|
||||
(argument
|
||||
name: (identifier)
|
||||
value: (function_definition)) @scope.function
|
||||
|
||||
;; ── Imports ────────────────────────────────────────────────────────────────
|
||||
|
||||
;; source("path.R") — side-effect: establishes a file dependency, binds no name.
|
||||
(call
|
||||
function: (identifier) @_srcfn
|
||||
(#match? @_srcfn "^source$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: [(identifier) (string)] @import.source))) @import.side-effect
|
||||
|
||||
;; library("pkg") / require("pkg") — wildcard: every top-level name of the
|
||||
;; named local package becomes visible (rProvider.expandsWildcardTo).
|
||||
(call
|
||||
function: (identifier) @_libfn
|
||||
(#match? @_libfn "^(library|require)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: [(identifier) (string)] @import.source))) @import.wildcard
|
||||
|
||||
;; ── Type bindings — R6 constructor (x <- Type$new(...)) ───────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier) @type-binding.name
|
||||
rhs: (call
|
||||
function: (extract_operator
|
||||
lhs: (identifier) @type-binding.type
|
||||
rhs: (identifier) @_newfn
|
||||
(#match? @_newfn "^new$")))) @type-binding.constructor
|
||||
|
||||
;; ── References — heritage: R6 inherit= ────────────────────────────────────
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
rhs: (call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @_r6ns_h
|
||||
(#match? @_r6ns_h "^R6Class$"))
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_inh1
|
||||
(#match? @_inh1 "^inherit$")
|
||||
value: (identifier) @reference.inherits))))
|
||||
|
||||
(binary_operator
|
||||
lhs: (identifier)
|
||||
rhs: (call
|
||||
function: (identifier) @_r6bare_h
|
||||
(#match? @_r6bare_h "^R6Class$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_inh2
|
||||
(#match? @_inh2 "^inherit$")
|
||||
value: (identifier) @reference.inherits))))
|
||||
|
||||
;; ── References — heritage: S4 contains= (single parent, incl. "VIRTUAL") ──
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4h1
|
||||
(#match? @_s4h1 "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_contains1
|
||||
(#match? @_contains1 "^(contains|CONTAINS)$")
|
||||
value: (string
|
||||
content: (string_content) @reference.inherits))))
|
||||
|
||||
;; ── References — heritage: S4 contains=c(...) (multiple parents) ──────────
|
||||
|
||||
(call
|
||||
function: (identifier) @_s4h2
|
||||
(#match? @_s4h2 "^(setClass|setRefClass)$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
name: (identifier) @_contains2
|
||||
(#match? @_contains2 "^(contains|CONTAINS)$")
|
||||
value: (call
|
||||
function: (identifier) @_cfn2
|
||||
(#match? @_cfn2 "^c$")
|
||||
arguments: (arguments
|
||||
(argument
|
||||
value: (string
|
||||
content: (string_content) @reference.inherits)))))))
|
||||
|
||||
;; ── References — calls ────────────────────────────────────────────────────
|
||||
|
||||
;; Free calls: foo(x)
|
||||
(call
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; Namespaced calls: pkg::func() and pkg:::func(). The whole namespace_operator
|
||||
;; is captured as the qualified name so the qualifier survives the parse cache.
|
||||
(call
|
||||
function: (namespace_operator
|
||||
rhs: (identifier) @reference.name) @reference.qualified-name) @reference.call.free
|
||||
|
||||
;; Member calls via $: obj$method()
|
||||
(call
|
||||
function: (extract_operator
|
||||
lhs: (identifier) @reference.receiver
|
||||
rhs: (identifier) @reference.name)) @reference.call.member
|
||||
`;
|
||||
|
||||
let _parser: Parser | null = null;
|
||||
let _query: Parser.Query | null = null;
|
||||
|
||||
export function getRParser(): Parser {
|
||||
if (_parser === null) {
|
||||
_parser = new Parser();
|
||||
_parser.setLanguage(R as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(R as Parameters<Parser['setLanguage']>[0], R_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
235
gitnexus/src/core/ingestion/languages/r/scope-resolver.ts
Normal file
235
gitnexus/src/core/ingestion/languages/r/scope-resolver.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
/**
|
||||
* R `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
|
||||
* generic `runScopeResolution` orchestrator.
|
||||
*
|
||||
* Thin wiring: import resolution reuses `resolveRImportTarget`
|
||||
* (`import-resolvers/r.ts`), an adapter over the existing
|
||||
* `resolveRImportInternal`, with the repo's local R packages
|
||||
* threaded through `loadRPackageConfig`. Heritage (R6 `inherit=`, S4
|
||||
* `contains=`) mostly rides the generic `@reference.inherits` mechanism —
|
||||
* R6/S4 base names are ordinary scope-bound class names, unlike Ruby's
|
||||
* `include`/`extend`, which are method calls. The one exception is S4's
|
||||
* `contains = "VIRTUAL"` sentinel (`setClass(x, contains = "VIRTUAL", ...)`,
|
||||
* R's spelling for an abstract base): no fixture ever declares
|
||||
* `setClass("VIRTUAL", ...)`, so the generic scope-based lookup
|
||||
* (`resolveInheritanceBaseInScope` / `findClassBindingInScope`) can never
|
||||
* find a binding for it — that lookup is scope-model-only and never
|
||||
* consults graph nodes directly. `emitRVirtualHeritageEdges` below mints
|
||||
* the placeholder `VIRTUAL` graph node and its EXTENDS edge directly,
|
||||
* mirroring Ruby's `emitRubyMixinEdges` shape for the same reason: a
|
||||
* heritage target that R code names but never declares.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import {
|
||||
findEnclosingClassDef,
|
||||
populateClassOwnedMembers,
|
||||
} from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { loadRPackageConfig } from './package-config.js';
|
||||
import { resolveRImportTarget } from '../../import-resolvers/r.js';
|
||||
import {
|
||||
isRGlobalNameFallbackPlausible,
|
||||
populateRNamespaceImports,
|
||||
rFileTopLevel,
|
||||
} from './namespace-imports.js';
|
||||
import { rProvider } from '../r.js';
|
||||
import { populateRQualifiedCalls, resolveRQualifiedFreeCall } from './qualified-call.js';
|
||||
import { rArityCompatibility, rMergeBindings } from './simple-hooks.js';
|
||||
|
||||
/** Literal string `"VIRTUAL"` graph id — one node shared by every S4 class
|
||||
* that names it as a base, minted lazily on first use. */
|
||||
const VIRTUAL_CLASS_NAME = 'VIRTUAL';
|
||||
|
||||
/**
|
||||
* Mint the `VIRTUAL` placeholder Class node (once) and an EXTENDS edge from
|
||||
* every S4 class whose `contains=` names it. Scope is deliberately limited to
|
||||
* the literal string `"VIRTUAL"`: other S4 base-type sentinels (`"numeric"`,
|
||||
* `"list"`, …) are not treated as placeholder classes, which would be a
|
||||
* separate design decision.
|
||||
*
|
||||
* Idempotent: re-seeds its dedup set from any `EXTENDS` edge already
|
||||
* targeting a node named `VIRTUAL` before emitting, and re-uses the graph's
|
||||
* existing `VIRTUAL` node instead of minting a second one when the
|
||||
* orchestrator re-runs resolution.
|
||||
*/
|
||||
function emitRVirtualHeritageEdges(
|
||||
graph: KnowledgeGraph,
|
||||
_parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
scopes?: ScopeResolutionIndexes,
|
||||
): void {
|
||||
if (scopes === undefined) return;
|
||||
|
||||
const virtualGraphId = generateId('Class', VIRTUAL_CLASS_NAME);
|
||||
const emitted = new Set<string>();
|
||||
for (const rel of graph.iterRelationshipsByType('EXTENDS')) {
|
||||
if (rel.targetId === virtualGraphId) emitted.add(rel.sourceId);
|
||||
}
|
||||
|
||||
let virtualNodeMinted = graph.getNode(virtualGraphId) !== undefined;
|
||||
|
||||
for (const site of scopes.referenceSites) {
|
||||
if (site.kind !== 'inherits' || site.name !== VIRTUAL_CLASS_NAME) continue;
|
||||
const callerClass = findEnclosingClassDef(site.inScope, scopes);
|
||||
if (callerClass === undefined) continue;
|
||||
const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup);
|
||||
if (callerGraphId === undefined || emitted.has(callerGraphId)) continue;
|
||||
|
||||
if (!virtualNodeMinted) {
|
||||
graph.addNode({
|
||||
id: virtualGraphId,
|
||||
label: 'Class',
|
||||
properties: { name: VIRTUAL_CLASS_NAME, filePath: callerClass.filePath },
|
||||
});
|
||||
virtualNodeMinted = true;
|
||||
}
|
||||
emitted.add(callerGraphId);
|
||||
graph.addRelationship({
|
||||
id: generateId('EXTENDS', `${callerGraphId}->${virtualGraphId}`),
|
||||
sourceId: callerGraphId,
|
||||
targetId: virtualGraphId,
|
||||
type: 'EXTENDS',
|
||||
confidence: 0.85,
|
||||
reason: 'scope-resolution: inherits (virtual)',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate all top-level names exported from a target module scope's file.
|
||||
* `library()`/`require()` are R's wildcard imports — every top-level name of
|
||||
* the named local package becomes visible, UNFILTERED by NAMESPACE (a
|
||||
* `library()`/`require()` call brings the whole package's namespace into
|
||||
* scope regardless of what NAMESPACE declares as exported — confirmed by a
|
||||
* fixture where `pkgA/NAMESPACE` does not export `ResultSet`, yet
|
||||
* `require("pkgA")` must still resolve it).
|
||||
*
|
||||
* A bare name is dropped when the same file also defines a dotted top-level
|
||||
* name with that tail (`foo` next to `print.foo`). The shared finalize indexes
|
||||
* every def by the text after its last `.`, so `foo` would be bound to
|
||||
* whichever of the two comes first in the file, and `print.foo` (an S3 method,
|
||||
* never callable as `foo()`) can win. The dropped name resolves through the
|
||||
* free-call fallback instead, where {@link rIsCallableVisibleFromCaller}
|
||||
* removes the dotted def from the candidates. Order-blind, like the matching
|
||||
* guard in `import-resolvers/r.ts`.
|
||||
*/
|
||||
export function expandRWildcardNames(
|
||||
targetModuleScope: ScopeId,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
): readonly string[] {
|
||||
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
|
||||
if (target === undefined) return [];
|
||||
const { dottedTails } = rFileTopLevel(target);
|
||||
const seen = new Set<string>();
|
||||
const names: string[] = [];
|
||||
for (const def of target.localDefs) {
|
||||
const qn = def.qualifiedName;
|
||||
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
|
||||
if (dottedTails.has(qn)) continue; // shares its tail with an S3 method: see above
|
||||
if (seen.has(qn)) continue;
|
||||
seen.add(qn);
|
||||
names.push(qn);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare R call `foo(x)` can never invoke a def whose name contains a `.`
|
||||
* (`print.foo` is only reachable as `print.foo(...)` or through S3 dispatch),
|
||||
* yet the free-call fallback's simple-name index keys it under `foo`. Refuse
|
||||
* such candidates so `foo(x)` cannot collide with `print.foo`.
|
||||
*
|
||||
* Applies to every candidate the hook sees, including R6/R5 members whose
|
||||
* qualified name is `Class.member`: a bare `member(x)` never reaches those
|
||||
* either.
|
||||
*/
|
||||
export function rIsCallableVisibleFromCaller(ctx: {
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
return !(ctx.candidate.qualifiedName ?? '').includes('.');
|
||||
}
|
||||
|
||||
export const rScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.R,
|
||||
languageProvider: rProvider,
|
||||
importEdgeReason: 'r-scope: import',
|
||||
|
||||
loadResolutionConfig: (repoPath: string) => loadRPackageConfig(repoPath),
|
||||
|
||||
resolveImportTarget: resolveRImportTarget,
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandRWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
mergeBindings: rMergeBindings,
|
||||
arityCompatibility: rArityCompatibility,
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
|
||||
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) =>
|
||||
emitRVirtualHeritageEdges(graph, parsedFiles, nodeLookup, scopes),
|
||||
|
||||
// R has no `super`.
|
||||
isSuperReceiver: () => false,
|
||||
|
||||
// R's dynamic-dispatch `self$field$method()` chains and R6/S4 field access
|
||||
// benefit from the same heuristic Ruby/Python rely on.
|
||||
fieldFallbackOnMethodLookup: true,
|
||||
|
||||
// `source()` is a side-effect import (binds no name) — a cross-file free
|
||||
// call into a `source()`d file (e.g. `HelperFunc(x)`) has no lexical or
|
||||
// import binding to resolve through, so it resolves via a unique
|
||||
// workspace-wide name match instead. `library()`/`require()`'s own
|
||||
// wildcard-bound names still resolve through `expandsWildcardTo` above;
|
||||
// this only covers what that path cannot reach.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
// A named import binds only to a MODULE-LEVEL definition of the target
|
||||
// file. Without this, `findExportByName` indexes every `localDefs` entry by
|
||||
// simple name, so an R6/R5 method named like an imported symbol could win
|
||||
// the binding over the top-level function.
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
// NAMESPACE `importFrom()` names provided by a LOCAL package become
|
||||
// synthesised `named` imports (`resolveRImportTarget`'s named branch binds
|
||||
// them). Runs before finalize and over warm-cache ParsedFiles too.
|
||||
//
|
||||
// Followed by `populateRQualifiedCalls`: it decides each
|
||||
// `pkg::name()` site by the qualifier — drops external / ambiguous ones and
|
||||
// records the single definition a local one names for
|
||||
// `resolveQualifiedFreeCall` below. Import synthesis is unchanged and first.
|
||||
populateWorkspaceReferences: (parsedFiles, ctx) => {
|
||||
populateRNamespaceImports(parsedFiles, ctx);
|
||||
populateRQualifiedCalls(parsedFiles, ctx);
|
||||
},
|
||||
resolveQualifiedFreeCall: resolveRQualifiedFreeCall,
|
||||
|
||||
// `populateRNamespaceImports` and `populateRQualifiedCalls` read only ParsedFile scopes, never source
|
||||
// text: without this, merely declaring the hook makes the pipeline read the
|
||||
// text of every R file on the main thread.
|
||||
postExtractSourceTextPolicy: 'uncached-files',
|
||||
|
||||
// A bare call whose name the caller package's NAMESPACE imports from a
|
||||
// package other than the one the unique-name guess landed in is impossible
|
||||
// in R (see `isRGlobalNameFallbackPlausible` for the exact rule, the
|
||||
// qualifier-first handling of `pkg::name()` calls and the documented
|
||||
// `import()` limitation).
|
||||
isGlobalNameFallbackPlausible: isRGlobalNameFallbackPlausible,
|
||||
|
||||
// The free-call fallback keys defs by the text after the last `.` of their
|
||||
// qualified name, so an S3 method `print.foo` sits next to a bare `foo` and
|
||||
// makes the bare call ambiguous (no edge). A bare call can never invoke a
|
||||
// dotted def, so drop those candidates.
|
||||
isCallableVisibleFromCaller: rIsCallableVisibleFromCaller,
|
||||
};
|
||||
49
gitnexus/src/core/ingestion/languages/r/simple-hooks.ts
Normal file
49
gitnexus/src/core/ingestion/languages/r/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { BindingRef, Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
local: 0,
|
||||
namespace: 1,
|
||||
import: 2,
|
||||
reexport: 3,
|
||||
wildcard: 4,
|
||||
};
|
||||
|
||||
/** Local declarations shadow imports; deterministic order within a tier.
|
||||
* Same shape as Zig's `zigMergeBindings` — R has no per-scope binding
|
||||
* precedence rule beyond "the tightest-origin binding wins". */
|
||||
export function rMergeBindings(
|
||||
existing: readonly BindingRef[],
|
||||
incoming: readonly BindingRef[],
|
||||
_scopeId: string,
|
||||
): BindingRef[] {
|
||||
const all = [...existing, ...incoming];
|
||||
if (all.length === 0) return [];
|
||||
let bestTier = 99;
|
||||
for (const b of all) {
|
||||
const t = TIER[b.origin] ?? 99;
|
||||
if (t < bestTier) bestTier = t;
|
||||
}
|
||||
const byNode = new Map<string, BindingRef>();
|
||||
for (const b of all) {
|
||||
if ((TIER[b.origin] ?? 99) !== bestTier) continue;
|
||||
if (!byNode.has(b.def.nodeId)) byNode.set(b.def.nodeId, b);
|
||||
}
|
||||
return [...byNode.values()].sort((a, b) => a.def.nodeId.localeCompare(b.def.nodeId));
|
||||
}
|
||||
|
||||
/** R declarations carry no synthesized arity metadata (no `...`/default-arg
|
||||
* capture exists yet), so the comparison is always 'unknown' — a real bounds
|
||||
* check that turns on if arity captures are added later. Same rationale as
|
||||
* Zig's `zigArityCompatibility`. */
|
||||
export function rArityCompatibility(
|
||||
callsite: Callsite,
|
||||
def: SymbolDefinition,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const max = def.parameterCount;
|
||||
const min = def.requiredParameterCount;
|
||||
if (max === undefined && min === undefined) return 'unknown';
|
||||
if (!Number.isFinite(callsite.arity) || callsite.arity! < 0) return 'unknown';
|
||||
if (min !== undefined && callsite.arity! < min) return 'incompatible';
|
||||
if (max !== undefined && callsite.arity! > max) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
250
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal file
250
gitnexus/src/core/ingestion/method-extractors/r.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// 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 { getRMatchedArgument } from '../languages/r/naming-argument.js';
|
||||
import { getRClassNameArgument } from '../field-extractors/r.js';
|
||||
import type {
|
||||
MethodExtractor,
|
||||
MethodExtractorContext,
|
||||
ExtractedMethods,
|
||||
MethodInfo,
|
||||
MethodVisibility,
|
||||
ParameterInfo,
|
||||
} from '../method-types.js';
|
||||
|
||||
/** `setMethod`'s formals, in order (methods package). */
|
||||
const SET_METHOD_FORMALS: readonly string[] = [
|
||||
'f',
|
||||
'signature',
|
||||
'definition',
|
||||
'where',
|
||||
'valueClass',
|
||||
'sealed',
|
||||
];
|
||||
|
||||
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;
|
||||
|
||||
// The owner is the `signature` argument, as R matches it (named first, then by position).
|
||||
const signature = getRMatchedArgument(args, SET_METHOD_FORMALS, 'signature');
|
||||
return extractQuotedString(signature?.childForFieldName('value') ?? 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,
|
||||
column: entry.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// The class name is the argument that takes the `Class` formal (by name, else the
|
||||
// first unnamed one), not the first string: `contains = "Base"` may precede it.
|
||||
const ownerName = getRClassNameArgument(args);
|
||||
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,
|
||||
column: entry.startPosition.column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -105,7 +105,7 @@ import {
|
|||
import { normalizeExtractedRoutePath } from '../route-extractors/route-path.js';
|
||||
import { resolveOperands } from '../route-extractors/python-const-resolver.js';
|
||||
import type { ModuleConstants } from '../route-extractors/constant-resolver.js';
|
||||
import { prepareRouteConstantsByProvider } from '../language-provider.js';
|
||||
import { prepareRouteConstantsByProvider, runPostParseHooks } from '../language-provider.js';
|
||||
import {
|
||||
resolveInheritedSpringRoutes,
|
||||
type SharedSpringType,
|
||||
|
|
@ -1548,6 +1548,15 @@ export async function runChunkedParseAndResolve(
|
|||
}
|
||||
}
|
||||
|
||||
// Language post-parse hooks: whole-graph work a language can only do once every chunk is merged
|
||||
// and before scope resolution (e.g. deferred owner resolution). Only languages with parsed files pay.
|
||||
const presentLanguages = new Set<SupportedLanguages>();
|
||||
for (const file of parseableScanned) {
|
||||
const language = languageForScannedFile(file);
|
||||
if (language !== null) presentLanguages.add(language);
|
||||
}
|
||||
await runPostParseHooks(presentLanguages, getProvider, { graph, model, repoPath });
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { dartScopeResolver } from '../../languages/dart/scope-resolver.js';
|
|||
import { vueScopeResolver } from '../../languages/vue/scope-resolver.js';
|
||||
import { zigScopeResolver } from '../../languages/zig/scope-resolver.js';
|
||||
import { objectiveCScopeResolver } from '../../languages/objective-c/scope-resolver.js';
|
||||
import { rScopeResolver } from '../../languages/r/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The scope-resolution phase
|
||||
* iterates this map directly — every registered resolver runs. This is the
|
||||
|
|
@ -55,4 +56,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.Vue, vueScopeResolver],
|
||||
[SupportedLanguages.Zig, zigScopeResolver],
|
||||
[SupportedLanguages.ObjectiveC, objectiveCScopeResolver],
|
||||
[SupportedLanguages.R, rScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -3051,6 +3051,189 @@ export const ZIG_QUERIES = `
|
|||
function: (field_expression
|
||||
member: (identifier) @call.name)) @call
|
||||
`;
|
||||
export const R_QUERIES = `
|
||||
; ── Functions (name <- function(...) or name = function(...)) ────────────────
|
||||
(binary_operator
|
||||
lhs: (identifier) @name
|
||||
rhs: (function_definition)) @definition.function
|
||||
|
||||
; ── S4 Classes (setClass("ClassName", ...)) ──────────────────────────────────
|
||||
; The S4 patterns below capture EVERY string argument of the call on purpose.
|
||||
; Which argument names the definition is decided by the provider's
|
||||
; \`shouldSkipDefinitionCapture\` (languages/r/naming-argument.ts): a query cannot skip an
|
||||
; unbounded run of leading comments and named arguments without a repeated
|
||||
; sibling group, which is quadratic in the argument count.
|
||||
(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
|
||||
|
||||
; ── 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)))))
|
||||
`;
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
||||
|
|
@ -3073,6 +3256,7 @@ 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
|
||||
[SupportedLanguages.Zig]: ZIG_QUERIES,
|
||||
};
|
||||
|
|
|
|||
270
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal file
270
gitnexus/src/core/ingestion/type-extractors/r.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import type {
|
||||
LanguageTypeConfig,
|
||||
ParameterExtractor,
|
||||
TypeBindingExtractor,
|
||||
InitializerExtractor,
|
||||
ConstructorBindingScanner,
|
||||
} from './types.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { getRClassNameArgument } from '../field-extractors/r.js';
|
||||
|
||||
/**
|
||||
* R type extractor — roxygen2 annotation parsing.
|
||||
*
|
||||
* R has no static type system and roxygen2 has no type syntax: a `@param`
|
||||
* description is free prose. A parameter is therefore typed from a roxygen
|
||||
* comment only when the type is stated in an anchored form AND names a class
|
||||
* the file defines:
|
||||
*
|
||||
* #' @param repo UserRepo (the description is the type alone)
|
||||
* #' @param backup {UserRepo} a spare (the type in braces, prose after it)
|
||||
* save <- function(repo, backup) { ... }
|
||||
*
|
||||
* Prose that merely starts with a capitalised word (`@param df Data frame of
|
||||
* observations`) binds nothing, even when a class called `Data` exists, and a
|
||||
* description may run over several lines: it is read up to the next tag.
|
||||
* The type is bound for a formal of the documented function, inside that
|
||||
* function only.
|
||||
*
|
||||
* Resolution tiers:
|
||||
* - Tier 0: roxygen2 @param annotations (extractParameter binds each documented formal)
|
||||
* - Tier 1: Constructor inference via `obj <- ClassName$new()` (R6) or `obj <- new("ClassName")` (S4)
|
||||
*/
|
||||
|
||||
/** A `@param` tag: the parameter name and the description text that follows it. */
|
||||
interface RoxygenParam {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
/** The whole description is a single type word: `UserRepo`. */
|
||||
const BARE_TYPE_RE = /^([A-Z][\w.]*)$/;
|
||||
|
||||
/** The description starts with a braced type: `{UserRepo} the repository`. */
|
||||
const BRACED_TYPE_RE = /^\{([A-Z][\w.]*)\}(?:\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');
|
||||
};
|
||||
|
||||
/**
|
||||
* Split a roxygen block into its `@param` tags. A tag's description is the
|
||||
* rest of its line plus every following line up to the next `@tag`, joined by
|
||||
* single spaces. A tag whose name is not one plain identifier (`x,y`, `...`,
|
||||
* `na.rm`) is skipped, and its continuation lines belong to no tag.
|
||||
*/
|
||||
const parseRoxygenParams = (block: string): RoxygenParam[] => {
|
||||
const params: RoxygenParam[] = [];
|
||||
let name: string | undefined;
|
||||
let parts: string[] = [];
|
||||
const flush = (): void => {
|
||||
if (name !== undefined) params.push({ name, description: parts.join(' ').trim() });
|
||||
name = undefined;
|
||||
parts = [];
|
||||
};
|
||||
|
||||
for (const rawLine of block.split('\n')) {
|
||||
const line = rawLine.replace(/^#'[ \t]*/, '').trim();
|
||||
const tag = /^@(\w+)(?:[ \t]+(.*))?$/.exec(line);
|
||||
if (tag) {
|
||||
flush();
|
||||
if (tag[1] !== 'param') continue;
|
||||
const head = /^(\w+)(?:[ \t]+(.*))?$/.exec(tag[2] ?? '');
|
||||
if (!head) continue;
|
||||
name = head[1];
|
||||
if (head[2]) parts.push(head[2]);
|
||||
} else if (name !== undefined && line.length > 0) {
|
||||
parts.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return params;
|
||||
};
|
||||
|
||||
/** The type a `@param` description states in an anchored form, if any. */
|
||||
const anchoredType = (description: string): string | undefined =>
|
||||
(BARE_TYPE_RE.exec(description) ?? BRACED_TYPE_RE.exec(description))?.[1];
|
||||
|
||||
/**
|
||||
* The class a node declares, in the shapes `query.ts` captures as
|
||||
* `@declaration.class`: `X <- R6Class(...)` / `X <- R6::R6Class(...)`, and
|
||||
* `setClass("X", ...)` / `setRefClass("X", ...)`.
|
||||
*/
|
||||
const declaredClassName = (node: SyntaxNode): string | undefined => {
|
||||
if (node.type === 'binary_operator') {
|
||||
const lhs = node.childForFieldName('lhs');
|
||||
const rhs = node.childForFieldName('rhs');
|
||||
if (lhs?.type !== 'identifier' || rhs?.type !== 'call') return undefined;
|
||||
const fn = rhs.childForFieldName('function');
|
||||
const calleeName =
|
||||
fn?.type === 'namespace_operator' ? fn.childForFieldName('rhs')?.text : fn?.text;
|
||||
return calleeName === 'R6Class' ? lhs.text : undefined;
|
||||
}
|
||||
if (node.type !== 'call') return undefined;
|
||||
const fn = node.childForFieldName('function');
|
||||
if (fn?.type !== 'identifier' || (fn.text !== 'setClass' && fn.text !== 'setRefClass')) {
|
||||
return undefined;
|
||||
}
|
||||
// The class name is the argument that takes the `Class` formal, by name or else
|
||||
// the first unnamed one; `contains = "Base"` ahead of `Class = "A"` is not it.
|
||||
const args = node.childForFieldName('arguments');
|
||||
return (args && getRClassNameArgument(args)) ?? undefined;
|
||||
};
|
||||
|
||||
/** Class names defined anywhere in the file, built on first use and kept per tree. */
|
||||
const localClassNamesByTree = new WeakMap<object, ReadonlySet<string>>();
|
||||
const localClassNames = (node: SyntaxNode): ReadonlySet<string> => {
|
||||
const cached = localClassNamesByTree.get(node.tree);
|
||||
if (cached) return cached;
|
||||
const names = new Set<string>();
|
||||
for (const candidate of node.tree.rootNode.descendantsOfType(['binary_operator', 'call'])) {
|
||||
const name = declaredClassName(candidate);
|
||||
if (name !== undefined) names.add(name);
|
||||
}
|
||||
localClassNamesByTree.set(node.tree, names);
|
||||
return names;
|
||||
};
|
||||
|
||||
/**
|
||||
* The roxygen2 `@param` types documented for an assigned function: the
|
||||
* comment block directly above `name <- function(...)`, keyed by parameter
|
||||
* name. Returns a map of paramName → typeName.
|
||||
*/
|
||||
const collectRoxygenParams = (assignment: SyntaxNode): Map<string, string> => {
|
||||
const params = new Map<string, string>();
|
||||
for (const { name, description } of parseRoxygenParams(collectRoxygenBlock(assignment))) {
|
||||
const typeName = anchoredType(description);
|
||||
if (typeName !== undefined && localClassNames(assignment).has(typeName)) {
|
||||
params.set(name, 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']);
|
||||
|
||||
/**
|
||||
* R declaration extraction. Roxygen types are bound per formal by
|
||||
* `extractParameter`, so a function definition binds nothing here: this
|
||||
* hook runs in the scope that CONTAINS the assignment (the file, for a
|
||||
* top-level function), and a type bound there would be visible in every
|
||||
* function of the file.
|
||||
*/
|
||||
const extractDeclaration: TypeBindingExtractor = (
|
||||
_node: SyntaxNode,
|
||||
_env: Map<string, string>,
|
||||
): void => {
|
||||
// Constructor bindings are made by extractInitializer; roxygen types by extractParameter.
|
||||
};
|
||||
|
||||
/**
|
||||
* R parameter extraction: the roxygen2 `@param` type of a formal.
|
||||
*
|
||||
* Runs once per formal with the env of the function that owns it, so the
|
||||
* binding is visible inside that function only. The roxygen block is the one
|
||||
* above the assignment `name <- function(...)` the formal belongs to; a name
|
||||
* the block documents but the function does not have is never visited, and a
|
||||
* function that is not the right-hand side of an assignment (a callback)
|
||||
* is documented by nothing. The `...` formal is not an identifier and is
|
||||
* skipped.
|
||||
*/
|
||||
const extractParameter: ParameterExtractor = (node: SyntaxNode, env: Map<string, string>): void => {
|
||||
if (node.type !== 'parameter') return;
|
||||
const name = node.childForFieldName('name');
|
||||
if (name?.type !== 'identifier') return;
|
||||
|
||||
const fn = node.parent?.parent;
|
||||
if (fn?.type !== 'function_definition') return;
|
||||
const assignment = fn.parent;
|
||||
if (assignment?.type !== 'binary_operator') return;
|
||||
const rhs = assignment.childForFieldName('rhs');
|
||||
if (rhs?.startIndex !== fn.startIndex || rhs.endIndex !== fn.endIndex) return;
|
||||
|
||||
const typeName = collectRoxygenParams(assignment).get(name.text);
|
||||
if (typeName !== undefined) env.set(name.text, typeName);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
// `new(Class, ...)`: the class is the argument that takes the `Class` formal.
|
||||
const args = rhs.childForFieldName('arguments');
|
||||
const className = args && getRClassNameArgument(args);
|
||||
if (className) return { varName: lhs.text, calleeName: className };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ import CPP from 'tree-sitter-cpp';
|
|||
import { getLanguageGrammar } from '../../tree-sitter/parser-loader.js';
|
||||
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 { requireVendoredGrammar } from '../../tree-sitter/vendored-grammars.js';
|
||||
|
|
@ -607,6 +608,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 } : {}),
|
||||
|
|
@ -687,7 +689,9 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
|
|||
* a type (`resolveFileTypeOwner`, e.g. a Zig file-struct), the tree root is
|
||||
* the owner node the method/field extractors should read members from. Same
|
||||
* root, same name as `findEnclosingClassInfo`'s root branch, so member ids and
|
||||
* owner ids agree.
|
||||
* owner ids agree. Failing both, the provider's `resolveMemberOwnerNode` gets
|
||||
* the last word (languages whose members belong to a call/assignment rather
|
||||
* than an enclosing container node, e.g. R's `R6Class(...)`).
|
||||
*/
|
||||
function findEnclosingClassNodeOrFileOwner(
|
||||
node: SyntaxNode,
|
||||
|
|
@ -696,10 +700,12 @@ function findEnclosingClassNodeOrFileOwner(
|
|||
): SyntaxNode | null {
|
||||
const container = findEnclosingClassNode(node);
|
||||
if (container !== null) return container;
|
||||
if (provider.resolveFileTypeOwner === undefined) return null;
|
||||
let root: SyntaxNode = node;
|
||||
while (root.parent) root = root.parent;
|
||||
return provider.resolveFileTypeOwner(root, filePath) !== null ? root : null;
|
||||
if (provider.resolveFileTypeOwner !== undefined) {
|
||||
let root: SyntaxNode = node;
|
||||
while (root.parent) root = root.parent;
|
||||
if (provider.resolveFileTypeOwner(root, filePath) !== null) return root;
|
||||
}
|
||||
return provider.resolveMemberOwnerNode?.(node) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -131,6 +131,11 @@ const SOURCES: Record<string, GrammarSource> = {
|
|||
unavailableNote:
|
||||
'Ruby parsing requires `tree-sitter-ruby`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.R]: {
|
||||
load: () => _require('@eagleoutice/tree-sitter-r'),
|
||||
unavailableNote:
|
||||
'R parsing requires `@eagleoutice/tree-sitter-r`. Check the install and native binding.',
|
||||
},
|
||||
[SupportedLanguages.Vue]: {
|
||||
load: () => _require('tree-sitter-typescript').typescript,
|
||||
unavailableNote:
|
||||
|
|
|
|||
|
|
@ -858,7 +858,25 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// and original graph positions. Both stores must re-extract v135 captures.
|
||||
// v137: Python module-accessor syntax proofs and exact assigned-call provenance
|
||||
// join the existing subtype capture payload. Re-extract v136 in both stores.
|
||||
const SCHEMA_BUMP = 137;
|
||||
// v138: R provider fixes, collapsed into one bump because none of them has
|
||||
// shipped. R scope queries anchor a `@scope.function` on every named-function
|
||||
// assignment and named function-valued argument (the same nodes the
|
||||
// `@declaration.function`/`@declaration.method` anchors use), so each def is
|
||||
// owned by its own Function scope instead of the module/Class scope, and
|
||||
// capture the `pkg::`/`pkg:::` qualifier of namespaced calls as
|
||||
// `@reference.qualified-name` so it reaches `site.rawQualifiedName`. The R
|
||||
// provider also keeps only the argument that names an S4 definition
|
||||
// (shouldSkipDefinitionCapture), so `setClass("A", contains = "VIRTUAL")` no
|
||||
// longer defines a Class `VIRTUAL`, `setGeneric(..., valueClass = "numeric")` a
|
||||
// Function `numeric`, nor `setMethod("show", "Foo", ...)` a Method `Foo`; the R
|
||||
// scope emitter likewise keeps only the argument that names a `setClass` class
|
||||
// or a `library`/`require`/`source` import, so `library(lib.loc = libO, pkgP)`
|
||||
// no longer imports `libO` and a comment before the first argument no longer
|
||||
// hides the class or import. Definitions and scope captures are parse-time
|
||||
// facts replayed verbatim from the warm cache, so stale ones (the pre-fix scope
|
||||
// tree, the unqualified call-site shape, the mis-selected arguments) would
|
||||
// persist on unchanged files; both stores must re-extract.
|
||||
const SCHEMA_BUMP = 138;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
38
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/leaves.R
vendored
Normal file
38
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/leaves.R
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Synthetic leaf callees for the R caller-attribution fixture (no calls inside).
|
||||
leaf_a <- function() NULL
|
||||
leaf_b <- function() NULL
|
||||
leaf_c <- function() NULL
|
||||
leaf_d <- function() NULL
|
||||
leaf_e <- function() NULL
|
||||
leaf_f <- function() NULL
|
||||
leaf_g <- function() NULL
|
||||
leaf_h <- function() NULL
|
||||
leaf_i <- function() NULL
|
||||
leaf_j <- function() NULL
|
||||
leaf_k <- function() NULL
|
||||
leaf_l <- function() NULL
|
||||
leaf_m <- function() NULL
|
||||
leaf_n <- function() NULL
|
||||
leaf_o <- function() NULL
|
||||
leaf_p <- function() NULL
|
||||
leaf_q <- function() NULL
|
||||
leaf_r <- function() NULL
|
||||
leaf_s <- function() NULL
|
||||
leaf_t <- function() NULL
|
||||
leaf_u <- function() NULL
|
||||
leaf_v <- function() NULL
|
||||
leaf_w <- function() NULL
|
||||
leaf_x <- function() NULL
|
||||
leaf_y <- function() NULL
|
||||
leaf_z <- function() NULL
|
||||
leaf_aa <- function() NULL
|
||||
leaf_ab <- function() NULL
|
||||
leaf_ac <- function() NULL
|
||||
leaf_ad <- function() NULL
|
||||
leaf_ae <- function() NULL
|
||||
leaf_af <- function() NULL
|
||||
leaf_ag <- function() NULL
|
||||
leaf_ah <- function() NULL
|
||||
leaf_ai <- function() NULL
|
||||
leaf_aj <- function() NULL
|
||||
leaf_ak <- function() NULL
|
||||
23
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/named_forms.R
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/named_forms.R
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Every file starts with a decoy first function: a call misattributed to the
|
||||
# file's first callable shows up as an edge from `decoy_named`.
|
||||
decoy_named <- function() leaf_a()
|
||||
|
||||
arrow_fn <- function(x) leaf_b(x)
|
||||
equals_fn = function(x) leaf_c(x)
|
||||
super_fn <<- function(x) leaf_d(x)
|
||||
lambda_fn <- \(x) leaf_e(x)
|
||||
dotted.name.fn <- function(x) leaf_f(x)
|
||||
`backtick name fn` <- function(x) leaf_g(x)
|
||||
|
||||
pair_one <- function() leaf_h(1); pair_two <- function() leaf_i(2)
|
||||
|
||||
if (TRUE) cond_fn <- function() leaf_j(4)
|
||||
|
||||
default_arg_fn <- function(a = leaf_k(1)) {
|
||||
a
|
||||
}
|
||||
|
||||
registry <- list()
|
||||
registry$member_fn <- function(x) leaf_l(x)
|
||||
|
||||
res <- leaf_x(1)
|
||||
19
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/nesting.R
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/nesting.R
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
decoy_nesting <- function() leaf_m()
|
||||
|
||||
outer <- function(x) {
|
||||
inner <- function(y) leaf_n(y)
|
||||
inner(x)
|
||||
lapply(x, function(z) leaf_o(z))
|
||||
sapply(x, FUN = function(z) leaf_p(z))
|
||||
tryCatch(leaf_q(x), error = function(e) leaf_r(e))
|
||||
}
|
||||
|
||||
level1 <- function() {
|
||||
level2 <- function() {
|
||||
level3 <- function() leaf_s()
|
||||
level3()
|
||||
leaf_t()
|
||||
}
|
||||
level2()
|
||||
leaf_u()
|
||||
}
|
||||
26
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/r6_methods.R
vendored
Normal file
26
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/r6_methods.R
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
decoy_r6 <- function() leaf_v()
|
||||
|
||||
Widget <- R6::R6Class("Widget",
|
||||
public = list(
|
||||
cache = leaf_w(),
|
||||
first = function() leaf_y(),
|
||||
run = function() {
|
||||
helper <- function() leaf_z()
|
||||
helper()
|
||||
lapply(1:3, function(i) leaf_aa(i))
|
||||
},
|
||||
relay = function() self$peer(),
|
||||
peer = function() leaf_ab()
|
||||
),
|
||||
private = list(
|
||||
hidden = function() leaf_ac()
|
||||
),
|
||||
active = list(
|
||||
shown = function() leaf_ad()
|
||||
)
|
||||
)
|
||||
|
||||
Counter <- setRefClass("Counter", methods = list(
|
||||
bump = function() leaf_ae(),
|
||||
reset = function() leaf_af()
|
||||
))
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/s4.R
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-call-attribution/R/s4.R
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
decoy_s4 <- function() leaf_ag()
|
||||
|
||||
setClass("Sq", representation(side = "numeric"))
|
||||
setGeneric("area", function(shape) standardGeneric("area"))
|
||||
setMethod("area", "Sq", function(shape) leaf_ah(shape))
|
||||
setClass("P", representation(x = "numeric"), validity = function(object) leaf_ai(object))
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-call-attribution/scripts/top_level.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-call-attribution/scripts/top_level.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
result <- leaf_aj(1)
|
||||
print(leaf_ak(result))
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: other
|
||||
Title: Other
|
||||
Version: 0.1.0
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/NAMESPACE
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export(other_user)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/R/use.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/other/R/use.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
other_helper <- function(v) helper.util(v)
|
||||
other_print <- function(v) print.widget(v)
|
||||
other_plain <- function(v) plain_fn(v)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: prov
|
||||
Title: Provider
|
||||
Version: 0.1.0
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/NAMESPACE
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export(helper.util)
|
||||
export(plain_fn)
|
||||
S3method(print, widget)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/R/defs.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/R/defs.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
helper.util <- function(x) x
|
||||
print.widget <- function(x, ...) invisible(x)
|
||||
plain_fn <- function(x) x
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/R/use.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/prov/R/use.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
same_pkg_helper <- function(v) helper.util(v)
|
||||
same_pkg_print <- function(v) print.widget(v)
|
||||
same_pkg_plain <- function(v) plain_fn(v)
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/scripts/bare.R
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/scripts/bare.R
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
bare_helper <- function(v) helper.util(v)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/scripts/run.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-cross-file/scripts/run.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
library(prov)
|
||||
script_helper <- function(v) helper.util(v)
|
||||
script_print <- function(v) print.widget(v)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: cons
|
||||
Title: Consumer
|
||||
Version: 0.1.0
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export(use_foo)
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/cons/R/cons.R
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
library(prov)
|
||||
use_foo <- function(v) foo(v)
|
||||
use_bar <- function(v) bar(v)
|
||||
use_baz <- function(v) baz(v)
|
||||
use_dotted <- function(x) print.foo(x)
|
||||
use_qux <- function(v) qux(v)
|
||||
use_odd <- function(v) `odd name`(v)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: prov
|
||||
Title: Provider
|
||||
Version: 0.1.0
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export(foo)
|
||||
export(bar)
|
||||
export(baz)
|
||||
S3method(print, foo)
|
||||
S3method(print, bar)
|
||||
S3method(summary, baz)
|
||||
export(qux)
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/other.R
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
same_pkg_foo <- function(v) foo(v)
|
||||
same_pkg_bar <- function(v) bar(v)
|
||||
same_pkg_baz <- function(v) baz(v)
|
||||
dotted_call <- function(x) print.foo(x)
|
||||
same_pkg_qux <- function(v) qux(v)
|
||||
same_pkg_odd <- function(v) `odd name`(v)
|
||||
12
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/prov/R/prov.R
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
print.foo <- function(x, ...) x
|
||||
foo <- function(v) structure(list(v = v), class = "foo")
|
||||
|
||||
bar <- function(v) structure(list(v = v), class = "bar")
|
||||
print.bar <- function(x, ...) x
|
||||
|
||||
baz <- function(v) v
|
||||
summary.baz <- function(object, ...) object
|
||||
|
||||
qux <- function(v) v
|
||||
|
||||
`odd name` <- function(v) v
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-dotted-s3-collision/scripts/run.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
library(prov)
|
||||
script_foo <- function(v) foo(v)
|
||||
script_bar <- function(v) bar(v)
|
||||
script_qux <- function(v) qux(v)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: analytics
|
||||
Title: NAMESPACE Import Fixture Caller
|
||||
Version: 0.1.0
|
||||
37
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE
vendored
Normal file
37
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export(run_all)
|
||||
# Single-line and multi-line importFrom() directives from local packages.
|
||||
importFrom(scorelib, tidy_scores)
|
||||
importFrom(
|
||||
scorelib,
|
||||
normalise_scores,
|
||||
rank_scores
|
||||
)
|
||||
importFrom("scorelib", "quoted_fn")
|
||||
# Exported by scorelib, but also defined in this package: the own package masks it.
|
||||
importFrom(scorelib, own_dup)
|
||||
# Duplicate names: the later directive wins (legacyscore).
|
||||
importFrom(scorelib, dup_fn)
|
||||
importFrom(legacyscore, dup_fn)
|
||||
# Not exported by scorelib; never exported name with no definition; whole-package import.
|
||||
importFrom(scorelib, hidden_helper)
|
||||
importFrom(scorelib, no_such_fn)
|
||||
import(reportlib)
|
||||
# Dotted names cannot bind precisely.
|
||||
importFrom(scorelib, normalise.scores)
|
||||
importFrom(scorelib, tidy)
|
||||
importFrom(scorelib, tally)
|
||||
importFrom(scorelib, plain_fn)
|
||||
# R6 members versus top-level definitions.
|
||||
importFrom(scorelib, score_it)
|
||||
importFrom(scorelib, describe_run)
|
||||
# Re-export through a local provider (scorelib imports it from corelib).
|
||||
importFrom(scorelib, reexp_fn)
|
||||
# Nested function sharing a name with a later top-level function.
|
||||
importFrom(scorelib, nest_fn)
|
||||
# External packages never bind.
|
||||
importFrom(dplyr, mutate)
|
||||
# Cross-package decoy guesses the NAMESPACE contradicts (name-guess veto).
|
||||
importFrom(dplyr, filter)
|
||||
importFrom(scorelib, dup_ext)
|
||||
importFrom(dplyr, dup_ext)
|
||||
importFrom(dplyr, scripts_only_fn)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/decoy_first.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# First function of the package in load order: a decoy that owns none of the
|
||||
# edges emitted from the functions written in later files.
|
||||
first_fn <- function(d) d
|
||||
15
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/dotted_use.R
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
dotted_name_user <- function(d) {
|
||||
normalise.scores(d)
|
||||
}
|
||||
|
||||
print_before_bare_user <- function(d) {
|
||||
tidy(d)
|
||||
}
|
||||
|
||||
bare_before_print_user <- function(d) {
|
||||
tally(d)
|
||||
}
|
||||
|
||||
underscore_user <- function(d) {
|
||||
plain_fn(d)
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/nested_use.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
nested_user <- function(d) {
|
||||
nest_fn(d)
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/own.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
own_dup <- function(d) d
|
||||
# Same name as an externally imported function: the own definition masks the import.
|
||||
filter <- function(d) d
|
||||
10
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/qualified_use.R
vendored
Normal file
10
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/qualified_use.R
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Explicitly qualified call to a package the NAMESPACE does not import it from.
|
||||
qualified_user <- function(d) {
|
||||
legacyscore::tidy_scores(d)
|
||||
}
|
||||
|
||||
# Correctly qualified call to the package that really defines `mutate`, while the
|
||||
# NAMESPACE imports `mutate` from dplyr.
|
||||
qualified_mutate_user <- function(d) {
|
||||
legacyscore::mutate(d)
|
||||
}
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/r6_use.R
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
r6_flag_user <- function(d) {
|
||||
score_it(d)
|
||||
}
|
||||
|
||||
r6_method_only_user <- function(d) {
|
||||
describe_run(d)
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/reexport_use.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
reexport_user <- function(d) {
|
||||
reexp_fn(d)
|
||||
}
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/second.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# A second R file: every R file of the package sees the NAMESPACE imports.
|
||||
second_user <- function(d) {
|
||||
tidy_scores(d)
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/use.R
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
run_all <- function(d) {
|
||||
tidy_scores(d)
|
||||
normalise_scores(d)
|
||||
rank_scores(d)
|
||||
quoted_fn(d)
|
||||
own_dup(d)
|
||||
dup_fn(d)
|
||||
hidden_helper(d)
|
||||
no_such_fn(d)
|
||||
d
|
||||
}
|
||||
17
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/veto_use.R
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/R/veto_use.R
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Callers for the name-guess veto scenarios. Each lives in its own function so the exact
|
||||
# call sets of the other fixture functions stay untouched.
|
||||
mutate_user <- function(d) {
|
||||
mutate(d)
|
||||
}
|
||||
|
||||
filter_user <- function(d) {
|
||||
filter(d)
|
||||
}
|
||||
|
||||
dup_ext_user <- function(d) {
|
||||
dup_ext(d)
|
||||
}
|
||||
|
||||
scripts_only_user <- function(d) {
|
||||
scripts_only_fn(d)
|
||||
}
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/scripts/helper.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Script outside any package R/ directory: NAMESPACE imports do not apply here.
|
||||
scripts_only_fn <- function(d) d
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# Outside the package R/ directory: NAMESPACE imports do not apply, so the name guess stands.
|
||||
test_mutate_user <- function(d) {
|
||||
mutate(d)
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/analytics/tests/testthat/test-x.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
test_that_user <- function(d) {
|
||||
tidy_scores(d)
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: corelib
|
||||
Title: NAMESPACE Import Fixture Origin
|
||||
Version: 0.1.0
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export(reexp_fn)
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/corelib/R/c.R
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
reexp_fn <- function(d) d
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: legacyscore
|
||||
Title: NAMESPACE Import Fixture Decoy
|
||||
Version: 0.1.0
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export(tidy_scores)
|
||||
export(dup_fn)
|
||||
export(mutate)
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/legacyscore/R/l.R
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
tidy_scores <- function(d) d
|
||||
dup_fn <- function(d) d
|
||||
mutate <- function(d) d
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: scorelib
|
||||
Title: NAMESPACE Import Fixture Provider
|
||||
Version: 0.1.0
|
||||
17
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE
vendored
Normal file
17
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/NAMESPACE
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export(tidy_scores)
|
||||
export(normalise_scores)
|
||||
export(rank_scores)
|
||||
export(quoted_fn)
|
||||
export(own_dup)
|
||||
export(dup_fn)
|
||||
export(no_such_fn)
|
||||
export(normalise.scores)
|
||||
export(tidy)
|
||||
export(tally)
|
||||
export(plain_fn)
|
||||
export(score_it)
|
||||
export(describe_run)
|
||||
export(reexp_fn)
|
||||
export(nest_fn)
|
||||
importFrom(corelib, reexp_fn)
|
||||
export(dup_ext)
|
||||
7
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R
vendored
Normal file
7
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dotted.R
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# S3-style dotted definitions: a dotted def is indexed under its last segment.
|
||||
print.tidy <- function(x, ...) x
|
||||
tidy <- function(d) d
|
||||
normalise.scores <- function(d) d
|
||||
|
||||
tally <- function(d) d
|
||||
print.tally <- function(x, ...) x
|
||||
5
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/dup.R
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Also defined (and exported) by legacyscore, which the importer names last.
|
||||
dup_fn <- function(d) d
|
||||
# Exported here, but the importer names an external package last: no binding, and the
|
||||
# unique-name guess to this definition is contradicted by the NAMESPACE.
|
||||
dup_ext <- function(d) d
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/internal.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Not listed in NAMESPACE: defined but not exported.
|
||||
hidden_helper <- function(d) d
|
||||
6
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R
vendored
Normal file
6
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/nested.R
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
nest_host <- function(d) {
|
||||
nest_fn <- function(y) y
|
||||
nest_fn(d)
|
||||
}
|
||||
|
||||
nest_fn <- function(d) d
|
||||
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R
vendored
Normal file
2
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/own_dup.R
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Also defined in the importing package, whose own definition masks this one.
|
||||
own_dup <- function(d) d
|
||||
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/plain.R
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
plain_fn <- function(d) d
|
||||
15
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/r6.R
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Scorer <- R6::R6Class("Scorer",
|
||||
public = list(
|
||||
# Method defined BEFORE the top-level function of the same name.
|
||||
score_it = function(d) d
|
||||
)
|
||||
)
|
||||
|
||||
score_it <- function(d) d
|
||||
|
||||
Reporter <- R6::R6Class("Reporter",
|
||||
public = list(
|
||||
# Only a method: no top-level function carries this name.
|
||||
describe_run = function(d) d
|
||||
)
|
||||
)
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/scorelib/R/s.R
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
tidy_scores <- function(d) d
|
||||
normalise_scores <- function(d) d
|
||||
rank_scores <- function(d) d
|
||||
quoted_fn <- function(d) d
|
||||
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/r-namespace-imports/vendor/dplyr.py
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Same-named file in another language: an external R package must never
|
||||
# suffix-match it.
|
||||
def mutate(d):
|
||||
return d
|
||||
3
gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/r-native-pipes/pkgmain/DESCRIPTION
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Package: pkgmain
|
||||
Title: Native Pipe Fixture Main
|
||||
Version: 0.1.0
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue