1use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::core::now_unix;
13use crate::store::Store;
14
15#[derive(Parser)]
19#[command(
20 name = "rq",
21 version,
22 about = "Ranked definition lookup — the one place a symbol is defined, first.",
23 long_about = "rq finds where a symbol is defined and ranks the one you most \
24likely meant to the top — not every match.\n\n\
25Search is the default action; operations are flags, not subcommands, so every \
26word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
27your current repo, recently-active files, and the files your branch changes. Run \
28`rq <query> --explain` to see the score behind each result.",
29 after_help = "EXAMPLES:\n \
30rq thing search for a definition named or like \"thing\"\n \
31rq wibble --explain same, plus the score behind each result\n \
32rq thing --json machine-readable results (for editors/agents)\n \
33rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
34rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
35rq thing app/web restrict to a directory (rg-style)\n \
36rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
37rq class Widget a leading kind keyword is shorthand for -k\n \
38rq --symbols FILE outline a file's definitions, in line order\n \
39rq thing -x rust restrict to a language (ruby/rust/go/python/ts/js)\n \
40rq 'Foo::Bar' qualify by scope — the surest way past an ambiguous name\n \
41rq 'Foo#bar' ...and by owner, for a method\n \
42rq save --anchor w.rb:9 rank as if asked from line 9 of w.rb\n \
43rq Foo.new the constructor (initialize, __init__, ...)\n \
44rq 'refund*proc' wildcards: * (any run), ? (one char) — quote them\n \
45rq -o thing open the best match in your editor\n \
46rq --index index the current repository\n \
47rq --status show indexing coverage\n \
48rq --usage show how rq has been called (by caller and flags)\n \
49rq --drop remove this repo's index (opposite of --index)\n\n\
50SHORT FLAGS (easy to misread):\n \
51-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
52The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
53automatically on the first search in a git repo. On a large, cold repo a search \
54keeps indexing until it can answer rather than reporting a premature \"no \
55matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
56= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
57)]
58struct Cli {
59 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
65 target: Option<String>,
66
67 #[arg(value_name = "PATH")]
69 dirs: Vec<String>,
70
71 #[arg(short = 'e', long)]
73 explain: bool,
74
75 #[arg(long = "no-wait")]
81 no_wait: bool,
82
83 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
88 wait: Option<Duration>,
89
90 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
96 open: bool,
97
98 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
103 web: bool,
104
105 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
109 show: bool,
110
111 #[arg(short = 'j', long)]
113 json: bool,
114
115 #[arg(short = 'J', long, conflicts_with = "json")]
117 ndjson: bool,
118
119 #[arg(short = 'p', long, value_name = "DIR")]
121 path: Vec<String>,
122
123 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
125 limit: usize,
126
127 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
131 kind: Vec<String>,
132
133 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
137 lang: Vec<String>,
138
139 #[arg(short = 'a', long = "all-repos")]
142 all_repos: bool,
143
144 #[arg(long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
150 anchor: Option<AnchorSpec>,
151
152 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
154 index: Option<Option<String>>,
155
156 #[arg(long, conflicts_with = "index")]
158 status: bool,
159
160 #[arg(long, conflicts_with_all = ["index", "status"])]
162 usage: bool,
163
164 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
167 symbols: Option<String>,
168
169 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
173 drop: bool,
174
175 #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "drop", "symbols", "open", "web", "show"])]
179 warm: Option<Option<String>>,
180
181 #[arg(long, value_name = "SHELL")]
183 completions: Option<Shell>,
184
185 #[arg(short = 'v', long)]
188 verbose: bool,
189
190 #[arg(long)]
194 profile: bool,
195
196 #[arg(long, value_name = "N", default_value_t = 0)]
199 jobs: usize,
200}
201
202pub fn run() -> ExitCode {
204 let cli = match Cli::try_parse() {
205 Ok(cli) => cli,
206 Err(err) => return clap_failure(err),
207 };
208 crate::trace::enable_from(cli.verbose);
209 crate::profile::enable_from(cli.profile);
210 crate::index::set_parse_jobs(cli.jobs);
211 let json_out = output_format(&cli) != Output::Text;
212 let code = dispatch(cli);
213 crate::profile::emit(json_out);
214 code
215}
216
217fn dispatch(cli: Cli) -> ExitCode {
218 if let Some(shell) = cli.completions {
219 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
220 return ExitCode::SUCCESS;
221 }
222 if let Some(path) = &cli.index {
223 let out = output_format(&cli);
225 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
226 }
227 if let Some(path) = &cli.warm {
228 return cmd_warm(path.as_deref());
229 }
230 if cli.status {
231 return cmd_status(output_format(&cli));
232 }
233 if cli.usage {
234 return cmd_usage(output_format(&cli));
235 }
236 if cli.drop {
237 let out = output_format(&cli);
238 return cmd_drop(cli.target, out);
239 }
240 let out = output_format(&cli);
241 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
242 return fail(out, Failure::Usage, format_args!("rq: empty query"));
243 }
244 let mut kinds: Vec<String> = Vec::new();
248 for k in &cli.kind {
249 match canonical_kind(k) {
250 Some(c) => kinds.push(c.to_string()),
251 None => {
252 return fail(
253 out,
254 Failure::Usage,
255 format_args!(
256 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant)"
257 ),
258 );
259 }
260 }
261 }
262 let mut langs: Vec<String> = Vec::new();
264 for x in &cli.lang {
265 let matched = canonical_langs(x);
266 if matched.is_empty() {
267 return fail(
268 out,
269 Failure::Usage,
270 format_args!(
271 "rq: unknown --lang {x:?} ({})",
272 crate::lang::languages().join(", ")
273 ),
274 );
275 }
276 langs.extend(matched);
277 }
278 if let Some(file) = &cli.symbols {
279 return cmd_symbols(file, &kinds, &langs, out);
280 }
281 let mut paths = cli.path.clone();
283 match cli.target {
284 Some(target) => {
285 let query = if cli.kind.is_empty() {
288 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
289 if let Some(k) = kw {
290 kinds.push(k.to_string());
291 }
292 paths.extend(dirs);
293 query
294 } else {
295 paths.extend(cli.dirs.clone());
296 target
297 };
298 let mut session = match Session::open(out) {
299 Ok(s) => s,
300 Err(code) => return code,
301 };
302 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
303 cmd_search(
304 &mut session,
305 &SearchArgs {
306 query: &query,
307 explain: cli.explain,
308 out,
309 paths: &paths,
310 kinds: &kinds,
311 langs: &langs,
312 want: requested_limit(cli.limit),
313 no_wait: cli.no_wait,
314 wait: cli.wait,
315 open: cli.open,
316 web: cli.web,
317 all_repos: cli.all_repos,
318 show: cli.show,
319 batch: false,
320 anchored: cli.anchor.is_some(),
321 },
322 )
323 }
324 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
327 None => {
329 let _ = Cli::command().print_long_help();
330 ExitCode::SUCCESS
331 }
332 }
333}
334
335#[derive(Clone, Copy, PartialEq)]
337enum Output {
338 Text,
339 Json,
340 Ndjson,
341}
342
343fn output_format(cli: &Cli) -> Output {
344 if cli.ndjson {
345 Output::Ndjson
346 } else if cli.json {
347 Output::Json
348 } else {
349 Output::Text
350 }
351}
352
353const DEFAULT_LIMIT: usize = 10;
355
356const PATH_HEADROOM: usize = 200;
359
360fn requested_limit(limit: usize) -> usize {
363 if limit == 0 { usize::MAX } else { limit }
364}
365
366fn record_usage(store: &Store, args: &SearchArgs, status: &str, coverage: Option<&str>) {
369 let _ = store.record_search(&crate::store::SearchRecord {
370 source: &crate::origin::detect(),
371 flags: &flag_summary(args),
372 status,
373 coverage: coverage.unwrap_or("none"),
374 });
375}
376
377fn flag_summary(args: &SearchArgs) -> String {
382 let mut on: Vec<&str> = Vec::new();
383 match args.out {
384 Output::Json => on.push("json"),
385 Output::Ndjson => on.push("ndjson"),
386 Output::Text => {}
387 }
388 for (present, name) in [
389 (args.explain, "explain"),
390 (args.show, "show"),
391 (args.open, "open"),
392 (args.web, "web"),
393 (args.all_repos, "all-repos"),
394 (args.no_wait, "no-wait"),
395 (args.batch, "batch"),
396 (args.anchored, "anchor"),
397 (!args.paths.is_empty(), "path"),
398 (!args.kinds.is_empty(), "kind"),
399 (!args.langs.is_empty(), "lang"),
400 (args.want != DEFAULT_LIMIT, "limit"),
401 ] {
402 if present {
403 on.push(name);
404 }
405 }
406 on.join(",")
407}
408
409const POLL_INTERVAL: Duration = Duration::from_millis(100);
416
417const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
421
422const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
426
427struct SearchArgs<'a> {
429 query: &'a str,
430 explain: bool,
431 out: Output,
432 paths: &'a [String],
433 kinds: &'a [String],
434 langs: &'a [String],
435 want: usize,
437 no_wait: bool,
439 wait: Option<Duration>,
442 open: bool,
443 web: bool,
444 all_repos: bool,
445 batch: bool,
449 show: bool,
450 anchored: bool,
452}
453
454struct Session {
465 store: Store,
466 cwd: Option<PathBuf>,
467 cwd_is_git: bool,
468 root: Option<PathBuf>,
469 active_paths: Vec<String>,
470 branch_refresh: Option<BranchRefresh>,
471 identity: Option<String>,
472 coverage: Option<String>,
473 anchor: Option<crate::search::Anchor>,
475}
476
477impl Session {
478 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
480 let open_span = crate::profile::span("store open");
481 let store = match open_store() {
482 Ok(s) => s,
483 Err(e) => {
484 return Err(fail(
485 out,
486 Failure::Database,
487 format_args!("rq: cannot open database: {e}"),
488 ));
489 }
490 };
491 drop(open_span);
492 let git_span = crate::profile::span("setup: git root");
493 let cwd = std::env::current_dir().ok();
494 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
495
496 let root = cwd
502 .as_deref()
503 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
504 drop(git_span);
505
506 let mut branch_span = crate::profile::span("setup: branch files");
509 let (active_paths, branch_refresh, cached_cost) = match &root {
510 Some(c) if cwd_is_git => cached_branch_files(&store, c),
511 _ => (Vec::new(), None, None),
512 };
513 branch_span.note(|| {
514 let how = if branch_refresh.is_some() {
515 "cached, refreshing alongside"
516 } else {
517 "cached"
518 };
519 let ttl = branch_files_ttl(cached_cost);
522 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
523 });
524 drop(branch_span);
525
526 let mut identity_span = crate::profile::span("setup: identity");
531 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
532 let coverage = identity
533 .as_deref()
534 .and_then(|id| store.coverage_status(id).ok())
535 .flatten();
536 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
537 drop(identity_span);
538 Ok(Session {
539 store,
540 cwd,
541 cwd_is_git,
542 root,
543 active_paths,
544 branch_refresh,
545 identity,
546 coverage,
547 anchor: None,
548 })
549 }
550
551 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
555 let _span = crate::profile::span("setup: anchor");
556 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
557 let abs = here.join(&spec.file);
558 let abs = abs.canonicalize().unwrap_or(abs);
559 let root = self
560 .root
561 .clone()
562 .filter(|r| abs.starts_with(r))
563 .or_else(|| crate::index::repo_root(&abs))
564 .or_else(|| abs.parent().map(PathBuf::from))
565 .unwrap_or(here);
566 let identity = resolve_identity(&self.store, &root);
567 let rel = abs
568 .strip_prefix(&root)
569 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
570 .into_owned();
571 let repo_id = self.store.repository_id(&identity).ok().flatten();
572 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
573 crate::search::Anchor::new(identity, rel, spec.line, &defs)
574 }
575}
576
577#[derive(Debug, Clone, PartialEq)]
579struct AnchorSpec {
580 file: PathBuf,
581 line: i64,
582}
583
584fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
587 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
588 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
589 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
590 let last = num(last).ok_or_else(bad)?;
591 let (file, line) = rest
593 .rsplit_once(':')
594 .and_then(|(file, line)| num(line).map(|line| (file, line)))
595 .unwrap_or((rest, last));
596 if file.is_empty() {
597 return Err(bad());
598 }
599 Ok(AnchorSpec {
600 file: PathBuf::from(file),
601 line,
602 })
603}
604
605fn cmd_batch(
620 cli: &Cli,
621 out: Output,
622 paths: &[String],
623 kinds: &[String],
624 langs: &[String],
625) -> ExitCode {
626 if out == Output::Json {
627 return fail(
628 out,
629 Failure::Usage,
630 format_args!(
631 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
632 where each line carries the query it answers"
633 ),
634 );
635 }
636 if cli.open || cli.web || cli.show {
637 return fail(
638 out,
639 Failure::Usage,
640 format_args!(
641 "rq: --open, --web and --show act on a single result, not a stream of queries"
642 ),
643 );
644 }
645
646 use std::io::BufRead;
647 let queries: Vec<String> = std::io::stdin()
648 .lock()
649 .lines()
650 .map_while(std::result::Result::ok)
651 .map(|l| l.trim().to_string())
652 .filter(|l| !l.is_empty())
653 .collect();
654 if queries.is_empty() {
658 let _ = Cli::command().print_long_help();
659 return ExitCode::SUCCESS;
660 }
661
662 let mut session = match Session::open(out) {
663 Ok(s) => s,
664 Err(code) => return code,
665 };
666 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
667
668 if !cli.no_wait
671 && session.coverage.as_deref() != Some("complete")
672 && let Some(root) = session.root.clone()
673 {
674 {
675 let budget = cli.wait.unwrap_or_else(wait_budget);
676 crate::trace!(
677 "batch: warming {} queries' worth of index first",
678 queries.len()
679 );
680 let active = session.active_paths.clone();
681 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
682 session.coverage = session
683 .identity
684 .as_deref()
685 .and_then(|id| session.store.coverage_status(id).ok())
686 .flatten();
687 }
688 }
689
690 let mut worst = ExitCode::SUCCESS;
691 let mut any_hit = false;
692 for query in &queries {
693 let code = cmd_search(
694 &mut session,
695 &SearchArgs {
696 query,
697 explain: cli.explain,
698 out,
699 paths,
700 kinds,
701 langs,
702 want: requested_limit(cli.limit),
703 no_wait: true,
707 wait: cli.wait,
708 open: false,
709 web: false,
710 all_repos: cli.all_repos,
711 show: false,
712 batch: true,
713 anchored: cli.anchor.is_some(),
714 },
715 );
716 if code == ExitCode::SUCCESS {
717 any_hit = true;
718 } else {
719 worst = code;
720 }
721 }
722 if any_hit { ExitCode::SUCCESS } else { worst }
725}
726
727fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
728 let &SearchArgs {
729 query,
730 out,
731 want,
732 no_wait,
733 wait,
734 open,
735 web,
736 all_repos,
737 show,
738 ..
739 } = args;
740 let wait_budget = wait.unwrap_or_else(wait_budget);
743 let no_wait = no_wait || wait_budget.is_zero();
744 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
747 want
748 } else {
749 want.saturating_mul(20).max(PATH_HEADROOM)
750 };
751 let _timer = crate::trace::Timer::start("search done");
752 let t_setup = std::time::Instant::now();
753 let setup_span = crate::profile::span("setup");
755 let Session {
758 store,
759 cwd,
760 cwd_is_git,
761 root,
762 active_paths,
763 branch_refresh,
764 identity,
765 coverage,
766 anchor,
767 } = session;
768 let cwd_is_git = *cwd_is_git;
769
770 let known = coverage.is_some();
778 let warming_ok = cwd_is_git || known;
779 if crate::trace::enabled() {
780 crate::trace!(
781 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
782 root.as_deref().map_or("?".into(), crate::trace::abbrev),
783 identity.as_deref().unwrap_or("none"),
784 coverage.as_deref().unwrap_or("none"),
785 active_paths.len(),
786 );
787 }
788 let repo_span = crate::profile::span("setup: repo state");
789 let repo_id = |store: &Store| {
790 identity
791 .as_deref()
792 .and_then(|id| store.repository_id(id).ok().flatten())
793 };
794 let mut current = repo_id(store);
795 let scope = |current| Scope::new(all_repos, warming_ok, current);
798 let ctx = crate::search::Context {
799 active: crate::search::ActiveFiles::new(active_paths.clone()),
800 anchor: anchor.clone(),
801 };
802
803 drop(repo_span);
804 let warm_span = crate::profile::span("setup: warm decision");
805
806 let warm_budget = if warm_detach_enabled() {
813 answer_warm_budget()
814 } else {
815 answer_warm_budget() + deferred_warm_budget()
816 };
817 let was_warming = coverage.as_deref() != Some("complete");
818
819 let indexed_head = (!was_warming)
830 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
831 .flatten();
832 let staleness = (!was_warming && warming_ok && !args.batch)
835 .then(|| root.clone())
836 .flatten()
837 .map(|c| {
838 if warm_detach_enabled() {
839 Staleness::Deferred(c, indexed_head)
840 } else {
841 Staleness::Running(std::thread::spawn(move || {
842 worktree_edits(&c, indexed_head.as_deref())
843 }))
844 }
845 });
846 let want_warm = warming_ok && was_warming && root.is_some();
849
850 let block = want_warm && was_warming && !no_wait;
864 let progress_ui = block && show_progress(out, stderr_interactive());
869 let indexer_budget = if block { wait_budget } else { warm_budget };
870 if progress_ui {
871 install_interrupt_handler();
872 }
873
874 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
877 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
878 crate::trace!(
879 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
880 crate::index::parse_jobs()
881 );
882 let root = root.clone().expect("checked");
883 let active = active_paths.clone();
884 let q = query.to_string();
885 let warm_done = std::sync::Arc::clone(&warm_done);
886 std::thread::spawn(move || {
887 if let Ok(mut idx) = open_store() {
888 let _ = if block {
890 crate::index::index_budgeted_cancellable(
893 &mut idx,
894 &root,
895 &active,
896 indexer_budget,
897 Some(&q),
898 &INTERRUPTED,
899 )
900 } else {
901 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
902 };
903 }
904 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
905 })
906 });
907
908 crate::trace!(
915 "setup (open + repo detect + warm decision): {} ms",
916 t_setup.elapsed().as_millis()
917 );
918 let poll_start = std::time::Instant::now();
919 let deadline = if progress_ui {
923 None
924 } else if block {
925 Some(poll_start + wait_budget)
926 } else {
927 Some(poll_start + answer_warm_budget())
928 };
929 drop(warm_span);
930 let polling = indexer.is_some() && was_warming;
931 drop(setup_span);
935 let mut query_span = crate::profile::span("query");
936 let label = repo_label(root.as_deref());
937 let mut drew_progress = false;
938 let mut last_draw = poll_start;
939 let rank_limit = limit.max(2);
943 let mut total;
944 let mut hits = loop {
945 if current.is_none() {
947 current = repo_id(store);
948 }
949 match scope(current).search(store, query, current, &ctx, rank_limit) {
950 Ok(m) => {
951 total = m.total;
952 let h = m.hits;
953 let confident = h.first().is_some_and(|hit| {
954 hit.features
955 .iter()
956 .any(|f| matches!(f.name, "exact" | "prefix"))
957 });
958 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
959 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
960 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
961 if !polling || confident || warm_finished || stopped || timed_out {
962 break h;
963 }
964 if progress_ui
965 && poll_start.elapsed() >= HEADS_UP_DELAY
966 && last_draw.elapsed() >= PROGRESS_REDRAW
967 {
968 draw_progress(store, identity.as_deref(), &label);
969 drew_progress = true;
970 last_draw = std::time::Instant::now();
971 }
972 }
973 Err(e) => {
974 if let Some(h) = indexer {
975 let _ = h.join();
976 }
977 return fail(out, Failure::Database, format_args!("rq: {e}"));
978 }
979 }
980 std::thread::sleep(POLL_INTERVAL);
981 };
982 query_span.note(|| {
983 if polling {
984 "polled a warming index".to_string()
985 } else {
986 String::new()
987 }
988 });
989 drop(query_span);
990 if drew_progress {
991 clear_progress();
992 }
993 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
995
996 let here = identity
997 .as_deref()
998 .zip(root.as_deref())
999 .map(|(identity, root)| Here { identity, root });
1000 if !hits.is_empty()
1002 && revalidate_top(store, &hits, here)
1003 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1004 {
1005 total = m.total;
1006 hits = m.hits;
1007 }
1008
1009 if !hits.iter().any(strong)
1013 && indexer.is_none()
1014 && coverage.is_none()
1015 && let Some(root) = &root
1016 {
1017 let tail = live_fallback(root, query, rank_limit, &ctx);
1018 hits = crate::search::merge(hits, tail, rank_limit);
1019 total = total.max(hits.len());
1020 }
1021
1022 apply_gates(query, &mut hits);
1023 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1024 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1028 total = hits.len();
1029 hits.truncate(want.max(2));
1030 }
1031
1032 if hits.is_empty() {
1033 if block {
1035 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1036 }
1037 if let Some(h) = indexer {
1038 let _ = h.join();
1039 }
1040 let mut incomplete = (block || no_wait)
1047 && identity
1048 .as_deref()
1049 .and_then(|id| store.coverage_status(id).ok().flatten())
1050 .as_deref()
1051 != Some("complete");
1052 incomplete |= settle_warm(
1062 store,
1063 staleness,
1064 false,
1065 was_warming,
1066 warming_ok,
1067 root.as_deref(),
1068 active_paths,
1069 query,
1070 warm_budget,
1071 no_wait,
1072 identity.as_deref(),
1073 );
1074 let elsewhere = match scope(current) {
1079 Scope::Nothing => None,
1080 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1081 Scope::Repo(id) => {
1082 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1083 }
1084 };
1085 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1086 let _span = crate::profile::span("after: record usage");
1090 record_usage(
1091 store,
1092 args,
1093 if incomplete { "warming" } else { "miss" },
1094 coverage.as_deref(),
1095 );
1096 return code;
1097 }
1098
1099 let counted_early = show || open || web;
1104 if counted_early {
1105 let _span = crate::profile::span("record usage");
1106 record_usage(store, args, "hit", coverage.as_deref());
1107 }
1108
1109 attach_confidence(&mut hits);
1113 hits.truncate(want);
1114 let total = total.max(hits.len());
1115 for hit in &mut hits {
1116 hit.total = total;
1117 if args.explain {
1118 hit.explain = Some(
1119 hit.features
1120 .iter()
1121 .map(|f| (f.name.to_string(), f.reported()))
1122 .collect(),
1123 );
1124 }
1125 }
1126
1127 let _signatures_span = crate::profile::span("signatures");
1130 for hit in &mut hits {
1131 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1132 hit.signature = root
1133 .as_deref()
1134 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1135 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1136 }
1137 drop(_signatures_span);
1138
1139 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1142 return code;
1143 }
1144
1145 if open || web {
1150 return finish_open(store, &hits, current, root.as_deref(), web);
1151 }
1152
1153 if let Some(code) = render_hits(args, &hits) {
1154 return code;
1155 }
1156
1157 crate::profile::mark("first answer");
1160
1161 if !counted_early {
1164 let _span = crate::profile::span("after: record usage");
1165 record_usage(store, args, "hit", coverage.as_deref());
1166 }
1167
1168 if let Some(refresh) = branch_refresh.take() {
1175 let _span = crate::profile::span("after: branch refresh");
1176 refresh.store(store);
1177 }
1178
1179 if block {
1184 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1185 }
1186 if let Some(h) = indexer {
1187 let _ = h.join();
1188 }
1189 let _ = settle_warm(
1190 store,
1191 staleness,
1192 true,
1193 was_warming,
1194 warming_ok,
1195 root.as_deref(),
1196 active_paths,
1197 query,
1198 warm_budget,
1199 no_wait,
1200 identity.as_deref(),
1201 );
1202
1203 ExitCode::SUCCESS
1204}
1205
1206fn maybe_detach_warm(
1209 store: &Store,
1210 want_warm: bool,
1211 changed: bool,
1212 root: Option<&std::path::Path>,
1213 identity: Option<&str>,
1214) {
1215 if !warm_detach_enabled() || !want_warm {
1216 return;
1217 }
1218 let (Some(root), Some(id)) = (root, identity) else {
1219 return;
1220 };
1221 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1225 return; }
1227 spawn_detached_warm(root);
1228}
1229
1230fn spawn_detached_warm(root: &std::path::Path) {
1234 use std::os::unix::process::CommandExt;
1235 let Ok(exe) = std::env::current_exe() else {
1236 return;
1237 };
1238 let mut cmd = std::process::Command::new(exe);
1239 cmd.arg("--warm")
1240 .arg(root)
1241 .stdin(std::process::Stdio::null())
1242 .stdout(std::process::Stdio::null())
1243 .stderr(std::process::Stdio::null())
1244 .process_group(0);
1245 match cmd.spawn() {
1246 Ok(child) => crate::trace!(
1247 "background warm (detached): pid {} for {}",
1248 child.id(),
1249 crate::trace::abbrev(root)
1250 ),
1251 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1252 }
1253}
1254
1255const WARM_LOCK_TTL_SECS: i64 = 600;
1258
1259fn warm_recheck_window() -> Duration {
1265 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1266}
1267
1268fn recently_verified(
1271 store: &Store,
1272 identity: Option<&str>,
1273 root: &std::path::Path,
1274 indexed_head: Option<&str>,
1275) -> bool {
1276 let _span = crate::profile::span("after: warm recently verified?");
1277 let (Some(id), Some(stamp)) = (
1278 identity,
1279 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1280 ) else {
1281 return false;
1282 };
1283 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1284 return false;
1285 };
1286 let age = now_unix().saturating_sub(at);
1287 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1288}
1289
1290fn cmd_warm(path: Option<&str>) -> ExitCode {
1295 #[cfg(target_os = "macos")]
1298 unsafe extern "C" {
1299 fn setiopolicy_np(
1302 iotype: libc::c_int,
1303 scope: libc::c_int,
1304 policy: libc::c_int,
1305 ) -> libc::c_int;
1306 }
1307 unsafe {
1308 libc::nice(10);
1309 #[cfg(target_os = "macos")]
1310 setiopolicy_np(0, 0, 3);
1311 }
1312 let mut store = match open_store() {
1313 Ok(s) => s,
1314 Err(_) => return ExitCode::FAILURE,
1315 };
1316 let start = path
1317 .map(PathBuf::from)
1318 .or_else(|| std::env::current_dir().ok())
1319 .unwrap_or_else(|| PathBuf::from("."));
1320 let root = crate::index::repo_root(&start).unwrap_or(start);
1321 let identity = resolve_identity(&store, &root);
1322
1323 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1326 && pid != std::process::id()
1327 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1328 && now_unix() - ts < WARM_LOCK_TTL_SECS
1329 {
1330 return ExitCode::SUCCESS;
1331 }
1332 let _ = store.set_warm_lock(&identity, std::process::id());
1333
1334 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1337 let head = store
1338 .repository_id(&identity)
1339 .ok()
1340 .flatten()
1341 .and_then(|id| store.indexed_head(id).ok().flatten());
1342 let checked_at = now_unix();
1346 let edits = worktree_edits(&root, head.as_deref());
1347 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1348 crate::trace!("warm: unchanged since indexed, nothing to do");
1349 if let Some(stamp) = head
1350 .as_deref()
1351 .and_then(|h| crate::index::git_state_stamp(&root, h))
1352 {
1353 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1354 }
1355 let _ = store.clear_warm_lock(&identity);
1356 return ExitCode::SUCCESS;
1357 }
1358 }
1359
1360 let deadline = std::time::Instant::now() + warm_bg_budget();
1363 let active = crate::index::branch_changed_files(&root);
1364 loop {
1365 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1366 if remaining.is_zero() {
1367 break;
1368 }
1369 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1370 {
1371 Ok(s) => s,
1372 Err(_) => break,
1373 };
1374 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1375 || stats.files_indexed == 0
1376 {
1377 break;
1378 }
1379 }
1380 let _ = store.clear_warm_lock(&identity);
1381 ExitCode::SUCCESS
1382}
1383
1384fn live_fallback(
1387 root: &std::path::Path,
1388 query: &str,
1389 limit: usize,
1390 ctx: &crate::search::Context,
1391) -> Vec<crate::search::Hit> {
1392 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1393 let deadline = std::time::Instant::now() + live_fallback_budget();
1394 let scan = |prefilter| {
1395 let _span = crate::profile::span(if prefilter {
1396 "live scan: prefiltered"
1397 } else {
1398 "live scan: unfiltered"
1399 });
1400 crate::search::live_search(
1401 root,
1402 query,
1403 limit,
1404 &HashSet::new(),
1405 Some(deadline),
1406 prefilter,
1407 ctx,
1408 )
1409 };
1410 let h = scan(true);
1411 if !h.is_empty() {
1412 return h;
1413 }
1414 scan(false)
1415}
1416
1417fn strong(h: &crate::search::Hit) -> bool {
1419 h.features
1420 .iter()
1421 .any(|f| matches!(f.name, "exact" | "prefix"))
1422}
1423
1424fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1433 if hits.iter().any(strong) {
1434 hits.retain(strong);
1435 }
1436 crate::search::apply_scope_gate(query, hits);
1437}
1438
1439fn apply_post_filters(
1442 args: &SearchArgs,
1443 cwd: Option<&std::path::Path>,
1444 root: Option<&std::path::Path>,
1445 hits: &mut Vec<crate::search::Hit>,
1446) {
1447 if !args.paths.is_empty() {
1448 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1452 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1453 let norm: Vec<String> = args
1454 .paths
1455 .iter()
1456 .map(|p| repo_relative(&base, &here, p))
1457 .collect();
1458 hits.retain(|h| under_any(&h.file, &norm));
1459 }
1460 if !args.kinds.is_empty() {
1461 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1462 }
1463 if !args.langs.is_empty() {
1464 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1465 }
1466}
1467
1468fn no_match_code(
1474 out: Output,
1475 query: &str,
1476 interrupted: bool,
1477 incomplete: bool,
1478 elsewhere: Option<&str>,
1482) -> ExitCode {
1483 let status = if interrupted {
1484 "interrupted"
1485 } else if incomplete {
1486 "warming"
1487 } else if elsewhere.is_some() {
1488 "scope_not_found"
1489 } else {
1490 "no_match"
1491 };
1492 match out {
1493 Output::Json | Output::Ndjson => {
1494 let mut obj = serde_json::json!({ "status": status, "query": query });
1495 if let Some(found_in) = elsewhere {
1496 obj["found_in"] = serde_json::json!(found_in);
1497 }
1498 let _ = emit_json(out, &obj); }
1500 Output::Text if interrupted => {
1501 eprintln!("rq: indexing interrupted — run again to finish")
1502 }
1503 Output::Text if incomplete => eprintln!(
1504 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1505 ),
1506 Output::Text if elsewhere.is_some() => eprintln!(
1507 "rq: nothing matching {query:?} — that name is defined under {}",
1508 elsewhere.unwrap_or_default()
1509 ),
1510 Output::Text => eprintln!("no matches for {query:?}"),
1511 }
1512 if incomplete {
1513 ExitCode::from(2)
1514 } else {
1515 ExitCode::FAILURE
1516 }
1517}
1518
1519fn attach_confidence(hits: &mut [crate::search::Hit]) {
1523 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1524 if t.is_none_or(|t| h.score > t) {
1525 (Some(h.score), t)
1526 } else if s.is_none_or(|s| h.score > s) {
1527 (t, Some(h.score))
1528 } else {
1529 (t, s)
1530 }
1531 });
1532 for hit in hits.iter_mut() {
1533 let best_other = if Some(hit.score) == top { second } else { top };
1534 hit.confidence = crate::search::confidence(
1535 hit.score,
1536 crate::search::match_quality(&hit.features),
1537 best_other,
1538 );
1539 }
1540}
1541
1542fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1545 let render_span = crate::profile::span("render");
1549 if args.batch {
1550 #[derive(serde::Serialize)]
1553 struct Tagged<'a> {
1554 query: &'a str,
1555 #[serde(flatten)]
1556 hit: &'a crate::search::Hit,
1557 }
1558 let rows: Vec<Tagged> = hits
1559 .iter()
1560 .map(|hit| Tagged {
1561 query: args.query,
1562 hit,
1563 })
1564 .collect();
1565 if let Some(code) = emit_rows(args.out, &rows) {
1566 return Some(code);
1567 }
1568 } else if let Some(code) = emit_rows(args.out, hits) {
1569 return Some(code);
1570 }
1571 if args.out != Output::Text {
1572 return None;
1573 }
1574 drop(render_span);
1575 let color = match_color();
1576 let c = color.as_deref();
1577 let query = args.query;
1578 if args.show {
1579 let total = hits.first().map_or(hits.len(), |h| h.total);
1581 eprintln!(
1582 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1583 hits.len()
1584 );
1585 }
1586 for hit in hits {
1587 let name = hl(&hit.name, query, c);
1590 let qualified = match &hit.parent {
1591 Some(p) => format!("{name} · {p}"),
1592 None => name,
1593 };
1594 println!(
1595 "{}:{} {} {}",
1596 hl_path(&hit.file, query, c),
1597 hit.line,
1598 hit.kind,
1599 qualified
1600 );
1601 if let Some(sig) = &hit.signature {
1602 println!(" {}", hl(sig, query, c));
1603 }
1604 if args.explain {
1605 let parts: Vec<String> = hit
1606 .features
1607 .iter()
1608 .map(|f| format!("{} {}", f.name, f.reported()))
1609 .collect();
1610 println!(
1611 " confidence {:.2} · score {:.0} = {}",
1612 hit.confidence,
1613 hit.score,
1614 parts.join(" + ")
1615 );
1616 }
1617 }
1618 None
1619}
1620
1621fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1625 use std::io::{IsTerminal, Write};
1626 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1627 return hits.first();
1628 }
1629 let mut err = std::io::stderr();
1630 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1631 for (i, h) in hits.iter().enumerate() {
1632 let _ = writeln!(
1633 err,
1634 " {}. {}:{} {} {}",
1635 i + 1,
1636 h.file,
1637 h.line,
1638 h.kind,
1639 h.name
1640 );
1641 }
1642 let _ = write!(err, "rq> ");
1643 let _ = err.flush();
1644 let mut line = String::new();
1645 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1646 return None; }
1648 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1649}
1650
1651fn parse_choice(input: &str, n: usize) -> Option<usize> {
1654 let s = input.trim();
1655 if s.is_empty() {
1656 return Some(0);
1657 }
1658 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1659 (i < n).then_some(i)
1660}
1661
1662fn finish_open(
1665 store: &Store,
1666 hits: &[crate::search::Hit],
1667 current: Option<i64>,
1668 root: Option<&std::path::Path>,
1669 web: bool,
1670) -> ExitCode {
1671 let Some(hit) = choose_hit(hits) else {
1672 return ExitCode::SUCCESS; };
1674
1675 if web {
1676 return open_web(store, hit, current, root);
1677 }
1678
1679 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1682 Some(r) => r.join(&hit.file),
1683 None => PathBuf::from(&hit.file),
1684 };
1685 launch_editor(&target, hit.line)
1686}
1687
1688#[derive(Clone, Copy)]
1690enum Scope {
1691 All,
1693 Repo(i64),
1694 Nothing,
1697}
1698
1699impl Scope {
1700 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1701 match current {
1702 _ if all_repos => Scope::All,
1703 Some(id) => Scope::Repo(id),
1704 None if in_repo => Scope::Nothing,
1705 None => Scope::All,
1706 }
1707 }
1708
1709 fn search(
1710 self,
1711 store: &Store,
1712 query: &str,
1713 current: Option<i64>,
1714 ctx: &crate::search::Context,
1715 limit: usize,
1716 ) -> crate::store::Result<crate::search::Matches> {
1717 let only_repo = match self {
1718 Scope::All => None,
1719 Scope::Repo(id) => Some(id),
1720 Scope::Nothing => {
1721 return Ok(crate::search::Matches {
1722 hits: Vec::new(),
1723 total: 0,
1724 });
1725 }
1726 };
1727 crate::search::search(store, query, current, only_repo, ctx, limit)
1728 }
1729}
1730
1731fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1735 use std::os::unix::process::CommandExt;
1736 let loc = format!("{}:{}", file.display(), line);
1737 match open_command(file, line, &loc) {
1738 Some((prog, args)) => {
1739 crate::profile::emit(false);
1741 let err = std::process::Command::new(&prog).args(&args).exec();
1743 fail(
1744 Output::Text,
1745 Failure::Launch,
1746 format_args!("rq --open: cannot run {prog}: {err}"),
1747 )
1748 }
1749 None => {
1750 println!("{loc}");
1751 ExitCode::SUCCESS
1752 }
1753 }
1754}
1755
1756fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1762 let fstr = file.to_string_lossy().into_owned();
1763
1764 if let Some(t) = std::env::var_os("RQ_OPEN") {
1765 let t = t.to_string_lossy();
1766 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1767 let mut parts = t.split_whitespace().map(|p| {
1768 p.replace("{file}", &fstr)
1769 .replace("{line}", &line.to_string())
1770 .replace("{}", loc)
1771 });
1772 if let Some(prog) = parts.next() {
1773 let mut args: Vec<String> = parts.collect();
1774 if !placeholder {
1775 args.push(loc.to_string());
1776 }
1777 return Some((prog, args));
1778 }
1779 }
1780
1781 if on_path("code") {
1782 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1783 }
1784
1785 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1786 let ed = ed.to_string_lossy().into_owned();
1787 let l = ed.to_ascii_lowercase();
1788 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1790 .iter()
1791 .any(|e| l.contains(e))
1792 {
1793 return Some((ed, vec![format!("+{line}"), fstr]));
1794 }
1795 return Some((ed, vec![fstr]));
1796 }
1797
1798 None
1799}
1800
1801fn open_web(
1806 store: &Store,
1807 hit: &crate::search::Hit,
1808 current: Option<i64>,
1809 root: Option<&std::path::Path>,
1810) -> ExitCode {
1811 if hit.repo_identity.starts_with("local:") {
1812 return fail(
1813 Output::Text,
1814 Failure::NoRemote,
1815 format_args!(
1816 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1817 locally with -o, or add one with `git remote add origin <url>`",
1818 hit.file, hit.line, hit.repo_identity
1819 ),
1820 );
1821 }
1822 let here =
1823 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1824 let rev = root
1825 .filter(|_| here)
1826 .and_then(crate::index::pushed_head)
1827 .unwrap_or_else(|| "HEAD".into());
1828 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1829
1830 use std::os::unix::process::CommandExt;
1831 let browser = std::env::var("BROWSER")
1832 .ok()
1833 .filter(|b| !b.is_empty())
1834 .or_else(|| {
1835 ["open", "xdg-open"]
1836 .into_iter()
1837 .find(|p| on_path(p))
1838 .map(str::to_string)
1839 });
1840 match browser {
1841 Some(prog) => {
1842 crate::profile::emit(false);
1844 let err = std::process::Command::new(&prog).arg(&url).exec();
1846 fail(
1847 Output::Text,
1848 Failure::Launch,
1849 format_args!("rq --web: cannot run {prog}: {err}"),
1850 )
1851 }
1852 None => {
1853 println!("{url}");
1854 ExitCode::SUCCESS
1855 }
1856 }
1857}
1858
1859fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1862 let path: String = file
1863 .bytes()
1864 .map(|b| match b {
1865 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1866 (b as char).to_string()
1867 }
1868 _ => format!("%{b:02X}"),
1869 })
1870 .collect();
1871 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1872}
1873
1874fn on_path(prog: &str) -> bool {
1876 std::env::var_os("PATH")
1877 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1878}
1879
1880const BRANCH_FILES_TTL_SECS: i64 = 15;
1886
1887const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1891
1892const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1904
1905fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1907 let earned = cost_ms.map_or(0, |ms| {
1910 i64::try_from(ms)
1911 .unwrap_or(i64::MAX)
1912 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1913 / 1000
1914 });
1915 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1916}
1917
1918struct BranchRefresh {
1922 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1925 identity: String,
1926 stamp: String,
1927}
1928
1929impl BranchRefresh {
1930 fn store(self, store: &Store) {
1932 let Ok((files, cost_ms)) = self.handle.join() else {
1933 return;
1934 };
1935 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
1936 }
1937}
1938
1939fn cached_branch_files(
1953 store: &Store,
1954 root: &std::path::Path,
1955) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
1956 let identity = resolve_identity(store, root);
1957 let stamp = crate::index::branch_files_stamp(root);
1958 let cached = store.branch_files_get(&identity).ok().flatten();
1959 let now = now_unix();
1960
1961 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
1962 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
1963 {
1964 return (hit.files.clone(), None, hit.cost_ms);
1965 }
1966 let owned_root = root.to_path_buf();
1967 let refresh = BranchRefresh {
1968 handle: std::thread::spawn(move || {
1969 let t = std::time::Instant::now();
1970 let files = crate::index::branch_changed_files(&owned_root);
1971 (files, t.elapsed().as_millis() as u64)
1972 }),
1973 identity,
1974 stamp: stamp.clone(),
1975 };
1976 return (hit.files.clone(), Some(refresh), hit.cost_ms);
1977 }
1978
1979 let t = std::time::Instant::now();
1981 let files = crate::index::branch_changed_files(root);
1982 let cost_ms = t.elapsed().as_millis() as u64;
1983 if let Some(stamp) = stamp {
1984 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
1985 }
1986 (files, None, Some(cost_ms))
1987}
1988
1989fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
1999 let head = indexed_head?;
2000 let _span = crate::profile::span("git: worktree changed?");
2001 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2002}
2003
2004fn changed_since_index(
2007 store: &Store,
2008 identity: Option<&str>,
2009 root: Option<&std::path::Path>,
2010 edits: Option<Vec<String>>,
2011) -> bool {
2012 let (Some(dirty), Some(root)) = (edits, root) else {
2013 return true;
2014 };
2015 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2016 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2017 None => !dirty.is_empty(),
2018 }
2019}
2020
2021enum Staleness {
2025 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2028 Deferred(PathBuf, Option<String>),
2032}
2033
2034#[allow(clippy::too_many_arguments)]
2041fn settle_warm(
2042 store: &Store,
2043 staleness: Option<Staleness>,
2044 hit: bool,
2045 was_warming: bool,
2046 warming_ok: bool,
2047 root: Option<&std::path::Path>,
2048 active: &[String],
2049 query: &str,
2050 budget: Duration,
2051 no_wait: bool,
2052 identity: Option<&str>,
2053) -> bool {
2054 let changed = match staleness {
2055 None => false,
2056 Some(Staleness::Deferred(r, head)) if hit => {
2059 if recently_verified(store, identity, &r, head.as_deref()) {
2060 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2061 } else {
2062 spawn_detached_warm(&r);
2063 }
2064 return false;
2065 }
2066 Some(Staleness::Deferred(r, head)) => {
2067 let _span = crate::profile::span("after: staleness check");
2068 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2069 }
2070 Some(Staleness::Running(h)) => {
2071 let mut span = crate::profile::span("after: staleness wait");
2073 let changed = h.join().map_or(true, |edits| {
2076 changed_since_index(store, identity, root, edits)
2077 });
2078 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2079 changed
2080 }
2081 };
2082 if changed
2092 && !no_wait
2093 && !warm_detach_enabled()
2094 && let Some(r) = root
2095 && let Ok(mut idx) = open_store()
2096 {
2097 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2098 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2099 }
2100 maybe_detach_warm(
2101 store,
2102 warming_ok && (was_warming || changed),
2103 changed,
2104 root,
2105 identity,
2106 );
2107 changed && warm_detach_enabled()
2111}
2112
2113fn answer_warm_budget() -> Duration {
2122 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2123}
2124
2125fn deferred_warm_budget() -> Duration {
2128 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2129}
2130
2131fn live_fallback_budget() -> Duration {
2134 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2135}
2136
2137fn warm_bg_budget() -> Duration {
2140 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2141}
2142
2143fn warm_detach_enabled() -> bool {
2147 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2148}
2149
2150fn wait_budget() -> Duration {
2159 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2160}
2161
2162fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2167 let s = s.trim();
2168 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2169 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2171 (n, 1.0)
2172 } else if let Some(n) = s.strip_suffix('s') {
2173 (n, 1_000.0)
2174 } else if let Some(n) = s.strip_suffix('m') {
2175 (n, 60_000.0)
2176 } else {
2177 (s, 1_000.0)
2179 };
2180 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2181 if !val.is_finite() || val < 0.0 {
2182 return Err(bad());
2183 }
2184 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2185}
2186
2187static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2191
2192extern "C" fn on_sigint(_: libc::c_int) {
2193 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2195}
2196
2197fn install_interrupt_handler() {
2200 static ONCE: std::sync::Once = std::sync::Once::new();
2201 ONCE.call_once(|| unsafe {
2202 let mut action: libc::sigaction = std::mem::zeroed();
2203 action.sa_sigaction = on_sigint as *const () as usize;
2204 libc::sigemptyset(&mut action.sa_mask);
2205 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2206 });
2207}
2208
2209fn stderr_interactive() -> bool {
2213 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2214}
2215
2216fn show_progress(out: Output, interactive: bool) -> bool {
2221 interactive && matches!(out, Output::Text)
2222}
2223
2224fn repo_label(root: Option<&std::path::Path>) -> String {
2227 root.and_then(|r| r.file_name())
2228 .map(|n| n.to_string_lossy().into_owned())
2229 .unwrap_or_else(|| "repo".into())
2230}
2231
2232fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2236 let files = identity
2237 .and_then(|id| store.repository_id(id).ok().flatten())
2238 .and_then(|rid| store.repo_totals(rid).ok())
2239 .map_or(0, |(f, _)| f);
2240 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2241 let _ = std::io::stderr().flush();
2242}
2243
2244fn clear_progress() {
2246 eprint!("\r\x1b[K");
2247 let _ = std::io::stderr().flush();
2248}
2249
2250fn env_budget(var: &str, default_ms: u64) -> Duration {
2254 let ms = std::env::var(var)
2255 .ok()
2256 .and_then(|v| v.parse().ok())
2257 .unwrap_or(default_ms);
2258 Duration::from_millis(ms)
2259}
2260
2261#[derive(Clone, Copy)]
2263struct Here<'a> {
2264 identity: &'a str,
2265 root: &'a std::path::Path,
2266}
2267
2268fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2274 let mut roots: Vec<PathBuf> = here
2275 .filter(|h| h.identity == repo_identity)
2276 .map(|h| h.root.to_path_buf())
2277 .into_iter()
2278 .collect();
2279 let recorded = store
2280 .repository_id(repo_identity)
2281 .ok()
2282 .flatten()
2283 .map(|id| store.checkout_roots(id).unwrap_or_default())
2284 .unwrap_or_default();
2285 for root in recorded.into_iter().map(PathBuf::from) {
2286 if !roots.contains(&root) {
2287 roots.push(root);
2288 }
2289 }
2290 roots
2291}
2292
2293fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2297 let mut roots = hit_file_roots(store, repo_identity, here);
2298 let at = roots
2299 .iter()
2300 .position(|r| r.join(file).is_file())
2301 .unwrap_or(0);
2302 (at < roots.len()).then(|| roots.swap_remove(at))
2303}
2304
2305fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2307 let src = std::fs::read_to_string(path).ok()?;
2308 signature_in(&src.lines().collect::<Vec<_>>(), line)
2309}
2310
2311const SHOW_CONFIDENCE: f64 = 0.85;
2315
2316fn show_top_definition(
2320 hits: &mut [crate::search::Hit],
2321 query: &str,
2322 out: Output,
2323) -> Option<ExitCode> {
2324 let top = hits.first()?;
2325 if top.confidence < SHOW_CONFIDENCE {
2326 return None; }
2328 let end = top.end_line.unwrap_or(top.line);
2329 let body = top.root.as_deref().and_then(|root| {
2330 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2331 span_in(&src, top.line, end)
2332 });
2333 hits[0].body = body;
2334 let top = &hits[0];
2335 let code = match out {
2336 Output::Json | Output::Ndjson => {
2337 emit_json(out, top)
2339 }
2340 Output::Text => {
2341 let color = match_color();
2342 let c = color.as_deref();
2343 let name = hl(&top.name, query, c);
2344 let qualified = match &top.parent {
2345 Some(p) => format!("{name} · {p}"),
2346 None => name,
2347 };
2348 println!(
2349 "{}:{} {} {}",
2350 hl_path(&top.file, query, c),
2351 top.line,
2352 top.kind,
2353 qualified
2354 );
2355 match (&top.body, &top.signature) {
2356 (Some(body), _) => println!("{body}"),
2357 (None, Some(sig)) => println!("{sig}"),
2359 (None, None) => {}
2360 }
2361 ExitCode::SUCCESS
2362 }
2363 };
2364
2365 Some(code)
2366}
2367
2368fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2371 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2372 let lines: Vec<&str> = content.lines().collect();
2373 if s >= lines.len() {
2374 return None;
2375 }
2376 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2377 Some(lines[s..e].join("\n"))
2378}
2379
2380fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2385 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2386 let l = lines.get(idx)?.trim();
2387 (!l.is_empty()).then(|| l.to_string())
2388}
2389
2390#[derive(serde::Serialize)]
2394struct SymbolOut {
2395 name: String,
2396 kind: String,
2397 language: String,
2398 file: String,
2399 root: String,
2401 line: i64,
2402 #[serde(skip_serializing_if = "Option::is_none")]
2403 end_line: Option<i64>,
2404 #[serde(skip_serializing_if = "Option::is_none")]
2405 parent: Option<String>,
2406 #[serde(skip_serializing_if = "Option::is_none")]
2407 visibility: Option<String>,
2408 repo: String,
2409 #[serde(skip_serializing_if = "Option::is_none")]
2410 signature: Option<String>,
2411}
2412
2413fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2418 let open_span = crate::profile::span("store open");
2419 let mut store = match open_store() {
2420 Ok(s) => s,
2421 Err(e) => {
2422 return fail(
2423 out,
2424 Failure::Database,
2425 format_args!("rq: cannot open database: {e}"),
2426 );
2427 }
2428 };
2429 drop(open_span);
2430 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2431 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2432 let rel = repo_relative(&root, &cwd, file_arg);
2433
2434 let identity = resolve_identity(&store, &root);
2435 let coverage = store.coverage_status(&identity).ok().flatten();
2436 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2437 let current = store.repository_id(&identity).ok().flatten();
2438 let path = root.join(&rel);
2439 if !path.is_file() {
2440 return fail(
2441 out,
2442 Failure::NotFound,
2443 format_args!("rq --symbols: no such file: {file_arg}"),
2444 );
2445 }
2446 let indexable = path
2447 .extension()
2448 .and_then(|e| e.to_str())
2449 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2450 match current {
2451 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2455 if indexable {
2456 let _span = crate::profile::span("symbols: refresh");
2457 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2458 }
2459 }
2460 _ if warming_ok => {
2464 let budget = answer_warm_budget() + deferred_warm_budget();
2465 let _span = crate::profile::span("symbols: warm");
2466 let active = [rel.clone()];
2467 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2468 }
2469 _ => {}
2470 }
2471
2472 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2473 return emit_symbols(out, &[]); };
2475 let mut query_span = crate::profile::span("symbols: query");
2476 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2477 Ok(r) => r,
2478 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2479 };
2480 query_span.note(|| format!("{} rows", rows.len()));
2481 drop(query_span);
2482 if !kinds.is_empty() {
2483 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2484 }
2485 if !langs.is_empty() {
2486 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2487 }
2488
2489 let signatures_span = crate::profile::span("symbols: signatures");
2492 let content = std::fs::read_to_string(&path).ok();
2493 let lines: Vec<&str> = content
2494 .as_deref()
2495 .map_or_else(Vec::new, |c| c.lines().collect());
2496 let root_str = root.to_string_lossy().into_owned();
2497 let syms: Vec<SymbolOut> = rows
2498 .into_iter()
2499 .map(|r| SymbolOut {
2500 signature: signature_in(&lines, r.line),
2501 name: r.name,
2502 kind: r.kind,
2503 language: r.language,
2504 file: r.file,
2505 root: root_str.clone(),
2506 line: r.line,
2507 end_line: r.end_line,
2508 parent: r.parent,
2509 visibility: r.visibility,
2510 repo: r.repo_identity,
2511 })
2512 .collect();
2513 drop(signatures_span);
2514 let _span = crate::profile::span("render");
2515 emit_symbols(out, &syms)
2516}
2517
2518fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2521 if syms.is_empty() {
2522 match out {
2523 Output::Json | Output::Ndjson => {
2524 let obj = serde_json::json!({ "status": "no_match" });
2525 let _ = emit_json(out, &obj); }
2527 Output::Text => eprintln!("no symbols"),
2528 }
2529 return ExitCode::FAILURE;
2530 }
2531 if let Some(code) = emit_rows(out, syms) {
2532 return code;
2533 }
2534 match out {
2535 Output::Json | Output::Ndjson => {}
2536 Output::Text => {
2537 for s in syms {
2538 let qualified = match &s.parent {
2539 Some(p) => format!("{} · {p}", s.name),
2540 None => s.name.clone(),
2541 };
2542 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2543 if let Some(sig) = &s.signature {
2544 println!(" {sig}");
2545 }
2546 }
2547 }
2548 }
2549 ExitCode::SUCCESS
2550}
2551
2552fn keyword_kind(token: &str) -> Option<&'static str> {
2557 match token.to_ascii_lowercase().as_str() {
2558 "class" => Some("class"),
2559 "module" => Some("module"),
2560 "method" => Some("method"),
2561 "function" | "fn" => Some("function"),
2562 "struct" | "type" => Some("struct"),
2563 "enum" => Some("enum"),
2564 "trait" | "interface" => Some("trait"),
2565 "constant" | "const" => Some("constant"),
2566 _ => None,
2567 }
2568}
2569
2570fn split_kind_keyword(
2576 target: String,
2577 dirs: Vec<String>,
2578) -> (Option<&'static str>, String, Vec<String>) {
2579 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2582 let rest = rest.trim();
2583 if let Some(k) = keyword_kind(head)
2584 && !rest.is_empty()
2585 {
2586 return (Some(k), rest.to_string(), dirs);
2587 }
2588 } else if let Some(k) = keyword_kind(&target)
2589 && let Some((query, extra)) = dirs.split_first()
2590 {
2591 return (Some(k), query.clone(), extra.to_vec());
2593 }
2594 (None, target, dirs)
2595}
2596
2597fn canonical_kind(s: &str) -> Option<&'static str> {
2600 Some(match s.to_ascii_lowercase().as_str() {
2601 "c" | "class" => "class",
2602 "m" | "method" => "method",
2603 "f" | "fn" | "func" | "function" => "function",
2604 "mod" | "module" => "module",
2605 "s" | "struct" | "type" => "struct",
2606 "e" | "enum" => "enum",
2607 "t" | "trait" | "interface" => "trait",
2608 "const" | "constant" => "constant",
2609 _ => return None,
2610 })
2611}
2612
2613fn canonical_langs(s: &str) -> Vec<String> {
2620 let t = s.to_ascii_lowercase();
2621 let alias = match t.as_str() {
2622 "rb" => Some("ruby"),
2623 "rs" => Some("rust"),
2624 "golang" => Some("go"),
2625 "ts" | "tsx" => Some("typescript"),
2626 "js" | "jsx" => Some("javascript"),
2627 _ => None,
2628 };
2629 let matched: Vec<String> = crate::lang::languages()
2630 .into_iter()
2631 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2632 .map(str::to_string)
2633 .collect();
2634 matched
2635}
2636
2637fn match_color() -> Option<String> {
2641 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2642 return None;
2643 }
2644 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2645 gc.split(':').find_map(|e| {
2646 e.strip_prefix("mt=")
2647 .or_else(|| e.strip_prefix("ms="))
2648 .filter(|v| !v.is_empty())
2649 .map(str::to_string)
2650 })
2651 });
2652 Some(style.unwrap_or_else(|| "1;31".to_string()))
2653}
2654
2655fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2658 match color {
2659 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2660 None => text.to_string(),
2661 }
2662}
2663
2664fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2667 let Some(c) = color else {
2668 return path.to_string();
2669 };
2670 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2671 let base_start = path[..base_byte].chars().count();
2672 let stem = crate::search::path_stem(path);
2676 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2677 .into_iter()
2678 .map(|p| p + base_start)
2679 .collect();
2680 highlight(path, &positions, c)
2681}
2682
2683fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2686 if positions.is_empty() {
2687 return text.to_string();
2688 }
2689 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2690 let mut out = String::new();
2691 let mut on = false;
2692 for (i, c) in text.chars().enumerate() {
2693 match (matched.contains(&i), on) {
2694 (true, false) => {
2695 out.push_str("\x1b[");
2696 out.push_str(color);
2697 out.push('m');
2698 on = true;
2699 }
2700 (false, true) => {
2701 out.push_str("\x1b[0m");
2702 on = false;
2703 }
2704 _ => {}
2705 }
2706 out.push(c);
2707 }
2708 if on {
2709 out.push_str("\x1b[0m");
2710 }
2711 out
2712}
2713
2714fn under_any(file: &str, paths: &[String]) -> bool {
2718 paths.iter().any(|p| {
2719 let p = p.trim_start_matches("./").trim_end_matches('/');
2720 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2721 })
2722}
2723
2724fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2726 let p = std::path::Path::new(file);
2727 let abs = if p.is_absolute() {
2728 p.to_path_buf()
2729 } else {
2730 cwd.join(p)
2731 };
2732 let abs = abs.canonicalize().unwrap_or(abs);
2733 abs.strip_prefix(root)
2734 .map(|r| r.to_string_lossy().into_owned())
2735 .unwrap_or_else(|_| file.to_string())
2736}
2737
2738fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2743 use std::collections::HashSet;
2744 let mut seen = HashSet::new();
2745 let mut changed = false;
2746 for hit in hits {
2747 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2748 continue;
2749 }
2750 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2751 continue;
2752 };
2753 let roots = hit_file_roots(store, &hit.repo_identity, here);
2754 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2755 continue;
2756 };
2757 if let Ok(crate::index::Refresh::Updated) =
2758 crate::index::refresh_file(store, repo_id, root, &hit.file)
2759 {
2760 changed = true;
2761 }
2762 }
2763 changed
2764}
2765
2766fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2772 if let Ok(canon) = cwd.canonicalize() {
2773 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2774 return identity;
2775 }
2776 if crate::index::repo_root(cwd).is_none() {
2777 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2778 }
2779 }
2780 crate::index::detect_identity(cwd).to_string()
2781}
2782
2783fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2784 let explicit = path.is_some();
2785 let target = path.unwrap_or_else(|| PathBuf::from("."));
2786 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2791 let mut subdirs = subdirs.to_vec();
2796 if explicit
2797 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2798 && t != r
2799 && let Ok(rel) = t.strip_prefix(&r)
2800 && !rel.as_os_str().is_empty()
2801 {
2802 subdirs.push(rel.to_string_lossy().into_owned());
2803 }
2804 let open_span = crate::profile::span("store open");
2805 let mut store = match open_store() {
2806 Ok(s) => s,
2807 Err(e) => {
2808 return fail(
2809 out,
2810 Failure::Database,
2811 format_args!("rq: cannot open database: {e}"),
2812 );
2813 }
2814 };
2815 drop(open_span);
2816 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2817 let identity = resolve_identity(&store, &root);
2820 match indexed {
2821 Ok(stats) => {
2822 let subtree = !subdirs.is_empty();
2823 let totals = store
2825 .repository_id(&identity)
2826 .ok()
2827 .flatten()
2828 .and_then(|id| store.repo_totals(id).ok());
2829 match out {
2830 Output::Json | Output::Ndjson => {
2831 let (files, symbols) = match totals {
2832 Some((f, s)) => (Some(f), Some(s)),
2833 None => (None, None),
2834 };
2835 return emit_json(
2836 out,
2837 &serde_json::json!({
2838 "repo": identity,
2839 "scope": if subtree { "subtree" } else { "full" },
2840 "files_added": stats.files_indexed,
2841 "symbols_added": stats.symbols,
2842 "files": files,
2843 "symbols": symbols,
2844 }),
2845 );
2846 }
2847 Output::Text => {
2848 let scope = if subtree { " (subtree seed)" } else { "" };
2849 match totals {
2850 Some((files, symbols)) => println!(
2851 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2852 stats.files_indexed, stats.symbols
2853 ),
2854 None => println!(
2855 "{} file(s)/{} symbol(s) added this run{scope}",
2856 stats.files_indexed, stats.symbols
2857 ),
2858 }
2859 }
2860 }
2861 ExitCode::SUCCESS
2862 }
2863 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2864 }
2865}
2866
2867fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2868 let mut store = match open_store() {
2869 Ok(s) => s,
2870 Err(e) => {
2871 return fail(
2872 out,
2873 Failure::Database,
2874 format_args!("rq: cannot open database: {e}"),
2875 );
2876 }
2877 };
2878
2879 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2883 let root = crate::index::repo_root(&path).unwrap_or(path);
2884 let from_path = crate::index::detect_identity(&root).to_string();
2885 let resolved = match store.repository_id(&from_path) {
2886 Ok(Some(id)) => Some((from_path.clone(), id)),
2887 Ok(None) => target.as_deref().and_then(|s| {
2888 store
2889 .repository_id(s)
2890 .ok()
2891 .flatten()
2892 .map(|id| (s.to_string(), id))
2893 }),
2894 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2895 };
2896
2897 let Some((identity, repo_id)) = resolved else {
2898 return match out {
2900 Output::Text => {
2901 println!("not indexed: {from_path}");
2902 ExitCode::SUCCESS
2903 }
2904 _ => emit_json(
2905 out,
2906 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2907 ),
2908 };
2909 };
2910
2911 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2912 match store.drop_repository(repo_id) {
2913 Ok(()) => match out {
2914 Output::Text => {
2915 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2916 ExitCode::SUCCESS
2917 }
2918 _ => emit_json(
2919 out,
2920 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2921 ),
2922 },
2923 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2924 }
2925}
2926
2927fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2931 let rendered = if out == Output::Json {
2932 serde_json::to_string_pretty(value)
2933 } else {
2934 serde_json::to_string(value)
2935 };
2936 match rendered {
2937 Ok(s) => {
2938 println!("{s}");
2939 ExitCode::SUCCESS
2940 }
2941 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
2942 }
2943}
2944
2945fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2949 match out {
2950 Output::Json => match serde_json::to_string_pretty(rows) {
2951 Ok(s) => println!("{s}"),
2952 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
2953 },
2954 Output::Ndjson => {
2955 for r in rows {
2956 match serde_json::to_string(r) {
2957 Ok(line) => println!("{line}"),
2958 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
2959 }
2960 }
2961 }
2962 Output::Text => {}
2963 }
2964 None
2965}
2966
2967fn cmd_status(out: Output) -> ExitCode {
2968 let store = match open_store() {
2969 Ok(s) => s,
2970 Err(e) => {
2971 return fail(
2972 out,
2973 Failure::Database,
2974 format_args!("rq: cannot open database: {e}"),
2975 );
2976 }
2977 };
2978 let rows = match store.coverage_overview() {
2979 Ok(rows) => rows,
2980 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
2981 };
2982 if let Some(code) = emit_rows(out, &rows) {
2983 return code;
2984 }
2985 match out {
2986 Output::Json | Output::Ndjson => {}
2987 Output::Text if rows.is_empty() => {
2988 println!("no repositories indexed yet (try `rq --index`)");
2989 }
2990 Output::Text => {
2991 for r in &rows {
2992 println!(
2993 "{:<10} {:>6} files {:>7} symbols {}",
2994 r.status, r.files, r.symbols, r.identity
2995 );
2996 }
2997 }
2998 }
2999 ExitCode::SUCCESS
3000}
3001
3002fn cmd_usage(out: Output) -> ExitCode {
3005 let store = match open_store() {
3006 Ok(s) => s,
3007 Err(e) => {
3008 return fail(
3009 out,
3010 Failure::Database,
3011 format_args!("rq: cannot open database: {e}"),
3012 );
3013 }
3014 };
3015 let rows = match store.usage_overview() {
3016 Ok(rows) => rows,
3017 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3018 };
3019 if let Some(code) = emit_rows(out, &rows) {
3020 return code;
3021 }
3022 match out {
3023 Output::Json | Output::Ndjson => {}
3024 Output::Text if rows.is_empty() => {
3025 println!("no usage recorded yet");
3026 }
3027 Output::Text => {
3028 println!(
3031 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3032 "day", "caller", "found", "missed", "warming"
3033 );
3034 for r in &rows {
3035 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3036 println!(
3037 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3038 r.day,
3039 r.source,
3040 r.searches - r.misses - r.warming,
3041 r.misses,
3042 r.warming,
3043 flags
3044 );
3045 }
3046 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3047 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3048 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3049 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3050 let plural = if searches == 1 { "search" } else { "searches" };
3051 println!(
3054 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
3055 );
3056 }
3057 }
3058 if rows.is_empty() {
3060 return ExitCode::from(1);
3061 }
3062 ExitCode::SUCCESS
3063}
3064
3065fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3067 let path = db_path()?;
3068 if let Some(parent) = path.parent() {
3069 std::fs::create_dir_all(parent)?;
3070 }
3071 Ok(Store::open(&path)?)
3072}
3073
3074fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3076 if let Ok(p) = std::env::var("RQ_DB") {
3077 return Ok(PathBuf::from(p));
3078 }
3079 let home = std::env::var("HOME")?;
3080 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3081}
3082
3083#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3085enum Failure {
3086 Usage,
3088 Database,
3090 NotFound,
3092 NoRemote,
3094 Launch,
3096 Index,
3098 Internal,
3100}
3101
3102impl Failure {
3103 fn as_str(self) -> &'static str {
3104 match self {
3105 Failure::Usage => "usage",
3106 Failure::Database => "database",
3107 Failure::NotFound => "not_found",
3108 Failure::NoRemote => "no_remote",
3109 Failure::Launch => "launch",
3110 Failure::Index => "index",
3111 Failure::Internal => "internal",
3112 }
3113 }
3114}
3115
3116fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3119 let message = args.to_string();
3120 eprintln!("{message}");
3121 emit_error(out, kind.as_str(), &message, 1);
3122 ExitCode::FAILURE
3123}
3124
3125fn emit_error(out: Output, kind: &str, message: &str, code: u8) {
3128 let obj = serde_json::json!({ "error": message, "kind": kind, "code": code });
3129 let rendered = match out {
3131 Output::Text => return,
3132 Output::Json => serde_json::to_string_pretty(&obj),
3133 Output::Ndjson => serde_json::to_string(&obj),
3134 };
3135 if let Ok(s) = rendered {
3136 println!("{s}");
3137 }
3138}
3139
3140fn clap_failure(err: clap::Error) -> ExitCode {
3144 let out = requested_output(std::env::args_os().skip(1));
3145 if !err.use_stderr() || out == Output::Text {
3147 err.exit();
3148 }
3149 let _ = err.print();
3150 let code = u8::try_from(err.exit_code()).unwrap_or(2);
3151 let text = err.to_string();
3152 emit_error(
3153 out,
3154 Failure::Usage.as_str(),
3155 text.lines().next().unwrap_or(""),
3156 code,
3157 );
3158 ExitCode::from(code)
3159}
3160
3161fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3166 let cmd = Cli::command();
3167 let takes_value = |c: char| {
3168 cmd.get_arguments()
3169 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3170 };
3171 let (mut json, mut ndjson) = (false, false);
3172 for arg in args {
3173 let arg = arg.to_string_lossy();
3174 match arg.as_ref() {
3175 "--" => break,
3176 "--json" => json = true,
3177 "--ndjson" => ndjson = true,
3178 a if a.starts_with('-') && !a.starts_with("--") => {
3179 for c in a.chars().skip(1) {
3180 match c {
3181 'j' => json = true,
3182 'J' => ndjson = true,
3183 c if takes_value(c) => break,
3184 _ => {}
3185 }
3186 }
3187 }
3188 _ => {}
3189 }
3190 }
3191 if ndjson {
3193 Output::Ndjson
3194 } else if json {
3195 Output::Json
3196 } else {
3197 Output::Text
3198 }
3199}
3200
3201#[cfg(test)]
3202mod tests {
3203 use super::*;
3204
3205 #[test]
3206 fn parses_an_anchor_from_the_right() {
3207 let ok = |file: &str, line| {
3208 Ok(AnchorSpec {
3209 file: PathBuf::from(file),
3210 line,
3211 })
3212 };
3213 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3214 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3215 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3216 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3217 assert!(parse_anchor(bad).is_err(), "{bad}");
3218 }
3219 }
3220
3221 #[test]
3222 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3223 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3224 for (args, want) in [
3225 (&["x", "--json"][..], Output::Json),
3226 (&["x", "--ndjson"], Output::Ndjson),
3227 (&["x", "-ej"], Output::Json),
3228 (&["-Je", "x"], Output::Ndjson),
3229 (&["x", "--json", "-J"], Output::Ndjson),
3230 (&["x"], Output::Text),
3231 (&["x", "-xj"], Output::Text),
3233 (&["x", "-l5j"], Output::Text),
3234 (&["--", "-j"], Output::Text),
3236 (&["x", "--jobs", "2"], Output::Text),
3237 ] {
3238 assert!(mode(args) == want, "{args:?}");
3239 }
3240 }
3241
3242 #[test]
3243 fn open_menu_choice_parsing() {
3244 assert_eq!(parse_choice("\n", 5), Some(0));
3246 assert_eq!(parse_choice(" ", 5), Some(0));
3247 assert_eq!(parse_choice("3", 5), Some(2));
3248 assert_eq!(parse_choice("5", 5), Some(4));
3249 assert_eq!(parse_choice("6", 5), None);
3251 assert_eq!(parse_choice("0", 5), None);
3252 assert_eq!(parse_choice("q", 5), None);
3253 }
3254
3255 #[test]
3256 fn web_url_shape() {
3257 assert_eq!(
3258 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3259 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3260 );
3261 }
3262
3263 #[test]
3264 fn wait_duration_parsing() {
3265 use std::time::Duration;
3266 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3268 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3269 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3270 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3271 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3273 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3274 assert!(parse_wait("0s").unwrap().is_zero());
3275 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3277 assert!(parse_wait("2x").is_err());
3279 assert!(parse_wait("").is_err());
3280 assert!(parse_wait("s").is_err());
3281 assert!(parse_wait("-1s").is_err());
3282 }
3283
3284 #[test]
3285 fn leading_kind_keyword_becomes_a_kind_filter() {
3286 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3287 assert_eq!(
3289 split_kind_keyword("class".into(), d(&["Widget"])),
3290 (Some("class"), "Widget".into(), vec![])
3291 );
3292 assert_eq!(
3294 split_kind_keyword("method zoom".into(), vec![]),
3295 (Some("method"), "zoom".into(), vec![])
3296 );
3297 assert_eq!(
3299 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3300 (Some("function"), "Foo::run".into(), vec![])
3301 );
3302 assert_eq!(
3304 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3305 (Some("struct"), "Gadget".into(), d(&["src"]))
3306 );
3307 }
3308
3309 #[test]
3310 fn a_bare_or_non_keyword_query_is_left_alone() {
3311 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3312 assert_eq!(
3314 split_kind_keyword("class".into(), vec![]),
3315 (None, "class".into(), vec![])
3316 );
3317 assert_eq!(
3319 split_kind_keyword("Widget".into(), d(&["app"])),
3320 (None, "Widget".into(), d(&["app"]))
3321 );
3322 assert_eq!(
3324 split_kind_keyword("c".into(), d(&["Foo"])),
3325 (None, "c".into(), d(&["Foo"]))
3326 );
3327 }
3328
3329 #[test]
3330 fn the_branch_window_scales_with_what_the_refresh_costs() {
3331 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3334 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3335 assert_eq!(branch_files_ttl(Some(700)), 70);
3339 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3340 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3343 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3344 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3346 }
3347
3348 #[test]
3349 fn a_language_selects_by_prefix_or_alias() {
3350 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3352 assert_eq!(canonical_langs("t"), ["typescript"]);
3353 assert_eq!(canonical_langs("ts"), ["typescript"]);
3355 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3356 assert_eq!(canonical_langs("rb"), ["ruby"]);
3357 assert!(canonical_langs("COBOL").is_empty());
3360 }
3361
3362 #[test]
3363 fn a_kind_normalizes_language_specific_spellings() {
3364 assert_eq!(canonical_kind("f"), Some("function"));
3365 assert_eq!(canonical_kind("interface"), Some("trait"));
3367 assert_eq!(canonical_kind("type"), Some("struct"));
3368 assert_eq!(canonical_kind("const"), Some("constant"));
3369 assert_eq!(canonical_kind("banana"), None);
3370 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3372 assert_eq!(
3373 split_kind_keyword("interface".into(), d(&["Renderer"])),
3374 (Some("trait"), "Renderer".into(), vec![])
3375 );
3376 }
3377
3378 #[test]
3379 fn highlight_wraps_matched_runs() {
3380 assert_eq!(
3381 highlight("FooThing", &[0, 1, 2], "1;31"),
3382 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3383 );
3384 assert_eq!(
3386 highlight("FooThing", &[0, 3], "1"),
3387 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3388 );
3389 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3391 }
3392
3393 #[test]
3394 fn progress_ui_only_for_an_interactive_text_terminal() {
3395 assert!(show_progress(Output::Text, true));
3397
3398 assert!(!show_progress(Output::Json, true));
3400 assert!(!show_progress(Output::Ndjson, true));
3401
3402 assert!(!show_progress(Output::Text, false));
3404 }
3405
3406 #[test]
3407 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3408 let lines = ["class Widget", "", " def go"];
3409 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3410 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3411 assert_eq!(signature_in(&lines, 2), None, "blank line");
3412 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3413 assert_eq!(signature_in(&lines, 4), None, "past the end");
3414 }
3415
3416 #[test]
3417 fn repo_label_uses_the_directory_name() {
3418 assert_eq!(
3419 repo_label(Some(std::path::Path::new("/src/widgets"))),
3420 "widgets"
3421 );
3422 assert_eq!(repo_label(None), "repo");
3423 }
3424
3425 #[test]
3426 fn hl_path_highlights_the_stem_not_the_extension() {
3427 let out = hl_path(
3430 "app/employees_controller.rb",
3431 "employeescontroller",
3432 Some("1;31"),
3433 );
3434 assert!(
3435 out.starts_with("app/\u{1b}[1;31memployees"),
3436 "stem highlighted: {out:?}"
3437 );
3438 assert!(
3439 out.ends_with("controller\u{1b}[0m.rb"),
3440 "`.rb` left un-highlighted: {out:?}"
3441 );
3442 }
3443}