1use std::collections::HashSet;
4use std::io::{IsTerminal, Write};
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::time::Duration;
8
9use clap::{CommandFactory, Parser};
10use clap_complete::Shell;
11
12use crate::core::now_unix;
13use crate::store::Store;
14
15#[derive(Parser)]
19#[command(
20 name = "rq",
21 version,
22 about = "Ranked definition lookup — the one place a symbol is defined, first.",
23 long_about = "rq finds where a symbol is defined and ranks the one you most \
24likely meant to the top — not every match.\n\n\
25Search is the default action; operations are flags, not subcommands, so every \
26word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
27your current repo, recently-active files, and the files your branch changes. Run \
28`rq <query> --explain` to see the score behind each result.",
29 after_help = "EXAMPLES:\n \
30rq thing search for a definition named or like \"thing\"\n \
31rq wibble --explain same, plus the score behind each result\n \
32rq thing --json machine-readable results (for editors/agents)\n \
33rq thing --no-wait answer now from the committed index; don't block on a rebuild\n \
34rq thing --wait 2s ...or wait up to a bounded time for the index to warm\n \
35rq thing app/web restrict to a directory (rg-style)\n \
36rq perform -k method restrict to a symbol kind (c/mod/m/f/s/e/t)\n \
37rq class Widget a leading kind keyword is shorthand for -k\n \
38rq --symbols FILE outline a file's definitions, in line order\n \
39rq thing -x rust restrict to a language (ruby/rust/go/python/ts/js)\n \
40rq 'Foo::Bar' qualify by scope — the surest way past an ambiguous name\n \
41rq 'Foo#bar' ...and by owner, for a method\n \
42rq save --anchor w.rb:9 rank as if asked from line 9 of w.rb\n \
43rq Foo.new the constructor (initialize, __init__, ...)\n \
44rq 'refund*proc' wildcards: * (any run), ? (one char) — quote them\n \
45rq -o thing open the best match in your editor\n \
46rq --index index the current repository\n \
47rq --status show indexing coverage\n \
48rq --usage show how rq has been called (by caller and flags)\n \
49rq --drop remove this repo's index (opposite of --index)\n\n\
50SHORT FLAGS (easy to misread):\n \
51-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
52The index is a SQLite file at $RQ_DB, 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\n \
591 no match\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")]
90 no_wait: bool,
91
92 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
97 wait: Option<Duration>,
98
99 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
105 open: bool,
106
107 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
112 web: bool,
113
114 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
118 show: bool,
119
120 #[arg(short = 'j', long)]
122 json: bool,
123
124 #[arg(short = 'J', long, conflicts_with = "json")]
126 ndjson: bool,
127
128 #[arg(short = 'p', long, value_name = "DIR")]
130 path: Vec<String>,
131
132 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
134 limit: usize,
135
136 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
140 kind: Vec<String>,
141
142 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
146 lang: Vec<String>,
147
148 #[arg(short = 'a', long = "all-repos")]
151 all_repos: bool,
152
153 #[arg(long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
159 anchor: Option<AnchorSpec>,
160
161 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
163 index: Option<Option<String>>,
164
165 #[arg(long, conflicts_with = "index")]
167 status: bool,
168
169 #[arg(long, conflicts_with_all = ["index", "status"])]
171 usage: bool,
172
173 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
176 symbols: Option<String>,
177
178 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
182 drop: bool,
183
184 #[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"])]
188 warm: Option<Option<String>>,
189
190 #[arg(long, value_name = "SHELL")]
192 completions: Option<Shell>,
193
194 #[arg(short = 'v', long)]
197 verbose: bool,
198
199 #[arg(long)]
203 profile: bool,
204
205 #[arg(long, value_name = "N", default_value_t = 0)]
208 jobs: usize,
209}
210
211pub fn run() -> ExitCode {
213 let cli = match Cli::try_parse() {
214 Ok(cli) => cli,
215 Err(err) => return clap_failure(err),
216 };
217 crate::trace::enable_from(cli.verbose);
218 crate::profile::enable_from(cli.profile);
219 crate::index::set_parse_jobs(cli.jobs);
220 let json_out = output_format(&cli) != Output::Text;
221 let code = dispatch(cli);
222 crate::profile::emit(json_out);
223 code
224}
225
226fn dispatch(cli: Cli) -> ExitCode {
227 if let Some(shell) = cli.completions {
228 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
229 return ExitCode::SUCCESS;
230 }
231 if let Some(db) = std::env::var_os("RQ_DB").filter(|p| std::path::Path::new(p).is_relative()) {
234 return fail(
235 output_format(&cli),
236 Failure::Usage,
237 format_args!(
238 "rq: RQ_DB must be an absolute path, not {:?} (e.g. RQ_DB=\"$PWD/{}\")",
239 db,
240 db.to_string_lossy()
241 ),
242 );
243 }
244 if let Some(path) = &cli.index {
245 let out = output_format(&cli);
247 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
248 }
249 if let Some(path) = &cli.warm {
250 return cmd_warm(path.as_deref());
251 }
252 if cli.status {
253 return cmd_status(output_format(&cli));
254 }
255 if cli.usage {
256 return cmd_usage(output_format(&cli));
257 }
258 if cli.drop {
259 let out = output_format(&cli);
260 return cmd_drop(cli.target, out);
261 }
262 let out = output_format(&cli);
263 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
264 return fail(out, Failure::Usage, format_args!("rq: empty query"));
265 }
266 let mut kinds: Vec<String> = Vec::new();
270 for k in &cli.kind {
271 match canonical_kind(k) {
272 Some(c) => kinds.push(c.to_string()),
273 None => {
274 return fail(
275 out,
276 Failure::Usage,
277 format_args!(
278 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant)"
279 ),
280 );
281 }
282 }
283 }
284 let mut langs: Vec<String> = Vec::new();
286 for x in &cli.lang {
287 let matched = canonical_langs(x);
288 if matched.is_empty() {
289 return fail(
290 out,
291 Failure::Usage,
292 format_args!(
293 "rq: unknown --lang {x:?} ({})",
294 crate::lang::languages().join(", ")
295 ),
296 );
297 }
298 langs.extend(matched);
299 }
300 if let Some(file) = &cli.symbols {
301 return cmd_symbols(file, &kinds, &langs, out);
302 }
303 let mut paths = cli.path.clone();
305 match cli.target {
306 Some(target) => {
307 let query = if cli.kind.is_empty() {
310 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
311 if let Some(k) = kw {
312 kinds.push(k.to_string());
313 }
314 paths.extend(dirs);
315 query
316 } else {
317 paths.extend(cli.dirs.clone());
318 target
319 };
320 let mut session = match Session::open(out) {
321 Ok(s) => s,
322 Err(code) => return code,
323 };
324 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
325 cmd_search(
326 &mut session,
327 &SearchArgs {
328 query: &query,
329 explain: cli.explain,
330 out,
331 paths: &paths,
332 kinds: &kinds,
333 langs: &langs,
334 want: requested_limit(cli.limit),
335 no_wait: cli.no_wait,
336 wait: cli.wait,
337 open: cli.open,
338 web: cli.web,
339 all_repos: cli.all_repos,
340 show: cli.show,
341 batch: false,
342 anchored: cli.anchor.is_some(),
343 },
344 )
345 }
346 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
349 None => {
351 let _ = Cli::command().print_long_help();
352 ExitCode::SUCCESS
353 }
354 }
355}
356
357#[derive(Clone, Copy, PartialEq)]
359enum Output {
360 Text,
361 Json,
362 Ndjson,
363}
364
365fn output_format(cli: &Cli) -> Output {
366 if cli.ndjson {
367 Output::Ndjson
368 } else if cli.json {
369 Output::Json
370 } else {
371 Output::Text
372 }
373}
374
375const DEFAULT_LIMIT: usize = 10;
377
378const PATH_HEADROOM: usize = 200;
381
382fn requested_limit(limit: usize) -> usize {
385 if limit == 0 { usize::MAX } else { limit }
386}
387
388fn record_usage(
391 store: &Store,
392 args: &SearchArgs,
393 status: &str,
394 coverage: Option<&str>,
395 live: bool,
396) {
397 let _ = store.record_search(&crate::store::SearchRecord {
398 source: &crate::origin::detect(),
399 flags: &flag_summary(args),
400 status,
401 coverage: coverage.unwrap_or("none"),
402 live,
403 });
404}
405
406fn flag_summary(args: &SearchArgs) -> String {
411 let mut on: Vec<&str> = Vec::new();
412 match args.out {
413 Output::Json => on.push("json"),
414 Output::Ndjson => on.push("ndjson"),
415 Output::Text => {}
416 }
417 for (present, name) in [
418 (args.explain, "explain"),
419 (args.show, "show"),
420 (args.open, "open"),
421 (args.web, "web"),
422 (args.all_repos, "all-repos"),
423 (args.no_wait, "no-wait"),
424 (args.batch, "batch"),
425 (args.anchored, "anchor"),
426 (!args.paths.is_empty(), "path"),
427 (!args.kinds.is_empty(), "kind"),
428 (!args.langs.is_empty(), "lang"),
429 (args.want != DEFAULT_LIMIT, "limit"),
430 ] {
431 if present {
432 on.push(name);
433 }
434 }
435 on.join(",")
436}
437
438const POLL_INTERVAL: Duration = Duration::from_millis(100);
445
446const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
450
451const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
455
456struct SearchArgs<'a> {
458 query: &'a str,
459 explain: bool,
460 out: Output,
461 paths: &'a [String],
462 kinds: &'a [String],
463 langs: &'a [String],
464 want: usize,
466 no_wait: bool,
468 wait: Option<Duration>,
471 open: bool,
472 web: bool,
473 all_repos: bool,
474 batch: bool,
478 show: bool,
479 anchored: bool,
481}
482
483struct Session {
494 store: Store,
495 cwd: Option<PathBuf>,
496 cwd_is_git: bool,
497 root: Option<PathBuf>,
498 active_paths: Vec<String>,
499 branch_refresh: Option<BranchRefresh>,
500 identity: Option<String>,
501 coverage: Option<String>,
502 anchor: Option<crate::search::Anchor>,
504}
505
506impl Session {
507 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
509 let open_span = crate::profile::span("store open");
510 let store = match open_store() {
511 Ok(s) => s,
512 Err(e) => {
513 return Err(fail(
514 out,
515 Failure::Database,
516 format_args!("rq: cannot open database: {e}"),
517 ));
518 }
519 };
520 drop(open_span);
521 let git_span = crate::profile::span("setup: git root");
522 let cwd = std::env::current_dir().ok();
523 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
524
525 let root = cwd
531 .as_deref()
532 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
533 drop(git_span);
534
535 let mut branch_span = crate::profile::span("setup: branch files");
538 let (active_paths, branch_refresh, cached_cost) = match &root {
539 Some(c) if cwd_is_git => cached_branch_files(&store, c),
540 _ => (Vec::new(), None, None),
541 };
542 branch_span.note(|| {
543 let how = if branch_refresh.is_some() {
544 "cached, refreshing alongside"
545 } else {
546 "cached"
547 };
548 let ttl = branch_files_ttl(cached_cost);
551 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
552 });
553 drop(branch_span);
554
555 let mut identity_span = crate::profile::span("setup: identity");
560 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
561 let coverage = identity
562 .as_deref()
563 .and_then(|id| store.coverage_status(id).ok())
564 .flatten();
565 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
566 drop(identity_span);
567 Ok(Session {
568 store,
569 cwd,
570 cwd_is_git,
571 root,
572 active_paths,
573 branch_refresh,
574 identity,
575 coverage,
576 anchor: None,
577 })
578 }
579
580 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
584 let _span = crate::profile::span("setup: anchor");
585 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
586 let abs = here.join(&spec.file);
587 let abs = abs.canonicalize().unwrap_or(abs);
588 let root = self
589 .root
590 .clone()
591 .filter(|r| abs.starts_with(r))
592 .or_else(|| crate::index::repo_root(&abs))
593 .or_else(|| abs.parent().map(PathBuf::from))
594 .unwrap_or(here);
595 let identity = resolve_identity(&self.store, &root);
596 let rel = abs
597 .strip_prefix(&root)
598 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
599 .into_owned();
600 let repo_id = self.store.repository_id(&identity).ok().flatten();
601 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
602 crate::search::Anchor::new(identity, rel, spec.line, &defs)
603 }
604}
605
606#[derive(Debug, Clone, PartialEq)]
608struct AnchorSpec {
609 file: PathBuf,
610 line: i64,
611}
612
613fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
616 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
617 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
618 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
619 let last = num(last).ok_or_else(bad)?;
620 let (file, line) = rest
622 .rsplit_once(':')
623 .and_then(|(file, line)| num(line).map(|line| (file, line)))
624 .unwrap_or((rest, last));
625 if file.is_empty() {
626 return Err(bad());
627 }
628 Ok(AnchorSpec {
629 file: PathBuf::from(file),
630 line,
631 })
632}
633
634fn cmd_batch(
649 cli: &Cli,
650 out: Output,
651 paths: &[String],
652 kinds: &[String],
653 langs: &[String],
654) -> ExitCode {
655 if out == Output::Json {
656 return fail(
657 out,
658 Failure::Usage,
659 format_args!(
660 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
661 where each line carries the query it answers"
662 ),
663 );
664 }
665 if cli.open || cli.web || cli.show {
666 return fail(
667 out,
668 Failure::Usage,
669 format_args!(
670 "rq: --open, --web and --show act on a single result, not a stream of queries"
671 ),
672 );
673 }
674
675 use std::io::BufRead;
676 let queries: Vec<String> = std::io::stdin()
677 .lock()
678 .lines()
679 .map_while(std::result::Result::ok)
680 .map(|l| l.trim().to_string())
681 .filter(|l| !l.is_empty())
682 .collect();
683 if queries.is_empty() {
687 let _ = Cli::command().print_long_help();
688 return ExitCode::SUCCESS;
689 }
690
691 let mut session = match Session::open(out) {
692 Ok(s) => s,
693 Err(code) => return code,
694 };
695 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
696
697 if !cli.no_wait
700 && session.coverage.as_deref() != Some("complete")
701 && let Some(root) = session.root.clone()
702 {
703 {
704 let budget = cli.wait.unwrap_or_else(wait_budget);
705 crate::trace!(
706 "batch: warming {} queries' worth of index first",
707 queries.len()
708 );
709 let active = session.active_paths.clone();
710 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
711 session.coverage = session
712 .identity
713 .as_deref()
714 .and_then(|id| session.store.coverage_status(id).ok())
715 .flatten();
716 }
717 }
718
719 let mut worst = ExitCode::SUCCESS;
720 let mut any_hit = false;
721 for query in &queries {
722 let code = cmd_search(
723 &mut session,
724 &SearchArgs {
725 query,
726 explain: cli.explain,
727 out,
728 paths,
729 kinds,
730 langs,
731 want: requested_limit(cli.limit),
732 no_wait: true,
736 wait: cli.wait,
737 open: false,
738 web: false,
739 all_repos: cli.all_repos,
740 show: false,
741 batch: true,
742 anchored: cli.anchor.is_some(),
743 },
744 );
745 if code == ExitCode::SUCCESS {
746 any_hit = true;
747 } else {
748 worst = code;
749 }
750 }
751 if any_hit { ExitCode::SUCCESS } else { worst }
754}
755
756fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
757 let &SearchArgs {
758 query,
759 out,
760 want,
761 no_wait,
762 wait,
763 open,
764 web,
765 all_repos,
766 show,
767 ..
768 } = args;
769 let wait_budget = wait.unwrap_or_else(wait_budget);
772 let no_wait = no_wait || wait_budget.is_zero();
773 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
776 want
777 } else {
778 want.saturating_mul(20).max(PATH_HEADROOM)
779 };
780 let _timer = crate::trace::Timer::start("search done");
781 let t_setup = std::time::Instant::now();
782 let setup_span = crate::profile::span("setup");
784 let Session {
787 store,
788 cwd,
789 cwd_is_git,
790 root,
791 active_paths,
792 branch_refresh,
793 identity,
794 coverage,
795 anchor,
796 } = session;
797 let cwd_is_git = *cwd_is_git;
798
799 let known = coverage.is_some();
807 let warming_ok = cwd_is_git || known;
808 if crate::trace::enabled() {
809 crate::trace!(
810 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
811 root.as_deref().map_or("?".into(), crate::trace::abbrev),
812 identity.as_deref().unwrap_or("none"),
813 coverage.as_deref().unwrap_or("none"),
814 active_paths.len(),
815 );
816 }
817 let repo_span = crate::profile::span("setup: repo state");
818 let repo_id = |store: &Store| {
819 identity
820 .as_deref()
821 .and_then(|id| store.repository_id(id).ok().flatten())
822 };
823 let mut current = repo_id(store);
824 let scope = |current| Scope::new(all_repos, warming_ok, current);
827 let ctx = crate::search::Context {
828 active: crate::search::ActiveFiles::new(active_paths.clone()),
829 anchor: anchor.clone(),
830 };
831
832 drop(repo_span);
833 let warm_span = crate::profile::span("setup: warm decision");
834
835 let warm_budget = if warm_detach_enabled() {
842 answer_warm_budget()
843 } else {
844 answer_warm_budget() + deferred_warm_budget()
845 };
846 let was_warming = coverage.as_deref() != Some("complete");
847
848 let indexed_head = (!was_warming)
859 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
860 .flatten();
861 let staleness = (!was_warming && warming_ok && !args.batch)
864 .then(|| root.clone())
865 .flatten()
866 .map(|c| {
867 if warm_detach_enabled() {
868 Staleness::Deferred(c, indexed_head)
869 } else {
870 Staleness::Running(std::thread::spawn(move || {
871 worktree_edits(&c, indexed_head.as_deref())
872 }))
873 }
874 });
875 let want_warm = warming_ok && was_warming && root.is_some();
878
879 let block = want_warm && was_warming && !no_wait;
893 let progress_ui = block && show_progress(out, stderr_interactive());
898 let indexer_budget = if block { wait_budget } else { warm_budget };
899 if progress_ui {
900 install_interrupt_handler();
901 }
902
903 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
906 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
907 crate::trace!(
908 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
909 crate::index::parse_jobs()
910 );
911 let root = root.clone().expect("checked");
912 let active = active_paths.clone();
913 let q = query.to_string();
914 let warm_done = std::sync::Arc::clone(&warm_done);
915 std::thread::spawn(move || {
916 if let Ok(mut idx) = open_store() {
917 let _ = if block {
919 crate::index::index_budgeted_cancellable(
922 &mut idx,
923 &root,
924 &active,
925 indexer_budget,
926 Some(&q),
927 &INTERRUPTED,
928 )
929 } else {
930 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
931 };
932 }
933 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
934 })
935 });
936
937 crate::trace!(
944 "setup (open + repo detect + warm decision): {} ms",
945 t_setup.elapsed().as_millis()
946 );
947 let poll_start = std::time::Instant::now();
948 let deadline = if progress_ui {
952 None
953 } else if block {
954 Some(poll_start + wait_budget)
955 } else {
956 Some(poll_start + answer_warm_budget())
957 };
958 drop(warm_span);
959 let polling = indexer.is_some() && was_warming;
960 drop(setup_span);
964 let mut query_span = crate::profile::span("query");
965 let label = repo_label(root.as_deref());
966 let mut drew_progress = false;
967 let mut last_draw = poll_start;
968 let rank_limit = limit.max(2);
972 let mut total;
973 let mut hits = loop {
974 if current.is_none() {
976 current = repo_id(store);
977 }
978 match scope(current).search(store, query, current, &ctx, rank_limit) {
979 Ok(m) => {
980 total = m.total;
981 let h = m.hits;
982 let confident = h.first().is_some_and(|hit| {
983 hit.features
984 .iter()
985 .any(|f| matches!(f.name, "exact" | "prefix"))
986 });
987 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
988 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
989 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
990 if !polling || confident || warm_finished || stopped || timed_out {
991 break h;
992 }
993 if progress_ui
994 && poll_start.elapsed() >= HEADS_UP_DELAY
995 && last_draw.elapsed() >= PROGRESS_REDRAW
996 {
997 draw_progress(store, identity.as_deref(), &label);
998 drew_progress = true;
999 last_draw = std::time::Instant::now();
1000 }
1001 }
1002 Err(e) => {
1003 if let Some(h) = indexer {
1004 let _ = h.join();
1005 }
1006 return fail(out, Failure::Database, format_args!("rq: {e}"));
1007 }
1008 }
1009 std::thread::sleep(POLL_INTERVAL);
1010 };
1011 query_span.note(|| {
1012 if polling {
1013 "polled a warming index".to_string()
1014 } else {
1015 String::new()
1016 }
1017 });
1018 drop(query_span);
1019 if drew_progress {
1020 clear_progress();
1021 }
1022 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1024
1025 let here = identity
1026 .as_deref()
1027 .zip(root.as_deref())
1028 .map(|(identity, root)| Here { identity, root });
1029 if !hits.is_empty()
1031 && revalidate_top(store, &hits, here)
1032 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1033 {
1034 total = m.total;
1035 hits = m.hits;
1036 }
1037
1038 let mut live_scan = None;
1042 if !hits.iter().any(strong)
1043 && indexer.is_none()
1044 && coverage.is_none()
1045 && let Some(root) = &root
1046 {
1047 let (tail, cost) = live_fallback(root, query, rank_limit, &ctx);
1048 hits = crate::search::merge(hits, tail, rank_limit);
1049 total = total.max(hits.len());
1050 live_scan = Some(cost);
1051 }
1052
1053 apply_gates(query, &mut hits);
1054 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1055 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1059 total = hits.len();
1060 hits.truncate(want.max(2));
1061 }
1062
1063 if hits.is_empty() {
1064 if block {
1066 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1067 }
1068 if let Some(h) = indexer {
1069 let _ = h.join();
1070 }
1071 let mut incomplete = (block || no_wait)
1078 && identity
1079 .as_deref()
1080 .and_then(|id| store.coverage_status(id).ok().flatten())
1081 .as_deref()
1082 != Some("complete");
1083 incomplete |= settle_warm(
1093 store,
1094 staleness,
1095 false,
1096 was_warming,
1097 warming_ok,
1098 root.as_deref(),
1099 active_paths,
1100 query,
1101 warm_budget,
1102 no_wait,
1103 identity.as_deref(),
1104 );
1105 let elsewhere = match scope(current) {
1110 Scope::Nothing => None,
1111 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1112 Scope::Repo(id) => {
1113 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1114 }
1115 };
1116 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1117 note_live_scan(root, cost, false);
1118 }
1119 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1120 let _span = crate::profile::span("after: record usage");
1124 record_usage(
1125 store,
1126 args,
1127 if incomplete { "warming" } else { "miss" },
1128 coverage.as_deref(),
1129 false,
1130 );
1131 return code;
1132 }
1133
1134 let live = hits
1137 .iter()
1138 .take(want)
1139 .any(|h| h.source == crate::search::Source::Live);
1140 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1141 note_live_scan(root, cost, live);
1142 }
1143
1144 let counted_early = show || open || web;
1149 if counted_early {
1150 let _span = crate::profile::span("record usage");
1151 record_usage(store, args, "hit", coverage.as_deref(), live);
1152 }
1153
1154 attach_confidence(&mut hits);
1158 hits.truncate(want);
1159 let total = total.max(hits.len());
1160 for hit in &mut hits {
1161 hit.total = total;
1162 if args.explain {
1163 hit.explain = Some(
1164 hit.features
1165 .iter()
1166 .map(|f| (f.name.to_string(), f.reported()))
1167 .collect(),
1168 );
1169 }
1170 }
1171
1172 let _signatures_span = crate::profile::span("signatures");
1175 for hit in &mut hits {
1176 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1177 hit.signature = root
1178 .as_deref()
1179 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1180 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1181 }
1182 drop(_signatures_span);
1183
1184 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1187 return code;
1188 }
1189
1190 if open || web {
1195 return finish_open(store, &hits, current, root.as_deref(), web);
1196 }
1197
1198 if let Some(code) = render_hits(args, &hits) {
1199 return code;
1200 }
1201
1202 crate::profile::mark("first answer");
1205
1206 if !counted_early {
1209 let _span = crate::profile::span("after: record usage");
1210 record_usage(store, args, "hit", coverage.as_deref(), live);
1211 }
1212
1213 if let Some(refresh) = branch_refresh.take() {
1220 let _span = crate::profile::span("after: branch refresh");
1221 refresh.store(store);
1222 }
1223
1224 if block {
1229 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1230 }
1231 if let Some(h) = indexer {
1232 let _ = h.join();
1233 }
1234 let _ = settle_warm(
1235 store,
1236 staleness,
1237 true,
1238 was_warming,
1239 warming_ok,
1240 root.as_deref(),
1241 active_paths,
1242 query,
1243 warm_budget,
1244 no_wait,
1245 identity.as_deref(),
1246 );
1247
1248 ExitCode::SUCCESS
1249}
1250
1251fn maybe_detach_warm(
1254 store: &Store,
1255 want_warm: bool,
1256 changed: bool,
1257 root: Option<&std::path::Path>,
1258 identity: Option<&str>,
1259) {
1260 if !warm_detach_enabled() || !want_warm {
1261 return;
1262 }
1263 let (Some(root), Some(id)) = (root, identity) else {
1264 return;
1265 };
1266 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1270 return; }
1272 spawn_detached_warm(root);
1273}
1274
1275fn spawn_detached_warm(root: &std::path::Path) {
1279 use std::os::unix::process::CommandExt;
1280 let Ok(exe) = std::env::current_exe() else {
1281 return;
1282 };
1283 let mut cmd = std::process::Command::new(exe);
1284 cmd.arg("--warm")
1285 .arg(root)
1286 .stdin(std::process::Stdio::null())
1287 .stdout(std::process::Stdio::null())
1288 .stderr(std::process::Stdio::null())
1289 .process_group(0);
1290 match cmd.spawn() {
1291 Ok(child) => crate::trace!(
1292 "background warm (detached): pid {} for {}",
1293 child.id(),
1294 crate::trace::abbrev(root)
1295 ),
1296 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1297 }
1298}
1299
1300const WARM_LOCK_TTL_SECS: i64 = 600;
1303
1304fn warm_recheck_window() -> Duration {
1310 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1311}
1312
1313fn recently_verified(
1316 store: &Store,
1317 identity: Option<&str>,
1318 root: &std::path::Path,
1319 indexed_head: Option<&str>,
1320) -> bool {
1321 let _span = crate::profile::span("after: warm recently verified?");
1322 let (Some(id), Some(stamp)) = (
1323 identity,
1324 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1325 ) else {
1326 return false;
1327 };
1328 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1329 return false;
1330 };
1331 let age = now_unix().saturating_sub(at);
1332 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1333}
1334
1335fn cmd_warm(path: Option<&str>) -> ExitCode {
1340 #[cfg(target_os = "macos")]
1343 unsafe extern "C" {
1344 fn setiopolicy_np(
1347 iotype: libc::c_int,
1348 scope: libc::c_int,
1349 policy: libc::c_int,
1350 ) -> libc::c_int;
1351 }
1352 unsafe {
1353 libc::nice(10);
1354 #[cfg(target_os = "macos")]
1355 setiopolicy_np(0, 0, 3);
1356 }
1357 let mut store = match open_store() {
1358 Ok(s) => s,
1359 Err(_) => return ExitCode::from(Failure::Database.exit_code()),
1360 };
1361 let start = path
1362 .map(PathBuf::from)
1363 .or_else(|| std::env::current_dir().ok())
1364 .unwrap_or_else(|| PathBuf::from("."));
1365 let root = crate::index::repo_root(&start).unwrap_or(start);
1366 let identity = resolve_identity(&store, &root);
1367
1368 let held = |pid: u32, ts: i64| {
1372 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
1373 alive && now_unix() - ts < WARM_LOCK_TTL_SECS
1374 };
1375 if !store
1376 .claim_warm_lock(&identity, std::process::id(), held)
1377 .unwrap_or(false)
1378 {
1379 return ExitCode::SUCCESS;
1380 }
1381
1382 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1385 let head = store
1386 .repository_id(&identity)
1387 .ok()
1388 .flatten()
1389 .and_then(|id| store.indexed_head(id).ok().flatten());
1390 let checked_at = now_unix();
1394 let edits = worktree_edits(&root, head.as_deref());
1395 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1396 crate::trace!("warm: unchanged since indexed, nothing to do");
1397 if let Some(id) = store.repository_id(&identity).ok().flatten() {
1399 let _ = store.maintain_name_index(id);
1400 }
1401 if let Some(stamp) = head
1402 .as_deref()
1403 .and_then(|h| crate::index::git_state_stamp(&root, h))
1404 {
1405 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1406 }
1407 let _ = store.clear_warm_lock(&identity);
1408 return ExitCode::SUCCESS;
1409 }
1410 }
1411
1412 let deadline = std::time::Instant::now() + warm_bg_budget();
1415 let active = crate::index::branch_changed_files(&root);
1416 loop {
1417 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1418 if remaining.is_zero() {
1419 break;
1420 }
1421 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1422 {
1423 Ok(s) => s,
1424 Err(_) => break,
1425 };
1426 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1427 || stats.files_indexed == 0
1428 {
1429 break;
1430 }
1431 }
1432 let _ = store.clear_warm_lock(&identity);
1433 ExitCode::SUCCESS
1434}
1435
1436fn live_fallback(
1439 root: &std::path::Path,
1440 query: &str,
1441 limit: usize,
1442 ctx: &crate::search::Context,
1443) -> (Vec<crate::search::Hit>, LiveCost) {
1444 let start = std::time::Instant::now();
1445 let deadline = start + live_fallback_budget();
1446 let mut files = 0;
1447 let mut scan = |prefilter| {
1448 let mut span = crate::profile::span(if prefilter {
1449 "live scan: prefiltered"
1450 } else {
1451 "live scan: unfiltered"
1452 });
1453 let found = crate::search::live_search(
1454 root,
1455 query,
1456 limit,
1457 &HashSet::new(),
1458 Some(deadline),
1459 prefilter,
1460 ctx,
1461 );
1462 span.note(|| format!("{} files", found.files));
1463 files += found.files;
1464 found.hits
1465 };
1466 let mut hits = scan(true);
1467 if hits.is_empty() {
1468 hits = scan(false);
1469 }
1470 let cost = LiveCost {
1471 files,
1472 elapsed: start.elapsed(),
1473 };
1474 (hits, cost)
1475}
1476
1477struct LiveCost {
1479 files: usize,
1480 elapsed: Duration,
1481}
1482
1483fn note_live_scan(root: &std::path::Path, cost: &LiveCost, answered: bool) {
1486 if !crate::trace::enabled() {
1487 return;
1488 }
1489 let budget = live_fallback_budget();
1490 let files = match cost.files {
1491 1 => "1 file".to_string(),
1492 n => format!("{n} files"),
1493 };
1494 let what = if answered {
1495 "answered from a live scan of"
1496 } else {
1497 "no answer from a live scan of"
1498 };
1499 let cut = if cost.elapsed >= budget {
1500 " (stopped at the budget)"
1501 } else {
1502 ""
1503 };
1504 crate::trace!(
1505 "{what} {}: {files} in {} ms, budget {} ms{cut}",
1506 crate::trace::abbrev(root),
1507 cost.elapsed.as_millis(),
1508 budget.as_millis(),
1509 );
1510}
1511
1512fn strong(h: &crate::search::Hit) -> bool {
1514 h.features
1515 .iter()
1516 .any(|f| matches!(f.name, "exact" | "prefix"))
1517}
1518
1519fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1528 if hits.iter().any(strong) {
1529 hits.retain(strong);
1530 }
1531 crate::search::apply_scope_gate(query, hits);
1532}
1533
1534fn apply_post_filters(
1537 args: &SearchArgs,
1538 cwd: Option<&std::path::Path>,
1539 root: Option<&std::path::Path>,
1540 hits: &mut Vec<crate::search::Hit>,
1541) {
1542 if !args.paths.is_empty() {
1543 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1547 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1548 let norm: Vec<String> = args
1549 .paths
1550 .iter()
1551 .map(|p| repo_relative(&base, &here, p))
1552 .collect();
1553 hits.retain(|h| under_any(&h.file, &norm));
1554 }
1555 if !args.kinds.is_empty() {
1556 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1557 }
1558 if !args.langs.is_empty() {
1559 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1560 }
1561}
1562
1563fn no_match_code(
1569 out: Output,
1570 query: &str,
1571 interrupted: bool,
1572 incomplete: bool,
1573 elsewhere: Option<&str>,
1577) -> ExitCode {
1578 let status = if interrupted {
1579 "interrupted"
1580 } else if incomplete {
1581 "warming"
1582 } else if elsewhere.is_some() {
1583 "scope_not_found"
1584 } else {
1585 "no_match"
1586 };
1587 match out {
1588 Output::Json | Output::Ndjson => {
1589 let mut obj = serde_json::json!({ "status": status, "query": query });
1590 if let Some(found_in) = elsewhere {
1591 obj["found_in"] = serde_json::json!(found_in);
1592 }
1593 let _ = emit_json(out, &obj); }
1595 Output::Text if interrupted => {
1596 eprintln!("rq: indexing interrupted — run again to finish")
1597 }
1598 Output::Text if incomplete => eprintln!(
1599 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1600 ),
1601 Output::Text if elsewhere.is_some() => eprintln!(
1602 "rq: nothing matching {query:?} — that name is defined under {}",
1603 elsewhere.unwrap_or_default()
1604 ),
1605 Output::Text => eprintln!("no matches for {query:?}"),
1606 }
1607 if incomplete {
1608 ExitCode::from(2)
1609 } else {
1610 ExitCode::FAILURE
1611 }
1612}
1613
1614fn attach_confidence(hits: &mut [crate::search::Hit]) {
1618 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1619 if t.is_none_or(|t| h.score > t) {
1620 (Some(h.score), t)
1621 } else if s.is_none_or(|s| h.score > s) {
1622 (t, Some(h.score))
1623 } else {
1624 (t, s)
1625 }
1626 });
1627 for hit in hits.iter_mut() {
1628 let best_other = if Some(hit.score) == top { second } else { top };
1629 hit.confidence = crate::search::confidence(
1630 hit.score,
1631 crate::search::match_quality(&hit.features),
1632 best_other,
1633 );
1634 }
1635}
1636
1637fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1640 let render_span = crate::profile::span("render");
1644 if args.batch {
1645 #[derive(serde::Serialize)]
1648 struct Tagged<'a> {
1649 query: &'a str,
1650 #[serde(flatten)]
1651 hit: &'a crate::search::Hit,
1652 }
1653 let rows: Vec<Tagged> = hits
1654 .iter()
1655 .map(|hit| Tagged {
1656 query: args.query,
1657 hit,
1658 })
1659 .collect();
1660 if let Some(code) = emit_rows(args.out, &rows) {
1661 return Some(code);
1662 }
1663 } else if let Some(code) = emit_rows(args.out, hits) {
1664 return Some(code);
1665 }
1666 if args.out != Output::Text {
1667 return None;
1668 }
1669 drop(render_span);
1670 let color = match_color();
1671 let c = color.as_deref();
1672 let query = args.query;
1673 if args.show {
1674 let total = hits.first().map_or(hits.len(), |h| h.total);
1676 eprintln!(
1677 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1678 hits.len()
1679 );
1680 }
1681 for hit in hits {
1682 let name = hl(&hit.name, query, c);
1685 let qualified = match &hit.parent {
1686 Some(p) => format!("{name} · {p}"),
1687 None => name,
1688 };
1689 println!(
1690 "{}:{} {} {}",
1691 hl_path(&hit.file, query, c),
1692 hit.line,
1693 hit.kind,
1694 qualified
1695 );
1696 if let Some(sig) = &hit.signature {
1697 println!(" {}", hl(sig, query, c));
1698 }
1699 if args.explain {
1700 let parts: Vec<String> = hit
1701 .features
1702 .iter()
1703 .map(|f| format!("{} {}", f.name, f.reported()))
1704 .collect();
1705 println!(
1706 " confidence {:.2} · score {:.0} = {}",
1707 hit.confidence,
1708 hit.score,
1709 parts.join(" + ")
1710 );
1711 }
1712 }
1713 None
1714}
1715
1716fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1720 use std::io::{IsTerminal, Write};
1721 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1722 return hits.first();
1723 }
1724 let mut err = std::io::stderr();
1725 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1726 for (i, h) in hits.iter().enumerate() {
1727 let _ = writeln!(
1728 err,
1729 " {}. {}:{} {} {}",
1730 i + 1,
1731 h.file,
1732 h.line,
1733 h.kind,
1734 h.name
1735 );
1736 }
1737 let _ = write!(err, "rq> ");
1738 let _ = err.flush();
1739 let mut line = String::new();
1740 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1741 return None; }
1743 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1744}
1745
1746fn parse_choice(input: &str, n: usize) -> Option<usize> {
1749 let s = input.trim();
1750 if s.is_empty() {
1751 return Some(0);
1752 }
1753 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1754 (i < n).then_some(i)
1755}
1756
1757fn finish_open(
1760 store: &Store,
1761 hits: &[crate::search::Hit],
1762 current: Option<i64>,
1763 root: Option<&std::path::Path>,
1764 web: bool,
1765) -> ExitCode {
1766 let Some(hit) = choose_hit(hits) else {
1767 return ExitCode::SUCCESS; };
1769
1770 if web {
1771 return open_web(store, hit, current, root);
1772 }
1773
1774 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1777 Some(r) => r.join(&hit.file),
1778 None => PathBuf::from(&hit.file),
1779 };
1780 launch_editor(&target, hit.line)
1781}
1782
1783#[derive(Clone, Copy)]
1785enum Scope {
1786 All,
1788 Repo(i64),
1789 Nothing,
1792}
1793
1794impl Scope {
1795 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1796 match current {
1797 _ if all_repos => Scope::All,
1798 Some(id) => Scope::Repo(id),
1799 None if in_repo => Scope::Nothing,
1800 None => Scope::All,
1801 }
1802 }
1803
1804 fn search(
1805 self,
1806 store: &Store,
1807 query: &str,
1808 current: Option<i64>,
1809 ctx: &crate::search::Context,
1810 limit: usize,
1811 ) -> crate::store::Result<crate::search::Matches> {
1812 let only_repo = match self {
1813 Scope::All => None,
1814 Scope::Repo(id) => Some(id),
1815 Scope::Nothing => {
1816 return Ok(crate::search::Matches {
1817 hits: Vec::new(),
1818 total: 0,
1819 });
1820 }
1821 };
1822 crate::search::search(store, query, current, only_repo, ctx, limit)
1823 }
1824}
1825
1826fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1830 use std::os::unix::process::CommandExt;
1831 let loc = format!("{}:{}", file.display(), line);
1832 match open_command(file, line, &loc) {
1833 Some((prog, args)) => {
1834 crate::profile::emit(false);
1836 let err = std::process::Command::new(&prog).args(&args).exec();
1838 fail(
1839 Output::Text,
1840 Failure::Launch,
1841 format_args!("rq --open: cannot run {prog}: {err}"),
1842 )
1843 }
1844 None => {
1845 println!("{loc}");
1846 ExitCode::SUCCESS
1847 }
1848 }
1849}
1850
1851fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1857 let fstr = file.to_string_lossy().into_owned();
1858
1859 if let Some(t) = std::env::var_os("RQ_OPEN") {
1860 let t = t.to_string_lossy();
1861 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1862 let mut parts = t.split_whitespace().map(|p| {
1863 p.replace("{file}", &fstr)
1864 .replace("{line}", &line.to_string())
1865 .replace("{}", loc)
1866 });
1867 if let Some(prog) = parts.next() {
1868 let mut args: Vec<String> = parts.collect();
1869 if !placeholder {
1870 args.push(loc.to_string());
1871 }
1872 return Some((prog, args));
1873 }
1874 }
1875
1876 if on_path("code") {
1877 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1878 }
1879
1880 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1881 let ed = ed.to_string_lossy().into_owned();
1882 let l = ed.to_ascii_lowercase();
1883 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1885 .iter()
1886 .any(|e| l.contains(e))
1887 {
1888 return Some((ed, vec![format!("+{line}"), fstr]));
1889 }
1890 return Some((ed, vec![fstr]));
1891 }
1892
1893 None
1894}
1895
1896fn open_web(
1901 store: &Store,
1902 hit: &crate::search::Hit,
1903 current: Option<i64>,
1904 root: Option<&std::path::Path>,
1905) -> ExitCode {
1906 if hit.repo_identity.starts_with("local:") {
1907 return fail(
1908 Output::Text,
1909 Failure::NoRemote,
1910 format_args!(
1911 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1912 locally with -o, or add one with `git remote add origin <url>`",
1913 hit.file, hit.line, hit.repo_identity
1914 ),
1915 );
1916 }
1917 let here =
1918 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1919 let rev = root
1920 .filter(|_| here)
1921 .and_then(crate::index::pushed_head)
1922 .unwrap_or_else(|| "HEAD".into());
1923 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1924
1925 use std::os::unix::process::CommandExt;
1926 let browser = std::env::var("BROWSER")
1927 .ok()
1928 .filter(|b| !b.is_empty())
1929 .or_else(|| {
1930 ["open", "xdg-open"]
1931 .into_iter()
1932 .find(|p| on_path(p))
1933 .map(str::to_string)
1934 });
1935 match browser {
1936 Some(prog) => {
1937 crate::profile::emit(false);
1939 let err = std::process::Command::new(&prog).arg(&url).exec();
1941 fail(
1942 Output::Text,
1943 Failure::Launch,
1944 format_args!("rq --web: cannot run {prog}: {err}"),
1945 )
1946 }
1947 None => {
1948 println!("{url}");
1949 ExitCode::SUCCESS
1950 }
1951 }
1952}
1953
1954fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1957 let path: String = file
1958 .bytes()
1959 .map(|b| match b {
1960 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1961 (b as char).to_string()
1962 }
1963 _ => format!("%{b:02X}"),
1964 })
1965 .collect();
1966 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1967}
1968
1969fn on_path(prog: &str) -> bool {
1971 std::env::var_os("PATH")
1972 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1973}
1974
1975const BRANCH_FILES_TTL_SECS: i64 = 15;
1981
1982const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1986
1987const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1999
2000fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
2002 let earned = cost_ms.map_or(0, |ms| {
2005 i64::try_from(ms)
2006 .unwrap_or(i64::MAX)
2007 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
2008 / 1000
2009 });
2010 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
2011}
2012
2013struct BranchRefresh {
2017 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
2020 identity: String,
2021 stamp: String,
2022}
2023
2024impl BranchRefresh {
2025 fn store(self, store: &Store) {
2027 let Ok((files, cost_ms)) = self.handle.join() else {
2028 return;
2029 };
2030 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
2031 }
2032}
2033
2034fn cached_branch_files(
2048 store: &Store,
2049 root: &std::path::Path,
2050) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
2051 let identity = resolve_identity(store, root);
2052 let stamp = crate::index::branch_files_stamp(root);
2053 let cached = store.branch_files_get(&identity).ok().flatten();
2054 let now = now_unix();
2055
2056 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
2057 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
2058 {
2059 return (hit.files.clone(), None, hit.cost_ms);
2060 }
2061 let owned_root = root.to_path_buf();
2062 let refresh = BranchRefresh {
2063 handle: std::thread::spawn(move || {
2064 let t = std::time::Instant::now();
2065 let files = crate::index::branch_changed_files(&owned_root);
2066 (files, t.elapsed().as_millis() as u64)
2067 }),
2068 identity,
2069 stamp: stamp.clone(),
2070 };
2071 return (hit.files.clone(), Some(refresh), hit.cost_ms);
2072 }
2073
2074 let t = std::time::Instant::now();
2076 let files = crate::index::branch_changed_files(root);
2077 let cost_ms = t.elapsed().as_millis() as u64;
2078 if let Some(stamp) = stamp {
2079 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
2080 }
2081 (files, None, Some(cost_ms))
2082}
2083
2084fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
2094 let head = indexed_head?;
2095 let _span = crate::profile::span("git: worktree changed?");
2096 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2097}
2098
2099fn changed_since_index(
2102 store: &Store,
2103 identity: Option<&str>,
2104 root: Option<&std::path::Path>,
2105 edits: Option<Vec<String>>,
2106) -> bool {
2107 let (Some(dirty), Some(root)) = (edits, root) else {
2108 return true;
2109 };
2110 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2111 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2112 None => !dirty.is_empty(),
2113 }
2114}
2115
2116enum Staleness {
2120 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2123 Deferred(PathBuf, Option<String>),
2127}
2128
2129#[allow(clippy::too_many_arguments)]
2136fn settle_warm(
2137 store: &Store,
2138 staleness: Option<Staleness>,
2139 hit: bool,
2140 was_warming: bool,
2141 warming_ok: bool,
2142 root: Option<&std::path::Path>,
2143 active: &[String],
2144 query: &str,
2145 budget: Duration,
2146 no_wait: bool,
2147 identity: Option<&str>,
2148) -> bool {
2149 let changed = match staleness {
2150 None => false,
2151 Some(Staleness::Deferred(r, head)) if hit => {
2154 if recently_verified(store, identity, &r, head.as_deref()) {
2155 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2156 } else {
2157 spawn_detached_warm(&r);
2158 }
2159 return false;
2160 }
2161 Some(Staleness::Deferred(r, head)) => {
2162 let _span = crate::profile::span("after: staleness check");
2163 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2164 }
2165 Some(Staleness::Running(h)) => {
2166 let mut span = crate::profile::span("after: staleness wait");
2168 let changed = h.join().map_or(true, |edits| {
2171 changed_since_index(store, identity, root, edits)
2172 });
2173 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2174 changed
2175 }
2176 };
2177 if changed
2187 && !no_wait
2188 && !warm_detach_enabled()
2189 && let Some(r) = root
2190 && let Ok(mut idx) = open_store()
2191 {
2192 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2193 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2194 }
2195 maybe_detach_warm(
2196 store,
2197 warming_ok && (was_warming || changed),
2198 changed,
2199 root,
2200 identity,
2201 );
2202 changed && warm_detach_enabled()
2206}
2207
2208fn answer_warm_budget() -> Duration {
2217 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2218}
2219
2220fn deferred_warm_budget() -> Duration {
2223 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2224}
2225
2226fn live_fallback_budget() -> Duration {
2229 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2230}
2231
2232fn warm_bg_budget() -> Duration {
2235 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2236}
2237
2238fn warm_detach_enabled() -> bool {
2242 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2243}
2244
2245fn wait_budget() -> Duration {
2254 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2255}
2256
2257fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2262 let s = s.trim();
2263 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2264 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2266 (n, 1.0)
2267 } else if let Some(n) = s.strip_suffix('s') {
2268 (n, 1_000.0)
2269 } else if let Some(n) = s.strip_suffix('m') {
2270 (n, 60_000.0)
2271 } else {
2272 (s, 1_000.0)
2274 };
2275 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2276 if !val.is_finite() || val < 0.0 {
2277 return Err(bad());
2278 }
2279 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2280}
2281
2282static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2286
2287extern "C" fn on_sigint(_: libc::c_int) {
2288 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2290}
2291
2292fn install_interrupt_handler() {
2295 static ONCE: std::sync::Once = std::sync::Once::new();
2296 ONCE.call_once(|| unsafe {
2297 let mut action: libc::sigaction = std::mem::zeroed();
2298 action.sa_sigaction = on_sigint as *const () as usize;
2299 libc::sigemptyset(&mut action.sa_mask);
2300 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2301 });
2302}
2303
2304fn stderr_interactive() -> bool {
2308 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2309}
2310
2311fn show_progress(out: Output, interactive: bool) -> bool {
2316 interactive && matches!(out, Output::Text)
2317}
2318
2319fn repo_label(root: Option<&std::path::Path>) -> String {
2322 root.and_then(|r| r.file_name())
2323 .map(|n| n.to_string_lossy().into_owned())
2324 .unwrap_or_else(|| "repo".into())
2325}
2326
2327fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2331 let files = identity
2332 .and_then(|id| store.repository_id(id).ok().flatten())
2333 .and_then(|rid| store.repo_totals(rid).ok())
2334 .map_or(0, |(f, _)| f);
2335 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2336 let _ = std::io::stderr().flush();
2337}
2338
2339fn clear_progress() {
2341 eprint!("\r\x1b[K");
2342 let _ = std::io::stderr().flush();
2343}
2344
2345fn env_budget(var: &str, default_ms: u64) -> Duration {
2349 let ms = std::env::var(var)
2350 .ok()
2351 .and_then(|v| v.parse().ok())
2352 .unwrap_or(default_ms);
2353 Duration::from_millis(ms)
2354}
2355
2356#[derive(Clone, Copy)]
2358struct Here<'a> {
2359 identity: &'a str,
2360 root: &'a std::path::Path,
2361}
2362
2363fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2369 let mut roots: Vec<PathBuf> = here
2370 .filter(|h| h.identity == repo_identity)
2371 .map(|h| h.root.to_path_buf())
2372 .into_iter()
2373 .collect();
2374 let recorded = store
2375 .repository_id(repo_identity)
2376 .ok()
2377 .flatten()
2378 .map(|id| store.checkout_roots(id).unwrap_or_default())
2379 .unwrap_or_default();
2380 for root in recorded.into_iter().map(PathBuf::from) {
2381 if !roots.contains(&root) {
2382 roots.push(root);
2383 }
2384 }
2385 roots
2386}
2387
2388fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2392 let mut roots = hit_file_roots(store, repo_identity, here);
2393 let at = roots
2394 .iter()
2395 .position(|r| r.join(file).is_file())
2396 .unwrap_or(0);
2397 (at < roots.len()).then(|| roots.swap_remove(at))
2398}
2399
2400fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2402 let src = std::fs::read_to_string(path).ok()?;
2403 signature_in(&src.lines().collect::<Vec<_>>(), line)
2404}
2405
2406const SHOW_CONFIDENCE: f64 = 0.85;
2410
2411fn show_top_definition(
2415 hits: &mut [crate::search::Hit],
2416 query: &str,
2417 out: Output,
2418) -> Option<ExitCode> {
2419 let top = hits.first()?;
2420 if top.confidence < SHOW_CONFIDENCE {
2421 return None; }
2423 let end = top.end_line.unwrap_or(top.line);
2424 let body = top.root.as_deref().and_then(|root| {
2425 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2426 span_in(&src, top.line, end)
2427 });
2428 hits[0].body = body;
2429 let top = &hits[0];
2430 let code = match out {
2431 Output::Json | Output::Ndjson => {
2432 emit_json(out, top)
2434 }
2435 Output::Text => {
2436 let color = match_color();
2437 let c = color.as_deref();
2438 let name = hl(&top.name, query, c);
2439 let qualified = match &top.parent {
2440 Some(p) => format!("{name} · {p}"),
2441 None => name,
2442 };
2443 println!(
2444 "{}:{} {} {}",
2445 hl_path(&top.file, query, c),
2446 top.line,
2447 top.kind,
2448 qualified
2449 );
2450 match (&top.body, &top.signature) {
2451 (Some(body), _) => println!("{body}"),
2452 (None, Some(sig)) => println!("{sig}"),
2454 (None, None) => {}
2455 }
2456 ExitCode::SUCCESS
2457 }
2458 };
2459
2460 Some(code)
2461}
2462
2463fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2466 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2467 let lines: Vec<&str> = content.lines().collect();
2468 if s >= lines.len() {
2469 return None;
2470 }
2471 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2472 Some(lines[s..e].join("\n"))
2473}
2474
2475fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2480 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2481 let l = lines.get(idx)?.trim();
2482 (!l.is_empty()).then(|| l.to_string())
2483}
2484
2485#[derive(serde::Serialize)]
2489struct SymbolOut {
2490 name: String,
2491 kind: String,
2492 language: String,
2493 file: String,
2494 root: String,
2496 line: i64,
2497 #[serde(skip_serializing_if = "Option::is_none")]
2498 end_line: Option<i64>,
2499 #[serde(skip_serializing_if = "Option::is_none")]
2500 parent: Option<String>,
2501 #[serde(skip_serializing_if = "Option::is_none")]
2502 visibility: Option<String>,
2503 repo: String,
2504 #[serde(skip_serializing_if = "Option::is_none")]
2505 signature: Option<String>,
2506}
2507
2508fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2513 let open_span = crate::profile::span("store open");
2514 let mut store = match open_store() {
2515 Ok(s) => s,
2516 Err(e) => {
2517 return fail(
2518 out,
2519 Failure::Database,
2520 format_args!("rq: cannot open database: {e}"),
2521 );
2522 }
2523 };
2524 drop(open_span);
2525 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2526 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2527 let rel = repo_relative(&root, &cwd, file_arg);
2528
2529 let identity = resolve_identity(&store, &root);
2530 let coverage = store.coverage_status(&identity).ok().flatten();
2531 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2532 let current = store.repository_id(&identity).ok().flatten();
2533 let path = root.join(&rel);
2534 if !path.is_file() {
2535 return fail(
2536 out,
2537 Failure::NotFound,
2538 format_args!("rq --symbols: no such file: {file_arg}"),
2539 );
2540 }
2541 let indexable = path
2542 .extension()
2543 .and_then(|e| e.to_str())
2544 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2545 match current {
2546 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2550 if indexable {
2551 let _span = crate::profile::span("symbols: refresh");
2552 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2553 }
2554 }
2555 _ if warming_ok => {
2559 let budget = answer_warm_budget() + deferred_warm_budget();
2560 let _span = crate::profile::span("symbols: warm");
2561 let active = [rel.clone()];
2562 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2563 }
2564 _ => {}
2565 }
2566
2567 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2568 return emit_symbols(out, &[]); };
2570 let mut query_span = crate::profile::span("symbols: query");
2571 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2572 Ok(r) => r,
2573 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2574 };
2575 query_span.note(|| format!("{} rows", rows.len()));
2576 drop(query_span);
2577 if !kinds.is_empty() {
2578 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2579 }
2580 if !langs.is_empty() {
2581 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2582 }
2583
2584 let signatures_span = crate::profile::span("symbols: signatures");
2587 let content = std::fs::read_to_string(&path).ok();
2588 let lines: Vec<&str> = content
2589 .as_deref()
2590 .map_or_else(Vec::new, |c| c.lines().collect());
2591 let root_str = root.to_string_lossy().into_owned();
2592 let syms: Vec<SymbolOut> = rows
2593 .into_iter()
2594 .map(|r| SymbolOut {
2595 signature: signature_in(&lines, r.line),
2596 name: r.name,
2597 kind: r.kind,
2598 language: r.language,
2599 file: r.file,
2600 root: root_str.clone(),
2601 line: r.line,
2602 end_line: r.end_line,
2603 parent: r.parent,
2604 visibility: r.visibility,
2605 repo: r.repo_identity,
2606 })
2607 .collect();
2608 drop(signatures_span);
2609 let _span = crate::profile::span("render");
2610 emit_symbols(out, &syms)
2611}
2612
2613fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2616 if syms.is_empty() {
2617 match out {
2618 Output::Json | Output::Ndjson => {
2619 let obj = serde_json::json!({ "status": "no_match" });
2620 let _ = emit_json(out, &obj); }
2622 Output::Text => eprintln!("no symbols"),
2623 }
2624 return ExitCode::FAILURE;
2625 }
2626 if let Some(code) = emit_rows(out, syms) {
2627 return code;
2628 }
2629 match out {
2630 Output::Json | Output::Ndjson => {}
2631 Output::Text => {
2632 for s in syms {
2633 let qualified = match &s.parent {
2634 Some(p) => format!("{} · {p}", s.name),
2635 None => s.name.clone(),
2636 };
2637 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2638 if let Some(sig) = &s.signature {
2639 println!(" {sig}");
2640 }
2641 }
2642 }
2643 }
2644 ExitCode::SUCCESS
2645}
2646
2647fn keyword_kind(token: &str) -> Option<&'static str> {
2652 match token.to_ascii_lowercase().as_str() {
2653 "class" => Some("class"),
2654 "module" => Some("module"),
2655 "method" => Some("method"),
2656 "function" | "fn" => Some("function"),
2657 "struct" | "type" => Some("struct"),
2658 "enum" => Some("enum"),
2659 "trait" | "interface" => Some("trait"),
2660 "constant" | "const" => Some("constant"),
2661 _ => None,
2662 }
2663}
2664
2665fn split_kind_keyword(
2671 target: String,
2672 dirs: Vec<String>,
2673) -> (Option<&'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 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" | "type" => "struct",
2701 "e" | "enum" => "enum",
2702 "t" | "trait" | "interface" => "trait",
2703 "const" | "constant" => "constant",
2704 _ => return None,
2705 })
2706}
2707
2708fn canonical_langs(s: &str) -> Vec<String> {
2715 let t = s.to_ascii_lowercase();
2716 let alias = match t.as_str() {
2717 "rb" => Some("ruby"),
2718 "rs" => Some("rust"),
2719 "golang" => Some("go"),
2720 "ts" | "tsx" => Some("typescript"),
2721 "js" | "jsx" => Some("javascript"),
2722 _ => None,
2723 };
2724 let matched: Vec<String> = crate::lang::languages()
2725 .into_iter()
2726 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2727 .map(str::to_string)
2728 .collect();
2729 matched
2730}
2731
2732fn match_color() -> Option<String> {
2736 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2737 return None;
2738 }
2739 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2740 gc.split(':').find_map(|e| {
2741 e.strip_prefix("mt=")
2742 .or_else(|| e.strip_prefix("ms="))
2743 .filter(|v| !v.is_empty())
2744 .map(str::to_string)
2745 })
2746 });
2747 Some(style.unwrap_or_else(|| "1;31".to_string()))
2748}
2749
2750fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2753 match color {
2754 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2755 None => text.to_string(),
2756 }
2757}
2758
2759fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2762 let Some(c) = color else {
2763 return path.to_string();
2764 };
2765 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2766 let base_start = path[..base_byte].chars().count();
2767 let stem = crate::search::path_stem(path);
2771 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2772 .into_iter()
2773 .map(|p| p + base_start)
2774 .collect();
2775 highlight(path, &positions, c)
2776}
2777
2778fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2781 if positions.is_empty() {
2782 return text.to_string();
2783 }
2784 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2785 let mut out = String::new();
2786 let mut on = false;
2787 for (i, c) in text.chars().enumerate() {
2788 match (matched.contains(&i), on) {
2789 (true, false) => {
2790 out.push_str("\x1b[");
2791 out.push_str(color);
2792 out.push('m');
2793 on = true;
2794 }
2795 (false, true) => {
2796 out.push_str("\x1b[0m");
2797 on = false;
2798 }
2799 _ => {}
2800 }
2801 out.push(c);
2802 }
2803 if on {
2804 out.push_str("\x1b[0m");
2805 }
2806 out
2807}
2808
2809fn under_any(file: &str, paths: &[String]) -> bool {
2813 paths.iter().any(|p| {
2814 let p = p.trim_start_matches("./").trim_end_matches('/');
2815 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2816 })
2817}
2818
2819fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2821 let p = std::path::Path::new(file);
2822 let abs = if p.is_absolute() {
2823 p.to_path_buf()
2824 } else {
2825 cwd.join(p)
2826 };
2827 let abs = abs.canonicalize().unwrap_or(abs);
2828 abs.strip_prefix(root)
2829 .map(|r| r.to_string_lossy().into_owned())
2830 .unwrap_or_else(|_| file.to_string())
2831}
2832
2833fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2838 use std::collections::HashSet;
2839 let mut seen = HashSet::new();
2840 let mut changed = false;
2841 for hit in hits {
2842 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2843 continue;
2844 }
2845 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2846 continue;
2847 };
2848 let roots = hit_file_roots(store, &hit.repo_identity, here);
2849 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2850 continue;
2851 };
2852 if let Ok(crate::index::Refresh::Updated) =
2853 crate::index::refresh_file(store, repo_id, root, &hit.file)
2854 {
2855 changed = true;
2856 }
2857 }
2858 changed
2859}
2860
2861fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2867 if let Ok(canon) = cwd.canonicalize() {
2868 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2869 return identity;
2870 }
2871 if crate::index::repo_root(cwd).is_none() {
2872 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2873 }
2874 }
2875 crate::index::detect_identity(cwd).to_string()
2876}
2877
2878fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2879 let explicit = path.is_some();
2880 let target = path.unwrap_or_else(|| PathBuf::from("."));
2881 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2886 let mut subdirs = subdirs.to_vec();
2891 if explicit
2892 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2893 && t != r
2894 && let Ok(rel) = t.strip_prefix(&r)
2895 && !rel.as_os_str().is_empty()
2896 {
2897 subdirs.push(rel.to_string_lossy().into_owned());
2898 }
2899 let open_span = crate::profile::span("store open");
2900 let mut store = match open_store() {
2901 Ok(s) => s,
2902 Err(e) => {
2903 return fail(
2904 out,
2905 Failure::Database,
2906 format_args!("rq: cannot open database: {e}"),
2907 );
2908 }
2909 };
2910 drop(open_span);
2911 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2912 let identity = resolve_identity(&store, &root);
2915 match indexed {
2916 Ok(stats) => {
2917 let subtree = !subdirs.is_empty();
2918 let totals = store
2920 .repository_id(&identity)
2921 .ok()
2922 .flatten()
2923 .and_then(|id| store.repo_totals(id).ok());
2924 match out {
2925 Output::Json | Output::Ndjson => {
2926 let (files, symbols) = match totals {
2927 Some((f, s)) => (Some(f), Some(s)),
2928 None => (None, None),
2929 };
2930 return emit_json(
2931 out,
2932 &serde_json::json!({
2933 "repo": identity,
2934 "scope": if subtree { "subtree" } else { "full" },
2935 "files_added": stats.files_indexed,
2936 "symbols_added": stats.symbols,
2937 "files": files,
2938 "symbols": symbols,
2939 }),
2940 );
2941 }
2942 Output::Text => {
2943 let scope = if subtree { " (subtree seed)" } else { "" };
2944 match totals {
2945 Some((files, symbols)) => println!(
2946 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2947 stats.files_indexed, stats.symbols
2948 ),
2949 None => println!(
2950 "{} file(s)/{} symbol(s) added this run{scope}",
2951 stats.files_indexed, stats.symbols
2952 ),
2953 }
2954 }
2955 }
2956 ExitCode::SUCCESS
2957 }
2958 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2959 }
2960}
2961
2962fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2963 let mut store = match open_store() {
2964 Ok(s) => s,
2965 Err(e) => {
2966 return fail(
2967 out,
2968 Failure::Database,
2969 format_args!("rq: cannot open database: {e}"),
2970 );
2971 }
2972 };
2973
2974 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2978 let root = crate::index::repo_root(&path).unwrap_or(path);
2979 let from_path = crate::index::detect_identity(&root).to_string();
2980 let resolved = match store.repository_id(&from_path) {
2981 Ok(Some(id)) => Some((from_path.clone(), id)),
2982 Ok(None) => target.as_deref().and_then(|s| {
2983 store
2984 .repository_id(s)
2985 .ok()
2986 .flatten()
2987 .map(|id| (s.to_string(), id))
2988 }),
2989 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2990 };
2991
2992 let Some((identity, repo_id)) = resolved else {
2993 return match out {
2995 Output::Text => {
2996 println!("not indexed: {from_path}");
2997 ExitCode::SUCCESS
2998 }
2999 _ => emit_json(
3000 out,
3001 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
3002 ),
3003 };
3004 };
3005
3006 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
3007 match store.drop_repository(repo_id) {
3008 Ok(()) => match out {
3009 Output::Text => {
3010 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
3011 ExitCode::SUCCESS
3012 }
3013 _ => emit_json(
3014 out,
3015 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
3016 ),
3017 },
3018 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
3019 }
3020}
3021
3022fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
3026 let rendered = if out == Output::Json {
3027 serde_json::to_string_pretty(value)
3028 } else {
3029 serde_json::to_string(value)
3030 };
3031 match rendered {
3032 Ok(s) => {
3033 println!("{s}");
3034 ExitCode::SUCCESS
3035 }
3036 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
3037 }
3038}
3039
3040fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
3044 match out {
3045 Output::Json => match serde_json::to_string_pretty(rows) {
3046 Ok(s) => println!("{s}"),
3047 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3048 },
3049 Output::Ndjson => {
3050 for r in rows {
3051 match serde_json::to_string(r) {
3052 Ok(line) => println!("{line}"),
3053 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3054 }
3055 }
3056 }
3057 Output::Text => {}
3058 }
3059 None
3060}
3061
3062fn cmd_status(out: Output) -> ExitCode {
3063 let store = match open_store() {
3064 Ok(s) => s,
3065 Err(e) => {
3066 return fail(
3067 out,
3068 Failure::Database,
3069 format_args!("rq: cannot open database: {e}"),
3070 );
3071 }
3072 };
3073 let rows = match store.coverage_overview() {
3074 Ok(rows) => rows,
3075 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
3076 };
3077 if let Some(code) = emit_rows(out, &rows) {
3078 return code;
3079 }
3080 match out {
3081 Output::Json | Output::Ndjson => {}
3082 Output::Text if rows.is_empty() => {
3083 println!("no repositories indexed yet (try `rq --index`)");
3084 }
3085 Output::Text => {
3086 for r in &rows {
3087 println!(
3088 "{:<10} {:>6} files {:>7} symbols {}",
3089 r.status, r.files, r.symbols, r.identity
3090 );
3091 }
3092 }
3093 }
3094 ExitCode::SUCCESS
3095}
3096
3097fn cmd_usage(out: Output) -> ExitCode {
3100 let store = match open_store() {
3101 Ok(s) => s,
3102 Err(e) => {
3103 return fail(
3104 out,
3105 Failure::Database,
3106 format_args!("rq: cannot open database: {e}"),
3107 );
3108 }
3109 };
3110 let rows = match store.usage_overview() {
3111 Ok(rows) => rows,
3112 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3113 };
3114 if let Some(code) = emit_rows(out, &rows) {
3115 return code;
3116 }
3117 match out {
3118 Output::Json | Output::Ndjson => {}
3119 Output::Text if rows.is_empty() => {
3120 println!("no usage recorded yet");
3121 }
3122 Output::Text => {
3123 println!(
3126 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3127 "day", "caller", "found", "missed", "warming"
3128 );
3129 for r in &rows {
3130 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3131 println!(
3132 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3133 r.day,
3134 r.source,
3135 r.searches - r.misses - r.warming,
3136 r.misses,
3137 r.warming,
3138 flags
3139 );
3140 }
3141 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3142 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3143 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3144 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3145 let live: i64 = rows.iter().map(|r| r.live).sum();
3146 let plural = if searches == 1 { "search" } else { "searches" };
3147 let live = if live > 0 {
3149 format!(" · {live} from a live scan")
3150 } else {
3151 String::new()
3152 };
3153 println!(
3156 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index{live}"
3157 );
3158 }
3159 }
3160 if rows.is_empty() {
3162 return ExitCode::from(1);
3163 }
3164 ExitCode::SUCCESS
3165}
3166
3167fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3169 let path = db_path()?;
3170 if let Some(parent) = path.parent() {
3171 std::fs::create_dir_all(parent)?;
3172 }
3173 Ok(Store::open(&path)?)
3174}
3175
3176fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3178 if let Ok(p) = std::env::var("RQ_DB") {
3179 return Ok(PathBuf::from(p));
3180 }
3181 let home = std::env::var("HOME")?;
3182 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3183}
3184
3185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3187enum Failure {
3188 Usage,
3190 Database,
3192 NotFound,
3194 NoRemote,
3196 Launch,
3198 Index,
3200 Internal,
3202}
3203
3204impl Failure {
3205 fn as_str(self) -> &'static str {
3206 match self {
3207 Failure::Usage => "usage",
3208 Failure::Database => "database",
3209 Failure::NotFound => "not_found",
3210 Failure::NoRemote => "no_remote",
3211 Failure::Launch => "launch",
3212 Failure::Index => "index",
3213 Failure::Internal => "internal",
3214 }
3215 }
3216
3217 fn exit_code(self) -> u8 {
3221 match self {
3222 Failure::Usage => 64, Failure::NotFound => 66, Failure::NoRemote | Failure::Launch => 69, Failure::Internal => 70, Failure::Database | Failure::Index => 74, }
3228 }
3229}
3230
3231fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3234 let message = args.to_string();
3235 eprintln!("{message}");
3236 emit_error(out, kind, &message);
3237 ExitCode::from(kind.exit_code())
3238}
3239
3240fn emit_error(out: Output, kind: Failure, message: &str) {
3243 let obj = serde_json::json!({
3244 "error": message,
3245 "kind": kind.as_str(),
3246 "code": kind.exit_code(),
3247 });
3248 let rendered = match out {
3250 Output::Text => return,
3251 Output::Json => serde_json::to_string_pretty(&obj),
3252 Output::Ndjson => serde_json::to_string(&obj),
3253 };
3254 if let Ok(s) = rendered {
3255 println!("{s}");
3256 }
3257}
3258
3259fn clap_failure(err: clap::Error) -> ExitCode {
3264 if !err.use_stderr() {
3266 err.exit();
3267 }
3268 let _ = err.print();
3269 let out = requested_output(std::env::args_os().skip(1));
3270 let text = err.to_string();
3271 emit_error(out, Failure::Usage, text.lines().next().unwrap_or(""));
3272 ExitCode::from(Failure::Usage.exit_code())
3273}
3274
3275fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3280 let cmd = Cli::command();
3281 let takes_value = |c: char| {
3282 cmd.get_arguments()
3283 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3284 };
3285 let (mut json, mut ndjson) = (false, false);
3286 for arg in args {
3287 let arg = arg.to_string_lossy();
3288 match arg.as_ref() {
3289 "--" => break,
3290 "--json" => json = true,
3291 "--ndjson" => ndjson = true,
3292 a if a.starts_with('-') && !a.starts_with("--") => {
3293 for c in a.chars().skip(1) {
3294 match c {
3295 'j' => json = true,
3296 'J' => ndjson = true,
3297 c if takes_value(c) => break,
3298 _ => {}
3299 }
3300 }
3301 }
3302 _ => {}
3303 }
3304 }
3305 if ndjson {
3307 Output::Ndjson
3308 } else if json {
3309 Output::Json
3310 } else {
3311 Output::Text
3312 }
3313}
3314
3315#[cfg(test)]
3316mod tests {
3317 use super::*;
3318
3319 #[test]
3320 fn parses_an_anchor_from_the_right() {
3321 let ok = |file: &str, line| {
3322 Ok(AnchorSpec {
3323 file: PathBuf::from(file),
3324 line,
3325 })
3326 };
3327 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3328 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3329 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3330 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3331 assert!(parse_anchor(bad).is_err(), "{bad}");
3332 }
3333 }
3334
3335 #[test]
3336 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3337 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3338 for (args, want) in [
3339 (&["x", "--json"][..], Output::Json),
3340 (&["x", "--ndjson"], Output::Ndjson),
3341 (&["x", "-ej"], Output::Json),
3342 (&["-Je", "x"], Output::Ndjson),
3343 (&["x", "--json", "-J"], Output::Ndjson),
3344 (&["x"], Output::Text),
3345 (&["x", "-xj"], Output::Text),
3347 (&["x", "-l5j"], Output::Text),
3348 (&["--", "-j"], Output::Text),
3350 (&["x", "--jobs", "2"], Output::Text),
3351 ] {
3352 assert!(mode(args) == want, "{args:?}");
3353 }
3354 }
3355
3356 #[test]
3357 fn open_menu_choice_parsing() {
3358 assert_eq!(parse_choice("\n", 5), Some(0));
3360 assert_eq!(parse_choice(" ", 5), Some(0));
3361 assert_eq!(parse_choice("3", 5), Some(2));
3362 assert_eq!(parse_choice("5", 5), Some(4));
3363 assert_eq!(parse_choice("6", 5), None);
3365 assert_eq!(parse_choice("0", 5), None);
3366 assert_eq!(parse_choice("q", 5), None);
3367 }
3368
3369 #[test]
3370 fn web_url_shape() {
3371 assert_eq!(
3372 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3373 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3374 );
3375 }
3376
3377 #[test]
3378 fn wait_duration_parsing() {
3379 use std::time::Duration;
3380 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3382 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3383 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3384 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3385 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3387 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3388 assert!(parse_wait("0s").unwrap().is_zero());
3389 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3391 assert!(parse_wait("2x").is_err());
3393 assert!(parse_wait("").is_err());
3394 assert!(parse_wait("s").is_err());
3395 assert!(parse_wait("-1s").is_err());
3396 }
3397
3398 #[test]
3399 fn leading_kind_keyword_becomes_a_kind_filter() {
3400 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3401 assert_eq!(
3403 split_kind_keyword("class".into(), d(&["Widget"])),
3404 (Some("class"), "Widget".into(), vec![])
3405 );
3406 assert_eq!(
3408 split_kind_keyword("method zoom".into(), vec![]),
3409 (Some("method"), "zoom".into(), vec![])
3410 );
3411 assert_eq!(
3413 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3414 (Some("function"), "Foo::run".into(), vec![])
3415 );
3416 assert_eq!(
3418 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3419 (Some("struct"), "Gadget".into(), d(&["src"]))
3420 );
3421 }
3422
3423 #[test]
3424 fn a_bare_or_non_keyword_query_is_left_alone() {
3425 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3426 assert_eq!(
3428 split_kind_keyword("class".into(), vec![]),
3429 (None, "class".into(), vec![])
3430 );
3431 assert_eq!(
3433 split_kind_keyword("Widget".into(), d(&["app"])),
3434 (None, "Widget".into(), d(&["app"]))
3435 );
3436 assert_eq!(
3438 split_kind_keyword("c".into(), d(&["Foo"])),
3439 (None, "c".into(), d(&["Foo"]))
3440 );
3441 }
3442
3443 #[test]
3444 fn the_branch_window_scales_with_what_the_refresh_costs() {
3445 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3448 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3449 assert_eq!(branch_files_ttl(Some(700)), 70);
3453 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3454 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3457 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3458 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3460 }
3461
3462 #[test]
3463 fn a_language_selects_by_prefix_or_alias() {
3464 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3466 assert_eq!(canonical_langs("t"), ["typescript"]);
3467 assert_eq!(canonical_langs("ts"), ["typescript"]);
3469 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3470 assert_eq!(canonical_langs("rb"), ["ruby"]);
3471 assert!(canonical_langs("COBOL").is_empty());
3474 }
3475
3476 #[test]
3477 fn a_kind_normalizes_language_specific_spellings() {
3478 assert_eq!(canonical_kind("f"), Some("function"));
3479 assert_eq!(canonical_kind("interface"), Some("trait"));
3481 assert_eq!(canonical_kind("type"), Some("struct"));
3482 assert_eq!(canonical_kind("const"), Some("constant"));
3483 assert_eq!(canonical_kind("banana"), None);
3484 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3486 assert_eq!(
3487 split_kind_keyword("interface".into(), d(&["Renderer"])),
3488 (Some("trait"), "Renderer".into(), vec![])
3489 );
3490 }
3491
3492 #[test]
3493 fn highlight_wraps_matched_runs() {
3494 assert_eq!(
3495 highlight("FooThing", &[0, 1, 2], "1;31"),
3496 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3497 );
3498 assert_eq!(
3500 highlight("FooThing", &[0, 3], "1"),
3501 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3502 );
3503 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3505 }
3506
3507 #[test]
3508 fn progress_ui_only_for_an_interactive_text_terminal() {
3509 assert!(show_progress(Output::Text, true));
3511
3512 assert!(!show_progress(Output::Json, true));
3514 assert!(!show_progress(Output::Ndjson, true));
3515
3516 assert!(!show_progress(Output::Text, false));
3518 }
3519
3520 #[test]
3521 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3522 let lines = ["class Widget", "", " def go"];
3523 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3524 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3525 assert_eq!(signature_in(&lines, 2), None, "blank line");
3526 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3527 assert_eq!(signature_in(&lines, 4), None, "past the end");
3528 }
3529
3530 #[test]
3531 fn repo_label_uses_the_directory_name() {
3532 assert_eq!(
3533 repo_label(Some(std::path::Path::new("/src/widgets"))),
3534 "widgets"
3535 );
3536 assert_eq!(repo_label(None), "repo");
3537 }
3538
3539 #[test]
3540 fn hl_path_highlights_the_stem_not_the_extension() {
3541 let out = hl_path(
3544 "app/employees_controller.rb",
3545 "employeescontroller",
3546 Some("1;31"),
3547 );
3548 assert!(
3549 out.starts_with("app/\u{1b}[1;31memployees"),
3550 "stem highlighted: {out:?}"
3551 );
3552 assert!(
3553 out.ends_with("controller\u{1b}[0m.rb"),
3554 "`.rb` left un-highlighted: {out:?}"
3555 );
3556 }
3557}