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; --status, --index, --drop: ran, even with nothing to show or drop\n \
591 no match; --usage: nothing recorded yet\n \
602 no match yet: the index is still warming, or indexing was interrupted — ask again\n \
6164 usage error: a bad flag, value or query — fix the command\n \
6266 a file the command names doesn't exist\n \
6369 no editor, browser or git remote to hand off to\n \
6470 internal error (a bug)\n \
6574 the index can't be opened, read or written"
66)]
67struct Cli {
68 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
74 target: Option<String>,
75
76 #[arg(value_name = "PATH")]
78 dirs: Vec<String>,
79
80 #[arg(short = 'e', long)]
82 explain: bool,
83
84 #[arg(long = "no-wait")]
91 no_wait: bool,
92
93 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
98 wait: Option<Duration>,
99
100 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "json", "ndjson"])]
106 open: bool,
107
108 #[arg(short = 'w', long, conflicts_with_all = ["open", "index", "status", "json", "ndjson"])]
113 web: bool,
114
115 #[arg(long, conflicts_with_all = ["open", "web", "index", "status", "symbols", "drop"])]
119 show: bool,
120
121 #[arg(short = 'j', long)]
123 json: bool,
124
125 #[arg(short = 'J', long, conflicts_with = "json")]
127 ndjson: bool,
128
129 #[arg(short = 'p', long, value_name = "DIR")]
131 path: Vec<String>,
132
133 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
135 limit: usize,
136
137 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
141 kind: Vec<String>,
142
143 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
147 lang: Vec<String>,
148
149 #[arg(short = 'a', long = "all-repos")]
152 all_repos: bool,
153
154 #[arg(long, value_name = "FILE:LINE[:COL]", value_parser = parse_anchor, conflicts_with_all = ["index", "status", "usage", "symbols", "drop", "warm"])]
160 anchor: Option<AnchorSpec>,
161
162 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with = "status")]
164 index: Option<Option<String>>,
165
166 #[arg(long, conflicts_with = "index")]
168 status: bool,
169
170 #[arg(long, conflicts_with_all = ["index", "status"])]
172 usage: bool,
173
174 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "drop", "open", "web"])]
177 symbols: Option<String>,
178
179 #[arg(long, conflicts_with_all = ["index", "status", "open", "web"])]
183 drop: bool,
184
185 #[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"])]
189 warm: Option<Option<String>>,
190
191 #[arg(long, value_name = "SHELL")]
193 completions: Option<Shell>,
194
195 #[arg(short = 'v', long)]
198 verbose: bool,
199
200 #[arg(long)]
204 profile: bool,
205
206 #[arg(long, value_name = "N", default_value_t = 0)]
209 jobs: usize,
210}
211
212pub fn run() -> ExitCode {
214 let cli = match Cli::try_parse() {
215 Ok(cli) => cli,
216 Err(err) => return clap_failure(err),
217 };
218 crate::trace::enable_from(cli.verbose);
219 crate::profile::enable_from(cli.profile);
220 crate::index::set_parse_jobs(cli.jobs);
221 let json_out = output_format(&cli) != Output::Text;
222 let code = dispatch(cli);
223 crate::profile::emit(json_out);
224 code
225}
226
227fn dispatch(cli: Cli) -> ExitCode {
228 if let Some(shell) = cli.completions {
229 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
230 return ExitCode::SUCCESS;
231 }
232 if let Err(message) = db_location() {
233 return fail(
234 output_format(&cli),
235 Failure::Usage,
236 format_args!("{message}"),
237 );
238 }
239 if let Some(path) = &cli.index {
240 let out = output_format(&cli);
242 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
243 }
244 if let Some(path) = &cli.warm {
245 return cmd_warm(path.as_deref());
246 }
247 if cli.status {
248 return cmd_status(output_format(&cli));
249 }
250 if cli.usage {
251 return cmd_usage(output_format(&cli));
252 }
253 if cli.drop {
254 let out = output_format(&cli);
255 return cmd_drop(cli.target, out);
256 }
257 let out = output_format(&cli);
258 if cli.target.as_deref().is_some_and(|t| t.trim().is_empty()) {
259 return fail(out, Failure::Usage, format_args!("rq: empty query"));
260 }
261 let mut kinds: Vec<String> = Vec::new();
265 for k in &cli.kind {
266 match canonical_kind(k) {
267 Some(c) => kinds.push(c.to_string()),
268 None => {
269 return fail(
270 out,
271 Failure::Usage,
272 format_args!(
273 "rq: unknown --kind {k:?} (class, module, method, function, struct, enum, trait, constant)"
274 ),
275 );
276 }
277 }
278 }
279 let mut langs: Vec<String> = Vec::new();
281 for x in &cli.lang {
282 let matched = canonical_langs(x);
283 if matched.is_empty() {
284 return fail(
285 out,
286 Failure::Usage,
287 format_args!(
288 "rq: unknown --lang {x:?} ({})",
289 crate::lang::languages().join(", ")
290 ),
291 );
292 }
293 langs.extend(matched);
294 }
295 if let Some(file) = &cli.symbols {
296 return cmd_symbols(file, &kinds, &langs, out);
297 }
298 let mut paths = cli.path.clone();
300 match cli.target {
301 Some(target) => {
302 let query = if cli.kind.is_empty() {
305 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
306 if let Some(k) = kw {
307 kinds.push(k.to_string());
308 }
309 paths.extend(dirs);
310 query
311 } else {
312 paths.extend(cli.dirs.clone());
313 target
314 };
315 let mut session = match Session::open(out) {
316 Ok(s) => s,
317 Err(code) => return code,
318 };
319 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
320 cmd_search(
321 &mut session,
322 &SearchArgs {
323 query: &query,
324 explain: cli.explain,
325 out,
326 paths: &paths,
327 kinds: &kinds,
328 langs: &langs,
329 want: requested_limit(cli.limit),
330 no_wait: cli.no_wait,
331 wait: cli.wait,
332 open: cli.open,
333 web: cli.web,
334 all_repos: cli.all_repos,
335 show: cli.show,
336 batch: false,
337 anchored: cli.anchor.is_some(),
338 },
339 )
340 }
341 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
344 None => {
346 let _ = Cli::command().print_long_help();
347 ExitCode::SUCCESS
348 }
349 }
350}
351
352#[derive(Clone, Copy, PartialEq)]
354enum Output {
355 Text,
356 Json,
357 Ndjson,
358}
359
360fn output_format(cli: &Cli) -> Output {
361 if cli.ndjson {
362 Output::Ndjson
363 } else if cli.json {
364 Output::Json
365 } else {
366 Output::Text
367 }
368}
369
370const DEFAULT_LIMIT: usize = 10;
372
373const PATH_HEADROOM: usize = 200;
376
377fn requested_limit(limit: usize) -> usize {
380 if limit == 0 { usize::MAX } else { limit }
381}
382
383fn record_usage(
386 store: &Store,
387 args: &SearchArgs,
388 status: &str,
389 coverage: Option<&str>,
390 live: bool,
391) {
392 let _ = store.record_search(&crate::store::SearchRecord {
393 source: &crate::origin::detect(),
394 flags: &flag_summary(args),
395 status,
396 coverage: coverage.unwrap_or("none"),
397 live,
398 });
399}
400
401fn flag_summary(args: &SearchArgs) -> String {
406 let mut on: Vec<&str> = Vec::new();
407 match args.out {
408 Output::Json => on.push("json"),
409 Output::Ndjson => on.push("ndjson"),
410 Output::Text => {}
411 }
412 for (present, name) in [
413 (args.explain, "explain"),
414 (args.show, "show"),
415 (args.open, "open"),
416 (args.web, "web"),
417 (args.all_repos, "all-repos"),
418 (args.no_wait, "no-wait"),
419 (args.batch, "batch"),
420 (args.anchored, "anchor"),
421 (!args.paths.is_empty(), "path"),
422 (!args.kinds.is_empty(), "kind"),
423 (!args.langs.is_empty(), "lang"),
424 (args.want != DEFAULT_LIMIT, "limit"),
425 ] {
426 if present {
427 on.push(name);
428 }
429 }
430 on.join(",")
431}
432
433const POLL_INTERVAL: Duration = Duration::from_millis(100);
440
441const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
445
446const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
450
451struct SearchArgs<'a> {
453 query: &'a str,
454 explain: bool,
455 out: Output,
456 paths: &'a [String],
457 kinds: &'a [String],
458 langs: &'a [String],
459 want: usize,
461 no_wait: bool,
463 wait: Option<Duration>,
466 open: bool,
467 web: bool,
468 all_repos: bool,
469 batch: bool,
473 show: bool,
474 anchored: bool,
476}
477
478struct Session {
489 store: Store,
490 cwd: Option<PathBuf>,
491 cwd_is_git: bool,
492 root: Option<PathBuf>,
493 active_paths: Vec<String>,
494 branch_refresh: Option<BranchRefresh>,
495 identity: Option<String>,
496 coverage: Option<String>,
497 anchor: Option<crate::search::Anchor>,
499}
500
501impl Session {
502 fn open(out: Output) -> std::result::Result<Session, ExitCode> {
504 let open_span = crate::profile::span("store open");
505 let store = match open_store() {
506 Ok(s) => s,
507 Err(e) => {
508 return Err(fail(
509 out,
510 Failure::Database,
511 format_args!("rq: cannot open database: {e}"),
512 ));
513 }
514 };
515 drop(open_span);
516 let git_span = crate::profile::span("setup: git root");
517 let cwd = std::env::current_dir().ok();
518 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
519
520 let root = cwd
526 .as_deref()
527 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
528 drop(git_span);
529
530 let mut branch_span = crate::profile::span("setup: branch files");
533 let (active_paths, branch_refresh, cached_cost) = match &root {
534 Some(c) if cwd_is_git => cached_branch_files(&store, c),
535 _ => (Vec::new(), None, None),
536 };
537 branch_span.note(|| {
538 let how = if branch_refresh.is_some() {
539 "cached, refreshing alongside"
540 } else {
541 "cached"
542 };
543 let ttl = branch_files_ttl(cached_cost);
546 format!("{} changed, {how} ({ttl}s window)", active_paths.len())
547 });
548 drop(branch_span);
549
550 let mut identity_span = crate::profile::span("setup: identity");
555 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
556 let coverage = identity
557 .as_deref()
558 .and_then(|id| store.coverage_status(id).ok())
559 .flatten();
560 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
561 drop(identity_span);
562 Ok(Session {
563 store,
564 cwd,
565 cwd_is_git,
566 root,
567 active_paths,
568 branch_refresh,
569 identity,
570 coverage,
571 anchor: None,
572 })
573 }
574
575 fn anchor_at(&self, spec: &AnchorSpec) -> crate::search::Anchor {
579 let _span = crate::profile::span("setup: anchor");
580 let here = self.cwd.clone().unwrap_or_else(|| PathBuf::from("."));
581 let abs = here.join(&spec.file);
582 let abs = abs.canonicalize().unwrap_or(abs);
583 let root = self
584 .root
585 .clone()
586 .filter(|r| abs.starts_with(r))
587 .or_else(|| crate::index::repo_root(&abs))
588 .or_else(|| abs.parent().map(PathBuf::from))
589 .unwrap_or(here);
590 let identity = resolve_identity(&self.store, &root);
591 let rel = abs
592 .strip_prefix(&root)
593 .map_or_else(|_| spec.file.to_string_lossy(), |r| r.to_string_lossy())
594 .into_owned();
595 let repo_id = self.store.repository_id(&identity).ok().flatten();
596 let defs = crate::index::current_definitions(&self.store, repo_id, &identity, &root, &rel);
597 crate::search::Anchor::new(identity, rel, spec.line, &defs)
598 }
599}
600
601#[derive(Debug, Clone, PartialEq)]
603struct AnchorSpec {
604 file: PathBuf,
605 line: i64,
606}
607
608fn parse_anchor(s: &str) -> Result<AnchorSpec, String> {
611 let num = |t: &str| t.parse::<i64>().ok().filter(|n| *n > 0);
612 let bad = || format!("expected FILE:LINE[:COL], got {s:?}");
613 let (rest, last) = s.rsplit_once(':').ok_or_else(bad)?;
614 let last = num(last).ok_or_else(bad)?;
615 let (file, line) = rest
617 .rsplit_once(':')
618 .and_then(|(file, line)| num(line).map(|line| (file, line)))
619 .unwrap_or((rest, last));
620 if file.is_empty() {
621 return Err(bad());
622 }
623 Ok(AnchorSpec {
624 file: PathBuf::from(file),
625 line,
626 })
627}
628
629fn cmd_batch(
644 cli: &Cli,
645 out: Output,
646 paths: &[String],
647 kinds: &[String],
648 langs: &[String],
649) -> ExitCode {
650 if out == Output::Json {
651 return fail(
652 out,
653 Failure::Usage,
654 format_args!(
655 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
656 where each line carries the query it answers"
657 ),
658 );
659 }
660 if cli.open || cli.web || cli.show {
661 return fail(
662 out,
663 Failure::Usage,
664 format_args!(
665 "rq: --open, --web and --show act on a single result, not a stream of queries"
666 ),
667 );
668 }
669
670 use std::io::BufRead;
671 let queries: Vec<String> = std::io::stdin()
672 .lock()
673 .lines()
674 .map_while(std::result::Result::ok)
675 .map(|l| l.trim().to_string())
676 .filter(|l| !l.is_empty())
677 .collect();
678 if queries.is_empty() {
682 let _ = Cli::command().print_long_help();
683 return ExitCode::SUCCESS;
684 }
685
686 let mut session = match Session::open(out) {
687 Ok(s) => s,
688 Err(code) => return code,
689 };
690 session.anchor = cli.anchor.as_ref().map(|a| session.anchor_at(a));
691
692 if !cli.no_wait
695 && session.coverage.as_deref() != Some("complete")
696 && let Some(root) = session.root.clone()
697 {
698 {
699 let budget = cli.wait.unwrap_or_else(wait_budget);
700 crate::trace!(
701 "batch: warming {} queries' worth of index first",
702 queries.len()
703 );
704 let active = session.active_paths.clone();
705 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
706 session.coverage = session
707 .identity
708 .as_deref()
709 .and_then(|id| session.store.coverage_status(id).ok())
710 .flatten();
711 }
712 }
713
714 let mut worst = ExitCode::SUCCESS;
715 let mut any_hit = false;
716 for query in &queries {
717 let code = cmd_search(
718 &mut session,
719 &SearchArgs {
720 query,
721 explain: cli.explain,
722 out,
723 paths,
724 kinds,
725 langs,
726 want: requested_limit(cli.limit),
727 no_wait: true,
731 wait: cli.wait,
732 open: false,
733 web: false,
734 all_repos: cli.all_repos,
735 show: false,
736 batch: true,
737 anchored: cli.anchor.is_some(),
738 },
739 );
740 if code == ExitCode::SUCCESS {
741 any_hit = true;
742 } else {
743 worst = code;
744 }
745 }
746 if any_hit { ExitCode::SUCCESS } else { worst }
749}
750
751fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
752 let &SearchArgs {
753 query,
754 out,
755 want,
756 no_wait,
757 wait,
758 open,
759 web,
760 all_repos,
761 show,
762 ..
763 } = args;
764 let wait_budget = wait.unwrap_or_else(wait_budget);
767 let no_wait = no_wait || wait_budget.is_zero();
768 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
771 want
772 } else {
773 want.saturating_mul(20).max(PATH_HEADROOM)
774 };
775 let _timer = crate::trace::Timer::start("search done");
776 let t_setup = std::time::Instant::now();
777 let setup_span = crate::profile::span("setup");
779 let Session {
782 store,
783 cwd,
784 cwd_is_git,
785 root,
786 active_paths,
787 branch_refresh,
788 identity,
789 coverage,
790 anchor,
791 } = session;
792 let cwd_is_git = *cwd_is_git;
793
794 let known = coverage.is_some();
802 let warming_ok = cwd_is_git || known;
803 if crate::trace::enabled() {
804 crate::trace!(
805 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
806 root.as_deref().map_or("?".into(), crate::trace::abbrev),
807 identity.as_deref().unwrap_or("none"),
808 coverage.as_deref().unwrap_or("none"),
809 active_paths.len(),
810 );
811 }
812 let repo_span = crate::profile::span("setup: repo state");
813 let repo_id = |store: &Store| {
814 identity
815 .as_deref()
816 .and_then(|id| store.repository_id(id).ok().flatten())
817 };
818 let mut current = repo_id(store);
819 let scope = |current| Scope::new(all_repos, warming_ok, current);
822 let ctx = crate::search::Context {
823 active: crate::search::ActiveFiles::new(active_paths.clone()),
824 anchor: anchor.clone(),
825 };
826
827 drop(repo_span);
828 let warm_span = crate::profile::span("setup: warm decision");
829
830 let warm_budget = if warm_detach_enabled() {
837 answer_warm_budget()
838 } else {
839 answer_warm_budget() + deferred_warm_budget()
840 };
841 let was_warming = coverage.as_deref() != Some("complete");
842
843 let indexed_head = (!was_warming)
854 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
855 .flatten();
856 let staleness = (!was_warming && warming_ok && !args.batch)
859 .then(|| root.clone())
860 .flatten()
861 .map(|c| {
862 if warm_detach_enabled() {
863 Staleness::Deferred(c, indexed_head)
864 } else {
865 Staleness::Running(std::thread::spawn(move || {
866 worktree_edits(&c, indexed_head.as_deref())
867 }))
868 }
869 });
870 let want_warm = warming_ok && was_warming && root.is_some();
873
874 let block = want_warm && was_warming && !no_wait;
888 let progress_ui = block && show_progress(out, stderr_interactive());
893 let indexer_budget = if block { wait_budget } else { warm_budget };
894 if progress_ui {
895 install_interrupt_handler();
896 }
897
898 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
901 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
902 crate::trace!(
903 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
904 crate::index::parse_jobs()
905 );
906 let root = root.clone().expect("checked");
907 let active = active_paths.clone();
908 let q = query.to_string();
909 let warm_done = std::sync::Arc::clone(&warm_done);
910 std::thread::spawn(move || {
911 if let Ok(mut idx) = open_store() {
912 let _ = if block {
914 crate::index::index_budgeted_cancellable(
917 &mut idx,
918 &root,
919 &active,
920 indexer_budget,
921 Some(&q),
922 &INTERRUPTED,
923 )
924 } else {
925 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
926 };
927 }
928 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
929 })
930 });
931
932 crate::trace!(
939 "setup (open + repo detect + warm decision): {} ms",
940 t_setup.elapsed().as_millis()
941 );
942 let poll_start = std::time::Instant::now();
943 let deadline = if progress_ui {
947 None
948 } else if block {
949 Some(poll_start + wait_budget)
950 } else {
951 Some(poll_start + answer_warm_budget())
952 };
953 drop(warm_span);
954 let polling = indexer.is_some() && was_warming;
955 drop(setup_span);
959 let mut query_span = crate::profile::span("query");
960 let label = repo_label(root.as_deref());
961 let mut drew_progress = false;
962 let mut last_draw = poll_start;
963 let rank_limit = limit.max(2);
967 let mut total;
968 let mut hits = loop {
969 if current.is_none() {
971 current = repo_id(store);
972 }
973 match scope(current).search(store, query, current, &ctx, rank_limit) {
974 Ok(m) => {
975 total = m.total;
976 let h = m.hits;
977 let confident = h.first().is_some_and(|hit| {
978 hit.features
979 .iter()
980 .any(|f| matches!(f.name, "exact" | "prefix"))
981 });
982 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
983 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
984 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
985 if !polling || confident || warm_finished || stopped || timed_out {
986 break h;
987 }
988 if progress_ui
989 && poll_start.elapsed() >= HEADS_UP_DELAY
990 && last_draw.elapsed() >= PROGRESS_REDRAW
991 {
992 draw_progress(store, identity.as_deref(), &label);
993 drew_progress = true;
994 last_draw = std::time::Instant::now();
995 }
996 }
997 Err(e) => {
998 if let Some(h) = indexer {
999 let _ = h.join();
1000 }
1001 return fail(out, Failure::Database, format_args!("rq: {e}"));
1002 }
1003 }
1004 std::thread::sleep(POLL_INTERVAL);
1005 };
1006 query_span.note(|| {
1007 if polling {
1008 "polled a warming index".to_string()
1009 } else {
1010 String::new()
1011 }
1012 });
1013 drop(query_span);
1014 if drew_progress {
1015 clear_progress();
1016 }
1017 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
1019
1020 let here = identity
1021 .as_deref()
1022 .zip(root.as_deref())
1023 .map(|(identity, root)| Here { identity, root });
1024 if !hits.is_empty()
1026 && revalidate_top(store, &hits, here)
1027 && let Ok(m) = scope(current).search(store, query, current, &ctx, rank_limit)
1028 {
1029 total = m.total;
1030 hits = m.hits;
1031 }
1032
1033 let mut live_scan = None;
1037 if !hits.iter().any(strong)
1038 && indexer.is_none()
1039 && coverage.is_none()
1040 && let Some(root) = &root
1041 {
1042 let (tail, cost) = live_fallback(root, query, rank_limit, &ctx);
1043 hits = crate::search::merge(hits, tail, rank_limit);
1044 total = total.max(hits.len());
1045 live_scan = Some(cost);
1046 }
1047
1048 apply_gates(query, &mut hits);
1049 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
1050 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1054 total = hits.len();
1055 hits.truncate(want.max(2));
1056 }
1057
1058 if hits.is_empty() {
1059 if block {
1061 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1062 }
1063 if let Some(h) = indexer {
1064 let _ = h.join();
1065 }
1066 let mut incomplete = (block || no_wait)
1073 && identity
1074 .as_deref()
1075 .and_then(|id| store.coverage_status(id).ok().flatten())
1076 .as_deref()
1077 != Some("complete");
1078 incomplete |= settle_warm(
1088 store,
1089 staleness,
1090 false,
1091 was_warming,
1092 warming_ok,
1093 root.as_deref(),
1094 active_paths,
1095 query,
1096 warm_budget,
1097 no_wait,
1098 identity.as_deref(),
1099 );
1100 let elsewhere = match scope(current) {
1105 Scope::Nothing => None,
1106 Scope::All => crate::search::scope_miss_owner(store, query, current, None, &ctx),
1107 Scope::Repo(id) => {
1108 crate::search::scope_miss_owner(store, query, current, Some(id), &ctx)
1109 }
1110 };
1111 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1112 note_live_scan(root, cost, false);
1113 }
1114 let code = no_match_code(out, query, interrupted, incomplete, elsewhere.as_deref());
1115 let _span = crate::profile::span("after: record usage");
1119 record_usage(
1120 store,
1121 args,
1122 if incomplete { "warming" } else { "miss" },
1123 coverage.as_deref(),
1124 false,
1125 );
1126 return code;
1127 }
1128
1129 let live = hits
1132 .iter()
1133 .take(want)
1134 .any(|h| h.source == crate::search::Source::Live);
1135 if let (Some(cost), Some(root)) = (&live_scan, root.as_deref()) {
1136 note_live_scan(root, cost, live);
1137 }
1138
1139 let counted_early = show || open || web;
1144 if counted_early {
1145 let _span = crate::profile::span("record usage");
1146 record_usage(store, args, "hit", coverage.as_deref(), live);
1147 }
1148
1149 attach_confidence(&mut hits);
1153 hits.truncate(want);
1154 let total = total.max(hits.len());
1155 for hit in &mut hits {
1156 hit.total = total;
1157 if args.explain {
1158 hit.explain = Some(
1159 hit.features
1160 .iter()
1161 .map(|f| (f.name.to_string(), f.reported()))
1162 .collect(),
1163 );
1164 }
1165 }
1166
1167 let _signatures_span = crate::profile::span("signatures");
1170 for hit in &mut hits {
1171 let root = hit_root(store, &hit.repo_identity, &hit.file, here);
1172 hit.signature = root
1173 .as_deref()
1174 .and_then(|r| read_signature(&r.join(&hit.file), hit.line));
1175 hit.root = root.map(|r| r.to_string_lossy().into_owned());
1176 }
1177 drop(_signatures_span);
1178
1179 if show && let Some(code) = show_top_definition(&mut hits, query, out) {
1182 return code;
1183 }
1184
1185 if open || web {
1190 return finish_open(store, &hits, current, root.as_deref(), web);
1191 }
1192
1193 if let Some(code) = render_hits(args, &hits) {
1194 return code;
1195 }
1196
1197 crate::profile::mark("first answer");
1200
1201 if !counted_early {
1204 let _span = crate::profile::span("after: record usage");
1205 record_usage(store, args, "hit", coverage.as_deref(), live);
1206 }
1207
1208 if let Some(refresh) = branch_refresh.take() {
1215 let _span = crate::profile::span("after: branch refresh");
1216 refresh.store(store);
1217 }
1218
1219 if block {
1224 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1225 }
1226 if let Some(h) = indexer {
1227 let _ = h.join();
1228 }
1229 let _ = settle_warm(
1230 store,
1231 staleness,
1232 true,
1233 was_warming,
1234 warming_ok,
1235 root.as_deref(),
1236 active_paths,
1237 query,
1238 warm_budget,
1239 no_wait,
1240 identity.as_deref(),
1241 );
1242
1243 ExitCode::SUCCESS
1244}
1245
1246fn maybe_detach_warm(
1249 store: &Store,
1250 want_warm: bool,
1251 changed: bool,
1252 root: Option<&std::path::Path>,
1253 identity: Option<&str>,
1254) {
1255 if !warm_detach_enabled() || !want_warm {
1256 return;
1257 }
1258 let (Some(root), Some(id)) = (root, identity) else {
1259 return;
1260 };
1261 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1265 return; }
1267 spawn_detached_warm(root);
1268}
1269
1270fn spawn_detached_warm(root: &std::path::Path) {
1274 use std::os::unix::process::CommandExt;
1275 let Ok(exe) = std::env::current_exe() else {
1276 return;
1277 };
1278 let mut cmd = std::process::Command::new(exe);
1279 cmd.arg("--warm")
1280 .arg(root)
1281 .stdin(std::process::Stdio::null())
1282 .stdout(std::process::Stdio::null())
1283 .stderr(std::process::Stdio::null())
1284 .process_group(0);
1285 match cmd.spawn() {
1286 Ok(child) => crate::trace!(
1287 "background warm (detached): pid {} for {}",
1288 child.id(),
1289 crate::trace::abbrev(root)
1290 ),
1291 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1292 }
1293}
1294
1295const WARM_LOCK_TTL_SECS: i64 = 600;
1298
1299fn warm_recheck_window() -> Duration {
1305 env_budget("RQ_WARM_RECHECK_MS", 10_000)
1306}
1307
1308fn recently_verified(
1311 store: &Store,
1312 identity: Option<&str>,
1313 root: &std::path::Path,
1314 indexed_head: Option<&str>,
1315) -> bool {
1316 let _span = crate::profile::span("after: warm recently verified?");
1317 let (Some(id), Some(stamp)) = (
1318 identity,
1319 indexed_head.and_then(|h| crate::index::git_state_stamp(root, h)),
1320 ) else {
1321 return false;
1322 };
1323 let Ok(Some((seen, at))) = store.warm_verified(id) else {
1324 return false;
1325 };
1326 let age = now_unix().saturating_sub(at);
1327 seen == stamp && (0..warm_recheck_window().as_secs() as i64).contains(&age)
1328}
1329
1330fn cmd_warm(path: Option<&str>) -> ExitCode {
1335 #[cfg(target_os = "macos")]
1338 unsafe extern "C" {
1339 fn setiopolicy_np(
1342 iotype: libc::c_int,
1343 scope: libc::c_int,
1344 policy: libc::c_int,
1345 ) -> libc::c_int;
1346 }
1347 unsafe {
1348 libc::nice(10);
1349 #[cfg(target_os = "macos")]
1350 setiopolicy_np(0, 0, 3);
1351 }
1352 let mut store = match open_store() {
1353 Ok(s) => s,
1354 Err(_) => return ExitCode::from(Failure::Database.exit_code()),
1355 };
1356 let start = path
1357 .map(PathBuf::from)
1358 .or_else(|| std::env::current_dir().ok())
1359 .unwrap_or_else(|| PathBuf::from("."));
1360 let root = crate::index::repo_root(&start).unwrap_or(start);
1361 let identity = resolve_identity(&store, &root);
1362
1363 let held = |pid: u32, ts: i64| {
1367 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
1368 alive && now_unix() - ts < WARM_LOCK_TTL_SECS
1369 };
1370 if !store
1371 .claim_warm_lock(&identity, std::process::id(), held)
1372 .unwrap_or(false)
1373 {
1374 return ExitCode::SUCCESS;
1375 }
1376
1377 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete") {
1380 let head = store
1381 .repository_id(&identity)
1382 .ok()
1383 .flatten()
1384 .and_then(|id| store.indexed_head(id).ok().flatten());
1385 let checked_at = now_unix();
1389 let edits = worktree_edits(&root, head.as_deref());
1390 if !changed_since_index(&store, Some(&identity), Some(&root), edits) {
1391 crate::trace!("warm: unchanged since indexed, nothing to do");
1392 if let Some(id) = store.repository_id(&identity).ok().flatten() {
1394 let _ = store.maintain_name_index(id);
1395 }
1396 if let Some(stamp) = head
1397 .as_deref()
1398 .and_then(|h| crate::index::git_state_stamp(&root, h))
1399 {
1400 let _ = store.set_warm_verified(&identity, &stamp, checked_at);
1401 }
1402 let _ = store.clear_warm_lock(&identity);
1403 return ExitCode::SUCCESS;
1404 }
1405 }
1406
1407 let deadline = std::time::Instant::now() + warm_bg_budget();
1410 let active = crate::index::branch_changed_files(&root);
1411 loop {
1412 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1413 if remaining.is_zero() {
1414 break;
1415 }
1416 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1417 {
1418 Ok(s) => s,
1419 Err(_) => break,
1420 };
1421 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1422 || stats.files_indexed == 0
1423 {
1424 break;
1425 }
1426 }
1427 let _ = store.clear_warm_lock(&identity);
1428 ExitCode::SUCCESS
1429}
1430
1431fn live_fallback(
1434 root: &std::path::Path,
1435 query: &str,
1436 limit: usize,
1437 ctx: &crate::search::Context,
1438) -> (Vec<crate::search::Hit>, LiveCost) {
1439 let start = std::time::Instant::now();
1440 let deadline = start + live_fallback_budget();
1441 let mut files = 0;
1442 let mut scan = |prefilter| {
1443 let mut span = crate::profile::span(if prefilter {
1444 "live scan: prefiltered"
1445 } else {
1446 "live scan: unfiltered"
1447 });
1448 let found = crate::search::live_search(
1449 root,
1450 query,
1451 limit,
1452 &HashSet::new(),
1453 Some(deadline),
1454 prefilter,
1455 ctx,
1456 );
1457 span.note(|| format!("{} files", found.files));
1458 files += found.files;
1459 found.hits
1460 };
1461 let mut hits = scan(true);
1462 if hits.is_empty() {
1463 hits = scan(false);
1464 }
1465 let cost = LiveCost {
1466 files,
1467 elapsed: start.elapsed(),
1468 };
1469 (hits, cost)
1470}
1471
1472struct LiveCost {
1474 files: usize,
1475 elapsed: Duration,
1476}
1477
1478fn note_live_scan(root: &std::path::Path, cost: &LiveCost, answered: bool) {
1481 if !crate::trace::enabled() {
1482 return;
1483 }
1484 let budget = live_fallback_budget();
1485 let files = match cost.files {
1486 1 => "1 file".to_string(),
1487 n => format!("{n} files"),
1488 };
1489 let what = if answered {
1490 "answered from a live scan of"
1491 } else {
1492 "no answer from a live scan of"
1493 };
1494 let cut = if cost.elapsed >= budget {
1495 " (stopped at the budget)"
1496 } else {
1497 ""
1498 };
1499 crate::trace!(
1500 "{what} {}: {files} in {} ms, budget {} ms{cut}",
1501 crate::trace::abbrev(root),
1502 cost.elapsed.as_millis(),
1503 budget.as_millis(),
1504 );
1505}
1506
1507fn strong(h: &crate::search::Hit) -> bool {
1509 h.features
1510 .iter()
1511 .any(|f| matches!(f.name, "exact" | "prefix"))
1512}
1513
1514fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1523 if hits.iter().any(strong) {
1524 hits.retain(strong);
1525 }
1526 crate::search::apply_scope_gate(query, hits);
1527}
1528
1529fn apply_post_filters(
1532 args: &SearchArgs,
1533 cwd: Option<&std::path::Path>,
1534 root: Option<&std::path::Path>,
1535 hits: &mut Vec<crate::search::Hit>,
1536) {
1537 if !args.paths.is_empty() {
1538 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1542 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1543 let norm: Vec<String> = args
1544 .paths
1545 .iter()
1546 .map(|p| repo_relative(&base, &here, p))
1547 .collect();
1548 hits.retain(|h| under_any(&h.file, &norm));
1549 }
1550 if !args.kinds.is_empty() {
1551 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1552 }
1553 if !args.langs.is_empty() {
1554 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1555 }
1556}
1557
1558fn no_match_code(
1564 out: Output,
1565 query: &str,
1566 interrupted: bool,
1567 incomplete: bool,
1568 elsewhere: Option<&str>,
1572) -> ExitCode {
1573 let status = if interrupted {
1574 "interrupted"
1575 } else if incomplete {
1576 "warming"
1577 } else if elsewhere.is_some() {
1578 "scope_not_found"
1579 } else {
1580 "no_match"
1581 };
1582 match out {
1583 Output::Json | Output::Ndjson => {
1584 let mut obj = serde_json::json!({ "status": status, "query": query });
1585 if let Some(found_in) = elsewhere {
1586 obj["found_in"] = serde_json::json!(found_in);
1587 }
1588 let _ = emit_json(out, &obj); }
1590 Output::Text if interrupted => {
1591 eprintln!("rq: indexing interrupted — run again to finish")
1592 }
1593 Output::Text if incomplete => eprintln!(
1594 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1595 ),
1596 Output::Text if elsewhere.is_some() => eprintln!(
1597 "rq: nothing matching {query:?} — that name is defined under {}",
1598 elsewhere.unwrap_or_default()
1599 ),
1600 Output::Text => eprintln!("no matches for {query:?}"),
1601 }
1602 if incomplete {
1603 ExitCode::from(2)
1604 } else {
1605 ExitCode::FAILURE
1606 }
1607}
1608
1609fn attach_confidence(hits: &mut [crate::search::Hit]) {
1613 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1614 if t.is_none_or(|t| h.score > t) {
1615 (Some(h.score), t)
1616 } else if s.is_none_or(|s| h.score > s) {
1617 (t, Some(h.score))
1618 } else {
1619 (t, s)
1620 }
1621 });
1622 for hit in hits.iter_mut() {
1623 let best_other = if Some(hit.score) == top { second } else { top };
1624 hit.confidence = crate::search::confidence(
1625 hit.score,
1626 crate::search::match_quality(&hit.features),
1627 best_other,
1628 );
1629 }
1630}
1631
1632fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1635 let render_span = crate::profile::span("render");
1639 if args.batch {
1640 #[derive(serde::Serialize)]
1643 struct Tagged<'a> {
1644 query: &'a str,
1645 #[serde(flatten)]
1646 hit: &'a crate::search::Hit,
1647 }
1648 let rows: Vec<Tagged> = hits
1649 .iter()
1650 .map(|hit| Tagged {
1651 query: args.query,
1652 hit,
1653 })
1654 .collect();
1655 if let Some(code) = emit_rows(args.out, &rows) {
1656 return Some(code);
1657 }
1658 } else if let Some(code) = emit_rows(args.out, hits) {
1659 return Some(code);
1660 }
1661 if args.out != Output::Text {
1662 return None;
1663 }
1664 drop(render_span);
1665 let color = match_color();
1666 let c = color.as_deref();
1667 let query = args.query;
1668 if args.show {
1669 let total = hits.first().map_or(hits.len(), |h| h.total);
1671 eprintln!(
1672 "rq: no single confident match for {query:?} — {} of {total} candidates below; narrow the query to --show one",
1673 hits.len()
1674 );
1675 }
1676 for hit in hits {
1677 let name = hl(&hit.name, query, c);
1680 let qualified = match &hit.parent {
1681 Some(p) => format!("{name} · {p}"),
1682 None => name,
1683 };
1684 println!(
1685 "{}:{} {} {}",
1686 hl_path(&hit.file, query, c),
1687 hit.line,
1688 hit.kind,
1689 qualified
1690 );
1691 if let Some(sig) = &hit.signature {
1692 println!(" {}", hl(sig, query, c));
1693 }
1694 if args.explain {
1695 let parts: Vec<String> = hit
1696 .features
1697 .iter()
1698 .map(|f| format!("{} {}", f.name, f.reported()))
1699 .collect();
1700 println!(
1701 " confidence {:.2} · score {:.0} = {}",
1702 hit.confidence,
1703 hit.score,
1704 parts.join(" + ")
1705 );
1706 }
1707 }
1708 None
1709}
1710
1711fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1715 use std::io::{IsTerminal, Write};
1716 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1717 return hits.first();
1718 }
1719 let mut err = std::io::stderr();
1720 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1721 for (i, h) in hits.iter().enumerate() {
1722 let _ = writeln!(
1723 err,
1724 " {}. {}:{} {} {}",
1725 i + 1,
1726 h.file,
1727 h.line,
1728 h.kind,
1729 h.name
1730 );
1731 }
1732 let _ = write!(err, "rq> ");
1733 let _ = err.flush();
1734 let mut line = String::new();
1735 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1736 return None; }
1738 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1739}
1740
1741fn parse_choice(input: &str, n: usize) -> Option<usize> {
1744 let s = input.trim();
1745 if s.is_empty() {
1746 return Some(0);
1747 }
1748 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1749 (i < n).then_some(i)
1750}
1751
1752fn finish_open(
1755 store: &Store,
1756 hits: &[crate::search::Hit],
1757 current: Option<i64>,
1758 root: Option<&std::path::Path>,
1759 web: bool,
1760) -> ExitCode {
1761 let Some(hit) = choose_hit(hits) else {
1762 return ExitCode::SUCCESS; };
1764
1765 if web {
1766 return open_web(store, hit, current, root);
1767 }
1768
1769 let target = match hit.root.as_deref().map(std::path::Path::new).or(root) {
1772 Some(r) => r.join(&hit.file),
1773 None => PathBuf::from(&hit.file),
1774 };
1775 launch_editor(&target, hit.line)
1776}
1777
1778#[derive(Clone, Copy)]
1780enum Scope {
1781 All,
1783 Repo(i64),
1784 Nothing,
1787}
1788
1789impl Scope {
1790 fn new(all_repos: bool, in_repo: bool, current: Option<i64>) -> Scope {
1791 match current {
1792 _ if all_repos => Scope::All,
1793 Some(id) => Scope::Repo(id),
1794 None if in_repo => Scope::Nothing,
1795 None => Scope::All,
1796 }
1797 }
1798
1799 fn search(
1800 self,
1801 store: &Store,
1802 query: &str,
1803 current: Option<i64>,
1804 ctx: &crate::search::Context,
1805 limit: usize,
1806 ) -> crate::store::Result<crate::search::Matches> {
1807 let only_repo = match self {
1808 Scope::All => None,
1809 Scope::Repo(id) => Some(id),
1810 Scope::Nothing => {
1811 return Ok(crate::search::Matches {
1812 hits: Vec::new(),
1813 total: 0,
1814 });
1815 }
1816 };
1817 crate::search::search(store, query, current, only_repo, ctx, limit)
1818 }
1819}
1820
1821fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1825 use std::os::unix::process::CommandExt;
1826 let loc = format!("{}:{}", file.display(), line);
1827 match open_command(file, line, &loc) {
1828 Some((prog, args)) => {
1829 crate::profile::emit(false);
1831 let err = std::process::Command::new(&prog).args(&args).exec();
1833 fail(
1834 Output::Text,
1835 Failure::Launch,
1836 format_args!("rq --open: cannot run {prog}: {err}"),
1837 )
1838 }
1839 None => {
1840 println!("{loc}");
1841 ExitCode::SUCCESS
1842 }
1843 }
1844}
1845
1846fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1852 let fstr = file.to_string_lossy().into_owned();
1853
1854 if let Some(t) = std::env::var_os("RQ_OPEN") {
1855 let t = t.to_string_lossy();
1856 let placeholder = ["{file}", "{line}", "{}"].iter().any(|p| t.contains(p));
1857 let mut parts = t.split_whitespace().map(|p| {
1858 p.replace("{file}", &fstr)
1859 .replace("{line}", &line.to_string())
1860 .replace("{}", loc)
1861 });
1862 if let Some(prog) = parts.next() {
1863 let mut args: Vec<String> = parts.collect();
1864 if !placeholder {
1865 args.push(loc.to_string());
1866 }
1867 return Some((prog, args));
1868 }
1869 }
1870
1871 if on_path("code") {
1872 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1873 }
1874
1875 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1876 let ed = ed.to_string_lossy().into_owned();
1877 let l = ed.to_ascii_lowercase();
1878 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1880 .iter()
1881 .any(|e| l.contains(e))
1882 {
1883 return Some((ed, vec![format!("+{line}"), fstr]));
1884 }
1885 return Some((ed, vec![fstr]));
1886 }
1887
1888 None
1889}
1890
1891fn open_web(
1896 store: &Store,
1897 hit: &crate::search::Hit,
1898 current: Option<i64>,
1899 root: Option<&std::path::Path>,
1900) -> ExitCode {
1901 if hit.repo_identity.starts_with("local:") {
1902 return fail(
1903 Output::Text,
1904 Failure::NoRemote,
1905 format_args!(
1906 "rq --web: {}:{} has no git remote to link to ({}) — open it \
1907 locally with -o, or add one with `git remote add origin <url>`",
1908 hit.file, hit.line, hit.repo_identity
1909 ),
1910 );
1911 }
1912 let here =
1913 current.is_some() && store.repository_id(&hit.repo_identity).ok().flatten() == current;
1914 let rev = root
1915 .filter(|_| here)
1916 .and_then(crate::index::pushed_head)
1917 .unwrap_or_else(|| "HEAD".into());
1918 let url = web_url(&hit.repo_identity, &rev, &hit.file, hit.line);
1919
1920 use std::os::unix::process::CommandExt;
1921 let browser = std::env::var("BROWSER")
1922 .ok()
1923 .filter(|b| !b.is_empty())
1924 .or_else(|| {
1925 ["open", "xdg-open"]
1926 .into_iter()
1927 .find(|p| on_path(p))
1928 .map(str::to_string)
1929 });
1930 match browser {
1931 Some(prog) => {
1932 crate::profile::emit(false);
1934 let err = std::process::Command::new(&prog).arg(&url).exec();
1936 fail(
1937 Output::Text,
1938 Failure::Launch,
1939 format_args!("rq --web: cannot run {prog}: {err}"),
1940 )
1941 }
1942 None => {
1943 println!("{url}");
1944 ExitCode::SUCCESS
1945 }
1946 }
1947}
1948
1949fn web_url(identity: &str, rev: &str, file: &str, line: i64) -> String {
1952 let path: String = file
1953 .bytes()
1954 .map(|b| match b {
1955 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1956 (b as char).to_string()
1957 }
1958 _ => format!("%{b:02X}"),
1959 })
1960 .collect();
1961 format!("https://{identity}/blob/{rev}/{path}#L{line}")
1962}
1963
1964fn on_path(prog: &str) -> bool {
1966 std::env::var_os("PATH")
1967 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1968}
1969
1970const BRANCH_FILES_TTL_SECS: i64 = 15;
1976
1977const BRANCH_FILES_TTL_MAX_SECS: i64 = 300;
1981
1982const BRANCH_FILES_WINDOW_MULTIPLE: i64 = 100;
1994
1995fn branch_files_ttl(cost_ms: Option<u64>) -> i64 {
1997 let earned = cost_ms.map_or(0, |ms| {
2000 i64::try_from(ms)
2001 .unwrap_or(i64::MAX)
2002 .saturating_mul(BRANCH_FILES_WINDOW_MULTIPLE)
2003 / 1000
2004 });
2005 earned.clamp(BRANCH_FILES_TTL_SECS, BRANCH_FILES_TTL_MAX_SECS)
2006}
2007
2008struct BranchRefresh {
2012 handle: std::thread::JoinHandle<(Vec<String>, u64)>,
2015 identity: String,
2016 stamp: String,
2017}
2018
2019impl BranchRefresh {
2020 fn store(self, store: &Store) {
2022 let Ok((files, cost_ms)) = self.handle.join() else {
2023 return;
2024 };
2025 let _ = store.branch_files_set(&self.identity, &self.stamp, now_unix(), cost_ms, &files);
2026 }
2027}
2028
2029fn cached_branch_files(
2043 store: &Store,
2044 root: &std::path::Path,
2045) -> (Vec<String>, Option<BranchRefresh>, Option<u64>) {
2046 let identity = resolve_identity(store, root);
2047 let stamp = crate::index::branch_files_stamp(root);
2048 let cached = store.branch_files_get(&identity).ok().flatten();
2049 let now = now_unix();
2050
2051 if let (Some(hit), Some(stamp)) = (&cached, &stamp) {
2052 if &hit.stamp == stamp && now.saturating_sub(hit.written_at) < branch_files_ttl(hit.cost_ms)
2053 {
2054 return (hit.files.clone(), None, hit.cost_ms);
2055 }
2056 let owned_root = root.to_path_buf();
2057 let refresh = BranchRefresh {
2058 handle: std::thread::spawn(move || {
2059 let t = std::time::Instant::now();
2060 let files = crate::index::branch_changed_files(&owned_root);
2061 (files, t.elapsed().as_millis() as u64)
2062 }),
2063 identity,
2064 stamp: stamp.clone(),
2065 };
2066 return (hit.files.clone(), Some(refresh), hit.cost_ms);
2067 }
2068
2069 let t = std::time::Instant::now();
2071 let files = crate::index::branch_changed_files(root);
2072 let cost_ms = t.elapsed().as_millis() as u64;
2073 if let Some(stamp) = stamp {
2074 let _ = store.branch_files_set(&identity, &stamp, now, cost_ms, &files);
2075 }
2076 (files, None, Some(cost_ms))
2077}
2078
2079fn worktree_edits(cwd: &std::path::Path, indexed_head: Option<&str>) -> Option<Vec<String>> {
2089 let head = indexed_head?;
2090 let _span = crate::profile::span("git: worktree changed?");
2091 (crate::index::head_state(cwd).as_deref() == Some(head)).then(|| crate::index::dirty_files(cwd))
2092}
2093
2094fn changed_since_index(
2097 store: &Store,
2098 identity: Option<&str>,
2099 root: Option<&std::path::Path>,
2100 edits: Option<Vec<String>>,
2101) -> bool {
2102 let (Some(dirty), Some(root)) = (edits, root) else {
2103 return true;
2104 };
2105 match identity.and_then(|id| store.repository_id(id).ok().flatten()) {
2106 Some(repo_id) => crate::index::has_unindexed_changes(store, repo_id, root, &dirty),
2107 None => !dirty.is_empty(),
2108 }
2109}
2110
2111enum Staleness {
2115 Running(std::thread::JoinHandle<Option<Vec<String>>>),
2118 Deferred(PathBuf, Option<String>),
2122}
2123
2124#[allow(clippy::too_many_arguments)]
2131fn settle_warm(
2132 store: &Store,
2133 staleness: Option<Staleness>,
2134 hit: bool,
2135 was_warming: bool,
2136 warming_ok: bool,
2137 root: Option<&std::path::Path>,
2138 active: &[String],
2139 query: &str,
2140 budget: Duration,
2141 no_wait: bool,
2142 identity: Option<&str>,
2143) -> bool {
2144 let changed = match staleness {
2145 None => false,
2146 Some(Staleness::Deferred(r, head)) if hit => {
2149 if recently_verified(store, identity, &r, head.as_deref()) {
2150 crate::trace!("warm: verified unchanged within the recheck window, not spawning");
2151 } else {
2152 spawn_detached_warm(&r);
2153 }
2154 return false;
2155 }
2156 Some(Staleness::Deferred(r, head)) => {
2157 let _span = crate::profile::span("after: staleness check");
2158 changed_since_index(store, identity, root, worktree_edits(&r, head.as_deref()))
2159 }
2160 Some(Staleness::Running(h)) => {
2161 let mut span = crate::profile::span("after: staleness wait");
2163 let changed = h.join().map_or(true, |edits| {
2166 changed_since_index(store, identity, root, edits)
2167 });
2168 span.note(|| if changed { "changed" } else { "unchanged" }.to_string());
2169 changed
2170 }
2171 };
2172 if changed
2182 && !no_wait
2183 && !warm_detach_enabled()
2184 && let Some(r) = root
2185 && let Ok(mut idx) = open_store()
2186 {
2187 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
2188 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
2189 }
2190 maybe_detach_warm(
2191 store,
2192 warming_ok && (was_warming || changed),
2193 changed,
2194 root,
2195 identity,
2196 );
2197 changed && warm_detach_enabled()
2201}
2202
2203fn answer_warm_budget() -> Duration {
2212 env_budget("RQ_ANSWER_BUDGET_MS", 500)
2213}
2214
2215fn deferred_warm_budget() -> Duration {
2218 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
2219}
2220
2221fn live_fallback_budget() -> Duration {
2224 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
2225}
2226
2227fn warm_bg_budget() -> Duration {
2230 env_budget("RQ_WARM_BUDGET_MS", 20_000)
2231}
2232
2233fn warm_detach_enabled() -> bool {
2237 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
2238}
2239
2240fn wait_budget() -> Duration {
2249 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
2250}
2251
2252fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
2257 let s = s.trim();
2258 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
2259 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
2261 (n, 1.0)
2262 } else if let Some(n) = s.strip_suffix('s') {
2263 (n, 1_000.0)
2264 } else if let Some(n) = s.strip_suffix('m') {
2265 (n, 60_000.0)
2266 } else {
2267 (s, 1_000.0)
2269 };
2270 let val: f64 = num.trim().parse().map_err(|_| bad())?;
2271 if !val.is_finite() || val < 0.0 {
2272 return Err(bad());
2273 }
2274 Ok(Duration::from_millis((val * unit_ms).round() as u64))
2275}
2276
2277static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
2281
2282extern "C" fn on_sigint(_: libc::c_int) {
2283 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
2285}
2286
2287fn install_interrupt_handler() {
2290 static ONCE: std::sync::Once = std::sync::Once::new();
2291 ONCE.call_once(|| unsafe {
2292 let mut action: libc::sigaction = std::mem::zeroed();
2293 action.sa_sigaction = on_sigint as *const () as usize;
2294 libc::sigemptyset(&mut action.sa_mask);
2295 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
2296 });
2297}
2298
2299fn stderr_interactive() -> bool {
2303 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
2304}
2305
2306fn show_progress(out: Output, interactive: bool) -> bool {
2311 interactive && matches!(out, Output::Text)
2312}
2313
2314fn repo_label(root: Option<&std::path::Path>) -> String {
2317 root.and_then(|r| r.file_name())
2318 .map(|n| n.to_string_lossy().into_owned())
2319 .unwrap_or_else(|| "repo".into())
2320}
2321
2322fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
2326 let files = identity
2327 .and_then(|id| store.repository_id(id).ok().flatten())
2328 .and_then(|rid| store.repo_totals(rid).ok())
2329 .map_or(0, |(f, _)| f);
2330 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
2331 let _ = std::io::stderr().flush();
2332}
2333
2334fn clear_progress() {
2336 eprint!("\r\x1b[K");
2337 let _ = std::io::stderr().flush();
2338}
2339
2340fn env_budget(var: &str, default_ms: u64) -> Duration {
2344 let ms = std::env::var(var)
2345 .ok()
2346 .and_then(|v| v.parse().ok())
2347 .unwrap_or(default_ms);
2348 Duration::from_millis(ms)
2349}
2350
2351#[derive(Clone, Copy)]
2353struct Here<'a> {
2354 identity: &'a str,
2355 root: &'a std::path::Path,
2356}
2357
2358fn hit_file_roots(store: &Store, repo_identity: &str, here: Option<Here>) -> Vec<PathBuf> {
2364 let mut roots: Vec<PathBuf> = here
2365 .filter(|h| h.identity == repo_identity)
2366 .map(|h| h.root.to_path_buf())
2367 .into_iter()
2368 .collect();
2369 let recorded = store
2370 .repository_id(repo_identity)
2371 .ok()
2372 .flatten()
2373 .map(|id| store.checkout_roots(id).unwrap_or_default())
2374 .unwrap_or_default();
2375 for root in recorded.into_iter().map(PathBuf::from) {
2376 if !roots.contains(&root) {
2377 roots.push(root);
2378 }
2379 }
2380 roots
2381}
2382
2383fn hit_root(store: &Store, repo_identity: &str, file: &str, here: Option<Here>) -> Option<PathBuf> {
2387 let mut roots = hit_file_roots(store, repo_identity, here);
2388 let at = roots
2389 .iter()
2390 .position(|r| r.join(file).is_file())
2391 .unwrap_or(0);
2392 (at < roots.len()).then(|| roots.swap_remove(at))
2393}
2394
2395fn read_signature(path: &std::path::Path, line: i64) -> Option<String> {
2397 let src = std::fs::read_to_string(path).ok()?;
2398 signature_in(&src.lines().collect::<Vec<_>>(), line)
2399}
2400
2401const SHOW_CONFIDENCE: f64 = 0.85;
2405
2406fn show_top_definition(
2410 hits: &mut [crate::search::Hit],
2411 query: &str,
2412 out: Output,
2413) -> Option<ExitCode> {
2414 let top = hits.first()?;
2415 if top.confidence < SHOW_CONFIDENCE {
2416 return None; }
2418 let end = top.end_line.unwrap_or(top.line);
2419 let body = top.root.as_deref().and_then(|root| {
2420 let src = std::fs::read_to_string(std::path::Path::new(root).join(&top.file)).ok()?;
2421 span_in(&src, top.line, end)
2422 });
2423 hits[0].body = body;
2424 let top = &hits[0];
2425 let code = match out {
2426 Output::Json | Output::Ndjson => {
2427 emit_json(out, top)
2429 }
2430 Output::Text => {
2431 let color = match_color();
2432 let c = color.as_deref();
2433 let name = hl(&top.name, query, c);
2434 let qualified = match &top.parent {
2435 Some(p) => format!("{name} · {p}"),
2436 None => name,
2437 };
2438 println!(
2439 "{}:{} {} {}",
2440 hl_path(&top.file, query, c),
2441 top.line,
2442 top.kind,
2443 qualified
2444 );
2445 match (&top.body, &top.signature) {
2446 (Some(body), _) => println!("{body}"),
2447 (None, Some(sig)) => println!("{sig}"),
2449 (None, None) => {}
2450 }
2451 ExitCode::SUCCESS
2452 }
2453 };
2454
2455 Some(code)
2456}
2457
2458fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2461 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2462 let lines: Vec<&str> = content.lines().collect();
2463 if s >= lines.len() {
2464 return None;
2465 }
2466 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2467 Some(lines[s..e].join("\n"))
2468}
2469
2470fn signature_in(lines: &[&str], line: i64) -> Option<String> {
2475 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2476 let l = lines.get(idx)?.trim();
2477 (!l.is_empty()).then(|| l.to_string())
2478}
2479
2480#[derive(serde::Serialize)]
2484struct SymbolOut {
2485 name: String,
2486 kind: String,
2487 language: String,
2488 file: String,
2489 root: String,
2491 line: i64,
2492 #[serde(skip_serializing_if = "Option::is_none")]
2493 end_line: Option<i64>,
2494 #[serde(skip_serializing_if = "Option::is_none")]
2495 parent: Option<String>,
2496 #[serde(skip_serializing_if = "Option::is_none")]
2497 visibility: Option<String>,
2498 repo: String,
2499 #[serde(skip_serializing_if = "Option::is_none")]
2500 signature: Option<String>,
2501}
2502
2503fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2508 let open_span = crate::profile::span("store open");
2509 let mut store = match open_store() {
2510 Ok(s) => s,
2511 Err(e) => {
2512 return fail(
2513 out,
2514 Failure::Database,
2515 format_args!("rq: cannot open database: {e}"),
2516 );
2517 }
2518 };
2519 drop(open_span);
2520 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2521 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2522 let rel = repo_relative(&root, &cwd, file_arg);
2523
2524 let identity = resolve_identity(&store, &root);
2525 let coverage = store.coverage_status(&identity).ok().flatten();
2526 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2527 let current = store.repository_id(&identity).ok().flatten();
2528 let path = root.join(&rel);
2529 if !path.is_file() {
2530 return fail(
2531 out,
2532 Failure::NotFound,
2533 format_args!("rq --symbols: no such file: {file_arg}"),
2534 );
2535 }
2536 let indexable = path
2537 .extension()
2538 .and_then(|e| e.to_str())
2539 .is_some_and(|e| crate::lang::plugin_for_extension(e).is_some());
2540 match current {
2541 Some(repo_id) if coverage.as_deref() == Some("complete") => {
2545 if indexable {
2546 let _span = crate::profile::span("symbols: refresh");
2547 let _ = crate::index::refresh_file(&mut store, repo_id, &root, &rel);
2548 }
2549 }
2550 _ if warming_ok => {
2554 let budget = answer_warm_budget() + deferred_warm_budget();
2555 let _span = crate::profile::span("symbols: warm");
2556 let active = [rel.clone()];
2557 let _ = crate::index::index_budgeted(&mut store, &root, &active, budget, None);
2558 }
2559 _ => {}
2560 }
2561
2562 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2563 return emit_symbols(out, &[]); };
2565 let mut query_span = crate::profile::span("symbols: query");
2566 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2567 Ok(r) => r,
2568 Err(e) => return fail(out, Failure::Database, format_args!("rq: {e}")),
2569 };
2570 query_span.note(|| format!("{} rows", rows.len()));
2571 drop(query_span);
2572 if !kinds.is_empty() {
2573 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2574 }
2575 if !langs.is_empty() {
2576 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2577 }
2578
2579 let signatures_span = crate::profile::span("symbols: signatures");
2582 let content = std::fs::read_to_string(&path).ok();
2583 let lines: Vec<&str> = content
2584 .as_deref()
2585 .map_or_else(Vec::new, |c| c.lines().collect());
2586 let root_str = root.to_string_lossy().into_owned();
2587 let syms: Vec<SymbolOut> = rows
2588 .into_iter()
2589 .map(|r| SymbolOut {
2590 signature: signature_in(&lines, r.line),
2591 name: r.name,
2592 kind: r.kind,
2593 language: r.language,
2594 file: r.file,
2595 root: root_str.clone(),
2596 line: r.line,
2597 end_line: r.end_line,
2598 parent: r.parent,
2599 visibility: r.visibility,
2600 repo: r.repo_identity,
2601 })
2602 .collect();
2603 drop(signatures_span);
2604 let _span = crate::profile::span("render");
2605 emit_symbols(out, &syms)
2606}
2607
2608fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2611 if syms.is_empty() {
2612 match out {
2613 Output::Json | Output::Ndjson => {
2614 let obj = serde_json::json!({ "status": "no_match" });
2615 let _ = emit_json(out, &obj); }
2617 Output::Text => eprintln!("no symbols"),
2618 }
2619 return ExitCode::FAILURE;
2620 }
2621 if let Some(code) = emit_rows(out, syms) {
2622 return code;
2623 }
2624 match out {
2625 Output::Json | Output::Ndjson => {}
2626 Output::Text => {
2627 for s in syms {
2628 let qualified = match &s.parent {
2629 Some(p) => format!("{} · {p}", s.name),
2630 None => s.name.clone(),
2631 };
2632 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2633 if let Some(sig) = &s.signature {
2634 println!(" {sig}");
2635 }
2636 }
2637 }
2638 }
2639 ExitCode::SUCCESS
2640}
2641
2642fn keyword_kind(token: &str) -> Option<&'static str> {
2647 match token.to_ascii_lowercase().as_str() {
2648 "class" => Some("class"),
2649 "module" => Some("module"),
2650 "method" => Some("method"),
2651 "function" | "fn" => Some("function"),
2652 "struct" | "type" => Some("struct"),
2653 "enum" => Some("enum"),
2654 "trait" | "interface" => Some("trait"),
2655 "constant" | "const" => Some("constant"),
2656 _ => None,
2657 }
2658}
2659
2660fn split_kind_keyword(
2666 target: String,
2667 dirs: Vec<String>,
2668) -> (Option<&'static str>, String, Vec<String>) {
2669 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2672 let rest = rest.trim();
2673 if let Some(k) = keyword_kind(head)
2674 && !rest.is_empty()
2675 {
2676 return (Some(k), rest.to_string(), dirs);
2677 }
2678 } else if let Some(k) = keyword_kind(&target)
2679 && let Some((query, extra)) = dirs.split_first()
2680 {
2681 return (Some(k), query.clone(), extra.to_vec());
2683 }
2684 (None, target, dirs)
2685}
2686
2687fn canonical_kind(s: &str) -> Option<&'static str> {
2690 Some(match s.to_ascii_lowercase().as_str() {
2691 "c" | "class" => "class",
2692 "m" | "method" => "method",
2693 "f" | "fn" | "func" | "function" => "function",
2694 "mod" | "module" => "module",
2695 "s" | "struct" | "type" => "struct",
2696 "e" | "enum" => "enum",
2697 "t" | "trait" | "interface" => "trait",
2698 "const" | "constant" => "constant",
2699 _ => return None,
2700 })
2701}
2702
2703fn canonical_langs(s: &str) -> Vec<String> {
2710 let t = s.to_ascii_lowercase();
2711 let alias = match t.as_str() {
2712 "rb" => Some("ruby"),
2713 "rs" => Some("rust"),
2714 "golang" => Some("go"),
2715 "ts" | "tsx" => Some("typescript"),
2716 "js" | "jsx" => Some("javascript"),
2717 _ => None,
2718 };
2719 let matched: Vec<String> = crate::lang::languages()
2720 .into_iter()
2721 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2722 .map(str::to_string)
2723 .collect();
2724 matched
2725}
2726
2727fn match_color() -> Option<String> {
2731 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2732 return None;
2733 }
2734 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2735 gc.split(':').find_map(|e| {
2736 e.strip_prefix("mt=")
2737 .or_else(|| e.strip_prefix("ms="))
2738 .filter(|v| !v.is_empty())
2739 .map(str::to_string)
2740 })
2741 });
2742 Some(style.unwrap_or_else(|| "1;31".to_string()))
2743}
2744
2745fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2748 match color {
2749 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2750 None => text.to_string(),
2751 }
2752}
2753
2754fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2757 let Some(c) = color else {
2758 return path.to_string();
2759 };
2760 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2761 let base_start = path[..base_byte].chars().count();
2762 let stem = crate::search::path_stem(path);
2766 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2767 .into_iter()
2768 .map(|p| p + base_start)
2769 .collect();
2770 highlight(path, &positions, c)
2771}
2772
2773fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2776 if positions.is_empty() {
2777 return text.to_string();
2778 }
2779 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2780 let mut out = String::new();
2781 let mut on = false;
2782 for (i, c) in text.chars().enumerate() {
2783 match (matched.contains(&i), on) {
2784 (true, false) => {
2785 out.push_str("\x1b[");
2786 out.push_str(color);
2787 out.push('m');
2788 on = true;
2789 }
2790 (false, true) => {
2791 out.push_str("\x1b[0m");
2792 on = false;
2793 }
2794 _ => {}
2795 }
2796 out.push(c);
2797 }
2798 if on {
2799 out.push_str("\x1b[0m");
2800 }
2801 out
2802}
2803
2804fn under_any(file: &str, paths: &[String]) -> bool {
2808 paths.iter().any(|p| {
2809 let p = p.trim_start_matches("./").trim_end_matches('/');
2810 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2811 })
2812}
2813
2814fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2816 let p = std::path::Path::new(file);
2817 let abs = if p.is_absolute() {
2818 p.to_path_buf()
2819 } else {
2820 cwd.join(p)
2821 };
2822 let abs = abs.canonicalize().unwrap_or(abs);
2823 abs.strip_prefix(root)
2824 .map(|r| r.to_string_lossy().into_owned())
2825 .unwrap_or_else(|_| file.to_string())
2826}
2827
2828fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit], here: Option<Here>) -> bool {
2833 use std::collections::HashSet;
2834 let mut seen = HashSet::new();
2835 let mut changed = false;
2836 for hit in hits {
2837 if !seen.insert((hit.repo_identity.as_str(), hit.file.as_str())) {
2838 continue;
2839 }
2840 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2841 continue;
2842 };
2843 let roots = hit_file_roots(store, &hit.repo_identity, here);
2844 let Some(root) = roots.iter().find(|r| r.join(&hit.file).is_file()) else {
2845 continue;
2846 };
2847 if let Ok(crate::index::Refresh::Updated) =
2848 crate::index::refresh_file(store, repo_id, root, &hit.file)
2849 {
2850 changed = true;
2851 }
2852 }
2853 changed
2854}
2855
2856fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2862 if let Ok(canon) = cwd.canonicalize() {
2863 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2864 return identity;
2865 }
2866 if crate::index::repo_root(cwd).is_none() {
2867 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2868 }
2869 }
2870 crate::index::detect_identity(cwd).to_string()
2871}
2872
2873fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2874 let explicit = path.is_some();
2875 let target = path.unwrap_or_else(|| PathBuf::from("."));
2876 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2881 let mut subdirs = subdirs.to_vec();
2886 if explicit
2887 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2888 && t != r
2889 && let Ok(rel) = t.strip_prefix(&r)
2890 && !rel.as_os_str().is_empty()
2891 {
2892 subdirs.push(rel.to_string_lossy().into_owned());
2893 }
2894 let open_span = crate::profile::span("store open");
2895 let mut store = match open_store() {
2896 Ok(s) => s,
2897 Err(e) => {
2898 return fail(
2899 out,
2900 Failure::Database,
2901 format_args!("rq: cannot open database: {e}"),
2902 );
2903 }
2904 };
2905 drop(open_span);
2906 let indexed = crate::index::index_under(&mut store, &root, &subdirs);
2907 let identity = resolve_identity(&store, &root);
2910 match indexed {
2911 Ok(stats) => {
2912 let subtree = !subdirs.is_empty();
2913 let totals = store
2915 .repository_id(&identity)
2916 .ok()
2917 .flatten()
2918 .and_then(|id| store.repo_totals(id).ok());
2919 match out {
2920 Output::Json | Output::Ndjson => {
2921 let (files, symbols) = match totals {
2922 Some((f, s)) => (Some(f), Some(s)),
2923 None => (None, None),
2924 };
2925 return emit_json(
2926 out,
2927 &serde_json::json!({
2928 "repo": identity,
2929 "scope": if subtree { "subtree" } else { "full" },
2930 "files_added": stats.files_indexed,
2931 "symbols_added": stats.symbols,
2932 "files": files,
2933 "symbols": symbols,
2934 }),
2935 );
2936 }
2937 Output::Text => {
2938 let scope = if subtree { " (subtree seed)" } else { "" };
2939 match totals {
2940 Some((files, symbols)) => println!(
2941 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2942 stats.files_indexed, stats.symbols
2943 ),
2944 None => println!(
2945 "{} file(s)/{} symbol(s) added this run{scope}",
2946 stats.files_indexed, stats.symbols
2947 ),
2948 }
2949 }
2950 }
2951 ExitCode::SUCCESS
2952 }
2953 Err(e) => fail(out, Failure::Index, format_args!("rq --index: {e}")),
2954 }
2955}
2956
2957fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2958 let mut store = match open_store() {
2959 Ok(s) => s,
2960 Err(e) => {
2961 return fail(
2962 out,
2963 Failure::Database,
2964 format_args!("rq: cannot open database: {e}"),
2965 );
2966 }
2967 };
2968
2969 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2973 let root = crate::index::repo_root(&path).unwrap_or(path);
2974 let from_path = crate::index::detect_identity(&root).to_string();
2975 let resolved = match store.repository_id(&from_path) {
2976 Ok(Some(id)) => Some((from_path.clone(), id)),
2977 Ok(None) => target.as_deref().and_then(|s| {
2978 store
2979 .repository_id(s)
2980 .ok()
2981 .flatten()
2982 .map(|id| (s.to_string(), id))
2983 }),
2984 Err(e) => return fail(out, Failure::Database, format_args!("rq --drop: {e}")),
2985 };
2986
2987 let Some((identity, repo_id)) = resolved else {
2988 return match out {
2990 Output::Text => {
2991 println!("not indexed: {from_path}");
2992 ExitCode::SUCCESS
2993 }
2994 _ => emit_json(
2995 out,
2996 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2997 ),
2998 };
2999 };
3000
3001 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
3002 match store.drop_repository(repo_id) {
3003 Ok(()) => match out {
3004 Output::Text => {
3005 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
3006 ExitCode::SUCCESS
3007 }
3008 _ => emit_json(
3009 out,
3010 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
3011 ),
3012 },
3013 Err(e) => fail(out, Failure::Database, format_args!("rq --drop: {e}")),
3014 }
3015}
3016
3017fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
3021 let rendered = if out == Output::Json {
3022 serde_json::to_string_pretty(value)
3023 } else {
3024 serde_json::to_string(value)
3025 };
3026 match rendered {
3027 Ok(s) => {
3028 println!("{s}");
3029 ExitCode::SUCCESS
3030 }
3031 Err(e) => fail(out, Failure::Internal, format_args!("rq: {e}")),
3032 }
3033}
3034
3035fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
3039 match out {
3040 Output::Json => match serde_json::to_string_pretty(rows) {
3041 Ok(s) => println!("{s}"),
3042 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3043 },
3044 Output::Ndjson => {
3045 for r in rows {
3046 match serde_json::to_string(r) {
3047 Ok(line) => println!("{line}"),
3048 Err(e) => return Some(fail(out, Failure::Internal, format_args!("rq: {e}"))),
3049 }
3050 }
3051 }
3052 Output::Text => {}
3053 }
3054 None
3055}
3056
3057fn cmd_status(out: Output) -> ExitCode {
3058 let store = match open_store() {
3059 Ok(s) => s,
3060 Err(e) => {
3061 return fail(
3062 out,
3063 Failure::Database,
3064 format_args!("rq: cannot open database: {e}"),
3065 );
3066 }
3067 };
3068 let rows = match store.coverage_overview() {
3069 Ok(rows) => rows,
3070 Err(e) => return fail(out, Failure::Database, format_args!("rq --status: {e}")),
3071 };
3072 if let Some(code) = emit_rows(out, &rows) {
3073 return code;
3074 }
3075 match out {
3076 Output::Json | Output::Ndjson => {}
3077 Output::Text if rows.is_empty() => {
3078 println!("no repositories indexed yet (try `rq --index`)");
3079 }
3080 Output::Text => {
3081 for r in &rows {
3082 println!(
3083 "{:<10} {:>6} files {:>7} symbols {}",
3084 r.status, r.files, r.symbols, r.identity
3085 );
3086 }
3087 }
3088 }
3089 ExitCode::SUCCESS
3090}
3091
3092fn cmd_usage(out: Output) -> ExitCode {
3095 let store = match open_store() {
3096 Ok(s) => s,
3097 Err(e) => {
3098 return fail(
3099 out,
3100 Failure::Database,
3101 format_args!("rq: cannot open database: {e}"),
3102 );
3103 }
3104 };
3105 let rows = match store.usage_overview() {
3106 Ok(rows) => rows,
3107 Err(e) => return fail(out, Failure::Database, format_args!("rq --usage: {e}")),
3108 };
3109 if let Some(code) = emit_rows(out, &rows) {
3110 return code;
3111 }
3112 match out {
3113 Output::Json | Output::Ndjson => {}
3114 Output::Text if rows.is_empty() => {
3115 println!("no usage recorded yet");
3116 }
3117 Output::Text => {
3118 println!(
3121 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
3122 "day", "caller", "found", "missed", "warming"
3123 );
3124 for r in &rows {
3125 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
3126 println!(
3127 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
3128 r.day,
3129 r.source,
3130 r.searches - r.misses - r.warming,
3131 r.misses,
3132 r.warming,
3133 flags
3134 );
3135 }
3136 let searches: i64 = rows.iter().map(|r| r.searches).sum();
3137 let misses: i64 = rows.iter().map(|r| r.misses).sum();
3138 let warming: i64 = rows.iter().map(|r| r.warming).sum();
3139 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
3140 let live: i64 = rows.iter().map(|r| r.live).sum();
3141 let plural = if searches == 1 { "search" } else { "searches" };
3142 let live = if live > 0 {
3144 format!(" · {live} from a live scan")
3145 } else {
3146 String::new()
3147 };
3148 println!(
3151 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index{live}"
3152 );
3153 }
3154 }
3155 if rows.is_empty() {
3158 return ExitCode::from(1);
3159 }
3160 ExitCode::SUCCESS
3161}
3162
3163fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
3165 let path = db_path()?;
3166 if let Some(parent) = path.parent() {
3167 std::fs::create_dir_all(parent)?;
3168 }
3169 Ok(Store::open(&path)?)
3170}
3171
3172fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
3174 Ok(db_location()?)
3175}
3176
3177fn db_location() -> Result<PathBuf, String> {
3181 let var = |name| std::env::var_os(name).filter(|v| !v.is_empty());
3182 db_location_from(var("RQ_DB"), var("HOME"))
3183}
3184
3185fn db_location_from(
3186 rq_db: Option<std::ffi::OsString>,
3187 home: Option<std::ffi::OsString>,
3188) -> Result<PathBuf, String> {
3189 if let Some(db) = rq_db {
3190 let shown = db.to_string_lossy();
3191 if std::path::Path::new(&db).is_relative() {
3192 let hint = match shown.strip_prefix("~/") {
3194 Some(rest) => format!("$HOME/{rest}"),
3195 None => format!("$PWD/{shown}"),
3196 };
3197 return Err(format!(
3198 "rq: RQ_DB must be an absolute path, not {shown:?} (e.g. RQ_DB=\"{hint}\")"
3199 ));
3200 }
3201 if shown.ends_with('/') || std::path::Path::new(&db).is_dir() {
3202 return Err(format!(
3203 "rq: RQ_DB names a directory, not a database file: {shown:?} (e.g. RQ_DB=\"{}/rq.db\")",
3204 shown.trim_end_matches('/')
3205 ));
3206 }
3207 return Ok(PathBuf::from(db));
3208 }
3209 let Some(home) = home else {
3210 return Err(
3211 "rq: HOME is not set, so there's no default index path; set HOME, or RQ_DB to an absolute path".into(),
3212 );
3213 };
3214 if std::path::Path::new(&home).is_relative() {
3215 return Err(format!(
3216 "rq: HOME is a relative path ({:?}), so the default index would move with the working directory; set RQ_DB to an absolute path",
3217 home.to_string_lossy()
3218 ));
3219 }
3220 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
3221}
3222
3223#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3225enum Failure {
3226 Usage,
3228 Database,
3230 NotFound,
3232 NoRemote,
3234 Launch,
3236 Index,
3238 Internal,
3240}
3241
3242impl Failure {
3243 fn as_str(self) -> &'static str {
3244 match self {
3245 Failure::Usage => "usage",
3246 Failure::Database => "database",
3247 Failure::NotFound => "not_found",
3248 Failure::NoRemote => "no_remote",
3249 Failure::Launch => "launch",
3250 Failure::Index => "index",
3251 Failure::Internal => "internal",
3252 }
3253 }
3254
3255 fn exit_code(self) -> u8 {
3259 match self {
3260 Failure::Usage => 64, Failure::NotFound => 66, Failure::NoRemote | Failure::Launch => 69, Failure::Internal => 70, Failure::Database | Failure::Index => 74, }
3266 }
3267}
3268
3269fn fail(out: Output, kind: Failure, args: std::fmt::Arguments) -> ExitCode {
3272 let message = args.to_string();
3273 eprintln!("{message}");
3274 emit_error(out, kind, &message);
3275 ExitCode::from(kind.exit_code())
3276}
3277
3278fn emit_error(out: Output, kind: Failure, message: &str) {
3281 let obj = serde_json::json!({
3282 "error": message,
3283 "kind": kind.as_str(),
3284 "code": kind.exit_code(),
3285 });
3286 let rendered = match out {
3288 Output::Text => return,
3289 Output::Json => serde_json::to_string_pretty(&obj),
3290 Output::Ndjson => serde_json::to_string(&obj),
3291 };
3292 if let Ok(s) = rendered {
3293 println!("{s}");
3294 }
3295}
3296
3297fn clap_failure(err: clap::Error) -> ExitCode {
3302 if !err.use_stderr() {
3304 err.exit();
3305 }
3306 let _ = err.print();
3307 let out = requested_output(std::env::args_os().skip(1));
3308 let text = err.to_string();
3309 emit_error(out, Failure::Usage, text.lines().next().unwrap_or(""));
3310 ExitCode::from(Failure::Usage.exit_code())
3311}
3312
3313fn requested_output(args: impl IntoIterator<Item = std::ffi::OsString>) -> Output {
3318 let cmd = Cli::command();
3319 let takes_value = |c: char| {
3320 cmd.get_arguments()
3321 .any(|a| a.get_short() == Some(c) && a.get_action().takes_values())
3322 };
3323 let (mut json, mut ndjson) = (false, false);
3324 for arg in args {
3325 let arg = arg.to_string_lossy();
3326 match arg.as_ref() {
3327 "--" => break,
3328 "--json" => json = true,
3329 "--ndjson" => ndjson = true,
3330 a if a.starts_with('-') && !a.starts_with("--") => {
3331 for c in a.chars().skip(1) {
3332 match c {
3333 'j' => json = true,
3334 'J' => ndjson = true,
3335 c if takes_value(c) => break,
3336 _ => {}
3337 }
3338 }
3339 }
3340 _ => {}
3341 }
3342 }
3343 if ndjson {
3345 Output::Ndjson
3346 } else if json {
3347 Output::Json
3348 } else {
3349 Output::Text
3350 }
3351}
3352
3353#[cfg(test)]
3354mod tests {
3355 use super::*;
3356
3357 #[test]
3358 fn the_database_path_is_refused_unless_it_is_an_absolute_file() {
3359 let at = |db: Option<&str>, home: Option<&str>| {
3360 db_location_from(db.map(Into::into), home.map(Into::into))
3361 };
3362 assert_eq!(
3363 at(None, Some("/home/a")).unwrap(),
3364 PathBuf::from("/home/a/.local/share/rq/rq.db")
3365 );
3366 assert_eq!(
3367 at(Some("/x/rq.db"), None).unwrap(),
3368 PathBuf::from("/x/rq.db")
3369 );
3370
3371 let refused = |db, home| at(db, home).unwrap_err();
3372 assert!(refused(Some("~/x.db"), None).contains("RQ_DB=\"$HOME/x.db\""));
3373 assert!(refused(Some("x.db"), None).contains("RQ_DB=\"$PWD/x.db\""));
3374 assert!(refused(Some("/x/"), None).contains("RQ_DB=\"/x/rq.db\""));
3375 assert!(refused(None, None).contains("HOME is not set"));
3376 assert!(refused(None, Some("home/a")).contains("HOME is a relative path"));
3377 }
3378
3379 #[test]
3380 fn parses_an_anchor_from_the_right() {
3381 let ok = |file: &str, line| {
3382 Ok(AnchorSpec {
3383 file: PathBuf::from(file),
3384 line,
3385 })
3386 };
3387 assert_eq!(parse_anchor("app/w.rb:42"), ok("app/w.rb", 42));
3388 assert_eq!(parse_anchor("app/w.rb:42:7"), ok("app/w.rb", 42));
3389 assert_eq!(parse_anchor("C:odd/w.rb:3"), ok("C:odd/w.rb", 3));
3390 for bad in ["app/w.rb", "app/w.rb:", "app/w.rb:0", ":12"] {
3391 assert!(parse_anchor(bad).is_err(), "{bad}");
3392 }
3393 }
3394
3395 #[test]
3396 fn the_output_mode_is_read_off_argv_before_clap_parses_it() {
3397 let mode = |args: &[&str]| requested_output(args.iter().map(std::ffi::OsString::from));
3398 for (args, want) in [
3399 (&["x", "--json"][..], Output::Json),
3400 (&["x", "--ndjson"], Output::Ndjson),
3401 (&["x", "-ej"], Output::Json),
3402 (&["-Je", "x"], Output::Ndjson),
3403 (&["x", "--json", "-J"], Output::Ndjson),
3404 (&["x"], Output::Text),
3405 (&["x", "-xj"], Output::Text),
3407 (&["x", "-l5j"], Output::Text),
3408 (&["--", "-j"], Output::Text),
3410 (&["x", "--jobs", "2"], Output::Text),
3411 ] {
3412 assert!(mode(args) == want, "{args:?}");
3413 }
3414 }
3415
3416 #[test]
3417 fn open_menu_choice_parsing() {
3418 assert_eq!(parse_choice("\n", 5), Some(0));
3420 assert_eq!(parse_choice(" ", 5), Some(0));
3421 assert_eq!(parse_choice("3", 5), Some(2));
3422 assert_eq!(parse_choice("5", 5), Some(4));
3423 assert_eq!(parse_choice("6", 5), None);
3425 assert_eq!(parse_choice("0", 5), None);
3426 assert_eq!(parse_choice("q", 5), None);
3427 }
3428
3429 #[test]
3430 fn web_url_shape() {
3431 assert_eq!(
3432 web_url("github.com/org/repo", "abc123", "src/a b#.rs", 42),
3433 "https://github.com/org/repo/blob/abc123/src/a%20b%23.rs#L42"
3434 );
3435 }
3436
3437 #[test]
3438 fn wait_duration_parsing() {
3439 use std::time::Duration;
3440 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
3442 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
3443 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
3444 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
3445 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
3447 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
3448 assert!(parse_wait("0s").unwrap().is_zero());
3449 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
3451 assert!(parse_wait("2x").is_err());
3453 assert!(parse_wait("").is_err());
3454 assert!(parse_wait("s").is_err());
3455 assert!(parse_wait("-1s").is_err());
3456 }
3457
3458 #[test]
3459 fn leading_kind_keyword_becomes_a_kind_filter() {
3460 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3461 assert_eq!(
3463 split_kind_keyword("class".into(), d(&["Widget"])),
3464 (Some("class"), "Widget".into(), vec![])
3465 );
3466 assert_eq!(
3468 split_kind_keyword("method zoom".into(), vec![]),
3469 (Some("method"), "zoom".into(), vec![])
3470 );
3471 assert_eq!(
3473 split_kind_keyword("fn".into(), d(&["Foo::run"])),
3474 (Some("function"), "Foo::run".into(), vec![])
3475 );
3476 assert_eq!(
3478 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
3479 (Some("struct"), "Gadget".into(), d(&["src"]))
3480 );
3481 }
3482
3483 #[test]
3484 fn a_bare_or_non_keyword_query_is_left_alone() {
3485 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3486 assert_eq!(
3488 split_kind_keyword("class".into(), vec![]),
3489 (None, "class".into(), vec![])
3490 );
3491 assert_eq!(
3493 split_kind_keyword("Widget".into(), d(&["app"])),
3494 (None, "Widget".into(), d(&["app"]))
3495 );
3496 assert_eq!(
3498 split_kind_keyword("c".into(), d(&["Foo"])),
3499 (None, "c".into(), d(&["Foo"]))
3500 );
3501 }
3502
3503 #[test]
3504 fn the_branch_window_scales_with_what_the_refresh_costs() {
3505 assert_eq!(branch_files_ttl(Some(5)), BRANCH_FILES_TTL_SECS);
3508 assert_eq!(branch_files_ttl(Some(150)), BRANCH_FILES_TTL_SECS);
3509 assert_eq!(branch_files_ttl(Some(700)), 70);
3513 assert_eq!(branch_files_ttl(Some(2_000)), 200);
3514 assert_eq!(branch_files_ttl(Some(60_000)), BRANCH_FILES_TTL_MAX_SECS);
3517 assert_eq!(branch_files_ttl(Some(u64::MAX)), BRANCH_FILES_TTL_MAX_SECS);
3518 assert_eq!(branch_files_ttl(None), BRANCH_FILES_TTL_SECS);
3520 }
3521
3522 #[test]
3523 fn a_language_selects_by_prefix_or_alias() {
3524 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
3526 assert_eq!(canonical_langs("t"), ["typescript"]);
3527 assert_eq!(canonical_langs("ts"), ["typescript"]);
3529 assert_eq!(canonical_langs("jsx"), ["javascript"]);
3530 assert_eq!(canonical_langs("rb"), ["ruby"]);
3531 assert!(canonical_langs("COBOL").is_empty());
3534 }
3535
3536 #[test]
3537 fn a_kind_normalizes_language_specific_spellings() {
3538 assert_eq!(canonical_kind("f"), Some("function"));
3539 assert_eq!(canonical_kind("interface"), Some("trait"));
3541 assert_eq!(canonical_kind("type"), Some("struct"));
3542 assert_eq!(canonical_kind("const"), Some("constant"));
3543 assert_eq!(canonical_kind("banana"), None);
3544 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
3546 assert_eq!(
3547 split_kind_keyword("interface".into(), d(&["Renderer"])),
3548 (Some("trait"), "Renderer".into(), vec![])
3549 );
3550 }
3551
3552 #[test]
3553 fn highlight_wraps_matched_runs() {
3554 assert_eq!(
3555 highlight("FooThing", &[0, 1, 2], "1;31"),
3556 "\u{1b}[1;31mFoo\u{1b}[0mThing"
3557 );
3558 assert_eq!(
3560 highlight("FooThing", &[0, 3], "1"),
3561 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
3562 );
3563 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
3565 }
3566
3567 #[test]
3568 fn progress_ui_only_for_an_interactive_text_terminal() {
3569 assert!(show_progress(Output::Text, true));
3571
3572 assert!(!show_progress(Output::Json, true));
3574 assert!(!show_progress(Output::Ndjson, true));
3575
3576 assert!(!show_progress(Output::Text, false));
3578 }
3579
3580 #[test]
3581 fn signature_in_reads_one_based_trimmed_nonblank_lines() {
3582 let lines = ["class Widget", "", " def go"];
3583 assert_eq!(signature_in(&lines, 1).as_deref(), Some("class Widget"));
3584 assert_eq!(signature_in(&lines, 3).as_deref(), Some("def go"));
3585 assert_eq!(signature_in(&lines, 2), None, "blank line");
3586 assert_eq!(signature_in(&lines, 0), None, "lines are 1-based");
3587 assert_eq!(signature_in(&lines, 4), None, "past the end");
3588 }
3589
3590 #[test]
3591 fn repo_label_uses_the_directory_name() {
3592 assert_eq!(
3593 repo_label(Some(std::path::Path::new("/src/widgets"))),
3594 "widgets"
3595 );
3596 assert_eq!(repo_label(None), "repo");
3597 }
3598
3599 #[test]
3600 fn hl_path_highlights_the_stem_not_the_extension() {
3601 let out = hl_path(
3604 "app/employees_controller.rb",
3605 "employeescontroller",
3606 Some("1;31"),
3607 );
3608 assert!(
3609 out.starts_with("app/\u{1b}[1;31memployees"),
3610 "stem highlighted: {out:?}"
3611 );
3612 assert!(
3613 out.ends_with("controller\u{1b}[0m.rb"),
3614 "`.rb` left un-highlighted: {out:?}"
3615 );
3616 }
3617}