gobby-code 1.0.0

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
Documentation
use std::collections::HashSet;

use postgres::Client;

use crate::config::Context;
use crate::models::{SearchResult, Symbol};
use crate::visibility;

use super::common::{
    FILTERED_FETCH_CAP, PgParam, SymbolFilters, SymbolOrder, append_unique_symbols, escape_like,
    push_param, query_symbols_by_conditions, sanitize_pg_search_query,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VisibleSearchOutcome<T> {
    pub results: Vec<T>,
    pub degraded: bool,
}

impl<T> VisibleSearchOutcome<T> {
    fn ok(results: Vec<T>) -> Self {
        Self {
            results,
            degraded: false,
        }
    }

    fn degraded(results: Vec<T>) -> Self {
        Self {
            results,
            degraded: true,
        }
    }
}

pub fn search_symbols_fts(
    conn: &mut Client,
    query: &str,
    project_id: &str,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> Vec<Symbol> {
    let bm25_query = sanitize_pg_search_query(query);
    if bm25_query.is_empty() || limit == 0 {
        return Vec::new();
    }

    let mut params = Vec::new();
    let query_placeholder = push_param(&mut params, bm25_query);
    let project_placeholder = push_param(&mut params, project_id.to_string());
    let conditions = vec![
        format!(
            "(cs.name @@@ {q} OR cs.qualified_name @@@ {q} OR cs.signature @@@ {q} OR cs.docstring @@@ {q} OR cs.summary @@@ {q})",
            q = query_placeholder
        ),
        format!("cs.project_id = {project_placeholder}"),
    ];
    let filters = SymbolFilters {
        kind,
        language,
        paths,
    };
    query_symbols_by_conditions(
        conn,
        conditions,
        params,
        filters,
        limit,
        SymbolOrder::Bm25Score,
    )
}

/// Fallback LIKE search on symbol names.
pub fn search_symbols_by_name(
    conn: &mut Client,
    query: &str,
    project_id: &str,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> Vec<Symbol> {
    if query.trim().is_empty() || limit == 0 {
        return Vec::new();
    }
    let escaped_query = escape_like(query);
    let pattern = format!("%{escaped_query}%");
    let mut params = Vec::new();
    let project_placeholder = push_param(&mut params, project_id.to_string());
    let name_placeholder = push_param(&mut params, pattern.clone());
    let qualified_placeholder = push_param(&mut params, pattern);
    let conditions = vec![
        format!("cs.project_id = {project_placeholder}"),
        format!(
            "(cs.name LIKE {name_placeholder} ESCAPE '\\' OR cs.qualified_name LIKE {qualified_placeholder} ESCAPE '\\')"
        ),
    ];
    query_symbols_by_conditions(
        conn,
        conditions,
        params,
        SymbolFilters {
            kind,
            language,
            paths,
        },
        limit,
        SymbolOrder::Name,
    )
}

pub fn search_symbols_exact_first(
    conn: &mut Client,
    query: &str,
    project_id: &str,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> Vec<Symbol> {
    if query.trim().is_empty() || limit == 0 {
        return Vec::new();
    }

    let mut results = Vec::new();
    let mut seen = HashSet::new();
    let filters = SymbolFilters {
        kind,
        language,
        paths,
    };

    let mut params = Vec::new();
    let project = push_param(&mut params, project_id.to_string());
    let query_param = push_param(&mut params, query.to_string());
    let order = SymbolOrder::ExactCaseFirst(query_param.clone());
    let exact = query_symbols_by_conditions(
        conn,
        vec![
            format!("cs.project_id = {project}"),
            format!(
                "(cs.name = {q} OR cs.qualified_name = {q} OR lower(cs.name) = lower({q}) OR lower(cs.qualified_name) = lower({q}))",
                q = query_param
            ),
        ],
        params,
        filters,
        limit,
        order,
    );
    append_unique_symbols(&mut results, &mut seen, exact, limit);
    if results.len() >= limit {
        return results;
    }

    let prefix_pattern = format!("{}%", escape_like(query));
    let mut params = Vec::new();
    let project = push_param(&mut params, project_id.to_string());
    let prefix = push_param(&mut params, prefix_pattern);
    let prefix_matches = query_symbols_by_conditions(
        conn,
        vec![
            format!("cs.project_id = {project}"),
            format!(
                "(cs.name LIKE {prefix} ESCAPE '\\' OR cs.qualified_name LIKE {prefix} ESCAPE '\\')"
            ),
        ],
        params,
        filters,
        limit,
        SymbolOrder::Name,
    );
    append_unique_symbols(&mut results, &mut seen, prefix_matches, limit);
    if results.len() >= limit {
        return results;
    }

    let contains = search_symbols_by_name(conn, query, project_id, kind, language, paths, limit);
    append_unique_symbols(&mut results, &mut seen, contains, limit);
    if results.len() >= limit {
        return results;
    }

    let fts = search_symbols_fts(conn, query, project_id, kind, language, paths, limit);
    append_unique_symbols(&mut results, &mut seen, fts, limit);

    results
}

pub fn search_symbols_fts_visible(
    conn: &mut Client,
    query: &str,
    ctx: &Context,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> VisibleSearchOutcome<Symbol> {
    let bm25_query = sanitize_pg_search_query(query);
    if bm25_query.is_empty() || limit == 0 {
        return VisibleSearchOutcome::ok(Vec::new());
    }

    let mut params = Vec::new();
    let query_placeholder = push_param(&mut params, bm25_query);
    let conditions = vec![format!(
        "(cs.name @@@ {q} OR cs.qualified_name @@@ {q} OR cs.signature @@@ {q} OR cs.docstring @@@ {q} OR cs.summary @@@ {q})",
        q = query_placeholder
    )];
    query_visible_symbols_by_conditions(
        conn,
        ctx,
        conditions,
        params,
        SymbolFilters {
            kind,
            language,
            paths,
        },
        limit,
        SymbolOrder::Bm25Score,
    )
}

pub fn search_symbols_by_name_visible(
    conn: &mut Client,
    query: &str,
    ctx: &Context,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> VisibleSearchOutcome<Symbol> {
    if query.trim().is_empty() || limit == 0 {
        return VisibleSearchOutcome::ok(Vec::new());
    }
    let escaped_query = escape_like(query);
    let pattern = format!("%{escaped_query}%");
    let mut params = Vec::new();
    let name_placeholder = push_param(&mut params, pattern.clone());
    let qualified_placeholder = push_param(&mut params, pattern);
    let conditions = vec![format!(
        "(cs.name LIKE {name_placeholder} ESCAPE '\\' OR cs.qualified_name LIKE {qualified_placeholder} ESCAPE '\\')"
    )];
    query_visible_symbols_by_conditions(
        conn,
        ctx,
        conditions,
        params,
        SymbolFilters {
            kind,
            language,
            paths,
        },
        limit,
        SymbolOrder::Name,
    )
}

pub fn search_symbols_exact_first_visible(
    conn: &mut Client,
    query: &str,
    ctx: &Context,
    kind: Option<&str>,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> VisibleSearchOutcome<Symbol> {
    if query.trim().is_empty() || limit == 0 {
        return VisibleSearchOutcome::ok(Vec::new());
    }

    let mut results = Vec::new();
    let mut seen = HashSet::new();
    let mut degraded = false;
    let filters = SymbolFilters {
        kind,
        language,
        paths,
    };

    let mut params = Vec::new();
    let query_param = push_param(&mut params, query.to_string());
    let order = SymbolOrder::ExactCaseFirst(query_param.clone());
    let exact = query_visible_symbols_by_conditions(
        conn,
        ctx,
        vec![format!(
            "(cs.name = {q} OR cs.qualified_name = {q} OR lower(cs.name) = lower({q}) OR lower(cs.qualified_name) = lower({q}))",
            q = query_param
        )],
        params,
        filters,
        limit,
        order,
    );
    degraded |= exact.degraded;
    append_unique_symbols(&mut results, &mut seen, exact.results, limit);
    if results.len() >= limit {
        return VisibleSearchOutcome { results, degraded };
    }

    let prefix_pattern = format!("{}%", escape_like(query));
    let mut params = Vec::new();
    let prefix = push_param(&mut params, prefix_pattern);
    let prefix_matches = query_visible_symbols_by_conditions(
        conn,
        ctx,
        vec![format!(
            "(cs.name LIKE {prefix} ESCAPE '\\' OR cs.qualified_name LIKE {prefix} ESCAPE '\\')"
        )],
        params,
        filters,
        limit,
        SymbolOrder::Name,
    );
    degraded |= prefix_matches.degraded;
    append_unique_symbols(&mut results, &mut seen, prefix_matches.results, limit);
    if results.len() >= limit {
        return VisibleSearchOutcome { results, degraded };
    }

    let contains = search_symbols_by_name_visible(conn, query, ctx, kind, language, paths, limit);
    degraded |= contains.degraded;
    append_unique_symbols(&mut results, &mut seen, contains.results, limit);
    if results.len() >= limit {
        return VisibleSearchOutcome { results, degraded };
    }

    let fts = search_symbols_fts_visible(conn, query, ctx, kind, language, paths, limit);
    degraded |= fts.degraded;
    append_unique_symbols(&mut results, &mut seen, fts.results, limit);

    VisibleSearchOutcome { results, degraded }
}

fn query_visible_symbols_by_conditions(
    conn: &mut Client,
    ctx: &Context,
    mut conditions: Vec<String>,
    mut params: Vec<PgParam>,
    filters: SymbolFilters<'_>,
    limit: usize,
    order: SymbolOrder,
) -> VisibleSearchOutcome<Symbol> {
    let project_ids = visibility::visible_project_ids(ctx);
    if project_ids.is_empty() || limit == 0 {
        return VisibleSearchOutcome::ok(Vec::new());
    }
    let project_placeholder = push_param(&mut params, project_ids);
    conditions.push(format!("cs.project_id = ANY({project_placeholder})"));
    let symbols = query_symbols_by_conditions(
        conn,
        conditions,
        params,
        filters,
        limit.max(FILTERED_FETCH_CAP),
        order,
    );
    let mut symbols = match visibility::filter_visible_symbols(conn, ctx, symbols) {
        Ok(symbols) => symbols,
        Err(error) => {
            log::error!("visible symbol filtering failed: {error}");
            return VisibleSearchOutcome::degraded(Vec::new());
        }
    };
    symbols.truncate(limit);
    VisibleSearchOutcome::ok(symbols)
}

/// Full-text search for symbols using pg_search BM25.
pub fn search_text(
    conn: &mut Client,
    query: &str,
    project_id: &str,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> Vec<SearchResult> {
    search_symbols_fts(conn, query, project_id, None, language, paths, limit)
        .into_iter()
        .map(|s| s.to_brief())
        .collect()
}

pub fn search_text_visible(
    conn: &mut Client,
    query: &str,
    ctx: &Context,
    language: Option<&str>,
    paths: &[String],
    limit: usize,
) -> VisibleSearchOutcome<SearchResult> {
    let results = search_symbols_fts_visible(conn, query, ctx, None, language, paths, limit);
    VisibleSearchOutcome {
        results: results.results.into_iter().map(|s| s.to_brief()).collect(),
        degraded: results.degraded,
    }
}