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 = "rq finds the code you're looking for: type a name, get its definition.",
23 long_about = "rq finds the code you're looking for. Name a class, method, function, \
24struct or constant in Ruby, Rust, Go, Python, TypeScript or JavaScript, and rq shows \
25where it's defined.",
26 after_help = "EXAMPLES:\n \
27rq HashWithIndifferentAccess the class's definition\n \
28rq hwia the same class: fuzzy and abbreviation-aware\n \
29rq 'Hash*Access' wildcards: * any run, ? one char (quote them)\n \
30rq ActiveRecord::Base the Base inside ActiveRecord\n \
31rq Persistence#save the save inside Persistence\n \
32rq hugolib.HugoSites a package, module or directory scopes too\n \
33rq Migration.new its constructor (initialize, __init__, constructor)\n \
34rq class Base classes named Base (same as -k class)\n \
35rq perform activejob the perform under activejob/\n \
36rq save -o open the best match in your editor\n \
37rq save --explain the score behind each result\n \
38rq save --json JSON, for editors, scripts and agents\n \
39rq --symbols app/w.rb outline a file, in line order\n\n\
40SHORT FLAGS (easy to misread):\n \
41-j is --json, not --jobs (long-only) -l is --limit, not --lang (that's -x)\n\n\
42THE INDEX:\n \
43One SQLite file for all repos, at $RQ_DB (an absolute path; default\n \
44~/.local/share/rq/rq.db). On a cold repo, a search keeps indexing until it can\n \
45answer instead of reporting a false \"no matches\"; --no-wait answers right away.\n\n\
46EXIT CODES:\n \
470 matched; for --status, --index, --drop: ran, even with nothing to show or drop\n \
481 no match; for --usage: nothing recorded yet\n \
492 no match yet: the index is still warming or indexing was interrupted; ask again\n \
5064 usage error: a bad flag, value or query\n \
5166 a file named in the command doesn't exist\n \
5269 no editor, browser or git remote to hand off to\n \
5370 internal error (a bug)\n \
5474 can't open, read or write the index"
55)]
56struct Cli {
57 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
63 target: Option<String>,
64
65 #[arg(value_name = "PATH")]
67 dirs: Vec<String>,
68
69 #[arg(
72 help_heading = "Narrow the search",
73 short = 'p',
74 long,
75 value_name = "DIR"
76 )]
77 path: Vec<String>,
78
79 #[arg(
86 help_heading = "Narrow the search",
87 short = 'k',
88 long,
89 value_name = "KIND",
90 value_delimiter = ','
91 )]
92 kind: Vec<String>,
93
94 #[arg(
100 help_heading = "Narrow the search",
101 short = 'x',
102 long = "lang",
103 value_name = "LANG",
104 value_delimiter = ','
105 )]
106 lang: Vec<String>,
107
108 #[arg(help_heading = "Narrow the search", long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
115 anchor: Option<AnchorSpec>,
116
117 #[arg(help_heading = "Narrow the search", short = 'a', long = "all-repos")]
119 all_repos: bool,
120
121 #[arg(help_heading = "Narrow the search", short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
123 limit: usize,
124
125 #[arg(help_heading = "Output", short = 'e', long)]
128 explain: bool,
129
130 #[arg(help_heading = "Output", short = 'j', long)]
132 json: bool,
133
134 #[arg(help_heading = "Output", short = 'J', long, conflicts_with = "json")]
136 ndjson: bool,
137
138 #[arg(help_heading = "Output", long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
143 show: bool,
144
145 #[arg(help_heading = "Output", short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
152 open: bool,
153
154 #[arg(help_heading = "Output", short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
160 web: bool,
161
162 #[arg(help_heading = "Waiting on the index", long = "no-wait")]
169 no_wait: bool,
170
171 #[arg(help_heading = "Waiting on the index", long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
176 wait: Option<Duration>,
177
178 #[arg(help_heading = "The index", long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
183 symbols: Option<String>,
184
185 #[arg(help_heading = "The index", long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
189 index: Option<Option<String>>,
190
191 #[arg(help_heading = "The index", long, conflicts_with = "index")]
193 status: bool,
194
195 #[arg(help_heading = "The index", long, conflicts_with_all = ["index", "status", "open", "web"])]
200 drop: bool,
201
202 #[arg(help_heading = "The index", long, conflicts_with_all = ["index", "status"])]
204 usage: bool,
205
206 #[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"])]
210 warm: Option<Option<String>>,
211
212 #[arg(help_heading = "Debugging", short = 'v', long)]
217 verbose: bool,
218
219 #[arg(help_heading = "Debugging", long)]
224 profile: bool,
225
226 #[arg(
230 help_heading = "Debugging",
231 long,
232 value_name = "N",
233 default_value_t = 0
234 )]
235 jobs: usize,
236
237 #[arg(help_heading = "Debugging", long, value_name = "SHELL")]
239 completions: Option<Shell>,
240}
241
242pub fn run() -> ExitCode {
244 let cli = match Cli::try_parse() {
245 Ok(cli) => cli,
246 Err(err) => return clap_failure(err),
247 };
248 crate::trace::enable_from(cli.verbose);
249 crate::profile::enable_from(cli.profile);
250 crate::index::set_parse_jobs(cli.jobs);
251 let json_out = output_format(&cli) != Output::Text;
252 let code = dispatch(cli);
253 crate::profile::emit(json_out);
254 code
255}
256
257fn dispatch(cli: Cli) -> ExitCode {
258 if let Some(shell) = cli.completions {
259 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
260 return ExitCode::SUCCESS;
261 }
262 if let Err(message) = db_location() {
263 return fail(
264 output_format(&cli),
265 Failure::Usage,
266 format_args!("{message}"),
267 );
268 }
269 if let Some(path) = &cli.index {
270 let out = output_format(&cli);
272 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
273 }
274 if let Some(path) = &cli.warm {
275 return cmd_warm(path.as_deref());
276 }
277 if cli.status {
278 return cmd_status(output_format(&cli));
279 }
280 if cli.usage {
281 return cmd_usage(output_format(&cli));
282 }
283 if cli.drop {
284 let out = output_format(&cli);
285 return cmd_drop(cli.target, out);
286 }
287 let out = output_format(&cli);
288 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
291 return fail(
292 out,
293 Failure::Usage,
294 format_args!(
295 "rq: empty query — name the definition to find; `rq --symbols FILE` lists a file's (-k to filter)"
296 ),
297 );
298 }
299 let mut kinds: Vec<String> = Vec::new();
303 for k in &cli.kind {
304 match canonical_kind(k) {
305 Some(c) => kinds.extend(c.iter().map(|k| k.to_string())),
306 None => {
307 return fail(
308 out,
309 Failure::Usage,
310 format_args!(
311 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant, type, macro, variant)"
312 ),
313 );
314 }
315 }
316 }
317 let mut langs: Vec<String> = Vec::new();
319 for x in &cli.lang {
320 let matched = canonical_langs(x);
321 if matched.is_empty() {
322 return fail(
323 out,
324 Failure::Usage,
325 format_args!(
326 "rq: unknown --lang {x:?} ({})",
327 crate::lang::languages().join(", ")
328 ),
329 );
330 }
331 langs.extend(matched);
332 }
333 if let Some(file) = &cli.symbols {
334 return cmd_symbols(file, &kinds, &langs, out);
335 }
336 let mut paths = cli.path.clone();
338 match cli.target {
339 Some(target) => {
340 let query = if cli.kind.is_empty() {
343 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
344 if let Some(k) = kw {
345 kinds.extend(k.iter().map(|k| k.to_string()));
346 }
347 paths.extend(dirs);
348 query
349 } else {
350 paths.extend(cli.dirs.clone());
351 target
352 };
353 let mut session = match Session::open(out) {
354 Ok(s) => s,
355 Err(code) => return code,
356 };
357 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
358 cmd_search(
359 &mut session,
360 &SearchArgs {
361 query: &query,
362 explain: cli.explain,
363 out,
364 paths: &paths,
365 kinds: &kinds,
366 langs: &langs,
367 want: requested_limit(cli.limit),
368 no_wait: cli.no_wait,
369 wait: cli.wait,
370 open: cli.open,
371 web: cli.web,
372 all_repos: cli.all_repos,
373 show: cli.show,
374 batch: false,
375 anchored: cli.anchor.is_some(),
376 },
377 )
378 }
379 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
382 None => {
384 let _ = Cli::command().print_long_help();
385 ExitCode::SUCCESS
386 }
387 }
388}
389
390#[derive(Clone, Copy, PartialEq)]
392enum Output {
393 Text,
394 Json,
395 Ndjson,
396}
397
398fn output_format(cli: &Cli) -> Output {
399 if cli.ndjson {
400 Output::Ndjson
401 } else if cli.json {
402 Output::Json
403 } else {
404 Output::Text
405 }
406}
407
408const DEFAULT_LIMIT: usize = 10;
410
411const PATH_HEADROOM: usize = 200;
414
415fn requested_limit(limit: usize) -> usize {
418 if limit == 0 { usize::MAX } else { limit }
419}
420
421fn record_usage(
424 store: &Store,
425 args: &SearchArgs,
426 status: &str,
427 coverage: Option<&str>,
428 live: bool,
429) {
430 let _ = store.record_search(&crate::store::SearchRecord {
431 source: &crate::origin::detect(),
432 flags: &flag_summary(args),
433 status,
434 coverage: coverage.unwrap_or("none"),
435 live,
436 });
437}
438
439fn flag_summary(args: &SearchArgs) -> String {
444 let mut on: Vec<&str> = Vec::new();
445 match args.out {
446 Output::Json => on.push("json"),
447 Output::Ndjson => on.push("ndjson"),
448 Output::Text => {}
449 }
450 for (present, name) in [
451 (args.explain, "explain"),
452 (args.show, "show"),
453 (args.open, "open"),
454 (args.web, "web"),
455 (args.all_repos, "all-repos"),
456 (args.no_wait, "no-wait"),
457 (args.batch, "batch"),
458 (args.anchored, "anchor"),
459 (!args.paths.is_empty(), "path"),
460 (!args.kinds.is_empty(), "kind"),
461 (!args.langs.is_empty(), "lang"),
462 (args.want != DEFAULT_LIMIT, "limit"),
463 ] {
464 if present {
465 on.push(name);
466 }
467 }
468 on.join(",")
469}
470
471const POLL_INTERVAL: Duration = Duration::from_millis(100);
478
479const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
483
484const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
488
489struct SearchArgs<'a> {
491 query: &'a str,
492 explain: bool,
493 out: Output,
494 paths: &'a [String],
495 kinds: &'a [String],
496 langs: &'a [String],
497 want: usize,
499 no_wait: bool,
501 wait: Option<Duration>,
504 open: bool,
505 web: bool,
506 all_repos: bool,
507 batch: bool,
511 show: bool,
512 anchored: bool,
514}
515
516struct Session {
527 store: Store,
528 cwd: Option<PathBuf>,
529 cwd_is_git: bool,
530 root: Option<PathBuf>,
531 active_paths: Vec<String>,
532 branch_refresh: Option<BranchRefresh>,
533 identity: Option<String>,
534 coverage: Option<String>,
535 anchor: Option<crate::search::Anchor>,
537}
538
539impl Session {
540 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
542 let open_span = crate::profile::span("store open");
543 let store = match open_store() {
544 Ok(s) => s,
545 Err(e) => {
546 return Err(fail(
547 out,
548 Failure::Database,
549 format_args!("rq: cannot open database: {e}"),
550 ));
551 }
552 };
553 drop(open_span);
554 let git_span = crate::profile::span("setup: git root");
555 let cwd = std::env::current_dir().ok();
556 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
557
558 let root = cwd
564 .as_deref()
565 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
566 drop(git_span);
567
568 let mut branch_span = crate::profile::span("setup: branch files");
571 let (active_paths, branch_refresh, cached_cost) = match &root {
572 Some(c) if cwd_is_git => cached_branch_files(&store, c),
573 _ => (Vec::new(), None, None),
574 };
575 branch_span.note(|| {
576 let how = if branch_refresh.is_some() {
577 "cached, refreshing alongside"
578 } else {
579 "cached"
580 };
581 let ttl = branch_files_ttl(cached_cost);
584 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
585 });
586 drop(branch_span);
587
588 let mut identity_span = crate::profile::span("setup: identity");
593 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
594 let coverage = identity
595 .as_deref()
596 .and_then(|id| store.coverage_status(id).ok())
597 .flatten();
598 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
599 drop(identity_span);
600 Ok(Session {
601 store,
602 cwd,
603 cwd_is_git,
604 root,
605 active_paths,
606 branch_refresh,
607 identity,
608 coverage,
609 anchor: None,
610 })
611 }
612
613 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
617 let _span = crate::profile::span("setup: anchor");
618 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
619 let abs = here.join(&spec.file);
620 let abs = abs.canonicalize().unwrap_or(abs);
621 let root = self
622 .root
623 .clone()
624 .filter(|r| abs.starts_with(r))
625 .or_else(|| crate::index::repo_root(&abs))
626 .or_else(|| abs.parent().map(PathBuf::from))
627 .unwrap_or(here);
628 let identity = resolve_identity(&self.store, &root);
629 let rel = abs
630 .strip_prefix(&root)
631 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
632 .into_owned();
633 let repo_id = self.store.repository_id(&identity).ok().flatten();
634 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
635 crate::search::Anchor::new(identity, rel, spec.line, &defs)
636 }
637}
638
639#[derive(Debug, Clone, PartialEq)]
641struct AnchorSpec {
642 file: PathBuf,
643 line: i64,
644}
645
646fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
649 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
650 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
651 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
652 let last = num(last).ok_or_else(bad)?;
653 let (file, line) = rest
655 .rsplit_once(':')
656 .and_then(|(file, line)| num(line).map(|line| (file, line)))
657 .unwrap_or((rest, last));
658 if file.is_empty() {
659 return Err(bad());
660 }
661 Ok(AnchorSpec {
662 file: PathBuf::from(file),
663 line,
664 })
665}
666
667fn cmd_batch(
682 cli: &Cli,
683 out: Output,
684 paths: &[String],
685 kinds: &[String],
686 langs: &[String],
687) -> ExitCode {
688 if out == Output::Json {
689 return fail(
690 out,
691 Failure::Usage,
692 format_args!(
693 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
694 where each line carries the query it answers"
695 ),
696 );
697 }
698 if cli.open || cli.web || cli.show {
699 return fail(
700 out,
701 Failure::Usage,
702 format_args!(
703 "rq: --open, --web and --show act on a single result, not a stream of queries"
704 ),
705 );
706 }
707
708 use std::io::BufRead;
709 let queries: Vec<String> = std::io::stdin()
710 .lock()
711 .lines()
712 .map_while(std::result::Result::ok)
713 .map(|l| l.trim().to_string())
714 .filter(|l| !l.is_empty())
715 .collect();
716 if queries.is_empty() {
720 let _ = Cli::command().print_long_help();
721 return ExitCode::SUCCESS;
722 }
723
724 let mut session = match Session::open(out) {
725 Ok(s) => s,
726 Err(code) => return code,
727 };
728 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
729
730 if !cli.no_wait
733 && session.coverage.as_deref() != Some("complete")
734 && let Some(root) = session.root.clone()
735 {
736 {
737 let budget = cli.wait.unwrap_or_else(wait_budget);
738 crate::trace!(
739 "batch: warming {} queries' worth of index first",
740 queries.len()
741 );
742 let active = session.active_paths.clone();
743 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
744 session.coverage = session
745 .identity
746 .as_deref()
747 .and_then(|id| session.store.coverage_status(id).ok())
748 .flatten();
749 }
750 }
751
752 let mut worst = ExitCode::SUCCESS;
753 let mut any_hit = false;
754 for query in &queries {
755 let code = cmd_search(
756 &mut session,
757 &SearchArgs {
758 query,
759 explain: cli.explain,
760 out,
761 paths,
762 kinds,
763 langs,
764 want: requested_limit(cli.limit),
765 no_wait: true,
769 wait: cli.wait,
770 open: false,
771 web: false,
772 all_repos: cli.all_repos,
773 show: false,
774 batch: true,
775 anchored: cli.anchor.is_some(),
776 },
777 );
778 if code == ExitCode::SUCCESS {
779 any_hit = true;
780 } else {
781 worst = code;
782 }
783 }
784 if any_hit { ExitCode::SUCCESS } else { worst }
787}
788
789fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
790 let &SearchArgs {
791 query,
792 out,
793 want,
794 no_wait,
795 wait,
796 open,
797 web,
798 all_repos,
799 show,
800 ..
801 } = args;
802 let wait_budget = wait.unwrap_or_else(wait_budget);
805 let no_wait = no_wait || wait_budget.is_zero();
806 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
809 want
810 } else {
811 want.saturating_mul(20).max(PATH_HEADROOM)
812 };
813 let _timer = crate::trace::Timer::start("search done");
814 let t_setup = std::time::Instant::now();
815 let setup_span = crate::profile::span("setup");
817 let Session {
820 store,
821 cwd,
822 cwd_is_git,
823 root,
824 active_paths,
825 branch_refresh,
826 identity,
827 coverage,
828 anchor,
829 } = session;
830 let cwd_is_git = *cwd_is_git;
831
832 let known = coverage.is_some();
840 let warming_ok = cwd_is_git || known;
841 if crate::trace::enabled() {
842 crate::trace!(
843 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
844 root.as_deref().map_or("?".into(), crate::trace::abbrev),
845 identity.as_deref().unwrap_or("none"),
846 coverage.as_deref().unwrap_or("none"),
847 active_paths.len(),
848 );
849 }
850 let repo_span = crate::profile::span("setup: repo state");
851 let repo_id = |store: &Store| {
852 identity
853 .as_deref()
854 .and_then(|id| store.repository_id(id).ok().flatten())
855 };
856 let mut current = repo_id(store);
857 let scope = |current| Scope::new(all_repos, warming_ok, current);
860 let ctx = crate::search::Context {
861 active: crate::search::ActiveFiles::new(active_paths.clone()),
862 anchor: anchor.clone(),
863 };
864
865 drop(repo_span);
866 let warm_span = crate::profile::span("setup: warm decision");
867
868 let warm_budget = if warm_detach_enabled() {
875 answer_warm_budget()
876 } else {
877 answer_warm_budget() + deferred_warm_budget()
878 };
879 let was_warming = coverage.as_deref() != Some("complete");
880
881 let indexed_head = (!was_warming)
892 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
893 .flatten();
894 let staleness = (!was_warming && warming_ok && !args.batch)
897 .then(|| root.clone())
898 .flatten()
899 .map(|c| {
900 if warm_detach_enabled() {
901 Staleness::Deferred(c, indexed_head)
902 } else {
903 Staleness::Running(std::thread::spawn(move || {
904 worktree_edits(&c, indexed_head.as_deref())
905 }))
906 }
907 });
908 let want_warm = warming_ok && was_warming && root.is_some();
911
912 let block = want_warm && was_warming && !no_wait;
926 let progress_ui = block && show_progress(out, stderr_interactive());
931 let indexer_budget = if block { wait_budget } else { warm_budget };
932 if progress_ui {
933 install_interrupt_handler();
934 }
935
936 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
939 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
940 crate::trace!(
941 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
942 crate::index::parse_jobs()
943 );
944 let root = root.clone().expect("checked");
945 let active = active_paths.clone();
946 let q = query.to_string();
947 let warm_done = std::sync::Arc::clone(&warm_done);
948 std::thread::spawn(move || {
949 if let Ok(mut idx) = open_store() {
950 let _ = if block {
952 crate::index::index_budgeted_cancellable(
955 &mut idx,
956 &root,
957 &active,
958 indexer_budget,
959 Some(&q),
960 &INTERRUPTED,
961 )
962 } else {
963 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
964 };
965 }
966 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
967 })
968 });
969
970 crate::trace!(
977 "setup (open + repo detect + warm decision): {} ms",
978 t_setup.elapsed().as_millis()
979 );
980 let poll_start = std::time::Instant::now();
981 let deadline = if progress_ui {
985 None
986 } else if block {
987 Some(poll_start + wait_budget)
988 } else {
989 Some(poll_start + answer_warm_budget())
990 };
991 drop(warm_span);
992 let polling = indexer.is_some() && was_warming;
993 drop(setup_span);
997 let mut query_span = crate::profile::span("query");
998 let label = repo_label(root.as_deref());
999 let mut drew_progress = false;
1000 let mut last_draw = poll_start;
1001 let rank_limit = limit.max(2);
1005 let mut total;
1006 let mut hits = loop {
1007 if current.is_none() {
1009 current = repo_id(store);
1010 }
1011 match scope(current).search(store, query, current, &ctx, rank_limit) {
1012 Ok(m) => {
1013 total = m.total;
1014 let h = m.hits;
1015 let confident = h.first().is_some_and(|hit| {
1016 hit.features
1017 .iter()
1018 .any(|f| matches!(f.name, "exact" | "prefix"))
1019 });
1020 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
1021 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1022 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
1023 if !polling || confident || warm_finished || stopped || timed_out {
1024 break h;
1025 }
1026 if progress_ui
1027 && poll_start.elapsed() >= HEADS_UP_DELAY
1028 && last_draw.elapsed() >= PROGRESS_REDRAW
1029 {
1030 draw_progress(store, identity.as_deref(), &label);
1031 drew_progress = true;
1032 last_draw = std::time::Instant::now();
1033 }
1034 }
1035 Err(e) => {
1036 if let Some(h) = indexer {
1037 let _ = h.join();
1038 }
1039 return fail(out, Failure::Database, format_args!("rq: {e}"));
1040 }
1041 }
1042 std::thread::sleep(POLL_INTERVAL);
1043 };
1044 query_span.note(|| {
1045 if polling {
1046 "polled a warming index".to_string()
1047 } else {
1048 String::new()
1049 }
1050 });
1051 drop(query_span);
1052 if drew_progress {
1053 clear_progress();
1054 }
1055 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1057
1058 let here = identity
1059 .as_deref()
1060 .zip(root.as_deref())
1061 .map(|(identity, root)| Here { identity, root });
1062 if !hits.is_empty()
1064 && revalidate_top(store, &hits, here)
1065 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1066 {
1067 total = m.total;
1068 hits = m.hits;
1069 }
1070
1071 let mut live_scan = None;
1075 if !hits.iter().any(strong)
1076 && indexer.is_none()
1077 && coverage.is_none()
1078 && let Some(root) = &root
1079 {
1080 let (tail, cost) = live_fallback(root, query, rank_limit, &ctx);
1081 hits = crate::search::merge(hits, tail, rank_limit);
1082 total = total.max(hits.len());
1083 live_scan = Some(cost);
1084 }
1085
1086 apply_gates(query, &mut hits);
1087 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1088 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1092 total = hits.len();
1093 hits.truncate(want.max(2));
1094 }
1095
1096 if hits.is_empty() {
1097 if block {
1099 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1100 }
1101 if let Some(h) = indexer {
1102 let _ = h.join();
1103 }
1104 let mut incomplete = (block || no_wait)
1111 && identity
1112 .as_deref()
1113 .and_then(|id| store.coverage_status(id).ok().flatten())
1114 .as_deref()
1115 != Some("complete");
1116 incomplete |= settle_warm(
1126 store,
1127 staleness,
1128 false,
1129 was_warming,
1130 warming_ok,
1131 root.as_deref(),
1132 active_paths,
1133 query,
1134 warm_budget,
1135 no_wait,
1136 identity.as_deref(),
1137 );
1138 let elsewhere = match scope(current) {
1143 Scope::Nothing => None,
1144 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1145 Scope::Repo(id) => {
1146 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1147 }
1148 };
1149 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1150 note_live_scan(root, cost, false);
1151 }
1152 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1153 let _span = crate::profile::span("after: record usage");
1157 record_usage(
1158 store,
1159 args,
1160 if incomplete { "warming" } else { "miss" },
1161 coverage.as_deref(),
1162 false,
1163 );
1164 return code;
1165 }
1166
1167 let live = hits
1170 .iter()
1171 .take(want)
1172 .any(|h| h.source == crate::search::Source::Live);
1173 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1174 note_live_scan(root, cost, live);
1175 }
1176
1177 let counted_early = show || open || web;
1182 if counted_early {
1183 let _span = crate::profile::span("record usage");
1184 record_usage(store, args, "hit", coverage.as_deref(), live);
1185 }
1186
1187 attach_confidence(&mut hits);
1191 hits.truncate(want);
1192 let total = total.max(hits.len());
1193 for hit in &mut hits {
1194 hit.total = total;
1195 if args.explain {
1196 hit.explain = Some(
1197 hit.features
1198 .iter()
1199 .map(|f| (f.name.to_string(), f.reported()))
1200 .collect(),
1201 );
1202 }
1203 }
1204
1205 let _signatures_span = crate::profile::span("signatures");
1208 for hit in &mut hits {
1209 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1210 hit.signature = root
1211 .as_deref()
1212 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1213 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1214 }
1215 drop(_signatures_span);
1216
1217 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1220 return code;
1221 }
1222
1223 if open || web {
1228 return finish_open(store, &hits, current, root.as_deref(), web);
1229 }
1230
1231 if let Some(code) = render_hits(args, &hits) {
1232 return code;
1233 }
1234
1235 crate::profile::mark("first answer");
1238
1239 if !counted_early {
1242 let _span = crate::profile::span("after: record usage");
1243 record_usage(store, args, "hit", coverage.as_deref(), live);
1244 }
1245
1246 if let Some(refresh) = branch_refresh.take() {
1253 let _span = crate::profile::span("after: branch refresh");
1254 refresh.store(store);
1255 }
1256
1257 if block {
1262 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1263 }
1264 if let Some(h) = indexer {
1265 let _ = h.join();
1266 }
1267 let _ = settle_warm(
1268 store,
1269 staleness,
1270 true,
1271 was_warming,
1272 warming_ok,
1273 root.as_deref(),
1274 active_paths,
1275 query,
1276 warm_budget,
1277 no_wait,
1278 identity.as_deref(),
1279 );
1280
1281 ExitCode::SUCCESS
1282}
1283
1284fn maybe_detach_warm(
1287 store: &Store,
1288 want_warm: bool,
1289 changed: bool,
1290 root: Option<&std::path::Path>,
1291 identity: Option<&str>,
1292) {
1293 if !warm_detach_enabled() || !want_warm {
1294 return;
1295 }
1296 let (Some(root), Some(id)) = (root, identity) else {
1297 return;
1298 };
1299 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1303 return; }
1305 spawn_detached_warm(root);
1306}
1307
1308fn spawn_detached_warm(root: &std::path::Path) {
1312 use std::os::unix::process::CommandExt;
1313 let Ok(exe) = std::env::current_exe() else {
1314 return;
1315 };
1316 let mut cmd = std::process::Command::new(exe);
1317 cmd.arg("--warm")
1318 .arg(root)
1319 .stdin(std::process::Stdio::null())
1320 .stdout(std::process::Stdio::null())
1321 .stderr(std::process::Stdio::null())
1322 .process_group(0);
1323 match cmd.spawn() {
1324 Ok(child) => crate::trace!(
1325 "background warm (detached): pid {} for {}",
1326 child.id(),
1327 crate::trace::abbrev(root)
1328 ),
1329 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1330 }
1331}
1332
1333const WARM_LOCK_TTL_SECS: i64 = 600;
1336
1337fn warm_recheck_window() -> Duration {
1343 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1344}
1345
1346fn recently_verified(
1349 store: &Store,
1350 identity: Option<&str>,
1351 root: &std::path::Path,
1352 indexed_head: Option<&str>,
1353) -> bool {
1354 let _span = crate::profile::span("after: warm recently verified?");
1355 let (Some(id), Some(stamp)) = (
1356 identity,
1357 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1358 ) else {
1359 return false;
1360 };
1361 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1362 return false;
1363 };
1364 let age = now_unix().saturating_sub(at);
1365 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1366}
1367
1368fn cmd_warm(path: Option<&str>) -> ExitCode {
1373 #[cfg(target_os = "macos")]
1376 unsafe extern "C" {
1377 fn setiopolicy_np(
1380 iotype: libc::c_int,
1381 scope: libc::c_int,
1382 policy: libc::c_int,
1383 ) -> libc::c_int;
1384 }
1385 unsafe {
1386 libc::nice(10);
1387 #[cfg(target_os = "macos")]
1388 setiopolicy_np(0, 0, 3);
1389 }
1390 let mut store = match open_store() {
1391 Ok(s) => s,
1392 Err(_) => return ExitCode::from(Failure::Database.exit_code()),
1393 };
1394 let start = path
1395 .map(PathBuf::from)
1396 .or_else(|| std::env::current_dir().ok())
1397 .unwrap_or_else(|| PathBuf::from("."));
1398 let root = crate::index::repo_root(&start).unwrap_or(start);
1399 let identity = resolve_identity(&store, &root);
1400
1401 let held = |pid: u32, ts: i64| {
1405 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
1406 alive && now_unix() - ts < WARM_LOCK_TTL_SECS
1407 };
1408 if !store
1409 .claim_warm_lock(&identity, std::process::id(), held)
1410 .unwrap_or(false)
1411 {
1412 return ExitCode::SUCCESS;
1413 }
1414
1415 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1418 let head = store
1419 .repository_id(&identity)
1420 .ok()
1421 .flatten()
1422 .and_then(|id| store.indexed_head(id).ok().flatten());
1423 let checked_at = now_unix();
1427 let edits = worktree_edits(&root, head.as_deref());
1428 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1429 crate::trace!("warm: unchanged since indexed, nothing to do");
1430 if let Some(id) = store.repository_id(&identity).ok().flatten() {
1432 let _ = store.maintain_name_index(id);
1433 }
1434 if let Some(stamp) = head
1435 .as_deref()
1436 .and_then(|h| crate::index::git_state_stamp(&root, h))
1437 {
1438 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1439 }
1440 let _ = store.clear_warm_lock(&identity);
1441 return ExitCode::SUCCESS;
1442 }
1443 }
1444
1445 let deadline = std::time::Instant::now() + warm_bg_budget();
1448 let active = crate::index::branch_changed_files(&root);
1449 loop {
1450 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1451 if remaining.is_zero() {
1452 break;
1453 }
1454 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1455 {
1456 Ok(s) => s,
1457 Err(_) => break,
1458 };
1459 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1460 || stats.files_indexed == 0
1461 {
1462 break;
1463 }
1464 }
1465 let _ = store.clear_warm_lock(&identity);
1466 ExitCode::SUCCESS
1467}
1468
1469fn live_fallback(
1472 root: &std::path::Path,
1473 query: &str,
1474 limit: usize,
1475 ctx: &crate::search::Context,
1476) -> (Vec<crate::search::Hit>, LiveCost) {
1477 let start = std::time::Instant::now();
1478 let deadline = start + live_fallback_budget();
1479 let mut files = 0;
1480 let mut scan = |prefilter| {
1481 let mut span = crate::profile::span(if prefilter {
1482 "live scan: prefiltered"
1483 } else {
1484 "live scan: unfiltered"
1485 });
1486 let found = crate::search::live_search(
1487 root,
1488 query,
1489 limit,
1490 &HashSet::new(),
1491 Some(deadline),
1492 prefilter,
1493 ctx,
1494 );
1495 span.note(|| format!("{} files", found.files));
1496 files += found.files;
1497 found.hits
1498 };
1499 let mut hits = scan(true);
1500 if hits.is_empty() {
1501 hits = scan(false);
1502 }
1503 let cost = LiveCost {
1504 files,
1505 elapsed: start.elapsed(),
1506 };
1507 (hits, cost)
1508}
1509
1510struct LiveCost {
1512 files: usize,
1513 elapsed: Duration,
1514}
1515
1516fn note_live_scan(root: &std::path::Path, cost: &LiveCost, answered: bool) {
1519 if !crate::trace::enabled() {
1520 return;
1521 }
1522 let budget = live_fallback_budget();
1523 let files = match cost.files {
1524 1 => "1 file".to_string(),
1525 n => format!("{n} files"),
1526 };
1527 let what = if answered {
1528 "answered from a live scan of"
1529 } else {
1530 "no answer from a live scan of"
1531 };
1532 let cut = if cost.elapsed >= budget {
1533 " (stopped at the budget)"
1534 } else {
1535 ""
1536 };
1537 crate::trace!(
1538 "{what} {}: {files} in {} ms, budget {} ms{cut}",
1539 crate::trace::abbrev(root),
1540 cost.elapsed.as_millis(),
1541 budget.as_millis(),
1542 );
1543}
1544
1545fn strong(h: &crate::search::Hit) -> bool {
1547 h.features
1548 .iter()
1549 .any(|f| matches!(f.name, "exact" | "prefix"))
1550}
1551
1552fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1561 if hits.iter().any(strong) {
1562 hits.retain(strong);
1563 }
1564 crate::search::apply_scope_gate(query, hits);
1565}
1566
1567fn apply_post_filters(
1570 args: &SearchArgs,
1571 cwd: Option<&std::path::Path>,
1572 root: Option<&std::path::Path>,
1573 hits: &mut Vec<crate::search::Hit>,
1574) {
1575 if !args.paths.is_empty() {
1576 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1580 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1581 let norm: Vec<String> = args
1582 .paths
1583 .iter()
1584 .map(|p| repo_relative(&base, &here, p))
1585 .collect();
1586 hits.retain(|h| under_any(&h.file, &norm));
1587 }
1588 if !args.kinds.is_empty() {
1589 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1590 }
1591 if !args.langs.is_empty() {
1592 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1593 }
1594}
1595
1596fn no_match_code(
1602 out: Output,
1603 query: &str,
1604 interrupted: bool,
1605 incomplete: bool,
1606 elsewhere: Option<&str>,
1610) -> ExitCode {
1611 let status = if interrupted {
1612 "interrupted"
1613 } else if incomplete {
1614 "warming"
1615 } else if elsewhere.is_some() {
1616 "scope_not_found"
1617 } else {
1618 "no_match"
1619 };
1620 match out {
1621 Output::Json | Output::Ndjson => {
1622 let mut obj = serde_json::json!({ "status": status, "query": query });
1623 if let Some(found_in) = elsewhere {
1624 obj["found_in"] = serde_json::json!(found_in);
1625 }
1626 let _ = emit_json(out, &obj); }
1628 Output::Text if interrupted => {
1629 eprintln!("rq: indexing interrupted — run again to finish")
1630 }
1631 Output::Text if incomplete => eprintln!(
1632 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1633 ),
1634 Output::Text if elsewhere.is_some() => eprintln!(
1635 "rq: nothing matching {query:?} in that scope — the name is defined elsewhere: {}",
1636 elsewhere.unwrap_or_default()
1637 ),
1638 Output::Text => eprintln!("no matches for {query:?}"),
1639 }
1640 if incomplete {
1641 ExitCode::from(2)
1642 } else {
1643 ExitCode::FAILURE
1644 }
1645}
1646
1647fn attach_confidence(hits: &mut [crate::search::Hit]) {
1651 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1652 if t.is_none_or(|t| h.score > t) {
1653 (Some(h.score), t)
1654 } else if s.is_none_or(|s| h.score > s) {
1655 (t, Some(h.score))
1656 } else {
1657 (t, s)
1658 }
1659 });
1660 for hit in hits.iter_mut() {
1661 let best_other = if Some(hit.score) == top { second } else { top };
1662 hit.confidence = crate::search::confidence(
1663 hit.score,
1664 crate::search::match_quality(&hit.features),
1665 best_other,
1666 );
1667 }
1668}
1669
1670fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1673 let render_span = crate::profile::span("render");
1677 if args.batch {
1678 #[derive(serde::Serialize)]
1681 struct Tagged<'a> {
1682 query: &'a str,
1683 #[serde(flatten)]
1684 hit: &'a crate::search::Hit,
1685 }
1686 let rows: Vec<Tagged> = hits
1687 .iter()
1688 .map(|hit| Tagged {
1689 query: args.query,
1690 hit,
1691 })
1692 .collect();
1693 if let Some(code) = emit_rows(args.out, &rows) {
1694 return Some(code);
1695 }
1696 } else if let Some(code) = emit_rows(args.out, hits) {
1697 return Some(code);
1698 }
1699 if args.out != Output::Text {
1700 return None;
1701 }
1702 drop(render_span);
1703 let color = match_color();
1704 let c = color.as_deref();
1705 let query = args.query;
1706 if args.show {
1707 let total = hits.first().map_or(hits.len(), |h| h.total);
1709 eprintln!(
1710 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1711 hits.len()
1712 );
1713 }
1714 for hit in hits {
1715 let name = hl(&hit.name, query, c);
1718 let qualified = match &hit.parent {
1719 Some(p) => format!("{name} · {p}"),
1720 None => name,
1721 };
1722 println!(
1723 "{}:{} {} {}",
1724 hl_path(&hit.file, query, c),
1725 hit.line,
1726 hit.kind,
1727 qualified
1728 );
1729 if let Some(sig) = &hit.signature {
1730 println!(" {}", hl(sig, query, c));
1731 }
1732 if args.explain {
1733 let parts: Vec<String> = hit
1734 .features
1735 .iter()
1736 .map(|f| format!("{} {}", f.name, f.reported()))
1737 .collect();
1738 println!(
1739 " confidence {:.2} · score {:.0} = {}",
1740 hit.confidence,
1741 hit.score,
1742 parts.join(" + ")
1743 );
1744 }
1745 }
1746 None
1747}
1748
1749fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1753 use std::io::{IsTerminal, Write};
1754 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1755 return hits.first();
1756 }
1757 let mut err = std::io::stderr();
1758 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1759 for (i, h) in hits.iter().enumerate() {
1760 let _ = writeln!(
1761 err,
1762 " {}. {}:{} {} {}",
1763 i + 1,
1764 h.file,
1765 h.line,
1766 h.kind,
1767 h.name
1768 );
1769 }
1770 let _ = write!(err, "rq> ");
1771 let _ = err.flush();
1772 let mut line = String::new();
1773 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1774 return None; }
1776 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1777}
1778
1779fn parse_choice(input: &str, n: usize) -> Option<usize> {
1782 let s = input.trim();
1783 if s.is_empty() {
1784 return Some(0);
1785 }
1786 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1787 (i < n).then_some(i)
1788}
1789
1790fn finish_open(
1793 store: &Store,
1794 hits: &[crate::search::Hit],
1795 current: Option<i64>,
1796 root: Option<&std::path::Path>,
1797 web: bool,
1798) -> ExitCode {
1799 let Some(hit) = choose_hit(hits) else {
1800 return ExitCode::SUCCESS; };
1802
1803 if web {
1804 return open_web(store, hit, current, root);
1805 }
1806
1807 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1810 Some(r) => r.join(&hit.file),
1811 None => PathBuf::from(&hit.file),
1812 };
1813 launch_editor(&target, hit.line)
1814}
1815
1816#[derive(Clone, Copy)]
1818enum Scope {
1819 All,
1821 Repo(i64),
1822 Nothing,
1825}
1826
1827impl Scope {
1828 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1829 match current {
1830 _ if all_repos => Scope::All,
1831 Some(id) => Scope::Repo(id),
1832 None if in_repo => Scope::Nothing,
1833 None => Scope::All,
1834 }
1835 }
1836
1837 fn search(
1838 self,
1839 store: &Store,
1840 query: &str,
1841 current: Option<i64>,
1842 ctx: &crate::search::Context,
1843 limit: usize,
1844 ) -> crate::store::Result<crate::search::Matches> {
1845 let only_repo = match self {
1846 Scope::All => None,
1847 Scope::Repo(id) => Some(id),
1848 Scope::Nothing => {
1849 return Ok(crate::search::Matches {
1850 hits: Vec::new(),
1851 total: 0,
1852 });
1853 }
1854 };
1855 crate::search::search(store, query, current, only_repo, ctx, limit)
1856 }
1857}
1858
1859fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1863 use std::os::unix::process::CommandExt;
1864 let loc = format!("{}:{}", file.display(), line);
1865 match open_command(file, line, &loc) {
1866 Some((prog, args)) => {
1867 crate::profile::emit(false);
1869 let err = std::process::Command::new(&prog).args(&args).exec();
1871 fail(
1872 Output::Text,
1873 Failure::Launch,
1874 format_args!("rq --open: cannot run {prog}: {err}"),
1875 )
1876 }
1877 None => {
1878 println!("{loc}");
1879 ExitCode::SUCCESS
1880 }
1881 }
1882}
1883
1884fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1890 let fstr = file.to_string_lossy().into_owned();
1891
1892 if let Some(t) = std::env::var_os("RQ_OPEN") {
1893 let t = t.to_string_lossy();
1894 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1895 let mut parts = t.split_whitespace().map(|p| {
1896 p.replace("{file}", &fstr)
1897 .replace("{line}", &line.to_string())
1898 .replace("{}", loc)
1899 });
1900 if let Some(prog) = parts.next() {
1901 let mut args: Vec<String> = parts.collect();
1902 if !placeholder {
1903 args.push(loc.to_string());
1904 }
1905 return Some((prog, args));
1906 }
1907 }
1908
1909 if on_path("code") {
1910 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1911 }
1912
1913 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1914 let ed = ed.to_string_lossy().into_owned();
1915 let l = ed.to_ascii_lowercase();
1916 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1918 .iter()
1919 .any(|e| l.contains(e))
1920 {
1921 return Some((ed, vec![format!("+{line}"), fstr]));
1922 }
1923 return Some((ed, vec![fstr]));
1924 }
1925
1926 None
1927}
1928
1929fn open_web(
1934 store: &Store,
1935 hit: &crate::search::Hit,
1936 current: Option<i64>,
1937 root: Option<&std::path::Path>,
1938) -> ExitCode {
1939 if hit.repo_identity.starts_with("local:") {
1940 return fail(
1941 Output::Text,
1942 Failure::NoRemote,
1943 format_args!(
1944 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1945 locally with -o, or add one with `git remote add origin <url>`",
1946 hit.file, hit.line, hit.repo_identity
1947 ),
1948 );
1949 }
1950 let here =
1951 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1952 let rev = root
1953 .filter(|_| here)
1954 .and_then(crate::index::pushed_head)
1955 .unwrap_or_else(|| "HEAD".into());
1956 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1957
1958 use std::os::unix::process::CommandExt;
1959 let browser = std::env::var("BROWSER")
1960 .ok()
1961 .filter(|b| !b.is_empty())
1962 .or_else(|| {
1963 ["open", "xdg-open"]
1964 .into_iter()
1965 .find(|p| on_path(p))
1966 .map(str::to_string)
1967 });
1968 match browser {
1969 Some(prog) => {
1970 crate::profile::emit(false);
1972 let err = std::process::Command::new(&prog).arg(&url).exec();
1974 fail(
1975 Output::Text,
1976 Failure::Launch,
1977 format_args!("rq --web: cannot run {prog}: {err}"),
1978 )
1979 }
1980 None => {
1981 println!("{url}");
1982 ExitCode::SUCCESS
1983 }
1984 }
1985}
1986
1987fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1990 let path: String = file
1991 .bytes()
1992 .map(|b| match b {
1993 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1994 (b as char).to_string()
1995 }
1996 _ => format!("%{b:02X}"),
1997 })
1998 .collect();
1999 format!("https://{identity}/blob/{rev}/{path}#L{line}")
2000}
2001
2002fn on_path(prog: &str) -> bool {
2004 std::env::var_os("PATH")
2005 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
2006}
2007
2008const BRANCH_FILES_TTL_SECS: i64 = 15;
2014
2015const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
2019
2020const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
2032
2033fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
2035 let earned = cost_ms.map_or(0, |ms| {
2038 i64::try_from(ms)
2039 .unwrap_or(i64::MAX)
2040 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
2041 / 1000
2042 });
2043 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
2044}
2045
2046struct BranchRefresh {
2050 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
2053 identity: String,
2054 stamp: String,
2055}
2056
2057impl BranchRefresh {
2058 fn store(self, store: &Store) {
2060 let Ok((files, cost_ms)) = self.handle.join() else {
2061 return;
2062 };
2063 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
2064 }
2065}
2066
2067fn cached_branch_files(
2081 store: &Store,
2082 root: &std::path::Path,
2083) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
2084 let identity = resolve_identity(store, root);
2085 let stamp = crate::index::branch_files_stamp(root);
2086 let cached = store.branch_files_get(&identity).ok().flatten();
2087 let now = now_unix();
2088
2089 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
2090 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
2091 {
2092 return (hit.files.clone(), None, hit.cost_ms);
2093 }
2094 let owned_root = root.to_path_buf();
2095 let refresh = BranchRefresh {
2096 handle: std::thread::spawn(move || {
2097 let t = std::time::Instant::now();
2098 let files = crate::index::branch_changed_files(&owned_root);
2099 (files, t.elapsed().as_millis() as u64)
2100 }),
2101 identity,
2102 stamp: stamp.clone(),
2103 };
2104 return (hit.files.clone(), Some(refresh), hit.cost_ms);
2105 }
2106
2107 let t = std::time::Instant::now();
2109 let files = crate::index::branch_changed_files(root);
2110 let cost_ms = t.elapsed().as_millis() as u64;
2111 if let Some(stamp) = stamp {
2112 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
2113 }
2114 (files, None, Some(cost_ms))
2115}
2116
2117fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
2127 let head = indexed_head?;
2128 let _span = crate::profile::span("git: worktree changed?");
2129 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2130}
2131
2132fn changed_since_index(
2135 store: &Store,
2136 identity: Option<&str>,
2137 root: Option<&std::path::Path>,
2138 edits: Option<Vec<String>>,
2139) -> bool {
2140 let (Some(dirty), Some(root)) = (edits, root) else {
2141 return true;
2142 };
2143 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2144 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2145 None => !dirty.is_empty(),
2146 }
2147}
2148
2149enum Staleness {
2153 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2156 Deferred(PathBuf, Option<String>),
2160}
2161
2162#[allow(clippy::too_many_arguments)]
2169fn settle_warm(
2170 store: &Store,
2171 staleness: Option<Staleness>,
2172 hit: bool,
2173 was_warming: bool,
2174 warming_ok: bool,
2175 root: Option<&std::path::Path>,
2176 active: &[String],
2177 query: &str,
2178 budget: Duration,
2179 no_wait: bool,
2180 identity: Option<&str>,
2181) -> bool {
2182 let changed = match staleness {
2183 None => false,
2184 Some(Staleness::Deferred(r, head)) if hit => {
2187 if recently_verified(store, identity, &r, head.as_deref()) {
2188 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2189 } else {
2190 spawn_detached_warm(&r);
2191 }
2192 return false;
2193 }
2194 Some(Staleness::Deferred(r, head)) => {
2195 let _span = crate::profile::span("after: staleness check");
2196 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2197 }
2198 Some(Staleness::Running(h)) => {
2199 let mut span = crate::profile::span("after: staleness wait");
2201 let changed = h.join().map_or(true, |edits| {
2204 changed_since_index(store, identity, root, edits)
2205 });
2206 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2207 changed
2208 }
2209 };
2210 if changed
2220 && !no_wait
2221 && !warm_detach_enabled()
2222 && let Some(r) = root
2223 && let Ok(mut idx) = open_store()
2224 {
2225 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2226 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2227 }
2228 maybe_detach_warm(
2229 store,
2230 warming_ok && (was_warming || changed),
2231 changed,
2232 root,
2233 identity,
2234 );
2235 changed && warm_detach_enabled()
2239}
2240
2241fn answer_warm_budget() -> Duration {
2250 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2251}
2252
2253fn deferred_warm_budget() -> Duration {
2256 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2257}
2258
2259fn live_fallback_budget() -> Duration {
2262 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2263}
2264
2265fn warm_bg_budget() -> Duration {
2268 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2269}
2270
2271fn warm_detach_enabled() -> bool {
2275 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2276}
2277
2278fn wait_budget() -> Duration {
2287 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2288}
2289
2290fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2295 let s = s.trim();
2296 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2297 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2299 (n, 1.0)
2300 } else if let Some(n) = s.strip_suffix('s') {
2301 (n, 1_000.0)
2302 } else if let Some(n) = s.strip_suffix('m') {
2303 (n, 60_000.0)
2304 } else {
2305 (s, 1_000.0)
2307 };
2308 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2309 if !val.is_finite() || val < 0.0 {
2310 return Err(bad());
2311 }
2312 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2313}
2314
2315static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2319
2320extern "C" fn on_sigint(_: libc::c_int) {
2321 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2323}
2324
2325fn install_interrupt_handler() {
2328 static ONCE: std::sync::Once = std::sync::Once::new();
2329 ONCE.call_once(|| unsafe {
2330 let mut action: libc::sigaction = std::mem::zeroed();
2331 action.sa_sigaction = on_sigint as *const () as usize;
2332 libc::sigemptyset(&mut action.sa_mask);
2333 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2334 });
2335}
2336
2337fn stderr_interactive() -> bool {
2341 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2342}
2343
2344fn show_progress(out: Output, interactive: bool) -> bool {
2349 interactive && matches!(out, Output::Text)
2350}
2351
2352fn repo_label(root: Option<&std::path::Path>) -> String {
2355 root.and_then(|r| r.file_name())
2356 .map(|n| n.to_string_lossy().into_owned())
2357 .unwrap_or_else(|| "repo".into())
2358}
2359
2360fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2364 let files = identity
2365 .and_then(|id| store.repository_id(id).ok().flatten())
2366 .and_then(|rid| store.repo_totals(rid).ok())
2367 .map_or(0, |(f, _)| f);
2368 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2369 let _ = std::io::stderr().flush();
2370}
2371
2372fn clear_progress() {
2374 eprint!("\r\x1b[K");
2375 let _ = std::io::stderr().flush();
2376}
2377
2378fn env_budget(var: &str, default_ms: u64) -> Duration {
2382 let ms = std::env::var(var)
2383 .ok()
2384 .and_then(|v| v.parse().ok())
2385 .unwrap_or(default_ms);
2386 Duration::from_millis(ms)
2387}
2388
2389#[derive(Clone, Copy)]
2391struct Here<'a> {
2392 identity: &'a str,
2393 root: &'a std::path::Path,
2394}
2395
2396fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2402 let mut roots: Vec<PathBuf> = here
2403 .filter(|h| h.identity == repo_identity)
2404 .map(|h| h.root.to_path_buf())
2405 .into_iter()
2406 .collect();
2407 let recorded = store
2408 .repository_id(repo_identity)
2409 .ok()
2410 .flatten()
2411 .map(|id| store.checkout_roots(id).unwrap_or_default())
2412 .unwrap_or_default();
2413 for root in recorded.into_iter().map(PathBuf::from) {
2414 if !roots.contains(&root) {
2415 roots.push(root);
2416 }
2417 }
2418 roots
2419}
2420
2421fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2425 let mut roots = hit_file_roots(store, repo_identity, here);
2426 let at = roots
2427 .iter()
2428 .position(|r| r.join(file).is_file())
2429 .unwrap_or(0);
2430 (at < roots.len()).then(|| roots.swap_remove(at))
2431}
2432
2433fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2435 let src = std::fs::read_to_string(path).ok()?;
2436 signature_in(&src.lines().collect::<Vec<_>>(), line)
2437}
2438
2439const SHOW_CONFIDENCE: f64 = 0.85;
2443
2444fn show_top_definition(
2448 hits: &mut [crate::search::Hit],
2449 query: &str,
2450 out: Output,
2451) -> Option<ExitCode> {
2452 let top = hits.first()?;
2453 if top.confidence < SHOW_CONFIDENCE {
2454 return None; }
2456 let end = top.end_line.unwrap_or(top.line);
2457 let body = top.root.as_deref().and_then(|root| {
2458 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2459 span_in(&src, top.line, end)
2460 });
2461 hits[0].body = body;
2462 let top = &hits[0];
2463 let code = match out {
2464 Output::Json | Output::Ndjson => {
2465 emit_json(out, top)
2467 }
2468 Output::Text => {
2469 let color = match_color();
2470 let c = color.as_deref();
2471 let name = hl(&top.name, query, c);
2472 let qualified = match &top.parent {
2473 Some(p) => format!("{name} · {p}"),
2474 None => name,
2475 };
2476 println!(
2477 "{}:{} {} {}",
2478 hl_path(&top.file, query, c),
2479 top.line,
2480 top.kind,
2481 qualified
2482 );
2483 match (&top.body, &top.signature) {
2484 (Some(body), _) => println!("{body}"),
2485 (None, Some(sig)) => println!("{sig}"),
2487 (None, None) => {}
2488 }
2489 ExitCode::SUCCESS
2490 }
2491 };
2492
2493 Some(code)
2494}
2495
2496fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2499 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2500 let lines: Vec<&str> = content.lines().collect();
2501 if s >= lines.len() {
2502 return None;
2503 }
2504 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2505 Some(lines[s..e].join("\n"))
2506}
2507
2508fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2513 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2514 let l = lines.get(idx)?.trim();
2515 (!l.is_empty()).then(|| l.to_string())
2516}
2517
2518#[derive(serde::Serialize)]
2522struct SymbolOut {
2523 name: String,
2524 kind: String,
2525 language: String,
2526 file: String,
2527 root: String,
2529 line: i64,
2530 #[serde(skip_serializing_if = "Option::is_none")]
2531 end_line: Option<i64>,
2532 #[serde(skip_serializing_if = "Option::is_none")]
2533 parent: Option<String>,
2534 #[serde(skip_serializing_if = "Option::is_none")]
2535 visibility: Option<String>,
2536 repo: String,
2537 #[serde(skip_serializing_if = "Option::is_none")]
2538 signature: Option<String>,
2539}
2540
2541fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2546 let open_span = crate::profile::span("store open");
2547 let mut store = match open_store() {
2548 Ok(s) => s,
2549 Err(e) => {
2550 return fail(
2551 out,
2552 Failure::Database,
2553 format_args!("rq: cannot open database: {e}"),
2554 );
2555 }
2556 };
2557 drop(open_span);
2558 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2559 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2560 let rel = repo_relative(&root, &cwd, file_arg);
2561
2562 let identity = resolve_identity(&store, &root);
2563 let coverage = store.coverage_status(&identity).ok().flatten();
2564 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2565 let current = store.repository_id(&identity).ok().flatten();
2566 let path = root.join(&rel);
2567 if !path.is_file() {
2568 return fail(
2569 out,
2570 Failure::NotFound,
2571 format_args!("rq --symbols: no such file: {file_arg}"),
2572 );
2573 }
2574 let indexable = path
2575 .extension()
2576 .and_then(|e| e.to_str())
2577 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2578 match current {
2579 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2583 if indexable {
2584 let _span = crate::profile::span("symbols: refresh");
2585 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2586 }
2587 }
2588 _ if warming_ok => {
2592 let budget = answer_warm_budget() + deferred_warm_budget();
2593 let _span = crate::profile::span("symbols: warm");
2594 let active = [rel.clone()];
2595 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2596 }
2597 _ => {}
2598 }
2599
2600 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2601 return emit_symbols(out, &[]); };
2603 let mut query_span = crate::profile::span("symbols: query");
2604 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2605 Ok(r) => r,
2606 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2607 };
2608 query_span.note(|| format!("{} rows", rows.len()));
2609 drop(query_span);
2610 if !kinds.is_empty() {
2611 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2612 }
2613 if !langs.is_empty() {
2614 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2615 }
2616
2617 let signatures_span = crate::profile::span("symbols: signatures");
2620 let content = std::fs::read_to_string(&path).ok();
2621 let lines: Vec<&str> = content
2622 .as_deref()
2623 .map_or_else(Vec::new, |c| c.lines().collect());
2624 let root_str = root.to_string_lossy().into_owned();
2625 let syms: Vec<SymbolOut> = rows
2626 .into_iter()
2627 .map(|r| SymbolOut {
2628 signature: signature_in(&lines, r.line),
2629 name: r.name,
2630 kind: r.kind,
2631 language: r.language,
2632 file: r.file,
2633 root: root_str.clone(),
2634 line: r.line,
2635 end_line: r.end_line,
2636 parent: r.parent,
2637 visibility: r.visibility,
2638 repo: r.repo_identity,
2639 })
2640 .collect();
2641 drop(signatures_span);
2642 let _span = crate::profile::span("render");
2643 emit_symbols(out, &syms)
2644}
2645
2646fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2649 if syms.is_empty() {
2650 match out {
2651 Output::Json | Output::Ndjson => {
2652 let obj = serde_json::json!({ "status": "no_match" });
2653 let _ = emit_json(out, &obj); }
2655 Output::Text => eprintln!("no symbols"),
2656 }
2657 return ExitCode::FAILURE;
2658 }
2659 if let Some(code) = emit_rows(out, syms) {
2660 return code;
2661 }
2662 match out {
2663 Output::Json | Output::Ndjson => {}
2664 Output::Text => {
2665 for s in syms {
2666 let qualified = match &s.parent {
2667 Some(p) => format!("{} · {p}", s.name),
2668 None => s.name.clone(),
2669 };
2670 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2671 if let Some(sig) = &s.signature {
2672 println!(" {sig}");
2673 }
2674 }
2675 }
2676 }
2677 ExitCode::SUCCESS
2678}
2679
2680fn keyword_kind(token: &str) -> Option<&'static [&'static str]> {
2685 match token.to_ascii_lowercase().as_str() {
2686 "class" | "module" | "method" | "function" | "fn" | "struct" | "type" | "enum"
2687 | "trait" | "interface" | "constant" | "const" | "macro" | "variant" => {
2688 canonical_kind(token)
2689 }
2690 _ => None,
2691 }
2692}
2693
2694fn split_kind_keyword(
2700 target: String,
2701 dirs: Vec<String>,
2702) -> (Option<&'static [&'static str]>, String, Vec<String>) {
2703 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2706 let rest = rest.trim();
2707 if let Some(k) = keyword_kind(head)
2708 && !rest.is_empty()
2709 {
2710 return (Some(k), rest.to_string(), dirs);
2711 }
2712 } else if let Some(k) = keyword_kind(&target)
2713 && let Some((query, extra)) = dirs.split_first()
2714 {
2715 return (Some(k), query.clone(), extra.to_vec());
2717 }
2718 (None, target, dirs)
2719}
2720
2721fn canonical_kind(s: &str) -> Option<&'static [&'static str]> {
2724 Some(match s.to_ascii_lowercase().as_str() {
2725 "c" | "class" => &["class"],
2726 "m" | "method" => &["method"],
2727 "f" | "fn" | "func" | "function" => &["function"],
2728 "mod" | "module" => &["module"],
2729 "s" | "struct" => &["struct"],
2730 "type" => &["type", "struct"],
2733 "alias" | "type_alias" => &["type"],
2734 "e" | "enum" => &["enum"],
2735 "t" | "trait" | "interface" => &["trait"],
2736 "const" | "constant" => &["constant"],
2737 "macro" | "macro_rules" => &["macro"],
2738 "v" | "variant" | "member" | "enum_member" => &["variant"],
2739 _ => return None,
2740 })
2741}
2742
2743fn canonical_langs(s: &str) -> Vec<String> {
2750 let t = s.to_ascii_lowercase();
2751 let alias = match t.as_str() {
2752 "rb" => Some("ruby"),
2753 "rs" => Some("rust"),
2754 "golang" => Some("go"),
2755 "ts" | "tsx" => Some("typescript"),
2756 "js" | "jsx" => Some("javascript"),
2757 _ => None,
2758 };
2759 let matched: Vec<String> = crate::lang::languages()
2760 .into_iter()
2761 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2762 .map(str::to_string)
2763 .collect();
2764 matched
2765}
2766
2767fn match_color() -> Option<String> {
2771 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2772 return None;
2773 }
2774 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2775 gc.split(':').find_map(|e| {
2776 e.strip_prefix("mt=")
2777 .or_else(|| e.strip_prefix("ms="))
2778 .filter(|v| !v.is_empty())
2779 .map(str::to_string)
2780 })
2781 });
2782 Some(style.unwrap_or_else(|| "1;31".to_string()))
2783}
2784
2785fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2788 match color {
2789 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2790 None => text.to_string(),
2791 }
2792}
2793
2794fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2797 let Some(c) = color else {
2798 return path.to_string();
2799 };
2800 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2801 let base_start = path[..base_byte].chars().count();
2802 let stem = crate::search::path_stem(path);
2806 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2807 .into_iter()
2808 .map(|p| p + base_start)
2809 .collect();
2810 highlight(path, &positions, c)
2811}
2812
2813fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2816 if positions.is_empty() {
2817 return text.to_string();
2818 }
2819 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2820 let mut out = String::new();
2821 let mut on = false;
2822 for (i, c) in text.chars().enumerate() {
2823 match (matched.contains(&i), on) {
2824 (true, false) => {
2825 out.push_str("\x1b[");
2826 out.push_str(color);
2827 out.push('m');
2828 on = true;
2829 }
2830 (false, true) => {
2831 out.push_str("\x1b[0m");
2832 on = false;
2833 }
2834 _ => {}
2835 }
2836 out.push(c);
2837 }
2838 if on {
2839 out.push_str("\x1b[0m");
2840 }
2841 out
2842}
2843
2844fn under_any(file: &str, paths: &[String]) -> bool {
2848 paths.iter().any(|p| {
2849 let p = p.trim_start_matches("./").trim_end_matches('/');
2850 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2851 })
2852}
2853
2854fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2856 let p = std::path::Path::new(file);
2857 let abs = if p.is_absolute() {
2858 p.to_path_buf()
2859 } else {
2860 cwd.join(p)
2861 };
2862 let abs = abs.canonicalize().unwrap_or(abs);
2863 abs.strip_prefix(root)
2864 .map(|r| r.to_string_lossy().into_owned())
2865 .unwrap_or_else(|_| file.to_string())
2866}
2867
2868fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2873 use std::collections::HashSet;
2874 let mut seen = HashSet::new();
2875 let mut changed = false;
2876 for hit in hits {
2877 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2878 continue;
2879 }
2880 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2881 continue;
2882 };
2883 let roots = hit_file_roots(store, &hit.repo_identity, here);
2884 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2885 continue;
2886 };
2887 if let Ok(crate::index::Refresh::Updated) =
2888 crate::index::refresh_file(store, repo_id, root, &hit.file)
2889 {
2890 changed = true;
2891 }
2892 }
2893 changed
2894}
2895
2896fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2902 if let Ok(canon) = cwd.canonicalize() {
2903 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2904 return identity;
2905 }
2906 if crate::index::repo_root(cwd).is_none() {
2907 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2908 }
2909 }
2910 crate::index::detect_identity(cwd).to_string()
2911}
2912
2913fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2914 let explicit = path.is_some();
2915 let target = path.unwrap_or_else(|| PathBuf::from("."));
2916 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2921 let mut subdirs = subdirs.to_vec();
2926 if explicit
2927 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2928 && t != r
2929 && let Ok(rel) = t.strip_prefix(&r)
2930 && !rel.as_os_str().is_empty()
2931 {
2932 subdirs.push(rel.to_string_lossy().into_owned());
2933 }
2934 let open_span = crate::profile::span("store open");
2935 let mut store = match open_store() {
2936 Ok(s) => s,
2937 Err(e) => {
2938 return fail(
2939 out,
2940 Failure::Database,
2941 format_args!("rq: cannot open database: {e}"),
2942 );
2943 }
2944 };
2945 drop(open_span);
2946 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2947 let identity = resolve_identity(&store, &root);
2950 match indexed {
2951 Ok(stats) => {
2952 let subtree = !subdirs.is_empty();
2953 let totals = store
2955 .repository_id(&identity)
2956 .ok()
2957 .flatten()
2958 .and_then(|id| store.repo_totals(id).ok());
2959 match out {
2960 Output::Json | Output::Ndjson => {
2961 let (files, symbols) = match totals {
2962 Some((f, s)) => (Some(f), Some(s)),
2963 None => (None, None),
2964 };
2965 return emit_json(
2966 out,
2967 &serde_json::json!({
2968 "repo": identity,
2969 "scope": if subtree { "subtree" } else { "full" },
2970 "files_added": stats.files_indexed,
2971 "symbols_added": stats.symbols,
2972 "files": files,
2973 "symbols": symbols,
2974 }),
2975 );
2976 }
2977 Output::Text => {
2978 let scope = if subtree { " (subtree seed)" } else { "" };
2979 match totals {
2980 Some((files, symbols)) => println!(
2981 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2982 stats.files_indexed, stats.symbols
2983 ),
2984 None => println!(
2985 "{} file(s)/{} symbol(s) added this run{scope}",
2986 stats.files_indexed, stats.symbols
2987 ),
2988 }
2989 }
2990 }
2991 ExitCode::SUCCESS
2992 }
2993 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2994 }
2995}
2996
2997fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2998 let mut store = match open_store() {
2999 Ok(s) => s,
3000 Err(e) => {
3001 return fail(
3002 out,
3003 Failure::Database,
3004 format_args!("rq: cannot open database: {e}"),
3005 );
3006 }
3007 };
3008
3009 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
3013 let root = crate::index::repo_root(&path).unwrap_or(path);
3014 let from_path = crate::index::detect_identity(&root).to_string();
3015 let resolved = match store.repository_id(&from_path) {
3016 Ok(Some(id)) => Some((from_path.clone(), id)),
3017 Ok(None) => target.as_deref().and_then(|s| {
3018 store
3019 .repository_id(s)
3020 .ok()
3021 .flatten()
3022 .map(|id| (s.to_string(), id))
3023 }),
3024 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
3025 };
3026
3027 let Some((identity, repo_id)) = resolved else {
3028 return match out {
3030 Output::Text => {
3031 println!("not indexed: {from_path}");
3032 ExitCode::SUCCESS
3033 }
3034 _ => emit_json(
3035 out,
3036 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
3037 ),
3038 };
3039 };
3040
3041 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
3042 match store.drop_repository(repo_id) {
3043 Ok(()) => match out {
3044 Output::Text => {
3045 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
3046 ExitCode::SUCCESS
3047 }
3048 _ => emit_json(
3049 out,
3050 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
3051 ),
3052 },
3053 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
3054 }
3055}
3056
3057fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
3061 let rendered = if out == Output::Json {
3062 serde_json::to_string_pretty(value)
3063 } else {
3064 serde_json::to_string(value)
3065 };
3066 match rendered {
3067 Ok(s) => {
3068 println!("{s}");
3069 ExitCode::SUCCESS
3070 }
3071 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
3072 }
3073}
3074
3075fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
3079 match out {
3080 Output::Json => match serde_json::to_string_pretty(rows) {
3081 Ok(s) => println!("{s}"),
3082 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3083 },
3084 Output::Ndjson => {
3085 for r in rows {
3086 match serde_json::to_string(r) {
3087 Ok(line) => println!("{line}"),
3088 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3089 }
3090 }
3091 }
3092 Output::Text => {}
3093 }
3094 None
3095}
3096
3097fn cmd_status(out: Output) -> ExitCode {
3098 let store = match open_store() {
3099 Ok(s) => s,
3100 Err(e) => {
3101 return fail(
3102 out,
3103 Failure::Database,
3104 format_args!("rq: cannot open database: {e}"),
3105 );
3106 }
3107 };
3108 let rows = match store.coverage_overview() {
3109 Ok(rows) => rows,
3110 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
3111 };
3112 if let Some(code) = emit_rows(out, &rows) {
3113 return code;
3114 }
3115 match out {
3116 Output::Json | Output::Ndjson => {}
3117 Output::Text if rows.is_empty() => {
3118 println!("no repositories indexed yet (try `rq --index`)");
3119 }
3120 Output::Text => {
3121 for r in &rows {
3122 println!(
3123 "{:<10} {:>6} files {:>7} symbols {}",
3124 r.status, r.files, r.symbols, r.identity
3125 );
3126 }
3127 }
3128 }
3129 ExitCode::SUCCESS
3130}
3131
3132fn cmd_usage(out: Output) -> ExitCode {
3135 let store = match open_store() {
3136 Ok(s) => s,
3137 Err(e) => {
3138 return fail(
3139 out,
3140 Failure::Database,
3141 format_args!("rq: cannot open database: {e}"),
3142 );
3143 }
3144 };
3145 let rows = match store.usage_overview() {
3146 Ok(rows) => rows,
3147 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3148 };
3149 if let Some(code) = emit_rows(out, &rows) {
3150 return code;
3151 }
3152 match out {
3153 Output::Json | Output::Ndjson => {}
3154 Output::Text if rows.is_empty() => {
3155 println!("no usage recorded yet");
3156 }
3157 Output::Text => {
3158 println!(
3161 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3162 "day", "caller", "found", "missed", "warming"
3163 );
3164 for r in &rows {
3165 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3166 println!(
3167 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3168 r.day,
3169 r.source,
3170 r.searches - r.misses - r.warming,
3171 r.misses,
3172 r.warming,
3173 flags
3174 );
3175 }
3176 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3177 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3178 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3179 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3180 let live: i64 = rows.iter().map(|r| r.live).sum();
3181 let plural = if searches == 1 { "search" } else { "searches" };
3182 let live = if live > 0 {
3184 format!(" · {live} from a live scan")
3185 } else {
3186 String::new()
3187 };
3188 println!(
3191 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index{live}"
3192 );
3193 }
3194 }
3195 if rows.is_empty() {
3198 return ExitCode::from(1);
3199 }
3200 ExitCode::SUCCESS
3201}
3202
3203fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3205 let path = db_path()?;
3206 if let Some(parent) = path.parent() {
3207 std::fs::create_dir_all(parent)?;
3208 }
3209 Ok(Store::open(&path)?)
3210}
3211
3212fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3214 Ok(db_location()?)
3215}
3216
3217fn db_location() -> Result<PathBuf, String> {
3221 let var = |name| std::env::var_os(name).filter(|v| !v.is_empty());
3222 db_location_from(var("RQ_DB"), var("HOME"))
3223}
3224
3225fn db_location_from(
3226 rq_db: Option<std::ffi::OsString>,
3227 home: Option<std::ffi::OsString>,
3228) -> Result<PathBuf, String> {
3229 if let Some(db) = rq_db {
3230 let shown = db.to_string_lossy();
3231 if std::path::Path::new(&db).is_relative() {
3232 let hint = match shown.strip_prefix("~/") {
3234 Some(rest) => format!("$HOME/{rest}"),
3235 None => format!("$PWD/{shown}"),
3236 };
3237 return Err(format!(
3238 "rq: RQ_DB must be an absolute path, not {shown:?} (e.g. RQ_DB=\"{hint}\")"
3239 ));
3240 }
3241 if shown.ends_with('/') || std::path::Path::new(&db).is_dir() {
3242 return Err(format!(
3243 "rq: RQ_DB names a directory, not a database file: {shown:?} (e.g. RQ_DB=\"{}/rq.db\")",
3244 shown.trim_end_matches('/')
3245 ));
3246 }
3247 return Ok(PathBuf::from(db));
3248 }
3249 let Some(home) = home else {
3250 return Err(
3251 "rq: HOME is not set, so there's no default index path; set HOME, or RQ_DB to an absolute path".into(),
3252 );
3253 };
3254 if std::path::Path::new(&home).is_relative() {
3255 return Err(format!(
3256 "rq: HOME is a relative path ({:?}), so the default index would move with the working directory; set RQ_DB to an absolute path",
3257 home.to_string_lossy()
3258 ));
3259 }
3260 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3261}
3262
3263#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3265enum Failure {
3266 Usage,
3268 Database,
3270 NotFound,
3272 NoRemote,
3274 Launch,
3276 Index,
3278 Internal,
3280}
3281
3282impl Failure {
3283 fn as_str(self) -> &'static str {
3284 match self {
3285 Failure::Usage => "usage",
3286 Failure::Database => "database",
3287 Failure::NotFound => "not_found",
3288 Failure::NoRemote => "no_remote",
3289 Failure::Launch => "launch",
3290 Failure::Index => "index",
3291 Failure::Internal => "internal",
3292 }
3293 }
3294
3295 fn exit_code(self) -> u8 {
3299 match self {
3300 Failure::Usage => 64, Failure::NotFound => 66, Failure::NoRemote | Failure::Launch => 69, Failure::Internal => 70, Failure::Database | Failure::Index => 74, }
3306 }
3307}
3308
3309fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3312 let message = args.to_string();
3313 eprintln!("{message}");
3314 emit_error(out, kind, &message);
3315 ExitCode::from(kind.exit_code())
3316}
3317
3318fn emit_error(out: Output, kind: Failure, message: &str) {
3321 let obj = serde_json::json!({
3322 "error": message,
3323 "kind": kind.as_str(),
3324 "code": kind.exit_code(),
3325 });
3326 let rendered = match out {
3328 Output::Text => return,
3329 Output::Json => serde_json::to_string_pretty(&obj),
3330 Output::Ndjson => serde_json::to_string(&obj),
3331 };
3332 if let Ok(s) = rendered {
3333 println!("{s}");
3334 }
3335}
3336
3337fn clap_failure(err: clap::Error) -> ExitCode {
3342 if !err.use_stderr() {
3344 err.exit();
3345 }
3346 let _ = err.print();
3347 let out = requested_output(std::env::args_os().skip(1));
3348 let text = err.to_string();
3349 emit_error(out, Failure::Usage, text.lines().next().unwrap_or(""));
3350 ExitCode::from(Failure::Usage.exit_code())
3351}
3352
3353fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3358 let cmd = Cli::command();
3359 let takes_value = |c: char| {
3360 cmd.get_arguments()
3361 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3362 };
3363 let (mut json, mut ndjson) = (false, false);
3364 for arg in args {
3365 let arg = arg.to_string_lossy();
3366 match arg.as_ref() {
3367 "--" => break,
3368 "--json" => json = true,
3369 "--ndjson" => ndjson = true,
3370 a if a.starts_with('-') && !a.starts_with("--") => {
3371 for c in a.chars().skip(1) {
3372 match c {
3373 'j' => json = true,
3374 'J' => ndjson = true,
3375 c if takes_value(c) => break,
3376 _ => {}
3377 }
3378 }
3379 }
3380 _ => {}
3381 }
3382 }
3383 if ndjson {
3385 Output::Ndjson
3386 } else if json {
3387 Output::Json
3388 } else {
3389 Output::Text
3390 }
3391}
3392
3393#[cfg(test)]
3394mod tests {
3395 use super::*;
3396
3397 #[test]
3398 fn every_help_example_parses() {
3399 let help = Cli::command().render_long_help().to_string();
3400 let examples = help
3401 .split("EXAMPLES:\n")
3402 .nth(1)
3403 .and_then(|rest| rest.split("\n\n").next())
3404 .expect("--help has an EXAMPLES section");
3405 let mut seen = 0;
3406 for line in examples.lines() {
3407 let command = line.trim().split(" ").next().unwrap().trim();
3409 let args: Vec<&str> = command
3410 .split_whitespace()
3411 .map(|w| w.trim_matches('\''))
3412 .collect();
3413 assert_eq!(args[0], "rq", "an example starts with rq: {line}");
3414 if let Err(e) = Cli::try_parse_from(&args) {
3415 panic!("example doesn't parse: {line}\n{e}");
3416 }
3417 seen += 1;
3418 }
3419 assert!(seen > 5, "found the examples: {examples}");
3420 }
3421
3422 #[test]
3423 fn the_database_path_is_refused_unless_it_is_an_absolute_file() {
3424 let at = |db: Option<&str>, home: Option<&str>| {
3425 db_location_from(db.map(Into::into), home.map(Into::into))
3426 };
3427 assert_eq!(
3428 at(None, Some("/home/a")).unwrap(),
3429 PathBuf::from("/home/a/.local/share/rq/rq.db")
3430 );
3431 assert_eq!(
3432 at(Some("/x/rq.db"), None).unwrap(),
3433 PathBuf::from("/x/rq.db")
3434 );
3435
3436 let refused = |db, home| at(db, home).unwrap_err();
3437 assert!(refused(Some("~/x.db"), None).contains("RQ_DB=\"$HOME/x.db\""));
3438 assert!(refused(Some("x.db"), None).contains("RQ_DB=\"$PWD/x.db\""));
3439 assert!(refused(Some("/x/"), None).contains("RQ_DB=\"/x/rq.db\""));
3440 assert!(refused(None, None).contains("HOME is not set"));
3441 assert!(refused(None, Some("home/a")).contains("HOME is a relative path"));
3442 }
3443
3444 #[test]
3445 fn parses_an_anchor_from_the_right() {
3446 let ok = |file: &str, line| {
3447 Ok(AnchorSpec {
3448 file: PathBuf::from(file),
3449 line,
3450 })
3451 };
3452 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3453 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3454 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3455 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3456 assert!(parse_anchor(bad).is_err(), "{bad}");
3457 }
3458 }
3459
3460 #[test]
3461 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3462 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3463 for (args, want) in [
3464 (&["x", "--json"][..], Output::Json),
3465 (&["x", "--ndjson"], Output::Ndjson),
3466 (&["x", "-ej"], Output::Json),
3467 (&["-Je", "x"], Output::Ndjson),
3468 (&["x", "--json", "-J"], Output::Ndjson),
3469 (&["x"], Output::Text),
3470 (&["x", "-xj"], Output::Text),
3472 (&["x", "-l5j"], Output::Text),
3473 (&["--", "-j"], Output::Text),
3475 (&["x", "--jobs", "2"], Output::Text),
3476 ] {
3477 assert!(mode(args) == want, "{args:?}");
3478 }
3479 }
3480
3481 #[test]
3482 fn open_menu_choice_parsing() {
3483 assert_eq!(parse_choice("\n", 5), Some(0));
3485 assert_eq!(parse_choice(" ", 5), Some(0));
3486 assert_eq!(parse_choice("3", 5), Some(2));
3487 assert_eq!(parse_choice("5", 5), Some(4));
3488 assert_eq!(parse_choice("6", 5), None);
3490 assert_eq!(parse_choice("0", 5), None);
3491 assert_eq!(parse_choice("q", 5), None);
3492 }
3493
3494 #[test]
3495 fn web_url_shape() {
3496 assert_eq!(
3497 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3498 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3499 );
3500 }
3501
3502 #[test]
3503 fn wait_duration_parsing() {
3504 use std::time::Duration;
3505 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3507 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3508 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3509 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3510 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3512 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3513 assert!(parse_wait("0s").unwrap().is_zero());
3514 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3516 assert!(parse_wait("2x").is_err());
3518 assert!(parse_wait("").is_err());
3519 assert!(parse_wait("s").is_err());
3520 assert!(parse_wait("-1s").is_err());
3521 }
3522
3523 #[test]
3524 fn leading_kind_keyword_becomes_a_kind_filter() {
3525 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3526 assert_eq!(
3528 split_kind_keyword("class".into(), d(&["Widget"])),
3529 (Some(&["class"][..]), "Widget".into(), vec![])
3530 );
3531 assert_eq!(
3533 split_kind_keyword("method zoom".into(), vec![]),
3534 (Some(&["method"][..]), "zoom".into(), vec![])
3535 );
3536 assert_eq!(
3538 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3539 (Some(&["function"][..]), "Foo::run".into(), vec![])
3540 );
3541 assert_eq!(
3543 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3544 (Some(&["struct"][..]), "Gadget".into(), d(&["src"]))
3545 );
3546 }
3547
3548 #[test]
3549 fn a_bare_or_non_keyword_query_is_left_alone() {
3550 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3551 assert_eq!(
3553 split_kind_keyword("class".into(), vec![]),
3554 (None, "class".into(), vec![])
3555 );
3556 assert_eq!(
3558 split_kind_keyword("Widget".into(), d(&["app"])),
3559 (None, "Widget".into(), d(&["app"]))
3560 );
3561 assert_eq!(
3563 split_kind_keyword("c".into(), d(&["Foo"])),
3564 (None, "c".into(), d(&["Foo"]))
3565 );
3566 }
3567
3568 #[test]
3569 fn the_branch_window_scales_with_what_the_refresh_costs() {
3570 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3573 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3574 assert_eq!(branch_files_ttl(Some(700)), 70);
3578 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3579 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3582 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3583 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3585 }
3586
3587 #[test]
3588 fn a_language_selects_by_prefix_or_alias() {
3589 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3591 assert_eq!(canonical_langs("t"), ["typescript"]);
3592 assert_eq!(canonical_langs("ts"), ["typescript"]);
3594 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3595 assert_eq!(canonical_langs("rb"), ["ruby"]);
3596 assert!(canonical_langs("COBOL").is_empty());
3599 }
3600
3601 #[test]
3602 fn a_kind_normalizes_language_specific_spellings() {
3603 assert_eq!(canonical_kind("f"), Some(&["function"][..]));
3604 assert_eq!(canonical_kind("interface"), Some(&["trait"][..]));
3606 assert_eq!(canonical_kind("type"), Some(&["type", "struct"][..]));
3608 assert_eq!(canonical_kind("alias"), Some(&["type"][..]));
3609 assert_eq!(canonical_kind("member"), Some(&["variant"][..]));
3610 assert_eq!(canonical_kind("macro"), Some(&["macro"][..]));
3611 assert_eq!(canonical_kind("const"), Some(&["constant"][..]));
3612 assert_eq!(canonical_kind("banana"), None);
3613 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3615 assert_eq!(
3616 split_kind_keyword("interface".into(), d(&["Renderer"])),
3617 (Some(&["trait"][..]), "Renderer".into(), vec![])
3618 );
3619 }
3620
3621 #[test]
3622 fn highlight_wraps_matched_runs() {
3623 assert_eq!(
3624 highlight("FooThing", &[0, 1, 2], "1;31"),
3625 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3626 );
3627 assert_eq!(
3629 highlight("FooThing", &[0, 3], "1"),
3630 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3631 );
3632 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3634 }
3635
3636 #[test]
3637 fn progress_ui_only_for_an_interactive_text_terminal() {
3638 assert!(show_progress(Output::Text, true));
3640
3641 assert!(!show_progress(Output::Json, true));
3643 assert!(!show_progress(Output::Ndjson, true));
3644
3645 assert!(!show_progress(Output::Text, false));
3647 }
3648
3649 #[test]
3650 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3651 let lines = ["class Widget", "", " def go"];
3652 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3653 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3654 assert_eq!(signature_in(&lines, 2), None, "blank line");
3655 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3656 assert_eq!(signature_in(&lines, 4), None, "past the end");
3657 }
3658
3659 #[test]
3660 fn repo_label_uses_the_directory_name() {
3661 assert_eq!(
3662 repo_label(Some(std::path::Path::new("/src/widgets"))),
3663 "widgets"
3664 );
3665 assert_eq!(repo_label(None), "repo");
3666 }
3667
3668 #[test]
3669 fn hl_path_highlights_the_stem_not_the_extension() {
3670 let out = hl_path(
3673 "app/employees_controller.rb",
3674 "employeescontroller",
3675 Some("1;31"),
3676 );
3677 assert!(
3678 out.starts_with("app/\u{1b}[1;31memployees"),
3679 "stem highlighted: {out:?}"
3680 );
3681 assert!(
3682 out.ends_with("controller\u{1b}[0m.rb"),
3683 "`.rb` left un-highlighted: {out:?}"
3684 );
3685 }
3686}