Skip to content

Commit 0f186eb

Browse files
committed
fix(search): honor fields and serve filters
1 parent 06af5e7 commit 0f186eb

5 files changed

Lines changed: 464 additions & 32 deletions

File tree

grapha/src/main.rs

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,7 +1020,6 @@ fn handle_symbol_command(
10201020
fields,
10211021
} => {
10221022
let field_set = resolve_field_set(&fields, &path);
1023-
let _render_options = render_options.with_fields(field_set);
10241023
let index = open_search_index(&path)?;
10251024
let options = search::SearchOptions {
10261025
kind,
@@ -1032,14 +1031,13 @@ fn handle_symbol_command(
10321031
let t = Instant::now();
10331032
let results = search::search_filtered(&index, &query, limit, &options)?;
10341033
let elapsed = t.elapsed();
1035-
1036-
if context {
1037-
let graph = load_graph(&path)?;
1038-
let enriched = search::enrich_with_context(&results, &graph);
1039-
print_json(&enriched)?;
1034+
let graph = if search::needs_graph_for_projection(field_set, context) {
1035+
Some(load_graph(&path)?)
10401036
} else {
1041-
print_json(&results)?;
1042-
}
1037+
None
1038+
};
1039+
let projected = search::project_results(&results, graph.as_ref(), field_set, context);
1040+
print_json(&projected)?;
10431041

10441042
eprintln!(
10451043
"\n {} results in {:.1}ms",

grapha/src/search.rs

Lines changed: 235 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use tantivy::schema::{IndexRecordOption, STORED, STRING, Schema, TEXT, Value};
99
use tantivy::{Index, IndexWriter, ReloadPolicy, TantivyDocument, Term, doc};
1010

1111
use crate::delta::{EntitySyncStats, GraphDelta, SyncMode};
12+
use crate::fields::FieldSet;
1213
use grapha_core::graph::{EdgeKind, Graph};
1314
use grapha_core::graph::{Node, NodeRole};
1415

@@ -402,12 +403,27 @@ pub fn search_filtered(
402403
Ok(results)
403404
}
404405

405-
#[derive(Debug, Serialize)]
406-
pub struct EnrichedSearchResult {
407-
#[serde(flatten)]
408-
pub base: SearchResult,
406+
#[derive(Debug, Clone, Serialize)]
407+
pub struct SearchOutputResult {
408+
pub name: String,
409+
pub kind: String,
410+
pub score: f32,
411+
#[serde(skip_serializing_if = "Option::is_none")]
412+
pub file: Option<String>,
413+
#[serde(skip_serializing_if = "Option::is_none")]
414+
pub id: Option<String>,
415+
#[serde(skip_serializing_if = "Option::is_none")]
416+
pub module: Option<String>,
417+
#[serde(skip_serializing_if = "Option::is_none")]
418+
pub span: Option<String>,
409419
#[serde(skip_serializing_if = "Option::is_none")]
410420
pub snippet: Option<String>,
421+
#[serde(skip_serializing_if = "Option::is_none")]
422+
pub visibility: Option<String>,
423+
#[serde(skip_serializing_if = "Option::is_none")]
424+
pub signature: Option<String>,
425+
#[serde(skip_serializing_if = "Option::is_none")]
426+
pub role: Option<String>,
411427
#[serde(skip_serializing_if = "Vec::is_empty")]
412428
pub calls: Vec<String>,
413429
#[serde(skip_serializing_if = "Vec::is_empty")]
@@ -416,40 +432,156 @@ pub struct EnrichedSearchResult {
416432
pub type_refs: Vec<String>,
417433
}
418434

419-
pub fn enrich_with_context(results: &[SearchResult], graph: &Graph) -> Vec<EnrichedSearchResult> {
435+
struct GraphSearchDetails<'a> {
436+
node: &'a Node,
437+
calls: Vec<String>,
438+
called_by: Vec<String>,
439+
type_refs: Vec<String>,
440+
}
441+
442+
fn visibility_to_string(node: &Node) -> String {
443+
serde_json::to_string(&node.visibility)
444+
.unwrap_or_else(|_| format!("{:?}", node.visibility))
445+
.trim_matches('"')
446+
.to_string()
447+
}
448+
449+
fn role_value(role: &Option<NodeRole>) -> Option<String> {
450+
role.as_ref().map(|role| match role {
451+
NodeRole::EntryPoint => "entry_point".to_string(),
452+
NodeRole::Terminal { .. } => "terminal".to_string(),
453+
NodeRole::Internal => "internal".to_string(),
454+
})
455+
}
456+
457+
fn node_span_string(node: &Node) -> String {
458+
format!(
459+
"{}:{}-{}:{}",
460+
node.span.start[0], node.span.start[1], node.span.end[0], node.span.end[1]
461+
)
462+
}
463+
464+
fn collect_graph_details<'a>(
465+
results: &[SearchResult],
466+
graph: &'a Graph,
467+
) -> Vec<Option<GraphSearchDetails<'a>>> {
420468
results
421469
.iter()
422-
.map(|r| {
423-
let node = graph.nodes.iter().find(|n| n.id == r.id);
424-
let snippet = node.and_then(|n| n.snippet.clone());
425-
426-
let calls: Vec<String> = graph
470+
.map(|result| {
471+
let node = graph.nodes.iter().find(|node| node.id == result.id)?;
472+
let calls = graph
427473
.edges
428474
.iter()
429-
.filter(|e| e.source == r.id && e.kind == EdgeKind::Calls)
475+
.filter(|e| e.source == result.id && e.kind == EdgeKind::Calls)
430476
.map(|e| e.target.clone())
431477
.collect();
432-
433-
let called_by: Vec<String> = graph
478+
let called_by = graph
434479
.edges
435480
.iter()
436-
.filter(|e| e.target == r.id && e.kind == EdgeKind::Calls)
481+
.filter(|e| e.target == result.id && e.kind == EdgeKind::Calls)
437482
.map(|e| e.source.clone())
438483
.collect();
439-
440-
let type_refs: Vec<String> = graph
484+
let type_refs = graph
441485
.edges
442486
.iter()
443-
.filter(|e| e.source == r.id && e.kind == EdgeKind::TypeRef)
487+
.filter(|e| e.source == result.id && e.kind == EdgeKind::TypeRef)
444488
.map(|e| e.target.clone())
445489
.collect();
446-
447-
EnrichedSearchResult {
448-
base: r.clone(),
449-
snippet,
490+
Some(GraphSearchDetails {
491+
node,
450492
calls,
451493
called_by,
452494
type_refs,
495+
})
496+
})
497+
.collect()
498+
}
499+
500+
pub fn needs_graph_for_projection(fields: FieldSet, include_context: bool) -> bool {
501+
include_context
502+
|| fields.span
503+
|| fields.snippet
504+
|| fields.visibility
505+
|| fields.signature
506+
|| fields.role
507+
}
508+
509+
pub fn project_results(
510+
results: &[SearchResult],
511+
graph: Option<&Graph>,
512+
fields: FieldSet,
513+
include_context: bool,
514+
) -> Vec<SearchOutputResult> {
515+
let graph_details = graph.map(|graph| collect_graph_details(results, graph));
516+
517+
results
518+
.iter()
519+
.enumerate()
520+
.map(|(index, result)| {
521+
let details = graph_details
522+
.as_ref()
523+
.and_then(|details| details.get(index))
524+
.and_then(|details| details.as_ref());
525+
let role = if fields.role {
526+
details
527+
.map(|details| role_value(&details.node.role))
528+
.unwrap_or_else(|| result.role.clone())
529+
} else {
530+
None
531+
};
532+
SearchOutputResult {
533+
name: result.name.clone(),
534+
kind: result.kind.clone(),
535+
score: result.score,
536+
file: fields.file.then(|| result.file.clone()),
537+
id: fields.id.then(|| result.id.clone()),
538+
module: if fields.module {
539+
result.module.clone()
540+
} else {
541+
None
542+
},
543+
span: if fields.span {
544+
details.map(|details| node_span_string(details.node))
545+
} else {
546+
None
547+
},
548+
snippet: if fields.snippet {
549+
details.and_then(|details| details.node.snippet.clone())
550+
} else {
551+
None
552+
},
553+
visibility: if fields.visibility {
554+
details.map(|details| visibility_to_string(details.node))
555+
} else {
556+
None
557+
},
558+
signature: if fields.signature {
559+
details.and_then(|details| details.node.signature.clone())
560+
} else {
561+
None
562+
},
563+
role,
564+
calls: if include_context {
565+
details
566+
.map(|details| details.calls.clone())
567+
.unwrap_or_default()
568+
} else {
569+
Vec::new()
570+
},
571+
called_by: if include_context {
572+
details
573+
.map(|details| details.called_by.clone())
574+
.unwrap_or_default()
575+
} else {
576+
Vec::new()
577+
},
578+
type_refs: if include_context {
579+
details
580+
.map(|details| details.type_refs.clone())
581+
.unwrap_or_default()
582+
} else {
583+
Vec::new()
584+
},
453585
}
454586
})
455587
.collect()
@@ -800,4 +932,86 @@ mod tests {
800932
let results = search_filtered(&index, "AppView", 10, &options).unwrap();
801933
assert!(results.is_empty());
802934
}
935+
936+
#[test]
937+
fn projection_respects_fields_and_context() {
938+
let graph = Graph {
939+
version: "0.1.0".to_string(),
940+
nodes: vec![
941+
Node {
942+
id: "app::main".into(),
943+
kind: NodeKind::Function,
944+
name: "main".into(),
945+
file: "src/main.rs".into(),
946+
span: Span {
947+
start: [1, 0],
948+
end: [3, 1],
949+
},
950+
visibility: Visibility::Public,
951+
metadata: HashMap::new(),
952+
role: Some(NodeRole::EntryPoint),
953+
signature: Some("fn main()".into()),
954+
doc_comment: None,
955+
module: Some("App".into()),
956+
snippet: Some("fn main() { helper(); }".into()),
957+
},
958+
Node {
959+
id: "app::helper".into(),
960+
kind: NodeKind::Function,
961+
name: "helper".into(),
962+
file: "src/main.rs".into(),
963+
span: Span {
964+
start: [5, 0],
965+
end: [5, 12],
966+
},
967+
visibility: Visibility::Private,
968+
metadata: HashMap::new(),
969+
role: None,
970+
signature: Some("fn helper()".into()),
971+
doc_comment: None,
972+
module: Some("App".into()),
973+
snippet: Some("fn helper() {}".into()),
974+
},
975+
],
976+
edges: vec![Edge {
977+
source: "app::main".into(),
978+
target: "app::helper".into(),
979+
kind: EdgeKind::Calls,
980+
confidence: 1.0,
981+
direction: None,
982+
operation: None,
983+
condition: None,
984+
async_boundary: Some(false),
985+
provenance: Vec::new(),
986+
}],
987+
};
988+
let results = vec![SearchResult {
989+
id: "app::main".into(),
990+
name: "main".into(),
991+
kind: "function".into(),
992+
file: "src/main.rs".into(),
993+
score: 1.0,
994+
module: Some("App".into()),
995+
role: Some("entry_point".into()),
996+
}];
997+
998+
let projected = project_results(
999+
&results,
1000+
Some(&graph),
1001+
FieldSet::parse("id,signature,role,snippet"),
1002+
true,
1003+
);
1004+
1005+
assert_eq!(projected.len(), 1);
1006+
let result = &projected[0];
1007+
assert_eq!(result.name, "main");
1008+
assert_eq!(result.kind, "function");
1009+
assert_eq!(result.id.as_deref(), Some("app::main"));
1010+
assert_eq!(result.signature.as_deref(), Some("fn main()"));
1011+
assert_eq!(result.role.as_deref(), Some("entry_point"));
1012+
assert_eq!(result.snippet.as_deref(), Some("fn main() { helper(); }"));
1013+
assert!(result.file.is_none());
1014+
assert_eq!(result.calls, vec!["app::helper".to_string()]);
1015+
assert!(result.called_by.is_empty());
1016+
}
8031017
}

0 commit comments

Comments
 (0)