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::core::now_unix;
13use crate::store::Store;
14
15#[derive(Parser)]
19#[command(
20 name = "rq",
21 version,
22 about = "Ranked definition lookup — the one place a symbol is defined, first.",
23 long_about = "rq finds where a symbol is defined and ranks the one you most \
24likely meant to the top — not every match.\n\n\
25Search is the default action; operations are flags, not subcommands, so every \
26word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
27your current repo and recently-active files, and learns from the results you open \
28(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
29 after_help = "EXAMPLES:\n \
30rq thing search for a definition named or like \"thing\"\n \
31rq wibble --explain same, plus the score behind each result\n \
32rq thing --json machine-readable results (for editors/agents)\n \
33rq thing --no-record search without recording it (speculative/agent queries)\n \
34rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
35rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
36rq thing app/web restrict to a directory (rg-style)\n \
37rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
38rq class Widget a leading kind keyword is shorthand for -k\n \
39rq --symbols FILE outline a file's definitions, in line order\n \
40rq thing -x rust restrict to a language (ruby/rust/go/python/ts/js)\n \
41rq 'Foo::Bar' qualify by scope — the surest way past an ambiguous name\n \
42rq 'Foo#bar' ...and by owner, for a method\n \
43rq Foo.new the constructor (initialize, __init__, ...)\n \
44rq 'refund*proc' wildcards: * (any run), ? (one char) — quote them\n \
45rq -o thing open the best match in your editor (and record it)\n \
46rq --index index the current repository\n \
47rq --status show indexing coverage\n \
48rq --usage show how rq has been called (by caller and flags)\n \
49rq --drop remove this repo's index (opposite of --index)\n\n\
50SHORT FLAGS (easy to misread):\n \
51-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
52RECORDING (editor/shell hook):\n \
53rq --record --file <path> --line <n> <query>\n \
54Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
55to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
56The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
57automatically on the first search in a git repo. On a large, cold repo a search \
58keeps indexing until it can answer rather than reporting a premature \"no \
59matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
60= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
61)]
62struct Cli {
63 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
70 target: Option<String>,
71
72 #[arg(value_name = "PATH")]
74 dirs: Vec<String>,
75
76 #[arg(short = 'e', long)]
78 explain: bool,
79
80 #[arg(long)]
85 no_record: bool,
86
87 #[arg(long = "no-wait")]
93 no_wait: bool,
94
95 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
100 wait: Option<Duration>,
101
102 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
107 open: bool,
108
109 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "record", "json", "ndjson"])]
114 web: bool,
115
116 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "record", "symbols", "drop"])]
120 show: bool,
121
122 #[arg(short = 'j', long)]
124 json: bool,
125
126 #[arg(short = 'J', long, conflicts_with = "json")]
128 ndjson: bool,
129
130 #[arg(short = 'p', long, value_name = "DIR")]
132 path: Vec<String>,
133
134 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
136 limit: usize,
137
138 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
142 kind: Vec<String>,
143
144 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
148 lang: Vec<String>,
149
150 #[arg(short = 'a', long = "all-repos")]
153 all_repos: bool,
154
155 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
157 index: Option<Option<String>>,
158
159 #[arg(long, conflicts_with_all = ["index", "record"])]
161 status: bool,
162
163 #[arg(long, conflicts_with_all = ["index", "record", "status"])]
165 usage: bool,
166
167 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open", "web"])]
170 symbols: Option<String>,
171
172 #[arg(long, conflicts_with_all = ["index", "status", "record", "open", "web"])]
176 drop: bool,
177
178 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
181 record: bool,
182
183 #[arg(long)]
185 file: Option<String>,
186
187 #[arg(long)]
189 line: Option<i64>,
190
191 #[arg(long, default_value = "select")]
193 event: String,
194
195 #[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", "web", "show"])]
199 warm: Option<Option<String>>,
200
201 #[arg(long, value_name = "SHELL")]
203 completions: Option<Shell>,
204
205 #[arg(short = 'v', long)]
208 verbose: bool,
209
210 #[arg(long)]
214 profile: bool,
215
216 #[arg(long, value_name = "N", default_value_t = 0)]
219 jobs: usize,
220}
221
222pub fn run() -> ExitCode {
224 let cli = Cli::parse();
225 crate::trace::enable_from(cli.verbose);
226 crate::profile::enable_from(cli.profile);
227 crate::index::set_parse_jobs(cli.jobs);
228 let json_out = output_format(&cli) != Output::Text;
229 let code = dispatch(cli);
230 crate::profile::emit(json_out);
231 code
232}
233
234fn dispatch(cli: Cli) -> ExitCode {
235 if let Some(shell) = cli.completions {
236 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
237 return ExitCode::SUCCESS;
238 }
239 if let Some(path) = &cli.index {
240 let out = output_format(&cli);
242 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
243 }
244 if let Some(path) = &cli.warm {
245 return cmd_warm(path.as_deref());
246 }
247 if cli.status {
248 return cmd_status(output_format(&cli));
249 }
250 if cli.usage {
251 return cmd_usage(output_format(&cli));
252 }
253 if cli.drop {
254 let out = output_format(&cli);
255 return cmd_drop(cli.target, out);
256 }
257 if cli.record {
258 if !matches!(cli.event.as_str(), "select" | "open") {
260 return fail(format_args!(
261 "rq --record: unknown --event {:?} (expected select or open)",
262 cli.event
263 ));
264 }
265 let file = cli.file.expect("--record requires --file");
267 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
268 }
269 let out = output_format(&cli);
270 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
271 return fail(format_args!("rq: empty query"));
272 }
273 let mut kinds: Vec<String> = Vec::new();
277 for k in &cli.kind {
278 match canonical_kind(k) {
279 Some(c) => kinds.push(c.to_string()),
280 None => {
281 return fail(format_args!(
282 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait)"
283 ));
284 }
285 }
286 }
287 let mut langs: Vec<String> = Vec::new();
289 for x in &cli.lang {
290 let matched = canonical_langs(x);
291 if matched.is_empty() {
292 return fail(format_args!(
293 "rq: unknown --lang {x:?} ({})",
294 crate::lang::languages().join(", ")
295 ));
296 }
297 langs.extend(matched);
298 }
299 if let Some(file) = &cli.symbols {
300 return cmd_symbols(file, &kinds, &langs, out);
301 }
302 let mut paths = cli.path.clone();
304 match cli.target {
305 Some(target) => {
306 let query = if cli.kind.is_empty() {
309 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
310 if let Some(k) = kw {
311 kinds.push(k.to_string());
312 }
313 paths.extend(dirs);
314 query
315 } else {
316 paths.extend(cli.dirs.clone());
317 target
318 };
319 let mut session = match Session::open() {
320 Ok(s) => s,
321 Err(code) => return code,
322 };
323 cmd_search(
324 &mut session,
325 &SearchArgs {
326 query: &query,
327 explain: cli.explain,
328 out,
329 paths: &paths,
330 kinds: &kinds,
331 langs: &langs,
332 want: requested_limit(cli.limit),
333 no_record: cli.no_record,
334 no_wait: cli.no_wait,
335 wait: cli.wait,
336 open: cli.open,
337 web: cli.web,
338 all_repos: cli.all_repos,
339 show: cli.show,
340 batch: false,
341 },
342 )
343 }
344 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
347 None => {
349 let _ = Cli::command().print_long_help();
350 ExitCode::SUCCESS
351 }
352 }
353}
354
355#[derive(Clone, Copy, PartialEq)]
357enum Output {
358 Text,
359 Json,
360 Ndjson,
361}
362
363fn output_format(cli: &Cli) -> Output {
364 if cli.ndjson {
365 Output::Ndjson
366 } else if cli.json {
367 Output::Json
368 } else {
369 Output::Text
370 }
371}
372
373const DEFAULT_LIMIT: usize = 10;
375
376const PATH_HEADROOM: usize = 200;
379
380fn requested_limit(limit: usize) -> usize {
383 if limit == 0 { usize::MAX } else { limit }
384}
385
386fn record_usage(
389 store: &mut Store,
390 args: &SearchArgs,
391 repository_id: Option<i64>,
392 results: usize,
393 status: &str,
394 coverage: Option<&str>,
395) {
396 if args.no_record {
397 return;
398 }
399 let _ = store.record_search(&crate::store::SearchRecord {
400 query: &args.query.to_ascii_lowercase(),
401 repository_id,
402 results,
403 source: &crate::origin::detect(),
404 flags: &flag_summary(args),
405 status,
406 coverage: coverage.unwrap_or("none"),
407 });
408}
409
410fn flag_summary(args: &SearchArgs) -> String {
415 let mut on: Vec<&str> = Vec::new();
416 match args.out {
417 Output::Json => on.push("json"),
418 Output::Ndjson => on.push("ndjson"),
419 Output::Text => {}
420 }
421 for (present, name) in [
422 (args.explain, "explain"),
423 (args.show, "show"),
424 (args.open, "open"),
425 (args.web, "web"),
426 (args.all_repos, "all-repos"),
427 (args.no_wait, "no-wait"),
428 (args.batch, "batch"),
429 (!args.paths.is_empty(), "path"),
430 (!args.kinds.is_empty(), "kind"),
431 (!args.langs.is_empty(), "lang"),
432 (args.want != DEFAULT_LIMIT, "limit"),
433 ] {
434 if present {
435 on.push(name);
436 }
437 }
438 on.join(",")
439}
440
441const POLL_INTERVAL: Duration = Duration::from_millis(100);
448
449const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
453
454const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
458
459struct SearchArgs<'a> {
461 query: &'a str,
462 explain: bool,
463 out: Output,
464 paths: &'a [String],
465 kinds: &'a [String],
466 langs: &'a [String],
467 want: usize,
469 no_record: bool,
470 no_wait: bool,
472 wait: Option<Duration>,
475 open: bool,
476 web: bool,
477 all_repos: bool,
478 batch: bool,
482 show: bool,
483}
484
485struct Session {
496 store: Store,
497 cwd: Option<PathBuf>,
498 cwd_is_git: bool,
499 root: Option<PathBuf>,
500 active_paths: Vec<String>,
501 branch_refresh: Option<BranchRefresh>,
502 identity: Option<String>,
503 coverage: Option<String>,
504}
505
506impl Session {
507 fn open() -> std::result::Result<Session, ExitCode> {
509 let open_span = crate::profile::span("store open");
510 let store = match open_store() {
511 Ok(s) => s,
512 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
513 };
514 drop(open_span);
515 let git_span = crate::profile::span("setup: git root");
516 let cwd = std::env::current_dir().ok();
517 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
518
519 let root = cwd
525 .as_deref()
526 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
527 drop(git_span);
528
529 let mut branch_span = crate::profile::span("setup: branch files");
532 let (active_paths, branch_refresh, cached_cost) = match &root {
533 Some(c) if cwd_is_git => cached_branch_files(&store, c),
534 _ => (Vec::new(), None, None),
535 };
536 branch_span.note(|| {
537 let how = if branch_refresh.is_some() {
538 "cached, refreshing alongside"
539 } else {
540 "cached"
541 };
542 let ttl = branch_files_ttl(cached_cost);
545 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
546 });
547 drop(branch_span);
548
549 let mut identity_span = crate::profile::span("setup: identity");
554 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
555 let coverage = identity
556 .as_deref()
557 .and_then(|id| store.coverage_status(id).ok())
558 .flatten();
559 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
560 drop(identity_span);
561 Ok(Session {
562 store,
563 cwd,
564 cwd_is_git,
565 root,
566 active_paths,
567 branch_refresh,
568 identity,
569 coverage,
570 })
571 }
572}
573
574fn cmd_batch(
589 cli: &Cli,
590 out: Output,
591 paths: &[String],
592 kinds: &[String],
593 langs: &[String],
594) -> ExitCode {
595 if out == Output::Json {
596 return fail(format_args!(
597 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
598 where each line carries the query it answers"
599 ));
600 }
601 if cli.open || cli.web || cli.show {
602 return fail(format_args!(
603 "rq: --open, --web and --show act on a single result, not a stream of queries"
604 ));
605 }
606
607 use std::io::BufRead;
608 let queries: Vec<String> = std::io::stdin()
609 .lock()
610 .lines()
611 .map_while(std::result::Result::ok)
612 .map(|l| l.trim().to_string())
613 .filter(|l| !l.is_empty())
614 .collect();
615 if queries.is_empty() {
619 let _ = Cli::command().print_long_help();
620 return ExitCode::SUCCESS;
621 }
622
623 let mut session = match Session::open() {
624 Ok(s) => s,
625 Err(code) => return code,
626 };
627
628 if !cli.no_wait
631 && session.coverage.as_deref() != Some("complete")
632 && let Some(root) = session.root.clone()
633 {
634 {
635 let budget = cli.wait.unwrap_or_else(wait_budget);
636 crate::trace!(
637 "batch: warming {} queries' worth of index first",
638 queries.len()
639 );
640 let active = session.active_paths.clone();
641 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
642 session.coverage = session
643 .identity
644 .as_deref()
645 .and_then(|id| session.store.coverage_status(id).ok())
646 .flatten();
647 }
648 }
649
650 let mut worst = ExitCode::SUCCESS;
651 let mut any_hit = false;
652 for query in &queries {
653 let code = cmd_search(
654 &mut session,
655 &SearchArgs {
656 query,
657 explain: cli.explain,
658 out,
659 paths,
660 kinds,
661 langs,
662 want: requested_limit(cli.limit),
663 no_record: cli.no_record,
664 no_wait: true,
668 wait: cli.wait,
669 open: false,
670 web: false,
671 all_repos: cli.all_repos,
672 show: false,
673 batch: true,
674 },
675 );
676 if code == ExitCode::SUCCESS {
677 any_hit = true;
678 } else {
679 worst = code;
680 }
681 }
682 if any_hit { ExitCode::SUCCESS } else { worst }
685}
686
687fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
688 let &SearchArgs {
689 query,
690 out,
691 want,
692 no_record,
693 no_wait,
694 wait,
695 open,
696 web,
697 all_repos,
698 show,
699 ..
700 } = args;
701 let wait_budget = wait.unwrap_or_else(wait_budget);
704 let no_wait = no_wait || wait_budget.is_zero();
705 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
708 want
709 } else {
710 want.saturating_mul(20).max(PATH_HEADROOM)
711 };
712 let _timer = crate::trace::Timer::start("search done");
713 let t_setup = std::time::Instant::now();
714 let setup_span = crate::profile::span("setup");
716 let Session {
719 store,
720 cwd,
721 cwd_is_git,
722 root,
723 active_paths,
724 branch_refresh,
725 identity,
726 coverage,
727 } = session;
728 let cwd_is_git = *cwd_is_git;
729
730 let known = coverage.is_some();
738 let warming_ok = cwd_is_git || known;
739 if crate::trace::enabled() {
740 crate::trace!(
741 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
742 root.as_deref().map_or("?".into(), crate::trace::abbrev),
743 identity.as_deref().unwrap_or("none"),
744 coverage.as_deref().unwrap_or("none"),
745 active_paths.len(),
746 );
747 }
748 let repo_span = crate::profile::span("setup: repo state");
749 let current = identity
750 .as_deref()
751 .and_then(|id| store.repository_id(id).ok().flatten());
752 let only_repo = if all_repos { None } else { current };
755 let active = crate::search::ActiveFiles::new(active_paths.clone());
756
757 drop(repo_span);
758 let warm_span = crate::profile::span("setup: warm decision");
759
760 let warm_budget = if warm_detach_enabled() {
767 answer_warm_budget()
768 } else {
769 answer_warm_budget() + deferred_warm_budget()
770 };
771 let was_warming = coverage.as_deref() != Some("complete");
772
773 let indexed_head = (!was_warming)
784 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
785 .flatten();
786 let staleness = (!was_warming && warming_ok && !args.batch)
789 .then(|| root.clone())
790 .flatten()
791 .map(|c| {
792 if warm_detach_enabled() {
793 Staleness::Deferred(c, indexed_head)
794 } else {
795 Staleness::Running(std::thread::spawn(move || {
796 worktree_edits(&c, indexed_head.as_deref())
797 }))
798 }
799 });
800 let want_warm = warming_ok && was_warming && root.is_some();
803
804 let block = want_warm && was_warming && !no_wait;
818 let progress_ui = block && show_progress(out, stderr_interactive());
823 let indexer_budget = if block { wait_budget } else { warm_budget };
824 if progress_ui {
825 install_interrupt_handler();
826 }
827
828 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
831 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
832 crate::trace!(
833 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
834 crate::index::parse_jobs()
835 );
836 let root = root.clone().expect("checked");
837 let active = active_paths.clone();
838 let q = query.to_string();
839 let warm_done = std::sync::Arc::clone(&warm_done);
840 std::thread::spawn(move || {
841 if let Ok(mut idx) = open_store() {
842 let _ = if block {
844 crate::index::index_budgeted_cancellable(
847 &mut idx,
848 &root,
849 &active,
850 indexer_budget,
851 Some(&q),
852 &INTERRUPTED,
853 )
854 } else {
855 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
856 };
857 }
858 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
859 })
860 });
861
862 crate::trace!(
869 "setup (open + repo detect + warm decision): {} ms",
870 t_setup.elapsed().as_millis()
871 );
872 let poll_start = std::time::Instant::now();
873 let deadline = if progress_ui {
877 None
878 } else if block {
879 Some(poll_start + wait_budget)
880 } else {
881 Some(poll_start + answer_warm_budget())
882 };
883 drop(warm_span);
884 let polling = indexer.is_some() && was_warming;
885 drop(setup_span);
889 let mut query_span = crate::profile::span("query");
890 let label = repo_label(root.as_deref());
891 let mut drew_progress = false;
892 let mut last_draw = poll_start;
893 let rank_limit = limit.max(2);
897 let mut total;
898 let mut hits = loop {
899 match crate::search::search(store, query, current, only_repo, &active, rank_limit) {
900 Ok(m) => {
901 total = m.total;
902 let h = m.hits;
903 let confident = h.first().is_some_and(|hit| {
904 hit.features
905 .iter()
906 .any(|f| matches!(f.name, "exact" | "prefix"))
907 });
908 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
909 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
910 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
911 if !polling || confident || warm_finished || stopped || timed_out {
912 break h;
913 }
914 if progress_ui
915 && poll_start.elapsed() >= HEADS_UP_DELAY
916 && last_draw.elapsed() >= PROGRESS_REDRAW
917 {
918 draw_progress(store, identity.as_deref(), &label);
919 drew_progress = true;
920 last_draw = std::time::Instant::now();
921 }
922 }
923 Err(e) => {
924 if let Some(h) = indexer {
925 let _ = h.join();
926 }
927 return fail(format_args!("rq: {e}"));
928 }
929 }
930 std::thread::sleep(POLL_INTERVAL);
931 };
932 query_span.note(|| {
933 if polling {
934 "polled a warming index".to_string()
935 } else {
936 String::new()
937 }
938 });
939 drop(query_span);
940 if drew_progress {
941 clear_progress();
942 }
943 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
945
946 let here = identity
947 .as_deref()
948 .zip(root.as_deref())
949 .map(|(identity, root)| Here { identity, root });
950 if !hits.is_empty()
952 && revalidate_top(store, &hits, here)
953 && let Ok(m) = crate::search::search(store, query, current, only_repo, &active, rank_limit)
954 {
955 total = m.total;
956 hits = m.hits;
957 }
958
959 if !hits.iter().any(strong)
963 && indexer.is_none()
964 && coverage.is_none()
965 && let Some(root) = &root
966 {
967 let tail = live_fallback(root, query, rank_limit);
968 hits = crate::search::merge(hits, tail, rank_limit);
969 total = total.max(hits.len());
970 }
971
972 apply_gates(query, &mut hits);
973 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
974 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
977 total = hits.len();
978 }
979
980 if hits.is_empty() {
981 if block {
983 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
984 }
985 if let Some(h) = indexer {
986 let _ = h.join();
987 }
988 let mut incomplete = (block || no_wait)
995 && identity
996 .as_deref()
997 .and_then(|id| store.coverage_status(id).ok().flatten())
998 .as_deref()
999 != Some("complete");
1000 incomplete |= settle_warm(
1010 store,
1011 staleness,
1012 false,
1013 was_warming,
1014 warming_ok,
1015 root.as_deref(),
1016 active_paths,
1017 query,
1018 warm_budget,
1019 no_wait,
1020 identity.as_deref(),
1021 );
1022 record_usage(
1026 store,
1027 args,
1028 current,
1029 0,
1030 if incomplete { "warming" } else { "miss" },
1031 coverage.as_deref(),
1032 );
1033 let elsewhere = crate::search::scope_miss_owner(store, query, current, only_repo, &active);
1038 return no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1039 }
1040
1041 {
1044 let _span = crate::profile::span("record usage");
1045 record_usage(store, args, current, hits.len(), "hit", coverage.as_deref());
1046 }
1047
1048 attach_confidence(&mut hits);
1052 hits.truncate(want);
1053 let total = total.max(hits.len());
1054 for hit in &mut hits {
1055 hit.total = total;
1056 if args.explain {
1057 hit.explain = Some(
1058 hit.features
1059 .iter()
1060 .map(|f| (f.name.to_string(), f.value))
1061 .collect(),
1062 );
1063 }
1064 }
1065
1066 let _signatures_span = crate::profile::span("signatures");
1069 for hit in &mut hits {
1070 hit.signature = read_signature(store, &hit.repo_identity, &hit.file, hit.line, here);
1071 }
1072 drop(_signatures_span);
1073
1074 if show
1077 && let Some(code) =
1078 show_top_definition(store, &mut hits, query, out, here, current, no_record)
1079 {
1080 return code;
1081 }
1082
1083 if open || web {
1088 return finish_open(
1089 store,
1090 &hits,
1091 query,
1092 current,
1093 root.as_deref(),
1094 no_record,
1095 web,
1096 );
1097 }
1098
1099 if let Some(code) = render_hits(args, &hits) {
1100 return code;
1101 }
1102
1103 crate::profile::mark("first answer");
1106
1107 if let Some(refresh) = branch_refresh.take() {
1114 let _span = crate::profile::span("after: branch refresh");
1115 refresh.store(store);
1116 }
1117
1118 {
1123 let _span = crate::profile::span("after: rollup + prune");
1124 deferred_maintenance(store);
1125 }
1126
1127 if block {
1132 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1133 }
1134 if let Some(h) = indexer {
1135 let _ = h.join();
1136 }
1137 let _ = settle_warm(
1138 store,
1139 staleness,
1140 true,
1141 was_warming,
1142 warming_ok,
1143 root.as_deref(),
1144 active_paths,
1145 query,
1146 warm_budget,
1147 no_wait,
1148 identity.as_deref(),
1149 );
1150
1151 ExitCode::SUCCESS
1152}
1153
1154fn maybe_detach_warm(
1157 store: &Store,
1158 want_warm: bool,
1159 changed: bool,
1160 root: Option<&std::path::Path>,
1161 identity: Option<&str>,
1162) {
1163 if !warm_detach_enabled() || !want_warm {
1164 return;
1165 }
1166 let (Some(root), Some(id)) = (root, identity) else {
1167 return;
1168 };
1169 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1173 return; }
1175 spawn_detached_warm(root);
1176}
1177
1178fn spawn_detached_warm(root: &std::path::Path) {
1182 use std::os::unix::process::CommandExt;
1183 let Ok(exe) = std::env::current_exe() else {
1184 return;
1185 };
1186 let mut cmd = std::process::Command::new(exe);
1187 cmd.arg("--warm")
1188 .arg(root)
1189 .stdin(std::process::Stdio::null())
1190 .stdout(std::process::Stdio::null())
1191 .stderr(std::process::Stdio::null())
1192 .process_group(0);
1193 match cmd.spawn() {
1194 Ok(child) => crate::trace!(
1195 "background warm (detached): pid {} for {}",
1196 child.id(),
1197 crate::trace::abbrev(root)
1198 ),
1199 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1200 }
1201}
1202
1203const WARM_LOCK_TTL_SECS: i64 = 600;
1206
1207fn cmd_warm(path: Option<&str>) -> ExitCode {
1212 #[cfg(target_os = "macos")]
1215 unsafe extern "C" {
1216 fn setiopolicy_np(
1219 iotype: libc::c_int,
1220 scope: libc::c_int,
1221 policy: libc::c_int,
1222 ) -> libc::c_int;
1223 }
1224 unsafe {
1225 libc::nice(10);
1226 #[cfg(target_os = "macos")]
1227 setiopolicy_np(0, 0, 3);
1228 }
1229 let mut store = match open_store() {
1230 Ok(s) => s,
1231 Err(_) => return ExitCode::FAILURE,
1232 };
1233 let start = path
1234 .map(PathBuf::from)
1235 .or_else(|| std::env::current_dir().ok())
1236 .unwrap_or_else(|| PathBuf::from("."));
1237 let root = crate::index::repo_root(&start).unwrap_or(start);
1238 let identity = resolve_identity(&store, &root);
1239
1240 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1243 && pid != std::process::id()
1244 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1245 && now_unix() - ts < WARM_LOCK_TTL_SECS
1246 {
1247 return ExitCode::SUCCESS;
1248 }
1249 let _ = store.set_warm_lock(&identity, std::process::id());
1250
1251 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1254 let head = store
1255 .repository_id(&identity)
1256 .ok()
1257 .flatten()
1258 .and_then(|id| store.indexed_head(id).ok().flatten());
1259 let edits = worktree_edits(&root, head.as_deref());
1260 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1261 crate::trace!("warm: unchanged since indexed, nothing to do");
1262 let _ = store.clear_warm_lock(&identity);
1263 return ExitCode::SUCCESS;
1264 }
1265 }
1266
1267 let deadline = std::time::Instant::now() + warm_bg_budget();
1270 let active = crate::index::branch_changed_files(&root);
1271 loop {
1272 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1273 if remaining.is_zero() {
1274 break;
1275 }
1276 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1277 {
1278 Ok(s) => s,
1279 Err(_) => break,
1280 };
1281 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1282 || stats.files_indexed == 0
1283 {
1284 break;
1285 }
1286 }
1287 let _ = store.clear_warm_lock(&identity);
1288 ExitCode::SUCCESS
1289}
1290
1291fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1294 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1295 let deadline = std::time::Instant::now() + live_fallback_budget();
1296 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1297 if !h.is_empty() {
1298 return h;
1299 }
1300 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1301}
1302
1303fn strong(h: &crate::search::Hit) -> bool {
1305 h.features
1306 .iter()
1307 .any(|f| matches!(f.name, "exact" | "prefix"))
1308}
1309
1310fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1319 if hits.iter().any(strong) {
1320 hits.retain(strong);
1321 }
1322 crate::search::apply_scope_gate(query, hits);
1323}
1324
1325fn apply_post_filters(
1328 args: &SearchArgs,
1329 cwd: Option<&std::path::Path>,
1330 root: Option<&std::path::Path>,
1331 hits: &mut Vec<crate::search::Hit>,
1332) {
1333 if !args.paths.is_empty() {
1334 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1338 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1339 let norm: Vec<String> = args
1340 .paths
1341 .iter()
1342 .map(|p| repo_relative(&base, &here, p))
1343 .collect();
1344 hits.retain(|h| under_any(&h.file, &norm));
1345 }
1346 if !args.kinds.is_empty() {
1347 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1348 }
1349 if !args.langs.is_empty() {
1350 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1351 }
1352 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1355 hits.truncate(args.want.max(2));
1356 }
1357}
1358
1359fn no_match_code(
1365 out: Output,
1366 query: &str,
1367 interrupted: bool,
1368 incomplete: bool,
1369 elsewhere: Option<&str>,
1373) -> ExitCode {
1374 let status = if interrupted {
1375 "interrupted"
1376 } else if incomplete {
1377 "warming"
1378 } else if elsewhere.is_some() {
1379 "scope_not_found"
1380 } else {
1381 "no_match"
1382 };
1383 match out {
1384 Output::Json | Output::Ndjson => {
1385 let mut obj = serde_json::json!({ "status": status, "query": query });
1386 if let Some(found_in) = elsewhere {
1387 obj["found_in"] = serde_json::json!(found_in);
1388 }
1389 let _ = emit_json(out, &obj); }
1391 Output::Text if interrupted => {
1392 eprintln!("rq: indexing interrupted — run again to finish")
1393 }
1394 Output::Text if incomplete => eprintln!(
1395 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1396 ),
1397 Output::Text if elsewhere.is_some() => eprintln!(
1398 "rq: nothing matching {query:?} — that name is defined under {}",
1399 elsewhere.unwrap_or_default()
1400 ),
1401 Output::Text => eprintln!("no matches for {query:?}"),
1402 }
1403 if incomplete {
1404 ExitCode::from(2)
1405 } else {
1406 ExitCode::FAILURE
1407 }
1408}
1409
1410fn attach_confidence(hits: &mut [crate::search::Hit]) {
1414 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1415 if t.is_none_or(|t| h.score > t) {
1416 (Some(h.score), t)
1417 } else if s.is_none_or(|s| h.score > s) {
1418 (t, Some(h.score))
1419 } else {
1420 (t, s)
1421 }
1422 });
1423 for hit in hits.iter_mut() {
1424 let best_other = if Some(hit.score) == top { second } else { top };
1425 hit.confidence = crate::search::confidence(
1426 hit.score,
1427 crate::search::match_quality(&hit.features),
1428 best_other,
1429 );
1430 }
1431}
1432
1433fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1436 let render_span = crate::profile::span("render");
1440 if args.batch {
1441 #[derive(serde::Serialize)]
1444 struct Tagged<'a> {
1445 query: &'a str,
1446 #[serde(flatten)]
1447 hit: &'a crate::search::Hit,
1448 }
1449 let rows: Vec<Tagged> = hits
1450 .iter()
1451 .map(|hit| Tagged {
1452 query: args.query,
1453 hit,
1454 })
1455 .collect();
1456 if let Some(code) = emit_rows(args.out, &rows) {
1457 return Some(code);
1458 }
1459 } else if let Some(code) = emit_rows(args.out, hits) {
1460 return Some(code);
1461 }
1462 if args.out != Output::Text {
1463 return None;
1464 }
1465 drop(render_span);
1466 let color = match_color();
1467 let c = color.as_deref();
1468 let query = args.query;
1469 if args.show {
1470 let total = hits.first().map_or(hits.len(), |h| h.total);
1472 eprintln!(
1473 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1474 hits.len()
1475 );
1476 }
1477 for hit in hits {
1478 let name = hl(&hit.name, query, c);
1481 let qualified = match &hit.parent {
1482 Some(p) => format!("{name} · {p}"),
1483 None => name,
1484 };
1485 println!(
1486 "{}:{} {} {}",
1487 hl_path(&hit.file, query, c),
1488 hit.line,
1489 hit.kind,
1490 qualified
1491 );
1492 if let Some(sig) = &hit.signature {
1493 println!(" {}", hl(sig, query, c));
1494 }
1495 if args.explain {
1496 let parts: Vec<String> = hit
1497 .features
1498 .iter()
1499 .map(|f| format!("{} {:.0}", f.name, f.value))
1500 .collect();
1501 println!(
1502 " confidence {:.2} · score {:.0} = {}",
1503 hit.confidence,
1504 hit.score,
1505 parts.join(" + ")
1506 );
1507 }
1508 }
1509 None
1510}
1511
1512fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1516 use std::io::{IsTerminal, Write};
1517 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1518 return hits.first();
1519 }
1520 let mut err = std::io::stderr();
1521 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1522 for (i, h) in hits.iter().enumerate() {
1523 let _ = writeln!(
1524 err,
1525 " {}. {}:{} {} {}",
1526 i + 1,
1527 h.file,
1528 h.line,
1529 h.kind,
1530 h.name
1531 );
1532 }
1533 let _ = write!(err, "rq> ");
1534 let _ = err.flush();
1535 let mut line = String::new();
1536 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1537 return None; }
1539 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1540}
1541
1542fn parse_choice(input: &str, n: usize) -> Option<usize> {
1545 let s = input.trim();
1546 if s.is_empty() {
1547 return Some(0);
1548 }
1549 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1550 (i < n).then_some(i)
1551}
1552
1553fn finish_open(
1557 store: &mut Store,
1558 hits: &[crate::search::Hit],
1559 query: &str,
1560 current: Option<i64>,
1561 root: Option<&std::path::Path>,
1562 no_record: bool,
1563 web: bool,
1564) -> ExitCode {
1565 let Some(hit) = choose_hit(hits) else {
1566 return ExitCode::SUCCESS; };
1568
1569 if !no_record {
1572 let _ = store.record_event(
1573 "select",
1574 Some(&query.to_ascii_lowercase()),
1575 current,
1576 Some(&hit.file),
1577 Some(hit.line),
1578 None,
1579 );
1580 deferred_maintenance(store);
1581 }
1582
1583 if web {
1584 return open_web(store, hit, current, root);
1585 }
1586
1587 let target = match root {
1590 Some(r) => r.join(&hit.file),
1591 None => PathBuf::from(&hit.file),
1592 };
1593 launch_editor(&target, hit.line)
1594}
1595
1596fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1600 use std::os::unix::process::CommandExt;
1601 let loc = format!("{}:{}", file.display(), line);
1602 match open_command(file, line, &loc) {
1603 Some((prog, args)) => {
1604 crate::profile::emit(false);
1606 let err = std::process::Command::new(&prog).args(&args).exec();
1608 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1609 }
1610 None => {
1611 println!("{loc}");
1612 ExitCode::SUCCESS
1613 }
1614 }
1615}
1616
1617fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1621 let fstr = file.to_string_lossy().into_owned();
1622
1623 if let Some(t) = std::env::var_os("RQ_OPEN") {
1624 let t = t.to_string_lossy();
1625 let mut parts = t.split_whitespace().map(|p| {
1626 p.replace("{file}", &fstr)
1627 .replace("{line}", &line.to_string())
1628 .replace("{}", loc)
1629 });
1630 if let Some(prog) = parts.next() {
1631 return Some((prog, parts.collect()));
1632 }
1633 }
1634
1635 if on_path("code") {
1636 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1637 }
1638
1639 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1640 let ed = ed.to_string_lossy().into_owned();
1641 let l = ed.to_ascii_lowercase();
1642 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1644 .iter()
1645 .any(|e| l.contains(e))
1646 {
1647 return Some((ed, vec![format!("+{line}"), fstr]));
1648 }
1649 return Some((ed, vec![fstr]));
1650 }
1651
1652 None
1653}
1654
1655fn open_web(
1660 store: &Store,
1661 hit: &crate::search::Hit,
1662 current: Option<i64>,
1663 root: Option<&std::path::Path>,
1664) -> ExitCode {
1665 if hit.repo_identity.starts_with("local:") {
1666 return fail(format_args!(
1667 "rq --web: {} has no git remote to link to",
1668 hit.repo_identity
1669 ));
1670 }
1671 let here =
1672 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1673 let rev = root
1674 .filter(|_| here)
1675 .and_then(crate::index::pushed_head)
1676 .unwrap_or_else(|| "HEAD".into());
1677 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1678
1679 use std::os::unix::process::CommandExt;
1680 let browser = std::env::var("BROWSER")
1681 .ok()
1682 .filter(|b| !b.is_empty())
1683 .or_else(|| {
1684 ["open", "xdg-open"]
1685 .into_iter()
1686 .find(|p| on_path(p))
1687 .map(str::to_string)
1688 });
1689 match browser {
1690 Some(prog) => {
1691 crate::profile::emit(false);
1693 let err = std::process::Command::new(&prog).arg(&url).exec();
1695 fail(format_args!("rq --web: cannot run {prog}: {err}"))
1696 }
1697 None => {
1698 println!("{url}");
1699 ExitCode::SUCCESS
1700 }
1701 }
1702}
1703
1704fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1707 let path: String = file
1708 .bytes()
1709 .map(|b| match b {
1710 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1711 (b as char).to_string()
1712 }
1713 _ => format!("%{b:02X}"),
1714 })
1715 .collect();
1716 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1717}
1718
1719fn on_path(prog: &str) -> bool {
1721 std::env::var_os("PATH")
1722 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1723}
1724
1725const BRANCH_FILES_TTL_SECS: i64 = 15;
1731
1732const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1736
1737const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1749
1750fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1752 let earned = cost_ms.map_or(0, |ms| {
1755 i64::try_from(ms)
1756 .unwrap_or(i64::MAX)
1757 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1758 / 1000
1759 });
1760 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1761}
1762
1763struct BranchRefresh {
1767 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1770 identity: String,
1771 stamp: String,
1772}
1773
1774impl BranchRefresh {
1775 fn store(self, store: &Store) {
1777 let Ok((files, cost_ms)) = self.handle.join() else {
1778 return;
1779 };
1780 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
1781 }
1782}
1783
1784fn cached_branch_files(
1798 store: &Store,
1799 root: &std::path::Path,
1800) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
1801 let identity = resolve_identity(store, root);
1802 let stamp = crate::index::branch_files_stamp(root);
1803 let cached = store.branch_files_get(&identity).ok().flatten();
1804 let now = now_unix();
1805
1806 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
1807 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
1808 {
1809 return (hit.files.clone(), None, hit.cost_ms);
1810 }
1811 let owned_root = root.to_path_buf();
1812 let refresh = BranchRefresh {
1813 handle: std::thread::spawn(move || {
1814 let t = std::time::Instant::now();
1815 let files = crate::index::branch_changed_files(&owned_root);
1816 (files, t.elapsed().as_millis() as u64)
1817 }),
1818 identity,
1819 stamp: stamp.clone(),
1820 };
1821 return (hit.files.clone(), Some(refresh), hit.cost_ms);
1822 }
1823
1824 let t = std::time::Instant::now();
1826 let files = crate::index::branch_changed_files(root);
1827 let cost_ms = t.elapsed().as_millis() as u64;
1828 if let Some(stamp) = stamp {
1829 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
1830 }
1831 (files, None, Some(cost_ms))
1832}
1833
1834fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
1844 let head = indexed_head?;
1845 let _span = crate::profile::span("git: worktree changed?");
1846 (crate::index::git_head(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
1847}
1848
1849fn changed_since_index(
1852 store: &Store,
1853 identity: Option<&str>,
1854 root: Option<&std::path::Path>,
1855 edits: Option<Vec<String>>,
1856) -> bool {
1857 let (Some(dirty), Some(root)) = (edits, root) else {
1858 return true;
1859 };
1860 if dirty.is_empty() {
1861 return false;
1862 }
1863 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
1864 Some(repo_id) => crate::index::has_unindexed_edits(store, repo_id, root, &dirty),
1865 None => true,
1866 }
1867}
1868
1869enum Staleness {
1873 Running(std::thread::JoinHandle<Option<Vec<String>>>),
1876 Deferred(PathBuf, Option<String>),
1880}
1881
1882#[allow(clippy::too_many_arguments)]
1889fn settle_warm(
1890 store: &Store,
1891 staleness: Option<Staleness>,
1892 hit: bool,
1893 was_warming: bool,
1894 warming_ok: bool,
1895 root: Option<&std::path::Path>,
1896 active: &[String],
1897 query: &str,
1898 budget: Duration,
1899 no_wait: bool,
1900 identity: Option<&str>,
1901) -> bool {
1902 let changed = match staleness {
1903 None => false,
1904 Some(Staleness::Deferred(..)) if hit => {
1907 if let Some(r) = root {
1908 spawn_detached_warm(r);
1909 }
1910 return false;
1911 }
1912 Some(Staleness::Deferred(r, head)) => {
1913 let _span = crate::profile::span("after: staleness check");
1914 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
1915 }
1916 Some(Staleness::Running(h)) => {
1917 let mut span = crate::profile::span("after: staleness wait");
1919 let changed = h.join().map_or(true, |edits| {
1922 changed_since_index(store, identity, root, edits)
1923 });
1924 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
1925 changed
1926 }
1927 };
1928 if changed
1938 && !no_wait
1939 && !warm_detach_enabled()
1940 && let Some(r) = root
1941 && let Ok(mut idx) = open_store()
1942 {
1943 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1944 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1945 }
1946 maybe_detach_warm(
1947 store,
1948 warming_ok && (was_warming || changed),
1949 changed,
1950 root,
1951 identity,
1952 );
1953 changed && warm_detach_enabled()
1957}
1958
1959fn answer_warm_budget() -> Duration {
1968 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1969}
1970
1971fn deferred_warm_budget() -> Duration {
1974 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1975}
1976
1977fn live_fallback_budget() -> Duration {
1980 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1981}
1982
1983fn warm_bg_budget() -> Duration {
1986 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1987}
1988
1989fn warm_detach_enabled() -> bool {
1993 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1994}
1995
1996fn wait_budget() -> Duration {
2005 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2006}
2007
2008fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2013 let s = s.trim();
2014 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2015 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2017 (n, 1.0)
2018 } else if let Some(n) = s.strip_suffix('s') {
2019 (n, 1_000.0)
2020 } else if let Some(n) = s.strip_suffix('m') {
2021 (n, 60_000.0)
2022 } else {
2023 (s, 1_000.0)
2025 };
2026 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2027 if !val.is_finite() || val < 0.0 {
2028 return Err(bad());
2029 }
2030 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2031}
2032
2033static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2037
2038extern "C" fn on_sigint(_: libc::c_int) {
2039 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2041}
2042
2043fn install_interrupt_handler() {
2046 static ONCE: std::sync::Once = std::sync::Once::new();
2047 ONCE.call_once(|| unsafe {
2048 let mut action: libc::sigaction = std::mem::zeroed();
2049 action.sa_sigaction = on_sigint as *const () as usize;
2050 libc::sigemptyset(&mut action.sa_mask);
2051 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2052 });
2053}
2054
2055fn stderr_interactive() -> bool {
2059 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2060}
2061
2062fn show_progress(out: Output, interactive: bool) -> bool {
2067 interactive && matches!(out, Output::Text)
2068}
2069
2070fn repo_label(root: Option<&std::path::Path>) -> String {
2073 root.and_then(|r| r.file_name())
2074 .map(|n| n.to_string_lossy().into_owned())
2075 .unwrap_or_else(|| "repo".into())
2076}
2077
2078fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2082 let files = identity
2083 .and_then(|id| store.repository_id(id).ok().flatten())
2084 .and_then(|rid| store.repo_totals(rid).ok())
2085 .map_or(0, |(f, _)| f);
2086 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2087 let _ = std::io::stderr().flush();
2088}
2089
2090fn clear_progress() {
2092 eprint!("\r\x1b[K");
2093 let _ = std::io::stderr().flush();
2094}
2095
2096fn env_budget(var: &str, default_ms: u64) -> Duration {
2100 let ms = std::env::var(var)
2101 .ok()
2102 .and_then(|v| v.parse().ok())
2103 .unwrap_or(default_ms);
2104 Duration::from_millis(ms)
2105}
2106
2107const AGGREGATE_BATCH: usize = 256;
2110
2111const KEEP_RECENT_EVENTS: i64 = 200;
2114
2115fn deferred_maintenance(store: &mut Store) {
2118 let _ = store.aggregate_events(AGGREGATE_BATCH);
2119 let _ = store.prune_events(KEEP_RECENT_EVENTS);
2120}
2121
2122fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
2125 let mut store = match open_store() {
2126 Ok(s) => s,
2127 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2128 };
2129 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2130 let identity = resolve_identity(
2132 &store,
2133 &crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone()),
2134 );
2135 let repo_id = store.repository_id(&identity).ok().flatten();
2136
2137 let root = crate::index::repo_root(&cwd).or_else(|| {
2140 let id = repo_id?;
2141 store
2142 .checkout_roots(id)
2143 .ok()?
2144 .into_iter()
2145 .next()
2146 .map(PathBuf::from)
2147 });
2148 let rel = match root {
2149 Some(root) => repo_relative(&root, &cwd, file),
2150 None => file.to_string(),
2151 };
2152 let query_norm = query.map(|q| q.to_ascii_lowercase());
2153
2154 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
2155 {
2156 return fail(format_args!("rq record: {e}"));
2157 }
2158 deferred_maintenance(&mut store);
2159 ExitCode::SUCCESS
2160}
2161
2162#[derive(Clone, Copy)]
2164struct Here<'a> {
2165 identity: &'a str,
2166 root: &'a std::path::Path,
2167}
2168
2169fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2175 let mut roots: Vec<PathBuf> = here
2176 .filter(|h| h.identity == repo_identity)
2177 .map(|h| h.root.to_path_buf())
2178 .into_iter()
2179 .collect();
2180 let recorded = store
2181 .repository_id(repo_identity)
2182 .ok()
2183 .flatten()
2184 .map(|id| store.checkout_roots(id).unwrap_or_default())
2185 .unwrap_or_default();
2186 for root in recorded.into_iter().map(PathBuf::from) {
2187 if !roots.contains(&root) {
2188 roots.push(root);
2189 }
2190 }
2191 roots
2192}
2193
2194fn read_signature(
2197 store: &Store,
2198 repo_identity: &str,
2199 file: &str,
2200 line: i64,
2201 here: Option<Here>,
2202) -> Option<String> {
2203 hit_file_roots(store, repo_identity, here)
2204 .into_iter()
2205 .find_map(|root| {
2206 let src = std::fs::read_to_string(root.join(file)).ok()?;
2207 signature_in(&src.lines().collect::<Vec<_>>(), line)
2208 })
2209}
2210
2211const SHOW_CONFIDENCE: f64 = 0.85;
2215
2216fn show_top_definition(
2225 store: &mut Store,
2226 hits: &mut [crate::search::Hit],
2227 query: &str,
2228 out: Output,
2229 here: Option<Here>,
2230 current: Option<i64>,
2231 no_record: bool,
2232) -> Option<ExitCode> {
2233 let top = hits.first()?;
2234 if top.confidence < SHOW_CONFIDENCE {
2235 return None; }
2237 let end = top.end_line.unwrap_or(top.line);
2238 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, here);
2239 hits[0].body = body;
2240 let top = &hits[0];
2241 let shown = (top.file.clone(), top.line);
2242 let code = match out {
2243 Output::Json | Output::Ndjson => {
2244 emit_json(out, top)
2246 }
2247 Output::Text => {
2248 let color = match_color();
2249 let c = color.as_deref();
2250 let name = hl(&top.name, query, c);
2251 let qualified = match &top.parent {
2252 Some(p) => format!("{name} · {p}"),
2253 None => name,
2254 };
2255 println!(
2256 "{}:{} {} {}",
2257 hl_path(&top.file, query, c),
2258 top.line,
2259 top.kind,
2260 qualified
2261 );
2262 match (&top.body, &top.signature) {
2263 (Some(body), _) => println!("{body}"),
2264 (None, Some(sig)) => println!("{sig}"),
2266 (None, None) => {}
2267 }
2268 ExitCode::SUCCESS
2269 }
2270 };
2271
2272 if !no_record {
2274 let (file, line) = shown;
2275 let _ = store.record_event(
2276 "select",
2277 Some(&query.to_ascii_lowercase()),
2278 current,
2279 Some(&file),
2280 Some(line),
2281 None,
2282 );
2283 deferred_maintenance(store);
2284 }
2285 Some(code)
2286}
2287
2288fn read_span(
2291 store: &Store,
2292 repo_identity: &str,
2293 file: &str,
2294 start: i64,
2295 end: i64,
2296 here: Option<Here>,
2297) -> Option<String> {
2298 hit_file_roots(store, repo_identity, here)
2299 .into_iter()
2300 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2301}
2302
2303fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2306 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2307 let lines: Vec<&str> = content.lines().collect();
2308 if s >= lines.len() {
2309 return None;
2310 }
2311 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2312 Some(lines[s..e].join("\n"))
2313}
2314
2315fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2320 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2321 let l = lines.get(idx)?.trim();
2322 (!l.is_empty()).then(|| l.to_string())
2323}
2324
2325#[derive(serde::Serialize)]
2329struct SymbolOut {
2330 name: String,
2331 kind: String,
2332 language: String,
2333 file: String,
2334 line: i64,
2335 #[serde(skip_serializing_if = "Option::is_none")]
2336 end_line: Option<i64>,
2337 #[serde(skip_serializing_if = "Option::is_none")]
2338 parent: Option<String>,
2339 #[serde(skip_serializing_if = "Option::is_none")]
2340 visibility: Option<String>,
2341 repo: String,
2342 #[serde(skip_serializing_if = "Option::is_none")]
2343 signature: Option<String>,
2344}
2345
2346fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2351 let open_span = crate::profile::span("store open");
2352 let mut store = match open_store() {
2353 Ok(s) => s,
2354 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2355 };
2356 drop(open_span);
2357 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2358 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2359 let rel = repo_relative(&root, &cwd, file_arg);
2360
2361 let identity = resolve_identity(&store, &root);
2362 let coverage = store.coverage_status(&identity).ok().flatten();
2363 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2364 let current = store.repository_id(&identity).ok().flatten();
2365 let path = root.join(&rel);
2366 if !path.is_file() {
2368 return emit_symbols(out, &[]);
2369 }
2370 let indexable = path
2371 .extension()
2372 .and_then(|e| e.to_str())
2373 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2374 match current {
2375 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2379 if indexable {
2380 let _span = crate::profile::span("symbols: refresh");
2381 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2382 }
2383 }
2384 _ if warming_ok => {
2387 let budget = answer_warm_budget() + deferred_warm_budget();
2388 let _span = crate::profile::span("symbols: warm");
2389 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2390 }
2391 _ => {}
2392 }
2393
2394 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2395 return emit_symbols(out, &[]); };
2397 let mut query_span = crate::profile::span("symbols: query");
2398 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2399 Ok(r) => r,
2400 Err(e) => return fail(format_args!("rq: {e}")),
2401 };
2402 query_span.note(|| format!("{} rows", rows.len()));
2403 drop(query_span);
2404 if !kinds.is_empty() {
2405 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2406 }
2407 if !langs.is_empty() {
2408 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2409 }
2410
2411 let signatures_span = crate::profile::span("symbols: signatures");
2414 let content = std::fs::read_to_string(&path).ok();
2415 let lines: Vec<&str> = content
2416 .as_deref()
2417 .map_or_else(Vec::new, |c| c.lines().collect());
2418 let syms: Vec<SymbolOut> = rows
2419 .into_iter()
2420 .map(|r| SymbolOut {
2421 signature: signature_in(&lines, r.line),
2422 name: r.name,
2423 kind: r.kind,
2424 language: r.language,
2425 file: r.file,
2426 line: r.line,
2427 end_line: r.end_line,
2428 parent: r.parent,
2429 visibility: r.visibility,
2430 repo: r.repo_identity,
2431 })
2432 .collect();
2433 drop(signatures_span);
2434 let _span = crate::profile::span("render");
2435 emit_symbols(out, &syms)
2436}
2437
2438fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2441 if syms.is_empty() {
2442 match out {
2443 Output::Json | Output::Ndjson => {
2444 let obj = serde_json::json!({ "status": "no_match" });
2445 let _ = emit_json(out, &obj); }
2447 Output::Text => eprintln!("no symbols"),
2448 }
2449 return ExitCode::FAILURE;
2450 }
2451 if let Some(code) = emit_rows(out, syms) {
2452 return code;
2453 }
2454 match out {
2455 Output::Json | Output::Ndjson => {}
2456 Output::Text => {
2457 for s in syms {
2458 let qualified = match &s.parent {
2459 Some(p) => format!("{} · {p}", s.name),
2460 None => s.name.clone(),
2461 };
2462 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2463 if let Some(sig) = &s.signature {
2464 println!(" {sig}");
2465 }
2466 }
2467 }
2468 }
2469 ExitCode::SUCCESS
2470}
2471
2472fn keyword_kind(token: &str) -> Option<&'static str> {
2477 match token.to_ascii_lowercase().as_str() {
2478 "class" => Some("class"),
2479 "module" => Some("module"),
2480 "method" => Some("method"),
2481 "function" | "fn" => Some("function"),
2482 "struct" | "type" => Some("struct"),
2483 "enum" => Some("enum"),
2484 "trait" | "interface" => Some("trait"),
2485 "constant" | "const" => Some("constant"),
2486 _ => None,
2487 }
2488}
2489
2490fn split_kind_keyword(
2496 target: String,
2497 dirs: Vec<String>,
2498) -> (Option<&'static str>, String, Vec<String>) {
2499 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2502 let rest = rest.trim();
2503 if let Some(k) = keyword_kind(head)
2504 && !rest.is_empty()
2505 {
2506 return (Some(k), rest.to_string(), dirs);
2507 }
2508 } else if let Some(k) = keyword_kind(&target)
2509 && let Some((query, extra)) = dirs.split_first()
2510 {
2511 return (Some(k), query.clone(), extra.to_vec());
2513 }
2514 (None, target, dirs)
2515}
2516
2517fn canonical_kind(s: &str) -> Option<&'static str> {
2520 Some(match s.to_ascii_lowercase().as_str() {
2521 "c" | "class" => "class",
2522 "m" | "method" => "method",
2523 "f" | "fn" | "func" | "function" => "function",
2524 "mod" | "module" => "module",
2525 "s" | "struct" | "type" => "struct",
2526 "e" | "enum" => "enum",
2527 "t" | "trait" | "interface" => "trait",
2528 "const" | "constant" => "constant",
2529 _ => return None,
2530 })
2531}
2532
2533fn canonical_langs(s: &str) -> Vec<String> {
2540 let t = s.to_ascii_lowercase();
2541 let alias = match t.as_str() {
2542 "rb" => Some("ruby"),
2543 "rs" => Some("rust"),
2544 "golang" => Some("go"),
2545 "ts" | "tsx" => Some("typescript"),
2546 "js" | "jsx" => Some("javascript"),
2547 _ => None,
2548 };
2549 let matched: Vec<String> = crate::lang::languages()
2550 .into_iter()
2551 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2552 .map(str::to_string)
2553 .collect();
2554 matched
2555}
2556
2557fn match_color() -> Option<String> {
2561 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2562 return None;
2563 }
2564 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2565 gc.split(':').find_map(|e| {
2566 e.strip_prefix("mt=")
2567 .or_else(|| e.strip_prefix("ms="))
2568 .filter(|v| !v.is_empty())
2569 .map(str::to_string)
2570 })
2571 });
2572 Some(style.unwrap_or_else(|| "1;31".to_string()))
2573}
2574
2575fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2578 match color {
2579 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2580 None => text.to_string(),
2581 }
2582}
2583
2584fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2587 let Some(c) = color else {
2588 return path.to_string();
2589 };
2590 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2591 let base_start = path[..base_byte].chars().count();
2592 let stem = crate::search::path_stem(path);
2596 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2597 .into_iter()
2598 .map(|p| p + base_start)
2599 .collect();
2600 highlight(path, &positions, c)
2601}
2602
2603fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2606 if positions.is_empty() {
2607 return text.to_string();
2608 }
2609 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2610 let mut out = String::new();
2611 let mut on = false;
2612 for (i, c) in text.chars().enumerate() {
2613 match (matched.contains(&i), on) {
2614 (true, false) => {
2615 out.push_str("\x1b[");
2616 out.push_str(color);
2617 out.push('m');
2618 on = true;
2619 }
2620 (false, true) => {
2621 out.push_str("\x1b[0m");
2622 on = false;
2623 }
2624 _ => {}
2625 }
2626 out.push(c);
2627 }
2628 if on {
2629 out.push_str("\x1b[0m");
2630 }
2631 out
2632}
2633
2634fn under_any(file: &str, paths: &[String]) -> bool {
2638 paths.iter().any(|p| {
2639 let p = p.trim_start_matches("./").trim_end_matches('/');
2640 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2641 })
2642}
2643
2644fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2646 let p = std::path::Path::new(file);
2647 let abs = if p.is_absolute() {
2648 p.to_path_buf()
2649 } else {
2650 cwd.join(p)
2651 };
2652 let abs = abs.canonicalize().unwrap_or(abs);
2653 abs.strip_prefix(root)
2654 .map(|r| r.to_string_lossy().into_owned())
2655 .unwrap_or_else(|_| file.to_string())
2656}
2657
2658fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2663 use std::collections::HashSet;
2664 let mut seen = HashSet::new();
2665 let mut changed = false;
2666 for hit in hits {
2667 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2668 continue;
2669 }
2670 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2671 continue;
2672 };
2673 let roots = hit_file_roots(store, &hit.repo_identity, here);
2674 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2675 continue;
2676 };
2677 if let Ok(crate::index::Refresh::Updated) =
2678 crate::index::refresh_file(store, repo_id, root, &hit.file)
2679 {
2680 changed = true;
2681 }
2682 }
2683 changed
2684}
2685
2686fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2692 if let Ok(canon) = cwd.canonicalize() {
2693 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2694 return identity;
2695 }
2696 if crate::index::repo_root(cwd).is_none() {
2697 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2698 }
2699 }
2700 crate::index::detect_identity(cwd).to_string()
2701}
2702
2703fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2704 let explicit = path.is_some();
2705 let target = path.unwrap_or_else(|| PathBuf::from("."));
2706 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2711 let mut subdirs = subdirs.to_vec();
2716 if explicit
2717 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2718 && t != r
2719 && let Ok(rel) = t.strip_prefix(&r)
2720 && !rel.as_os_str().is_empty()
2721 {
2722 subdirs.push(rel.to_string_lossy().into_owned());
2723 }
2724 let open_span = crate::profile::span("store open");
2725 let mut store = match open_store() {
2726 Ok(s) => s,
2727 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2728 };
2729 drop(open_span);
2730 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2731 let identity = resolve_identity(&store, &root);
2734 match indexed {
2735 Ok(stats) => {
2736 let subtree = !subdirs.is_empty();
2737 let totals = store
2739 .repository_id(&identity)
2740 .ok()
2741 .flatten()
2742 .and_then(|id| store.repo_totals(id).ok());
2743 match out {
2744 Output::Json | Output::Ndjson => {
2745 let (files, symbols) = match totals {
2746 Some((f, s)) => (Some(f), Some(s)),
2747 None => (None, None),
2748 };
2749 return emit_json(
2750 out,
2751 &serde_json::json!({
2752 "repo": identity,
2753 "scope": if subtree { "subtree" } else { "full" },
2754 "files_added": stats.files_indexed,
2755 "symbols_added": stats.symbols,
2756 "files": files,
2757 "symbols": symbols,
2758 }),
2759 );
2760 }
2761 Output::Text => {
2762 let scope = if subtree { " (subtree seed)" } else { "" };
2763 match totals {
2764 Some((files, symbols)) => println!(
2765 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2766 stats.files_indexed, stats.symbols
2767 ),
2768 None => println!(
2769 "{} file(s)/{} symbol(s) added this run{scope}",
2770 stats.files_indexed, stats.symbols
2771 ),
2772 }
2773 }
2774 }
2775 ExitCode::SUCCESS
2776 }
2777 Err(e) => fail(format_args!("rq --index: {e}")),
2778 }
2779}
2780
2781fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2782 let mut store = match open_store() {
2783 Ok(s) => s,
2784 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2785 };
2786
2787 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2791 let root = crate::index::repo_root(&path).unwrap_or(path);
2792 let from_path = crate::index::detect_identity(&root).to_string();
2793 let resolved = match store.repository_id(&from_path) {
2794 Ok(Some(id)) => Some((from_path.clone(), id)),
2795 Ok(None) => target.as_deref().and_then(|s| {
2796 store
2797 .repository_id(s)
2798 .ok()
2799 .flatten()
2800 .map(|id| (s.to_string(), id))
2801 }),
2802 Err(e) => return fail(format_args!("rq --drop: {e}")),
2803 };
2804
2805 let Some((identity, repo_id)) = resolved else {
2806 return match out {
2808 Output::Text => {
2809 println!("not indexed: {from_path}");
2810 ExitCode::SUCCESS
2811 }
2812 _ => emit_json(
2813 out,
2814 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2815 ),
2816 };
2817 };
2818
2819 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2820 match store.drop_repository(repo_id) {
2821 Ok(()) => match out {
2822 Output::Text => {
2823 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2824 ExitCode::SUCCESS
2825 }
2826 _ => emit_json(
2827 out,
2828 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2829 ),
2830 },
2831 Err(e) => fail(format_args!("rq --drop: {e}")),
2832 }
2833}
2834
2835fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2839 let rendered = if out == Output::Json {
2840 serde_json::to_string_pretty(value)
2841 } else {
2842 serde_json::to_string(value)
2843 };
2844 match rendered {
2845 Ok(s) => {
2846 println!("{s}");
2847 ExitCode::SUCCESS
2848 }
2849 Err(e) => fail(format_args!("rq: {e}")),
2850 }
2851}
2852
2853fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2857 match out {
2858 Output::Json => match serde_json::to_string_pretty(rows) {
2859 Ok(s) => println!("{s}"),
2860 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2861 },
2862 Output::Ndjson => {
2863 for r in rows {
2864 match serde_json::to_string(r) {
2865 Ok(line) => println!("{line}"),
2866 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2867 }
2868 }
2869 }
2870 Output::Text => {}
2871 }
2872 None
2873}
2874
2875fn cmd_status(out: Output) -> ExitCode {
2876 let store = match open_store() {
2877 Ok(s) => s,
2878 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2879 };
2880 let rows = match store.coverage_overview() {
2881 Ok(rows) => rows,
2882 Err(e) => return fail(format_args!("rq --status: {e}")),
2883 };
2884 if let Some(code) = emit_rows(out, &rows) {
2885 return code;
2886 }
2887 match out {
2888 Output::Json | Output::Ndjson => {}
2889 Output::Text if rows.is_empty() => {
2890 println!("no repositories indexed yet (try `rq --index`)");
2891 }
2892 Output::Text => {
2893 for r in &rows {
2894 println!(
2895 "{:<10} {:>6} files {:>7} symbols {}",
2896 r.status, r.files, r.symbols, r.identity
2897 );
2898 }
2899 }
2900 }
2901 ExitCode::SUCCESS
2902}
2903
2904fn cmd_usage(out: Output) -> ExitCode {
2907 let store = match open_store() {
2908 Ok(s) => s,
2909 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2910 };
2911 let rows = match store.usage_overview() {
2912 Ok(rows) => rows,
2913 Err(e) => return fail(format_args!("rq --usage: {e}")),
2914 };
2915 if let Some(code) = emit_rows(out, &rows) {
2916 return code;
2917 }
2918 match out {
2919 Output::Json | Output::Ndjson => {}
2920 Output::Text if rows.is_empty() => {
2921 println!("no usage recorded yet");
2922 }
2923 Output::Text => {
2924 println!(
2927 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2928 "day", "caller", "found", "missed", "warming"
2929 );
2930 for r in &rows {
2931 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2932 println!(
2933 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2934 r.day,
2935 r.source,
2936 r.searches - r.misses - r.warming,
2937 r.misses,
2938 r.warming,
2939 flags
2940 );
2941 }
2942 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2943 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2944 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2945 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2946 let plural = if searches == 1 { "search" } else { "searches" };
2947 println!(
2950 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2951 );
2952 }
2953 }
2954 if rows.is_empty() {
2956 return ExitCode::from(1);
2957 }
2958 ExitCode::SUCCESS
2959}
2960
2961fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2963 let path = db_path()?;
2964 if let Some(parent) = path.parent() {
2965 std::fs::create_dir_all(parent)?;
2966 }
2967 Ok(Store::open(&path)?)
2968}
2969
2970fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2972 if let Ok(p) = std::env::var("RQ_DB") {
2973 return Ok(PathBuf::from(p));
2974 }
2975 let home = std::env::var("HOME")?;
2976 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2977}
2978
2979fn fail(args: std::fmt::Arguments) -> ExitCode {
2980 eprintln!("{args}");
2981 ExitCode::FAILURE
2982}
2983
2984#[cfg(test)]
2985mod tests {
2986 use super::*;
2987
2988 #[test]
2989 fn open_menu_choice_parsing() {
2990 assert_eq!(parse_choice("\n", 5), Some(0));
2992 assert_eq!(parse_choice(" ", 5), Some(0));
2993 assert_eq!(parse_choice("3", 5), Some(2));
2994 assert_eq!(parse_choice("5", 5), Some(4));
2995 assert_eq!(parse_choice("6", 5), None);
2997 assert_eq!(parse_choice("0", 5), None);
2998 assert_eq!(parse_choice("q", 5), None);
2999 }
3000
3001 #[test]
3002 fn web_url_shape() {
3003 assert_eq!(
3004 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3005 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3006 );
3007 }
3008
3009 #[test]
3010 fn wait_duration_parsing() {
3011 use std::time::Duration;
3012 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3014 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3015 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3016 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3017 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3019 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3020 assert!(parse_wait("0s").unwrap().is_zero());
3021 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3023 assert!(parse_wait("2x").is_err());
3025 assert!(parse_wait("").is_err());
3026 assert!(parse_wait("s").is_err());
3027 assert!(parse_wait("-1s").is_err());
3028 }
3029
3030 #[test]
3031 fn leading_kind_keyword_becomes_a_kind_filter() {
3032 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3033 assert_eq!(
3035 split_kind_keyword("class".into(), d(&["Widget"])),
3036 (Some("class"), "Widget".into(), vec![])
3037 );
3038 assert_eq!(
3040 split_kind_keyword("method zoom".into(), vec![]),
3041 (Some("method"), "zoom".into(), vec![])
3042 );
3043 assert_eq!(
3045 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3046 (Some("function"), "Foo::run".into(), vec![])
3047 );
3048 assert_eq!(
3050 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3051 (Some("struct"), "Gadget".into(), d(&["src"]))
3052 );
3053 }
3054
3055 #[test]
3056 fn a_bare_or_non_keyword_query_is_left_alone() {
3057 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3058 assert_eq!(
3060 split_kind_keyword("class".into(), vec![]),
3061 (None, "class".into(), vec![])
3062 );
3063 assert_eq!(
3065 split_kind_keyword("Widget".into(), d(&["app"])),
3066 (None, "Widget".into(), d(&["app"]))
3067 );
3068 assert_eq!(
3070 split_kind_keyword("c".into(), d(&["Foo"])),
3071 (None, "c".into(), d(&["Foo"]))
3072 );
3073 }
3074
3075 #[test]
3076 fn the_branch_window_scales_with_what_the_refresh_costs() {
3077 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3080 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3081 assert_eq!(branch_files_ttl(Some(700)), 70);
3085 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3086 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3089 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3090 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3092 }
3093
3094 #[test]
3095 fn a_language_selects_by_prefix_or_alias() {
3096 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3098 assert_eq!(canonical_langs("t"), ["typescript"]);
3099 assert_eq!(canonical_langs("ts"), ["typescript"]);
3101 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3102 assert_eq!(canonical_langs("rb"), ["ruby"]);
3103 assert!(canonical_langs("COBOL").is_empty());
3106 }
3107
3108 #[test]
3109 fn a_kind_normalizes_language_specific_spellings() {
3110 assert_eq!(canonical_kind("f"), Some("function"));
3111 assert_eq!(canonical_kind("interface"), Some("trait"));
3113 assert_eq!(canonical_kind("type"), Some("struct"));
3114 assert_eq!(canonical_kind("const"), Some("constant"));
3115 assert_eq!(canonical_kind("banana"), None);
3116 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3118 assert_eq!(
3119 split_kind_keyword("interface".into(), d(&["Renderer"])),
3120 (Some("trait"), "Renderer".into(), vec![])
3121 );
3122 }
3123
3124 #[test]
3125 fn highlight_wraps_matched_runs() {
3126 assert_eq!(
3127 highlight("FooThing", &[0, 1, 2], "1;31"),
3128 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3129 );
3130 assert_eq!(
3132 highlight("FooThing", &[0, 3], "1"),
3133 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3134 );
3135 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3137 }
3138
3139 #[test]
3140 fn progress_ui_only_for_an_interactive_text_terminal() {
3141 assert!(show_progress(Output::Text, true));
3143
3144 assert!(!show_progress(Output::Json, true));
3146 assert!(!show_progress(Output::Ndjson, true));
3147
3148 assert!(!show_progress(Output::Text, false));
3150 }
3151
3152 #[test]
3153 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3154 let lines = ["class Widget", "", " def go"];
3155 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3156 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3157 assert_eq!(signature_in(&lines, 2), None, "blank line");
3158 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3159 assert_eq!(signature_in(&lines, 4), None, "past the end");
3160 }
3161
3162 #[test]
3163 fn repo_label_uses_the_directory_name() {
3164 assert_eq!(
3165 repo_label(Some(std::path::Path::new("/src/widgets"))),
3166 "widgets"
3167 );
3168 assert_eq!(repo_label(None), "repo");
3169 }
3170
3171 #[test]
3172 fn hl_path_highlights_the_stem_not_the_extension() {
3173 let out = hl_path(
3176 "app/employees_controller.rb",
3177 "employeescontroller",
3178 Some("1;31"),
3179 );
3180 assert!(
3181 out.starts_with("app/\u{1b}[1;31memployees"),
3182 "stem highlighted: {out:?}"
3183 );
3184 assert!(
3185 out.ends_with("controller\u{1b}[0m.rb"),
3186 "`.rb` left un-highlighted: {out:?}"
3187 );
3188 }
3189}