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(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
113 show: bool,
114
115 #[arg(short = 'j', long)]
117 json: bool,
118
119 #[arg(short = 'J', long, conflicts_with = "json")]
121 ndjson: bool,
122
123 #[arg(short = 'p', long, value_name = "DIR")]
125 path: Vec<String>,
126
127 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
129 limit: usize,
130
131 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
135 kind: Vec<String>,
136
137 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
141 lang: Vec<String>,
142
143 #[arg(short = 'a', long = "all-repos")]
146 all_repos: bool,
147
148 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
150 index: Option<Option<String>>,
151
152 #[arg(long, conflicts_with_all = ["index", "record"])]
154 status: bool,
155
156 #[arg(long, conflicts_with_all = ["index", "record", "status"])]
158 usage: bool,
159
160 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
163 symbols: Option<String>,
164
165 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
169 drop: bool,
170
171 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
174 record: bool,
175
176 #[arg(long)]
178 file: Option<String>,
179
180 #[arg(long)]
182 line: Option<i64>,
183
184 #[arg(long, default_value = "select")]
186 event: String,
187
188 #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "record", "drop", "symbols", "open", "show"])]
192 warm: Option<Option<String>>,
193
194 #[arg(long, value_name = "SHELL")]
196 completions: Option<Shell>,
197
198 #[arg(short = 'v', long)]
201 verbose: bool,
202
203 #[arg(long)]
207 profile: bool,
208
209 #[arg(long, value_name = "N", default_value_t = 0)]
212 jobs: usize,
213}
214
215pub fn run() -> ExitCode {
217 let cli = Cli::parse();
218 crate::trace::enable_from(cli.verbose);
219 crate::profile::enable_from(cli.profile);
220 crate::index::set_parse_jobs(cli.jobs);
221
222 if let Some(shell) = cli.completions {
223 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
224 return ExitCode::SUCCESS;
225 }
226 if let Some(path) = &cli.index {
227 let out = output_format(&cli);
229 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
230 }
231 if let Some(path) = &cli.warm {
232 return cmd_warm(path.as_deref());
233 }
234 if cli.status {
235 return cmd_status(output_format(&cli));
236 }
237 if cli.usage {
238 return cmd_usage(output_format(&cli));
239 }
240 if cli.drop {
241 let out = output_format(&cli);
242 return cmd_drop(cli.target, out);
243 }
244 if cli.record {
245 if !matches!(cli.event.as_str(), "select" | "open") {
247 return fail(format_args!(
248 "rq --record: unknown --event {:?} (expected select or open)",
249 cli.event
250 ));
251 }
252 let file = cli.file.expect("--record requires --file");
254 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
255 }
256 let out = output_format(&cli);
257 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
258 return fail(format_args!("rq: empty query"));
259 }
260 let mut kinds: Vec<String> = Vec::new();
264 for k in &cli.kind {
265 match canonical_kind(k) {
266 Some(c) => kinds.push(c.to_string()),
267 None => {
268 return fail(format_args!(
269 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait)"
270 ));
271 }
272 }
273 }
274 let mut langs: Vec<String> = Vec::new();
276 for x in &cli.lang {
277 let matched = canonical_langs(x);
278 if matched.is_empty() {
279 return fail(format_args!(
280 "rq: unknown --lang {x:?} ({})",
281 crate::lang::languages().join(", ")
282 ));
283 }
284 langs.extend(matched);
285 }
286 if let Some(file) = &cli.symbols {
287 return cmd_symbols(file, &kinds, &langs, out);
288 }
289 let mut paths = cli.path.clone();
291 match cli.target {
292 Some(target) => {
293 let query = if cli.kind.is_empty() {
296 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
297 if let Some(k) = kw {
298 kinds.push(k.to_string());
299 }
300 paths.extend(dirs);
301 query
302 } else {
303 paths.extend(cli.dirs.clone());
304 target
305 };
306 let mut session = match Session::open() {
307 Ok(s) => s,
308 Err(code) => return code,
309 };
310 cmd_search(
311 &mut session,
312 &SearchArgs {
313 query: &query,
314 explain: cli.explain,
315 out,
316 paths: &paths,
317 kinds: &kinds,
318 langs: &langs,
319 want: requested_limit(cli.limit),
320 no_record: cli.no_record,
321 no_wait: cli.no_wait,
322 wait: cli.wait,
323 open: cli.open,
324 all_repos: cli.all_repos,
325 show: cli.show,
326 batch: false,
327 },
328 )
329 }
330 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
333 None => {
335 let _ = Cli::command().print_long_help();
336 ExitCode::SUCCESS
337 }
338 }
339}
340
341#[derive(Clone, Copy, PartialEq)]
343enum Output {
344 Text,
345 Json,
346 Ndjson,
347}
348
349fn output_format(cli: &Cli) -> Output {
350 if cli.ndjson {
351 Output::Ndjson
352 } else if cli.json {
353 Output::Json
354 } else {
355 Output::Text
356 }
357}
358
359const DEFAULT_LIMIT: usize = 10;
361
362const PATH_HEADROOM: usize = 200;
365
366fn requested_limit(limit: usize) -> usize {
369 if limit == 0 { usize::MAX } else { limit }
370}
371
372fn record_usage(
375 store: &mut Store,
376 args: &SearchArgs,
377 repository_id: Option<i64>,
378 results: usize,
379 status: &str,
380 coverage: Option<&str>,
381) {
382 if args.no_record {
383 return;
384 }
385 let _ = store.record_search(&crate::store::SearchRecord {
386 query: &args.query.to_ascii_lowercase(),
387 repository_id,
388 results,
389 source: &crate::origin::detect(),
390 flags: &flag_summary(args),
391 status,
392 coverage: coverage.unwrap_or("none"),
393 });
394}
395
396fn flag_summary(args: &SearchArgs) -> String {
401 let mut on: Vec<&str> = Vec::new();
402 match args.out {
403 Output::Json => on.push("json"),
404 Output::Ndjson => on.push("ndjson"),
405 Output::Text => {}
406 }
407 for (present, name) in [
408 (args.explain, "explain"),
409 (args.show, "show"),
410 (args.open, "open"),
411 (args.all_repos, "all-repos"),
412 (args.no_wait, "no-wait"),
413 (args.batch, "batch"),
414 (!args.paths.is_empty(), "path"),
415 (!args.kinds.is_empty(), "kind"),
416 (!args.langs.is_empty(), "lang"),
417 (args.want != DEFAULT_LIMIT, "limit"),
418 ] {
419 if present {
420 on.push(name);
421 }
422 }
423 on.join(",")
424}
425
426const POLL_INTERVAL: Duration = Duration::from_millis(100);
433
434const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
438
439const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
443
444struct SearchArgs<'a> {
446 query: &'a str,
447 explain: bool,
448 out: Output,
449 paths: &'a [String],
450 kinds: &'a [String],
451 langs: &'a [String],
452 want: usize,
454 no_record: bool,
455 no_wait: bool,
457 wait: Option<Duration>,
460 open: bool,
461 all_repos: bool,
462 batch: bool,
466 show: bool,
467}
468
469struct Session {
480 store: Store,
481 cwd: Option<PathBuf>,
482 cwd_is_git: bool,
483 root: Option<PathBuf>,
484 active_paths: Vec<String>,
485 branch_refresh: Option<BranchRefresh>,
486 identity: Option<String>,
487 coverage: Option<String>,
488}
489
490impl Session {
491 fn open() -> std::result::Result<Session, ExitCode> {
493 let open_span = crate::profile::span("store open");
494 let store = match open_store() {
495 Ok(s) => s,
496 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
497 };
498 drop(open_span);
499 let git_span = crate::profile::span("setup: git root");
500 let cwd = std::env::current_dir().ok();
501 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
502
503 let root = cwd
509 .as_deref()
510 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
511 drop(git_span);
512
513 let mut branch_span = crate::profile::span("setup: branch files");
516 let (active_paths, branch_refresh, cached_cost) = match &root {
517 Some(c) if cwd_is_git => cached_branch_files(&store, c),
518 _ => (Vec::new(), None, None),
519 };
520 branch_span.note(|| {
521 let how = if branch_refresh.is_some() {
522 "cached, refreshing alongside"
523 } else {
524 "cached"
525 };
526 let ttl = branch_files_ttl(cached_cost);
529 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
530 });
531 drop(branch_span);
532
533 let mut identity_span = crate::profile::span("setup: identity");
538 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
539 let coverage = identity
540 .as_deref()
541 .and_then(|id| store.coverage_status(id).ok())
542 .flatten();
543 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
544 drop(identity_span);
545 Ok(Session {
546 store,
547 cwd,
548 cwd_is_git,
549 root,
550 active_paths,
551 branch_refresh,
552 identity,
553 coverage,
554 })
555 }
556}
557
558fn cmd_batch(
573 cli: &Cli,
574 out: Output,
575 paths: &[String],
576 kinds: &[String],
577 langs: &[String],
578) -> ExitCode {
579 if out == Output::Json {
580 return fail(format_args!(
581 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
582 where each line carries the query it answers"
583 ));
584 }
585 if cli.open || cli.show {
586 return fail(format_args!(
587 "rq: --open and --show act on a single result, not a stream of queries"
588 ));
589 }
590
591 use std::io::BufRead;
592 let queries: Vec<String> = std::io::stdin()
593 .lock()
594 .lines()
595 .map_while(std::result::Result::ok)
596 .map(|l| l.trim().to_string())
597 .filter(|l| !l.is_empty())
598 .collect();
599 if queries.is_empty() {
603 let _ = Cli::command().print_long_help();
604 return ExitCode::SUCCESS;
605 }
606
607 let mut session = match Session::open() {
608 Ok(s) => s,
609 Err(code) => return code,
610 };
611
612 if !cli.no_wait
615 && session.coverage.as_deref() != Some("complete")
616 && let Some(root) = session.root.clone()
617 {
618 {
619 let budget = cli.wait.unwrap_or_else(wait_budget);
620 crate::trace!(
621 "batch: warming {} queries' worth of index first",
622 queries.len()
623 );
624 let active = session.active_paths.clone();
625 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
626 session.coverage = session
627 .identity
628 .as_deref()
629 .and_then(|id| session.store.coverage_status(id).ok())
630 .flatten();
631 }
632 }
633
634 let mut worst = ExitCode::SUCCESS;
635 let mut any_hit = false;
636 for query in &queries {
637 let code = cmd_search(
638 &mut session,
639 &SearchArgs {
640 query,
641 explain: cli.explain,
642 out,
643 paths,
644 kinds,
645 langs,
646 want: requested_limit(cli.limit),
647 no_record: cli.no_record,
648 no_wait: true,
652 wait: cli.wait,
653 open: false,
654 all_repos: cli.all_repos,
655 show: false,
656 batch: true,
657 },
658 );
659 if code == ExitCode::SUCCESS {
660 any_hit = true;
661 } else {
662 worst = code;
663 }
664 }
665 if any_hit { ExitCode::SUCCESS } else { worst }
668}
669
670fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
671 let &SearchArgs {
672 query,
673 out,
674 want,
675 no_record,
676 no_wait,
677 wait,
678 open,
679 all_repos,
680 show,
681 ..
682 } = args;
683 let wait_budget = wait.unwrap_or_else(wait_budget);
686 let no_wait = no_wait || wait_budget.is_zero();
687 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
690 want
691 } else {
692 want.saturating_mul(20).max(PATH_HEADROOM)
693 };
694 let _timer = crate::trace::Timer::start("search done");
695 let profile_started = std::time::Instant::now();
696 let t_setup = std::time::Instant::now();
697 let setup_span = crate::profile::span("setup");
699 let Session {
702 store,
703 cwd,
704 cwd_is_git,
705 root,
706 active_paths,
707 branch_refresh,
708 identity,
709 coverage,
710 } = session;
711 let cwd_is_git = *cwd_is_git;
712
713 let known = coverage.is_some();
721 let warming_ok = cwd_is_git || known;
722 if crate::trace::enabled() {
723 crate::trace!(
724 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
725 root.as_deref().map_or("?".into(), crate::trace::abbrev),
726 identity.as_deref().unwrap_or("none"),
727 coverage.as_deref().unwrap_or("none"),
728 active_paths.len(),
729 );
730 }
731 let repo_span = crate::profile::span("setup: repo state");
732 let current = identity
733 .as_deref()
734 .and_then(|id| store.repository_id(id).ok().flatten());
735 let only_repo = if all_repos { None } else { current };
738 let active = crate::search::ActiveFiles::new(active_paths.clone());
739
740 drop(repo_span);
741 let warm_span = crate::profile::span("setup: warm decision");
742
743 let warm_budget = if warm_detach_enabled() {
750 answer_warm_budget()
751 } else {
752 answer_warm_budget() + deferred_warm_budget()
753 };
754 let was_warming = coverage.as_deref() != Some("complete");
755
756 let indexed_head = (!was_warming)
767 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
768 .flatten();
769 let staleness = (!was_warming && warming_ok && !args.batch)
772 .then(|| root.clone())
773 .flatten()
774 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
775 let want_warm = warming_ok && was_warming && root.is_some();
778
779 let block = want_warm && was_warming && !no_wait;
793 let progress_ui = block && show_progress(out, stderr_interactive());
798 let indexer_budget = if block { wait_budget } else { warm_budget };
799 if progress_ui {
800 install_interrupt_handler();
801 }
802
803 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
806 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
807 crate::trace!(
808 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
809 crate::index::parse_jobs()
810 );
811 let root = root.clone().expect("checked");
812 let active = active_paths.clone();
813 let q = query.to_string();
814 let warm_done = std::sync::Arc::clone(&warm_done);
815 std::thread::spawn(move || {
816 if let Ok(mut idx) = open_store() {
817 let _ = if block {
819 crate::index::index_budgeted_cancellable(
822 &mut idx,
823 &root,
824 &active,
825 indexer_budget,
826 Some(&q),
827 &INTERRUPTED,
828 )
829 } else {
830 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
831 };
832 }
833 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
834 })
835 });
836
837 crate::trace!(
844 "setup (open + repo detect + warm decision): {} ms",
845 t_setup.elapsed().as_millis()
846 );
847 let poll_start = std::time::Instant::now();
848 let deadline = if progress_ui {
852 None
853 } else if block {
854 Some(poll_start + wait_budget)
855 } else {
856 Some(poll_start + answer_warm_budget())
857 };
858 drop(warm_span);
859 let polling = indexer.is_some() && was_warming;
860 drop(setup_span);
864 let mut query_span = crate::profile::span("query");
865 let label = repo_label(root.as_deref());
866 let mut drew_progress = false;
867 let mut last_draw = poll_start;
868 let rank_limit = limit.max(2);
872 let mut total;
873 let mut hits = loop {
874 match crate::search::search(store, query, current, only_repo, &active, rank_limit) {
875 Ok(m) => {
876 total = m.total;
877 let h = m.hits;
878 let confident = h.first().is_some_and(|hit| {
879 hit.features
880 .iter()
881 .any(|f| matches!(f.name, "exact" | "prefix"))
882 });
883 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
884 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
885 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
886 if !polling || confident || warm_finished || stopped || timed_out {
887 break h;
888 }
889 if progress_ui
890 && poll_start.elapsed() >= HEADS_UP_DELAY
891 && last_draw.elapsed() >= PROGRESS_REDRAW
892 {
893 draw_progress(store, identity.as_deref(), &label);
894 drew_progress = true;
895 last_draw = std::time::Instant::now();
896 }
897 }
898 Err(e) => {
899 if let Some(h) = indexer {
900 let _ = h.join();
901 }
902 return fail(format_args!("rq: {e}"));
903 }
904 }
905 std::thread::sleep(POLL_INTERVAL);
906 };
907 query_span.note(|| {
908 if polling {
909 "polled a warming index".to_string()
910 } else {
911 String::new()
912 }
913 });
914 drop(query_span);
915 if drew_progress {
916 clear_progress();
917 }
918 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
920
921 if !hits.is_empty()
923 && revalidate_top(store, &hits)
924 && let Ok(m) = crate::search::search(store, query, current, only_repo, &active, rank_limit)
925 {
926 total = m.total;
927 hits = m.hits;
928 }
929
930 if !hits.iter().any(strong)
934 && indexer.is_none()
935 && coverage.is_none()
936 && let Some(root) = &root
937 {
938 let tail = live_fallback(root, query, rank_limit);
939 hits = crate::search::merge(hits, tail, rank_limit);
940 total = total.max(hits.len());
941 }
942
943 apply_gates(query, &mut hits);
944 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
945 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
948 total = hits.len();
949 }
950
951 if hits.is_empty() {
952 if block {
954 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
955 }
956 if let Some(h) = indexer {
957 let _ = h.join();
958 }
959 let mut incomplete = (block || no_wait)
966 && identity
967 .as_deref()
968 .and_then(|id| store.coverage_status(id).ok().flatten())
969 .as_deref()
970 != Some("complete");
971 incomplete |= settle_warm(
981 store,
982 staleness,
983 was_warming,
984 warming_ok,
985 root.as_deref(),
986 active_paths,
987 query,
988 warm_budget,
989 no_wait,
990 identity.as_deref(),
991 );
992 record_usage(
996 store,
997 args,
998 current,
999 0,
1000 if incomplete { "warming" } else { "miss" },
1001 coverage.as_deref(),
1002 );
1003 let elsewhere = crate::search::scope_miss_owner(store, query, current, only_repo, &active);
1008 return no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1009 }
1010
1011 record_usage(store, args, current, hits.len(), "hit", coverage.as_deref());
1014
1015 attach_confidence(&mut hits);
1019 hits.truncate(want);
1020 let total = total.max(hits.len());
1021 for hit in &mut hits {
1022 hit.total = total;
1023 if args.explain {
1024 hit.explain = Some(
1025 hit.features
1026 .iter()
1027 .map(|f| (f.name.to_string(), f.value))
1028 .collect(),
1029 );
1030 }
1031 }
1032
1033 for hit in &mut hits {
1036 hit.signature = read_signature(
1037 store,
1038 &hit.repo_identity,
1039 &hit.file,
1040 hit.line,
1041 cwd.as_deref(),
1042 );
1043 }
1044
1045 if show
1048 && let Some(code) = show_top_definition(
1049 store,
1050 &mut hits,
1051 query,
1052 out,
1053 cwd.as_deref(),
1054 current,
1055 no_record,
1056 )
1057 {
1058 return code;
1059 }
1060
1061 if open {
1065 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
1066 }
1067
1068 if let Some(code) = render_hits(args, &hits) {
1069 return code;
1070 }
1071
1072 if crate::profile::enabled() {
1075 let total = profile_started.elapsed();
1076 if args.out == Output::Text {
1077 for line in crate::profile::report(total) {
1078 eprintln!("{line}");
1079 }
1080 } else {
1081 eprintln!("{}", crate::profile::json(total));
1084 }
1085 }
1086
1087 if let Some(refresh) = branch_refresh.take() {
1094 refresh.store(store);
1095 }
1096
1097 deferred_maintenance(store);
1102
1103 if block {
1108 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1109 }
1110 if let Some(h) = indexer {
1111 let _ = h.join();
1112 }
1113 let _ = settle_warm(
1114 store,
1115 staleness,
1116 was_warming,
1117 warming_ok,
1118 root.as_deref(),
1119 active_paths,
1120 query,
1121 warm_budget,
1122 no_wait,
1123 identity.as_deref(),
1124 );
1125
1126 ExitCode::SUCCESS
1127}
1128
1129fn maybe_detach_warm(
1132 store: &Store,
1133 want_warm: bool,
1134 changed: bool,
1135 root: Option<&std::path::Path>,
1136 identity: Option<&str>,
1137) {
1138 if !warm_detach_enabled() || !want_warm {
1139 return;
1140 }
1141 let (Some(root), Some(id)) = (root, identity) else {
1142 return;
1143 };
1144 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1148 return; }
1150 spawn_detached_warm(root);
1151}
1152
1153fn spawn_detached_warm(root: &std::path::Path) {
1157 use std::os::unix::process::CommandExt;
1158 let Ok(exe) = std::env::current_exe() else {
1159 return;
1160 };
1161 let mut cmd = std::process::Command::new(exe);
1162 cmd.arg("--warm")
1163 .arg(root)
1164 .stdin(std::process::Stdio::null())
1165 .stdout(std::process::Stdio::null())
1166 .stderr(std::process::Stdio::null())
1167 .process_group(0);
1168 match cmd.spawn() {
1169 Ok(child) => crate::trace!(
1170 "background warm (detached): pid {} for {}",
1171 child.id(),
1172 crate::trace::abbrev(root)
1173 ),
1174 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1175 }
1176}
1177
1178const WARM_LOCK_TTL_SECS: i64 = 600;
1181
1182fn cmd_warm(path: Option<&str>) -> ExitCode {
1187 #[cfg(target_os = "macos")]
1190 unsafe extern "C" {
1191 fn setiopolicy_np(
1194 iotype: libc::c_int,
1195 scope: libc::c_int,
1196 policy: libc::c_int,
1197 ) -> libc::c_int;
1198 }
1199 unsafe {
1200 libc::nice(10);
1201 #[cfg(target_os = "macos")]
1202 setiopolicy_np(0, 0, 3);
1203 }
1204 let mut store = match open_store() {
1205 Ok(s) => s,
1206 Err(_) => return ExitCode::FAILURE,
1207 };
1208 let start = path
1209 .map(PathBuf::from)
1210 .or_else(|| std::env::current_dir().ok())
1211 .unwrap_or_else(|| PathBuf::from("."));
1212 let root = crate::index::repo_root(&start).unwrap_or(start);
1213 let identity = resolve_identity(&store, &root);
1214
1215 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1218 && pid != std::process::id()
1219 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1220 && now_unix() - ts < WARM_LOCK_TTL_SECS
1221 {
1222 return ExitCode::SUCCESS;
1223 }
1224 let _ = store.set_warm_lock(&identity, std::process::id());
1225
1226 let deadline = std::time::Instant::now() + warm_bg_budget();
1229 let active = crate::index::branch_changed_files(&root);
1230 loop {
1231 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1232 if remaining.is_zero() {
1233 break;
1234 }
1235 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1236 {
1237 Ok(s) => s,
1238 Err(_) => break,
1239 };
1240 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1241 || stats.files_indexed == 0
1242 {
1243 break;
1244 }
1245 }
1246 let _ = store.clear_warm_lock(&identity);
1247 ExitCode::SUCCESS
1248}
1249
1250fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1253 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1254 let deadline = std::time::Instant::now() + live_fallback_budget();
1255 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1256 if !h.is_empty() {
1257 return h;
1258 }
1259 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1260}
1261
1262fn strong(h: &crate::search::Hit) -> bool {
1264 h.features
1265 .iter()
1266 .any(|f| matches!(f.name, "exact" | "prefix"))
1267}
1268
1269fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1278 if hits.iter().any(strong) {
1279 hits.retain(strong);
1280 }
1281 crate::search::apply_scope_gate(query, hits);
1282}
1283
1284fn apply_post_filters(
1287 args: &SearchArgs,
1288 cwd: Option<&std::path::Path>,
1289 root: Option<&std::path::Path>,
1290 hits: &mut Vec<crate::search::Hit>,
1291) {
1292 if !args.paths.is_empty() {
1293 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1297 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1298 let norm: Vec<String> = args
1299 .paths
1300 .iter()
1301 .map(|p| repo_relative(&base, &here, p))
1302 .collect();
1303 hits.retain(|h| under_any(&h.file, &norm));
1304 }
1305 if !args.kinds.is_empty() {
1306 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1307 }
1308 if !args.langs.is_empty() {
1309 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1310 }
1311 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1314 hits.truncate(args.want.max(2));
1315 }
1316}
1317
1318fn no_match_code(
1324 out: Output,
1325 query: &str,
1326 interrupted: bool,
1327 incomplete: bool,
1328 elsewhere: Option<&str>,
1332) -> ExitCode {
1333 let status = if interrupted {
1334 "interrupted"
1335 } else if incomplete {
1336 "warming"
1337 } else if elsewhere.is_some() {
1338 "scope_not_found"
1339 } else {
1340 "no_match"
1341 };
1342 match out {
1343 Output::Json | Output::Ndjson => {
1344 let mut obj = serde_json::json!({ "status": status, "query": query });
1345 if let Some(found_in) = elsewhere {
1346 obj["found_in"] = serde_json::json!(found_in);
1347 }
1348 let _ = emit_json(out, &obj); }
1350 Output::Text if interrupted => {
1351 eprintln!("rq: indexing interrupted — run again to finish")
1352 }
1353 Output::Text if incomplete => eprintln!(
1354 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1355 ),
1356 Output::Text if elsewhere.is_some() => eprintln!(
1357 "rq: nothing matching {query:?} — that name is defined under {}",
1358 elsewhere.unwrap_or_default()
1359 ),
1360 Output::Text => eprintln!("no matches for {query:?}"),
1361 }
1362 if incomplete {
1363 ExitCode::from(2)
1364 } else {
1365 ExitCode::FAILURE
1366 }
1367}
1368
1369fn attach_confidence(hits: &mut [crate::search::Hit]) {
1373 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1374 if t.is_none_or(|t| h.score > t) {
1375 (Some(h.score), t)
1376 } else if s.is_none_or(|s| h.score > s) {
1377 (t, Some(h.score))
1378 } else {
1379 (t, s)
1380 }
1381 });
1382 for hit in hits.iter_mut() {
1383 let best_other = if Some(hit.score) == top { second } else { top };
1384 hit.confidence = crate::search::confidence(
1385 hit.score,
1386 crate::search::match_quality(&hit.features),
1387 best_other,
1388 );
1389 }
1390}
1391
1392fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1395 let render_span = crate::profile::span("render");
1399 if args.batch {
1400 #[derive(serde::Serialize)]
1403 struct Tagged<'a> {
1404 query: &'a str,
1405 #[serde(flatten)]
1406 hit: &'a crate::search::Hit,
1407 }
1408 let rows: Vec<Tagged> = hits
1409 .iter()
1410 .map(|hit| Tagged {
1411 query: args.query,
1412 hit,
1413 })
1414 .collect();
1415 if let Some(code) = emit_rows(args.out, &rows) {
1416 return Some(code);
1417 }
1418 } else if let Some(code) = emit_rows(args.out, hits) {
1419 return Some(code);
1420 }
1421 if args.out != Output::Text {
1422 return None;
1423 }
1424 drop(render_span);
1425 let color = match_color();
1426 let c = color.as_deref();
1427 let query = args.query;
1428 if args.show {
1429 let total = hits.first().map_or(hits.len(), |h| h.total);
1431 eprintln!(
1432 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1433 hits.len()
1434 );
1435 }
1436 for hit in hits {
1437 let name = hl(&hit.name, query, c);
1440 let qualified = match &hit.parent {
1441 Some(p) => format!("{name} · {p}"),
1442 None => name,
1443 };
1444 println!(
1445 "{}:{} {} {}",
1446 hl_path(&hit.file, query, c),
1447 hit.line,
1448 hit.kind,
1449 qualified
1450 );
1451 if let Some(sig) = &hit.signature {
1452 println!(" {}", hl(sig, query, c));
1453 }
1454 if args.explain {
1455 let parts: Vec<String> = hit
1456 .features
1457 .iter()
1458 .map(|f| format!("{} {:.0}", f.name, f.value))
1459 .collect();
1460 println!(
1461 " confidence {:.2} · score {:.0} = {}",
1462 hit.confidence,
1463 hit.score,
1464 parts.join(" + ")
1465 );
1466 }
1467 }
1468 None
1469}
1470
1471fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1475 use std::io::{IsTerminal, Write};
1476 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1477 return hits.first();
1478 }
1479 let mut err = std::io::stderr();
1480 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1481 for (i, h) in hits.iter().enumerate() {
1482 let _ = writeln!(
1483 err,
1484 " {}. {}:{} {} {}",
1485 i + 1,
1486 h.file,
1487 h.line,
1488 h.kind,
1489 h.name
1490 );
1491 }
1492 let _ = write!(err, "rq> ");
1493 let _ = err.flush();
1494 let mut line = String::new();
1495 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1496 return None; }
1498 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1499}
1500
1501fn parse_choice(input: &str, n: usize) -> Option<usize> {
1504 let s = input.trim();
1505 if s.is_empty() {
1506 return Some(0);
1507 }
1508 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1509 (i < n).then_some(i)
1510}
1511
1512fn finish_open(
1516 store: &mut Store,
1517 hits: &[crate::search::Hit],
1518 query: &str,
1519 current: Option<i64>,
1520 root: Option<&std::path::Path>,
1521 no_record: bool,
1522) -> ExitCode {
1523 let Some(hit) = choose_hit(hits) else {
1524 return ExitCode::SUCCESS; };
1526
1527 if !no_record {
1530 let _ = store.record_event(
1531 "select",
1532 Some(&query.to_ascii_lowercase()),
1533 current,
1534 Some(&hit.file),
1535 Some(hit.line),
1536 None,
1537 );
1538 deferred_maintenance(store);
1539 }
1540
1541 let target = match root {
1544 Some(r) => r.join(&hit.file),
1545 None => PathBuf::from(&hit.file),
1546 };
1547 launch_editor(&target, hit.line)
1548}
1549
1550fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1554 use std::os::unix::process::CommandExt;
1555 let loc = format!("{}:{}", file.display(), line);
1556 match open_command(file, line, &loc) {
1557 Some((prog, args)) => {
1558 let err = std::process::Command::new(&prog).args(&args).exec();
1560 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1561 }
1562 None => {
1563 println!("{loc}");
1564 ExitCode::SUCCESS
1565 }
1566 }
1567}
1568
1569fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1573 let fstr = file.to_string_lossy().into_owned();
1574
1575 if let Some(t) = std::env::var_os("RQ_OPEN") {
1576 let t = t.to_string_lossy();
1577 let mut parts = t.split_whitespace().map(|p| {
1578 p.replace("{file}", &fstr)
1579 .replace("{line}", &line.to_string())
1580 .replace("{}", loc)
1581 });
1582 if let Some(prog) = parts.next() {
1583 return Some((prog, parts.collect()));
1584 }
1585 }
1586
1587 if on_path("code") {
1588 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1589 }
1590
1591 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1592 let ed = ed.to_string_lossy().into_owned();
1593 let l = ed.to_ascii_lowercase();
1594 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1596 .iter()
1597 .any(|e| l.contains(e))
1598 {
1599 return Some((ed, vec![format!("+{line}"), fstr]));
1600 }
1601 return Some((ed, vec![fstr]));
1602 }
1603
1604 None
1605}
1606
1607fn on_path(prog: &str) -> bool {
1609 std::env::var_os("PATH")
1610 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1611}
1612
1613const BRANCH_FILES_TTL_SECS: i64 = 15;
1619
1620const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1624
1625const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1637
1638fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1640 let earned = cost_ms.map_or(0, |ms| {
1643 i64::try_from(ms)
1644 .unwrap_or(i64::MAX)
1645 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1646 / 1000
1647 });
1648 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1649}
1650
1651struct BranchRefresh {
1655 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1658 identity: String,
1659 stamp: String,
1660}
1661
1662impl BranchRefresh {
1663 fn store(self, store: &Store) {
1665 let Ok((files, cost_ms)) = self.handle.join() else {
1666 return;
1667 };
1668 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
1669 }
1670}
1671
1672fn cached_branch_files(
1686 store: &Store,
1687 root: &std::path::Path,
1688) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
1689 let identity = resolve_identity(store, root);
1690 let stamp = crate::index::branch_files_stamp(root);
1691 let cached = store.branch_files_get(&identity).ok().flatten();
1692 let now = now_unix();
1693
1694 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
1695 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
1696 {
1697 return (hit.files.clone(), None, hit.cost_ms);
1698 }
1699 let owned_root = root.to_path_buf();
1700 let refresh = BranchRefresh {
1701 handle: std::thread::spawn(move || {
1702 let t = std::time::Instant::now();
1703 let files = crate::index::branch_changed_files(&owned_root);
1704 (files, t.elapsed().as_millis() as u64)
1705 }),
1706 identity,
1707 stamp: stamp.clone(),
1708 };
1709 return (hit.files.clone(), Some(refresh), hit.cost_ms);
1710 }
1711
1712 let t = std::time::Instant::now();
1714 let files = crate::index::branch_changed_files(root);
1715 let cost_ms = t.elapsed().as_millis() as u64;
1716 if let Some(stamp) = stamp {
1717 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
1718 }
1719 (files, None, Some(cost_ms))
1720}
1721
1722fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1731 let Some(head) = indexed_head else {
1732 return true;
1733 };
1734 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1735}
1736
1737#[allow(clippy::too_many_arguments)]
1745fn settle_warm(
1746 store: &Store,
1747 staleness: Option<std::thread::JoinHandle<bool>>,
1748 was_warming: bool,
1749 warming_ok: bool,
1750 root: Option<&std::path::Path>,
1751 active: &[String],
1752 query: &str,
1753 budget: Duration,
1754 no_wait: bool,
1755 identity: Option<&str>,
1756) -> bool {
1757 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1760 if changed
1770 && !no_wait
1771 && !warm_detach_enabled()
1772 && let Some(r) = root
1773 && let Ok(mut idx) = open_store()
1774 {
1775 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1776 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1777 }
1778 maybe_detach_warm(
1779 store,
1780 warming_ok && (was_warming || changed),
1781 changed,
1782 root,
1783 identity,
1784 );
1785 changed && warm_detach_enabled()
1789}
1790
1791fn answer_warm_budget() -> Duration {
1800 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1801}
1802
1803fn deferred_warm_budget() -> Duration {
1806 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1807}
1808
1809fn live_fallback_budget() -> Duration {
1812 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1813}
1814
1815fn warm_bg_budget() -> Duration {
1818 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1819}
1820
1821fn warm_detach_enabled() -> bool {
1825 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1826}
1827
1828fn wait_budget() -> Duration {
1837 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1838}
1839
1840fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1845 let s = s.trim();
1846 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1847 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1849 (n, 1.0)
1850 } else if let Some(n) = s.strip_suffix('s') {
1851 (n, 1_000.0)
1852 } else if let Some(n) = s.strip_suffix('m') {
1853 (n, 60_000.0)
1854 } else {
1855 (s, 1_000.0)
1857 };
1858 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1859 if !val.is_finite() || val < 0.0 {
1860 return Err(bad());
1861 }
1862 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1863}
1864
1865static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1869
1870extern "C" fn on_sigint(_: libc::c_int) {
1871 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1873}
1874
1875fn install_interrupt_handler() {
1878 static ONCE: std::sync::Once = std::sync::Once::new();
1879 ONCE.call_once(|| unsafe {
1880 let mut action: libc::sigaction = std::mem::zeroed();
1881 action.sa_sigaction = on_sigint as *const () as usize;
1882 libc::sigemptyset(&mut action.sa_mask);
1883 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1884 });
1885}
1886
1887fn stderr_interactive() -> bool {
1891 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1892}
1893
1894fn show_progress(out: Output, interactive: bool) -> bool {
1899 interactive && matches!(out, Output::Text)
1900}
1901
1902fn repo_label(root: Option<&std::path::Path>) -> String {
1905 root.and_then(|r| r.file_name())
1906 .map(|n| n.to_string_lossy().into_owned())
1907 .unwrap_or_else(|| "repo".into())
1908}
1909
1910fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1914 let files = identity
1915 .and_then(|id| store.repository_id(id).ok().flatten())
1916 .and_then(|rid| store.repo_totals(rid).ok())
1917 .map_or(0, |(f, _)| f);
1918 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1919 let _ = std::io::stderr().flush();
1920}
1921
1922fn clear_progress() {
1924 eprint!("\r\x1b[K");
1925 let _ = std::io::stderr().flush();
1926}
1927
1928fn env_budget(var: &str, default_ms: u64) -> Duration {
1932 let ms = std::env::var(var)
1933 .ok()
1934 .and_then(|v| v.parse().ok())
1935 .unwrap_or(default_ms);
1936 Duration::from_millis(ms)
1937}
1938
1939const AGGREGATE_BATCH: usize = 256;
1942
1943const KEEP_RECENT_EVENTS: i64 = 200;
1946
1947fn deferred_maintenance(store: &mut Store) {
1950 let _ = store.aggregate_events(AGGREGATE_BATCH);
1951 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1952}
1953
1954fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1957 let mut store = match open_store() {
1958 Ok(s) => s,
1959 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1960 };
1961 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1962 let identity = crate::index::detect_identity(&cwd).to_string();
1963 let repo_id = store.repository_id(&identity).ok().flatten();
1964
1965 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1968 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1969 None => file.to_string(),
1970 };
1971 let query_norm = query.map(|q| q.to_ascii_lowercase());
1972
1973 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1974 {
1975 return fail(format_args!("rq record: {e}"));
1976 }
1977 deferred_maintenance(&mut store);
1978 ExitCode::SUCCESS
1979}
1980
1981fn hit_file_roots(
1987 store: &Store,
1988 repo_identity: &str,
1989 cwd: Option<&std::path::Path>,
1990) -> Vec<PathBuf> {
1991 let mut roots: Vec<PathBuf> = store
1992 .repository_id(repo_identity)
1993 .ok()
1994 .flatten()
1995 .map(|id| store.checkout_roots(id).unwrap_or_default())
1996 .unwrap_or_default()
1997 .into_iter()
1998 .map(PathBuf::from)
1999 .collect();
2000 if let Some(c) = cwd {
2001 let c = c.to_path_buf();
2002 if !roots.contains(&c) {
2003 roots.push(c);
2004 }
2005 }
2006 roots
2007}
2008
2009fn read_signature(
2012 store: &Store,
2013 repo_identity: &str,
2014 file: &str,
2015 line: i64,
2016 cwd: Option<&std::path::Path>,
2017) -> Option<String> {
2018 hit_file_roots(store, repo_identity, cwd)
2019 .into_iter()
2020 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
2021}
2022
2023const SHOW_CONFIDENCE: f64 = 0.85;
2027
2028fn show_top_definition(
2037 store: &mut Store,
2038 hits: &mut [crate::search::Hit],
2039 query: &str,
2040 out: Output,
2041 cwd: Option<&std::path::Path>,
2042 current: Option<i64>,
2043 no_record: bool,
2044) -> Option<ExitCode> {
2045 let top = hits.first()?;
2046 if top.confidence < SHOW_CONFIDENCE {
2047 return None; }
2049 let end = top.end_line.unwrap_or(top.line);
2050 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
2051 hits[0].body = body;
2052 let top = &hits[0];
2053 let shown = (top.file.clone(), top.line);
2054 let code = match out {
2055 Output::Json | Output::Ndjson => {
2056 emit_json(out, top)
2058 }
2059 Output::Text => {
2060 let color = match_color();
2061 let c = color.as_deref();
2062 let name = hl(&top.name, query, c);
2063 let qualified = match &top.parent {
2064 Some(p) => format!("{name} · {p}"),
2065 None => name,
2066 };
2067 println!(
2068 "{}:{} {} {}",
2069 hl_path(&top.file, query, c),
2070 top.line,
2071 top.kind,
2072 qualified
2073 );
2074 match (&top.body, &top.signature) {
2075 (Some(body), _) => println!("{body}"),
2076 (None, Some(sig)) => println!("{sig}"),
2078 (None, None) => {}
2079 }
2080 ExitCode::SUCCESS
2081 }
2082 };
2083
2084 if !no_record {
2086 let (file, line) = shown;
2087 let _ = store.record_event(
2088 "select",
2089 Some(&query.to_ascii_lowercase()),
2090 current,
2091 Some(&file),
2092 Some(line),
2093 None,
2094 );
2095 deferred_maintenance(store);
2096 }
2097 Some(code)
2098}
2099
2100fn read_span(
2103 store: &Store,
2104 repo_identity: &str,
2105 file: &str,
2106 start: i64,
2107 end: i64,
2108 cwd: Option<&std::path::Path>,
2109) -> Option<String> {
2110 hit_file_roots(store, repo_identity, cwd)
2111 .into_iter()
2112 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2113}
2114
2115fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2118 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2119 let lines: Vec<&str> = content.lines().collect();
2120 if s >= lines.len() {
2121 return None;
2122 }
2123 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2124 Some(lines[s..e].join("\n"))
2125}
2126
2127fn signature_in(content: &str, line: i64) -> Option<String> {
2131 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2132 let l = content.lines().nth(idx)?.trim();
2133 (!l.is_empty()).then(|| l.to_string())
2134}
2135
2136#[derive(serde::Serialize)]
2140struct SymbolOut {
2141 name: String,
2142 kind: String,
2143 language: String,
2144 file: String,
2145 line: i64,
2146 #[serde(skip_serializing_if = "Option::is_none")]
2147 end_line: Option<i64>,
2148 #[serde(skip_serializing_if = "Option::is_none")]
2149 parent: Option<String>,
2150 #[serde(skip_serializing_if = "Option::is_none")]
2151 visibility: Option<String>,
2152 repo: String,
2153 #[serde(skip_serializing_if = "Option::is_none")]
2154 signature: Option<String>,
2155}
2156
2157fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2162 let mut store = match open_store() {
2163 Ok(s) => s,
2164 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2165 };
2166 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2167 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2168 let rel = repo_relative(&root, &cwd, file_arg);
2169
2170 let identity = resolve_identity(&store, &root);
2171 let coverage = store.coverage_status(&identity).ok().flatten();
2172 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2173 let current = store.repository_id(&identity).ok().flatten();
2174 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
2177 let needs_warm = warming_ok
2178 && (coverage.as_deref() != Some("complete")
2179 || worktree_changed(&root, indexed_head.as_deref()));
2180 if needs_warm {
2181 let budget = answer_warm_budget() + deferred_warm_budget();
2183 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2184 }
2185
2186 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2187 return emit_symbols(out, &[]); };
2189 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2190 Ok(r) => r,
2191 Err(e) => return fail(format_args!("rq: {e}")),
2192 };
2193 if !kinds.is_empty() {
2194 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2195 }
2196 if !langs.is_empty() {
2197 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2198 }
2199
2200 let content = hit_file_roots(&store, &identity, Some(&root))
2204 .iter()
2205 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2206 let syms: Vec<SymbolOut> = rows
2207 .into_iter()
2208 .map(|r| SymbolOut {
2209 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2210 name: r.name,
2211 kind: r.kind,
2212 language: r.language,
2213 file: r.file,
2214 line: r.line,
2215 end_line: r.end_line,
2216 parent: r.parent,
2217 visibility: r.visibility,
2218 repo: r.repo_identity,
2219 })
2220 .collect();
2221 emit_symbols(out, &syms)
2222}
2223
2224fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2227 if syms.is_empty() {
2228 match out {
2229 Output::Json | Output::Ndjson => {
2230 let obj = serde_json::json!({ "status": "no_match" });
2231 let _ = emit_json(out, &obj); }
2233 Output::Text => eprintln!("no symbols"),
2234 }
2235 return ExitCode::FAILURE;
2236 }
2237 if let Some(code) = emit_rows(out, syms) {
2238 return code;
2239 }
2240 match out {
2241 Output::Json | Output::Ndjson => {}
2242 Output::Text => {
2243 for s in syms {
2244 let qualified = match &s.parent {
2245 Some(p) => format!("{} · {p}", s.name),
2246 None => s.name.clone(),
2247 };
2248 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2249 if let Some(sig) = &s.signature {
2250 println!(" {sig}");
2251 }
2252 }
2253 }
2254 }
2255 ExitCode::SUCCESS
2256}
2257
2258fn keyword_kind(token: &str) -> Option<&'static str> {
2263 match token.to_ascii_lowercase().as_str() {
2264 "class" => Some("class"),
2265 "module" => Some("module"),
2266 "method" => Some("method"),
2267 "function" | "fn" => Some("function"),
2268 "struct" | "type" => Some("struct"),
2269 "enum" => Some("enum"),
2270 "trait" | "interface" => Some("trait"),
2271 "constant" | "const" => Some("constant"),
2272 _ => None,
2273 }
2274}
2275
2276fn split_kind_keyword(
2282 target: String,
2283 dirs: Vec<String>,
2284) -> (Option<&'static str>, String, Vec<String>) {
2285 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2288 let rest = rest.trim();
2289 if let Some(k) = keyword_kind(head)
2290 && !rest.is_empty()
2291 {
2292 return (Some(k), rest.to_string(), dirs);
2293 }
2294 } else if let Some(k) = keyword_kind(&target)
2295 && let Some((query, extra)) = dirs.split_first()
2296 {
2297 return (Some(k), query.clone(), extra.to_vec());
2299 }
2300 (None, target, dirs)
2301}
2302
2303fn canonical_kind(s: &str) -> Option<&'static str> {
2306 Some(match s.to_ascii_lowercase().as_str() {
2307 "c" | "class" => "class",
2308 "m" | "method" => "method",
2309 "f" | "fn" | "func" | "function" => "function",
2310 "mod" | "module" => "module",
2311 "s" | "struct" | "type" => "struct",
2312 "e" | "enum" => "enum",
2313 "t" | "trait" | "interface" => "trait",
2314 "const" | "constant" => "constant",
2315 _ => return None,
2316 })
2317}
2318
2319fn canonical_langs(s: &str) -> Vec<String> {
2326 let t = s.to_ascii_lowercase();
2327 let alias = match t.as_str() {
2328 "rb" => Some("ruby"),
2329 "rs" => Some("rust"),
2330 "golang" => Some("go"),
2331 "ts" | "tsx" => Some("typescript"),
2332 "js" | "jsx" => Some("javascript"),
2333 _ => None,
2334 };
2335 let matched: Vec<String> = crate::lang::languages()
2336 .into_iter()
2337 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2338 .map(str::to_string)
2339 .collect();
2340 matched
2341}
2342
2343fn match_color() -> Option<String> {
2347 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2348 return None;
2349 }
2350 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2351 gc.split(':').find_map(|e| {
2352 e.strip_prefix("mt=")
2353 .or_else(|| e.strip_prefix("ms="))
2354 .filter(|v| !v.is_empty())
2355 .map(str::to_string)
2356 })
2357 });
2358 Some(style.unwrap_or_else(|| "1;31".to_string()))
2359}
2360
2361fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2364 match color {
2365 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2366 None => text.to_string(),
2367 }
2368}
2369
2370fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2373 let Some(c) = color else {
2374 return path.to_string();
2375 };
2376 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2377 let base_start = path[..base_byte].chars().count();
2378 let stem = crate::search::path_stem(path);
2382 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2383 .into_iter()
2384 .map(|p| p + base_start)
2385 .collect();
2386 highlight(path, &positions, c)
2387}
2388
2389fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2392 if positions.is_empty() {
2393 return text.to_string();
2394 }
2395 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2396 let mut out = String::new();
2397 let mut on = false;
2398 for (i, c) in text.chars().enumerate() {
2399 match (matched.contains(&i), on) {
2400 (true, false) => {
2401 out.push_str("\x1b[");
2402 out.push_str(color);
2403 out.push('m');
2404 on = true;
2405 }
2406 (false, true) => {
2407 out.push_str("\x1b[0m");
2408 on = false;
2409 }
2410 _ => {}
2411 }
2412 out.push(c);
2413 }
2414 if on {
2415 out.push_str("\x1b[0m");
2416 }
2417 out
2418}
2419
2420fn under_any(file: &str, paths: &[String]) -> bool {
2424 paths.iter().any(|p| {
2425 let p = p.trim_start_matches("./").trim_end_matches('/');
2426 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2427 })
2428}
2429
2430fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2432 let p = std::path::Path::new(file);
2433 let abs = if p.is_absolute() {
2434 p.to_path_buf()
2435 } else {
2436 cwd.join(p)
2437 };
2438 let abs = abs.canonicalize().unwrap_or(abs);
2439 abs.strip_prefix(root)
2440 .map(|r| r.to_string_lossy().into_owned())
2441 .unwrap_or_else(|_| file.to_string())
2442}
2443
2444fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2448 use std::collections::HashSet;
2449 let mut seen = HashSet::new();
2450 let mut changed = false;
2451 for hit in hits {
2452 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2453 continue;
2454 }
2455 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2456 continue;
2457 };
2458 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2459 continue;
2460 };
2461 if let Ok(crate::index::Refresh::Updated) =
2462 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2463 {
2464 changed = true;
2465 }
2466 }
2467 changed
2468}
2469
2470fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2476 if let Ok(canon) = cwd.canonicalize() {
2477 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2478 return identity;
2479 }
2480 if crate::index::repo_root(cwd).is_none() {
2481 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2482 }
2483 }
2484 crate::index::detect_identity(cwd).to_string()
2485}
2486
2487fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2488 let explicit = path.is_some();
2489 let target = path.unwrap_or_else(|| PathBuf::from("."));
2490 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2495 let mut subdirs = subdirs.to_vec();
2500 if explicit
2501 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2502 && t != r
2503 && let Ok(rel) = t.strip_prefix(&r)
2504 && !rel.as_os_str().is_empty()
2505 {
2506 subdirs.push(rel.to_string_lossy().into_owned());
2507 }
2508 let mut store = match open_store() {
2509 Ok(s) => s,
2510 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2511 };
2512 let identity = crate::index::detect_identity(&root).to_string();
2513 let started = std::time::Instant::now();
2514 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2515 if crate::profile::enabled() {
2519 let total = started.elapsed();
2520 if out == Output::Text {
2521 for line in crate::profile::report(total) {
2522 eprintln!("{line}");
2523 }
2524 } else {
2525 eprintln!("{}", crate::profile::json(total));
2526 }
2527 }
2528 match indexed {
2529 Ok(stats) => {
2530 let subtree = !subdirs.is_empty();
2531 let totals = store
2533 .repository_id(&identity)
2534 .ok()
2535 .flatten()
2536 .and_then(|id| store.repo_totals(id).ok());
2537 match out {
2538 Output::Json | Output::Ndjson => {
2539 let (files, symbols) = match totals {
2540 Some((f, s)) => (Some(f), Some(s)),
2541 None => (None, None),
2542 };
2543 return emit_json(
2544 out,
2545 &serde_json::json!({
2546 "repo": identity,
2547 "scope": if subtree { "subtree" } else { "full" },
2548 "files_added": stats.files_indexed,
2549 "symbols_added": stats.symbols,
2550 "files": files,
2551 "symbols": symbols,
2552 }),
2553 );
2554 }
2555 Output::Text => {
2556 let scope = if subtree { " (subtree seed)" } else { "" };
2557 match totals {
2558 Some((files, symbols)) => println!(
2559 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2560 stats.files_indexed, stats.symbols
2561 ),
2562 None => println!(
2563 "{} file(s)/{} symbol(s) added this run{scope}",
2564 stats.files_indexed, stats.symbols
2565 ),
2566 }
2567 }
2568 }
2569 ExitCode::SUCCESS
2570 }
2571 Err(e) => fail(format_args!("rq --index: {e}")),
2572 }
2573}
2574
2575fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2576 let mut store = match open_store() {
2577 Ok(s) => s,
2578 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2579 };
2580
2581 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2585 let root = crate::index::repo_root(&path).unwrap_or(path);
2586 let from_path = crate::index::detect_identity(&root).to_string();
2587 let resolved = match store.repository_id(&from_path) {
2588 Ok(Some(id)) => Some((from_path.clone(), id)),
2589 Ok(None) => target.as_deref().and_then(|s| {
2590 store
2591 .repository_id(s)
2592 .ok()
2593 .flatten()
2594 .map(|id| (s.to_string(), id))
2595 }),
2596 Err(e) => return fail(format_args!("rq --drop: {e}")),
2597 };
2598
2599 let Some((identity, repo_id)) = resolved else {
2600 return match out {
2602 Output::Text => {
2603 println!("not indexed: {from_path}");
2604 ExitCode::SUCCESS
2605 }
2606 _ => emit_json(
2607 out,
2608 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2609 ),
2610 };
2611 };
2612
2613 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2614 match store.drop_repository(repo_id) {
2615 Ok(()) => match out {
2616 Output::Text => {
2617 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2618 ExitCode::SUCCESS
2619 }
2620 _ => emit_json(
2621 out,
2622 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2623 ),
2624 },
2625 Err(e) => fail(format_args!("rq --drop: {e}")),
2626 }
2627}
2628
2629fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2633 let rendered = if out == Output::Json {
2634 serde_json::to_string_pretty(value)
2635 } else {
2636 serde_json::to_string(value)
2637 };
2638 match rendered {
2639 Ok(s) => {
2640 println!("{s}");
2641 ExitCode::SUCCESS
2642 }
2643 Err(e) => fail(format_args!("rq: {e}")),
2644 }
2645}
2646
2647fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2651 match out {
2652 Output::Json => match serde_json::to_string_pretty(rows) {
2653 Ok(s) => println!("{s}"),
2654 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2655 },
2656 Output::Ndjson => {
2657 for r in rows {
2658 match serde_json::to_string(r) {
2659 Ok(line) => println!("{line}"),
2660 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2661 }
2662 }
2663 }
2664 Output::Text => {}
2665 }
2666 None
2667}
2668
2669fn cmd_status(out: Output) -> ExitCode {
2670 let store = match open_store() {
2671 Ok(s) => s,
2672 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2673 };
2674 let rows = match store.coverage_overview() {
2675 Ok(rows) => rows,
2676 Err(e) => return fail(format_args!("rq --status: {e}")),
2677 };
2678 if let Some(code) = emit_rows(out, &rows) {
2679 return code;
2680 }
2681 match out {
2682 Output::Json | Output::Ndjson => {}
2683 Output::Text if rows.is_empty() => {
2684 println!("no repositories indexed yet (try `rq --index`)");
2685 }
2686 Output::Text => {
2687 for r in &rows {
2688 println!(
2689 "{:<10} {:>6} files {:>7} symbols {}",
2690 r.status, r.files, r.symbols, r.identity
2691 );
2692 }
2693 }
2694 }
2695 ExitCode::SUCCESS
2696}
2697
2698fn cmd_usage(out: Output) -> ExitCode {
2701 let store = match open_store() {
2702 Ok(s) => s,
2703 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2704 };
2705 let rows = match store.usage_overview() {
2706 Ok(rows) => rows,
2707 Err(e) => return fail(format_args!("rq --usage: {e}")),
2708 };
2709 if let Some(code) = emit_rows(out, &rows) {
2710 return code;
2711 }
2712 match out {
2713 Output::Json | Output::Ndjson => {}
2714 Output::Text if rows.is_empty() => {
2715 println!("no usage recorded yet");
2716 }
2717 Output::Text => {
2718 println!(
2721 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2722 "day", "caller", "found", "missed", "warming"
2723 );
2724 for r in &rows {
2725 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2726 println!(
2727 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2728 r.day,
2729 r.source,
2730 r.searches - r.misses - r.warming,
2731 r.misses,
2732 r.warming,
2733 flags
2734 );
2735 }
2736 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2737 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2738 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2739 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2740 let plural = if searches == 1 { "search" } else { "searches" };
2741 println!(
2744 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2745 );
2746 }
2747 }
2748 if rows.is_empty() {
2750 return ExitCode::from(1);
2751 }
2752 ExitCode::SUCCESS
2753}
2754
2755fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2757 let path = db_path()?;
2758 if let Some(parent) = path.parent() {
2759 std::fs::create_dir_all(parent)?;
2760 }
2761 Ok(Store::open(&path)?)
2762}
2763
2764fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2766 if let Ok(p) = std::env::var("RQ_DB") {
2767 return Ok(PathBuf::from(p));
2768 }
2769 let home = std::env::var("HOME")?;
2770 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2771}
2772
2773fn fail(args: std::fmt::Arguments) -> ExitCode {
2774 eprintln!("{args}");
2775 ExitCode::FAILURE
2776}
2777
2778#[cfg(test)]
2779mod tests {
2780 use super::*;
2781
2782 #[test]
2783 fn open_menu_choice_parsing() {
2784 assert_eq!(parse_choice("\n", 5), Some(0));
2786 assert_eq!(parse_choice(" ", 5), Some(0));
2787 assert_eq!(parse_choice("3", 5), Some(2));
2788 assert_eq!(parse_choice("5", 5), Some(4));
2789 assert_eq!(parse_choice("6", 5), None);
2791 assert_eq!(parse_choice("0", 5), None);
2792 assert_eq!(parse_choice("q", 5), None);
2793 }
2794
2795 #[test]
2796 fn wait_duration_parsing() {
2797 use std::time::Duration;
2798 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2800 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2801 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2802 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2803 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2805 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2806 assert!(parse_wait("0s").unwrap().is_zero());
2807 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2809 assert!(parse_wait("2x").is_err());
2811 assert!(parse_wait("").is_err());
2812 assert!(parse_wait("s").is_err());
2813 assert!(parse_wait("-1s").is_err());
2814 }
2815
2816 #[test]
2817 fn leading_kind_keyword_becomes_a_kind_filter() {
2818 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2819 assert_eq!(
2821 split_kind_keyword("class".into(), d(&["Widget"])),
2822 (Some("class"), "Widget".into(), vec![])
2823 );
2824 assert_eq!(
2826 split_kind_keyword("method zoom".into(), vec![]),
2827 (Some("method"), "zoom".into(), vec![])
2828 );
2829 assert_eq!(
2831 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2832 (Some("function"), "Foo::run".into(), vec![])
2833 );
2834 assert_eq!(
2836 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2837 (Some("struct"), "Gadget".into(), d(&["src"]))
2838 );
2839 }
2840
2841 #[test]
2842 fn a_bare_or_non_keyword_query_is_left_alone() {
2843 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2844 assert_eq!(
2846 split_kind_keyword("class".into(), vec![]),
2847 (None, "class".into(), vec![])
2848 );
2849 assert_eq!(
2851 split_kind_keyword("Widget".into(), d(&["app"])),
2852 (None, "Widget".into(), d(&["app"]))
2853 );
2854 assert_eq!(
2856 split_kind_keyword("c".into(), d(&["Foo"])),
2857 (None, "c".into(), d(&["Foo"]))
2858 );
2859 }
2860
2861 #[test]
2862 fn the_branch_window_scales_with_what_the_refresh_costs() {
2863 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
2866 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
2867 assert_eq!(branch_files_ttl(Some(700)), 70);
2871 assert_eq!(branch_files_ttl(Some(2_000)), 200);
2872 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
2875 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
2876 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
2878 }
2879
2880 #[test]
2881 fn a_language_selects_by_prefix_or_alias() {
2882 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2884 assert_eq!(canonical_langs("t"), ["typescript"]);
2885 assert_eq!(canonical_langs("ts"), ["typescript"]);
2887 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2888 assert_eq!(canonical_langs("rb"), ["ruby"]);
2889 assert!(canonical_langs("COBOL").is_empty());
2892 }
2893
2894 #[test]
2895 fn a_kind_normalizes_language_specific_spellings() {
2896 assert_eq!(canonical_kind("f"), Some("function"));
2897 assert_eq!(canonical_kind("interface"), Some("trait"));
2899 assert_eq!(canonical_kind("type"), Some("struct"));
2900 assert_eq!(canonical_kind("const"), Some("constant"));
2901 assert_eq!(canonical_kind("banana"), None);
2902 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2904 assert_eq!(
2905 split_kind_keyword("interface".into(), d(&["Renderer"])),
2906 (Some("trait"), "Renderer".into(), vec![])
2907 );
2908 }
2909
2910 #[test]
2911 fn highlight_wraps_matched_runs() {
2912 assert_eq!(
2913 highlight("FooThing", &[0, 1, 2], "1;31"),
2914 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2915 );
2916 assert_eq!(
2918 highlight("FooThing", &[0, 3], "1"),
2919 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2920 );
2921 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2923 }
2924
2925 #[test]
2926 fn progress_ui_only_for_an_interactive_text_terminal() {
2927 assert!(show_progress(Output::Text, true));
2929
2930 assert!(!show_progress(Output::Json, true));
2932 assert!(!show_progress(Output::Ndjson, true));
2933
2934 assert!(!show_progress(Output::Text, false));
2936 }
2937
2938 #[test]
2939 fn repo_label_uses_the_directory_name() {
2940 assert_eq!(
2941 repo_label(Some(std::path::Path::new("/src/widgets"))),
2942 "widgets"
2943 );
2944 assert_eq!(repo_label(None), "repo");
2945 }
2946
2947 #[test]
2948 fn hl_path_highlights_the_stem_not_the_extension() {
2949 let out = hl_path(
2952 "app/employees_controller.rb",
2953 "employeescontroller",
2954 Some("1;31"),
2955 );
2956 assert!(
2957 out.starts_with("app/\u{1b}[1;31memployees"),
2958 "stem highlighted: {out:?}"
2959 );
2960 assert!(
2961 out.ends_with("controller\u{1b}[0m.rb"),
2962 "`.rb` left un-highlighted: {out:?}"
2963 );
2964 }
2965}