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)\n \
40rq -o thing open the best match in your editor (and record it)\n \
41rq --index index the current repository\n \
42rq --status show indexing coverage\n \
43rq --drop remove this repo's index (opposite of --index)\n\n\
44SHORT FLAGS (easy to misread):\n \
45-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
46RECORDING (editor/shell hook):\n \
47rq --record --file <path> --line <n> <query>\n \
48Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
49to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
50The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
51automatically on the first search in a git repo. On a large, cold repo a search \
52keeps indexing until it can answer rather than reporting a premature \"no \
53matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
54= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
55)]
56struct Cli {
57 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
64 target: Option<String>,
65
66 #[arg(value_name = "PATH")]
68 dirs: Vec<String>,
69
70 #[arg(short = 'e', long)]
72 explain: bool,
73
74 #[arg(long)]
76 no_record: bool,
77
78 #[arg(long = "no-wait")]
84 no_wait: bool,
85
86 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
91 wait: Option<Duration>,
92
93 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
98 open: bool,
99
100 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
104 show: bool,
105
106 #[arg(short = 'j', long)]
108 json: bool,
109
110 #[arg(short = 'J', long, conflicts_with = "json")]
112 ndjson: bool,
113
114 #[arg(short = 'p', long, value_name = "DIR")]
116 path: Vec<String>,
117
118 #[arg(short = 'l', long, value_name = "N", default_value_t = 10)]
120 limit: usize,
121
122 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
125 kind: Vec<String>,
126
127 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
131 lang: Vec<String>,
132
133 #[arg(long = "all-repos")]
136 all_repos: bool,
137
138 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
140 index: Option<Option<String>>,
141
142 #[arg(long, conflicts_with_all = ["index", "record"])]
144 status: bool,
145
146 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
149 symbols: Option<String>,
150
151 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
155 drop: bool,
156
157 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
160 record: bool,
161
162 #[arg(long)]
164 file: Option<String>,
165
166 #[arg(long)]
168 line: Option<i64>,
169
170 #[arg(long, default_value = "select")]
172 event: String,
173
174 #[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"])]
178 warm: Option<Option<String>>,
179
180 #[arg(long, value_name = "SHELL")]
182 completions: Option<Shell>,
183
184 #[arg(short = 'v', long)]
187 verbose: bool,
188
189 #[arg(long)]
193 profile: bool,
194
195 #[arg(long, value_name = "N", default_value_t = 0)]
198 jobs: usize,
199}
200
201pub fn run() -> ExitCode {
203 let cli = Cli::parse();
204 crate::trace::enable_from(cli.verbose);
205 crate::profile::enable_from(cli.profile);
206 crate::index::set_parse_jobs(cli.jobs);
207
208 if let Some(shell) = cli.completions {
209 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
210 return ExitCode::SUCCESS;
211 }
212 if let Some(path) = &cli.index {
213 let out = output_format(&cli);
215 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
216 }
217 if let Some(path) = &cli.warm {
218 return cmd_warm(path.as_deref());
219 }
220 if cli.status {
221 return cmd_status(output_format(&cli));
222 }
223 if cli.drop {
224 let out = output_format(&cli);
225 return cmd_drop(cli.target, out);
226 }
227 if cli.record {
228 if !matches!(cli.event.as_str(), "select" | "open") {
230 return fail(format_args!(
231 "rq --record: unknown --event {:?} (expected select or open)",
232 cli.event
233 ));
234 }
235 let file = cli.file.expect("--record requires --file");
237 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
238 }
239 let out = output_format(&cli);
240 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
241 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
243 if let Some(file) = &cli.symbols {
244 return cmd_symbols(file, &kinds, &langs, out);
245 }
246 let mut paths = cli.path.clone();
248 match cli.target {
249 Some(target) => {
250 let query = if cli.kind.is_empty() {
253 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
254 if let Some(k) = kw {
255 kinds.push(k.to_string());
256 }
257 paths.extend(dirs);
258 query
259 } else {
260 paths.extend(cli.dirs.clone());
261 target
262 };
263 cmd_search(&SearchArgs {
264 query: &query,
265 explain: cli.explain,
266 out,
267 paths: &paths,
268 kinds: &kinds,
269 langs: &langs,
270 want: cli.limit,
271 no_record: cli.no_record,
272 no_wait: cli.no_wait,
273 wait: cli.wait,
274 open: cli.open,
275 all_repos: cli.all_repos,
276 show: cli.show,
277 })
278 }
279 None => {
281 let _ = Cli::command().print_long_help();
282 ExitCode::SUCCESS
283 }
284 }
285}
286
287#[derive(Clone, Copy, PartialEq)]
289enum Output {
290 Text,
291 Json,
292 Ndjson,
293}
294
295fn output_format(cli: &Cli) -> Output {
296 if cli.ndjson {
297 Output::Ndjson
298 } else if cli.json {
299 Output::Json
300 } else {
301 Output::Text
302 }
303}
304
305const PATH_HEADROOM: usize = 200;
308
309const POLL_INTERVAL: Duration = Duration::from_millis(100);
316
317const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
321
322const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
326
327struct SearchArgs<'a> {
329 query: &'a str,
330 explain: bool,
331 out: Output,
332 paths: &'a [String],
333 kinds: &'a [String],
334 langs: &'a [String],
335 want: usize,
337 no_record: bool,
338 no_wait: bool,
340 wait: Option<Duration>,
343 open: bool,
344 all_repos: bool,
345 show: bool,
346}
347
348fn cmd_search(args: &SearchArgs) -> ExitCode {
350 let &SearchArgs {
351 query,
352 out,
353 want,
354 no_record,
355 no_wait,
356 wait,
357 open,
358 all_repos,
359 show,
360 ..
361 } = args;
362 let wait_budget = wait.unwrap_or_else(wait_budget);
365 let no_wait = no_wait || wait_budget.is_zero();
366 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
369 want
370 } else {
371 (want * 20).max(PATH_HEADROOM)
372 };
373 let _timer = crate::trace::Timer::start("search done");
374 let profile_started = std::time::Instant::now();
375 let t_setup = std::time::Instant::now();
376 let open_span = crate::profile::span("store open");
377 let mut store = match open_store() {
378 Ok(s) => s,
379 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
380 };
381 drop(open_span);
382 let setup_span = crate::profile::span("setup");
383 let git_span = crate::profile::span("setup: git root");
384 let cwd = std::env::current_dir().ok();
385 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
386
387 let root = cwd
393 .as_deref()
394 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
395 drop(git_span);
396
397 let mut branch_span = crate::profile::span("setup: branch files");
400 let (active_paths, branch_refresh) = match &root {
401 Some(c) if cwd_is_git => cached_branch_files(&store, c),
402 _ => (Vec::new(), None),
403 };
404 branch_span.note(|| {
405 let how = if branch_refresh.is_some() {
406 "cached, refreshing alongside"
407 } else {
408 "cached"
409 };
410 format!("{} changed, {how}", active_paths.len())
411 });
412 drop(branch_span);
413
414 let mut identity_span = crate::profile::span("setup: identity");
419 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
420 let coverage = identity
421 .as_deref()
422 .and_then(|id| store.coverage_status(id).ok())
423 .flatten();
424 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
425 drop(identity_span);
426
427 let known = coverage.is_some();
435 let warming_ok = cwd_is_git || known;
436 if crate::trace::enabled() {
437 crate::trace!(
438 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
439 root.as_deref().map_or("?".into(), crate::trace::abbrev),
440 identity.as_deref().unwrap_or("none"),
441 coverage.as_deref().unwrap_or("none"),
442 active_paths.len(),
443 );
444 }
445 let repo_span = crate::profile::span("setup: repo state");
446 let current = identity
447 .as_deref()
448 .and_then(|id| store.repository_id(id).ok().flatten());
449 let only_repo = if all_repos { None } else { current };
452 let active = crate::search::ActiveFiles::new(active_paths.clone());
453
454 if !no_record && let Some(repo) = current {
458 let qn = query.to_ascii_lowercase();
459 if store.is_repeat_search(repo, &qn).unwrap_or(false) {
460 let _ = store.decay_selections(repo, &qn);
461 }
462 }
463
464 drop(repo_span);
465 let warm_span = crate::profile::span("setup: warm decision");
466
467 let warm_budget = if warm_detach_enabled() {
474 answer_warm_budget()
475 } else {
476 answer_warm_budget() + deferred_warm_budget()
477 };
478 let was_warming = coverage.as_deref() != Some("complete");
479 let want_warm = warming_ok
480 && match &root {
481 Some(c) => {
482 was_warming || !repo_unchanged_since_index(&store, c, current, coverage.as_deref())
483 }
484 None => false,
485 };
486
487 let block = want_warm && was_warming && !no_wait;
501 let progress_ui = block && show_progress(out, stderr_interactive());
506 let indexer_budget = if block { wait_budget } else { warm_budget };
507 if progress_ui {
508 install_interrupt_handler();
509 }
510
511 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
514 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
515 crate::trace!(
516 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
517 crate::index::parse_jobs()
518 );
519 let root = root.clone().expect("checked");
520 let active = active_paths.clone();
521 let q = query.to_string();
522 let warm_done = std::sync::Arc::clone(&warm_done);
523 std::thread::spawn(move || {
524 if let Ok(mut idx) = open_store() {
525 let _ = if block {
527 crate::index::index_budgeted_cancellable(
530 &mut idx,
531 &root,
532 &active,
533 indexer_budget,
534 Some(&q),
535 &INTERRUPTED,
536 )
537 } else {
538 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
539 };
540 }
541 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
542 })
543 });
544
545 crate::trace!(
552 "setup (open + repo detect + warm decision): {} ms",
553 t_setup.elapsed().as_millis()
554 );
555 let poll_start = std::time::Instant::now();
556 let deadline = if progress_ui {
560 None
561 } else if block {
562 Some(poll_start + wait_budget)
563 } else {
564 Some(poll_start + answer_warm_budget())
565 };
566 drop(warm_span);
567 let polling = indexer.is_some() && was_warming;
568 drop(setup_span);
572 let mut query_span = crate::profile::span("query");
573 let label = repo_label(root.as_deref());
574 let mut drew_progress = false;
575 let mut last_draw = poll_start;
576 let mut hits = loop {
577 match crate::search::search(&store, query, current, only_repo, &active, limit) {
578 Ok(h) => {
579 let confident = h.first().is_some_and(|hit| {
580 hit.features
581 .iter()
582 .any(|f| matches!(f.name, "exact" | "prefix"))
583 });
584 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
585 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
586 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
587 if !polling || confident || warm_finished || stopped || timed_out {
588 break h;
589 }
590 if progress_ui
591 && poll_start.elapsed() >= HEADS_UP_DELAY
592 && last_draw.elapsed() >= PROGRESS_REDRAW
593 {
594 draw_progress(&store, identity.as_deref(), &label);
595 drew_progress = true;
596 last_draw = std::time::Instant::now();
597 }
598 }
599 Err(e) => {
600 if let Some(h) = indexer {
601 let _ = h.join();
602 }
603 return fail(format_args!("rq: {e}"));
604 }
605 }
606 std::thread::sleep(POLL_INTERVAL);
607 };
608 query_span.note(|| {
609 if polling {
610 "polled a warming index".to_string()
611 } else {
612 String::new()
613 }
614 });
615 drop(query_span);
616 if drew_progress {
617 clear_progress();
618 }
619 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
621
622 if !hits.is_empty() && revalidate_top(&mut store, &hits) {
624 hits = crate::search::search(&store, query, current, only_repo, &active, limit)
625 .unwrap_or_default();
626 }
627
628 if !hits.iter().any(strong)
632 && indexer.is_none()
633 && coverage.is_none()
634 && let Some(root) = &root
635 {
636 let tail = live_fallback(root, query, limit);
637 hits = crate::search::merge(hits, tail, limit);
638 }
639
640 apply_gates(query, &mut hits);
641 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
642
643 if hits.is_empty() {
644 if block {
646 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
647 }
648 if let Some(h) = indexer {
649 let _ = h.join();
650 }
651 let incomplete = (block || no_wait)
658 && identity
659 .as_deref()
660 .and_then(|id| store.coverage_status(id).ok().flatten())
661 .as_deref()
662 != Some("complete");
663 maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
666 return no_match_code(out, query, interrupted, incomplete);
667 }
668
669 for hit in &mut hits {
672 hit.signature = read_signature(
673 &store,
674 &hit.repo_identity,
675 &hit.file,
676 hit.line,
677 cwd.as_deref(),
678 );
679 }
680 attach_confidence(&mut hits);
681
682 if show && let Some(code) = show_top_definition(&store, &mut hits, query, out, cwd.as_deref()) {
685 return code;
686 }
687
688 if open {
692 return finish_open(
693 &mut store,
694 &hits,
695 query,
696 current,
697 root.as_deref(),
698 no_record,
699 );
700 }
701
702 if let Some(code) = render_hits(args, &hits) {
703 return code;
704 }
705
706 if crate::profile::enabled() {
709 let total = profile_started.elapsed();
710 if args.out == Output::Text {
711 for line in crate::profile::report(total) {
712 eprintln!("{line}");
713 }
714 } else {
715 eprintln!("{}", crate::profile::json(total));
718 }
719 }
720
721 if let Some(refresh) = branch_refresh {
726 refresh.store(&store);
727 }
728
729 if !no_record {
734 let _ = store.record_event(
735 "search",
736 Some(&query.to_ascii_lowercase()),
737 current,
738 None,
739 None,
740 None,
741 );
742 }
743 deferred_maintenance(&mut store);
744
745 if block {
750 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
751 }
752 if let Some(h) = indexer {
753 let _ = h.join();
754 }
755 maybe_detach_warm(&store, want_warm, root.as_deref(), identity.as_deref());
756
757 ExitCode::SUCCESS
758}
759
760fn maybe_detach_warm(
763 store: &Store,
764 want_warm: bool,
765 root: Option<&std::path::Path>,
766 identity: Option<&str>,
767) {
768 if !warm_detach_enabled() || !want_warm {
769 return;
770 }
771 let (Some(root), Some(id)) = (root, identity) else {
772 return;
773 };
774 if store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
775 return; }
777 spawn_detached_warm(root);
778}
779
780fn spawn_detached_warm(root: &std::path::Path) {
784 use std::os::unix::process::CommandExt;
785 let Ok(exe) = std::env::current_exe() else {
786 return;
787 };
788 let mut cmd = std::process::Command::new(exe);
789 cmd.arg("--warm")
790 .arg(root)
791 .stdin(std::process::Stdio::null())
792 .stdout(std::process::Stdio::null())
793 .stderr(std::process::Stdio::null())
794 .process_group(0);
795 match cmd.spawn() {
796 Ok(child) => crate::trace!(
797 "detached warm: pid {} for {}",
798 child.id(),
799 crate::trace::abbrev(root)
800 ),
801 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
802 }
803}
804
805const WARM_LOCK_TTL_SECS: i64 = 600;
808
809fn cmd_warm(path: Option<&str>) -> ExitCode {
814 #[cfg(target_os = "macos")]
817 unsafe extern "C" {
818 fn setiopolicy_np(
821 iotype: libc::c_int,
822 scope: libc::c_int,
823 policy: libc::c_int,
824 ) -> libc::c_int;
825 }
826 unsafe {
827 libc::nice(10);
828 #[cfg(target_os = "macos")]
829 setiopolicy_np(0, 0, 3);
830 }
831 let mut store = match open_store() {
832 Ok(s) => s,
833 Err(_) => return ExitCode::FAILURE,
834 };
835 let start = path
836 .map(PathBuf::from)
837 .or_else(|| std::env::current_dir().ok())
838 .unwrap_or_else(|| PathBuf::from("."));
839 let root = crate::index::repo_root(&start).unwrap_or(start);
840 let identity = resolve_identity(&store, &root);
841
842 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
845 && pid != std::process::id()
846 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
847 && now_secs() - ts < WARM_LOCK_TTL_SECS
848 {
849 return ExitCode::SUCCESS;
850 }
851 let _ = store.set_warm_lock(&identity, std::process::id());
852
853 let deadline = std::time::Instant::now() + warm_bg_budget();
856 let active = crate::index::branch_changed_files(&root);
857 loop {
858 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
859 if remaining.is_zero() {
860 break;
861 }
862 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
863 {
864 Ok(s) => s,
865 Err(_) => break,
866 };
867 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
868 || stats.files_indexed == 0
869 {
870 break;
871 }
872 }
873 let _ = store.clear_warm_lock(&identity);
874 ExitCode::SUCCESS
875}
876
877fn now_secs() -> i64 {
878 std::time::SystemTime::now()
879 .duration_since(std::time::UNIX_EPOCH)
880 .map(|d| d.as_secs() as i64)
881 .unwrap_or(0)
882}
883
884fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
887 crate::trace!("empty → live (in-memory) scan of an untracked dir");
888 let deadline = std::time::Instant::now() + live_fallback_budget();
889 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
890 if !h.is_empty() {
891 return h;
892 }
893 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
894}
895
896fn strong(h: &crate::search::Hit) -> bool {
898 h.features
899 .iter()
900 .any(|f| matches!(f.name, "exact" | "prefix"))
901}
902
903fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
912 if hits.iter().any(strong) {
913 hits.retain(strong);
914 }
915 crate::search::apply_scope_gate(query, hits);
916}
917
918fn apply_post_filters(
921 args: &SearchArgs,
922 cwd: Option<&std::path::Path>,
923 root: Option<&std::path::Path>,
924 hits: &mut Vec<crate::search::Hit>,
925) {
926 if !args.paths.is_empty() {
927 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
931 let base = root.map_or_else(|| here.clone(), PathBuf::from);
932 let norm: Vec<String> = args
933 .paths
934 .iter()
935 .map(|p| repo_relative(&base, &here, p))
936 .collect();
937 hits.retain(|h| under_any(&h.file, &norm));
938 }
939 if !args.kinds.is_empty() {
940 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
941 }
942 if !args.langs.is_empty() {
943 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
944 }
945 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
946 hits.truncate(args.want);
947 }
948}
949
950fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
956 let status = if interrupted {
957 "interrupted"
958 } else if incomplete {
959 "warming"
960 } else {
961 "no_match"
962 };
963 match out {
964 Output::Json | Output::Ndjson => {
965 let obj = serde_json::json!({ "status": status, "query": query });
966 let _ = emit_json(out, &obj); }
968 Output::Text if interrupted => {
969 eprintln!("rq: indexing interrupted — run again to finish")
970 }
971 Output::Text if incomplete => eprintln!(
972 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
973 ),
974 Output::Text => eprintln!("no matches for {query:?}"),
975 }
976 if incomplete {
977 ExitCode::from(2)
978 } else {
979 ExitCode::FAILURE
980 }
981}
982
983fn attach_confidence(hits: &mut [crate::search::Hit]) {
987 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
988 if t.is_none_or(|t| h.score > t) {
989 (Some(h.score), t)
990 } else if s.is_none_or(|s| h.score > s) {
991 (t, Some(h.score))
992 } else {
993 (t, s)
994 }
995 });
996 for hit in hits.iter_mut() {
997 let best_other = if Some(hit.score) == top { second } else { top };
998 hit.confidence = crate::search::confidence(
999 hit.score,
1000 crate::search::match_quality(&hit.features),
1001 best_other,
1002 );
1003 }
1004}
1005
1006fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1009 let render_span = crate::profile::span("render");
1013 if let Some(code) = emit_rows(args.out, hits) {
1014 return Some(code);
1015 }
1016 if args.out != Output::Text {
1017 return None;
1018 }
1019 drop(render_span);
1020 let color = match_color();
1021 let c = color.as_deref();
1022 let query = args.query;
1023 if args.show {
1024 eprintln!(
1026 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1027 hits.len()
1028 );
1029 }
1030 for hit in hits {
1031 let name = hl(&hit.name, query, c);
1034 let qualified = match &hit.parent {
1035 Some(p) => format!("{name} · {p}"),
1036 None => name,
1037 };
1038 println!(
1039 "{}:{} {} {}",
1040 hl_path(&hit.file, query, c),
1041 hit.line,
1042 hit.kind,
1043 qualified
1044 );
1045 if let Some(sig) = &hit.signature {
1046 println!(" {}", hl(sig, query, c));
1047 }
1048 if args.explain {
1049 let parts: Vec<String> = hit
1050 .features
1051 .iter()
1052 .map(|f| format!("{} {:.0}", f.name, f.value))
1053 .collect();
1054 println!(
1055 " confidence {:.2} · score {:.0} = {}",
1056 hit.confidence,
1057 hit.score,
1058 parts.join(" + ")
1059 );
1060 }
1061 }
1062 None
1063}
1064
1065fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1069 use std::io::{IsTerminal, Write};
1070 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1071 return hits.first();
1072 }
1073 let mut err = std::io::stderr();
1074 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1075 for (i, h) in hits.iter().enumerate() {
1076 let _ = writeln!(
1077 err,
1078 " {}. {}:{} {} {}",
1079 i + 1,
1080 h.file,
1081 h.line,
1082 h.kind,
1083 h.name
1084 );
1085 }
1086 let _ = write!(err, "rq> ");
1087 let _ = err.flush();
1088 let mut line = String::new();
1089 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1090 return None; }
1092 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1093}
1094
1095fn parse_choice(input: &str, n: usize) -> Option<usize> {
1098 let s = input.trim();
1099 if s.is_empty() {
1100 return Some(0);
1101 }
1102 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1103 (i < n).then_some(i)
1104}
1105
1106fn finish_open(
1110 store: &mut Store,
1111 hits: &[crate::search::Hit],
1112 query: &str,
1113 current: Option<i64>,
1114 root: Option<&std::path::Path>,
1115 no_record: bool,
1116) -> ExitCode {
1117 let Some(hit) = choose_hit(hits) else {
1118 return ExitCode::SUCCESS; };
1120
1121 if !no_record {
1124 let _ = store.record_event(
1125 "select",
1126 Some(&query.to_ascii_lowercase()),
1127 current,
1128 Some(&hit.file),
1129 Some(hit.line),
1130 None,
1131 );
1132 deferred_maintenance(store);
1133 }
1134
1135 let target = match root {
1138 Some(r) => r.join(&hit.file),
1139 None => PathBuf::from(&hit.file),
1140 };
1141 launch_editor(&target, hit.line)
1142}
1143
1144fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1148 use std::os::unix::process::CommandExt;
1149 let loc = format!("{}:{}", file.display(), line);
1150 match open_command(file, line, &loc) {
1151 Some((prog, args)) => {
1152 let err = std::process::Command::new(&prog).args(&args).exec();
1154 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1155 }
1156 None => {
1157 println!("{loc}");
1158 ExitCode::SUCCESS
1159 }
1160 }
1161}
1162
1163fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1167 let fstr = file.to_string_lossy().into_owned();
1168
1169 if let Some(t) = std::env::var_os("RQ_OPEN") {
1170 let t = t.to_string_lossy();
1171 let mut parts = t.split_whitespace().map(|p| {
1172 p.replace("{file}", &fstr)
1173 .replace("{line}", &line.to_string())
1174 .replace("{}", loc)
1175 });
1176 if let Some(prog) = parts.next() {
1177 return Some((prog, parts.collect()));
1178 }
1179 }
1180
1181 if on_path("code") {
1182 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1183 }
1184
1185 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1186 let ed = ed.to_string_lossy().into_owned();
1187 let l = ed.to_ascii_lowercase();
1188 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1190 .iter()
1191 .any(|e| l.contains(e))
1192 {
1193 return Some((ed, vec![format!("+{line}"), fstr]));
1194 }
1195 return Some((ed, vec![fstr]));
1196 }
1197
1198 None
1199}
1200
1201fn on_path(prog: &str) -> bool {
1203 std::env::var_os("PATH")
1204 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1205}
1206
1207fn unix_now() -> i64 {
1217 std::time::SystemTime::now()
1218 .duration_since(std::time::UNIX_EPOCH)
1219 .map(|d| d.as_secs() as i64)
1220 .unwrap_or(0)
1221}
1222
1223const BRANCH_FILES_TTL_SECS: i64 = 15;
1229
1230struct BranchRefresh {
1234 handle: std::thread::JoinHandle<Vec<String>>,
1235 identity: String,
1236 stamp: String,
1237}
1238
1239impl BranchRefresh {
1240 fn store(self, store: &Store) {
1242 let Ok(files) = self.handle.join() else {
1243 return;
1244 };
1245 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1246 }
1247}
1248
1249fn cached_branch_files(
1263 store: &Store,
1264 root: &std::path::Path,
1265) -> (Vec<String>, Option<BranchRefresh>) {
1266 let identity = resolve_identity(store, root);
1267 let stamp = crate::index::branch_files_stamp(root);
1268 let cached = store.branch_files_get(&identity).ok().flatten();
1269 let now = unix_now();
1270
1271 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1272 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1273 return (files.clone(), None);
1274 }
1275 let owned_root = root.to_path_buf();
1276 let refresh = BranchRefresh {
1277 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1278 identity,
1279 stamp: stamp.clone(),
1280 };
1281 return (files.clone(), Some(refresh));
1282 }
1283
1284 let files = crate::index::branch_changed_files(root);
1286 if let Some(stamp) = stamp {
1287 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1288 }
1289 (files, None)
1290}
1291
1292fn repo_unchanged_since_index(
1293 store: &Store,
1294 cwd: &std::path::Path,
1295 current: Option<i64>,
1296 coverage: Option<&str>,
1297) -> bool {
1298 if coverage != Some("complete") {
1299 return false;
1300 }
1301 let Some(id) = current else { return false };
1302 let indexed_head = store.indexed_head(id).ok().flatten();
1303 indexed_head.is_some()
1304 && crate::index::git_head(cwd) == indexed_head
1305 && !crate::index::is_dirty(cwd)
1306}
1307
1308fn answer_warm_budget() -> Duration {
1317 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1318}
1319
1320fn deferred_warm_budget() -> Duration {
1323 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1324}
1325
1326fn live_fallback_budget() -> Duration {
1329 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1330}
1331
1332fn warm_bg_budget() -> Duration {
1335 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1336}
1337
1338fn warm_detach_enabled() -> bool {
1342 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1343}
1344
1345fn wait_budget() -> Duration {
1354 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1355}
1356
1357fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1362 let s = s.trim();
1363 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1364 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1366 (n, 1.0)
1367 } else if let Some(n) = s.strip_suffix('s') {
1368 (n, 1_000.0)
1369 } else if let Some(n) = s.strip_suffix('m') {
1370 (n, 60_000.0)
1371 } else {
1372 (s, 1_000.0)
1374 };
1375 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1376 if !val.is_finite() || val < 0.0 {
1377 return Err(bad());
1378 }
1379 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1380}
1381
1382static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1386
1387extern "C" fn on_sigint(_: libc::c_int) {
1388 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1390}
1391
1392fn install_interrupt_handler() {
1395 static ONCE: std::sync::Once = std::sync::Once::new();
1396 ONCE.call_once(|| unsafe {
1397 let mut action: libc::sigaction = std::mem::zeroed();
1398 action.sa_sigaction = on_sigint as *const () as usize;
1399 libc::sigemptyset(&mut action.sa_mask);
1400 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1401 });
1402}
1403
1404fn stderr_interactive() -> bool {
1408 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1409}
1410
1411fn show_progress(out: Output, interactive: bool) -> bool {
1416 interactive && matches!(out, Output::Text)
1417}
1418
1419fn repo_label(root: Option<&std::path::Path>) -> String {
1422 root.and_then(|r| r.file_name())
1423 .map(|n| n.to_string_lossy().into_owned())
1424 .unwrap_or_else(|| "repo".into())
1425}
1426
1427fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1431 let files = identity
1432 .and_then(|id| store.repository_id(id).ok().flatten())
1433 .and_then(|rid| store.repo_totals(rid).ok())
1434 .map_or(0, |(f, _)| f);
1435 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1436 let _ = std::io::stderr().flush();
1437}
1438
1439fn clear_progress() {
1441 eprint!("\r\x1b[K");
1442 let _ = std::io::stderr().flush();
1443}
1444
1445fn env_budget(var: &str, default_ms: u64) -> Duration {
1449 let ms = std::env::var(var)
1450 .ok()
1451 .and_then(|v| v.parse().ok())
1452 .unwrap_or(default_ms);
1453 Duration::from_millis(ms)
1454}
1455
1456const AGGREGATE_BATCH: usize = 256;
1459
1460const KEEP_RECENT_EVENTS: i64 = 200;
1463
1464fn deferred_maintenance(store: &mut Store) {
1467 let _ = store.aggregate_events(AGGREGATE_BATCH);
1468 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1469}
1470
1471fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1474 let mut store = match open_store() {
1475 Ok(s) => s,
1476 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1477 };
1478 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1479 let identity = crate::index::detect_identity(&cwd).to_string();
1480 let repo_id = store.repository_id(&identity).ok().flatten();
1481
1482 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1485 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1486 None => file.to_string(),
1487 };
1488 let query_norm = query.map(|q| q.to_ascii_lowercase());
1489
1490 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1491 {
1492 return fail(format_args!("rq record: {e}"));
1493 }
1494 deferred_maintenance(&mut store);
1495 ExitCode::SUCCESS
1496}
1497
1498fn hit_file_roots(
1504 store: &Store,
1505 repo_identity: &str,
1506 cwd: Option<&std::path::Path>,
1507) -> Vec<PathBuf> {
1508 let mut roots: Vec<PathBuf> = store
1509 .repository_id(repo_identity)
1510 .ok()
1511 .flatten()
1512 .map(|id| store.checkout_roots(id).unwrap_or_default())
1513 .unwrap_or_default()
1514 .into_iter()
1515 .map(PathBuf::from)
1516 .collect();
1517 if let Some(c) = cwd {
1518 let c = c.to_path_buf();
1519 if !roots.contains(&c) {
1520 roots.push(c);
1521 }
1522 }
1523 roots
1524}
1525
1526fn read_signature(
1529 store: &Store,
1530 repo_identity: &str,
1531 file: &str,
1532 line: i64,
1533 cwd: Option<&std::path::Path>,
1534) -> Option<String> {
1535 hit_file_roots(store, repo_identity, cwd)
1536 .into_iter()
1537 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1538}
1539
1540const SHOW_CONFIDENCE: f64 = 0.85;
1544
1545fn show_top_definition(
1549 store: &Store,
1550 hits: &mut [crate::search::Hit],
1551 query: &str,
1552 out: Output,
1553 cwd: Option<&std::path::Path>,
1554) -> Option<ExitCode> {
1555 let top = hits.first()?;
1556 if top.confidence < SHOW_CONFIDENCE {
1557 return None; }
1559 let end = top.end_line.unwrap_or(top.line);
1560 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1561 hits[0].body = body;
1562 let top = &hits[0];
1563 match out {
1564 Output::Json | Output::Ndjson => {
1565 return Some(emit_json(out, top));
1567 }
1568 Output::Text => {
1569 let color = match_color();
1570 let c = color.as_deref();
1571 let name = hl(&top.name, query, c);
1572 let qualified = match &top.parent {
1573 Some(p) => format!("{name} · {p}"),
1574 None => name,
1575 };
1576 println!(
1577 "{}:{} {} {}",
1578 hl_path(&top.file, query, c),
1579 top.line,
1580 top.kind,
1581 qualified
1582 );
1583 match (&top.body, &top.signature) {
1584 (Some(body), _) => println!("{body}"),
1585 (None, Some(sig)) => println!("{sig}"),
1587 (None, None) => {}
1588 }
1589 }
1590 }
1591 Some(ExitCode::SUCCESS)
1592}
1593
1594fn read_span(
1597 store: &Store,
1598 repo_identity: &str,
1599 file: &str,
1600 start: i64,
1601 end: i64,
1602 cwd: Option<&std::path::Path>,
1603) -> Option<String> {
1604 hit_file_roots(store, repo_identity, cwd)
1605 .into_iter()
1606 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1607}
1608
1609fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1612 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1613 let lines: Vec<&str> = content.lines().collect();
1614 if s >= lines.len() {
1615 return None;
1616 }
1617 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1618 Some(lines[s..e].join("\n"))
1619}
1620
1621fn signature_in(content: &str, line: i64) -> Option<String> {
1625 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1626 let l = content.lines().nth(idx)?.trim();
1627 (!l.is_empty()).then(|| l.to_string())
1628}
1629
1630#[derive(serde::Serialize)]
1634struct SymbolOut {
1635 name: String,
1636 kind: String,
1637 language: String,
1638 file: String,
1639 line: i64,
1640 #[serde(skip_serializing_if = "Option::is_none")]
1641 end_line: Option<i64>,
1642 #[serde(skip_serializing_if = "Option::is_none")]
1643 parent: Option<String>,
1644 #[serde(skip_serializing_if = "Option::is_none")]
1645 visibility: Option<String>,
1646 repo: String,
1647 #[serde(skip_serializing_if = "Option::is_none")]
1648 signature: Option<String>,
1649}
1650
1651fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
1656 let mut store = match open_store() {
1657 Ok(s) => s,
1658 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1659 };
1660 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1661 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
1662 let rel = repo_relative(&root, &cwd, file_arg);
1663
1664 let identity = resolve_identity(&store, &root);
1665 let coverage = store.coverage_status(&identity).ok().flatten();
1666 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
1667 let current = store.repository_id(&identity).ok().flatten();
1668 let needs_warm = warming_ok
1669 && (coverage.as_deref() != Some("complete")
1670 || !repo_unchanged_since_index(&store, &root, current, coverage.as_deref()));
1671 if needs_warm {
1672 let budget = answer_warm_budget() + deferred_warm_budget();
1674 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
1675 }
1676
1677 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
1678 return emit_symbols(out, &[]); };
1680 let mut rows = match store.symbols_in_file(repo_id, &rel) {
1681 Ok(r) => r,
1682 Err(e) => return fail(format_args!("rq: {e}")),
1683 };
1684 if !kinds.is_empty() {
1685 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
1686 }
1687 if !langs.is_empty() {
1688 rows.retain(|r| langs.iter().any(|l| l == &r.language));
1689 }
1690
1691 let content = hit_file_roots(&store, &identity, Some(&root))
1695 .iter()
1696 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
1697 let syms: Vec<SymbolOut> = rows
1698 .into_iter()
1699 .map(|r| SymbolOut {
1700 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
1701 name: r.name,
1702 kind: r.kind,
1703 language: r.language,
1704 file: r.file,
1705 line: r.line,
1706 end_line: r.end_line,
1707 parent: r.parent,
1708 visibility: r.visibility,
1709 repo: r.repo_identity,
1710 })
1711 .collect();
1712 emit_symbols(out, &syms)
1713}
1714
1715fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
1718 if syms.is_empty() {
1719 match out {
1720 Output::Json | Output::Ndjson => {
1721 let obj = serde_json::json!({ "status": "no_match" });
1722 let _ = emit_json(out, &obj); }
1724 Output::Text => eprintln!("no symbols"),
1725 }
1726 return ExitCode::FAILURE;
1727 }
1728 if let Some(code) = emit_rows(out, syms) {
1729 return code;
1730 }
1731 match out {
1732 Output::Json | Output::Ndjson => {}
1733 Output::Text => {
1734 for s in syms {
1735 let qualified = match &s.parent {
1736 Some(p) => format!("{} · {p}", s.name),
1737 None => s.name.clone(),
1738 };
1739 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
1740 if let Some(sig) = &s.signature {
1741 println!(" {sig}");
1742 }
1743 }
1744 }
1745 }
1746 ExitCode::SUCCESS
1747}
1748
1749fn keyword_kind(token: &str) -> Option<&'static str> {
1754 match token.to_ascii_lowercase().as_str() {
1755 "class" => Some("class"),
1756 "module" => Some("module"),
1757 "method" => Some("method"),
1758 "function" | "fn" => Some("function"),
1759 "struct" => Some("struct"),
1760 "enum" => Some("enum"),
1761 "trait" => Some("trait"),
1762 _ => None,
1763 }
1764}
1765
1766fn split_kind_keyword(
1772 target: String,
1773 dirs: Vec<String>,
1774) -> (Option<&'static str>, String, Vec<String>) {
1775 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
1778 let rest = rest.trim();
1779 if let Some(k) = keyword_kind(head)
1780 && !rest.is_empty()
1781 {
1782 return (Some(k), rest.to_string(), dirs);
1783 }
1784 } else if let Some(k) = keyword_kind(&target)
1785 && let Some((query, extra)) = dirs.split_first()
1786 {
1787 return (Some(k), query.clone(), extra.to_vec());
1789 }
1790 (None, target, dirs)
1791}
1792
1793fn canonical_kind(s: &str) -> String {
1796 match s.to_ascii_lowercase().as_str() {
1797 "c" | "class" => "class",
1798 "m" | "method" => "method",
1799 "f" | "fn" | "func" | "function" => "function",
1800 "mod" | "module" => "module",
1801 "s" | "struct" => "struct",
1802 "e" | "enum" => "enum",
1803 "t" | "trait" => "trait",
1804 other => return other.to_string(),
1805 }
1806 .to_string()
1807}
1808
1809fn canonical_langs(s: &str) -> Vec<String> {
1814 let t = s.to_ascii_lowercase();
1815 let alias = match t.as_str() {
1816 "rb" => Some("ruby"),
1817 "rs" => Some("rust"),
1818 "golang" => Some("go"),
1819 _ => None,
1820 };
1821 let matched: Vec<String> = crate::lang::languages()
1822 .into_iter()
1823 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
1824 .map(str::to_string)
1825 .collect();
1826 if matched.is_empty() { vec![t] } else { matched }
1827}
1828
1829fn match_color() -> Option<String> {
1833 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
1834 return None;
1835 }
1836 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
1837 gc.split(':').find_map(|e| {
1838 e.strip_prefix("mt=")
1839 .or_else(|| e.strip_prefix("ms="))
1840 .filter(|v| !v.is_empty())
1841 .map(str::to_string)
1842 })
1843 });
1844 Some(style.unwrap_or_else(|| "1;31".to_string()))
1845}
1846
1847fn hl(text: &str, query: &str, color: Option<&str>) -> String {
1850 match color {
1851 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
1852 None => text.to_string(),
1853 }
1854}
1855
1856fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
1859 let Some(c) = color else {
1860 return path.to_string();
1861 };
1862 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
1863 let base_start = path[..base_byte].chars().count();
1864 let stem = crate::search::path_stem(path);
1868 let positions: Vec<usize> = crate::search::match_positions(query, stem)
1869 .into_iter()
1870 .map(|p| p + base_start)
1871 .collect();
1872 highlight(path, &positions, c)
1873}
1874
1875fn highlight(text: &str, positions: &[usize], color: &str) -> String {
1878 if positions.is_empty() {
1879 return text.to_string();
1880 }
1881 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
1882 let mut out = String::new();
1883 let mut on = false;
1884 for (i, c) in text.chars().enumerate() {
1885 match (matched.contains(&i), on) {
1886 (true, false) => {
1887 out.push_str("\x1b[");
1888 out.push_str(color);
1889 out.push('m');
1890 on = true;
1891 }
1892 (false, true) => {
1893 out.push_str("\x1b[0m");
1894 on = false;
1895 }
1896 _ => {}
1897 }
1898 out.push(c);
1899 }
1900 if on {
1901 out.push_str("\x1b[0m");
1902 }
1903 out
1904}
1905
1906fn under_any(file: &str, paths: &[String]) -> bool {
1910 paths.iter().any(|p| {
1911 let p = p.trim_start_matches("./").trim_end_matches('/');
1912 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
1913 })
1914}
1915
1916fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
1918 let p = std::path::Path::new(file);
1919 let abs = if p.is_absolute() {
1920 p.to_path_buf()
1921 } else {
1922 cwd.join(p)
1923 };
1924 let abs = abs.canonicalize().unwrap_or(abs);
1925 abs.strip_prefix(root)
1926 .map(|r| r.to_string_lossy().into_owned())
1927 .unwrap_or_else(|_| file.to_string())
1928}
1929
1930fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
1934 use std::collections::HashSet;
1935 let mut seen = HashSet::new();
1936 let mut changed = false;
1937 for hit in hits {
1938 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
1939 continue;
1940 }
1941 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
1942 continue;
1943 };
1944 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
1945 continue;
1946 };
1947 if let Ok(crate::index::Refresh::Updated) =
1948 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
1949 {
1950 changed = true;
1951 }
1952 }
1953 changed
1954}
1955
1956fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
1962 if let Ok(canon) = cwd.canonicalize() {
1963 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
1964 return identity;
1965 }
1966 if crate::index::repo_root(cwd).is_none() {
1967 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
1968 }
1969 }
1970 crate::index::detect_identity(cwd).to_string()
1971}
1972
1973fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
1974 let explicit = path.is_some();
1975 let target = path.unwrap_or_else(|| PathBuf::from("."));
1976 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
1981 let mut subdirs = subdirs.to_vec();
1986 if explicit
1987 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
1988 && t != r
1989 && let Ok(rel) = t.strip_prefix(&r)
1990 && !rel.as_os_str().is_empty()
1991 {
1992 subdirs.push(rel.to_string_lossy().into_owned());
1993 }
1994 let mut store = match open_store() {
1995 Ok(s) => s,
1996 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1997 };
1998 let identity = crate::index::detect_identity(&root).to_string();
1999 match crate::index::index_under(&mut store, &root, &subdirs) {
2000 Ok(stats) => {
2001 let subtree = !subdirs.is_empty();
2002 let totals = store
2004 .repository_id(&identity)
2005 .ok()
2006 .flatten()
2007 .and_then(|id| store.repo_totals(id).ok());
2008 match out {
2009 Output::Json | Output::Ndjson => {
2010 let (files, symbols) = match totals {
2011 Some((f, s)) => (Some(f), Some(s)),
2012 None => (None, None),
2013 };
2014 return emit_json(
2015 out,
2016 &serde_json::json!({
2017 "repo": identity,
2018 "scope": if subtree { "subtree" } else { "full" },
2019 "files_added": stats.files_indexed,
2020 "symbols_added": stats.symbols,
2021 "files": files,
2022 "symbols": symbols,
2023 }),
2024 );
2025 }
2026 Output::Text => {
2027 let scope = if subtree { " (subtree seed)" } else { "" };
2028 match totals {
2029 Some((files, symbols)) => println!(
2030 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2031 stats.files_indexed, stats.symbols
2032 ),
2033 None => println!(
2034 "{} file(s)/{} symbol(s) added this run{scope}",
2035 stats.files_indexed, stats.symbols
2036 ),
2037 }
2038 }
2039 }
2040 ExitCode::SUCCESS
2041 }
2042 Err(e) => fail(format_args!("rq --index: {e}")),
2043 }
2044}
2045
2046fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2047 let mut store = match open_store() {
2048 Ok(s) => s,
2049 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2050 };
2051
2052 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2056 let root = crate::index::repo_root(&path).unwrap_or(path);
2057 let from_path = crate::index::detect_identity(&root).to_string();
2058 let resolved = match store.repository_id(&from_path) {
2059 Ok(Some(id)) => Some((from_path.clone(), id)),
2060 Ok(None) => target.as_deref().and_then(|s| {
2061 store
2062 .repository_id(s)
2063 .ok()
2064 .flatten()
2065 .map(|id| (s.to_string(), id))
2066 }),
2067 Err(e) => return fail(format_args!("rq --drop: {e}")),
2068 };
2069
2070 let Some((identity, repo_id)) = resolved else {
2071 return match out {
2073 Output::Text => {
2074 println!("not indexed: {from_path}");
2075 ExitCode::SUCCESS
2076 }
2077 _ => emit_json(
2078 out,
2079 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2080 ),
2081 };
2082 };
2083
2084 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2085 match store.drop_repository(repo_id) {
2086 Ok(()) => match out {
2087 Output::Text => {
2088 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2089 ExitCode::SUCCESS
2090 }
2091 _ => emit_json(
2092 out,
2093 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2094 ),
2095 },
2096 Err(e) => fail(format_args!("rq --drop: {e}")),
2097 }
2098}
2099
2100fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2104 let rendered = if out == Output::Json {
2105 serde_json::to_string_pretty(value)
2106 } else {
2107 serde_json::to_string(value)
2108 };
2109 match rendered {
2110 Ok(s) => {
2111 println!("{s}");
2112 ExitCode::SUCCESS
2113 }
2114 Err(e) => fail(format_args!("rq: {e}")),
2115 }
2116}
2117
2118fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2122 match out {
2123 Output::Json => match serde_json::to_string_pretty(rows) {
2124 Ok(s) => println!("{s}"),
2125 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2126 },
2127 Output::Ndjson => {
2128 for r in rows {
2129 match serde_json::to_string(r) {
2130 Ok(line) => println!("{line}"),
2131 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2132 }
2133 }
2134 }
2135 Output::Text => {}
2136 }
2137 None
2138}
2139
2140fn cmd_status(out: Output) -> ExitCode {
2141 let store = match open_store() {
2142 Ok(s) => s,
2143 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2144 };
2145 let rows = match store.coverage_overview() {
2146 Ok(rows) => rows,
2147 Err(e) => return fail(format_args!("rq --status: {e}")),
2148 };
2149 if let Some(code) = emit_rows(out, &rows) {
2150 return code;
2151 }
2152 match out {
2153 Output::Json | Output::Ndjson => {}
2154 Output::Text if rows.is_empty() => {
2155 println!("no repositories indexed yet (try `rq --index`)");
2156 }
2157 Output::Text => {
2158 for r in &rows {
2159 println!(
2160 "{:<10} {:>6} files {:>7} symbols {}",
2161 r.status, r.files, r.symbols, r.identity
2162 );
2163 }
2164 }
2165 }
2166 ExitCode::SUCCESS
2167}
2168
2169fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2171 let path = db_path()?;
2172 if let Some(parent) = path.parent() {
2173 std::fs::create_dir_all(parent)?;
2174 }
2175 Ok(Store::open(&path)?)
2176}
2177
2178fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2180 if let Ok(p) = std::env::var("RQ_DB") {
2181 return Ok(PathBuf::from(p));
2182 }
2183 let home = std::env::var("HOME")?;
2184 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2185}
2186
2187fn fail(args: std::fmt::Arguments) -> ExitCode {
2188 eprintln!("{args}");
2189 ExitCode::FAILURE
2190}
2191
2192#[cfg(test)]
2193mod tests {
2194 use super::*;
2195
2196 #[test]
2197 fn open_menu_choice_parsing() {
2198 assert_eq!(parse_choice("\n", 5), Some(0));
2200 assert_eq!(parse_choice(" ", 5), Some(0));
2201 assert_eq!(parse_choice("3", 5), Some(2));
2202 assert_eq!(parse_choice("5", 5), Some(4));
2203 assert_eq!(parse_choice("6", 5), None);
2205 assert_eq!(parse_choice("0", 5), None);
2206 assert_eq!(parse_choice("q", 5), None);
2207 }
2208
2209 #[test]
2210 fn wait_duration_parsing() {
2211 use std::time::Duration;
2212 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2214 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2215 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2216 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2217 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2219 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2220 assert!(parse_wait("0s").unwrap().is_zero());
2221 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2223 assert!(parse_wait("2x").is_err());
2225 assert!(parse_wait("").is_err());
2226 assert!(parse_wait("s").is_err());
2227 assert!(parse_wait("-1s").is_err());
2228 }
2229
2230 #[test]
2231 fn leading_kind_keyword_becomes_a_kind_filter() {
2232 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2233 assert_eq!(
2235 split_kind_keyword("class".into(), d(&["Widget"])),
2236 (Some("class"), "Widget".into(), vec![])
2237 );
2238 assert_eq!(
2240 split_kind_keyword("method zoom".into(), vec![]),
2241 (Some("method"), "zoom".into(), vec![])
2242 );
2243 assert_eq!(
2245 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2246 (Some("function"), "Foo::run".into(), vec![])
2247 );
2248 assert_eq!(
2250 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2251 (Some("struct"), "Gadget".into(), d(&["src"]))
2252 );
2253 }
2254
2255 #[test]
2256 fn a_bare_or_non_keyword_query_is_left_alone() {
2257 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2258 assert_eq!(
2260 split_kind_keyword("class".into(), vec![]),
2261 (None, "class".into(), vec![])
2262 );
2263 assert_eq!(
2265 split_kind_keyword("Widget".into(), d(&["app"])),
2266 (None, "Widget".into(), d(&["app"]))
2267 );
2268 assert_eq!(
2270 split_kind_keyword("c".into(), d(&["Foo"])),
2271 (None, "c".into(), d(&["Foo"]))
2272 );
2273 }
2274
2275 #[test]
2276 fn highlight_wraps_matched_runs() {
2277 assert_eq!(
2278 highlight("FooThing", &[0, 1, 2], "1;31"),
2279 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2280 );
2281 assert_eq!(
2283 highlight("FooThing", &[0, 3], "1"),
2284 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2285 );
2286 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2288 }
2289
2290 #[test]
2291 fn progress_ui_only_for_an_interactive_text_terminal() {
2292 assert!(show_progress(Output::Text, true));
2294
2295 assert!(!show_progress(Output::Json, true));
2297 assert!(!show_progress(Output::Ndjson, true));
2298
2299 assert!(!show_progress(Output::Text, false));
2301 }
2302
2303 #[test]
2304 fn repo_label_uses_the_directory_name() {
2305 assert_eq!(
2306 repo_label(Some(std::path::Path::new("/src/widgets"))),
2307 "widgets"
2308 );
2309 assert_eq!(repo_label(None), "repo");
2310 }
2311
2312 #[test]
2313 fn hl_path_highlights_the_stem_not_the_extension() {
2314 let out = hl_path(
2317 "app/employees_controller.rb",
2318 "employeescontroller",
2319 Some("1;31"),
2320 );
2321 assert!(
2322 out.starts_with("app/\u{1b}[1;31memployees"),
2323 "stem highlighted: {out:?}"
2324 );
2325 assert!(
2326 out.ends_with("controller\u{1b}[0m.rb"),
2327 "`.rb` left un-highlighted: {out:?}"
2328 );
2329 }
2330}