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}