hypergumbo-lang-rust-analyzer 5.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Hypergumbo SCIP-backed Rust analyzer.
3
+
4
+ Slice A surface: :func:`translate_scip_to_hg` converts a serialized SCIP
5
+ ``Index`` blob into ``(symbols, edges)`` using the in-core shim modules
6
+ plus the ``rust.py`` stable-id parity helper. Later slices add a live
7
+ ``rust-analyzer`` invocation, analyzer-registry wiring, and the opt-in
8
+ flag machinery.
9
+ """
10
+
11
+ from hypergumbo_lang_rust_analyzer.gate import (
12
+ ENV_VAR_NAME,
13
+ should_use_rust_analyzer_backend,
14
+ )
15
+ from hypergumbo_lang_rust_analyzer.graceful_degrade import (
16
+ try_analyze_with_rust_analyzer,
17
+ )
18
+ from hypergumbo_lang_rust_analyzer.invoke import (
19
+ RustAnalyzerError,
20
+ RustAnalyzerInvocationFailed,
21
+ RustAnalyzerNoOutput,
22
+ RustAnalyzerNotInstalled,
23
+ run_rust_analyzer_scip,
24
+ )
25
+ from hypergumbo_lang_rust_analyzer.translate import (
26
+ reassign_rust_stable_ids,
27
+ translate_scip_to_hg,
28
+ )
29
+
30
+ __version__ = "5.0.0"
31
+
32
+ # Module paths for analyzer discovery via entry-points (ADR-0012 Step 1).
33
+ # Importing the listed module triggers the @register_analyzer decorator.
34
+ ANALYZER_MODULES = [
35
+ "hypergumbo_lang_rust_analyzer.analyzer",
36
+ ]
37
+
38
+ __all__ = [
39
+ "ANALYZER_MODULES",
40
+ "ENV_VAR_NAME",
41
+ "RustAnalyzerError",
42
+ "RustAnalyzerInvocationFailed",
43
+ "RustAnalyzerNoOutput",
44
+ "RustAnalyzerNotInstalled",
45
+ "__version__",
46
+ "reassign_rust_stable_ids",
47
+ "run_rust_analyzer_scip",
48
+ "should_use_rust_analyzer_backend",
49
+ "translate_scip_to_hg",
50
+ "try_analyze_with_rust_analyzer",
51
+ ]
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Registered analyzer entry point for the SCIP-backed Rust backend (WI-duzul Slice C-final).
3
+
4
+ This module threads the four primitives the earlier slices delivered
5
+ into the hypergumbo-core analyzer-registry surface:
6
+
7
+ 1. :func:`hypergumbo_lang_rust_analyzer.gate.should_use_rust_analyzer_backend`
8
+ gates the whole function on the user's opt-in + binary availability.
9
+ 2. :func:`hypergumbo_lang_rust_analyzer.graceful_degrade.try_analyze_with_rust_analyzer`
10
+ handles the actual shell-out + SCIP → IR translation and returns
11
+ ``None`` on any of the WI-nohah fall-through conditions.
12
+ 3. Stable-id parity is already threaded inside
13
+ :func:`~hypergumbo_lang_rust_analyzer.translate.translate_scip_to_hg`,
14
+ so the Symbol objects this analyzer emits are deduplicable against
15
+ the tree-sitter ``rust.py`` analyzer's output when cross-pass dedup
16
+ runs (WI-bajuz).
17
+
18
+ Registration
19
+ ------------
20
+ Registered as analyzer name ``"rust_analyzer"`` (distinct from
21
+ ``rust.py``'s ``"rust"`` registration) so both analyzers coexist in
22
+ the registry: ``rust.py`` remains the default, this one only produces
23
+ output when the user opts in. Priority 45 (vs. the registry default
24
+ 50) so the SCIP pass runs slightly earlier when active — not load-
25
+ bearing, just consistent with the "higher quality backend runs first"
26
+ convention.
27
+
28
+ When the opt-in gate returns False (the default for every session
29
+ that hasn't set ``HYPERGUMBO_RUST_ANALYZER=1`` or passed
30
+ ``--backend rust-analyzer``), this analyzer returns an empty
31
+ :class:`AnalysisResult` immediately. No file walk, no subprocess.
32
+ ``rust.py`` takes care of Rust analysis in that case.
33
+
34
+ Provenance
35
+ ----------
36
+ Successful SCIP-sourced Symbols already carry ``origin="scip"`` from
37
+ :func:`~hypergumbo_core.scip.index.scip_index_to_symbols`, which
38
+ preserves the provenance marker the WI-nohah description calls out.
39
+ Downstream tools can filter on ``symbol.origin`` to trust-weight SCIP-
40
+ derived symbols separately from tree-sitter-derived ones.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from pathlib import Path
46
+
47
+ from hypergumbo_core.analyze.base import AnalysisResult
48
+ from hypergumbo_core.analyze.registry import register_analyzer
49
+
50
+ from hypergumbo_lang_rust_analyzer.gate import should_use_rust_analyzer_backend
51
+ from hypergumbo_lang_rust_analyzer.graceful_degrade import (
52
+ try_analyze_with_rust_analyzer,
53
+ )
54
+
55
+
56
+ def _disk_source_reader(path: str) -> bytes | None:
57
+ """Read *path* from disk, swallowing I/O errors as ``None``.
58
+
59
+ The translate-layer's reassign_rust_stable_ids takes a caller-owned
60
+ reader callable so tests can inject in-memory fakes. Production
61
+ callers (this function) pass a disk-backed reader with exception
62
+ handling that matches the reassignment pass's contract: any read
63
+ failure maps to "source unavailable, skip parity rewrite" rather
64
+ than crashing the analyzer.
65
+ """
66
+ try:
67
+ return Path(path).read_bytes()
68
+ except (OSError, ValueError): # pragma: no cover — pure defensive
69
+ return None
70
+
71
+
72
+ @register_analyzer("rust_analyzer", priority=45)
73
+ def analyze_rust_with_scip(repo_root: Path) -> AnalysisResult:
74
+ """Entry point for the SCIP-backed Rust analyzer.
75
+
76
+ Returns an empty result when the opt-in gate is False; otherwise
77
+ shells out to ``rust-analyzer scip <repo_root>``, translates the
78
+ emitted SCIP index into hypergumbo ``Symbol`` / ``Edge`` objects
79
+ (with rust.py stable_id parity), and returns them. All three
80
+ WI-nohah fall-through conditions are handled inside
81
+ :func:`try_analyze_with_rust_analyzer` and surface here as a
82
+ ``None`` return — this function swallows that to an empty
83
+ AnalysisResult so the registry treats "no SCIP run" identically
84
+ to "SCIP run produced nothing".
85
+
86
+ The registry calls this with ``repo_root`` being the workspace
87
+ root, which is exactly what ``cargo metadata`` + ``rust-analyzer
88
+ scip`` expect.
89
+ """
90
+ if not should_use_rust_analyzer_backend():
91
+ return AnalysisResult()
92
+
93
+ result = try_analyze_with_rust_analyzer(repo_root, _disk_source_reader)
94
+ if result is None:
95
+ return AnalysisResult()
96
+
97
+ symbols, edges = result
98
+ return AnalysisResult(symbols=symbols, edges=edges)
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Opt-in gate for the SCIP-backed Rust analyzer (WI-duzul Slice C gate).
3
+
4
+ The rust-analyzer backend is opt-in because SCIP indexing is ~10x slower
5
+ than tree-sitter at every realistic size (WI-zakub §4). Activating it
6
+ requires two conditions:
7
+
8
+ 1. The user explicitly asked for it, either via the
9
+ ``HYPERGUMBO_RUST_ANALYZER`` environment variable (``"1"`` / ``"true"``
10
+ / ``"yes"``, case-insensitive) OR via the ``--backend rust-analyzer``
11
+ CLI flag (which the caller resolves to a string and passes in).
12
+ 2. The ``rust-analyzer`` binary is resolvable on ``PATH``
13
+ (:func:`hypergumbo_core.rust_analyzer_install.is_rust_analyzer_available`).
14
+
15
+ :func:`should_use_rust_analyzer_backend` is the single decision point.
16
+ The function is pure — ``environ`` / ``is_available`` are injected so
17
+ tests can exercise every branch without mutating ``os.environ`` or
18
+ shelling out to ``shutil.which``. Production callers pass ``None`` for
19
+ both and pick up :data:`os.environ` + the real availability check.
20
+
21
+ The split between this module and :mod:`graceful_degrade` is
22
+ intentional: graceful-degrade answers "the user asked; did it work?"
23
+ (handling runtime failures), while this gate answers "did the user
24
+ actually ask?" (handling opt-in). Slice C's analyzer-registry wrapper
25
+ chains them in that order.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ from typing import Callable, Mapping, Optional
32
+
33
+ ENV_VAR_NAME = "HYPERGUMBO_RUST_ANALYZER"
34
+
35
+ # Strings accepted as "yes, opt in" (case-insensitive). Matches the
36
+ # convention used by other tools' truthy-env-var parsing.
37
+ _TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"})
38
+
39
+ # Backend-selector strings accepted by the --backend CLI flag. ``None`` /
40
+ # empty string means "use the default (tree-sitter rust.py)".
41
+ _RUST_ANALYZER_FLAG_VALUES = frozenset({"rust-analyzer", "rust_analyzer", "scip"})
42
+
43
+
44
+ def _is_env_enabled(environ: Mapping[str, str]) -> bool:
45
+ """Return True when the opt-in env var resolves to a truthy value."""
46
+ raw = environ.get(ENV_VAR_NAME, "")
47
+ return raw.strip().lower() in _TRUTHY_VALUES
48
+
49
+
50
+ def _is_flag_enabled(backend_flag: Optional[str]) -> bool:
51
+ """Return True when the caller-supplied ``--backend`` flag selects SCIP."""
52
+ if backend_flag is None:
53
+ return False
54
+ return backend_flag.strip().lower() in _RUST_ANALYZER_FLAG_VALUES
55
+
56
+
57
+ def should_use_rust_analyzer_backend(
58
+ *,
59
+ backend_flag: Optional[str] = None,
60
+ environ: Optional[Mapping[str, str]] = None,
61
+ is_available: Optional[Callable[[], bool]] = None,
62
+ ) -> bool:
63
+ """Return True iff the rust-analyzer backend should run.
64
+
65
+ Two conditions must both hold:
66
+
67
+ - The user opted in, via either ``backend_flag`` or the
68
+ ``HYPERGUMBO_RUST_ANALYZER`` env var. Either one alone is enough.
69
+ - The ``rust-analyzer`` binary is resolvable on ``PATH`` (so
70
+ activating the backend cannot fail at spawn-time for a configuration
71
+ error the user can fix with ``hypergumbo install-rust-analyzer``).
72
+
73
+ When the user opted in but the binary is missing, this helper
74
+ returns False silently — the opt-in is honoured up to the limits of
75
+ what is installed, and the analyzer-registry wrapper falls through
76
+ to ``rust.py``. Callers that want to warn the user about the
77
+ mismatch should check the two conditions separately.
78
+ """
79
+ env = environ if environ is not None else os.environ
80
+ if not (_is_flag_enabled(backend_flag) or _is_env_enabled(env)):
81
+ return False
82
+
83
+ if is_available is None:
84
+ from hypergumbo_core.rust_analyzer_install import (
85
+ is_rust_analyzer_available,
86
+ )
87
+ is_available = is_rust_analyzer_available
88
+ return is_available()
@@ -0,0 +1,121 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Graceful-degrade orchestrator for the SCIP-backed Rust analyzer (WI-nohah).
3
+
4
+ When a caller wants "use rust-analyzer if available, otherwise tell me
5
+ to fall through to :mod:`hypergumbo_lang_mainstream.rust`", it should
6
+ call :func:`try_analyze_with_rust_analyzer`. The helper returns
7
+ ``(symbols, edges)`` on the happy path and ``None`` on any of the
8
+ three fall-through conditions WI-nohah enumerates:
9
+
10
+ 1. ``rust-analyzer`` is not resolvable on ``PATH`` (no install, or
11
+ install in a dir we don't scan).
12
+ 2. ``rust-analyzer scip`` exits non-zero / times out
13
+ (:class:`RustAnalyzerInvocationFailed`), or the workspace does not
14
+ produce an ``index.scip`` file (:class:`RustAnalyzerNoOutput` —
15
+ typically a ``cargo metadata`` error on a workspace with private
16
+ deps or an unusual target triple).
17
+ 3. The SCIP bytes decode fails
18
+ (:class:`google.protobuf.message.DecodeError`) — defensive; the
19
+ only known way to trip this is a truncated file from a killed
20
+ ``rust-analyzer`` process, so treat it identically to failure
21
+ mode 2.
22
+
23
+ Returning ``None`` is the contract — the caller (WI-duzul Slice C's
24
+ analyzer-registry wrapper) is responsible for the actual fall-through
25
+ to ``rust.py``. Keeping the decision point pure lets the fall-through
26
+ logic stay testable without mounting a real analyzer registry.
27
+
28
+ The ``invoke`` and ``translate`` callables are injectable so tests can
29
+ exercise every failure mode without shelling out to a real
30
+ ``rust-analyzer`` binary. Production callers pass ``None`` to pick up
31
+ the default :func:`run_rust_analyzer_scip` /
32
+ :func:`translate_scip_to_hg` surfaces.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import tempfile
38
+ from pathlib import Path
39
+ from typing import Callable, List, Optional, Tuple
40
+
41
+ from google.protobuf.message import DecodeError
42
+
43
+ from hypergumbo_core.ir import Edge, Symbol
44
+
45
+ from .invoke import (
46
+ RustAnalyzerError,
47
+ run_rust_analyzer_scip,
48
+ )
49
+ from .translate import SourceReader, translate_scip_to_hg
50
+
51
+ InvokeFn = Callable[..., bytes]
52
+ TranslateFn = Callable[[bytes, SourceReader], Tuple[List[Symbol], List[Edge]]]
53
+
54
+ # One-time log marker so repeated fall-through attempts don't spam the
55
+ # user's terminal. A set because the helper may be called across
56
+ # multiple workspaces in a single process (monorepo analysis).
57
+ _LOGGED_FALLBACK: set[str] = set()
58
+
59
+
60
+ def _reset_logged_fallback_for_tests() -> None:
61
+ """Clear the once-per-process log marker; test-only helper."""
62
+ _LOGGED_FALLBACK.clear()
63
+
64
+
65
+ def try_analyze_with_rust_analyzer(
66
+ workspace: Path,
67
+ source_reader: SourceReader,
68
+ *,
69
+ invoke: Optional[InvokeFn] = None,
70
+ translate: Optional[TranslateFn] = None,
71
+ log: Optional[Callable[[str], None]] = None,
72
+ ) -> Optional[Tuple[List[Symbol], List[Edge]]]:
73
+ """Run rust-analyzer + SCIP translate on *workspace*, or ``None``.
74
+
75
+ The return type is intentionally ``None | (symbols, edges)`` rather
76
+ than raising — the caller wants the decision "was this a real
77
+ result, or should I fall through?" packaged as a single expression.
78
+ Every failure mode WI-nohah lists maps to ``None``; only a
79
+ successful invoke+translate produces a non-None return.
80
+
81
+ ``source_reader`` is forwarded to :func:`translate_scip_to_hg` for
82
+ the rust.py stable-id parity pass.
83
+
84
+ ``invoke`` defaults to :func:`run_rust_analyzer_scip`;
85
+ ``translate`` defaults to :func:`translate_scip_to_hg`. Both are
86
+ injected in tests to simulate each failure shape without spawning
87
+ a subprocess or constructing a SCIP fixture.
88
+
89
+ ``log`` defaults to a no-op; tests (and the future analyzer
90
+ registry wrapper) pass a real logger so the user sees one line
91
+ explaining why the backend degraded.
92
+ """
93
+ invoke_fn = invoke if invoke is not None else run_rust_analyzer_scip
94
+ translate_fn = translate if translate is not None else translate_scip_to_hg
95
+ emit = log if log is not None else (lambda _msg: None)
96
+
97
+ with tempfile.TemporaryDirectory(prefix="hg_rust_analyzer_") as tmpdir:
98
+ scratch = Path(tmpdir)
99
+ try:
100
+ scip_bytes = invoke_fn(workspace, cwd=scratch)
101
+ except RustAnalyzerError as exc:
102
+ key = f"{type(exc).__name__}:{workspace}"
103
+ if key not in _LOGGED_FALLBACK:
104
+ _LOGGED_FALLBACK.add(key)
105
+ emit(
106
+ f"rust-analyzer backend unavailable for {workspace}: "
107
+ f"{type(exc).__name__} — falling through to rust.py",
108
+ )
109
+ return None
110
+
111
+ try:
112
+ return translate_fn(scip_bytes, source_reader)
113
+ except DecodeError as exc:
114
+ key = f"DecodeError:{workspace}"
115
+ if key not in _LOGGED_FALLBACK:
116
+ _LOGGED_FALLBACK.add(key)
117
+ emit(
118
+ f"rust-analyzer SCIP decode failed for {workspace}: "
119
+ f"{exc} — falling through to rust.py",
120
+ )
121
+ return None
@@ -0,0 +1,165 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Shell-out wrapper for ``rust-analyzer scip`` (WI-duzul Slice B-first).
3
+
4
+ How It Works
5
+ ------------
6
+ Calling ``rust-analyzer scip /path/to/workspace`` emits ``index.scip``
7
+ in the current working directory. This module wraps that invocation in
8
+ a single function, :func:`run_rust_analyzer_scip`, that:
9
+
10
+ 1. Confirms ``rust-analyzer`` is resolvable on ``PATH`` (or at the
11
+ explicit binary path the caller passes in).
12
+ 2. Invokes ``rust-analyzer scip <workspace>`` with stdout/stderr
13
+ captured, a configurable timeout, and a scratch cwd.
14
+ 3. Returns the emitted SCIP index as ``bytes`` suitable for feeding
15
+ straight into :func:`translate_scip_to_hg`.
16
+
17
+ Failure modes are mapped to three dedicated exceptions so callers can
18
+ discriminate between "binary not installed" (WI-nohah territory — fall
19
+ through to ``rust.py``), "invocation failed or timed out" (genuine
20
+ error — surface to the user), and "binary ran but produced no .scip
21
+ file" (likely a cargo metadata error — surface with the captured
22
+ stderr).
23
+
24
+ Why This Design
25
+ ---------------
26
+ * WI-zakub established that rust-analyzer SCIP indexing is 10x slower
27
+ than tree-sitter at every realistic size. The ``timeout`` argument
28
+ defaults to 600 seconds so an unexpectedly large workspace fails
29
+ visibly rather than hanging the analyzer pipeline.
30
+ * A dedicated ``rust_analyzer_bin`` kwarg (default ``"rust-analyzer"``)
31
+ keeps the function testable without shell PATH mutations: tests
32
+ pass an absolute path to a fake script. Production callers use the
33
+ default to pick up the user's install.
34
+ * The scratch ``cwd`` is supplied by the caller (typically a
35
+ ``tempfile.mkdtemp()``); this module does not own temporary
36
+ directories so callers can manage cleanup and reuse for caching if
37
+ they choose.
38
+
39
+ Out of scope for this module (tracked under WI-duzul Slice C+):
40
+ * Analyzer-registry wiring — ``RustAnalyzerAnalyzer`` class that hooks
41
+ into the analyzer base class and is registered at higher priority
42
+ than ``rust.py``.
43
+ * The opt-in flag (``HYPERGUMBO_RUST_ANALYZER`` env var +
44
+ ``--backend rust-analyzer`` CLI flag) that gates whether the shell-
45
+ out fires at all.
46
+ * Graceful-degrade when the binary is absent (WI-nohah). Callers
47
+ decide what to do with :class:`RustAnalyzerNotInstalled`.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import shutil
53
+ import subprocess # nosec B404 — required for rust-analyzer scip invocation
54
+ from pathlib import Path
55
+ from typing import Optional
56
+
57
+
58
+ class RustAnalyzerError(Exception):
59
+ """Base class for :func:`run_rust_analyzer_scip` failures."""
60
+
61
+
62
+ class RustAnalyzerNotInstalled(RustAnalyzerError):
63
+ """Raised when the ``rust-analyzer`` binary is not resolvable.
64
+
65
+ Callers should catch this and fall through to the tree-sitter
66
+ ``rust.py`` analyzer (WI-nohah). The message carries the binary
67
+ name the caller asked for so a misconfigured ``rust_analyzer_bin``
68
+ kwarg is visible in the error text.
69
+ """
70
+
71
+
72
+ class RustAnalyzerInvocationFailed(RustAnalyzerError):
73
+ """Raised when the binary ran but exited non-zero or timed out.
74
+
75
+ The exception carries the captured stderr (best-effort; empty
76
+ bytes when the process was killed before producing any output) so
77
+ the caller can surface it to the user.
78
+ """
79
+
80
+ def __init__(self, message: str, stderr: bytes) -> None:
81
+ super().__init__(message)
82
+ self.stderr = stderr
83
+
84
+
85
+ class RustAnalyzerNoOutput(RustAnalyzerError):
86
+ """Raised when the invocation succeeded but no ``index.scip`` was written.
87
+
88
+ This happens when ``rust-analyzer`` cannot parse the workspace
89
+ (e.g. missing ``Cargo.toml`` at the requested path, or a
90
+ ``cargo metadata`` error) but still exits 0. The captured stderr is
91
+ attached so the caller can surface the underlying cargo diagnostic.
92
+ """
93
+
94
+ def __init__(self, message: str, stderr: bytes) -> None:
95
+ super().__init__(message)
96
+ self.stderr = stderr
97
+
98
+
99
+ def run_rust_analyzer_scip(
100
+ workspace: Path,
101
+ *,
102
+ cwd: Path,
103
+ rust_analyzer_bin: str = "rust-analyzer",
104
+ timeout_sec: float = 600.0,
105
+ which: Optional[callable] = None, # type: ignore[valid-type]
106
+ runner: Optional[callable] = None, # type: ignore[valid-type]
107
+ ) -> bytes:
108
+ """Run ``rust-analyzer scip`` on *workspace* and return the SCIP bytes.
109
+
110
+ ``cwd`` is the directory in which the subprocess runs; this is where
111
+ ``index.scip`` is written and subsequently read. Callers are
112
+ expected to pass a fresh empty directory (``tempfile.mkdtemp()`` is
113
+ the usual pattern) so the read is unambiguous.
114
+
115
+ ``rust_analyzer_bin`` is looked up via ``shutil.which`` unless it is
116
+ already an absolute path. The lookup is injectable via the ``which``
117
+ kwarg so tests can simulate a missing binary without mutating the
118
+ process environment.
119
+
120
+ ``runner`` is an injectable ``subprocess.run`` stand-in (signature
121
+ ``runner(cmd, *, cwd, capture_output, timeout) -> CompletedProcess``)
122
+ so tests can exercise the success / non-zero-exit / timeout /
123
+ no-output paths without actually spawning rust-analyzer.
124
+
125
+ Raises :class:`RustAnalyzerNotInstalled`, :class:`RustAnalyzerInvocationFailed`,
126
+ or :class:`RustAnalyzerNoOutput` per the module docstring.
127
+ """
128
+ resolve = which if which is not None else shutil.which
129
+ resolved = resolve(rust_analyzer_bin)
130
+ if resolved is None:
131
+ raise RustAnalyzerNotInstalled(
132
+ f"rust-analyzer binary not found on PATH "
133
+ f"(requested: {rust_analyzer_bin!r})",
134
+ )
135
+
136
+ # resolved comes from shutil.which (or caller-supplied absolute
137
+ # path); never shell=True; args are list-form.
138
+ run = runner if runner is not None else subprocess.run
139
+ cmd = [resolved, "scip", str(workspace)]
140
+ try:
141
+ completed = run( # nosec B603
142
+ cmd,
143
+ cwd=str(cwd),
144
+ capture_output=True,
145
+ timeout=timeout_sec,
146
+ )
147
+ except subprocess.TimeoutExpired as exc:
148
+ raise RustAnalyzerInvocationFailed(
149
+ f"rust-analyzer scip timed out after {timeout_sec}s",
150
+ exc.stderr or b"",
151
+ ) from exc
152
+
153
+ if completed.returncode != 0:
154
+ raise RustAnalyzerInvocationFailed(
155
+ f"rust-analyzer scip exited {completed.returncode}",
156
+ completed.stderr or b"",
157
+ )
158
+
159
+ index_path = cwd / "index.scip"
160
+ if not index_path.is_file():
161
+ raise RustAnalyzerNoOutput(
162
+ "rust-analyzer scip exited 0 but produced no index.scip",
163
+ completed.stderr or b"",
164
+ )
165
+ return index_path.read_bytes()
@@ -0,0 +1,170 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """SCIP bytes → hypergumbo ``(Symbol, Edge)`` translation with rust.py parity.
3
+
4
+ How It Works
5
+ ------------
6
+ ``rust-analyzer scip`` emits a Protobuf-encoded ``scip.Index`` file
7
+ describing every defined symbol, every reference, and every
8
+ relationship in a Rust workspace. This module wraps the hypergumbo-core
9
+ SCIP shim (``hypergumbo_core.scip.*``) with two things the generic
10
+ shim cannot do on its own:
11
+
12
+ 1. **Stable-ID parity with rust.py.** The core shim fills
13
+ ``Symbol.stable_id`` with the raw SCIP symbol string. That string is
14
+ correct for cross-SCIP-pass dedup but does not match the
15
+ signature-derived stable_id rust.py computes for the same function
16
+ in the same source file. Without reassignment, enabling this
17
+ backend on a cached analysis double-counts every Rust symbol.
18
+ :func:`reassign_rust_stable_ids` replaces the SCIP-derived
19
+ ``stable_id`` with the rust.py-equivalent one whenever a Rust
20
+ function span can be located in the caller-provided source reader,
21
+ using
22
+ :func:`hypergumbo_lang_mainstream.rust_scip.compute_rust_stable_id_from_source`.
23
+ Non-Rust Symbols (SCIP can carry multiple languages in one index),
24
+ non-function Rust Symbols (structs, modules, constants — rust.py
25
+ has no signature-level parity for these), and Symbols whose source
26
+ cannot be read pass through unchanged.
27
+
28
+ 2. **One-shot translate.** :func:`translate_scip_to_hg` bundles the
29
+ three core shim calls (``scip_index_to_symbols``,
30
+ ``scip_index_to_edges``, ``scip_index_to_call_edges``) with the
31
+ parity reassignment so Slice-B's analyzer wrapper can consume a
32
+ single entry point.
33
+
34
+ Why This Design
35
+ ---------------
36
+ WI-zakub (2026-04-17 mini trial) established that rust-analyzer leaves
37
+ ``SymbolInformation.relationships`` empty; the primary edge source is
38
+ therefore non-Definition ``Occurrence``s routed through
39
+ ``scip_index_to_call_edges``. Both ``scip_index_to_edges`` and
40
+ ``scip_index_to_call_edges`` are still called here — the former is
41
+ zero-cost when the relationship set is empty, and keeping both in the
42
+ pipeline lets the translator also work on SCIP indexes produced by
43
+ other tools (scip-python, scip-java) that do populate relationships.
44
+
45
+ The ``source_reader`` callable is a caller-owned I/O boundary:
46
+ production callers pass a function that reads ``path`` bytes from the
47
+ indexed workspace; tests pass a dict-backed fake so the whole
48
+ translate path can be exercised without filesystem access. Reader
49
+ failures (``OSError``, ``FileNotFoundError``, arbitrary exceptions)
50
+ degrade to "skip this symbol's reassignment" rather than aborting the
51
+ whole translate — the SCIP-derived ``stable_id`` remains as a
52
+ best-effort fallback.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from typing import Callable, Iterable, List, Optional
58
+
59
+ from hypergumbo_core.ir import Edge, Symbol
60
+ from hypergumbo_core.scip._generated import scip_pb2
61
+ from hypergumbo_core.scip.calls import scip_index_to_call_edges
62
+ from hypergumbo_core.scip.edges import scip_index_to_edges
63
+ from hypergumbo_core.scip.index import scip_index_to_symbols
64
+ from hypergumbo_lang_mainstream.rust_scip import (
65
+ compute_rust_stable_id_from_source,
66
+ )
67
+
68
+
69
+ SourceReader = Callable[[str], Optional[bytes]]
70
+
71
+
72
+ def _read_source(source_reader: SourceReader, path: str) -> Optional[bytes]:
73
+ """Invoke *source_reader*, swallowing I/O errors as ``None``.
74
+
75
+ Reader exceptions (FileNotFoundError, OSError, or anything the
76
+ caller's implementation chooses to raise) map to "source
77
+ unavailable, skip reassignment" — the translate pass still emits
78
+ the Symbol with the SCIP-derived stable_id rather than dropping it.
79
+ """
80
+ try:
81
+ return source_reader(path)
82
+ except (OSError, ValueError):
83
+ return None
84
+
85
+
86
+ def reassign_rust_stable_ids(
87
+ symbols: Iterable[Symbol],
88
+ source_reader: SourceReader,
89
+ ) -> List[Symbol]:
90
+ """Return *symbols* with Rust function ``stable_id``s rust.py-compatible.
91
+
92
+ For each input Symbol, the reassignment applies only when:
93
+
94
+ - ``language == "rust"``
95
+ - ``kind == "function"`` or ``kind == "method"``
96
+ - ``span`` is present (SCIP Definition Occurrences always carry one)
97
+ - ``source_reader(path)`` returns bytes
98
+ - ``compute_rust_stable_id_from_source`` finds the function at the
99
+ span and returns a non-None stable_id
100
+
101
+ All other Symbols (non-Rust, non-function, span-less, source-less,
102
+ or parity-lookup-failed) pass through with their original
103
+ ``stable_id`` untouched. The return value is a new list — inputs
104
+ are not mutated.
105
+ """
106
+ reassigned: List[Symbol] = []
107
+ source_cache: dict[str, Optional[bytes]] = {}
108
+ for sym in symbols:
109
+ new_sym = sym
110
+ if (
111
+ sym.language == "rust"
112
+ and sym.kind in {"function", "method"}
113
+ and sym.span is not None
114
+ and sym.path
115
+ ):
116
+ if sym.path not in source_cache:
117
+ source_cache[sym.path] = _read_source(source_reader, sym.path)
118
+ source = source_cache[sym.path]
119
+ if source is not None:
120
+ parity = compute_rust_stable_id_from_source(
121
+ source, sym.span.start_line, sym.span.end_line,
122
+ )
123
+ if parity is not None:
124
+ new_sym = Symbol(
125
+ id=sym.id,
126
+ name=sym.name,
127
+ kind=sym.kind,
128
+ language=sym.language,
129
+ path=sym.path,
130
+ span=sym.span,
131
+ origin=sym.origin,
132
+ stable_id=parity,
133
+ signature=sym.signature,
134
+ meta=sym.meta,
135
+ )
136
+ reassigned.append(new_sym)
137
+ return reassigned
138
+
139
+
140
+ def translate_scip_to_hg(
141
+ scip_bytes: bytes,
142
+ source_reader: SourceReader,
143
+ ) -> tuple[List[Symbol], List[Edge]]:
144
+ """Parse *scip_bytes* and return ``(symbols, edges)`` with parity.
145
+
146
+ The three core SCIP shim passes are invoked in order:
147
+
148
+ 1. :func:`scip_index_to_symbols` to build the Symbol list.
149
+ 2. :func:`reassign_rust_stable_ids` to overwrite Rust function
150
+ ``stable_id`` fields with rust.py-compatible values.
151
+ 3. :func:`scip_index_to_edges` for Relationship-derived edges.
152
+ 4. :func:`scip_index_to_call_edges` for Occurrence-ref edges via
153
+ span-enclosure resolution.
154
+
155
+ The Edge list is the concatenation of the two edge sources; callers
156
+ expecting a deduplicated edge set should run the standard
157
+ ``deduplicate_edges`` pass themselves (not done here — translate
158
+ preserves the shim outputs verbatim so downstream passes can see
159
+ the raw signal and ``deduplicate_edges`` is the only place the
160
+ dedup rule lives).
161
+ """
162
+ index = scip_pb2.Index()
163
+ index.ParseFromString(scip_bytes)
164
+ symbols = reassign_rust_stable_ids(
165
+ scip_index_to_symbols(index), source_reader,
166
+ )
167
+ edges: List[Edge] = []
168
+ edges.extend(scip_index_to_edges(index))
169
+ edges.extend(scip_index_to_call_edges(index))
170
+ return symbols, edges
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: hypergumbo-lang-rust-analyzer
3
+ Version: 5.0.0
4
+ Summary: SCIP-backed Rust analyzer for hypergumbo (rust-analyzer integration)
5
+ Author: Hypergumbo contributors
6
+ License: AGPL-3.0-or-later
7
+ Keywords: code-graph,rust,rust-analyzer,scip,static-analysis
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: hypergumbo-core==5.0.0
14
+ Requires-Dist: hypergumbo-lang-mainstream==5.0.0
15
+ Description-Content-Type: text/markdown
16
+
17
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
18
+ # hypergumbo-lang-rust-analyzer
19
+
20
+ SCIP-backed Rust analyzer for hypergumbo.
21
+
22
+ This optional package integrates `rust-analyzer scip` output into hypergumbo,
23
+ providing precise type-resolved symbols and call edges for Rust workspaces
24
+ beyond what the tree-sitter `rust.py` analyzer can recover. It is designed
25
+ as an opt-in alternative to `rust.py`, not a replacement.
26
+
27
+ ## Status
28
+
29
+ Slice A: pure-Python translation surface from parsed SCIP `Index` bytes to
30
+ `(symbols, edges)`. No live `rust-analyzer` invocation yet — that arrives
31
+ in Slice B alongside analyzer-registry wiring and the opt-in flag
32
+ (`HYPERGUMBO_RUST_ANALYZER` env var or `--backend rust-analyzer` CLI flag).
33
+
34
+ ## Why SCIP, not LSP
35
+
36
+ Rust-analyzer emits SCIP with a single shot of `rust-analyzer scip` instead
37
+ of requiring a long-lived LSP session. SCIP is slower than tree-sitter
38
+ (~10× at every realistic size per WI-zakub), so this backend is opt-in and
39
+ falls through to `rust.py` when unavailable or not requested.
40
+
41
+ ## Stable-ID parity
42
+
43
+ `rust.py` and this analyzer both produce `stable_id`s via
44
+ `hypergumbo_lang_mainstream.rust_scip.compute_rust_stable_id_from_source`,
45
+ so cross-pass dedup works. Shared symbols carry the same `stable_id` under
46
+ both backends; rust-analyzer-only symbols (e.g. trait-resolved method
47
+ dispatch) extend the id space with SCIP-only suffixes.
48
+
49
+ ## Upstream shim
50
+
51
+ Symbol / edge emission builds on `hypergumbo_core.scip.*`:
52
+
53
+ - `scip_index_to_symbols` — `Document` walk → `Symbol` objects.
54
+ - `scip_index_to_edges` — `SymbolInformation.relationships` → `Edge`s.
55
+ - `scip_index_to_call_edges` — non-Definition `Occurrence` → calls / references
56
+ edges via span-enclosure resolution.
57
+
58
+ Rust-analyzer's `Relationship` set is empty (per WI-zakub), so the primary
59
+ edge source here is `scip_index_to_call_edges`.
@@ -0,0 +1,10 @@
1
+ hypergumbo_lang_rust_analyzer/__init__.py,sha256=7e2d2EwdsWlN0yDYvYxyWvmVfVtqc_J69rA0aWcmfvg,1513
2
+ hypergumbo_lang_rust_analyzer/analyzer.py,sha256=_HVAsSmWeMuYV9y5qhHcqV6-oqSQZSEG7lglWBZSQdA,4224
3
+ hypergumbo_lang_rust_analyzer/gate.py,sha256=jw5sK4FDFQHvNSp8WpShS1lgHy5aM86n3lCPHTPoJ4E,3714
4
+ hypergumbo_lang_rust_analyzer/graceful_degrade.py,sha256=QacACz2Yuakjje5GPAkizgpni3z5w-qrtgSqnY38PgI,4912
5
+ hypergumbo_lang_rust_analyzer/invoke.py,sha256=HeyDkfxVN-_ztoX5KqSZRB_oVAwjv0blRZnZ5yHV-Ok,6569
6
+ hypergumbo_lang_rust_analyzer/translate.py,sha256=_mJPxZmThaKwZUw5iC3xshkF-BdEhNrBtypuxCJezgA,7080
7
+ hypergumbo_lang_rust_analyzer-5.0.0.dist-info/METADATA,sha256=twHOxfBPg1u36WgjbQ1SvRLzQlOyEOQTGwQ46X2cYic,2517
8
+ hypergumbo_lang_rust_analyzer-5.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ hypergumbo_lang_rust_analyzer-5.0.0.dist-info/entry_points.txt,sha256=zJQjXxwrzFMhVWkXb5UbF8RzjcgnrdcOWepL6oewJog,86
10
+ hypergumbo_lang_rust_analyzer-5.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [hypergumbo.analyzers]
2
+ rust_analyzer = hypergumbo_lang_rust_analyzer:ANALYZER_MODULES