1use std::collections::BTreeMap;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use anyhow::Context as _;
12
13use crate::config::Context;
14use crate::graph::typed_query::{TypedQuery, TypedValue};
15use crate::models::{
16 CallRelation, CallTargetKind, ImportRelation, Symbol, make_external_symbol_id,
17 make_unresolved_callee_id,
18};
19use gobby_core::degradation::ServiceState;
20use gobby_core::falkor::GraphClient;
21
22use super::GraphReadError;
23use super::connection::with_required_core_graph;
24
25const PROJECT_NODE_PREDICATE: &str =
26 "n:CodeFile OR n:CodeSymbol OR n:CodeModule OR n:UnresolvedCallee OR n:ExternalSymbol";
27const EXTRACTED_PROVENANCE: &str = "EXTRACTED";
28const SOURCE_SYSTEM_GCODE: &str = crate::models::SOURCE_SYSTEM_GCODE;
29const PROJECT_INDEXED_LABELS: &[&str] = &[
30 "CodeFile",
31 "CodeSymbol",
32 "CodeModule",
33 "UnresolvedCallee",
34 "ExternalSymbol",
35];
36static SYNC_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(0);
37const ADD_IMPORTS_CYPHER: &str = "UNWIND $imports AS import
38 MERGE (f:CodeFile {path: import.source_file, project: $project})
39 MERGE (m:CodeModule {name: import.target_module, project: $project})
40 MERGE (f)-[r:IMPORTS]->(m)
41 SET r.provenance = $provenance,
42 r.confidence = $confidence,
43 r.source_system = $source_system,
44 r.source_file_path = import.source_file,
45 r.sync_token = $sync_token";
46const ADD_DEFINITIONS_CYPHER: &str = "UNWIND $symbols AS symbol
47 MERGE (f:CodeFile {path: $file_path, project: $project})
48 MERGE (s:CodeSymbol {id: symbol.id, project: $project})
49 SET s.name = symbol.name,
50 s.qualified_name = symbol.qualified_name,
51 s.kind = symbol.kind,
52 s.language = symbol.language,
53 s.file_path = $file_path,
54 s.line_start = symbol.line_start,
55 s.line_end = symbol.line_end,
56 s.updated_at = timestamp(),
57 s.sync_token = $sync_token
58 MERGE (f)-[r:DEFINES]->(s)
59 SET r.provenance = $provenance,
60 r.confidence = $confidence,
61 r.source_system = $source_system,
62 r.source_file_path = $file_path,
63 r.source_line = symbol.line_start,
64 r.source_symbol_id = symbol.id,
65 r.sync_token = $sync_token";
66const ADD_SYMBOL_CALLS_CYPHER: &str = "UNWIND $symbol_calls AS call
67 MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
68 MERGE (callee:CodeSymbol {id: call.target_id, project: $project})
69 ON CREATE SET callee.name = call.callee_name, callee.updated_at = timestamp()
70 MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
71 SET r.provenance = $provenance,
72 r.confidence = $confidence,
73 r.source_system = $source_system,
74 r.source_file_path = call.file_path,
75 r.source_line = call.line,
76 r.source_symbol_id = call.caller_id,
77 r.sync_token = $sync_token";
78const ADD_EXTERNAL_CALLS_CYPHER: &str = "UNWIND $external_calls AS call
79 MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
80 MERGE (callee:ExternalSymbol {id: call.target_id, project: $project})
81 ON CREATE SET callee.name = call.callee_name,
82 callee.external_module = call.callee_module,
83 callee.module = call.callee_module,
84 callee.updated_at = timestamp(),
85 callee.sync_token = $sync_token
86 MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
87 SET r.provenance = $provenance,
88 r.confidence = $confidence,
89 r.source_system = $source_system,
90 r.source_file_path = call.file_path,
91 r.source_line = call.line,
92 r.source_symbol_id = call.caller_id,
93 r.sync_token = $sync_token";
94const ADD_UNRESOLVED_CALLS_CYPHER: &str = "UNWIND $unresolved_calls AS call
95 MERGE (caller:CodeSymbol {id: call.caller_id, project: $project})
96 MERGE (callee:UnresolvedCallee {id: call.target_id, project: $project})
97 ON CREATE SET callee.name = call.callee_name,
98 callee.updated_at = timestamp(),
99 callee.sync_token = $sync_token
100 MERGE (caller)-[r:CALLS {file: call.file_path, line: call.line}]->(callee)
101 SET r.provenance = $provenance,
102 r.confidence = $confidence,
103 r.source_system = $source_system,
104 r.source_file_path = call.file_path,
105 r.source_line = call.line,
106 r.source_symbol_id = call.caller_id,
107 r.sync_token = $sync_token";
108
109pub struct CodeGraph<'a> {
110 project_id: &'a str,
111 client: &'a mut GraphClient,
112}
113
114impl<'a> CodeGraph<'a> {
115 pub fn new(project_id: &'a str, client: &'a mut GraphClient) -> Self {
116 Self { project_id, client }
117 }
118
119 pub fn sync_file(
120 &mut self,
121 file_path: &str,
122 imports: &[ImportRelation],
123 definitions: &[Symbol],
124 calls: &[CallRelation],
125 cleanup_orphans: bool,
126 ) -> anyhow::Result<usize> {
127 let sync_token = new_sync_token(file_path);
128 let import_items = import_graph_items(file_path, imports);
129 let symbols = definition_graph_symbols(definitions);
130 let current_symbol_ids = symbols
131 .iter()
132 .map(|symbol| symbol.id.clone())
133 .collect::<Vec<_>>();
134 let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
135 let relationship_count = import_items.len()
136 + symbols.len()
137 + call_groups.symbol.len()
138 + call_groups.external.len()
139 + call_groups.unresolved.len();
140 execute_write_query(
141 self.client,
142 sync_file_mutation_query(SyncFileMutation {
143 project_id: self.project_id,
144 file_path,
145 symbol_count: definitions.len(),
146 imports: &import_items,
147 symbols: &symbols,
148 calls: &call_groups,
149 sync_token: &sync_token,
150 })?,
151 )?;
152 self.delete_stale_file_graph(file_path, ¤t_symbol_ids, &sync_token)?;
153 if cleanup_orphans {
154 self.cleanup_orphans()?;
155 }
156 Ok(relationship_count)
157 }
158
159 pub fn ensure_project_indexes(&mut self) -> anyhow::Result<()> {
160 for label in PROJECT_INDEXED_LABELS {
161 self.client.ensure_exact_node_index(label, "project")?;
162 }
163 Ok(())
164 }
165
166 pub fn ensure_file_node(
167 &mut self,
168 file_path: &str,
169 symbol_count: usize,
170 sync_token: &str,
171 ) -> anyhow::Result<()> {
172 execute_write_query(
173 self.client,
174 ensure_file_node_query(self.project_id, file_path, symbol_count, sync_token)?,
175 )
176 }
177
178 pub fn add_imports(
179 &mut self,
180 file_path: &str,
181 imports: &[ImportRelation],
182 sync_token: &str,
183 ) -> anyhow::Result<usize> {
184 let items = import_graph_items(file_path, imports);
185 if items.is_empty() {
186 return Ok(0);
187 }
188 let written = items.len();
189 execute_write_query(
190 self.client,
191 add_imports_query(self.project_id, &items, sync_token)?,
192 )?;
193 Ok(written)
194 }
195
196 pub fn add_definitions(
197 &mut self,
198 file_path: &str,
199 definitions: &[Symbol],
200 sync_token: &str,
201 ) -> anyhow::Result<usize> {
202 let symbols = definitions
203 .iter()
204 .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
205 .collect::<Vec<_>>();
206 if symbols.is_empty() {
207 return Ok(0);
208 }
209 let written = symbols.len();
210 execute_write_query(
211 self.client,
212 add_definitions_query(self.project_id, file_path, &symbols, sync_token)?,
213 )?;
214 Ok(written)
215 }
216
217 pub fn add_calls(
218 &mut self,
219 file_path: &str,
220 calls: &[CallRelation],
221 sync_token: &str,
222 ) -> anyhow::Result<usize> {
223 let call_groups = partition_call_graph_items(self.project_id, file_path, calls);
224
225 let mut written = 0;
226 if !call_groups.symbol.is_empty() {
227 written += call_groups.symbol.len();
228 execute_write_query(
229 self.client,
230 add_symbol_calls_query(self.project_id, &call_groups.symbol, sync_token)?,
231 )?;
232 }
233 if !call_groups.external.is_empty() {
234 written += call_groups.external.len();
235 execute_write_query(
236 self.client,
237 add_external_calls_query(self.project_id, &call_groups.external, sync_token)?,
238 )?;
239 }
240 if !call_groups.unresolved.is_empty() {
241 written += call_groups.unresolved.len();
242 execute_write_query(
243 self.client,
244 add_unresolved_calls_query(self.project_id, &call_groups.unresolved, sync_token)?,
245 )?;
246 }
247 Ok(written)
248 }
249
250 pub fn delete_stale_file_graph(
251 &mut self,
252 file_path: &str,
253 current_symbol_ids: &[String],
254 sync_token: &str,
255 ) -> anyhow::Result<()> {
256 for query in delete_stale_file_graph_queries(
257 self.project_id,
258 file_path,
259 current_symbol_ids,
260 sync_token,
261 )? {
262 execute_write_query(self.client, query)?;
263 }
264 Ok(())
265 }
266
267 pub fn delete_file_graph(
268 &mut self,
269 file_path: &str,
270 current_symbol_ids: &[String],
271 ) -> anyhow::Result<()> {
272 for query in delete_file_graph_queries(self.project_id, file_path, current_symbol_ids)? {
273 execute_write_query(self.client, query)?;
274 }
275 Ok(())
276 }
277
278 pub fn delete_file_node(&mut self, file_path: &str) -> anyhow::Result<()> {
279 execute_write_query(
280 self.client,
281 delete_file_node_query(self.project_id, file_path)?,
282 )
283 }
284
285 pub fn delete_file_projection(&mut self, file_path: &str) -> anyhow::Result<()> {
286 self.delete_file_graph(file_path, &[])?;
287 self.delete_file_node(file_path)?;
288 self.cleanup_orphans()
289 }
290
291 pub fn cleanup_orphans(&mut self) -> anyhow::Result<()> {
292 for query in cleanup_orphans_queries(self.project_id)? {
293 execute_write_query(self.client, query)?;
294 }
295 Ok(())
296 }
297
298 pub fn clear_project(&mut self) -> anyhow::Result<()> {
299 execute_write_query(self.client, clear_project_query(self.project_id)?)
300 }
301}
302
303pub fn sync_file_graph(
304 ctx: &Context,
305 file_path: &str,
306 imports: &[ImportRelation],
307 definitions: &[Symbol],
308 calls: &[CallRelation],
309 cleanup_orphans: bool,
310) -> anyhow::Result<usize> {
311 with_code_graph(ctx, |graph| {
312 graph.sync_file(file_path, imports, definitions, calls, cleanup_orphans)
313 })
314}
315
316pub fn with_code_graph<T>(
317 ctx: &Context,
318 f: impl FnOnce(&mut CodeGraph<'_>) -> anyhow::Result<T>,
319) -> anyhow::Result<T> {
320 with_required_core_graph(ctx, |client| {
321 let mut graph = CodeGraph::new(&ctx.project_id, client);
322 graph.ensure_project_indexes()?;
323 f(&mut graph)
324 })
325}
326
327pub fn delete_file_graph(
328 ctx: &Context,
329 file_path: &str,
330 current_symbol_ids: &[String],
331) -> anyhow::Result<()> {
332 with_required_core_graph(ctx, |client| {
333 CodeGraph::new(&ctx.project_id, client).delete_file_graph(file_path, current_symbol_ids)
334 })
335}
336
337pub fn delete_file_projection(ctx: &Context, file_path: &str) -> anyhow::Result<()> {
338 with_required_core_graph(ctx, |client| {
339 CodeGraph::new(&ctx.project_id, client).delete_file_projection(file_path)
340 })
341}
342
343pub fn cleanup_orphans(ctx: &Context) -> anyhow::Result<()> {
344 with_code_graph(ctx, |graph| graph.cleanup_orphans())
345}
346
347pub fn clear_project(ctx: &Context) -> anyhow::Result<()> {
348 with_required_core_graph(ctx, |client| {
349 CodeGraph::new(&ctx.project_id, client).clear_project()
350 })
351}
352
353pub fn clear_all_code_index(config: &crate::config::FalkorConfig) -> anyhow::Result<()> {
354 let connection_config = config.connection_config();
355 match gobby_core::falkor::with_graph(
356 Some(&connection_config),
357 &config.graph_name,
358 None,
359 |client| execute_write_query(client, clear_all_code_index_query()?).map(Some),
360 ) {
361 Ok((Some(()), ServiceState::Available)) => Ok(()),
362 Ok((_, ServiceState::NotConfigured)) => Err(GraphReadError::NotConfigured.into()),
363 Ok((_, ServiceState::Unreachable { message })) => {
364 log::warn!("FalkorDB was unreachable while clearing code graph: {message}");
365 Err(GraphReadError::Unreachable { message }.into())
366 }
367 Ok((None, ServiceState::Available)) => Err(GraphReadError::QueryFailed {
368 message: "graph clear returned no value".to_string(),
369 }
370 .into()),
371 Err(error) => Err(GraphReadError::QueryFailed {
372 message: error.to_string(),
373 }
374 .into()),
375 }
376}
377
378fn execute_write_query(client: &mut GraphClient, query: TypedQuery) -> anyhow::Result<()> {
379 let TypedQuery { cypher, params } = query;
380 client.query(&cypher, Some(params))?;
381 Ok(())
382}
383
384fn new_sync_token(file_path: &str) -> String {
385 let nanos = SystemTime::now()
386 .duration_since(UNIX_EPOCH)
387 .map(|duration| duration.as_nanos())
388 .unwrap_or_default();
389 let suffix = SYNC_TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed);
390 format!("{}:{}:{nanos}:{suffix}", std::process::id(), file_path)
391}
392
393fn typed_query<I, K>(cypher: impl Into<String>, params: I) -> anyhow::Result<TypedQuery>
394where
395 I: IntoIterator<Item = (K, TypedValue)>,
396 K: Into<String>,
397{
398 Ok(TypedQuery::with_params(cypher, params)?)
399}
400
401fn usize_value(value: usize) -> anyhow::Result<TypedValue> {
402 Ok(TypedValue::Integer(i64::try_from(value).context(
403 "graph integer value exceeds FalkorDB i64 range",
404 )?))
405}
406
407#[derive(Debug, Clone)]
408pub(super) struct ImportGraphItem {
409 pub(super) source_file: String,
410 target_module: String,
411}
412
413#[derive(Debug, Clone)]
414pub(super) struct CallGraphItem {
415 caller_id: String,
416 target_id: String,
417 callee_name: String,
418 pub(super) file_path: String,
419 line: usize,
420 callee_module: Option<String>,
421}
422
423#[derive(Debug, Clone, Default)]
424pub(super) struct CallGraphItems {
425 symbol: Vec<CallGraphItem>,
426 external: Vec<CallGraphItem>,
427 pub(super) unresolved: Vec<CallGraphItem>,
428}
429
430fn map_value(values: impl IntoIterator<Item = (&'static str, TypedValue)>) -> TypedValue {
431 TypedValue::Map(
432 values
433 .into_iter()
434 .map(|(key, value)| (key.to_string(), value))
435 .collect::<BTreeMap<_, _>>(),
436 )
437}
438
439pub(super) fn import_graph_items(
440 file_path: &str,
441 imports: &[ImportRelation],
442) -> Vec<ImportGraphItem> {
443 imports
444 .iter()
445 .filter(|import| !import.module_name.is_empty())
446 .map(|import| ImportGraphItem {
447 source_file: file_path.to_string(),
448 target_module: import.module_name.clone(),
449 })
450 .collect()
451}
452
453fn definition_graph_symbols(definitions: &[Symbol]) -> Vec<&Symbol> {
454 definitions
455 .iter()
456 .filter(|symbol| !symbol.id.is_empty() && !symbol.name.is_empty())
457 .collect()
458}
459
460pub(super) fn partition_call_graph_items(
461 project_id: &str,
462 file_path: &str,
463 calls: &[CallRelation],
464) -> CallGraphItems {
465 let mut groups = CallGraphItems::default();
466 for call in calls {
467 if call.caller_symbol_id.is_empty() {
468 continue;
469 }
470 let Some(target) = GraphCallTarget::from_call(project_id, call) else {
471 continue;
472 };
473 let item = CallGraphItem {
474 caller_id: call.caller_symbol_id.clone(),
475 target_id: target.id().to_string(),
476 callee_name: call.callee_name.clone(),
477 file_path: file_path.to_string(),
478 line: call.line,
479 callee_module: target.module().map(str::to_string),
480 };
481 match target {
482 GraphCallTarget::Symbol { .. } => groups.symbol.push(item),
483 GraphCallTarget::External { .. } => groups.external.push(item),
484 GraphCallTarget::Unresolved { .. } => groups.unresolved.push(item),
485 }
486 }
487 groups
488}
489
490fn metadata_params(sync_token: &str) -> Vec<(&'static str, TypedValue)> {
491 vec![
492 (
493 "provenance",
494 TypedValue::String(EXTRACTED_PROVENANCE.to_string()),
495 ),
496 ("confidence", TypedValue::Float(1.0)),
497 (
498 "source_system",
499 TypedValue::String(SOURCE_SYSTEM_GCODE.to_string()),
500 ),
501 sync_token_param(sync_token),
502 ]
503}
504
505fn sync_token_param(sync_token: &str) -> (&'static str, TypedValue) {
506 ("sync_token", TypedValue::String(sync_token.to_string()))
507}
508
509fn append_sync_segment(cypher: &mut String, segment: &str) {
510 if !cypher.is_empty() {
511 cypher.push_str("\nWITH DISTINCT 1 AS _\n");
512 }
513 cypher.push_str(segment);
514}
515
516struct SyncFileMutation<'a> {
517 project_id: &'a str,
518 file_path: &'a str,
519 symbol_count: usize,
520 imports: &'a [ImportGraphItem],
521 symbols: &'a [&'a Symbol],
522 calls: &'a CallGraphItems,
523 sync_token: &'a str,
524}
525
526fn sync_file_mutation_query(input: SyncFileMutation<'_>) -> anyhow::Result<TypedQuery> {
527 let mut cypher = String::new();
528 append_sync_segment(
529 &mut cypher,
530 "MERGE (f:CodeFile {path: $file_path, project: $project})
531 SET f.updated_at = timestamp(),
532 f.symbol_count = $symbol_count,
533 f.sync_token = $sync_token",
534 );
535 if !input.imports.is_empty() {
536 append_sync_segment(&mut cypher, ADD_IMPORTS_CYPHER);
537 }
538 if !input.symbols.is_empty() {
539 append_sync_segment(&mut cypher, ADD_DEFINITIONS_CYPHER);
540 }
541 if !input.calls.symbol.is_empty() {
542 append_sync_segment(&mut cypher, ADD_SYMBOL_CALLS_CYPHER);
543 }
544 if !input.calls.external.is_empty() {
545 append_sync_segment(&mut cypher, ADD_EXTERNAL_CALLS_CYPHER);
546 }
547 if !input.calls.unresolved.is_empty() {
548 append_sync_segment(&mut cypher, ADD_UNRESOLVED_CALLS_CYPHER);
549 }
550 let mut params = vec![
551 ("project", TypedValue::String(input.project_id.to_string())),
552 ("file_path", TypedValue::String(input.file_path.to_string())),
553 ("symbol_count", usize_value(input.symbol_count)?),
554 ("imports", import_rows(input.imports)),
555 ("symbols", symbol_rows(input.symbols)?),
556 ("symbol_calls", call_rows(&input.calls.symbol)?),
557 ("external_calls", call_rows(&input.calls.external)?),
558 ("unresolved_calls", call_rows(&input.calls.unresolved)?),
559 ];
560 params.extend(metadata_params(input.sync_token));
561 typed_query(cypher, params)
562}
563
564pub(crate) fn ensure_file_node_query(
565 project_id: &str,
566 file_path: &str,
567 symbol_count: usize,
568 sync_token: &str,
569) -> anyhow::Result<TypedQuery> {
570 typed_query(
571 "MERGE (f:CodeFile {path: $file_path, project: $project})
572 SET f.updated_at = timestamp(),
573 f.symbol_count = $symbol_count,
574 f.sync_token = $sync_token",
575 [
576 ("project", TypedValue::String(project_id.to_string())),
577 ("file_path", TypedValue::String(file_path.to_string())),
578 ("symbol_count", usize_value(symbol_count)?),
579 sync_token_param(sync_token),
580 ],
581 )
582}
583
584fn add_imports_query(
585 project_id: &str,
586 imports: &[ImportGraphItem],
587 sync_token: &str,
588) -> anyhow::Result<TypedQuery> {
589 let mut params = vec![
590 ("project", TypedValue::String(project_id.to_string())),
591 (
592 "imports",
593 TypedValue::List(
594 imports
595 .iter()
596 .map(|import| {
597 map_value([
598 (
599 "source_file",
600 TypedValue::String(import.source_file.clone()),
601 ),
602 (
603 "target_module",
604 TypedValue::String(import.target_module.clone()),
605 ),
606 ])
607 })
608 .collect(),
609 ),
610 ),
611 ];
612 params.extend(metadata_params(sync_token));
613 typed_query(ADD_IMPORTS_CYPHER, params)
614}
615
616fn add_definitions_query(
617 project_id: &str,
618 file_path: &str,
619 symbols: &[&Symbol],
620 sync_token: &str,
621) -> anyhow::Result<TypedQuery> {
622 let mut params = vec![
623 ("project", TypedValue::String(project_id.to_string())),
624 ("file_path", TypedValue::String(file_path.to_string())),
625 (
626 "symbols",
627 TypedValue::List(
628 symbols
629 .iter()
630 .map(|symbol| {
631 Ok(map_value([
632 ("id", TypedValue::String(symbol.id.clone())),
633 ("name", TypedValue::String(symbol.name.clone())),
634 (
635 "qualified_name",
636 TypedValue::String(symbol.qualified_name.clone()),
637 ),
638 ("kind", TypedValue::String(symbol.kind.clone())),
639 ("language", TypedValue::String(symbol.language.clone())),
640 ("line_start", usize_value(symbol.line_start)?),
641 ("line_end", usize_value(symbol.line_end)?),
642 ]))
643 })
644 .collect::<anyhow::Result<Vec<_>>>()?,
645 ),
646 ),
647 ];
648 params.extend(metadata_params(sync_token));
649 typed_query(ADD_DEFINITIONS_CYPHER, params)
650}
651
652enum GraphCallTarget {
653 Symbol { id: String },
654 External { id: String, module: String },
655 Unresolved { id: String },
656}
657
658impl GraphCallTarget {
659 fn from_call(project_id: &str, call: &CallRelation) -> Option<Self> {
660 if let Some(id) = call.callee_symbol_id.as_deref().filter(|id| !id.is_empty()) {
661 return Some(Self::Symbol { id: id.to_string() });
662 }
663 if call.callee_name.is_empty() {
664 return None;
665 }
666 if call.callee_target_kind == CallTargetKind::External {
667 let module = call.callee_external_module.clone().unwrap_or_default();
668 return Some(Self::External {
669 id: make_external_symbol_id(project_id, &call.callee_name, Some(&module)),
670 module,
671 });
672 }
673 Some(Self::Unresolved {
674 id: make_unresolved_callee_id(project_id, &call.callee_name),
675 })
676 }
677
678 fn id(&self) -> &str {
679 match self {
680 Self::Symbol { id } | Self::External { id, .. } | Self::Unresolved { id } => id,
681 }
682 }
683
684 fn module(&self) -> Option<&str> {
685 match self {
686 Self::External { module, .. } => Some(module),
687 Self::Symbol { .. } | Self::Unresolved { .. } => None,
688 }
689 }
690}
691
692pub fn call_target_id(project_id: &str, call: &CallRelation) -> Option<String> {
693 match GraphCallTarget::from_call(project_id, call)? {
694 GraphCallTarget::Symbol { id }
695 | GraphCallTarget::External { id, .. }
696 | GraphCallTarget::Unresolved { id } => Some(id),
697 }
698}
699
700fn call_rows(calls: &[CallGraphItem]) -> anyhow::Result<TypedValue> {
701 Ok(TypedValue::List(
702 calls
703 .iter()
704 .map(|call| {
705 Ok(map_value([
706 ("caller_id", TypedValue::String(call.caller_id.clone())),
707 ("target_id", TypedValue::String(call.target_id.clone())),
708 ("callee_name", TypedValue::String(call.callee_name.clone())),
709 ("file_path", TypedValue::String(call.file_path.clone())),
710 ("line", usize_value(call.line)?),
711 (
712 "callee_module",
713 TypedValue::String(call.callee_module.clone().unwrap_or_default()),
714 ),
715 ]))
716 })
717 .collect::<anyhow::Result<Vec<_>>>()?,
718 ))
719}
720
721fn import_rows(imports: &[ImportGraphItem]) -> TypedValue {
722 TypedValue::List(
723 imports
724 .iter()
725 .map(|import| {
726 map_value([
727 (
728 "source_file",
729 TypedValue::String(import.source_file.clone()),
730 ),
731 (
732 "target_module",
733 TypedValue::String(import.target_module.clone()),
734 ),
735 ])
736 })
737 .collect(),
738 )
739}
740
741fn symbol_rows(symbols: &[&Symbol]) -> anyhow::Result<TypedValue> {
742 Ok(TypedValue::List(
743 symbols
744 .iter()
745 .map(|symbol| {
746 Ok(map_value([
747 ("id", TypedValue::String(symbol.id.clone())),
748 ("name", TypedValue::String(symbol.name.clone())),
749 (
750 "qualified_name",
751 TypedValue::String(symbol.qualified_name.clone()),
752 ),
753 ("kind", TypedValue::String(symbol.kind.clone())),
754 ("language", TypedValue::String(symbol.language.clone())),
755 ("line_start", usize_value(symbol.line_start)?),
756 ("line_end", usize_value(symbol.line_end)?),
757 ]))
758 })
759 .collect::<anyhow::Result<Vec<_>>>()?,
760 ))
761}
762
763fn add_symbol_calls_query(
764 project_id: &str,
765 calls: &[CallGraphItem],
766 sync_token: &str,
767) -> anyhow::Result<TypedQuery> {
768 let mut params = vec![
769 ("project", TypedValue::String(project_id.to_string())),
770 ("symbol_calls", call_rows(calls)?),
771 ];
772 params.extend(metadata_params(sync_token));
773 typed_query(ADD_SYMBOL_CALLS_CYPHER, params)
774}
775
776fn add_external_calls_query(
777 project_id: &str,
778 calls: &[CallGraphItem],
779 sync_token: &str,
780) -> anyhow::Result<TypedQuery> {
781 let mut params = vec![
782 ("project", TypedValue::String(project_id.to_string())),
783 ("external_calls", call_rows(calls)?),
784 ];
785 params.extend(metadata_params(sync_token));
786 typed_query(ADD_EXTERNAL_CALLS_CYPHER, params)
787}
788
789fn add_unresolved_calls_query(
790 project_id: &str,
791 calls: &[CallGraphItem],
792 sync_token: &str,
793) -> anyhow::Result<TypedQuery> {
794 let mut params = vec![
795 ("project", TypedValue::String(project_id.to_string())),
796 ("unresolved_calls", call_rows(calls)?),
797 ];
798 params.extend(metadata_params(sync_token));
799 typed_query(ADD_UNRESOLVED_CALLS_CYPHER, params)
800}
801
802pub(crate) fn delete_file_graph_queries(
803 project_id: &str,
804 file_path: &str,
805 current_symbol_ids: &[String],
806) -> anyhow::Result<Vec<TypedQuery>> {
807 let base_params = || {
808 [
809 ("project", TypedValue::String(project_id.to_string())),
810 ("file_path", TypedValue::String(file_path.to_string())),
811 ]
812 };
813 let mut queries = vec![
814 typed_query(
815 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:IMPORTS]->(:CodeModule)
816 DELETE r",
817 base_params(),
818 )?,
819 typed_query(
820 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:DEFINES]->(:CodeSymbol)
821 DELETE r",
822 base_params(),
823 )?,
824 typed_query(
825 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})-[r:CALLS]->()
826 DELETE r",
827 base_params(),
828 )?,
829 ];
830
831 if current_symbol_ids.is_empty() {
832 queries.push(typed_query(
833 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
834 DETACH DELETE s",
835 base_params(),
836 )?);
837 } else {
838 let mut params = vec![
839 ("project", TypedValue::String(project_id.to_string())),
840 ("file_path", TypedValue::String(file_path.to_string())),
841 (
842 "symbol_ids",
843 TypedValue::List(
844 current_symbol_ids
845 .iter()
846 .map(|id| TypedValue::String(id.clone()))
847 .collect(),
848 ),
849 ),
850 ];
851 queries.push(typed_query(
852 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
853 WHERE NOT s.id IN $symbol_ids
854 DETACH DELETE s",
855 params.drain(..),
856 )?);
857 }
858
859 Ok(queries)
860}
861
862pub(crate) fn delete_stale_file_graph_queries(
863 project_id: &str,
864 file_path: &str,
865 current_symbol_ids: &[String],
866 sync_token: &str,
867) -> anyhow::Result<Vec<TypedQuery>> {
868 let base_params = || {
869 [
870 ("project", TypedValue::String(project_id.to_string())),
871 ("file_path", TypedValue::String(file_path.to_string())),
872 sync_token_param(sync_token),
873 ]
874 };
875 let mut queries = vec![
876 typed_query(
877 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:IMPORTS]->(:CodeModule {project: $project})
878 WHERE r.sync_token IS NULL OR r.sync_token <> $sync_token
879 DELETE r",
880 base_params(),
881 )?,
882 typed_query(
883 "MATCH (f:CodeFile {path: $file_path, project: $project})-[r:DEFINES]->(:CodeSymbol {project: $project})
884 WHERE r.sync_token IS NULL OR r.sync_token <> $sync_token
885 DELETE r",
886 base_params(),
887 )?,
888 typed_query(
889 "MATCH (s:CodeSymbol {project: $project})-[r:CALLS]->(n {project: $project})
890 WHERE (r.file = $file_path OR r.source_file_path = $file_path)
891 AND (r.sync_token IS NULL OR r.sync_token <> $sync_token)
892 DELETE r",
893 base_params(),
894 )?,
895 ];
896
897 let mut symbol_params = vec![
898 ("project", TypedValue::String(project_id.to_string())),
899 ("file_path", TypedValue::String(file_path.to_string())),
900 sync_token_param(sync_token),
901 ];
902 if current_symbol_ids.is_empty() {
903 queries.push(typed_query(
904 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
905 WHERE s.sync_token IS NULL OR s.sync_token <> $sync_token
906 DETACH DELETE s",
907 symbol_params,
908 )?);
909 } else {
910 symbol_params.push((
911 "symbol_ids",
912 TypedValue::List(
913 current_symbol_ids
914 .iter()
915 .map(|id| TypedValue::String(id.clone()))
916 .collect(),
917 ),
918 ));
919 queries.push(typed_query(
920 "MATCH (s:CodeSymbol {project: $project, file_path: $file_path})
921 WHERE (s.sync_token IS NULL OR s.sync_token <> $sync_token)
922 AND NOT s.id IN $symbol_ids
923 DETACH DELETE s",
924 symbol_params,
925 )?);
926 }
927
928 Ok(queries)
929}
930
931pub(crate) fn delete_file_node_query(
932 project_id: &str,
933 file_path: &str,
934) -> anyhow::Result<TypedQuery> {
935 typed_query(
936 "MATCH (f:CodeFile {path: $file_path, project: $project})
937 DETACH DELETE f",
938 [
939 ("project", TypedValue::String(project_id.to_string())),
940 ("file_path", TypedValue::String(file_path.to_string())),
941 ],
942 )
943}
944
945pub(crate) fn cleanup_orphans_queries(project_id: &str) -> anyhow::Result<Vec<TypedQuery>> {
946 let project_param = || [("project", TypedValue::String(project_id.to_string()))];
947 cleanup_orphans_cypher_segments()
950 .into_iter()
951 .map(|cypher| typed_query(cypher, project_param()))
952 .collect()
953}
954
955fn cleanup_orphans_cypher_segments() -> [&'static str; 3] {
956 [
957 "MATCH (m:CodeModule {project: $project})
958 WHERE NOT (:CodeFile {project: $project})-[:IMPORTS]->(m)
959 DETACH DELETE m",
960 "MATCH (n {project: $project})
961 WHERE (n:UnresolvedCallee OR n:ExternalSymbol)
962 AND NOT ({project: $project})-[:CALLS]->(n)
963 DETACH DELETE n",
964 "MATCH (s:CodeSymbol {project: $project})
965 WHERE s.file_path IS NULL
966 AND NOT (:CodeFile {project: $project})-[:DEFINES]->(s)
967 AND NOT ({project: $project})-[:CALLS]->(s)
968 AND NOT (s)-[:CALLS]->({project: $project})
969 DETACH DELETE s",
970 ]
971}
972
973pub(crate) fn clear_project_query(project_id: &str) -> anyhow::Result<TypedQuery> {
974 typed_query(
975 format!(
976 "MATCH (n {{project: $project}})
977 WHERE {PROJECT_NODE_PREDICATE}
978 DETACH DELETE n"
979 ),
980 [("project", TypedValue::String(project_id.to_string()))],
981 )
982}
983
984pub(crate) fn clear_all_code_index_query() -> anyhow::Result<TypedQuery> {
985 typed_query(
986 format!(
987 "MATCH (n)
988 WHERE {PROJECT_NODE_PREDICATE}
989 DETACH DELETE n"
990 ),
991 Vec::<(&str, TypedValue)>::new(),
992 )
993}