ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
/// Lightweight symbol index built by regex-scanning workspace source files.
/// Supports Rust, Python, JS/TS, Go, Java/Kotlin.
use ignore::WalkBuilder;
use regex::Regex;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

// ── Types ─────────────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub enum SymbolKind {
    Function,
    AsyncFunction,
    Struct,
    Enum,
    Trait,
    Impl,
    Class,
    Method,
    Type,
    Const,
    Interface,
    Module,
}

impl std::fmt::Display for SymbolKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            SymbolKind::Function => "fn",
            SymbolKind::AsyncFunction => "async fn",
            SymbolKind::Struct => "struct",
            SymbolKind::Enum => "enum",
            SymbolKind::Trait => "trait",
            SymbolKind::Impl => "impl",
            SymbolKind::Class => "class",
            SymbolKind::Method => "method",
            SymbolKind::Type => "type",
            SymbolKind::Const => "const",
            SymbolKind::Interface => "interface",
            SymbolKind::Module => "mod",
        };
        write!(f, "{}", s)
    }
}

#[derive(Debug, Clone)]
pub struct Symbol {
    pub name: String,
    pub kind: SymbolKind,
    /// Path relative to workspace root.
    pub file: PathBuf,
    /// 1-based line where the symbol definition starts.
    pub line_start: usize,
    /// 1-based line where the symbol body ends (estimated).
    pub line_end: usize,
}

// ── Index ─────────────────────────────────────────────────────────────────────

pub struct SymbolIndex {
    /// Lowercase symbol name → all definitions with that name.
    index: HashMap<String, Vec<Symbol>>,
    workspace: PathBuf,
}

impl SymbolIndex {
    /// Walk all source files under `workspace` and build the index.
    pub fn build(workspace: &Path) -> Self {
        let mut index: HashMap<String, Vec<Symbol>> = HashMap::new();

        let walker = WalkBuilder::new(workspace)
            .hidden(false)
            .ignore(true)
            .git_ignore(true)
            .build();

        for entry in walker.flatten() {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            if !is_source_ext(ext) {
                continue;
            }
            let content = match std::fs::read_to_string(path) {
                Ok(c) => c,
                Err(_) => continue,
            };
            let rel = path.strip_prefix(workspace).unwrap_or(path).to_path_buf();
            for sym in extract_symbols(&content, &rel, ext) {
                index.entry(sym.name.to_lowercase()).or_default().push(sym);
            }
        }

        Self {
            index,
            workspace: workspace.to_path_buf(),
        }
    }

    /// Find all symbols whose name contains `query` (case-insensitive).
    /// Exact matches are returned first.
    pub fn find(&self, query: &str, limit: usize) -> Vec<&Symbol> {
        let q = query.to_lowercase();
        let mut results: Vec<&Symbol> = Vec::new();

        // Exact match first
        if let Some(exact) = self.index.get(&q) {
            results.extend(exact.iter());
        }

        // Substring matches
        if results.len() < limit {
            for (key, syms) in &self.index {
                if key == &q {
                    continue; // already added
                }
                if key.contains(q.as_str()) {
                    for sym in syms {
                        if results.len() >= limit {
                            break;
                        }
                        results.push(sym);
                    }
                }
                if results.len() >= limit {
                    break;
                }
            }
        }

        results.truncate(limit);
        results
    }

    /// Read the source body of the first symbol matching `name` (exact, case-insensitive).
    pub fn read_body(&self, name: &str) -> Option<String> {
        let sym = self.index.get(&name.to_lowercase())?.first()?;
        let full = self.workspace.join(&sym.file);
        let content = std::fs::read_to_string(&full).ok()?;
        let lines: Vec<&str> = content.lines().collect();
        let start = sym.line_start.saturating_sub(1).min(lines.len());
        let end = sym.line_end.min(lines.len());
        if start > end {
            return None;
        }
        Some(format!(
            "// {}:{}\n{}",
            sym.file.display(),
            sym.line_start,
            lines[start..end].join("\n")
        ))
    }

    pub fn symbol_count(&self) -> usize {
        self.index.values().map(|v| v.len()).sum()
    }
}

// ── Extraction ────────────────────────────────────────────────────────────────

fn is_source_ext(ext: &str) -> bool {
    matches!(
        ext,
        "rs" | "py"
            | "js"
            | "jsx"
            | "ts"
            | "tsx"
            | "go"
            | "java"
            | "kt"
            | "rb"
            | "c"
            | "cc"
            | "cpp"
            | "cxx"
            | "h"
            | "hpp"
            | "hxx"
    )
}

struct PatternDef {
    re: Regex,
    kind: SymbolKind,
}

fn extract_symbols(content: &str, file: &Path, ext: &str) -> Vec<Symbol> {
    let pats = patterns_for(ext);
    let lines: Vec<&str> = content.lines().collect();
    let mut out: Vec<Symbol> = Vec::new();
    let mut seen_positions = std::collections::HashSet::new();

    for pd in pats {
        for cap in pd.re.captures_iter(content) {
            let name = match cap.get(1) {
                Some(m) => m.as_str().to_string(),
                None => continue,
            };
            if name.len() > 64 {
                continue;
            }
            let byte_pos = cap.get(0).unwrap().start();
            let line_start = content[..byte_pos].matches('\n').count() + 1;
            if !seen_positions.insert(line_start) {
                continue; // duplicate at same line
            }
            let line_end = estimate_end(&lines, line_start - 1, ext);
            out.push(Symbol {
                name,
                kind: pd.kind.clone(),
                file: file.to_path_buf(),
                line_start,
                line_end,
            });
        }
    }

    out.sort_by_key(|s| s.line_start);
    out
}

/// Build patterns for a given extension group (called at most once per group).
fn build_patterns(specs: &[(&str, SymbolKind)]) -> Vec<PatternDef> {
    specs
        .iter()
        .filter_map(|(re_str, kind)| {
            Regex::new(re_str).ok().map(|re| PatternDef {
                re,
                kind: kind.clone(),
            })
        })
        .collect()
}

fn patterns_for(ext: &str) -> &'static [PatternDef] {
    use std::sync::OnceLock;

    static RS: OnceLock<Vec<PatternDef>> = OnceLock::new();
    static PY: OnceLock<Vec<PatternDef>> = OnceLock::new();
    static JS: OnceLock<Vec<PatternDef>> = OnceLock::new();
    static GO: OnceLock<Vec<PatternDef>> = OnceLock::new();
    static JV: OnceLock<Vec<PatternDef>> = OnceLock::new();
    static CC: OnceLock<Vec<PatternDef>> = OnceLock::new();

    match ext {
        "rs" => RS.get_or_init(|| build_patterns(&[
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?async\s+fn\s+(\w+)", SymbolKind::AsyncFunction),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?fn\s+(\w+)",         SymbolKind::Function),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)",     SymbolKind::Struct),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)",       SymbolKind::Enum),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)",      SymbolKind::Trait),
            (r"(?m)^[ \t]*impl(?:<[^>]*>)?\s+(\w+)",                    SymbolKind::Impl),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)",       SymbolKind::Type),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+(\w+)", SymbolKind::Const),
            (r"(?m)^[ \t]*(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)",        SymbolKind::Module),
        ])),
        "py" => PY.get_or_init(|| build_patterns(&[
            (r"(?m)^[ \t]*async\s+def\s+(\w+)", SymbolKind::AsyncFunction),
            (r"(?m)^[ \t]*def\s+(\w+)",         SymbolKind::Function),
            (r"(?m)^[ \t]*class\s+(\w+)",        SymbolKind::Class),
        ])),
        "js" | "jsx" | "ts" | "tsx" => JS.get_or_init(|| build_patterns(&[
            (r"(?m)^[ \t]*(?:export\s+)?(?:default\s+)?async\s+function\s+(\w+)", SymbolKind::AsyncFunction),
            (r"(?m)^[ \t]*(?:export\s+)?(?:default\s+)?function\s+(\w+)",         SymbolKind::Function),
            (r"(?m)^[ \t]*(?:export\s+)?(?:default\s+)?class\s+(\w+)",            SymbolKind::Class),
            (r"(?m)^[ \t]*(?:export\s+)?interface\s+(\w+)",                       SymbolKind::Interface),
            (r"(?m)^[ \t]*(?:export\s+)?type\s+(\w+)\s*=",                        SymbolKind::Type),
            (r"(?m)^[ \t]*(?:export\s+)?(?:const|let)\s+(\w+)\s*[=:]",           SymbolKind::Const),
        ])),
        "go" => GO.get_or_init(|| build_patterns(&[
            (r"(?m)^func\s+(?:\([^)]*\)\s+)?(\w+)", SymbolKind::Function),
            (r"(?m)^type\s+(\w+)\s+struct",         SymbolKind::Struct),
            (r"(?m)^type\s+(\w+)\s+interface",      SymbolKind::Interface),
            (r"(?m)^type\s+(\w+)\b",                SymbolKind::Type),
            (r"(?m)^const\s+(\w+)",                 SymbolKind::Const),
        ])),
        "java" | "kt" | "rb" => JV.get_or_init(|| build_patterns(&[
            (r"(?m)^[ \t]*(?:public\s+|private\s+|protected\s+)?(?:static\s+)?(?:abstract\s+)?(?:class|record)\s+(\w+)", SymbolKind::Class),
            (r"(?m)^[ \t]*(?:public\s+|private\s+|protected\s+)?interface\s+(\w+)", SymbolKind::Interface),
        ])),
        "c" | "cc" | "cpp" | "cxx" | "h" | "hpp" | "hxx" => CC.get_or_init(|| build_patterns(&[
            (r"(?m)^[ \t]*struct\s+(\w+)",                         SymbolKind::Struct),
            (r"(?m)^[ \t]*class\s+(\w+)",                          SymbolKind::Class),
            (r"(?m)^[ \t]*enum\s+(?:class\s+)?(\w+)",              SymbolKind::Enum),
            (r"(?m)^[ \t]*union\s+(\w+)",                          SymbolKind::Struct),
            (r"(?m)^[ \t]*namespace\s+(\w+)",                      SymbolKind::Module),
            (r"(?m)^[ \t]*using\s+(\w+)\s*=",                      SymbolKind::Type),
            (r"(?m)^[ \t]*typedef\s+[\w\s*&:<>]+\s+(\w+)\s*;",    SymbolKind::Type),
            (r"(?m)^[ \t]*template\s*<[^>]*>\s*(?:class|struct)\s+(\w+)", SymbolKind::Struct),
            (r"(?m)^[ \t]*(?:(?:static|inline|extern|virtual|explicit|constexpr|override|friend|__attribute__\S*)\s+)*(?:[\w:*&<>\[\]~]+\s+)+(\w+)\s*\(", SymbolKind::Function),
        ])),
        _ => {
            static EMPTY: OnceLock<Vec<PatternDef>> = OnceLock::new();
            EMPTY.get_or_init(Vec::new)
        }
    }
}

/// Estimate the end line of a symbol starting at `start_idx` (0-based).
fn estimate_end(lines: &[&str], start_idx: usize, ext: &str) -> usize {
    let total = lines.len();
    if start_idx >= total {
        return total;
    }

    if ext == "py" {
        // Python: end when indentation returns to or below the def/class line
        let base = leading_spaces(lines[start_idx]);
        for i in (start_idx + 1)..total {
            let line = lines[i];
            if line.trim().is_empty() || line.trim().starts_with('#') {
                continue;
            }
            if leading_spaces(line) <= base {
                return i;
            }
        }
        return total;
    }

    // Brace languages: scan for balanced braces
    let mut depth: i32 = 0;
    let mut found_open = false;
    let limit = (start_idx + 250).min(total);
    for i in start_idx..limit {
        for ch in lines[i].chars() {
            match ch {
                '{' => {
                    depth += 1;
                    found_open = true;
                }
                '}' => {
                    depth -= 1;
                    if found_open && depth == 0 {
                        return i + 1;
                    }
                }
                _ => {}
            }
        }
    }
    limit
}

fn leading_spaces(s: &str) -> usize {
    s.len() - s.trim_start().len()
}