Skip to main content

gobby_code/search/fts/
symbols.rs

1use std::collections::HashSet;
2
3use postgres::Client;
4
5use crate::config::Context;
6use crate::models::{SearchResult, Symbol};
7use crate::visibility;
8
9use super::common::{
10    FILTERED_FETCH_CAP, PgParam, SymbolFilters, SymbolOrder, append_unique_symbols, escape_like,
11    push_param, query_symbols_by_conditions, sanitize_pg_search_query,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct VisibleSearchOutcome<T> {
16    pub results: Vec<T>,
17    pub degraded: bool,
18}
19
20impl<T> VisibleSearchOutcome<T> {
21    fn ok(results: Vec<T>) -> Self {
22        Self {
23            results,
24            degraded: false,
25        }
26    }
27
28    fn degraded(results: Vec<T>) -> Self {
29        Self {
30            results,
31            degraded: true,
32        }
33    }
34}
35
36pub fn search_symbols_fts(
37    conn: &mut Client,
38    query: &str,
39    project_id: &str,
40    kind: Option<&str>,
41    language: Option<&str>,
42    paths: &[String],
43    limit: usize,
44) -> Vec<Symbol> {
45    let bm25_query = sanitize_pg_search_query(query);
46    if bm25_query.is_empty() || limit == 0 {
47        return Vec::new();
48    }
49
50    let mut params = Vec::new();
51    let query_placeholder = push_param(&mut params, bm25_query);
52    let project_placeholder = push_param(&mut params, project_id.to_string());
53    let conditions = vec![
54        format!(
55            "(cs.name @@@ {q} OR cs.qualified_name @@@ {q} OR cs.signature @@@ {q} OR cs.docstring @@@ {q} OR cs.summary @@@ {q})",
56            q = query_placeholder
57        ),
58        format!("cs.project_id = {project_placeholder}"),
59    ];
60    let filters = SymbolFilters {
61        kind,
62        language,
63        paths,
64    };
65    query_symbols_by_conditions(
66        conn,
67        conditions,
68        params,
69        filters,
70        limit,
71        SymbolOrder::Bm25Score,
72    )
73}
74
75/// Fallback LIKE search on symbol names.
76pub fn search_symbols_by_name(
77    conn: &mut Client,
78    query: &str,
79    project_id: &str,
80    kind: Option<&str>,
81    language: Option<&str>,
82    paths: &[String],
83    limit: usize,
84) -> Vec<Symbol> {
85    if query.trim().is_empty() || limit == 0 {
86        return Vec::new();
87    }
88    let escaped_query = escape_like(query);
89    let pattern = format!("%{escaped_query}%");
90    let mut params = Vec::new();
91    let project_placeholder = push_param(&mut params, project_id.to_string());
92    let name_placeholder = push_param(&mut params, pattern.clone());
93    let qualified_placeholder = push_param(&mut params, pattern);
94    let conditions = vec![
95        format!("cs.project_id = {project_placeholder}"),
96        format!(
97            "(cs.name LIKE {name_placeholder} ESCAPE '\\' OR cs.qualified_name LIKE {qualified_placeholder} ESCAPE '\\')"
98        ),
99    ];
100    query_symbols_by_conditions(
101        conn,
102        conditions,
103        params,
104        SymbolFilters {
105            kind,
106            language,
107            paths,
108        },
109        limit,
110        SymbolOrder::Name,
111    )
112}
113
114pub fn search_symbols_exact_first(
115    conn: &mut Client,
116    query: &str,
117    project_id: &str,
118    kind: Option<&str>,
119    language: Option<&str>,
120    paths: &[String],
121    limit: usize,
122) -> Vec<Symbol> {
123    if query.trim().is_empty() || limit == 0 {
124        return Vec::new();
125    }
126
127    let mut results = Vec::new();
128    let mut seen = HashSet::new();
129    let filters = SymbolFilters {
130        kind,
131        language,
132        paths,
133    };
134
135    let mut params = Vec::new();
136    let project = push_param(&mut params, project_id.to_string());
137    let query_param = push_param(&mut params, query.to_string());
138    let order = SymbolOrder::ExactCaseFirst(query_param.clone());
139    let exact = query_symbols_by_conditions(
140        conn,
141        vec![
142            format!("cs.project_id = {project}"),
143            format!(
144                "(cs.name = {q} OR cs.qualified_name = {q} OR lower(cs.name) = lower({q}) OR lower(cs.qualified_name) = lower({q}))",
145                q = query_param
146            ),
147        ],
148        params,
149        filters,
150        limit,
151        order,
152    );
153    append_unique_symbols(&mut results, &mut seen, exact, limit);
154    if results.len() >= limit {
155        return results;
156    }
157
158    let prefix_pattern = format!("{}%", escape_like(query));
159    let mut params = Vec::new();
160    let project = push_param(&mut params, project_id.to_string());
161    let prefix = push_param(&mut params, prefix_pattern);
162    let prefix_matches = query_symbols_by_conditions(
163        conn,
164        vec![
165            format!("cs.project_id = {project}"),
166            format!(
167                "(cs.name LIKE {prefix} ESCAPE '\\' OR cs.qualified_name LIKE {prefix} ESCAPE '\\')"
168            ),
169        ],
170        params,
171        filters,
172        limit,
173        SymbolOrder::Name,
174    );
175    append_unique_symbols(&mut results, &mut seen, prefix_matches, limit);
176    if results.len() >= limit {
177        return results;
178    }
179
180    let contains = search_symbols_by_name(conn, query, project_id, kind, language, paths, limit);
181    append_unique_symbols(&mut results, &mut seen, contains, limit);
182    if results.len() >= limit {
183        return results;
184    }
185
186    let fts = search_symbols_fts(conn, query, project_id, kind, language, paths, limit);
187    append_unique_symbols(&mut results, &mut seen, fts, limit);
188
189    results
190}
191
192pub fn search_symbols_fts_visible(
193    conn: &mut Client,
194    query: &str,
195    ctx: &Context,
196    kind: Option<&str>,
197    language: Option<&str>,
198    paths: &[String],
199    limit: usize,
200) -> VisibleSearchOutcome<Symbol> {
201    let bm25_query = sanitize_pg_search_query(query);
202    if bm25_query.is_empty() || limit == 0 {
203        return VisibleSearchOutcome::ok(Vec::new());
204    }
205
206    let mut params = Vec::new();
207    let query_placeholder = push_param(&mut params, bm25_query);
208    let conditions = vec![format!(
209        "(cs.name @@@ {q} OR cs.qualified_name @@@ {q} OR cs.signature @@@ {q} OR cs.docstring @@@ {q} OR cs.summary @@@ {q})",
210        q = query_placeholder
211    )];
212    query_visible_symbols_by_conditions(
213        conn,
214        ctx,
215        conditions,
216        params,
217        SymbolFilters {
218            kind,
219            language,
220            paths,
221        },
222        limit,
223        SymbolOrder::Bm25Score,
224    )
225}
226
227pub fn search_symbols_by_name_visible(
228    conn: &mut Client,
229    query: &str,
230    ctx: &Context,
231    kind: Option<&str>,
232    language: Option<&str>,
233    paths: &[String],
234    limit: usize,
235) -> VisibleSearchOutcome<Symbol> {
236    if query.trim().is_empty() || limit == 0 {
237        return VisibleSearchOutcome::ok(Vec::new());
238    }
239    let escaped_query = escape_like(query);
240    let pattern = format!("%{escaped_query}%");
241    let mut params = Vec::new();
242    let name_placeholder = push_param(&mut params, pattern.clone());
243    let qualified_placeholder = push_param(&mut params, pattern);
244    let conditions = vec![format!(
245        "(cs.name LIKE {name_placeholder} ESCAPE '\\' OR cs.qualified_name LIKE {qualified_placeholder} ESCAPE '\\')"
246    )];
247    query_visible_symbols_by_conditions(
248        conn,
249        ctx,
250        conditions,
251        params,
252        SymbolFilters {
253            kind,
254            language,
255            paths,
256        },
257        limit,
258        SymbolOrder::Name,
259    )
260}
261
262pub fn search_symbols_exact_first_visible(
263    conn: &mut Client,
264    query: &str,
265    ctx: &Context,
266    kind: Option<&str>,
267    language: Option<&str>,
268    paths: &[String],
269    limit: usize,
270) -> VisibleSearchOutcome<Symbol> {
271    if query.trim().is_empty() || limit == 0 {
272        return VisibleSearchOutcome::ok(Vec::new());
273    }
274
275    let mut results = Vec::new();
276    let mut seen = HashSet::new();
277    let mut degraded = false;
278    let filters = SymbolFilters {
279        kind,
280        language,
281        paths,
282    };
283
284    let mut params = Vec::new();
285    let query_param = push_param(&mut params, query.to_string());
286    let order = SymbolOrder::ExactCaseFirst(query_param.clone());
287    let exact = query_visible_symbols_by_conditions(
288        conn,
289        ctx,
290        vec![format!(
291            "(cs.name = {q} OR cs.qualified_name = {q} OR lower(cs.name) = lower({q}) OR lower(cs.qualified_name) = lower({q}))",
292            q = query_param
293        )],
294        params,
295        filters,
296        limit,
297        order,
298    );
299    degraded |= exact.degraded;
300    append_unique_symbols(&mut results, &mut seen, exact.results, limit);
301    if results.len() >= limit {
302        return VisibleSearchOutcome { results, degraded };
303    }
304
305    let prefix_pattern = format!("{}%", escape_like(query));
306    let mut params = Vec::new();
307    let prefix = push_param(&mut params, prefix_pattern);
308    let prefix_matches = query_visible_symbols_by_conditions(
309        conn,
310        ctx,
311        vec![format!(
312            "(cs.name LIKE {prefix} ESCAPE '\\' OR cs.qualified_name LIKE {prefix} ESCAPE '\\')"
313        )],
314        params,
315        filters,
316        limit,
317        SymbolOrder::Name,
318    );
319    degraded |= prefix_matches.degraded;
320    append_unique_symbols(&mut results, &mut seen, prefix_matches.results, limit);
321    if results.len() >= limit {
322        return VisibleSearchOutcome { results, degraded };
323    }
324
325    let contains = search_symbols_by_name_visible(conn, query, ctx, kind, language, paths, limit);
326    degraded |= contains.degraded;
327    append_unique_symbols(&mut results, &mut seen, contains.results, limit);
328    if results.len() >= limit {
329        return VisibleSearchOutcome { results, degraded };
330    }
331
332    let fts = search_symbols_fts_visible(conn, query, ctx, kind, language, paths, limit);
333    degraded |= fts.degraded;
334    append_unique_symbols(&mut results, &mut seen, fts.results, limit);
335
336    VisibleSearchOutcome { results, degraded }
337}
338
339fn query_visible_symbols_by_conditions(
340    conn: &mut Client,
341    ctx: &Context,
342    mut conditions: Vec<String>,
343    mut params: Vec<PgParam>,
344    filters: SymbolFilters<'_>,
345    limit: usize,
346    order: SymbolOrder,
347) -> VisibleSearchOutcome<Symbol> {
348    let project_ids = visibility::visible_project_ids(ctx);
349    if project_ids.is_empty() || limit == 0 {
350        return VisibleSearchOutcome::ok(Vec::new());
351    }
352    let project_placeholder = push_param(&mut params, project_ids);
353    conditions.push(format!("cs.project_id = ANY({project_placeholder})"));
354    let symbols = query_symbols_by_conditions(
355        conn,
356        conditions,
357        params,
358        filters,
359        limit.max(FILTERED_FETCH_CAP),
360        order,
361    );
362    let mut symbols = match visibility::filter_visible_symbols(conn, ctx, symbols) {
363        Ok(symbols) => symbols,
364        Err(error) => {
365            log::error!("visible symbol filtering failed: {error}");
366            return VisibleSearchOutcome::degraded(Vec::new());
367        }
368    };
369    symbols.truncate(limit);
370    VisibleSearchOutcome::ok(symbols)
371}
372
373/// Full-text search for symbols using pg_search BM25.
374pub fn search_text(
375    conn: &mut Client,
376    query: &str,
377    project_id: &str,
378    language: Option<&str>,
379    paths: &[String],
380    limit: usize,
381) -> Vec<SearchResult> {
382    search_symbols_fts(conn, query, project_id, None, language, paths, limit)
383        .into_iter()
384        .map(|s| s.to_brief())
385        .collect()
386}
387
388pub fn search_text_visible(
389    conn: &mut Client,
390    query: &str,
391    ctx: &Context,
392    language: Option<&str>,
393    paths: &[String],
394    limit: usize,
395) -> VisibleSearchOutcome<SearchResult> {
396    let results = search_symbols_fts_visible(conn, query, ctx, None, language, paths, limit);
397    VisibleSearchOutcome {
398        results: results.results.into_iter().map(|s| s.to_brief()).collect(),
399        degraded: results.degraded,
400    }
401}