1use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
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 --no-wait answer now from the committed index; don't block on a rebuild\n \
34rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
35rq thing app/web restrict to a directory (rg-style)\n \
36rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
37rq class Widget a leading kind keyword is shorthand for -k\n \
38rq --symbols FILE outline a file's definitions, in line order\n \
39rq thing -x rust restrict to a language (ruby/rust/go/python)\n \
40rq -o thing open the best match in your editor (and record it)\n \
41rq --index index the current repository\n \
42rq --status show indexing coverage\n \
43rq --drop remove this repo's index (opposite of --index)\n\n\
44SHORT FLAGS (easy to misread):\n \
45-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
46RECORDING (editor/shell hook):\n \
47rq --record --file <path> --line <n> <query>\n \
48Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
49to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
50The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
51automatically on the first search in a git repo. On a large, cold repo a search \
52keeps indexing until it can answer rather than reporting a premature \"no \
53matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
54= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
55)]
56struct Cli {
57 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
64 target: Option<String>,
65
66 #[arg(value_name = "PATH")]
68 dirs: Vec<String>,
69
70 #[arg(short = 'e', long)]
72 explain: bool,
73
74 #[arg(long)]
76 no_record: bool,
77
78 #[arg(long = "no-wait")]
84 no_wait: bool,
85
86 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
91 wait: Option<Duration>,
92
93 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
98 open: bool,
99
100 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
104 show: bool,
105
106 #[arg(short = 'j', long)]
108 json: bool,
109
110 #[arg(short = 'J', long, conflicts_with = "json")]
112 ndjson: bool,
113
114 #[arg(short = 'p', long, value_name = "DIR")]
116 path: Vec<String>,
117
118 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
120 limit: usize,
121
122 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
125 kind: Vec<String>,
126
127 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
131 lang: Vec<String>,
132
133 #[arg(long = "all-repos")]
136 all_repos: bool,
137
138 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
140 index: Option<Option<String>>,
141
142 #[arg(long, conflicts_with_all = ["index", "record"])]
144 status: bool,
145
146 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
149 symbols: Option<String>,
150
151 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
155 drop: bool,
156
157 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
160 record: bool,
161
162 #[arg(long)]
164 file: Option<String>,
165
166 #[arg(long)]
168 line: Option<i64>,
169
170 #[arg(long, default_value = "select")]
172 event: String,
173
174 #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "record", "drop", "symbols", "open", "show"])]
178 warm: Option<Option<String>>,
179
180 #[arg(long, value_name = "SHELL")]
182 completions: Option<Shell>,
183
184 #[arg(short = 'v', long)]
187 verbose: bool,
188
189 #[arg(long, value_name = "N", default_value_t = 0)]
192 jobs: usize,
193}
194
195pub fn run() -> ExitCode {
197 let cli = Cli::parse();
198 crate::trace::enable_from(cli.verbose);
199 crate::index::set_parse_jobs(cli.jobs);
200
201 if let Some(shell) = cli.completions {
202 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
203 return ExitCode::SUCCESS;
204 }
205 if let Some(path) = &cli.index {
206 let out = output_format(&cli);
208 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
209 }
210 if let Some(path) = &cli.warm {
211 return cmd_warm(path.as_deref());
212 }
213 if cli.status {
214 return cmd_status(output_format(&cli));
215 }
216 if cli.drop {
217 let out = output_format(&cli);
218 return cmd_drop(cli.target, out);
219 }
220 if cli.record {
221 if !matches!(cli.event.as_str(), "select" | "open") {
223 return fail(format_args!(
224 "rq --record: unknown --event {:?} (expected select or open)",
225 cli.event
226 ));
227 }
228 let file = cli.file.expect("--record requires --file");
230 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
231 }
232 let out = output_format(&cli);
233 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
234 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
236 if let Some(file) = &cli.symbols {
237 return cmd_symbols(file, &kinds, &langs, out);
238 }
239 let mut paths = cli.path.clone();
241 match cli.target {
242 Some(target) => {
243 let query = if cli.kind.is_empty() {
246 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
247 if let Some(k) = kw {
248 kinds.push(k.to_string());
249 }
250 paths.extend(dirs);
251 query
252 } else {
253 paths.extend(cli.dirs.clone());
254 target
255 };
256 cmd_search(&SearchArgs {
257 query: &query,
258 explain: cli.explain,
259 out,
260 paths: &paths,
261 kinds: &kinds,
262 langs: &langs,
263 want: cli.limit,
264 no_record: cli.no_record,
265 no_wait: cli.no_wait,
266 wait: cli.wait,
267 open: cli.open,
268 all_repos: cli.all_repos,
269 show: cli.show,
270 })
271 }
272 None => {
274 let _ = Cli::command().print_long_help();
275 ExitCode::SUCCESS
276 }
277 }
278}
279
280#[derive(Clone, Copy, PartialEq)]
282enum Output {
283 Text,
284 Json,
285 Ndjson,
286}
287
288fn output_format(cli: &Cli) -> Output {
289 if cli.ndjson {
290 Output::Ndjson
291 } else if cli.json {
292 Output::Json
293 } else {
294 Output::Text
295 }
296}
297
298const PATH_HEADROOM: usize = 200;
301
302const POLL_INTERVAL: Duration = Duration::from_millis(100);
309
310const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
314
315const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
319
320struct SearchArgs<'a> {
322 query: &'a str,
323 explain: bool,
324 out: Output,
325 paths: &'a [String],
326 kinds: &'a [String],
327 langs: &'a [String],
328 want: usize,
330 no_record: bool,
331 no_wait: bool,
333 wait: Option<Duration>,
336 open: bool,
337 all_repos: bool,
338 show: bool,
339}
340
341fn cmd_search(args: &SearchArgs) -> ExitCode {
343 let &SearchArgs {
344 query,
345 out,
346 want,
347 no_record,
348 no_wait,
349 wait,
350 open,
351 all_repos,
352 show,
353 ..
354 } = args;
355 let wait_budget = wait.unwrap_or_else(wait_budget);
358 let no_wait = no_wait || wait_budget.is_zero();
359 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
362 want
363 } else {
364 (want * 20).max(PATH_HEADROOM)
365 };
366 let _timer = crate::trace::Timer::start("search done");
367 let t_setup = std::time::Instant::now();
368 let mut store = match open_store() {
369 Ok(s) => s,
370 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
371 };
372 let cwd = std::env::current_dir().ok();
373 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
374
375 let root = cwd
381 .as_deref()
382 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
383
384 let active_paths: Vec<String> = match &root {
387 Some(c) if cwd_is_git => crate::index::branch_changed_files(c),
388 _ => Vec::new(),
389 };
390
391 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
396 let coverage = identity
397 .as_deref()
398 .and_then(|id| store.coverage_status(id).ok())
399 .flatten();
400
401 let known = coverage.is_some();
409 let warming_ok = cwd_is_git || known;
410 if crate::trace::enabled() {
411 crate::trace!(
412 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
413 root.as_deref().map_or("?".into(), crate::trace::abbrev),
414 identity.as_deref().unwrap_or("none"),
415 coverage.as_deref().unwrap_or("none"),
416 active_paths.len(),
417 );
418 }
419 let current = identity
420 .as_deref()
421 .and_then(|id| store.repository_id(id).ok().flatten());
422 let only_repo = if all_repos { None } else { current };
425 let active = crate::search::ActiveFiles::new(active_paths.clone());
426
427 if !no_record && let Some(repo) = current {
431 let qn = query.to_ascii_lowercase();
432 if store.is_repeat_search(repo, &qn).unwrap_or(false) {
433 let _ = store.decay_selections(repo, &qn);
434 }
435 }
436
437 let warm_budget = if warm_detach_enabled() {
444 answer_warm_budget()
445 } else {
446 answer_warm_budget() + deferred_warm_budget()
447 };
448 let was_warming = coverage.as_deref() != Some("complete");
449 let want_warm = warming_ok
450 && match &root {
451 Some(c) => {
452 was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
453 }
454 None => false,
455 };
456
457 let block = want_warm && was_warming && !no_wait;
471 let progress_ui = block && show_progress(out, stderr_interactive());
476 let indexer_budget = if block { wait_budget } else { warm_budget };
477 if progress_ui {
478 install_interrupt_handler();
479 }
480
481 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
484 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
485 crate::trace!(
486 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
487 crate::index::parse_jobs()
488 );
489 let root = root.clone().expect("checked");
490 let active = active_paths.clone();
491 let q = query.to_string();
492 let warm_done = std::sync::Arc::clone(&warm_done);
493 std::thread::spawn(move || {
494 if let Ok(mut idx) = open_store() {
495 let _ = if block {
497 crate::index::index_budgeted_cancellable(
500 &mut idx,
501 &root,
502 &active,
503 indexer_budget,
504 Some(&q),
505 &INTERRUPTED,
506 )
507 } else {
508 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
509 };
510 }
511 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
512 })
513 });
514
515 crate::trace!(
522 "setup (open + repo detect + warm decision): {} ms",
523 t_setup.elapsed().as_millis()
524 );
525 let poll_start = std::time::Instant::now();
526 let deadline = if progress_ui {
530 None
531 } else if block {
532 Some(poll_start + wait_budget)
533 } else {
534 Some(poll_start + answer_warm_budget())
535 };
536 let polling = indexer.is_some() && was_warming;
537 let label = repo_label(root.as_deref());
538 let mut drew_progress = false;
539 let mut last_draw = poll_start;
540 let mut hits = loop {
541 match crate::search::search(&store, query, current, only_repo, &active, limit) {
542 Ok(h) => {
543 let confident = h.first().is_some_and(|hit| {
544 hit.features
545 .iter()
546 .any(|f| matches!(f.name, "exact" | "prefix"))
547 });
548 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
549 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
550 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
551 if !polling || confident || warm_finished || stopped || timed_out {
552 break h;
553 }
554 if progress_ui
555 && poll_start.elapsed() >= HEADS_UP_DELAY
556 && last_draw.elapsed() >= PROGRESS_REDRAW
557 {
558 draw_progress(&store, identity.as_deref(), &label);
559 drew_progress = true;
560 last_draw = std::time::Instant::now();
561 }
562 }
563 Err(e) => {
564 if let Some(h) = indexer {
565 let _ = h.join();
566 }
567 return fail(format_args!("rq: {e}"));
568 }
569 }
570 std::thread::sleep(POLL_INTERVAL);
571 };
572 if drew_progress {
573 clear_progress();
574 }
575 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
577
578 if !hits.is_empty() && revalidate_top(&mut store, &hits) {
580 hits = crate::search::search(&store, query, current, only_repo, &active, limit)
581 .unwrap_or_default();
582 }
583
584 if !hits.iter().any(strong)
588 && indexer.is_none()
589 && coverage.is_none()
590 && let Some(root) = &root
591 {
592 let tail = live_fallback(root, query, limit);
593 hits = crate::search::merge(hits, tail, limit);
594 }
595
596 apply_gates(query, &mut hits);
597 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
598
599 if hits.is_empty() {
600 if block {
602 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
603 }
604 if let Some(h) = indexer {
605 let _ = h.join();
606 }
607 let incomplete = (block || no_wait)
614 && identity
615 .as_deref()
616 .and_then(|id| store.coverage_status(id).ok().flatten())
617 .as_deref()
618 != Some("complete");
619 maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
622 return no_match_code(out, query, interrupted, incomplete);
623 }
624
625 for hit in &mut hits {
628 hit.signature = read_signature(
629 &store,
630 &hit.repo_identity,
631 &hit.file,
632 hit.line,
633 cwd.as_deref(),
634 );
635 }
636 attach_confidence(&mut hits);
637
638 if show && let Some(code) = show_top_definition(&store, &mut hits, query, out, cwd.as_deref()) {
641 return code;
642 }
643
644 if open {
648 return finish_open(
649 &mut store,
650 &hits,
651 query,
652 current,
653 root.as_deref(),
654 no_record,
655 );
656 }
657
658 if let Some(code) = render_hits(args, &hits) {
659 return code;
660 }
661
662 if !no_record {
667 let _ = store.record_event(
668 "search",
669 Some(&query.to_ascii_lowercase()),
670 current,
671 None,
672 None,
673 None,
674 );
675 }
676 deferred_maintenance(&mut store);
677
678 if block {
683 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
684 }
685 if let Some(h) = indexer {
686 let _ = h.join();
687 }
688 maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
689
690 ExitCode::SUCCESS
691}
692
693fn maybe_detach_warm(
696 store: &Store,
697 want_warm: bool,
698 root: Option<&std::path::Path>,
699 identity: Option<&str>,
700) {
701 if !warm_detach_enabled() || !want_warm {
702 return;
703 }
704 let (Some(root), Some(id)) = (root, identity) else {
705 return;
706 };
707 if store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
708 return; }
710 spawn_detached_warm(root);
711}
712
713fn spawn_detached_warm(root: &std::path::Path) {
717 use std::os::unix::process::CommandExt;
718 let Ok(exe) = std::env::current_exe() else {
719 return;
720 };
721 let mut cmd = std::process::Command::new(exe);
722 cmd.arg("--warm")
723 .arg(root)
724 .stdin(std::process::Stdio::null())
725 .stdout(std::process::Stdio::null())
726 .stderr(std::process::Stdio::null())
727 .process_group(0);
728 match cmd.spawn() {
729 Ok(child) => crate::trace!(
730 "detached warm: pid {} for {}",
731 child.id(),
732 crate::trace::abbrev(root)
733 ),
734 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
735 }
736}
737
738const WARM_LOCK_TTL_SECS: i64 = 600;
741
742fn cmd_warm(path: Option<&str>) -> ExitCode {
747 #[cfg(target_os = "macos")]
750 unsafe extern "C" {
751 fn setiopolicy_np(
754 iotype: libc::c_int,
755 scope: libc::c_int,
756 policy: libc::c_int,
757 ) -> libc::c_int;
758 }
759 unsafe {
760 libc::nice(10);
761 #[cfg(target_os = "macos")]
762 setiopolicy_np(0, 0, 3);
763 }
764 let mut store = match open_store() {
765 Ok(s) => s,
766 Err(_) => return ExitCode::FAILURE,
767 };
768 let start = path
769 .map(PathBuf::from)
770 .or_else(|| std::env::current_dir().ok())
771 .unwrap_or_else(|| PathBuf::from("."));
772 let root = crate::index::repo_root(&start).unwrap_or(start);
773 let identity = resolve_identity(&store, &root);
774
775 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
778 && pid != std::process::id()
779 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
780 && now_secs() - ts < WARM_LOCK_TTL_SECS
781 {
782 return ExitCode::SUCCESS;
783 }
784 let _ = store.set_warm_lock(&identity, std::process::id());
785
786 let deadline = std::time::Instant::now() + warm_bg_budget();
789 let active = crate::index::branch_changed_files(&root);
790 loop {
791 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
792 if remaining.is_zero() {
793 break;
794 }
795 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
796 {
797 Ok(s) => s,
798 Err(_) => break,
799 };
800 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
801 || stats.files_indexed == 0
802 {
803 break;
804 }
805 }
806 let _ = store.clear_warm_lock(&identity);
807 ExitCode::SUCCESS
808}
809
810fn now_secs() -> i64 {
811 std::time::SystemTime::now()
812 .duration_since(std::time::UNIX_EPOCH)
813 .map(|d| d.as_secs() as i64)
814 .unwrap_or(0)
815}
816
817fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
820 crate::trace!("empty → live (in-memory) scan of an untracked dir");
821 let deadline = std::time::Instant::now() + live_fallback_budget();
822 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
823 if !h.is_empty() {
824 return h;
825 }
826 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
827}
828
829fn strong(h: &crate::search::Hit) -> bool {
831 h.features
832 .iter()
833 .any(|f| matches!(f.name, "exact" | "prefix"))
834}
835
836fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
845 if hits.iter().any(strong) {
846 hits.retain(strong);
847 }
848 crate::search::apply_scope_gate(query, hits);
849}
850
851fn apply_post_filters(
854 args: &SearchArgs,
855 cwd: Option<&std::path::Path>,
856 root: Option<&std::path::Path>,
857 hits: &mut Vec<crate::search::Hit>,
858) {
859 if !args.paths.is_empty() {
860 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
864 let base = root.map_or_else(|| here.clone(), PathBuf::from);
865 let norm: Vec<String> = args
866 .paths
867 .iter()
868 .map(|p| repo_relative(&base, &here, p))
869 .collect();
870 hits.retain(|h| under_any(&h.file, &norm));
871 }
872 if !args.kinds.is_empty() {
873 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
874 }
875 if !args.langs.is_empty() {
876 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
877 }
878 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
879 hits.truncate(args.want);
880 }
881}
882
883fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
889 let status = if interrupted {
890 "interrupted"
891 } else if incomplete {
892 "warming"
893 } else {
894 "no_match"
895 };
896 match out {
897 Output::Json | Output::Ndjson => {
898 let obj = serde_json::json!({ "status": status, "query": query });
899 let _ = emit_json(out, &obj); }
901 Output::Text if interrupted => {
902 eprintln!("rq: indexing interrupted — run again to finish")
903 }
904 Output::Text if incomplete => eprintln!(
905 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
906 ),
907 Output::Text => eprintln!("no matches for {query:?}"),
908 }
909 if incomplete {
910 ExitCode::from(2)
911 } else {
912 ExitCode::FAILURE
913 }
914}
915
916fn attach_confidence(hits: &mut [crate::search::Hit]) {
920 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
921 if t.is_none_or(|t| h.score > t) {
922 (Some(h.score), t)
923 } else if s.is_none_or(|s| h.score > s) {
924 (t, Some(h.score))
925 } else {
926 (t, s)
927 }
928 });
929 for hit in hits.iter_mut() {
930 let best_other = if Some(hit.score) == top { second } else { top };
931 hit.confidence = crate::search::confidence(
932 hit.score,
933 crate::search::match_quality(&hit.features),
934 best_other,
935 );
936 }
937}
938
939fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
942 if let Some(code) = emit_rows(args.out, hits) {
943 return Some(code);
944 }
945 if args.out != Output::Text {
946 return None;
947 }
948 let color = match_color();
949 let c = color.as_deref();
950 let query = args.query;
951 if args.show {
952 eprintln!(
954 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
955 hits.len()
956 );
957 }
958 for hit in hits {
959 let name = hl(&hit.name, query, c);
962 let qualified = match &hit.parent {
963 Some(p) => format!("{name} · {p}"),
964 None => name,
965 };
966 println!(
967 "{}:{} {} {}",
968 hl_path(&hit.file, query, c),
969 hit.line,
970 hit.kind,
971 qualified
972 );
973 if let Some(sig) = &hit.signature {
974 println!(" {}", hl(sig, query, c));
975 }
976 if args.explain {
977 let parts: Vec<String> = hit
978 .features
979 .iter()
980 .map(|f| format!("{} {:.0}", f.name, f.value))
981 .collect();
982 println!(
983 " confidence {:.2} · score {:.0} = {}",
984 hit.confidence,
985 hit.score,
986 parts.join(" + ")
987 );
988 }
989 }
990 None
991}
992
993fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
997 use std::io::{IsTerminal, Write};
998 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
999 return hits.first();
1000 }
1001 let mut err = std::io::stderr();
1002 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1003 for (i, h) in hits.iter().enumerate() {
1004 let _ = writeln!(
1005 err,
1006 " {}. {}:{} {} {}",
1007 i + 1,
1008 h.file,
1009 h.line,
1010 h.kind,
1011 h.name
1012 );
1013 }
1014 let _ = write!(err, "rq> ");
1015 let _ = err.flush();
1016 let mut line = String::new();
1017 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1018 return None; }
1020 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1021}
1022
1023fn parse_choice(input: &str, n: usize) -> Option<usize> {
1026 let s = input.trim();
1027 if s.is_empty() {
1028 return Some(0);
1029 }
1030 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1031 (i < n).then_some(i)
1032}
1033
1034fn finish_open(
1038 store: &mut Store,
1039 hits: &[crate::search::Hit],
1040 query: &str,
1041 current: Option<i64>,
1042 root: Option<&std::path::Path>,
1043 no_record: bool,
1044) -> ExitCode {
1045 let Some(hit) = choose_hit(hits) else {
1046 return ExitCode::SUCCESS; };
1048
1049 if !no_record {
1052 let _ = store.record_event(
1053 "select",
1054 Some(&query.to_ascii_lowercase()),
1055 current,
1056 Some(&hit.file),
1057 Some(hit.line),
1058 None,
1059 );
1060 deferred_maintenance(store);
1061 }
1062
1063 let target = match root {
1066 Some(r) => r.join(&hit.file),
1067 None => PathBuf::from(&hit.file),
1068 };
1069 launch_editor(&target, hit.line)
1070}
1071
1072fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1076 use std::os::unix::process::CommandExt;
1077 let loc = format!("{}:{}", file.display(), line);
1078 match open_command(file, line, &loc) {
1079 Some((prog, args)) => {
1080 let err = std::process::Command::new(&prog).args(&args).exec();
1082 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1083 }
1084 None => {
1085 println!("{loc}");
1086 ExitCode::SUCCESS
1087 }
1088 }
1089}
1090
1091fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1095 let fstr = file.to_string_lossy().into_owned();
1096
1097 if let Some(t) = std::env::var_os("RQ_OPEN") {
1098 let t = t.to_string_lossy();
1099 let mut parts = t.split_whitespace().map(|p| {
1100 p.replace("{file}", &fstr)
1101 .replace("{line}", &line.to_string())
1102 .replace("{}", loc)
1103 });
1104 if let Some(prog) = parts.next() {
1105 return Some((prog, parts.collect()));
1106 }
1107 }
1108
1109 if on_path("code") {
1110 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1111 }
1112
1113 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1114 let ed = ed.to_string_lossy().into_owned();
1115 let l = ed.to_ascii_lowercase();
1116 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1118 .iter()
1119 .any(|e| l.contains(e))
1120 {
1121 return Some((ed, vec![format!("+{line}"), fstr]));
1122 }
1123 return Some((ed, vec![fstr]));
1124 }
1125
1126 None
1127}
1128
1129fn on_path(prog: &str) -> bool {
1131 std::env::var_os("PATH")
1132 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1133}
1134
1135fn repo_unchanged_since_index(
1144 store: &Store,
1145 cwd: &std::path::Path,
1146 current: Option<i64>,
1147 coverage: Option<&str>,
1148) -> bool {
1149 if coverage != Some("complete") {
1150 return false;
1151 }
1152 let Some(id) = current else { return false };
1153 let indexed_head = store.indexed_head(id).ok().flatten();
1154 indexed_head.is_some()
1155 && crate::index::git_head(cwd) == indexed_head
1156 && !crate::index::is_dirty(cwd)
1157}
1158
1159fn answer_warm_budget() -> Duration {
1168 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1169}
1170
1171fn deferred_warm_budget() -> Duration {
1174 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1175}
1176
1177fn live_fallback_budget() -> Duration {
1180 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1181}
1182
1183fn warm_bg_budget() -> Duration {
1186 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1187}
1188
1189fn warm_detach_enabled() -> bool {
1193 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1194}
1195
1196fn wait_budget() -> Duration {
1205 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1206}
1207
1208fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1213 let s = s.trim();
1214 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1215 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1217 (n, 1.0)
1218 } else if let Some(n) = s.strip_suffix('s') {
1219 (n, 1_000.0)
1220 } else if let Some(n) = s.strip_suffix('m') {
1221 (n, 60_000.0)
1222 } else {
1223 (s, 1_000.0)
1225 };
1226 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1227 if !val.is_finite() || val < 0.0 {
1228 return Err(bad());
1229 }
1230 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1231}
1232
1233static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1237
1238extern "C" fn on_sigint(_: libc::c_int) {
1239 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1241}
1242
1243fn install_interrupt_handler() {
1246 static ONCE: std::sync::Once = std::sync::Once::new();
1247 ONCE.call_once(|| unsafe {
1248 let mut action: libc::sigaction = std::mem::zeroed();
1249 action.sa_sigaction = on_sigint as *const () as usize;
1250 libc::sigemptyset(&mut action.sa_mask);
1251 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1252 });
1253}
1254
1255fn stderr_interactive() -> bool {
1259 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1260}
1261
1262fn show_progress(out: Output, interactive: bool) -> bool {
1267 interactive && matches!(out, Output::Text)
1268}
1269
1270fn repo_label(root: Option<&std::path::Path>) -> String {
1273 root.and_then(|r| r.file_name())
1274 .map(|n| n.to_string_lossy().into_owned())
1275 .unwrap_or_else(|| "repo".into())
1276}
1277
1278fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1282 let files = identity
1283 .and_then(|id| store.repository_id(id).ok().flatten())
1284 .and_then(|rid| store.repo_totals(rid).ok())
1285 .map_or(0, |(f, _)| f);
1286 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1287 let _ = std::io::stderr().flush();
1288}
1289
1290fn clear_progress() {
1292 eprint!("\r\x1b[K");
1293 let _ = std::io::stderr().flush();
1294}
1295
1296fn env_budget(var: &str, default_ms: u64) -> Duration {
1300 let ms = std::env::var(var)
1301 .ok()
1302 .and_then(|v| v.parse().ok())
1303 .unwrap_or(default_ms);
1304 Duration::from_millis(ms)
1305}
1306
1307const AGGREGATE_BATCH: usize = 256;
1310
1311const KEEP_RECENT_EVENTS: i64 = 200;
1314
1315fn deferred_maintenance(store: &mut Store) {
1318 let _ = store.aggregate_events(AGGREGATE_BATCH);
1319 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1320}
1321
1322fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1325 let mut store = match open_store() {
1326 Ok(s) => s,
1327 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1328 };
1329 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1330 let identity = crate::index::detect_identity(&cwd).to_string();
1331 let repo_id = store.repository_id(&identity).ok().flatten();
1332
1333 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1336 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1337 None => file.to_string(),
1338 };
1339 let query_norm = query.map(|q| q.to_ascii_lowercase());
1340
1341 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1342 {
1343 return fail(format_args!("rq record: {e}"));
1344 }
1345 deferred_maintenance(&mut store);
1346 ExitCode::SUCCESS
1347}
1348
1349fn hit_file_roots(
1355 store: &Store,
1356 repo_identity: &str,
1357 cwd: Option<&std::path::Path>,
1358) -> Vec<PathBuf> {
1359 let mut roots: Vec<PathBuf> = store
1360 .repository_id(repo_identity)
1361 .ok()
1362 .flatten()
1363 .map(|id| store.checkout_roots(id).unwrap_or_default())
1364 .unwrap_or_default()
1365 .into_iter()
1366 .map(PathBuf::from)
1367 .collect();
1368 if let Some(c) = cwd {
1369 let c = c.to_path_buf();
1370 if !roots.contains(&c) {
1371 roots.push(c);
1372 }
1373 }
1374 roots
1375}
1376
1377fn read_signature(
1380 store: &Store,
1381 repo_identity: &str,
1382 file: &str,
1383 line: i64,
1384 cwd: Option<&std::path::Path>,
1385) -> Option<String> {
1386 hit_file_roots(store, repo_identity, cwd)
1387 .into_iter()
1388 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1389}
1390
1391const SHOW_CONFIDENCE: f64 = 0.85;
1395
1396fn show_top_definition(
1400 store: &Store,
1401 hits: &mut [crate::search::Hit],
1402 query: &str,
1403 out: Output,
1404 cwd: Option<&std::path::Path>,
1405) -> Option<ExitCode> {
1406 let top = hits.first()?;
1407 if top.confidence < SHOW_CONFIDENCE {
1408 return None; }
1410 let end = top.end_line.unwrap_or(top.line);
1411 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1412 hits[0].body = body;
1413 let top = &hits[0];
1414 match out {
1415 Output::Json | Output::Ndjson => {
1416 return Some(emit_json(out, top));
1418 }
1419 Output::Text => {
1420 let color = match_color();
1421 let c = color.as_deref();
1422 let name = hl(&top.name, query, c);
1423 let qualified = match &top.parent {
1424 Some(p) => format!("{name} · {p}"),
1425 None => name,
1426 };
1427 println!(
1428 "{}:{} {} {}",
1429 hl_path(&top.file, query, c),
1430 top.line,
1431 top.kind,
1432 qualified
1433 );
1434 match (&top.body, &top.signature) {
1435 (Some(body), _) => println!("{body}"),
1436 (None, Some(sig)) => println!("{sig}"),
1438 (None, None) => {}
1439 }
1440 }
1441 }
1442 Some(ExitCode::SUCCESS)
1443}
1444
1445fn read_span(
1448 store: &Store,
1449 repo_identity: &str,
1450 file: &str,
1451 start: i64,
1452 end: i64,
1453 cwd: Option<&std::path::Path>,
1454) -> Option<String> {
1455 hit_file_roots(store, repo_identity, cwd)
1456 .into_iter()
1457 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1458}
1459
1460fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1463 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1464 let lines: Vec<&str> = content.lines().collect();
1465 if s >= lines.len() {
1466 return None;
1467 }
1468 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1469 Some(lines[s..e].join("\n"))
1470}
1471
1472fn signature_in(content: &str, line: i64) -> Option<String> {
1476 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1477 let l = content.lines().nth(idx)?.trim();
1478 (!l.is_empty()).then(|| l.to_string())
1479}
1480
1481#[derive(serde::Serialize)]
1485struct SymbolOut {
1486 name: String,
1487 kind: String,
1488 language: String,
1489 file: String,
1490 line: i64,
1491 #[serde(skip_serializing_if = "Option::is_none")]
1492 end_line: Option<i64>,
1493 #[serde(skip_serializing_if = "Option::is_none")]
1494 parent: Option<String>,
1495 #[serde(skip_serializing_if = "Option::is_none")]
1496 visibility: Option<String>,
1497 repo: String,
1498 #[serde(skip_serializing_if = "Option::is_none")]
1499 signature: Option<String>,
1500}
1501
1502fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1507 let mut store = match open_store() {
1508 Ok(s) => s,
1509 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1510 };
1511 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1512 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1513 let rel = repo_relative(&root, &cwd, file_arg);
1514
1515 let identity = resolve_identity(&store, &root);
1516 let coverage = store.coverage_status(&identity).ok().flatten();
1517 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1518 let current = store.repository_id(&identity).ok().flatten();
1519 let needs_warm = warming_ok
1520 && (coverage.as_deref() != Some("complete")
1521 || !repo_unchanged_since_index(&store, &root, current, coverage.as_deref()));
1522 if needs_warm {
1523 let budget = answer_warm_budget() + deferred_warm_budget();
1525 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1526 }
1527
1528 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1529 return emit_symbols(out, &[]); };
1531 let mut rows = match store.symbols_in_file(repo_id, &rel) {
1532 Ok(r) => r,
1533 Err(e) => return fail(format_args!("rq: {e}")),
1534 };
1535 if !kinds.is_empty() {
1536 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
1537 }
1538 if !langs.is_empty() {
1539 rows.retain(|r| langs.iter().any(|l| l == &r.language));
1540 }
1541
1542 let content = hit_file_roots(&store, &identity, Some(&root))
1546 .iter()
1547 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
1548 let syms: Vec<SymbolOut> = rows
1549 .into_iter()
1550 .map(|r| SymbolOut {
1551 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
1552 name: r.name,
1553 kind: r.kind,
1554 language: r.language,
1555 file: r.file,
1556 line: r.line,
1557 end_line: r.end_line,
1558 parent: r.parent,
1559 visibility: r.visibility,
1560 repo: r.repo_identity,
1561 })
1562 .collect();
1563 emit_symbols(out, &syms)
1564}
1565
1566fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
1569 if syms.is_empty() {
1570 match out {
1571 Output::Json | Output::Ndjson => {
1572 let obj = serde_json::json!({ "status": "no_match" });
1573 let _ = emit_json(out, &obj); }
1575 Output::Text => eprintln!("no symbols"),
1576 }
1577 return ExitCode::FAILURE;
1578 }
1579 if let Some(code) = emit_rows(out, syms) {
1580 return code;
1581 }
1582 match out {
1583 Output::Json | Output::Ndjson => {}
1584 Output::Text => {
1585 for s in syms {
1586 let qualified = match &s.parent {
1587 Some(p) => format!("{} · {p}", s.name),
1588 None => s.name.clone(),
1589 };
1590 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
1591 if let Some(sig) = &s.signature {
1592 println!(" {sig}");
1593 }
1594 }
1595 }
1596 }
1597 ExitCode::SUCCESS
1598}
1599
1600fn keyword_kind(token: &str) -> Option<&'static str> {
1605 match token.to_ascii_lowercase().as_str() {
1606 "class" => Some("class"),
1607 "module" => Some("module"),
1608 "method" => Some("method"),
1609 "function" | "fn" => Some("function"),
1610 "struct" => Some("struct"),
1611 "enum" => Some("enum"),
1612 "trait" => Some("trait"),
1613 _ => None,
1614 }
1615}
1616
1617fn split_kind_keyword(
1623 target: String,
1624 dirs: Vec<String>,
1625) -> (Option<&'static str>, String, Vec<String>) {
1626 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
1629 let rest = rest.trim();
1630 if let Some(k) = keyword_kind(head)
1631 && !rest.is_empty()
1632 {
1633 return (Some(k), rest.to_string(), dirs);
1634 }
1635 } else if let Some(k) = keyword_kind(&target)
1636 && let Some((query, extra)) = dirs.split_first()
1637 {
1638 return (Some(k), query.clone(), extra.to_vec());
1640 }
1641 (None, target, dirs)
1642}
1643
1644fn canonical_kind(s: &str) -> String {
1647 match s.to_ascii_lowercase().as_str() {
1648 "c" | "class" => "class",
1649 "m" | "method" => "method",
1650 "f" | "fn" | "func" | "function" => "function",
1651 "mod" | "module" => "module",
1652 "s" | "struct" => "struct",
1653 "e" | "enum" => "enum",
1654 "t" | "trait" => "trait",
1655 other => return other.to_string(),
1656 }
1657 .to_string()
1658}
1659
1660fn canonical_langs(s: &str) -> Vec<String> {
1665 let t = s.to_ascii_lowercase();
1666 let alias = match t.as_str() {
1667 "rb" => Some("ruby"),
1668 "rs" => Some("rust"),
1669 "golang" => Some("go"),
1670 _ => None,
1671 };
1672 let matched: Vec<String> = crate::lang::languages()
1673 .into_iter()
1674 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
1675 .map(str::to_string)
1676 .collect();
1677 if matched.is_empty() { vec![t] } else { matched }
1678}
1679
1680fn match_color() -> Option<String> {
1684 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
1685 return None;
1686 }
1687 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
1688 gc.split(':').find_map(|e| {
1689 e.strip_prefix("mt=")
1690 .or_else(|| e.strip_prefix("ms="))
1691 .filter(|v| !v.is_empty())
1692 .map(str::to_string)
1693 })
1694 });
1695 Some(style.unwrap_or_else(|| "1;31".to_string()))
1696}
1697
1698fn hl(text: &str, query: &str, color: Option<&str>) -> String {
1701 match color {
1702 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
1703 None => text.to_string(),
1704 }
1705}
1706
1707fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
1710 let Some(c) = color else {
1711 return path.to_string();
1712 };
1713 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
1714 let base_start = path[..base_byte].chars().count();
1715 let stem = crate::search::path_stem(path);
1719 let positions: Vec<usize> = crate::search::match_positions(query, stem)
1720 .into_iter()
1721 .map(|p| p + base_start)
1722 .collect();
1723 highlight(path, &positions, c)
1724}
1725
1726fn highlight(text: &str, positions: &[usize], color: &str) -> String {
1729 if positions.is_empty() {
1730 return text.to_string();
1731 }
1732 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
1733 let mut out = String::new();
1734 let mut on = false;
1735 for (i, c) in text.chars().enumerate() {
1736 match (matched.contains(&i), on) {
1737 (true, false) => {
1738 out.push_str("\x1b[");
1739 out.push_str(color);
1740 out.push('m');
1741 on = true;
1742 }
1743 (false, true) => {
1744 out.push_str("\x1b[0m");
1745 on = false;
1746 }
1747 _ => {}
1748 }
1749 out.push(c);
1750 }
1751 if on {
1752 out.push_str("\x1b[0m");
1753 }
1754 out
1755}
1756
1757fn under_any(file: &str, paths: &[String]) -> bool {
1761 paths.iter().any(|p| {
1762 let p = p.trim_start_matches("./").trim_end_matches('/');
1763 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
1764 })
1765}
1766
1767fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
1769 let p = std::path::Path::new(file);
1770 let abs = if p.is_absolute() {
1771 p.to_path_buf()
1772 } else {
1773 cwd.join(p)
1774 };
1775 let abs = abs.canonicalize().unwrap_or(abs);
1776 abs.strip_prefix(root)
1777 .map(|r| r.to_string_lossy().into_owned())
1778 .unwrap_or_else(|_| file.to_string())
1779}
1780
1781fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
1785 use std::collections::HashSet;
1786 let mut seen = HashSet::new();
1787 let mut changed = false;
1788 for hit in hits {
1789 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
1790 continue;
1791 }
1792 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
1793 continue;
1794 };
1795 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
1796 continue;
1797 };
1798 if let Ok(crate::index::Refresh::Updated) =
1799 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
1800 {
1801 changed = true;
1802 }
1803 }
1804 changed
1805}
1806
1807fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
1813 if let Ok(canon) = cwd.canonicalize() {
1814 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
1815 return identity;
1816 }
1817 if crate::index::repo_root(cwd).is_none() {
1818 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
1819 }
1820 }
1821 crate::index::detect_identity(cwd).to_string()
1822}
1823
1824fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
1825 let explicit = path.is_some();
1826 let target = path.unwrap_or_else(|| PathBuf::from("."));
1827 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
1832 let mut subdirs = subdirs.to_vec();
1837 if explicit
1838 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
1839 && t != r
1840 && let Ok(rel) = t.strip_prefix(&r)
1841 && !rel.as_os_str().is_empty()
1842 {
1843 subdirs.push(rel.to_string_lossy().into_owned());
1844 }
1845 let mut store = match open_store() {
1846 Ok(s) => s,
1847 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1848 };
1849 let identity = crate::index::detect_identity(&root).to_string();
1850 match crate::index::index_under(&mut store, &root, &subdirs) {
1851 Ok(stats) => {
1852 let subtree = !subdirs.is_empty();
1853 let totals = store
1855 .repository_id(&identity)
1856 .ok()
1857 .flatten()
1858 .and_then(|id| store.repo_totals(id).ok());
1859 match out {
1860 Output::Json | Output::Ndjson => {
1861 let (files, symbols) = match totals {
1862 Some((f, s)) => (Some(f), Some(s)),
1863 None => (None, None),
1864 };
1865 return emit_json(
1866 out,
1867 &serde_json::json!({
1868 "repo": identity,
1869 "scope": if subtree { "subtree" } else { "full" },
1870 "files_added": stats.files_indexed,
1871 "symbols_added": stats.symbols,
1872 "files": files,
1873 "symbols": symbols,
1874 }),
1875 );
1876 }
1877 Output::Text => {
1878 let scope = if subtree { " (subtree seed)" } else { "" };
1879 match totals {
1880 Some((files, symbols)) => println!(
1881 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
1882 stats.files_indexed, stats.symbols
1883 ),
1884 None => println!(
1885 "{} file(s)/{} symbol(s) added this run{scope}",
1886 stats.files_indexed, stats.symbols
1887 ),
1888 }
1889 }
1890 }
1891 ExitCode::SUCCESS
1892 }
1893 Err(e) => fail(format_args!("rq --index: {e}")),
1894 }
1895}
1896
1897fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
1898 let mut store = match open_store() {
1899 Ok(s) => s,
1900 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1901 };
1902
1903 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
1907 let root = crate::index::repo_root(&path).unwrap_or(path);
1908 let from_path = crate::index::detect_identity(&root).to_string();
1909 let resolved = match store.repository_id(&from_path) {
1910 Ok(Some(id)) => Some((from_path.clone(), id)),
1911 Ok(None) => target.as_deref().and_then(|s| {
1912 store
1913 .repository_id(s)
1914 .ok()
1915 .flatten()
1916 .map(|id| (s.to_string(), id))
1917 }),
1918 Err(e) => return fail(format_args!("rq --drop: {e}")),
1919 };
1920
1921 let Some((identity, repo_id)) = resolved else {
1922 return match out {
1924 Output::Text => {
1925 println!("not indexed: {from_path}");
1926 ExitCode::SUCCESS
1927 }
1928 _ => emit_json(
1929 out,
1930 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
1931 ),
1932 };
1933 };
1934
1935 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
1936 match store.drop_repository(repo_id) {
1937 Ok(()) => match out {
1938 Output::Text => {
1939 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
1940 ExitCode::SUCCESS
1941 }
1942 _ => emit_json(
1943 out,
1944 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
1945 ),
1946 },
1947 Err(e) => fail(format_args!("rq --drop: {e}")),
1948 }
1949}
1950
1951fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
1955 let rendered = if out == Output::Json {
1956 serde_json::to_string_pretty(value)
1957 } else {
1958 serde_json::to_string(value)
1959 };
1960 match rendered {
1961 Ok(s) => {
1962 println!("{s}");
1963 ExitCode::SUCCESS
1964 }
1965 Err(e) => fail(format_args!("rq: {e}")),
1966 }
1967}
1968
1969fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
1973 match out {
1974 Output::Json => match serde_json::to_string_pretty(rows) {
1975 Ok(s) => println!("{s}"),
1976 Err(e) => return Some(fail(format_args!("rq: {e}"))),
1977 },
1978 Output::Ndjson => {
1979 for r in rows {
1980 match serde_json::to_string(r) {
1981 Ok(line) => println!("{line}"),
1982 Err(e) => return Some(fail(format_args!("rq: {e}"))),
1983 }
1984 }
1985 }
1986 Output::Text => {}
1987 }
1988 None
1989}
1990
1991fn cmd_status(out: Output) -> ExitCode {
1992 let store = match open_store() {
1993 Ok(s) => s,
1994 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1995 };
1996 let rows = match store.coverage_overview() {
1997 Ok(rows) => rows,
1998 Err(e) => return fail(format_args!("rq --status: {e}")),
1999 };
2000 if let Some(code) = emit_rows(out, &rows) {
2001 return code;
2002 }
2003 match out {
2004 Output::Json | Output::Ndjson => {}
2005 Output::Text if rows.is_empty() => {
2006 println!("no repositories indexed yet (try `rq --index`)");
2007 }
2008 Output::Text => {
2009 for r in &rows {
2010 println!(
2011 "{:<10} {:>6} files {:>7} symbols {}",
2012 r.status, r.files, r.symbols, r.identity
2013 );
2014 }
2015 }
2016 }
2017 ExitCode::SUCCESS
2018}
2019
2020fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2022 let path = db_path()?;
2023 if let Some(parent) = path.parent() {
2024 std::fs::create_dir_all(parent)?;
2025 }
2026 Ok(Store::open(&path)?)
2027}
2028
2029fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2031 if let Ok(p) = std::env::var("RQ_DB") {
2032 return Ok(PathBuf::from(p));
2033 }
2034 let home = std::env::var("HOME")?;
2035 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2036}
2037
2038fn fail(args: std::fmt::Arguments) -> ExitCode {
2039 eprintln!("{args}");
2040 ExitCode::FAILURE
2041}
2042
2043#[cfg(test)]
2044mod tests {
2045 use super::*;
2046
2047 #[test]
2048 fn open_menu_choice_parsing() {
2049 assert_eq!(parse_choice("\n", 5), Some(0));
2051 assert_eq!(parse_choice(" ", 5), Some(0));
2052 assert_eq!(parse_choice("3", 5), Some(2));
2053 assert_eq!(parse_choice("5", 5), Some(4));
2054 assert_eq!(parse_choice("6", 5), None);
2056 assert_eq!(parse_choice("0", 5), None);
2057 assert_eq!(parse_choice("q", 5), None);
2058 }
2059
2060 #[test]
2061 fn wait_duration_parsing() {
2062 use std::time::Duration;
2063 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2065 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2066 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2067 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2068 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2070 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2071 assert!(parse_wait("0s").unwrap().is_zero());
2072 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2074 assert!(parse_wait("2x").is_err());
2076 assert!(parse_wait("").is_err());
2077 assert!(parse_wait("s").is_err());
2078 assert!(parse_wait("-1s").is_err());
2079 }
2080
2081 #[test]
2082 fn leading_kind_keyword_becomes_a_kind_filter() {
2083 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2084 assert_eq!(
2086 split_kind_keyword("class".into(), d(&["Widget"])),
2087 (Some("class"), "Widget".into(), vec![])
2088 );
2089 assert_eq!(
2091 split_kind_keyword("method zoom".into(), vec![]),
2092 (Some("method"), "zoom".into(), vec![])
2093 );
2094 assert_eq!(
2096 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2097 (Some("function"), "Foo::run".into(), vec![])
2098 );
2099 assert_eq!(
2101 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2102 (Some("struct"), "Gadget".into(), d(&["src"]))
2103 );
2104 }
2105
2106 #[test]
2107 fn a_bare_or_non_keyword_query_is_left_alone() {
2108 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2109 assert_eq!(
2111 split_kind_keyword("class".into(), vec![]),
2112 (None, "class".into(), vec![])
2113 );
2114 assert_eq!(
2116 split_kind_keyword("Widget".into(), d(&["app"])),
2117 (None, "Widget".into(), d(&["app"]))
2118 );
2119 assert_eq!(
2121 split_kind_keyword("c".into(), d(&["Foo"])),
2122 (None, "c".into(), d(&["Foo"]))
2123 );
2124 }
2125
2126 #[test]
2127 fn highlight_wraps_matched_runs() {
2128 assert_eq!(
2129 highlight("FooThing", &[0, 1, 2], "1;31"),
2130 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2131 );
2132 assert_eq!(
2134 highlight("FooThing", &[0, 3], "1"),
2135 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2136 );
2137 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2139 }
2140
2141 #[test]
2142 fn progress_ui_only_for_an_interactive_text_terminal() {
2143 assert!(show_progress(Output::Text, true));
2145
2146 assert!(!show_progress(Output::Json, true));
2148 assert!(!show_progress(Output::Ndjson, true));
2149
2150 assert!(!show_progress(Output::Text, false));
2152 }
2153
2154 #[test]
2155 fn repo_label_uses_the_directory_name() {
2156 assert_eq!(
2157 repo_label(Some(std::path::Path::new("/src/widgets"))),
2158 "widgets"
2159 );
2160 assert_eq!(repo_label(None), "repo");
2161 }
2162
2163 #[test]
2164 fn hl_path_highlights_the_stem_not_the_extension() {
2165 let out = hl_path(
2168 "app/employees_controller.rb",
2169 "employeescontroller",
2170 Some("1;31"),
2171 );
2172 assert!(
2173 out.starts_with("app/\u{1b}[1;31memployees"),
2174 "stem highlighted: {out:?}"
2175 );
2176 assert!(
2177 out.ends_with("controller\u{1b}[0m.rb"),
2178 "`.rb` left un-highlighted: {out:?}"
2179 );
2180 }
2181}