Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Features

- Connect imported Tauri `invoke` calls with literal command names to decorated, registered Rust handlers in conventional `src-tauri` applications. Edges retain invoke and handler-registration evidence.

### Fixes

- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
Expand Down
80 changes: 80 additions & 0 deletions __tests__/tauri-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import CodeGraph from '../src/index';
import { hasSynthesisPattern } from '../src/resolution/callback-synthesizer';

const roots: string[] = []; const graphs: CodeGraph[] = [];
function write(root: string, file: string, source: string) { const target = path.join(root, file); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, source); }
const backend = '#[tauri::command]\nfn greet() {}\n#[tauri::command]\nfn unregistered() {}\nfn not_command() {}\nfn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![greet, not_command]).run(); }\n';
async function fixture(files: Record<string, string>) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tauri-')); roots.push(root);
for (const [file, source] of Object.entries(files)) write(root, file, source);
const graph = CodeGraph.initSync(root); graphs.push(graph); await graph.indexAll(); await graph.resolveReferences();
return { root, graph };
}
function links(graph: CodeGraph) { return graph.getNodesByName('greet').flatMap(node => graph.getCallers(node.id)).filter(row => row.edge.metadata?.synthesizedBy === 'tauri-command'); }
afterEach(() => { for (const graph of graphs.splice(0)) graph.close(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); });
describe('Tauri IPC links', () => {
it('links direct, aliased and namespace imports to registered decorated commands', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport async function click() { return invoke<string>("greet"); }', 'src/b.ts': 'import { invoke as callRust } from "@tauri-apps/api/tauri";\nexport function otherClick() { return callRust("greet"); }', 'src/c.ts': 'import * as core from "@tauri-apps/api/core";\nexport function namespaceClick() { return core.invoke("greet"); }' });
const rows = links(graph); expect(rows.map(row => row.node.name).sort()).toEqual(['click', 'namespaceClick', 'otherClick']);
expect(rows.every(row => row.edge.provenance === 'heuristic')).toBe(true);
expect(rows.every(row => row.edge.metadata?.registeredAt === 'src-tauri/src/lib.rs:6')).toBe(true);
expect(rows.every(row => row.edge.line === 2)).toBe(true);
});
it('rejects shadowed imports, dynamic keys, wrappers and absent registrations', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': [
'import { invoke } from "@tauri-apps/api/core";',
'function parameter(invoke: (key: string) => unknown) { return invoke("greet"); }',
'function destructured() { const { invoke } = other; return invoke("greet"); }',
'function dynamic(key: string) { return invoke(key); }',
'function absent() { invoke("unregistered"); invoke("not_command"); invoke("plugin:store|get"); }',
'function literal() { return "invoke(\\"greet\\")"; }',
'// invoke("greet")',
].join('\n'), 'src/unrelated.ts': 'import { invoke } from "./wrapper"; export function wrapperClick() { return invoke("greet"); }' });
expect(links(graph)).toEqual([]);
});
it('maps a qualified module command to its external bare key', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': 'mod commands; fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![crate::commands::greet]); }', 'src-tauri/src/commands.rs': '#[tauri::command]\npub fn greet() {}', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' });
expect(links(graph)).toHaveLength(1);
});
it('does not connect comment/string decoys or a detached macro', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': '#[tauri::command]\nfn greet() {}\nfn run() { let detached = tauri::generate_handler![greet]; let text = "tauri::Builder::default().invoke_handler(tauri::generate_handler![greet])"; /* tauri::Builder::default().invoke_handler(tauri::generate_handler![greet]); */ }', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' });
expect(links(graph)).toEqual([]);
});
it('isolates sibling apps and a nested app without registration', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/root.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function rootClick() { return invoke("greet"); }', 'apps/a/src-tauri/src/lib.rs': backend, 'apps/a/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function appClick() { return invoke("greet"); }', 'apps/b/src-tauri/src/lib.rs': '#[tauri::command]\nfn greet() {}', 'apps/b/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function unregisteredAppClick() { return invoke("greet"); }' });
const rows = links(graph); expect(rows).toHaveLength(2);
const targetOf = new Map(graph.getNodesByName('greet').map(node => [node.id, node.filePath]));
for (const row of rows) expect(targetOf.get(row.edge.target)).toBe(row.node.name === 'appClick' ? 'apps/a/src-tauri/src/lib.rs' : 'src-tauri/src/lib.rs');
});
it('removes and restores unchanged callers when handler registration changes', async () => {
const { graph, root } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' });
expect(links(graph)).toHaveLength(1);
write(root, 'src-tauri/src/lib.rs', backend.replace('generate_handler![greet, not_command]', 'generate_handler![not_command]')); await graph.sync();
expect(links(graph)).toEqual([]);
write(root, 'src-tauri/src/lib.rs', backend); await graph.sync(); expect(links(graph)).toHaveLength(1);
write(root, 'src/a.ts', 'export function click() { return 0; }'); await graph.sync(); expect(links(graph)).toEqual([]);
});
it('rejects duplicate external command keys from distinct registered modules', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': 'mod one; mod two; fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![one::greet, two::greet]); }', 'src-tauri/src/one.rs': '#[tauri::command]\nfn greet() {}', 'src-tauri/src/two.rs': '#[tauri::command]\nfn greet() {}', 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }' });
expect(links(graph)).toEqual([]);
});

it('keeps namespace and destructured parameter shadows unresolved', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import * as core from "@tauri-apps/api/core";\nfunction shadow(core: any) { return core.invoke("greet"); }', 'src/b.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction shadow({invoke}: {invoke: any}) { return invoke("greet"); }' });
expect(links(graph)).toEqual([]);
});

it('attributes adjacent same-line calls to the correct callable', async () => {
const { graph } = await fixture({ 'src-tauri/src/lib.rs': backend, 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction first() { return invoke("greet"); } function second() { return 0; }' });
expect(links(graph).map(row => row.node.name)).toEqual(['first']);
});

it('tracks aliased import and registration-only inputs for incremental synthesis', () => {
expect(hasSynthesisPattern('src/a.ts', 'import { invoke as callRust } from "@tauri-apps/api/core"; callRust("greet");')).toBe(true);
expect(hasSynthesisPattern('src-tauri/src/lib.rs', '#[tauri::command] fn greet() {}')).toBe(true);
});
});
11 changes: 11 additions & 0 deletions docs/tauri-ipc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Tauri Command Links

The Tauri synthesis pass links JavaScript/TypeScript calls imported from `@tauri-apps/api/core` or `@tauri-apps/api/tauri` to Rust commands. Named import aliases and namespace imports are supported. The first argument must be a static string literal.

A destination requires both `#[tauri::command]` and registration in `tauri::generate_handler![...]` passed directly to an `invoke_handler` call whose receiver chain originates at `tauri::Builder::default()`. Rust macro token trees and frontend import/call nodes are parsed, so comments and string decoys do not establish registration. Standard module paths such as `commands::save` are mapped under `src-tauri/src`; the external command key remains `save`.

Calls stay within the nearest conventional application root. Ambiguous keys, shadowed imports, dynamic keys, plugin commands, top-level calls without a callable owner, and unregistered/undecorated functions produce no edge. Edges use `provenance: heuristic`, `synthesizedBy: tauri-command`, invoke line/column and `registeredAt` evidence. Existing synthesis input tracking rebuilds the links when registration or invocation changes.

This initial contribution does not model global `window.__TAURI__` access, wrapper/re-export functions, aliased Rust macros, builder variables, nonstandard crate/module paths or frontend Vue/Svelte scripts. Explicit registration is source evidence, not a claim that an application ran or granted command permissions.

Fixture precision and sync behavior are tested. Required small/medium/large real-repository probes and agent A/B validation remain pending; framework support is not marked fully validated.
46 changes: 46 additions & 0 deletions src/graph/source-syntax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Node as TreeNode } from 'web-tree-sitter';
import type { Language } from '../types';
import { getParser } from '../extraction/grammars';

export type SyntaxNode = TreeNode;

export function visitSyntax(node: SyntaxNode, visit: (node: SyntaxNode) => void): void {
const pending = [node];
while (pending.length) {
const current = pending.pop()!;
visit(current);
for (let i = current.namedChildCount - 1; i >= 0; i--) pending.push(current.namedChild(i)!);
}
}

/** The callback cannot retain nodes after the tree is released. */
export function withSourceTree<T>(source: string, language: Language, read: (root: SyntaxNode) => T): T | null {
const parser = getParser(language);
if (!parser) return null;
const tree = parser.parse(source);
if (!tree) return null;
try { return read(tree.rootNode); }
finally { tree.delete(); }
}

export function literalValue(node: SyntaxNode | null): string | null {
if (!node || !['string', 'string_literal', 'interpreted_string_literal', 'raw_string_literal'].includes(node.type)) return null;
const text = node.text;
if (/^r#*"/.test(text)) return text.replace(/^r#*"/, '').replace(/"#*$/, '');
if (text.startsWith('`') && text.endsWith('`')) return text.slice(1, -1);
if (text.startsWith('"')) {
try { return JSON.parse(text) as string; } catch { return null; }
}
if (text.startsWith("'") && text.endsWith("'") && !text.slice(1, -1).includes('\\')) return text.slice(1, -1);
return null;
}

export function attributesBefore(node: SyntaxNode): string {
const attributes: string[] = [];
for (let previous = node.previousNamedSibling; previous; previous = previous.previousNamedSibling) {
if (previous.type.includes('comment')) continue;
if (previous.type !== 'attribute_item') break;
attributes.unshift(previous.text);
}
return attributes.join('\n');
}
7 changes: 7 additions & 0 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
import { goframeRouteEdges } from './goframe-synthesizer';
import { tauriCommandEdges } from './tauri-synthesizer';

import { expoRouterReturnEdges } from './expo-router-synthesizer';
import { nextLinkEdges } from './next-router-synthesizer';
import { reactRouterLinkEdges } from './react-router-synthesizer';
Expand Down Expand Up @@ -4384,6 +4386,9 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean
/\b(?:struct|union|typedef|virtual|override)\b|#\s*(?:include|define|if)|=|->|\[/.test(content)) return true;
if (/\b(?:class|interface|protocol|trait|impl|extends|implements|expect|actual)\b/.test(content)) return true;
if (/\.go$/.test(filePath) && /\b(?:struct|interface)\b|\bfunc\s*\(/.test(content)) return true;
if (/\.rs$/.test(filePath) && /tauri\s*::\s*(?:command|generate_handler)/.test(content)) return true;
if (/\.(?:[cm]?[jt]sx?)$/.test(filePath) && content.includes('@tauri-apps/api/')) return true;

if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true;
if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true;
if (/\.(?:forEach|append|add|push|insert|fire|dispatchEvent|addListener|Use|GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD|Any|Handle)\s*\(/.test(content)) return true;
Expand Down Expand Up @@ -4419,6 +4424,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [
// a queue job onto its consumer, a bus / socket event onto its handler.
// Before the in-process emitter pass: the same (source, target) pair
// keeps the more specific edge — the one that says which tier it crosses.
{ name: 'tauriEdges', gate: (has) => has('rust') && has(...JS_FAMILY), run: (_q, c, y) => tauriCommandEdges(c, y) },

{ name: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) },
// A Spring / Laravel test's request (`mockMvc.perform(post("/x"))`,
// `$this->postJson('api/x')`) onto the route it reaches, so the handler
Expand Down
Loading