diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a86ffa2d..e3c936d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph install` now sets up IBM Bob, for Bob IDE and Bob Shell: it adds the CodeGraph MCP server to `~/.bob/settings/mcp.json`, or to `.bob/mcp.json` in a project. Thanks @QuentinDstl. - `codegraph install` now sets up Factory Droid: it adds the CodeGraph MCP server to `~/.factory/mcp.json` or a project's `.factory/mcp.json`, plus a short CodeGraph section in `AGENTS.md`, and Droid picks it up without a restart. Thanks @Rubyj and @KrystianMarek. - `codegraph install` now sets up Windsurf: it adds the CodeGraph MCP server to `~/.codeium/windsurf/mcp_config.json`, which Devin, Windsurf's new name, imports by default. Thanks @SEPURI-SAI-KRISHNA. +- Tauri apps now connect across the line between the frontend and Rust: `codegraph_explore`, callers and impact follow `invoke('save_file')`, tauri-specta's typed `commands.saveFile()` and your own wrappers around `invoke` to the `#[tauri::command]` function that answers, and follow events between Rust and the frontend, raw `emit` / `listen` and tauri-specta's typed events alike. Thanks @vdavid and @nk33-dev. - `codegraph install` now sets up Qwen Code: it adds the CodeGraph MCP server to `settings.json`, keeping any comments in that file, plus a short CodeGraph section in `QWEN.md`, and it can auto-allow CodeGraph's tools as it does for Claude Code. Thanks @ZevGit and @LiangshouX. - `codegraph install` now sets up DeepSeek Harness (`dsh`): one entry in `~/.dsh/cordis.patch.yml` covers every profile, a short CodeGraph section goes in `~/.dsh/AGENTS.md`, and an entry you wrote yourself is left as it is. Thanks @hfldqwe. - `codegraph install` now sets up Grok Build: it adds the CodeGraph MCP server to `~/.grok/config.toml` or a project's `.grok/config.toml`, plus a short rule in `rules/codegraph.md`. Thanks @ximing. diff --git a/README.md b/README.md index 9511ef7ac..43b1208fe 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | +| **Tauri** | Follows a desktop app across its web frontend and Rust core: `invoke('save_file')`, tauri-specta's typed `commands.saveFile()`, your own wrappers around `invoke`, and events in both directions | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only |
diff --git a/__tests__/tauri-ipc.test.ts b/__tests__/tauri-ipc.test.ts new file mode 100644 index 000000000..c0961d9f0 --- /dev/null +++ b/__tests__/tauri-ipc.test.ts @@ -0,0 +1,732 @@ +/** + * Tauri IPC: a frontend's `invoke('cmd')` onto the Rust `#[tauri::command]` + * that answers it, tauri-specta's typed client, and events across that seam. + * Each fixture is indexed for real, so the extraction of both languages, the + * pass and the merge are all under test. + */ +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'; +import { describeSynthesizedHop } from '../src/graph/synthesized-hop'; +import { ToolHandler } from '../src/mcp/tools'; +import type { Edge, Node } from '../src/types'; + +const roots: string[] = []; +const graphs: CodeGraph[] = []; + +function write(root: string, file: string, source: string): void { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); +} + +async function index(files: Record): Promise<{ root: string; graph: CodeGraph }> { + 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(); + return { root, graph }; +} + +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 }); +}); + +/** Everything the pass drew, in edge order. */ +function links(graph: CodeGraph, by = 'tauri-command'): Array<{ from: Node; to: Node; edge: Edge }> { + const db = (graph as unknown as { db: { db: { prepare(sql: string): { all(...args: unknown[]): unknown[] } } } }).db.db; + const rows = db.prepare( + `SELECT source, target, kind, line, col AS "column", provenance, metadata FROM edges WHERE json_extract(metadata, '$.synthesizedBy') = ?` + ).all(by) as Array & { metadata: string }>; + return rows.map((row) => { + const edge = { ...row, metadata: JSON.parse(row.metadata) as Record }; + return { from: graph.getNode(row.source)!, to: graph.getNode(row.target)!, edge }; + }); +} + +const names = (rows: ReadonlyArray<{ from: Node; to: Node }>): string[] => rows.map((r) => `${r.from.name} > ${r.to.name}`).sort(); + +const CORE = `#[tauri::command] +fn greet() {} +#[tauri::command] +fn unregistered() {} +fn not_command() {} +fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![greet, not_command]).run(); } +`; + +describe('Tauri commands', () => { + it('links direct, aliased and namespace imports to the registered command', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport async function click() { return invoke("greet"); }\n', + 'src/b.ts': 'import { invoke as callRust } from "@tauri-apps/api/tauri";\nexport function otherClick() { return callRust("greet"); }\n', + 'src/c.ts': 'import * as core from "@tauri-apps/api/core";\nexport function namespaceClick() { return core.invoke("greet"); }\n', + }); + const rows = links(graph); + expect(names(rows)).toEqual(['click > greet', 'namespaceClick > greet', 'otherClick > greet']); + for (const { edge } of rows) { + expect(edge.provenance).toBe('heuristic'); + expect(edge.kind).toBe('calls'); + expect(edge.line).toBe(2); + expect(edge.metadata?.registeredAt).toBe('src-tauri/src/lib.rs:6'); + expect(edge.metadata?.command).toBe('greet'); + expect(edge.metadata?.tier).toBe('client→server'); + } + }); + + it('reads an API imported where it is used', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [CORE, 'use tauri::Listener;', 'fn wire(app: tauri::AppHandle) { app.listen("ready", |_e| {}); }'].join('\n'), + 'src/a.ts': [ + 'export async function lazy() {', + " const { invoke } = await import('@tauri-apps/api/core');", + " return invoke('greet');", + '}', + 'export async function tell() {', + " const { emit, emitTo: toWindow } = await import('@tauri-apps/api/event');", + " await emit('ready');", + " await toWindow('main', 'other', 1);", + '}', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['lazy > greet']); + expect(names(links(graph, 'tauri-event'))).toEqual(['tell > wire']); + }); + + it('reads the v1 API root and its tauri namespace', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': CORE, + 'src/a.jsx': "import { invoke } from '@tauri-apps/api';\nexport function Rooted() { invoke('greet'); return null; }\n", + 'src/b.js': "import { tauri } from '@tauri-apps/api';\nexport function holder() { return tauri.invoke('greet'); }\n", + }); + expect(names(links(graph))).toEqual(['Rooted > greet', 'holder > greet']); + }); + + it('reads the global API of an app built with no bundler', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/main.js': [ + 'const { invoke } = window.__TAURI__.core;', + 'function viaDestructure() { return invoke("greet"); }', + 'function viaGlobal() { return window.__TAURI__.core.invoke("greet"); }', + 'function viaV1Global() { return window.__TAURI__.invoke("greet"); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['viaDestructure > greet', 'viaGlobal > greet', 'viaV1Global > greet']); + }); + + it('links nothing a call cannot prove', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'function parameter(invoke: (key: string) => unknown) { return invoke("greet"); }', + 'function destructured(other: any) { const { invoke } = other; return invoke("greet"); }', + 'function arrowParam() { return [1].map((invoke: any) => invoke("greet")); }', + 'function dynamic(key: string) { return invoke(key); }', + 'function templated(id: string) { return invoke(`greet_${id}`); }', + 'function concatenated(id: string) { return invoke("greet" + id); }', + 'function absent() { invoke("unregistered"); invoke("not_command"); invoke("plugin:store|get"); }', + 'function literal() { return "invoke(\\"greet\\")"; }', + '// invoke("greet")', + '/* invoke("greet") */', + ].join('\n'), + 'src/wrapped.ts': 'import { invoke } from "./wrapper";\nexport function wrapperClick() { return invoke("greet"); }\n', + 'src/other.ts': 'function invoke(key: string) { return key; }\nexport function localClick() { return invoke("greet"); }\n', + }); + expect(names(links(graph))).toEqual([]); + }); + + it('keeps a namespace or destructured parameter that hides the import unlinked', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.ts': 'import * as core from "@tauri-apps/api/core";\nfunction shadow(core: any) { return core.invoke("greet"); }\n', + 'src/b.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction shadow({ invoke }: { invoke: any }) { return invoke("greet"); }\n', + }); + expect(names(links(graph))).toEqual([]); + }); + + it('does not take a comment, a string or an unrelated macro for a registration', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + '#[tauri::command]', + 'fn greet() {}', + 'fn run() {', + ' let text = "tauri::generate_handler![greet]";', + ' // tauri::generate_handler![greet]', + ' /* tauri::generate_handler![greet] */', + ' other_handler![greet];', + '}', + ].join('\n'), + 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }\n', + }); + expect(names(links(graph))).toEqual([]); + }); + + it('reads a list that is built apart from the builder and tauri-specta collectors', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + 'mod cmd;', + 'fn handlers() -> impl Fn(tauri::ipc::Invoke) -> bool { tauri::generate_handler![cmd::listed] }', + 'pub fn builder() -> tauri_specta::Builder {', + ' tauri_specta::Builder::new().commands(tauri_specta::collect_commands![', + ' cmd::typed,', + ' #[cfg(target_os = "macos")]', + ' cmd::conditional,', + ' cmd::generic::,', + ' ])', + '}', + ].join('\n'), + // A module's commands are re-exported by its parent: the path names the parent. + 'src-tauri/src/cmd/mod.rs': 'mod system;\npub use system::*;\n', + 'src-tauri/src/cmd/system.rs': [ + '#[tauri::command] pub fn listed() {}', + '#[tauri::command] #[specta::specta] pub fn typed() {}', + '#[tauri::command] pub fn conditional() {}', + '#[tauri::command] pub fn generic() {}', + ].join('\n'), + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function a() { return invoke("listed"); }', + 'export function b() { return invoke("typed"); }', + 'export function c() { return invoke("conditional"); }', + 'export function d() { return invoke("generic"); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['a > listed', 'b > typed', 'c > conditional', 'd > generic']); + }); + + it('walks through a generated tauri-specta client to the command', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + '#[tauri::command] #[specta::specta] fn get_mcp_port() -> u16 { 0 }', + 'fn run() { tauri_specta::Builder::new().commands(tauri_specta::collect_commands![get_mcp_port]); }', + ].join('\n'), + 'src/bindings.ts': [ + '// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.', + 'export const commands = {', + ' async getMcpPort(): Promise {', + ' return await TAURI_INVOKE("get_mcp_port");', + ' },', + ' getMcpPortArrow: () => __TAURI_INVOKE<{ a: string; b: Array }>("get_mcp_port"),', + '};', + 'import { invoke as TAURI_INVOKE, invoke as __TAURI_INVOKE } from "@tauri-apps/api/core";', + ].join('\n'), + 'src/settings.ts': 'import { commands } from "./bindings";\nexport async function loadPort() { return commands.getMcpPort(); }\n', + }); + expect(names(links(graph))).toEqual(['getMcpPort > get_mcp_port', 'getMcpPortArrow > get_mcp_port']); + const getMcpPort = graph.getNodesByName('getMcpPort')[0]!; + expect(graph.getCallers(getMcpPort.id).map((c) => c.node.name)).toContain('loadPort'); + }); + + it('reads commands.fooBar() by name when the bindings are not in the index', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + '#[tauri::command] #[specta::specta] fn get_mcp_port() -> u16 { 0 }', + 'fn run() { tauri_specta::Builder::new().commands(tauri_specta::collect_commands![get_mcp_port]); }', + ].join('\n'), + 'src/settings.ts': [ + 'import { commands } from "./bindings";', + 'import { commands as other } from "some-package";', + 'export async function loadPort() { return commands.getMcpPort(); }', + 'export function notACommand() { return commands.somethingElse(); }', + 'export function notBindings() { return other.getMcpPort(); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['loadPort > get_mcp_port']); + }); + + it('answers from what the app declares when a macro builds its handler list', async () => { + const { graph } = await index({ + 'apps/desktop/src-tauri/src/ipc.rs': [ + 'macro_rules! build_invoke_handler {', + ' ($($($typed:ident)::+,)*) => { tauri::generate_handler![ $($($typed)::+,)* ] };', + '}', + '#[tauri::command] pub fn from_manifest() {}', + ].join('\n'), + 'apps/desktop/src-tauri/src/commands/other.rs': '#[tauri::command]\npub fn from_another_file() {}\n', + 'apps/desktop/src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function click() { return invoke("from_manifest"); }', + 'export function other() { return invoke("from_another_file"); }', + ].join('\n'), + }); + const rows = links(graph); + expect(names(rows)).toEqual(['click > from_manifest', 'other > from_another_file']); + expect(rows[0]!.edge.metadata?.registeredAt).toBe('apps/desktop/src-tauri/src/ipc.rs:2'); + }); + + it('accepts a command an attribute macro makes, and no plain namesake of an outside entry', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + 'use tauri::command;', + 'use tauri_plugin_x::commands::version;', + '#[command]', + 'async fn short_form() {}', + '#[app_api(try_from = Thing)]', + '#[tracing::instrument]', + 'pub fn macro_made() {}', + 'fn version() {}', + 'fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![short_form, api::tauri_macro_made::macro_made, tauri_plugin_x::commands::version]); }', + ].join('\n'), + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function a() { return invoke("short_form"); }', + 'export function b() { return invoke("macro_made"); }', + 'export function c() { return invoke("version"); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['a > short_form', 'b > macro_made']); + }); + + it('keeps two apps of one repository apart, and a key two handlers claim unlinked', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/root.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function rootClick() { return invoke("greet"); }\n', + 'apps/a/src-tauri/src/lib.rs': CORE, + 'apps/a/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function appClick() { return invoke("greet"); }\n', + 'apps/b/src-tauri/src/lib.rs': '#[tauri::command]\nfn greet() {}\n', + 'apps/b/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function unregisteredAppClick() { return invoke("greet"); }\n', + 'apps/c/src-tauri/src/lib.rs': [ + 'mod one; mod two;', + 'fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![one::greet, two::greet]); }', + ].join('\n'), + 'apps/c/src-tauri/src/one.rs': '#[tauri::command]\npub fn greet() {}\n', + 'apps/c/src-tauri/src/two.rs': '#[tauri::command]\npub fn greet() {}\n', + 'apps/c/src/click.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function tiedClick() { return invoke("greet"); }\n', + }); + const rows = links(graph); + expect(names(rows)).toEqual(['appClick > greet', 'rootClick > greet']); + for (const row of rows) { + expect(row.to.filePath).toBe(row.from.name === 'appClick' ? 'apps/a/src-tauri/src/lib.rs' : 'src-tauri/src/lib.rs'); + } + }); + + it('finds a crate and frontend that sit apart', async () => { + const { graph } = await index({ + 'crates/app-tauri/Cargo.toml': '[package]\nname = "app-tauri"\nversion = "0.1.0"\n', + 'crates/app-tauri/src/main.rs': 'fn main() { tauri::Builder::default().invoke_handler(tauri::generate_handler![api::save]); }\n', + 'crates/api/Cargo.toml': '[package]\nname = "api"\nversion = "0.1.0"\n', + 'crates/api/src/lib.rs': '#[tauri::command]\npub fn save() {}\n', + 'apps/desktop/src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("save"); }\n', + }); + expect(names(links(graph))).toEqual(['click > save']); + }); + + it('routes a plugin command to the plugin that registers it', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': 'fn main() { tauri::Builder::default().invoke_handler(tauri::generate_handler![list]); }\n#[tauri::command] fn list() {}\n', + 'plugins/fonts/Cargo.toml': '[package]\nname = "fonts"\nversion = "0.1.0"\n', + 'plugins/fonts/src/lib.rs': [ + 'use tauri::{generate_handler, plugin::{Builder, TauriPlugin}, Runtime};', + '#[tauri::command] pub fn list() {}', + 'pub fn init() -> TauriPlugin { Builder::new("fonts").invoke_handler(generate_handler![list]).build() }', + ].join('\n'), + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function pluginList() { return invoke("plugin:fonts|list"); }', + 'export function appList() { return invoke("list"); }', + ].join('\n'), + }); + const rows = links(graph); + expect(names(rows)).toEqual(['appList > list', 'pluginList > list']); + expect(rows.find((r) => r.from.name === 'pluginList')!.to.filePath).toBe('plugins/fonts/src/lib.rs'); + expect(rows.find((r) => r.from.name === 'appList')!.to.filePath).toBe('src-tauri/src/main.rs'); + }); + + it('reads a plugin named through a constant and tauri-specta\'s builder', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': 'fn main() { tauri::Builder::default().plugin(my_plugin::init()); }\n', + 'plugins/mine/Cargo.toml': '[package]\nname = "my_plugin"\nversion = "0.1.0"\n', + 'plugins/mine/src/lib.rs': [ + 'const PLUGIN_NAME: &str = "my-plugin";', + '#[tauri::command] #[specta::specta] fn add_numbers() {}', + 'fn builder() -> tauri_specta::Builder {', + ' tauri_specta::Builder::new().plugin_name(PLUGIN_NAME).commands(tauri_specta::collect_commands![add_numbers::])', + '}', + ].join('\n'), + 'plugins/mine/guest-js/index.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function add() { return invoke("plugin:my-plugin|add_numbers"); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['add > add_numbers']); + }); + + it('reads Svelte and Vue components, in their scripts and their markup', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': CORE, + 'src/Page.svelte': [ + '', + '', + ].join('\n'), + 'src/Top.svelte': [ + '', + ].join('\n'), + 'src/Panel.vue': [ + '', + '', + ].join('\n'), + }); + const rows = links(graph); + expect(names(rows)).toEqual(['Top > greet', 'load > greet', 'save > greet']); + expect(rows.find((r) => r.from.name === 'save')!.edge.line).toBe(3); + expect(rows.find((r) => r.from.name === 'load')!.edge.line).toBe(4); + }); + + it('attributes a call to the function it is in when two share a line', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nfunction first() { return invoke("greet"); } function second() { return 0; }\n', + }); + expect(names(links(graph))).toEqual(['first > greet']); + }); + + it('links a command written for each platform to every one of them', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': [ + 'mod ocr;', + 'mod stubs;', + 'fn main() { tauri::Builder::default().invoke_handler(tauri::generate_handler![ocr::read_text, stubs::only_stub]); }', + ].join('\n'), + 'src-tauri/src/ocr.rs': [ + '#[tauri::command(async)]', + '#[cfg(target_os = "windows")]', + 'pub fn read_text() {}', + '#[tauri::command(async)]', + '#[cfg(target_os = "macos")]', + 'pub fn read_text() {}', + ].join('\n'), + 'src-tauri/src/stubs.rs': '#[tauri::command]\npub fn only_stub() {}\n', + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function read() { return invoke("read_text"); }', + ].join('\n'), + }); + const rows = links(graph); + expect(names(rows)).toEqual(['read > read_text', 'read > read_text']); + expect(rows.map((r) => r.to.startLine).sort()).toEqual([3, 6]); + }); + + it('follows the project\'s own functions around invoke, and a field set to one', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/ipc.ts': [ + 'import { invoke as tauriInvoke } from "@tauri-apps/api/core";', + 'export async function safeInvoke(command: string, args?: unknown): Promise {', + ' return await tauriInvoke(command, args as never);', + '}', + 'export const logged = (command: string) => { console.log(command); return safeInvoke(command); };', + 'export function notAWrapper(command: string, other: string) { return tauriInvoke(other); }', + 'export class Backend { invoke = safeInvoke; }', + ].join('\n'), + 'src/a.ts': 'import { safeInvoke } from "./ipc";\nexport function click() { return safeInvoke("greet"); }\n', + 'src/b.ts': 'import { logged as log } from "./ipc";\nexport function twice() { return log("greet"); }\n', + 'src/c.ts': [ + 'export class Service {', + ' constructor(private backend: { invoke(c: string): unknown }) {}', + ' load() { return this.backend.invoke("greet"); }', + '}', + ].join('\n'), + 'src/d.ts': 'import { notAWrapper } from "./ipc";\nexport function notLinked() { return notAWrapper("greet", "x"); }\n', + 'src/settings.ts': [ + 'export class Settings {', + ' constructor(private backend: { invoke(c: string): unknown }) {}', + ' async update() { await this.invokeAndRefresh("greet"); }', + ' private async invokeAndRefresh(command: string) { await this.backend.invoke(command); }', + '}', + ].join('\n'), + 'src/api.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function plain(cmd: string) { return invoke(cmd); }', + 'export class Api { call(cmd: string) { return invoke(cmd); } }', + 'export function noParameter() { const cmd = "greet"; return invoke(cmd); }', + ].join('\n'), + 'src/e.ts': [ + 'import { plain, Api } from "./api";', + 'export function viaPlain() { return plain("greet"); }', + 'export function viaMethod(api: Api) { return api.call("greet"); }', + ].join('\n'), + }); + expect(names(links(graph))).toEqual(['click > greet', 'load > greet', 'noParameter > greet', 'twice > greet', 'update > greet', 'viaMethod > greet', 'viaPlain > greet']); + }); + + it('leaves test code out, where the backend is mocked', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.test.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function testClick() { return invoke("greet"); }\n', + }); + expect(names(links(graph))).toEqual([]); + }); + + it('follows a registration and a call through sync, as a fresh index would', async () => { + const { graph, root } = await index({ + 'src-tauri/src/lib.rs': CORE, + 'src/a.ts': 'import { invoke } from "@tauri-apps/api/core";\nexport function click() { return invoke("greet"); }\n', + }); + expect(names(links(graph))).toEqual(['click > greet']); + write(root, 'src-tauri/src/lib.rs', CORE.replace('generate_handler![greet, not_command]', 'generate_handler![not_command]')); + await graph.sync(); + expect(names(links(graph))).toEqual([]); + write(root, 'src-tauri/src/lib.rs', CORE); + await graph.sync(); + expect(names(links(graph))).toEqual(['click > greet']); + write(root, 'src/a.ts', 'export function click() { return 0; }\n'); + await graph.sync(); + expect(names(links(graph))).toEqual([]); + write(root, 'src/late.ts', 'import { invoke } from "@tauri-apps/api/core";\nexport function late() { return invoke("greet"); }\n'); + await graph.sync(); + expect(names(links(graph))).toEqual(['late > greet']); + }); + + it('marks what a sync must read again', () => { + expect(hasSynthesisPattern('src/a.ts', 'import { invoke as callRust } from "@tauri-apps/api/core"; callRust("greet");')).toBe(true); + expect(hasSynthesisPattern('src/a.js', 'const x = window.__TAURI__;')).toBe(true); + expect(hasSynthesisPattern('src-tauri/src/lib.rs', '#[tauri::command] fn greet() {}')).toBe(true); + expect(hasSynthesisPattern('src-tauri/src/lib.rs', 'fn run() { b.invoke_handler(generate_handler![greet]); }')).toBe(true); + }); + + it('draws nothing in a Rust and TypeScript project that is not a Tauri app', async () => { + const { graph } = await index({ + 'native/src/lib.rs': 'pub fn greet() {}\n#[derive(Clone)]\npub struct Saved;\n', + 'src/a.ts': 'import { invoke } from "./rpc";\nexport function click() { return invoke("greet"); }\n', + 'src/rpc.ts': 'export function invoke(name: string) { return name; }\n', + }); + expect(names(links(graph))).toEqual([]); + expect(names(links(graph, 'tauri-event'))).toEqual([]); + }); +}); + +describe('Tauri events', () => { + it('links a raw emit to a named or inline listener, across languages', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': [ + 'use tauri::Emitter;', + 'const CHANGED: &str = "theme-changed";', + 'fn notify(window: tauri::Window) { window.emit("new_text", "x").unwrap(); }', + 'fn broadcast(app: tauri::AppHandle) { app.emit_all("new_text", ()).unwrap(); }', + 'fn targeted(app: tauri::AppHandle) { app.emit_to("main", "new_text", ()).unwrap(); }', + 'fn constant(app: tauri::AppHandle) { app.emit(CHANGED, ()).unwrap(); }', + ].join('\n'), + 'src/a.ts': [ + "import { listen, once } from '@tauri-apps/api/event';", + "function onText(e: unknown) { return e; }", + "export function named() { listen('new_text', onText); }", + "export function inline() { void once('theme-changed', (e) => console.log(e)); }", + "export function unknown(name: string) { listen(name, onText); }", + ].join('\n'), + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows)).toEqual(['broadcast > onText', 'constant > inline', 'notify > onText', 'targeted > onText']); + for (const { edge } of rows) { + expect(edge.kind).toBe('calls'); + expect(edge.metadata?.tier).toBe('server→client'); + expect(edge.metadata?.channel).toBe('event'); + } + const notify = rows.find((r) => r.from.name === 'notify')!; + expect(notify.edge.metadata?.event).toBe('new_text'); + expect(notify.edge.metadata?.registeredAt).toBe('src/a.ts:3'); + expect(notify.edge.line).toBe(3); + }); + + it('reads a listener on a window handle, and leaves built-in events alone', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': 'use tauri::Emitter;\nfn notify(window: tauri::Window) { window.emit("ready", ()).unwrap(); }\n', + 'src/a.ts': [ + "import { getCurrentWindow } from '@tauri-apps/api/window';", + "export function viaHandle() { getCurrentWindow().listen('ready', () => {}); }", + "export function builtIn() { getCurrentWindow().listen('tauri://resize', () => {}); }", + ].join('\n'), + }); + expect(names(links(graph, 'tauri-event'))).toEqual(['notify > viaHandle']); + }); + + it('links a frontend emit to a Rust listener and to another window', async () => { + const { graph } = await index({ + 'src-tauri/src/main.rs': [ + 'use tauri::Listener;', + 'fn wire(app: tauri::AppHandle) { app.listen("write-settled", |_e| {}); }', + 'fn other(app: tauri::AppHandle) { app.listen_any("write-settled", |_e| {}); }', + ].join('\n'), + 'src/a.ts': [ + "import { emit, listen } from '@tauri-apps/api/event';", + "export async function settle() { await emit('write-settled', { ok: true }); }", + "export function otherWindow() { listen('write-settled', () => {}); }", + ].join('\n'), + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows)).toEqual(['settle > other', 'settle > otherWindow', 'settle > wire']); + expect(rows.find((r) => r.to.name === 'wire')!.edge.metadata?.tier).toBe('client→server'); + expect(rows.find((r) => r.to.name === 'otherWindow')!.edge.metadata?.tier).toBeUndefined(); + }); + + const TYPED = [ + 'use serde::Serialize;', + 'use tauri::Manager;', + 'use tauri_specta::Event;', + '#[derive(Clone, Serialize, specta::Type, Event)]', + 'pub struct FileSaved { pub path: String }', + '#[derive(Clone, Serialize, specta::Type, Event)]', + '#[tauri_specta(event_name = "low-disk")]', + 'pub struct LowDiskPayload { pub path: String }', + '#[derive(Clone, Serialize, specta::Type, tauri_specta::Event)]', + 'pub struct AIModelReady;', + '#[derive(Clone, Serialize)]', + 'pub struct NotAnEvent;', + 'fn literal(app: &tauri::AppHandle) { let _ = FileSaved { path: String::new() }.emit(app); }', + 'fn wrapped(app: &tauri::AppHandle) { if let Err(e) = (LowDiskPayload { path: String::new() }).emit(app) { drop(e); } }', + 'fn unit(app: &tauri::AppHandle) { let _ = AIModelReady.emit(app); }', + 'fn local(app: &tauri::AppHandle) {', + ' let event = FileSaved { path: String::new() };', + ' let _ = event.emit(app);', + '}', + 'fn from_param(app: &tauri::AppHandle, payload: &LowDiskPayload) { let _ = payload.emit(app); }', + 'fn chained(app: &tauri::AppHandle) {', + ' let _ = FileSaved {', + ' path: String::new(),', + ' }', + ' .emit(app);', + '}', + 'fn targeted(app: &tauri::AppHandle) { let _ = FileSaved { path: String::new() }.emit_to(app, "main"); }', + 'fn not_one(app: &tauri::AppHandle) { let _ = NotAnEvent.emit(app); }', + 'fn run() { tauri_specta::Builder::::new().events(tauri_specta::collect_events![FileSaved, LowDiskPayload, AIModelReady]); }', + ].join('\n'); + + it('links tauri-specta events through their structs', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': TYPED, + 'src/a.ts': [ + "import { events } from '#lib/ipc/bindings.js';", + 'export function onSaved() { return events.fileSaved.listen((e) => e); }', + 'export function onDisk() { return events.lowDiskPayload(appWindow).once((e) => e); }', + 'export function onModel() { return events.aiModelReady.listen((e) => e); }', + 'export function notAnEvent() { return events.notAnEvent.listen((e) => e); }', + ].join('\n'), + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows.filter((r) => r.edge.kind === 'references'))).toEqual([ + 'onDisk > LowDiskPayload', 'onModel > AIModelReady', 'onSaved > FileSaved', + ]); + expect(names(rows.filter((r) => r.edge.kind === 'calls'))).toEqual([ + 'chained > onSaved', 'from_param > onDisk', 'literal > onSaved', 'local > onSaved', 'targeted > onSaved', 'unit > onModel', 'wrapped > onDisk', + ]); + const typed = rows.find((r) => r.to.name === 'LowDiskPayload')!; + expect(typed.edge.metadata?.event).toBe('low-disk'); + expect(typed.edge.metadata?.registeredAt).toBe('src-tauri/src/lib.rs:8'); + }); + + it('meets a raw listener on the wire name of a typed event', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': TYPED, + 'src/a.ts': [ + "import { listen } from '@tauri-apps/api/event';", + "export function rawSaved() { return listen('file-saved', () => {}); }", + "export function rawDisk() { return listen('low-disk', () => {}); }", + ].join('\n'), + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows.filter((r) => r.edge.kind === 'references'))).toEqual(['rawDisk > LowDiskPayload', 'rawSaved > FileSaved']); + expect(names(rows.filter((r) => r.edge.kind === 'calls'))).toEqual([ + 'chained > rawSaved', 'from_param > rawDisk', 'literal > rawSaved', 'local > rawSaved', 'targeted > rawSaved', 'wrapped > rawDisk', + ]); + }); + + it('links a typed emit in the frontend to a typed listener in Rust', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + 'use tauri_specta::Event;', + '#[derive(Clone, serde::Serialize, specta::Type, Event)]', + 'pub struct DemoEvent(String);', + 'fn wire(app: &tauri::AppHandle) { DemoEvent::listen(app, |event| { drop(event); }); }', + ].join('\n'), + 'src/a.ts': "import { events } from './bindings';\nexport function tell() { return events.demoEvent.emit('x'); }\n", + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows)).toEqual(['tell > wire']); + expect(rows[0]!.edge.metadata?.tier).toBe('client→server'); + expect(rows[0]!.edge.metadata?.event).toBe('demo-event'); + }); + + it('keeps the events of two apps apart', async () => { + const app = (name: string) => [ + 'use tauri_specta::Event;', + '#[derive(Clone, serde::Serialize, specta::Type, Event)]', + 'pub struct Ready;', + `fn ${name}_raw(app: tauri::AppHandle) { app.emit("tick", ()).unwrap(); }`, + `fn ${name}_typed(app: &tauri::AppHandle) { let _ = Ready.emit(app); }`, + ].join('\n'); + const front = (name: string) => [ + "import { listen } from '@tauri-apps/api/event';", + "import { events } from './bindings';", + `export function ${name}Tick() { return listen('tick', () => {}); }`, + `export function ${name}Ready() { return events.ready.listen(() => {}); }`, + ].join('\n'); + const { graph } = await index({ + 'apps/a/src-tauri/src/main.rs': app('a'), + 'apps/a/src/main.ts': front('a'), + 'apps/b/src-tauri/src/main.rs': app('b'), + 'apps/b/src/main.ts': front('b'), + }); + const rows = links(graph, 'tauri-event'); + expect(names(rows.filter((r) => r.edge.kind === 'calls'))).toEqual([ + 'a_raw > aTick', 'a_typed > aReady', 'b_raw > bTick', 'b_typed > bReady', + ]); + expect(rows.filter((r) => r.edge.kind === 'references').map((r) => `${r.from.name} ${r.to.filePath}`).sort()).toEqual([ + 'aReady apps/a/src-tauri/src/main.rs', 'bReady apps/b/src-tauri/src/main.rs', + ]); + }); + + it('skips an event too many functions emit to read as a flow', async () => { + const emitters = Array.from({ length: 20 }, (_, i) => `fn e${i}(a: tauri::AppHandle) { a.emit("busy", ()).unwrap(); }`); + const { graph } = await index({ + 'src-tauri/src/main.rs': ['use tauri::Emitter;', ...emitters].join('\n'), + 'src/a.ts': "import { listen } from '@tauri-apps/api/event';\nexport function on() { listen('busy', () => {}); }\n", + }); + expect(names(links(graph, 'tauri-event'))).toEqual([]); + }); +}); + +describe('How a Tauri hop reads', () => { + it('names the command and the event, with the wiring site', () => { + const edge = (metadata: Record): Edge => ({ source: 'a', target: 'b', kind: 'calls', provenance: 'heuristic', metadata }); + const command = describeSynthesizedHop(edge({ synthesizedBy: 'tauri-command', command: 'greet', registeredAt: 'src-tauri/src/lib.rs:6' })); + expect(command).toMatchObject({ summary: 'Tauri command `greet`', registeredAt: 'src-tauri/src/lib.rs:6' }); + const event = describeSynthesizedHop(edge({ synthesizedBy: 'tauri-event', event: 'saved', tier: 'server→client', registeredAt: 'src/a.ts:3' })); + expect(event).toMatchObject({ summary: 'Tauri event `saved`', detail: ' → frontend', registeredAt: 'src/a.ts:3' }); + }); + + it('carries a flow across the seam in codegraph_explore', async () => { + const { graph } = await index({ + 'src-tauri/src/lib.rs': [ + 'fn persist() {}', + '#[tauri::command]', + 'fn save_file() { persist(); }', + 'fn run() { tauri::Builder::default().invoke_handler(tauri::generate_handler![save_file]); }', + ].join('\n'), + 'src/a.ts': [ + 'import { invoke } from "@tauri-apps/api/core";', + 'export function onSaveClick() { return invoke("save_file"); }', + ].join('\n'), + }); + const handler = new ToolHandler(graph); + const res = await handler.execute('codegraph_explore', { query: 'onSaveClick save_file persist' }); + const text = res.content[0]!.text; + expect(text).toContain('Tauri command `save_file`'); + expect(text).toContain('src-tauri/src/lib.rs:4'); + }); +}); diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index 8224fcaef..5ab12a684 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 (raw + tauri-specta) | frontend `invoke('foo_bar')` / `commands.fooBar()` → Rust `#[tauri::command] fn foo_bar`; `emit` ↔ `listen` either way; a listener → `#[derive(Event)]` struct | S (cross-language, literal on both sides) | ✅ **tauri-synthesizer** (`tauriEdges`): `tauri-command` from the calling function to the function a `generate_handler!` / `collect_commands!` list registers (`registeredAt` = the list entry), per app folder, a tie links nothing; a macro-built list falls back to the app's declared commands; `#[cfg]` platform variants all link; `plugin:name\|cmd` goes to the plugin's crate; a project's own function that forwards its first parameter to `invoke` counts as `invoke`. `tauri-event` pairs emit and listen by wire name in any direction, tauri-specta's `Struct { … }.emit(&app)` and `events.struct.listen()` included. The pass starts from two indexed lookups, so a Rust + TS project with no Tauri pays 2 ms. Validated: pot-desktop S (raw, 37 edges, 20 of 22 commands with a caller; the other 2 have none in its frontend), clash-verge-rev M (typed, 93 of 93 commands, the same 93 by camelCase with `bindings.ts` removed), Cmdr L (typed and raw on a macro-built list, 1,588-file IPC subtree: 611 of 630 commands, 135 event hops, 90 listener → struct), GitButler (wrappers, `backend.invoke`: 33 of 35 literal call sites), Yaak (6 plugin commands; the rest goes through one `rpc` command). Controls lightningcss, express, ripgrep byte-identical. 🔬 runtime-built names, GitButler's `extraOptions: { command }` table, listening through a project's wrapper around `listen` | (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/db/queries.ts b/src/db/queries.ts index c91effda7..695905863 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1627,6 +1627,13 @@ export class QueryBuilder { return new Set(rows.map((r) => r.language)); } + /** Whether any function of `language` carries `decorator`: a project's marker for a framework it uses. */ + hasDecoratedFunction(language: Language, decorator: string): boolean { + return !!this.db.prepare( + `SELECT 1 FROM nodes WHERE kind = 'function' AND language = ? AND decorators LIKE ? LIMIT 1` + ).get(language, `%"${decorator}"%`); + } + /** * Get nodes by exact name match (uses idx_nodes_name index). * diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts index 0344b851e..582e09280 100644 --- a/src/graph/synthesized-hop.ts +++ b/src/graph/synthesized-hop.ts @@ -59,6 +59,12 @@ export function describeSynthesizedHop(edge: Edge | null | undefined): Synthesiz } case 'event-emitter': return hop(`event ${quoted(m.event, 'an event')}`); + case 'tauri-command': + return hop(`Tauri command ${quoted(m.command, 'a command')}`, " — the frontend's invoke onto its Rust handler"); + case 'tauri-event': { + const direction = m.tier === 'server→client' ? ' → frontend' : m.tier === 'client→server' ? ' → Rust' : ''; + return hop(`Tauri event ${quoted(m.event, 'an event')}`, direction); + } case 'react-render': return hop('React re-render via setState'); case 'jsx-render': diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 6975bdaaf..6c1cb0800 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -41,6 +41,7 @@ import { angularTemplateEdges } from './angular-template-synthesizer'; import { svelteKitLinkEdges, svelteKitPageComponentEdges } from './sveltekit-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { crossTierEdges, hasCrossTierPattern, hasTestRequestPattern, testRequestEdges } from './tier-synthesizer'; +import { hasTauriPattern, tauriEdges } from './tauri-synthesizer'; import { enclosingFn, makeLineAt } from './synth-utils'; import { resolveImportPath } from './import-resolver'; import { cppSpecializationArguments, crossesCodeBoundary, goRefQualification, isManualMockHiddenFrom, isManualMockPath, jsCodeBindsName } from './name-matcher'; @@ -5113,7 +5114,7 @@ export function hasSynthesisPattern(filePath: string, content: string): boolean // Every Go file: besides the declarations the interface passes read, a call // through a func field, or a function put in one, can sit in any of them. if (/\.go$/.test(filePath)) return true; - if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content)) return true; + if (hasCrossTierPattern(content) || hasTestRequestPattern(filePath, content) || hasTauriPattern(filePath, content)) return true; if (/\b(?:render|build|setState|defineStore|createStore|createApi|Store|href|sendEvent|sendEventWithName)\b|<\/|\/>/.test(content)) return true; // A wrapper renders the component it wraps (jsxEdges), in a file with no markup of its own too. if (WRAPPER_CALL_RE.test(content)) return true; @@ -5151,6 +5152,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [ // 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: 'tierEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => crossTierEdges(c, y) }, + // A Tauri frontend's `invoke('x')` onto the Rust command, and events across that seam. + { name: 'tauriEdges', gate: (has) => has('rust') && has('vue', 'svelte', ...JS_FAMILY), run: (q, c, y) => tauriEdges(q, c, y) }, // A Spring / Laravel test's request (`mockMvc.perform(post("/x"))`, // `$this->postJson('api/x')`) onto the route it reaches, so the handler // counts as tested. diff --git a/src/resolution/tauri-synthesizer.ts b/src/resolution/tauri-synthesizer.ts new file mode 100644 index 000000000..8ba607288 --- /dev/null +++ b/src/resolution/tauri-synthesizer.ts @@ -0,0 +1,1239 @@ +/** + * Tauri IPC — the hop between a desktop app's web frontend and its Rust core. + * + * A Tauri app is two programs in one window: the page calls + * `invoke('save_file', { path })` and the Rust `#[tauri::command] fn save_file` + * answers, while the core sends `app.emit("saved", …)` back to a page that wrote + * `listen('saved', …)`. Neither side names the other, and tree-sitter sees a + * string and a function in two languages. The string is the evidence, as it is + * for the cross-tier channels (`tier-synthesizer.ts`): this pass pairs the + * literals and draws the hop the runtime makes. + * + * - **`tauri-command`** — the function a frontend call is written in → the Rust + * function that answers it. A command answers only if a handler list registers + * it (`generate_handler![…]`, tauri-specta's `collect_commands![…]`), so an + * `invoke` that names an unregistered function stays unlinked, as it fails at + * run time. tauri-specta's generated client needs nothing of its own when it + * is in the index: `commands.saveFile()` already reaches `bindings.ts`, whose + * body is an aliased `invoke('save_file')`. When it is not (a gitignored + * bindings file), the call is read by its camelCase name. + * - **`tauri-event`** — an `emit` → the listeners of that event, in either + * language and between windows (`calls`), and a listener → the Rust struct + * deriving tauri-specta's `Event` (`references`), so a struct's callers list + * who subscribes. A typed `Struct { … }.emit(&app)` and an + * `events.struct.listen(…)` meet a raw `emit("struct")` / `listen('struct')` + * on the same wire name. + * + * A name is only as good as the app it is looked up in, so the table is per app: + * a monorepo's two `greet`s stay apart, and a tie is nothing. + */ + +import type { Edge, Language, Node } from '../types'; +import type { QueryBuilder } from '../db/queries'; +import type { ImportMapping, ResolutionContext } from './types'; +import type { MaybeYield } from './cooperative-yield'; +import { blankStringContents, stripCommentsForRegex } from './strip-comments'; +import { resolveImportPath } from './import-resolver'; +import { isTestPath } from '../search/query-utils'; +import { HOLE, readStringAt } from './frameworks/expo-router'; +import { FN_KINDS, enclosingFn, enclosingValue, makeLineAt } from './synth-utils'; +import { TIER_CLIENT_TO_SERVER, TIER_SERVER_TO_CLIENT } from './tier-synthesizer'; +import { jsCodeBindsName } from './name-matcher'; + +const FRONTEND_FILE = /\.(?:[cm]?[jt]sx?|vue|svelte)$/; +const FRONTEND_GATE = /invoke|listen|once|\bemit|\bcommands\b|\bevents\b|__TAURI/; +const RUST_GATE = /\btauri|generate_handler|collect_commands|\.\s*(?:emit|listen|once)/; + +/** One command's platform variants: more than this and the name is not a command's. */ +const MAX_VARIANTS = 6; + +/** Above this many emitters or listeners, an event is too generic to read as a flow. */ +const EVENT_FANOUT_CAP = 16; + +const API_ROOT = '@tauri-apps/api'; +const CORE_MODULES: ReadonlySet = new Set([`${API_ROOT}/core`, `${API_ROOT}/tauri`, API_ROOT]); +const EVENT_MODULE = `${API_ROOT}/event`; +/** Modules whose handles (`getCurrentWindow()`, `appWindow`, …) have their own `listen` / `once` / `emit`. */ +const HANDLE_MODULES: ReadonlySet = new Set([`${API_ROOT}/window`, `${API_ROOT}/webviewWindow`, `${API_ROOT}/webview`]); + +// ============================================================================= +// Reading source +// ============================================================================= + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const CLOSER: Readonly> = { '(': ')', '[': ']', '{': '}' }; + +/** Index of the bracket closing the one at `open`, over text with its strings blanked; -1 if none. */ +function closeBracket(code: string, open: number): number { + const stack = [CLOSER[code[open]!]!]; + for (let i = open + 1; i < code.length; i++) { + const c = code[i]!; + if (CLOSER[c]) stack.push(CLOSER[c]!); + else if (c === stack[stack.length - 1]) { + stack.pop(); + if (stack.length === 0) return i; + } + } + return -1; +} + +/** Index of the bracket opening the one at `close`, over text with its strings blanked; -1 if none. */ +function openBracket(code: string, close: number): number { + const opener = code[close] === ')' ? '(' : code[close] === ']' ? '[' : '{'; + let depth = 0; + for (let i = close; i >= 0; i--) { + const c = code[i]; + if (c === code[close]) depth++; + else if (c === opener && --depth === 0) return i; + } + return -1; +} + +/** The `[start, end)` spans of the arguments of the call whose `(` is at `open`, split at depth-0 commas. */ +function argumentSpans(code: string, open: number): Array<[number, number]> { + const close = closeBracket(code, open); + if (close < 0) return []; + const spans: Array<[number, number]> = []; + let start = open + 1; + for (let i = open + 1; i < close; i++) { + const c = code[i]!; + if (CLOSER[c]) { + const end = closeBracket(code, i); + if (end < 0) return spans; + i = end; + } else if (c === ',') { + spans.push([start, i]); + start = i + 1; + } + } + if (code.slice(start, close).trim()) spans.push([start, close]); + return spans; +} + +/** Index of the first non-space at or after `i`. */ +function skipSpace(s: string, i: number): number { + while (i < s.length && /\s/.test(s[i]!)) i++; + return i; +} + +/** Past the `<…>` type arguments at `at`, an arrow's `>` not closing them; `at` when there are none. */ +function skipTypeArguments(code: string, at: number): number { + if (code[at] !== '<') return at; + let depth = 0; + const limit = Math.min(code.length, at + 40_000); + for (let i = at; i < limit; i++) { + const c = code[i]; + if (c === '<') depth++; + else if (c === '>' && code[i - 1] !== '=' && --depth === 0) return skipSpace(code, i + 1); + } + return at; +} + +// ============================================================================= +// Names +// ============================================================================= + +/** The words of a Rust name, the way `heck` (tauri-specta's casing) splits them: `AIModelReady` → AI, Model, Ready. */ +function words(name: string): string[] { + return name.split('_').flatMap((part) => part.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+|[0-9]+/g) ?? []); +} + +/** `save_file` / `SaveFile` → `saveFile`. */ +function toCamel(name: string): string { + return words(name).map((w, i) => { + const lower = w.toLowerCase(); + return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }).join(''); +} + +/** `SaveFile` → `save-file`: an event struct's name on the wire. */ +function toKebab(name: string): string { + return words(name).map((w) => w.toLowerCase()).join('-'); +} + +// ============================================================================= +// The Rust side +// ============================================================================= + +interface Command { + /** One function, or one per platform: `#[cfg(target_os = "macos")] #[tauri::command] fn x` and its siblings. */ + nodes: Node[]; + /** Where it is wired in: `file:line`. */ + registeredAt: string; +} + +/** A key with several answers is null: a tie links nothing. */ +type CommandTable = Map; + +interface TauriApp { + /** The directory the app's code sits under; '' at the repository root. */ + root: string; + commands: CommandTable; + /** Every decorated function counts: a macro builds the handler list, so it can't be read. */ + openList: string | null; +} + +interface EventStruct { + node: Node; + /** The name on the wire. */ + event: string; + scope: string | null; +} + +interface Endpoint { + node: Node; + event: string; + file: string; + line: number; + column: number; + rust: boolean; + scope: string | null; +} + +interface RustFile { + file: string; + source: string; + /** Comments blanked, strings kept. */ + text: string; + /** Comments and string contents blanked. */ + code: string; + nodes: Node[]; + lineAt: (index: number) => number; + offsetOfLine: (line: number) => number; +} + +interface ListEntry { + /** The path as written, with no turbofish: `cmd::save_file`. */ + segments: string[]; + line: number; +} + +interface HandlerList { + file: string; + line: number; + entries: ListEntry[]; + /** A `$…` metavariable in the list: the names are the macro's to fill in. */ + opaque: boolean; + /** The plugin these commands are registered under, `plugin:|`. */ + plugin: string | null; +} + +const HANDLER_LIST = /\b(?:generate_handler|collect_commands)\s*!\s*[[({]/g; +const PLUGIN_HEAD = /\bBuilder\s*(?:::\s*<[^;()]*>\s*)?::\s*(?:new\s*\(\s*(?:"([^"]+)"|([A-Z][A-Z0-9_]*))|default\s*\()|\.\s*plugin_name\s*\(\s*(?:"([^"]+)"|([A-Z][A-Z0-9_]*))/g; + +/** Rust's own attributes and the usual tracing ones: none of them makes a function a command. */ +const PLAIN_ATTRIBUTE = /^(?:cfg|cfg_attr|doc|allow|expect|warn|deny|forbid|inline|cold|must_use|deprecated|track_caller|test|ignore|no_mangle|rustfmt|clippy|instrument|tracing|specta|async_trait)\b/; + +function readHandlerList(rust: RustFile, constants: ReadonlyMap, at: number, open: number): HandlerList | null { + const close = closeBracket(rust.code, open); + if (close < 0) return null; + const entries: ListEntry[] = []; + let opaque = false; + for (const [start, end] of argumentSpans(rust.code, open)) { + // A `#[cfg(…)]` on an entry is the entry's business, not part of its name. + const item = rust.code.slice(start, end).replace(/#\s*\[[^\]]*\]/g, ' ').trim(); + if (item.includes('$')) { + opaque = true; + continue; + } + const path = item.replace(/::\s*<[\s\S]*>$/, '').replace(/\s+/g, ''); + if (!/^[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*$/.test(path)) continue; + const segments = path.split('::'); + const named = rust.code.indexOf(segments[segments.length - 1]!, end - item.length); + entries.push({ segments, line: rust.lineAt(named >= start && named < end ? named : start) }); + } + return { file: rust.file, line: rust.lineAt(at), entries, opaque, plugin: pluginOf(rust, constants, at) }; +} + +/** The plugin a handler list sits in: the nearest builder before it, when it names one. */ +function pluginOf(rust: RustFile, constants: ReadonlyMap, at: number): string | null { + let plugin: string | null = null; + PLUGIN_HEAD.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = PLUGIN_HEAD.exec(rust.text)) !== null && m.index < at) { + const literal = m[1] ?? m[3]; + const constant = m[2] ?? m[4]; + plugin = literal ?? (constant ? constants.get(constant) ?? null : null); + } + return plugin; +} + +/** The attributes written on and directly above the line a function starts on. */ +function attributesAbove(lines: readonly string[], startLine: number): string[] { + const out: string[] = [lines[startLine - 1]?.trim() ?? '']; + for (let i = startLine - 2; i >= 0; i--) { + const line = lines[i]!.trim(); + if (!line || line.startsWith('//')) continue; + if (line.startsWith('#[') || line.endsWith(']') || line.endsWith(',') || line.endsWith('(')) { + out.push(line); + if (out.length > 16) break; + continue; + } + break; + } + return out; +} + +/** + * Whether an attribute made this function a command. `generate_handler!` reads + * what an attribute macro generates, so a plain function can't be registered: + * a namesake of a list's entry that has none is some other function, the entry + * being an external crate's. `#[tauri::command]` is on the node; `#[command]` + * (with `use tauri::command`) and a project's own macro around it + * (GitButler's `#[but_api]`) are read from the lines above it. + */ +function isCommandFunction(node: Node, ctx: ResolutionContext): boolean { + return attributeNames(node, ctx).some((name) => !PLAIN_ATTRIBUTE.test(name)); +} + +/** The stricter reading, for a list nothing can be checked against: Tauri's own attribute. */ +function isDeclaredCommand(node: Node, ctx: ResolutionContext): boolean { + return attributeNames(node, ctx).some((name) => name === 'command' || name === 'tauri::command'); +} + +function attributeNames(node: Node, ctx: ResolutionContext): string[] { + if (node.decorators?.includes('tauri::command')) return ['tauri::command']; + const lines = ctx.getFileLines?.(node.filePath) ?? ctx.readFile(node.filePath)?.split('\n'); + if (!lines) return []; + return attributesAbove(lines, node.startLine).flatMap((line) => { + const name = /^#\[\s*([A-Za-z_][\w:]*)/.exec(line)?.[1]; + return name ? [name] : []; + }); +} + +/** + * The names a Rust file answers to in a path: its crate and its modules. + * `crates/my-api/src/cmd/network.rs` → my_api, cmd, network. + */ +function moduleNames(file: string): Set { + const parts = file.replace(/\.rs$/, '').split('/'); + const src = parts.lastIndexOf('src'); + const names = src >= 0 ? parts.slice(Math.max(0, src - 1)).filter((_, i) => i !== 1 || src === 0) : parts.slice(-1); + return new Set(names.map((n) => n.replace(/-/g, '_'))); +} + +/** How many segments of a registered path name `node`'s crate or modules: a module re-exported from its parent still counts its parent. */ +function pathScore(segments: readonly string[], node: Node): number { + const names = moduleNames(node.filePath); + return segments.slice(0, -1).filter((s) => names.has(s)).length; +} + +/** Where a crate starts: the nearest directory above `file` holding a `Cargo.toml`. */ +function crateRoot(file: string, ctx: ResolutionContext, memo: Map): string { + const parts = file.split('/').slice(0, -1); + const seen: string[] = []; + for (let n = parts.length; n >= 0; n--) { + const dir = parts.slice(0, n).join('/'); + const known = memo.get(dir); + if (known !== undefined) { + for (const d of seen) memo.set(d, known); + return known; + } + seen.push(dir); + if (ctx.fileExists(dir ? `${dir}/Cargo.toml` : 'Cargo.toml')) { + for (const d of seen) memo.set(d, dir); + return dir; + } + } + for (const d of seen) memo.set(d, ''); + return ''; +} + +/** The directory above a `src-tauri` folder, the frontend's root by convention; null outside one. */ +function conventionalRoot(file: string): string | null { + const at = file.startsWith('src-tauri/') ? 0 : file.indexOf('/src-tauri/'); + if (at < 0) return null; + return file.slice(0, at); +} + +function isWithin(file: string, root: string): boolean { + return root === '' || file.startsWith(`${root}/`); +} + +function addCommand(table: CommandTable, key: string, command: Command): void { + const known = table.get(key); + if (known === undefined) table.set(key, command); + else if (known !== null && !sameNodes(known.nodes, command.nodes)) table.set(key, null); +} + +function sameNodes(a: readonly Node[], b: readonly Node[]): boolean { + return a.length === b.length && a.every((n) => b.some((m) => m.id === n.id)); +} + +/** + * The functions a list entry names. A name a list holds once can still be + * several functions, a platform's each behind its own `#[cfg]` or module, and + * every one of them is that command on its platform; a path that tells them + * apart decides. Too many is a name, not a command. + */ +function commandsFor(entry: ListEntry, list: HandlerList, crates: Map, ctx: ResolutionContext): Node[] { + const name = entry.segments[entry.segments.length - 1]!; + const named = ctx.getNodesByName(name).filter((n) => n.language === 'rust' && n.kind === 'function' && !isTestPath(n.filePath)); + let candidates = named.filter((n) => isCommandFunction(n, ctx)); + if (candidates.length > 1) { + const scope = (file: string): string => conventionalRoot(file) ?? crateRoot(file, ctx, crates); + const own = scope(list.file); + const sameScope = candidates.filter((n) => scope(n.filePath) === own); + if (sameScope.length > 0) candidates = sameScope; + } + if (candidates.length > 1) { + const scores = candidates.map((n) => pathScore(entry.segments, n)); + const best = Math.max(...scores); + candidates = candidates.filter((_, i) => scores[i] === best); + } + return candidates.length <= MAX_VARIANTS ? candidates : []; +} + +interface RustSide { + apps: TauriApp[]; + /** `plugin:|` → its command, wherever the plugin's crate sits. */ + plugins: CommandTable; + /** Whole-repository view, for a frontend no app's folder holds. */ + anyApp: CommandTable; + /** Event structs by wire name, by type name, and by the camelCase name of their `events.x`. */ + byEvent: Map; + byType: Map; + byCamel: Map; + emitters: Endpoint[]; + listeners: Endpoint[]; + /** The app folder `file` belongs to by convention; null outside every one, which pairs with any. */ + scopeOf: (file: string) => string | null; +} + +function sameScope(a: string | null, b: string | null): boolean { + return a === null || b === null || a === b; +} + +/** The struct of the file's own app, else one no app holds. */ +function structInScope(found: readonly EventStruct[] | undefined, scope: string | null): EventStruct | null { + return found?.find((s) => s.scope === scope) ?? found?.find((s) => sameScope(s.scope, scope)) ?? null; +} + +function addStruct(index: Map, key: string, struct: EventStruct): void { + const known = index.get(key); + if (known) known.push(struct); + else index.set(key, [struct]); +} + +/** `const NAME: &str = "value";` across the Rust files; a name given two values is null. */ +const STR_CONST = /\b(?:const|static)\s+([A-Z][A-Z0-9_]*)\s*:\s*&\s*(?:'static\s+)?str\s*=\s*"([^"\n]*)"/g; + +const EVENT_DERIVE = /#\s*\[\s*derive\s*\(([^)]*)\)\s*\]/g; +const EVENT_ITEM = /^(?:\s*#\s*\[(?:[^\]]|\][^\s])*\])*\s*(?:pub(?:\s*\([^)]*\))?\s+)?(?:struct|enum)\s+([A-Za-z_]\w*)/; +const EVENT_RENAME = /#\s*\[\s*tauri_specta\s*\(\s*event_name\s*=\s*"([^"]+)"\s*\)\s*\]/; + +const EMIT_CALL = /\.\s*(emit(?:_str|_all|_to|_str_to|_filter|_str_filter|_and_trigger)?)\s*(?:::\s*<[^()]*>\s*)?\(/g; +const LISTEN_CALL = /\.\s*(?:listen|once)(?:_any|_global)?\s*(?:::\s*<[^()]*>\s*)?\(/g; +/** tauri-specta's typed form: `Saved::listen(app, |e| …)`. */ +const TYPED_LISTEN_CALL = /\b([A-Z]\w*)\s*::\s*(?:listen|once)(?:_any)?\s*\(/g; + +/** The string a Rust argument is: a literal, or a `&str` constant. */ +function rustStringAt(rust: RustFile, span: [number, number] | undefined, constants: ReadonlyMap): string | null { + if (!span) return null; + const raw = rust.text.slice(span[0], span[1]).trim(); + const literal = /^&?"([^"\\]*)"$/.exec(raw); + if (literal) return literal[1]!; + const name = /^(?:[a-z_]\w*::)*([A-Z][A-Z0-9_]*)$/.exec(raw)?.[1]; + return name ? constants.get(name) ?? null : null; +} + +/** The last segment of the path ending at `end` that names a type: `events::Saved` → `Saved`. */ +function typeAt(code: string, end: number): string | null { + const head = /(?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*\s*$/.exec(code.slice(Math.max(0, end - 200), end + 1)); + if (!head) return null; + const segments = head[0].split('::').map((s) => s.trim()); + for (let i = segments.length - 1; i >= 0; i--) if (/^[A-Z]/.test(segments[i]!)) return segments[i]!; + return null; +} + +/** The type `x` was given in the function around `at`: `let x = Saved { … }`, `let x: Saved`, `x: &Saved`. */ +function localType(code: string, name: string, from: number, at: number): string | null { + const n = escapeRe(name); + const pattern = new RegExp( + `\\blet\\s+(?:mut\\s+)?${n}\\s*(?::\\s*&?\\s*(?:[a-z_]\\w*::)*([A-Z]\\w*)[^=;]*)?=\\s*&?\\s*(?:[a-z_]\\w*::)*([A-Z]\\w*)|\\b${n}\\s*:\\s*&?\\s*(?:[a-z_]\\w*::)*([A-Z]\\w*)`, + 'g' + ); + const body = code.slice(from, at); + let found: string | null = null; + let m: RegExpExecArray | null; + while ((m = pattern.exec(body)) !== null) found = m[1] ?? m[2] ?? m[3] ?? found; + return found; +} + +/** + * The type a `.emit(…)` is called on, read back from its `.`: + * `Saved`, `Saved { … }`, `(Saved { … })`, `Saved(…)`, `Saved::new(…)`, or a + * local one of those was put in. + */ +function receiverType(code: string, dot: number, functionStart: number): string | null { + let i = dot - 1; + while (i >= 0 && /\s/.test(code[i]!)) i--; + if (i < 0) return null; + const c = code[i]!; + if (c === ')' || c === '}') { + const open = openBracket(code, i); + if (open < 0) return null; + let before = open - 1; + while (before >= 0 && /\s/.test(code[before]!)) before--; + if (before >= 0 && /\w/.test(code[before]!)) return typeAt(code, before); + // `(Saved { … })`: the parentheses only wrap it. + return /^\s*&?\s*(?:[a-z_]\w*::)*([A-Z]\w*)/.exec(code.slice(open + 1, i))?.[1] ?? null; + } + if (!/\w/.test(c)) return null; + const word = /[A-Za-z_]\w*$/.exec(code.slice(Math.max(0, i - 120), i + 1))?.[0]; + if (!word) return null; + if (/^[A-Z]/.test(word)) return word; + return localType(code, word, functionStart, i); +} + +function ownerOf(nodes: readonly Node[], line: number, column: number): Node | null { + let best: Node | null = null; + for (const n of nodes) { + if (!FN_KINDS.has(n.kind) || n.startLine > line || n.endLine < line) continue; + if ((n.startLine === line && n.startColumn > column) || (n.endLine === line && n.endColumn <= column)) continue; + if (!best || n.startLine > best.startLine || (n.startLine === best.startLine && n.startColumn >= best.startColumn)) best = n; + } + return best ?? enclosingValue(nodes, line) ?? nodes.find((n) => n.kind === 'file') ?? null; +} + +function columnAt(text: string, index: number): number { + return index - (text.lastIndexOf('\n', index - 1) + 1); +} + +/** The offset a 1-based line starts at, from an index of line starts built on first use. */ +function makeOffsetOfLine(source: string): (line: number) => number { + let starts: number[] | null = null; + return (line: number) => { + if (!starts) { + starts = [0]; + for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) starts.push(i + 1); + } + return starts[Math.min(line, starts.length) - 1] ?? 0; + }; +} + +function readRustFile(file: string, source: string, ctx: ResolutionContext): RustFile { + const text = stripCommentsForRegex(source, 'rust'); + return { + file, source, text, code: blankStringContents(text), nodes: ctx.getNodesInFile(file), + lineAt: makeLineAt(source, 1), offsetOfLine: makeOffsetOfLine(source), + }; +} + +function readEventStructs(rust: RustFile, out: RustSide): void { + if (!rust.source.includes('tauri_specta')) return; + EVENT_DERIVE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = EVENT_DERIVE.exec(rust.code)) !== null) { + if (!/\bEvent\b/.test(m[1]!)) continue; + const item = EVENT_ITEM.exec(rust.code.slice(m.index + m[0].length, m.index + m[0].length + 4000)); + if (!item) continue; + const name = item[1]!; + const node = rust.nodes.find((n) => n.name === name && (n.kind === 'struct' || n.kind === 'enum')); + if (!node) continue; + const span = rust.text.slice(Math.max(0, m.index - 400), m.index + m[0].length + item[0].length); + const event = EVENT_RENAME.exec(span.slice(span.lastIndexOf('}') + 1))?.[1] ?? toKebab(name); + const struct: EventStruct = { node, event, scope: out.scopeOf(rust.file) }; + addStruct(out.byEvent, event, struct); + addStruct(out.byType, name, struct); + addStruct(out.byCamel, toCamel(name), struct); + } +} + +function readEmitters(rust: RustFile, constants: ReadonlyMap, out: RustSide): void { + EMIT_CALL.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = EMIT_CALL.exec(rust.code)) !== null) { + const open = m.index + m[0].length - 1; + const spans = argumentSpans(rust.code, open); + const line = rust.lineAt(m.index); + const column = columnAt(rust.source, m.index); + const owner = enclosingFn(rust.nodes, line); + if (!owner) continue; + // `app.emit_to("main", "saved", payload)` names its target first, and a typed + // `Saved { … }.emit_to(&app, "main")` has no event among its arguments. + const targeted = /_to$/.test(m[1]!); + let event = targeted && spans.length < 3 ? null : rustStringAt(rust, spans[targeted ? 1 : 0], constants); + if (event === null) { + const type = receiverType(rust.code, m.index, rust.offsetOfLine(owner.startLine)); + event = type ? structInScope(out.byType.get(type), out.scopeOf(rust.file))?.event ?? null : null; + } + if (event !== null) out.emitters.push({ node: owner, event, file: rust.file, line, column, rust: true, scope: out.scopeOf(rust.file) }); + } +} + +function readListeners(rust: RustFile, constants: ReadonlyMap, out: RustSide): void { + LISTEN_CALL.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = LISTEN_CALL.exec(rust.code)) !== null) { + const open = m.index + m[0].length - 1; + const spans = argumentSpans(rust.code, open); + const event = rustStringAt(rust, spans[0], constants); + if (event === null) continue; + const line = rust.lineAt(m.index); + const owner = enclosingFn(rust.nodes, line); + if (owner) out.listeners.push({ node: owner, event, file: rust.file, line, column: columnAt(rust.source, m.index), rust: true, scope: out.scopeOf(rust.file) }); + } + TYPED_LISTEN_CALL.lastIndex = 0; + while ((m = TYPED_LISTEN_CALL.exec(rust.code)) !== null) { + const event = structInScope(out.byType.get(m[1]!), out.scopeOf(rust.file))?.event; + const line = rust.lineAt(m.index); + const owner = event ? enclosingFn(rust.nodes, line) : null; + if (event && owner) out.listeners.push({ node: owner, event, file: rust.file, line, column: columnAt(rust.source, m.index), rust: true, scope: out.scopeOf(rust.file) }); + } +} + +async function readRustSide(ctx: ResolutionContext, onYield: MaybeYield): Promise { + const files: RustFile[] = []; + const conventional = new Set(); + const constants = new Map(); + let scanned = 0; + for (const file of ctx.getAllFiles()) { + if (!file.endsWith('.rs')) continue; + const root = conventionalRoot(file); + if (root !== null) conventional.add(root); + if (isTestPath(file)) continue; + if ((++scanned & 63) === 0) await onYield(); + const source = ctx.readFile(file); + if (!source || !RUST_GATE.test(source)) continue; + const rust = readRustFile(file, source, ctx); + files.push(rust); + STR_CONST.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = STR_CONST.exec(rust.text)) !== null) { + const known = constants.get(m[1]!); + constants.set(m[1]!, known === undefined || known === m[2] ? m[2]! : null); + } + } + + const roots = [...conventional].sort((a, b) => b.length - a.length); + const side: RustSide = { + apps: [], plugins: new Map(), anyApp: new Map(), byEvent: new Map(), byType: new Map(), byCamel: new Map(), emitters: [], listeners: [], + scopeOf: (file) => roots.find((root) => isWithin(file, root)) ?? null, + }; + const apps = new Map(); + const appAt = (root: string): TauriApp => { + let app = apps.get(root); + if (!app) { + app = { root, commands: new Map(), openList: null }; + apps.set(root, app); + } + return app; + }; + for (const root of conventional) appAt(root); + + const crates = new Map(); + for (const rust of files) { + await onYield(); + readEventStructs(rust, side); + HANDLER_LIST.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = HANDLER_LIST.exec(rust.code)) !== null) { + const list = readHandlerList(rust, constants, m.index, m.index + m[0].length - 1); + if (!list) continue; + const app = appAt(conventionalRoot(rust.file) ?? crateRoot(rust.file, ctx, crates)); + if (list.opaque) app.openList = `${list.file}:${list.line}`; + for (const entry of list.entries) { + const nodes = commandsFor(entry, list, crates, ctx); + if (nodes.length === 0) continue; + const command: Command = { nodes, registeredAt: `${list.file}:${entry.line}` }; + const name = entry.segments[entry.segments.length - 1]!; + if (list.plugin) addCommand(side.plugins, `plugin:${list.plugin}|${name}`, command); + else addCommand(app.commands, name, command); + } + } + } + + // A list a macro builds names nothing here, so what the app declares answers. + for (const app of apps.values()) { + if (app.openList === null) continue; + const declared = new Map(); + for (const rust of files) { + if (!isWithin(rust.file, app.root)) continue; + for (const n of rust.nodes) { + if (n.kind !== 'function' || !isDeclaredCommand(n, ctx)) continue; + const same = declared.get(n.name); + if (same) same.push(n); + else declared.set(n.name, [n]); + } + } + for (const [name, nodes] of declared) { + if (nodes.length <= MAX_VARIANTS) addCommand(app.commands, name, { nodes, registeredAt: app.openList }); + } + } + + // Emit sites come after every struct is known: a type can be used before its + // file comes up. + for (const rust of files) { + await onYield(); + readEmitters(rust, constants, side); + readListeners(rust, constants, side); + } + + side.apps = [...apps.values()].sort((a, b) => b.root.length - a.root.length); + for (const app of side.apps) { + for (const [key, command] of app.commands) { + if (command === null) side.anyApp.set(key, null); + else addCommand(side.anyApp, key, command); + } + } + return side; +} + +/** The deepest app whose folder holds `file`; a frontend beside its crate looks across every app. */ +function tableFor(file: string, side: RustSide): CommandTable { + return side.apps.find((app) => isWithin(file, app.root) && (app.root !== '' || side.apps.length === 1 || app.commands.size > 0))?.commands + ?? side.anyApp; +} + +// ============================================================================= +// The frontend +// ============================================================================= + +interface FrontFile { + file: string; + /** Comments blanked, strings kept. */ + text: string; + /** Comments and string contents blanked. */ + code: string; + nodes: Node[]; + lineAt: (index: number) => number; + offsetOfLine: (line: number) => number; +} + +interface FrontScan { + front: FrontFile; + imports: readonly ImportMapping[]; + language: Language; + bindings: FrontBindings; +} + +/** A project function that takes a command as its first argument and hands it to `invoke`. */ +interface Forwarder { + name: string; + file: string; +} + +interface CallSite { + /** Offset of the callee's first character. */ + at: number; + /** Offset of its `(`. */ + open: number; +} + +/** Every call of the callee `pattern` matches, from where the match ends; type arguments are skipped. */ +function callsOf(front: FrontFile, pattern: RegExp): CallSite[] { + const sites: CallSite[] = []; + pattern.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = pattern.exec(front.code)) !== null) { + const open = skipTypeArguments(front.code, skipSpace(front.code, m.index + m[0].length)); + if (front.code[open] === '(') sites.push({ at: m.index, open }); + } + return sites; +} + +/** What a JS argument is known to say: a literal with no holes, or a same-file constant holding one. */ +function jsStringAt(front: FrontFile, span: [number, number] | undefined): string | null { + if (!span) return null; + const at = skipSpace(front.text, span[0]); + const c = front.text[at]; + if (c === '"' || c === "'" || c === '`') { + const value = readStringAt(front.text, at); + if (value === null || value.includes(HOLE)) return null; + // The whole argument, not the head of a concatenation. + return front.text.slice(at, span[1]).trim().length === closedLength(front.text, at) ? value : null; + } + const name = /^[A-Za-z_$][\w$]*$/.exec(front.text.slice(span[0], span[1]).trim())?.[0]; + if (!name) return null; + const constant = new RegExp(`\\bconst\\s+${escapeRe(name)}\\s*(?::[^=\\n]*)?=\\s*(['"\`])([^'"\`\\n$]*)\\1`).exec(front.text); + if (!constant) return null; + // Another function's local is not this argument. + const declaredIn = ownerAt(front, constant.index); + const local = declaredIn?.kind === 'function' || declaredIn?.kind === 'method'; + return local && declaredIn !== ownerAt(front, span[0]) ? null : constant[2]!; +} + +function ownerAt(front: FrontFile, offset: number): Node | null { + return ownerOf(front.nodes, front.lineAt(offset), columnAt(front.text, offset)); +} + +/** The length of the string literal at `at`, its quotes included. */ +function closedLength(text: string, at: number): number { + const q = text[at]; + for (let i = at + 1; i < text.length; i++) { + if (text[i] === '\\') i++; + else if (text[i] === q) return i - at + 1; + } + return text.length - at; +} + +/** A relative or aliased specifier, never a package from npm: a project's own bindings file. */ +const PROJECT_SPECIFIER = /^(?:\.{1,2}\/|[~@]\/|#\/|[#$]\w)/; + +interface FrontBindings { + /** Local names of Tauri's `invoke`. */ + invoke: Set; + /** Local names of a namespace holding `invoke`: `core.invoke(…)`. */ + invokeHolders: Set; + listen: Set; + emit: Set; + /** The names in `emit` that are `emitTo(target, 'x', payload)`. */ + emitTo: Set; + /** Names bound by `const { invoke } = await import(…)`: a local that is the binding, not a shadow of it. */ + dynamic: Set; + /** Local names of a namespace holding `listen` / `once` / `emit`: `event.listen(…)`. */ + eventHolders: Set; + /** The file holds a window or webview handle: `appWindow.listen('x', …)`. */ + handles: boolean; + /** tauri-specta's `commands`, its module not in the index. */ + typedCommands: Set; + /** tauri-specta's `events`. */ + typedEvents: Set; +} + +const GLOBAL_API = String.raw`(?:window\s*\.\s*)?__TAURI(?:_INTERNALS)?__`; +const GLOBAL_INVOKE = new RegExp(String.raw`(? p.trim()); + if (!name) continue; + dynamic.push({ localName: local || name, exportedName: name, source: m[2]!, isDefault: false, isNamespace: false }); + b.dynamic.add(local || name); + } + } + for (const m of [...imports, ...dynamic]) { + if (m.source === API_ROOT && m.exportedName === 'tauri') b.invokeHolders.add(m.localName); + else if (m.source === API_ROOT && m.exportedName === 'event') b.eventHolders.add(m.localName); + else if (CORE_MODULES.has(m.source)) { + if (m.isNamespace) b.invokeHolders.add(m.localName); + else if (m.exportedName === 'invoke') b.invoke.add(m.localName); + } else if (m.source === EVENT_MODULE) { + if (m.isNamespace) b.eventHolders.add(m.localName); + else if (m.exportedName === 'listen' || m.exportedName === 'once') b.listen.add(m.localName); + else if (m.exportedName === 'emit' || m.exportedName === 'emitTo') { + b.emit.add(m.localName); + if (m.exportedName === 'emitTo') b.emitTo.add(m.localName); + } + } else if (HANDLE_MODULES.has(m.source)) b.handles = true; + else if (m.exportedName === 'commands') { + if (PROJECT_SPECIFIER.test(m.source) && resolveImportPath(m.source, front.file, language, ctx) === null) b.typedCommands.add(m.localName); + } else if (m.exportedName === 'events') { + if (PROJECT_SPECIFIER.test(m.source) || resolveImportPath(m.source, front.file, language, ctx) !== null) b.typedEvents.add(m.localName); + } + } + GLOBAL_INVOKE_BINDING.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = GLOBAL_INVOKE_BINDING.exec(front.code)) !== null) b.invoke.add(m[1] ?? m[2] ?? 'invoke'); + GLOBAL_EVENT_BINDING.lastIndex = 0; + while ((m = GLOBAL_EVENT_BINDING.exec(front.code)) !== null) { + for (const part of m[1]!.split(',')) { + const [name, local] = part.split(':').map((s) => s.trim()); + if (name === 'listen' || name === 'once') b.listen.add(local || name); + else if (name === 'emit' || name === 'emitTo') { + b.emit.add(local || name); + if (name === 'emitTo') b.emitTo.add(local || name); + } + } + } + return b; +} + +/** Whether a local or parameter hides the imported name at the call. */ +function shadowed(name: string, owner: Node, front: FrontFile, line: number, column: number, ctx: ResolutionContext): boolean { + if (owner.kind !== 'function' && owner.kind !== 'method') return false; + if (jsCodeBindsName(name, owner, front.file, line, ctx)) return true; + // Destructured, which that check leaves alone: `const { invoke } = api`, `({ invoke }) => …`. + const before = front.code.slice(front.offsetOfLine(owner.startLine) + owner.startColumn, front.offsetOfLine(line) + column); + const n = escapeRe(name); + return new RegExp(`(?:\\b(?:const|let|var)\\s*|\\(\\s*)[{[][^=;(){}]*?(? n.name === name && FN_KINDS.has(n.kind)); + if (here) return here; + const imported = imports.find((m) => m.localName === name); + if (!imported) return owner; + const path = resolveImportPath(imported.source, front.file, language, ctx); + if (!path) return owner; + return ctx.getNodesInFile(path).find((n) => n.name === imported.exportedName && FN_KINDS.has(n.kind)) ?? owner; +} + +interface Collected { + edges: Edge[]; + seen: Set; + emitters: Endpoint[]; + listeners: Endpoint[]; + /** The listening function → the event struct, once per pair. */ + subscribed: Map; +} + +function pushCommand(out: Collected, owner: Node, key: string, command: Command, callee: string, line: number, column: number): void { + for (const target of command.nodes) { + const pair = `${owner.id}>${target.id}`; + if (out.seen.has(pair)) continue; + out.seen.add(pair); + out.edges.push({ + source: owner.id, + target: target.id, + kind: 'calls', + line, + column, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'tauri-command', + channel: 'ipc', + tier: TIER_CLIENT_TO_SERVER, + callee, + command: key, + registeredAt: command.registeredAt, + }, + }); + } +} + +/** A callee's bare name, not a member of something else. */ +function bareCallee(name: string): RegExp { + return new RegExp(`(?(); + const forwardersIn = (scan: FrontScan): Forwarder[] => { + const here = new Map(); + const callees: RegExp[] = [ + ...[...scan.bindings.invoke].map(bareCallee), + ...[...scan.bindings.invokeHolders].map((h) => new RegExp(`(?') continue; + if (firstParameter(scan.front, owner) === argument) here.set(owner.name, { name: owner.name, file: scan.front.file }); + } + } + return [...here.values()]; + }; + const aliasesOf = (forwarder: Forwarder, scan: FrontScan): void => { + const alias = new RegExp(`(?:^|[\\s;{])(?:(?:public|private|protected|readonly|static)\\s+)*([A-Za-z_$][\\w$]*)\\s*(?::[^=;\\n]*)?=\\s*${escapeRe(forwarder.name)}\\s*[;\\n]`, 'g'); + for (const m of scan.front.code.matchAll(alias)) { + if (!found.has(m[1]!)) found.set(m[1]!, { name: m[1]!, file: forwarder.file }); + } + }; + // A wrapper of a wrapper: a few rounds, since nobody writes a tall stack. + for (let round = 0; round < 3; round++) { + const before = found.size; + for (const scan of scans) { + for (const forwarder of forwardersIn(scan)) if (!found.has(forwarder.name)) found.set(forwarder.name, forwarder); + for (const forwarder of [...found.values()]) if (forwarder.file === scan.front.file) aliasesOf(forwarder, scan); + } + if (found.size === before) break; + } + return [...found.values()]; +} + +/** The name a forwarder has in this file: its own, or the one an import gives it. */ +function localForwarder(forwarder: Forwarder, scan: FrontScan, ctx: ResolutionContext): string | null { + if (forwarder.file === scan.front.file) return forwarder.name; + const imported = scan.imports.find((m) => m.exportedName === forwarder.name); + if (!imported) return null; + return resolveImportPath(imported.source, scan.front.file, scan.language, ctx) !== null ? imported.localName : null; +} + +function readFrontend(scan: FrontScan, forwarders: readonly Forwarder[], side: RustSide, ctx: ResolutionContext, out: Collected): void { + const { front, imports, language, bindings: b } = scan; + const table = tableFor(front.file, side); + const scope = side.scopeOf(front.file); + + /** A local or parameter that hides `binding` where the call is. */ + const hidden = (binding: string | null, at: { owner: Node; line: number; column: number }): boolean => + binding !== null && !b.dynamic.has(binding) && shadowed(binding, at.owner, front, at.line, at.column, ctx); + const site = (call: CallSite): { owner: Node; line: number; column: number } | null => { + const line = front.lineAt(call.at); + const column = columnAt(front.text, call.at); + const owner = ownerOf(front.nodes, line, column); + return owner ? { owner, line, column } : null; + }; + const command = (key: string): Command | null => { + const plugin = key.startsWith('plugin:'); + return (plugin ? side.plugins.get(key) : table.get(key)) ?? null; + }; + + const invokeCall = (call: CallSite, binding: string | null, callee: string): void => { + const key = jsStringAt(front, argumentSpans(front.code, call.open)[0]); + if (key === null) return; + const target = command(key); + if (!target) return; + const at = site(call); + if (!at || hidden(binding, at)) return; + pushCommand(out, at.owner, key, target, callee, at.line, at.column); + }; + + for (const name of b.invoke) { + for (const call of callsOf(front, bareCallee(name))) invokeCall(call, name, name); + } + for (const holder of b.invokeHolders) { + for (const call of callsOf(front, new RegExp(`(?(); + for (const key of table.keys()) camel.set(toCamel(key), key); + const pattern = new RegExp(`(? { + const spans = argumentSpans(front.code, call.open); + const name = event ?? jsStringAt(front, spans[0]); + if (name === null || name.startsWith('tauri://')) return; + const at = site(call); + if (!at || hidden(binding, at)) return; + out.listeners.push({ + node: handlerOf(front, spans[handlerIndex], imports, language, at.owner, ctx), + event: name, file: front.file, line: at.line, column: at.column, rust: false, scope, + }); + const struct = structInScope(side.byEvent.get(name), scope); + if (struct) { + const key = `${at.owner.id}>${struct.node.id}`; + if (!out.subscribed.has(key)) out.subscribed.set(key, { owner: at.owner, struct, line: at.line, column: at.column }); + } + }; + const emitCall = (call: CallSite, binding: string | null, event: string | null, eventIndex: number): void => { + const name = event ?? jsStringAt(front, argumentSpans(front.code, call.open)[eventIndex]); + if (name === null) return; + const at = site(call); + if (!at || hidden(binding, at)) return; + out.emitters.push({ node: at.owner, event: name, file: front.file, line: at.line, column: at.column, rust: false, scope }); + }; + + for (const name of b.listen) { + for (const call of callsOf(front, bareCallee(name))) listenCall(call, name, null, 1); + } + for (const name of b.emit) { + for (const call of callsOf(front, bareCallee(name))) emitCall(call, name, null, b.emitTo.has(name) ? 1 : 0); + } + for (const holder of b.eventHolders) { + for (const call of callsOf(front, new RegExp(`(?(); + for (const e of emitters) { + const list = emitting.get(e.event); + if (list) list.push(e); + else emitting.set(e.event, [e]); + } + const listening = new Map(); + for (const l of listeners) { + const list = listening.get(l.event); + if (list) list.push(l); + else listening.set(l.event, [l]); + } + for (const [event, from] of emitting) { + const to = listening.get(event); + if (!to || distinctNodes(from) > EVENT_FANOUT_CAP || distinctNodes(to) > EVENT_FANOUT_CAP) continue; + for (const e of from) { + for (const l of to) { + if (e.node.id === l.node.id || !sameScope(e.scope, l.scope)) continue; + const pair = `${e.node.id}>${l.node.id}`; + if (out.seen.has(pair)) continue; + out.seen.add(pair); + const tier = e.rust === l.rust ? undefined : e.rust ? TIER_SERVER_TO_CLIENT : TIER_CLIENT_TO_SERVER; + out.edges.push({ + source: e.node.id, + target: l.node.id, + kind: 'calls', + line: e.line, + column: e.column, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'tauri-event', + channel: 'event', + ...(tier ? { tier } : {}), + event, + registeredAt: `${l.file}:${l.line}`, + }, + }); + } + } + } +} + +function distinctNodes(ends: readonly Endpoint[]): number { + return new Set(ends.map((e) => e.node.id)).size; +} + +/** + * Whether this project has a Tauri core at all, from two indexed lookups: a + * Rust `use tauri…` is an import node, and a `#[tauri::command]` is a decorator. + * A Rust + TypeScript project that has neither pays for nothing else here. + */ +function hasTauriCore(queries: QueryBuilder, ctx: ResolutionContext): boolean { + return ['tauri', 'tauri_specta'].some((name) => ctx.getNodesByName(name).some((n) => n.kind === 'import' && n.language === 'rust')) || + queries.hasDecoratedFunction('rust', 'tauri::command'); +} + +export async function tauriEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { + if (!hasTauriCore(queries, ctx)) return []; + const side = await readRustSide(ctx, onYield); + const out: Collected = { edges: [], seen: new Set(), emitters: [...side.emitters], listeners: [...side.listeners], subscribed: new Map() }; + + const frontendFiles = ctx.getAllFiles().filter((file) => FRONTEND_FILE.test(file) && !isTestPath(file)); + const scans = new Map(); + const scanFile = (file: string, source: string): void => { + const text = stripCommentsForRegex(source, 'typescript'); + const language = languageOf(file); + const imports = ctx.getImportMappings(file, language); + const front: FrontFile = { + file, text, code: blankStringContents(text), nodes: ctx.getNodesInFile(file), + lineAt: makeLineAt(source, 1), offsetOfLine: makeOffsetOfLine(source), + }; + scans.set(file, { front, imports, language, bindings: readBindings(front, imports, language, ctx) }); + }; + let scanned = 0; + for (const file of frontendFiles) { + if ((++scanned & 63) === 0) await onYield(); + const source = ctx.readFile(file); + if (source && FRONTEND_GATE.test(source)) scanFile(file, source); + } + const forwarders = findForwarders([...scans.values()], ctx); + if (forwarders.length > 0) { + // A wrapper's callers need not mention Tauri. + for (const file of frontendFiles) { + if (scans.has(file)) continue; + if ((++scanned & 63) === 0) await onYield(); + const source = ctx.readFile(file); + if (source && forwarders.some((f) => source.includes(f.name))) scanFile(file, source); + } + } + for (const scan of scans.values()) { + if ((++scanned & 63) === 0) await onYield(); + readFrontend(scan, forwarders, side, ctx, out); + } + + pairEvents(out.emitters, out.listeners, out); + for (const { owner, struct, line, column } of out.subscribed.values()) { + out.edges.push({ + source: owner.id, + target: struct.node.id, + kind: 'references', + line, + column, + provenance: 'heuristic', + metadata: { + synthesizedBy: 'tauri-event', + channel: 'event', + event: struct.event, + registeredAt: `${struct.node.filePath}:${struct.node.startLine}`, + }, + }); + } + return out.edges; +} + +/** Files this pass reads as input; a sync that changes one draws the links again. */ +export function hasTauriPattern(filePath: string, content: string): boolean { + if (filePath.endsWith('.rs')) return /\btauri|generate_handler|collect_commands|\.\s*(?:emit|listen|once)\w*\s*\(/.test(content); + if (!FRONTEND_FILE.test(filePath)) return false; + return /@tauri-apps\/|__TAURI|[iI]nvoke\w*\s*[<(]|\b(?:commands|events)\s*\.\s*\w/.test(content); +}