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