1use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::store::Store;
13
14#[derive(Parser)]
18#[command(
19 name = "rq",
20 version,
21 about = "Ranked definition lookup — the one place a symbol is defined, first.",
22 long_about = "rq finds where a symbol is defined and ranks the one you most \
23likely meant to the top — not every match.\n\n\
24Search is the default action; operations are flags, not subcommands, so every \
25word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
26your current repo and recently-active files, and learns from the results you open \
27(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
28 after_help = "EXAMPLES:\n \
29rq thing search for a definition named or like \"thing\"\n \
30rq wibble --explain same, plus the score behind each result\n \
31rq thing --json machine-readable results (for editors/agents)\n \
32rq thing --no-record search without recording it (speculative/agent queries)\n \
33rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
34rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
35rq thing app/web restrict to a directory (rg-style)\n \
36rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
37rq class Widget a leading kind keyword is shorthand for -k\n \
38rq --symbols FILE outline a file's definitions, in line order\n \
39rq thing -x rust restrict to a language (ruby/rust/go/python/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 'refund*proc' wildcards: * (any run), ? (one char) — quote them\n \
43rq -o thing open the best match in your editor (and record it)\n \
44rq --index index the current repository\n \
45rq --status show indexing coverage\n \
46rq --usage show how rq has been called (by caller and flags)\n \
47rq --drop remove this repo's index (opposite of --index)\n\n\
48SHORT FLAGS (easy to misread):\n \
49-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
50RECORDING (editor/shell hook):\n \
51rq --record --file <path> --line <n> <query>\n \
52Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
53to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
54The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
55automatically on the first search in a git repo. On a large, cold repo a search \
56keeps indexing until it can answer rather than reporting a premature \"no \
57matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
58= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
59)]
60struct Cli {
61 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
68 target: Option<String>,
69
70 #[arg(value_name = "PATH")]
72 dirs: Vec<String>,
73
74 #[arg(short = 'e', long)]
76 explain: bool,
77
78 #[arg(long)]
83 no_record: bool,
84
85 #[arg(long = "no-wait")]
91 no_wait: bool,
92
93 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
98 wait: Option<Duration>,
99
100 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
105 open: bool,
106
107 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
111 show: bool,
112
113 #[arg(short = 'j', long)]
115 json: bool,
116
117 #[arg(short = 'J', long, conflicts_with = "json")]
119 ndjson: bool,
120
121 #[arg(short = 'p', long, value_name = "DIR")]
123 path: Vec<String>,
124
125 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
127 limit: usize,
128
129 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
133 kind: Vec<String>,
134
135 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
139 lang: Vec<String>,
140
141 #[arg(short = 'a', long = "all-repos")]
144 all_repos: bool,
145
146 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
148 index: Option<Option<String>>,
149
150 #[arg(long, conflicts_with_all = ["index", "record"])]
152 status: bool,
153
154 #[arg(long, conflicts_with_all = ["index", "record", "status"])]
156 usage: bool,
157
158 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
161 symbols: Option<String>,
162
163 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
167 drop: bool,
168
169 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
172 record: bool,
173
174 #[arg(long)]
176 file: Option<String>,
177
178 #[arg(long)]
180 line: Option<i64>,
181
182 #[arg(long, default_value = "select")]
184 event: String,
185
186 #[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"])]
190 warm: Option<Option<String>>,
191
192 #[arg(long, value_name = "SHELL")]
194 completions: Option<Shell>,
195
196 #[arg(short = 'v', long)]
199 verbose: bool,
200
201 #[arg(long)]
205 profile: bool,
206
207 #[arg(long, value_name = "N", default_value_t = 0)]
210 jobs: usize,
211}
212
213pub fn run() -> ExitCode {
215 let cli = Cli::parse();
216 crate::trace::enable_from(cli.verbose);
217 crate::profile::enable_from(cli.profile);
218 crate::index::set_parse_jobs(cli.jobs);
219
220 if let Some(shell) = cli.completions {
221 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
222 return ExitCode::SUCCESS;
223 }
224 if let Some(path) = &cli.index {
225 let out = output_format(&cli);
227 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
228 }
229 if let Some(path) = &cli.warm {
230 return cmd_warm(path.as_deref());
231 }
232 if cli.status {
233 return cmd_status(output_format(&cli));
234 }
235 if cli.usage {
236 return cmd_usage(output_format(&cli));
237 }
238 if cli.drop {
239 let out = output_format(&cli);
240 return cmd_drop(cli.target, out);
241 }
242 if cli.record {
243 if !matches!(cli.event.as_str(), "select" | "open") {
245 return fail(format_args!(
246 "rq --record: unknown --event {:?} (expected select or open)",
247 cli.event
248 ));
249 }
250 let file = cli.file.expect("--record requires --file");
252 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
253 }
254 let out = output_format(&cli);
255 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
256 return fail(format_args!("rq: empty query"));
257 }
258 let mut kinds: Vec<String> = Vec::new();
262 for k in &cli.kind {
263 match canonical_kind(k) {
264 Some(c) => kinds.push(c.to_string()),
265 None => {
266 return fail(format_args!(
267 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait)"
268 ));
269 }
270 }
271 }
272 let mut langs: Vec<String> = Vec::new();
274 for x in &cli.lang {
275 let matched = canonical_langs(x);
276 if matched.is_empty() {
277 return fail(format_args!(
278 "rq: unknown --lang {x:?} ({})",
279 crate::lang::languages().join(", ")
280 ));
281 }
282 langs.extend(matched);
283 }
284 if let Some(file) = &cli.symbols {
285 return cmd_symbols(file, &kinds, &langs, out);
286 }
287 let mut paths = cli.path.clone();
289 match cli.target {
290 Some(target) => {
291 let query = if cli.kind.is_empty() {
294 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
295 if let Some(k) = kw {
296 kinds.push(k.to_string());
297 }
298 paths.extend(dirs);
299 query
300 } else {
301 paths.extend(cli.dirs.clone());
302 target
303 };
304 let mut session = match Session::open() {
305 Ok(s) => s,
306 Err(code) => return code,
307 };
308 cmd_search(
309 &mut session,
310 &SearchArgs {
311 query: &query,
312 explain: cli.explain,
313 out,
314 paths: &paths,
315 kinds: &kinds,
316 langs: &langs,
317 want: requested_limit(cli.limit),
318 no_record: cli.no_record,
319 no_wait: cli.no_wait,
320 wait: cli.wait,
321 open: cli.open,
322 all_repos: cli.all_repos,
323 show: cli.show,
324 batch: false,
325 },
326 )
327 }
328 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
331 None => {
333 let _ = Cli::command().print_long_help();
334 ExitCode::SUCCESS
335 }
336 }
337}
338
339#[derive(Clone, Copy, PartialEq)]
341enum Output {
342 Text,
343 Json,
344 Ndjson,
345}
346
347fn output_format(cli: &Cli) -> Output {
348 if cli.ndjson {
349 Output::Ndjson
350 } else if cli.json {
351 Output::Json
352 } else {
353 Output::Text
354 }
355}
356
357const DEFAULT_LIMIT: usize = 10;
359
360const PATH_HEADROOM: usize = 200;
363
364fn requested_limit(limit: usize) -> usize {
367 if limit == 0 { usize::MAX } else { limit }
368}
369
370fn record_usage(
373 store: &mut Store,
374 args: &SearchArgs,
375 repository_id: Option<i64>,
376 results: usize,
377 status: &str,
378 coverage: Option<&str>,
379) {
380 if args.no_record {
381 return;
382 }
383 let _ = store.record_search(&crate::store::SearchRecord {
384 query: &args.query.to_ascii_lowercase(),
385 repository_id,
386 results,
387 source: &crate::origin::detect(),
388 flags: &flag_summary(args),
389 status,
390 coverage: coverage.unwrap_or("none"),
391 });
392}
393
394fn flag_summary(args: &SearchArgs) -> String {
399 let mut on: Vec<&str> = Vec::new();
400 match args.out {
401 Output::Json => on.push("json"),
402 Output::Ndjson => on.push("ndjson"),
403 Output::Text => {}
404 }
405 for (present, name) in [
406 (args.explain, "explain"),
407 (args.show, "show"),
408 (args.open, "open"),
409 (args.all_repos, "all-repos"),
410 (args.no_wait, "no-wait"),
411 (args.batch, "batch"),
412 (!args.paths.is_empty(), "path"),
413 (!args.kinds.is_empty(), "kind"),
414 (!args.langs.is_empty(), "lang"),
415 (args.want != DEFAULT_LIMIT, "limit"),
416 ] {
417 if present {
418 on.push(name);
419 }
420 }
421 on.join(",")
422}
423
424const POLL_INTERVAL: Duration = Duration::from_millis(100);
431
432const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
436
437const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
441
442struct SearchArgs<'a> {
444 query: &'a str,
445 explain: bool,
446 out: Output,
447 paths: &'a [String],
448 kinds: &'a [String],
449 langs: &'a [String],
450 want: usize,
452 no_record: bool,
453 no_wait: bool,
455 wait: Option<Duration>,
458 open: bool,
459 all_repos: bool,
460 batch: bool,
464 show: bool,
465}
466
467struct Session {
478 store: Store,
479 cwd: Option<PathBuf>,
480 cwd_is_git: bool,
481 root: Option<PathBuf>,
482 active_paths: Vec<String>,
483 branch_refresh: Option<BranchRefresh>,
484 identity: Option<String>,
485 coverage: Option<String>,
486}
487
488impl Session {
489 fn open() -> std::result::Result<Session, ExitCode> {
491 let open_span = crate::profile::span("store open");
492 let store = match open_store() {
493 Ok(s) => s,
494 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
495 };
496 drop(open_span);
497 let git_span = crate::profile::span("setup: git root");
498 let cwd = std::env::current_dir().ok();
499 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
500
501 let root = cwd
507 .as_deref()
508 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
509 drop(git_span);
510
511 let mut branch_span = crate::profile::span("setup: branch files");
514 let (active_paths, branch_refresh, cached_cost) = match &root {
515 Some(c) if cwd_is_git => cached_branch_files(&store, c),
516 _ => (Vec::new(), None, None),
517 };
518 branch_span.note(|| {
519 let how = if branch_refresh.is_some() {
520 "cached, refreshing alongside"
521 } else {
522 "cached"
523 };
524 let ttl = branch_files_ttl(cached_cost);
527 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
528 });
529 drop(branch_span);
530
531 let mut identity_span = crate::profile::span("setup: identity");
536 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
537 let coverage = identity
538 .as_deref()
539 .and_then(|id| store.coverage_status(id).ok())
540 .flatten();
541 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
542 drop(identity_span);
543 Ok(Session {
544 store,
545 cwd,
546 cwd_is_git,
547 root,
548 active_paths,
549 branch_refresh,
550 identity,
551 coverage,
552 })
553 }
554}
555
556fn cmd_batch(
571 cli: &Cli,
572 out: Output,
573 paths: &[String],
574 kinds: &[String],
575 langs: &[String],
576) -> ExitCode {
577 if out == Output::Json {
578 return fail(format_args!(
579 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
580 where each line carries the query it answers"
581 ));
582 }
583 if cli.open || cli.show {
584 return fail(format_args!(
585 "rq: --open and --show act on a single result, not a stream of queries"
586 ));
587 }
588
589 use std::io::BufRead;
590 let queries: Vec<String> = std::io::stdin()
591 .lock()
592 .lines()
593 .map_while(std::result::Result::ok)
594 .map(|l| l.trim().to_string())
595 .filter(|l| !l.is_empty())
596 .collect();
597 if queries.is_empty() {
601 let _ = Cli::command().print_long_help();
602 return ExitCode::SUCCESS;
603 }
604
605 let mut session = match Session::open() {
606 Ok(s) => s,
607 Err(code) => return code,
608 };
609
610 if !cli.no_wait
613 && session.coverage.as_deref() != Some("complete")
614 && let Some(root) = session.root.clone()
615 {
616 {
617 let budget = cli.wait.unwrap_or_else(wait_budget);
618 crate::trace!(
619 "batch: warming {} queries' worth of index first",
620 queries.len()
621 );
622 let active = session.active_paths.clone();
623 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
624 session.coverage = session
625 .identity
626 .as_deref()
627 .and_then(|id| session.store.coverage_status(id).ok())
628 .flatten();
629 }
630 }
631
632 let mut worst = ExitCode::SUCCESS;
633 let mut any_hit = false;
634 for query in &queries {
635 let code = cmd_search(
636 &mut session,
637 &SearchArgs {
638 query,
639 explain: cli.explain,
640 out,
641 paths,
642 kinds,
643 langs,
644 want: requested_limit(cli.limit),
645 no_record: cli.no_record,
646 no_wait: true,
650 wait: cli.wait,
651 open: false,
652 all_repos: cli.all_repos,
653 show: false,
654 batch: true,
655 },
656 );
657 if code == ExitCode::SUCCESS {
658 any_hit = true;
659 } else {
660 worst = code;
661 }
662 }
663 if any_hit { ExitCode::SUCCESS } else { worst }
666}
667
668fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
669 let &SearchArgs {
670 query,
671 out,
672 want,
673 no_record,
674 no_wait,
675 wait,
676 open,
677 all_repos,
678 show,
679 ..
680 } = args;
681 let wait_budget = wait.unwrap_or_else(wait_budget);
684 let no_wait = no_wait || wait_budget.is_zero();
685 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
688 want
689 } else {
690 want.saturating_mul(20).max(PATH_HEADROOM)
691 };
692 let _timer = crate::trace::Timer::start("search done");
693 let profile_started = std::time::Instant::now();
694 let t_setup = std::time::Instant::now();
695 let setup_span = crate::profile::span("setup");
697 let Session {
700 store,
701 cwd,
702 cwd_is_git,
703 root,
704 active_paths,
705 branch_refresh,
706 identity,
707 coverage,
708 } = session;
709 let cwd_is_git = *cwd_is_git;
710
711 let known = coverage.is_some();
719 let warming_ok = cwd_is_git || known;
720 if crate::trace::enabled() {
721 crate::trace!(
722 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
723 root.as_deref().map_or("?".into(), crate::trace::abbrev),
724 identity.as_deref().unwrap_or("none"),
725 coverage.as_deref().unwrap_or("none"),
726 active_paths.len(),
727 );
728 }
729 let repo_span = crate::profile::span("setup: repo state");
730 let current = identity
731 .as_deref()
732 .and_then(|id| store.repository_id(id).ok().flatten());
733 let only_repo = if all_repos { None } else { current };
736 let active = crate::search::ActiveFiles::new(active_paths.clone());
737
738 drop(repo_span);
739 let warm_span = crate::profile::span("setup: warm decision");
740
741 let warm_budget = if warm_detach_enabled() {
748 answer_warm_budget()
749 } else {
750 answer_warm_budget() + deferred_warm_budget()
751 };
752 let was_warming = coverage.as_deref() != Some("complete");
753
754 let indexed_head = (!was_warming)
765 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
766 .flatten();
767 let staleness = (!was_warming && warming_ok && !args.batch)
770 .then(|| root.clone())
771 .flatten()
772 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
773 let want_warm = warming_ok && was_warming && root.is_some();
776
777 let block = want_warm && was_warming && !no_wait;
791 let progress_ui = block && show_progress(out, stderr_interactive());
796 let indexer_budget = if block { wait_budget } else { warm_budget };
797 if progress_ui {
798 install_interrupt_handler();
799 }
800
801 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
804 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
805 crate::trace!(
806 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
807 crate::index::parse_jobs()
808 );
809 let root = root.clone().expect("checked");
810 let active = active_paths.clone();
811 let q = query.to_string();
812 let warm_done = std::sync::Arc::clone(&warm_done);
813 std::thread::spawn(move || {
814 if let Ok(mut idx) = open_store() {
815 let _ = if block {
817 crate::index::index_budgeted_cancellable(
820 &mut idx,
821 &root,
822 &active,
823 indexer_budget,
824 Some(&q),
825 &INTERRUPTED,
826 )
827 } else {
828 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
829 };
830 }
831 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
832 })
833 });
834
835 crate::trace!(
842 "setup (open + repo detect + warm decision): {} ms",
843 t_setup.elapsed().as_millis()
844 );
845 let poll_start = std::time::Instant::now();
846 let deadline = if progress_ui {
850 None
851 } else if block {
852 Some(poll_start + wait_budget)
853 } else {
854 Some(poll_start + answer_warm_budget())
855 };
856 drop(warm_span);
857 let polling = indexer.is_some() && was_warming;
858 drop(setup_span);
862 let mut query_span = crate::profile::span("query");
863 let label = repo_label(root.as_deref());
864 let mut drew_progress = false;
865 let mut last_draw = poll_start;
866 let rank_limit = limit.max(2);
870 let mut total;
871 let mut hits = loop {
872 match crate::search::search(store, query, current, only_repo, &active, rank_limit) {
873 Ok(m) => {
874 total = m.total;
875 let h = m.hits;
876 let confident = h.first().is_some_and(|hit| {
877 hit.features
878 .iter()
879 .any(|f| matches!(f.name, "exact" | "prefix"))
880 });
881 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
882 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
883 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
884 if !polling || confident || warm_finished || stopped || timed_out {
885 break h;
886 }
887 if progress_ui
888 && poll_start.elapsed() >= HEADS_UP_DELAY
889 && last_draw.elapsed() >= PROGRESS_REDRAW
890 {
891 draw_progress(store, identity.as_deref(), &label);
892 drew_progress = true;
893 last_draw = std::time::Instant::now();
894 }
895 }
896 Err(e) => {
897 if let Some(h) = indexer {
898 let _ = h.join();
899 }
900 return fail(format_args!("rq: {e}"));
901 }
902 }
903 std::thread::sleep(POLL_INTERVAL);
904 };
905 query_span.note(|| {
906 if polling {
907 "polled a warming index".to_string()
908 } else {
909 String::new()
910 }
911 });
912 drop(query_span);
913 if drew_progress {
914 clear_progress();
915 }
916 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
918
919 if !hits.is_empty()
921 && revalidate_top(store, &hits)
922 && let Ok(m) = crate::search::search(store, query, current, only_repo, &active, rank_limit)
923 {
924 total = m.total;
925 hits = m.hits;
926 }
927
928 if !hits.iter().any(strong)
932 && indexer.is_none()
933 && coverage.is_none()
934 && let Some(root) = &root
935 {
936 let tail = live_fallback(root, query, rank_limit);
937 hits = crate::search::merge(hits, tail, rank_limit);
938 total = total.max(hits.len());
939 }
940
941 apply_gates(query, &mut hits);
942 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
943 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
946 total = hits.len();
947 }
948
949 if hits.is_empty() {
950 if block {
952 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
953 }
954 if let Some(h) = indexer {
955 let _ = h.join();
956 }
957 let mut incomplete = (block || no_wait)
964 && identity
965 .as_deref()
966 .and_then(|id| store.coverage_status(id).ok().flatten())
967 .as_deref()
968 != Some("complete");
969 incomplete |= settle_warm(
979 store,
980 staleness,
981 was_warming,
982 warming_ok,
983 root.as_deref(),
984 active_paths,
985 query,
986 warm_budget,
987 no_wait,
988 identity.as_deref(),
989 );
990 record_usage(
994 store,
995 args,
996 current,
997 0,
998 if incomplete { "warming" } else { "miss" },
999 coverage.as_deref(),
1000 );
1001 let elsewhere = crate::search::scope_miss_owner(store, query, current, only_repo, &active);
1006 return no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1007 }
1008
1009 record_usage(store, args, current, hits.len(), "hit", coverage.as_deref());
1012
1013 attach_confidence(&mut hits);
1017 hits.truncate(want);
1018 let total = total.max(hits.len());
1019 for hit in &mut hits {
1020 hit.total = total;
1021 if args.explain {
1022 hit.explain = Some(
1023 hit.features
1024 .iter()
1025 .map(|f| (f.name.to_string(), f.value))
1026 .collect(),
1027 );
1028 }
1029 }
1030
1031 for hit in &mut hits {
1034 hit.signature = read_signature(
1035 store,
1036 &hit.repo_identity,
1037 &hit.file,
1038 hit.line,
1039 cwd.as_deref(),
1040 );
1041 }
1042
1043 if show
1046 && let Some(code) = show_top_definition(
1047 store,
1048 &mut hits,
1049 query,
1050 out,
1051 cwd.as_deref(),
1052 current,
1053 no_record,
1054 )
1055 {
1056 return code;
1057 }
1058
1059 if open {
1063 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
1064 }
1065
1066 if let Some(code) = render_hits(args, &hits) {
1067 return code;
1068 }
1069
1070 if crate::profile::enabled() {
1073 let total = profile_started.elapsed();
1074 if args.out == Output::Text {
1075 for line in crate::profile::report(total) {
1076 eprintln!("{line}");
1077 }
1078 } else {
1079 eprintln!("{}", crate::profile::json(total));
1082 }
1083 }
1084
1085 if let Some(refresh) = branch_refresh.take() {
1092 refresh.store(store);
1093 }
1094
1095 deferred_maintenance(store);
1100
1101 if block {
1106 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1107 }
1108 if let Some(h) = indexer {
1109 let _ = h.join();
1110 }
1111 let _ = settle_warm(
1112 store,
1113 staleness,
1114 was_warming,
1115 warming_ok,
1116 root.as_deref(),
1117 active_paths,
1118 query,
1119 warm_budget,
1120 no_wait,
1121 identity.as_deref(),
1122 );
1123
1124 ExitCode::SUCCESS
1125}
1126
1127fn maybe_detach_warm(
1130 store: &Store,
1131 want_warm: bool,
1132 changed: bool,
1133 root: Option<&std::path::Path>,
1134 identity: Option<&str>,
1135) {
1136 if !warm_detach_enabled() || !want_warm {
1137 return;
1138 }
1139 let (Some(root), Some(id)) = (root, identity) else {
1140 return;
1141 };
1142 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1146 return; }
1148 spawn_detached_warm(root);
1149}
1150
1151fn spawn_detached_warm(root: &std::path::Path) {
1155 use std::os::unix::process::CommandExt;
1156 let Ok(exe) = std::env::current_exe() else {
1157 return;
1158 };
1159 let mut cmd = std::process::Command::new(exe);
1160 cmd.arg("--warm")
1161 .arg(root)
1162 .stdin(std::process::Stdio::null())
1163 .stdout(std::process::Stdio::null())
1164 .stderr(std::process::Stdio::null())
1165 .process_group(0);
1166 match cmd.spawn() {
1167 Ok(child) => crate::trace!(
1168 "background warm (detached): pid {} for {}",
1169 child.id(),
1170 crate::trace::abbrev(root)
1171 ),
1172 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1173 }
1174}
1175
1176const WARM_LOCK_TTL_SECS: i64 = 600;
1179
1180fn cmd_warm(path: Option<&str>) -> ExitCode {
1185 #[cfg(target_os = "macos")]
1188 unsafe extern "C" {
1189 fn setiopolicy_np(
1192 iotype: libc::c_int,
1193 scope: libc::c_int,
1194 policy: libc::c_int,
1195 ) -> libc::c_int;
1196 }
1197 unsafe {
1198 libc::nice(10);
1199 #[cfg(target_os = "macos")]
1200 setiopolicy_np(0, 0, 3);
1201 }
1202 let mut store = match open_store() {
1203 Ok(s) => s,
1204 Err(_) => return ExitCode::FAILURE,
1205 };
1206 let start = path
1207 .map(PathBuf::from)
1208 .or_else(|| std::env::current_dir().ok())
1209 .unwrap_or_else(|| PathBuf::from("."));
1210 let root = crate::index::repo_root(&start).unwrap_or(start);
1211 let identity = resolve_identity(&store, &root);
1212
1213 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1216 && pid != std::process::id()
1217 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1218 && now_secs() - ts < WARM_LOCK_TTL_SECS
1219 {
1220 return ExitCode::SUCCESS;
1221 }
1222 let _ = store.set_warm_lock(&identity, std::process::id());
1223
1224 let deadline = std::time::Instant::now() + warm_bg_budget();
1227 let active = crate::index::branch_changed_files(&root);
1228 loop {
1229 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1230 if remaining.is_zero() {
1231 break;
1232 }
1233 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1234 {
1235 Ok(s) => s,
1236 Err(_) => break,
1237 };
1238 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1239 || stats.files_indexed == 0
1240 {
1241 break;
1242 }
1243 }
1244 let _ = store.clear_warm_lock(&identity);
1245 ExitCode::SUCCESS
1246}
1247
1248fn now_secs() -> i64 {
1249 std::time::SystemTime::now()
1250 .duration_since(std::time::UNIX_EPOCH)
1251 .map(|d| d.as_secs() as i64)
1252 .unwrap_or(0)
1253}
1254
1255fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1258 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1259 let deadline = std::time::Instant::now() + live_fallback_budget();
1260 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1261 if !h.is_empty() {
1262 return h;
1263 }
1264 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1265}
1266
1267fn strong(h: &crate::search::Hit) -> bool {
1269 h.features
1270 .iter()
1271 .any(|f| matches!(f.name, "exact" | "prefix"))
1272}
1273
1274fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1283 if hits.iter().any(strong) {
1284 hits.retain(strong);
1285 }
1286 crate::search::apply_scope_gate(query, hits);
1287}
1288
1289fn apply_post_filters(
1292 args: &SearchArgs,
1293 cwd: Option<&std::path::Path>,
1294 root: Option<&std::path::Path>,
1295 hits: &mut Vec<crate::search::Hit>,
1296) {
1297 if !args.paths.is_empty() {
1298 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1302 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1303 let norm: Vec<String> = args
1304 .paths
1305 .iter()
1306 .map(|p| repo_relative(&base, &here, p))
1307 .collect();
1308 hits.retain(|h| under_any(&h.file, &norm));
1309 }
1310 if !args.kinds.is_empty() {
1311 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1312 }
1313 if !args.langs.is_empty() {
1314 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1315 }
1316 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1319 hits.truncate(args.want.max(2));
1320 }
1321}
1322
1323fn no_match_code(
1329 out: Output,
1330 query: &str,
1331 interrupted: bool,
1332 incomplete: bool,
1333 elsewhere: Option<&str>,
1337) -> ExitCode {
1338 let status = if interrupted {
1339 "interrupted"
1340 } else if incomplete {
1341 "warming"
1342 } else if elsewhere.is_some() {
1343 "scope_not_found"
1344 } else {
1345 "no_match"
1346 };
1347 match out {
1348 Output::Json | Output::Ndjson => {
1349 let mut obj = serde_json::json!({ "status": status, "query": query });
1350 if let Some(found_in) = elsewhere {
1351 obj["found_in"] = serde_json::json!(found_in);
1352 }
1353 let _ = emit_json(out, &obj); }
1355 Output::Text if interrupted => {
1356 eprintln!("rq: indexing interrupted — run again to finish")
1357 }
1358 Output::Text if incomplete => eprintln!(
1359 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1360 ),
1361 Output::Text if elsewhere.is_some() => eprintln!(
1362 "rq: nothing matching {query:?} — that name is defined under {}",
1363 elsewhere.unwrap_or_default()
1364 ),
1365 Output::Text => eprintln!("no matches for {query:?}"),
1366 }
1367 if incomplete {
1368 ExitCode::from(2)
1369 } else {
1370 ExitCode::FAILURE
1371 }
1372}
1373
1374fn attach_confidence(hits: &mut [crate::search::Hit]) {
1378 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1379 if t.is_none_or(|t| h.score > t) {
1380 (Some(h.score), t)
1381 } else if s.is_none_or(|s| h.score > s) {
1382 (t, Some(h.score))
1383 } else {
1384 (t, s)
1385 }
1386 });
1387 for hit in hits.iter_mut() {
1388 let best_other = if Some(hit.score) == top { second } else { top };
1389 hit.confidence = crate::search::confidence(
1390 hit.score,
1391 crate::search::match_quality(&hit.features),
1392 best_other,
1393 );
1394 }
1395}
1396
1397fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1400 let render_span = crate::profile::span("render");
1404 if args.batch {
1405 #[derive(serde::Serialize)]
1408 struct Tagged<'a> {
1409 query: &'a str,
1410 #[serde(flatten)]
1411 hit: &'a crate::search::Hit,
1412 }
1413 let rows: Vec<Tagged> = hits
1414 .iter()
1415 .map(|hit| Tagged {
1416 query: args.query,
1417 hit,
1418 })
1419 .collect();
1420 if let Some(code) = emit_rows(args.out, &rows) {
1421 return Some(code);
1422 }
1423 } else if let Some(code) = emit_rows(args.out, hits) {
1424 return Some(code);
1425 }
1426 if args.out != Output::Text {
1427 return None;
1428 }
1429 drop(render_span);
1430 let color = match_color();
1431 let c = color.as_deref();
1432 let query = args.query;
1433 if args.show {
1434 let total = hits.first().map_or(hits.len(), |h| h.total);
1436 eprintln!(
1437 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1438 hits.len()
1439 );
1440 }
1441 for hit in hits {
1442 let name = hl(&hit.name, query, c);
1445 let qualified = match &hit.parent {
1446 Some(p) => format!("{name} · {p}"),
1447 None => name,
1448 };
1449 println!(
1450 "{}:{} {} {}",
1451 hl_path(&hit.file, query, c),
1452 hit.line,
1453 hit.kind,
1454 qualified
1455 );
1456 if let Some(sig) = &hit.signature {
1457 println!(" {}", hl(sig, query, c));
1458 }
1459 if args.explain {
1460 let parts: Vec<String> = hit
1461 .features
1462 .iter()
1463 .map(|f| format!("{} {:.0}", f.name, f.value))
1464 .collect();
1465 println!(
1466 " confidence {:.2} · score {:.0} = {}",
1467 hit.confidence,
1468 hit.score,
1469 parts.join(" + ")
1470 );
1471 }
1472 }
1473 None
1474}
1475
1476fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1480 use std::io::{IsTerminal, Write};
1481 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1482 return hits.first();
1483 }
1484 let mut err = std::io::stderr();
1485 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1486 for (i, h) in hits.iter().enumerate() {
1487 let _ = writeln!(
1488 err,
1489 " {}. {}:{} {} {}",
1490 i + 1,
1491 h.file,
1492 h.line,
1493 h.kind,
1494 h.name
1495 );
1496 }
1497 let _ = write!(err, "rq> ");
1498 let _ = err.flush();
1499 let mut line = String::new();
1500 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1501 return None; }
1503 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1504}
1505
1506fn parse_choice(input: &str, n: usize) -> Option<usize> {
1509 let s = input.trim();
1510 if s.is_empty() {
1511 return Some(0);
1512 }
1513 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1514 (i < n).then_some(i)
1515}
1516
1517fn finish_open(
1521 store: &mut Store,
1522 hits: &[crate::search::Hit],
1523 query: &str,
1524 current: Option<i64>,
1525 root: Option<&std::path::Path>,
1526 no_record: bool,
1527) -> ExitCode {
1528 let Some(hit) = choose_hit(hits) else {
1529 return ExitCode::SUCCESS; };
1531
1532 if !no_record {
1535 let _ = store.record_event(
1536 "select",
1537 Some(&query.to_ascii_lowercase()),
1538 current,
1539 Some(&hit.file),
1540 Some(hit.line),
1541 None,
1542 );
1543 deferred_maintenance(store);
1544 }
1545
1546 let target = match root {
1549 Some(r) => r.join(&hit.file),
1550 None => PathBuf::from(&hit.file),
1551 };
1552 launch_editor(&target, hit.line)
1553}
1554
1555fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1559 use std::os::unix::process::CommandExt;
1560 let loc = format!("{}:{}", file.display(), line);
1561 match open_command(file, line, &loc) {
1562 Some((prog, args)) => {
1563 let err = std::process::Command::new(&prog).args(&args).exec();
1565 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1566 }
1567 None => {
1568 println!("{loc}");
1569 ExitCode::SUCCESS
1570 }
1571 }
1572}
1573
1574fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1578 let fstr = file.to_string_lossy().into_owned();
1579
1580 if let Some(t) = std::env::var_os("RQ_OPEN") {
1581 let t = t.to_string_lossy();
1582 let mut parts = t.split_whitespace().map(|p| {
1583 p.replace("{file}", &fstr)
1584 .replace("{line}", &line.to_string())
1585 .replace("{}", loc)
1586 });
1587 if let Some(prog) = parts.next() {
1588 return Some((prog, parts.collect()));
1589 }
1590 }
1591
1592 if on_path("code") {
1593 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1594 }
1595
1596 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1597 let ed = ed.to_string_lossy().into_owned();
1598 let l = ed.to_ascii_lowercase();
1599 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1601 .iter()
1602 .any(|e| l.contains(e))
1603 {
1604 return Some((ed, vec![format!("+{line}"), fstr]));
1605 }
1606 return Some((ed, vec![fstr]));
1607 }
1608
1609 None
1610}
1611
1612fn on_path(prog: &str) -> bool {
1614 std::env::var_os("PATH")
1615 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1616}
1617
1618fn unix_now() -> i64 {
1628 std::time::SystemTime::now()
1629 .duration_since(std::time::UNIX_EPOCH)
1630 .map(|d| d.as_secs() as i64)
1631 .unwrap_or(0)
1632}
1633
1634const BRANCH_FILES_TTL_SECS: i64 = 15;
1640
1641const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1645
1646const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1658
1659fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1661 let earned = cost_ms.map_or(0, |ms| {
1664 i64::try_from(ms)
1665 .unwrap_or(i64::MAX)
1666 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1667 / 1000
1668 });
1669 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1670}
1671
1672struct BranchRefresh {
1676 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1679 identity: String,
1680 stamp: String,
1681}
1682
1683impl BranchRefresh {
1684 fn store(self, store: &Store) {
1686 let Ok((files, cost_ms)) = self.handle.join() else {
1687 return;
1688 };
1689 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), cost_ms, &files);
1690 }
1691}
1692
1693fn cached_branch_files(
1707 store: &Store,
1708 root: &std::path::Path,
1709) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
1710 let identity = resolve_identity(store, root);
1711 let stamp = crate::index::branch_files_stamp(root);
1712 let cached = store.branch_files_get(&identity).ok().flatten();
1713 let now = unix_now();
1714
1715 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
1716 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
1717 {
1718 return (hit.files.clone(), None, hit.cost_ms);
1719 }
1720 let owned_root = root.to_path_buf();
1721 let refresh = BranchRefresh {
1722 handle: std::thread::spawn(move || {
1723 let t = std::time::Instant::now();
1724 let files = crate::index::branch_changed_files(&owned_root);
1725 (files, t.elapsed().as_millis() as u64)
1726 }),
1727 identity,
1728 stamp: stamp.clone(),
1729 };
1730 return (hit.files.clone(), Some(refresh), hit.cost_ms);
1731 }
1732
1733 let t = std::time::Instant::now();
1735 let files = crate::index::branch_changed_files(root);
1736 let cost_ms = t.elapsed().as_millis() as u64;
1737 if let Some(stamp) = stamp {
1738 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
1739 }
1740 (files, None, Some(cost_ms))
1741}
1742
1743fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1752 let Some(head) = indexed_head else {
1753 return true;
1754 };
1755 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1756}
1757
1758#[allow(clippy::too_many_arguments)]
1766fn settle_warm(
1767 store: &Store,
1768 staleness: Option<std::thread::JoinHandle<bool>>,
1769 was_warming: bool,
1770 warming_ok: bool,
1771 root: Option<&std::path::Path>,
1772 active: &[String],
1773 query: &str,
1774 budget: Duration,
1775 no_wait: bool,
1776 identity: Option<&str>,
1777) -> bool {
1778 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1781 if changed
1791 && !no_wait
1792 && !warm_detach_enabled()
1793 && let Some(r) = root
1794 && let Ok(mut idx) = open_store()
1795 {
1796 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1797 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1798 }
1799 maybe_detach_warm(
1800 store,
1801 warming_ok && (was_warming || changed),
1802 changed,
1803 root,
1804 identity,
1805 );
1806 changed && warm_detach_enabled()
1810}
1811
1812fn answer_warm_budget() -> Duration {
1821 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1822}
1823
1824fn deferred_warm_budget() -> Duration {
1827 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1828}
1829
1830fn live_fallback_budget() -> Duration {
1833 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1834}
1835
1836fn warm_bg_budget() -> Duration {
1839 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1840}
1841
1842fn warm_detach_enabled() -> bool {
1846 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1847}
1848
1849fn wait_budget() -> Duration {
1858 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1859}
1860
1861fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1866 let s = s.trim();
1867 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1868 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1870 (n, 1.0)
1871 } else if let Some(n) = s.strip_suffix('s') {
1872 (n, 1_000.0)
1873 } else if let Some(n) = s.strip_suffix('m') {
1874 (n, 60_000.0)
1875 } else {
1876 (s, 1_000.0)
1878 };
1879 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1880 if !val.is_finite() || val < 0.0 {
1881 return Err(bad());
1882 }
1883 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1884}
1885
1886static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1890
1891extern "C" fn on_sigint(_: libc::c_int) {
1892 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1894}
1895
1896fn install_interrupt_handler() {
1899 static ONCE: std::sync::Once = std::sync::Once::new();
1900 ONCE.call_once(|| unsafe {
1901 let mut action: libc::sigaction = std::mem::zeroed();
1902 action.sa_sigaction = on_sigint as *const () as usize;
1903 libc::sigemptyset(&mut action.sa_mask);
1904 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1905 });
1906}
1907
1908fn stderr_interactive() -> bool {
1912 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1913}
1914
1915fn show_progress(out: Output, interactive: bool) -> bool {
1920 interactive && matches!(out, Output::Text)
1921}
1922
1923fn repo_label(root: Option<&std::path::Path>) -> String {
1926 root.and_then(|r| r.file_name())
1927 .map(|n| n.to_string_lossy().into_owned())
1928 .unwrap_or_else(|| "repo".into())
1929}
1930
1931fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1935 let files = identity
1936 .and_then(|id| store.repository_id(id).ok().flatten())
1937 .and_then(|rid| store.repo_totals(rid).ok())
1938 .map_or(0, |(f, _)| f);
1939 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1940 let _ = std::io::stderr().flush();
1941}
1942
1943fn clear_progress() {
1945 eprint!("\r\x1b[K");
1946 let _ = std::io::stderr().flush();
1947}
1948
1949fn env_budget(var: &str, default_ms: u64) -> Duration {
1953 let ms = std::env::var(var)
1954 .ok()
1955 .and_then(|v| v.parse().ok())
1956 .unwrap_or(default_ms);
1957 Duration::from_millis(ms)
1958}
1959
1960const AGGREGATE_BATCH: usize = 256;
1963
1964const KEEP_RECENT_EVENTS: i64 = 200;
1967
1968fn deferred_maintenance(store: &mut Store) {
1971 let _ = store.aggregate_events(AGGREGATE_BATCH);
1972 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1973}
1974
1975fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1978 let mut store = match open_store() {
1979 Ok(s) => s,
1980 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1981 };
1982 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1983 let identity = crate::index::detect_identity(&cwd).to_string();
1984 let repo_id = store.repository_id(&identity).ok().flatten();
1985
1986 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1989 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1990 None => file.to_string(),
1991 };
1992 let query_norm = query.map(|q| q.to_ascii_lowercase());
1993
1994 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1995 {
1996 return fail(format_args!("rq record: {e}"));
1997 }
1998 deferred_maintenance(&mut store);
1999 ExitCode::SUCCESS
2000}
2001
2002fn hit_file_roots(
2008 store: &Store,
2009 repo_identity: &str,
2010 cwd: Option<&std::path::Path>,
2011) -> Vec<PathBuf> {
2012 let mut roots: Vec<PathBuf> = store
2013 .repository_id(repo_identity)
2014 .ok()
2015 .flatten()
2016 .map(|id| store.checkout_roots(id).unwrap_or_default())
2017 .unwrap_or_default()
2018 .into_iter()
2019 .map(PathBuf::from)
2020 .collect();
2021 if let Some(c) = cwd {
2022 let c = c.to_path_buf();
2023 if !roots.contains(&c) {
2024 roots.push(c);
2025 }
2026 }
2027 roots
2028}
2029
2030fn read_signature(
2033 store: &Store,
2034 repo_identity: &str,
2035 file: &str,
2036 line: i64,
2037 cwd: Option<&std::path::Path>,
2038) -> Option<String> {
2039 hit_file_roots(store, repo_identity, cwd)
2040 .into_iter()
2041 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
2042}
2043
2044const SHOW_CONFIDENCE: f64 = 0.85;
2048
2049fn show_top_definition(
2058 store: &mut Store,
2059 hits: &mut [crate::search::Hit],
2060 query: &str,
2061 out: Output,
2062 cwd: Option<&std::path::Path>,
2063 current: Option<i64>,
2064 no_record: bool,
2065) -> Option<ExitCode> {
2066 let top = hits.first()?;
2067 if top.confidence < SHOW_CONFIDENCE {
2068 return None; }
2070 let end = top.end_line.unwrap_or(top.line);
2071 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
2072 hits[0].body = body;
2073 let top = &hits[0];
2074 let shown = (top.file.clone(), top.line);
2075 let code = match out {
2076 Output::Json | Output::Ndjson => {
2077 emit_json(out, top)
2079 }
2080 Output::Text => {
2081 let color = match_color();
2082 let c = color.as_deref();
2083 let name = hl(&top.name, query, c);
2084 let qualified = match &top.parent {
2085 Some(p) => format!("{name} · {p}"),
2086 None => name,
2087 };
2088 println!(
2089 "{}:{} {} {}",
2090 hl_path(&top.file, query, c),
2091 top.line,
2092 top.kind,
2093 qualified
2094 );
2095 match (&top.body, &top.signature) {
2096 (Some(body), _) => println!("{body}"),
2097 (None, Some(sig)) => println!("{sig}"),
2099 (None, None) => {}
2100 }
2101 ExitCode::SUCCESS
2102 }
2103 };
2104
2105 if !no_record {
2107 let (file, line) = shown;
2108 let _ = store.record_event(
2109 "select",
2110 Some(&query.to_ascii_lowercase()),
2111 current,
2112 Some(&file),
2113 Some(line),
2114 None,
2115 );
2116 deferred_maintenance(store);
2117 }
2118 Some(code)
2119}
2120
2121fn read_span(
2124 store: &Store,
2125 repo_identity: &str,
2126 file: &str,
2127 start: i64,
2128 end: i64,
2129 cwd: Option<&std::path::Path>,
2130) -> Option<String> {
2131 hit_file_roots(store, repo_identity, cwd)
2132 .into_iter()
2133 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2134}
2135
2136fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2139 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2140 let lines: Vec<&str> = content.lines().collect();
2141 if s >= lines.len() {
2142 return None;
2143 }
2144 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2145 Some(lines[s..e].join("\n"))
2146}
2147
2148fn signature_in(content: &str, line: i64) -> Option<String> {
2152 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2153 let l = content.lines().nth(idx)?.trim();
2154 (!l.is_empty()).then(|| l.to_string())
2155}
2156
2157#[derive(serde::Serialize)]
2161struct SymbolOut {
2162 name: String,
2163 kind: String,
2164 language: String,
2165 file: String,
2166 line: i64,
2167 #[serde(skip_serializing_if = "Option::is_none")]
2168 end_line: Option<i64>,
2169 #[serde(skip_serializing_if = "Option::is_none")]
2170 parent: Option<String>,
2171 #[serde(skip_serializing_if = "Option::is_none")]
2172 visibility: Option<String>,
2173 repo: String,
2174 #[serde(skip_serializing_if = "Option::is_none")]
2175 signature: Option<String>,
2176}
2177
2178fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2183 let mut store = match open_store() {
2184 Ok(s) => s,
2185 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2186 };
2187 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2188 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2189 let rel = repo_relative(&root, &cwd, file_arg);
2190
2191 let identity = resolve_identity(&store, &root);
2192 let coverage = store.coverage_status(&identity).ok().flatten();
2193 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2194 let current = store.repository_id(&identity).ok().flatten();
2195 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
2198 let needs_warm = warming_ok
2199 && (coverage.as_deref() != Some("complete")
2200 || worktree_changed(&root, indexed_head.as_deref()));
2201 if needs_warm {
2202 let budget = answer_warm_budget() + deferred_warm_budget();
2204 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2205 }
2206
2207 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2208 return emit_symbols(out, &[]); };
2210 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2211 Ok(r) => r,
2212 Err(e) => return fail(format_args!("rq: {e}")),
2213 };
2214 if !kinds.is_empty() {
2215 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2216 }
2217 if !langs.is_empty() {
2218 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2219 }
2220
2221 let content = hit_file_roots(&store, &identity, Some(&root))
2225 .iter()
2226 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2227 let syms: Vec<SymbolOut> = rows
2228 .into_iter()
2229 .map(|r| SymbolOut {
2230 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2231 name: r.name,
2232 kind: r.kind,
2233 language: r.language,
2234 file: r.file,
2235 line: r.line,
2236 end_line: r.end_line,
2237 parent: r.parent,
2238 visibility: r.visibility,
2239 repo: r.repo_identity,
2240 })
2241 .collect();
2242 emit_symbols(out, &syms)
2243}
2244
2245fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2248 if syms.is_empty() {
2249 match out {
2250 Output::Json | Output::Ndjson => {
2251 let obj = serde_json::json!({ "status": "no_match" });
2252 let _ = emit_json(out, &obj); }
2254 Output::Text => eprintln!("no symbols"),
2255 }
2256 return ExitCode::FAILURE;
2257 }
2258 if let Some(code) = emit_rows(out, syms) {
2259 return code;
2260 }
2261 match out {
2262 Output::Json | Output::Ndjson => {}
2263 Output::Text => {
2264 for s in syms {
2265 let qualified = match &s.parent {
2266 Some(p) => format!("{} · {p}", s.name),
2267 None => s.name.clone(),
2268 };
2269 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2270 if let Some(sig) = &s.signature {
2271 println!(" {sig}");
2272 }
2273 }
2274 }
2275 }
2276 ExitCode::SUCCESS
2277}
2278
2279fn keyword_kind(token: &str) -> Option<&'static str> {
2284 match token.to_ascii_lowercase().as_str() {
2285 "class" => Some("class"),
2286 "module" => Some("module"),
2287 "method" => Some("method"),
2288 "function" | "fn" => Some("function"),
2289 "struct" | "type" => Some("struct"),
2290 "enum" => Some("enum"),
2291 "trait" | "interface" => Some("trait"),
2292 _ => None,
2293 }
2294}
2295
2296fn split_kind_keyword(
2302 target: String,
2303 dirs: Vec<String>,
2304) -> (Option<&'static str>, String, Vec<String>) {
2305 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2308 let rest = rest.trim();
2309 if let Some(k) = keyword_kind(head)
2310 && !rest.is_empty()
2311 {
2312 return (Some(k), rest.to_string(), dirs);
2313 }
2314 } else if let Some(k) = keyword_kind(&target)
2315 && let Some((query, extra)) = dirs.split_first()
2316 {
2317 return (Some(k), query.clone(), extra.to_vec());
2319 }
2320 (None, target, dirs)
2321}
2322
2323fn canonical_kind(s: &str) -> Option<&'static str> {
2326 Some(match s.to_ascii_lowercase().as_str() {
2327 "c" | "class" => "class",
2328 "m" | "method" => "method",
2329 "f" | "fn" | "func" | "function" => "function",
2330 "mod" | "module" => "module",
2331 "s" | "struct" | "type" => "struct",
2332 "e" | "enum" => "enum",
2333 "t" | "trait" | "interface" => "trait",
2334 _ => return None,
2335 })
2336}
2337
2338fn canonical_langs(s: &str) -> Vec<String> {
2345 let t = s.to_ascii_lowercase();
2346 let alias = match t.as_str() {
2347 "rb" => Some("ruby"),
2348 "rs" => Some("rust"),
2349 "golang" => Some("go"),
2350 "ts" | "tsx" => Some("typescript"),
2351 "js" | "jsx" => Some("javascript"),
2352 _ => None,
2353 };
2354 let matched: Vec<String> = crate::lang::languages()
2355 .into_iter()
2356 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2357 .map(str::to_string)
2358 .collect();
2359 matched
2360}
2361
2362fn match_color() -> Option<String> {
2366 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2367 return None;
2368 }
2369 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2370 gc.split(':').find_map(|e| {
2371 e.strip_prefix("mt=")
2372 .or_else(|| e.strip_prefix("ms="))
2373 .filter(|v| !v.is_empty())
2374 .map(str::to_string)
2375 })
2376 });
2377 Some(style.unwrap_or_else(|| "1;31".to_string()))
2378}
2379
2380fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2383 match color {
2384 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2385 None => text.to_string(),
2386 }
2387}
2388
2389fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2392 let Some(c) = color else {
2393 return path.to_string();
2394 };
2395 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2396 let base_start = path[..base_byte].chars().count();
2397 let stem = crate::search::path_stem(path);
2401 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2402 .into_iter()
2403 .map(|p| p + base_start)
2404 .collect();
2405 highlight(path, &positions, c)
2406}
2407
2408fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2411 if positions.is_empty() {
2412 return text.to_string();
2413 }
2414 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2415 let mut out = String::new();
2416 let mut on = false;
2417 for (i, c) in text.chars().enumerate() {
2418 match (matched.contains(&i), on) {
2419 (true, false) => {
2420 out.push_str("\x1b[");
2421 out.push_str(color);
2422 out.push('m');
2423 on = true;
2424 }
2425 (false, true) => {
2426 out.push_str("\x1b[0m");
2427 on = false;
2428 }
2429 _ => {}
2430 }
2431 out.push(c);
2432 }
2433 if on {
2434 out.push_str("\x1b[0m");
2435 }
2436 out
2437}
2438
2439fn under_any(file: &str, paths: &[String]) -> bool {
2443 paths.iter().any(|p| {
2444 let p = p.trim_start_matches("./").trim_end_matches('/');
2445 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2446 })
2447}
2448
2449fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2451 let p = std::path::Path::new(file);
2452 let abs = if p.is_absolute() {
2453 p.to_path_buf()
2454 } else {
2455 cwd.join(p)
2456 };
2457 let abs = abs.canonicalize().unwrap_or(abs);
2458 abs.strip_prefix(root)
2459 .map(|r| r.to_string_lossy().into_owned())
2460 .unwrap_or_else(|_| file.to_string())
2461}
2462
2463fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2467 use std::collections::HashSet;
2468 let mut seen = HashSet::new();
2469 let mut changed = false;
2470 for hit in hits {
2471 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2472 continue;
2473 }
2474 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2475 continue;
2476 };
2477 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2478 continue;
2479 };
2480 if let Ok(crate::index::Refresh::Updated) =
2481 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2482 {
2483 changed = true;
2484 }
2485 }
2486 changed
2487}
2488
2489fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2495 if let Ok(canon) = cwd.canonicalize() {
2496 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2497 return identity;
2498 }
2499 if crate::index::repo_root(cwd).is_none() {
2500 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2501 }
2502 }
2503 crate::index::detect_identity(cwd).to_string()
2504}
2505
2506fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2507 let explicit = path.is_some();
2508 let target = path.unwrap_or_else(|| PathBuf::from("."));
2509 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2514 let mut subdirs = subdirs.to_vec();
2519 if explicit
2520 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2521 && t != r
2522 && let Ok(rel) = t.strip_prefix(&r)
2523 && !rel.as_os_str().is_empty()
2524 {
2525 subdirs.push(rel.to_string_lossy().into_owned());
2526 }
2527 let mut store = match open_store() {
2528 Ok(s) => s,
2529 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2530 };
2531 let identity = crate::index::detect_identity(&root).to_string();
2532 match crate::index::index_under(&mut store, &root, &subdirs) {
2533 Ok(stats) => {
2534 let subtree = !subdirs.is_empty();
2535 let totals = store
2537 .repository_id(&identity)
2538 .ok()
2539 .flatten()
2540 .and_then(|id| store.repo_totals(id).ok());
2541 match out {
2542 Output::Json | Output::Ndjson => {
2543 let (files, symbols) = match totals {
2544 Some((f, s)) => (Some(f), Some(s)),
2545 None => (None, None),
2546 };
2547 return emit_json(
2548 out,
2549 &serde_json::json!({
2550 "repo": identity,
2551 "scope": if subtree { "subtree" } else { "full" },
2552 "files_added": stats.files_indexed,
2553 "symbols_added": stats.symbols,
2554 "files": files,
2555 "symbols": symbols,
2556 }),
2557 );
2558 }
2559 Output::Text => {
2560 let scope = if subtree { " (subtree seed)" } else { "" };
2561 match totals {
2562 Some((files, symbols)) => println!(
2563 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2564 stats.files_indexed, stats.symbols
2565 ),
2566 None => println!(
2567 "{} file(s)/{} symbol(s) added this run{scope}",
2568 stats.files_indexed, stats.symbols
2569 ),
2570 }
2571 }
2572 }
2573 ExitCode::SUCCESS
2574 }
2575 Err(e) => fail(format_args!("rq --index: {e}")),
2576 }
2577}
2578
2579fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2580 let mut store = match open_store() {
2581 Ok(s) => s,
2582 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2583 };
2584
2585 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2589 let root = crate::index::repo_root(&path).unwrap_or(path);
2590 let from_path = crate::index::detect_identity(&root).to_string();
2591 let resolved = match store.repository_id(&from_path) {
2592 Ok(Some(id)) => Some((from_path.clone(), id)),
2593 Ok(None) => target.as_deref().and_then(|s| {
2594 store
2595 .repository_id(s)
2596 .ok()
2597 .flatten()
2598 .map(|id| (s.to_string(), id))
2599 }),
2600 Err(e) => return fail(format_args!("rq --drop: {e}")),
2601 };
2602
2603 let Some((identity, repo_id)) = resolved else {
2604 return match out {
2606 Output::Text => {
2607 println!("not indexed: {from_path}");
2608 ExitCode::SUCCESS
2609 }
2610 _ => emit_json(
2611 out,
2612 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2613 ),
2614 };
2615 };
2616
2617 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2618 match store.drop_repository(repo_id) {
2619 Ok(()) => match out {
2620 Output::Text => {
2621 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2622 ExitCode::SUCCESS
2623 }
2624 _ => emit_json(
2625 out,
2626 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2627 ),
2628 },
2629 Err(e) => fail(format_args!("rq --drop: {e}")),
2630 }
2631}
2632
2633fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2637 let rendered = if out == Output::Json {
2638 serde_json::to_string_pretty(value)
2639 } else {
2640 serde_json::to_string(value)
2641 };
2642 match rendered {
2643 Ok(s) => {
2644 println!("{s}");
2645 ExitCode::SUCCESS
2646 }
2647 Err(e) => fail(format_args!("rq: {e}")),
2648 }
2649}
2650
2651fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2655 match out {
2656 Output::Json => match serde_json::to_string_pretty(rows) {
2657 Ok(s) => println!("{s}"),
2658 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2659 },
2660 Output::Ndjson => {
2661 for r in rows {
2662 match serde_json::to_string(r) {
2663 Ok(line) => println!("{line}"),
2664 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2665 }
2666 }
2667 }
2668 Output::Text => {}
2669 }
2670 None
2671}
2672
2673fn cmd_status(out: Output) -> ExitCode {
2674 let store = match open_store() {
2675 Ok(s) => s,
2676 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2677 };
2678 let rows = match store.coverage_overview() {
2679 Ok(rows) => rows,
2680 Err(e) => return fail(format_args!("rq --status: {e}")),
2681 };
2682 if let Some(code) = emit_rows(out, &rows) {
2683 return code;
2684 }
2685 match out {
2686 Output::Json | Output::Ndjson => {}
2687 Output::Text if rows.is_empty() => {
2688 println!("no repositories indexed yet (try `rq --index`)");
2689 }
2690 Output::Text => {
2691 for r in &rows {
2692 println!(
2693 "{:<10} {:>6} files {:>7} symbols {}",
2694 r.status, r.files, r.symbols, r.identity
2695 );
2696 }
2697 }
2698 }
2699 ExitCode::SUCCESS
2700}
2701
2702fn cmd_usage(out: Output) -> ExitCode {
2705 let store = match open_store() {
2706 Ok(s) => s,
2707 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2708 };
2709 let rows = match store.usage_overview() {
2710 Ok(rows) => rows,
2711 Err(e) => return fail(format_args!("rq --usage: {e}")),
2712 };
2713 if let Some(code) = emit_rows(out, &rows) {
2714 return code;
2715 }
2716 match out {
2717 Output::Json | Output::Ndjson => {}
2718 Output::Text if rows.is_empty() => {
2719 println!("no usage recorded yet");
2720 }
2721 Output::Text => {
2722 println!(
2725 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2726 "day", "caller", "found", "missed", "warming"
2727 );
2728 for r in &rows {
2729 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2730 println!(
2731 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2732 r.day,
2733 r.source,
2734 r.searches - r.misses - r.warming,
2735 r.misses,
2736 r.warming,
2737 flags
2738 );
2739 }
2740 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2741 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2742 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2743 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2744 let plural = if searches == 1 { "search" } else { "searches" };
2745 println!(
2748 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2749 );
2750 }
2751 }
2752 if rows.is_empty() {
2754 return ExitCode::from(1);
2755 }
2756 ExitCode::SUCCESS
2757}
2758
2759fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2761 let path = db_path()?;
2762 if let Some(parent) = path.parent() {
2763 std::fs::create_dir_all(parent)?;
2764 }
2765 Ok(Store::open(&path)?)
2766}
2767
2768fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2770 if let Ok(p) = std::env::var("RQ_DB") {
2771 return Ok(PathBuf::from(p));
2772 }
2773 let home = std::env::var("HOME")?;
2774 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2775}
2776
2777fn fail(args: std::fmt::Arguments) -> ExitCode {
2778 eprintln!("{args}");
2779 ExitCode::FAILURE
2780}
2781
2782#[cfg(test)]
2783mod tests {
2784 use super::*;
2785
2786 #[test]
2787 fn open_menu_choice_parsing() {
2788 assert_eq!(parse_choice("\n", 5), Some(0));
2790 assert_eq!(parse_choice(" ", 5), Some(0));
2791 assert_eq!(parse_choice("3", 5), Some(2));
2792 assert_eq!(parse_choice("5", 5), Some(4));
2793 assert_eq!(parse_choice("6", 5), None);
2795 assert_eq!(parse_choice("0", 5), None);
2796 assert_eq!(parse_choice("q", 5), None);
2797 }
2798
2799 #[test]
2800 fn wait_duration_parsing() {
2801 use std::time::Duration;
2802 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2804 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2805 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2806 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2807 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2809 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2810 assert!(parse_wait("0s").unwrap().is_zero());
2811 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2813 assert!(parse_wait("2x").is_err());
2815 assert!(parse_wait("").is_err());
2816 assert!(parse_wait("s").is_err());
2817 assert!(parse_wait("-1s").is_err());
2818 }
2819
2820 #[test]
2821 fn leading_kind_keyword_becomes_a_kind_filter() {
2822 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2823 assert_eq!(
2825 split_kind_keyword("class".into(), d(&["Widget"])),
2826 (Some("class"), "Widget".into(), vec![])
2827 );
2828 assert_eq!(
2830 split_kind_keyword("method zoom".into(), vec![]),
2831 (Some("method"), "zoom".into(), vec![])
2832 );
2833 assert_eq!(
2835 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2836 (Some("function"), "Foo::run".into(), vec![])
2837 );
2838 assert_eq!(
2840 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2841 (Some("struct"), "Gadget".into(), d(&["src"]))
2842 );
2843 }
2844
2845 #[test]
2846 fn a_bare_or_non_keyword_query_is_left_alone() {
2847 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2848 assert_eq!(
2850 split_kind_keyword("class".into(), vec![]),
2851 (None, "class".into(), vec![])
2852 );
2853 assert_eq!(
2855 split_kind_keyword("Widget".into(), d(&["app"])),
2856 (None, "Widget".into(), d(&["app"]))
2857 );
2858 assert_eq!(
2860 split_kind_keyword("c".into(), d(&["Foo"])),
2861 (None, "c".into(), d(&["Foo"]))
2862 );
2863 }
2864
2865 #[test]
2866 fn the_branch_window_scales_with_what_the_refresh_costs() {
2867 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
2870 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
2871 assert_eq!(branch_files_ttl(Some(700)), 70);
2875 assert_eq!(branch_files_ttl(Some(2_000)), 200);
2876 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
2879 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
2880 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
2882 }
2883
2884 #[test]
2885 fn a_language_selects_by_prefix_or_alias() {
2886 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2888 assert_eq!(canonical_langs("t"), ["typescript"]);
2889 assert_eq!(canonical_langs("ts"), ["typescript"]);
2891 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2892 assert_eq!(canonical_langs("rb"), ["ruby"]);
2893 assert!(canonical_langs("COBOL").is_empty());
2896 }
2897
2898 #[test]
2899 fn a_kind_normalizes_language_specific_spellings() {
2900 assert_eq!(canonical_kind("f"), Some("function"));
2901 assert_eq!(canonical_kind("interface"), Some("trait"));
2903 assert_eq!(canonical_kind("type"), Some("struct"));
2904 assert_eq!(canonical_kind("banana"), None);
2905 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2907 assert_eq!(
2908 split_kind_keyword("interface".into(), d(&["Renderer"])),
2909 (Some("trait"), "Renderer".into(), vec![])
2910 );
2911 }
2912
2913 #[test]
2914 fn highlight_wraps_matched_runs() {
2915 assert_eq!(
2916 highlight("FooThing", &[0, 1, 2], "1;31"),
2917 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2918 );
2919 assert_eq!(
2921 highlight("FooThing", &[0, 3], "1"),
2922 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2923 );
2924 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2926 }
2927
2928 #[test]
2929 fn progress_ui_only_for_an_interactive_text_terminal() {
2930 assert!(show_progress(Output::Text, true));
2932
2933 assert!(!show_progress(Output::Json, true));
2935 assert!(!show_progress(Output::Ndjson, true));
2936
2937 assert!(!show_progress(Output::Text, false));
2939 }
2940
2941 #[test]
2942 fn repo_label_uses_the_directory_name() {
2943 assert_eq!(
2944 repo_label(Some(std::path::Path::new("/src/widgets"))),
2945 "widgets"
2946 );
2947 assert_eq!(repo_label(None), "repo");
2948 }
2949
2950 #[test]
2951 fn hl_path_highlights_the_stem_not_the_extension() {
2952 let out = hl_path(
2955 "app/employees_controller.rb",
2956 "employeescontroller",
2957 Some("1;31"),
2958 );
2959 assert!(
2960 out.starts_with("app/\u{1b}[1;31memployees"),
2961 "stem highlighted: {out:?}"
2962 );
2963 assert!(
2964 out.ends_with("controller\u{1b}[0m.rb"),
2965 "`.rb` left un-highlighted: {out:?}"
2966 );
2967 }
2968}