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::store::Store;
13
14#[derive(Parser)]
18#[command(
19 name = "rq",
20 version,
21 about = "Ranked definition lookup — the one place a symbol is defined, first.",
22 long_about = "rq finds where a symbol is defined and ranks the one you most \
23likely meant to the top — not every match.\n\n\
24Search is the default action; operations are flags, not subcommands, so every \
25word (including \"index\", \"status\", \"record\") stays searchable. Ranking favors \
26your current repo and recently-active files, and learns from the results you open \
27(see RECORDING below). Run `rq <query> --explain` to see the score behind each result.",
28 after_help = "EXAMPLES:\n \
29rq thing search for a definition named or like \"thing\"\n \
30rq wibble --explain same, plus the score behind each result\n \
31rq thing --json machine-readable results (for editors/agents)\n \
32rq thing --no-record search without recording it (speculative/agent queries)\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 -o thing open the best match in your editor (and record it)\n \
41rq --index index the current repository\n \
42rq --status show indexing coverage\n \
43rq --usage show how rq has been called (by caller and flags)\n \
44rq --drop remove this repo's index (opposite of --index)\n\n\
45SHORT FLAGS (easy to misread):\n \
46-j = --json (not jobs; --jobs is long-only) -l = --limit (not lang) -x = --lang\n\n\
47RECORDING (editor/shell hook):\n \
48rq --record --file <path> --line <n> <query>\n \
49Tells rq which result you opened for a query, so ranking learns. Pass --no-record \
50to a search to skip this. Editors and the script/rq-open wrapper call --record for you.\n\n\
51The index is a SQLite file at $RQ_DB (default ~/.local/share/rq/rq.db); it warms \
52automatically on the first search in a git repo. On a large, cold repo a search \
53keeps indexing until it can answer rather than reporting a premature \"no \
54matches\" (an interactive run shows progress and stops on Ctrl-C). Exit codes: 0 \
55= matched, 1 = no match, 2 = no match yet (index still warming — try again)."
56)]
57struct Cli {
58 #[arg(value_name = "TARGET", value_hint = clap::ValueHint::Other)]
65 target: Option<String>,
66
67 #[arg(value_name = "PATH")]
69 dirs: Vec<String>,
70
71 #[arg(short = 'e', long)]
73 explain: bool,
74
75 #[arg(long)]
80 no_record: bool,
81
82 #[arg(long = "no-wait")]
88 no_wait: bool,
89
90 #[arg(long, value_name = "DUR", value_parser = parse_wait, conflicts_with = "no_wait")]
95 wait: Option<Duration>,
96
97 #[arg(short = 'o', long, conflicts_with_all = ["index", "status", "record", "json", "ndjson"])]
102 open: bool,
103
104 #[arg(long, conflicts_with_all = ["open", "index", "status", "record", "symbols", "drop"])]
108 show: bool,
109
110 #[arg(short = 'j', long)]
112 json: bool,
113
114 #[arg(short = 'J', long, conflicts_with = "json")]
116 ndjson: bool,
117
118 #[arg(short = 'p', long, value_name = "DIR")]
120 path: Vec<String>,
121
122 #[arg(short = 'l', long, value_name = "N", default_value_t = DEFAULT_LIMIT)]
124 limit: usize,
125
126 #[arg(short = 'k', long, value_name = "KIND", value_delimiter = ',')]
130 kind: Vec<String>,
131
132 #[arg(short = 'x', long = "lang", value_name = "LANG", value_delimiter = ',')]
136 lang: Vec<String>,
137
138 #[arg(short = 'a', long = "all-repos")]
141 all_repos: bool,
142
143 #[arg(long, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["status", "record"])]
145 index: Option<Option<String>>,
146
147 #[arg(long, conflicts_with_all = ["index", "record"])]
149 status: bool,
150
151 #[arg(long, conflicts_with_all = ["index", "record", "status"])]
153 usage: bool,
154
155 #[arg(long, value_name = "FILE", value_hint = clap::ValueHint::FilePath, conflicts_with_all = ["index", "status", "record", "drop", "open"])]
158 symbols: Option<String>,
159
160 #[arg(long, conflicts_with_all = ["index", "status", "record", "open"])]
164 drop: bool,
165
166 #[arg(long, requires = "file", conflicts_with_all = ["index", "status"])]
169 record: bool,
170
171 #[arg(long)]
173 file: Option<String>,
174
175 #[arg(long)]
177 line: Option<i64>,
178
179 #[arg(long, default_value = "select")]
181 event: String,
182
183 #[arg(long, hide = true, value_name = "PATH", num_args = 0..=1, value_hint = clap::ValueHint::AnyPath, conflicts_with_all = ["index", "status", "record", "drop", "symbols", "open", "show"])]
187 warm: Option<Option<String>>,
188
189 #[arg(long, value_name = "SHELL")]
191 completions: Option<Shell>,
192
193 #[arg(short = 'v', long)]
196 verbose: bool,
197
198 #[arg(long)]
202 profile: bool,
203
204 #[arg(long, value_name = "N", default_value_t = 0)]
207 jobs: usize,
208}
209
210pub fn run() -> ExitCode {
212 let cli = Cli::parse();
213 crate::trace::enable_from(cli.verbose);
214 crate::profile::enable_from(cli.profile);
215 crate::index::set_parse_jobs(cli.jobs);
216
217 if let Some(shell) = cli.completions {
218 clap_complete::generate(shell, &mut Cli::command(), "rq", &mut std::io::stdout());
219 return ExitCode::SUCCESS;
220 }
221 if let Some(path) = &cli.index {
222 let out = output_format(&cli);
224 return cmd_index(path.as_deref().map(PathBuf::from), &cli.path, out);
225 }
226 if let Some(path) = &cli.warm {
227 return cmd_warm(path.as_deref());
228 }
229 if cli.status {
230 return cmd_status(output_format(&cli));
231 }
232 if cli.usage {
233 return cmd_usage(output_format(&cli));
234 }
235 if cli.drop {
236 let out = output_format(&cli);
237 return cmd_drop(cli.target, out);
238 }
239 if cli.record {
240 if !matches!(cli.event.as_str(), "select" | "open") {
242 return fail(format_args!(
243 "rq --record: unknown --event {:?} (expected select or open)",
244 cli.event
245 ));
246 }
247 let file = cli.file.expect("--record requires --file");
249 return cmd_record(&cli.event, cli.target.as_deref(), &file, cli.line);
250 }
251 let out = output_format(&cli);
252 let mut kinds: Vec<String> = cli.kind.iter().map(|k| canonical_kind(k)).collect();
253 let langs: Vec<String> = cli.lang.iter().flat_map(|x| canonical_langs(x)).collect();
255 if let Some(file) = &cli.symbols {
256 return cmd_symbols(file, &kinds, &langs, out);
257 }
258 let mut paths = cli.path.clone();
260 match cli.target {
261 Some(target) => {
262 let query = if cli.kind.is_empty() {
265 let (kw, query, dirs) = split_kind_keyword(target, cli.dirs.clone());
266 if let Some(k) = kw {
267 kinds.push(k.to_string());
268 }
269 paths.extend(dirs);
270 query
271 } else {
272 paths.extend(cli.dirs.clone());
273 target
274 };
275 let mut session = match Session::open() {
276 Ok(s) => s,
277 Err(code) => return code,
278 };
279 cmd_search(
280 &mut session,
281 &SearchArgs {
282 query: &query,
283 explain: cli.explain,
284 out,
285 paths: &paths,
286 kinds: &kinds,
287 langs: &langs,
288 want: requested_limit(cli.limit),
289 no_record: cli.no_record,
290 no_wait: cli.no_wait,
291 wait: cli.wait,
292 open: cli.open,
293 all_repos: cli.all_repos,
294 show: cli.show,
295 batch: false,
296 },
297 )
298 }
299 None if !std::io::stdin().is_terminal() => cmd_batch(&cli, out, &paths, &kinds, &langs),
302 None => {
304 let _ = Cli::command().print_long_help();
305 ExitCode::SUCCESS
306 }
307 }
308}
309
310#[derive(Clone, Copy, PartialEq)]
312enum Output {
313 Text,
314 Json,
315 Ndjson,
316}
317
318fn output_format(cli: &Cli) -> Output {
319 if cli.ndjson {
320 Output::Ndjson
321 } else if cli.json {
322 Output::Json
323 } else {
324 Output::Text
325 }
326}
327
328const DEFAULT_LIMIT: usize = 10;
330
331const PATH_HEADROOM: usize = 200;
334
335fn requested_limit(limit: usize) -> usize {
338 if limit == 0 { usize::MAX } else { limit }
339}
340
341fn record_usage(
344 store: &mut Store,
345 args: &SearchArgs,
346 repository_id: Option<i64>,
347 results: usize,
348 status: &str,
349 coverage: Option<&str>,
350) {
351 if args.no_record {
352 return;
353 }
354 let _ = store.record_search(&crate::store::SearchRecord {
355 query: &args.query.to_ascii_lowercase(),
356 repository_id,
357 results,
358 source: &crate::origin::detect(),
359 flags: &flag_summary(args),
360 status,
361 coverage: coverage.unwrap_or("none"),
362 });
363}
364
365fn flag_summary(args: &SearchArgs) -> String {
370 let mut on: Vec<&str> = Vec::new();
371 match args.out {
372 Output::Json => on.push("json"),
373 Output::Ndjson => on.push("ndjson"),
374 Output::Text => {}
375 }
376 for (present, name) in [
377 (args.explain, "explain"),
378 (args.show, "show"),
379 (args.open, "open"),
380 (args.all_repos, "all-repos"),
381 (args.no_wait, "no-wait"),
382 (args.batch, "batch"),
383 (!args.paths.is_empty(), "path"),
384 (!args.kinds.is_empty(), "kind"),
385 (!args.langs.is_empty(), "lang"),
386 (args.want != DEFAULT_LIMIT, "limit"),
387 ] {
388 if present {
389 on.push(name);
390 }
391 }
392 on.join(",")
393}
394
395const POLL_INTERVAL: Duration = Duration::from_millis(100);
402
403const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
407
408const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
412
413struct SearchArgs<'a> {
415 query: &'a str,
416 explain: bool,
417 out: Output,
418 paths: &'a [String],
419 kinds: &'a [String],
420 langs: &'a [String],
421 want: usize,
423 no_record: bool,
424 no_wait: bool,
426 wait: Option<Duration>,
429 open: bool,
430 all_repos: bool,
431 batch: bool,
435 show: bool,
436}
437
438struct Session {
449 store: Store,
450 cwd: Option<PathBuf>,
451 cwd_is_git: bool,
452 root: Option<PathBuf>,
453 active_paths: Vec<String>,
454 branch_refresh: Option<BranchRefresh>,
455 identity: Option<String>,
456 coverage: Option<String>,
457}
458
459impl Session {
460 fn open() -> std::result::Result<Session, ExitCode> {
462 let open_span = crate::profile::span("store open");
463 let store = match open_store() {
464 Ok(s) => s,
465 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
466 };
467 drop(open_span);
468 let git_span = crate::profile::span("setup: git root");
469 let cwd = std::env::current_dir().ok();
470 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
471
472 let root = cwd
478 .as_deref()
479 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
480 drop(git_span);
481
482 let mut branch_span = crate::profile::span("setup: branch files");
485 let (active_paths, branch_refresh) = match &root {
486 Some(c) if cwd_is_git => cached_branch_files(&store, c),
487 _ => (Vec::new(), None),
488 };
489 branch_span.note(|| {
490 let how = if branch_refresh.is_some() {
491 "cached, refreshing alongside"
492 } else {
493 "cached"
494 };
495 format!("{} changed, {how}", active_paths.len())
496 });
497 drop(branch_span);
498
499 let mut identity_span = crate::profile::span("setup: identity");
504 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
505 let coverage = identity
506 .as_deref()
507 .and_then(|id| store.coverage_status(id).ok())
508 .flatten();
509 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
510 drop(identity_span);
511 Ok(Session {
512 store,
513 cwd,
514 cwd_is_git,
515 root,
516 active_paths,
517 branch_refresh,
518 identity,
519 coverage,
520 })
521 }
522}
523
524fn cmd_batch(
539 cli: &Cli,
540 out: Output,
541 paths: &[String],
542 kinds: &[String],
543 langs: &[String],
544) -> ExitCode {
545 if out == Output::Json {
546 return fail(format_args!(
547 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
548 where each line carries the query it answers"
549 ));
550 }
551 if cli.open || cli.show {
552 return fail(format_args!(
553 "rq: --open and --show act on a single result, not a stream of queries"
554 ));
555 }
556
557 use std::io::BufRead;
558 let queries: Vec<String> = std::io::stdin()
559 .lock()
560 .lines()
561 .map_while(std::result::Result::ok)
562 .map(|l| l.trim().to_string())
563 .filter(|l| !l.is_empty())
564 .collect();
565 if queries.is_empty() {
569 let _ = Cli::command().print_long_help();
570 return ExitCode::SUCCESS;
571 }
572
573 let mut session = match Session::open() {
574 Ok(s) => s,
575 Err(code) => return code,
576 };
577
578 if !cli.no_wait
581 && session.coverage.as_deref() != Some("complete")
582 && let Some(root) = session.root.clone()
583 {
584 {
585 let budget = cli.wait.unwrap_or_else(wait_budget);
586 crate::trace!(
587 "batch: warming {} queries' worth of index first",
588 queries.len()
589 );
590 let active = session.active_paths.clone();
591 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
592 session.coverage = session
593 .identity
594 .as_deref()
595 .and_then(|id| session.store.coverage_status(id).ok())
596 .flatten();
597 }
598 }
599
600 let mut worst = ExitCode::SUCCESS;
601 let mut any_hit = false;
602 for query in &queries {
603 let code = cmd_search(
604 &mut session,
605 &SearchArgs {
606 query,
607 explain: cli.explain,
608 out,
609 paths,
610 kinds,
611 langs,
612 want: requested_limit(cli.limit),
613 no_record: cli.no_record,
614 no_wait: true,
618 wait: cli.wait,
619 open: false,
620 all_repos: cli.all_repos,
621 show: false,
622 batch: true,
623 },
624 );
625 if code == ExitCode::SUCCESS {
626 any_hit = true;
627 } else {
628 worst = code;
629 }
630 }
631 if any_hit { ExitCode::SUCCESS } else { worst }
634}
635
636fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
637 let &SearchArgs {
638 query,
639 out,
640 want,
641 no_record,
642 no_wait,
643 wait,
644 open,
645 all_repos,
646 show,
647 ..
648 } = args;
649 let wait_budget = wait.unwrap_or_else(wait_budget);
652 let no_wait = no_wait || wait_budget.is_zero();
653 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
656 want
657 } else {
658 want.saturating_mul(20).max(PATH_HEADROOM)
659 };
660 let _timer = crate::trace::Timer::start("search done");
661 let profile_started = std::time::Instant::now();
662 let t_setup = std::time::Instant::now();
663 let setup_span = crate::profile::span("setup");
665 let Session {
668 store,
669 cwd,
670 cwd_is_git,
671 root,
672 active_paths,
673 branch_refresh,
674 identity,
675 coverage,
676 } = session;
677 let cwd_is_git = *cwd_is_git;
678
679 let known = coverage.is_some();
687 let warming_ok = cwd_is_git || known;
688 if crate::trace::enabled() {
689 crate::trace!(
690 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
691 root.as_deref().map_or("?".into(), crate::trace::abbrev),
692 identity.as_deref().unwrap_or("none"),
693 coverage.as_deref().unwrap_or("none"),
694 active_paths.len(),
695 );
696 }
697 let repo_span = crate::profile::span("setup: repo state");
698 let current = identity
699 .as_deref()
700 .and_then(|id| store.repository_id(id).ok().flatten());
701 let only_repo = if all_repos { None } else { current };
704 let active = crate::search::ActiveFiles::new(active_paths.clone());
705
706 drop(repo_span);
707 let warm_span = crate::profile::span("setup: warm decision");
708
709 let warm_budget = if warm_detach_enabled() {
716 answer_warm_budget()
717 } else {
718 answer_warm_budget() + deferred_warm_budget()
719 };
720 let was_warming = coverage.as_deref() != Some("complete");
721
722 let indexed_head = (!was_warming)
733 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
734 .flatten();
735 let staleness = (!was_warming && warming_ok && !args.batch)
738 .then(|| root.clone())
739 .flatten()
740 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
741 let want_warm = warming_ok && was_warming && root.is_some();
744
745 let block = want_warm && was_warming && !no_wait;
759 let progress_ui = block && show_progress(out, stderr_interactive());
764 let indexer_budget = if block { wait_budget } else { warm_budget };
765 if progress_ui {
766 install_interrupt_handler();
767 }
768
769 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
772 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
773 crate::trace!(
774 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
775 crate::index::parse_jobs()
776 );
777 let root = root.clone().expect("checked");
778 let active = active_paths.clone();
779 let q = query.to_string();
780 let warm_done = std::sync::Arc::clone(&warm_done);
781 std::thread::spawn(move || {
782 if let Ok(mut idx) = open_store() {
783 let _ = if block {
785 crate::index::index_budgeted_cancellable(
788 &mut idx,
789 &root,
790 &active,
791 indexer_budget,
792 Some(&q),
793 &INTERRUPTED,
794 )
795 } else {
796 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
797 };
798 }
799 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
800 })
801 });
802
803 crate::trace!(
810 "setup (open + repo detect + warm decision): {} ms",
811 t_setup.elapsed().as_millis()
812 );
813 let poll_start = std::time::Instant::now();
814 let deadline = if progress_ui {
818 None
819 } else if block {
820 Some(poll_start + wait_budget)
821 } else {
822 Some(poll_start + answer_warm_budget())
823 };
824 drop(warm_span);
825 let polling = indexer.is_some() && was_warming;
826 drop(setup_span);
830 let mut query_span = crate::profile::span("query");
831 let label = repo_label(root.as_deref());
832 let mut drew_progress = false;
833 let mut last_draw = poll_start;
834 let mut hits = loop {
835 match crate::search::search(store, query, current, only_repo, &active, limit) {
836 Ok(h) => {
837 let confident = h.first().is_some_and(|hit| {
838 hit.features
839 .iter()
840 .any(|f| matches!(f.name, "exact" | "prefix"))
841 });
842 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
843 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
844 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
845 if !polling || confident || warm_finished || stopped || timed_out {
846 break h;
847 }
848 if progress_ui
849 && poll_start.elapsed() >= HEADS_UP_DELAY
850 && last_draw.elapsed() >= PROGRESS_REDRAW
851 {
852 draw_progress(store, identity.as_deref(), &label);
853 drew_progress = true;
854 last_draw = std::time::Instant::now();
855 }
856 }
857 Err(e) => {
858 if let Some(h) = indexer {
859 let _ = h.join();
860 }
861 return fail(format_args!("rq: {e}"));
862 }
863 }
864 std::thread::sleep(POLL_INTERVAL);
865 };
866 query_span.note(|| {
867 if polling {
868 "polled a warming index".to_string()
869 } else {
870 String::new()
871 }
872 });
873 drop(query_span);
874 if drew_progress {
875 clear_progress();
876 }
877 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
879
880 if !hits.is_empty() && revalidate_top(store, &hits) {
882 hits = crate::search::search(store, query, current, only_repo, &active, limit)
883 .unwrap_or_default();
884 }
885
886 if !hits.iter().any(strong)
890 && indexer.is_none()
891 && coverage.is_none()
892 && let Some(root) = &root
893 {
894 let tail = live_fallback(root, query, limit);
895 hits = crate::search::merge(hits, tail, limit);
896 }
897
898 apply_gates(query, &mut hits);
899 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
900
901 if hits.is_empty() {
902 if block {
904 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
905 }
906 if let Some(h) = indexer {
907 let _ = h.join();
908 }
909 let mut incomplete = (block || no_wait)
916 && identity
917 .as_deref()
918 .and_then(|id| store.coverage_status(id).ok().flatten())
919 .as_deref()
920 != Some("complete");
921 incomplete |= settle_warm(
931 store,
932 staleness,
933 was_warming,
934 warming_ok,
935 root.as_deref(),
936 active_paths,
937 query,
938 warm_budget,
939 no_wait,
940 identity.as_deref(),
941 );
942 record_usage(
946 store,
947 args,
948 current,
949 0,
950 if incomplete { "warming" } else { "miss" },
951 coverage.as_deref(),
952 );
953 return no_match_code(out, query, interrupted, incomplete);
954 }
955
956 record_usage(store, args, current, hits.len(), "hit", coverage.as_deref());
959
960 for hit in &mut hits {
963 hit.signature = read_signature(
964 store,
965 &hit.repo_identity,
966 &hit.file,
967 hit.line,
968 cwd.as_deref(),
969 );
970 }
971 attach_confidence(&mut hits);
972
973 if show
976 && let Some(code) = show_top_definition(
977 store,
978 &mut hits,
979 query,
980 out,
981 cwd.as_deref(),
982 current,
983 no_record,
984 )
985 {
986 return code;
987 }
988
989 if open {
993 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
994 }
995
996 if let Some(code) = render_hits(args, &hits) {
997 return code;
998 }
999
1000 if crate::profile::enabled() {
1003 let total = profile_started.elapsed();
1004 if args.out == Output::Text {
1005 for line in crate::profile::report(total) {
1006 eprintln!("{line}");
1007 }
1008 } else {
1009 eprintln!("{}", crate::profile::json(total));
1012 }
1013 }
1014
1015 if let Some(refresh) = branch_refresh.take() {
1022 refresh.store(store);
1023 }
1024
1025 deferred_maintenance(store);
1030
1031 if block {
1036 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1037 }
1038 if let Some(h) = indexer {
1039 let _ = h.join();
1040 }
1041 let _ = settle_warm(
1042 store,
1043 staleness,
1044 was_warming,
1045 warming_ok,
1046 root.as_deref(),
1047 active_paths,
1048 query,
1049 warm_budget,
1050 no_wait,
1051 identity.as_deref(),
1052 );
1053
1054 ExitCode::SUCCESS
1055}
1056
1057fn maybe_detach_warm(
1060 store: &Store,
1061 want_warm: bool,
1062 changed: bool,
1063 root: Option<&std::path::Path>,
1064 identity: Option<&str>,
1065) {
1066 if !warm_detach_enabled() || !want_warm {
1067 return;
1068 }
1069 let (Some(root), Some(id)) = (root, identity) else {
1070 return;
1071 };
1072 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1076 return; }
1078 spawn_detached_warm(root);
1079}
1080
1081fn spawn_detached_warm(root: &std::path::Path) {
1085 use std::os::unix::process::CommandExt;
1086 let Ok(exe) = std::env::current_exe() else {
1087 return;
1088 };
1089 let mut cmd = std::process::Command::new(exe);
1090 cmd.arg("--warm")
1091 .arg(root)
1092 .stdin(std::process::Stdio::null())
1093 .stdout(std::process::Stdio::null())
1094 .stderr(std::process::Stdio::null())
1095 .process_group(0);
1096 match cmd.spawn() {
1097 Ok(child) => crate::trace!(
1098 "background warm (detached): pid {} for {}",
1099 child.id(),
1100 crate::trace::abbrev(root)
1101 ),
1102 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1103 }
1104}
1105
1106const WARM_LOCK_TTL_SECS: i64 = 600;
1109
1110fn cmd_warm(path: Option<&str>) -> ExitCode {
1115 #[cfg(target_os = "macos")]
1118 unsafe extern "C" {
1119 fn setiopolicy_np(
1122 iotype: libc::c_int,
1123 scope: libc::c_int,
1124 policy: libc::c_int,
1125 ) -> libc::c_int;
1126 }
1127 unsafe {
1128 libc::nice(10);
1129 #[cfg(target_os = "macos")]
1130 setiopolicy_np(0, 0, 3);
1131 }
1132 let mut store = match open_store() {
1133 Ok(s) => s,
1134 Err(_) => return ExitCode::FAILURE,
1135 };
1136 let start = path
1137 .map(PathBuf::from)
1138 .or_else(|| std::env::current_dir().ok())
1139 .unwrap_or_else(|| PathBuf::from("."));
1140 let root = crate::index::repo_root(&start).unwrap_or(start);
1141 let identity = resolve_identity(&store, &root);
1142
1143 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1146 && pid != std::process::id()
1147 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1148 && now_secs() - ts < WARM_LOCK_TTL_SECS
1149 {
1150 return ExitCode::SUCCESS;
1151 }
1152 let _ = store.set_warm_lock(&identity, std::process::id());
1153
1154 let deadline = std::time::Instant::now() + warm_bg_budget();
1157 let active = crate::index::branch_changed_files(&root);
1158 loop {
1159 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1160 if remaining.is_zero() {
1161 break;
1162 }
1163 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1164 {
1165 Ok(s) => s,
1166 Err(_) => break,
1167 };
1168 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1169 || stats.files_indexed == 0
1170 {
1171 break;
1172 }
1173 }
1174 let _ = store.clear_warm_lock(&identity);
1175 ExitCode::SUCCESS
1176}
1177
1178fn now_secs() -> i64 {
1179 std::time::SystemTime::now()
1180 .duration_since(std::time::UNIX_EPOCH)
1181 .map(|d| d.as_secs() as i64)
1182 .unwrap_or(0)
1183}
1184
1185fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1188 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1189 let deadline = std::time::Instant::now() + live_fallback_budget();
1190 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1191 if !h.is_empty() {
1192 return h;
1193 }
1194 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1195}
1196
1197fn strong(h: &crate::search::Hit) -> bool {
1199 h.features
1200 .iter()
1201 .any(|f| matches!(f.name, "exact" | "prefix"))
1202}
1203
1204fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1213 if hits.iter().any(strong) {
1214 hits.retain(strong);
1215 }
1216 crate::search::apply_scope_gate(query, hits);
1217}
1218
1219fn apply_post_filters(
1222 args: &SearchArgs,
1223 cwd: Option<&std::path::Path>,
1224 root: Option<&std::path::Path>,
1225 hits: &mut Vec<crate::search::Hit>,
1226) {
1227 if !args.paths.is_empty() {
1228 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1232 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1233 let norm: Vec<String> = args
1234 .paths
1235 .iter()
1236 .map(|p| repo_relative(&base, &here, p))
1237 .collect();
1238 hits.retain(|h| under_any(&h.file, &norm));
1239 }
1240 if !args.kinds.is_empty() {
1241 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1242 }
1243 if !args.langs.is_empty() {
1244 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1245 }
1246 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1247 hits.truncate(args.want);
1248 }
1249}
1250
1251fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1257 let status = if interrupted {
1258 "interrupted"
1259 } else if incomplete {
1260 "warming"
1261 } else {
1262 "no_match"
1263 };
1264 match out {
1265 Output::Json | Output::Ndjson => {
1266 let obj = serde_json::json!({ "status": status, "query": query });
1267 let _ = emit_json(out, &obj); }
1269 Output::Text if interrupted => {
1270 eprintln!("rq: indexing interrupted — run again to finish")
1271 }
1272 Output::Text if incomplete => eprintln!(
1273 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1274 ),
1275 Output::Text => eprintln!("no matches for {query:?}"),
1276 }
1277 if incomplete {
1278 ExitCode::from(2)
1279 } else {
1280 ExitCode::FAILURE
1281 }
1282}
1283
1284fn attach_confidence(hits: &mut [crate::search::Hit]) {
1288 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1289 if t.is_none_or(|t| h.score > t) {
1290 (Some(h.score), t)
1291 } else if s.is_none_or(|s| h.score > s) {
1292 (t, Some(h.score))
1293 } else {
1294 (t, s)
1295 }
1296 });
1297 for hit in hits.iter_mut() {
1298 let best_other = if Some(hit.score) == top { second } else { top };
1299 hit.confidence = crate::search::confidence(
1300 hit.score,
1301 crate::search::match_quality(&hit.features),
1302 best_other,
1303 );
1304 }
1305}
1306
1307fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1310 let render_span = crate::profile::span("render");
1314 if args.batch {
1315 #[derive(serde::Serialize)]
1318 struct Tagged<'a> {
1319 query: &'a str,
1320 #[serde(flatten)]
1321 hit: &'a crate::search::Hit,
1322 }
1323 let rows: Vec<Tagged> = hits
1324 .iter()
1325 .map(|hit| Tagged {
1326 query: args.query,
1327 hit,
1328 })
1329 .collect();
1330 if let Some(code) = emit_rows(args.out, &rows) {
1331 return Some(code);
1332 }
1333 } else if let Some(code) = emit_rows(args.out, hits) {
1334 return Some(code);
1335 }
1336 if args.out != Output::Text {
1337 return None;
1338 }
1339 drop(render_span);
1340 let color = match_color();
1341 let c = color.as_deref();
1342 let query = args.query;
1343 if args.show {
1344 eprintln!(
1346 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1347 hits.len()
1348 );
1349 }
1350 for hit in hits {
1351 let name = hl(&hit.name, query, c);
1354 let qualified = match &hit.parent {
1355 Some(p) => format!("{name} · {p}"),
1356 None => name,
1357 };
1358 println!(
1359 "{}:{} {} {}",
1360 hl_path(&hit.file, query, c),
1361 hit.line,
1362 hit.kind,
1363 qualified
1364 );
1365 if let Some(sig) = &hit.signature {
1366 println!(" {}", hl(sig, query, c));
1367 }
1368 if args.explain {
1369 let parts: Vec<String> = hit
1370 .features
1371 .iter()
1372 .map(|f| format!("{} {:.0}", f.name, f.value))
1373 .collect();
1374 println!(
1375 " confidence {:.2} · score {:.0} = {}",
1376 hit.confidence,
1377 hit.score,
1378 parts.join(" + ")
1379 );
1380 }
1381 }
1382 None
1383}
1384
1385fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1389 use std::io::{IsTerminal, Write};
1390 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1391 return hits.first();
1392 }
1393 let mut err = std::io::stderr();
1394 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1395 for (i, h) in hits.iter().enumerate() {
1396 let _ = writeln!(
1397 err,
1398 " {}. {}:{} {} {}",
1399 i + 1,
1400 h.file,
1401 h.line,
1402 h.kind,
1403 h.name
1404 );
1405 }
1406 let _ = write!(err, "rq> ");
1407 let _ = err.flush();
1408 let mut line = String::new();
1409 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1410 return None; }
1412 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1413}
1414
1415fn parse_choice(input: &str, n: usize) -> Option<usize> {
1418 let s = input.trim();
1419 if s.is_empty() {
1420 return Some(0);
1421 }
1422 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1423 (i < n).then_some(i)
1424}
1425
1426fn finish_open(
1430 store: &mut Store,
1431 hits: &[crate::search::Hit],
1432 query: &str,
1433 current: Option<i64>,
1434 root: Option<&std::path::Path>,
1435 no_record: bool,
1436) -> ExitCode {
1437 let Some(hit) = choose_hit(hits) else {
1438 return ExitCode::SUCCESS; };
1440
1441 if !no_record {
1444 let _ = store.record_event(
1445 "select",
1446 Some(&query.to_ascii_lowercase()),
1447 current,
1448 Some(&hit.file),
1449 Some(hit.line),
1450 None,
1451 );
1452 deferred_maintenance(store);
1453 }
1454
1455 let target = match root {
1458 Some(r) => r.join(&hit.file),
1459 None => PathBuf::from(&hit.file),
1460 };
1461 launch_editor(&target, hit.line)
1462}
1463
1464fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1468 use std::os::unix::process::CommandExt;
1469 let loc = format!("{}:{}", file.display(), line);
1470 match open_command(file, line, &loc) {
1471 Some((prog, args)) => {
1472 let err = std::process::Command::new(&prog).args(&args).exec();
1474 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1475 }
1476 None => {
1477 println!("{loc}");
1478 ExitCode::SUCCESS
1479 }
1480 }
1481}
1482
1483fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1487 let fstr = file.to_string_lossy().into_owned();
1488
1489 if let Some(t) = std::env::var_os("RQ_OPEN") {
1490 let t = t.to_string_lossy();
1491 let mut parts = t.split_whitespace().map(|p| {
1492 p.replace("{file}", &fstr)
1493 .replace("{line}", &line.to_string())
1494 .replace("{}", loc)
1495 });
1496 if let Some(prog) = parts.next() {
1497 return Some((prog, parts.collect()));
1498 }
1499 }
1500
1501 if on_path("code") {
1502 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1503 }
1504
1505 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1506 let ed = ed.to_string_lossy().into_owned();
1507 let l = ed.to_ascii_lowercase();
1508 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1510 .iter()
1511 .any(|e| l.contains(e))
1512 {
1513 return Some((ed, vec![format!("+{line}"), fstr]));
1514 }
1515 return Some((ed, vec![fstr]));
1516 }
1517
1518 None
1519}
1520
1521fn on_path(prog: &str) -> bool {
1523 std::env::var_os("PATH")
1524 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1525}
1526
1527fn unix_now() -> i64 {
1537 std::time::SystemTime::now()
1538 .duration_since(std::time::UNIX_EPOCH)
1539 .map(|d| d.as_secs() as i64)
1540 .unwrap_or(0)
1541}
1542
1543const BRANCH_FILES_TTL_SECS: i64 = 15;
1549
1550struct BranchRefresh {
1554 handle: std::thread::JoinHandle<Vec<String>>,
1555 identity: String,
1556 stamp: String,
1557}
1558
1559impl BranchRefresh {
1560 fn store(self, store: &Store) {
1562 let Ok(files) = self.handle.join() else {
1563 return;
1564 };
1565 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1566 }
1567}
1568
1569fn cached_branch_files(
1583 store: &Store,
1584 root: &std::path::Path,
1585) -> (Vec<String>, Option<BranchRefresh>) {
1586 let identity = resolve_identity(store, root);
1587 let stamp = crate::index::branch_files_stamp(root);
1588 let cached = store.branch_files_get(&identity).ok().flatten();
1589 let now = unix_now();
1590
1591 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1592 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1593 return (files.clone(), None);
1594 }
1595 let owned_root = root.to_path_buf();
1596 let refresh = BranchRefresh {
1597 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1598 identity,
1599 stamp: stamp.clone(),
1600 };
1601 return (files.clone(), Some(refresh));
1602 }
1603
1604 let files = crate::index::branch_changed_files(root);
1606 if let Some(stamp) = stamp {
1607 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1608 }
1609 (files, None)
1610}
1611
1612fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1621 let Some(head) = indexed_head else {
1622 return true;
1623 };
1624 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1625}
1626
1627#[allow(clippy::too_many_arguments)]
1635fn settle_warm(
1636 store: &Store,
1637 staleness: Option<std::thread::JoinHandle<bool>>,
1638 was_warming: bool,
1639 warming_ok: bool,
1640 root: Option<&std::path::Path>,
1641 active: &[String],
1642 query: &str,
1643 budget: Duration,
1644 no_wait: bool,
1645 identity: Option<&str>,
1646) -> bool {
1647 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1650 if changed
1660 && !no_wait
1661 && !warm_detach_enabled()
1662 && let Some(r) = root
1663 && let Ok(mut idx) = open_store()
1664 {
1665 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1666 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1667 }
1668 maybe_detach_warm(
1669 store,
1670 warming_ok && (was_warming || changed),
1671 changed,
1672 root,
1673 identity,
1674 );
1675 changed && warm_detach_enabled()
1679}
1680
1681fn answer_warm_budget() -> Duration {
1690 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1691}
1692
1693fn deferred_warm_budget() -> Duration {
1696 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1697}
1698
1699fn live_fallback_budget() -> Duration {
1702 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1703}
1704
1705fn warm_bg_budget() -> Duration {
1708 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1709}
1710
1711fn warm_detach_enabled() -> bool {
1715 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1716}
1717
1718fn wait_budget() -> Duration {
1727 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1728}
1729
1730fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1735 let s = s.trim();
1736 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1737 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1739 (n, 1.0)
1740 } else if let Some(n) = s.strip_suffix('s') {
1741 (n, 1_000.0)
1742 } else if let Some(n) = s.strip_suffix('m') {
1743 (n, 60_000.0)
1744 } else {
1745 (s, 1_000.0)
1747 };
1748 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1749 if !val.is_finite() || val < 0.0 {
1750 return Err(bad());
1751 }
1752 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1753}
1754
1755static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1759
1760extern "C" fn on_sigint(_: libc::c_int) {
1761 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1763}
1764
1765fn install_interrupt_handler() {
1768 static ONCE: std::sync::Once = std::sync::Once::new();
1769 ONCE.call_once(|| unsafe {
1770 let mut action: libc::sigaction = std::mem::zeroed();
1771 action.sa_sigaction = on_sigint as *const () as usize;
1772 libc::sigemptyset(&mut action.sa_mask);
1773 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1774 });
1775}
1776
1777fn stderr_interactive() -> bool {
1781 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1782}
1783
1784fn show_progress(out: Output, interactive: bool) -> bool {
1789 interactive && matches!(out, Output::Text)
1790}
1791
1792fn repo_label(root: Option<&std::path::Path>) -> String {
1795 root.and_then(|r| r.file_name())
1796 .map(|n| n.to_string_lossy().into_owned())
1797 .unwrap_or_else(|| "repo".into())
1798}
1799
1800fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1804 let files = identity
1805 .and_then(|id| store.repository_id(id).ok().flatten())
1806 .and_then(|rid| store.repo_totals(rid).ok())
1807 .map_or(0, |(f, _)| f);
1808 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1809 let _ = std::io::stderr().flush();
1810}
1811
1812fn clear_progress() {
1814 eprint!("\r\x1b[K");
1815 let _ = std::io::stderr().flush();
1816}
1817
1818fn env_budget(var: &str, default_ms: u64) -> Duration {
1822 let ms = std::env::var(var)
1823 .ok()
1824 .and_then(|v| v.parse().ok())
1825 .unwrap_or(default_ms);
1826 Duration::from_millis(ms)
1827}
1828
1829const AGGREGATE_BATCH: usize = 256;
1832
1833const KEEP_RECENT_EVENTS: i64 = 200;
1836
1837fn deferred_maintenance(store: &mut Store) {
1840 let _ = store.aggregate_events(AGGREGATE_BATCH);
1841 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1842}
1843
1844fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1847 let mut store = match open_store() {
1848 Ok(s) => s,
1849 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1850 };
1851 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1852 let identity = crate::index::detect_identity(&cwd).to_string();
1853 let repo_id = store.repository_id(&identity).ok().flatten();
1854
1855 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1858 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1859 None => file.to_string(),
1860 };
1861 let query_norm = query.map(|q| q.to_ascii_lowercase());
1862
1863 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1864 {
1865 return fail(format_args!("rq record: {e}"));
1866 }
1867 deferred_maintenance(&mut store);
1868 ExitCode::SUCCESS
1869}
1870
1871fn hit_file_roots(
1877 store: &Store,
1878 repo_identity: &str,
1879 cwd: Option<&std::path::Path>,
1880) -> Vec<PathBuf> {
1881 let mut roots: Vec<PathBuf> = store
1882 .repository_id(repo_identity)
1883 .ok()
1884 .flatten()
1885 .map(|id| store.checkout_roots(id).unwrap_or_default())
1886 .unwrap_or_default()
1887 .into_iter()
1888 .map(PathBuf::from)
1889 .collect();
1890 if let Some(c) = cwd {
1891 let c = c.to_path_buf();
1892 if !roots.contains(&c) {
1893 roots.push(c);
1894 }
1895 }
1896 roots
1897}
1898
1899fn read_signature(
1902 store: &Store,
1903 repo_identity: &str,
1904 file: &str,
1905 line: i64,
1906 cwd: Option<&std::path::Path>,
1907) -> Option<String> {
1908 hit_file_roots(store, repo_identity, cwd)
1909 .into_iter()
1910 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1911}
1912
1913const SHOW_CONFIDENCE: f64 = 0.85;
1917
1918fn show_top_definition(
1927 store: &mut Store,
1928 hits: &mut [crate::search::Hit],
1929 query: &str,
1930 out: Output,
1931 cwd: Option<&std::path::Path>,
1932 current: Option<i64>,
1933 no_record: bool,
1934) -> Option<ExitCode> {
1935 let top = hits.first()?;
1936 if top.confidence < SHOW_CONFIDENCE {
1937 return None; }
1939 let end = top.end_line.unwrap_or(top.line);
1940 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1941 hits[0].body = body;
1942 let top = &hits[0];
1943 let shown = (top.file.clone(), top.line);
1944 let code = match out {
1945 Output::Json | Output::Ndjson => {
1946 emit_json(out, top)
1948 }
1949 Output::Text => {
1950 let color = match_color();
1951 let c = color.as_deref();
1952 let name = hl(&top.name, query, c);
1953 let qualified = match &top.parent {
1954 Some(p) => format!("{name} · {p}"),
1955 None => name,
1956 };
1957 println!(
1958 "{}:{} {} {}",
1959 hl_path(&top.file, query, c),
1960 top.line,
1961 top.kind,
1962 qualified
1963 );
1964 match (&top.body, &top.signature) {
1965 (Some(body), _) => println!("{body}"),
1966 (None, Some(sig)) => println!("{sig}"),
1968 (None, None) => {}
1969 }
1970 ExitCode::SUCCESS
1971 }
1972 };
1973
1974 if !no_record {
1976 let (file, line) = shown;
1977 let _ = store.record_event(
1978 "select",
1979 Some(&query.to_ascii_lowercase()),
1980 current,
1981 Some(&file),
1982 Some(line),
1983 None,
1984 );
1985 deferred_maintenance(store);
1986 }
1987 Some(code)
1988}
1989
1990fn read_span(
1993 store: &Store,
1994 repo_identity: &str,
1995 file: &str,
1996 start: i64,
1997 end: i64,
1998 cwd: Option<&std::path::Path>,
1999) -> Option<String> {
2000 hit_file_roots(store, repo_identity, cwd)
2001 .into_iter()
2002 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
2003}
2004
2005fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
2008 let s = usize::try_from(start).ok()?.checked_sub(1)?;
2009 let lines: Vec<&str> = content.lines().collect();
2010 if s >= lines.len() {
2011 return None;
2012 }
2013 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
2014 Some(lines[s..e].join("\n"))
2015}
2016
2017fn signature_in(content: &str, line: i64) -> Option<String> {
2021 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
2022 let l = content.lines().nth(idx)?.trim();
2023 (!l.is_empty()).then(|| l.to_string())
2024}
2025
2026#[derive(serde::Serialize)]
2030struct SymbolOut {
2031 name: String,
2032 kind: String,
2033 language: String,
2034 file: String,
2035 line: i64,
2036 #[serde(skip_serializing_if = "Option::is_none")]
2037 end_line: Option<i64>,
2038 #[serde(skip_serializing_if = "Option::is_none")]
2039 parent: Option<String>,
2040 #[serde(skip_serializing_if = "Option::is_none")]
2041 visibility: Option<String>,
2042 repo: String,
2043 #[serde(skip_serializing_if = "Option::is_none")]
2044 signature: Option<String>,
2045}
2046
2047fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2052 let mut store = match open_store() {
2053 Ok(s) => s,
2054 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2055 };
2056 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2057 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2058 let rel = repo_relative(&root, &cwd, file_arg);
2059
2060 let identity = resolve_identity(&store, &root);
2061 let coverage = store.coverage_status(&identity).ok().flatten();
2062 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2063 let current = store.repository_id(&identity).ok().flatten();
2064 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
2067 let needs_warm = warming_ok
2068 && (coverage.as_deref() != Some("complete")
2069 || worktree_changed(&root, indexed_head.as_deref()));
2070 if needs_warm {
2071 let budget = answer_warm_budget() + deferred_warm_budget();
2073 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2074 }
2075
2076 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2077 return emit_symbols(out, &[]); };
2079 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2080 Ok(r) => r,
2081 Err(e) => return fail(format_args!("rq: {e}")),
2082 };
2083 if !kinds.is_empty() {
2084 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2085 }
2086 if !langs.is_empty() {
2087 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2088 }
2089
2090 let content = hit_file_roots(&store, &identity, Some(&root))
2094 .iter()
2095 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2096 let syms: Vec<SymbolOut> = rows
2097 .into_iter()
2098 .map(|r| SymbolOut {
2099 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2100 name: r.name,
2101 kind: r.kind,
2102 language: r.language,
2103 file: r.file,
2104 line: r.line,
2105 end_line: r.end_line,
2106 parent: r.parent,
2107 visibility: r.visibility,
2108 repo: r.repo_identity,
2109 })
2110 .collect();
2111 emit_symbols(out, &syms)
2112}
2113
2114fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2117 if syms.is_empty() {
2118 match out {
2119 Output::Json | Output::Ndjson => {
2120 let obj = serde_json::json!({ "status": "no_match" });
2121 let _ = emit_json(out, &obj); }
2123 Output::Text => eprintln!("no symbols"),
2124 }
2125 return ExitCode::FAILURE;
2126 }
2127 if let Some(code) = emit_rows(out, syms) {
2128 return code;
2129 }
2130 match out {
2131 Output::Json | Output::Ndjson => {}
2132 Output::Text => {
2133 for s in syms {
2134 let qualified = match &s.parent {
2135 Some(p) => format!("{} · {p}", s.name),
2136 None => s.name.clone(),
2137 };
2138 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2139 if let Some(sig) = &s.signature {
2140 println!(" {sig}");
2141 }
2142 }
2143 }
2144 }
2145 ExitCode::SUCCESS
2146}
2147
2148fn keyword_kind(token: &str) -> Option<&'static str> {
2153 match token.to_ascii_lowercase().as_str() {
2154 "class" => Some("class"),
2155 "module" => Some("module"),
2156 "method" => Some("method"),
2157 "function" | "fn" => Some("function"),
2158 "struct" | "type" => Some("struct"),
2159 "enum" => Some("enum"),
2160 "trait" | "interface" => Some("trait"),
2161 _ => None,
2162 }
2163}
2164
2165fn split_kind_keyword(
2171 target: String,
2172 dirs: Vec<String>,
2173) -> (Option<&'static str>, String, Vec<String>) {
2174 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2177 let rest = rest.trim();
2178 if let Some(k) = keyword_kind(head)
2179 && !rest.is_empty()
2180 {
2181 return (Some(k), rest.to_string(), dirs);
2182 }
2183 } else if let Some(k) = keyword_kind(&target)
2184 && let Some((query, extra)) = dirs.split_first()
2185 {
2186 return (Some(k), query.clone(), extra.to_vec());
2188 }
2189 (None, target, dirs)
2190}
2191
2192fn canonical_kind(s: &str) -> String {
2195 match s.to_ascii_lowercase().as_str() {
2196 "c" | "class" => "class",
2197 "m" | "method" => "method",
2198 "f" | "fn" | "func" | "function" => "function",
2199 "mod" | "module" => "module",
2200 "s" | "struct" | "type" => "struct",
2201 "e" | "enum" => "enum",
2202 "t" | "trait" | "interface" => "trait",
2203 other => return other.to_string(),
2204 }
2205 .to_string()
2206}
2207
2208fn canonical_langs(s: &str) -> Vec<String> {
2215 let t = s.to_ascii_lowercase();
2216 let alias = match t.as_str() {
2217 "rb" => Some("ruby"),
2218 "rs" => Some("rust"),
2219 "golang" => Some("go"),
2220 "ts" | "tsx" => Some("typescript"),
2221 "js" | "jsx" => Some("javascript"),
2222 _ => None,
2223 };
2224 let matched: Vec<String> = crate::lang::languages()
2225 .into_iter()
2226 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2227 .map(str::to_string)
2228 .collect();
2229 if matched.is_empty() { vec![t] } else { matched }
2230}
2231
2232fn match_color() -> Option<String> {
2236 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2237 return None;
2238 }
2239 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2240 gc.split(':').find_map(|e| {
2241 e.strip_prefix("mt=")
2242 .or_else(|| e.strip_prefix("ms="))
2243 .filter(|v| !v.is_empty())
2244 .map(str::to_string)
2245 })
2246 });
2247 Some(style.unwrap_or_else(|| "1;31".to_string()))
2248}
2249
2250fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2253 match color {
2254 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2255 None => text.to_string(),
2256 }
2257}
2258
2259fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2262 let Some(c) = color else {
2263 return path.to_string();
2264 };
2265 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2266 let base_start = path[..base_byte].chars().count();
2267 let stem = crate::search::path_stem(path);
2271 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2272 .into_iter()
2273 .map(|p| p + base_start)
2274 .collect();
2275 highlight(path, &positions, c)
2276}
2277
2278fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2281 if positions.is_empty() {
2282 return text.to_string();
2283 }
2284 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2285 let mut out = String::new();
2286 let mut on = false;
2287 for (i, c) in text.chars().enumerate() {
2288 match (matched.contains(&i), on) {
2289 (true, false) => {
2290 out.push_str("\x1b[");
2291 out.push_str(color);
2292 out.push('m');
2293 on = true;
2294 }
2295 (false, true) => {
2296 out.push_str("\x1b[0m");
2297 on = false;
2298 }
2299 _ => {}
2300 }
2301 out.push(c);
2302 }
2303 if on {
2304 out.push_str("\x1b[0m");
2305 }
2306 out
2307}
2308
2309fn under_any(file: &str, paths: &[String]) -> bool {
2313 paths.iter().any(|p| {
2314 let p = p.trim_start_matches("./").trim_end_matches('/');
2315 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2316 })
2317}
2318
2319fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2321 let p = std::path::Path::new(file);
2322 let abs = if p.is_absolute() {
2323 p.to_path_buf()
2324 } else {
2325 cwd.join(p)
2326 };
2327 let abs = abs.canonicalize().unwrap_or(abs);
2328 abs.strip_prefix(root)
2329 .map(|r| r.to_string_lossy().into_owned())
2330 .unwrap_or_else(|_| file.to_string())
2331}
2332
2333fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2337 use std::collections::HashSet;
2338 let mut seen = HashSet::new();
2339 let mut changed = false;
2340 for hit in hits {
2341 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2342 continue;
2343 }
2344 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2345 continue;
2346 };
2347 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2348 continue;
2349 };
2350 if let Ok(crate::index::Refresh::Updated) =
2351 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2352 {
2353 changed = true;
2354 }
2355 }
2356 changed
2357}
2358
2359fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2365 if let Ok(canon) = cwd.canonicalize() {
2366 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2367 return identity;
2368 }
2369 if crate::index::repo_root(cwd).is_none() {
2370 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2371 }
2372 }
2373 crate::index::detect_identity(cwd).to_string()
2374}
2375
2376fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2377 let explicit = path.is_some();
2378 let target = path.unwrap_or_else(|| PathBuf::from("."));
2379 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2384 let mut subdirs = subdirs.to_vec();
2389 if explicit
2390 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2391 && t != r
2392 && let Ok(rel) = t.strip_prefix(&r)
2393 && !rel.as_os_str().is_empty()
2394 {
2395 subdirs.push(rel.to_string_lossy().into_owned());
2396 }
2397 let mut store = match open_store() {
2398 Ok(s) => s,
2399 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2400 };
2401 let identity = crate::index::detect_identity(&root).to_string();
2402 match crate::index::index_under(&mut store, &root, &subdirs) {
2403 Ok(stats) => {
2404 let subtree = !subdirs.is_empty();
2405 let totals = store
2407 .repository_id(&identity)
2408 .ok()
2409 .flatten()
2410 .and_then(|id| store.repo_totals(id).ok());
2411 match out {
2412 Output::Json | Output::Ndjson => {
2413 let (files, symbols) = match totals {
2414 Some((f, s)) => (Some(f), Some(s)),
2415 None => (None, None),
2416 };
2417 return emit_json(
2418 out,
2419 &serde_json::json!({
2420 "repo": identity,
2421 "scope": if subtree { "subtree" } else { "full" },
2422 "files_added": stats.files_indexed,
2423 "symbols_added": stats.symbols,
2424 "files": files,
2425 "symbols": symbols,
2426 }),
2427 );
2428 }
2429 Output::Text => {
2430 let scope = if subtree { " (subtree seed)" } else { "" };
2431 match totals {
2432 Some((files, symbols)) => println!(
2433 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2434 stats.files_indexed, stats.symbols
2435 ),
2436 None => println!(
2437 "{} file(s)/{} symbol(s) added this run{scope}",
2438 stats.files_indexed, stats.symbols
2439 ),
2440 }
2441 }
2442 }
2443 ExitCode::SUCCESS
2444 }
2445 Err(e) => fail(format_args!("rq --index: {e}")),
2446 }
2447}
2448
2449fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2450 let mut store = match open_store() {
2451 Ok(s) => s,
2452 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2453 };
2454
2455 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2459 let root = crate::index::repo_root(&path).unwrap_or(path);
2460 let from_path = crate::index::detect_identity(&root).to_string();
2461 let resolved = match store.repository_id(&from_path) {
2462 Ok(Some(id)) => Some((from_path.clone(), id)),
2463 Ok(None) => target.as_deref().and_then(|s| {
2464 store
2465 .repository_id(s)
2466 .ok()
2467 .flatten()
2468 .map(|id| (s.to_string(), id))
2469 }),
2470 Err(e) => return fail(format_args!("rq --drop: {e}")),
2471 };
2472
2473 let Some((identity, repo_id)) = resolved else {
2474 return match out {
2476 Output::Text => {
2477 println!("not indexed: {from_path}");
2478 ExitCode::SUCCESS
2479 }
2480 _ => emit_json(
2481 out,
2482 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2483 ),
2484 };
2485 };
2486
2487 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2488 match store.drop_repository(repo_id) {
2489 Ok(()) => match out {
2490 Output::Text => {
2491 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2492 ExitCode::SUCCESS
2493 }
2494 _ => emit_json(
2495 out,
2496 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2497 ),
2498 },
2499 Err(e) => fail(format_args!("rq --drop: {e}")),
2500 }
2501}
2502
2503fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2507 let rendered = if out == Output::Json {
2508 serde_json::to_string_pretty(value)
2509 } else {
2510 serde_json::to_string(value)
2511 };
2512 match rendered {
2513 Ok(s) => {
2514 println!("{s}");
2515 ExitCode::SUCCESS
2516 }
2517 Err(e) => fail(format_args!("rq: {e}")),
2518 }
2519}
2520
2521fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2525 match out {
2526 Output::Json => match serde_json::to_string_pretty(rows) {
2527 Ok(s) => println!("{s}"),
2528 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2529 },
2530 Output::Ndjson => {
2531 for r in rows {
2532 match serde_json::to_string(r) {
2533 Ok(line) => println!("{line}"),
2534 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2535 }
2536 }
2537 }
2538 Output::Text => {}
2539 }
2540 None
2541}
2542
2543fn cmd_status(out: Output) -> ExitCode {
2544 let store = match open_store() {
2545 Ok(s) => s,
2546 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2547 };
2548 let rows = match store.coverage_overview() {
2549 Ok(rows) => rows,
2550 Err(e) => return fail(format_args!("rq --status: {e}")),
2551 };
2552 if let Some(code) = emit_rows(out, &rows) {
2553 return code;
2554 }
2555 match out {
2556 Output::Json | Output::Ndjson => {}
2557 Output::Text if rows.is_empty() => {
2558 println!("no repositories indexed yet (try `rq --index`)");
2559 }
2560 Output::Text => {
2561 for r in &rows {
2562 println!(
2563 "{:<10} {:>6} files {:>7} symbols {}",
2564 r.status, r.files, r.symbols, r.identity
2565 );
2566 }
2567 }
2568 }
2569 ExitCode::SUCCESS
2570}
2571
2572fn cmd_usage(out: Output) -> ExitCode {
2575 let store = match open_store() {
2576 Ok(s) => s,
2577 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2578 };
2579 let rows = match store.usage_overview() {
2580 Ok(rows) => rows,
2581 Err(e) => return fail(format_args!("rq --usage: {e}")),
2582 };
2583 if let Some(code) = emit_rows(out, &rows) {
2584 return code;
2585 }
2586 match out {
2587 Output::Json | Output::Ndjson => {}
2588 Output::Text if rows.is_empty() => {
2589 println!("no usage recorded yet");
2590 }
2591 Output::Text => {
2592 println!(
2595 "{:<10} {:<16} {:>6} {:>7} {:>8} flags",
2596 "day", "caller", "found", "missed", "warming"
2597 );
2598 for r in &rows {
2599 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2600 println!(
2601 "{:<10} {:<16} {:>6} {:>7} {:>8} {}",
2602 r.day,
2603 r.source,
2604 r.searches - r.misses - r.warming,
2605 r.misses,
2606 r.warming,
2607 flags
2608 );
2609 }
2610 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2611 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2612 let warming: i64 = rows.iter().map(|r| r.warming).sum();
2613 let complete: i64 = rows.iter().map(|r| r.on_complete).sum();
2614 let plural = if searches == 1 { "search" } else { "searches" };
2615 println!(
2618 "{searches} {plural} · {misses} missed · {warming} asked too early · {complete} on a complete index"
2619 );
2620 }
2621 }
2622 if rows.is_empty() {
2624 return ExitCode::from(1);
2625 }
2626 ExitCode::SUCCESS
2627}
2628
2629fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2631 let path = db_path()?;
2632 if let Some(parent) = path.parent() {
2633 std::fs::create_dir_all(parent)?;
2634 }
2635 Ok(Store::open(&path)?)
2636}
2637
2638fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2640 if let Ok(p) = std::env::var("RQ_DB") {
2641 return Ok(PathBuf::from(p));
2642 }
2643 let home = std::env::var("HOME")?;
2644 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2645}
2646
2647fn fail(args: std::fmt::Arguments) -> ExitCode {
2648 eprintln!("{args}");
2649 ExitCode::FAILURE
2650}
2651
2652#[cfg(test)]
2653mod tests {
2654 use super::*;
2655
2656 #[test]
2657 fn open_menu_choice_parsing() {
2658 assert_eq!(parse_choice("\n", 5), Some(0));
2660 assert_eq!(parse_choice(" ", 5), Some(0));
2661 assert_eq!(parse_choice("3", 5), Some(2));
2662 assert_eq!(parse_choice("5", 5), Some(4));
2663 assert_eq!(parse_choice("6", 5), None);
2665 assert_eq!(parse_choice("0", 5), None);
2666 assert_eq!(parse_choice("q", 5), None);
2667 }
2668
2669 #[test]
2670 fn wait_duration_parsing() {
2671 use std::time::Duration;
2672 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2674 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2675 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2676 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2677 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2679 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2680 assert!(parse_wait("0s").unwrap().is_zero());
2681 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2683 assert!(parse_wait("2x").is_err());
2685 assert!(parse_wait("").is_err());
2686 assert!(parse_wait("s").is_err());
2687 assert!(parse_wait("-1s").is_err());
2688 }
2689
2690 #[test]
2691 fn leading_kind_keyword_becomes_a_kind_filter() {
2692 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2693 assert_eq!(
2695 split_kind_keyword("class".into(), d(&["Widget"])),
2696 (Some("class"), "Widget".into(), vec![])
2697 );
2698 assert_eq!(
2700 split_kind_keyword("method zoom".into(), vec![]),
2701 (Some("method"), "zoom".into(), vec![])
2702 );
2703 assert_eq!(
2705 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2706 (Some("function"), "Foo::run".into(), vec![])
2707 );
2708 assert_eq!(
2710 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2711 (Some("struct"), "Gadget".into(), d(&["src"]))
2712 );
2713 }
2714
2715 #[test]
2716 fn a_bare_or_non_keyword_query_is_left_alone() {
2717 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2718 assert_eq!(
2720 split_kind_keyword("class".into(), vec![]),
2721 (None, "class".into(), vec![])
2722 );
2723 assert_eq!(
2725 split_kind_keyword("Widget".into(), d(&["app"])),
2726 (None, "Widget".into(), d(&["app"]))
2727 );
2728 assert_eq!(
2730 split_kind_keyword("c".into(), d(&["Foo"])),
2731 (None, "c".into(), d(&["Foo"]))
2732 );
2733 }
2734
2735 #[test]
2736 fn a_language_selects_by_prefix_or_alias() {
2737 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2739 assert_eq!(canonical_langs("t"), ["typescript"]);
2740 assert_eq!(canonical_langs("ts"), ["typescript"]);
2742 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2743 assert_eq!(canonical_langs("rb"), ["ruby"]);
2744 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2746 }
2747
2748 #[test]
2749 fn a_kind_normalizes_language_specific_spellings() {
2750 assert_eq!(canonical_kind("f"), "function");
2751 assert_eq!(canonical_kind("interface"), "trait");
2753 assert_eq!(canonical_kind("type"), "struct");
2754 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2756 assert_eq!(
2757 split_kind_keyword("interface".into(), d(&["Renderer"])),
2758 (Some("trait"), "Renderer".into(), vec![])
2759 );
2760 }
2761
2762 #[test]
2763 fn highlight_wraps_matched_runs() {
2764 assert_eq!(
2765 highlight("FooThing", &[0, 1, 2], "1;31"),
2766 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2767 );
2768 assert_eq!(
2770 highlight("FooThing", &[0, 3], "1"),
2771 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2772 );
2773 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2775 }
2776
2777 #[test]
2778 fn progress_ui_only_for_an_interactive_text_terminal() {
2779 assert!(show_progress(Output::Text, true));
2781
2782 assert!(!show_progress(Output::Json, true));
2784 assert!(!show_progress(Output::Ndjson, true));
2785
2786 assert!(!show_progress(Output::Text, false));
2788 }
2789
2790 #[test]
2791 fn repo_label_uses_the_directory_name() {
2792 assert_eq!(
2793 repo_label(Some(std::path::Path::new("/src/widgets"))),
2794 "widgets"
2795 );
2796 assert_eq!(repo_label(None), "repo");
2797 }
2798
2799 #[test]
2800 fn hl_path_highlights_the_stem_not_the_extension() {
2801 let out = hl_path(
2804 "app/employees_controller.rb",
2805 "employeescontroller",
2806 Some("1;31"),
2807 );
2808 assert!(
2809 out.starts_with("app/\u{1b}[1;31memployees"),
2810 "stem highlighted: {out:?}"
2811 );
2812 assert!(
2813 out.ends_with("controller\u{1b}[0m.rb"),
2814 "`.rb` left un-highlighted: {out:?}"
2815 );
2816 }
2817}