33//! Uses a second `#[napi] impl NativeDatabase` block (Rust allows multiple impl blocks).
44//! All methods use `conn.prepare_cached()` for automatic statement caching.
55
6- use std:: collections:: { HashSet , VecDeque } ;
6+ use std:: collections:: { HashMap , HashSet , VecDeque } ;
77
88use napi_derive:: napi;
99use rusqlite:: params;
@@ -28,6 +28,15 @@ fn escape_like(s: &str) -> String {
2828 out
2929}
3030
31+ /// Check if a file path looks like a test file (mirrors `isTestFile` in JS).
32+ fn is_test_file ( file : & str ) -> bool {
33+ file. contains ( ".test." )
34+ || file. contains ( ".spec." )
35+ || file. contains ( "__test__" )
36+ || file. contains ( "__tests__" )
37+ || file. contains ( ".stories." )
38+ }
39+
3140/// Build test-file exclusion clauses for a column.
3241fn test_filter_clauses ( column : & str ) -> String {
3342 format ! (
@@ -1682,4 +1691,323 @@ impl NativeDatabase {
16821691
16831692 Ok ( results)
16841693 }
1694+
1695+ // ── Composite Queries ─────────────────────────────────────────────────
1696+
1697+ /// Complete fnDeps query in a single native call.
1698+ ///
1699+ /// Finds matching nodes, collects callees/callers, and runs BFS transitive
1700+ /// caller traversal — all in Rust with `prepare_cached` statements.
1701+ /// Eliminates per-query NAPI boundary crossings that made the JS-orchestrated
1702+ /// version ~3x slower than direct better-sqlite3.
1703+ #[ napi]
1704+ pub fn fn_deps (
1705+ & self ,
1706+ name : String ,
1707+ depth : Option < i32 > ,
1708+ no_tests : Option < bool > ,
1709+ file : Option < String > ,
1710+ kind : Option < String > ,
1711+ ) -> napi:: Result < FnDepsResult > {
1712+ let conn = self . conn ( ) ?;
1713+ let depth = depth. unwrap_or ( 3 ) . max ( 1 ) as usize ;
1714+ let no_tests = no_tests. unwrap_or ( false ) ;
1715+ let lower_query = name. to_lowercase ( ) ;
1716+
1717+ // ── Step 1: Find matching nodes with fan-in (relevance ranking) ───
1718+ let default_kinds = vec ! [
1719+ "function" . to_string( ) ,
1720+ "method" . to_string( ) ,
1721+ "class" . to_string( ) ,
1722+ "constant" . to_string( ) ,
1723+ ] ;
1724+ let kinds = if let Some ( ref k) = kind {
1725+ vec ! [ k. clone( ) ]
1726+ } else {
1727+ default_kinds
1728+ } ;
1729+
1730+ let mut sql = String :: from (
1731+ "SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line, n.role, \
1732+ COALESCE(fi.cnt, 0) AS fan_in \
1733+ FROM nodes n \
1734+ LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id) fi \
1735+ ON fi.target_id = n.id \
1736+ WHERE n.name LIKE ?1",
1737+ ) ;
1738+ let mut param_values: Vec < Box < dyn rusqlite:: types:: ToSql > > =
1739+ vec ! [ Box :: new( format!( "%{name}%" ) ) ] ;
1740+ let mut idx = 2 ;
1741+
1742+ if !kinds. is_empty ( ) {
1743+ let placeholders: Vec < String > =
1744+ kinds. iter ( ) . enumerate ( ) . map ( |( i, _) | format ! ( "?{}" , idx + i) ) . collect ( ) ;
1745+ sql. push_str ( & format ! ( " AND n.kind IN ({})" , placeholders. join( ", " ) ) ) ;
1746+ for k in & kinds {
1747+ param_values. push ( Box :: new ( k. clone ( ) ) ) ;
1748+ }
1749+ idx += kinds. len ( ) ;
1750+ }
1751+ if let Some ( ref f) = file {
1752+ sql. push_str ( & format ! ( " AND n.file LIKE ?{idx} ESCAPE '\\ '" ) ) ;
1753+ param_values. push ( Box :: new ( format ! ( "%{}%" , escape_like( f) ) ) ) ;
1754+ }
1755+
1756+ let params_ref: Vec < & dyn rusqlite:: types:: ToSql > =
1757+ param_values. iter ( ) . map ( |p| p. as_ref ( ) ) . collect ( ) ;
1758+
1759+ struct MatchedNode {
1760+ id : i32 ,
1761+ name : String ,
1762+ kind : String ,
1763+ file : String ,
1764+ line : Option < i32 > ,
1765+ end_line : Option < i32 > ,
1766+ role : Option < String > ,
1767+ fan_in : i32 ,
1768+ }
1769+
1770+ let mut matched: Vec < MatchedNode > = {
1771+ let mut stmt = conn. prepare_cached ( & sql)
1772+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps find_nodes prepare: {e}" ) ) ) ?;
1773+ let rows = stmt. query_map ( params_ref. as_slice ( ) , |row| {
1774+ Ok ( MatchedNode {
1775+ id : row. get ( "id" ) ?,
1776+ name : row. get ( "name" ) ?,
1777+ kind : row. get ( "kind" ) ?,
1778+ file : row. get ( "file" ) ?,
1779+ line : row. get ( "line" ) ?,
1780+ end_line : row. get ( "end_line" ) ?,
1781+ role : row. get ( "role" ) ?,
1782+ fan_in : row. get ( "fan_in" ) ?,
1783+ } )
1784+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps find_nodes: {e}" ) ) ) ?;
1785+ rows. collect :: < Result < Vec < _ > , _ > > ( )
1786+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps find_nodes collect: {e}" ) ) ) ?
1787+ } ;
1788+
1789+ // Filter test files
1790+ if no_tests {
1791+ matched. retain ( |n| !is_test_file ( & n. file ) ) ;
1792+ }
1793+
1794+ // Relevance scoring (mirrors JS findMatchingNodes)
1795+ matched. sort_by ( |a, b| {
1796+ let score = |node : & MatchedNode | -> f64 {
1797+ let lower_name = node. name . to_lowercase ( ) ;
1798+ let bare_name = lower_name. rsplit ( '.' ) . next ( ) . unwrap_or ( & lower_name) ;
1799+ let match_score = if lower_name == lower_query || bare_name == lower_query {
1800+ 100.0
1801+ } else if lower_name. starts_with ( & lower_query) || bare_name. starts_with ( & lower_query) {
1802+ 60.0
1803+ } else if lower_name. contains ( & format ! ( ".{lower_query}" ) ) || lower_name. contains ( & format ! ( "{lower_query}." ) ) {
1804+ 40.0
1805+ } else {
1806+ 10.0
1807+ } ;
1808+ let fan_in_bonus = ( ( node. fan_in as f64 + 1.0 ) . log2 ( ) * 5.0 ) . min ( 25.0 ) ;
1809+ match_score + fan_in_bonus
1810+ } ;
1811+ score ( b) . partial_cmp ( & score ( a) ) . unwrap_or ( std:: cmp:: Ordering :: Equal )
1812+ } ) ;
1813+
1814+ // ── Step 2: Build result for each matched node ────────────────────
1815+ let mut file_hash_cache: HashMap < String , Option < String > > = HashMap :: new ( ) ;
1816+
1817+ let mut results = Vec :: with_capacity ( matched. len ( ) ) ;
1818+ for node in & matched {
1819+ // Callees
1820+ let callees: Vec < FnDepsNode > = {
1821+ let mut stmt = conn. prepare_cached (
1822+ "SELECT DISTINCT n.id, n.name, n.kind, n.file, n.line \
1823+ FROM edges e JOIN nodes n ON e.target_id = n.id \
1824+ WHERE e.source_id = ?1 AND e.kind = 'calls'"
1825+ ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callees prepare: {e}" ) ) ) ?;
1826+ let rows = stmt. query_map ( params ! [ node. id] , |row| {
1827+ Ok ( FnDepsNode {
1828+ name : row. get ( "name" ) ?,
1829+ kind : row. get ( "kind" ) ?,
1830+ file : row. get ( "file" ) ?,
1831+ line : row. get ( "line" ) ?,
1832+ } )
1833+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callees: {e}" ) ) ) ?;
1834+ let mut v: Vec < FnDepsNode > = rows. collect :: < Result < Vec < _ > , _ > > ( )
1835+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callees collect: {e}" ) ) ) ?;
1836+ if no_tests {
1837+ v. retain ( |c| !is_test_file ( & c. file ) ) ;
1838+ }
1839+ v
1840+ } ;
1841+
1842+ // Callers (direct) — query includes `id` for BFS reuse
1843+ struct CallerWithId { id : i32 , name : String , kind : String , file : String , line : Option < i32 > , via_hierarchy : Option < String > }
1844+ let mut callers_with_id: Vec < CallerWithId > = {
1845+ let mut stmt = conn. prepare_cached (
1846+ "SELECT n.id, n.name, n.kind, n.file, n.line \
1847+ FROM edges e JOIN nodes n ON e.source_id = n.id \
1848+ WHERE e.target_id = ?1 AND e.kind = 'calls'"
1849+ ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callers prepare: {e}" ) ) ) ?;
1850+ let rows = stmt. query_map ( params ! [ node. id] , |row| {
1851+ Ok ( CallerWithId {
1852+ id : row. get ( "id" ) ?,
1853+ name : row. get ( "name" ) ?,
1854+ kind : row. get ( "kind" ) ?,
1855+ file : row. get ( "file" ) ?,
1856+ line : row. get ( "line" ) ?,
1857+ via_hierarchy : None ,
1858+ } )
1859+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callers: {e}" ) ) ) ?;
1860+ rows. collect :: < Result < Vec < _ > , _ > > ( )
1861+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps callers collect: {e}" ) ) ) ?
1862+ } ;
1863+
1864+ // Method hierarchy resolution
1865+ if node. kind == "method" && node. name . contains ( '.' ) {
1866+ if let Some ( method_name) = node. name . split ( '.' ) . last ( ) {
1867+ let pattern = format ! ( "%.{method_name}" ) ;
1868+ let related: Vec < ( i32 , String ) > = {
1869+ let mut stmt = conn. prepare_cached (
1870+ "SELECT n.id, n.name FROM nodes n \
1871+ LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id) fi \
1872+ ON fi.target_id = n.id \
1873+ WHERE n.name LIKE ?1 AND n.kind = 'method'"
1874+ ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy prepare: {e}" ) ) ) ?;
1875+ let rows = stmt. query_map ( params ! [ pattern] , |row| {
1876+ Ok ( ( row. get :: < _ , i32 > ( "id" ) ?, row. get :: < _ , String > ( "name" ) ?) )
1877+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy: {e}" ) ) ) ?;
1878+ rows. collect :: < Result < Vec < _ > , _ > > ( )
1879+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy collect: {e}" ) ) ) ?
1880+ } ;
1881+ for ( rm_id, rm_name) in & related {
1882+ if * rm_id == node. id { continue ; }
1883+ let mut stmt = conn. prepare_cached (
1884+ "SELECT n.id, n.name, n.kind, n.file, n.line \
1885+ FROM edges e JOIN nodes n ON e.source_id = n.id \
1886+ WHERE e.target_id = ?1 AND e.kind = 'calls'"
1887+ ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy callers prepare: {e}" ) ) ) ?;
1888+ let rows = stmt. query_map ( params ! [ rm_id] , |row| {
1889+ Ok ( CallerWithId {
1890+ id : row. get ( "id" ) ?,
1891+ name : row. get ( "name" ) ?,
1892+ kind : row. get ( "kind" ) ?,
1893+ file : row. get ( "file" ) ?,
1894+ line : row. get ( "line" ) ?,
1895+ via_hierarchy : Some ( rm_name. clone ( ) ) ,
1896+ } )
1897+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy callers: {e}" ) ) ) ?;
1898+ let extra: Vec < CallerWithId > = rows. collect :: < Result < Vec < _ > , _ > > ( )
1899+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps hierarchy callers collect: {e}" ) ) ) ?;
1900+ callers_with_id. extend ( extra) ;
1901+ }
1902+ }
1903+ }
1904+ if no_tests {
1905+ callers_with_id. retain ( |c| !is_test_file ( & c. file ) ) ;
1906+ }
1907+
1908+ // Convert to FnDepsCallerNode for output (strip id)
1909+ let callers: Vec < FnDepsCallerNode > = callers_with_id. iter ( ) . map ( |c| FnDepsCallerNode {
1910+ name : c. name . clone ( ) ,
1911+ kind : c. kind . clone ( ) ,
1912+ file : c. file . clone ( ) ,
1913+ line : c. line ,
1914+ via_hierarchy : c. via_hierarchy . clone ( ) ,
1915+ } ) . collect ( ) ;
1916+
1917+ // BFS transitive callers — reuse callers_with_id as initial frontier
1918+ let transitive_callers = if depth > 1 {
1919+ let mut visited = HashSet :: new ( ) ;
1920+ visited. insert ( node. id ) ;
1921+ let initial_frontier: Vec < CallerWithId > = callers_with_id. iter ( ) . map ( |c| CallerWithId {
1922+ id : c. id , name : c. name . clone ( ) , kind : c. kind . clone ( ) , file : c. file . clone ( ) , line : c. line , via_hierarchy : c. via_hierarchy . clone ( ) ,
1923+ } ) . collect ( ) ;
1924+ let mut frontier: Vec < CallerWithId > = initial_frontier;
1925+ let mut groups: Vec < FnDepsTransitiveGroup > = Vec :: new ( ) ;
1926+
1927+ for d in 2 ..=depth {
1928+ let unvisited: Vec < & CallerWithId > = frontier. iter ( )
1929+ . filter ( |f| !visited. contains ( & f. id ) )
1930+ . collect ( ) ;
1931+ for f in & unvisited {
1932+ visited. insert ( f. id ) ;
1933+ }
1934+ if unvisited. is_empty ( ) { break ; }
1935+
1936+ // Batch query: find all callers of the unvisited frontier
1937+ let mut next_frontier: Vec < CallerWithId > = Vec :: new ( ) ;
1938+ let mut next_ids = HashSet :: new ( ) ;
1939+ for f in & unvisited {
1940+ let mut stmt = conn. prepare_cached (
1941+ "SELECT n.id, n.name, n.kind, n.file, n.line \
1942+ FROM edges e JOIN nodes n ON e.source_id = n.id \
1943+ WHERE e.target_id = ?1 AND e.kind = 'calls'"
1944+ ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps bfs prepare: {e}" ) ) ) ?;
1945+ let rows = stmt. query_map ( params ! [ f. id] , |row| {
1946+ Ok ( CallerWithId {
1947+ id : row. get ( "id" ) ?,
1948+ name : row. get ( "name" ) ?,
1949+ kind : row. get ( "kind" ) ?,
1950+ file : row. get ( "file" ) ?,
1951+ line : row. get ( "line" ) ?,
1952+ via_hierarchy : None ,
1953+ } )
1954+ } ) . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps bfs: {e}" ) ) ) ?;
1955+ let upstream: Vec < CallerWithId > = rows. collect :: < Result < Vec < _ > , _ > > ( )
1956+ . map_err ( |e| napi:: Error :: from_reason ( format ! ( "fn_deps bfs collect: {e}" ) ) ) ?;
1957+ for u in upstream {
1958+ if no_tests && is_test_file ( & u. file ) { continue ; }
1959+ if !visited. contains ( & u. id ) && !next_ids. contains ( & u. id ) {
1960+ next_ids. insert ( u. id ) ;
1961+ next_frontier. push ( u) ;
1962+ }
1963+ }
1964+ }
1965+
1966+ if !next_frontier. is_empty ( ) {
1967+ groups. push ( FnDepsTransitiveGroup {
1968+ depth : d as i32 ,
1969+ callers : next_frontier. iter ( ) . map ( |n| FnDepsNode {
1970+ name : n. name . clone ( ) ,
1971+ kind : n. kind . clone ( ) ,
1972+ file : n. file . clone ( ) ,
1973+ line : n. line ,
1974+ } ) . collect ( ) ,
1975+ } ) ;
1976+ }
1977+ frontier = next_frontier;
1978+ }
1979+ groups
1980+ } else {
1981+ Vec :: new ( )
1982+ } ;
1983+
1984+ // File hash (cached)
1985+ let file_hash = if !file_hash_cache. contains_key ( & node. file ) {
1986+ let hash: Option < String > = conn. prepare_cached (
1987+ "SELECT hash FROM file_hashes WHERE file = ?1"
1988+ ) . ok ( ) . and_then ( |mut stmt| {
1989+ stmt. query_row ( params ! [ node. file] , |row| row. get ( 0 ) ) . ok ( )
1990+ } ) ;
1991+ file_hash_cache. insert ( node. file . clone ( ) , hash. clone ( ) ) ;
1992+ hash
1993+ } else {
1994+ file_hash_cache. get ( & node. file ) . cloned ( ) . flatten ( )
1995+ } ;
1996+
1997+ results. push ( FnDepsEntry {
1998+ name : node. name . clone ( ) ,
1999+ kind : node. kind . clone ( ) ,
2000+ file : node. file . clone ( ) ,
2001+ line : node. line ,
2002+ end_line : node. end_line ,
2003+ role : node. role . clone ( ) ,
2004+ file_hash,
2005+ callees,
2006+ callers,
2007+ transitive_callers,
2008+ } ) ;
2009+ }
2010+
2011+ Ok ( FnDepsResult { name, results } )
2012+ }
16852013}
0 commit comments