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) = match &root {
515 Some(c) if cwd_is_git => cached_branch_files(&store, c),
516 _ => (Vec::new(), None),
517 };
518 branch_span.note(|| {
519 let how = if branch_refresh.is_some() {
520 "cached, refreshing alongside"
521 } else {
522 "cached"
523 };
524 format!("{} changed, {how}", active_paths.len())
525 });
526 drop(branch_span);
527
528 let mut identity_span = crate::profile::span("setup: identity");
533 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
534 let coverage = identity
535 .as_deref()
536 .and_then(|id| store.coverage_status(id).ok())
537 .flatten();
538 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
539 drop(identity_span);
540 Ok(Session {
541 store,
542 cwd,
543 cwd_is_git,
544 root,
545 active_paths,
546 branch_refresh,
547 identity,
548 coverage,
549 })
550 }
551}
552
553fn cmd_batch(
568 cli: &Cli,
569 out: Output,
570 paths: &[String],
571 kinds: &[String],
572 langs: &[String],
573) -> ExitCode {
574 if out == Output::Json {
575 return fail(format_args!(
576 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
577 where each line carries the query it answers"
578 ));
579 }
580 if cli.open || cli.show {
581 return fail(format_args!(
582 "rq: --open and --show act on a single result, not a stream of queries"
583 ));
584 }
585
586 use std::io::BufRead;
587 let queries: Vec<String> = std::io::stdin()
588 .lock()
589 .lines()
590 .map_while(std::result::Result::ok)
591 .map(|l| l.trim().to_string())
592 .filter(|l| !l.is_empty())
593 .collect();
594 if queries.is_empty() {
598 let _ = Cli::command().print_long_help();
599 return ExitCode::SUCCESS;
600 }
601
602 let mut session = match Session::open() {
603 Ok(s) => s,
604 Err(code) => return code,
605 };
606
607 if !cli.no_wait
610 && session.coverage.as_deref() != Some("complete")
611 && let Some(root) = session.root.clone()
612 {
613 {
614 let budget = cli.wait.unwrap_or_else(wait_budget);
615 crate::trace!(
616 "batch: warming {} queries' worth of index first",
617 queries.len()
618 );
619 let active = session.active_paths.clone();
620 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
621 session.coverage = session
622 .identity
623 .as_deref()
624 .and_then(|id| session.store.coverage_status(id).ok())
625 .flatten();
626 }
627 }
628
629 let mut worst = ExitCode::SUCCESS;
630 let mut any_hit = false;
631 for query in &queries {
632 let code = cmd_search(
633 &mut session,
634 &SearchArgs {
635 query,
636 explain: cli.explain,
637 out,
638 paths,
639 kinds,
640 langs,
641 want: requested_limit(cli.limit),
642 no_record: cli.no_record,
643 no_wait: true,
647 wait: cli.wait,
648 open: false,
649 all_repos: cli.all_repos,
650 show: false,
651 batch: true,
652 },
653 );
654 if code == ExitCode::SUCCESS {
655 any_hit = true;
656 } else {
657 worst = code;
658 }
659 }
660 if any_hit { ExitCode::SUCCESS } else { worst }
663}
664
665fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
666 let &SearchArgs {
667 query,
668 out,
669 want,
670 no_record,
671 no_wait,
672 wait,
673 open,
674 all_repos,
675 show,
676 ..
677 } = args;
678 let wait_budget = wait.unwrap_or_else(wait_budget);
681 let no_wait = no_wait || wait_budget.is_zero();
682 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
685 want
686 } else {
687 want.saturating_mul(20).max(PATH_HEADROOM)
688 };
689 let _timer = crate::trace::Timer::start("search done");
690 let profile_started = std::time::Instant::now();
691 let t_setup = std::time::Instant::now();
692 let setup_span = crate::profile::span("setup");
694 let Session {
697 store,
698 cwd,
699 cwd_is_git,
700 root,
701 active_paths,
702 branch_refresh,
703 identity,
704 coverage,
705 } = session;
706 let cwd_is_git = *cwd_is_git;
707
708 let known = coverage.is_some();
716 let warming_ok = cwd_is_git || known;
717 if crate::trace::enabled() {
718 crate::trace!(
719 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
720 root.as_deref().map_or("?".into(), crate::trace::abbrev),
721 identity.as_deref().unwrap_or("none"),
722 coverage.as_deref().unwrap_or("none"),
723 active_paths.len(),
724 );
725 }
726 let repo_span = crate::profile::span("setup: repo state");
727 let current = identity
728 .as_deref()
729 .and_then(|id| store.repository_id(id).ok().flatten());
730 let only_repo = if all_repos { None } else { current };
733 let active = crate::search::ActiveFiles::new(active_paths.clone());
734
735 drop(repo_span);
736 let warm_span = crate::profile::span("setup: warm decision");
737
738 let warm_budget = if warm_detach_enabled() {
745 answer_warm_budget()
746 } else {
747 answer_warm_budget() + deferred_warm_budget()
748 };
749 let was_warming = coverage.as_deref() != Some("complete");
750
751 let indexed_head = (!was_warming)
762 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
763 .flatten();
764 let staleness = (!was_warming && warming_ok && !args.batch)
767 .then(|| root.clone())
768 .flatten()
769 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
770 let want_warm = warming_ok && was_warming && root.is_some();
773
774 let block = want_warm && was_warming && !no_wait;
788 let progress_ui = block && show_progress(out, stderr_interactive());
793 let indexer_budget = if block { wait_budget } else { warm_budget };
794 if progress_ui {
795 install_interrupt_handler();
796 }
797
798 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
801 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
802 crate::trace!(
803 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
804 crate::index::parse_jobs()
805 );
806 let root = root.clone().expect("checked");
807 let active = active_paths.clone();
808 let q = query.to_string();
809 let warm_done = std::sync::Arc::clone(&warm_done);
810 std::thread::spawn(move || {
811 if let Ok(mut idx) = open_store() {
812 let _ = if block {
814 crate::index::index_budgeted_cancellable(
817 &mut idx,
818 &root,
819 &active,
820 indexer_budget,
821 Some(&q),
822 &INTERRUPTED,
823 )
824 } else {
825 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
826 };
827 }
828 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
829 })
830 });
831
832 crate::trace!(
839 "setup (open + repo detect + warm decision): {} ms",
840 t_setup.elapsed().as_millis()
841 );
842 let poll_start = std::time::Instant::now();
843 let deadline = if progress_ui {
847 None
848 } else if block {
849 Some(poll_start + wait_budget)
850 } else {
851 Some(poll_start + answer_warm_budget())
852 };
853 drop(warm_span);
854 let polling = indexer.is_some() && was_warming;
855 drop(setup_span);
859 let mut query_span = crate::profile::span("query");
860 let label = repo_label(root.as_deref());
861 let mut drew_progress = false;
862 let mut last_draw = poll_start;
863 let rank_limit = limit.max(2);
867 let mut total;
868 let mut hits = loop {
869 match crate::search::search(store, query, current, only_repo, &active, rank_limit) {
870 Ok(m) => {
871 total = m.total;
872 let h = m.hits;
873 let confident = h.first().is_some_and(|hit| {
874 hit.features
875 .iter()
876 .any(|f| matches!(f.name, "exact" | "prefix"))
877 });
878 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
879 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
880 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
881 if !polling || confident || warm_finished || stopped || timed_out {
882 break h;
883 }
884 if progress_ui
885 && poll_start.elapsed() >= HEADS_UP_DELAY
886 && last_draw.elapsed() >= PROGRESS_REDRAW
887 {
888 draw_progress(store, identity.as_deref(), &label);
889 drew_progress = true;
890 last_draw = std::time::Instant::now();
891 }
892 }
893 Err(e) => {
894 if let Some(h) = indexer {
895 let _ = h.join();
896 }
897 return fail(format_args!("rq: {e}"));
898 }
899 }
900 std::thread::sleep(POLL_INTERVAL);
901 };
902 query_span.note(|| {
903 if polling {
904 "polled a warming index".to_string()
905 } else {
906 String::new()
907 }
908 });
909 drop(query_span);
910 if drew_progress {
911 clear_progress();
912 }
913 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
915
916 if !hits.is_empty()
918 && revalidate_top(store, &hits)
919 && let Ok(m) = crate::search::search(store, query, current, only_repo, &active, rank_limit)
920 {
921 total = m.total;
922 hits = m.hits;
923 }
924
925 if !hits.iter().any(strong)
929 && indexer.is_none()
930 && coverage.is_none()
931 && let Some(root) = &root
932 {
933 let tail = live_fallback(root, query, rank_limit);
934 hits = crate::search::merge(hits, tail, rank_limit);
935 total = total.max(hits.len());
936 }
937
938 apply_gates(query, &mut hits);
939 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
940 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
943 total = hits.len();
944 }
945
946 if hits.is_empty() {
947 if block {
949 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
950 }
951 if let Some(h) = indexer {
952 let _ = h.join();
953 }
954 let mut incomplete = (block || no_wait)
961 && identity
962 .as_deref()
963 .and_then(|id| store.coverage_status(id).ok().flatten())
964 .as_deref()
965 != Some("complete");
966 incomplete |= settle_warm(
976 store,
977 staleness,
978 was_warming,
979 warming_ok,
980 root.as_deref(),
981 active_paths,
982 query,
983 warm_budget,
984 no_wait,
985 identity.as_deref(),
986 );
987 record_usage(
991 store,
992 args,
993 current,
994 0,
995 if incomplete { "warming" } else { "miss" },
996 coverage.as_deref(),
997 );
998 let elsewhere = crate::search::scope_miss_owner(store, query, current, only_repo, &active);
1003 return no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1004 }
1005
1006 record_usage(store, args, current, hits.len(), "hit", coverage.as_deref());
1009
1010 attach_confidence(&mut hits);
1014 hits.truncate(want);
1015 let total = total.max(hits.len());
1016 for hit in &mut hits {
1017 hit.total = total;
1018 if args.explain {
1019 hit.explain = Some(
1020 hit.features
1021 .iter()
1022 .map(|f| (f.name.to_string(), f.value))
1023 .collect(),
1024 );
1025 }
1026 }
1027
1028 for hit in &mut hits {
1031 hit.signature = read_signature(
1032 store,
1033 &hit.repo_identity,
1034 &hit.file,
1035 hit.line,
1036 cwd.as_deref(),
1037 );
1038 }
1039
1040 if show
1043 && let Some(code) = show_top_definition(
1044 store,
1045 &mut hits,
1046 query,
1047 out,
1048 cwd.as_deref(),
1049 current,
1050 no_record,
1051 )
1052 {
1053 return code;
1054 }
1055
1056 if open {
1060 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
1061 }
1062
1063 if let Some(code) = render_hits(args, &hits) {
1064 return code;
1065 }
1066
1067 if crate::profile::enabled() {
1070 let total = profile_started.elapsed();
1071 if args.out == Output::Text {
1072 for line in crate::profile::report(total) {
1073 eprintln!("{line}");
1074 }
1075 } else {
1076 eprintln!("{}", crate::profile::json(total));
1079 }
1080 }
1081
1082 if let Some(refresh) = branch_refresh.take() {
1089 refresh.store(store);
1090 }
1091
1092 deferred_maintenance(store);
1097
1098 if block {
1103 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1104 }
1105 if let Some(h) = indexer {
1106 let _ = h.join();
1107 }
1108 let _ = settle_warm(
1109 store,
1110 staleness,
1111 was_warming,
1112 warming_ok,
1113 root.as_deref(),
1114 active_paths,
1115 query,
1116 warm_budget,
1117 no_wait,
1118 identity.as_deref(),
1119 );
1120
1121 ExitCode::SUCCESS
1122}
1123
1124fn maybe_detach_warm(
1127 store: &Store,
1128 want_warm: bool,
1129 changed: bool,
1130 root: Option<&std::path::Path>,
1131 identity: Option<&str>,
1132) {
1133 if !warm_detach_enabled() || !want_warm {
1134 return;
1135 }
1136 let (Some(root), Some(id)) = (root, identity) else {
1137 return;
1138 };
1139 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1143 return; }
1145 spawn_detached_warm(root);
1146}
1147
1148fn spawn_detached_warm(root: &std::path::Path) {
1152 use std::os::unix::process::CommandExt;
1153 let Ok(exe) = std::env::current_exe() else {
1154 return;
1155 };
1156 let mut cmd = std::process::Command::new(exe);
1157 cmd.arg("--warm")
1158 .arg(root)
1159 .stdin(std::process::Stdio::null())
1160 .stdout(std::process::Stdio::null())
1161 .stderr(std::process::Stdio::null())
1162 .process_group(0);
1163 match cmd.spawn() {
1164 Ok(child) => crate::trace!(
1165 "background warm (detached): pid {} for {}",
1166 child.id(),
1167 crate::trace::abbrev(root)
1168 ),
1169 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1170 }
1171}
1172
1173const WARM_LOCK_TTL_SECS: i64 = 600;
1176
1177fn cmd_warm(path: Option<&str>) -> ExitCode {
1182 #[cfg(target_os = "macos")]
1185 unsafe extern "C" {
1186 fn setiopolicy_np(
1189 iotype: libc::c_int,
1190 scope: libc::c_int,
1191 policy: libc::c_int,
1192 ) -> libc::c_int;
1193 }
1194 unsafe {
1195 libc::nice(10);
1196 #[cfg(target_os = "macos")]
1197 setiopolicy_np(0, 0, 3);
1198 }
1199 let mut store = match open_store() {
1200 Ok(s) => s,
1201 Err(_) => return ExitCode::FAILURE,
1202 };
1203 let start = path
1204 .map(PathBuf::from)
1205 .or_else(|| std::env::current_dir().ok())
1206 .unwrap_or_else(|| PathBuf::from("."));
1207 let root = crate::index::repo_root(&start).unwrap_or(start);
1208 let identity = resolve_identity(&store, &root);
1209
1210 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1213 && pid != std::process::id()
1214 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1215 && now_secs() - ts < WARM_LOCK_TTL_SECS
1216 {
1217 return ExitCode::SUCCESS;
1218 }
1219 let _ = store.set_warm_lock(&identity, std::process::id());
1220
1221 let deadline = std::time::Instant::now() + warm_bg_budget();
1224 let active = crate::index::branch_changed_files(&root);
1225 loop {
1226 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1227 if remaining.is_zero() {
1228 break;
1229 }
1230 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1231 {
1232 Ok(s) => s,
1233 Err(_) => break,
1234 };
1235 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1236 || stats.files_indexed == 0
1237 {
1238 break;
1239 }
1240 }
1241 let _ = store.clear_warm_lock(&identity);
1242 ExitCode::SUCCESS
1243}
1244
1245fn now_secs() -> i64 {
1246 std::time::SystemTime::now()
1247 .duration_since(std::time::UNIX_EPOCH)
1248 .map(|d| d.as_secs() as i64)
1249 .unwrap_or(0)
1250}
1251
1252fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1255 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1256 let deadline = std::time::Instant::now() + live_fallback_budget();
1257 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1258 if !h.is_empty() {
1259 return h;
1260 }
1261 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1262}
1263
1264fn strong(h: &crate::search::Hit) -> bool {
1266 h.features
1267 .iter()
1268 .any(|f| matches!(f.name, "exact" | "prefix"))
1269}
1270
1271fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1280 if hits.iter().any(strong) {
1281 hits.retain(strong);
1282 }
1283 crate::search::apply_scope_gate(query, hits);
1284}
1285
1286fn apply_post_filters(
1289 args: &SearchArgs,
1290 cwd: Option<&std::path::Path>,
1291 root: Option<&std::path::Path>,
1292 hits: &mut Vec<crate::search::Hit>,
1293) {
1294 if !args.paths.is_empty() {
1295 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1299 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1300 let norm: Vec<String> = args
1301 .paths
1302 .iter()
1303 .map(|p| repo_relative(&base, &here, p))
1304 .collect();
1305 hits.retain(|h| under_any(&h.file, &norm));
1306 }
1307 if !args.kinds.is_empty() {
1308 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1309 }
1310 if !args.langs.is_empty() {
1311 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1312 }
1313 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1316 hits.truncate(args.want.max(2));
1317 }
1318}
1319
1320fn no_match_code(
1326 out: Output,
1327 query: &str,
1328 interrupted: bool,
1329 incomplete: bool,
1330 elsewhere: Option<&str>,
1334) -> ExitCode {
1335 let status = if interrupted {
1336 "interrupted"
1337 } else if incomplete {
1338 "warming"
1339 } else if elsewhere.is_some() {
1340 "scope_not_found"
1341 } else {
1342 "no_match"
1343 };
1344 match out {
1345 Output::Json | Output::Ndjson => {
1346 let mut obj = serde_json::json!({ "status": status, "query": query });
1347 if let Some(found_in) = elsewhere {
1348 obj["found_in"] = serde_json::json!(found_in);
1349 }
1350 let _ = emit_json(out, &obj); }
1352 Output::Text if interrupted => {
1353 eprintln!("rq: indexing interrupted — run again to finish")
1354 }
1355 Output::Text if incomplete => eprintln!(
1356 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1357 ),
1358 Output::Text if elsewhere.is_some() => eprintln!(
1359 "rq: nothing matching {query:?} — that name is defined under {}",
1360 elsewhere.unwrap_or_default()
1361 ),
1362 Output::Text => eprintln!("no matches for {query:?}"),
1363 }
1364 if incomplete {
1365 ExitCode::from(2)
1366 } else {
1367 ExitCode::FAILURE
1368 }
1369}
1370
1371fn attach_confidence(hits: &mut [crate::search::Hit]) {
1375 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1376 if t.is_none_or(|t| h.score > t) {
1377 (Some(h.score), t)
1378 } else if s.is_none_or(|s| h.score > s) {
1379 (t, Some(h.score))
1380 } else {
1381 (t, s)
1382 }
1383 });
1384 for hit in hits.iter_mut() {
1385 let best_other = if Some(hit.score) == top { second } else { top };
1386 hit.confidence = crate::search::confidence(
1387 hit.score,
1388 crate::search::match_quality(&hit.features),
1389 best_other,
1390 );
1391 }
1392}
1393
1394fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1397 let render_span = crate::profile::span("render");
1401 if args.batch {
1402 #[derive(serde::Serialize)]
1405 struct Tagged<'a> {
1406 query: &'a str,
1407 #[serde(flatten)]
1408 hit: &'a crate::search::Hit,
1409 }
1410 let rows: Vec<Tagged> = hits
1411 .iter()
1412 .map(|hit| Tagged {
1413 query: args.query,
1414 hit,
1415 })
1416 .collect();
1417 if let Some(code) = emit_rows(args.out, &rows) {
1418 return Some(code);
1419 }
1420 } else if let Some(code) = emit_rows(args.out, hits) {
1421 return Some(code);
1422 }
1423 if args.out != Output::Text {
1424 return None;
1425 }
1426 drop(render_span);
1427 let color = match_color();
1428 let c = color.as_deref();
1429 let query = args.query;
1430 if args.show {
1431 let total = hits.first().map_or(hits.len(), |h| h.total);
1433 eprintln!(
1434 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1435 hits.len()
1436 );
1437 }
1438 for hit in hits {
1439 let name = hl(&hit.name, query, c);
1442 let qualified = match &hit.parent {
1443 Some(p) => format!("{name} · {p}"),
1444 None => name,
1445 };
1446 println!(
1447 "{}:{} {} {}",
1448 hl_path(&hit.file, query, c),
1449 hit.line,
1450 hit.kind,
1451 qualified
1452 );
1453 if let Some(sig) = &hit.signature {
1454 println!(" {}", hl(sig, query, c));
1455 }
1456 if args.explain {
1457 let parts: Vec<String> = hit
1458 .features
1459 .iter()
1460 .map(|f| format!("{} {:.0}", f.name, f.value))
1461 .collect();
1462 println!(
1463 " confidence {:.2} · score {:.0} = {}",
1464 hit.confidence,
1465 hit.score,
1466 parts.join(" + ")
1467 );
1468 }
1469 }
1470 None
1471}
1472
1473fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1477 use std::io::{IsTerminal, Write};
1478 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1479 return hits.first();
1480 }
1481 let mut err = std::io::stderr();
1482 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1483 for (i, h) in hits.iter().enumerate() {
1484 let _ = writeln!(
1485 err,
1486 " {}. {}:{} {} {}",
1487 i + 1,
1488 h.file,
1489 h.line,
1490 h.kind,
1491 h.name
1492 );
1493 }
1494 let _ = write!(err, "rq> ");
1495 let _ = err.flush();
1496 let mut line = String::new();
1497 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1498 return None; }
1500 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1501}
1502
1503fn parse_choice(input: &str, n: usize) -> Option<usize> {
1506 let s = input.trim();
1507 if s.is_empty() {
1508 return Some(0);
1509 }
1510 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1511 (i < n).then_some(i)
1512}
1513
1514fn finish_open(
1518 store: &mut Store,
1519 hits: &[crate::search::Hit],
1520 query: &str,
1521 current: Option<i64>,
1522 root: Option<&std::path::Path>,
1523 no_record: bool,
1524) -> ExitCode {
1525 let Some(hit) = choose_hit(hits) else {
1526 return ExitCode::SUCCESS; };
1528
1529 if !no_record {
1532 let _ = store.record_event(
1533 "select",
1534 Some(&query.to_ascii_lowercase()),
1535 current,
1536 Some(&hit.file),
1537 Some(hit.line),
1538 None,
1539 );
1540 deferred_maintenance(store);
1541 }
1542
1543 let target = match root {
1546 Some(r) => r.join(&hit.file),
1547 None => PathBuf::from(&hit.file),
1548 };
1549 launch_editor(&target, hit.line)
1550}
1551
1552fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1556 use std::os::unix::process::CommandExt;
1557 let loc = format!("{}:{}", file.display(), line);
1558 match open_command(file, line, &loc) {
1559 Some((prog, args)) => {
1560 let err = std::process::Command::new(&prog).args(&args).exec();
1562 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1563 }
1564 None => {
1565 println!("{loc}");
1566 ExitCode::SUCCESS
1567 }
1568 }
1569}
1570
1571fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1575 let fstr = file.to_string_lossy().into_owned();
1576
1577 if let Some(t) = std::env::var_os("RQ_OPEN") {
1578 let t = t.to_string_lossy();
1579 let mut parts = t.split_whitespace().map(|p| {
1580 p.replace("{file}", &fstr)
1581 .replace("{line}", &line.to_string())
1582 .replace("{}", loc)
1583 });
1584 if let Some(prog) = parts.next() {
1585 return Some((prog, parts.collect()));
1586 }
1587 }
1588
1589 if on_path("code") {
1590 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1591 }
1592
1593 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1594 let ed = ed.to_string_lossy().into_owned();
1595 let l = ed.to_ascii_lowercase();
1596 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1598 .iter()
1599 .any(|e| l.contains(e))
1600 {
1601 return Some((ed, vec![format!("+{line}"), fstr]));
1602 }
1603 return Some((ed, vec![fstr]));
1604 }
1605
1606 None
1607}
1608
1609fn on_path(prog: &str) -> bool {
1611 std::env::var_os("PATH")
1612 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1613}
1614
1615fn unix_now() -> i64 {
1625 std::time::SystemTime::now()
1626 .duration_since(std::time::UNIX_EPOCH)
1627 .map(|d| d.as_secs() as i64)
1628 .unwrap_or(0)
1629}
1630
1631const BRANCH_FILES_TTL_SECS: i64 = 15;
1637
1638struct BranchRefresh {
1642 handle: std::thread::JoinHandle<Vec<String>>,
1643 identity: String,
1644 stamp: String,
1645}
1646
1647impl BranchRefresh {
1648 fn store(self, store: &Store) {
1650 let Ok(files) = self.handle.join() else {
1651 return;
1652 };
1653 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1654 }
1655}
1656
1657fn cached_branch_files(
1671 store: &Store,
1672 root: &std::path::Path,
1673) -> (Vec<String>, Option<BranchRefresh>) {
1674 let identity = resolve_identity(store, root);
1675 let stamp = crate::index::branch_files_stamp(root);
1676 let cached = store.branch_files_get(&identity).ok().flatten();
1677 let now = unix_now();
1678
1679 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1680 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1681 return (files.clone(), None);
1682 }
1683 let owned_root = root.to_path_buf();
1684 let refresh = BranchRefresh {
1685 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1686 identity,
1687 stamp: stamp.clone(),
1688 };
1689 return (files.clone(), Some(refresh));
1690 }
1691
1692 let files = crate::index::branch_changed_files(root);
1694 if let Some(stamp) = stamp {
1695 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1696 }
1697 (files, None)
1698}
1699
1700fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1709 let Some(head) = indexed_head else {
1710 return true;
1711 };
1712 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1713}
1714
1715#[allow(clippy::too_many_arguments)]
1723fn settle_warm(
1724 store: &Store,
1725 staleness: Option<std::thread::JoinHandle<bool>>,
1726 was_warming: bool,
1727 warming_ok: bool,
1728 root: Option<&std::path::Path>,
1729 active: &[String],
1730 query: &str,
1731 budget: Duration,
1732 no_wait: bool,
1733 identity: Option<&str>,
1734) -> bool {
1735 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1738 if changed
1748 && !no_wait
1749 && !warm_detach_enabled()
1750 && let Some(r) = root
1751 && let Ok(mut idx) = open_store()
1752 {
1753 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1754 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1755 }
1756 maybe_detach_warm(
1757 store,
1758 warming_ok && (was_warming || changed),
1759 changed,
1760 root,
1761 identity,
1762 );
1763 changed && warm_detach_enabled()
1767}
1768
1769fn answer_warm_budget() -> Duration {
1778 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1779}
1780
1781fn deferred_warm_budget() -> Duration {
1784 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1785}
1786
1787fn live_fallback_budget() -> Duration {
1790 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1791}
1792
1793fn warm_bg_budget() -> Duration {
1796 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1797}
1798
1799fn warm_detach_enabled() -> bool {
1803 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1804}
1805
1806fn wait_budget() -> Duration {
1815 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1816}
1817
1818fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1823 let s = s.trim();
1824 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1825 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1827 (n, 1.0)
1828 } else if let Some(n) = s.strip_suffix('s') {
1829 (n, 1_000.0)
1830 } else if let Some(n) = s.strip_suffix('m') {
1831 (n, 60_000.0)
1832 } else {
1833 (s, 1_000.0)
1835 };
1836 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1837 if !val.is_finite() || val < 0.0 {
1838 return Err(bad());
1839 }
1840 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1841}
1842
1843static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1847
1848extern "C" fn on_sigint(_: libc::c_int) {
1849 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1851}
1852
1853fn install_interrupt_handler() {
1856 static ONCE: std::sync::Once = std::sync::Once::new();
1857 ONCE.call_once(|| unsafe {
1858 let mut action: libc::sigaction = std::mem::zeroed();
1859 action.sa_sigaction = on_sigint as *const () as usize;
1860 libc::sigemptyset(&mut action.sa_mask);
1861 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1862 });
1863}
1864
1865fn stderr_interactive() -> bool {
1869 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1870}
1871
1872fn show_progress(out: Output, interactive: bool) -> bool {
1877 interactive && matches!(out, Output::Text)
1878}
1879
1880fn repo_label(root: Option<&std::path::Path>) -> String {
1883 root.and_then(|r| r.file_name())
1884 .map(|n| n.to_string_lossy().into_owned())
1885 .unwrap_or_else(|| "repo".into())
1886}
1887
1888fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1892 let files = identity
1893 .and_then(|id| store.repository_id(id).ok().flatten())
1894 .and_then(|rid| store.repo_totals(rid).ok())
1895 .map_or(0, |(f, _)| f);
1896 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1897 let _ = std::io::stderr().flush();
1898}
1899
1900fn clear_progress() {
1902 eprint!("\r\x1b[K");
1903 let _ = std::io::stderr().flush();
1904}
1905
1906fn env_budget(var: &str, default_ms: u64) -> Duration {
1910 let ms = std::env::var(var)
1911 .ok()
1912 .and_then(|v| v.parse().ok())
1913 .unwrap_or(default_ms);
1914 Duration::from_millis(ms)
1915}
1916
1917const AGGREGATE_BATCH: usize = 256;
1920
1921const KEEP_RECENT_EVENTS: i64 = 200;
1924
1925fn deferred_maintenance(store: &mut Store) {
1928 let _ = store.aggregate_events(AGGREGATE_BATCH);
1929 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1930}
1931
1932fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1935 let mut store = match open_store() {
1936 Ok(s) => s,
1937 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1938 };
1939 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1940 let identity = crate::index::detect_identity(&cwd).to_string();
1941 let repo_id = store.repository_id(&identity).ok().flatten();
1942
1943 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1946 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1947 None => file.to_string(),
1948 };
1949 let query_norm = query.map(|q| q.to_ascii_lowercase());
1950
1951 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1952 {
1953 return fail(format_args!("rq record: {e}"));
1954 }
1955 deferred_maintenance(&mut store);
1956 ExitCode::SUCCESS
1957}
1958
1959fn hit_file_roots(
1965 store: &Store,
1966 repo_identity: &str,
1967 cwd: Option<&std::path::Path>,
1968) -> Vec<PathBuf> {
1969 let mut roots: Vec<PathBuf> = store
1970 .repository_id(repo_identity)
1971 .ok()
1972 .flatten()
1973 .map(|id| store.checkout_roots(id).unwrap_or_default())
1974 .unwrap_or_default()
1975 .into_iter()
1976 .map(PathBuf::from)
1977 .collect();
1978 if let Some(c) = cwd {
1979 let c = c.to_path_buf();
1980 if !roots.contains(&c) {
1981 roots.push(c);
1982 }
1983 }
1984 roots
1985}
1986
1987fn read_signature(
1990 store: &Store,
1991 repo_identity: &str,
1992 file: &str,
1993 line: i64,
1994 cwd: Option<&std::path::Path>,
1995) -> Option<String> {
1996 hit_file_roots(store, repo_identity, cwd)
1997 .into_iter()
1998 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1999}
2000
2001const SHOW_CONFIDENCE: f64 = 0.85;
2005
2006fn show_top_definition(
2015 store: &mut Store,
2016 hits: &mut [crate::search::Hit],
2017 query: &str,
2018 out: Output,
2019 cwd: Option<&std::path::Path>,
2020 current: Option<i64>,
2021 no_record: bool,
2022) -> Option<ExitCode> {
2023 let top = hits.first()?;
2024 if top.confidence < SHOW_CONFIDENCE {
2025 return None; }
2027 let end = top.end_line.unwrap_or(top.line);
2028 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
2029 hits[0].body = body;
2030 let top = &hits[0];
2031 let shown = (top.file.clone(), top.line);
2032 let code = match out {
2033 Output::Json | Output::Ndjson => {
2034 emit_json(out, top)
2036 }
2037 Output::Text => {
2038 let color = match_color();
2039 let c = color.as_deref();
2040 let name = hl(&top.name, query, c);
2041 let qualified = match &top.parent {
2042 Some(p) => format!("{name} · {p}"),
2043 None => name,
2044 };
2045 println!(
2046 "{}:{} {} {}",
2047 hl_path(&top.file, query, c),
2048 top.line,
2049 top.kind,
2050 qualified
2051 );
2052 match (&top.body, &top.signature) {
2053 (Some(body), _) => println!("{body}"),
2054 (None, Some(sig)) => println!("{sig}"),
2056 (None, None) => {}
2057 }
2058 ExitCode::SUCCESS
2059 }
2060 };
2061
2062 if !no_record {
2064 let (file, line) = shown;
2065 let _ = store.record_event(
2066 "select",
2067 Some(&query.to_ascii_lowercase()),
2068 current,
2069 Some(&file),
2070 Some(line),
2071 None,
2072 );
2073 deferred_maintenance(store);
2074 }
2075 Some(code)
2076}
2077
2078fn read_span(
2081 store: &Store,
2082 repo_identity: &str,
2083 file: &str,
2084 start: i64,
2085 end: i64,
2086 cwd: Option<&std::path::Path>,
2087) -> Option<String> {
2088 hit_file_roots(store, repo_identity, cwd)
2089 .into_iter()
2090 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2091}
2092
2093fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2096 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2097 let lines: Vec<&str> = content.lines().collect();
2098 if s >= lines.len() {
2099 return None;
2100 }
2101 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2102 Some(lines[s..e].join("\n"))
2103}
2104
2105fn signature_in(content: &str, line: i64) -> Option<String> {
2109 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2110 let l = content.lines().nth(idx)?.trim();
2111 (!l.is_empty()).then(|| l.to_string())
2112}
2113
2114#[derive(serde::Serialize)]
2118struct SymbolOut {
2119 name: String,
2120 kind: String,
2121 language: String,
2122 file: String,
2123 line: i64,
2124 #[serde(skip_serializing_if = "Option::is_none")]
2125 end_line: Option<i64>,
2126 #[serde(skip_serializing_if = "Option::is_none")]
2127 parent: Option<String>,
2128 #[serde(skip_serializing_if = "Option::is_none")]
2129 visibility: Option<String>,
2130 repo: String,
2131 #[serde(skip_serializing_if = "Option::is_none")]
2132 signature: Option<String>,
2133}
2134
2135fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2140 let mut store = match open_store() {
2141 Ok(s) => s,
2142 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2143 };
2144 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2145 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2146 let rel = repo_relative(&root, &cwd, file_arg);
2147
2148 let identity = resolve_identity(&store, &root);
2149 let coverage = store.coverage_status(&identity).ok().flatten();
2150 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2151 let current = store.repository_id(&identity).ok().flatten();
2152 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
2155 let needs_warm = warming_ok
2156 && (coverage.as_deref() != Some("complete")
2157 || worktree_changed(&root, indexed_head.as_deref()));
2158 if needs_warm {
2159 let budget = answer_warm_budget() + deferred_warm_budget();
2161 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2162 }
2163
2164 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2165 return emit_symbols(out, &[]); };
2167 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2168 Ok(r) => r,
2169 Err(e) => return fail(format_args!("rq: {e}")),
2170 };
2171 if !kinds.is_empty() {
2172 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2173 }
2174 if !langs.is_empty() {
2175 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2176 }
2177
2178 let content = hit_file_roots(&store, &identity, Some(&root))
2182 .iter()
2183 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2184 let syms: Vec<SymbolOut> = rows
2185 .into_iter()
2186 .map(|r| SymbolOut {
2187 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2188 name: r.name,
2189 kind: r.kind,
2190 language: r.language,
2191 file: r.file,
2192 line: r.line,
2193 end_line: r.end_line,
2194 parent: r.parent,
2195 visibility: r.visibility,
2196 repo: r.repo_identity,
2197 })
2198 .collect();
2199 emit_symbols(out, &syms)
2200}
2201
2202fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2205 if syms.is_empty() {
2206 match out {
2207 Output::Json | Output::Ndjson => {
2208 let obj = serde_json::json!({ "status": "no_match" });
2209 let _ = emit_json(out, &obj); }
2211 Output::Text => eprintln!("no symbols"),
2212 }
2213 return ExitCode::FAILURE;
2214 }
2215 if let Some(code) = emit_rows(out, syms) {
2216 return code;
2217 }
2218 match out {
2219 Output::Json | Output::Ndjson => {}
2220 Output::Text => {
2221 for s in syms {
2222 let qualified = match &s.parent {
2223 Some(p) => format!("{} · {p}", s.name),
2224 None => s.name.clone(),
2225 };
2226 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2227 if let Some(sig) = &s.signature {
2228 println!(" {sig}");
2229 }
2230 }
2231 }
2232 }
2233 ExitCode::SUCCESS
2234}
2235
2236fn keyword_kind(token: &str) -> Option<&'static str> {
2241 match token.to_ascii_lowercase().as_str() {
2242 "class" => Some("class"),
2243 "module" => Some("module"),
2244 "method" => Some("method"),
2245 "function" | "fn" => Some("function"),
2246 "struct" | "type" => Some("struct"),
2247 "enum" => Some("enum"),
2248 "trait" | "interface" => Some("trait"),
2249 _ => None,
2250 }
2251}
2252
2253fn split_kind_keyword(
2259 target: String,
2260 dirs: Vec<String>,
2261) -> (Option<&'static str>, String, Vec<String>) {
2262 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2265 let rest = rest.trim();
2266 if let Some(k) = keyword_kind(head)
2267 && !rest.is_empty()
2268 {
2269 return (Some(k), rest.to_string(), dirs);
2270 }
2271 } else if let Some(k) = keyword_kind(&target)
2272 && let Some((query, extra)) = dirs.split_first()
2273 {
2274 return (Some(k), query.clone(), extra.to_vec());
2276 }
2277 (None, target, dirs)
2278}
2279
2280fn canonical_kind(s: &str) -> Option<&'static str> {
2283 Some(match s.to_ascii_lowercase().as_str() {
2284 "c" | "class" => "class",
2285 "m" | "method" => "method",
2286 "f" | "fn" | "func" | "function" => "function",
2287 "mod" | "module" => "module",
2288 "s" | "struct" | "type" => "struct",
2289 "e" | "enum" => "enum",
2290 "t" | "trait" | "interface" => "trait",
2291 _ => return None,
2292 })
2293}
2294
2295fn canonical_langs(s: &str) -> Vec<String> {
2302 let t = s.to_ascii_lowercase();
2303 let alias = match t.as_str() {
2304 "rb" => Some("ruby"),
2305 "rs" => Some("rust"),
2306 "golang" => Some("go"),
2307 "ts" | "tsx" => Some("typescript"),
2308 "js" | "jsx" => Some("javascript"),
2309 _ => None,
2310 };
2311 let matched: Vec<String> = crate::lang::languages()
2312 .into_iter()
2313 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2314 .map(str::to_string)
2315 .collect();
2316 matched
2317}
2318
2319fn match_color() -> Option<String> {
2323 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2324 return None;
2325 }
2326 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2327 gc.split(':').find_map(|e| {
2328 e.strip_prefix("mt=")
2329 .or_else(|| e.strip_prefix("ms="))
2330 .filter(|v| !v.is_empty())
2331 .map(str::to_string)
2332 })
2333 });
2334 Some(style.unwrap_or_else(|| "1;31".to_string()))
2335}
2336
2337fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2340 match color {
2341 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2342 None => text.to_string(),
2343 }
2344}
2345
2346fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2349 let Some(c) = color else {
2350 return path.to_string();
2351 };
2352 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2353 let base_start = path[..base_byte].chars().count();
2354 let stem = crate::search::path_stem(path);
2358 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2359 .into_iter()
2360 .map(|p| p + base_start)
2361 .collect();
2362 highlight(path, &positions, c)
2363}
2364
2365fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2368 if positions.is_empty() {
2369 return text.to_string();
2370 }
2371 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2372 let mut out = String::new();
2373 let mut on = false;
2374 for (i, c) in text.chars().enumerate() {
2375 match (matched.contains(&i), on) {
2376 (true, false) => {
2377 out.push_str("\x1b[");
2378 out.push_str(color);
2379 out.push('m');
2380 on = true;
2381 }
2382 (false, true) => {
2383 out.push_str("\x1b[0m");
2384 on = false;
2385 }
2386 _ => {}
2387 }
2388 out.push(c);
2389 }
2390 if on {
2391 out.push_str("\x1b[0m");
2392 }
2393 out
2394}
2395
2396fn under_any(file: &str, paths: &[String]) -> bool {
2400 paths.iter().any(|p| {
2401 let p = p.trim_start_matches("./").trim_end_matches('/');
2402 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2403 })
2404}
2405
2406fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2408 let p = std::path::Path::new(file);
2409 let abs = if p.is_absolute() {
2410 p.to_path_buf()
2411 } else {
2412 cwd.join(p)
2413 };
2414 let abs = abs.canonicalize().unwrap_or(abs);
2415 abs.strip_prefix(root)
2416 .map(|r| r.to_string_lossy().into_owned())
2417 .unwrap_or_else(|_| file.to_string())
2418}
2419
2420fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2424 use std::collections::HashSet;
2425 let mut seen = HashSet::new();
2426 let mut changed = false;
2427 for hit in hits {
2428 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2429 continue;
2430 }
2431 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2432 continue;
2433 };
2434 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2435 continue;
2436 };
2437 if let Ok(crate::index::Refresh::Updated) =
2438 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2439 {
2440 changed = true;
2441 }
2442 }
2443 changed
2444}
2445
2446fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2452 if let Ok(canon) = cwd.canonicalize() {
2453 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2454 return identity;
2455 }
2456 if crate::index::repo_root(cwd).is_none() {
2457 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2458 }
2459 }
2460 crate::index::detect_identity(cwd).to_string()
2461}
2462
2463fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2464 let explicit = path.is_some();
2465 let target = path.unwrap_or_else(|| PathBuf::from("."));
2466 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2471 let mut subdirs = subdirs.to_vec();
2476 if explicit
2477 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2478 && t != r
2479 && let Ok(rel) = t.strip_prefix(&r)
2480 && !rel.as_os_str().is_empty()
2481 {
2482 subdirs.push(rel.to_string_lossy().into_owned());
2483 }
2484 let mut store = match open_store() {
2485 Ok(s) => s,
2486 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2487 };
2488 let identity = crate::index::detect_identity(&root).to_string();
2489 match crate::index::index_under(&mut store, &root, &subdirs) {
2490 Ok(stats) => {
2491 let subtree = !subdirs.is_empty();
2492 let totals = store
2494 .repository_id(&identity)
2495 .ok()
2496 .flatten()
2497 .and_then(|id| store.repo_totals(id).ok());
2498 match out {
2499 Output::Json | Output::Ndjson => {
2500 let (files, symbols) = match totals {
2501 Some((f, s)) => (Some(f), Some(s)),
2502 None => (None, None),
2503 };
2504 return emit_json(
2505 out,
2506 &serde_json::json!({
2507 "repo": identity,
2508 "scope": if subtree { "subtree" } else { "full" },
2509 "files_added": stats.files_indexed,
2510 "symbols_added": stats.symbols,
2511 "files": files,
2512 "symbols": symbols,
2513 }),
2514 );
2515 }
2516 Output::Text => {
2517 let scope = if subtree { " (subtree seed)" } else { "" };
2518 match totals {
2519 Some((files, symbols)) => println!(
2520 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2521 stats.files_indexed, stats.symbols
2522 ),
2523 None => println!(
2524 "{} file(s)/{} symbol(s) added this run{scope}",
2525 stats.files_indexed, stats.symbols
2526 ),
2527 }
2528 }
2529 }
2530 ExitCode::SUCCESS
2531 }
2532 Err(e) => fail(format_args!("rq --index: {e}")),
2533 }
2534}
2535
2536fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2537 let mut store = match open_store() {
2538 Ok(s) => s,
2539 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2540 };
2541
2542 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2546 let root = crate::index::repo_root(&path).unwrap_or(path);
2547 let from_path = crate::index::detect_identity(&root).to_string();
2548 let resolved = match store.repository_id(&from_path) {
2549 Ok(Some(id)) => Some((from_path.clone(), id)),
2550 Ok(None) => target.as_deref().and_then(|s| {
2551 store
2552 .repository_id(s)
2553 .ok()
2554 .flatten()
2555 .map(|id| (s.to_string(), id))
2556 }),
2557 Err(e) => return fail(format_args!("rq --drop: {e}")),
2558 };
2559
2560 let Some((identity, repo_id)) = resolved else {
2561 return match out {
2563 Output::Text => {
2564 println!("not indexed: {from_path}");
2565 ExitCode::SUCCESS
2566 }
2567 _ => emit_json(
2568 out,
2569 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2570 ),
2571 };
2572 };
2573
2574 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2575 match store.drop_repository(repo_id) {
2576 Ok(()) => match out {
2577 Output::Text => {
2578 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2579 ExitCode::SUCCESS
2580 }
2581 _ => emit_json(
2582 out,
2583 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2584 ),
2585 },
2586 Err(e) => fail(format_args!("rq --drop: {e}")),
2587 }
2588}
2589
2590fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2594 let rendered = if out == Output::Json {
2595 serde_json::to_string_pretty(value)
2596 } else {
2597 serde_json::to_string(value)
2598 };
2599 match rendered {
2600 Ok(s) => {
2601 println!("{s}");
2602 ExitCode::SUCCESS
2603 }
2604 Err(e) => fail(format_args!("rq: {e}")),
2605 }
2606}
2607
2608fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2612 match out {
2613 Output::Json => match serde_json::to_string_pretty(rows) {
2614 Ok(s) => println!("{s}"),
2615 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2616 },
2617 Output::Ndjson => {
2618 for r in rows {
2619 match serde_json::to_string(r) {
2620 Ok(line) => println!("{line}"),
2621 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2622 }
2623 }
2624 }
2625 Output::Text => {}
2626 }
2627 None
2628}
2629
2630fn cmd_status(out: Output) -> ExitCode {
2631 let store = match open_store() {
2632 Ok(s) => s,
2633 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2634 };
2635 let rows = match store.coverage_overview() {
2636 Ok(rows) => rows,
2637 Err(e) => return fail(format_args!("rq --status: {e}")),
2638 };
2639 if let Some(code) = emit_rows(out, &rows) {
2640 return code;
2641 }
2642 match out {
2643 Output::Json | Output::Ndjson => {}
2644 Output::Text if rows.is_empty() => {
2645 println!("no repositories indexed yet (try `rq --index`)");
2646 }
2647 Output::Text => {
2648 for r in &rows {
2649 println!(
2650 "{:<10} {:>6} files {:>7} symbols {}",
2651 r.status, r.files, r.symbols, r.identity
2652 );
2653 }
2654 }
2655 }
2656 ExitCode::SUCCESS
2657}
2658
2659fn cmd_usage(out: Output) -> ExitCode {
2662 let store = match open_store() {
2663 Ok(s) => s,
2664 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2665 };
2666 let rows = match store.usage_overview() {
2667 Ok(rows) => rows,
2668 Err(e) => return fail(format_args!("rq --usage: {e}")),
2669 };
2670 if let Some(code) = emit_rows(out, &rows) {
2671 return code;
2672 }
2673 match out {
2674 Output::Json | Output::Ndjson => {}
2675 Output::Text if rows.is_empty() => {
2676 println!("no usage recorded yet");
2677 }
2678 Output::Text => {
2679 println!(
2682 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2683 "day", "caller", "found", "missed", "warming"
2684 );
2685 for r in &rows {
2686 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2687 println!(
2688 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2689 r.day,
2690 r.source,
2691 r.searches - r.misses - r.warming,
2692 r.misses,
2693 r.warming,
2694 flags
2695 );
2696 }
2697 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2698 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2699 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2700 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2701 let plural = if searches == 1 { "search" } else { "searches" };
2702 println!(
2705 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2706 );
2707 }
2708 }
2709 if rows.is_empty() {
2711 return ExitCode::from(1);
2712 }
2713 ExitCode::SUCCESS
2714}
2715
2716fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2718 let path = db_path()?;
2719 if let Some(parent) = path.parent() {
2720 std::fs::create_dir_all(parent)?;
2721 }
2722 Ok(Store::open(&path)?)
2723}
2724
2725fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2727 if let Ok(p) = std::env::var("RQ_DB") {
2728 return Ok(PathBuf::from(p));
2729 }
2730 let home = std::env::var("HOME")?;
2731 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2732}
2733
2734fn fail(args: std::fmt::Arguments) -> ExitCode {
2735 eprintln!("{args}");
2736 ExitCode::FAILURE
2737}
2738
2739#[cfg(test)]
2740mod tests {
2741 use super::*;
2742
2743 #[test]
2744 fn open_menu_choice_parsing() {
2745 assert_eq!(parse_choice("\n", 5), Some(0));
2747 assert_eq!(parse_choice(" ", 5), Some(0));
2748 assert_eq!(parse_choice("3", 5), Some(2));
2749 assert_eq!(parse_choice("5", 5), Some(4));
2750 assert_eq!(parse_choice("6", 5), None);
2752 assert_eq!(parse_choice("0", 5), None);
2753 assert_eq!(parse_choice("q", 5), None);
2754 }
2755
2756 #[test]
2757 fn wait_duration_parsing() {
2758 use std::time::Duration;
2759 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2761 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2762 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2763 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2764 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2766 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2767 assert!(parse_wait("0s").unwrap().is_zero());
2768 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2770 assert!(parse_wait("2x").is_err());
2772 assert!(parse_wait("").is_err());
2773 assert!(parse_wait("s").is_err());
2774 assert!(parse_wait("-1s").is_err());
2775 }
2776
2777 #[test]
2778 fn leading_kind_keyword_becomes_a_kind_filter() {
2779 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2780 assert_eq!(
2782 split_kind_keyword("class".into(), d(&["Widget"])),
2783 (Some("class"), "Widget".into(), vec![])
2784 );
2785 assert_eq!(
2787 split_kind_keyword("method zoom".into(), vec![]),
2788 (Some("method"), "zoom".into(), vec![])
2789 );
2790 assert_eq!(
2792 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2793 (Some("function"), "Foo::run".into(), vec![])
2794 );
2795 assert_eq!(
2797 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2798 (Some("struct"), "Gadget".into(), d(&["src"]))
2799 );
2800 }
2801
2802 #[test]
2803 fn a_bare_or_non_keyword_query_is_left_alone() {
2804 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2805 assert_eq!(
2807 split_kind_keyword("class".into(), vec![]),
2808 (None, "class".into(), vec![])
2809 );
2810 assert_eq!(
2812 split_kind_keyword("Widget".into(), d(&["app"])),
2813 (None, "Widget".into(), d(&["app"]))
2814 );
2815 assert_eq!(
2817 split_kind_keyword("c".into(), d(&["Foo"])),
2818 (None, "c".into(), d(&["Foo"]))
2819 );
2820 }
2821
2822 #[test]
2823 fn a_language_selects_by_prefix_or_alias() {
2824 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2826 assert_eq!(canonical_langs("t"), ["typescript"]);
2827 assert_eq!(canonical_langs("ts"), ["typescript"]);
2829 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2830 assert_eq!(canonical_langs("rb"), ["ruby"]);
2831 assert!(canonical_langs("COBOL").is_empty());
2834 }
2835
2836 #[test]
2837 fn a_kind_normalizes_language_specific_spellings() {
2838 assert_eq!(canonical_kind("f"), Some("function"));
2839 assert_eq!(canonical_kind("interface"), Some("trait"));
2841 assert_eq!(canonical_kind("type"), Some("struct"));
2842 assert_eq!(canonical_kind("banana"), None);
2843 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2845 assert_eq!(
2846 split_kind_keyword("interface".into(), d(&["Renderer"])),
2847 (Some("trait"), "Renderer".into(), vec![])
2848 );
2849 }
2850
2851 #[test]
2852 fn highlight_wraps_matched_runs() {
2853 assert_eq!(
2854 highlight("FooThing", &[0, 1, 2], "1;31"),
2855 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2856 );
2857 assert_eq!(
2859 highlight("FooThing", &[0, 3], "1"),
2860 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2861 );
2862 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2864 }
2865
2866 #[test]
2867 fn progress_ui_only_for_an_interactive_text_terminal() {
2868 assert!(show_progress(Output::Text, true));
2870
2871 assert!(!show_progress(Output::Json, true));
2873 assert!(!show_progress(Output::Ndjson, true));
2874
2875 assert!(!show_progress(Output::Text, false));
2877 }
2878
2879 #[test]
2880 fn repo_label_uses_the_directory_name() {
2881 assert_eq!(
2882 repo_label(Some(std::path::Path::new("/src/widgets"))),
2883 "widgets"
2884 );
2885 assert_eq!(repo_label(None), "repo");
2886 }
2887
2888 #[test]
2889 fn hl_path_highlights_the_stem_not_the_extension() {
2890 let out = hl_path(
2893 "app/employees_controller.rb",
2894 "employeescontroller",
2895 Some("1;31"),
2896 );
2897 assert!(
2898 out.starts_with("app/\u{1b}[1;31memployees"),
2899 "stem highlighted: {out:?}"
2900 );
2901 assert!(
2902 out.ends_with("controller\u{1b}[0m.rb"),
2903 "`.rb` left un-highlighted: {out:?}"
2904 );
2905 }
2906}