Skip to main content

gobby_code/search/fts/
graph.rs

1use 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
105/// Resolve user input to a canonical symbol id for graph queries.
106///
107/// Resolution is fail-closed: ambiguous matches return `None` with suggestions.
108pub 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}