Skip to main content

aft/callgraph_store/
dead_code_projection.rs

1use rusqlite::{Connection, OpenFlags};
2use std::collections::BTreeSet;
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime};
5
6use crate::inspect::job::{CallgraphExport, CallgraphOutboundCall, CallgraphSnapshot};
7use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
8
9use super::{database_ready, CallGraphStoreError, Result, BACKEND_TREESITTER};
10
11pub fn project_dead_code_snapshot(db_path: &Path) -> Result<CallgraphSnapshot> {
12    if !db_path.is_file() {
13        return Err(CallGraphStoreError::Unavailable(format!(
14            "database does not exist: {}",
15            db_path.display()
16        )));
17    }
18
19    let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
20    conn.busy_timeout(Duration::from_millis(5_000))?;
21    if !database_ready(&conn).unwrap_or(false) {
22        return Err(CallGraphStoreError::Unavailable(
23            "database is missing, stale, or mid-build".to_string(),
24        ));
25    }
26
27    let project_root = project_root_from_backend_state(&conn)?;
28    let files = project_files_from_store(&conn, &project_root)?;
29    let exported_symbols = exported_symbols_from_store(&conn, &project_root)?;
30    let outbound_calls = outbound_calls_from_store(&conn, &project_root)?;
31    let entry_points = entry_points_for_files(&project_root, &files);
32
33    Ok(CallgraphSnapshot {
34        generated_at: Some(SystemTime::now()),
35        files,
36        exported_symbols,
37        outbound_calls,
38        entry_points,
39    })
40}
41
42fn project_root_from_backend_state(conn: &Connection) -> Result<PathBuf> {
43    let mut statement = conn.prepare(
44        "SELECT DISTINCT workspace_root
45         FROM backend_file_state
46         WHERE backend = ?1
47         ORDER BY workspace_root",
48    )?;
49    let roots = statement
50        .query_map([BACKEND_TREESITTER], |row| row.get::<_, String>(0))?
51        .collect::<std::result::Result<Vec<_>, _>>()?;
52
53    match roots.as_slice() {
54        [root] => Ok(PathBuf::from(root)),
55        [] => Err(CallGraphStoreError::Unavailable(
56            "database has no workspace root rows".to_string(),
57        )),
58        _ => Err(CallGraphStoreError::Unavailable(format!(
59            "database has multiple workspace roots: {}",
60            roots.join(", ")
61        ))),
62    }
63}
64
65fn project_files_from_store(conn: &Connection, project_root: &Path) -> Result<Vec<PathBuf>> {
66    let mut statement = conn.prepare("SELECT path FROM files ORDER BY path")?;
67    let files = statement
68        .query_map([], |row| row.get::<_, String>(0))?
69        .map(|path| {
70            path.map(|path| canonicalize_for_snapshot(&absolute_store_path(project_root, &path)))
71        })
72        .collect::<std::result::Result<Vec<_>, _>>()?;
73    Ok(files)
74}
75
76fn exported_symbols_from_store(
77    conn: &Connection,
78    project_root: &Path,
79) -> Result<Vec<CallgraphExport>> {
80    let mut statement = conn.prepare(
81        "SELECT file_path, name, kind, start_line, exported, is_default_export
82         FROM nodes
83         WHERE exported != 0 OR is_default_export != 0
84         ORDER BY file_path, start_line, name, kind, id",
85    )?;
86    let rows = statement.query_map([], |row| {
87        Ok(ExportRow {
88            file_path: row.get(0)?,
89            name: row.get(1)?,
90            kind: row.get(2)?,
91            line: (row.get::<_, i64>(3)?.max(0) as u32).saturating_add(1),
92            exported: row.get::<_, i64>(4)? != 0,
93            is_default_export: row.get::<_, i64>(5)? != 0,
94        })
95    })?;
96
97    let mut exports = Vec::new();
98    for row in rows {
99        let row = row?;
100        let file = canonicalize_for_snapshot(&absolute_store_path(project_root, &row.file_path));
101        if row.exported {
102            exports.push(CallgraphExport {
103                file: file.clone(),
104                symbol: row.name.clone(),
105                kind: row.kind,
106                line: row.line,
107            });
108        }
109        if row.is_default_export {
110            exports.push(CallgraphExport {
111                file,
112                symbol: row.name,
113                kind: DEFAULT_EXPORT_MARKER_KIND.to_string(),
114                line: row.line,
115            });
116        }
117    }
118    Ok(exports)
119}
120
121fn outbound_calls_from_store(
122    conn: &Connection,
123    project_root: &Path,
124) -> Result<Vec<CallgraphOutboundCall>> {
125    let mut statement = conn.prepare(
126        "SELECT r.ref_id,
127                r.caller_file,
128                n.scoped_name,
129                r.short_name,
130                r.full_ref,
131                r.status,
132                COALESCE(r.target_file, e.target_file),
133                COALESCE(r.target_symbol, e.target_symbol),
134                r.line
135         FROM refs r
136         LEFT JOIN nodes n ON n.id = r.caller_node
137         LEFT JOIN edges e ON e.ref_id = r.ref_id AND e.kind = 'call'
138         WHERE r.kind = 'call'
139         ORDER BY r.caller_file, n.scoped_name, r.line, r.byte_start, r.byte_end, r.ref_id",
140    )?;
141    let rows = statement.query_map([], |row| {
142        Ok(OutboundRow {
143            ref_id: row.get(0)?,
144            caller_file: row.get(1)?,
145            caller_symbol: row.get(2)?,
146            short_name: row.get(3)?,
147            full_ref: row.get(4)?,
148            status: row.get(5)?,
149            target_file: row.get(6)?,
150            target_symbol: row.get(7)?,
151            line: row.get::<_, i64>(8)? as u32,
152        })
153    })?;
154
155    let mut calls = Vec::new();
156    for row in rows {
157        let row = row?;
158        let caller_file =
159            canonicalize_for_snapshot(&absolute_store_path(project_root, &row.caller_file));
160        let caller_symbol = caller_symbol_from_row(&row)?;
161        let short_name = row
162            .short_name
163            .as_deref()
164            .or(row.full_ref.as_deref())
165            .unwrap_or_default();
166        let mut target = if is_resolved_status(&row.status) {
167            match (row.target_file.as_deref(), row.target_symbol.as_deref()) {
168                (Some(target_file), Some(target_symbol)) => {
169                    let target_file =
170                        canonicalize_for_snapshot(&absolute_store_path(project_root, target_file));
171                    format!("{}::{target_symbol}", target_file.display())
172                }
173                _ => short_name.to_string(),
174            }
175        } else {
176            short_name.to_string()
177        };
178
179        if row
180            .full_ref
181            .as_deref()
182            .is_some_and(|full_ref| is_method_dispatch_callee(full_ref, short_name))
183        {
184            target.push(crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR);
185            target.push_str(row.full_ref.as_deref().unwrap_or_default());
186        }
187
188        calls.push(CallgraphOutboundCall {
189            caller_file,
190            caller_symbol,
191            target,
192            line: row.line,
193        });
194    }
195    Ok(calls)
196}
197
198fn entry_points_for_files(project_root: &Path, files: &[PathBuf]) -> BTreeSet<PathBuf> {
199    let resolved_entry_points = crate::inspect::resolve_entry_points(project_root);
200    files
201        .iter()
202        .filter(|file| resolved_entry_points.is_entry_point(file))
203        .cloned()
204        .collect()
205}
206
207fn caller_symbol_from_row(row: &OutboundRow) -> Result<String> {
208    row.caller_symbol.clone().ok_or_else(|| {
209        CallGraphStoreError::Unavailable(format!(
210            "call ref {} is missing caller symbol",
211            row.ref_id
212        ))
213    })
214}
215
216fn is_resolved_status(status: &str) -> bool {
217    matches!(status, "resolved" | "resolved_local")
218}
219
220fn is_method_dispatch_callee(full_callee: &str, callee_name: &str) -> bool {
221    let full_callee = full_callee.trim();
222    if !full_callee.contains('.') || full_callee == callee_name.trim() {
223        return false;
224    }
225
226    full_callee
227        .rsplit('.')
228        .next()
229        .map(|segment| segment.trim().trim_start_matches('?') == callee_name.trim())
230        .unwrap_or(false)
231}
232
233fn absolute_store_path(project_root: &Path, store_path: &str) -> PathBuf {
234    let path = Path::new(store_path);
235    if path.is_absolute() {
236        path.to_path_buf()
237    } else {
238        project_root.join(path)
239    }
240}
241
242fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
243    std::fs::canonicalize(path).unwrap_or_else(|_| crate::inspect::job::normalize_path(path))
244}
245
246#[derive(Debug)]
247struct ExportRow {
248    file_path: String,
249    name: String,
250    kind: String,
251    line: u32,
252    exported: bool,
253    is_default_export: bool,
254}
255
256#[derive(Debug)]
257struct OutboundRow {
258    ref_id: String,
259    caller_file: String,
260    caller_symbol: Option<String>,
261    short_name: Option<String>,
262    full_ref: Option<String>,
263    status: String,
264    target_file: Option<String>,
265    target_symbol: Option<String>,
266    line: u32,
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn assert_send<T: Send>() {}
274
275    #[test]
276    fn projection_result_is_send() {
277        assert_send::<Result<CallgraphSnapshot>>();
278    }
279}