gobby_code/search/fts/
graph.rs1use std::collections::HashSet;
2
3use postgres::{Client, Row};
4
5use crate::db;
6use crate::models::Symbol;
7
8use super::common::ResolvedGraphSymbol;
9use super::symbols::{search_symbols_by_name, search_symbols_fts};
10
11const EXACT_ID_MATCH_LIMIT: usize = 2;
12const EXACT_QUALIFIED_NAME_MATCH_LIMIT: usize = 6;
13const EXACT_NAME_MATCH_LIMIT: usize = 6;
14const FUZZY_NAME_MATCH_LIMIT: usize = 6;
15
16fn exact_symbol_matches_result(
17 conn: &mut Client,
18 project_id: &str,
19 column: &str,
20 input: &str,
21 limit: usize,
22) -> anyhow::Result<Vec<Symbol>> {
23 let columns = db::symbol_select_columns("");
24 let column = match column {
25 "id" | "qualified_name" | "name" => column,
26 _ => return Ok(Vec::new()),
27 };
28 let sql = format!(
29 "SELECT {columns}
30 FROM code_symbols
31 WHERE project_id = $1 AND {column} = $2
32 ORDER BY file_path ASC, line_start ASC
33 LIMIT $3"
34 );
35 let rows = conn.query(&sql, &[&project_id, &input, &(limit as i64)])?;
36 let mut symbols = Vec::new();
37 for row in &rows {
38 match Symbol::from_row(row) {
39 Ok(symbol) => symbols.push(symbol),
40 Err(error) => log::warn!(
41 "discarding malformed graph symbol row during exact {column} lookup \
42 for project_id={project_id} input={input:?}: id={} name={} file_path={}: {error}",
43 row_string(row, "id"),
44 row_string(row, "name"),
45 row_string(row, "file_path"),
46 ),
47 }
48 }
49 Ok(symbols)
50}
51
52fn row_string(row: &Row, column: &str) -> String {
53 row.try_get::<_, String>(column)
54 .unwrap_or_else(|_| "<unavailable>".to_string())
55}
56
57fn suggestion_label(symbol: &Symbol) -> String {
58 format!(
59 "{} ({}:{})",
60 symbol.qualified_name, symbol.file_path, symbol.line_start
61 )
62}
63
64fn resolved_symbol(symbol: &Symbol) -> ResolvedGraphSymbol {
65 ResolvedGraphSymbol {
66 id: symbol.id.clone(),
67 display_name: symbol.name.clone(),
68 }
69}
70
71pub fn resolve_graph_symbol_by_id(
72 conn: &mut Client,
73 symbol_id: &str,
74 project_id: &str,
75) -> anyhow::Result<Option<ResolvedGraphSymbol>> {
76 let matches = exact_symbol_matches_result(conn, project_id, "id", symbol_id, 1)?;
77 Ok(matches.first().map(resolved_symbol))
78}
79
80fn resolve_from_candidates(candidates: Vec<Symbol>) -> (Option<ResolvedGraphSymbol>, Vec<String>) {
81 match candidates.len() {
82 0 => (None, vec![]),
83 1 => (Some(resolved_symbol(&candidates[0])), vec![]),
84 _ => {
85 let mut suggestions = Vec::new();
86 let mut seen = HashSet::new();
87 for symbol in &candidates {
88 let label = suggestion_label(symbol);
89 if seen.insert(label.clone()) {
90 suggestions.push(label);
91 }
92 }
93 (None, suggestions)
94 }
95 }
96}
97
98fn decisive_resolution(
99 candidates: Vec<Symbol>,
100) -> Option<(Option<ResolvedGraphSymbol>, Vec<String>)> {
101 let (resolved, suggestions) = resolve_from_candidates(candidates);
102 (resolved.is_some() || !suggestions.is_empty()).then_some((resolved, suggestions))
103}
104
105pub fn resolve_graph_symbol(
109 conn: &mut Client,
110 input: &str,
111 project_id: &str,
112) -> anyhow::Result<(Option<ResolvedGraphSymbol>, Vec<String>)> {
113 for (column, limit) in [
114 ("id", EXACT_ID_MATCH_LIMIT),
115 ("qualified_name", EXACT_QUALIFIED_NAME_MATCH_LIMIT),
116 ("name", EXACT_NAME_MATCH_LIMIT),
117 ] {
118 if let Some(result) = decisive_resolution(exact_symbol_matches_result(
119 conn, project_id, column, input, limit,
120 )?) {
121 return Ok(result);
122 }
123 }
124
125 if let Some(result) = decisive_resolution(search_symbols_by_name(
126 conn,
127 input,
128 project_id,
129 None,
130 None,
131 &[],
132 FUZZY_NAME_MATCH_LIMIT,
133 )) {
134 return Ok(result);
135 }
136
137 let fts_results = search_symbols_fts(
138 conn,
139 input,
140 project_id,
141 None,
142 None,
143 &[],
144 FUZZY_NAME_MATCH_LIMIT,
145 );
146 Ok(resolve_from_candidates(fts_results))
147}