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/v, type, macro)\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, an absolute path (default \
53~/.local/share/rq/rq.db); it warms \
54automatically on the first search in a git repo. On a large, cold repo a search \
55keeps indexing until it can answer rather than reporting a premature \"no \
56matches\" (an interactive run shows progress and stops on Ctrl-C).\n\n\
57EXIT CODES:\n \
580 matched; --status, --index, --drop: ran, even with nothing to show or drop\n \
591 no match; --usage: nothing recorded yet\n \
602 no match yet: the index is still warming, or indexing was interrupted — ask again\n \
6164 usage error: a bad flag, value or query — fix the command\n \
6266 a file the command names doesn't exist\n \
6369 no editor, browser or git remote to hand off to\n \
6470 internal error (a bug)\n \
6574 the index can't be opened, read or written"
66)]
67struct Cli {
68 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
74 target: Option<String>,
75
76 #[arg(value_name = "PATH")]
78 dirs: Vec<String>,
79
80 #[arg(short = 'e', long)]
82 explain: bool,
83
84 #[arg(long = "no-wait")]
91 no_wait: bool,
92
93 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
98 wait: Option<Duration>,
99
100 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
106 open: bool,
107
108 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
113 web: bool,
114
115 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
119 show: bool,
120
121 #[arg(short = 'j', long)]
123 json: bool,
124
125 #[arg(short = 'J', long, conflicts_with = "json")]
127 ndjson: bool,
128
129 #[arg(short = 'p', long, value_name = "DIR")]
131 path: Vec<String>,
132
133 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
135 limit: usize,
136
137 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
142 kind: Vec<String>,
143
144 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
148 lang: Vec<String>,
149
150 #[arg(short = 'a', long = "all-repos")]
153 all_repos: bool,
154
155 #[arg(long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
161 anchor: Option<AnchorSpec>,
162
163 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
165 index: Option<Option<String>>,
166
167 #[arg(long, conflicts_with = "index")]
169 status: bool,
170
171 #[arg(long, conflicts_with_all = ["index", "status"])]
173 usage: bool,
174
175 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
178 symbols: Option<String>,
179
180 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
184 drop: bool,
185
186 #[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"])]
190 warm: Option<Option<String>>,
191
192 #[arg(long, value_name = "SHELL")]
194 completions: Option<Shell>,
195
196 #[arg(short = 'v', long)]
199 verbose: bool,
200
201 #[arg(long)]
205 profile: bool,
206
207 #[arg(long, value_name = "N", default_value_t = 0)]
210 jobs: usize,
211}
212
213pub fn run() -> ExitCode {
215 let cli = match Cli::try_parse() {
216 Ok(cli) => cli,
217 Err(err) => return clap_failure(err),
218 };
219 crate::trace::enable_from(cli.verbose);
220 crate::profile::enable_from(cli.profile);
221 crate::index::set_parse_jobs(cli.jobs);
222 let json_out = output_format(&cli) != Output::Text;
223 let code = dispatch(cli);
224 crate::profile::emit(json_out);
225 code
226}
227
228fn dispatch(cli: Cli) -> ExitCode {
229 if let Some(shell) = cli.completions {
230 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
231 return ExitCode::SUCCESS;
232 }
233 if let Err(message) = db_location() {
234 return fail(
235 output_format(&cli),
236 Failure::Usage,
237 format_args!("{message}"),
238 );
239 }
240 if let Some(path) = &cli.index {
241 let out = output_format(&cli);
243 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
244 }
245 if let Some(path) = &cli.warm {
246 return cmd_warm(path.as_deref());
247 }
248 if cli.status {
249 return cmd_status(output_format(&cli));
250 }
251 if cli.usage {
252 return cmd_usage(output_format(&cli));
253 }
254 if cli.drop {
255 let out = output_format(&cli);
256 return cmd_drop(cli.target, out);
257 }
258 let out = output_format(&cli);
259 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
262 return fail(
263 out,
264 Failure::Usage,
265 format_args!(
266 "rq: empty query — name the definition to find; `rq --symbols FILE` lists a file's (-k to filter)"
267 ),
268 );
269 }
270 let mut kinds: Vec<String> = Vec::new();
274 for k in &cli.kind {
275 match canonical_kind(k) {
276 Some(c) => kinds.extend(c.iter().map(|k| k.to_string())),
277 None => {
278 return fail(
279 out,
280 Failure::Usage,
281 format_args!(
282 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant, type, macro, variant)"
283 ),
284 );
285 }
286 }
287 }
288 let mut langs: Vec<String> = Vec::new();
290 for x in &cli.lang {
291 let matched = canonical_langs(x);
292 if matched.is_empty() {
293 return fail(
294 out,
295 Failure::Usage,
296 format_args!(
297 "rq: unknown --lang {x:?} ({})",
298 crate::lang::languages().join(", ")
299 ),
300 );
301 }
302 langs.extend(matched);
303 }
304 if let Some(file) = &cli.symbols {
305 return cmd_symbols(file, &kinds, &langs, out);
306 }
307 let mut paths = cli.path.clone();
309 match cli.target {
310 Some(target) => {
311 let query = if cli.kind.is_empty() {
314 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
315 if let Some(k) = kw {
316 kinds.extend(k.iter().map(|k| k.to_string()));
317 }
318 paths.extend(dirs);
319 query
320 } else {
321 paths.extend(cli.dirs.clone());
322 target
323 };
324 let mut session = match Session::open(out) {
325 Ok(s) => s,
326 Err(code) => return code,
327 };
328 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
329 cmd_search(
330 &mut session,
331 &SearchArgs {
332 query: &query,
333 explain: cli.explain,
334 out,
335 paths: &paths,
336 kinds: &kinds,
337 langs: &langs,
338 want: requested_limit(cli.limit),
339 no_wait: cli.no_wait,
340 wait: cli.wait,
341 open: cli.open,
342 web: cli.web,
343 all_repos: cli.all_repos,
344 show: cli.show,
345 batch: false,
346 anchored: cli.anchor.is_some(),
347 },
348 )
349 }
350 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
353 None => {
355 let _ = Cli::command().print_long_help();
356 ExitCode::SUCCESS
357 }
358 }
359}
360
361#[derive(Clone, Copy, PartialEq)]
363enum Output {
364 Text,
365 Json,
366 Ndjson,
367}
368
369fn output_format(cli: &Cli) -> Output {
370 if cli.ndjson {
371 Output::Ndjson
372 } else if cli.json {
373 Output::Json
374 } else {
375 Output::Text
376 }
377}
378
379const DEFAULT_LIMIT: usize = 10;
381
382const PATH_HEADROOM: usize = 200;
385
386fn requested_limit(limit: usize) -> usize {
389 if limit == 0 { usize::MAX } else { limit }
390}
391
392fn record_usage(
395 store: &Store,
396 args: &SearchArgs,
397 status: &str,
398 coverage: Option<&str>,
399 live: bool,
400) {
401 let _ = store.record_search(&crate::store::SearchRecord {
402 source: &crate::origin::detect(),
403 flags: &flag_summary(args),
404 status,
405 coverage: coverage.unwrap_or("none"),
406 live,
407 });
408}
409
410fn flag_summary(args: &SearchArgs) -> String {
415 let mut on: Vec<&str> = Vec::new();
416 match args.out {
417 Output::Json => on.push("json"),
418 Output::Ndjson => on.push("ndjson"),
419 Output::Text => {}
420 }
421 for (present, name) in [
422 (args.explain, "explain"),
423 (args.show, "show"),
424 (args.open, "open"),
425 (args.web, "web"),
426 (args.all_repos, "all-repos"),
427 (args.no_wait, "no-wait"),
428 (args.batch, "batch"),
429 (args.anchored, "anchor"),
430 (!args.paths.is_empty(), "path"),
431 (!args.kinds.is_empty(), "kind"),
432 (!args.langs.is_empty(), "lang"),
433 (args.want != DEFAULT_LIMIT, "limit"),
434 ] {
435 if present {
436 on.push(name);
437 }
438 }
439 on.join(",")
440}
441
442const POLL_INTERVAL: Duration = Duration::from_millis(100);
449
450const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
454
455const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
459
460struct SearchArgs<'a> {
462 query: &'a str,
463 explain: bool,
464 out: Output,
465 paths: &'a [String],
466 kinds: &'a [String],
467 langs: &'a [String],
468 want: usize,
470 no_wait: bool,
472 wait: Option<Duration>,
475 open: bool,
476 web: bool,
477 all_repos: bool,
478 batch: bool,
482 show: bool,
483 anchored: bool,
485}
486
487struct Session {
498 store: Store,
499 cwd: Option<PathBuf>,
500 cwd_is_git: bool,
501 root: Option<PathBuf>,
502 active_paths: Vec<String>,
503 branch_refresh: Option<BranchRefresh>,
504 identity: Option<String>,
505 coverage: Option<String>,
506 anchor: Option<crate::search::Anchor>,
508}
509
510impl Session {
511 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
513 let open_span = crate::profile::span("store open");
514 let store = match open_store() {
515 Ok(s) => s,
516 Err(e) => {
517 return Err(fail(
518 out,
519 Failure::Database,
520 format_args!("rq: cannot open database: {e}"),
521 ));
522 }
523 };
524 drop(open_span);
525 let git_span = crate::profile::span("setup: git root");
526 let cwd = std::env::current_dir().ok();
527 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
528
529 let root = cwd
535 .as_deref()
536 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
537 drop(git_span);
538
539 let mut branch_span = crate::profile::span("setup: branch files");
542 let (active_paths, branch_refresh, cached_cost) = match &root {
543 Some(c) if cwd_is_git => cached_branch_files(&store, c),
544 _ => (Vec::new(), None, None),
545 };
546 branch_span.note(|| {
547 let how = if branch_refresh.is_some() {
548 "cached, refreshing alongside"
549 } else {
550 "cached"
551 };
552 let ttl = branch_files_ttl(cached_cost);
555 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
556 });
557 drop(branch_span);
558
559 let mut identity_span = crate::profile::span("setup: identity");
564 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
565 let coverage = identity
566 .as_deref()
567 .and_then(|id| store.coverage_status(id).ok())
568 .flatten();
569 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
570 drop(identity_span);
571 Ok(Session {
572 store,
573 cwd,
574 cwd_is_git,
575 root,
576 active_paths,
577 branch_refresh,
578 identity,
579 coverage,
580 anchor: None,
581 })
582 }
583
584 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
588 let _span = crate::profile::span("setup: anchor");
589 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
590 let abs = here.join(&spec.file);
591 let abs = abs.canonicalize().unwrap_or(abs);
592 let root = self
593 .root
594 .clone()
595 .filter(|r| abs.starts_with(r))
596 .or_else(|| crate::index::repo_root(&abs))
597 .or_else(|| abs.parent().map(PathBuf::from))
598 .unwrap_or(here);
599 let identity = resolve_identity(&self.store, &root);
600 let rel = abs
601 .strip_prefix(&root)
602 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
603 .into_owned();
604 let repo_id = self.store.repository_id(&identity).ok().flatten();
605 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
606 crate::search::Anchor::new(identity, rel, spec.line, &defs)
607 }
608}
609
610#[derive(Debug, Clone, PartialEq)]
612struct AnchorSpec {
613 file: PathBuf,
614 line: i64,
615}
616
617fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
620 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
621 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
622 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
623 let last = num(last).ok_or_else(bad)?;
624 let (file, line) = rest
626 .rsplit_once(':')
627 .and_then(|(file, line)| num(line).map(|line| (file, line)))
628 .unwrap_or((rest, last));
629 if file.is_empty() {
630 return Err(bad());
631 }
632 Ok(AnchorSpec {
633 file: PathBuf::from(file),
634 line,
635 })
636}
637
638fn cmd_batch(
653 cli: &Cli,
654 out: Output,
655 paths: &[String],
656 kinds: &[String],
657 langs: &[String],
658) -> ExitCode {
659 if out == Output::Json {
660 return fail(
661 out,
662 Failure::Usage,
663 format_args!(
664 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
665 where each line carries the query it answers"
666 ),
667 );
668 }
669 if cli.open || cli.web || cli.show {
670 return fail(
671 out,
672 Failure::Usage,
673 format_args!(
674 "rq: --open, --web and --show act on a single result, not a stream of queries"
675 ),
676 );
677 }
678
679 use std::io::BufRead;
680 let queries: Vec<String> = std::io::stdin()
681 .lock()
682 .lines()
683 .map_while(std::result::Result::ok)
684 .map(|l| l.trim().to_string())
685 .filter(|l| !l.is_empty())
686 .collect();
687 if queries.is_empty() {
691 let _ = Cli::command().print_long_help();
692 return ExitCode::SUCCESS;
693 }
694
695 let mut session = match Session::open(out) {
696 Ok(s) => s,
697 Err(code) => return code,
698 };
699 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
700
701 if !cli.no_wait
704 && session.coverage.as_deref() != Some("complete")
705 && let Some(root) = session.root.clone()
706 {
707 {
708 let budget = cli.wait.unwrap_or_else(wait_budget);
709 crate::trace!(
710 "batch: warming {} queries' worth of index first",
711 queries.len()
712 );
713 let active = session.active_paths.clone();
714 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
715 session.coverage = session
716 .identity
717 .as_deref()
718 .and_then(|id| session.store.coverage_status(id).ok())
719 .flatten();
720 }
721 }
722
723 let mut worst = ExitCode::SUCCESS;
724 let mut any_hit = false;
725 for query in &queries {
726 let code = cmd_search(
727 &mut session,
728 &SearchArgs {
729 query,
730 explain: cli.explain,
731 out,
732 paths,
733 kinds,
734 langs,
735 want: requested_limit(cli.limit),
736 no_wait: true,
740 wait: cli.wait,
741 open: false,
742 web: false,
743 all_repos: cli.all_repos,
744 show: false,
745 batch: true,
746 anchored: cli.anchor.is_some(),
747 },
748 );
749 if code == ExitCode::SUCCESS {
750 any_hit = true;
751 } else {
752 worst = code;
753 }
754 }
755 if any_hit { ExitCode::SUCCESS } else { worst }
758}
759
760fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
761 let &SearchArgs {
762 query,
763 out,
764 want,
765 no_wait,
766 wait,
767 open,
768 web,
769 all_repos,
770 show,
771 ..
772 } = args;
773 let wait_budget = wait.unwrap_or_else(wait_budget);
776 let no_wait = no_wait || wait_budget.is_zero();
777 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
780 want
781 } else {
782 want.saturating_mul(20).max(PATH_HEADROOM)
783 };
784 let _timer = crate::trace::Timer::start("search done");
785 let t_setup = std::time::Instant::now();
786 let setup_span = crate::profile::span("setup");
788 let Session {
791 store,
792 cwd,
793 cwd_is_git,
794 root,
795 active_paths,
796 branch_refresh,
797 identity,
798 coverage,
799 anchor,
800 } = session;
801 let cwd_is_git = *cwd_is_git;
802
803 let known = coverage.is_some();
811 let warming_ok = cwd_is_git || known;
812 if crate::trace::enabled() {
813 crate::trace!(
814 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
815 root.as_deref().map_or("?".into(), crate::trace::abbrev),
816 identity.as_deref().unwrap_or("none"),
817 coverage.as_deref().unwrap_or("none"),
818 active_paths.len(),
819 );
820 }
821 let repo_span = crate::profile::span("setup: repo state");
822 let repo_id = |store: &Store| {
823 identity
824 .as_deref()
825 .and_then(|id| store.repository_id(id).ok().flatten())
826 };
827 let mut current = repo_id(store);
828 let scope = |current| Scope::new(all_repos, warming_ok, current);
831 let ctx = crate::search::Context {
832 active: crate::search::ActiveFiles::new(active_paths.clone()),
833 anchor: anchor.clone(),
834 };
835
836 drop(repo_span);
837 let warm_span = crate::profile::span("setup: warm decision");
838
839 let warm_budget = if warm_detach_enabled() {
846 answer_warm_budget()
847 } else {
848 answer_warm_budget() + deferred_warm_budget()
849 };
850 let was_warming = coverage.as_deref() != Some("complete");
851
852 let indexed_head = (!was_warming)
863 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
864 .flatten();
865 let staleness = (!was_warming && warming_ok && !args.batch)
868 .then(|| root.clone())
869 .flatten()
870 .map(|c| {
871 if warm_detach_enabled() {
872 Staleness::Deferred(c, indexed_head)
873 } else {
874 Staleness::Running(std::thread::spawn(move || {
875 worktree_edits(&c, indexed_head.as_deref())
876 }))
877 }
878 });
879 let want_warm = warming_ok && was_warming && root.is_some();
882
883 let block = want_warm && was_warming && !no_wait;
897 let progress_ui = block && show_progress(out, stderr_interactive());
902 let indexer_budget = if block { wait_budget } else { warm_budget };
903 if progress_ui {
904 install_interrupt_handler();
905 }
906
907 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
910 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
911 crate::trace!(
912 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
913 crate::index::parse_jobs()
914 );
915 let root = root.clone().expect("checked");
916 let active = active_paths.clone();
917 let q = query.to_string();
918 let warm_done = std::sync::Arc::clone(&warm_done);
919 std::thread::spawn(move || {
920 if let Ok(mut idx) = open_store() {
921 let _ = if block {
923 crate::index::index_budgeted_cancellable(
926 &mut idx,
927 &root,
928 &active,
929 indexer_budget,
930 Some(&q),
931 &INTERRUPTED,
932 )
933 } else {
934 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
935 };
936 }
937 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
938 })
939 });
940
941 crate::trace!(
948 "setup (open + repo detect + warm decision): {} ms",
949 t_setup.elapsed().as_millis()
950 );
951 let poll_start = std::time::Instant::now();
952 let deadline = if progress_ui {
956 None
957 } else if block {
958 Some(poll_start + wait_budget)
959 } else {
960 Some(poll_start + answer_warm_budget())
961 };
962 drop(warm_span);
963 let polling = indexer.is_some() && was_warming;
964 drop(setup_span);
968 let mut query_span = crate::profile::span("query");
969 let label = repo_label(root.as_deref());
970 let mut drew_progress = false;
971 let mut last_draw = poll_start;
972 let rank_limit = limit.max(2);
976 let mut total;
977 let mut hits = loop {
978 if current.is_none() {
980 current = repo_id(store);
981 }
982 match scope(current).search(store, query, current, &ctx, rank_limit) {
983 Ok(m) => {
984 total = m.total;
985 let h = m.hits;
986 let confident = h.first().is_some_and(|hit| {
987 hit.features
988 .iter()
989 .any(|f| matches!(f.name, "exact" | "prefix"))
990 });
991 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
992 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
993 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
994 if !polling || confident || warm_finished || stopped || timed_out {
995 break h;
996 }
997 if progress_ui
998 && poll_start.elapsed() >= HEADS_UP_DELAY
999 && last_draw.elapsed() >= PROGRESS_REDRAW
1000 {
1001 draw_progress(store, identity.as_deref(), &label);
1002 drew_progress = true;
1003 last_draw = std::time::Instant::now();
1004 }
1005 }
1006 Err(e) => {
1007 if let Some(h) = indexer {
1008 let _ = h.join();
1009 }
1010 return fail(out, Failure::Database, format_args!("rq: {e}"));
1011 }
1012 }
1013 std::thread::sleep(POLL_INTERVAL);
1014 };
1015 query_span.note(|| {
1016 if polling {
1017 "polled a warming index".to_string()
1018 } else {
1019 String::new()
1020 }
1021 });
1022 drop(query_span);
1023 if drew_progress {
1024 clear_progress();
1025 }
1026 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1028
1029 let here = identity
1030 .as_deref()
1031 .zip(root.as_deref())
1032 .map(|(identity, root)| Here { identity, root });
1033 if !hits.is_empty()
1035 && revalidate_top(store, &hits, here)
1036 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1037 {
1038 total = m.total;
1039 hits = m.hits;
1040 }
1041
1042 let mut live_scan = None;
1046 if !hits.iter().any(strong)
1047 && indexer.is_none()
1048 && coverage.is_none()
1049 && let Some(root) = &root
1050 {
1051 let (tail, cost) = live_fallback(root, query, rank_limit, &ctx);
1052 hits = crate::search::merge(hits, tail, rank_limit);
1053 total = total.max(hits.len());
1054 live_scan = Some(cost);
1055 }
1056
1057 apply_gates(query, &mut hits);
1058 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1059 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1063 total = hits.len();
1064 hits.truncate(want.max(2));
1065 }
1066
1067 if hits.is_empty() {
1068 if block {
1070 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1071 }
1072 if let Some(h) = indexer {
1073 let _ = h.join();
1074 }
1075 let mut incomplete = (block || no_wait)
1082 && identity
1083 .as_deref()
1084 .and_then(|id| store.coverage_status(id).ok().flatten())
1085 .as_deref()
1086 != Some("complete");
1087 incomplete |= settle_warm(
1097 store,
1098 staleness,
1099 false,
1100 was_warming,
1101 warming_ok,
1102 root.as_deref(),
1103 active_paths,
1104 query,
1105 warm_budget,
1106 no_wait,
1107 identity.as_deref(),
1108 );
1109 let elsewhere = match scope(current) {
1114 Scope::Nothing => None,
1115 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1116 Scope::Repo(id) => {
1117 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1118 }
1119 };
1120 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1121 note_live_scan(root, cost, false);
1122 }
1123 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1124 let _span = crate::profile::span("after: record usage");
1128 record_usage(
1129 store,
1130 args,
1131 if incomplete { "warming" } else { "miss" },
1132 coverage.as_deref(),
1133 false,
1134 );
1135 return code;
1136 }
1137
1138 let live = hits
1141 .iter()
1142 .take(want)
1143 .any(|h| h.source == crate::search::Source::Live);
1144 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1145 note_live_scan(root, cost, live);
1146 }
1147
1148 let counted_early = show || open || web;
1153 if counted_early {
1154 let _span = crate::profile::span("record usage");
1155 record_usage(store, args, "hit", coverage.as_deref(), live);
1156 }
1157
1158 attach_confidence(&mut hits);
1162 hits.truncate(want);
1163 let total = total.max(hits.len());
1164 for hit in &mut hits {
1165 hit.total = total;
1166 if args.explain {
1167 hit.explain = Some(
1168 hit.features
1169 .iter()
1170 .map(|f| (f.name.to_string(), f.reported()))
1171 .collect(),
1172 );
1173 }
1174 }
1175
1176 let _signatures_span = crate::profile::span("signatures");
1179 for hit in &mut hits {
1180 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1181 hit.signature = root
1182 .as_deref()
1183 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1184 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1185 }
1186 drop(_signatures_span);
1187
1188 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1191 return code;
1192 }
1193
1194 if open || web {
1199 return finish_open(store, &hits, current, root.as_deref(), web);
1200 }
1201
1202 if let Some(code) = render_hits(args, &hits) {
1203 return code;
1204 }
1205
1206 crate::profile::mark("first answer");
1209
1210 if !counted_early {
1213 let _span = crate::profile::span("after: record usage");
1214 record_usage(store, args, "hit", coverage.as_deref(), live);
1215 }
1216
1217 if let Some(refresh) = branch_refresh.take() {
1224 let _span = crate::profile::span("after: branch refresh");
1225 refresh.store(store);
1226 }
1227
1228 if block {
1233 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1234 }
1235 if let Some(h) = indexer {
1236 let _ = h.join();
1237 }
1238 let _ = settle_warm(
1239 store,
1240 staleness,
1241 true,
1242 was_warming,
1243 warming_ok,
1244 root.as_deref(),
1245 active_paths,
1246 query,
1247 warm_budget,
1248 no_wait,
1249 identity.as_deref(),
1250 );
1251
1252 ExitCode::SUCCESS
1253}
1254
1255fn maybe_detach_warm(
1258 store: &Store,
1259 want_warm: bool,
1260 changed: bool,
1261 root: Option<&std::path::Path>,
1262 identity: Option<&str>,
1263) {
1264 if !warm_detach_enabled() || !want_warm {
1265 return;
1266 }
1267 let (Some(root), Some(id)) = (root, identity) else {
1268 return;
1269 };
1270 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1274 return; }
1276 spawn_detached_warm(root);
1277}
1278
1279fn spawn_detached_warm(root: &std::path::Path) {
1283 use std::os::unix::process::CommandExt;
1284 let Ok(exe) = std::env::current_exe() else {
1285 return;
1286 };
1287 let mut cmd = std::process::Command::new(exe);
1288 cmd.arg("--warm")
1289 .arg(root)
1290 .stdin(std::process::Stdio::null())
1291 .stdout(std::process::Stdio::null())
1292 .stderr(std::process::Stdio::null())
1293 .process_group(0);
1294 match cmd.spawn() {
1295 Ok(child) => crate::trace!(
1296 "background warm (detached): pid {} for {}",
1297 child.id(),
1298 crate::trace::abbrev(root)
1299 ),
1300 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1301 }
1302}
1303
1304const WARM_LOCK_TTL_SECS: i64 = 600;
1307
1308fn warm_recheck_window() -> Duration {
1314 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1315}
1316
1317fn recently_verified(
1320 store: &Store,
1321 identity: Option<&str>,
1322 root: &std::path::Path,
1323 indexed_head: Option<&str>,
1324) -> bool {
1325 let _span = crate::profile::span("after: warm recently verified?");
1326 let (Some(id), Some(stamp)) = (
1327 identity,
1328 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1329 ) else {
1330 return false;
1331 };
1332 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1333 return false;
1334 };
1335 let age = now_unix().saturating_sub(at);
1336 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1337}
1338
1339fn cmd_warm(path: Option<&str>) -> ExitCode {
1344 #[cfg(target_os = "macos")]
1347 unsafe extern "C" {
1348 fn setiopolicy_np(
1351 iotype: libc::c_int,
1352 scope: libc::c_int,
1353 policy: libc::c_int,
1354 ) -> libc::c_int;
1355 }
1356 unsafe {
1357 libc::nice(10);
1358 #[cfg(target_os = "macos")]
1359 setiopolicy_np(0, 0, 3);
1360 }
1361 let mut store = match open_store() {
1362 Ok(s) => s,
1363 Err(_) => return ExitCode::from(Failure::Database.exit_code()),
1364 };
1365 let start = path
1366 .map(PathBuf::from)
1367 .or_else(|| std::env::current_dir().ok())
1368 .unwrap_or_else(|| PathBuf::from("."));
1369 let root = crate::index::repo_root(&start).unwrap_or(start);
1370 let identity = resolve_identity(&store, &root);
1371
1372 let held = |pid: u32, ts: i64| {
1376 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
1377 alive && now_unix() - ts < WARM_LOCK_TTL_SECS
1378 };
1379 if !store
1380 .claim_warm_lock(&identity, std::process::id(), held)
1381 .unwrap_or(false)
1382 {
1383 return ExitCode::SUCCESS;
1384 }
1385
1386 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1389 let head = store
1390 .repository_id(&identity)
1391 .ok()
1392 .flatten()
1393 .and_then(|id| store.indexed_head(id).ok().flatten());
1394 let checked_at = now_unix();
1398 let edits = worktree_edits(&root, head.as_deref());
1399 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1400 crate::trace!("warm: unchanged since indexed, nothing to do");
1401 if let Some(id) = store.repository_id(&identity).ok().flatten() {
1403 let _ = store.maintain_name_index(id);
1404 }
1405 if let Some(stamp) = head
1406 .as_deref()
1407 .and_then(|h| crate::index::git_state_stamp(&root, h))
1408 {
1409 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1410 }
1411 let _ = store.clear_warm_lock(&identity);
1412 return ExitCode::SUCCESS;
1413 }
1414 }
1415
1416 let deadline = std::time::Instant::now() + warm_bg_budget();
1419 let active = crate::index::branch_changed_files(&root);
1420 loop {
1421 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1422 if remaining.is_zero() {
1423 break;
1424 }
1425 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1426 {
1427 Ok(s) => s,
1428 Err(_) => break,
1429 };
1430 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1431 || stats.files_indexed == 0
1432 {
1433 break;
1434 }
1435 }
1436 let _ = store.clear_warm_lock(&identity);
1437 ExitCode::SUCCESS
1438}
1439
1440fn live_fallback(
1443 root: &std::path::Path,
1444 query: &str,
1445 limit: usize,
1446 ctx: &crate::search::Context,
1447) -> (Vec<crate::search::Hit>, LiveCost) {
1448 let start = std::time::Instant::now();
1449 let deadline = start + live_fallback_budget();
1450 let mut files = 0;
1451 let mut scan = |prefilter| {
1452 let mut span = crate::profile::span(if prefilter {
1453 "live scan: prefiltered"
1454 } else {
1455 "live scan: unfiltered"
1456 });
1457 let found = crate::search::live_search(
1458 root,
1459 query,
1460 limit,
1461 &HashSet::new(),
1462 Some(deadline),
1463 prefilter,
1464 ctx,
1465 );
1466 span.note(|| format!("{} files", found.files));
1467 files += found.files;
1468 found.hits
1469 };
1470 let mut hits = scan(true);
1471 if hits.is_empty() {
1472 hits = scan(false);
1473 }
1474 let cost = LiveCost {
1475 files,
1476 elapsed: start.elapsed(),
1477 };
1478 (hits, cost)
1479}
1480
1481struct LiveCost {
1483 files: usize,
1484 elapsed: Duration,
1485}
1486
1487fn note_live_scan(root: &std::path::Path, cost: &LiveCost, answered: bool) {
1490 if !crate::trace::enabled() {
1491 return;
1492 }
1493 let budget = live_fallback_budget();
1494 let files = match cost.files {
1495 1 => "1 file".to_string(),
1496 n => format!("{n} files"),
1497 };
1498 let what = if answered {
1499 "answered from a live scan of"
1500 } else {
1501 "no answer from a live scan of"
1502 };
1503 let cut = if cost.elapsed >= budget {
1504 " (stopped at the budget)"
1505 } else {
1506 ""
1507 };
1508 crate::trace!(
1509 "{what} {}: {files} in {} ms, budget {} ms{cut}",
1510 crate::trace::abbrev(root),
1511 cost.elapsed.as_millis(),
1512 budget.as_millis(),
1513 );
1514}
1515
1516fn strong(h: &crate::search::Hit) -> bool {
1518 h.features
1519 .iter()
1520 .any(|f| matches!(f.name, "exact" | "prefix"))
1521}
1522
1523fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1532 if hits.iter().any(strong) {
1533 hits.retain(strong);
1534 }
1535 crate::search::apply_scope_gate(query, hits);
1536}
1537
1538fn apply_post_filters(
1541 args: &SearchArgs,
1542 cwd: Option<&std::path::Path>,
1543 root: Option<&std::path::Path>,
1544 hits: &mut Vec<crate::search::Hit>,
1545) {
1546 if !args.paths.is_empty() {
1547 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1551 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1552 let norm: Vec<String> = args
1553 .paths
1554 .iter()
1555 .map(|p| repo_relative(&base, &here, p))
1556 .collect();
1557 hits.retain(|h| under_any(&h.file, &norm));
1558 }
1559 if !args.kinds.is_empty() {
1560 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1561 }
1562 if !args.langs.is_empty() {
1563 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1564 }
1565}
1566
1567fn no_match_code(
1573 out: Output,
1574 query: &str,
1575 interrupted: bool,
1576 incomplete: bool,
1577 elsewhere: Option<&str>,
1581) -> ExitCode {
1582 let status = if interrupted {
1583 "interrupted"
1584 } else if incomplete {
1585 "warming"
1586 } else if elsewhere.is_some() {
1587 "scope_not_found"
1588 } else {
1589 "no_match"
1590 };
1591 match out {
1592 Output::Json | Output::Ndjson => {
1593 let mut obj = serde_json::json!({ "status": status, "query": query });
1594 if let Some(found_in) = elsewhere {
1595 obj["found_in"] = serde_json::json!(found_in);
1596 }
1597 let _ = emit_json(out, &obj); }
1599 Output::Text if interrupted => {
1600 eprintln!("rq: indexing interrupted — run again to finish")
1601 }
1602 Output::Text if incomplete => eprintln!(
1603 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1604 ),
1605 Output::Text if elsewhere.is_some() => eprintln!(
1606 "rq: nothing matching {query:?} in that scope — the name is defined elsewhere: {}",
1607 elsewhere.unwrap_or_default()
1608 ),
1609 Output::Text => eprintln!("no matches for {query:?}"),
1610 }
1611 if incomplete {
1612 ExitCode::from(2)
1613 } else {
1614 ExitCode::FAILURE
1615 }
1616}
1617
1618fn attach_confidence(hits: &mut [crate::search::Hit]) {
1622 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1623 if t.is_none_or(|t| h.score > t) {
1624 (Some(h.score), t)
1625 } else if s.is_none_or(|s| h.score > s) {
1626 (t, Some(h.score))
1627 } else {
1628 (t, s)
1629 }
1630 });
1631 for hit in hits.iter_mut() {
1632 let best_other = if Some(hit.score) == top { second } else { top };
1633 hit.confidence = crate::search::confidence(
1634 hit.score,
1635 crate::search::match_quality(&hit.features),
1636 best_other,
1637 );
1638 }
1639}
1640
1641fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1644 let render_span = crate::profile::span("render");
1648 if args.batch {
1649 #[derive(serde::Serialize)]
1652 struct Tagged<'a> {
1653 query: &'a str,
1654 #[serde(flatten)]
1655 hit: &'a crate::search::Hit,
1656 }
1657 let rows: Vec<Tagged> = hits
1658 .iter()
1659 .map(|hit| Tagged {
1660 query: args.query,
1661 hit,
1662 })
1663 .collect();
1664 if let Some(code) = emit_rows(args.out, &rows) {
1665 return Some(code);
1666 }
1667 } else if let Some(code) = emit_rows(args.out, hits) {
1668 return Some(code);
1669 }
1670 if args.out != Output::Text {
1671 return None;
1672 }
1673 drop(render_span);
1674 let color = match_color();
1675 let c = color.as_deref();
1676 let query = args.query;
1677 if args.show {
1678 let total = hits.first().map_or(hits.len(), |h| h.total);
1680 eprintln!(
1681 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1682 hits.len()
1683 );
1684 }
1685 for hit in hits {
1686 let name = hl(&hit.name, query, c);
1689 let qualified = match &hit.parent {
1690 Some(p) => format!("{name} · {p}"),
1691 None => name,
1692 };
1693 println!(
1694 "{}:{} {} {}",
1695 hl_path(&hit.file, query, c),
1696 hit.line,
1697 hit.kind,
1698 qualified
1699 );
1700 if let Some(sig) = &hit.signature {
1701 println!(" {}", hl(sig, query, c));
1702 }
1703 if args.explain {
1704 let parts: Vec<String> = hit
1705 .features
1706 .iter()
1707 .map(|f| format!("{} {}", f.name, f.reported()))
1708 .collect();
1709 println!(
1710 " confidence {:.2} · score {:.0} = {}",
1711 hit.confidence,
1712 hit.score,
1713 parts.join(" + ")
1714 );
1715 }
1716 }
1717 None
1718}
1719
1720fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1724 use std::io::{IsTerminal, Write};
1725 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1726 return hits.first();
1727 }
1728 let mut err = std::io::stderr();
1729 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1730 for (i, h) in hits.iter().enumerate() {
1731 let _ = writeln!(
1732 err,
1733 " {}. {}:{} {} {}",
1734 i + 1,
1735 h.file,
1736 h.line,
1737 h.kind,
1738 h.name
1739 );
1740 }
1741 let _ = write!(err, "rq> ");
1742 let _ = err.flush();
1743 let mut line = String::new();
1744 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1745 return None; }
1747 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1748}
1749
1750fn parse_choice(input: &str, n: usize) -> Option<usize> {
1753 let s = input.trim();
1754 if s.is_empty() {
1755 return Some(0);
1756 }
1757 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1758 (i < n).then_some(i)
1759}
1760
1761fn finish_open(
1764 store: &Store,
1765 hits: &[crate::search::Hit],
1766 current: Option<i64>,
1767 root: Option<&std::path::Path>,
1768 web: bool,
1769) -> ExitCode {
1770 let Some(hit) = choose_hit(hits) else {
1771 return ExitCode::SUCCESS; };
1773
1774 if web {
1775 return open_web(store, hit, current, root);
1776 }
1777
1778 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1781 Some(r) => r.join(&hit.file),
1782 None => PathBuf::from(&hit.file),
1783 };
1784 launch_editor(&target, hit.line)
1785}
1786
1787#[derive(Clone, Copy)]
1789enum Scope {
1790 All,
1792 Repo(i64),
1793 Nothing,
1796}
1797
1798impl Scope {
1799 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1800 match current {
1801 _ if all_repos => Scope::All,
1802 Some(id) => Scope::Repo(id),
1803 None if in_repo => Scope::Nothing,
1804 None => Scope::All,
1805 }
1806 }
1807
1808 fn search(
1809 self,
1810 store: &Store,
1811 query: &str,
1812 current: Option<i64>,
1813 ctx: &crate::search::Context,
1814 limit: usize,
1815 ) -> crate::store::Result<crate::search::Matches> {
1816 let only_repo = match self {
1817 Scope::All => None,
1818 Scope::Repo(id) => Some(id),
1819 Scope::Nothing => {
1820 return Ok(crate::search::Matches {
1821 hits: Vec::new(),
1822 total: 0,
1823 });
1824 }
1825 };
1826 crate::search::search(store, query, current, only_repo, ctx, limit)
1827 }
1828}
1829
1830fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1834 use std::os::unix::process::CommandExt;
1835 let loc = format!("{}:{}", file.display(), line);
1836 match open_command(file, line, &loc) {
1837 Some((prog, args)) => {
1838 crate::profile::emit(false);
1840 let err = std::process::Command::new(&prog).args(&args).exec();
1842 fail(
1843 Output::Text,
1844 Failure::Launch,
1845 format_args!("rq --open: cannot run {prog}: {err}"),
1846 )
1847 }
1848 None => {
1849 println!("{loc}");
1850 ExitCode::SUCCESS
1851 }
1852 }
1853}
1854
1855fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1861 let fstr = file.to_string_lossy().into_owned();
1862
1863 if let Some(t) = std::env::var_os("RQ_OPEN") {
1864 let t = t.to_string_lossy();
1865 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1866 let mut parts = t.split_whitespace().map(|p| {
1867 p.replace("{file}", &fstr)
1868 .replace("{line}", &line.to_string())
1869 .replace("{}", loc)
1870 });
1871 if let Some(prog) = parts.next() {
1872 let mut args: Vec<String> = parts.collect();
1873 if !placeholder {
1874 args.push(loc.to_string());
1875 }
1876 return Some((prog, args));
1877 }
1878 }
1879
1880 if on_path("code") {
1881 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1882 }
1883
1884 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1885 let ed = ed.to_string_lossy().into_owned();
1886 let l = ed.to_ascii_lowercase();
1887 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1889 .iter()
1890 .any(|e| l.contains(e))
1891 {
1892 return Some((ed, vec![format!("+{line}"), fstr]));
1893 }
1894 return Some((ed, vec![fstr]));
1895 }
1896
1897 None
1898}
1899
1900fn open_web(
1905 store: &Store,
1906 hit: &crate::search::Hit,
1907 current: Option<i64>,
1908 root: Option<&std::path::Path>,
1909) -> ExitCode {
1910 if hit.repo_identity.starts_with("local:") {
1911 return fail(
1912 Output::Text,
1913 Failure::NoRemote,
1914 format_args!(
1915 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1916 locally with -o, or add one with `git remote add origin <url>`",
1917 hit.file, hit.line, hit.repo_identity
1918 ),
1919 );
1920 }
1921 let here =
1922 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1923 let rev = root
1924 .filter(|_| here)
1925 .and_then(crate::index::pushed_head)
1926 .unwrap_or_else(|| "HEAD".into());
1927 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1928
1929 use std::os::unix::process::CommandExt;
1930 let browser = std::env::var("BROWSER")
1931 .ok()
1932 .filter(|b| !b.is_empty())
1933 .or_else(|| {
1934 ["open", "xdg-open"]
1935 .into_iter()
1936 .find(|p| on_path(p))
1937 .map(str::to_string)
1938 });
1939 match browser {
1940 Some(prog) => {
1941 crate::profile::emit(false);
1943 let err = std::process::Command::new(&prog).arg(&url).exec();
1945 fail(
1946 Output::Text,
1947 Failure::Launch,
1948 format_args!("rq --web: cannot run {prog}: {err}"),
1949 )
1950 }
1951 None => {
1952 println!("{url}");
1953 ExitCode::SUCCESS
1954 }
1955 }
1956}
1957
1958fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1961 let path: String = file
1962 .bytes()
1963 .map(|b| match b {
1964 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1965 (b as char).to_string()
1966 }
1967 _ => format!("%{b:02X}"),
1968 })
1969 .collect();
1970 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1971}
1972
1973fn on_path(prog: &str) -> bool {
1975 std::env::var_os("PATH")
1976 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1977}
1978
1979const BRANCH_FILES_TTL_SECS: i64 = 15;
1985
1986const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1990
1991const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
2003
2004fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
2006 let earned = cost_ms.map_or(0, |ms| {
2009 i64::try_from(ms)
2010 .unwrap_or(i64::MAX)
2011 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
2012 / 1000
2013 });
2014 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
2015}
2016
2017struct BranchRefresh {
2021 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
2024 identity: String,
2025 stamp: String,
2026}
2027
2028impl BranchRefresh {
2029 fn store(self, store: &Store) {
2031 let Ok((files, cost_ms)) = self.handle.join() else {
2032 return;
2033 };
2034 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
2035 }
2036}
2037
2038fn cached_branch_files(
2052 store: &Store,
2053 root: &std::path::Path,
2054) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
2055 let identity = resolve_identity(store, root);
2056 let stamp = crate::index::branch_files_stamp(root);
2057 let cached = store.branch_files_get(&identity).ok().flatten();
2058 let now = now_unix();
2059
2060 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
2061 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
2062 {
2063 return (hit.files.clone(), None, hit.cost_ms);
2064 }
2065 let owned_root = root.to_path_buf();
2066 let refresh = BranchRefresh {
2067 handle: std::thread::spawn(move || {
2068 let t = std::time::Instant::now();
2069 let files = crate::index::branch_changed_files(&owned_root);
2070 (files, t.elapsed().as_millis() as u64)
2071 }),
2072 identity,
2073 stamp: stamp.clone(),
2074 };
2075 return (hit.files.clone(), Some(refresh), hit.cost_ms);
2076 }
2077
2078 let t = std::time::Instant::now();
2080 let files = crate::index::branch_changed_files(root);
2081 let cost_ms = t.elapsed().as_millis() as u64;
2082 if let Some(stamp) = stamp {
2083 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
2084 }
2085 (files, None, Some(cost_ms))
2086}
2087
2088fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
2098 let head = indexed_head?;
2099 let _span = crate::profile::span("git: worktree changed?");
2100 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2101}
2102
2103fn changed_since_index(
2106 store: &Store,
2107 identity: Option<&str>,
2108 root: Option<&std::path::Path>,
2109 edits: Option<Vec<String>>,
2110) -> bool {
2111 let (Some(dirty), Some(root)) = (edits, root) else {
2112 return true;
2113 };
2114 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2115 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2116 None => !dirty.is_empty(),
2117 }
2118}
2119
2120enum Staleness {
2124 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2127 Deferred(PathBuf, Option<String>),
2131}
2132
2133#[allow(clippy::too_many_arguments)]
2140fn settle_warm(
2141 store: &Store,
2142 staleness: Option<Staleness>,
2143 hit: bool,
2144 was_warming: bool,
2145 warming_ok: bool,
2146 root: Option<&std::path::Path>,
2147 active: &[String],
2148 query: &str,
2149 budget: Duration,
2150 no_wait: bool,
2151 identity: Option<&str>,
2152) -> bool {
2153 let changed = match staleness {
2154 None => false,
2155 Some(Staleness::Deferred(r, head)) if hit => {
2158 if recently_verified(store, identity, &r, head.as_deref()) {
2159 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2160 } else {
2161 spawn_detached_warm(&r);
2162 }
2163 return false;
2164 }
2165 Some(Staleness::Deferred(r, head)) => {
2166 let _span = crate::profile::span("after: staleness check");
2167 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2168 }
2169 Some(Staleness::Running(h)) => {
2170 let mut span = crate::profile::span("after: staleness wait");
2172 let changed = h.join().map_or(true, |edits| {
2175 changed_since_index(store, identity, root, edits)
2176 });
2177 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2178 changed
2179 }
2180 };
2181 if changed
2191 && !no_wait
2192 && !warm_detach_enabled()
2193 && let Some(r) = root
2194 && let Ok(mut idx) = open_store()
2195 {
2196 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2197 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2198 }
2199 maybe_detach_warm(
2200 store,
2201 warming_ok && (was_warming || changed),
2202 changed,
2203 root,
2204 identity,
2205 );
2206 changed && warm_detach_enabled()
2210}
2211
2212fn answer_warm_budget() -> Duration {
2221 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2222}
2223
2224fn deferred_warm_budget() -> Duration {
2227 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2228}
2229
2230fn live_fallback_budget() -> Duration {
2233 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2234}
2235
2236fn warm_bg_budget() -> Duration {
2239 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2240}
2241
2242fn warm_detach_enabled() -> bool {
2246 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2247}
2248
2249fn wait_budget() -> Duration {
2258 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2259}
2260
2261fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2266 let s = s.trim();
2267 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2268 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2270 (n, 1.0)
2271 } else if let Some(n) = s.strip_suffix('s') {
2272 (n, 1_000.0)
2273 } else if let Some(n) = s.strip_suffix('m') {
2274 (n, 60_000.0)
2275 } else {
2276 (s, 1_000.0)
2278 };
2279 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2280 if !val.is_finite() || val < 0.0 {
2281 return Err(bad());
2282 }
2283 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2284}
2285
2286static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2290
2291extern "C" fn on_sigint(_: libc::c_int) {
2292 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2294}
2295
2296fn install_interrupt_handler() {
2299 static ONCE: std::sync::Once = std::sync::Once::new();
2300 ONCE.call_once(|| unsafe {
2301 let mut action: libc::sigaction = std::mem::zeroed();
2302 action.sa_sigaction = on_sigint as *const () as usize;
2303 libc::sigemptyset(&mut action.sa_mask);
2304 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2305 });
2306}
2307
2308fn stderr_interactive() -> bool {
2312 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2313}
2314
2315fn show_progress(out: Output, interactive: bool) -> bool {
2320 interactive && matches!(out, Output::Text)
2321}
2322
2323fn repo_label(root: Option<&std::path::Path>) -> String {
2326 root.and_then(|r| r.file_name())
2327 .map(|n| n.to_string_lossy().into_owned())
2328 .unwrap_or_else(|| "repo".into())
2329}
2330
2331fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2335 let files = identity
2336 .and_then(|id| store.repository_id(id).ok().flatten())
2337 .and_then(|rid| store.repo_totals(rid).ok())
2338 .map_or(0, |(f, _)| f);
2339 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2340 let _ = std::io::stderr().flush();
2341}
2342
2343fn clear_progress() {
2345 eprint!("\r\x1b[K");
2346 let _ = std::io::stderr().flush();
2347}
2348
2349fn env_budget(var: &str, default_ms: u64) -> Duration {
2353 let ms = std::env::var(var)
2354 .ok()
2355 .and_then(|v| v.parse().ok())
2356 .unwrap_or(default_ms);
2357 Duration::from_millis(ms)
2358}
2359
2360#[derive(Clone, Copy)]
2362struct Here<'a> {
2363 identity: &'a str,
2364 root: &'a std::path::Path,
2365}
2366
2367fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2373 let mut roots: Vec<PathBuf> = here
2374 .filter(|h| h.identity == repo_identity)
2375 .map(|h| h.root.to_path_buf())
2376 .into_iter()
2377 .collect();
2378 let recorded = store
2379 .repository_id(repo_identity)
2380 .ok()
2381 .flatten()
2382 .map(|id| store.checkout_roots(id).unwrap_or_default())
2383 .unwrap_or_default();
2384 for root in recorded.into_iter().map(PathBuf::from) {
2385 if !roots.contains(&root) {
2386 roots.push(root);
2387 }
2388 }
2389 roots
2390}
2391
2392fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2396 let mut roots = hit_file_roots(store, repo_identity, here);
2397 let at = roots
2398 .iter()
2399 .position(|r| r.join(file).is_file())
2400 .unwrap_or(0);
2401 (at < roots.len()).then(|| roots.swap_remove(at))
2402}
2403
2404fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2406 let src = std::fs::read_to_string(path).ok()?;
2407 signature_in(&src.lines().collect::<Vec<_>>(), line)
2408}
2409
2410const SHOW_CONFIDENCE: f64 = 0.85;
2414
2415fn show_top_definition(
2419 hits: &mut [crate::search::Hit],
2420 query: &str,
2421 out: Output,
2422) -> Option<ExitCode> {
2423 let top = hits.first()?;
2424 if top.confidence < SHOW_CONFIDENCE {
2425 return None; }
2427 let end = top.end_line.unwrap_or(top.line);
2428 let body = top.root.as_deref().and_then(|root| {
2429 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2430 span_in(&src, top.line, end)
2431 });
2432 hits[0].body = body;
2433 let top = &hits[0];
2434 let code = match out {
2435 Output::Json | Output::Ndjson => {
2436 emit_json(out, top)
2438 }
2439 Output::Text => {
2440 let color = match_color();
2441 let c = color.as_deref();
2442 let name = hl(&top.name, query, c);
2443 let qualified = match &top.parent {
2444 Some(p) => format!("{name} · {p}"),
2445 None => name,
2446 };
2447 println!(
2448 "{}:{} {} {}",
2449 hl_path(&top.file, query, c),
2450 top.line,
2451 top.kind,
2452 qualified
2453 );
2454 match (&top.body, &top.signature) {
2455 (Some(body), _) => println!("{body}"),
2456 (None, Some(sig)) => println!("{sig}"),
2458 (None, None) => {}
2459 }
2460 ExitCode::SUCCESS
2461 }
2462 };
2463
2464 Some(code)
2465}
2466
2467fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2470 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2471 let lines: Vec<&str> = content.lines().collect();
2472 if s >= lines.len() {
2473 return None;
2474 }
2475 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2476 Some(lines[s..e].join("\n"))
2477}
2478
2479fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2484 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2485 let l = lines.get(idx)?.trim();
2486 (!l.is_empty()).then(|| l.to_string())
2487}
2488
2489#[derive(serde::Serialize)]
2493struct SymbolOut {
2494 name: String,
2495 kind: String,
2496 language: String,
2497 file: String,
2498 root: String,
2500 line: i64,
2501 #[serde(skip_serializing_if = "Option::is_none")]
2502 end_line: Option<i64>,
2503 #[serde(skip_serializing_if = "Option::is_none")]
2504 parent: Option<String>,
2505 #[serde(skip_serializing_if = "Option::is_none")]
2506 visibility: Option<String>,
2507 repo: String,
2508 #[serde(skip_serializing_if = "Option::is_none")]
2509 signature: Option<String>,
2510}
2511
2512fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2517 let open_span = crate::profile::span("store open");
2518 let mut store = match open_store() {
2519 Ok(s) => s,
2520 Err(e) => {
2521 return fail(
2522 out,
2523 Failure::Database,
2524 format_args!("rq: cannot open database: {e}"),
2525 );
2526 }
2527 };
2528 drop(open_span);
2529 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2530 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2531 let rel = repo_relative(&root, &cwd, file_arg);
2532
2533 let identity = resolve_identity(&store, &root);
2534 let coverage = store.coverage_status(&identity).ok().flatten();
2535 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2536 let current = store.repository_id(&identity).ok().flatten();
2537 let path = root.join(&rel);
2538 if !path.is_file() {
2539 return fail(
2540 out,
2541 Failure::NotFound,
2542 format_args!("rq --symbols: no such file: {file_arg}"),
2543 );
2544 }
2545 let indexable = path
2546 .extension()
2547 .and_then(|e| e.to_str())
2548 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2549 match current {
2550 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2554 if indexable {
2555 let _span = crate::profile::span("symbols: refresh");
2556 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2557 }
2558 }
2559 _ if warming_ok => {
2563 let budget = answer_warm_budget() + deferred_warm_budget();
2564 let _span = crate::profile::span("symbols: warm");
2565 let active = [rel.clone()];
2566 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2567 }
2568 _ => {}
2569 }
2570
2571 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2572 return emit_symbols(out, &[]); };
2574 let mut query_span = crate::profile::span("symbols: query");
2575 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2576 Ok(r) => r,
2577 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2578 };
2579 query_span.note(|| format!("{} rows", rows.len()));
2580 drop(query_span);
2581 if !kinds.is_empty() {
2582 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2583 }
2584 if !langs.is_empty() {
2585 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2586 }
2587
2588 let signatures_span = crate::profile::span("symbols: signatures");
2591 let content = std::fs::read_to_string(&path).ok();
2592 let lines: Vec<&str> = content
2593 .as_deref()
2594 .map_or_else(Vec::new, |c| c.lines().collect());
2595 let root_str = root.to_string_lossy().into_owned();
2596 let syms: Vec<SymbolOut> = rows
2597 .into_iter()
2598 .map(|r| SymbolOut {
2599 signature: signature_in(&lines, r.line),
2600 name: r.name,
2601 kind: r.kind,
2602 language: r.language,
2603 file: r.file,
2604 root: root_str.clone(),
2605 line: r.line,
2606 end_line: r.end_line,
2607 parent: r.parent,
2608 visibility: r.visibility,
2609 repo: r.repo_identity,
2610 })
2611 .collect();
2612 drop(signatures_span);
2613 let _span = crate::profile::span("render");
2614 emit_symbols(out, &syms)
2615}
2616
2617fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2620 if syms.is_empty() {
2621 match out {
2622 Output::Json | Output::Ndjson => {
2623 let obj = serde_json::json!({ "status": "no_match" });
2624 let _ = emit_json(out, &obj); }
2626 Output::Text => eprintln!("no symbols"),
2627 }
2628 return ExitCode::FAILURE;
2629 }
2630 if let Some(code) = emit_rows(out, syms) {
2631 return code;
2632 }
2633 match out {
2634 Output::Json | Output::Ndjson => {}
2635 Output::Text => {
2636 for s in syms {
2637 let qualified = match &s.parent {
2638 Some(p) => format!("{} · {p}", s.name),
2639 None => s.name.clone(),
2640 };
2641 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2642 if let Some(sig) = &s.signature {
2643 println!(" {sig}");
2644 }
2645 }
2646 }
2647 }
2648 ExitCode::SUCCESS
2649}
2650
2651fn keyword_kind(token: &str) -> Option<&'static [&'static str]> {
2656 match token.to_ascii_lowercase().as_str() {
2657 "class" | "module" | "method" | "function" | "fn" | "struct" | "type" | "enum"
2658 | "trait" | "interface" | "constant" | "const" | "macro" | "variant" => {
2659 canonical_kind(token)
2660 }
2661 _ => None,
2662 }
2663}
2664
2665fn split_kind_keyword(
2671 target: String,
2672 dirs: Vec<String>,
2673) -> (Option<&'static [&'static str]>, String, Vec<String>) {
2674 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2677 let rest = rest.trim();
2678 if let Some(k) = keyword_kind(head)
2679 && !rest.is_empty()
2680 {
2681 return (Some(k), rest.to_string(), dirs);
2682 }
2683 } else if let Some(k) = keyword_kind(&target)
2684 && let Some((query, extra)) = dirs.split_first()
2685 {
2686 return (Some(k), query.clone(), extra.to_vec());
2688 }
2689 (None, target, dirs)
2690}
2691
2692fn canonical_kind(s: &str) -> Option<&'static [&'static str]> {
2695 Some(match s.to_ascii_lowercase().as_str() {
2696 "c" | "class" => &["class"],
2697 "m" | "method" => &["method"],
2698 "f" | "fn" | "func" | "function" => &["function"],
2699 "mod" | "module" => &["module"],
2700 "s" | "struct" => &["struct"],
2701 "type" => &["type", "struct"],
2704 "alias" | "type_alias" => &["type"],
2705 "e" | "enum" => &["enum"],
2706 "t" | "trait" | "interface" => &["trait"],
2707 "const" | "constant" => &["constant"],
2708 "macro" | "macro_rules" => &["macro"],
2709 "v" | "variant" | "member" | "enum_member" => &["variant"],
2710 _ => return None,
2711 })
2712}
2713
2714fn canonical_langs(s: &str) -> Vec<String> {
2721 let t = s.to_ascii_lowercase();
2722 let alias = match t.as_str() {
2723 "rb" => Some("ruby"),
2724 "rs" => Some("rust"),
2725 "golang" => Some("go"),
2726 "ts" | "tsx" => Some("typescript"),
2727 "js" | "jsx" => Some("javascript"),
2728 _ => None,
2729 };
2730 let matched: Vec<String> = crate::lang::languages()
2731 .into_iter()
2732 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2733 .map(str::to_string)
2734 .collect();
2735 matched
2736}
2737
2738fn match_color() -> Option<String> {
2742 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2743 return None;
2744 }
2745 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2746 gc.split(':').find_map(|e| {
2747 e.strip_prefix("mt=")
2748 .or_else(|| e.strip_prefix("ms="))
2749 .filter(|v| !v.is_empty())
2750 .map(str::to_string)
2751 })
2752 });
2753 Some(style.unwrap_or_else(|| "1;31".to_string()))
2754}
2755
2756fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2759 match color {
2760 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2761 None => text.to_string(),
2762 }
2763}
2764
2765fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2768 let Some(c) = color else {
2769 return path.to_string();
2770 };
2771 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2772 let base_start = path[..base_byte].chars().count();
2773 let stem = crate::search::path_stem(path);
2777 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2778 .into_iter()
2779 .map(|p| p + base_start)
2780 .collect();
2781 highlight(path, &positions, c)
2782}
2783
2784fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2787 if positions.is_empty() {
2788 return text.to_string();
2789 }
2790 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2791 let mut out = String::new();
2792 let mut on = false;
2793 for (i, c) in text.chars().enumerate() {
2794 match (matched.contains(&i), on) {
2795 (true, false) => {
2796 out.push_str("\x1b[");
2797 out.push_str(color);
2798 out.push('m');
2799 on = true;
2800 }
2801 (false, true) => {
2802 out.push_str("\x1b[0m");
2803 on = false;
2804 }
2805 _ => {}
2806 }
2807 out.push(c);
2808 }
2809 if on {
2810 out.push_str("\x1b[0m");
2811 }
2812 out
2813}
2814
2815fn under_any(file: &str, paths: &[String]) -> bool {
2819 paths.iter().any(|p| {
2820 let p = p.trim_start_matches("./").trim_end_matches('/');
2821 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2822 })
2823}
2824
2825fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2827 let p = std::path::Path::new(file);
2828 let abs = if p.is_absolute() {
2829 p.to_path_buf()
2830 } else {
2831 cwd.join(p)
2832 };
2833 let abs = abs.canonicalize().unwrap_or(abs);
2834 abs.strip_prefix(root)
2835 .map(|r| r.to_string_lossy().into_owned())
2836 .unwrap_or_else(|_| file.to_string())
2837}
2838
2839fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2844 use std::collections::HashSet;
2845 let mut seen = HashSet::new();
2846 let mut changed = false;
2847 for hit in hits {
2848 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2849 continue;
2850 }
2851 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2852 continue;
2853 };
2854 let roots = hit_file_roots(store, &hit.repo_identity, here);
2855 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2856 continue;
2857 };
2858 if let Ok(crate::index::Refresh::Updated) =
2859 crate::index::refresh_file(store, repo_id, root, &hit.file)
2860 {
2861 changed = true;
2862 }
2863 }
2864 changed
2865}
2866
2867fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2873 if let Ok(canon) = cwd.canonicalize() {
2874 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2875 return identity;
2876 }
2877 if crate::index::repo_root(cwd).is_none() {
2878 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2879 }
2880 }
2881 crate::index::detect_identity(cwd).to_string()
2882}
2883
2884fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2885 let explicit = path.is_some();
2886 let target = path.unwrap_or_else(|| PathBuf::from("."));
2887 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2892 let mut subdirs = subdirs.to_vec();
2897 if explicit
2898 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2899 && t != r
2900 && let Ok(rel) = t.strip_prefix(&r)
2901 && !rel.as_os_str().is_empty()
2902 {
2903 subdirs.push(rel.to_string_lossy().into_owned());
2904 }
2905 let open_span = crate::profile::span("store open");
2906 let mut store = match open_store() {
2907 Ok(s) => s,
2908 Err(e) => {
2909 return fail(
2910 out,
2911 Failure::Database,
2912 format_args!("rq: cannot open database: {e}"),
2913 );
2914 }
2915 };
2916 drop(open_span);
2917 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2918 let identity = resolve_identity(&store, &root);
2921 match indexed {
2922 Ok(stats) => {
2923 let subtree = !subdirs.is_empty();
2924 let totals = store
2926 .repository_id(&identity)
2927 .ok()
2928 .flatten()
2929 .and_then(|id| store.repo_totals(id).ok());
2930 match out {
2931 Output::Json | Output::Ndjson => {
2932 let (files, symbols) = match totals {
2933 Some((f, s)) => (Some(f), Some(s)),
2934 None => (None, None),
2935 };
2936 return emit_json(
2937 out,
2938 &serde_json::json!({
2939 "repo": identity,
2940 "scope": if subtree { "subtree" } else { "full" },
2941 "files_added": stats.files_indexed,
2942 "symbols_added": stats.symbols,
2943 "files": files,
2944 "symbols": symbols,
2945 }),
2946 );
2947 }
2948 Output::Text => {
2949 let scope = if subtree { " (subtree seed)" } else { "" };
2950 match totals {
2951 Some((files, symbols)) => println!(
2952 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2953 stats.files_indexed, stats.symbols
2954 ),
2955 None => println!(
2956 "{} file(s)/{} symbol(s) added this run{scope}",
2957 stats.files_indexed, stats.symbols
2958 ),
2959 }
2960 }
2961 }
2962 ExitCode::SUCCESS
2963 }
2964 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2965 }
2966}
2967
2968fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2969 let mut store = match open_store() {
2970 Ok(s) => s,
2971 Err(e) => {
2972 return fail(
2973 out,
2974 Failure::Database,
2975 format_args!("rq: cannot open database: {e}"),
2976 );
2977 }
2978 };
2979
2980 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2984 let root = crate::index::repo_root(&path).unwrap_or(path);
2985 let from_path = crate::index::detect_identity(&root).to_string();
2986 let resolved = match store.repository_id(&from_path) {
2987 Ok(Some(id)) => Some((from_path.clone(), id)),
2988 Ok(None) => target.as_deref().and_then(|s| {
2989 store
2990 .repository_id(s)
2991 .ok()
2992 .flatten()
2993 .map(|id| (s.to_string(), id))
2994 }),
2995 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2996 };
2997
2998 let Some((identity, repo_id)) = resolved else {
2999 return match out {
3001 Output::Text => {
3002 println!("not indexed: {from_path}");
3003 ExitCode::SUCCESS
3004 }
3005 _ => emit_json(
3006 out,
3007 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
3008 ),
3009 };
3010 };
3011
3012 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
3013 match store.drop_repository(repo_id) {
3014 Ok(()) => match out {
3015 Output::Text => {
3016 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
3017 ExitCode::SUCCESS
3018 }
3019 _ => emit_json(
3020 out,
3021 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
3022 ),
3023 },
3024 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
3025 }
3026}
3027
3028fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
3032 let rendered = if out == Output::Json {
3033 serde_json::to_string_pretty(value)
3034 } else {
3035 serde_json::to_string(value)
3036 };
3037 match rendered {
3038 Ok(s) => {
3039 println!("{s}");
3040 ExitCode::SUCCESS
3041 }
3042 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
3043 }
3044}
3045
3046fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
3050 match out {
3051 Output::Json => match serde_json::to_string_pretty(rows) {
3052 Ok(s) => println!("{s}"),
3053 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3054 },
3055 Output::Ndjson => {
3056 for r in rows {
3057 match serde_json::to_string(r) {
3058 Ok(line) => println!("{line}"),
3059 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3060 }
3061 }
3062 }
3063 Output::Text => {}
3064 }
3065 None
3066}
3067
3068fn cmd_status(out: Output) -> ExitCode {
3069 let store = match open_store() {
3070 Ok(s) => s,
3071 Err(e) => {
3072 return fail(
3073 out,
3074 Failure::Database,
3075 format_args!("rq: cannot open database: {e}"),
3076 );
3077 }
3078 };
3079 let rows = match store.coverage_overview() {
3080 Ok(rows) => rows,
3081 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
3082 };
3083 if let Some(code) = emit_rows(out, &rows) {
3084 return code;
3085 }
3086 match out {
3087 Output::Json | Output::Ndjson => {}
3088 Output::Text if rows.is_empty() => {
3089 println!("no repositories indexed yet (try `rq --index`)");
3090 }
3091 Output::Text => {
3092 for r in &rows {
3093 println!(
3094 "{:<10} {:>6} files {:>7} symbols {}",
3095 r.status, r.files, r.symbols, r.identity
3096 );
3097 }
3098 }
3099 }
3100 ExitCode::SUCCESS
3101}
3102
3103fn cmd_usage(out: Output) -> ExitCode {
3106 let store = match open_store() {
3107 Ok(s) => s,
3108 Err(e) => {
3109 return fail(
3110 out,
3111 Failure::Database,
3112 format_args!("rq: cannot open database: {e}"),
3113 );
3114 }
3115 };
3116 let rows = match store.usage_overview() {
3117 Ok(rows) => rows,
3118 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3119 };
3120 if let Some(code) = emit_rows(out, &rows) {
3121 return code;
3122 }
3123 match out {
3124 Output::Json | Output::Ndjson => {}
3125 Output::Text if rows.is_empty() => {
3126 println!("no usage recorded yet");
3127 }
3128 Output::Text => {
3129 println!(
3132 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3133 "day", "caller", "found", "missed", "warming"
3134 );
3135 for r in &rows {
3136 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3137 println!(
3138 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3139 r.day,
3140 r.source,
3141 r.searches - r.misses - r.warming,
3142 r.misses,
3143 r.warming,
3144 flags
3145 );
3146 }
3147 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3148 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3149 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3150 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3151 let live: i64 = rows.iter().map(|r| r.live).sum();
3152 let plural = if searches == 1 { "search" } else { "searches" };
3153 let live = if live > 0 {
3155 format!(" · {live} from a live scan")
3156 } else {
3157 String::new()
3158 };
3159 println!(
3162 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index{live}"
3163 );
3164 }
3165 }
3166 if rows.is_empty() {
3169 return ExitCode::from(1);
3170 }
3171 ExitCode::SUCCESS
3172}
3173
3174fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3176 let path = db_path()?;
3177 if let Some(parent) = path.parent() {
3178 std::fs::create_dir_all(parent)?;
3179 }
3180 Ok(Store::open(&path)?)
3181}
3182
3183fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3185 Ok(db_location()?)
3186}
3187
3188fn db_location() -> Result<PathBuf, String> {
3192 let var = |name| std::env::var_os(name).filter(|v| !v.is_empty());
3193 db_location_from(var("RQ_DB"), var("HOME"))
3194}
3195
3196fn db_location_from(
3197 rq_db: Option<std::ffi::OsString>,
3198 home: Option<std::ffi::OsString>,
3199) -> Result<PathBuf, String> {
3200 if let Some(db) = rq_db {
3201 let shown = db.to_string_lossy();
3202 if std::path::Path::new(&db).is_relative() {
3203 let hint = match shown.strip_prefix("~/") {
3205 Some(rest) => format!("$HOME/{rest}"),
3206 None => format!("$PWD/{shown}"),
3207 };
3208 return Err(format!(
3209 "rq: RQ_DB must be an absolute path, not {shown:?} (e.g. RQ_DB=\"{hint}\")"
3210 ));
3211 }
3212 if shown.ends_with('/') || std::path::Path::new(&db).is_dir() {
3213 return Err(format!(
3214 "rq: RQ_DB names a directory, not a database file: {shown:?} (e.g. RQ_DB=\"{}/rq.db\")",
3215 shown.trim_end_matches('/')
3216 ));
3217 }
3218 return Ok(PathBuf::from(db));
3219 }
3220 let Some(home) = home else {
3221 return Err(
3222 "rq: HOME is not set, so there's no default index path; set HOME, or RQ_DB to an absolute path".into(),
3223 );
3224 };
3225 if std::path::Path::new(&home).is_relative() {
3226 return Err(format!(
3227 "rq: HOME is a relative path ({:?}), so the default index would move with the working directory; set RQ_DB to an absolute path",
3228 home.to_string_lossy()
3229 ));
3230 }
3231 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3232}
3233
3234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3236enum Failure {
3237 Usage,
3239 Database,
3241 NotFound,
3243 NoRemote,
3245 Launch,
3247 Index,
3249 Internal,
3251}
3252
3253impl Failure {
3254 fn as_str(self) -> &'static str {
3255 match self {
3256 Failure::Usage => "usage",
3257 Failure::Database => "database",
3258 Failure::NotFound => "not_found",
3259 Failure::NoRemote => "no_remote",
3260 Failure::Launch => "launch",
3261 Failure::Index => "index",
3262 Failure::Internal => "internal",
3263 }
3264 }
3265
3266 fn exit_code(self) -> u8 {
3270 match self {
3271 Failure::Usage => 64, Failure::NotFound => 66, Failure::NoRemote | Failure::Launch => 69, Failure::Internal => 70, Failure::Database | Failure::Index => 74, }
3277 }
3278}
3279
3280fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3283 let message = args.to_string();
3284 eprintln!("{message}");
3285 emit_error(out, kind, &message);
3286 ExitCode::from(kind.exit_code())
3287}
3288
3289fn emit_error(out: Output, kind: Failure, message: &str) {
3292 let obj = serde_json::json!({
3293 "error": message,
3294 "kind": kind.as_str(),
3295 "code": kind.exit_code(),
3296 });
3297 let rendered = match out {
3299 Output::Text => return,
3300 Output::Json => serde_json::to_string_pretty(&obj),
3301 Output::Ndjson => serde_json::to_string(&obj),
3302 };
3303 if let Ok(s) = rendered {
3304 println!("{s}");
3305 }
3306}
3307
3308fn clap_failure(err: clap::Error) -> ExitCode {
3313 if !err.use_stderr() {
3315 err.exit();
3316 }
3317 let _ = err.print();
3318 let out = requested_output(std::env::args_os().skip(1));
3319 let text = err.to_string();
3320 emit_error(out, Failure::Usage, text.lines().next().unwrap_or(""));
3321 ExitCode::from(Failure::Usage.exit_code())
3322}
3323
3324fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3329 let cmd = Cli::command();
3330 let takes_value = |c: char| {
3331 cmd.get_arguments()
3332 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3333 };
3334 let (mut json, mut ndjson) = (false, false);
3335 for arg in args {
3336 let arg = arg.to_string_lossy();
3337 match arg.as_ref() {
3338 "--" => break,
3339 "--json" => json = true,
3340 "--ndjson" => ndjson = true,
3341 a if a.starts_with('-') && !a.starts_with("--") => {
3342 for c in a.chars().skip(1) {
3343 match c {
3344 'j' => json = true,
3345 'J' => ndjson = true,
3346 c if takes_value(c) => break,
3347 _ => {}
3348 }
3349 }
3350 }
3351 _ => {}
3352 }
3353 }
3354 if ndjson {
3356 Output::Ndjson
3357 } else if json {
3358 Output::Json
3359 } else {
3360 Output::Text
3361 }
3362}
3363
3364#[cfg(test)]
3365mod tests {
3366 use super::*;
3367
3368 #[test]
3369 fn the_database_path_is_refused_unless_it_is_an_absolute_file() {
3370 let at = |db: Option<&str>, home: Option<&str>| {
3371 db_location_from(db.map(Into::into), home.map(Into::into))
3372 };
3373 assert_eq!(
3374 at(None, Some("/home/a")).unwrap(),
3375 PathBuf::from("/home/a/.local/share/rq/rq.db")
3376 );
3377 assert_eq!(
3378 at(Some("/x/rq.db"), None).unwrap(),
3379 PathBuf::from("/x/rq.db")
3380 );
3381
3382 let refused = |db, home| at(db, home).unwrap_err();
3383 assert!(refused(Some("~/x.db"), None).contains("RQ_DB=\"$HOME/x.db\""));
3384 assert!(refused(Some("x.db"), None).contains("RQ_DB=\"$PWD/x.db\""));
3385 assert!(refused(Some("/x/"), None).contains("RQ_DB=\"/x/rq.db\""));
3386 assert!(refused(None, None).contains("HOME is not set"));
3387 assert!(refused(None, Some("home/a")).contains("HOME is a relative path"));
3388 }
3389
3390 #[test]
3391 fn parses_an_anchor_from_the_right() {
3392 let ok = |file: &str, line| {
3393 Ok(AnchorSpec {
3394 file: PathBuf::from(file),
3395 line,
3396 })
3397 };
3398 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3399 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3400 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3401 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3402 assert!(parse_anchor(bad).is_err(), "{bad}");
3403 }
3404 }
3405
3406 #[test]
3407 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3408 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3409 for (args, want) in [
3410 (&["x", "--json"][..], Output::Json),
3411 (&["x", "--ndjson"], Output::Ndjson),
3412 (&["x", "-ej"], Output::Json),
3413 (&["-Je", "x"], Output::Ndjson),
3414 (&["x", "--json", "-J"], Output::Ndjson),
3415 (&["x"], Output::Text),
3416 (&["x", "-xj"], Output::Text),
3418 (&["x", "-l5j"], Output::Text),
3419 (&["--", "-j"], Output::Text),
3421 (&["x", "--jobs", "2"], Output::Text),
3422 ] {
3423 assert!(mode(args) == want, "{args:?}");
3424 }
3425 }
3426
3427 #[test]
3428 fn open_menu_choice_parsing() {
3429 assert_eq!(parse_choice("\n", 5), Some(0));
3431 assert_eq!(parse_choice(" ", 5), Some(0));
3432 assert_eq!(parse_choice("3", 5), Some(2));
3433 assert_eq!(parse_choice("5", 5), Some(4));
3434 assert_eq!(parse_choice("6", 5), None);
3436 assert_eq!(parse_choice("0", 5), None);
3437 assert_eq!(parse_choice("q", 5), None);
3438 }
3439
3440 #[test]
3441 fn web_url_shape() {
3442 assert_eq!(
3443 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3444 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3445 );
3446 }
3447
3448 #[test]
3449 fn wait_duration_parsing() {
3450 use std::time::Duration;
3451 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3453 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3454 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3455 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3456 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3458 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3459 assert!(parse_wait("0s").unwrap().is_zero());
3460 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3462 assert!(parse_wait("2x").is_err());
3464 assert!(parse_wait("").is_err());
3465 assert!(parse_wait("s").is_err());
3466 assert!(parse_wait("-1s").is_err());
3467 }
3468
3469 #[test]
3470 fn leading_kind_keyword_becomes_a_kind_filter() {
3471 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3472 assert_eq!(
3474 split_kind_keyword("class".into(), d(&["Widget"])),
3475 (Some(&["class"][..]), "Widget".into(), vec![])
3476 );
3477 assert_eq!(
3479 split_kind_keyword("method zoom".into(), vec![]),
3480 (Some(&["method"][..]), "zoom".into(), vec![])
3481 );
3482 assert_eq!(
3484 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3485 (Some(&["function"][..]), "Foo::run".into(), vec![])
3486 );
3487 assert_eq!(
3489 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3490 (Some(&["struct"][..]), "Gadget".into(), d(&["src"]))
3491 );
3492 }
3493
3494 #[test]
3495 fn a_bare_or_non_keyword_query_is_left_alone() {
3496 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3497 assert_eq!(
3499 split_kind_keyword("class".into(), vec![]),
3500 (None, "class".into(), vec![])
3501 );
3502 assert_eq!(
3504 split_kind_keyword("Widget".into(), d(&["app"])),
3505 (None, "Widget".into(), d(&["app"]))
3506 );
3507 assert_eq!(
3509 split_kind_keyword("c".into(), d(&["Foo"])),
3510 (None, "c".into(), d(&["Foo"]))
3511 );
3512 }
3513
3514 #[test]
3515 fn the_branch_window_scales_with_what_the_refresh_costs() {
3516 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3519 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3520 assert_eq!(branch_files_ttl(Some(700)), 70);
3524 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3525 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3528 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3529 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3531 }
3532
3533 #[test]
3534 fn a_language_selects_by_prefix_or_alias() {
3535 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3537 assert_eq!(canonical_langs("t"), ["typescript"]);
3538 assert_eq!(canonical_langs("ts"), ["typescript"]);
3540 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3541 assert_eq!(canonical_langs("rb"), ["ruby"]);
3542 assert!(canonical_langs("COBOL").is_empty());
3545 }
3546
3547 #[test]
3548 fn a_kind_normalizes_language_specific_spellings() {
3549 assert_eq!(canonical_kind("f"), Some(&["function"][..]));
3550 assert_eq!(canonical_kind("interface"), Some(&["trait"][..]));
3552 assert_eq!(canonical_kind("type"), Some(&["type", "struct"][..]));
3554 assert_eq!(canonical_kind("alias"), Some(&["type"][..]));
3555 assert_eq!(canonical_kind("member"), Some(&["variant"][..]));
3556 assert_eq!(canonical_kind("macro"), Some(&["macro"][..]));
3557 assert_eq!(canonical_kind("const"), Some(&["constant"][..]));
3558 assert_eq!(canonical_kind("banana"), None);
3559 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3561 assert_eq!(
3562 split_kind_keyword("interface".into(), d(&["Renderer"])),
3563 (Some(&["trait"][..]), "Renderer".into(), vec![])
3564 );
3565 }
3566
3567 #[test]
3568 fn highlight_wraps_matched_runs() {
3569 assert_eq!(
3570 highlight("FooThing", &[0, 1, 2], "1;31"),
3571 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3572 );
3573 assert_eq!(
3575 highlight("FooThing", &[0, 3], "1"),
3576 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3577 );
3578 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3580 }
3581
3582 #[test]
3583 fn progress_ui_only_for_an_interactive_text_terminal() {
3584 assert!(show_progress(Output::Text, true));
3586
3587 assert!(!show_progress(Output::Json, true));
3589 assert!(!show_progress(Output::Ndjson, true));
3590
3591 assert!(!show_progress(Output::Text, false));
3593 }
3594
3595 #[test]
3596 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3597 let lines = ["class Widget", "", " def go"];
3598 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3599 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3600 assert_eq!(signature_in(&lines, 2), None, "blank line");
3601 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3602 assert_eq!(signature_in(&lines, 4), None, "past the end");
3603 }
3604
3605 #[test]
3606 fn repo_label_uses_the_directory_name() {
3607 assert_eq!(
3608 repo_label(Some(std::path::Path::new("/src/widgets"))),
3609 "widgets"
3610 );
3611 assert_eq!(repo_label(None), "repo");
3612 }
3613
3614 #[test]
3615 fn hl_path_highlights_the_stem_not_the_extension() {
3616 let out = hl_path(
3619 "app/employees_controller.rb",
3620 "employeescontroller",
3621 Some("1;31"),
3622 );
3623 assert!(
3624 out.starts_with("app/\u{1b}[1;31memployees"),
3625 "stem highlighted: {out:?}"
3626 );
3627 assert!(
3628 out.ends_with("controller\u{1b}[0m.rb"),
3629 "`.rb` left un-highlighted: {out:?}"
3630 );
3631 }
3632}