diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79c..ea111c94ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Full details in the entries below. - Parallel reference resolution now engages adaptively instead of by a fixed project-size cutoff: the indexer measures the actual per-reference resolution rate on the first batch and spins up the worker pool mid-run whenever the remaining work justifies it. Languages whose references are expensive to resolve benefit most — Rust especially: a fresh index of tokio runs about 23% faster, with the graph byte-for-byte identical. Small projects and low-core machines (2-core CI runners) keep the single-threaded path exactly as before. - The dynamic-dispatch analysis at the end of indexing now skips passes that provably can't produce anything for the project at hand: React re-render bridging when no class has a `render` method, React Native and Expo cross-platform pairing when the required languages aren't present, and MyBatis mapper linking when there's no mapper XML. Previously each of these scanned the whole graph before coming up empty — on a 4,000-file Java project that was about 0.9 seconds of wasted analysis per fresh index. The interface-implementation bridging pass also got cheaper on real work: it no longer re-fetches a hub interface's method list once per implementer, and classes that extend or implement nothing are skipped before any per-class lookups. Graphs remain byte-for-byte identical. - Indexing large C and C++ codebases spends much less time in the function-pointer dispatch analysis (the pass that connects handler tables like a command table or an ops struct to their call sites): each source file is now read and prepared once instead of four times, files that can't contribute any dispatch wiring are skipped outright in the later linking steps, and on platforms with the native engine the per-file scanning itself now runs natively too. On a Linux-kernel-scale tree the pass runs about a third faster end-to-end, with graphs byte-for-byte identical; platforms without a native binary keep the same results on the previous path. +- **CodeGraph now traces Tauri's Rust ↔ JavaScript IPC boundary.** In a [Tauri](https://tauri.app) app the frontend reaches the Rust backend through a runtime IPC hop, so `callers` and `impact` on a `#[tauri::command]` function came back empty even when the entire frontend called it — the call crosses both a language boundary and a name change (the `tauri-specta` bindings turn `get_mcp_port` into `getMcpPort`). CodeGraph now bridges it: TypeScript / JavaScript / Svelte callers of `commands.fooBar()` (and raw `invoke('foo_bar')`) resolve to their Rust `#[tauri::command]` handler, and `events.fooBar.listen(...)` (and raw `listen('foo-bar')`) resolve to the Rust `#[derive(…Event)]` struct behind them, across the `snake_case` / `camelCase` / `PascalCase` / `kebab-case` conventions tauri-specta uses. It's picked up automatically from a `tauri.conf.json`, an `@tauri-apps/api` dependency, or any `#[tauri::command]` in the Rust sources (monorepo layouts included). Validated across three real apps: a `tauri-specta` app (Tauri 2 + Svelte 5) where 291 commands now surface their frontend callers, plus two raw-`invoke` apps where the string wire names resolve too (76 of 79 commands in one). (#772) ### Fixes diff --git a/__tests__/tauri-ipc-bridge.test.ts b/__tests__/tauri-ipc-bridge.test.ts new file mode 100644 index 0000000000..01e7cbc195 --- /dev/null +++ b/__tests__/tauri-ipc-bridge.test.ts @@ -0,0 +1,463 @@ +import { describe, it, expect, beforeEach, afterEach, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Node, Language } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import { tauriBridgeResolver } from '../src/resolution/frameworks/tauri'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +function makeContext(nodes: Node[], fileContents: Record = {}): ResolutionContext { + const byName = new Map(); + const byFile = new Map(); + for (const n of nodes) { + const arr = byName.get(n.name); + if (arr) arr.push(n); + else byName.set(n.name, [n]); + const fArr = byFile.get(n.filePath); + if (fArr) fArr.push(n); + else byFile.set(n.filePath, [n]); + } + const allFiles = new Set( + [...nodes.map((n) => n.filePath), ...Object.keys(fileContents)] + ); + return { + getNodesInFile: (fp) => byFile.get(fp) ?? [], + getNodesByName: (name) => byName.get(name) ?? [], + getNodesByQualifiedName: () => [], + getNodesByKind: (kind) => nodes.filter((n) => n.kind === kind), + getNodesByLowerName: () => [], + fileExists: (fp) => allFiles.has(fp), + readFile: (fp) => fileContents[fp] ?? null, + getProjectRoot: () => '/test', + getAllFiles: () => Array.from(allFiles), + getImportMappings: () => [], + }; +} + +function rustFn(name: string, filePath: string, startLine = 10): Node { + return { + id: `rust:${filePath}:${name}:${startLine}`, + kind: 'function', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'rust', + startLine, + endLine: startLine + 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + } as Node; +} + +function rustStruct(name: string, filePath: string, startLine = 10): Node { + return { + id: `rust:${filePath}:${name}:${startLine}`, + kind: 'struct', + name, + qualifiedName: `${filePath}::${name}`, + filePath, + language: 'rust', + startLine, + endLine: startLine + 5, + startColumn: 0, + endColumn: 0, + updatedAt: Date.now(), + } as Node; +} + +function ref(name: string, language: Language, filePath: string): UnresolvedRef { + return { + fromNodeId: `caller:${filePath}`, + referenceName: name, + referenceKind: 'calls', + line: 1, + column: 0, + filePath, + language, + }; +} + +describe('Tauri IPC bridge resolver', () => { + describe('detect()', () => { + it('returns true when tauri.conf.json exists at root', () => { + const ctx = makeContext([], { 'tauri.conf.json': '{}' }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when tauri.conf.json exists in a subdirectory', () => { + const ctx = makeContext([], { + 'apps/desktop/src-tauri/tauri.conf.json': '{}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns true when package.json depends on @tauri-apps/api', () => { + const ctx = makeContext([], { + 'package.json': '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(true); + }); + + it('returns false when no Tauri signals are present', () => { + const ctx = makeContext([], { + 'package.json': '{"dependencies":{"react":"^18.0"}}', + }); + expect(tauriBridgeResolver.detect(ctx)).toBe(false); + }); + }); + + describe('commands', () => { + it('resolves typed camelCase call to snake_case Rust command', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('getMcpPort', 'typescript', 'src/settings.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves receiver-qualified call (commands.getMcpPort)', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('commands.getMcpPort', 'typescript', 'src/settings.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('resolves raw invoke with snake_case wire name', () => { + const node = rustFn('list_directory', 'src/commands/fs.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/fs.rs': + '#[tauri::command]\npub async fn list_directory(path: String) -> Vec { vec![] }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('list_directory', 'typescript', 'src/fs.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('handles commands with multiple attributes (specta + tauri::command)', () => { + const node = rustFn('get_settings', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[specta::specta]\n#[tauri::command]\npub async fn get_settings() -> Settings { todo!() }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('getSettings', 'typescript', 'src/app.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('returns null for a Rust-language caller', () => { + const node = rustFn('get_mcp_port', 'src/commands/settings.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/commands/settings.rs': + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 0 }\n', + }); + expect( + tauriBridgeResolver.resolve(ref('get_mcp_port', 'rust', 'src/other.rs'), ctx) + ).toBeNull(); + }); + + it('returns null when no matching command exists', () => { + const ctx = makeContext([], { + 'src-tauri/tauri.conf.json': '{}', + }); + expect( + tauriBridgeResolver.resolve(ref('nonExistent', 'typescript', 'src/app.ts'), ctx) + ).toBeNull(); + }); + }); + + describe('events', () => { + it('resolves typed camelCase event listener to Rust Event struct', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('volumeSpaceChanged', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + expect(result?.resolvedBy).toBe('framework'); + }); + + it('resolves receiver-qualified event (events.volumeSpaceChanged)', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('events.volumeSpaceChanged', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('resolves raw kebab-case event listener', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('volume-space-changed', 'typescript', 'src/volumes.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('returns null for a Rust-language listener', () => { + const node = rustStruct('VolumeSpaceChanged', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\npub struct VolumeSpaceChanged { pub path: String }\n', + }); + expect( + tauriBridgeResolver.resolve( + ref('VolumeSpaceChanged', 'rust', 'src/other.rs'), + ctx + ) + ).toBeNull(); + }); + + it('handles bare Event derive (use tauri_specta::Event in scope)', () => { + const node = rustStruct('AccentColorChanged', 'src/theme.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/theme.rs': + 'use tauri_specta::Event;\n' + + '#[derive(Clone, Serialize, Event)]\n' + + 'pub struct AccentColorChanged { pub color: String }\n', + }); + const result = tauriBridgeResolver.resolve( + ref('accentColorChanged', 'typescript', 'src/theme.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + + it('respects #[tauri_specta(event_name = "...")] override', () => { + const node = rustStruct('LowDiskSpacePayload', 'src/space_poller.rs'); + const ctx = makeContext([node], { + 'src-tauri/tauri.conf.json': '{}', + 'src/space_poller.rs': + '#[derive(Clone, Serialize, tauri_specta::Event)]\n' + + '#[tauri_specta(event_name = "low-disk-space")]\n' + + 'pub struct LowDiskSpacePayload { pub path: String }\n', + }); + // The override kebab name should resolve. + const result = tauriBridgeResolver.resolve( + ref('low-disk-space', 'typescript', 'src/space.ts'), + ctx + ); + expect(result?.targetNodeId).toBe(node.id); + }); + }); + + describe('extract() — raw invoke/listen wire names', () => { + it('emits a reference for invoke(\'snake_name\') attributed to the file node', () => { + const result = tauriBridgeResolver.extract!( + 'src/api.ts', + "import { invoke } from '@tauri-apps/api/core';\n" + + "export const detect = (t) => invoke('lang_detect', { text: t });\n" + ); + expect(result.nodes).toEqual([]); + expect(result.references).toHaveLength(1); + const ref = result.references[0]!; + expect(ref.referenceName).toBe('lang_detect'); + expect(ref.fromNodeId).toBe('file:src/api.ts'); + expect(ref.referenceKind).toBe('calls'); + expect(ref.language).toBe('typescript'); + }); + + it('emits references for invoke(...), listen(...), and once(...)', () => { + const refs = tauriBridgeResolver.extract!( + 'src/x.tsx', + "invoke('get_port');\n" + + "listen('volume-space-changed', cb);\n" + + "once('app-ready', cb);\n" + ).references.map((r) => r.referenceName); + expect(refs).toEqual(['get_port', 'volume-space-changed', 'app-ready']); + }); + + it('skips dynamic (non-literal) names', () => { + const refs = tauriBridgeResolver.extract!( + 'src/x.ts', + 'invoke(cmdName);\ninvoke(`evt_${id}`);\n' + ).references; + expect(refs).toEqual([]); + }); + + it('returns nothing for a non-JS file', () => { + const result = tauriBridgeResolver.extract!('src/main.rs', "invoke('x')"); + expect(result.references).toEqual([]); + }); + }); +}); + +describe('Tauri IPC end-to-end', () => { + let dir: string; + beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tauri-ipc-')); }); + afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); + + it('links a TS typed command call to a Rust #[tauri::command] fn', async () => { + // Minimal Tauri project structure. + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'tauri.conf.json'), + '{"identifier":"test"}' + ); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'main.rs'), + '#[tauri::command]\npub async fn get_mcp_port() -> u16 { 9090 }\n' + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}' + ); + fs.writeFileSync( + path.join(dir, 'settings.ts'), + 'import { commands } from "./bindings";\n' + + 'export async function loadPort() { return commands.getMcpPort(); }\n' + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The Rust command fn should be in the graph. + const rustNodes = db.prepare( + "SELECT * FROM nodes WHERE name='get_mcp_port' AND language='rust'" + ).all(); + expect(rustNodes.length).toBeGreaterThan(0); + + // The TS call site should have an edge to the Rust fn. + const edges = db.prepare( + `SELECT e.* FROM edges e + JOIN nodes s ON s.id=e.source + JOIN nodes t ON t.id=e.target + WHERE t.name='get_mcp_port' AND t.language='rust' + AND s.language IN ('typescript','tsx','javascript') + AND e.kind IN ('calls','references')` + ).all(); + + cg.close?.(); + expect(edges.length).toBeGreaterThan(0); + }); + + it('links a TS typed event listener to a Rust Event struct', async () => { + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'tauri.conf.json'), + '{"identifier":"test"}' + ); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'lib.rs'), + '#[derive(Clone, serde::Serialize, tauri_specta::Event)]\n' + + 'pub struct VolumeSpaceChanged {\n' + + ' pub path: String,\n' + + ' pub available: u64,\n' + + '}\n' + ); + fs.writeFileSync( + path.join(dir, 'package.json'), + '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}' + ); + fs.writeFileSync( + path.join(dir, 'volumes.ts'), + 'import { events } from "./bindings";\n' + + 'events.volumeSpaceChanged.listen((e) => console.log(e));\n' + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + // The Rust Event struct should be in the graph. + const rustNodes = db.prepare( + "SELECT * FROM nodes WHERE name='VolumeSpaceChanged' AND language='rust'" + ).all(); + expect(rustNodes.length).toBeGreaterThan(0); + + cg.close?.(); + }); + + it('links a raw invoke(\'wire_name\') call to a Rust #[tauri::command] fn', async () => { + // The non-specta convention: the wire name is a string literal, so the JS + // extractor never emits it as a reference. extract() must surface it. + const srcTauri = path.join(dir, 'src-tauri', 'src'); + fs.mkdirSync(srcTauri, { recursive: true }); + fs.writeFileSync(path.join(dir, 'src-tauri', 'tauri.conf.json'), '{"identifier":"test"}'); + fs.writeFileSync( + path.join(dir, 'src-tauri', 'Cargo.toml'), + '[package]\nname = "test"\nversion = "0.1.0"\n' + ); + fs.writeFileSync( + path.join(srcTauri, 'main.rs'), + '#[tauri::command]\npub fn lang_detect(text: String) -> String { text }\n' + ); + fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"@tauri-apps/api":"^2.0.0"}}'); + fs.writeFileSync( + path.join(dir, 'detect.ts'), + "import { invoke } from '@tauri-apps/api/core';\n" + + "export const detect = (t: string) => invoke('lang_detect', { text: t });\n" + ); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + + const edges = db.prepare( + `SELECT e.* FROM edges e + JOIN nodes t ON t.id=e.target + WHERE t.name='lang_detect' AND t.language='rust' + AND json_extract(e.metadata,'$.resolvedBy')='framework'` + ).all(); + + cg.close?.(); + expect(edges.length).toBeGreaterThan(0); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index c61fb9f31a..295974d10c 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -274,6 +274,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | ObjC/Java/Kotlin → JS | React Native event emitters | native `sendEventWithName:`/`emit(...)` → JS `addListener('e', handler)` | S (cross-lang channel) | ✅ **rn-event-channel synthesizer** — matches ObjC `sendEventWithName:@"X"`, Swift `sendEvent(withName: "X", ...)`, and JVM `.emit("X", ...)` to JS `addListener('X', handler)` keyed by literal event name. Same fan-out cap (`EVENT_FANOUT_CAP=6`) as in-language channel. **Subscribe-wrapper fallback** for RN-library APIs (`const Foo = { watchX(listener) { addListener('e', listener) } }`) — when the handler arg is a parameter, falls back to the enclosing function and then the enclosing `constant`/`variable` (reachability-correct attribution to the JS API surface). RNFirebase L **3 push-notification flow edges** (UIApplicationDelegate → JS `onMessage`/`onNotificationOpenedApp`), RNGeolocation S **2 location-event edges** (Swift `onLocationChange`/`onLocationError` → JS `Geolocation`). 🔬 inline arrow handlers `addListener('e', d => …)` (anonymous frontier) | | JS × Swift/Kotlin | Expo Modules | JS `requireNativeModule('X').fn(...)` → Swift/Kotlin `Function("fn") { ... }` | R (extract → synthetic method nodes) | ✅ **expo-modules framework extractor** — parses Swift/Kotlin `Module { Name("X"); Function("y") { ... }; AsyncFunction("z") { ... }; Property("w") { ... } }` literals and synthesizes `method` nodes named after each declaration. JS callsites resolve via existing name-matcher (no separate `resolve()` needed). expo-haptics S **6 method nodes** (`notificationAsync`, `impactAsync`, `selectionAsync` × Swift + Kotlin), expo-camera M **41** (full SDK surface incl. `takePictureAsync`, `record`, `scanFromURLAsync`, view props `width`/`height`), expo SDK sweep L **134** (7 packages, 72 Swift + 62 Kotlin). Same-name JS wrappers in the package itself shadow the native names (`CameraView.tsx`'s `pausePreview` wraps native `pausePreview`); external consumer apps bridge through to native directly. 🔬 closure body extraction (the Function trailing closure isn't a body-range node yet) | | JS × native | React Native Fabric / Codegen + legacy Paper view components | JSX `` → Codegen spec → native class (or Paper `RCT_EXPORT_VIEW_PROPERTY` / `@ReactProp`) | R (extract) + S (native-impl) + JSX | ✅ **fabric-view extractor + fabric-native-impl synthesizer** — extractor parses **both** modern Codegen TS specs (`codegenNativeComponent('Name', ...)`) **and** legacy Paper view-manager macros (`RCT_EXPORT_VIEW_PROPERTY` on ObjC, `@ReactProp` on Java/Kotlin). Emits a `component` node per declaration + a `property` node per declared prop. Synthesizer links the component to its native impl class by RN's convention-based name+suffix (`exact`/`View`/`ComponentView`/`Manager`/`ViewManager`). Combined with `reactJsxChildEdges`, full consumer flow: JSX `` → fabric `component` → native class. Validated on RNSegmentedControl S **(legacy Paper) 1 component + 11 props + 4 bridges**, RNScreens M **(pure Codegen) 27 components + 272 props + 68 bridges** (was 0 before Phase 6), RNSkia L **(hybrid + monorepo) 5 + 14 + 15 across Codegen TS + Android Java + iOS ObjC**. **Monorepo detect** added: probes `packages//package.json` etc. via `listDirectories` when the root manifest is a workspace declaration (was the gating bug on RNSkia). 🔬 Fabric event-handler props (`onTap={cb}`) — JSX attribute extraction needed | +| Rust × JS/TS | Tauri IPC (tauri-specta + raw) | TS `commands.fooBar()` / `invoke('foo_bar')` → Rust `#[tauri::command] fn foo_bar`; TS `events.fooBar.listen()` / `listen('foo-bar')` → Rust `#[derive(…Event)] struct FooBar` | R + X | ✅ **Tauri cross-language IPC bridge** — `frameworks/tauri.ts` joins the TS↔Rust wire boundary tree-sitter can't see (two languages, name-mangled by tauri-specta's `snake_case`→`camelCase` for commands and `PascalCase`→`kebab-case` for events). Indexes every `#[tauri::command]` fn (handles stacked `#[specta::specta]` + attribute ordering) by both its `camelCase` and `snake_case` wire name, and every `#[derive(…Event)]` struct (incl. bare `Event` with `use tauri_specta::Event` in scope, and `#[tauri_specta(event_name="…")]` overrides) by camel/kebab/Pascal/snake. Two TS-side shapes: the **typed** `commands.*` / `events.*` member access is already a reference the JS extractor emits, joined in `resolve()` (call-site granularity); the **raw** `invoke('x')` / `listen('y')` / `once('y')` wire name is a string literal the extractor drops, so `extract()` surfaces it as a `calls` ref attributed to the file node (file granularity — a fn-node id is a sha256 of `path:kind:name:line` a string scan can't rebuild). Confidence 0.7 commands / 0.6 events (name-match wins ties), marked `metadata.resolvedBy:'framework'` like the RN/Fabric bridges; over-matching is harmless (resolve() only joins names hitting a real command/event). Detects via `tauri.conf.json[5]` (root or nested monorepo), an `@tauri-apps/api` dep, or any `tauri::command` in tracked Rust. Validated on **three real apps**: Cmdr L (tauri-specta, Tauri 2 + Svelte 5, 25k nodes) — `callers(get_mcp_port)` **"no callers"→4**, **291 distinct commands** gain framework-tagged TS callers (distinct from codegraph's pre-existing `exact-match` noise), event `accentColorChanged.listen → AccentColorChanged` bridges; **clash-verge-rev M** (raw `invoke`) — **76 of 79 commands** bridge through its `cmds.ts` wrapper, spot-checked all real `#[tauri::command]`s; **pot-desktop S** (raw `invoke`) — 3/3 literal invokes (`lang_detect`, `run_binary`, `reload_store`). **Agent A/B (new-vs-baseline, Sonnet, clash-verge-rev + Cmdr): no free-choice read-reduction** — graph correctness is MCP-verified (`codegraph_callers(get_mcp_port)` returns the real TS consumers through the actual tool), but a free-choice agent greps "who-calls-X" by reflex and Tauri's wire name is the same string on both sides (raw) or paired on one `bindings.ts` line (typed `getMcpPort: () => __TAURI_INVOKE('get_mcp_port')`), so grep bridges it without the graph. The win is in codegraph-only / `impact` / `explore` use, not against a grep-free agent — adoption-gated like the Spring/MyBatis rows. 🔬 raw edges are file-granular not call-site; runtime-built names (`app.emit(var)`, `viewer:file-changed:`, `invoke(varName)`) are intentionally skipped (no literal to read) | (Verify the exact supported set against `src/extraction/languages/` and `src/resolution/frameworks/` before starting — this table is a starting point.) diff --git a/src/resolution/frameworks/index.ts b/src/resolution/frameworks/index.ts index 91da9a01c7..ccc3e60aae 100644 --- a/src/resolution/frameworks/index.ts +++ b/src/resolution/frameworks/index.ts @@ -27,6 +27,7 @@ import { swiftObjcBridgeResolver } from './swift-objc'; import { reactNativeBridgeResolver } from './react-native'; import { expoModulesResolver } from './expo-modules'; import { fabricViewResolver } from './fabric'; +import { tauriBridgeResolver } from './tauri'; import { cicsResolver } from './cics'; import { terraformResolver } from './terraform'; @@ -72,6 +73,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [ expoModulesResolver, // React Native Fabric / Codegen view components — TS spec → component nodes fabricViewResolver, + // Tauri IPC — TS commands/events ↔ Rust #[tauri::command] / Event structs + tauriBridgeResolver, // CICS pseudo-conversational TRANSID hops (COBOL) cicsResolver, // Terraform / OpenTofu — disambiguate var/local/module/resource refs to same-dir module @@ -152,3 +155,4 @@ export { swiftObjcBridgeResolver } from './swift-objc'; export { reactNativeBridgeResolver } from './react-native'; export { expoModulesResolver } from './expo-modules'; export { fabricViewResolver } from './fabric'; +export { tauriBridgeResolver } from './tauri'; diff --git a/src/resolution/frameworks/tauri.ts b/src/resolution/frameworks/tauri.ts new file mode 100644 index 0000000000..8417f1e32f --- /dev/null +++ b/src/resolution/frameworks/tauri.ts @@ -0,0 +1,308 @@ +/** + * Tauri IPC cross-language bridge resolver. + * + * Joins TypeScript command/event callsites to their Rust handlers: + * + * **Commands** (TS call -> Rust fn): + * - Typed (tauri-specta): `commands.fooBar(...)` where `fooBar` is the + * camelCase form of a `#[tauri::command] fn foo_bar`. + * - Raw: `invoke('foo_bar', ...)` using the exact snake_case wire name. + * + * **Events** (TS listen -> Rust emit): + * - Typed: `events.fooBar.listen(...)` or wrapper functions. + * - Raw: `listen('foo-bar', ...)` using the kebab-case wire name. + * + * The resolver only redirects JS/TS callers to Rust targets; Rust-side + * references resolve through the normal Rust extractor. + * + * The wire join is a heuristic name inference (the real link is a runtime IPC + * hop, not an AST edge), so it runs through `resolve()` like the React Native / + * Fabric native bridges: the edge carries `metadata.resolvedBy: 'framework'` + * with a sub-1.0 confidence, and an exact name-match always wins a tie. (The + * `provenance:'heuristic'` column is reserved for `callback-synthesizer.ts`, + * which fabricates edges for dynamic dispatch that has no statically-resolvable + * name; a Tauri command/event name IS statically joinable, so it belongs on the + * resolver path.) + */ +import type { Language, Node } from '../../types'; +import { + FrameworkExtractionResult, + FrameworkResolver, + ResolutionContext, + UnresolvedRef, +} from '../types'; + +// -- Name conversion utilities ------------------------------------------------ + +/** snake_case -> camelCase (e.g. `get_mcp_port` -> `getMcpPort`). */ +function snakeToCamel(s: string): string { + return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +} + +/** kebab-case -> snake_case (e.g. `volume-space-changed` -> `volume_space_changed`). */ +function kebabToSnake(s: string): string { + return s.replace(/-/g, '_'); +} + +/** PascalCase -> snake_case (e.g. `VolumeSpaceChanged` -> `volume_space_changed`). */ +function pascalToSnake(s: string): string { + return s.replace(/([A-Z])/g, (c, _, i) => (i > 0 ? '_' : '') + c.toLowerCase()); +} + +// -- Rust-side index ---------------------------------------------------------- + +interface TauriCommand { + /** snake_case Rust fn name (the wire name). */ + rustName: string; + /** The graph node for the Rust fn. */ + node: Node; +} + +interface TauriEvent { + /** snake_case form of the event struct name. */ + rustName: string; + /** The graph node for the Rust struct/enum. */ + node: Node; +} + +/** Per-context lazy cache. */ +const indexCache: WeakMap< + ResolutionContext, + { commands: Map; events: Map } +> = new WeakMap(); + +/** + * Scan all Rust files for `#[tauri::command]` functions and event structs, + * building lookup maps keyed by the JS-visible name. + */ +function buildIndex(context: ResolutionContext) { + const cached = indexCache.get(context); + if (cached) return cached; + + const commands = new Map(); + const events = new Map(); + + for (const file of context.getAllFiles()) { + if (!file.endsWith('.rs')) continue; + const source = context.readFile(file); + if (!source) continue; + + // Commands: #[tauri::command] (possibly preceded by #[specta::specta]) + // followed by `fn name`. + if (source.includes('tauri::command')) { + const nodes = context.getNodesInFile(file); + // Attributes can appear in either order. Match the fn that follows + // an attribute block containing #[tauri::command]. + const cmdRegex = /(?:#\[[^\]]*\]\s*)*#\[tauri::command[^\]]*\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/g; + let m: RegExpExecArray | null; + while ((m = cmdRegex.exec(source)) !== null) { + const rustName = m[1]!; + const camelName = snakeToCamel(rustName); + const node = nodes.find( + (n) => n.name === rustName && (n.kind === 'function' || n.kind === 'method') + ); + if (!node) continue; + const entry: TauriCommand = { rustName, node }; + commands.set(camelName, entry); + commands.set(rustName, entry); + } + } + + // Events: struct deriving Event (tauri_specta::Event, tauri::Event, or + // bare Event when `use tauri_specta::Event` is in scope). + if (source.includes('Event')) { + const eventRegex = + /#\[derive\([^\)]*\b(?:tauri_specta::|tauri::)?Event\b[^\)]*\)\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?struct\s+(\w+)/g; + let m: RegExpExecArray | null; + while ((m = eventRegex.exec(source)) !== null) { + const structName = m[1]!; + + // Check for #[tauri_specta(event_name = "...")] override between + // the derive and the struct keyword. + const blockBefore = source.slice( + Math.max(0, m.index - 200), + m.index + m[0].length + ); + const overrideMatch = blockBefore.match( + /#\[tauri_specta\s*\(\s*event_name\s*=\s*"([^"]+)"\s*\)\]/ + ); + + const snakeName = pascalToSnake(structName); + const camelName = snakeToCamel(snakeName); + const kebabName = overrideMatch?.[1] ?? snakeName.replace(/_/g, '-'); + const nodes = context.getNodesInFile(file); + const node = nodes.find( + (n) => n.name === structName && (n.kind === 'struct' || n.kind === 'enum') + ); + if (!node) continue; + const entry: TauriEvent = { rustName: structName, node }; + events.set(camelName, entry); + events.set(kebabName, entry); + events.set(structName, entry); + events.set(snakeName, entry); + } + } + } + + const result = { commands, events }; + indexCache.set(context, result); + return result; +} + +// -- Extraction (raw invoke/listen wire-name references) ---------------------- + +const JS_EXT_TO_LANGUAGE: Record = { + '.ts': 'typescript', + '.mts': 'typescript', + '.cts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'jsx', + '.svelte': 'svelte', + '.vue': 'typescript', +}; + +function jsLanguageForFile(filePath: string): Language | null { + const dot = filePath.lastIndexOf('.'); + if (dot < 0) return null; + return JS_EXT_TO_LANGUAGE[filePath.slice(dot).toLowerCase()] ?? null; +} + +/** + * Raw Tauri IPC uses string wire names the JS extractor never emits as + * references: `invoke('foo_bar')`, `listen('foo-bar')`, `once('foo-bar')`. The + * command/event name is a string argument, so the only call edge extraction + * records is to `invoke`/`listen` itself, and `resolve()` never sees the wire + * name. (The typed `commands.fooBar()` / `events.fooBar.listen()` path doesn't + * need this: those are member accesses the JS extractor already emits a `fooBar` + * reference for.) This scan surfaces each wire name as a `calls` reference so + * `resolve()` can join it to the Rust handler / Event struct. + * + * Matches the `invoke`/`listen`/`once` API name on an identifier boundary + * (`\b`), with optional generics and a string-literal first argument. A + * dynamic name (`invoke(`${x}`)`) fails the literal match and is skipped. + * Over-matching is harmless: `resolve()` only joins names that hit a real + * command/event, so a stray `arr.includes(...)`-style call resolves to nothing. + */ +const WIRE_CALL_RE = /\b(?:invoke|listen|once)\s*(?:<[^>(]*>)?\s*\(\s*(['"`])([\w./:-]+)\1/g; + +/** + * Attributed to the file node (`file:`, a stable un-hashed id) rather + * than the enclosing function: a function's node id is a sha256 of + * `path:kind:name:line`, which a string scan can't reconstruct without + * re-parsing. File granularity still answers "which files use this command" for + * `callers` / `impact` — coarser than the typed path's call-site edge, but + * accurate and robust. + */ +function extractWireReferences(filePath: string, source: string): UnresolvedRef[] { + const language = jsLanguageForFile(filePath); + if (!language) return []; + + const refs: UnresolvedRef[] = []; + const fromNodeId = `file:${filePath}`; + WIRE_CALL_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = WIRE_CALL_RE.exec(source)) !== null) { + const wireName = m[2]!; + // Line/column of the wire-name literal, for the edge's location. + const upto = source.slice(0, m.index); + const line = upto.split('\n').length; + const lastNl = upto.lastIndexOf('\n'); + refs.push({ + fromNodeId, + referenceName: wireName, + referenceKind: 'calls', + line, + column: m.index - lastNl - 1, + filePath, + language, + }); + } + return refs; +} + +// -- Resolver ----------------------------------------------------------------- + +const TS_LANGUAGES = new Set(['javascript', 'typescript', 'tsx', 'jsx', 'svelte']); + +export const tauriBridgeResolver: FrameworkResolver = { + name: 'tauri-ipc', + languages: ['javascript', 'typescript', 'tsx', 'jsx', 'svelte', 'rust'], + + detect(context) { + // tauri.conf.json (or tauri.conf.json5) is the definitive marker at root. + if (context.fileExists('tauri.conf.json') || context.fileExists('tauri.conf.json5')) { + return true; + } + // Nested Tauri app (e.g. monorepo): check for src-tauri/tauri.conf.json if tracked, + // though json files might not be tracked. + const files = context.getAllFiles(); + for (const f of files) { + if (f.endsWith('tauri.conf.json') || f.endsWith('tauri.conf.json5')) return true; + } + // Fallback 1: package.json at root depends on @tauri-apps/api. + const pkg = context.readFile('package.json'); + if (pkg && /"@tauri-apps\/api"/.test(pkg)) return true; + + // Fallback 2 (Definitive for monorepos): any tracked Rust file uses tauri::command. + for (const f of files) { + if (!f.endsWith('.rs')) continue; + const src = context.readFile(f); + if (src && src.includes('tauri::command')) return true; + } + + return false; + }, + + extract(filePath, source): FrameworkExtractionResult { + // Only the raw `invoke`/`listen`/`once` wire-name references; the typed + // `commands.*` / `events.*` path resolves through the JS extractor's own + // member references. No framework nodes. + if (!source.includes('invoke') && !source.includes('listen') && !source.includes('once')) { + return { nodes: [], references: [] }; + } + return { nodes: [], references: extractWireReferences(filePath, source) }; + }, + + resolve(ref, context) { + // Only redirect JS/TS callers. + if (!TS_LANGUAGES.has(ref.language)) return null; + + const { commands, events } = buildIndex(context); + + // Strip receiver prefix: `commands.getMcpPort` -> `getMcpPort`, + // `events.volumeSpaceChanged` -> `volumeSpaceChanged`. + const name = ref.referenceName.includes('.') + ? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1) + : ref.referenceName; + + // Try command lookup first. + let entry = commands.get(name); + // For raw invoke('snake_name'), also try kebab -> snake conversion. + if (!entry) entry = commands.get(kebabToSnake(name)); + if (entry) { + return { + original: ref, + targetNodeId: entry.node.id, + confidence: 0.7, + resolvedBy: 'framework', + }; + } + + // Try event lookup. + let evt = events.get(name); + if (!evt) evt = events.get(kebabToSnake(name)); + if (evt) { + return { + original: ref, + targetNodeId: evt.node.id, + confidence: 0.6, + resolvedBy: 'framework', + }; + } + + return null; + }, +};