1use crate::{
7 cli::{Cli, Command},
8 config::SymbolFilter,
9 fetch::{CachePolicy, FetchSource, HierarchyQuery, HierarchyResponse},
10 state::{
11 HierarchyDirection, HierarchyKind, NodeId, SourceLocation, SymbolIdentity, Viewport,
12 graph::RelationGraph,
13 },
14};
15
16use std::path::PathBuf;
17
18mod config;
19mod help;
20mod save;
21mod search;
22
23pub use help::HelpState;
24pub use save::{SaveState, SaveStatus};
25use search::refresh_search_items;
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum AnalysisBackend {
29 Lsp(String),
30 TreeSitter(String),
31 None,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum AnalysisPhase {
36 Inactive,
37 Ready,
38 Working,
39 Warning,
40 Error,
41 Disconnected,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct AnalysisStatus {
51 pub backend: AnalysisBackend,
52 pub phase: AnalysisPhase,
53 pub message: Option<String>,
54 pub percentage: Option<u32>,
55}
56
57impl AnalysisStatus {
58 pub fn inactive(message: impl Into<String>) -> Self {
59 Self {
60 backend: AnalysisBackend::None,
61 phase: AnalysisPhase::Inactive,
62 message: Some(message.into()),
63 percentage: None,
64 }
65 }
66
67 pub fn lsp(server: impl Into<String>, phase: AnalysisPhase) -> Self {
68 Self {
69 backend: AnalysisBackend::Lsp(server.into()),
70 phase,
71 message: None,
72 percentage: None,
73 }
74 }
75
76 pub fn tree_sitter(language: impl Into<String>, phase: AnalysisPhase) -> Self {
77 Self {
78 backend: AnalysisBackend::TreeSitter(language.into()),
79 phase,
80 message: None,
81 percentage: None,
82 }
83 }
84}
85
86#[derive(Clone, Copy, Debug, Eq, PartialEq)]
87pub enum SearchKind {
88 Call,
89 Type,
90}
91
92impl SearchKind {
93 pub fn hierarchy_kind(self) -> HierarchyKind {
94 match self {
95 Self::Call => HierarchyKind::Call,
96 Self::Type => HierarchyKind::Type,
97 }
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct SearchItem {
103 pub name: String,
104 pub container_name: Option<String>,
105 pub location: String,
106 pub source: Option<SourceLocation>,
107}
108
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub enum SearchStatus {
111 Debouncing,
112 Loading,
113 Ready,
114 Error(String),
115}
116
117#[derive(Debug)]
118pub struct SearchState {
119 pub kind: SearchKind,
120 pub input: String,
121 pub items: Vec<SearchItem>,
122 pub selected: Option<usize>,
123 pub status: SearchStatus,
124 candidates: Vec<SearchItem>,
125 request_id: u64,
126 provider_available: bool,
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct SearchRequest {
131 pub request_id: u64,
132 pub kind: SearchKind,
133 pub query: String,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct HierarchyLoadRequest {
138 pub request_id: u64,
139 pub node_id: NodeId,
140 pub query: HierarchyQuery,
141 pub cache_policy: CachePolicy,
142 previous_load_state: crate::state::LoadState,
143}
144
145#[derive(Debug)]
146pub struct App {
147 pub should_quit: bool,
148 pub workspace: PathBuf,
149 pub graph: RelationGraph,
150 pub selected: Option<NodeId>,
151 pub pending_key: Option<char>,
152 pub search: Option<SearchState>,
153 pub save: Option<SaveState>,
154 pub help: Option<HelpState>,
155 pub analysis_status: AnalysisStatus,
156 pub viewport: Viewport,
157 pub canvas_notice: Option<String>,
158 symbol_filter: SymbolFilter,
159 analysis_error: Option<String>,
160 next_search_request_id: u64,
161 next_hierarchy_request_id: u64,
162}
163
164impl App {
165 pub fn from_cli(cli: Cli) -> Self {
166 let mut graph = RelationGraph::default();
167 let workspace = cli.workspace.clone();
168 let selected = cli.command.map(|command| {
169 let (symbol, kind) = match command {
170 Command::Call { symbol } => (symbol, HierarchyKind::Call),
171 Command::Type { symbol } => (symbol, HierarchyKind::Type),
172 };
173 graph.pin_symbol(SymbolIdentity {
174 symbol,
175 kind,
176 location: None,
177 })
178 });
179
180 Self {
181 should_quit: false,
182 workspace,
183 graph,
184 selected,
185 pending_key: None,
186 search: None,
187 save: None,
188 help: None,
189 analysis_status: AnalysisStatus::inactive("No analysis backend"),
190 viewport: Viewport::default(),
191 canvas_notice: None,
192 symbol_filter: SymbolFilter::default(),
193 analysis_error: None,
194 next_search_request_id: 1,
195 next_hierarchy_request_id: 1,
196 }
197 }
198
199 pub fn quit(&mut self) {
200 self.should_quit = true;
201 }
202
203 pub fn set_analysis_error(&mut self, error: impl Into<String>) {
204 self.analysis_error = Some(error.into());
205 }
206
207 pub fn set_analysis_status(&mut self, status: AnalysisStatus) {
208 self.analysis_status = status;
209 }
210
211 pub fn pan_viewport(&mut self, delta_x: i32, delta_y: i32) {
212 self.viewport.offset_x = self.viewport.offset_x.saturating_add(delta_x);
213 self.viewport.offset_y = self.viewport.offset_y.saturating_add(delta_y);
214 }
215
216 pub fn open_search(
217 &mut self,
218 kind: SearchKind,
219 provider_available: bool,
220 ) -> Option<SearchRequest> {
221 let status = if provider_available {
222 SearchStatus::Debouncing
223 } else {
224 SearchStatus::Error(
225 self.analysis_error
226 .clone()
227 .unwrap_or_else(|| "No workspace-symbol provider is available".to_owned()),
228 )
229 };
230 self.pending_key = None;
231 self.search = Some(SearchState {
232 kind,
233 input: String::new(),
234 items: Vec::new(),
235 selected: None,
236 status,
237 candidates: Vec::new(),
238 request_id: 0,
239 provider_available,
240 });
241
242 self.request_current_search()
243 }
244
245 pub fn close_search(&mut self) {
246 self.search = None;
247 }
248
249 pub fn push_search_char(&mut self, character: char) -> Option<SearchRequest> {
250 let search = self.search.as_mut()?;
251 search.input.push(character);
252 refresh_search_items(search);
253 self.request_current_search()
254 }
255
256 pub fn pop_search_char(&mut self) -> Option<SearchRequest> {
257 let search = self.search.as_mut()?;
258 search.input.pop();
259 refresh_search_items(search);
260 self.request_current_search()
261 }
262
263 pub fn finish_search(&mut self, request_id: u64, result: Result<Vec<SearchItem>, String>) {
264 let symbol_filter = &self.symbol_filter;
265 let Some(search) = self.search.as_mut() else {
266 return;
267 };
268 if search.request_id != request_id {
271 return;
272 }
273
274 match result {
275 Ok(candidates) => {
276 search.candidates = candidates
277 .into_iter()
278 .filter(|candidate| !symbol_filter.is_ignored(&candidate.name))
279 .collect();
280 search.status = SearchStatus::Ready;
281 refresh_search_items(search);
282 }
283 Err(error) => {
284 search.candidates.clear();
285 search.items.clear();
286 search.selected = None;
287 search.status = SearchStatus::Error(error);
288 }
289 }
290 }
291
292 pub fn start_search(&mut self, request_id: u64) {
293 let Some(search) = self.search.as_mut() else {
294 return;
295 };
296 if search.request_id == request_id {
297 search.status = SearchStatus::Loading;
298 }
299 }
300
301 pub fn select_search_item(&mut self, index: usize) {
302 let Some(search) = self.search.as_mut() else {
303 return;
304 };
305 if index < search.items.len() {
306 search.selected = Some(index);
307 }
308 }
309
310 pub fn move_search_selection(&mut self, offset: isize) {
311 let Some(search) = self.search.as_mut() else {
312 return;
313 };
314 if search.items.is_empty() {
315 search.selected = None;
316 return;
317 }
318
319 let current = search.selected.unwrap_or(0);
320 let last = search.items.len() - 1;
321 search.selected = Some(current.saturating_add_signed(offset).min(last));
322 }
323
324 pub fn accept_search_selection(&mut self) {
325 let Some(search) = self.search.as_ref() else {
326 return;
327 };
328 let Some(item) = search.selected.and_then(|index| search.items.get(index)) else {
329 return;
330 };
331 let node_id = self.graph.pin_symbol(SymbolIdentity {
332 symbol: item.name.clone(),
333 kind: search.kind.hierarchy_kind(),
334 location: item.source.clone(),
335 });
336 self.selected = Some(node_id);
337 self.viewport = Viewport::default();
338 self.close_search();
339 }
340
341 pub fn focus_symbol(&mut self, identity: SymbolIdentity) -> Result<NodeId, String> {
342 if identity.symbol.trim().is_empty() {
343 return Err("symbol must not be empty".to_owned());
344 }
345 if let Some(location) = &identity.location
346 && (location
347 .uri
348 .strip_prefix("file://")
349 .is_none_or(str::is_empty)
350 || location.uri.chars().any(char::is_control)
351 || location.line.is_none()
352 || location.character.is_none())
353 {
354 return Err(
355 "location must contain a file URI and exact zero-based line/character".to_owned(),
356 );
357 }
358
359 let node_id = if identity.location.is_some() {
360 self.graph.pin_symbol(identity)
361 } else {
362 match self
363 .graph
364 .nodes_named(&identity.symbol, identity.kind)
365 .as_slice()
366 {
367 [] => self.graph.pin_symbol(identity),
368 [node_id] => {
369 self.graph.pin(*node_id);
370 *node_id
371 }
372 _ => {
373 return Err(format!(
374 "symbol {:?} is ambiguous; include an exact source location",
375 identity.symbol
376 ));
377 }
378 }
379 };
380 self.selected = Some(node_id);
381 self.viewport = Viewport::default();
382 self.canvas_notice = None;
383 Ok(node_id)
384 }
385
386 pub fn delete_selected_anchor(&mut self) -> bool {
387 let Some(selected) = self.selected else {
388 return false;
389 };
390 if !self.graph.unpin(selected) {
391 self.canvas_notice = Some("Selected node is not an anchor".to_owned());
392 return false;
393 }
394 self.canvas_notice = None;
395 self.selected = self.graph.anchors().last().copied();
396 true
397 }
398
399 pub fn delete_selected_branch(&mut self, direction: HierarchyDirection) -> bool {
400 let Some(selected) = self.selected else {
401 return false;
402 };
403 let cleared = self.graph.clear_branch(selected, direction);
404 if cleared {
405 self.canvas_notice = None;
406 }
407 cleared
408 }
409
410 pub fn select_node(&mut self, node_id: NodeId) -> bool {
411 if let Some(node_id) = self.graph.resolve_id(node_id) {
412 self.selected = Some(node_id);
413 return true;
414 }
415 false
416 }
417
418 pub fn toggle_selected_branch(
419 &mut self,
420 direction: HierarchyDirection,
421 hierarchy_available: bool,
422 ) -> Option<HierarchyLoadRequest> {
423 let selected = self.selected?;
424 self.toggle_node_branch(selected, direction, hierarchy_available)
425 }
426
427 pub fn toggle_node_branch(
428 &mut self,
429 node_id: NodeId,
430 direction: HierarchyDirection,
431 hierarchy_available: bool,
432 ) -> Option<HierarchyLoadRequest> {
433 let node_id = self.graph.resolve_id(node_id)?;
434 let node = self.graph.node_mut(node_id)?;
435 self.selected = Some(node_id);
436 let identity = node.identity();
437 let branch = node.branch_mut(direction);
438
439 if branch.can_toggle() {
440 branch.toggle();
441 return None;
442 }
443 if branch.load_state == crate::state::LoadState::Loading {
444 branch.expanded = !branch.expanded;
445 return None;
446 }
447 if branch.load_state == crate::state::LoadState::Loaded {
448 return None;
449 }
450 if !hierarchy_available {
451 branch.load_state = crate::state::LoadState::Failed;
452 branch.expanded = false;
453 branch.failure = Some("Hierarchy requires an available LSP server".to_owned());
454 branch.active_request_id = None;
455 return None;
456 }
457
458 self.begin_hierarchy_load(node_id, identity, direction, CachePolicy::UseCache, true)
459 }
460
461 pub fn refresh_selected_branches(
462 &mut self,
463 hierarchy_available: bool,
464 ) -> Vec<HierarchyLoadRequest> {
465 let Some(selected) = self
466 .selected
467 .and_then(|node_id| self.graph.resolve_id(node_id))
468 else {
469 return Vec::new();
470 };
471 self.selected = Some(selected);
472 if !hierarchy_available {
473 self.canvas_notice = Some("Refresh requires an available LSP server".to_owned());
474 return Vec::new();
475 }
476
477 self.canvas_notice = None;
478 let identity = self
479 .graph
480 .node(selected)
481 .expect("resolved graph nodes exist")
482 .identity();
483 [HierarchyDirection::Incoming, HierarchyDirection::Outgoing]
484 .into_iter()
485 .filter_map(|direction| {
486 self.begin_hierarchy_load(
487 selected,
488 identity.clone(),
489 direction,
490 CachePolicy::Refresh,
491 false,
492 )
493 })
494 .collect()
495 }
496
497 pub fn finish_hierarchy(
498 &mut self,
499 request: &HierarchyLoadRequest,
500 result: Result<HierarchyResponse, String>,
501 ) -> bool {
502 let Some(node_id) = self.graph.resolve_id(request.node_id) else {
503 return false;
504 };
505 let branch = self
506 .graph
507 .node_mut(node_id)
508 .expect("resolved graph nodes exist")
509 .branch_mut(request.query.direction);
510 if branch.active_request_id != Some(request.request_id) {
511 return false;
512 }
513 match result {
514 Ok(response) => {
515 let source = response.source;
516 let was_selected = self.selected == Some(node_id);
517 let Some(node_id) = self
518 .graph
519 .resolve_symbol(node_id, response.query.symbol.clone())
520 else {
521 return false;
522 };
523 if was_selected {
524 self.selected = Some(node_id);
525 }
526 if self
527 .graph
528 .node(node_id)
529 .expect("resolved graph nodes exist")
530 .branch(request.query.direction)
531 .active_request_id
532 != Some(request.request_id)
533 {
534 return false;
535 }
536 let children = response
537 .children
538 .into_iter()
539 .filter(|child| !self.symbol_filter.is_ignored(&child.symbol))
540 .collect();
541 self.graph
542 .replace_branch_neighbors(node_id, request.query.direction, children);
543 let branch = self
544 .graph
545 .node_mut(node_id)
546 .expect("resolved graph nodes exist")
547 .branch_mut(request.query.direction);
548 branch.active_request_id = None;
549 branch.load_state = crate::state::LoadState::Loaded;
550 branch.failure = None;
551 if branch.neighbors.is_empty() {
552 branch.expanded = false;
553 }
554 if source == FetchSource::TreeSitter {
555 self.canvas_notice = Some(
556 "Tree-sitter: syntactic relations only; dynamic dispatch may be omitted"
557 .to_owned(),
558 );
559 }
560 }
561 Err(error) => {
562 let branch = self
563 .graph
564 .node_mut(node_id)
565 .expect("resolved graph nodes exist")
566 .branch_mut(request.query.direction);
567 branch.active_request_id = None;
568 if request.cache_policy == CachePolicy::Refresh {
569 branch.load_state = request.previous_load_state;
570 } else {
571 branch.load_state = crate::state::LoadState::Failed;
572 branch.expanded = false;
573 }
574 branch.failure = Some(error);
575 }
576 }
577 true
578 }
579
580 fn begin_hierarchy_load(
581 &mut self,
582 node_id: NodeId,
583 identity: SymbolIdentity,
584 direction: HierarchyDirection,
585 cache_policy: CachePolicy,
586 expand: bool,
587 ) -> Option<HierarchyLoadRequest> {
588 let request_id = self.next_hierarchy_request_id;
589 self.next_hierarchy_request_id = self.next_hierarchy_request_id.wrapping_add(1);
590 let branch = self.graph.node_mut(node_id)?.branch_mut(direction);
591 let previous_load_state = match branch.load_state {
592 crate::state::LoadState::Loading if branch.neighbors.is_empty() => {
593 crate::state::LoadState::NotLoaded
594 }
595 crate::state::LoadState::Loading => crate::state::LoadState::Loaded,
596 state => state,
597 };
598 branch.load_state = crate::state::LoadState::Loading;
599 if expand {
600 branch.expanded = true;
601 }
602 branch.failure = None;
603 branch.active_request_id = Some(request_id);
604 Some(HierarchyLoadRequest {
605 request_id,
606 node_id,
607 query: HierarchyQuery {
608 symbol: identity,
609 direction,
610 },
611 cache_policy,
612 previous_load_state,
613 })
614 }
615
616 fn request_current_search(&mut self) -> Option<SearchRequest> {
617 let search = self.search.as_ref()?;
618 if !search.provider_available {
619 return None;
620 }
621
622 let request_id = self.next_search_request_id;
623 self.next_search_request_id = self.next_search_request_id.wrapping_add(1);
624 let search = self.search.as_mut().expect("search was checked above");
625 search.request_id = request_id;
626 search.status = SearchStatus::Debouncing;
627 Some(SearchRequest {
628 request_id,
629 kind: search.kind,
630 query: search.input.clone(),
631 })
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use clap::Parser;
638
639 use super::{App, SearchItem, SearchKind, SearchStatus};
640 use crate::{
641 cli::Cli,
642 config::SymbolFilter,
643 fetch::{CachePolicy, FetchSource, HierarchyResponse},
644 state::{
645 HierarchyDirection, HierarchyKind, LoadState, NodeId, SourceLocation, SymbolIdentity,
646 },
647 };
648
649 #[test]
650 fn queries_on_open_and_after_each_text_change() {
651 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
652 let open_request = app.open_search(SearchKind::Call, true).unwrap();
653
654 assert_eq!(open_request.query, "");
655 assert_eq!(
656 app.search.as_ref().unwrap().status,
657 SearchStatus::Debouncing
658 );
659 let first_request = app.push_search_char('F').unwrap();
660 let current_request = app.push_search_char('B').unwrap();
661 assert_eq!(first_request.query, "F");
662 assert_eq!(current_request.query, "FB");
663 app.start_search(open_request.request_id);
664 assert_eq!(
665 app.search.as_ref().unwrap().status,
666 SearchStatus::Debouncing
667 );
668 app.start_search(current_request.request_id);
669 assert_eq!(app.search.as_ref().unwrap().status, SearchStatus::Loading);
670
671 app.finish_search(open_request.request_id, Ok(vec![item("stale")]));
672 app.finish_search(
673 current_request.request_id,
674 Ok(vec![item("Bar"), item("FooBar"), item("FastBuffer")]),
675 );
676
677 let search = app.search.as_ref().unwrap();
678 assert_eq!(
679 search
680 .items
681 .iter()
682 .map(|item| item.name.as_str())
683 .collect::<Vec<_>>(),
684 ["FooBar", "FastBuffer"]
685 );
686 assert_eq!(search.status, SearchStatus::Ready);
687 }
688
689 #[test]
690 fn ignores_results_from_a_closed_search_session() {
691 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
692 let old_request = app.open_search(SearchKind::Call, true).unwrap();
693 app.close_search();
694 let current_request = app.open_search(SearchKind::Call, true).unwrap();
695
696 app.finish_search(old_request.request_id, Ok(vec![item("old")]));
697 assert!(app.search.as_ref().unwrap().items.is_empty());
698
699 app.finish_search(current_request.request_id, Ok(vec![item("current")]));
700 assert_eq!(app.search.as_ref().unwrap().items[0].name, "current");
701 }
702
703 #[test]
704 fn ranks_exact_prefix_and_subsequence_matches() {
705 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
706 app.open_search(SearchKind::Call, true).unwrap();
707 let mut request = None;
708 for character in "main".chars() {
709 request = app.push_search_char(character);
710 }
711 app.finish_search(
712 request.unwrap().request_id,
713 Ok(vec![item("my_main"), item("main_loop"), item("main")]),
714 );
715
716 let names = app
717 .search
718 .as_ref()
719 .unwrap()
720 .items
721 .iter()
722 .map(|item| item.name.as_str())
723 .collect::<Vec<_>>();
724 assert_eq!(names, ["main", "main_loop", "my_main"]);
725 }
726
727 #[test]
728 fn matches_remaining_query_parts_against_container_and_path() {
729 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
730 app.open_search(SearchKind::Call, true).unwrap();
731 let mut request = None;
732 for character in "run service".chars() {
733 request = app.push_search_char(character);
734 }
735 let mut service_run = item("run");
736 service_run.container_name = Some("Service".to_owned());
737 let mut controller_run = item("run");
738 controller_run.container_name = Some("Controller".to_owned());
739 app.finish_search(
740 request.unwrap().request_id,
741 Ok(vec![controller_run, service_run]),
742 );
743
744 let names = app
745 .search
746 .as_ref()
747 .unwrap()
748 .items
749 .iter()
750 .map(|item| item.container_name.as_deref().unwrap())
751 .collect::<Vec<_>>();
752 assert_eq!(names, ["Service"]);
753 }
754
755 #[test]
756 fn applies_project_symbol_filter_to_search_and_hierarchy_results() {
757 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
758 app.set_symbol_filter(
759 SymbolFilter::from_patterns(["*::into", "Option::is_some", "*::Some"]).unwrap(),
760 );
761 let search = app.open_search(SearchKind::Call, true).unwrap();
762 app.finish_search(
763 search.request_id,
764 Ok(vec![item("Vec::into"), item("main"), item("Option::Some")]),
765 );
766
767 assert_eq!(
768 app.search
769 .as_ref()
770 .unwrap()
771 .items
772 .iter()
773 .map(|item| item.name.as_str())
774 .collect::<Vec<_>>(),
775 ["main"]
776 );
777 app.accept_search_selection();
778 let hierarchy = app
779 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
780 .unwrap();
781 assert!(app.finish_hierarchy(
782 &hierarchy,
783 Ok(HierarchyResponse {
784 query: hierarchy.query.clone(),
785 children: vec![
786 identity("Option::is_some", HierarchyKind::Call),
787 identity("work", HierarchyKind::Call),
788 identity("Option::some", HierarchyKind::Call),
789 ],
790 source: FetchSource::Lsp,
791 })
792 ));
793
794 let root = app.selected.unwrap();
795 assert_eq!(
796 branch_names(&app, root, HierarchyDirection::Outgoing),
797 ["work", "Option::some"]
798 );
799 }
800
801 #[test]
802 fn accepts_a_result_as_a_deduplicated_anchor() {
803 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
804 app.open_search(SearchKind::Type, true).unwrap();
805 app.push_search_char('S').unwrap();
806 let request = app.push_search_char('t').unwrap();
807 app.finish_search(request.request_id, Ok(vec![item("Student")]));
808 app.accept_search_selection();
809
810 assert!(app.search.is_none());
811 assert_eq!(app.graph.anchors().len(), 1);
812 let existing_root = app.graph.anchors()[0];
813 assert_eq!(app.graph.node(existing_root).unwrap().symbol, "Student");
814 assert_eq!(
815 app.graph
816 .node(existing_root)
817 .unwrap()
818 .location
819 .as_ref()
820 .unwrap()
821 .uri,
822 "file:///workspace/main.rs"
823 );
824
825 app.pan_viewport(12, -4);
826 app.open_search(SearchKind::Type, true).unwrap();
827 app.push_search_char('S').unwrap();
828 let request = app.push_search_char('t').unwrap();
829 app.finish_search(request.request_id, Ok(vec![item("Student")]));
830 app.accept_search_selection();
831
832 assert_eq!(app.graph.anchors().len(), 1);
833 assert_eq!(app.selected, Some(existing_root));
834 assert_eq!(app.viewport.offset_x, 0);
835 assert_eq!(app.viewport.offset_y, 0);
836 }
837
838 #[test]
839 fn external_focus_reuses_semantic_nodes_and_rejects_ambiguous_names() {
840 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
841 let first = identity("run", HierarchyKind::Call);
842 let first_id = app.graph.insert_symbol(first.clone());
843 app.viewport.offset_x = 17;
844
845 assert_eq!(app.focus_symbol(first.clone()).unwrap(), first_id);
846 assert_eq!(app.graph.node_count(), 1);
847 assert_eq!(app.graph.anchors(), [first_id]);
848 assert_eq!(app.selected, Some(first_id));
849 assert_eq!(app.viewport, crate::state::Viewport::default());
850
851 let second = SymbolIdentity {
852 symbol: "run".to_owned(),
853 kind: HierarchyKind::Call,
854 location: Some(SourceLocation {
855 uri: "file:///workspace/src/other.rs".to_owned(),
856 line: Some(3),
857 character: Some(1),
858 }),
859 };
860 app.graph.insert_symbol(second);
861 let error = app
862 .focus_symbol(SymbolIdentity {
863 symbol: "run".to_owned(),
864 kind: HierarchyKind::Call,
865 location: None,
866 })
867 .unwrap_err();
868 assert!(error.contains("ambiguous"));
869 assert_eq!(app.graph.node_count(), 2);
870
871 let created = app
872 .focus_symbol(SymbolIdentity {
873 symbol: "new_type".to_owned(),
874 kind: HierarchyKind::Type,
875 location: None,
876 })
877 .unwrap();
878 assert_eq!(app.graph.node(created).unwrap().symbol, "new_type");
879 assert!(app.graph.is_anchor(created));
880
881 for location in [
882 SourceLocation {
883 uri: "file://".to_owned(),
884 line: Some(0),
885 character: Some(0),
886 },
887 SourceLocation {
888 uri: "file:///workspace/src/main.py".to_owned(),
889 line: Some(0),
890 character: None,
891 },
892 ] {
893 let error = app
894 .focus_symbol(SymbolIdentity {
895 symbol: "invalid".to_owned(),
896 kind: HierarchyKind::Call,
897 location: Some(location),
898 })
899 .unwrap_err();
900 assert!(error.contains("exact zero-based line/character"));
901 }
902 }
903
904 #[test]
905 fn only_deletes_selected_anchors_and_selects_a_remaining_anchor() {
906 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
907 let first_root = app.graph.pin_symbol(identity("first", HierarchyKind::Call));
908 let child_id = app
909 .graph
910 .replace_branch_neighbors(
911 first_root,
912 HierarchyDirection::Outgoing,
913 vec![identity("child", HierarchyKind::Call)],
914 )
915 .unwrap()[0];
916 let second_root = app
917 .graph
918 .pin_symbol(identity("second", HierarchyKind::Type));
919 app.selected = Some(child_id);
920
921 assert!(!app.delete_selected_anchor());
922 assert_eq!(
923 app.canvas_notice.as_deref(),
924 Some("Selected node is not an anchor")
925 );
926 assert_eq!(app.graph.anchors(), [first_root, second_root]);
927
928 app.selected = Some(first_root);
929 assert!(app.delete_selected_anchor());
930 assert_eq!(app.graph.anchors(), [second_root]);
931 assert_eq!(app.selected, Some(second_root));
932
933 assert!(app.delete_selected_anchor());
934 assert!(app.graph.anchors().is_empty());
935 assert_eq!(app.selected, None);
936 }
937
938 #[test]
939 fn deletes_only_the_selected_nodes_requested_branch() {
940 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
941 let selected = app.graph.pin_symbol(identity("root", HierarchyKind::Call));
942 app.graph
943 .replace_branch_neighbors(
944 selected,
945 HierarchyDirection::Incoming,
946 vec![identity("caller", HierarchyKind::Call)],
947 )
948 .unwrap();
949 app.graph
950 .replace_branch_neighbors(
951 selected,
952 HierarchyDirection::Outgoing,
953 vec![identity("callee", HierarchyKind::Call)],
954 )
955 .unwrap();
956 app.selected = Some(selected);
957
958 assert!(app.delete_selected_branch(HierarchyDirection::Incoming));
959 assert!(
960 app.graph
961 .node(selected)
962 .unwrap()
963 .incoming
964 .neighbors
965 .is_empty()
966 );
967 assert_eq!(
968 app.graph.node(selected).unwrap().outgoing.neighbors.len(),
969 1
970 );
971 }
972
973 #[test]
974 fn selects_nested_nodes_and_toggles_one_requested_branch() {
975 let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
976 let root = app.graph.pin_symbol(identity("root", HierarchyKind::Call));
977 let child_id = app
978 .graph
979 .replace_branch_neighbors(
980 root,
981 HierarchyDirection::Incoming,
982 vec![identity("child", HierarchyKind::Call)],
983 )
984 .unwrap()[0];
985 app.graph
986 .replace_branch_neighbors(
987 child_id,
988 HierarchyDirection::Outgoing,
989 vec![identity("grandchild", HierarchyKind::Call)],
990 )
991 .unwrap();
992 app.graph.node_mut(root).unwrap().incoming.expanded = true;
993
994 assert!(app.select_node(child_id));
995 assert_eq!(app.selected, Some(child_id));
996 assert_eq!(
997 app.toggle_selected_branch(HierarchyDirection::Outgoing, false),
998 None
999 );
1000 assert!(app.graph.node(child_id).unwrap().outgoing.expanded);
1001 assert!(!app.select_node(NodeId(u64::MAX)));
1002 assert_eq!(app.selected, Some(child_id));
1003 }
1004
1005 #[test]
1006 fn lazily_loads_a_branch_once_and_reuses_its_children() {
1007 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1008 let request = app
1009 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1010 .unwrap();
1011 let root = app.selected.unwrap();
1012
1013 assert_eq!(
1014 app.graph.node(root).unwrap().outgoing.load_state,
1015 LoadState::Loading
1016 );
1017 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1018 assert_eq!(
1019 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1020 None
1021 );
1022 assert!(!app.graph.node(root).unwrap().outgoing.expanded);
1023
1024 assert!(app.finish_hierarchy(
1025 &request,
1026 Ok(HierarchyResponse {
1027 query: request.query.clone(),
1028 children: vec![identity("child", HierarchyKind::Call)],
1029 source: FetchSource::Lsp,
1030 })
1031 ));
1032 assert_eq!(
1033 app.graph.node(root).unwrap().outgoing.load_state,
1034 LoadState::Loaded
1035 );
1036 assert_eq!(app.graph.node(root).unwrap().outgoing.neighbors.len(), 1);
1037 assert!(!app.graph.node(root).unwrap().outgoing.expanded);
1038
1039 assert_eq!(
1040 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1041 None
1042 );
1043 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1044 }
1045
1046 #[test]
1047 fn retries_failed_hierarchy_and_ignores_stale_results() {
1048 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "type", "Root"]).unwrap());
1049 let failed = app
1050 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1051 .unwrap();
1052 let root = app.selected.unwrap();
1053 assert!(app.finish_hierarchy(&failed, Err("not supported".to_owned())));
1054 assert_eq!(
1055 app.graph.node(root).unwrap().incoming.load_state,
1056 LoadState::Failed
1057 );
1058 assert_eq!(
1059 app.graph.node(root).unwrap().incoming.failure(),
1060 Some("not supported")
1061 );
1062
1063 let retry = app
1064 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1065 .unwrap();
1066 assert_ne!(retry.request_id, failed.request_id);
1067 assert!(!app.finish_hierarchy(
1068 &failed,
1069 Ok(HierarchyResponse {
1070 query: failed.query.clone(),
1071 children: vec![identity("stale", HierarchyKind::Type)],
1072 source: FetchSource::Lsp,
1073 })
1074 ));
1075 assert!(app.graph.node(root).unwrap().incoming.neighbors.is_empty());
1076 assert!(app.finish_hierarchy(
1077 &retry,
1078 Ok(HierarchyResponse {
1079 query: retry.query.clone(),
1080 children: vec![identity("Parent", HierarchyKind::Type)],
1081 source: FetchSource::Lsp,
1082 })
1083 ));
1084 assert_eq!(
1085 branch_names(&app, root, HierarchyDirection::Incoming),
1086 ["Parent"]
1087 );
1088 }
1089
1090 #[test]
1091 fn keeps_successful_empty_hierarchy_distinct_from_failure() {
1092 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "leaf"]).unwrap());
1093 let request = app
1094 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1095 .unwrap();
1096 let root = app.selected.unwrap();
1097
1098 assert!(app.finish_hierarchy(
1099 &request,
1100 Ok(HierarchyResponse {
1101 query: request.query.clone(),
1102 children: Vec::new(),
1103 source: FetchSource::Lsp,
1104 })
1105 ));
1106 assert_eq!(
1107 app.graph.node(root).unwrap().outgoing.load_state,
1108 LoadState::Loaded
1109 );
1110 assert!(app.graph.node(root).unwrap().outgoing.neighbors.is_empty());
1111 assert_eq!(app.graph.node(root).unwrap().outgoing.failure(), None);
1112 assert_eq!(
1113 app.toggle_selected_branch(HierarchyDirection::Outgoing, true),
1114 None
1115 );
1116 assert_eq!(
1117 app.graph.node(root).unwrap().outgoing.load_state,
1118 LoadState::Loaded
1119 );
1120 }
1121
1122 #[test]
1123 fn tree_sitter_hierarchy_reports_its_syntactic_confidence() {
1124 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1125 let request = app
1126 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1127 .unwrap();
1128
1129 assert!(app.finish_hierarchy(
1130 &request,
1131 Ok(HierarchyResponse {
1132 query: request.query.clone(),
1133 children: vec![identity("child", HierarchyKind::Call)],
1134 source: FetchSource::TreeSitter,
1135 })
1136 ));
1137
1138 assert_eq!(
1139 app.canvas_notice.as_deref(),
1140 Some("Tree-sitter: syntactic relations only; dynamic dispatch may be omitted")
1141 );
1142 }
1143
1144 #[test]
1145 fn deduplicates_children_globally_but_preserves_both_direction_relations() {
1146 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1147 let incoming = app
1148 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1149 .unwrap();
1150 let shared = identity("shared", HierarchyKind::Call);
1151 assert!(app.finish_hierarchy(
1152 &incoming,
1153 Ok(HierarchyResponse {
1154 query: incoming.query.clone(),
1155 children: vec![
1156 shared.clone(),
1157 shared.clone(),
1158 identity("left-only", HierarchyKind::Call),
1159 ],
1160 source: FetchSource::Lsp,
1161 })
1162 ));
1163
1164 let outgoing = app
1165 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1166 .unwrap();
1167 assert!(app.finish_hierarchy(
1168 &outgoing,
1169 Ok(HierarchyResponse {
1170 query: outgoing.query.clone(),
1171 children: vec![
1172 shared.clone(),
1173 shared,
1174 identity("right-only", HierarchyKind::Call),
1175 ],
1176 source: FetchSource::Lsp,
1177 })
1178 ));
1179
1180 let root = app.selected.unwrap();
1181 let incoming_names = branch_names(&app, root, HierarchyDirection::Incoming);
1182 let outgoing_names = branch_names(&app, root, HierarchyDirection::Outgoing);
1183 assert_eq!(incoming_names, ["shared", "left-only"]);
1184 assert_eq!(outgoing_names, ["shared", "right-only"]);
1185 assert_eq!(app.graph.node_count(), 4);
1186 }
1187
1188 #[test]
1189 fn refreshes_both_branches_and_preserves_existing_descendant_state() {
1190 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1191 let incoming = app
1192 .toggle_selected_branch(HierarchyDirection::Incoming, true)
1193 .unwrap();
1194 let caller_identity = identity("caller", HierarchyKind::Call);
1195 assert!(app.finish_hierarchy(
1196 &incoming,
1197 Ok(HierarchyResponse {
1198 query: incoming.query.clone(),
1199 children: vec![caller_identity.clone()],
1200 source: FetchSource::Lsp,
1201 })
1202 ));
1203 let outgoing = app
1204 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1205 .unwrap();
1206 let removed_identity = identity("removed", HierarchyKind::Call);
1207 assert!(app.finish_hierarchy(
1208 &outgoing,
1209 Ok(HierarchyResponse {
1210 query: outgoing.query.clone(),
1211 children: vec![removed_identity],
1212 source: FetchSource::Lsp,
1213 })
1214 ));
1215
1216 let root = app.selected.unwrap();
1217 let caller = app.graph.node(root).unwrap().incoming.neighbors[0];
1218 app.graph
1219 .replace_branch_neighbors(
1220 caller,
1221 HierarchyDirection::Outgoing,
1222 vec![identity("grandchild", HierarchyKind::Call)],
1223 )
1224 .unwrap();
1225 let caller_branch = &mut app.graph.node_mut(caller).unwrap().outgoing;
1226 caller_branch.load_state = LoadState::Loaded;
1227 caller_branch.expanded = true;
1228
1229 let requests = app.refresh_selected_branches(true);
1230 assert_eq!(requests.len(), 2);
1231 assert!(
1232 requests
1233 .iter()
1234 .all(|request| request.cache_policy == CachePolicy::Refresh)
1235 );
1236 assert_eq!(
1237 requests
1238 .iter()
1239 .map(|request| request.query.direction)
1240 .collect::<Vec<_>>(),
1241 [HierarchyDirection::Incoming, HierarchyDirection::Outgoing]
1242 );
1243 assert!(app.graph.node(root).unwrap().incoming.expanded);
1244 assert!(app.graph.node(root).unwrap().outgoing.expanded);
1245
1246 for request in &requests {
1247 let children = match request.query.direction {
1248 HierarchyDirection::Incoming => vec![
1249 caller_identity.clone(),
1250 identity("new-caller", HierarchyKind::Call),
1251 ],
1252 HierarchyDirection::Outgoing => {
1253 vec![identity("new-callee", HierarchyKind::Call)]
1254 }
1255 };
1256 assert!(app.finish_hierarchy(
1257 request,
1258 Ok(HierarchyResponse {
1259 query: request.query.clone(),
1260 children,
1261 source: FetchSource::Lsp,
1262 })
1263 ));
1264 }
1265
1266 assert_eq!(app.graph.node(root).unwrap().incoming.neighbors[0], caller);
1267 assert!(app.graph.node(caller).unwrap().outgoing.expanded);
1268 assert_eq!(
1269 branch_names(&app, caller, HierarchyDirection::Outgoing),
1270 ["grandchild"]
1271 );
1272 assert_eq!(
1273 branch_names(&app, root, HierarchyDirection::Incoming),
1274 ["caller", "new-caller"]
1275 );
1276 assert_eq!(
1277 branch_names(&app, root, HierarchyDirection::Outgoing),
1278 ["new-callee"]
1279 );
1280 let new_callee = app.graph.node(root).unwrap().outgoing.neighbors[0];
1281 assert_eq!(
1282 app.graph.node(new_callee).unwrap().outgoing.load_state,
1283 LoadState::NotLoaded
1284 );
1285 assert!(
1286 app.graph.visible_graph().nodes.iter().all(|node_id| app
1287 .graph
1288 .node(*node_id)
1289 .unwrap()
1290 .symbol
1291 != "removed")
1292 );
1293 }
1294
1295 #[test]
1296 fn failed_refresh_keeps_cached_neighbors_and_rejects_older_results() {
1297 let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1298 let initial = app
1299 .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1300 .unwrap();
1301 assert!(app.finish_hierarchy(
1302 &initial,
1303 Ok(HierarchyResponse {
1304 query: initial.query.clone(),
1305 children: vec![identity("cached", HierarchyKind::Call)],
1306 source: FetchSource::Lsp,
1307 })
1308 ));
1309 let root = app.selected.unwrap();
1310
1311 let older = app.refresh_selected_branches(true);
1312 let current = app.refresh_selected_branches(true);
1313 let older_outgoing = older
1314 .iter()
1315 .find(|request| request.query.direction == HierarchyDirection::Outgoing)
1316 .unwrap();
1317 assert!(!app.finish_hierarchy(
1318 older_outgoing,
1319 Ok(HierarchyResponse {
1320 query: older_outgoing.query.clone(),
1321 children: vec![identity("stale", HierarchyKind::Call)],
1322 source: FetchSource::Lsp,
1323 })
1324 ));
1325
1326 for request in ¤t {
1327 assert!(app.finish_hierarchy(request, Err("refresh failed".to_owned())));
1328 }
1329 assert_eq!(
1330 branch_names(&app, root, HierarchyDirection::Outgoing),
1331 ["cached"]
1332 );
1333 let outgoing = &app.graph.node(root).unwrap().outgoing;
1334 assert_eq!(outgoing.load_state, LoadState::Loaded);
1335 assert!(outgoing.expanded);
1336 assert_eq!(outgoing.failure(), Some("refresh failed"));
1337 }
1338
1339 fn item(name: &str) -> SearchItem {
1340 SearchItem {
1341 name: name.to_owned(),
1342 container_name: None,
1343 location: "file:///workspace/main.rs:1".to_owned(),
1344 source: Some(crate::state::SourceLocation {
1345 uri: "file:///workspace/main.rs".to_owned(),
1346 line: Some(0),
1347 character: Some(0),
1348 }),
1349 }
1350 }
1351
1352 fn identity(symbol: &str, kind: HierarchyKind) -> SymbolIdentity {
1353 SymbolIdentity {
1354 symbol: symbol.to_owned(),
1355 kind,
1356 location: Some(SourceLocation {
1357 uri: "file:///workspace/main.rs".to_owned(),
1358 line: Some(symbol.bytes().map(u32::from).sum()),
1359 character: Some(0),
1360 }),
1361 }
1362 }
1363
1364 fn branch_names(app: &App, node_id: NodeId, direction: HierarchyDirection) -> Vec<&str> {
1365 app.graph
1366 .node(node_id)
1367 .unwrap()
1368 .branch(direction)
1369 .neighbors
1370 .iter()
1371 .map(|neighbor| app.graph.node(*neighbor).unwrap().symbol.as_str())
1372 .collect()
1373 }
1374}