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, recently-active files, and the files your branch changes. Run \
28`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-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/ts/js)\n \
40rq 'Foo::Bar' qualify by scope — the surest way past an ambiguous name\n \
41rq 'Foo#bar' ...and by owner, for a method\n \
42rq Foo.new the constructor (initialize, __init__, ...)\n \
43rq 'refund*proc' wildcards: * (any run), ? (one char) — quote them\n \
44rq -o thing open the best match in your editor\n \
45rq --index index the current repository\n \
46rq --status show indexing coverage\n \
47rq --usage show how rq has been called (by caller and flags)\n \
48rq --drop remove this repo's index (opposite of --index)\n\n\
49SHORT FLAGS (easy to misread):\n \
50-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
51The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
52automatically on the first search in a git repo. On a large, cold repo a search \
53keeps indexing until it can answer rather than reporting a premature \"no \
54matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
55= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
56)]
57struct Cli {
58 #[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 = "no-wait")]
80 no_wait: bool,
81
82 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
87 wait: Option<Duration>,
88
89 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
94 open: bool,
95
96 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
101 web: bool,
102
103 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
107 show: bool,
108
109 #[arg(short = 'j', long)]
111 json: bool,
112
113 #[arg(short = 'J', long, conflicts_with = "json")]
115 ndjson: bool,
116
117 #[arg(short = 'p', long, value_name = "DIR")]
119 path: Vec<String>,
120
121 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
123 limit: usize,
124
125 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
129 kind: Vec<String>,
130
131 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
135 lang: Vec<String>,
136
137 #[arg(short = 'a', long = "all-repos")]
140 all_repos: bool,
141
142 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
144 index: Option<Option<String>>,
145
146 #[arg(long, conflicts_with = "index")]
148 status: bool,
149
150 #[arg(long, conflicts_with_all = ["index", "status"])]
152 usage: bool,
153
154 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
157 symbols: Option<String>,
158
159 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
163 drop: bool,
164
165 #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "drop", "symbols", "open", "web", "show"])]
169 warm: Option<Option<String>>,
170
171 #[arg(long, value_name = "SHELL")]
173 completions: Option<Shell>,
174
175 #[arg(short = 'v', long)]
178 verbose: bool,
179
180 #[arg(long)]
184 profile: bool,
185
186 #[arg(long, value_name = "N", default_value_t = 0)]
189 jobs: usize,
190}
191
192pub fn run() -> ExitCode {
194 let cli = Cli::parse();
195 crate::trace::enable_from(cli.verbose);
196 crate::profile::enable_from(cli.profile);
197 crate::index::set_parse_jobs(cli.jobs);
198 let json_out = output_format(&cli) != Output::Text;
199 let code = dispatch(cli);
200 crate::profile::emit(json_out);
201 code
202}
203
204fn dispatch(cli: Cli) -> ExitCode {
205 if let Some(shell) = cli.completions {
206 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
207 return ExitCode::SUCCESS;
208 }
209 if let Some(path) = &cli.index {
210 let out = output_format(&cli);
212 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
213 }
214 if let Some(path) = &cli.warm {
215 return cmd_warm(path.as_deref());
216 }
217 if cli.status {
218 return cmd_status(output_format(&cli));
219 }
220 if cli.usage {
221 return cmd_usage(output_format(&cli));
222 }
223 if cli.drop {
224 let out = output_format(&cli);
225 return cmd_drop(cli.target, out);
226 }
227 let out = output_format(&cli);
228 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
229 return fail(format_args!("rq: empty query"));
230 }
231 let mut kinds: Vec<String> = Vec::new();
235 for k in &cli.kind {
236 match canonical_kind(k) {
237 Some(c) => kinds.push(c.to_string()),
238 None => {
239 return fail(format_args!(
240 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait)"
241 ));
242 }
243 }
244 }
245 let mut langs: Vec<String> = Vec::new();
247 for x in &cli.lang {
248 let matched = canonical_langs(x);
249 if matched.is_empty() {
250 return fail(format_args!(
251 "rq: unknown --lang {x:?} ({})",
252 crate::lang::languages().join(", ")
253 ));
254 }
255 langs.extend(matched);
256 }
257 if let Some(file) = &cli.symbols {
258 return cmd_symbols(file, &kinds, &langs, out);
259 }
260 let mut paths = cli.path.clone();
262 match cli.target {
263 Some(target) => {
264 let query = if cli.kind.is_empty() {
267 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
268 if let Some(k) = kw {
269 kinds.push(k.to_string());
270 }
271 paths.extend(dirs);
272 query
273 } else {
274 paths.extend(cli.dirs.clone());
275 target
276 };
277 let mut session = match Session::open() {
278 Ok(s) => s,
279 Err(code) => return code,
280 };
281 cmd_search(
282 &mut session,
283 &SearchArgs {
284 query: &query,
285 explain: cli.explain,
286 out,
287 paths: &paths,
288 kinds: &kinds,
289 langs: &langs,
290 want: requested_limit(cli.limit),
291 no_wait: cli.no_wait,
292 wait: cli.wait,
293 open: cli.open,
294 web: cli.web,
295 all_repos: cli.all_repos,
296 show: cli.show,
297 batch: false,
298 },
299 )
300 }
301 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
304 None => {
306 let _ = Cli::command().print_long_help();
307 ExitCode::SUCCESS
308 }
309 }
310}
311
312#[derive(Clone, Copy, PartialEq)]
314enum Output {
315 Text,
316 Json,
317 Ndjson,
318}
319
320fn output_format(cli: &Cli) -> Output {
321 if cli.ndjson {
322 Output::Ndjson
323 } else if cli.json {
324 Output::Json
325 } else {
326 Output::Text
327 }
328}
329
330const DEFAULT_LIMIT: usize = 10;
332
333const PATH_HEADROOM: usize = 200;
336
337fn requested_limit(limit: usize) -> usize {
340 if limit == 0 { usize::MAX } else { limit }
341}
342
343fn record_usage(store: &Store, args: &SearchArgs, status: &str, coverage: Option<&str>) {
346 let _ = store.record_search(&crate::store::SearchRecord {
347 source: &crate::origin::detect(),
348 flags: &flag_summary(args),
349 status,
350 coverage: coverage.unwrap_or("none"),
351 });
352}
353
354fn flag_summary(args: &SearchArgs) -> String {
359 let mut on: Vec<&str> = Vec::new();
360 match args.out {
361 Output::Json => on.push("json"),
362 Output::Ndjson => on.push("ndjson"),
363 Output::Text => {}
364 }
365 for (present, name) in [
366 (args.explain, "explain"),
367 (args.show, "show"),
368 (args.open, "open"),
369 (args.web, "web"),
370 (args.all_repos, "all-repos"),
371 (args.no_wait, "no-wait"),
372 (args.batch, "batch"),
373 (!args.paths.is_empty(), "path"),
374 (!args.kinds.is_empty(), "kind"),
375 (!args.langs.is_empty(), "lang"),
376 (args.want != DEFAULT_LIMIT, "limit"),
377 ] {
378 if present {
379 on.push(name);
380 }
381 }
382 on.join(",")
383}
384
385const POLL_INTERVAL: Duration = Duration::from_millis(100);
392
393const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
397
398const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
402
403struct SearchArgs<'a> {
405 query: &'a str,
406 explain: bool,
407 out: Output,
408 paths: &'a [String],
409 kinds: &'a [String],
410 langs: &'a [String],
411 want: usize,
413 no_wait: bool,
415 wait: Option<Duration>,
418 open: bool,
419 web: bool,
420 all_repos: bool,
421 batch: bool,
425 show: bool,
426}
427
428struct Session {
439 store: Store,
440 cwd: Option<PathBuf>,
441 cwd_is_git: bool,
442 root: Option<PathBuf>,
443 active_paths: Vec<String>,
444 branch_refresh: Option<BranchRefresh>,
445 identity: Option<String>,
446 coverage: Option<String>,
447}
448
449impl Session {
450 fn open() -> std::result::Result<Session, ExitCode> {
452 let open_span = crate::profile::span("store open");
453 let store = match open_store() {
454 Ok(s) => s,
455 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
456 };
457 drop(open_span);
458 let git_span = crate::profile::span("setup: git root");
459 let cwd = std::env::current_dir().ok();
460 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
461
462 let root = cwd
468 .as_deref()
469 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
470 drop(git_span);
471
472 let mut branch_span = crate::profile::span("setup: branch files");
475 let (active_paths, branch_refresh, cached_cost) = match &root {
476 Some(c) if cwd_is_git => cached_branch_files(&store, c),
477 _ => (Vec::new(), None, None),
478 };
479 branch_span.note(|| {
480 let how = if branch_refresh.is_some() {
481 "cached, refreshing alongside"
482 } else {
483 "cached"
484 };
485 let ttl = branch_files_ttl(cached_cost);
488 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
489 });
490 drop(branch_span);
491
492 let mut identity_span = crate::profile::span("setup: identity");
497 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
498 let coverage = identity
499 .as_deref()
500 .and_then(|id| store.coverage_status(id).ok())
501 .flatten();
502 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
503 drop(identity_span);
504 Ok(Session {
505 store,
506 cwd,
507 cwd_is_git,
508 root,
509 active_paths,
510 branch_refresh,
511 identity,
512 coverage,
513 })
514 }
515}
516
517fn cmd_batch(
532 cli: &Cli,
533 out: Output,
534 paths: &[String],
535 kinds: &[String],
536 langs: &[String],
537) -> ExitCode {
538 if out == Output::Json {
539 return fail(format_args!(
540 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
541 where each line carries the query it answers"
542 ));
543 }
544 if cli.open || cli.web || cli.show {
545 return fail(format_args!(
546 "rq: --open, --web and --show act on a single result, not a stream of queries"
547 ));
548 }
549
550 use std::io::BufRead;
551 let queries: Vec<String> = std::io::stdin()
552 .lock()
553 .lines()
554 .map_while(std::result::Result::ok)
555 .map(|l| l.trim().to_string())
556 .filter(|l| !l.is_empty())
557 .collect();
558 if queries.is_empty() {
562 let _ = Cli::command().print_long_help();
563 return ExitCode::SUCCESS;
564 }
565
566 let mut session = match Session::open() {
567 Ok(s) => s,
568 Err(code) => return code,
569 };
570
571 if !cli.no_wait
574 && session.coverage.as_deref() != Some("complete")
575 && let Some(root) = session.root.clone()
576 {
577 {
578 let budget = cli.wait.unwrap_or_else(wait_budget);
579 crate::trace!(
580 "batch: warming {} queries' worth of index first",
581 queries.len()
582 );
583 let active = session.active_paths.clone();
584 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
585 session.coverage = session
586 .identity
587 .as_deref()
588 .and_then(|id| session.store.coverage_status(id).ok())
589 .flatten();
590 }
591 }
592
593 let mut worst = ExitCode::SUCCESS;
594 let mut any_hit = false;
595 for query in &queries {
596 let code = cmd_search(
597 &mut session,
598 &SearchArgs {
599 query,
600 explain: cli.explain,
601 out,
602 paths,
603 kinds,
604 langs,
605 want: requested_limit(cli.limit),
606 no_wait: true,
610 wait: cli.wait,
611 open: false,
612 web: false,
613 all_repos: cli.all_repos,
614 show: false,
615 batch: true,
616 },
617 );
618 if code == ExitCode::SUCCESS {
619 any_hit = true;
620 } else {
621 worst = code;
622 }
623 }
624 if any_hit { ExitCode::SUCCESS } else { worst }
627}
628
629fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
630 let &SearchArgs {
631 query,
632 out,
633 want,
634 no_wait,
635 wait,
636 open,
637 web,
638 all_repos,
639 show,
640 ..
641 } = args;
642 let wait_budget = wait.unwrap_or_else(wait_budget);
645 let no_wait = no_wait || wait_budget.is_zero();
646 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
649 want
650 } else {
651 want.saturating_mul(20).max(PATH_HEADROOM)
652 };
653 let _timer = crate::trace::Timer::start("search done");
654 let t_setup = std::time::Instant::now();
655 let setup_span = crate::profile::span("setup");
657 let Session {
660 store,
661 cwd,
662 cwd_is_git,
663 root,
664 active_paths,
665 branch_refresh,
666 identity,
667 coverage,
668 } = session;
669 let cwd_is_git = *cwd_is_git;
670
671 let known = coverage.is_some();
679 let warming_ok = cwd_is_git || known;
680 if crate::trace::enabled() {
681 crate::trace!(
682 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
683 root.as_deref().map_or("?".into(), crate::trace::abbrev),
684 identity.as_deref().unwrap_or("none"),
685 coverage.as_deref().unwrap_or("none"),
686 active_paths.len(),
687 );
688 }
689 let repo_span = crate::profile::span("setup: repo state");
690 let current = identity
691 .as_deref()
692 .and_then(|id| store.repository_id(id).ok().flatten());
693 let only_repo = if all_repos { None } else { current };
696 let active = crate::search::ActiveFiles::new(active_paths.clone());
697
698 drop(repo_span);
699 let warm_span = crate::profile::span("setup: warm decision");
700
701 let warm_budget = if warm_detach_enabled() {
708 answer_warm_budget()
709 } else {
710 answer_warm_budget() + deferred_warm_budget()
711 };
712 let was_warming = coverage.as_deref() != Some("complete");
713
714 let indexed_head = (!was_warming)
725 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
726 .flatten();
727 let staleness = (!was_warming && warming_ok && !args.batch)
730 .then(|| root.clone())
731 .flatten()
732 .map(|c| {
733 if warm_detach_enabled() {
734 Staleness::Deferred(c, indexed_head)
735 } else {
736 Staleness::Running(std::thread::spawn(move || {
737 worktree_edits(&c, indexed_head.as_deref())
738 }))
739 }
740 });
741 let want_warm = warming_ok && was_warming && root.is_some();
744
745 let block = want_warm && was_warming && !no_wait;
759 let progress_ui = block && show_progress(out, stderr_interactive());
764 let indexer_budget = if block { wait_budget } else { warm_budget };
765 if progress_ui {
766 install_interrupt_handler();
767 }
768
769 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
772 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
773 crate::trace!(
774 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
775 crate::index::parse_jobs()
776 );
777 let root = root.clone().expect("checked");
778 let active = active_paths.clone();
779 let q = query.to_string();
780 let warm_done = std::sync::Arc::clone(&warm_done);
781 std::thread::spawn(move || {
782 if let Ok(mut idx) = open_store() {
783 let _ = if block {
785 crate::index::index_budgeted_cancellable(
788 &mut idx,
789 &root,
790 &active,
791 indexer_budget,
792 Some(&q),
793 &INTERRUPTED,
794 )
795 } else {
796 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
797 };
798 }
799 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
800 })
801 });
802
803 crate::trace!(
810 "setup (open + repo detect + warm decision): {} ms",
811 t_setup.elapsed().as_millis()
812 );
813 let poll_start = std::time::Instant::now();
814 let deadline = if progress_ui {
818 None
819 } else if block {
820 Some(poll_start + wait_budget)
821 } else {
822 Some(poll_start + answer_warm_budget())
823 };
824 drop(warm_span);
825 let polling = indexer.is_some() && was_warming;
826 drop(setup_span);
830 let mut query_span = crate::profile::span("query");
831 let label = repo_label(root.as_deref());
832 let mut drew_progress = false;
833 let mut last_draw = poll_start;
834 let rank_limit = limit.max(2);
838 let mut total;
839 let mut hits = loop {
840 match crate::search::search(store, query, current, only_repo, &active, rank_limit) {
841 Ok(m) => {
842 total = m.total;
843 let h = m.hits;
844 let confident = h.first().is_some_and(|hit| {
845 hit.features
846 .iter()
847 .any(|f| matches!(f.name, "exact" | "prefix"))
848 });
849 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
850 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
851 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
852 if !polling || confident || warm_finished || stopped || timed_out {
853 break h;
854 }
855 if progress_ui
856 && poll_start.elapsed() >= HEADS_UP_DELAY
857 && last_draw.elapsed() >= PROGRESS_REDRAW
858 {
859 draw_progress(store, identity.as_deref(), &label);
860 drew_progress = true;
861 last_draw = std::time::Instant::now();
862 }
863 }
864 Err(e) => {
865 if let Some(h) = indexer {
866 let _ = h.join();
867 }
868 return fail(format_args!("rq: {e}"));
869 }
870 }
871 std::thread::sleep(POLL_INTERVAL);
872 };
873 query_span.note(|| {
874 if polling {
875 "polled a warming index".to_string()
876 } else {
877 String::new()
878 }
879 });
880 drop(query_span);
881 if drew_progress {
882 clear_progress();
883 }
884 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
886
887 let here = identity
888 .as_deref()
889 .zip(root.as_deref())
890 .map(|(identity, root)| Here { identity, root });
891 if !hits.is_empty()
893 && revalidate_top(store, &hits, here)
894 && let Ok(m) = crate::search::search(store, query, current, only_repo, &active, rank_limit)
895 {
896 total = m.total;
897 hits = m.hits;
898 }
899
900 if !hits.iter().any(strong)
904 && indexer.is_none()
905 && coverage.is_none()
906 && let Some(root) = &root
907 {
908 let tail = live_fallback(root, query, rank_limit);
909 hits = crate::search::merge(hits, tail, rank_limit);
910 total = total.max(hits.len());
911 }
912
913 apply_gates(query, &mut hits);
914 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
915 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
918 total = hits.len();
919 }
920
921 if hits.is_empty() {
922 if block {
924 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
925 }
926 if let Some(h) = indexer {
927 let _ = h.join();
928 }
929 let mut incomplete = (block || no_wait)
936 && identity
937 .as_deref()
938 .and_then(|id| store.coverage_status(id).ok().flatten())
939 .as_deref()
940 != Some("complete");
941 incomplete |= settle_warm(
951 store,
952 staleness,
953 false,
954 was_warming,
955 warming_ok,
956 root.as_deref(),
957 active_paths,
958 query,
959 warm_budget,
960 no_wait,
961 identity.as_deref(),
962 );
963 let elsewhere = crate::search::scope_miss_owner(store, query, current, only_repo, &active);
968 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
969 let _span = crate::profile::span("after: record usage");
973 record_usage(
974 store,
975 args,
976 if incomplete { "warming" } else { "miss" },
977 coverage.as_deref(),
978 );
979 return code;
980 }
981
982 let counted_early = show || open || web;
987 if counted_early {
988 let _span = crate::profile::span("record usage");
989 record_usage(store, args, "hit", coverage.as_deref());
990 }
991
992 attach_confidence(&mut hits);
996 hits.truncate(want);
997 let total = total.max(hits.len());
998 for hit in &mut hits {
999 hit.total = total;
1000 if args.explain {
1001 hit.explain = Some(
1002 hit.features
1003 .iter()
1004 .map(|f| (f.name.to_string(), f.value))
1005 .collect(),
1006 );
1007 }
1008 }
1009
1010 let _signatures_span = crate::profile::span("signatures");
1013 for hit in &mut hits {
1014 hit.signature = read_signature(store, &hit.repo_identity, &hit.file, hit.line, here);
1015 }
1016 drop(_signatures_span);
1017
1018 if show && let Some(code) = show_top_definition(store, &mut hits, query, out, here) {
1021 return code;
1022 }
1023
1024 if open || web {
1029 return finish_open(store, &hits, current, root.as_deref(), web);
1030 }
1031
1032 if let Some(code) = render_hits(args, &hits) {
1033 return code;
1034 }
1035
1036 crate::profile::mark("first answer");
1039
1040 if !counted_early {
1043 let _span = crate::profile::span("after: record usage");
1044 record_usage(store, args, "hit", coverage.as_deref());
1045 }
1046
1047 if let Some(refresh) = branch_refresh.take() {
1054 let _span = crate::profile::span("after: branch refresh");
1055 refresh.store(store);
1056 }
1057
1058 if block {
1063 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1064 }
1065 if let Some(h) = indexer {
1066 let _ = h.join();
1067 }
1068 let _ = settle_warm(
1069 store,
1070 staleness,
1071 true,
1072 was_warming,
1073 warming_ok,
1074 root.as_deref(),
1075 active_paths,
1076 query,
1077 warm_budget,
1078 no_wait,
1079 identity.as_deref(),
1080 );
1081
1082 ExitCode::SUCCESS
1083}
1084
1085fn maybe_detach_warm(
1088 store: &Store,
1089 want_warm: bool,
1090 changed: bool,
1091 root: Option<&std::path::Path>,
1092 identity: Option<&str>,
1093) {
1094 if !warm_detach_enabled() || !want_warm {
1095 return;
1096 }
1097 let (Some(root), Some(id)) = (root, identity) else {
1098 return;
1099 };
1100 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1104 return; }
1106 spawn_detached_warm(root);
1107}
1108
1109fn spawn_detached_warm(root: &std::path::Path) {
1113 use std::os::unix::process::CommandExt;
1114 let Ok(exe) = std::env::current_exe() else {
1115 return;
1116 };
1117 let mut cmd = std::process::Command::new(exe);
1118 cmd.arg("--warm")
1119 .arg(root)
1120 .stdin(std::process::Stdio::null())
1121 .stdout(std::process::Stdio::null())
1122 .stderr(std::process::Stdio::null())
1123 .process_group(0);
1124 match cmd.spawn() {
1125 Ok(child) => crate::trace!(
1126 "background warm (detached): pid {} for {}",
1127 child.id(),
1128 crate::trace::abbrev(root)
1129 ),
1130 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1131 }
1132}
1133
1134const WARM_LOCK_TTL_SECS: i64 = 600;
1137
1138fn warm_recheck_window() -> Duration {
1144 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1145}
1146
1147fn recently_verified(
1150 store: &Store,
1151 identity: Option<&str>,
1152 root: &std::path::Path,
1153 indexed_head: Option<&str>,
1154) -> bool {
1155 let _span = crate::profile::span("after: warm recently verified?");
1156 let (Some(id), Some(stamp)) = (
1157 identity,
1158 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1159 ) else {
1160 return false;
1161 };
1162 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1163 return false;
1164 };
1165 let age = now_unix().saturating_sub(at);
1166 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1167}
1168
1169fn cmd_warm(path: Option<&str>) -> ExitCode {
1174 #[cfg(target_os = "macos")]
1177 unsafe extern "C" {
1178 fn setiopolicy_np(
1181 iotype: libc::c_int,
1182 scope: libc::c_int,
1183 policy: libc::c_int,
1184 ) -> libc::c_int;
1185 }
1186 unsafe {
1187 libc::nice(10);
1188 #[cfg(target_os = "macos")]
1189 setiopolicy_np(0, 0, 3);
1190 }
1191 let mut store = match open_store() {
1192 Ok(s) => s,
1193 Err(_) => return ExitCode::FAILURE,
1194 };
1195 let start = path
1196 .map(PathBuf::from)
1197 .or_else(|| std::env::current_dir().ok())
1198 .unwrap_or_else(|| PathBuf::from("."));
1199 let root = crate::index::repo_root(&start).unwrap_or(start);
1200 let identity = resolve_identity(&store, &root);
1201
1202 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1205 && pid != std::process::id()
1206 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1207 && now_unix() - ts < WARM_LOCK_TTL_SECS
1208 {
1209 return ExitCode::SUCCESS;
1210 }
1211 let _ = store.set_warm_lock(&identity, std::process::id());
1212
1213 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1216 let head = store
1217 .repository_id(&identity)
1218 .ok()
1219 .flatten()
1220 .and_then(|id| store.indexed_head(id).ok().flatten());
1221 let checked_at = now_unix();
1225 let edits = worktree_edits(&root, head.as_deref());
1226 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1227 crate::trace!("warm: unchanged since indexed, nothing to do");
1228 if let Some(stamp) = head
1229 .as_deref()
1230 .and_then(|h| crate::index::git_state_stamp(&root, h))
1231 {
1232 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1233 }
1234 let _ = store.clear_warm_lock(&identity);
1235 return ExitCode::SUCCESS;
1236 }
1237 }
1238
1239 let deadline = std::time::Instant::now() + warm_bg_budget();
1242 let active = crate::index::branch_changed_files(&root);
1243 loop {
1244 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1245 if remaining.is_zero() {
1246 break;
1247 }
1248 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1249 {
1250 Ok(s) => s,
1251 Err(_) => break,
1252 };
1253 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1254 || stats.files_indexed == 0
1255 {
1256 break;
1257 }
1258 }
1259 let _ = store.clear_warm_lock(&identity);
1260 ExitCode::SUCCESS
1261}
1262
1263fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1266 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1267 let deadline = std::time::Instant::now() + live_fallback_budget();
1268 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1269 if !h.is_empty() {
1270 return h;
1271 }
1272 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1273}
1274
1275fn strong(h: &crate::search::Hit) -> bool {
1277 h.features
1278 .iter()
1279 .any(|f| matches!(f.name, "exact" | "prefix"))
1280}
1281
1282fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1291 if hits.iter().any(strong) {
1292 hits.retain(strong);
1293 }
1294 crate::search::apply_scope_gate(query, hits);
1295}
1296
1297fn apply_post_filters(
1300 args: &SearchArgs,
1301 cwd: Option<&std::path::Path>,
1302 root: Option<&std::path::Path>,
1303 hits: &mut Vec<crate::search::Hit>,
1304) {
1305 if !args.paths.is_empty() {
1306 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1310 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1311 let norm: Vec<String> = args
1312 .paths
1313 .iter()
1314 .map(|p| repo_relative(&base, &here, p))
1315 .collect();
1316 hits.retain(|h| under_any(&h.file, &norm));
1317 }
1318 if !args.kinds.is_empty() {
1319 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1320 }
1321 if !args.langs.is_empty() {
1322 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1323 }
1324 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1327 hits.truncate(args.want.max(2));
1328 }
1329}
1330
1331fn no_match_code(
1337 out: Output,
1338 query: &str,
1339 interrupted: bool,
1340 incomplete: bool,
1341 elsewhere: Option<&str>,
1345) -> ExitCode {
1346 let status = if interrupted {
1347 "interrupted"
1348 } else if incomplete {
1349 "warming"
1350 } else if elsewhere.is_some() {
1351 "scope_not_found"
1352 } else {
1353 "no_match"
1354 };
1355 match out {
1356 Output::Json | Output::Ndjson => {
1357 let mut obj = serde_json::json!({ "status": status, "query": query });
1358 if let Some(found_in) = elsewhere {
1359 obj["found_in"] = serde_json::json!(found_in);
1360 }
1361 let _ = emit_json(out, &obj); }
1363 Output::Text if interrupted => {
1364 eprintln!("rq: indexing interrupted — run again to finish")
1365 }
1366 Output::Text if incomplete => eprintln!(
1367 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1368 ),
1369 Output::Text if elsewhere.is_some() => eprintln!(
1370 "rq: nothing matching {query:?} — that name is defined under {}",
1371 elsewhere.unwrap_or_default()
1372 ),
1373 Output::Text => eprintln!("no matches for {query:?}"),
1374 }
1375 if incomplete {
1376 ExitCode::from(2)
1377 } else {
1378 ExitCode::FAILURE
1379 }
1380}
1381
1382fn attach_confidence(hits: &mut [crate::search::Hit]) {
1386 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1387 if t.is_none_or(|t| h.score > t) {
1388 (Some(h.score), t)
1389 } else if s.is_none_or(|s| h.score > s) {
1390 (t, Some(h.score))
1391 } else {
1392 (t, s)
1393 }
1394 });
1395 for hit in hits.iter_mut() {
1396 let best_other = if Some(hit.score) == top { second } else { top };
1397 hit.confidence = crate::search::confidence(
1398 hit.score,
1399 crate::search::match_quality(&hit.features),
1400 best_other,
1401 );
1402 }
1403}
1404
1405fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1408 let render_span = crate::profile::span("render");
1412 if args.batch {
1413 #[derive(serde::Serialize)]
1416 struct Tagged<'a> {
1417 query: &'a str,
1418 #[serde(flatten)]
1419 hit: &'a crate::search::Hit,
1420 }
1421 let rows: Vec<Tagged> = hits
1422 .iter()
1423 .map(|hit| Tagged {
1424 query: args.query,
1425 hit,
1426 })
1427 .collect();
1428 if let Some(code) = emit_rows(args.out, &rows) {
1429 return Some(code);
1430 }
1431 } else if let Some(code) = emit_rows(args.out, hits) {
1432 return Some(code);
1433 }
1434 if args.out != Output::Text {
1435 return None;
1436 }
1437 drop(render_span);
1438 let color = match_color();
1439 let c = color.as_deref();
1440 let query = args.query;
1441 if args.show {
1442 let total = hits.first().map_or(hits.len(), |h| h.total);
1444 eprintln!(
1445 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1446 hits.len()
1447 );
1448 }
1449 for hit in hits {
1450 let name = hl(&hit.name, query, c);
1453 let qualified = match &hit.parent {
1454 Some(p) => format!("{name} · {p}"),
1455 None => name,
1456 };
1457 println!(
1458 "{}:{} {} {}",
1459 hl_path(&hit.file, query, c),
1460 hit.line,
1461 hit.kind,
1462 qualified
1463 );
1464 if let Some(sig) = &hit.signature {
1465 println!(" {}", hl(sig, query, c));
1466 }
1467 if args.explain {
1468 let parts: Vec<String> = hit
1469 .features
1470 .iter()
1471 .map(|f| format!("{} {:.0}", f.name, f.value))
1472 .collect();
1473 println!(
1474 " confidence {:.2} · score {:.0} = {}",
1475 hit.confidence,
1476 hit.score,
1477 parts.join(" + ")
1478 );
1479 }
1480 }
1481 None
1482}
1483
1484fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1488 use std::io::{IsTerminal, Write};
1489 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1490 return hits.first();
1491 }
1492 let mut err = std::io::stderr();
1493 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1494 for (i, h) in hits.iter().enumerate() {
1495 let _ = writeln!(
1496 err,
1497 " {}. {}:{} {} {}",
1498 i + 1,
1499 h.file,
1500 h.line,
1501 h.kind,
1502 h.name
1503 );
1504 }
1505 let _ = write!(err, "rq> ");
1506 let _ = err.flush();
1507 let mut line = String::new();
1508 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1509 return None; }
1511 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1512}
1513
1514fn parse_choice(input: &str, n: usize) -> Option<usize> {
1517 let s = input.trim();
1518 if s.is_empty() {
1519 return Some(0);
1520 }
1521 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1522 (i < n).then_some(i)
1523}
1524
1525fn finish_open(
1528 store: &Store,
1529 hits: &[crate::search::Hit],
1530 current: Option<i64>,
1531 root: Option<&std::path::Path>,
1532 web: bool,
1533) -> ExitCode {
1534 let Some(hit) = choose_hit(hits) else {
1535 return ExitCode::SUCCESS; };
1537
1538 if web {
1539 return open_web(store, hit, current, root);
1540 }
1541
1542 let target = match root {
1545 Some(r) => r.join(&hit.file),
1546 None => PathBuf::from(&hit.file),
1547 };
1548 launch_editor(&target, hit.line)
1549}
1550
1551fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1555 use std::os::unix::process::CommandExt;
1556 let loc = format!("{}:{}", file.display(), line);
1557 match open_command(file, line, &loc) {
1558 Some((prog, args)) => {
1559 crate::profile::emit(false);
1561 let err = std::process::Command::new(&prog).args(&args).exec();
1563 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1564 }
1565 None => {
1566 println!("{loc}");
1567 ExitCode::SUCCESS
1568 }
1569 }
1570}
1571
1572fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1576 let fstr = file.to_string_lossy().into_owned();
1577
1578 if let Some(t) = std::env::var_os("RQ_OPEN") {
1579 let t = t.to_string_lossy();
1580 let mut parts = t.split_whitespace().map(|p| {
1581 p.replace("{file}", &fstr)
1582 .replace("{line}", &line.to_string())
1583 .replace("{}", loc)
1584 });
1585 if let Some(prog) = parts.next() {
1586 return Some((prog, parts.collect()));
1587 }
1588 }
1589
1590 if on_path("code") {
1591 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1592 }
1593
1594 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1595 let ed = ed.to_string_lossy().into_owned();
1596 let l = ed.to_ascii_lowercase();
1597 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1599 .iter()
1600 .any(|e| l.contains(e))
1601 {
1602 return Some((ed, vec![format!("+{line}"), fstr]));
1603 }
1604 return Some((ed, vec![fstr]));
1605 }
1606
1607 None
1608}
1609
1610fn open_web(
1615 store: &Store,
1616 hit: &crate::search::Hit,
1617 current: Option<i64>,
1618 root: Option<&std::path::Path>,
1619) -> ExitCode {
1620 if hit.repo_identity.starts_with("local:") {
1621 return fail(format_args!(
1622 "rq --web: {} has no git remote to link to",
1623 hit.repo_identity
1624 ));
1625 }
1626 let here =
1627 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1628 let rev = root
1629 .filter(|_| here)
1630 .and_then(crate::index::pushed_head)
1631 .unwrap_or_else(|| "HEAD".into());
1632 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1633
1634 use std::os::unix::process::CommandExt;
1635 let browser = std::env::var("BROWSER")
1636 .ok()
1637 .filter(|b| !b.is_empty())
1638 .or_else(|| {
1639 ["open", "xdg-open"]
1640 .into_iter()
1641 .find(|p| on_path(p))
1642 .map(str::to_string)
1643 });
1644 match browser {
1645 Some(prog) => {
1646 crate::profile::emit(false);
1648 let err = std::process::Command::new(&prog).arg(&url).exec();
1650 fail(format_args!("rq --web: cannot run {prog}: {err}"))
1651 }
1652 None => {
1653 println!("{url}");
1654 ExitCode::SUCCESS
1655 }
1656 }
1657}
1658
1659fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1662 let path: String = file
1663 .bytes()
1664 .map(|b| match b {
1665 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1666 (b as char).to_string()
1667 }
1668 _ => format!("%{b:02X}"),
1669 })
1670 .collect();
1671 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1672}
1673
1674fn on_path(prog: &str) -> bool {
1676 std::env::var_os("PATH")
1677 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1678}
1679
1680const BRANCH_FILES_TTL_SECS: i64 = 15;
1686
1687const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1691
1692const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1704
1705fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1707 let earned = cost_ms.map_or(0, |ms| {
1710 i64::try_from(ms)
1711 .unwrap_or(i64::MAX)
1712 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1713 / 1000
1714 });
1715 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1716}
1717
1718struct BranchRefresh {
1722 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1725 identity: String,
1726 stamp: String,
1727}
1728
1729impl BranchRefresh {
1730 fn store(self, store: &Store) {
1732 let Ok((files, cost_ms)) = self.handle.join() else {
1733 return;
1734 };
1735 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
1736 }
1737}
1738
1739fn cached_branch_files(
1753 store: &Store,
1754 root: &std::path::Path,
1755) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
1756 let identity = resolve_identity(store, root);
1757 let stamp = crate::index::branch_files_stamp(root);
1758 let cached = store.branch_files_get(&identity).ok().flatten();
1759 let now = now_unix();
1760
1761 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
1762 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
1763 {
1764 return (hit.files.clone(), None, hit.cost_ms);
1765 }
1766 let owned_root = root.to_path_buf();
1767 let refresh = BranchRefresh {
1768 handle: std::thread::spawn(move || {
1769 let t = std::time::Instant::now();
1770 let files = crate::index::branch_changed_files(&owned_root);
1771 (files, t.elapsed().as_millis() as u64)
1772 }),
1773 identity,
1774 stamp: stamp.clone(),
1775 };
1776 return (hit.files.clone(), Some(refresh), hit.cost_ms);
1777 }
1778
1779 let t = std::time::Instant::now();
1781 let files = crate::index::branch_changed_files(root);
1782 let cost_ms = t.elapsed().as_millis() as u64;
1783 if let Some(stamp) = stamp {
1784 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
1785 }
1786 (files, None, Some(cost_ms))
1787}
1788
1789fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
1799 let head = indexed_head?;
1800 let _span = crate::profile::span("git: worktree changed?");
1801 (crate::index::git_head(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
1802}
1803
1804fn changed_since_index(
1807 store: &Store,
1808 identity: Option<&str>,
1809 root: Option<&std::path::Path>,
1810 edits: Option<Vec<String>>,
1811) -> bool {
1812 let (Some(dirty), Some(root)) = (edits, root) else {
1813 return true;
1814 };
1815 if dirty.is_empty() {
1816 return false;
1817 }
1818 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
1819 Some(repo_id) => crate::index::has_unindexed_edits(store, repo_id, root, &dirty),
1820 None => true,
1821 }
1822}
1823
1824enum Staleness {
1828 Running(std::thread::JoinHandle<Option<Vec<String>>>),
1831 Deferred(PathBuf, Option<String>),
1835}
1836
1837#[allow(clippy::too_many_arguments)]
1844fn settle_warm(
1845 store: &Store,
1846 staleness: Option<Staleness>,
1847 hit: bool,
1848 was_warming: bool,
1849 warming_ok: bool,
1850 root: Option<&std::path::Path>,
1851 active: &[String],
1852 query: &str,
1853 budget: Duration,
1854 no_wait: bool,
1855 identity: Option<&str>,
1856) -> bool {
1857 let changed = match staleness {
1858 None => false,
1859 Some(Staleness::Deferred(r, head)) if hit => {
1862 if recently_verified(store, identity, &r, head.as_deref()) {
1863 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
1864 } else {
1865 spawn_detached_warm(&r);
1866 }
1867 return false;
1868 }
1869 Some(Staleness::Deferred(r, head)) => {
1870 let _span = crate::profile::span("after: staleness check");
1871 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
1872 }
1873 Some(Staleness::Running(h)) => {
1874 let mut span = crate::profile::span("after: staleness wait");
1876 let changed = h.join().map_or(true, |edits| {
1879 changed_since_index(store, identity, root, edits)
1880 });
1881 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
1882 changed
1883 }
1884 };
1885 if changed
1895 && !no_wait
1896 && !warm_detach_enabled()
1897 && let Some(r) = root
1898 && let Ok(mut idx) = open_store()
1899 {
1900 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1901 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1902 }
1903 maybe_detach_warm(
1904 store,
1905 warming_ok && (was_warming || changed),
1906 changed,
1907 root,
1908 identity,
1909 );
1910 changed && warm_detach_enabled()
1914}
1915
1916fn answer_warm_budget() -> Duration {
1925 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1926}
1927
1928fn deferred_warm_budget() -> Duration {
1931 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1932}
1933
1934fn live_fallback_budget() -> Duration {
1937 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1938}
1939
1940fn warm_bg_budget() -> Duration {
1943 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1944}
1945
1946fn warm_detach_enabled() -> bool {
1950 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1951}
1952
1953fn wait_budget() -> Duration {
1962 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1963}
1964
1965fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1970 let s = s.trim();
1971 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1972 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1974 (n, 1.0)
1975 } else if let Some(n) = s.strip_suffix('s') {
1976 (n, 1_000.0)
1977 } else if let Some(n) = s.strip_suffix('m') {
1978 (n, 60_000.0)
1979 } else {
1980 (s, 1_000.0)
1982 };
1983 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1984 if !val.is_finite() || val < 0.0 {
1985 return Err(bad());
1986 }
1987 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1988}
1989
1990static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1994
1995extern "C" fn on_sigint(_: libc::c_int) {
1996 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1998}
1999
2000fn install_interrupt_handler() {
2003 static ONCE: std::sync::Once = std::sync::Once::new();
2004 ONCE.call_once(|| unsafe {
2005 let mut action: libc::sigaction = std::mem::zeroed();
2006 action.sa_sigaction = on_sigint as *const () as usize;
2007 libc::sigemptyset(&mut action.sa_mask);
2008 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2009 });
2010}
2011
2012fn stderr_interactive() -> bool {
2016 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2017}
2018
2019fn show_progress(out: Output, interactive: bool) -> bool {
2024 interactive && matches!(out, Output::Text)
2025}
2026
2027fn repo_label(root: Option<&std::path::Path>) -> String {
2030 root.and_then(|r| r.file_name())
2031 .map(|n| n.to_string_lossy().into_owned())
2032 .unwrap_or_else(|| "repo".into())
2033}
2034
2035fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2039 let files = identity
2040 .and_then(|id| store.repository_id(id).ok().flatten())
2041 .and_then(|rid| store.repo_totals(rid).ok())
2042 .map_or(0, |(f, _)| f);
2043 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2044 let _ = std::io::stderr().flush();
2045}
2046
2047fn clear_progress() {
2049 eprint!("\r\x1b[K");
2050 let _ = std::io::stderr().flush();
2051}
2052
2053fn env_budget(var: &str, default_ms: u64) -> Duration {
2057 let ms = std::env::var(var)
2058 .ok()
2059 .and_then(|v| v.parse().ok())
2060 .unwrap_or(default_ms);
2061 Duration::from_millis(ms)
2062}
2063
2064#[derive(Clone, Copy)]
2066struct Here<'a> {
2067 identity: &'a str,
2068 root: &'a std::path::Path,
2069}
2070
2071fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2077 let mut roots: Vec<PathBuf> = here
2078 .filter(|h| h.identity == repo_identity)
2079 .map(|h| h.root.to_path_buf())
2080 .into_iter()
2081 .collect();
2082 let recorded = store
2083 .repository_id(repo_identity)
2084 .ok()
2085 .flatten()
2086 .map(|id| store.checkout_roots(id).unwrap_or_default())
2087 .unwrap_or_default();
2088 for root in recorded.into_iter().map(PathBuf::from) {
2089 if !roots.contains(&root) {
2090 roots.push(root);
2091 }
2092 }
2093 roots
2094}
2095
2096fn read_signature(
2099 store: &Store,
2100 repo_identity: &str,
2101 file: &str,
2102 line: i64,
2103 here: Option<Here>,
2104) -> Option<String> {
2105 hit_file_roots(store, repo_identity, here)
2106 .into_iter()
2107 .find_map(|root| {
2108 let src = std::fs::read_to_string(root.join(file)).ok()?;
2109 signature_in(&src.lines().collect::<Vec<_>>(), line)
2110 })
2111}
2112
2113const SHOW_CONFIDENCE: f64 = 0.85;
2117
2118fn show_top_definition(
2122 store: &Store,
2123 hits: &mut [crate::search::Hit],
2124 query: &str,
2125 out: Output,
2126 here: Option<Here>,
2127) -> Option<ExitCode> {
2128 let top = hits.first()?;
2129 if top.confidence < SHOW_CONFIDENCE {
2130 return None; }
2132 let end = top.end_line.unwrap_or(top.line);
2133 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, here);
2134 hits[0].body = body;
2135 let top = &hits[0];
2136 let code = match out {
2137 Output::Json | Output::Ndjson => {
2138 emit_json(out, top)
2140 }
2141 Output::Text => {
2142 let color = match_color();
2143 let c = color.as_deref();
2144 let name = hl(&top.name, query, c);
2145 let qualified = match &top.parent {
2146 Some(p) => format!("{name} · {p}"),
2147 None => name,
2148 };
2149 println!(
2150 "{}:{} {} {}",
2151 hl_path(&top.file, query, c),
2152 top.line,
2153 top.kind,
2154 qualified
2155 );
2156 match (&top.body, &top.signature) {
2157 (Some(body), _) => println!("{body}"),
2158 (None, Some(sig)) => println!("{sig}"),
2160 (None, None) => {}
2161 }
2162 ExitCode::SUCCESS
2163 }
2164 };
2165
2166 Some(code)
2167}
2168
2169fn read_span(
2172 store: &Store,
2173 repo_identity: &str,
2174 file: &str,
2175 start: i64,
2176 end: i64,
2177 here: Option<Here>,
2178) -> Option<String> {
2179 hit_file_roots(store, repo_identity, here)
2180 .into_iter()
2181 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2182}
2183
2184fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2187 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2188 let lines: Vec<&str> = content.lines().collect();
2189 if s >= lines.len() {
2190 return None;
2191 }
2192 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2193 Some(lines[s..e].join("\n"))
2194}
2195
2196fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2201 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2202 let l = lines.get(idx)?.trim();
2203 (!l.is_empty()).then(|| l.to_string())
2204}
2205
2206#[derive(serde::Serialize)]
2210struct SymbolOut {
2211 name: String,
2212 kind: String,
2213 language: String,
2214 file: String,
2215 line: i64,
2216 #[serde(skip_serializing_if = "Option::is_none")]
2217 end_line: Option<i64>,
2218 #[serde(skip_serializing_if = "Option::is_none")]
2219 parent: Option<String>,
2220 #[serde(skip_serializing_if = "Option::is_none")]
2221 visibility: Option<String>,
2222 repo: String,
2223 #[serde(skip_serializing_if = "Option::is_none")]
2224 signature: Option<String>,
2225}
2226
2227fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2232 let open_span = crate::profile::span("store open");
2233 let mut store = match open_store() {
2234 Ok(s) => s,
2235 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2236 };
2237 drop(open_span);
2238 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2239 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2240 let rel = repo_relative(&root, &cwd, file_arg);
2241
2242 let identity = resolve_identity(&store, &root);
2243 let coverage = store.coverage_status(&identity).ok().flatten();
2244 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2245 let current = store.repository_id(&identity).ok().flatten();
2246 let path = root.join(&rel);
2247 if !path.is_file() {
2249 return emit_symbols(out, &[]);
2250 }
2251 let indexable = path
2252 .extension()
2253 .and_then(|e| e.to_str())
2254 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2255 match current {
2256 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2260 if indexable {
2261 let _span = crate::profile::span("symbols: refresh");
2262 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2263 }
2264 }
2265 _ if warming_ok => {
2269 let budget = answer_warm_budget() + deferred_warm_budget();
2270 let _span = crate::profile::span("symbols: warm");
2271 let active = [rel.clone()];
2272 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2273 }
2274 _ => {}
2275 }
2276
2277 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2278 return emit_symbols(out, &[]); };
2280 let mut query_span = crate::profile::span("symbols: query");
2281 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2282 Ok(r) => r,
2283 Err(e) => return fail(format_args!("rq: {e}")),
2284 };
2285 query_span.note(|| format!("{} rows", rows.len()));
2286 drop(query_span);
2287 if !kinds.is_empty() {
2288 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2289 }
2290 if !langs.is_empty() {
2291 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2292 }
2293
2294 let signatures_span = crate::profile::span("symbols: signatures");
2297 let content = std::fs::read_to_string(&path).ok();
2298 let lines: Vec<&str> = content
2299 .as_deref()
2300 .map_or_else(Vec::new, |c| c.lines().collect());
2301 let syms: Vec<SymbolOut> = rows
2302 .into_iter()
2303 .map(|r| SymbolOut {
2304 signature: signature_in(&lines, r.line),
2305 name: r.name,
2306 kind: r.kind,
2307 language: r.language,
2308 file: r.file,
2309 line: r.line,
2310 end_line: r.end_line,
2311 parent: r.parent,
2312 visibility: r.visibility,
2313 repo: r.repo_identity,
2314 })
2315 .collect();
2316 drop(signatures_span);
2317 let _span = crate::profile::span("render");
2318 emit_symbols(out, &syms)
2319}
2320
2321fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2324 if syms.is_empty() {
2325 match out {
2326 Output::Json | Output::Ndjson => {
2327 let obj = serde_json::json!({ "status": "no_match" });
2328 let _ = emit_json(out, &obj); }
2330 Output::Text => eprintln!("no symbols"),
2331 }
2332 return ExitCode::FAILURE;
2333 }
2334 if let Some(code) = emit_rows(out, syms) {
2335 return code;
2336 }
2337 match out {
2338 Output::Json | Output::Ndjson => {}
2339 Output::Text => {
2340 for s in syms {
2341 let qualified = match &s.parent {
2342 Some(p) => format!("{} · {p}", s.name),
2343 None => s.name.clone(),
2344 };
2345 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2346 if let Some(sig) = &s.signature {
2347 println!(" {sig}");
2348 }
2349 }
2350 }
2351 }
2352 ExitCode::SUCCESS
2353}
2354
2355fn keyword_kind(token: &str) -> Option<&'static str> {
2360 match token.to_ascii_lowercase().as_str() {
2361 "class" => Some("class"),
2362 "module" => Some("module"),
2363 "method" => Some("method"),
2364 "function" | "fn" => Some("function"),
2365 "struct" | "type" => Some("struct"),
2366 "enum" => Some("enum"),
2367 "trait" | "interface" => Some("trait"),
2368 "constant" | "const" => Some("constant"),
2369 _ => None,
2370 }
2371}
2372
2373fn split_kind_keyword(
2379 target: String,
2380 dirs: Vec<String>,
2381) -> (Option<&'static str>, String, Vec<String>) {
2382 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2385 let rest = rest.trim();
2386 if let Some(k) = keyword_kind(head)
2387 && !rest.is_empty()
2388 {
2389 return (Some(k), rest.to_string(), dirs);
2390 }
2391 } else if let Some(k) = keyword_kind(&target)
2392 && let Some((query, extra)) = dirs.split_first()
2393 {
2394 return (Some(k), query.clone(), extra.to_vec());
2396 }
2397 (None, target, dirs)
2398}
2399
2400fn canonical_kind(s: &str) -> Option<&'static str> {
2403 Some(match s.to_ascii_lowercase().as_str() {
2404 "c" | "class" => "class",
2405 "m" | "method" => "method",
2406 "f" | "fn" | "func" | "function" => "function",
2407 "mod" | "module" => "module",
2408 "s" | "struct" | "type" => "struct",
2409 "e" | "enum" => "enum",
2410 "t" | "trait" | "interface" => "trait",
2411 "const" | "constant" => "constant",
2412 _ => return None,
2413 })
2414}
2415
2416fn canonical_langs(s: &str) -> Vec<String> {
2423 let t = s.to_ascii_lowercase();
2424 let alias = match t.as_str() {
2425 "rb" => Some("ruby"),
2426 "rs" => Some("rust"),
2427 "golang" => Some("go"),
2428 "ts" | "tsx" => Some("typescript"),
2429 "js" | "jsx" => Some("javascript"),
2430 _ => None,
2431 };
2432 let matched: Vec<String> = crate::lang::languages()
2433 .into_iter()
2434 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2435 .map(str::to_string)
2436 .collect();
2437 matched
2438}
2439
2440fn match_color() -> Option<String> {
2444 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2445 return None;
2446 }
2447 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2448 gc.split(':').find_map(|e| {
2449 e.strip_prefix("mt=")
2450 .or_else(|| e.strip_prefix("ms="))
2451 .filter(|v| !v.is_empty())
2452 .map(str::to_string)
2453 })
2454 });
2455 Some(style.unwrap_or_else(|| "1;31".to_string()))
2456}
2457
2458fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2461 match color {
2462 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2463 None => text.to_string(),
2464 }
2465}
2466
2467fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2470 let Some(c) = color else {
2471 return path.to_string();
2472 };
2473 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2474 let base_start = path[..base_byte].chars().count();
2475 let stem = crate::search::path_stem(path);
2479 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2480 .into_iter()
2481 .map(|p| p + base_start)
2482 .collect();
2483 highlight(path, &positions, c)
2484}
2485
2486fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2489 if positions.is_empty() {
2490 return text.to_string();
2491 }
2492 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2493 let mut out = String::new();
2494 let mut on = false;
2495 for (i, c) in text.chars().enumerate() {
2496 match (matched.contains(&i), on) {
2497 (true, false) => {
2498 out.push_str("\x1b[");
2499 out.push_str(color);
2500 out.push('m');
2501 on = true;
2502 }
2503 (false, true) => {
2504 out.push_str("\x1b[0m");
2505 on = false;
2506 }
2507 _ => {}
2508 }
2509 out.push(c);
2510 }
2511 if on {
2512 out.push_str("\x1b[0m");
2513 }
2514 out
2515}
2516
2517fn under_any(file: &str, paths: &[String]) -> bool {
2521 paths.iter().any(|p| {
2522 let p = p.trim_start_matches("./").trim_end_matches('/');
2523 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2524 })
2525}
2526
2527fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2529 let p = std::path::Path::new(file);
2530 let abs = if p.is_absolute() {
2531 p.to_path_buf()
2532 } else {
2533 cwd.join(p)
2534 };
2535 let abs = abs.canonicalize().unwrap_or(abs);
2536 abs.strip_prefix(root)
2537 .map(|r| r.to_string_lossy().into_owned())
2538 .unwrap_or_else(|_| file.to_string())
2539}
2540
2541fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2546 use std::collections::HashSet;
2547 let mut seen = HashSet::new();
2548 let mut changed = false;
2549 for hit in hits {
2550 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2551 continue;
2552 }
2553 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2554 continue;
2555 };
2556 let roots = hit_file_roots(store, &hit.repo_identity, here);
2557 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2558 continue;
2559 };
2560 if let Ok(crate::index::Refresh::Updated) =
2561 crate::index::refresh_file(store, repo_id, root, &hit.file)
2562 {
2563 changed = true;
2564 }
2565 }
2566 changed
2567}
2568
2569fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2575 if let Ok(canon) = cwd.canonicalize() {
2576 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2577 return identity;
2578 }
2579 if crate::index::repo_root(cwd).is_none() {
2580 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2581 }
2582 }
2583 crate::index::detect_identity(cwd).to_string()
2584}
2585
2586fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2587 let explicit = path.is_some();
2588 let target = path.unwrap_or_else(|| PathBuf::from("."));
2589 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2594 let mut subdirs = subdirs.to_vec();
2599 if explicit
2600 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2601 && t != r
2602 && let Ok(rel) = t.strip_prefix(&r)
2603 && !rel.as_os_str().is_empty()
2604 {
2605 subdirs.push(rel.to_string_lossy().into_owned());
2606 }
2607 let open_span = crate::profile::span("store open");
2608 let mut store = match open_store() {
2609 Ok(s) => s,
2610 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2611 };
2612 drop(open_span);
2613 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2614 let identity = resolve_identity(&store, &root);
2617 match indexed {
2618 Ok(stats) => {
2619 let subtree = !subdirs.is_empty();
2620 let totals = store
2622 .repository_id(&identity)
2623 .ok()
2624 .flatten()
2625 .and_then(|id| store.repo_totals(id).ok());
2626 match out {
2627 Output::Json | Output::Ndjson => {
2628 let (files, symbols) = match totals {
2629 Some((f, s)) => (Some(f), Some(s)),
2630 None => (None, None),
2631 };
2632 return emit_json(
2633 out,
2634 &serde_json::json!({
2635 "repo": identity,
2636 "scope": if subtree { "subtree" } else { "full" },
2637 "files_added": stats.files_indexed,
2638 "symbols_added": stats.symbols,
2639 "files": files,
2640 "symbols": symbols,
2641 }),
2642 );
2643 }
2644 Output::Text => {
2645 let scope = if subtree { " (subtree seed)" } else { "" };
2646 match totals {
2647 Some((files, symbols)) => println!(
2648 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2649 stats.files_indexed, stats.symbols
2650 ),
2651 None => println!(
2652 "{} file(s)/{} symbol(s) added this run{scope}",
2653 stats.files_indexed, stats.symbols
2654 ),
2655 }
2656 }
2657 }
2658 ExitCode::SUCCESS
2659 }
2660 Err(e) => fail(format_args!("rq --index: {e}")),
2661 }
2662}
2663
2664fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2665 let mut store = match open_store() {
2666 Ok(s) => s,
2667 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2668 };
2669
2670 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2674 let root = crate::index::repo_root(&path).unwrap_or(path);
2675 let from_path = crate::index::detect_identity(&root).to_string();
2676 let resolved = match store.repository_id(&from_path) {
2677 Ok(Some(id)) => Some((from_path.clone(), id)),
2678 Ok(None) => target.as_deref().and_then(|s| {
2679 store
2680 .repository_id(s)
2681 .ok()
2682 .flatten()
2683 .map(|id| (s.to_string(), id))
2684 }),
2685 Err(e) => return fail(format_args!("rq --drop: {e}")),
2686 };
2687
2688 let Some((identity, repo_id)) = resolved else {
2689 return match out {
2691 Output::Text => {
2692 println!("not indexed: {from_path}");
2693 ExitCode::SUCCESS
2694 }
2695 _ => emit_json(
2696 out,
2697 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2698 ),
2699 };
2700 };
2701
2702 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2703 match store.drop_repository(repo_id) {
2704 Ok(()) => match out {
2705 Output::Text => {
2706 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2707 ExitCode::SUCCESS
2708 }
2709 _ => emit_json(
2710 out,
2711 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2712 ),
2713 },
2714 Err(e) => fail(format_args!("rq --drop: {e}")),
2715 }
2716}
2717
2718fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2722 let rendered = if out == Output::Json {
2723 serde_json::to_string_pretty(value)
2724 } else {
2725 serde_json::to_string(value)
2726 };
2727 match rendered {
2728 Ok(s) => {
2729 println!("{s}");
2730 ExitCode::SUCCESS
2731 }
2732 Err(e) => fail(format_args!("rq: {e}")),
2733 }
2734}
2735
2736fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2740 match out {
2741 Output::Json => match serde_json::to_string_pretty(rows) {
2742 Ok(s) => println!("{s}"),
2743 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2744 },
2745 Output::Ndjson => {
2746 for r in rows {
2747 match serde_json::to_string(r) {
2748 Ok(line) => println!("{line}"),
2749 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2750 }
2751 }
2752 }
2753 Output::Text => {}
2754 }
2755 None
2756}
2757
2758fn cmd_status(out: Output) -> ExitCode {
2759 let store = match open_store() {
2760 Ok(s) => s,
2761 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2762 };
2763 let rows = match store.coverage_overview() {
2764 Ok(rows) => rows,
2765 Err(e) => return fail(format_args!("rq --status: {e}")),
2766 };
2767 if let Some(code) = emit_rows(out, &rows) {
2768 return code;
2769 }
2770 match out {
2771 Output::Json | Output::Ndjson => {}
2772 Output::Text if rows.is_empty() => {
2773 println!("no repositories indexed yet (try `rq --index`)");
2774 }
2775 Output::Text => {
2776 for r in &rows {
2777 println!(
2778 "{:<10} {:>6} files {:>7} symbols {}",
2779 r.status, r.files, r.symbols, r.identity
2780 );
2781 }
2782 }
2783 }
2784 ExitCode::SUCCESS
2785}
2786
2787fn cmd_usage(out: Output) -> ExitCode {
2790 let store = match open_store() {
2791 Ok(s) => s,
2792 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2793 };
2794 let rows = match store.usage_overview() {
2795 Ok(rows) => rows,
2796 Err(e) => return fail(format_args!("rq --usage: {e}")),
2797 };
2798 if let Some(code) = emit_rows(out, &rows) {
2799 return code;
2800 }
2801 match out {
2802 Output::Json | Output::Ndjson => {}
2803 Output::Text if rows.is_empty() => {
2804 println!("no usage recorded yet");
2805 }
2806 Output::Text => {
2807 println!(
2810 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2811 "day", "caller", "found", "missed", "warming"
2812 );
2813 for r in &rows {
2814 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2815 println!(
2816 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2817 r.day,
2818 r.source,
2819 r.searches - r.misses - r.warming,
2820 r.misses,
2821 r.warming,
2822 flags
2823 );
2824 }
2825 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2826 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2827 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2828 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2829 let plural = if searches == 1 { "search" } else { "searches" };
2830 println!(
2833 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2834 );
2835 }
2836 }
2837 if rows.is_empty() {
2839 return ExitCode::from(1);
2840 }
2841 ExitCode::SUCCESS
2842}
2843
2844fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2846 let path = db_path()?;
2847 if let Some(parent) = path.parent() {
2848 std::fs::create_dir_all(parent)?;
2849 }
2850 Ok(Store::open(&path)?)
2851}
2852
2853fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2855 if let Ok(p) = std::env::var("RQ_DB") {
2856 return Ok(PathBuf::from(p));
2857 }
2858 let home = std::env::var("HOME")?;
2859 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2860}
2861
2862fn fail(args: std::fmt::Arguments) -> ExitCode {
2863 eprintln!("{args}");
2864 ExitCode::FAILURE
2865}
2866
2867#[cfg(test)]
2868mod tests {
2869 use super::*;
2870
2871 #[test]
2872 fn open_menu_choice_parsing() {
2873 assert_eq!(parse_choice("\n", 5), Some(0));
2875 assert_eq!(parse_choice(" ", 5), Some(0));
2876 assert_eq!(parse_choice("3", 5), Some(2));
2877 assert_eq!(parse_choice("5", 5), Some(4));
2878 assert_eq!(parse_choice("6", 5), None);
2880 assert_eq!(parse_choice("0", 5), None);
2881 assert_eq!(parse_choice("q", 5), None);
2882 }
2883
2884 #[test]
2885 fn web_url_shape() {
2886 assert_eq!(
2887 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
2888 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
2889 );
2890 }
2891
2892 #[test]
2893 fn wait_duration_parsing() {
2894 use std::time::Duration;
2895 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2897 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2898 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2899 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2900 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2902 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2903 assert!(parse_wait("0s").unwrap().is_zero());
2904 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2906 assert!(parse_wait("2x").is_err());
2908 assert!(parse_wait("").is_err());
2909 assert!(parse_wait("s").is_err());
2910 assert!(parse_wait("-1s").is_err());
2911 }
2912
2913 #[test]
2914 fn leading_kind_keyword_becomes_a_kind_filter() {
2915 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2916 assert_eq!(
2918 split_kind_keyword("class".into(), d(&["Widget"])),
2919 (Some("class"), "Widget".into(), vec![])
2920 );
2921 assert_eq!(
2923 split_kind_keyword("method zoom".into(), vec![]),
2924 (Some("method"), "zoom".into(), vec![])
2925 );
2926 assert_eq!(
2928 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2929 (Some("function"), "Foo::run".into(), vec![])
2930 );
2931 assert_eq!(
2933 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2934 (Some("struct"), "Gadget".into(), d(&["src"]))
2935 );
2936 }
2937
2938 #[test]
2939 fn a_bare_or_non_keyword_query_is_left_alone() {
2940 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2941 assert_eq!(
2943 split_kind_keyword("class".into(), vec![]),
2944 (None, "class".into(), vec![])
2945 );
2946 assert_eq!(
2948 split_kind_keyword("Widget".into(), d(&["app"])),
2949 (None, "Widget".into(), d(&["app"]))
2950 );
2951 assert_eq!(
2953 split_kind_keyword("c".into(), d(&["Foo"])),
2954 (None, "c".into(), d(&["Foo"]))
2955 );
2956 }
2957
2958 #[test]
2959 fn the_branch_window_scales_with_what_the_refresh_costs() {
2960 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
2963 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
2964 assert_eq!(branch_files_ttl(Some(700)), 70);
2968 assert_eq!(branch_files_ttl(Some(2_000)), 200);
2969 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
2972 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
2973 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
2975 }
2976
2977 #[test]
2978 fn a_language_selects_by_prefix_or_alias() {
2979 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2981 assert_eq!(canonical_langs("t"), ["typescript"]);
2982 assert_eq!(canonical_langs("ts"), ["typescript"]);
2984 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2985 assert_eq!(canonical_langs("rb"), ["ruby"]);
2986 assert!(canonical_langs("COBOL").is_empty());
2989 }
2990
2991 #[test]
2992 fn a_kind_normalizes_language_specific_spellings() {
2993 assert_eq!(canonical_kind("f"), Some("function"));
2994 assert_eq!(canonical_kind("interface"), Some("trait"));
2996 assert_eq!(canonical_kind("type"), Some("struct"));
2997 assert_eq!(canonical_kind("const"), Some("constant"));
2998 assert_eq!(canonical_kind("banana"), None);
2999 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3001 assert_eq!(
3002 split_kind_keyword("interface".into(), d(&["Renderer"])),
3003 (Some("trait"), "Renderer".into(), vec![])
3004 );
3005 }
3006
3007 #[test]
3008 fn highlight_wraps_matched_runs() {
3009 assert_eq!(
3010 highlight("FooThing", &[0, 1, 2], "1;31"),
3011 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3012 );
3013 assert_eq!(
3015 highlight("FooThing", &[0, 3], "1"),
3016 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3017 );
3018 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3020 }
3021
3022 #[test]
3023 fn progress_ui_only_for_an_interactive_text_terminal() {
3024 assert!(show_progress(Output::Text, true));
3026
3027 assert!(!show_progress(Output::Json, true));
3029 assert!(!show_progress(Output::Ndjson, true));
3030
3031 assert!(!show_progress(Output::Text, false));
3033 }
3034
3035 #[test]
3036 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3037 let lines = ["class Widget", "", " def go"];
3038 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3039 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3040 assert_eq!(signature_in(&lines, 2), None, "blank line");
3041 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3042 assert_eq!(signature_in(&lines, 4), None, "past the end");
3043 }
3044
3045 #[test]
3046 fn repo_label_uses_the_directory_name() {
3047 assert_eq!(
3048 repo_label(Some(std::path::Path::new("/src/widgets"))),
3049 "widgets"
3050 );
3051 assert_eq!(repo_label(None), "repo");
3052 }
3053
3054 #[test]
3055 fn hl_path_highlights_the_stem_not_the_extension() {
3056 let out = hl_path(
3059 "app/employees_controller.rb",
3060 "employeescontroller",
3061 Some("1;31"),
3062 );
3063 assert!(
3064 out.starts_with("app/\u{1b}[1;31memployees"),
3065 "stem highlighted: {out:?}"
3066 );
3067 assert!(
3068 out.ends_with("controller\u{1b}[0m.rb"),
3069 "`.rb` left un-highlighted: {out:?}"
3070 );
3071 }
3072}