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).\n\n\
56EXIT CODES:\n \
570 matched\n \
581 no match\n \
592 no match yet: the index is still warming, or indexing was interrupted — ask again\n \
6064 usage error: a bad flag, value or query — fix the command\n \
6166 a file the command names doesn't exist\n \
6269 no editor, browser or git remote to hand off to\n \
6370 internal error (a bug)\n \
6474 the index can't be opened, read or written"
65)]
66struct Cli {
67 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
73 target: Option<String>,
74
75 #[arg(value_name = "PATH")]
77 dirs: Vec<String>,
78
79 #[arg(short = 'e', long)]
81 explain: bool,
82
83 #[arg(long = "no-wait")]
89 no_wait: bool,
90
91 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
96 wait: Option<Duration>,
97
98 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
104 open: bool,
105
106 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
111 web: bool,
112
113 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
117 show: bool,
118
119 #[arg(short = 'j', long)]
121 json: bool,
122
123 #[arg(short = 'J', long, conflicts_with = "json")]
125 ndjson: bool,
126
127 #[arg(short = 'p', long, value_name = "DIR")]
129 path: Vec<String>,
130
131 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
133 limit: usize,
134
135 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
139 kind: Vec<String>,
140
141 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
145 lang: Vec<String>,
146
147 #[arg(short = 'a', long = "all-repos")]
150 all_repos: bool,
151
152 #[arg(long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
158 anchor: Option<AnchorSpec>,
159
160 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
162 index: Option<Option<String>>,
163
164 #[arg(long, conflicts_with = "index")]
166 status: bool,
167
168 #[arg(long, conflicts_with_all = ["index", "status"])]
170 usage: bool,
171
172 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
175 symbols: Option<String>,
176
177 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
181 drop: bool,
182
183 #[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"])]
187 warm: Option<Option<String>>,
188
189 #[arg(long, value_name = "SHELL")]
191 completions: Option<Shell>,
192
193 #[arg(short = 'v', long)]
196 verbose: bool,
197
198 #[arg(long)]
202 profile: bool,
203
204 #[arg(long, value_name = "N", default_value_t = 0)]
207 jobs: usize,
208}
209
210pub fn run() -> ExitCode {
212 let cli = match Cli::try_parse() {
213 Ok(cli) => cli,
214 Err(err) => return clap_failure(err),
215 };
216 crate::trace::enable_from(cli.verbose);
217 crate::profile::enable_from(cli.profile);
218 crate::index::set_parse_jobs(cli.jobs);
219 let json_out = output_format(&cli) != Output::Text;
220 let code = dispatch(cli);
221 crate::profile::emit(json_out);
222 code
223}
224
225fn dispatch(cli: Cli) -> ExitCode {
226 if let Some(shell) = cli.completions {
227 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
228 return ExitCode::SUCCESS;
229 }
230 if let Some(path) = &cli.index {
231 let out = output_format(&cli);
233 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
234 }
235 if let Some(path) = &cli.warm {
236 return cmd_warm(path.as_deref());
237 }
238 if cli.status {
239 return cmd_status(output_format(&cli));
240 }
241 if cli.usage {
242 return cmd_usage(output_format(&cli));
243 }
244 if cli.drop {
245 let out = output_format(&cli);
246 return cmd_drop(cli.target, out);
247 }
248 let out = output_format(&cli);
249 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
250 return fail(out, Failure::Usage, format_args!("rq: empty query"));
251 }
252 let mut kinds: Vec<String> = Vec::new();
256 for k in &cli.kind {
257 match canonical_kind(k) {
258 Some(c) => kinds.push(c.to_string()),
259 None => {
260 return fail(
261 out,
262 Failure::Usage,
263 format_args!(
264 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant)"
265 ),
266 );
267 }
268 }
269 }
270 let mut langs: Vec<String> = Vec::new();
272 for x in &cli.lang {
273 let matched = canonical_langs(x);
274 if matched.is_empty() {
275 return fail(
276 out,
277 Failure::Usage,
278 format_args!(
279 "rq: unknown --lang {x:?} ({})",
280 crate::lang::languages().join(", ")
281 ),
282 );
283 }
284 langs.extend(matched);
285 }
286 if let Some(file) = &cli.symbols {
287 return cmd_symbols(file, &kinds, &langs, out);
288 }
289 let mut paths = cli.path.clone();
291 match cli.target {
292 Some(target) => {
293 let query = if cli.kind.is_empty() {
296 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
297 if let Some(k) = kw {
298 kinds.push(k.to_string());
299 }
300 paths.extend(dirs);
301 query
302 } else {
303 paths.extend(cli.dirs.clone());
304 target
305 };
306 let mut session = match Session::open(out) {
307 Ok(s) => s,
308 Err(code) => return code,
309 };
310 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
311 cmd_search(
312 &mut session,
313 &SearchArgs {
314 query: &query,
315 explain: cli.explain,
316 out,
317 paths: &paths,
318 kinds: &kinds,
319 langs: &langs,
320 want: requested_limit(cli.limit),
321 no_wait: cli.no_wait,
322 wait: cli.wait,
323 open: cli.open,
324 web: cli.web,
325 all_repos: cli.all_repos,
326 show: cli.show,
327 batch: false,
328 anchored: cli.anchor.is_some(),
329 },
330 )
331 }
332 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
335 None => {
337 let _ = Cli::command().print_long_help();
338 ExitCode::SUCCESS
339 }
340 }
341}
342
343#[derive(Clone, Copy, PartialEq)]
345enum Output {
346 Text,
347 Json,
348 Ndjson,
349}
350
351fn output_format(cli: &Cli) -> Output {
352 if cli.ndjson {
353 Output::Ndjson
354 } else if cli.json {
355 Output::Json
356 } else {
357 Output::Text
358 }
359}
360
361const DEFAULT_LIMIT: usize = 10;
363
364const PATH_HEADROOM: usize = 200;
367
368fn requested_limit(limit: usize) -> usize {
371 if limit == 0 { usize::MAX } else { limit }
372}
373
374fn record_usage(
377 store: &Store,
378 args: &SearchArgs,
379 status: &str,
380 coverage: Option<&str>,
381 live: bool,
382) {
383 let _ = store.record_search(&crate::store::SearchRecord {
384 source: &crate::origin::detect(),
385 flags: &flag_summary(args),
386 status,
387 coverage: coverage.unwrap_or("none"),
388 live,
389 });
390}
391
392fn flag_summary(args: &SearchArgs) -> String {
397 let mut on: Vec<&str> = Vec::new();
398 match args.out {
399 Output::Json => on.push("json"),
400 Output::Ndjson => on.push("ndjson"),
401 Output::Text => {}
402 }
403 for (present, name) in [
404 (args.explain, "explain"),
405 (args.show, "show"),
406 (args.open, "open"),
407 (args.web, "web"),
408 (args.all_repos, "all-repos"),
409 (args.no_wait, "no-wait"),
410 (args.batch, "batch"),
411 (args.anchored, "anchor"),
412 (!args.paths.is_empty(), "path"),
413 (!args.kinds.is_empty(), "kind"),
414 (!args.langs.is_empty(), "lang"),
415 (args.want != DEFAULT_LIMIT, "limit"),
416 ] {
417 if present {
418 on.push(name);
419 }
420 }
421 on.join(",")
422}
423
424const POLL_INTERVAL: Duration = Duration::from_millis(100);
431
432const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
436
437const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
441
442struct SearchArgs<'a> {
444 query: &'a str,
445 explain: bool,
446 out: Output,
447 paths: &'a [String],
448 kinds: &'a [String],
449 langs: &'a [String],
450 want: usize,
452 no_wait: bool,
454 wait: Option<Duration>,
457 open: bool,
458 web: bool,
459 all_repos: bool,
460 batch: bool,
464 show: bool,
465 anchored: bool,
467}
468
469struct Session {
480 store: Store,
481 cwd: Option<PathBuf>,
482 cwd_is_git: bool,
483 root: Option<PathBuf>,
484 active_paths: Vec<String>,
485 branch_refresh: Option<BranchRefresh>,
486 identity: Option<String>,
487 coverage: Option<String>,
488 anchor: Option<crate::search::Anchor>,
490}
491
492impl Session {
493 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
495 let open_span = crate::profile::span("store open");
496 let store = match open_store() {
497 Ok(s) => s,
498 Err(e) => {
499 return Err(fail(
500 out,
501 Failure::Database,
502 format_args!("rq: cannot open database: {e}"),
503 ));
504 }
505 };
506 drop(open_span);
507 let git_span = crate::profile::span("setup: git root");
508 let cwd = std::env::current_dir().ok();
509 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
510
511 let root = cwd
517 .as_deref()
518 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
519 drop(git_span);
520
521 let mut branch_span = crate::profile::span("setup: branch files");
524 let (active_paths, branch_refresh, cached_cost) = match &root {
525 Some(c) if cwd_is_git => cached_branch_files(&store, c),
526 _ => (Vec::new(), None, None),
527 };
528 branch_span.note(|| {
529 let how = if branch_refresh.is_some() {
530 "cached, refreshing alongside"
531 } else {
532 "cached"
533 };
534 let ttl = branch_files_ttl(cached_cost);
537 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
538 });
539 drop(branch_span);
540
541 let mut identity_span = crate::profile::span("setup: identity");
546 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
547 let coverage = identity
548 .as_deref()
549 .and_then(|id| store.coverage_status(id).ok())
550 .flatten();
551 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
552 drop(identity_span);
553 Ok(Session {
554 store,
555 cwd,
556 cwd_is_git,
557 root,
558 active_paths,
559 branch_refresh,
560 identity,
561 coverage,
562 anchor: None,
563 })
564 }
565
566 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
570 let _span = crate::profile::span("setup: anchor");
571 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
572 let abs = here.join(&spec.file);
573 let abs = abs.canonicalize().unwrap_or(abs);
574 let root = self
575 .root
576 .clone()
577 .filter(|r| abs.starts_with(r))
578 .or_else(|| crate::index::repo_root(&abs))
579 .or_else(|| abs.parent().map(PathBuf::from))
580 .unwrap_or(here);
581 let identity = resolve_identity(&self.store, &root);
582 let rel = abs
583 .strip_prefix(&root)
584 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
585 .into_owned();
586 let repo_id = self.store.repository_id(&identity).ok().flatten();
587 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
588 crate::search::Anchor::new(identity, rel, spec.line, &defs)
589 }
590}
591
592#[derive(Debug, Clone, PartialEq)]
594struct AnchorSpec {
595 file: PathBuf,
596 line: i64,
597}
598
599fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
602 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
603 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
604 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
605 let last = num(last).ok_or_else(bad)?;
606 let (file, line) = rest
608 .rsplit_once(':')
609 .and_then(|(file, line)| num(line).map(|line| (file, line)))
610 .unwrap_or((rest, last));
611 if file.is_empty() {
612 return Err(bad());
613 }
614 Ok(AnchorSpec {
615 file: PathBuf::from(file),
616 line,
617 })
618}
619
620fn cmd_batch(
635 cli: &Cli,
636 out: Output,
637 paths: &[String],
638 kinds: &[String],
639 langs: &[String],
640) -> ExitCode {
641 if out == Output::Json {
642 return fail(
643 out,
644 Failure::Usage,
645 format_args!(
646 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
647 where each line carries the query it answers"
648 ),
649 );
650 }
651 if cli.open || cli.web || cli.show {
652 return fail(
653 out,
654 Failure::Usage,
655 format_args!(
656 "rq: --open, --web and --show act on a single result, not a stream of queries"
657 ),
658 );
659 }
660
661 use std::io::BufRead;
662 let queries: Vec<String> = std::io::stdin()
663 .lock()
664 .lines()
665 .map_while(std::result::Result::ok)
666 .map(|l| l.trim().to_string())
667 .filter(|l| !l.is_empty())
668 .collect();
669 if queries.is_empty() {
673 let _ = Cli::command().print_long_help();
674 return ExitCode::SUCCESS;
675 }
676
677 let mut session = match Session::open(out) {
678 Ok(s) => s,
679 Err(code) => return code,
680 };
681 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
682
683 if !cli.no_wait
686 && session.coverage.as_deref() != Some("complete")
687 && let Some(root) = session.root.clone()
688 {
689 {
690 let budget = cli.wait.unwrap_or_else(wait_budget);
691 crate::trace!(
692 "batch: warming {} queries' worth of index first",
693 queries.len()
694 );
695 let active = session.active_paths.clone();
696 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
697 session.coverage = session
698 .identity
699 .as_deref()
700 .and_then(|id| session.store.coverage_status(id).ok())
701 .flatten();
702 }
703 }
704
705 let mut worst = ExitCode::SUCCESS;
706 let mut any_hit = false;
707 for query in &queries {
708 let code = cmd_search(
709 &mut session,
710 &SearchArgs {
711 query,
712 explain: cli.explain,
713 out,
714 paths,
715 kinds,
716 langs,
717 want: requested_limit(cli.limit),
718 no_wait: true,
722 wait: cli.wait,
723 open: false,
724 web: false,
725 all_repos: cli.all_repos,
726 show: false,
727 batch: true,
728 anchored: cli.anchor.is_some(),
729 },
730 );
731 if code == ExitCode::SUCCESS {
732 any_hit = true;
733 } else {
734 worst = code;
735 }
736 }
737 if any_hit { ExitCode::SUCCESS } else { worst }
740}
741
742fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
743 let &SearchArgs {
744 query,
745 out,
746 want,
747 no_wait,
748 wait,
749 open,
750 web,
751 all_repos,
752 show,
753 ..
754 } = args;
755 let wait_budget = wait.unwrap_or_else(wait_budget);
758 let no_wait = no_wait || wait_budget.is_zero();
759 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
762 want
763 } else {
764 want.saturating_mul(20).max(PATH_HEADROOM)
765 };
766 let _timer = crate::trace::Timer::start("search done");
767 let t_setup = std::time::Instant::now();
768 let setup_span = crate::profile::span("setup");
770 let Session {
773 store,
774 cwd,
775 cwd_is_git,
776 root,
777 active_paths,
778 branch_refresh,
779 identity,
780 coverage,
781 anchor,
782 } = session;
783 let cwd_is_git = *cwd_is_git;
784
785 let known = coverage.is_some();
793 let warming_ok = cwd_is_git || known;
794 if crate::trace::enabled() {
795 crate::trace!(
796 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
797 root.as_deref().map_or("?".into(), crate::trace::abbrev),
798 identity.as_deref().unwrap_or("none"),
799 coverage.as_deref().unwrap_or("none"),
800 active_paths.len(),
801 );
802 }
803 let repo_span = crate::profile::span("setup: repo state");
804 let repo_id = |store: &Store| {
805 identity
806 .as_deref()
807 .and_then(|id| store.repository_id(id).ok().flatten())
808 };
809 let mut current = repo_id(store);
810 let scope = |current| Scope::new(all_repos, warming_ok, current);
813 let ctx = crate::search::Context {
814 active: crate::search::ActiveFiles::new(active_paths.clone()),
815 anchor: anchor.clone(),
816 };
817
818 drop(repo_span);
819 let warm_span = crate::profile::span("setup: warm decision");
820
821 let warm_budget = if warm_detach_enabled() {
828 answer_warm_budget()
829 } else {
830 answer_warm_budget() + deferred_warm_budget()
831 };
832 let was_warming = coverage.as_deref() != Some("complete");
833
834 let indexed_head = (!was_warming)
845 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
846 .flatten();
847 let staleness = (!was_warming && warming_ok && !args.batch)
850 .then(|| root.clone())
851 .flatten()
852 .map(|c| {
853 if warm_detach_enabled() {
854 Staleness::Deferred(c, indexed_head)
855 } else {
856 Staleness::Running(std::thread::spawn(move || {
857 worktree_edits(&c, indexed_head.as_deref())
858 }))
859 }
860 });
861 let want_warm = warming_ok && was_warming && root.is_some();
864
865 let block = want_warm && was_warming && !no_wait;
879 let progress_ui = block && show_progress(out, stderr_interactive());
884 let indexer_budget = if block { wait_budget } else { warm_budget };
885 if progress_ui {
886 install_interrupt_handler();
887 }
888
889 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
892 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
893 crate::trace!(
894 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
895 crate::index::parse_jobs()
896 );
897 let root = root.clone().expect("checked");
898 let active = active_paths.clone();
899 let q = query.to_string();
900 let warm_done = std::sync::Arc::clone(&warm_done);
901 std::thread::spawn(move || {
902 if let Ok(mut idx) = open_store() {
903 let _ = if block {
905 crate::index::index_budgeted_cancellable(
908 &mut idx,
909 &root,
910 &active,
911 indexer_budget,
912 Some(&q),
913 &INTERRUPTED,
914 )
915 } else {
916 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
917 };
918 }
919 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
920 })
921 });
922
923 crate::trace!(
930 "setup (open + repo detect + warm decision): {} ms",
931 t_setup.elapsed().as_millis()
932 );
933 let poll_start = std::time::Instant::now();
934 let deadline = if progress_ui {
938 None
939 } else if block {
940 Some(poll_start + wait_budget)
941 } else {
942 Some(poll_start + answer_warm_budget())
943 };
944 drop(warm_span);
945 let polling = indexer.is_some() && was_warming;
946 drop(setup_span);
950 let mut query_span = crate::profile::span("query");
951 let label = repo_label(root.as_deref());
952 let mut drew_progress = false;
953 let mut last_draw = poll_start;
954 let rank_limit = limit.max(2);
958 let mut total;
959 let mut hits = loop {
960 if current.is_none() {
962 current = repo_id(store);
963 }
964 match scope(current).search(store, query, current, &ctx, rank_limit) {
965 Ok(m) => {
966 total = m.total;
967 let h = m.hits;
968 let confident = h.first().is_some_and(|hit| {
969 hit.features
970 .iter()
971 .any(|f| matches!(f.name, "exact" | "prefix"))
972 });
973 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
974 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
975 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
976 if !polling || confident || warm_finished || stopped || timed_out {
977 break h;
978 }
979 if progress_ui
980 && poll_start.elapsed() >= HEADS_UP_DELAY
981 && last_draw.elapsed() >= PROGRESS_REDRAW
982 {
983 draw_progress(store, identity.as_deref(), &label);
984 drew_progress = true;
985 last_draw = std::time::Instant::now();
986 }
987 }
988 Err(e) => {
989 if let Some(h) = indexer {
990 let _ = h.join();
991 }
992 return fail(out, Failure::Database, format_args!("rq: {e}"));
993 }
994 }
995 std::thread::sleep(POLL_INTERVAL);
996 };
997 query_span.note(|| {
998 if polling {
999 "polled a warming index".to_string()
1000 } else {
1001 String::new()
1002 }
1003 });
1004 drop(query_span);
1005 if drew_progress {
1006 clear_progress();
1007 }
1008 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1010
1011 let here = identity
1012 .as_deref()
1013 .zip(root.as_deref())
1014 .map(|(identity, root)| Here { identity, root });
1015 if !hits.is_empty()
1017 && revalidate_top(store, &hits, here)
1018 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1019 {
1020 total = m.total;
1021 hits = m.hits;
1022 }
1023
1024 let mut live_scan = None;
1028 if !hits.iter().any(strong)
1029 && indexer.is_none()
1030 && coverage.is_none()
1031 && let Some(root) = &root
1032 {
1033 let (tail, cost) = live_fallback(root, query, rank_limit, &ctx);
1034 hits = crate::search::merge(hits, tail, rank_limit);
1035 total = total.max(hits.len());
1036 live_scan = Some(cost);
1037 }
1038
1039 apply_gates(query, &mut hits);
1040 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1041 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1045 total = hits.len();
1046 hits.truncate(want.max(2));
1047 }
1048
1049 if hits.is_empty() {
1050 if block {
1052 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1053 }
1054 if let Some(h) = indexer {
1055 let _ = h.join();
1056 }
1057 let mut incomplete = (block || no_wait)
1064 && identity
1065 .as_deref()
1066 .and_then(|id| store.coverage_status(id).ok().flatten())
1067 .as_deref()
1068 != Some("complete");
1069 incomplete |= settle_warm(
1079 store,
1080 staleness,
1081 false,
1082 was_warming,
1083 warming_ok,
1084 root.as_deref(),
1085 active_paths,
1086 query,
1087 warm_budget,
1088 no_wait,
1089 identity.as_deref(),
1090 );
1091 let elsewhere = match scope(current) {
1096 Scope::Nothing => None,
1097 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1098 Scope::Repo(id) => {
1099 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1100 }
1101 };
1102 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1103 note_live_scan(root, cost, false);
1104 }
1105 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1106 let _span = crate::profile::span("after: record usage");
1110 record_usage(
1111 store,
1112 args,
1113 if incomplete { "warming" } else { "miss" },
1114 coverage.as_deref(),
1115 false,
1116 );
1117 return code;
1118 }
1119
1120 let live = hits
1123 .iter()
1124 .take(want)
1125 .any(|h| h.source == crate::search::Source::Live);
1126 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1127 note_live_scan(root, cost, live);
1128 }
1129
1130 let counted_early = show || open || web;
1135 if counted_early {
1136 let _span = crate::profile::span("record usage");
1137 record_usage(store, args, "hit", coverage.as_deref(), live);
1138 }
1139
1140 attach_confidence(&mut hits);
1144 hits.truncate(want);
1145 let total = total.max(hits.len());
1146 for hit in &mut hits {
1147 hit.total = total;
1148 if args.explain {
1149 hit.explain = Some(
1150 hit.features
1151 .iter()
1152 .map(|f| (f.name.to_string(), f.reported()))
1153 .collect(),
1154 );
1155 }
1156 }
1157
1158 let _signatures_span = crate::profile::span("signatures");
1161 for hit in &mut hits {
1162 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1163 hit.signature = root
1164 .as_deref()
1165 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1166 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1167 }
1168 drop(_signatures_span);
1169
1170 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1173 return code;
1174 }
1175
1176 if open || web {
1181 return finish_open(store, &hits, current, root.as_deref(), web);
1182 }
1183
1184 if let Some(code) = render_hits(args, &hits) {
1185 return code;
1186 }
1187
1188 crate::profile::mark("first answer");
1191
1192 if !counted_early {
1195 let _span = crate::profile::span("after: record usage");
1196 record_usage(store, args, "hit", coverage.as_deref(), live);
1197 }
1198
1199 if let Some(refresh) = branch_refresh.take() {
1206 let _span = crate::profile::span("after: branch refresh");
1207 refresh.store(store);
1208 }
1209
1210 if block {
1215 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1216 }
1217 if let Some(h) = indexer {
1218 let _ = h.join();
1219 }
1220 let _ = settle_warm(
1221 store,
1222 staleness,
1223 true,
1224 was_warming,
1225 warming_ok,
1226 root.as_deref(),
1227 active_paths,
1228 query,
1229 warm_budget,
1230 no_wait,
1231 identity.as_deref(),
1232 );
1233
1234 ExitCode::SUCCESS
1235}
1236
1237fn maybe_detach_warm(
1240 store: &Store,
1241 want_warm: bool,
1242 changed: bool,
1243 root: Option<&std::path::Path>,
1244 identity: Option<&str>,
1245) {
1246 if !warm_detach_enabled() || !want_warm {
1247 return;
1248 }
1249 let (Some(root), Some(id)) = (root, identity) else {
1250 return;
1251 };
1252 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1256 return; }
1258 spawn_detached_warm(root);
1259}
1260
1261fn spawn_detached_warm(root: &std::path::Path) {
1265 use std::os::unix::process::CommandExt;
1266 let Ok(exe) = std::env::current_exe() else {
1267 return;
1268 };
1269 let mut cmd = std::process::Command::new(exe);
1270 cmd.arg("--warm")
1271 .arg(root)
1272 .stdin(std::process::Stdio::null())
1273 .stdout(std::process::Stdio::null())
1274 .stderr(std::process::Stdio::null())
1275 .process_group(0);
1276 match cmd.spawn() {
1277 Ok(child) => crate::trace!(
1278 "background warm (detached): pid {} for {}",
1279 child.id(),
1280 crate::trace::abbrev(root)
1281 ),
1282 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1283 }
1284}
1285
1286const WARM_LOCK_TTL_SECS: i64 = 600;
1289
1290fn warm_recheck_window() -> Duration {
1296 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1297}
1298
1299fn recently_verified(
1302 store: &Store,
1303 identity: Option<&str>,
1304 root: &std::path::Path,
1305 indexed_head: Option<&str>,
1306) -> bool {
1307 let _span = crate::profile::span("after: warm recently verified?");
1308 let (Some(id), Some(stamp)) = (
1309 identity,
1310 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1311 ) else {
1312 return false;
1313 };
1314 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1315 return false;
1316 };
1317 let age = now_unix().saturating_sub(at);
1318 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1319}
1320
1321fn cmd_warm(path: Option<&str>) -> ExitCode {
1326 #[cfg(target_os = "macos")]
1329 unsafe extern "C" {
1330 fn setiopolicy_np(
1333 iotype: libc::c_int,
1334 scope: libc::c_int,
1335 policy: libc::c_int,
1336 ) -> libc::c_int;
1337 }
1338 unsafe {
1339 libc::nice(10);
1340 #[cfg(target_os = "macos")]
1341 setiopolicy_np(0, 0, 3);
1342 }
1343 let mut store = match open_store() {
1344 Ok(s) => s,
1345 Err(_) => return ExitCode::from(Failure::Database.exit_code()),
1346 };
1347 let start = path
1348 .map(PathBuf::from)
1349 .or_else(|| std::env::current_dir().ok())
1350 .unwrap_or_else(|| PathBuf::from("."));
1351 let root = crate::index::repo_root(&start).unwrap_or(start);
1352 let identity = resolve_identity(&store, &root);
1353
1354 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1357 && pid != std::process::id()
1358 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1359 && now_unix() - ts < WARM_LOCK_TTL_SECS
1360 {
1361 return ExitCode::SUCCESS;
1362 }
1363 let _ = store.set_warm_lock(&identity, std::process::id());
1364
1365 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1368 let head = store
1369 .repository_id(&identity)
1370 .ok()
1371 .flatten()
1372 .and_then(|id| store.indexed_head(id).ok().flatten());
1373 let checked_at = now_unix();
1377 let edits = worktree_edits(&root, head.as_deref());
1378 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1379 crate::trace!("warm: unchanged since indexed, nothing to do");
1380 if let Some(stamp) = head
1381 .as_deref()
1382 .and_then(|h| crate::index::git_state_stamp(&root, h))
1383 {
1384 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1385 }
1386 let _ = store.clear_warm_lock(&identity);
1387 return ExitCode::SUCCESS;
1388 }
1389 }
1390
1391 let deadline = std::time::Instant::now() + warm_bg_budget();
1394 let active = crate::index::branch_changed_files(&root);
1395 loop {
1396 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1397 if remaining.is_zero() {
1398 break;
1399 }
1400 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1401 {
1402 Ok(s) => s,
1403 Err(_) => break,
1404 };
1405 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1406 || stats.files_indexed == 0
1407 {
1408 break;
1409 }
1410 }
1411 let _ = store.clear_warm_lock(&identity);
1412 ExitCode::SUCCESS
1413}
1414
1415fn live_fallback(
1418 root: &std::path::Path,
1419 query: &str,
1420 limit: usize,
1421 ctx: &crate::search::Context,
1422) -> (Vec<crate::search::Hit>, LiveCost) {
1423 let start = std::time::Instant::now();
1424 let deadline = start + live_fallback_budget();
1425 let mut files = 0;
1426 let mut scan = |prefilter| {
1427 let mut span = crate::profile::span(if prefilter {
1428 "live scan: prefiltered"
1429 } else {
1430 "live scan: unfiltered"
1431 });
1432 let found = crate::search::live_search(
1433 root,
1434 query,
1435 limit,
1436 &HashSet::new(),
1437 Some(deadline),
1438 prefilter,
1439 ctx,
1440 );
1441 span.note(|| format!("{} files", found.files));
1442 files += found.files;
1443 found.hits
1444 };
1445 let mut hits = scan(true);
1446 if hits.is_empty() {
1447 hits = scan(false);
1448 }
1449 let cost = LiveCost {
1450 files,
1451 elapsed: start.elapsed(),
1452 };
1453 (hits, cost)
1454}
1455
1456struct LiveCost {
1458 files: usize,
1459 elapsed: Duration,
1460}
1461
1462fn note_live_scan(root: &std::path::Path, cost: &LiveCost, answered: bool) {
1465 if !crate::trace::enabled() {
1466 return;
1467 }
1468 let budget = live_fallback_budget();
1469 let files = match cost.files {
1470 1 => "1 file".to_string(),
1471 n => format!("{n} files"),
1472 };
1473 let what = if answered {
1474 "answered from a live scan of"
1475 } else {
1476 "no answer from a live scan of"
1477 };
1478 let cut = if cost.elapsed >= budget {
1479 " (stopped at the budget)"
1480 } else {
1481 ""
1482 };
1483 crate::trace!(
1484 "{what} {}: {files} in {} ms, budget {} ms{cut}",
1485 crate::trace::abbrev(root),
1486 cost.elapsed.as_millis(),
1487 budget.as_millis(),
1488 );
1489}
1490
1491fn strong(h: &crate::search::Hit) -> bool {
1493 h.features
1494 .iter()
1495 .any(|f| matches!(f.name, "exact" | "prefix"))
1496}
1497
1498fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1507 if hits.iter().any(strong) {
1508 hits.retain(strong);
1509 }
1510 crate::search::apply_scope_gate(query, hits);
1511}
1512
1513fn apply_post_filters(
1516 args: &SearchArgs,
1517 cwd: Option<&std::path::Path>,
1518 root: Option<&std::path::Path>,
1519 hits: &mut Vec<crate::search::Hit>,
1520) {
1521 if !args.paths.is_empty() {
1522 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1526 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1527 let norm: Vec<String> = args
1528 .paths
1529 .iter()
1530 .map(|p| repo_relative(&base, &here, p))
1531 .collect();
1532 hits.retain(|h| under_any(&h.file, &norm));
1533 }
1534 if !args.kinds.is_empty() {
1535 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1536 }
1537 if !args.langs.is_empty() {
1538 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1539 }
1540}
1541
1542fn no_match_code(
1548 out: Output,
1549 query: &str,
1550 interrupted: bool,
1551 incomplete: bool,
1552 elsewhere: Option<&str>,
1556) -> ExitCode {
1557 let status = if interrupted {
1558 "interrupted"
1559 } else if incomplete {
1560 "warming"
1561 } else if elsewhere.is_some() {
1562 "scope_not_found"
1563 } else {
1564 "no_match"
1565 };
1566 match out {
1567 Output::Json | Output::Ndjson => {
1568 let mut obj = serde_json::json!({ "status": status, "query": query });
1569 if let Some(found_in) = elsewhere {
1570 obj["found_in"] = serde_json::json!(found_in);
1571 }
1572 let _ = emit_json(out, &obj); }
1574 Output::Text if interrupted => {
1575 eprintln!("rq: indexing interrupted — run again to finish")
1576 }
1577 Output::Text if incomplete => eprintln!(
1578 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1579 ),
1580 Output::Text if elsewhere.is_some() => eprintln!(
1581 "rq: nothing matching {query:?} — that name is defined under {}",
1582 elsewhere.unwrap_or_default()
1583 ),
1584 Output::Text => eprintln!("no matches for {query:?}"),
1585 }
1586 if incomplete {
1587 ExitCode::from(2)
1588 } else {
1589 ExitCode::FAILURE
1590 }
1591}
1592
1593fn attach_confidence(hits: &mut [crate::search::Hit]) {
1597 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1598 if t.is_none_or(|t| h.score > t) {
1599 (Some(h.score), t)
1600 } else if s.is_none_or(|s| h.score > s) {
1601 (t, Some(h.score))
1602 } else {
1603 (t, s)
1604 }
1605 });
1606 for hit in hits.iter_mut() {
1607 let best_other = if Some(hit.score) == top { second } else { top };
1608 hit.confidence = crate::search::confidence(
1609 hit.score,
1610 crate::search::match_quality(&hit.features),
1611 best_other,
1612 );
1613 }
1614}
1615
1616fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1619 let render_span = crate::profile::span("render");
1623 if args.batch {
1624 #[derive(serde::Serialize)]
1627 struct Tagged<'a> {
1628 query: &'a str,
1629 #[serde(flatten)]
1630 hit: &'a crate::search::Hit,
1631 }
1632 let rows: Vec<Tagged> = hits
1633 .iter()
1634 .map(|hit| Tagged {
1635 query: args.query,
1636 hit,
1637 })
1638 .collect();
1639 if let Some(code) = emit_rows(args.out, &rows) {
1640 return Some(code);
1641 }
1642 } else if let Some(code) = emit_rows(args.out, hits) {
1643 return Some(code);
1644 }
1645 if args.out != Output::Text {
1646 return None;
1647 }
1648 drop(render_span);
1649 let color = match_color();
1650 let c = color.as_deref();
1651 let query = args.query;
1652 if args.show {
1653 let total = hits.first().map_or(hits.len(), |h| h.total);
1655 eprintln!(
1656 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1657 hits.len()
1658 );
1659 }
1660 for hit in hits {
1661 let name = hl(&hit.name, query, c);
1664 let qualified = match &hit.parent {
1665 Some(p) => format!("{name} · {p}"),
1666 None => name,
1667 };
1668 println!(
1669 "{}:{} {} {}",
1670 hl_path(&hit.file, query, c),
1671 hit.line,
1672 hit.kind,
1673 qualified
1674 );
1675 if let Some(sig) = &hit.signature {
1676 println!(" {}", hl(sig, query, c));
1677 }
1678 if args.explain {
1679 let parts: Vec<String> = hit
1680 .features
1681 .iter()
1682 .map(|f| format!("{} {}", f.name, f.reported()))
1683 .collect();
1684 println!(
1685 " confidence {:.2} · score {:.0} = {}",
1686 hit.confidence,
1687 hit.score,
1688 parts.join(" + ")
1689 );
1690 }
1691 }
1692 None
1693}
1694
1695fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1699 use std::io::{IsTerminal, Write};
1700 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1701 return hits.first();
1702 }
1703 let mut err = std::io::stderr();
1704 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1705 for (i, h) in hits.iter().enumerate() {
1706 let _ = writeln!(
1707 err,
1708 " {}. {}:{} {} {}",
1709 i + 1,
1710 h.file,
1711 h.line,
1712 h.kind,
1713 h.name
1714 );
1715 }
1716 let _ = write!(err, "rq> ");
1717 let _ = err.flush();
1718 let mut line = String::new();
1719 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1720 return None; }
1722 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1723}
1724
1725fn parse_choice(input: &str, n: usize) -> Option<usize> {
1728 let s = input.trim();
1729 if s.is_empty() {
1730 return Some(0);
1731 }
1732 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1733 (i < n).then_some(i)
1734}
1735
1736fn finish_open(
1739 store: &Store,
1740 hits: &[crate::search::Hit],
1741 current: Option<i64>,
1742 root: Option<&std::path::Path>,
1743 web: bool,
1744) -> ExitCode {
1745 let Some(hit) = choose_hit(hits) else {
1746 return ExitCode::SUCCESS; };
1748
1749 if web {
1750 return open_web(store, hit, current, root);
1751 }
1752
1753 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1756 Some(r) => r.join(&hit.file),
1757 None => PathBuf::from(&hit.file),
1758 };
1759 launch_editor(&target, hit.line)
1760}
1761
1762#[derive(Clone, Copy)]
1764enum Scope {
1765 All,
1767 Repo(i64),
1768 Nothing,
1771}
1772
1773impl Scope {
1774 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1775 match current {
1776 _ if all_repos => Scope::All,
1777 Some(id) => Scope::Repo(id),
1778 None if in_repo => Scope::Nothing,
1779 None => Scope::All,
1780 }
1781 }
1782
1783 fn search(
1784 self,
1785 store: &Store,
1786 query: &str,
1787 current: Option<i64>,
1788 ctx: &crate::search::Context,
1789 limit: usize,
1790 ) -> crate::store::Result<crate::search::Matches> {
1791 let only_repo = match self {
1792 Scope::All => None,
1793 Scope::Repo(id) => Some(id),
1794 Scope::Nothing => {
1795 return Ok(crate::search::Matches {
1796 hits: Vec::new(),
1797 total: 0,
1798 });
1799 }
1800 };
1801 crate::search::search(store, query, current, only_repo, ctx, limit)
1802 }
1803}
1804
1805fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1809 use std::os::unix::process::CommandExt;
1810 let loc = format!("{}:{}", file.display(), line);
1811 match open_command(file, line, &loc) {
1812 Some((prog, args)) => {
1813 crate::profile::emit(false);
1815 let err = std::process::Command::new(&prog).args(&args).exec();
1817 fail(
1818 Output::Text,
1819 Failure::Launch,
1820 format_args!("rq --open: cannot run {prog}: {err}"),
1821 )
1822 }
1823 None => {
1824 println!("{loc}");
1825 ExitCode::SUCCESS
1826 }
1827 }
1828}
1829
1830fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1836 let fstr = file.to_string_lossy().into_owned();
1837
1838 if let Some(t) = std::env::var_os("RQ_OPEN") {
1839 let t = t.to_string_lossy();
1840 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1841 let mut parts = t.split_whitespace().map(|p| {
1842 p.replace("{file}", &fstr)
1843 .replace("{line}", &line.to_string())
1844 .replace("{}", loc)
1845 });
1846 if let Some(prog) = parts.next() {
1847 let mut args: Vec<String> = parts.collect();
1848 if !placeholder {
1849 args.push(loc.to_string());
1850 }
1851 return Some((prog, args));
1852 }
1853 }
1854
1855 if on_path("code") {
1856 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1857 }
1858
1859 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1860 let ed = ed.to_string_lossy().into_owned();
1861 let l = ed.to_ascii_lowercase();
1862 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1864 .iter()
1865 .any(|e| l.contains(e))
1866 {
1867 return Some((ed, vec![format!("+{line}"), fstr]));
1868 }
1869 return Some((ed, vec![fstr]));
1870 }
1871
1872 None
1873}
1874
1875fn open_web(
1880 store: &Store,
1881 hit: &crate::search::Hit,
1882 current: Option<i64>,
1883 root: Option<&std::path::Path>,
1884) -> ExitCode {
1885 if hit.repo_identity.starts_with("local:") {
1886 return fail(
1887 Output::Text,
1888 Failure::NoRemote,
1889 format_args!(
1890 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1891 locally with -o, or add one with `git remote add origin <url>`",
1892 hit.file, hit.line, hit.repo_identity
1893 ),
1894 );
1895 }
1896 let here =
1897 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1898 let rev = root
1899 .filter(|_| here)
1900 .and_then(crate::index::pushed_head)
1901 .unwrap_or_else(|| "HEAD".into());
1902 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1903
1904 use std::os::unix::process::CommandExt;
1905 let browser = std::env::var("BROWSER")
1906 .ok()
1907 .filter(|b| !b.is_empty())
1908 .or_else(|| {
1909 ["open", "xdg-open"]
1910 .into_iter()
1911 .find(|p| on_path(p))
1912 .map(str::to_string)
1913 });
1914 match browser {
1915 Some(prog) => {
1916 crate::profile::emit(false);
1918 let err = std::process::Command::new(&prog).arg(&url).exec();
1920 fail(
1921 Output::Text,
1922 Failure::Launch,
1923 format_args!("rq --web: cannot run {prog}: {err}"),
1924 )
1925 }
1926 None => {
1927 println!("{url}");
1928 ExitCode::SUCCESS
1929 }
1930 }
1931}
1932
1933fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1936 let path: String = file
1937 .bytes()
1938 .map(|b| match b {
1939 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1940 (b as char).to_string()
1941 }
1942 _ => format!("%{b:02X}"),
1943 })
1944 .collect();
1945 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1946}
1947
1948fn on_path(prog: &str) -> bool {
1950 std::env::var_os("PATH")
1951 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1952}
1953
1954const BRANCH_FILES_TTL_SECS: i64 = 15;
1960
1961const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1965
1966const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1978
1979fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1981 let earned = cost_ms.map_or(0, |ms| {
1984 i64::try_from(ms)
1985 .unwrap_or(i64::MAX)
1986 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
1987 / 1000
1988 });
1989 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
1990}
1991
1992struct BranchRefresh {
1996 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
1999 identity: String,
2000 stamp: String,
2001}
2002
2003impl BranchRefresh {
2004 fn store(self, store: &Store) {
2006 let Ok((files, cost_ms)) = self.handle.join() else {
2007 return;
2008 };
2009 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
2010 }
2011}
2012
2013fn cached_branch_files(
2027 store: &Store,
2028 root: &std::path::Path,
2029) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
2030 let identity = resolve_identity(store, root);
2031 let stamp = crate::index::branch_files_stamp(root);
2032 let cached = store.branch_files_get(&identity).ok().flatten();
2033 let now = now_unix();
2034
2035 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
2036 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
2037 {
2038 return (hit.files.clone(), None, hit.cost_ms);
2039 }
2040 let owned_root = root.to_path_buf();
2041 let refresh = BranchRefresh {
2042 handle: std::thread::spawn(move || {
2043 let t = std::time::Instant::now();
2044 let files = crate::index::branch_changed_files(&owned_root);
2045 (files, t.elapsed().as_millis() as u64)
2046 }),
2047 identity,
2048 stamp: stamp.clone(),
2049 };
2050 return (hit.files.clone(), Some(refresh), hit.cost_ms);
2051 }
2052
2053 let t = std::time::Instant::now();
2055 let files = crate::index::branch_changed_files(root);
2056 let cost_ms = t.elapsed().as_millis() as u64;
2057 if let Some(stamp) = stamp {
2058 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
2059 }
2060 (files, None, Some(cost_ms))
2061}
2062
2063fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
2073 let head = indexed_head?;
2074 let _span = crate::profile::span("git: worktree changed?");
2075 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2076}
2077
2078fn changed_since_index(
2081 store: &Store,
2082 identity: Option<&str>,
2083 root: Option<&std::path::Path>,
2084 edits: Option<Vec<String>>,
2085) -> bool {
2086 let (Some(dirty), Some(root)) = (edits, root) else {
2087 return true;
2088 };
2089 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2090 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2091 None => !dirty.is_empty(),
2092 }
2093}
2094
2095enum Staleness {
2099 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2102 Deferred(PathBuf, Option<String>),
2106}
2107
2108#[allow(clippy::too_many_arguments)]
2115fn settle_warm(
2116 store: &Store,
2117 staleness: Option<Staleness>,
2118 hit: bool,
2119 was_warming: bool,
2120 warming_ok: bool,
2121 root: Option<&std::path::Path>,
2122 active: &[String],
2123 query: &str,
2124 budget: Duration,
2125 no_wait: bool,
2126 identity: Option<&str>,
2127) -> bool {
2128 let changed = match staleness {
2129 None => false,
2130 Some(Staleness::Deferred(r, head)) if hit => {
2133 if recently_verified(store, identity, &r, head.as_deref()) {
2134 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2135 } else {
2136 spawn_detached_warm(&r);
2137 }
2138 return false;
2139 }
2140 Some(Staleness::Deferred(r, head)) => {
2141 let _span = crate::profile::span("after: staleness check");
2142 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2143 }
2144 Some(Staleness::Running(h)) => {
2145 let mut span = crate::profile::span("after: staleness wait");
2147 let changed = h.join().map_or(true, |edits| {
2150 changed_since_index(store, identity, root, edits)
2151 });
2152 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2153 changed
2154 }
2155 };
2156 if changed
2166 && !no_wait
2167 && !warm_detach_enabled()
2168 && let Some(r) = root
2169 && let Ok(mut idx) = open_store()
2170 {
2171 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2172 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2173 }
2174 maybe_detach_warm(
2175 store,
2176 warming_ok && (was_warming || changed),
2177 changed,
2178 root,
2179 identity,
2180 );
2181 changed && warm_detach_enabled()
2185}
2186
2187fn answer_warm_budget() -> Duration {
2196 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2197}
2198
2199fn deferred_warm_budget() -> Duration {
2202 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2203}
2204
2205fn live_fallback_budget() -> Duration {
2208 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2209}
2210
2211fn warm_bg_budget() -> Duration {
2214 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2215}
2216
2217fn warm_detach_enabled() -> bool {
2221 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2222}
2223
2224fn wait_budget() -> Duration {
2233 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2234}
2235
2236fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2241 let s = s.trim();
2242 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2243 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2245 (n, 1.0)
2246 } else if let Some(n) = s.strip_suffix('s') {
2247 (n, 1_000.0)
2248 } else if let Some(n) = s.strip_suffix('m') {
2249 (n, 60_000.0)
2250 } else {
2251 (s, 1_000.0)
2253 };
2254 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2255 if !val.is_finite() || val < 0.0 {
2256 return Err(bad());
2257 }
2258 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2259}
2260
2261static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2265
2266extern "C" fn on_sigint(_: libc::c_int) {
2267 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2269}
2270
2271fn install_interrupt_handler() {
2274 static ONCE: std::sync::Once = std::sync::Once::new();
2275 ONCE.call_once(|| unsafe {
2276 let mut action: libc::sigaction = std::mem::zeroed();
2277 action.sa_sigaction = on_sigint as *const () as usize;
2278 libc::sigemptyset(&mut action.sa_mask);
2279 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2280 });
2281}
2282
2283fn stderr_interactive() -> bool {
2287 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2288}
2289
2290fn show_progress(out: Output, interactive: bool) -> bool {
2295 interactive && matches!(out, Output::Text)
2296}
2297
2298fn repo_label(root: Option<&std::path::Path>) -> String {
2301 root.and_then(|r| r.file_name())
2302 .map(|n| n.to_string_lossy().into_owned())
2303 .unwrap_or_else(|| "repo".into())
2304}
2305
2306fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2310 let files = identity
2311 .and_then(|id| store.repository_id(id).ok().flatten())
2312 .and_then(|rid| store.repo_totals(rid).ok())
2313 .map_or(0, |(f, _)| f);
2314 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2315 let _ = std::io::stderr().flush();
2316}
2317
2318fn clear_progress() {
2320 eprint!("\r\x1b[K");
2321 let _ = std::io::stderr().flush();
2322}
2323
2324fn env_budget(var: &str, default_ms: u64) -> Duration {
2328 let ms = std::env::var(var)
2329 .ok()
2330 .and_then(|v| v.parse().ok())
2331 .unwrap_or(default_ms);
2332 Duration::from_millis(ms)
2333}
2334
2335#[derive(Clone, Copy)]
2337struct Here<'a> {
2338 identity: &'a str,
2339 root: &'a std::path::Path,
2340}
2341
2342fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2348 let mut roots: Vec<PathBuf> = here
2349 .filter(|h| h.identity == repo_identity)
2350 .map(|h| h.root.to_path_buf())
2351 .into_iter()
2352 .collect();
2353 let recorded = store
2354 .repository_id(repo_identity)
2355 .ok()
2356 .flatten()
2357 .map(|id| store.checkout_roots(id).unwrap_or_default())
2358 .unwrap_or_default();
2359 for root in recorded.into_iter().map(PathBuf::from) {
2360 if !roots.contains(&root) {
2361 roots.push(root);
2362 }
2363 }
2364 roots
2365}
2366
2367fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2371 let mut roots = hit_file_roots(store, repo_identity, here);
2372 let at = roots
2373 .iter()
2374 .position(|r| r.join(file).is_file())
2375 .unwrap_or(0);
2376 (at < roots.len()).then(|| roots.swap_remove(at))
2377}
2378
2379fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2381 let src = std::fs::read_to_string(path).ok()?;
2382 signature_in(&src.lines().collect::<Vec<_>>(), line)
2383}
2384
2385const SHOW_CONFIDENCE: f64 = 0.85;
2389
2390fn show_top_definition(
2394 hits: &mut [crate::search::Hit],
2395 query: &str,
2396 out: Output,
2397) -> Option<ExitCode> {
2398 let top = hits.first()?;
2399 if top.confidence < SHOW_CONFIDENCE {
2400 return None; }
2402 let end = top.end_line.unwrap_or(top.line);
2403 let body = top.root.as_deref().and_then(|root| {
2404 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2405 span_in(&src, top.line, end)
2406 });
2407 hits[0].body = body;
2408 let top = &hits[0];
2409 let code = match out {
2410 Output::Json | Output::Ndjson => {
2411 emit_json(out, top)
2413 }
2414 Output::Text => {
2415 let color = match_color();
2416 let c = color.as_deref();
2417 let name = hl(&top.name, query, c);
2418 let qualified = match &top.parent {
2419 Some(p) => format!("{name} · {p}"),
2420 None => name,
2421 };
2422 println!(
2423 "{}:{} {} {}",
2424 hl_path(&top.file, query, c),
2425 top.line,
2426 top.kind,
2427 qualified
2428 );
2429 match (&top.body, &top.signature) {
2430 (Some(body), _) => println!("{body}"),
2431 (None, Some(sig)) => println!("{sig}"),
2433 (None, None) => {}
2434 }
2435 ExitCode::SUCCESS
2436 }
2437 };
2438
2439 Some(code)
2440}
2441
2442fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2445 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2446 let lines: Vec<&str> = content.lines().collect();
2447 if s >= lines.len() {
2448 return None;
2449 }
2450 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2451 Some(lines[s..e].join("\n"))
2452}
2453
2454fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2459 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2460 let l = lines.get(idx)?.trim();
2461 (!l.is_empty()).then(|| l.to_string())
2462}
2463
2464#[derive(serde::Serialize)]
2468struct SymbolOut {
2469 name: String,
2470 kind: String,
2471 language: String,
2472 file: String,
2473 root: String,
2475 line: i64,
2476 #[serde(skip_serializing_if = "Option::is_none")]
2477 end_line: Option<i64>,
2478 #[serde(skip_serializing_if = "Option::is_none")]
2479 parent: Option<String>,
2480 #[serde(skip_serializing_if = "Option::is_none")]
2481 visibility: Option<String>,
2482 repo: String,
2483 #[serde(skip_serializing_if = "Option::is_none")]
2484 signature: Option<String>,
2485}
2486
2487fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2492 let open_span = crate::profile::span("store open");
2493 let mut store = match open_store() {
2494 Ok(s) => s,
2495 Err(e) => {
2496 return fail(
2497 out,
2498 Failure::Database,
2499 format_args!("rq: cannot open database: {e}"),
2500 );
2501 }
2502 };
2503 drop(open_span);
2504 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2505 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2506 let rel = repo_relative(&root, &cwd, file_arg);
2507
2508 let identity = resolve_identity(&store, &root);
2509 let coverage = store.coverage_status(&identity).ok().flatten();
2510 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2511 let current = store.repository_id(&identity).ok().flatten();
2512 let path = root.join(&rel);
2513 if !path.is_file() {
2514 return fail(
2515 out,
2516 Failure::NotFound,
2517 format_args!("rq --symbols: no such file: {file_arg}"),
2518 );
2519 }
2520 let indexable = path
2521 .extension()
2522 .and_then(|e| e.to_str())
2523 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2524 match current {
2525 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2529 if indexable {
2530 let _span = crate::profile::span("symbols: refresh");
2531 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2532 }
2533 }
2534 _ if warming_ok => {
2538 let budget = answer_warm_budget() + deferred_warm_budget();
2539 let _span = crate::profile::span("symbols: warm");
2540 let active = [rel.clone()];
2541 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2542 }
2543 _ => {}
2544 }
2545
2546 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2547 return emit_symbols(out, &[]); };
2549 let mut query_span = crate::profile::span("symbols: query");
2550 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2551 Ok(r) => r,
2552 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2553 };
2554 query_span.note(|| format!("{} rows", rows.len()));
2555 drop(query_span);
2556 if !kinds.is_empty() {
2557 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2558 }
2559 if !langs.is_empty() {
2560 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2561 }
2562
2563 let signatures_span = crate::profile::span("symbols: signatures");
2566 let content = std::fs::read_to_string(&path).ok();
2567 let lines: Vec<&str> = content
2568 .as_deref()
2569 .map_or_else(Vec::new, |c| c.lines().collect());
2570 let root_str = root.to_string_lossy().into_owned();
2571 let syms: Vec<SymbolOut> = rows
2572 .into_iter()
2573 .map(|r| SymbolOut {
2574 signature: signature_in(&lines, r.line),
2575 name: r.name,
2576 kind: r.kind,
2577 language: r.language,
2578 file: r.file,
2579 root: root_str.clone(),
2580 line: r.line,
2581 end_line: r.end_line,
2582 parent: r.parent,
2583 visibility: r.visibility,
2584 repo: r.repo_identity,
2585 })
2586 .collect();
2587 drop(signatures_span);
2588 let _span = crate::profile::span("render");
2589 emit_symbols(out, &syms)
2590}
2591
2592fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2595 if syms.is_empty() {
2596 match out {
2597 Output::Json | Output::Ndjson => {
2598 let obj = serde_json::json!({ "status": "no_match" });
2599 let _ = emit_json(out, &obj); }
2601 Output::Text => eprintln!("no symbols"),
2602 }
2603 return ExitCode::FAILURE;
2604 }
2605 if let Some(code) = emit_rows(out, syms) {
2606 return code;
2607 }
2608 match out {
2609 Output::Json | Output::Ndjson => {}
2610 Output::Text => {
2611 for s in syms {
2612 let qualified = match &s.parent {
2613 Some(p) => format!("{} · {p}", s.name),
2614 None => s.name.clone(),
2615 };
2616 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2617 if let Some(sig) = &s.signature {
2618 println!(" {sig}");
2619 }
2620 }
2621 }
2622 }
2623 ExitCode::SUCCESS
2624}
2625
2626fn keyword_kind(token: &str) -> Option<&'static str> {
2631 match token.to_ascii_lowercase().as_str() {
2632 "class" => Some("class"),
2633 "module" => Some("module"),
2634 "method" => Some("method"),
2635 "function" | "fn" => Some("function"),
2636 "struct" | "type" => Some("struct"),
2637 "enum" => Some("enum"),
2638 "trait" | "interface" => Some("trait"),
2639 "constant" | "const" => Some("constant"),
2640 _ => None,
2641 }
2642}
2643
2644fn split_kind_keyword(
2650 target: String,
2651 dirs: Vec<String>,
2652) -> (Option<&'static str>, String, Vec<String>) {
2653 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2656 let rest = rest.trim();
2657 if let Some(k) = keyword_kind(head)
2658 && !rest.is_empty()
2659 {
2660 return (Some(k), rest.to_string(), dirs);
2661 }
2662 } else if let Some(k) = keyword_kind(&target)
2663 && let Some((query, extra)) = dirs.split_first()
2664 {
2665 return (Some(k), query.clone(), extra.to_vec());
2667 }
2668 (None, target, dirs)
2669}
2670
2671fn canonical_kind(s: &str) -> Option<&'static str> {
2674 Some(match s.to_ascii_lowercase().as_str() {
2675 "c" | "class" => "class",
2676 "m" | "method" => "method",
2677 "f" | "fn" | "func" | "function" => "function",
2678 "mod" | "module" => "module",
2679 "s" | "struct" | "type" => "struct",
2680 "e" | "enum" => "enum",
2681 "t" | "trait" | "interface" => "trait",
2682 "const" | "constant" => "constant",
2683 _ => return None,
2684 })
2685}
2686
2687fn canonical_langs(s: &str) -> Vec<String> {
2694 let t = s.to_ascii_lowercase();
2695 let alias = match t.as_str() {
2696 "rb" => Some("ruby"),
2697 "rs" => Some("rust"),
2698 "golang" => Some("go"),
2699 "ts" | "tsx" => Some("typescript"),
2700 "js" | "jsx" => Some("javascript"),
2701 _ => None,
2702 };
2703 let matched: Vec<String> = crate::lang::languages()
2704 .into_iter()
2705 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2706 .map(str::to_string)
2707 .collect();
2708 matched
2709}
2710
2711fn match_color() -> Option<String> {
2715 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2716 return None;
2717 }
2718 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2719 gc.split(':').find_map(|e| {
2720 e.strip_prefix("mt=")
2721 .or_else(|| e.strip_prefix("ms="))
2722 .filter(|v| !v.is_empty())
2723 .map(str::to_string)
2724 })
2725 });
2726 Some(style.unwrap_or_else(|| "1;31".to_string()))
2727}
2728
2729fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2732 match color {
2733 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2734 None => text.to_string(),
2735 }
2736}
2737
2738fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2741 let Some(c) = color else {
2742 return path.to_string();
2743 };
2744 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2745 let base_start = path[..base_byte].chars().count();
2746 let stem = crate::search::path_stem(path);
2750 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2751 .into_iter()
2752 .map(|p| p + base_start)
2753 .collect();
2754 highlight(path, &positions, c)
2755}
2756
2757fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2760 if positions.is_empty() {
2761 return text.to_string();
2762 }
2763 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2764 let mut out = String::new();
2765 let mut on = false;
2766 for (i, c) in text.chars().enumerate() {
2767 match (matched.contains(&i), on) {
2768 (true, false) => {
2769 out.push_str("\x1b[");
2770 out.push_str(color);
2771 out.push('m');
2772 on = true;
2773 }
2774 (false, true) => {
2775 out.push_str("\x1b[0m");
2776 on = false;
2777 }
2778 _ => {}
2779 }
2780 out.push(c);
2781 }
2782 if on {
2783 out.push_str("\x1b[0m");
2784 }
2785 out
2786}
2787
2788fn under_any(file: &str, paths: &[String]) -> bool {
2792 paths.iter().any(|p| {
2793 let p = p.trim_start_matches("./").trim_end_matches('/');
2794 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2795 })
2796}
2797
2798fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2800 let p = std::path::Path::new(file);
2801 let abs = if p.is_absolute() {
2802 p.to_path_buf()
2803 } else {
2804 cwd.join(p)
2805 };
2806 let abs = abs.canonicalize().unwrap_or(abs);
2807 abs.strip_prefix(root)
2808 .map(|r| r.to_string_lossy().into_owned())
2809 .unwrap_or_else(|_| file.to_string())
2810}
2811
2812fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2817 use std::collections::HashSet;
2818 let mut seen = HashSet::new();
2819 let mut changed = false;
2820 for hit in hits {
2821 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2822 continue;
2823 }
2824 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2825 continue;
2826 };
2827 let roots = hit_file_roots(store, &hit.repo_identity, here);
2828 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2829 continue;
2830 };
2831 if let Ok(crate::index::Refresh::Updated) =
2832 crate::index::refresh_file(store, repo_id, root, &hit.file)
2833 {
2834 changed = true;
2835 }
2836 }
2837 changed
2838}
2839
2840fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2846 if let Ok(canon) = cwd.canonicalize() {
2847 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2848 return identity;
2849 }
2850 if crate::index::repo_root(cwd).is_none() {
2851 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2852 }
2853 }
2854 crate::index::detect_identity(cwd).to_string()
2855}
2856
2857fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2858 let explicit = path.is_some();
2859 let target = path.unwrap_or_else(|| PathBuf::from("."));
2860 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2865 let mut subdirs = subdirs.to_vec();
2870 if explicit
2871 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2872 && t != r
2873 && let Ok(rel) = t.strip_prefix(&r)
2874 && !rel.as_os_str().is_empty()
2875 {
2876 subdirs.push(rel.to_string_lossy().into_owned());
2877 }
2878 let open_span = crate::profile::span("store open");
2879 let mut store = match open_store() {
2880 Ok(s) => s,
2881 Err(e) => {
2882 return fail(
2883 out,
2884 Failure::Database,
2885 format_args!("rq: cannot open database: {e}"),
2886 );
2887 }
2888 };
2889 drop(open_span);
2890 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2891 let identity = resolve_identity(&store, &root);
2894 match indexed {
2895 Ok(stats) => {
2896 let subtree = !subdirs.is_empty();
2897 let totals = store
2899 .repository_id(&identity)
2900 .ok()
2901 .flatten()
2902 .and_then(|id| store.repo_totals(id).ok());
2903 match out {
2904 Output::Json | Output::Ndjson => {
2905 let (files, symbols) = match totals {
2906 Some((f, s)) => (Some(f), Some(s)),
2907 None => (None, None),
2908 };
2909 return emit_json(
2910 out,
2911 &serde_json::json!({
2912 "repo": identity,
2913 "scope": if subtree { "subtree" } else { "full" },
2914 "files_added": stats.files_indexed,
2915 "symbols_added": stats.symbols,
2916 "files": files,
2917 "symbols": symbols,
2918 }),
2919 );
2920 }
2921 Output::Text => {
2922 let scope = if subtree { " (subtree seed)" } else { "" };
2923 match totals {
2924 Some((files, symbols)) => println!(
2925 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2926 stats.files_indexed, stats.symbols
2927 ),
2928 None => println!(
2929 "{} file(s)/{} symbol(s) added this run{scope}",
2930 stats.files_indexed, stats.symbols
2931 ),
2932 }
2933 }
2934 }
2935 ExitCode::SUCCESS
2936 }
2937 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2938 }
2939}
2940
2941fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2942 let mut store = match open_store() {
2943 Ok(s) => s,
2944 Err(e) => {
2945 return fail(
2946 out,
2947 Failure::Database,
2948 format_args!("rq: cannot open database: {e}"),
2949 );
2950 }
2951 };
2952
2953 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2957 let root = crate::index::repo_root(&path).unwrap_or(path);
2958 let from_path = crate::index::detect_identity(&root).to_string();
2959 let resolved = match store.repository_id(&from_path) {
2960 Ok(Some(id)) => Some((from_path.clone(), id)),
2961 Ok(None) => target.as_deref().and_then(|s| {
2962 store
2963 .repository_id(s)
2964 .ok()
2965 .flatten()
2966 .map(|id| (s.to_string(), id))
2967 }),
2968 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2969 };
2970
2971 let Some((identity, repo_id)) = resolved else {
2972 return match out {
2974 Output::Text => {
2975 println!("not indexed: {from_path}");
2976 ExitCode::SUCCESS
2977 }
2978 _ => emit_json(
2979 out,
2980 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2981 ),
2982 };
2983 };
2984
2985 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2986 match store.drop_repository(repo_id) {
2987 Ok(()) => match out {
2988 Output::Text => {
2989 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2990 ExitCode::SUCCESS
2991 }
2992 _ => emit_json(
2993 out,
2994 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2995 ),
2996 },
2997 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2998 }
2999}
3000
3001fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
3005 let rendered = if out == Output::Json {
3006 serde_json::to_string_pretty(value)
3007 } else {
3008 serde_json::to_string(value)
3009 };
3010 match rendered {
3011 Ok(s) => {
3012 println!("{s}");
3013 ExitCode::SUCCESS
3014 }
3015 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
3016 }
3017}
3018
3019fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
3023 match out {
3024 Output::Json => match serde_json::to_string_pretty(rows) {
3025 Ok(s) => println!("{s}"),
3026 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3027 },
3028 Output::Ndjson => {
3029 for r in rows {
3030 match serde_json::to_string(r) {
3031 Ok(line) => println!("{line}"),
3032 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3033 }
3034 }
3035 }
3036 Output::Text => {}
3037 }
3038 None
3039}
3040
3041fn cmd_status(out: Output) -> ExitCode {
3042 let store = match open_store() {
3043 Ok(s) => s,
3044 Err(e) => {
3045 return fail(
3046 out,
3047 Failure::Database,
3048 format_args!("rq: cannot open database: {e}"),
3049 );
3050 }
3051 };
3052 let rows = match store.coverage_overview() {
3053 Ok(rows) => rows,
3054 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
3055 };
3056 if let Some(code) = emit_rows(out, &rows) {
3057 return code;
3058 }
3059 match out {
3060 Output::Json | Output::Ndjson => {}
3061 Output::Text if rows.is_empty() => {
3062 println!("no repositories indexed yet (try `rq --index`)");
3063 }
3064 Output::Text => {
3065 for r in &rows {
3066 println!(
3067 "{:<10} {:>6} files {:>7} symbols {}",
3068 r.status, r.files, r.symbols, r.identity
3069 );
3070 }
3071 }
3072 }
3073 ExitCode::SUCCESS
3074}
3075
3076fn cmd_usage(out: Output) -> ExitCode {
3079 let store = match open_store() {
3080 Ok(s) => s,
3081 Err(e) => {
3082 return fail(
3083 out,
3084 Failure::Database,
3085 format_args!("rq: cannot open database: {e}"),
3086 );
3087 }
3088 };
3089 let rows = match store.usage_overview() {
3090 Ok(rows) => rows,
3091 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3092 };
3093 if let Some(code) = emit_rows(out, &rows) {
3094 return code;
3095 }
3096 match out {
3097 Output::Json | Output::Ndjson => {}
3098 Output::Text if rows.is_empty() => {
3099 println!("no usage recorded yet");
3100 }
3101 Output::Text => {
3102 println!(
3105 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3106 "day", "caller", "found", "missed", "warming"
3107 );
3108 for r in &rows {
3109 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3110 println!(
3111 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3112 r.day,
3113 r.source,
3114 r.searches - r.misses - r.warming,
3115 r.misses,
3116 r.warming,
3117 flags
3118 );
3119 }
3120 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3121 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3122 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3123 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3124 let live: i64 = rows.iter().map(|r| r.live).sum();
3125 let plural = if searches == 1 { "search" } else { "searches" };
3126 let live = if live > 0 {
3128 format!(" · {live} from a live scan")
3129 } else {
3130 String::new()
3131 };
3132 println!(
3135 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index{live}"
3136 );
3137 }
3138 }
3139 if rows.is_empty() {
3141 return ExitCode::from(1);
3142 }
3143 ExitCode::SUCCESS
3144}
3145
3146fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3148 let path = db_path()?;
3149 if let Some(parent) = path.parent() {
3150 std::fs::create_dir_all(parent)?;
3151 }
3152 Ok(Store::open(&path)?)
3153}
3154
3155fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3157 if let Ok(p) = std::env::var("RQ_DB") {
3158 return Ok(PathBuf::from(p));
3159 }
3160 let home = std::env::var("HOME")?;
3161 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3162}
3163
3164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3166enum Failure {
3167 Usage,
3169 Database,
3171 NotFound,
3173 NoRemote,
3175 Launch,
3177 Index,
3179 Internal,
3181}
3182
3183impl Failure {
3184 fn as_str(self) -> &'static str {
3185 match self {
3186 Failure::Usage => "usage",
3187 Failure::Database => "database",
3188 Failure::NotFound => "not_found",
3189 Failure::NoRemote => "no_remote",
3190 Failure::Launch => "launch",
3191 Failure::Index => "index",
3192 Failure::Internal => "internal",
3193 }
3194 }
3195
3196 fn exit_code(self) -> u8 {
3200 match self {
3201 Failure::Usage => 64, Failure::NotFound => 66, Failure::NoRemote | Failure::Launch => 69, Failure::Internal => 70, Failure::Database | Failure::Index => 74, }
3207 }
3208}
3209
3210fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3213 let message = args.to_string();
3214 eprintln!("{message}");
3215 emit_error(out, kind, &message);
3216 ExitCode::from(kind.exit_code())
3217}
3218
3219fn emit_error(out: Output, kind: Failure, message: &str) {
3222 let obj = serde_json::json!({
3223 "error": message,
3224 "kind": kind.as_str(),
3225 "code": kind.exit_code(),
3226 });
3227 let rendered = match out {
3229 Output::Text => return,
3230 Output::Json => serde_json::to_string_pretty(&obj),
3231 Output::Ndjson => serde_json::to_string(&obj),
3232 };
3233 if let Ok(s) = rendered {
3234 println!("{s}");
3235 }
3236}
3237
3238fn clap_failure(err: clap::Error) -> ExitCode {
3243 if !err.use_stderr() {
3245 err.exit();
3246 }
3247 let _ = err.print();
3248 let out = requested_output(std::env::args_os().skip(1));
3249 let text = err.to_string();
3250 emit_error(out, Failure::Usage, text.lines().next().unwrap_or(""));
3251 ExitCode::from(Failure::Usage.exit_code())
3252}
3253
3254fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3259 let cmd = Cli::command();
3260 let takes_value = |c: char| {
3261 cmd.get_arguments()
3262 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3263 };
3264 let (mut json, mut ndjson) = (false, false);
3265 for arg in args {
3266 let arg = arg.to_string_lossy();
3267 match arg.as_ref() {
3268 "--" => break,
3269 "--json" => json = true,
3270 "--ndjson" => ndjson = true,
3271 a if a.starts_with('-') && !a.starts_with("--") => {
3272 for c in a.chars().skip(1) {
3273 match c {
3274 'j' => json = true,
3275 'J' => ndjson = true,
3276 c if takes_value(c) => break,
3277 _ => {}
3278 }
3279 }
3280 }
3281 _ => {}
3282 }
3283 }
3284 if ndjson {
3286 Output::Ndjson
3287 } else if json {
3288 Output::Json
3289 } else {
3290 Output::Text
3291 }
3292}
3293
3294#[cfg(test)]
3295mod tests {
3296 use super::*;
3297
3298 #[test]
3299 fn parses_an_anchor_from_the_right() {
3300 let ok = |file: &str, line| {
3301 Ok(AnchorSpec {
3302 file: PathBuf::from(file),
3303 line,
3304 })
3305 };
3306 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3307 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3308 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3309 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3310 assert!(parse_anchor(bad).is_err(), "{bad}");
3311 }
3312 }
3313
3314 #[test]
3315 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3316 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3317 for (args, want) in [
3318 (&["x", "--json"][..], Output::Json),
3319 (&["x", "--ndjson"], Output::Ndjson),
3320 (&["x", "-ej"], Output::Json),
3321 (&["-Je", "x"], Output::Ndjson),
3322 (&["x", "--json", "-J"], Output::Ndjson),
3323 (&["x"], Output::Text),
3324 (&["x", "-xj"], Output::Text),
3326 (&["x", "-l5j"], Output::Text),
3327 (&["--", "-j"], Output::Text),
3329 (&["x", "--jobs", "2"], Output::Text),
3330 ] {
3331 assert!(mode(args) == want, "{args:?}");
3332 }
3333 }
3334
3335 #[test]
3336 fn open_menu_choice_parsing() {
3337 assert_eq!(parse_choice("\n", 5), Some(0));
3339 assert_eq!(parse_choice(" ", 5), Some(0));
3340 assert_eq!(parse_choice("3", 5), Some(2));
3341 assert_eq!(parse_choice("5", 5), Some(4));
3342 assert_eq!(parse_choice("6", 5), None);
3344 assert_eq!(parse_choice("0", 5), None);
3345 assert_eq!(parse_choice("q", 5), None);
3346 }
3347
3348 #[test]
3349 fn web_url_shape() {
3350 assert_eq!(
3351 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3352 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3353 );
3354 }
3355
3356 #[test]
3357 fn wait_duration_parsing() {
3358 use std::time::Duration;
3359 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3361 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3362 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3363 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3364 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3366 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3367 assert!(parse_wait("0s").unwrap().is_zero());
3368 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3370 assert!(parse_wait("2x").is_err());
3372 assert!(parse_wait("").is_err());
3373 assert!(parse_wait("s").is_err());
3374 assert!(parse_wait("-1s").is_err());
3375 }
3376
3377 #[test]
3378 fn leading_kind_keyword_becomes_a_kind_filter() {
3379 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3380 assert_eq!(
3382 split_kind_keyword("class".into(), d(&["Widget"])),
3383 (Some("class"), "Widget".into(), vec![])
3384 );
3385 assert_eq!(
3387 split_kind_keyword("method zoom".into(), vec![]),
3388 (Some("method"), "zoom".into(), vec![])
3389 );
3390 assert_eq!(
3392 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3393 (Some("function"), "Foo::run".into(), vec![])
3394 );
3395 assert_eq!(
3397 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3398 (Some("struct"), "Gadget".into(), d(&["src"]))
3399 );
3400 }
3401
3402 #[test]
3403 fn a_bare_or_non_keyword_query_is_left_alone() {
3404 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3405 assert_eq!(
3407 split_kind_keyword("class".into(), vec![]),
3408 (None, "class".into(), vec![])
3409 );
3410 assert_eq!(
3412 split_kind_keyword("Widget".into(), d(&["app"])),
3413 (None, "Widget".into(), d(&["app"]))
3414 );
3415 assert_eq!(
3417 split_kind_keyword("c".into(), d(&["Foo"])),
3418 (None, "c".into(), d(&["Foo"]))
3419 );
3420 }
3421
3422 #[test]
3423 fn the_branch_window_scales_with_what_the_refresh_costs() {
3424 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3427 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3428 assert_eq!(branch_files_ttl(Some(700)), 70);
3432 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3433 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3436 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3437 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3439 }
3440
3441 #[test]
3442 fn a_language_selects_by_prefix_or_alias() {
3443 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3445 assert_eq!(canonical_langs("t"), ["typescript"]);
3446 assert_eq!(canonical_langs("ts"), ["typescript"]);
3448 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3449 assert_eq!(canonical_langs("rb"), ["ruby"]);
3450 assert!(canonical_langs("COBOL").is_empty());
3453 }
3454
3455 #[test]
3456 fn a_kind_normalizes_language_specific_spellings() {
3457 assert_eq!(canonical_kind("f"), Some("function"));
3458 assert_eq!(canonical_kind("interface"), Some("trait"));
3460 assert_eq!(canonical_kind("type"), Some("struct"));
3461 assert_eq!(canonical_kind("const"), Some("constant"));
3462 assert_eq!(canonical_kind("banana"), None);
3463 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3465 assert_eq!(
3466 split_kind_keyword("interface".into(), d(&["Renderer"])),
3467 (Some("trait"), "Renderer".into(), vec![])
3468 );
3469 }
3470
3471 #[test]
3472 fn highlight_wraps_matched_runs() {
3473 assert_eq!(
3474 highlight("FooThing", &[0, 1, 2], "1;31"),
3475 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3476 );
3477 assert_eq!(
3479 highlight("FooThing", &[0, 3], "1"),
3480 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3481 );
3482 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3484 }
3485
3486 #[test]
3487 fn progress_ui_only_for_an_interactive_text_terminal() {
3488 assert!(show_progress(Output::Text, true));
3490
3491 assert!(!show_progress(Output::Json, true));
3493 assert!(!show_progress(Output::Ndjson, true));
3494
3495 assert!(!show_progress(Output::Text, false));
3497 }
3498
3499 #[test]
3500 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3501 let lines = ["class Widget", "", " def go"];
3502 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3503 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3504 assert_eq!(signature_in(&lines, 2), None, "blank line");
3505 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3506 assert_eq!(signature_in(&lines, 4), None, "past the end");
3507 }
3508
3509 #[test]
3510 fn repo_label_uses_the_directory_name() {
3511 assert_eq!(
3512 repo_label(Some(std::path::Path::new("/src/widgets"))),
3513 "widgets"
3514 );
3515 assert_eq!(repo_label(None), "repo");
3516 }
3517
3518 #[test]
3519 fn hl_path_highlights_the_stem_not_the_extension() {
3520 let out = hl_path(
3523 "app/employees_controller.rb",
3524 "employeescontroller",
3525 Some("1;31"),
3526 );
3527 assert!(
3528 out.starts_with("app/\u{1b}[1;31memployees"),
3529 "stem highlighted: {out:?}"
3530 );
3531 assert!(
3532 out.ends_with("controller\u{1b}[0m.rb"),
3533 "`.rb` left un-highlighted: {out:?}"
3534 );
3535 }
3536}