1use std::collections::HashSet;
4use std::io::IsTerminal;
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::store::Store;
13
14#[derive(Parser)]
18#[command(
19 name = "rq",
20 version,
21 about = "Ranked definition lookup — the one place a symbol is defined, first.",
22 long_about = "rq finds where a symbol is defined and ranks the one you most \
23likely meant to the top — not every match.\n\n\
24Search is the default action; operations are flags, not subcommands, so every \
25word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
26your current repo and recently-active files, and learns from the results you open \
27(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
28 after_help = "EXAMPLES:\n \
29rq thing search for a definition named or like \"thing\"\n \
30rq wibble --explain same, plus the score behind each result\n \
31rq thing --json machine-readable results (for editors/agents)\n \
32rq thing --no-record search without recording it (speculative/agent queries)\n \
33rq thing app/web restrict to a directory (rg-style)\n \
34rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
35rq --symbols FILE outline a file's definitions, in line order\n \
36rq thing -x rust restrict to a language (ruby/rust/go/python)\n \
37rq -o thing open the best match in your editor (and record it)\n \
38rq --index index the current repository\n \
39rq --status show indexing coverage\n \
40rq --drop remove this repo's index (opposite of --index)\n\n\
41SHORT FLAGS (easy to misread):\n \
42-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
43RECORDING (editor/shell hook):\n \
44rq --record --file <path> --line <n> <query>\n \
45Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
46to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
47The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
48automatically on the first search in a git repo."
49)]
50struct Cli {
51 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
58 target: Option<String>,
59
60 #[arg(value_name = "PATH")]
62 dirs: Vec<String>,
63
64 #[arg(short = 'e', long)]
66 explain: bool,
67
68 #[arg(long)]
70 no_record: bool,
71
72 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
77 open: bool,
78
79 #[arg(short = 'j', long)]
81 json: bool,
82
83 #[arg(short = 'J', long, conflicts_with = "json")]
85 ndjson: bool,
86
87 #[arg(short = 'p', long, value_name = "DIR")]
89 path: Vec<String>,
90
91 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
93 limit: usize,
94
95 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
98 kind: Vec<String>,
99
100 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
104 lang: Vec<String>,
105
106 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
108 index: Option<Option<String>>,
109
110 #[arg(long, conflicts_with_all = ["index", "record"])]
112 status: bool,
113
114 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
117 symbols: Option<String>,
118
119 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
123 drop: bool,
124
125 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
128 record: bool,
129
130 #[arg(long)]
132 file: Option<String>,
133
134 #[arg(long)]
136 line: Option<i64>,
137
138 #[arg(long, default_value = "select")]
140 event: String,
141
142 #[arg(long, value_name = "SHELL")]
144 completions: Option<Shell>,
145
146 #[arg(short = 'v', long)]
149 verbose: bool,
150
151 #[arg(long, value_name = "N", default_value_t = 0)]
154 jobs: usize,
155}
156
157pub fn run() -> ExitCode {
159 let cli = Cli::parse();
160 crate::trace::enable_from(cli.verbose);
161 crate::index::set_parse_jobs(cli.jobs);
162
163 if let Some(shell) = cli.completions {
164 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
165 return ExitCode::SUCCESS;
166 }
167 if let Some(path) = &cli.index {
168 let out = output_format(&cli);
170 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
171 }
172 if cli.status {
173 return cmd_status(output_format(&cli));
174 }
175 if cli.drop {
176 let out = output_format(&cli);
177 return cmd_drop(cli.target, out);
178 }
179 if cli.record {
180 let file = cli.file.expect("--record requires --file");
182 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
183 }
184 let out = output_format(&cli);
185 let mut paths = cli.path.clone();
187 paths.extend(cli.dirs.clone());
188 let kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
189 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
191 if let Some(file) = &cli.symbols {
192 return cmd_symbols(file, &kinds, &langs, out);
193 }
194 match cli.target {
195 Some(query) => cmd_search(
196 &query,
197 cli.explain,
198 out,
199 &paths,
200 &kinds,
201 &langs,
202 cli.limit,
203 cli.no_record,
204 cli.open,
205 ),
206 None => {
208 let _ = Cli::command().print_long_help();
209 ExitCode::SUCCESS
210 }
211 }
212}
213
214#[derive(Clone, Copy, PartialEq)]
216enum Output {
217 Text,
218 Json,
219 Ndjson,
220}
221
222fn output_format(cli: &Cli) -> Output {
223 if cli.ndjson {
224 Output::Ndjson
225 } else if cli.json {
226 Output::Json
227 } else {
228 Output::Text
229 }
230}
231
232const PATH_HEADROOM: usize = 200;
235
236const POLL_INTERVAL: Duration = Duration::from_millis(15);
239
240#[allow(clippy::too_many_arguments)]
243fn cmd_search(
244 query: &str,
245 explain: bool,
246 out: Output,
247 paths: &[String],
248 kinds: &[String],
249 langs: &[String],
250 want: usize,
251 no_record: bool,
252 open: bool,
253) -> ExitCode {
254 let limit = if paths.is_empty() && kinds.is_empty() && langs.is_empty() {
257 want
258 } else {
259 (want * 20).max(PATH_HEADROOM)
260 };
261 let _timer = crate::trace::Timer::start("search done");
262 let t_setup = std::time::Instant::now();
263 let mut store = match open_store() {
264 Ok(s) => s,
265 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
266 };
267 let cwd = std::env::current_dir().ok();
268 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
269
270 let root = cwd
276 .as_deref()
277 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
278
279 let active_paths: Vec<String> = match &root {
282 Some(c) if cwd_is_git => crate::index::branch_changed_files(c),
283 _ => Vec::new(),
284 };
285
286 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
291 let coverage = identity
292 .as_deref()
293 .and_then(|id| store.coverage_status(id).ok())
294 .flatten();
295
296 let known = coverage.is_some();
303 let warming_ok = (cwd_is_git || known) && coverage.as_deref() != Some("partial");
304 if crate::trace::enabled() {
305 crate::trace!(
306 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
307 root.as_deref().map_or("?".into(), crate::trace::abbrev),
308 identity.as_deref().unwrap_or("none"),
309 coverage.as_deref().unwrap_or("none"),
310 active_paths.len(),
311 );
312 }
313 let current = identity
314 .as_deref()
315 .and_then(|id| store.repository_id(id).ok().flatten());
316 let active = crate::search::ActiveFiles::new(active_paths.clone());
317
318 if !no_record && let Some(repo) = current {
322 let qn = query.to_ascii_lowercase();
323 if store.is_repeat_search(repo, &qn).unwrap_or(false) {
324 let _ = store.decay_selections(repo, &qn);
325 }
326 }
327
328 let warm_budget = answer_warm_budget() + deferred_warm_budget();
334 let was_warming = coverage.as_deref() != Some("complete");
335 let want_warm = warming_ok
336 && match &root {
337 Some(c) => {
338 was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
339 }
340 None => false,
341 };
342 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
345 let indexer = (want_warm && root.is_some()).then(|| {
346 crate::trace!(
347 "background warm ({warm_budget:?}, {} jobs)",
348 crate::index::parse_jobs()
349 );
350 let root = root.clone().expect("checked");
351 let active = active_paths.clone();
352 let q = query.to_string();
353 let warm_done = std::sync::Arc::clone(&warm_done);
354 std::thread::spawn(move || {
355 if let Ok(mut idx) = open_store() {
356 let _ =
358 crate::index::index_budgeted(&mut idx, &root, &active, warm_budget, Some(&q));
359 }
360 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
361 })
362 });
363
364 crate::trace!(
370 "setup (open + repo detect + warm decision): {} ms",
371 t_setup.elapsed().as_millis()
372 );
373 let answer_deadline = std::time::Instant::now() + answer_warm_budget();
374 let polling = indexer.is_some() && was_warming;
375 let mut hits = loop {
376 match crate::search::search(&store, query, current, &active, limit) {
377 Ok(h) => {
378 let confident = h.first().is_some_and(|hit| {
379 hit.features
380 .iter()
381 .any(|f| matches!(f.name, "exact" | "prefix"))
382 });
383 if !polling
384 || confident
385 || warm_done.load(std::sync::atomic::Ordering::Relaxed)
386 || std::time::Instant::now() >= answer_deadline
387 {
388 break h;
389 }
390 }
391 Err(e) => {
392 if let Some(h) = indexer {
393 let _ = h.join();
394 }
395 return fail(format_args!("rq: {e}"));
396 }
397 }
398 std::thread::sleep(POLL_INTERVAL);
399 };
400
401 if !hits.is_empty() && revalidate_top(&mut store, &hits) {
403 hits = crate::search::search(&store, query, current, &active, limit).unwrap_or_default();
404 }
405
406 if hits.is_empty()
410 && indexer.is_none()
411 && coverage.is_none()
412 && let Some(root) = &root
413 {
414 crate::trace!("empty → live (in-memory) scan of an untracked dir");
415 let deadline = std::time::Instant::now() + live_fallback_budget();
416 let mut h =
417 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
418 if h.is_empty() {
419 h = crate::search::live_search(
420 root,
421 query,
422 limit,
423 &HashSet::new(),
424 Some(deadline),
425 false,
426 );
427 }
428 hits = h;
429 }
430
431 let strong = |h: &crate::search::Hit| {
436 h.features
437 .iter()
438 .any(|f| matches!(f.name, "exact" | "prefix"))
439 };
440 if hits.iter().any(strong) {
441 hits.retain(strong);
442 }
443
444 if !paths.is_empty() {
447 let here = cwd.clone().unwrap_or_else(|| PathBuf::from("."));
451 let base = root.clone().unwrap_or_else(|| here.clone());
452 let norm: Vec<String> = paths
453 .iter()
454 .map(|p| repo_relative(&base, &here, p))
455 .collect();
456 hits.retain(|h| under_any(&h.file, &norm));
457 }
458 if !kinds.is_empty() {
459 hits.retain(|h| kinds.iter().any(|k| k == &h.kind));
460 }
461 if !langs.is_empty() {
462 hits.retain(|h| langs.iter().any(|l| l == &h.language));
463 }
464 if !paths.is_empty() || !kinds.is_empty() || !langs.is_empty() {
465 hits.truncate(want);
466 }
467
468 if hits.is_empty() {
469 match out {
470 Output::Json => println!("[]"),
471 Output::Ndjson => {}
472 Output::Text => eprintln!("no matches for {query:?}"),
473 }
474 if let Some(h) = indexer {
476 let _ = h.join();
477 }
478 return ExitCode::FAILURE;
479 }
480
481 for hit in &mut hits {
484 hit.signature = read_signature(
485 &store,
486 &hit.repo_identity,
487 &hit.file,
488 hit.line,
489 cwd.as_deref(),
490 );
491 }
492
493 if open {
497 return finish_open(
498 &mut store,
499 &hits,
500 query,
501 current,
502 root.as_deref(),
503 no_record,
504 );
505 }
506
507 match out {
508 Output::Ndjson => {
509 for hit in &hits {
510 match serde_json::to_string(hit) {
511 Ok(line) => println!("{line}"),
512 Err(e) => return fail(format_args!("rq: {e}")),
513 }
514 }
515 }
516 Output::Json => match serde_json::to_string_pretty(&hits) {
517 Ok(s) => println!("{s}"),
518 Err(e) => return fail(format_args!("rq: {e}")),
519 },
520 Output::Text => {
521 let color = match_color();
522 let c = color.as_deref();
523 for hit in &hits {
524 let name = hl(&hit.name, query, c);
527 let qualified = match &hit.parent {
528 Some(p) => format!("{name} · {p}"),
529 None => name,
530 };
531 println!(
532 "{}:{} {} {}",
533 hl_path(&hit.file, query, c),
534 hit.line,
535 hit.kind,
536 qualified
537 );
538 if let Some(sig) = &hit.signature {
539 println!(" {}", hl(sig, query, c));
540 }
541 if explain {
542 let parts: Vec<String> = hit
543 .features
544 .iter()
545 .map(|f| format!("{} {:.0}", f.name, f.value))
546 .collect();
547 println!(" score {:.0} = {}", hit.score, parts.join(" + "));
548 }
549 }
550 }
551 }
552
553 if !no_record {
558 let _ = store.record_event(
559 "search",
560 Some(&query.to_ascii_lowercase()),
561 current,
562 None,
563 None,
564 None,
565 );
566 }
567 deferred_maintenance(&mut store);
568
569 if let Some(h) = indexer {
575 let _ = h.join();
576 }
577
578 ExitCode::SUCCESS
579}
580
581fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
585 use std::io::{IsTerminal, Write};
586 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
587 return hits.first();
588 }
589 let mut err = std::io::stderr();
590 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
591 for (i, h) in hits.iter().enumerate() {
592 let _ = writeln!(
593 err,
594 " {}. {}:{} {} {}",
595 i + 1,
596 h.file,
597 h.line,
598 h.kind,
599 h.name
600 );
601 }
602 let _ = write!(err, "rq> ");
603 let _ = err.flush();
604 let mut line = String::new();
605 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
606 return None; }
608 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
609}
610
611fn parse_choice(input: &str, n: usize) -> Option<usize> {
614 let s = input.trim();
615 if s.is_empty() {
616 return Some(0);
617 }
618 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
619 (i < n).then_some(i)
620}
621
622fn finish_open(
626 store: &mut Store,
627 hits: &[crate::search::Hit],
628 query: &str,
629 current: Option<i64>,
630 root: Option<&std::path::Path>,
631 no_record: bool,
632) -> ExitCode {
633 let Some(hit) = choose_hit(hits) else {
634 return ExitCode::SUCCESS; };
636
637 if !no_record {
640 let _ = store.record_event(
641 "select",
642 Some(&query.to_ascii_lowercase()),
643 current,
644 Some(&hit.file),
645 Some(hit.line),
646 None,
647 );
648 deferred_maintenance(store);
649 }
650
651 let target = match root {
654 Some(r) => r.join(&hit.file),
655 None => PathBuf::from(&hit.file),
656 };
657 launch_editor(&target, hit.line)
658}
659
660fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
664 use std::os::unix::process::CommandExt;
665 let loc = format!("{}:{}", file.display(), line);
666 match open_command(file, line, &loc) {
667 Some((prog, args)) => {
668 let err = std::process::Command::new(&prog).args(&args).exec();
670 fail(format_args!("rq --open: cannot run {prog}: {err}"))
671 }
672 None => {
673 println!("{loc}");
674 ExitCode::SUCCESS
675 }
676 }
677}
678
679fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
683 let fstr = file.to_string_lossy().into_owned();
684
685 if let Some(t) = std::env::var_os("RQ_OPEN") {
686 let t = t.to_string_lossy();
687 let mut parts = t.split_whitespace().map(|p| {
688 p.replace("{file}", &fstr)
689 .replace("{line}", &line.to_string())
690 .replace("{}", loc)
691 });
692 if let Some(prog) = parts.next() {
693 return Some((prog, parts.collect()));
694 }
695 }
696
697 if on_path("code") {
698 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
699 }
700
701 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
702 let ed = ed.to_string_lossy().into_owned();
703 let l = ed.to_ascii_lowercase();
704 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
706 .iter()
707 .any(|e| l.contains(e))
708 {
709 return Some((ed, vec![format!("+{line}"), fstr]));
710 }
711 return Some((ed, vec![fstr]));
712 }
713
714 None
715}
716
717fn on_path(prog: &str) -> bool {
719 std::env::var_os("PATH")
720 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
721}
722
723fn repo_unchanged_since_index(
732 store: &Store,
733 cwd: &std::path::Path,
734 current: Option<i64>,
735 coverage: Option<&str>,
736) -> bool {
737 if coverage != Some("complete") {
738 return false;
739 }
740 let Some(id) = current else { return false };
741 let indexed_head = store.indexed_head(id).ok().flatten();
742 indexed_head.is_some()
743 && crate::index::git_head(cwd) == indexed_head
744 && !crate::index::is_dirty(cwd)
745}
746
747fn answer_warm_budget() -> Duration {
756 env_budget("RQ_ANSWER_BUDGET_MS", 500)
757}
758
759fn deferred_warm_budget() -> Duration {
762 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
763}
764
765fn live_fallback_budget() -> Duration {
768 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
769}
770
771fn env_budget(var: &str, default_ms: u64) -> Duration {
775 let ms = std::env::var(var)
776 .ok()
777 .and_then(|v| v.parse().ok())
778 .unwrap_or(default_ms);
779 Duration::from_millis(ms)
780}
781
782const AGGREGATE_BATCH: usize = 256;
785
786const KEEP_RECENT_EVENTS: i64 = 200;
789
790fn deferred_maintenance(store: &mut Store) {
793 let _ = store.aggregate_events(AGGREGATE_BATCH);
794 let _ = store.prune_events(KEEP_RECENT_EVENTS);
795}
796
797fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
800 let mut store = match open_store() {
801 Ok(s) => s,
802 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
803 };
804 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
805 let identity = crate::index::detect_identity(&cwd).to_string();
806 let repo_id = store.repository_id(&identity).ok().flatten();
807
808 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
811 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
812 None => file.to_string(),
813 };
814 let query_norm = query.map(|q| q.to_ascii_lowercase());
815
816 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
817 {
818 return fail(format_args!("rq record: {e}"));
819 }
820 deferred_maintenance(&mut store);
821 ExitCode::SUCCESS
822}
823
824fn read_signature(
827 store: &Store,
828 repo_identity: &str,
829 file: &str,
830 line: i64,
831 cwd: Option<&std::path::Path>,
832) -> Option<String> {
833 let root = store
834 .repository_id(repo_identity)
835 .ok()
836 .flatten()
837 .and_then(|id| store.checkout_root(id).ok().flatten())
838 .map(PathBuf::from)
839 .or_else(|| cwd.map(std::path::Path::to_path_buf))?;
840 let content = std::fs::read_to_string(root.join(file)).ok()?;
841 signature_in(&content, line)
842}
843
844fn signature_in(content: &str, line: i64) -> Option<String> {
848 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
849 let l = content.lines().nth(idx)?.trim();
850 (!l.is_empty()).then(|| l.to_string())
851}
852
853#[derive(serde::Serialize)]
857struct SymbolOut {
858 name: String,
859 kind: String,
860 language: String,
861 file: String,
862 line: i64,
863 #[serde(skip_serializing_if = "Option::is_none")]
864 parent: Option<String>,
865 repo: String,
866 #[serde(skip_serializing_if = "Option::is_none")]
867 signature: Option<String>,
868}
869
870fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
875 let mut store = match open_store() {
876 Ok(s) => s,
877 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
878 };
879 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
880 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
881 let rel = repo_relative(&root, &cwd, file_arg);
882
883 let identity = resolve_identity(&store, &root);
884 let coverage = store.coverage_status(&identity).ok().flatten();
885 let warming_ok = (crate::index::is_git_repo(&root) || coverage.is_some())
886 && coverage.as_deref() != Some("partial");
887 let current = store.repository_id(&identity).ok().flatten();
888 let needs_warm = warming_ok
889 && (coverage.as_deref() != Some("complete")
890 || !repo_unchanged_since_index(&store, &root, current, coverage.as_deref()));
891 if needs_warm {
892 let budget = answer_warm_budget() + deferred_warm_budget();
894 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
895 }
896
897 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
898 return emit_symbols(out, &[]); };
900 let mut rows = match store.symbols_in_file(repo_id, &rel) {
901 Ok(r) => r,
902 Err(e) => return fail(format_args!("rq: {e}")),
903 };
904 if !kinds.is_empty() {
905 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
906 }
907 if !langs.is_empty() {
908 rows.retain(|r| langs.iter().any(|l| l == &r.language));
909 }
910
911 let file_root = store
913 .checkout_root(repo_id)
914 .ok()
915 .flatten()
916 .map(PathBuf::from)
917 .unwrap_or_else(|| root.clone());
918 let content = std::fs::read_to_string(file_root.join(&rel)).ok();
919 let syms: Vec<SymbolOut> = rows
920 .into_iter()
921 .map(|r| SymbolOut {
922 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
923 name: r.name,
924 kind: r.kind,
925 language: r.language,
926 file: r.file,
927 line: r.line,
928 parent: r.parent,
929 repo: r.repo_identity,
930 })
931 .collect();
932 emit_symbols(out, &syms)
933}
934
935fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
938 if syms.is_empty() {
939 match out {
940 Output::Json => println!("[]"),
941 Output::Ndjson => {}
942 Output::Text => eprintln!("no symbols"),
943 }
944 return ExitCode::FAILURE;
945 }
946 match out {
947 Output::Ndjson => {
948 for s in syms {
949 match serde_json::to_string(s) {
950 Ok(line) => println!("{line}"),
951 Err(e) => return fail(format_args!("rq: {e}")),
952 }
953 }
954 }
955 Output::Json => match serde_json::to_string_pretty(&syms) {
956 Ok(s) => println!("{s}"),
957 Err(e) => return fail(format_args!("rq: {e}")),
958 },
959 Output::Text => {
960 for s in syms {
961 let qualified = match &s.parent {
962 Some(p) => format!("{} · {p}", s.name),
963 None => s.name.clone(),
964 };
965 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
966 if let Some(sig) = &s.signature {
967 println!(" {sig}");
968 }
969 }
970 }
971 }
972 ExitCode::SUCCESS
973}
974
975fn canonical_kind(s: &str) -> String {
978 match s.to_ascii_lowercase().as_str() {
979 "c" | "class" => "class",
980 "m" | "method" => "method",
981 "f" | "fn" | "func" | "function" => "function",
982 "mod" | "module" => "module",
983 "s" | "struct" => "struct",
984 "e" | "enum" => "enum",
985 "t" | "trait" => "trait",
986 other => return other.to_string(),
987 }
988 .to_string()
989}
990
991fn canonical_langs(s: &str) -> Vec<String> {
996 let t = s.to_ascii_lowercase();
997 let alias = match t.as_str() {
998 "rb" => Some("ruby"),
999 "rs" => Some("rust"),
1000 "golang" => Some("go"),
1001 _ => None,
1002 };
1003 let matched: Vec<String> = crate::lang::languages()
1004 .into_iter()
1005 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
1006 .map(str::to_string)
1007 .collect();
1008 if matched.is_empty() { vec![t] } else { matched }
1009}
1010
1011fn match_color() -> Option<String> {
1015 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
1016 return None;
1017 }
1018 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
1019 gc.split(':').find_map(|e| {
1020 e.strip_prefix("mt=")
1021 .or_else(|| e.strip_prefix("ms="))
1022 .filter(|v| !v.is_empty())
1023 .map(str::to_string)
1024 })
1025 });
1026 Some(style.unwrap_or_else(|| "1;31".to_string()))
1027}
1028
1029fn hl(text: &str, query: &str, color: Option<&str>) -> String {
1032 match color {
1033 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
1034 None => text.to_string(),
1035 }
1036}
1037
1038fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
1041 let Some(c) = color else {
1042 return path.to_string();
1043 };
1044 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
1045 let base_start = path[..base_byte].chars().count();
1046 let base = &path[base_byte..];
1050 let stem = match base.rfind('.') {
1051 Some(i) if i > 0 => &base[..i],
1052 _ => base,
1053 };
1054 let positions: Vec<usize> = crate::search::match_positions(query, stem)
1055 .into_iter()
1056 .map(|p| p + base_start)
1057 .collect();
1058 highlight(path, &positions, c)
1059}
1060
1061fn highlight(text: &str, positions: &[usize], color: &str) -> String {
1064 if positions.is_empty() {
1065 return text.to_string();
1066 }
1067 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
1068 let mut out = String::new();
1069 let mut on = false;
1070 for (i, c) in text.chars().enumerate() {
1071 match (matched.contains(&i), on) {
1072 (true, false) => {
1073 out.push_str("\x1b[");
1074 out.push_str(color);
1075 out.push('m');
1076 on = true;
1077 }
1078 (false, true) => {
1079 out.push_str("\x1b[0m");
1080 on = false;
1081 }
1082 _ => {}
1083 }
1084 out.push(c);
1085 }
1086 if on {
1087 out.push_str("\x1b[0m");
1088 }
1089 out
1090}
1091
1092fn under_any(file: &str, paths: &[String]) -> bool {
1096 paths.iter().any(|p| {
1097 let p = p.trim_start_matches("./").trim_end_matches('/');
1098 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
1099 })
1100}
1101
1102fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
1104 let p = std::path::Path::new(file);
1105 let abs = if p.is_absolute() {
1106 p.to_path_buf()
1107 } else {
1108 cwd.join(p)
1109 };
1110 let abs = abs.canonicalize().unwrap_or(abs);
1111 abs.strip_prefix(root)
1112 .map(|r| r.to_string_lossy().into_owned())
1113 .unwrap_or_else(|_| file.to_string())
1114}
1115
1116fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
1120 use std::collections::HashSet;
1121 let mut seen = HashSet::new();
1122 let mut changed = false;
1123 for hit in hits {
1124 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
1125 continue;
1126 }
1127 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
1128 continue;
1129 };
1130 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
1131 continue;
1132 };
1133 if let Ok(crate::index::Refresh::Updated) =
1134 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
1135 {
1136 changed = true;
1137 }
1138 }
1139 changed
1140}
1141
1142fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
1148 if let Ok(canon) = cwd.canonicalize() {
1149 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
1150 return identity;
1151 }
1152 if crate::index::repo_root(cwd).is_none() {
1153 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
1154 }
1155 }
1156 crate::index::detect_identity(cwd).to_string()
1157}
1158
1159fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
1160 let explicit = path.is_some();
1161 let target = path.unwrap_or_else(|| PathBuf::from("."));
1162 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
1167 let mut subdirs = subdirs.to_vec();
1172 if explicit
1173 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
1174 && t != r
1175 && let Ok(rel) = t.strip_prefix(&r)
1176 && !rel.as_os_str().is_empty()
1177 {
1178 subdirs.push(rel.to_string_lossy().into_owned());
1179 }
1180 let mut store = match open_store() {
1181 Ok(s) => s,
1182 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1183 };
1184 let identity = crate::index::detect_identity(&root).to_string();
1185 match crate::index::index_under(&mut store, &root, &subdirs) {
1186 Ok(stats) => {
1187 let partial = !subdirs.is_empty();
1188 let totals = store
1190 .repository_id(&identity)
1191 .ok()
1192 .flatten()
1193 .and_then(|id| store.repo_totals(id).ok());
1194 match out {
1195 Output::Json | Output::Ndjson => {
1196 let (files, symbols) = match totals {
1197 Some((f, s)) => (Some(f), Some(s)),
1198 None => (None, None),
1199 };
1200 return emit_json(
1201 out,
1202 &serde_json::json!({
1203 "repo": identity,
1204 "scope": if partial { "partial" } else { "full" },
1205 "files_added": stats.files_indexed,
1206 "symbols_added": stats.symbols,
1207 "files": files,
1208 "symbols": symbols,
1209 }),
1210 );
1211 }
1212 Output::Text => {
1213 let scope = if partial { " (partial)" } else { "" };
1214 match totals {
1215 Some((files, symbols)) => println!(
1216 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
1217 stats.files_indexed, stats.symbols
1218 ),
1219 None => println!(
1220 "{} file(s)/{} symbol(s) added this run{scope}",
1221 stats.files_indexed, stats.symbols
1222 ),
1223 }
1224 }
1225 }
1226 ExitCode::SUCCESS
1227 }
1228 Err(e) => fail(format_args!("rq --index: {e}")),
1229 }
1230}
1231
1232fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
1233 let mut store = match open_store() {
1234 Ok(s) => s,
1235 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1236 };
1237
1238 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
1242 let root = crate::index::repo_root(&path).unwrap_or(path);
1243 let from_path = crate::index::detect_identity(&root).to_string();
1244 let resolved = match store.repository_id(&from_path) {
1245 Ok(Some(id)) => Some((from_path.clone(), id)),
1246 Ok(None) => target.as_deref().and_then(|s| {
1247 store
1248 .repository_id(s)
1249 .ok()
1250 .flatten()
1251 .map(|id| (s.to_string(), id))
1252 }),
1253 Err(e) => return fail(format_args!("rq --drop: {e}")),
1254 };
1255
1256 let Some((identity, repo_id)) = resolved else {
1257 return match out {
1259 Output::Text => {
1260 println!("not indexed: {from_path}");
1261 ExitCode::SUCCESS
1262 }
1263 _ => emit_json(
1264 out,
1265 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
1266 ),
1267 };
1268 };
1269
1270 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
1271 match store.drop_repository(repo_id) {
1272 Ok(()) => match out {
1273 Output::Text => {
1274 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
1275 ExitCode::SUCCESS
1276 }
1277 _ => emit_json(
1278 out,
1279 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
1280 ),
1281 },
1282 Err(e) => fail(format_args!("rq --drop: {e}")),
1283 }
1284}
1285
1286fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
1290 let rendered = if out == Output::Json {
1291 serde_json::to_string_pretty(value)
1292 } else {
1293 serde_json::to_string(value)
1294 };
1295 match rendered {
1296 Ok(s) => {
1297 println!("{s}");
1298 ExitCode::SUCCESS
1299 }
1300 Err(e) => fail(format_args!("rq: {e}")),
1301 }
1302}
1303
1304fn cmd_status(out: Output) -> ExitCode {
1305 let store = match open_store() {
1306 Ok(s) => s,
1307 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1308 };
1309 let rows = match store.coverage_overview() {
1310 Ok(rows) => rows,
1311 Err(e) => return fail(format_args!("rq --status: {e}")),
1312 };
1313 match out {
1314 Output::Json => match serde_json::to_string_pretty(&rows) {
1315 Ok(s) => println!("{s}"),
1316 Err(e) => return fail(format_args!("rq: {e}")),
1317 },
1318 Output::Ndjson => {
1319 for r in &rows {
1320 match serde_json::to_string(r) {
1321 Ok(line) => println!("{line}"),
1322 Err(e) => return fail(format_args!("rq: {e}")),
1323 }
1324 }
1325 }
1326 Output::Text if rows.is_empty() => {
1327 println!("no repositories indexed yet (try `rq --index`)");
1328 }
1329 Output::Text => {
1330 for r in &rows {
1331 println!(
1332 "{:<10} {:>6} files {:>7} symbols {}",
1333 r.status, r.files, r.symbols, r.identity
1334 );
1335 }
1336 }
1337 }
1338 ExitCode::SUCCESS
1339}
1340
1341fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
1343 let path = db_path()?;
1344 if let Some(parent) = path.parent() {
1345 std::fs::create_dir_all(parent)?;
1346 }
1347 Ok(Store::open(&path)?)
1348}
1349
1350fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
1352 if let Ok(p) = std::env::var("RQ_DB") {
1353 return Ok(PathBuf::from(p));
1354 }
1355 let home = std::env::var("HOME")?;
1356 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
1357}
1358
1359fn fail(args: std::fmt::Arguments) -> ExitCode {
1360 eprintln!("{args}");
1361 ExitCode::FAILURE
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367
1368 #[test]
1369 fn open_menu_choice_parsing() {
1370 assert_eq!(parse_choice("\n", 5), Some(0));
1372 assert_eq!(parse_choice(" ", 5), Some(0));
1373 assert_eq!(parse_choice("3", 5), Some(2));
1374 assert_eq!(parse_choice("5", 5), Some(4));
1375 assert_eq!(parse_choice("6", 5), None);
1377 assert_eq!(parse_choice("0", 5), None);
1378 assert_eq!(parse_choice("q", 5), None);
1379 }
1380
1381 #[test]
1382 fn highlight_wraps_matched_runs() {
1383 assert_eq!(
1384 highlight("FooThing", &[0, 1, 2], "1;31"),
1385 "\u{1b}[1;31mFoo\u{1b}[0mThing"
1386 );
1387 assert_eq!(
1389 highlight("FooThing", &[0, 3], "1"),
1390 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
1391 );
1392 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
1394 }
1395
1396 #[test]
1397 fn hl_path_highlights_the_stem_not_the_extension() {
1398 let out = hl_path(
1401 "app/employees_controller.rb",
1402 "employeescontroller",
1403 Some("1;31"),
1404 );
1405 assert!(
1406 out.starts_with("app/\u{1b}[1;31memployees"),
1407 "stem highlighted: {out:?}"
1408 );
1409 assert!(
1410 out.ends_with("controller\u{1b}[0m.rb"),
1411 "`.rb` left un-highlighted: {out:?}"
1412 );
1413 }
1414}