diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py index c4cfa0860..6d26eae4d 100644 --- a/.github/scripts/check-tree-sitter-upgrade-readiness.py +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -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"), ) diff --git a/.github/scripts/test_check_tree_sitter_upgrade_readiness.py b/.github/scripts/test_check_tree_sitter_upgrade_readiness.py index c75e7caca..b31957c32 100644 --- a/.github/scripts/test_check_tree_sitter_upgrade_readiness.py +++ b/.github/scripts/test_check_tree_sitter_upgrade_readiness.py @@ -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()) diff --git a/.github/workflows/tree-sitter-upgrade-readiness.yml b/.github/workflows/tree-sitter-upgrade-readiness.yml index 889e6cd76..afdf69882 100644 --- a/.github/workflows/tree-sitter-upgrade-readiness.yml +++ b/.github/workflows/tree-sitter-upgrade-readiness.yml @@ -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; diff --git a/AGENTS.md b/AGENTS.md index 2172825f5..c211bf02e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ - - + + -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 (`/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. | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a13abd94..62b47a37b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` — missing a language is a compile error. +19 providers in `languages/index.ts` via `satisfies Record` — missing a language is a compile error. ### Unified capture tags diff --git a/README.md b/README.md index d89a7ef05..e805a18be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/languages/r-provider.md b/docs/languages/r-provider.md new file mode 100644 index 000000000..35eaac0b1 --- /dev/null +++ b/docs/languages/r-provider.md @@ -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 `.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 `.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`). diff --git a/gitnexus-shared/src/language-detection.ts b/gitnexus-shared/src/language-detection.ts index 583a0fd5f..eb465959d 100644 --- a/gitnexus-shared/src/language-detection.ts +++ b/gitnexus-shared/src/language-detection.ts @@ -53,6 +53,7 @@ const EXTENSION_MAP: Record = { [SupportedLanguages.Swift]: ['.swift'], [SupportedLanguages.Dart]: ['.dart'], [SupportedLanguages.Vue]: ['.vue'], + [SupportedLanguages.R]: ['.r'], [SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'], [SupportedLanguages.Zig]: ['.zig'], } satisfies Record; // Ensure exhaustiveness @@ -127,6 +128,7 @@ const SYNTAX_MAP: Record = { [SupportedLanguages.Swift]: 'swift', [SupportedLanguages.Dart]: 'dart', [SupportedLanguages.Vue]: 'typescript', + [SupportedLanguages.R]: 'r', [SupportedLanguages.Cobol]: 'cobol', [SupportedLanguages.Zig]: 'zig', } satisfies Record; // Ensure exhaustiveness diff --git a/gitnexus-shared/src/languages.ts b/gitnexus-shared/src/languages.ts index eec45e612..85a67e9ad 100644 --- a/gitnexus-shared/src/languages.ts +++ b/gitnexus-shared/src/languages.ts @@ -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', diff --git a/gitnexus-shared/src/scope-resolution/language-classification.ts b/gitnexus-shared/src/scope-resolution/language-classification.ts index 2e635c8e2..1d9a1fff0 100644 --- a/gitnexus-shared/src/scope-resolution/language-classification.ts +++ b/gitnexus-shared/src/scope-resolution/language-classification.ts @@ -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 9976.2 us per import (50.6x), and Python's from-import submodule-precedence branch, the only spelling that reads context.parsedFiles at all. Fifteen of the seventeen arms cannot observe a context (their hooks declare three or four parameters) and are handed none, so their numbers did not move; which two CAN is now reconciled against SCOPE_RESOLVERS' hook arity rather than asserted in prose. NOTHING ELSE IN THIS FILE COULD HAVE GATED IT, which is why the context arm exists: fingerprints and shape can remain unchanged while dropping context only makes timing faster. The deterministic context arm is therefore the guard for this wiring. The arm is one import per language resolved through resolveOne twice, with and without the pass's parsedFiles, whose two answers must DIFFER and must both match what is recorded. WHAT REMAINS UNMEASURED, narrowed rather than deleted: Python's parsedFileByPath memo is exercised by the five timing arms and cannot be reached by the heap arm at all, because retainedPassBytes requires a probe that MISSES while every path that builds that memo returns a non-null packageTarget \u2014 so no ceiling bounds that Map (one pointer per parsed file, O(files), no depth term) and the contract test's count gate is what holds it to one build per pass. PHP's sole arm carries a representative Composer PSR-4 map, so mapped hits and authoritative misses exercise that production branch directly. And the const tail of PHP's leg is a different ANSWER at the same cost \u2014 it runs the identical candidate gather and localDefs filter and diverges in the last two lines \u2014 so it is gated by count in test/unit/scope-resolution/import-target-index-reuse.contract.test.ts, which stays the gate to read alongside this file.", diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 60a73ffee..e07e39e27 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -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 `/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 `/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 `/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); /** diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index b9a2b9dd6..52b1735f0 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index 8b9ccc5bc..2d68ed0ae 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/scripts/assert-native-parsers.mjs b/gitnexus/scripts/assert-native-parsers.mjs index 84ef1bc97..af3d68081 100644 --- a/gitnexus/scripts/assert-native-parsers.mjs +++ b/gitnexus/scripts/assert-native-parsers.mjs @@ -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) { diff --git a/gitnexus/scripts/prepare-tree-sitter-bundle.cjs b/gitnexus/scripts/prepare-tree-sitter-bundle.cjs index 648cb895a..e5ba9354c 100644 --- a/gitnexus/scripts/prepare-tree-sitter-bundle.cjs +++ b/gitnexus/scripts/prepare-tree-sitter-bundle.cjs @@ -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 ( diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 143358c6f..56a9f5f01 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/field-extractors/r.ts b/gitnexus/src/core/ingestion/field-extractors/r.ts new file mode 100644 index 000000000..3e6c38964 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/r.ts @@ -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, ''); +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/r.ts b/gitnexus/src/core/ingestion/import-resolvers/r.ts new file mode 100644 index 000000000..15a06a5bc --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/r.ts @@ -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[] | 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; + /** Top-level name → files (in `parsedFiles` order) that define it. */ + readonly definingFiles: ReadonlyMap; +} + +const packageDefIndexes = new WeakMap< + readonly ParsedFile[], + WeakMap> +>(); + +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>(); + const defining = new Map>(); + 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(); + 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, + 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); +} diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 6dd8ddca1..500593e96 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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; + /** * 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, + providerFor: (language: SupportedLanguages) => Pick | undefined, + ctx: PostParseContext, +): Promise { + for (const language of [...presentLanguages].sort()) { + await providerFor(language)?.postParse?.(ctx); + } +} + const DEFAULTS: Pick = { mroStrategy: 'first-wins', }; diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index b10822a6f..0ade266c9 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/r.ts b/gitnexus/src/core/ingestion/languages/r.ts new file mode 100644 index 000000000..e143dea1d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r.ts @@ -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 = 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, +}); diff --git a/gitnexus/src/core/ingestion/languages/r/captures.ts b/gitnexus/src/core/ingestion/languages/r/captures.ts new file mode 100644 index 000000000..edfe74c27 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/captures.ts @@ -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['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 = {}; + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/r/export-pattern.ts b/gitnexus/src/core/ingestion/languages/r/export-pattern.ts new file mode 100644 index 000000000..f60920ce4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/export-pattern.ts @@ -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> = { + 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> = { + 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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/r/interpret.ts b/gitnexus/src/core/ingestion/languages/r/interpret.ts new file mode 100644 index 000000000..7632a08aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/interpret.ts @@ -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' }; +} diff --git a/gitnexus/src/core/ingestion/languages/r/linear-regex.ts b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts new file mode 100644 index 000000000..2a068a87e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/linear-regex.ts @@ -0,0 +1,1041 @@ +/** + * A linear-time matcher for the regular-expression subset that `exportPattern()` + * arguments use. + * + * `exportPattern()` text comes from the repository being analysed and is run against + * every node name. Compiling it to a JavaScript `RegExp` lets a pattern such as + * `^(a+)+$` take exponential time on a short name and hang the analyzer, whereas R + * evaluates the same text with TRE, which does not backtrack. {@link compileLinearRegex} + * parses the pattern (JavaScript syntax, non-Unicode mode, the dialect `RegExp` + * accepted before) into a Thompson NFA and simulates it with a set of active states. + * A match attempt costs O(name length x NFA size) whatever the pattern says, and the + * total work one matcher may do is capped (see the cost model below). + * + * Supported: literals, `.`, bracket expressions, `\d \D \w \W \s \S \b \B`, the + * single-character escapes, `^ $`, groups (capturing and `(?:)`), alternation and the + * quantifiers `* + ? {n} {n,} {n,m}` (lazy forms behave identically for a yes/no + * test). Not supported, and compiled to `null`: back-references and look-around (no + * linear-time algorithm), named groups, legacy octal escapes, patterns longer than + * {@link MAX_PATTERN_LENGTH} and patterns whose expansion exceeds {@link MAX_STATES}. + * A syntax error also yields `null`. Matching is over UTF-16 code units, as + * `RegExp` without the `u` flag does. {@link compileLinearRegexDetailed} reports why a + * pattern was refused. + * + * Cost model. Compiling is O(pattern length + states). Matching one name is + * O(name length x active states), and a state count alone does not make a pattern + * slow: `[[:alpha:]]{1,2000}` has 4,000 states but one of them is active at a time, + * and an alternation of literals keeps only the ones whose next character matches. + * What is slow is many active states on a long name, and a repository controls both. + * So the caps are sized for legitimate patterns and the hostile case is bounded + * separately, by {@link MAX_TOTAL_WORK}: each matcher counts every state it visits + * and, once the budget is spent, answers false for names it has not already decided + * and reports `exhausted`. A visit costs 10-20 ns, so the budget is 0.4-0.8 s per + * package however many names are tested: measured on the development machine, `a?` + * repeated 500 or 8,000 times then `c`, `[A-Za-z]{1,2000}c`, and a 120-way alternation + * of `a...ab` each exhaust it in ~0.4 s against 50,000 distinct 100-character names. + * Realistic patterns stay far below it: 500 alternated identifiers, anchored or not, + * cost about 20 visits per name, because alternatives that share a prefix share + * states. Verdicts are memoised per distinct name, so a repeated name costs one lookup. + * + * The per-matcher cap does not bound how many matchers there are, and a NAMESPACE can + * carry as many `exportPattern()` lines as it likes (200 hostile ones cost ~77 s). So + * matchers may also draw on a {@link SharedWorkBudget}, which the caller creates once + * for a whole package-config load and hands to every matcher it compiles, across all + * the packages it discovers; a matcher stops when its own cap or the shared budget is + * spent, whichever comes first. A count cap would not do: a per-package cap still + * allows many packages, and a per-run cap would penalise a monorepo whose packages + * each carry a few benign patterns, which use next to no work. + * + * Matching is not the only cost that grows with the number of patterns. Every compiled + * pattern keeps an NFA (about 100 bytes a state), and a NAMESPACE can carry hundreds of + * thousands of them, so the budget also holds an allowance of NFA states: a pattern that + * does not fit in what is left is refused (the build stops at the allowance, so its NFA is + * never allocated in full) and, once nothing is left, later patterns are refused without + * being parsed (the first pattern that does not fit ends the allowance; a pattern is at most + * {@link MAX_STATES} states, so that only happens after the allowance is nearly used up). + * Budgets nest: a package's budget is a {@link SharedWorkBudget.child} of the + * load's, so one package cannot take the whole load's allowance and leave every other + * package's patterns unable to run. + */ + +/** Longest pattern accepted: room for an alternation of ~1,000 identifiers. */ +export const MAX_PATTERN_LENGTH = 16384; + +/** + * Most NFA states a pattern may expand to. Bounded repetition such as `a{1000}` is + * expanded copy by copy, so this caps memory and compile time; the matching cost is + * bounded by {@link MAX_TOTAL_WORK}. Room for ~1,000 alternated identifiers of 15 + * characters, or `[[:alpha:]]{1,2000}`. + */ +export const MAX_STATES = 16384; + +/** Deepest group nesting accepted; bounds the parser's and the builder's recursion. */ +const MAX_NESTING = 2048; + +/** + * Most `build` calls for one pattern. States are capped separately, but a body that + * creates no states (`(){1000}`) can still be repeated exponentially when nested. + */ +const MAX_BUILD_STEPS = 4 * MAX_STATES; + +/** + * State visits one matcher may spend across all the names it is asked about (see the + * cost model above). + */ +export const MAX_TOTAL_WORK = 40_000_000; + +/** + * State visits all the matchers of one package-config load may spend together (the + * default limit of a {@link SharedWorkBudget}), in addition to the per-matcher + * {@link MAX_TOTAL_WORK}. At 9-10 ns a visit it is about one second: enough for two + * and a half pathological patterns to run to their own cap, after which the rest of + * the load's patterns stop and are reported, so a hostile NAMESPACE cannot cost more + * than that however many patterns it carries. Benign work is a tiny fraction of it: a + * 50-package monorepo with three realistic patterns per package and 2,500 functions + * spends about 15,000 visits, and one realistic pattern run against 50,000 names + * spends 0.2-7 million. Counted in visits, not wall-clock time, so the outcome is the + * same on every machine. + */ +export const MAX_SHARED_WORK = 100_000_000; + +/** + * NFA states all the patterns of one load may keep together. A state costs about 700 + * bytes once compiled (parallel arrays plus a character set per character state; + * measured: 2,000,000 states held 1.4 GB), so this bounds what a NAMESPACE can make the + * analyzer hold at about 350 MB. A realistic pattern is tens to a few thousand states + * (an alternation of 500 identifiers is about 7,500; `[[:alpha:]]{1,2000}` is 4,000), so + * the 150 benign patterns of the 50-package monorepo above use under 5,000 states in + * total, 1 % of this, and the allowance holds some 65 such alternations. + */ +export const MAX_SHARED_STATES = 500_000; + +/** + * State visits one package may spend on matching, out of {@link MAX_SHARED_WORK} (30 %, + * about 0.3 s): one hostile package then cannot take the load's whole budget, so the + * packages after it keep their patterns. Three hostile packages spend the load's + * budget; the packages after those degrade, with a warning per pattern. + */ +export const MAX_PACKAGE_WORK = 30_000_000; + +/** + * NFA states one package may keep, out of {@link MAX_SHARED_STATES} (20 %, about 70 MB): + * six patterns at the {@link MAX_STATES} cap, or some 13 alternations of 500 identifiers, + * far beyond what a NAMESPACE writes by hand, while five such packages still cannot + * exceed the load's allowance on their own. + */ +export const MAX_PACKAGE_STATES = 100_000; + +/** Why a pattern was refused because the allowance of states was spent. */ +const STATE_ALLOWANCE_REASON = + 'the NFA state allowance for this package or repository is spent (too many or too large patterns)'; + +/** + * A work allowance shared by several matchers: state visits spent matching, and NFA + * states kept by compiled patterns. Create one per package-config load and give each + * package a {@link child}; every matcher compiled with a budget adds the state visits + * it spends to {@link spent} (and to every ancestor's), and all of them stop once any + * budget up the chain is spent. Counted in work units, never time. + */ +export class SharedWorkBudget { + /** State visits spent so far by every matcher drawing on this budget. */ + spent = 0; + /** NFA states kept so far by every pattern compiled against this budget. */ + states = 0; + /** + * Set by the caller that skipped matching because the budget was spent, so that the + * patterns it did not get to run can be reported. + */ + cutShort = false; + + constructor( + /** State visits the matchers may spend together. */ + readonly limit: number = MAX_SHARED_WORK, + /** NFA states the compiled patterns may keep together. */ + readonly stateLimit: number = MAX_SHARED_STATES, + private readonly parent?: SharedWorkBudget, + ) {} + + /** A sub-budget for one package: it draws on this budget too, and cannot exceed it. */ + child( + limit: number = MAX_PACKAGE_WORK, + stateLimit: number = MAX_PACKAGE_STATES, + ): SharedWorkBudget { + return new SharedWorkBudget(limit, stateLimit, this); + } + + /** True once this budget or any ancestor is spent; a matcher then answers false for undecided names. */ + get isSpent(): boolean { + return this.spent >= this.limit || this.parent?.isSpent === true; + } + + /** + * NFA states that may still be kept, here and in every ancestor (never negative). Zero + * once a pattern was refused for not fitting (see {@link closeStates}). + */ + get statesLeft(): number { + const own = this.statesClosed ? 0 : Math.max(0, this.stateLimit - this.states); + return this.parent === undefined ? own : Math.min(own, this.parent.statesLeft); + } + + /** + * A pattern was refused because it did not fit in `room` states: close every budget up + * the chain whose own allowance is that tight, so later patterns are refused at once, + * without being parsed. Otherwise a small remainder that no pattern fits would make + * every one of hundreds of thousands of later patterns cost a parse and a partial build. + */ + closeStates(room: number): void { + if (Math.max(0, this.stateLimit - this.states) <= room) this.statesClosed = true; + this.parent?.closeStates(room); + } + + private statesClosed = false; + + /** Add `visits` state visits to this budget and every ancestor. */ + charge(visits: number): void { + this.spent += visits; + this.parent?.charge(visits); + } + + /** Record `count` NFA states kept by a compiled pattern, here and in every ancestor. */ + reserveStates(count: number): void { + this.states += count; + this.parent?.reserveStates(count); + } +} + +/** Most distinct names whose verdict a matcher remembers. */ +const MAX_MEMO_ENTRIES = 100_000; + +/** Thrown internally for unsupported or invalid input; never escapes this module. */ +class Reject extends Error {} + +const reject = (why: string): never => { + throw new Reject(why); +}; + +type Range = readonly [number, number]; + +interface CharSet { + readonly ranges: readonly Range[]; + readonly negated: boolean; +} + +type Node = + | { readonly kind: 'empty' } + | { readonly kind: 'set'; readonly set: CharSet } + | { readonly kind: 'assert'; readonly which: 'bol' | 'eol' | 'wordb' | 'nwordb' } + | { readonly kind: 'seq'; readonly items: readonly Node[] } + | { readonly kind: 'alt'; readonly items: readonly Node[] } + | { readonly kind: 'repeat'; readonly min: number; readonly max: number; readonly node: Node }; + +/** + * The code units of `node` when it matches exactly one fixed string (a literal, or a + * sequence of literals), else null. + */ +function literalWord(node: Node): number[] | null { + switch (node.kind) { + case 'empty': + return []; + case 'set': { + const { ranges, negated } = node.set; + return !negated && ranges.length === 1 && ranges[0][0] === ranges[0][1] + ? [ranges[0][0]] + : null; + } + case 'seq': { + const word: number[] = []; + for (const item of node.items) { + const part = literalWord(item); + if (part === null) return null; + for (const code of part) word.push(code); + } + return word; + } + default: + return null; + } +} + +/** The strings of an alternation whose every alternative is a fixed string, else null. */ +function literalWords(items: readonly Node[]): number[][] | null { + const words: number[][] = []; + for (const item of items) { + const word = literalWord(item); + if (word === null) return null; + words.push(word); + } + return words; +} + +const MAX_CODE_UNIT = 0xffff; + +const DIGIT: readonly Range[] = [[0x30, 0x39]]; +const WORD: readonly Range[] = [ + [0x30, 0x39], + [0x41, 0x5a], + [0x5f, 0x5f], + [0x61, 0x7a], +]; +const SPACE: readonly Range[] = [ + [0x09, 0x0d], + [0x20, 0x20], + [0xa0, 0xa0], + [0x1680, 0x1680], + [0x2000, 0x200a], + [0x2028, 0x2029], + [0x202f, 0x202f], + [0x205f, 0x205f], + [0x3000, 0x3000], + [0xfeff, 0xfeff], +]; +const LINE_TERMINATORS: readonly Range[] = [ + [0x0a, 0x0a], + [0x0d, 0x0d], + [0x2028, 0x2029], +]; + +/** Complement of a sorted, non-overlapping range list over the UTF-16 code units. */ +function complement(ranges: readonly Range[]): Range[] { + const out: Range[] = []; + let next = 0; + for (const [lo, hi] of ranges) { + if (lo > next) out.push([next, lo - 1]); + next = hi + 1; + } + if (next <= MAX_CODE_UNIT) out.push([next, MAX_CODE_UNIT]); + return out; +} + +const isWordUnit = (c: number): boolean => + (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a) || c === 0x5f || (c >= 0x61 && c <= 0x7a); + +const isDecimal = (c: string | undefined): boolean => c !== undefined && c >= '0' && c <= '9'; +const isHexDigit = (c: string | undefined): boolean => c !== undefined && /^[0-9A-Fa-f]$/.test(c); +const isAsciiLetter = (c: string | undefined): boolean => c !== undefined && /^[A-Za-z]$/.test(c); + +/** What `\x`, `\u` and friends resolve to: a single code unit, or a class. */ +type EscapeResult = + | { readonly code: number } + | { readonly ranges: readonly Range[]; readonly classEscape: true } + | { readonly assertion: 'wordb' | 'nwordb' }; + +/** `{n}`, `{n,}`, `{n,m}` at the sticky cursor. */ +const BRACES = /\{(\d+)(?:(,)(\d*))?\}/y; + +class Parser { + private pos = 0; + private depth = 0; + + constructor(private readonly src: string) {} + + parse(): Node { + const node = this.parseDisjunction(); + if (this.pos < this.src.length) reject('unmatched )'); + return node; + } + + private peek(offset = 0): string | undefined { + return this.src[this.pos + offset]; + } + + private parseDisjunction(): Node { + const alternatives: Node[] = [this.parseAlternative()]; + while (this.peek() === '|') { + this.pos++; + alternatives.push(this.parseAlternative()); + } + return alternatives.length === 1 ? alternatives[0] : { kind: 'alt', items: alternatives }; + } + + private parseAlternative(): Node { + const items: Node[] = []; + while (this.pos < this.src.length) { + const ch = this.peek(); + if (ch === '|' || ch === ')') break; + items.push(this.parseTerm()); + } + if (items.length === 0) return { kind: 'empty' }; + return items.length === 1 ? items[0] : { kind: 'seq', items }; + } + + private parseTerm(): Node { + const ch = this.peek() as string; + if (ch === '^' || ch === '$') { + this.pos++; + this.rejectQuantifierAfterAssertion(); + return { kind: 'assert', which: ch === '^' ? 'bol' : 'eol' }; + } + const atom = this.parseAtom(); + if (atom.kind === 'assert') { + this.rejectQuantifierAfterAssertion(); + return atom; + } + return this.parseQuantifier(atom); + } + + private rejectQuantifierAfterAssertion(): void { + const ch = this.peek(); + if (ch === '*' || ch === '+' || ch === '?') reject('nothing to repeat'); + if (ch === '{' && this.tryReadBraces() !== null) reject('nothing to repeat'); + } + + private parseAtom(): Node { + const ch = this.peek() as string; + switch (ch) { + case '.': + this.pos++; + return { kind: 'set', set: { ranges: LINE_TERMINATORS, negated: true } }; + case '(': + return this.parseGroup(); + case '[': + return this.parseClass(); + case '\\': + return this.parseAtomEscape(); + case '*': + case '+': + case '?': + return reject('nothing to repeat'); + case '{': + if (this.tryReadBraces() !== null) reject('nothing to repeat'); + this.pos++; + return literal(ch.charCodeAt(0)); + default: + this.pos++; + return literal(ch.charCodeAt(0)); + } + } + + private parseGroup(): Node { + this.pos++; // ( + if (this.peek() === '?') { + // `(?:` is a plain group; `(?=`, `(?!`, `(?<` and everything else is unsupported or invalid. + // Only a `(` the parser reaches here opens a group: one inside a character class + // (`[(?<]`) or after a backslash (`\(?<`) is a literal and never gets this far. + if (this.peek(1) === '<') reject('named groups and look-behind are unsupported'); + if (this.peek(1) !== ':') reject('look-around and group modifiers are unsupported'); + this.pos += 2; + } + if (++this.depth > MAX_NESTING) reject('groups are nested too deeply'); + const inner = this.parseDisjunction(); + this.depth--; + if (this.peek() !== ')') reject('unterminated group'); + this.pos++; + // Wrapped so that `(?:$)*` and `(\b)+` stay quantifiable, unlike a bare assertion. + return { kind: 'seq', items: [inner] }; + } + + /** Read `{n}`, `{n,}` or `{n,m}` at the cursor without consuming; null when not a quantifier. */ + private tryReadBraces(): { min: number; max: number; length: number } | null { + BRACES.lastIndex = this.pos; + const m = BRACES.exec(this.src); + if (m === null) return null; + const min = Number(m[1]); + const max = m[2] === undefined ? min : m[3] === '' ? Infinity : Number(m[3]); + return { min, max, length: m[0].length }; + } + + private parseQuantifier(atom: Node): Node { + const ch = this.peek(); + let min: number; + let max: number; + if (ch === '*') { + min = 0; + max = Infinity; + this.pos++; + } else if (ch === '+') { + min = 1; + max = Infinity; + this.pos++; + } else if (ch === '?') { + min = 0; + max = 1; + this.pos++; + } else if (ch === '{') { + const braces = this.tryReadBraces(); + if (braces === null) return atom; // a literal `{`, parsed as the next atom + if (braces.max < braces.min) reject('numbers out of order in quantifier'); + min = braces.min; + max = braces.max; + this.pos += braces.length; + } else { + return atom; + } + if (this.peek() === '?') this.pos++; // lazy: irrelevant for a yes/no match + return { kind: 'repeat', min, max, node: atom }; + } + + /** `\` outside a bracket expression. */ + private parseAtomEscape(): Node { + const result = this.readEscape(false); + if ('assertion' in result) return { kind: 'assert', which: result.assertion }; + if ('classEscape' in result) { + return { kind: 'set', set: { ranges: result.ranges, negated: false } }; + } + return literal(result.code); + } + + /** + * Decode the escape at the cursor (`src[pos] === '\\'`) and advance past it. + * `inClass` selects the bracket-expression meanings (`\b` is backspace there). + */ + private readEscape(inClass: boolean): EscapeResult { + this.pos++; // backslash + const c = this.peek(); + if (c === undefined) return reject('\\ at end of pattern'); + this.pos++; + switch (c) { + case 'd': + return { ranges: DIGIT, classEscape: true }; + case 'D': + return { ranges: complement(DIGIT), classEscape: true }; + case 'w': + return { ranges: WORD, classEscape: true }; + case 'W': + return { ranges: complement(WORD), classEscape: true }; + case 's': + return { ranges: SPACE, classEscape: true }; + case 'S': + return { ranges: complement(SPACE), classEscape: true }; + case 'b': + return inClass ? { code: 0x08 } : { assertion: 'wordb' }; + case 'B': + return inClass ? { code: 0x42 } : { assertion: 'nwordb' }; + case 't': + return { code: 0x09 }; + case 'n': + return { code: 0x0a }; + case 'v': + return { code: 0x0b }; + case 'f': + return { code: 0x0c }; + case 'r': + return { code: 0x0d }; + case '0': + if (isDecimal(this.peek())) reject('legacy octal escape'); + return { code: 0 }; + case 'c': { + const letter = this.peek(); + // Inside a bracket expression Annex B also accepts a digit or `_` after `\c`. + if (isAsciiLetter(letter) || (inClass && (isDecimal(letter) || letter === '_'))) { + this.pos++; + return { code: (letter as string).charCodeAt(0) % 32 }; + } + // `\c` without a letter is a literal backslash; the `c` is read next. + this.pos--; + return { code: 0x5c }; + } + case 'x': { + if (isHexDigit(this.peek()) && isHexDigit(this.peek(1))) { + const code = parseInt(this.src.slice(this.pos, this.pos + 2), 16); + this.pos += 2; + return { code }; + } + return { code: 0x78 }; + } + case 'u': { + const digits = this.src.slice(this.pos, this.pos + 4); + if (/^[0-9A-Fa-f]{4}$/.test(digits)) { + this.pos += 4; + return { code: parseInt(digits, 16) }; + } + return { code: 0x75 }; + } + case 'k': + // Identity escape (no named groups exist; `parseGroup` rejects those). + return { code: 0x6b }; + default: + if (c >= '1' && c <= '9') reject('back-reference or legacy octal escape'); + return { code: c.charCodeAt(0) }; + } + } + + private parseClass(): Node { + this.pos++; // [ + let negated = false; + if (this.peek() === '^') { + negated = true; + this.pos++; + } + const ranges: Range[] = []; + for (;;) { + const ch = this.peek(); + if (ch === undefined) reject('unterminated character class'); + if (ch === ']') { + this.pos++; + break; + } + const lo = this.readClassAtom(); + if (this.peek() === '-' && this.peek(1) !== ']' && this.peek(1) !== undefined) { + // Possible range: a class escape on either side makes the `-` a literal (Annex B). + this.pos++; // - + const hi = this.readClassAtom(); + if ('code' in lo && 'code' in hi) { + if (lo.code > hi.code) reject('range out of order in character class'); + ranges.push([lo.code, hi.code]); + } else { + this.addClassAtom(ranges, lo); + ranges.push([0x2d, 0x2d]); + this.addClassAtom(ranges, hi); + } + } else { + this.addClassAtom(ranges, lo); + } + } + return { kind: 'set', set: { ranges: normalise(ranges), negated } }; + } + + private addClassAtom( + ranges: Range[], + atom: { readonly code: number } | { readonly ranges: readonly Range[] }, + ): void { + if ('code' in atom) ranges.push([atom.code, atom.code]); + else ranges.push(...atom.ranges); + } + + private readClassAtom(): { readonly code: number } | { readonly ranges: readonly Range[] } { + const ch = this.peek() as string; + if (ch !== '\\') { + this.pos++; + return { code: ch.charCodeAt(0) }; + } + const result = this.readEscape(true); + if ('assertion' in result) return reject('unreachable: no assertions inside a class'); + if ('classEscape' in result) return { ranges: result.ranges }; + return { code: result.code }; + } +} + +const literal = (code: number): Node => ({ + kind: 'set', + set: { ranges: [[code, code]], negated: false }, +}); + +/** Sort and merge overlapping or adjacent ranges. */ +function normalise(ranges: readonly Range[]): Range[] { + const sorted = [...ranges].sort((a, b) => a[0] - b[0]); + const out: Array<[number, number]> = []; + for (const [lo, hi] of sorted) { + const last = out[out.length - 1]; + if (last !== undefined && lo <= last[1] + 1) last[1] = Math.max(last[1], hi); + else out.push([lo, hi]); + } + return out; +} + +// --- NFA --------------------------------------------------------------------------- + +const K_CHAR = 0; +const K_SPLIT = 1; +const K_ASSERT = 2; +const K_MATCH = 3; + +const A_BOL = 0; +const A_EOL = 1; +const A_WORDB = 2; +const A_NWORDB = 3; + +const ASSERT_CODES = { bol: A_BOL, eol: A_EOL, wordb: A_WORDB, nwordb: A_NWORDB } as const; + +/** Thompson NFA over parallel arrays; state `i` is described by index `i` of each. */ +class Nfa { + constructor( + /** Most states this NFA may have: {@link MAX_STATES}, or less when an allowance is low. */ + private readonly limit: number = MAX_STATES, + private readonly limitReason: string = 'pattern expands beyond the state cap', + ) {} + + readonly kind: number[] = []; + readonly out: number[] = []; + /** Second branch of a split; unused otherwise. */ + readonly out1: number[] = []; + /** Character set (K_CHAR) or assertion code (K_ASSERT). */ + readonly arg: Array = []; + private steps = 0; + + add(kind: number, out: number, out1: number, arg: CharSet | number | null): number { + if (this.kind.length >= this.limit) reject(this.limitReason); + this.kind.push(kind); + this.out.push(out); + this.out1.push(out1); + this.arg.push(arg); + return this.kind.length - 1; + } + + /** Build `node` so that it continues into `next`; returns its entry state. */ + build(node: Node, next: number): number { + if (++this.steps > MAX_BUILD_STEPS) reject('pattern expands beyond the work cap'); + switch (node.kind) { + case 'empty': + return next; + case 'set': + return this.add(K_CHAR, next, -1, node.set); + case 'assert': + return this.add(K_ASSERT, next, -1, ASSERT_CODES[node.which]); + case 'seq': { + let cont = next; + for (let i = node.items.length - 1; i >= 0; i--) cont = this.build(node.items[i], cont); + return cont; + } + case 'alt': { + const words = literalWords(node.items); + if (words !== null) return this.buildWords(words, next); + let entry = this.build(node.items[node.items.length - 1], next); + for (let i = node.items.length - 2; i >= 0; i--) { + entry = this.add(K_SPLIT, this.build(node.items[i], next), entry, null); + } + return entry; + } + case 'repeat': + return this.buildRepeat(node, next); + } + } + + /** + * Build an alternation of literal strings as a trie, so that alternatives sharing a + * prefix share states (`my_function_0|my_function_1|...` keeps one state active on + * the common prefix instead of one per alternative). Matches the same strings as the + * plain alternation. Iterative: a literal may be thousands of characters long. + */ + private buildWords(words: readonly (readonly number[])[], next: number): number { + interface TrieNode { + readonly children: Map; + terminal: boolean; + } + const root: TrieNode = { children: new Map(), terminal: false }; + for (const word of words) { + let at = root; + for (const code of word) { + let child = at.children.get(code); + if (child === undefined) { + child = { children: new Map(), terminal: false }; + at.children.set(code, child); + } + at = child; + } + at.terminal = true; + } + // Each entry: a trie node, and the character state whose `out` is that node's entry + // (-1 for the root, whose entry is the result). + let rootEntry = next; + const pending: Array<{ node: TrieNode; via: number }> = [{ node: root, via: -1 }]; + for (let task = pending.pop(); task !== undefined; task = pending.pop()) { + if (++this.steps > MAX_BUILD_STEPS) reject('pattern expands beyond the work cap'); + const branches: number[] = []; + if (task.node.terminal) branches.push(next); + for (const [code, child] of task.node.children) { + const state = this.add(K_CHAR, -1, -1, { + ranges: [[code, code]], + negated: false, + }); + branches.push(state); + pending.push({ node: child, via: state }); + } + let entry = branches[branches.length - 1]; + for (let i = branches.length - 2; i >= 0; i--) { + entry = this.add(K_SPLIT, branches[i], entry, null); + } + if (branches.length === 0) entry = next; + if (task.via === -1) rootEntry = entry; + else this.out[task.via] = entry; + } + return rootEntry; + } + + private buildRepeat(node: Extract, next: number): number { + // Reject before expanding: a count beyond the cap cannot fit whatever the body is. + if (node.min > MAX_STATES) reject('repetition count beyond the state cap'); + let cont: number; + if (node.max === Infinity) { + const loop = this.add(K_SPLIT, -1, next, null); + this.out[loop] = this.build(node.node, loop); + cont = loop; + } else { + if (node.max - node.min > MAX_STATES) reject('repetition count beyond the state cap'); + cont = next; + for (let i = node.min; i < node.max; i++) { + cont = this.add(K_SPLIT, this.build(node.node, cont), next, null); + } + } + for (let i = 0; i < node.min; i++) cont = this.build(node.node, cont); + return cont; + } +} + +/** Shared by {@link LinearRegex.startStates} entries that reach the match state. */ +const NO_STATES: readonly number[] = []; + +/** Empty-transition closure of the start state under one assertion context. */ +interface StartClosure { + /** True when the match state is reachable without consuming a character. */ + readonly matches: boolean; + /** Character states reachable without consuming a character. */ + readonly chars: readonly number[]; +} + +/** A compiled pattern. `test` has the same yes/no contract as `RegExp.prototype.test`. */ +export class LinearRegex { + /** Number of NFA states, exposed so tests can assert the structural bound. */ + readonly stateCount: number; + /** + * True once the work budget (this matcher's own, or the shared one) ran out; later + * unseen names are answered false. + */ + exhausted = false; + /** State visits spent so far, against {@link MAX_TOTAL_WORK}. */ + work = 0; + private readonly kind: readonly number[]; + private readonly out: readonly number[]; + private readonly out1: readonly number[]; + private readonly arg: ReadonlyArray; + private readonly start: number; + private readonly mark: Int32Array; + private readonly stack: number[] = []; + private stamp = 0; + /** Start closures by assertion context (see {@link contextAt}). */ + private readonly startClosures: Array = new Array(16); + /** Character states of a start closure that accept one code unit, by context and unit. */ + private readonly startStates = new Map(); + private readonly verdicts = new Map(); + + constructor( + /** The pattern text this matcher was compiled from (after any POSIX-class translation). */ + readonly source: string, + nfa: Nfa, + start: number, + /** Allowance shared with other matchers; they all stop when it is spent. */ + private readonly shared?: SharedWorkBudget, + ) { + this.kind = nfa.kind; + this.out = nfa.out; + this.out1 = nfa.out1; + this.arg = nfa.arg; + this.start = start; + this.stateCount = nfa.kind.length; + this.mark = new Int32Array(this.stateCount); + } + + /** True when the pattern matches anywhere in `name` (an unanchored search). */ + test(name: string): boolean { + const known = this.verdicts.get(name); + if (known !== undefined) return known; + if (this.work >= MAX_TOTAL_WORK || this.shared?.isSpent === true) { + this.exhausted = true; + return false; + } + const verdict = this.search(name); + // A search the budget cut short has no verdict; do not remember it. + if (!this.exhausted && this.verdicts.size < MAX_MEMO_ENTRIES) this.verdicts.set(name, verdict); + return verdict; + } + + /** Count `visits` state visits against this matcher's cap and the shared budget. */ + private charge(visits: number): void { + this.work += visits; + this.shared?.charge(visits); + } + + private search(name: string): boolean { + const length = name.length; + let current: number[] = []; + let following: number[] = []; + this.stamp++; + if (this.seed(current, name, 0)) return true; + for (let p = 0; p < length; p++) { + if (this.work > MAX_TOTAL_WORK || this.shared?.isSpent === true) { + this.exhausted = true; + return false; + } + const c = name.charCodeAt(p); + this.stamp++; + following.length = 0; + this.charge(current.length); + for (let i = 0; i < current.length; i++) { + const s = current[i]; + if (inSet(this.arg[s] as CharSet, c) && this.add(following, this.out[s], name, p + 1)) { + return true; + } + } + // Unanchored search: a match may also begin at the next position. + if (this.seed(following, name, p + 1)) return true; + const swap = current; + current = following; + following = swap; + } + return false; + } + + /** + * Start a match attempt at position `p`: true when the pattern matches there without + * consuming anything, otherwise the states that can consume `name[p]` are added to + * `list`. Those are the only start states worth keeping (the rest die on the next + * step), and looking them up by code unit keeps a long alternation from costing + * one visit per alternative at every position. + */ + private seed(list: number[], name: string, p: number): boolean { + const context = contextAt(name, p); + const closure = (this.startClosures[context] ??= this.closeStart(context)); + if (closure.matches) return true; + if (p === name.length) return false; + const c = name.charCodeAt(p); + const key = context * 0x10000 + c; + let states = this.startStates.get(key); + if (states === undefined) { + states = closure.chars.filter((s) => inSet(this.arg[s] as CharSet, c)); + if (this.startStates.size < 4096) this.startStates.set(key, states); + } + this.charge(1 + states.length); + for (let i = 0; i < states.length; i++) { + const s = states[i]; + if (this.mark[s] !== this.stamp) { + this.mark[s] = this.stamp; + list.push(s); + } + } + return false; + } + + /** Closure of the start state where the assertion outcomes are those of `context`. */ + private closeStart(context: number): StartClosure { + const seen = new Uint8Array(this.stateCount); + const chars: number[] = []; + const stack = [this.start]; + while (stack.length > 0) { + const s = stack.pop() as number; + if (seen[s] === 1) continue; + seen[s] = 1; + switch (this.kind[s]) { + case K_MATCH: + return { matches: true, chars: NO_STATES }; + case K_CHAR: + chars.push(s); + break; + case K_SPLIT: + stack.push(this.out1[s], this.out[s]); + break; + default: + if (holdsIn(this.arg[s] as number, context)) stack.push(this.out[s]); + } + } + return { matches: false, chars }; + } + + /** + * Follow empty transitions from `state` at position `p`, pushing character states + * onto `list`. Returns true on reaching the match state. A state is entered at most + * once per list (the stamp), which also stops empty-loop cycles such as `(a*)*`. + * Iterative: a long alternation is a long chain of splits. + */ + private add(list: number[], state: number, name: string, p: number): boolean { + const stack = this.stack; + stack.length = 0; + stack.push(state); + while (stack.length > 0) { + const s = stack.pop() as number; + if (this.mark[s] === this.stamp) continue; + this.mark[s] = this.stamp; + this.charge(1); + switch (this.kind[s]) { + case K_MATCH: + return true; + case K_CHAR: + list.push(s); + break; + case K_SPLIT: + stack.push(this.out1[s], this.out[s]); + break; + default: + if (holdsIn(this.arg[s] as number, contextAt(name, p))) stack.push(this.out[s]); + } + } + return false; + } +} + +const CTX_BOL = 1; +const CTX_EOL = 2; +const CTX_WORD_BEFORE = 4; +const CTX_WORD_AFTER = 8; + +/** What the assertions can observe at position `p`: the two ends and word-ness either side. */ +function contextAt(name: string, p: number): number { + let context = 0; + if (p === 0) context |= CTX_BOL; + else if (isWordUnit(name.charCodeAt(p - 1))) context |= CTX_WORD_BEFORE; + if (p === name.length) context |= CTX_EOL; + else if (isWordUnit(name.charCodeAt(p))) context |= CTX_WORD_AFTER; + return context; +} + +function holdsIn(assertion: number, context: number): boolean { + switch (assertion) { + case A_BOL: + return (context & CTX_BOL) !== 0; + case A_EOL: + return (context & CTX_EOL) !== 0; + default: { + const before = (context & CTX_WORD_BEFORE) !== 0; + const after = (context & CTX_WORD_AFTER) !== 0; + return assertion === A_WORDB ? before !== after : before === after; + } + } +} + +function inSet(set: CharSet, c: number): boolean { + let hit = false; + const ranges = set.ranges; + for (let i = 0; i < ranges.length; i++) { + const r = ranges[i]; + if (c < r[0]) break; // ranges are sorted + if (c <= r[1]) { + hit = true; + break; + } + } + return hit !== set.negated; +} + +/** The outcome of {@link compileLinearRegexDetailed}: a matcher, or why there is none. */ +export type LinearRegexResult = + | { readonly regex: LinearRegex } + | { readonly regex: null; readonly reason: string }; + +/** + * Compile a JavaScript-syntax pattern to a linear-time matcher. When the pattern is + * invalid, unsupported or too large the result carries the reason instead. Never throws. + * A `shared` budget makes the matcher draw on it as well as on its own cap, and the + * pattern's NFA states count against the budget's allowance of states. + */ +export function compileLinearRegexDetailed( + source: string, + shared?: SharedWorkBudget, +): LinearRegexResult { + // With nothing left of the state allowance, refuse without even parsing the pattern. + const room = shared?.statesLeft ?? MAX_STATES; + if (room <= 0) return { regex: null, reason: STATE_ALLOWANCE_REASON }; + if (source.length > MAX_PATTERN_LENGTH) { + return { regex: null, reason: `pattern is longer than ${MAX_PATTERN_LENGTH} characters` }; + } + try { + const ast = new Parser(source).parse(); + // The build stops at the allowance, so a pattern that does not fit never has its NFA + // allocated in full. + const nfa = room < MAX_STATES ? new Nfa(room, STATE_ALLOWANCE_REASON) : new Nfa(); + const match = nfa.add(K_MATCH, -1, -1, null); + const start = nfa.build(ast, match); + shared?.reserveStates(nfa.kind.length); + return { regex: new LinearRegex(source, nfa, start, shared) }; + } catch (err) { + // `Reject` is the expected path; a stack overflow or any other failure also means + // "cannot match safely", which for an exportPattern is the same as never matching. + if (err instanceof Reject && err.message === STATE_ALLOWANCE_REASON) shared?.closeStates(room); + return { + regex: null, + reason: err instanceof Reject ? err.message : 'pattern could not be compiled', + }; + } +} + +/** {@link compileLinearRegexDetailed} without the reason: null when there is no matcher. */ +export function compileLinearRegex(source: string, shared?: SharedWorkBudget): LinearRegex | null { + return compileLinearRegexDetailed(source, shared).regex; +} diff --git a/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts b/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts new file mode 100644 index 000000000..ac0d86841 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/namespace-imports.ts @@ -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 = new Set(['importFrom']); + +/** Directives whose arguments name exported symbols (`S3method` is handled separately). */ +const EXPORT_NAME_DIRECTIVES: ReadonlySet = new Set([ + 'export', + 'exportClasses', + 'exportMethods', +]); + +const EXPORT_DIRECTIVES: ReadonlySet = new Set([ + ...EXPORT_NAME_DIRECTIVES, + 'S3method', + 'exportPattern', +]); + +/** Directives whose following statement is conditional and therefore ignored. */ +const CONDITIONAL_HEADS: ReadonlySet = 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): 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 `/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 = 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; + /** For each dotted name in {@link names}, the text after its last `.`. */ + readonly dottedTails: ReadonlySet; +} + +const EMPTY_TOP_LEVEL: RFileTopLevel = { names: new Set(), dottedTails: new Set() }; +const topLevelCache = new WeakMap(); + +/** + * 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(); + const dottedTails = new Set(); + 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>>(); + +/** Recorded top-level names per package for `resolutionConfig`, if the hook ran with imports. */ +export function rRecordedPackageTopLevelNames( + resolutionConfig: unknown, +): ReadonlyMap> | 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; +} + +/** + * 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(); + const needNamesFor = new Set(); + 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(); + for (const entry of info.importFrom) last.set(entry.name, entry.pkg); + const bindable = new Map(); + 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>(); + const callerFiles = new Map(); + 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[] = []; + 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> +>(); + +/** Record (replacing any earlier record) the per-package definition counts for `resolutionConfig`. */ +export function rRecordQualifiedDefinitionCounts( + resolutionConfig: object, + counts: ReadonlyMap>, +): 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 `/R/**` of a package with a NAMESPACE + * that has an `importFrom(P, )` 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; +} diff --git a/gitnexus/src/core/ingestion/languages/r/naming-argument.ts b/gitnexus/src/core/ingestion/languages/r/naming-argument.ts new file mode 100644 index 000000000..f379171d6 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/naming-argument.ts @@ -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> = { + 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>(); + +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(); + 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 = 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); +} diff --git a/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts b/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts new file mode 100644 index 000000000..084b48809 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/owner-hooks.ts @@ -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> | undefined => { + const ownerNameHint = + ctx.nodeLabel === 'Method' + ? (getRTopLevelMethodOwnerName(ctx.definitionNode) ?? + getRTopLevelPropertyOwnerName(ctx.definitionNode)) + : ctx.nodeLabel === 'Property' + ? getRTopLevelPropertyOwnerName(ctx.definitionNode) + : null; + return ownerNameHint ? { ownerNameHint } : undefined; +}; diff --git a/gitnexus/src/core/ingestion/languages/r/package-config.ts b/gitnexus/src/core/ingestion/languages/r/package-config.ts new file mode 100644 index 000000000..e1d0e4d94 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/package-config.ts @@ -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`, `.Rcheck`) are + * not searched, so their packages are not entries here. + */ + packages: Map; + /** Package-scoped NAMESPACE config keyed by package dir relative to repo root. */ + namespaceInfoByPackageDir: Map; + /** + * 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; + /** + * 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; + /** 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, + 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 = 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 = new Set(['renv', 'packrat', 'revdep']); + +/** Suffix of the `.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 `.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 { + 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, + budget: number = HIDDEN_SCAN_BUDGET, +): Promise { + 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 { + const packages = new Map(); + const namespaceInfoByPackageDir = new Map(); + const packageDirs = new Set(); + // 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(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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/r/post-parse.ts b/gitnexus/src/core/ingestion/languages/r/post-parse.ts new file mode 100644 index 000000000..cc9b26da4 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/post-parse.ts @@ -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(); + 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 => { + 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); +}; diff --git a/gitnexus/src/core/ingestion/languages/r/qualified-call.ts b/gitnexus/src/core/ingestion/languages/r/qualified-call.ts new file mode 100644 index 000000000..e1f8bc047 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/qualified-call.ts @@ -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 `/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 + * `/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, +): 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 = 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 { + const byName = new Map(); + 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 | undefined; + const localityByPackage = new Map(); + 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>(); + 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 `/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)); +} diff --git a/gitnexus/src/core/ingestion/languages/r/query.ts b/gitnexus/src/core/ingestion/languages/r/query.ts new file mode 100644 index 000000000..8aa2c337d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/query.ts @@ -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 <- ` 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[0]); + } + return _parser; +} + +export function getRScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(R as Parameters[0], R_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts new file mode 100644 index 000000000..98175fe1a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/scope-resolver.ts @@ -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(); + 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(); + 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, +}; diff --git a/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts new file mode 100644 index 000000000..bc81c30da --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/r/simple-hooks.ts @@ -0,0 +1,49 @@ +import type { BindingRef, Callsite, SymbolDefinition } from 'gitnexus-shared'; + +const TIER: Record = { + 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(); + 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'; +} diff --git a/gitnexus/src/core/ingestion/method-extractors/r.ts b/gitnexus/src/core/ingestion/method-extractors/r.ts new file mode 100644 index 000000000..30746779a --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/r.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index cc72a04d5..07e773c98 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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(); + 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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index ee98e3807..82f7c681c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -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 = n [SupportedLanguages.Vue, vueScopeResolver], [SupportedLanguages.Zig, zigScopeResolver], [SupportedLanguages.ObjectiveC, objectiveCScopeResolver], + [SupportedLanguages.R, rScopeResolver], ]); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index efc733844..c5b2c6134 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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.Swift]: SWIFT_QUERIES, [SupportedLanguages.Dart]: DART_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue