1use std::collections::BTreeMap;
4
5use anyhow::{anyhow, bail, Result};
6use rusqlite::{params_from_iter, Connection, OptionalExtension};
7use serde_json::{json, Map, Value};
8
9use crate::model::Confidence;
10use crate::resolve::{sym_from_row, Resolver, SymRow, SYM_COLS, SYM_COLS_S};
11
12pub fn like_escape(s: &str) -> String {
13 s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_")
14}
15
16pub fn def_json(s: &SymRow) -> Value {
17 let mut m = Map::new();
18 m.insert("name".into(), json!(s.qualname));
19 m.insert("kind".into(), json!(s.kind));
20 m.insert("file".into(), json!(s.file));
21 m.insert("line".into(), json!(s.start_line));
22 m.insert("end_line".into(), json!(s.end_line));
23 m.insert("signature".into(), json!(s.signature));
24 if let Some(d) = &s.doc {
25 m.insert("doc".into(), json!(d));
26 }
27 Value::Object(m)
28}
29
30pub fn skeleton(conn: &Connection, file: &str, max_depth: Option<u32>) -> Result<String> {
33 let info: Option<(String, i64)> = conn
34 .query_row("SELECT lang,lines FROM files WHERE path=?1", [file], |r| {
35 Ok((r.get(0)?, r.get(1)?))
36 })
37 .optional()?;
38 let Some((lang, lines)) = info else {
39 let base = file.rsplit('/').next().unwrap_or(file);
40 let mut st = conn.prepare("SELECT path FROM files WHERE path LIKE ?1 ESCAPE '\\' LIMIT 5")?;
41 let like = format!("%{}", like_escape(base));
42 let similar: Vec<String> = st
43 .query_map([like], |r| r.get::<_, String>(0))?
44 .collect::<rusqlite::Result<_>>()?;
45 if similar.is_empty() {
46 bail!("file not indexed: {file} (unsupported language, ignored by .gitignore, or too large)");
47 }
48 bail!("file not indexed: {file}. Did you mean: {}", similar.join(", "));
49 };
50 let mut st = conn.prepare(
51 "SELECT start_line,depth,signature,doc FROM symbols WHERE file=?1 ORDER BY id",
52 )?;
53 let rows: Vec<(u32, u32, String, Option<String>)> = st
54 .query_map([file], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
55 .collect::<rusqlite::Result<_>>()?;
56 let total = rows.len();
57 let mut out = format!("# {file} · {lang} · {lines} lines · {total} symbols\n");
58 for (line, depth, sig, doc) in rows {
59 if let Some(m) = max_depth {
60 if depth > m {
61 continue;
62 }
63 }
64 let indent = " ".repeat(depth as usize);
65 let mut first = true;
66 for l in sig.lines() {
67 if first {
68 out.push_str(&format!("{line:>5}| {indent}{l}"));
69 first = false;
70 } else {
71 out.push_str(&format!("\n | {indent}{l}"));
72 }
73 }
74 if let Some(d) = doc {
75 out.push_str(&format!(" — {d}"));
76 }
77 out.push('\n');
78 }
79 Ok(out)
80}
81
82pub fn workspace_map(
85 conn: &Connection,
86 path: Option<&str>,
87 depth: usize,
88 max_chars: usize,
89) -> Result<String> {
90 let prefix = path
91 .map(|p| p.trim_matches('/').to_string())
92 .filter(|p| !p.is_empty());
93 let total_files: i64 = conn.query_row("SELECT count(*) FROM files", [], |r| r.get(0))?;
94 let total_syms: i64 = conn.query_row("SELECT count(*) FROM symbols", [], |r| r.get(0))?;
95
96 let mut st = conn.prepare(
97 "SELECT f.path, f.lang, s.name FROM files f
98 LEFT JOIN symbols s ON s.file=f.path AND s.depth=0 AND s.exported=1 AND s.kind<>'impl'
99 ORDER BY f.path, s.start_line",
100 )?;
101 let rows = st.query_map([], |r| {
102 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Option<String>>(2)?))
103 })?;
104 let mut files: BTreeMap<String, (String, Vec<String>)> = BTreeMap::new();
105 for r in rows {
106 let (p, lang, name) = r?;
107 if let Some(pre) = &prefix {
108 if p != *pre && !p.starts_with(&format!("{pre}/")) {
109 continue;
110 }
111 }
112 let e = files.entry(p).or_insert((lang, Vec::new()));
113 if let Some(n) = name {
114 if !e.1.contains(&n) {
115 e.1.push(n);
116 }
117 }
118 }
119
120 let base_depth = prefix.as_ref().map_or(0, |p| p.split('/').count());
121 let mut deep_counts: BTreeMap<String, usize> = BTreeMap::new();
122 for p in files.keys() {
123 let comps: Vec<&str> = p.split('/').collect();
124 let dir_comps = comps.len() - 1;
125 if dir_comps > base_depth + depth {
126 let key = comps[..base_depth + depth].join("/");
127 *deep_counts.entry(key).or_insert(0) += 1;
128 }
129 }
130
131 let mut out = format!(
132 "# workspace map · {total_files} files · {total_syms} symbols{}\n",
133 prefix.as_ref().map_or(String::new(), |p| format!(" · under {p}/"))
134 );
135 let mut printed: Vec<String> = Vec::new();
136 let mut deep_done: BTreeMap<String, bool> = BTreeMap::new();
137 let mut truncated = false;
138 for (p, (lang, names)) in &files {
139 let comps: Vec<&str> = p.split('/').collect();
140 let dir: Vec<String> = comps[..comps.len() - 1].iter().map(|s| s.to_string()).collect();
141 let limit = base_depth + depth;
142 let shown_dir: Vec<String> = dir.iter().take(limit).cloned().collect();
143 let mut common = 0;
144 while common < printed.len() && common < shown_dir.len() && printed[common] == shown_dir[common] {
145 common += 1;
146 }
147 let mut chunk = String::new();
148 for (i, d) in shown_dir.iter().enumerate().skip(common) {
149 chunk.push_str(&format!("{}{}/\n", " ".repeat(i), d));
150 }
151 printed = shown_dir.clone();
152 if dir.len() > limit {
153 let key = shown_dir.join("/");
154 if !deep_done.contains_key(&key) {
155 deep_done.insert(key.clone(), true);
156 let n = deep_counts.get(&key).copied().unwrap_or(0);
157 chunk.push_str(&format!("{}… {n} files in deeper directories\n", " ".repeat(shown_dir.len())));
158 }
159 } else {
160 let fname = comps[comps.len() - 1];
161 let mut shown: Vec<&str> = names.iter().take(8).map(|s| s.as_str()).collect();
162 let more = names.len().saturating_sub(8);
163 let tail = if more > 0 { format!(", +{more}") } else { String::new() };
164 if shown.is_empty() {
165 shown.push("");
166 }
167 chunk.push_str(&format!(
168 "{}{} [{}] {}{}\n",
169 " ".repeat(shown_dir.len()),
170 fname,
171 short_lang(lang),
172 shown.join(", "),
173 tail
174 ));
175 }
176 if out.len() + chunk.len() > max_chars {
177 truncated = true;
178 break;
179 }
180 out.push_str(&chunk);
181 }
182 if truncated {
183 out.push_str("… map truncated; pass `path` to zoom into a subtree or lower `depth`\n");
184 }
185 Ok(out)
186}
187
188fn short_lang(l: &str) -> &str {
189 match l {
190 "python" => "py",
191 "typescript" => "ts",
192 "javascript" => "js",
193 "rust" => "rs",
194 other => other,
195 }
196}
197
198pub fn search_symbols(conn: &Connection, query: &str, kind: Option<&str>, limit: usize) -> Result<String> {
201 let q = query.trim().to_lowercase().replace("::", ".");
202 if q.is_empty() {
203 bail!("query must not be empty");
204 }
205 let tokens: Vec<String> = q
206 .split(|c: char| c.is_whitespace() || c == '_' || c == '.' || c == '-')
207 .filter(|t| !t.is_empty())
208 .map(|t| t.to_string())
209 .collect();
210 let mut sql = format!("SELECT {SYM_COLS} FROM symbols WHERE kind<>'impl'");
211 let mut args: Vec<String> = Vec::new();
212 for t in &tokens {
213 args.push(format!("%{}%", like_escape(t)));
214 sql.push_str(&format!(" AND lower(qualname) LIKE ?{} ESCAPE '\\'", args.len()));
215 }
216 if let Some(k) = kind {
217 args.push(k.to_string());
218 sql.push_str(&format!(" AND kind=?{}", args.len()));
219 }
220 sql.push_str(" LIMIT 500");
221 let mut st = conn.prepare(&sql)?;
222 let rows: Vec<SymRow> = st
223 .query_map(params_from_iter(args.iter()), sym_from_row)?
224 .collect::<rusqlite::Result<_>>()?;
225
226 let compact: String = q.chars().filter(|c| c.is_alphanumeric()).collect();
227 let mut scored: Vec<(i32, SymRow)> = rows
228 .into_iter()
229 .map(|s| {
230 let n = s.name.to_lowercase();
231 let nc: String = n.chars().filter(|c| c.is_alphanumeric()).collect();
232 let mut score = if n == q || nc == compact {
233 100
234 } else if nc.starts_with(&compact) {
235 80
236 } else if nc.contains(&compact) {
237 60
238 } else {
239 40
240 };
241 if s.exported {
242 score += 5;
243 }
244 score -= (s.depth as i32).min(5);
245 (score, s)
246 })
247 .collect();
248 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.file.cmp(&b.1.file)).then(a.1.start_line.cmp(&b.1.start_line)));
249 let total = scored.len();
250 let results: Vec<Value> = scored.into_iter().take(limit).map(|(_, s)| def_json(&s)).collect();
251 Ok(serde_json::to_string(&json!({ "total_matches": total, "results": results }))?)
252}
253
254#[derive(Default)]
257pub struct GraphFilter<'a> {
258 pub kind: Option<&'a str>,
259 pub name: Option<&'a str>,
260 pub path_prefix: Option<&'a str>,
261 pub calls: Option<&'a str>,
262 pub called_by: Option<&'a str>,
263 pub exported: Option<bool>,
264 pub limit: usize,
265}
266
267pub fn query_graph(conn: &Connection, f: &GraphFilter<'_>) -> Result<String> {
270 let mut sql = format!("SELECT DISTINCT {SYM_COLS_S} FROM symbols s WHERE s.kind<>'impl'");
271 let mut args: Vec<String> = Vec::new();
272 if let Some(k) = f.kind {
273 args.push(k.to_string());
274 sql.push_str(&format!(" AND s.kind=?{}", args.len()));
275 }
276 if let Some(n) = f.name {
277 args.push(format!("%{}%", like_escape(&n.to_lowercase())));
278 sql.push_str(&format!(" AND lower(s.qualname) LIKE ?{} ESCAPE '\\'", args.len()));
279 }
280 if let Some(p) = f.path_prefix {
281 args.push(format!("{}%", like_escape(p.trim_start_matches("./"))));
282 sql.push_str(&format!(" AND s.file LIKE ?{} ESCAPE '\\'", args.len()));
283 }
284 if let Some(c) = f.calls {
285 args.push(c.to_string());
286 sql.push_str(&format!(
287 " AND s.id IN (SELECT r.enclosing_id FROM refs r WHERE r.name=?{} AND r.enclosing_id IS NOT NULL)",
288 args.len()
289 ));
290 }
291 if let Some(c) = f.called_by {
292 args.push(c.to_string());
293 sql.push_str(&format!(
294 " AND s.name IN (SELECT r.name FROM refs r JOIN symbols c ON c.id=r.enclosing_id WHERE c.name=?{})",
295 args.len()
296 ));
297 }
298 if let Some(e) = f.exported {
299 sql.push_str(if e { " AND s.exported=1" } else { " AND s.exported=0" });
300 }
301 let limit = f.limit.clamp(1, 200);
302 sql.push_str(&format!(" ORDER BY s.file, s.start_line LIMIT {limit}"));
303 let mut st = conn.prepare(&sql)?;
304 let rows: Vec<SymRow> = st
305 .query_map(params_from_iter(args.iter()), sym_from_row)?
306 .collect::<rusqlite::Result<_>>()?;
307 let results: Vec<Value> = rows.iter().map(def_json).collect();
308 Ok(serde_json::to_string(&json!({
309 "count": results.len(),
310 "note": "name-level match; use trace_symbol for resolved, confidence-tagged edges",
311 "results": results
312 }))?)
313}
314
315pub fn find_defs(conn: &Connection, name: &str, file: Option<&str>) -> Result<Vec<SymRow>> {
318 let clean = name.trim();
319 let norm = clean.replace("::", ".");
320 let leaf = clean
321 .rsplit("::")
322 .next()
323 .unwrap_or(clean)
324 .rsplit('.')
325 .next()
326 .unwrap_or(clean);
327
328 let mut sql = format!(
329 "SELECT {SYM_COLS} FROM symbols
330 WHERE (name=?1 OR qualname=?1 OR qualname=?2 OR name=?3 OR qualname LIKE ?4 ESCAPE '\\') AND kind<>'impl'"
331 );
332 let mut args: Vec<String> = vec![
333 clean.to_string(),
334 norm.clone(),
335 leaf.to_string(),
336 format!("%.{}", like_escape(leaf)),
337 ];
338 if let Some(f) = file {
339 args.push(format!("%{}", like_escape(f.trim_start_matches("./"))));
340 sql.push_str(&format!(" AND file LIKE ?{} ESCAPE '\\'", args.len()));
341 }
342 sql.push_str(" ORDER BY exported DESC, file, start_line LIMIT 50");
343 let mut st = conn.prepare(&sql)?;
344 let mut rows: Vec<SymRow> = st
345 .query_map(params_from_iter(args.iter()), sym_from_row)?
346 .collect::<rusqlite::Result<_>>()?;
347
348 if rows.is_empty() {
350 let mut fallback_sql = format!(
351 "SELECT {SYM_COLS} FROM symbols
352 WHERE (lower(name) LIKE ?1 ESCAPE '\\' OR lower(qualname) LIKE ?1 ESCAPE '\\') AND kind<>'impl'"
353 );
354 let mut fb_args: Vec<String> = vec![format!("%{}%", like_escape(&leaf.to_lowercase()))];
355 if let Some(f) = file {
356 fb_args.push(format!("%{}", like_escape(f.trim_start_matches("./"))));
357 fallback_sql.push_str(&format!(" AND file LIKE ?{} ESCAPE '\\'", fb_args.len()));
358 }
359 fallback_sql.push_str(" ORDER BY exported DESC, file, start_line LIMIT 20");
360 let mut fb_st = conn.prepare(&fallback_sql)?;
361 rows = fb_st
362 .query_map(params_from_iter(fb_args.iter()), sym_from_row)?
363 .collect::<rusqlite::Result<_>>()?;
364 }
365
366 Ok(rows)
367}
368
369pub fn trace_symbol(
370 conn: &Connection,
371 name: &str,
372 file: Option<&str>,
373 direction: &str,
374 limit: usize,
375) -> Result<String> {
376 let defs = find_defs(conn, name, file)?;
377 if defs.is_empty() {
378 bail!("no symbol named '{name}' found; use search_symbols for fuzzy lookup");
379 }
380 let resolver = Resolver::new(conn, vec![])?;
381 let limit = limit.clamp(1, 500);
382 let mut matches = Vec::new();
383 for d in defs.iter().take(3) {
384 let mut obj = Map::new();
385 obj.insert("definition".into(), def_json(d));
386 if direction != "callees" {
387 let (hits, truncated) = resolver.callers(d)?;
388 let (mut ex, mut pr, mut he) = (0, 0, 0);
389 for h in &hits {
390 match h.confidence {
391 Confidence::Exact => ex += 1,
392 Confidence::Probable => pr += 1,
393 Confidence::Heuristic => he += 1,
394 }
395 }
396 let list: Vec<Value> = hits
397 .iter()
398 .take(limit)
399 .map(|h| {
400 json!({
401 "file": h.file, "line": h.line, "usage": h.usage,
402 "in": h.enclosing, "confidence": h.confidence.as_str()
403 })
404 })
405 .collect();
406 obj.insert(
407 "callers_summary".into(),
408 json!({ "total": hits.len(), "exact": ex, "probable": pr, "heuristic": he,
409 "candidates_truncated": truncated }),
410 );
411 obj.insert("callers".into(), Value::Array(list));
412 }
413 if direction != "callers" {
414 let callees = resolver.callees(d)?;
415 let list: Vec<Value> = callees
416 .iter()
417 .take(limit)
418 .map(|c| {
419 json!({
420 "name": c.name, "line": c.line,
421 "targets": c.targets.iter().map(|(s, conf)| json!({
422 "symbol": s.qualname, "file": s.file, "line": s.start_line,
423 "confidence": conf.as_str()
424 })).collect::<Vec<_>>()
425 })
426 })
427 .collect();
428 obj.insert("callees".into(), Value::Array(list));
429 }
430 matches.push(Value::Object(obj));
431 }
432 let mut res = Map::new();
433 res.insert("matches".into(), Value::Array(matches));
434 if defs.len() > 3 {
435 res.insert(
436 "other_matches".into(),
437 Value::Array(defs[3..].iter().map(def_json).collect()),
438 );
439 res.insert(
440 "hint".into(),
441 json!("several definitions match; pass file_path to focus on one"),
442 );
443 }
444 Ok(serde_json::to_string(&Value::Object(res)).map_err(|e| anyhow!(e))?)
445}
446
447use std::collections::HashSet;
452use std::path::Path;
453
454use crate::resolve::ImportRow;
455use crate::store::{events_since, EventRow};
456
457const MAX_SOURCE_LINES: usize = 400;
458
459pub fn event_json(e: &EventRow) -> Value {
460 json!({
461 "seq": e.seq,
462 "ts": e.ts,
463 "file": e.file,
464 "kind": e.kind,
465 "added": e.added,
466 "removed": e.removed,
467 "signature_changed": e.changed,
468 })
469}
470
471pub fn workspace_changes(
473 conn: &Connection,
474 since: Option<i64>,
475 file: Option<&str>,
476 limit: usize,
477) -> Result<String> {
478 let limit = limit.clamp(1, 200);
479 let mut events = match since {
480 Some(s) => events_since(conn, s, file, limit, false)?,
481 None => {
482 let mut v = events_since(conn, 0, file, limit, true)?;
483 v.reverse();
484 v
485 }
486 };
487 events.truncate(limit);
488 let latest = crate::store::max_seq(conn)?;
489 Ok(serde_json::to_string(&json!({
490 "latest_seq": latest,
491 "events": events.iter().map(event_json).collect::<Vec<_>>(),
492 "hint": "pass since=<latest_seq> next time to see only newer changes"
493 }))?)
494}
495
496pub fn symbol_source(
498 conn: &Connection,
499 root: &Path,
500 name: &str,
501 file: Option<&str>,
502 context: usize,
503) -> Result<String> {
504 let defs = find_defs(conn, name, file)?;
505 if defs.is_empty() {
506 bail!("no symbol named '{name}' found; use search_symbols for fuzzy lookup");
507 }
508 let context = context.min(20);
509 let mut out = String::new();
510 for d in defs.iter().take(3) {
511 let text = std::fs::read_to_string(root.join(&d.file))
512 .map_err(|e| anyhow!("cannot read {}: {e}", d.file))?;
513 let lines: Vec<&str> = text.lines().collect();
514 let start = (d.start_line as usize).saturating_sub(1 + context).min(lines.len());
515 let mut end = ((d.end_line as usize) + context).min(lines.len());
516 let mut truncated = false;
517 if end > start && end - start > MAX_SOURCE_LINES {
518 end = start + MAX_SOURCE_LINES;
519 truncated = true;
520 }
521 out.push_str(&format!(
522 "// {}:{}-{} · {} {}\n",
523 d.file, d.start_line, d.end_line, d.kind, d.qualname
524 ));
525 for (i, l) in lines[start..end].iter().enumerate() {
526 out.push_str(&format!("{:>5}| {}\n", start + i + 1, l));
527 }
528 if truncated {
529 out.push_str("// … truncated; use get_file_skeleton to pick a smaller symbol\n");
530 }
531 out.push('\n');
532 }
533 if defs.len() > 3 {
534 out.push_str(&format!("// {} more matches; pass file_path to focus\n", defs.len() - 3));
535 }
536 Ok(out)
537}
538
539fn file_stem_key(file: &str) -> String {
540 let (dir, fname) = file.rsplit_once('/').unwrap_or(("", file));
541 let stem = fname.rsplit_once('.').map_or(fname, |x| x.0);
542 if matches!(stem, "__init__" | "mod" | "index") {
543 dir.rsplit('/').next().unwrap_or(stem).to_string()
544 } else {
545 stem.to_string()
546 }
547}
548
549pub fn file_context(conn: &Connection, file: &str) -> Result<String> {
552 let sk = skeleton(conn, file, None)?;
553 let resolver = Resolver::new(conn, vec![])?;
554
555 let mut st = conn.prepare(
556 "SELECT local,module,original,wildcard,line FROM imports WHERE file=?1 ORDER BY line",
557 )?;
558 let imps: Vec<ImportRow> = st
559 .query_map([file], |r| {
560 Ok(ImportRow {
561 local: r.get(0)?,
562 module: r.get(1)?,
563 original: r.get(2)?,
564 wildcard: r.get(3)?,
565 line: r.get(4)?,
566 })
567 })?
568 .collect::<rusqlite::Result<_>>()?;
569 let imports: Vec<Value> = imps
570 .iter()
571 .take(60)
572 .map(|imp| {
573 let files = resolver.import_files(file, imp);
574 json!({
575 "module": imp.module,
576 "name": if imp.wildcard { "*".to_string() } else { imp.original.clone().unwrap_or_else(|| imp.local.clone()) },
577 "line": imp.line,
578 "internal": if files.is_empty() { Value::Null } else { json!(files.iter().collect::<Vec<_>>()) },
579 })
580 })
581 .collect();
582
583 let mut nst = conn.prepare(
585 "SELECT DISTINCT name FROM symbols WHERE file=?1 AND depth=0 AND exported=1 AND kind<>'impl' LIMIT 100",
586 )?;
587 let names: Vec<String> = nst
588 .query_map([file], |r| r.get::<_, String>(0))?
589 .collect::<rusqlite::Result<_>>()?;
590 let mut args: Vec<String> = vec![file.to_string(), format!("%{}%", like_escape(&file_stem_key(file)))];
591 let mut sql = String::from(
592 "SELECT file,local,module,original,wildcard,line FROM imports WHERE file<>?1 AND (module LIKE ?2 ESCAPE '\\'",
593 );
594 if !names.is_empty() {
595 let ph: Vec<String> = (0..names.len()).map(|i| format!("?{}", i + 3)).collect();
596 sql.push_str(&format!(" OR original IN ({})", ph.join(",")));
597 args.extend(names.iter().cloned());
598 }
599 sql.push_str(") LIMIT 5000");
600 let mut dst = conn.prepare(&sql)?;
601 let cand: Vec<(String, ImportRow)> = dst
602 .query_map(params_from_iter(args.iter()), |r| {
603 Ok((
604 r.get::<_, String>(0)?,
605 ImportRow {
606 local: r.get(1)?,
607 module: r.get(2)?,
608 original: r.get(3)?,
609 wildcard: r.get(4)?,
610 line: r.get(5)?,
611 },
612 ))
613 })?
614 .collect::<rusqlite::Result<_>>()?;
615 let mut dependents: BTreeMap<String, u32> = BTreeMap::new();
616 let mut checked: HashSet<(String, String)> = HashSet::new();
617 for (f, imp) in &cand {
618 if !checked.insert((f.clone(), imp.module.clone())) {
619 continue;
620 }
621 if resolver.import_files(f, imp).iter().any(|x| x == file) {
622 let e = dependents.entry(f.clone()).or_insert(imp.line);
623 *e = (*e).min(imp.line);
624 }
625 }
626 let total = dependents.len();
627 let dep_list: Vec<Value> = dependents
628 .iter()
629 .take(40)
630 .map(|(f, l)| json!({ "file": f, "line": l }))
631 .collect();
632
633 let recent: Vec<Value> = events_since(conn, 0, Some(file), 5, true)?
634 .iter()
635 .map(event_json)
636 .collect();
637
638 Ok(serde_json::to_string(&json!({
639 "file": file,
640 "skeleton": sk,
641 "imports": imports,
642 "dependents": { "total": total, "files": dep_list },
643 "recent_changes": recent,
644 }))?)
645}
646
647pub fn is_test_file(path: &str) -> bool {
650 let p = path.replace('\\', "/").to_lowercase();
651 let file_name = p.rsplit('/').next().unwrap_or(&p);
652
653 if p.starts_with("tests/")
654 || p.contains("/tests/")
655 || p.starts_with("test/")
656 || p.contains("/test/")
657 || p.contains("/__tests__/")
658 || p.starts_with("spec/")
659 || p.contains("/spec/")
660 {
661 return true;
662 }
663
664 file_name.starts_with("test_")
665 || file_name.ends_with("_test.py")
666 || file_name.ends_with("_test.go")
667 || file_name.ends_with("_test.rs")
668 || file_name.ends_with(".test.ts")
669 || file_name.ends_with(".spec.ts")
670 || file_name.ends_with(".test.tsx")
671 || file_name.ends_with(".spec.tsx")
672 || file_name.ends_with(".test.js")
673 || file_name.ends_with(".spec.js")
674 || file_name.ends_with(".test.jsx")
675 || file_name.ends_with(".spec.jsx")
676 || file_name.ends_with("test.java")
677 || file_name.ends_with("tests.java")
678 || file_name.ends_with("testcase.java")
679 || file_name.ends_with("test.cs")
680 || file_name.ends_with("tests.cs")
681 || file_name.ends_with("test.php")
682 || file_name.ends_with("_spec.rb")
683 || file_name.ends_with("_test.rb")
684}
685
686pub fn suggested_test_command(test_file: &str, test_symbol: Option<&str>) -> String {
687 let p = test_file.replace('\\', "/");
688 let ext = p.rsplit('.').next().unwrap_or("");
689 let stem = p.rsplit('/').next().unwrap_or(&p).trim_end_matches(&format!(".{ext}"));
690
691 match ext {
692 "py" => {
693 if let Some(sym) = test_symbol {
694 format!("pytest {p} -k {sym}")
695 } else {
696 format!("pytest {p}")
697 }
698 }
699 "rs" => {
700 if let Some(sym) = test_symbol {
701 if p.starts_with("tests/") {
702 format!("cargo test --test {stem} {sym}")
703 } else {
704 format!("cargo test {sym}")
705 }
706 } else if p.starts_with("tests/") {
707 format!("cargo test --test {stem}")
708 } else {
709 "cargo test".to_string()
710 }
711 }
712 "go" => {
713 let dir = p.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
714 if let Some(sym) = test_symbol {
715 format!("go test ./{dir} -run {sym}")
716 } else {
717 format!("go test ./{dir}")
718 }
719 }
720 "ts" | "tsx" | "js" | "jsx" => {
721 if let Some(sym) = test_symbol {
722 format!("npm test -- {p} -t {sym}")
723 } else {
724 format!("npm test -- {p}")
725 }
726 }
727 "java" => {
728 if let Some(sym) = test_symbol {
729 format!("mvn test -Dtest={stem}#{sym}")
730 } else {
731 format!("mvn test -Dtest={stem}")
732 }
733 }
734 "cs" => {
735 if let Some(sym) = test_symbol {
736 format!("dotnet test --filter FullyQualifiedName~{sym}")
737 } else {
738 "dotnet test".to_string()
739 }
740 }
741 "php" => {
742 if let Some(sym) = test_symbol {
743 format!("vendor/bin/phpunit {p} --filter {sym}")
744 } else {
745 format!("vendor/bin/phpunit {p}")
746 }
747 }
748 "rb" => {
749 if let Some(sym) = test_symbol {
750 format!("bundle exec rspec {p} -e {sym}")
751 } else {
752 format!("bundle exec rspec {p}")
753 }
754 }
755 _ => format!("test {p}"),
756 }
757}
758
759pub fn affected_tests(
760 conn: &Connection,
761 file_path: Option<&str>,
762 symbol_name: Option<&str>,
763) -> Result<String> {
764 if file_path.is_none() && symbol_name.is_none() {
765 bail!("at least one of file_path or symbol_name must be provided");
766 }
767
768 let resolver = Resolver::new(conn, vec![])?;
769 let mut affected_files = HashSet::new();
770 let mut test_symbols = Vec::new();
771 let mut suggested_cmds = HashSet::new();
772
773 let mut defs = Vec::new();
774 if let Some(sym) = symbol_name {
775 defs = find_defs(conn, sym, file_path)?;
776 } else if let Some(f) = file_path {
777 let mut st = conn.prepare(&format!(
778 "SELECT {SYM_COLS} FROM symbols WHERE file=?1 AND kind<>'impl'"
779 ))?;
780 defs = st.query_map([f], sym_from_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
781 }
782
783 for def in &defs {
784 let (callers, _) = resolver.callers(def)?;
785 for h in callers {
786 let is_tf = is_test_file(&h.file);
787 let is_tsym = h.enclosing.as_ref().map_or(false, |e| {
788 let lower = e.to_lowercase();
789 lower.starts_with("test") || lower.contains("test")
790 });
791
792 if is_tf || is_tsym {
793 affected_files.insert(h.file.clone());
794 let enc_name = h.enclosing.clone().unwrap_or_else(|| "test".to_string());
795 suggested_cmds.insert(suggested_test_command(&h.file, Some(&enc_name)));
796 test_symbols.push(json!({
797 "test_file": h.file,
798 "test_symbol": enc_name,
799 "line": h.line,
800 "target_symbol": def.qualname,
801 "confidence": h.confidence.as_str()
802 }));
803 }
804 }
805 }
806
807 if let Some(f) = file_path {
808 let mut ist = conn.prepare(
809 "SELECT file, line FROM imports WHERE original LIKE ?1 OR module LIKE ?1",
810 )?;
811 let stem = f.rsplit('/').next().unwrap_or(f);
812 let like_pat = format!("%{}%", like_escape(stem));
813 let imp_rows = ist.query_map([like_pat], |r| Ok((r.get::<_, String>(0)?, r.get::<_, u32>(1)?)))?;
814 for row in imp_rows.flatten() {
815 let (imp_file, line) = row;
816 if imp_file != f && is_test_file(&imp_file) {
817 if affected_files.insert(imp_file.clone()) {
818 suggested_cmds.insert(suggested_test_command(&imp_file, None));
819 test_symbols.push(json!({
820 "test_file": imp_file,
821 "test_symbol": "<import>",
822 "line": line,
823 "target_symbol": f,
824 "confidence": "probable"
825 }));
826 }
827 }
828 }
829 }
830
831 let mut files_vec: Vec<String> = affected_files.into_iter().collect();
832 files_vec.sort();
833
834 let mut cmds_vec: Vec<String> = suggested_cmds.into_iter().collect();
835 cmds_vec.sort();
836
837 Ok(serde_json::to_string(&json!({
838 "target": {
839 "file": file_path,
840 "symbol": symbol_name
841 },
842 "affected_test_files": files_vec,
843 "affected_test_symbols": test_symbols,
844 "suggested_commands": cmds_vec,
845 "summary": format!("Found {} affected test files and {} test callers.", files_vec.len(), test_symbols.len())
846 }))?)
847}
848
849pub fn find_dead_code(
852 conn: &Connection,
853 path_prefix: Option<&str>,
854 limit: usize,
855) -> Result<String> {
856 let mut sql = format!(
857 "SELECT {SYM_COLS} FROM symbols s
858 WHERE s.kind IN ('function', 'method', 'class', 'struct', 'enum', 'interface', 'trait')
859 AND s.depth <= 1"
860 );
861 let mut args: Vec<String> = Vec::new();
862 if let Some(p) = path_prefix {
863 args.push(format!("{}%", like_escape(p.trim_start_matches("./"))));
864 sql.push_str(&format!(" AND s.file LIKE ?{} ESCAPE '\\'", args.len()));
865 }
866 sql.push_str(" ORDER BY s.exported ASC, s.file, s.start_line");
867
868 let mut st = conn.prepare(&sql)?;
869 let candidates: Vec<SymRow> = st
870 .query_map(params_from_iter(args.iter()), sym_from_row)?
871 .collect::<rusqlite::Result<_>>()?;
872
873 let mut ref_st = conn.prepare("SELECT count(*) FROM refs WHERE name=?1")?;
874 let mut imp_st = conn.prepare("SELECT count(*) FROM imports WHERE original=?1 OR local=?1")?;
875
876 let ignored_names: &[&str] = &[
877 "main", "init", "run", "real_main", "new", "default", "handler", "execute",
878 "activate", "deactivate", "setup", "teardown", "start", "stop", "close",
879 "dispose", "from", "into", "as_ref", "clone", "to_string", "fmt",
880 ];
881
882 let mut dead = Vec::new();
883 for s in candidates {
884 if is_test_file(&s.file) {
885 continue;
886 }
887 let lower = s.name.to_lowercase();
888 if ignored_names.contains(&lower.as_str())
889 || lower.starts_with("test_")
890 || lower.starts_with("test")
891 || lower.starts_with("__")
892 || lower.starts_with("on_")
893 || lower.starts_with("handle_")
894 {
895 continue;
896 }
897
898 let ref_count: i64 = ref_st.query_row([&s.name], |r| r.get(0))?;
899 if ref_count > 0 {
900 continue;
901 }
902 let imp_count: i64 = imp_st.query_row([&s.name], |r| r.get(0))?;
903 if imp_count > 0 {
904 continue;
905 }
906
907 let confidence = if s.exported {
908 Confidence::Heuristic
909 } else {
910 Confidence::Probable
911 };
912
913 dead.push(json!({
914 "name": s.qualname,
915 "file": s.file,
916 "line": s.start_line,
917 "kind": s.kind,
918 "signature": s.signature,
919 "exported": s.exported,
920 "confidence": confidence.as_str()
921 }));
922
923 if dead.len() >= limit.clamp(1, 200) {
924 break;
925 }
926 }
927
928 Ok(serde_json::to_string(&json!({
929 "count": dead.len(),
930 "candidates": dead,
931 "note": "Probable indicates internal unreferenced symbols; Heuristic indicates unreferenced exported symbols."
932 }))?)
933}