Skip to main content

lean_ctx/core/property_graph/
node.rs

1//! Node types and CRUD operations for graph nodes.
2
3use rusqlite::{Connection, OptionalExtension, params};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum NodeKind {
7    File,
8    Symbol,
9    Module,
10    Commit,
11    Test,
12    CIRun,
13    Knowledge,
14    Issue,
15}
16
17impl NodeKind {
18    pub fn as_str(&self) -> &'static str {
19        match self {
20            Self::File => "file",
21            Self::Symbol => "symbol",
22            Self::Module => "module",
23            Self::Commit => "commit",
24            Self::Test => "test",
25            Self::CIRun => "ci_run",
26            Self::Knowledge => "knowledge",
27            Self::Issue => "issue",
28        }
29    }
30
31    pub fn parse(s: &str) -> Self {
32        match s {
33            "symbol" => Self::Symbol,
34            "module" => Self::Module,
35            "commit" => Self::Commit,
36            "test" => Self::Test,
37            "ci_run" => Self::CIRun,
38            "knowledge" => Self::Knowledge,
39            "issue" => Self::Issue,
40            _ => Self::File,
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct Node {
47    pub id: Option<i64>,
48    pub kind: NodeKind,
49    pub name: String,
50    pub file_path: String,
51    pub line_start: Option<usize>,
52    pub line_end: Option<usize>,
53    pub metadata: Option<String>,
54}
55
56impl Node {
57    pub fn file(path: &str) -> Self {
58        Self {
59            id: None,
60            kind: NodeKind::File,
61            name: path.to_string(),
62            file_path: path.to_string(),
63            line_start: None,
64            line_end: None,
65            metadata: None,
66        }
67    }
68
69    pub fn symbol(name: &str, file_path: &str, kind: NodeKind) -> Self {
70        Self {
71            id: None,
72            kind,
73            name: name.to_string(),
74            file_path: file_path.to_string(),
75            line_start: None,
76            line_end: None,
77            metadata: None,
78        }
79    }
80
81    pub fn with_lines(mut self, start: usize, end: usize) -> Self {
82        self.line_start = Some(start);
83        self.line_end = Some(end);
84        self
85    }
86
87    pub fn with_metadata(mut self, meta: &str) -> Self {
88        self.metadata = Some(meta.to_string());
89        self
90    }
91
92    pub fn commit(hash: &str, message: &str) -> Self {
93        Self {
94            id: None,
95            kind: NodeKind::Commit,
96            name: hash.to_string(),
97            file_path: String::new(),
98            line_start: None,
99            line_end: None,
100            metadata: Some(message.to_string()),
101        }
102    }
103
104    pub fn test(path: &str, test_name: &str) -> Self {
105        Self {
106            id: None,
107            kind: NodeKind::Test,
108            name: test_name.to_string(),
109            file_path: path.to_string(),
110            line_start: None,
111            line_end: None,
112            metadata: None,
113        }
114    }
115
116    pub fn knowledge(id: &str, summary: &str) -> Self {
117        Self {
118            id: None,
119            kind: NodeKind::Knowledge,
120            name: id.to_string(),
121            file_path: String::new(),
122            line_start: None,
123            line_end: None,
124            metadata: Some(summary.to_string()),
125        }
126    }
127
128    pub fn issue(id: &str, title: &str) -> Self {
129        Self {
130            id: None,
131            kind: NodeKind::Issue,
132            name: id.to_string(),
133            file_path: String::new(),
134            line_start: None,
135            line_end: None,
136            metadata: Some(title.to_string()),
137        }
138    }
139}
140
141pub(super) fn upsert(conn: &Connection, node: &Node) -> anyhow::Result<i64> {
142    let file_id = super::path_id::intern(conn, &node.file_path)?;
143    conn.execute(
144        "INSERT INTO nodes (kind, name, file_id, line_start, line_end, metadata)
145         VALUES (?1, ?2, ?3, ?4, ?5, ?6)
146         ON CONFLICT(kind, name, file_id) DO UPDATE SET
147            line_start = excluded.line_start,
148            line_end = excluded.line_end,
149            metadata = excluded.metadata",
150        params![
151            node.kind.as_str(),
152            node.name,
153            file_id.raw(),
154            node.line_start.map(|v| v as i64),
155            node.line_end.map(|v| v as i64),
156            node.metadata,
157        ],
158    )?;
159
160    let id: i64 = conn.query_row(
161        "SELECT id FROM nodes WHERE kind = ?1 AND name = ?2 AND file_id = ?3",
162        params![node.kind.as_str(), node.name, file_id.raw()],
163        |row| row.get(0),
164    )?;
165
166    Ok(id)
167}
168
169pub(super) fn get_by_path(conn: &Connection, file_path: &str) -> anyhow::Result<Option<Node>> {
170    let result = conn
171        .query_row(
172            "SELECT n.id, n.kind, n.name, p.path, n.line_start, n.line_end, n.metadata
173             FROM nodes n JOIN paths p ON p.id = n.file_id
174             WHERE n.kind = 'file' AND p.path = ?1",
175            params![file_path],
176            |row| {
177                Ok(Node {
178                    id: Some(row.get(0)?),
179                    kind: NodeKind::parse(&row.get::<_, String>(1)?),
180                    name: row.get(2)?,
181                    file_path: row.get(3)?,
182                    line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
183                    line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
184                    metadata: row.get(6)?,
185                })
186            },
187        )
188        .optional()?;
189    Ok(result)
190}
191
192pub(super) fn get_by_symbol(
193    conn: &Connection,
194    name: &str,
195    file_path: &str,
196) -> anyhow::Result<Option<Node>> {
197    let result = conn
198        .query_row(
199            "SELECT n.id, n.kind, n.name, p.path, n.line_start, n.line_end, n.metadata
200             FROM nodes n JOIN paths p ON p.id = n.file_id
201             WHERE n.name = ?1 AND p.path = ?2 AND n.kind != 'file'",
202            params![name, file_path],
203            |row| {
204                Ok(Node {
205                    id: Some(row.get(0)?),
206                    kind: NodeKind::parse(&row.get::<_, String>(1)?),
207                    name: row.get(2)?,
208                    file_path: row.get(3)?,
209                    line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
210                    line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
211                    metadata: row.get(6)?,
212                })
213            },
214        )
215        .optional()?;
216    Ok(result)
217}
218
219pub(super) fn remove_by_file(conn: &Connection, file_path: &str) -> anyhow::Result<()> {
220    conn.execute(
221        "DELETE FROM edges WHERE source_id IN (
222             SELECT n.id FROM nodes n JOIN paths p ON p.id = n.file_id WHERE p.path = ?1
223         ) OR target_id IN (
224             SELECT n.id FROM nodes n JOIN paths p ON p.id = n.file_id WHERE p.path = ?1
225         )",
226        params![file_path],
227    )?;
228    conn.execute(
229        "DELETE FROM nodes WHERE file_id = (SELECT id FROM paths WHERE path = ?1)",
230        params![file_path],
231    )?;
232    Ok(())
233}
234
235pub(super) fn find_symbols(
236    conn: &Connection,
237    name: &str,
238    file_filter: Option<&str>,
239    kind_filter: Option<&str>,
240) -> anyhow::Result<Vec<Node>> {
241    let name_lower = name.to_lowercase();
242    let mut sql = String::from(
243        "SELECT n.id, n.kind, n.name, p.path, n.line_start, n.line_end, n.metadata
244         FROM nodes n JOIN paths p ON p.id = n.file_id WHERE n.kind != 'file'
245         AND LOWER(name) LIKE '%' || ?1 || '%'",
246    );
247    let mut param_idx = 2;
248    if file_filter.is_some() {
249        sql.push_str(&format!(" AND p.path LIKE '%' || ?{param_idx} || '%'"));
250        param_idx += 1;
251    }
252    if kind_filter.is_some() {
253        sql.push_str(&format!(" AND kind = ?{param_idx}"));
254    }
255    sql.push_str(" ORDER BY p.path, n.line_start LIMIT 100");
256
257    let mut stmt = conn.prepare(&sql)?;
258
259    let params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = {
260        let mut v: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(name_lower)];
261        if let Some(f) = file_filter {
262            v.push(Box::new(f.to_string()));
263        }
264        if let Some(k) = kind_filter {
265            v.push(Box::new(k.to_string()));
266        }
267        v
268    };
269    let refs: Vec<&dyn rusqlite::types::ToSql> =
270        params_vec.iter().map(std::convert::AsRef::as_ref).collect();
271
272    let rows = stmt.query_map(refs.as_slice(), |row| {
273        Ok(Node {
274            id: Some(row.get(0)?),
275            kind: NodeKind::parse(&row.get::<_, String>(1)?),
276            name: row.get(2)?,
277            file_path: row.get(3)?,
278            line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
279            line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
280            metadata: row.get(6)?,
281        })
282    })?;
283
284    let mut results = Vec::new();
285    for r in rows {
286        results.push(r?);
287    }
288    Ok(results)
289}
290
291/// Distinct files that define a symbol whose name matches `name` exactly
292/// (case-sensitive). Every symbol node carries the file that *defines* it as
293/// its `file_path` (definitions, call-sites and `type_ref` targets all stamp
294/// the definer), so this maps a bare type/class name to its source file(s).
295///
296/// Powers the `ctx_impact analyze` symbol-name fallback (GH #398): a user who
297/// asks for the impact of a *class* (`ctx_impact analyze ArcPoint`) instead of
298/// a file path is resolved to the file(s) that define it. Results are sorted
299/// for output determinism (#498).
300pub(super) fn resolve_symbol_def_files(
301    conn: &Connection,
302    name: &str,
303) -> anyhow::Result<Vec<String>> {
304    let mut stmt = conn.prepare(
305        "SELECT DISTINCT p.path FROM nodes n JOIN paths p ON p.id = n.file_id
306         WHERE n.kind = 'symbol' AND n.name = ?1 AND p.path != ''
307         ORDER BY p.path",
308    )?;
309    let rows = stmt.query_map(params![name], |row| row.get::<_, String>(0))?;
310    let mut out = Vec::new();
311    for r in rows {
312        out.push(r?);
313    }
314    Ok(out)
315}
316
317/// Every symbol node (`kind != 'file'`) with its line span — the unfiltered
318/// counterpart of [`find_symbols`]. Powers the backend-agnostic symbol table
319/// that the call-graph builder needs (replacing `ProjectIndex::symbols`).
320pub(super) fn all_symbols(conn: &Connection) -> anyhow::Result<Vec<Node>> {
321    let mut stmt = conn.prepare(
322        "SELECT n.id, n.kind, n.name, p.path, n.line_start, n.line_end, n.metadata
323         FROM nodes n JOIN paths p ON p.id = n.file_id WHERE n.kind != 'file'
324         ORDER BY p.path, n.line_start",
325    )?;
326    let rows = stmt.query_map([], |row| {
327        Ok(Node {
328            id: Some(row.get(0)?),
329            kind: NodeKind::parse(&row.get::<_, String>(1)?),
330            name: row.get(2)?,
331            file_path: row.get(3)?,
332            line_start: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
333            line_end: row.get::<_, Option<i64>>(5)?.map(|v| v as usize),
334            metadata: row.get(6)?,
335        })
336    })?;
337    let mut results = Vec::new();
338    for r in rows {
339        results.push(r?);
340    }
341    Ok(results)
342}
343
344pub(super) fn symbol_count(conn: &Connection) -> anyhow::Result<usize> {
345    let c: i64 = conn.query_row(
346        "SELECT COUNT(*) FROM nodes WHERE kind != 'file'",
347        [],
348        |row| row.get(0),
349    )?;
350    Ok(c as usize)
351}
352
353/// Number of `file` nodes — accurate on both builder paths (the `file_catalog`
354/// table is only populated by the minimal/non-embeddings builder, so it cannot
355/// back a count). Used by the `ctx_impact analyze` diagnostic (GH #398).
356pub(super) fn file_count(conn: &Connection) -> anyhow::Result<usize> {
357    let c: i64 = conn.query_row(
358        "SELECT COUNT(*) FROM nodes WHERE kind = 'file'",
359        [],
360        |row| row.get(0),
361    )?;
362    Ok(c as usize)
363}
364
365pub(super) fn all_edges_flat(
366    conn: &Connection,
367) -> anyhow::Result<Vec<(String, String, String, f64)>> {
368    // The `edges` table carries no weight column — impact scoring derives the
369    // weight from the edge kind (see `queries::edge_weight`). Selecting a
370    // non-existent `e.weight` made this query error out, so PG `edges()` always
371    // returned empty (#682.3). Compute the weight from the kind instead.
372    let mut stmt = conn.prepare(
373        "SELECT p1.path, p2.path, e.kind
374         FROM edges e
375         JOIN nodes n1 ON e.source_id = n1.id
376         JOIN nodes n2 ON e.target_id = n2.id
377         JOIN paths p1 ON p1.id = n1.file_id
378         JOIN paths p2 ON p2.id = n2.file_id
379         WHERE n1.kind = 'file' AND n2.kind = 'file'",
380    )?;
381    let rows = stmt.query_map([], |row| {
382        Ok((
383            row.get::<_, String>(0)?,
384            row.get::<_, String>(1)?,
385            row.get::<_, String>(2)?,
386        ))
387    })?;
388    let mut result = Vec::new();
389    for r in rows {
390        let (from, to, kind) = r?;
391        let weight = super::queries::edge_weight(&kind);
392        result.push((from, to, kind, weight));
393    }
394    Ok(result)
395}
396
397pub(super) fn count(conn: &Connection) -> anyhow::Result<usize> {
398    let c: i64 = conn.query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?;
399    Ok(c as usize)
400}