Skip to main content

cartog_db/store/
lsp.rs

1//! LSP-resolution helper queries (unresolved edges, marker updates).
2//!
3//! Part of the [`Database`](super::Database) impl, split out of `lib.rs` for navigability.
4
5use super::*;
6
7impl Database {
8    // ── LSP Resolution Helpers ──
9    //
10    // These helpers (`unresolved_edges`, `find_symbol_at_location`,
11    // `update_edge_target`, `mark_edge_unresolvable`, `mark_edge_external`,
12    // `edge_resolution_state`) are called from `cartog-lsp::lsp_resolve_edges`,
13    // which itself runs inside `index_directory`'s outer indexing transaction
14    // (see [`Self::begin_indexing_tx`]). They MUST remain transaction-free —
15    // any future addition of `unchecked_transaction()` here would re-introduce
16    // the Phase 3 atomicity bug at runtime ("cannot start a transaction
17    // within a transaction").
18
19    /// Return edges still waiting for resolution (`resolution_state = 0`).
20    ///
21    /// Edges marked `state = 2` (unresolvable), `state = 3` (external), or
22    /// `state = 4` (heuristic-exhausted) are excluded so a dirty reindex
23    /// doesn't re-query the language server for edges it already classified.
24    /// All three are sticky and re-enter the unresolved set only via
25    /// [`Self::reset_unresolvable_for_names`] when a matching symbol is added,
26    /// [`Self::reset_all_unresolvable`] on `--force`, or (state 4 only)
27    /// [`Self::reopen_heuristic_exhausted`] before an LSP-enabled reindex.
28    /// ([`Self::invalidate_edges_targeting`] only touches state=1 rows because
29    /// it filters on `target_id IS NOT NULL`, and state {2, 3, 4} rows always
30    /// have `target_id NULL`.)
31    ///
32    /// tx-safe: read-only single statement — see note above the section header.
33    pub fn unresolved_edges(&self) -> Result<Vec<UnresolvedEdge>> {
34        let mut stmt = self.conn.prepare(
35            "SELECT e.id, e.target_name, e.file_path, e.line
36             FROM edges e
37             WHERE e.resolution_state = 0",
38        )?;
39
40        let rows = stmt.query_map([], |row| {
41            Ok(UnresolvedEdge {
42                edge_id: row.get(0)?,
43                target_name: row.get(1)?,
44                file_path: row.get(2)?,
45                line: row.get(3)?,
46            })
47        })?;
48
49        rows.collect::<rusqlite::Result<Vec<_>>>()
50            .map_err(Into::into)
51    }
52
53    /// Find the tightest-enclosing symbol at a given file + line.
54    ///
55    /// tx-safe: read-only single statement — see the LSP-section header note.
56    pub fn find_symbol_at_location(&self, file_path: &str, line: u32) -> Result<Option<String>> {
57        let id: Option<String> = self
58            .conn
59            .query_row(
60                "SELECT id FROM symbols
61                 WHERE file_path = ?1 AND start_line <= ?2 AND end_line >= ?2
62                 ORDER BY (end_line - start_line) ASC
63                 LIMIT 1",
64                params![file_path, line],
65                |row| row.get(0),
66            )
67            .optional()?;
68        Ok(id)
69    }
70
71    /// Update a single edge's target_id and flip it to `resolution_state = 1`.
72    ///
73    /// tx-safe: single statement — see the LSP-section header note. If you
74    /// ever batch this internally with `unchecked_transaction()`, also update
75    /// `index_directory` so it does not call `lsp_resolve_edges` inside its
76    /// outer transaction.
77    pub fn update_edge_target(&self, edge_id: i64, target_id: &str) -> Result<()> {
78        // Overwrites any heuristic provenance: an LSP definition is more precise.
79        self.conn.execute(
80            "UPDATE edges SET target_id = ?1, resolution_state = 1, resolution_source = ?2
81             WHERE id = ?3",
82            params![target_id, EdgeProvenance::Lsp.as_str(), edge_id],
83        )?;
84        Ok(())
85    }
86
87    /// Test-only inspector: returns true when the edge is at `resolution_state = 2`.
88    ///
89    /// Exposed because the column is otherwise crate-private, and downstream
90    /// integration tests (cartog-indexer) need a read-only way to assert the
91    /// marker state without snapshotting raw SQL.
92    pub fn is_edge_unresolvable(&self, edge_id: i64) -> Result<bool> {
93        Ok(self.edge_resolution_state(edge_id)? == 2)
94    }
95
96    /// Test-only inspector: returns the raw `resolution_state` value for an edge.
97    ///
98    /// 0=unresolved, 1=resolved, 2=unresolvable, 3=external, 4=heuristic-exhausted.
99    pub fn edge_resolution_state(&self, edge_id: i64) -> Result<i64> {
100        let state: i64 = self.conn.query_row(
101            "SELECT resolution_state FROM edges WHERE id = ?1",
102            params![edge_id],
103            |row| row.get(0),
104        )?;
105        Ok(state)
106    }
107
108    /// Whether any edge sits at `resolution_state = 4` (heuristic-exhausted).
109    /// Gates the MCP warm-LSP catch-up on no-op reindexes.
110    ///
111    /// tx-safe: read-only single statement — see the LSP-section header note.
112    pub fn has_heuristic_exhausted(&self) -> Result<bool> {
113        let exists: bool = self.conn.query_row(
114            "SELECT EXISTS(SELECT 1 FROM edges WHERE resolution_state = 4)",
115            [],
116            |row| row.get(0),
117        )?;
118        Ok(exists)
119    }
120
121    /// Test-only inspector: count edges at a given raw `resolution_state`
122    /// (see [`Self::edge_resolution_state`] for the state legend).
123    ///
124    /// tx-safe: read-only single statement — see the LSP-section header note.
125    pub fn count_edges_in_state(&self, state: i64) -> Result<u32> {
126        let n: u32 = self.conn.query_row(
127            "SELECT COUNT(*) FROM edges WHERE resolution_state = ?1",
128            params![state],
129            |row| row.get(0),
130        )?;
131        Ok(n)
132    }
133
134    /// Reset every edge at `resolution_state IN (2, 3, 4)` back to `0`. Used by
135    /// `cartog index --force` to honor the "retry everything" contract:
136    /// without this, the heuristic + LSP would still skip permanently-marked
137    /// edges (unresolvable, external, or heuristic-exhausted) even on a forced
138    /// re-index.
139    ///
140    /// tx-safe: single statement — see the LSP-section header note.
141    pub fn reset_all_unresolvable(&self) -> Result<u32> {
142        let n = self.conn.execute(
143            "UPDATE edges SET resolution_state = 0, resolution_source = NULL
144             WHERE resolution_state IN (2, 3, 4)",
145            [],
146        )?;
147        Ok(n as u32)
148    }
149
150    /// Reset `resolution_state` from `4` (heuristic-exhausted) → `0` so a
151    /// later LSP-enabled reindex retries them.
152    ///
153    /// State 4 is written by [`Self::mark_heuristic_exhausted_in_tx`] only in
154    /// LSP-disabled runs (`--no-lsp`, `watch`). When a subsequent `cartog
155    /// index` does run LSP, those edges have never seen the language server, so
156    /// the indexer reopens them here before the LSP pass. Distinct from
157    /// [`Self::reset_all_unresolvable`]: this leaves the genuine LSP verdicts
158    /// (state {2, 3}) sealed.
159    ///
160    /// tx-safe: single statement — see the LSP-section header note.
161    pub fn reopen_heuristic_exhausted(&self) -> Result<u32> {
162        let n = self.conn.execute(
163            "UPDATE edges SET resolution_state = 0 WHERE resolution_state = 4",
164            [],
165        )?;
166        Ok(n as u32)
167    }
168
169    /// Mark a single edge as `resolution_state = 2` (LSP definitively gave up).
170    ///
171    /// Callers MUST only invoke this after a definitive negative answer from
172    /// the language server. Never call from a transient-error branch (server
173    /// crash, didOpen failure, half-loaded warmup) — the marker is sticky
174    /// across runs until [`Self::reset_unresolvable_for_names`] reopens it
175    /// (on a matching new symbol) or [`Self::reset_all_unresolvable`] runs
176    /// (`--force`).
177    ///
178    /// The `WHERE resolution_state = 0` guard preserves the invariant that
179    /// state {2, 3} rows have `target_id IS NULL` — without it an accidental
180    /// call on a state=1 (resolved) edge would silently flip the state while
181    /// keeping the stale target, hiding a corrupted edge from
182    /// [`Self::unresolved_edges`].
183    ///
184    /// tx-safe: single statement — see the LSP-section header note.
185    pub fn mark_edge_unresolvable(&self, edge_id: i64) -> Result<()> {
186        self.conn.execute(
187            "UPDATE edges SET resolution_state = 2, resolution_source = ?2
188             WHERE id = ?1 AND resolution_state = 0",
189            params![edge_id, EdgeProvenance::LspUnresolvable.as_str()],
190        )?;
191        Ok(())
192    }
193
194    /// Mark a single edge as `resolution_state = 3` (LSP located the target
195    /// outside the indexed root — stdlib, third-party deps, node_modules).
196    ///
197    /// Same stickiness contract as [`Self::mark_edge_unresolvable`]: only call
198    /// after a definitive positive answer naming an out-of-root URI;
199    /// reopened by the same name-keyed and force-reset paths. The
200    /// `WHERE resolution_state = 0` guard preserves the `target_id IS NULL`
201    /// invariant for state=3 rows.
202    ///
203    /// tx-safe: single statement — see the LSP-section header note.
204    pub fn mark_edge_external(&self, edge_id: i64) -> Result<()> {
205        self.conn.execute(
206            "UPDATE edges SET resolution_state = 3, resolution_source = ?2
207             WHERE id = ?1 AND resolution_state = 0",
208            params![edge_id, EdgeProvenance::LspExternal.as_str()],
209        )?;
210        Ok(())
211    }
212
213    /// Reset `resolution_state` from {2, 3, 4} → 0 for edges whose target_name is in `names`.
214    ///
215    /// Called from the indexer when new symbols are added: an edge previously
216    /// "unresolvable" (no symbol with this name), "external" (target outside
217    /// the index, now vendored in-tree), or "heuristic-exhausted" may now
218    /// resolve against the freshly-added target. Returns edges reopened; no-op
219    /// when `names` is empty.
220    ///
221    /// tx-safe: single statement. Names are batched to honor SQLite's default
222    /// 999-parameter limit; only rows at state {2, 3, 4} are touched so the
223    /// write set stays tiny even on a large rename.
224    pub fn reset_unresolvable_for_names(&self, names: &[String]) -> Result<u32> {
225        if names.is_empty() {
226            return Ok(0);
227        }
228        const CHUNK: usize = 500;
229        let mut total: u32 = 0;
230        for chunk in names.chunks(CHUNK) {
231            let placeholders = vec!["?"; chunk.len()].join(",");
232            let sql = format!(
233                "UPDATE edges
234                 SET resolution_state = 0, resolution_source = NULL
235                 WHERE resolution_state IN (2, 3, 4)
236                   AND target_name IN ({placeholders})"
237            );
238            let params: Vec<&dyn rusqlite::ToSql> =
239                chunk.iter().map(|n| n as &dyn rusqlite::ToSql).collect();
240            let n = self.conn.execute(&sql, params.as_slice())?;
241            total += n as u32;
242        }
243        Ok(total)
244    }
245}