Skip to main content

agentstatedeveloper_cli/commands/
references.rs

1//! `asd references <name>` — exact-symbol reference search with rg parity.
2//!
3//! ASD's index stores one row per symbol definition; it does not index
4//! per-occurrence call sites or text references. For literal "find every line
5//! that mentions this identifier" queries (the rg use case) we shell out to
6//! `rg --json --fixed-strings --word-regexp <name>`. We then merge the
7//! definition record(s) from the index so the caller gets both:
8//!
9//!   - `definitions`: canonical Symbol records (with effects/ledger context)
10//!   - `occurrences`: every literal text occurrence in the worktree
11//!
12//! This is intentionally precision-over-recall: no tokenization, no CamelCase
13//! splitting, no BM25 reranking. If `rg` is not on PATH, returns an error
14//! pointing the user at installation — we do not silently degrade to a slower
15//! Rust walker (Plan B may add a native fallback).
16//!
17//! See Plan A, t-004.
18
19use std::path::PathBuf;
20use std::process::Command as Proc;
21
22use anyhow::{Context, Result};
23use clap::Args;
24use serde_json::{Value, json};
25
26use agentstatedeveloper_core::{AsgIndexStore, Engine, IndexStore, Symbol};
27
28use crate::config::Config;
29
30#[derive(Debug, Args)]
31pub struct ReferencesArgs {
32    /// Symbol name to find references for. Pass a qname (e.g. `pkg.mod.Type`)
33    /// for exact definition lookup, or a bare identifier (e.g. `MasterBusParams`)
34    /// to match any symbol whose qname ends with `.<name>` plus all text
35    /// occurrences.
36    pub name: String,
37
38    /// Project root to search. Defaults to the current directory.
39    /// `rg` respects `.gitignore` from this directory downward.
40    #[arg(long)]
41    pub path: Option<PathBuf>,
42
43    /// Cap the number of occurrences returned. 0 = unlimited.
44    #[arg(long, default_value = "500")]
45    pub limit: usize,
46
47    /// Restrict the rg scan to files matching this glob (passed through
48    /// as `rg --glob`). Repeatable.
49    #[arg(long = "glob")]
50    pub globs: Vec<String>,
51}
52
53pub fn run(cfg: &Config, args: ReferencesArgs) -> Result<()> {
54    let engine = Engine::open_sqlite(&cfg.db_path)?;
55    let index = AsgIndexStore::from_engine(&engine);
56
57    let definitions = lookup_definitions(&engine, &index, &args.name)?;
58
59    let search_root = args
60        .path
61        .clone()
62        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
63
64    let rg_result = rg_occurrences(&search_root, &args.name, &args.globs, args.limit);
65
66    let (occurrences, scan_status) = match rg_result {
67        Ok(occ) => (occ, "ok"),
68        Err(e) => (
69            Vec::new(),
70            Box::leak(e.to_string().into_boxed_str()) as &str,
71        ),
72    };
73
74    let out = json!({
75        "name": args.name,
76        "search_root": search_root.display().to_string(),
77        "definitions": definitions,
78        "occurrences": occurrences,
79        "occurrence_count": occurrences.len(),
80        "limit": args.limit,
81        "scan": {
82            "tool": "rg",
83            "status": scan_status,
84            "flags": ["--fixed-strings", "--word-regexp"],
85        },
86        "confidence": "exact-literal text match via rg + index definition lookup; no fuzzy/BM25 ranking",
87    });
88
89    println!("{}", serde_json::to_string_pretty(&out)?);
90    Ok(())
91}
92
93/// Resolve the user's `name` into one or more canonical Symbol records.
94/// - If `name` contains `.`, treat as a qname for direct lookup.
95/// - Otherwise scan the qname index and collect symbols whose qname equals
96///   `name` or ends with `.name` (case-sensitive — matches rg's default).
97fn lookup_definitions(engine: &Engine, index: &AsgIndexStore, name: &str) -> Result<Vec<Symbol>> {
98    if name.contains('.') {
99        return Ok(index
100            .get_symbol_by_qname(&engine.ref_name, name)?
101            .into_iter()
102            .collect());
103    }
104
105    let prefix = format!(
106        "{}/index/by-qname",
107        agentstatedeveloper_core::paths::ASD_ROOT
108    );
109    let tree = match engine.repo.get_tree(&engine.ref_name, &prefix) {
110        Ok(t) => t,
111        Err(_) => return Ok(Vec::new()),
112    };
113    let qnames: Vec<String> = match tree {
114        Value::Object(map) => map.keys().cloned().collect(),
115        _ => return Ok(Vec::new()),
116    };
117
118    let needle_dot = format!(".{name}");
119    let mut out = Vec::new();
120    for qn in qnames {
121        let matches = qn == name || qn.ends_with(&needle_dot);
122        if !matches {
123            continue;
124        }
125        if let Some(sym) = index.get_symbol_by_qname(&engine.ref_name, &qn)? {
126            out.push(sym);
127        }
128    }
129    out.sort_by(|a, b| a.qname.cmp(&b.qname));
130    Ok(out)
131}
132
133/// Shell out to `rg --json --fixed-strings --word-regexp <name>` and parse
134/// the JSON event stream into a flat occurrence list.
135fn rg_occurrences(
136    root: &std::path::Path,
137    name: &str,
138    globs: &[String],
139    limit: usize,
140) -> Result<Vec<Value>> {
141    let mut cmd = Proc::new("rg");
142    cmd.arg("--json")
143        .arg("--fixed-strings")
144        .arg("--word-regexp")
145        .arg("--no-messages");
146    for g in globs {
147        cmd.arg("--glob").arg(g);
148    }
149    cmd.arg("--").arg(name).arg(".");
150    cmd.current_dir(root);
151
152    let output = cmd
153        .output()
154        .with_context(|| "failed to spawn `rg`. Install ripgrep (https://github.com/BurntSushi/ripgrep) or skip this command.")?;
155
156    // rg exits 1 when there are no matches — that is not a failure for us.
157    let stdout = String::from_utf8_lossy(&output.stdout);
158    let mut occurrences = Vec::new();
159    for line in stdout.lines() {
160        let v: Value = match serde_json::from_str(line) {
161            Ok(v) => v,
162            Err(_) => continue,
163        };
164        if v.get("type").and_then(|t| t.as_str()) != Some("match") {
165            continue;
166        }
167        let data = match v.get("data") {
168            Some(d) => d,
169            None => continue,
170        };
171        let path = data
172            .get("path")
173            .and_then(|p| p.get("text"))
174            .and_then(|t| t.as_str())
175            .unwrap_or("");
176        let line_no = data
177            .get("line_number")
178            .and_then(|n| n.as_u64())
179            .unwrap_or(0);
180        let text = data
181            .get("lines")
182            .and_then(|l| l.get("text"))
183            .and_then(|t| t.as_str())
184            .unwrap_or("")
185            .trim_end_matches('\n')
186            .to_string();
187        let submatches: Vec<Value> = data
188            .get("submatches")
189            .and_then(|s| s.as_array())
190            .map(|arr| arr.clone())
191            .unwrap_or_default();
192        let columns: Vec<u64> = submatches
193            .iter()
194            .filter_map(|sm| sm.get("start").and_then(|s| s.as_u64()))
195            .collect();
196        occurrences.push(json!({
197            "file": path,
198            "line": line_no,
199            "columns": columns,
200            "text": text,
201        }));
202        if limit > 0 && occurrences.len() >= limit {
203            break;
204        }
205    }
206    Ok(occurrences)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn rg_scan_finds_literal_occurrences_in_self() {
215        // Skip silently if rg isn't on PATH — we don't want CI to fail on
216        // environments without it. Real coverage runs locally.
217        if Proc::new("rg").arg("--version").output().is_err() {
218            return;
219        }
220        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
221        let workspace_root = manifest.parent().unwrap().parent().unwrap();
222        let hits = rg_occurrences(workspace_root, "ReferencesArgs", &[], 50).unwrap();
223        assert!(
224            hits.iter().any(|h| h
225                .get("file")
226                .and_then(|f| f.as_str())
227                .unwrap_or("")
228                .ends_with("references.rs")),
229            "expected at least one ReferencesArgs hit in references.rs; got {hits:?}"
230        );
231    }
232
233    #[test]
234    fn rg_word_boundary_avoids_substrings() {
235        if Proc::new("rg").arg("--version").output().is_err() {
236            return;
237        }
238        let tmp = tempfile::tempdir().unwrap();
239        std::fs::write(tmp.path().join("a.txt"), "foo\nfoobar\nfoo_bar\n").unwrap();
240        let hits = rg_occurrences(tmp.path(), "foo", &[], 50).unwrap();
241        // --word-regexp matches "foo" on its own line and "foo_bar" (underscore
242        // is a word char), but NOT "foobar".
243        let lines: Vec<u64> = hits
244            .iter()
245            .filter_map(|h| h.get("line").and_then(|l| l.as_u64()))
246            .collect();
247        assert!(lines.contains(&1));
248        assert!(
249            !lines.contains(&2),
250            "foobar should not match word `foo`; got {hits:?}"
251        );
252        assert!(lines.contains(&3));
253    }
254}