agentstatedeveloper_cli/commands/
references.rs1use 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 pub name: String,
37
38 #[arg(long)]
41 pub path: Option<PathBuf>,
42
43 #[arg(long, default_value = "500")]
45 pub limit: usize,
46
47 #[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
93fn 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
133fn 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 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 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 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}