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 flag_summary(args: &SearchArgs) -> String {
346 let mut on: Vec<&str> = Vec::new();
347 match args.out {
348 Output::Json => on.push("json"),
349 Output::Ndjson => on.push("ndjson"),
350 Output::Text => {}
351 }
352 for (present, name) in [
353 (args.explain, "explain"),
354 (args.show, "show"),
355 (args.open, "open"),
356 (args.all_repos, "all-repos"),
357 (args.no_wait, "no-wait"),
358 (args.batch, "batch"),
359 (!args.paths.is_empty(), "path"),
360 (!args.kinds.is_empty(), "kind"),
361 (!args.langs.is_empty(), "lang"),
362 (args.want != DEFAULT_LIMIT, "limit"),
363 ] {
364 if present {
365 on.push(name);
366 }
367 }
368 on.join(",")
369}
370
371const POLL_INTERVAL: Duration = Duration::from_millis(100);
378
379const HEADS_UP_DELAY: Duration = Duration::from_millis(500);
383
384const PROGRESS_REDRAW: Duration = Duration::from_millis(120);
388
389struct SearchArgs<'a> {
391 query: &'a str,
392 explain: bool,
393 out: Output,
394 paths: &'a [String],
395 kinds: &'a [String],
396 langs: &'a [String],
397 want: usize,
399 no_record: bool,
400 no_wait: bool,
402 wait: Option<Duration>,
405 open: bool,
406 all_repos: bool,
407 batch: bool,
411 show: bool,
412}
413
414struct Session {
425 store: Store,
426 cwd: Option<PathBuf>,
427 cwd_is_git: bool,
428 root: Option<PathBuf>,
429 active_paths: Vec<String>,
430 branch_refresh: Option<BranchRefresh>,
431 identity: Option<String>,
432 coverage: Option<String>,
433}
434
435impl Session {
436 fn open() -> std::result::Result<Session, ExitCode> {
438 let open_span = crate::profile::span("store open");
439 let store = match open_store() {
440 Ok(s) => s,
441 Err(e) => return Err(fail(format_args!("rq: cannot open database: {e}"))),
442 };
443 drop(open_span);
444 let git_span = crate::profile::span("setup: git root");
445 let cwd = std::env::current_dir().ok();
446 let cwd_is_git = cwd.as_deref().is_some_and(crate::index::is_git_repo);
447
448 let root = cwd
454 .as_deref()
455 .map(|c| crate::index::repo_root(c).unwrap_or_else(|| c.to_path_buf()));
456 drop(git_span);
457
458 let mut branch_span = crate::profile::span("setup: branch files");
461 let (active_paths, branch_refresh) = match &root {
462 Some(c) if cwd_is_git => cached_branch_files(&store, c),
463 _ => (Vec::new(), None),
464 };
465 branch_span.note(|| {
466 let how = if branch_refresh.is_some() {
467 "cached, refreshing alongside"
468 } else {
469 "cached"
470 };
471 format!("{} changed, {how}", active_paths.len())
472 });
473 drop(branch_span);
474
475 let mut identity_span = crate::profile::span("setup: identity");
480 let identity = root.as_deref().map(|c| resolve_identity(&store, c));
481 let coverage = identity
482 .as_deref()
483 .and_then(|id| store.coverage_status(id).ok())
484 .flatten();
485 identity_span.note(|| coverage.as_deref().unwrap_or("unknown").to_string());
486 drop(identity_span);
487 Ok(Session {
488 store,
489 cwd,
490 cwd_is_git,
491 root,
492 active_paths,
493 branch_refresh,
494 identity,
495 coverage,
496 })
497 }
498}
499
500fn cmd_batch(
515 cli: &Cli,
516 out: Output,
517 paths: &[String],
518 kinds: &[String],
519 langs: &[String],
520) -> ExitCode {
521 if out == Output::Json {
522 return fail(format_args!(
523 "rq: --json can't frame a stream of queries — use --ndjson (-J), \
524 where each line carries the query it answers"
525 ));
526 }
527 if cli.open || cli.show {
528 return fail(format_args!(
529 "rq: --open and --show act on a single result, not a stream of queries"
530 ));
531 }
532
533 use std::io::BufRead;
534 let queries: Vec<String> = std::io::stdin()
535 .lock()
536 .lines()
537 .map_while(std::result::Result::ok)
538 .map(|l| l.trim().to_string())
539 .filter(|l| !l.is_empty())
540 .collect();
541 if queries.is_empty() {
545 let _ = Cli::command().print_long_help();
546 return ExitCode::SUCCESS;
547 }
548
549 let mut session = match Session::open() {
550 Ok(s) => s,
551 Err(code) => return code,
552 };
553
554 if !cli.no_wait
557 && session.coverage.as_deref() != Some("complete")
558 && let Some(root) = session.root.clone()
559 {
560 {
561 let budget = cli.wait.unwrap_or_else(wait_budget);
562 crate::trace!(
563 "batch: warming {} queries' worth of index first",
564 queries.len()
565 );
566 let active = session.active_paths.clone();
567 let _ = crate::index::index_budgeted(&mut session.store, &root, &active, budget, None);
568 session.coverage = session
569 .identity
570 .as_deref()
571 .and_then(|id| session.store.coverage_status(id).ok())
572 .flatten();
573 }
574 }
575
576 let mut worst = ExitCode::SUCCESS;
577 let mut any_hit = false;
578 for query in &queries {
579 let code = cmd_search(
580 &mut session,
581 &SearchArgs {
582 query,
583 explain: cli.explain,
584 out,
585 paths,
586 kinds,
587 langs,
588 want: requested_limit(cli.limit),
589 no_record: cli.no_record,
590 no_wait: true,
594 wait: cli.wait,
595 open: false,
596 all_repos: cli.all_repos,
597 show: false,
598 batch: true,
599 },
600 );
601 if code == ExitCode::SUCCESS {
602 any_hit = true;
603 } else {
604 worst = code;
605 }
606 }
607 if any_hit { ExitCode::SUCCESS } else { worst }
610}
611
612fn cmd_search(session: &mut Session, args: &SearchArgs) -> ExitCode {
613 let &SearchArgs {
614 query,
615 out,
616 want,
617 no_record,
618 no_wait,
619 wait,
620 open,
621 all_repos,
622 show,
623 ..
624 } = args;
625 let wait_budget = wait.unwrap_or_else(wait_budget);
628 let no_wait = no_wait || wait_budget.is_zero();
629 let limit = if args.paths.is_empty() && args.kinds.is_empty() && args.langs.is_empty() {
632 want
633 } else {
634 want.saturating_mul(20).max(PATH_HEADROOM)
635 };
636 let _timer = crate::trace::Timer::start("search done");
637 let profile_started = std::time::Instant::now();
638 let t_setup = std::time::Instant::now();
639 let setup_span = crate::profile::span("setup");
641 let Session {
644 store,
645 cwd,
646 cwd_is_git,
647 root,
648 active_paths,
649 branch_refresh,
650 identity,
651 coverage,
652 } = session;
653 let cwd_is_git = *cwd_is_git;
654
655 let known = coverage.is_some();
663 let warming_ok = cwd_is_git || known;
664 if crate::trace::enabled() {
665 crate::trace!(
666 "query {query:?}: root={} identity={} coverage={} warming_ok={warming_ok} active={}",
667 root.as_deref().map_or("?".into(), crate::trace::abbrev),
668 identity.as_deref().unwrap_or("none"),
669 coverage.as_deref().unwrap_or("none"),
670 active_paths.len(),
671 );
672 }
673 let repo_span = crate::profile::span("setup: repo state");
674 let current = identity
675 .as_deref()
676 .and_then(|id| store.repository_id(id).ok().flatten());
677 let only_repo = if all_repos { None } else { current };
680 let active = crate::search::ActiveFiles::new(active_paths.clone());
681
682 drop(repo_span);
683 let warm_span = crate::profile::span("setup: warm decision");
684
685 let warm_budget = if warm_detach_enabled() {
692 answer_warm_budget()
693 } else {
694 answer_warm_budget() + deferred_warm_budget()
695 };
696 let was_warming = coverage.as_deref() != Some("complete");
697
698 let indexed_head = (!was_warming)
709 .then(|| current.and_then(|id| store.indexed_head(id).ok().flatten()))
710 .flatten();
711 let staleness = (!was_warming && warming_ok && !args.batch)
714 .then(|| root.clone())
715 .flatten()
716 .map(|c| std::thread::spawn(move || worktree_changed(&c, indexed_head.as_deref())));
717 let want_warm = warming_ok && was_warming && root.is_some();
720
721 let block = want_warm && was_warming && !no_wait;
735 let progress_ui = block && show_progress(out, stderr_interactive());
740 let indexer_budget = if block { wait_budget } else { warm_budget };
741 if progress_ui {
742 install_interrupt_handler();
743 }
744
745 let warm_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
748 let indexer = (want_warm && root.is_some() && !no_wait).then(|| {
749 crate::trace!(
750 "background warm ({indexer_budget:?}, block={block}, progress_ui={progress_ui}, {} jobs)",
751 crate::index::parse_jobs()
752 );
753 let root = root.clone().expect("checked");
754 let active = active_paths.clone();
755 let q = query.to_string();
756 let warm_done = std::sync::Arc::clone(&warm_done);
757 std::thread::spawn(move || {
758 if let Ok(mut idx) = open_store() {
759 let _ = if block {
761 crate::index::index_budgeted_cancellable(
764 &mut idx,
765 &root,
766 &active,
767 indexer_budget,
768 Some(&q),
769 &INTERRUPTED,
770 )
771 } else {
772 crate::index::index_budgeted(&mut idx, &root, &active, indexer_budget, Some(&q))
773 };
774 }
775 warm_done.store(true, std::sync::atomic::Ordering::Relaxed);
776 })
777 });
778
779 crate::trace!(
786 "setup (open + repo detect + warm decision): {} ms",
787 t_setup.elapsed().as_millis()
788 );
789 let poll_start = std::time::Instant::now();
790 let deadline = if progress_ui {
794 None
795 } else if block {
796 Some(poll_start + wait_budget)
797 } else {
798 Some(poll_start + answer_warm_budget())
799 };
800 drop(warm_span);
801 let polling = indexer.is_some() && was_warming;
802 drop(setup_span);
806 let mut query_span = crate::profile::span("query");
807 let label = repo_label(root.as_deref());
808 let mut drew_progress = false;
809 let mut last_draw = poll_start;
810 let mut hits = loop {
811 match crate::search::search(store, query, current, only_repo, &active, limit) {
812 Ok(h) => {
813 let confident = h.first().is_some_and(|hit| {
814 hit.features
815 .iter()
816 .any(|f| matches!(f.name, "exact" | "prefix"))
817 });
818 let warm_finished = warm_done.load(std::sync::atomic::Ordering::Relaxed);
819 let stopped = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
820 let timed_out = deadline.is_some_and(|d| std::time::Instant::now() >= d);
821 if !polling || confident || warm_finished || stopped || timed_out {
822 break h;
823 }
824 if progress_ui
825 && poll_start.elapsed() >= HEADS_UP_DELAY
826 && last_draw.elapsed() >= PROGRESS_REDRAW
827 {
828 draw_progress(store, identity.as_deref(), &label);
829 drew_progress = true;
830 last_draw = std::time::Instant::now();
831 }
832 }
833 Err(e) => {
834 if let Some(h) = indexer {
835 let _ = h.join();
836 }
837 return fail(format_args!("rq: {e}"));
838 }
839 }
840 std::thread::sleep(POLL_INTERVAL);
841 };
842 query_span.note(|| {
843 if polling {
844 "polled a warming index".to_string()
845 } else {
846 String::new()
847 }
848 });
849 drop(query_span);
850 if drew_progress {
851 clear_progress();
852 }
853 let interrupted = INTERRUPTED.load(std::sync::atomic::Ordering::Relaxed);
855
856 if !hits.is_empty() && revalidate_top(store, &hits) {
858 hits = crate::search::search(store, query, current, only_repo, &active, limit)
859 .unwrap_or_default();
860 }
861
862 if !hits.iter().any(strong)
866 && indexer.is_none()
867 && coverage.is_none()
868 && let Some(root) = &root
869 {
870 let tail = live_fallback(root, query, limit);
871 hits = crate::search::merge(hits, tail, limit);
872 }
873
874 apply_gates(query, &mut hits);
875 apply_post_filters(args, cwd.as_deref(), root.as_deref(), &mut hits);
876
877 if !no_record {
881 let _ = store.record_search(
882 &query.to_ascii_lowercase(),
883 current,
884 hits.len(),
885 &crate::origin::detect(),
886 &flag_summary(args),
887 );
888 }
889
890 if hits.is_empty() {
891 if block {
893 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
894 }
895 if let Some(h) = indexer {
896 let _ = h.join();
897 }
898 let mut incomplete = (block || no_wait)
905 && identity
906 .as_deref()
907 .and_then(|id| store.coverage_status(id).ok().flatten())
908 .as_deref()
909 != Some("complete");
910 incomplete |= settle_warm(
920 store,
921 staleness,
922 was_warming,
923 warming_ok,
924 root.as_deref(),
925 active_paths,
926 query,
927 warm_budget,
928 no_wait,
929 identity.as_deref(),
930 );
931 return no_match_code(out, query, interrupted, incomplete);
932 }
933
934 for hit in &mut hits {
937 hit.signature = read_signature(
938 store,
939 &hit.repo_identity,
940 &hit.file,
941 hit.line,
942 cwd.as_deref(),
943 );
944 }
945 attach_confidence(&mut hits);
946
947 if show
950 && let Some(code) = show_top_definition(
951 store,
952 &mut hits,
953 query,
954 out,
955 cwd.as_deref(),
956 current,
957 no_record,
958 )
959 {
960 return code;
961 }
962
963 if open {
967 return finish_open(store, &hits, query, current, root.as_deref(), no_record);
968 }
969
970 if let Some(code) = render_hits(args, &hits) {
971 return code;
972 }
973
974 if crate::profile::enabled() {
977 let total = profile_started.elapsed();
978 if args.out == Output::Text {
979 for line in crate::profile::report(total) {
980 eprintln!("{line}");
981 }
982 } else {
983 eprintln!("{}", crate::profile::json(total));
986 }
987 }
988
989 if let Some(refresh) = branch_refresh.take() {
996 refresh.store(store);
997 }
998
999 deferred_maintenance(store);
1004
1005 if block {
1010 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1011 }
1012 if let Some(h) = indexer {
1013 let _ = h.join();
1014 }
1015 let _ = settle_warm(
1016 store,
1017 staleness,
1018 was_warming,
1019 warming_ok,
1020 root.as_deref(),
1021 active_paths,
1022 query,
1023 warm_budget,
1024 no_wait,
1025 identity.as_deref(),
1026 );
1027
1028 ExitCode::SUCCESS
1029}
1030
1031fn maybe_detach_warm(
1034 store: &Store,
1035 want_warm: bool,
1036 changed: bool,
1037 root: Option<&std::path::Path>,
1038 identity: Option<&str>,
1039) {
1040 if !warm_detach_enabled() || !want_warm {
1041 return;
1042 }
1043 let (Some(root), Some(id)) = (root, identity) else {
1044 return;
1045 };
1046 if !changed && store.coverage_status(id).ok().flatten().as_deref() == Some("complete") {
1050 return; }
1052 spawn_detached_warm(root);
1053}
1054
1055fn spawn_detached_warm(root: &std::path::Path) {
1059 use std::os::unix::process::CommandExt;
1060 let Ok(exe) = std::env::current_exe() else {
1061 return;
1062 };
1063 let mut cmd = std::process::Command::new(exe);
1064 cmd.arg("--warm")
1065 .arg(root)
1066 .stdin(std::process::Stdio::null())
1067 .stdout(std::process::Stdio::null())
1068 .stderr(std::process::Stdio::null())
1069 .process_group(0);
1070 match cmd.spawn() {
1071 Ok(child) => crate::trace!(
1072 "background warm (detached): pid {} for {}",
1073 child.id(),
1074 crate::trace::abbrev(root)
1075 ),
1076 Err(e) => crate::trace!("detached warm failed to spawn: {e}"),
1077 }
1078}
1079
1080const WARM_LOCK_TTL_SECS: i64 = 600;
1083
1084fn cmd_warm(path: Option<&str>) -> ExitCode {
1089 #[cfg(target_os = "macos")]
1092 unsafe extern "C" {
1093 fn setiopolicy_np(
1096 iotype: libc::c_int,
1097 scope: libc::c_int,
1098 policy: libc::c_int,
1099 ) -> libc::c_int;
1100 }
1101 unsafe {
1102 libc::nice(10);
1103 #[cfg(target_os = "macos")]
1104 setiopolicy_np(0, 0, 3);
1105 }
1106 let mut store = match open_store() {
1107 Ok(s) => s,
1108 Err(_) => return ExitCode::FAILURE,
1109 };
1110 let start = path
1111 .map(PathBuf::from)
1112 .or_else(|| std::env::current_dir().ok())
1113 .unwrap_or_else(|| PathBuf::from("."));
1114 let root = crate::index::repo_root(&start).unwrap_or(start);
1115 let identity = resolve_identity(&store, &root);
1116
1117 if let Ok(Some((pid, ts))) = store.warm_lock(&identity)
1120 && pid != std::process::id()
1121 && unsafe { libc::kill(pid as libc::pid_t, 0) } == 0
1122 && now_secs() - ts < WARM_LOCK_TTL_SECS
1123 {
1124 return ExitCode::SUCCESS;
1125 }
1126 let _ = store.set_warm_lock(&identity, std::process::id());
1127
1128 let deadline = std::time::Instant::now() + warm_bg_budget();
1131 let active = crate::index::branch_changed_files(&root);
1132 loop {
1133 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1134 if remaining.is_zero() {
1135 break;
1136 }
1137 let stats = match crate::index::index_budgeted(&mut store, &root, &active, remaining, None)
1138 {
1139 Ok(s) => s,
1140 Err(_) => break,
1141 };
1142 if store.coverage_status(&identity).ok().flatten().as_deref() == Some("complete")
1143 || stats.files_indexed == 0
1144 {
1145 break;
1146 }
1147 }
1148 let _ = store.clear_warm_lock(&identity);
1149 ExitCode::SUCCESS
1150}
1151
1152fn now_secs() -> i64 {
1153 std::time::SystemTime::now()
1154 .duration_since(std::time::UNIX_EPOCH)
1155 .map(|d| d.as_secs() as i64)
1156 .unwrap_or(0)
1157}
1158
1159fn live_fallback(root: &std::path::Path, query: &str, limit: usize) -> Vec<crate::search::Hit> {
1162 crate::trace!("empty → live (in-memory) scan of an untracked dir");
1163 let deadline = std::time::Instant::now() + live_fallback_budget();
1164 let h = crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), true);
1165 if !h.is_empty() {
1166 return h;
1167 }
1168 crate::search::live_search(root, query, limit, &HashSet::new(), Some(deadline), false)
1169}
1170
1171fn strong(h: &crate::search::Hit) -> bool {
1173 h.features
1174 .iter()
1175 .any(|f| matches!(f.name, "exact" | "prefix"))
1176}
1177
1178fn apply_gates(query: &str, hits: &mut Vec<crate::search::Hit>) {
1187 if hits.iter().any(strong) {
1188 hits.retain(strong);
1189 }
1190 crate::search::apply_scope_gate(query, hits);
1191}
1192
1193fn apply_post_filters(
1196 args: &SearchArgs,
1197 cwd: Option<&std::path::Path>,
1198 root: Option<&std::path::Path>,
1199 hits: &mut Vec<crate::search::Hit>,
1200) {
1201 if !args.paths.is_empty() {
1202 let here = cwd.map_or_else(|| PathBuf::from("."), PathBuf::from);
1206 let base = root.map_or_else(|| here.clone(), PathBuf::from);
1207 let norm: Vec<String> = args
1208 .paths
1209 .iter()
1210 .map(|p| repo_relative(&base, &here, p))
1211 .collect();
1212 hits.retain(|h| under_any(&h.file, &norm));
1213 }
1214 if !args.kinds.is_empty() {
1215 hits.retain(|h| args.kinds.iter().any(|k| k == &h.kind));
1216 }
1217 if !args.langs.is_empty() {
1218 hits.retain(|h| args.langs.iter().any(|l| l == &h.language));
1219 }
1220 if !args.paths.is_empty() || !args.kinds.is_empty() || !args.langs.is_empty() {
1221 hits.truncate(args.want);
1222 }
1223}
1224
1225fn no_match_code(out: Output, query: &str, interrupted: bool, incomplete: bool) -> ExitCode {
1231 let status = if interrupted {
1232 "interrupted"
1233 } else if incomplete {
1234 "warming"
1235 } else {
1236 "no_match"
1237 };
1238 match out {
1239 Output::Json | Output::Ndjson => {
1240 let obj = serde_json::json!({ "status": status, "query": query });
1241 let _ = emit_json(out, &obj); }
1243 Output::Text if interrupted => {
1244 eprintln!("rq: indexing interrupted — run again to finish")
1245 }
1246 Output::Text if incomplete => eprintln!(
1247 "rq: still indexing — no match for {query:?} yet (run again, or `rq --index` to finish)"
1248 ),
1249 Output::Text => eprintln!("no matches for {query:?}"),
1250 }
1251 if incomplete {
1252 ExitCode::from(2)
1253 } else {
1254 ExitCode::FAILURE
1255 }
1256}
1257
1258fn attach_confidence(hits: &mut [crate::search::Hit]) {
1262 let (top, second) = hits.iter().fold((None::<f64>, None::<f64>), |(t, s), h| {
1263 if t.is_none_or(|t| h.score > t) {
1264 (Some(h.score), t)
1265 } else if s.is_none_or(|s| h.score > s) {
1266 (t, Some(h.score))
1267 } else {
1268 (t, s)
1269 }
1270 });
1271 for hit in hits.iter_mut() {
1272 let best_other = if Some(hit.score) == top { second } else { top };
1273 hit.confidence = crate::search::confidence(
1274 hit.score,
1275 crate::search::match_quality(&hit.features),
1276 best_other,
1277 );
1278 }
1279}
1280
1281fn render_hits(args: &SearchArgs, hits: &[crate::search::Hit]) -> Option<ExitCode> {
1284 let render_span = crate::profile::span("render");
1288 if args.batch {
1289 #[derive(serde::Serialize)]
1292 struct Tagged<'a> {
1293 query: &'a str,
1294 #[serde(flatten)]
1295 hit: &'a crate::search::Hit,
1296 }
1297 let rows: Vec<Tagged> = hits
1298 .iter()
1299 .map(|hit| Tagged {
1300 query: args.query,
1301 hit,
1302 })
1303 .collect();
1304 if let Some(code) = emit_rows(args.out, &rows) {
1305 return Some(code);
1306 }
1307 } else if let Some(code) = emit_rows(args.out, hits) {
1308 return Some(code);
1309 }
1310 if args.out != Output::Text {
1311 return None;
1312 }
1313 drop(render_span);
1314 let color = match_color();
1315 let c = color.as_deref();
1316 let query = args.query;
1317 if args.show {
1318 eprintln!(
1320 "rq: no single confident match for {query:?} — {} candidates below; narrow the query to --show one",
1321 hits.len()
1322 );
1323 }
1324 for hit in hits {
1325 let name = hl(&hit.name, query, c);
1328 let qualified = match &hit.parent {
1329 Some(p) => format!("{name} · {p}"),
1330 None => name,
1331 };
1332 println!(
1333 "{}:{} {} {}",
1334 hl_path(&hit.file, query, c),
1335 hit.line,
1336 hit.kind,
1337 qualified
1338 );
1339 if let Some(sig) = &hit.signature {
1340 println!(" {}", hl(sig, query, c));
1341 }
1342 if args.explain {
1343 let parts: Vec<String> = hit
1344 .features
1345 .iter()
1346 .map(|f| format!("{} {:.0}", f.name, f.value))
1347 .collect();
1348 println!(
1349 " confidence {:.2} · score {:.0} = {}",
1350 hit.confidence,
1351 hit.score,
1352 parts.join(" + ")
1353 );
1354 }
1355 }
1356 None
1357}
1358
1359fn choose_hit(hits: &[crate::search::Hit]) -> Option<&crate::search::Hit> {
1363 use std::io::{IsTerminal, Write};
1364 if hits.len() == 1 || !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() {
1365 return hits.first();
1366 }
1367 let mut err = std::io::stderr();
1368 let _ = writeln!(err, "rq: {} matches — pick one (enter = 1):", hits.len());
1369 for (i, h) in hits.iter().enumerate() {
1370 let _ = writeln!(
1371 err,
1372 " {}. {}:{} {} {}",
1373 i + 1,
1374 h.file,
1375 h.line,
1376 h.kind,
1377 h.name
1378 );
1379 }
1380 let _ = write!(err, "rq> ");
1381 let _ = err.flush();
1382 let mut line = String::new();
1383 if std::io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
1384 return None; }
1386 parse_choice(&line, hits.len()).and_then(|i| hits.get(i))
1387}
1388
1389fn parse_choice(input: &str, n: usize) -> Option<usize> {
1392 let s = input.trim();
1393 if s.is_empty() {
1394 return Some(0);
1395 }
1396 let i = s.parse::<usize>().ok()?.checked_sub(1)?;
1397 (i < n).then_some(i)
1398}
1399
1400fn finish_open(
1404 store: &mut Store,
1405 hits: &[crate::search::Hit],
1406 query: &str,
1407 current: Option<i64>,
1408 root: Option<&std::path::Path>,
1409 no_record: bool,
1410) -> ExitCode {
1411 let Some(hit) = choose_hit(hits) else {
1412 return ExitCode::SUCCESS; };
1414
1415 if !no_record {
1418 let _ = store.record_event(
1419 "select",
1420 Some(&query.to_ascii_lowercase()),
1421 current,
1422 Some(&hit.file),
1423 Some(hit.line),
1424 None,
1425 );
1426 deferred_maintenance(store);
1427 }
1428
1429 let target = match root {
1432 Some(r) => r.join(&hit.file),
1433 None => PathBuf::from(&hit.file),
1434 };
1435 launch_editor(&target, hit.line)
1436}
1437
1438fn launch_editor(file: &std::path::Path, line: i64) -> ExitCode {
1442 use std::os::unix::process::CommandExt;
1443 let loc = format!("{}:{}", file.display(), line);
1444 match open_command(file, line, &loc) {
1445 Some((prog, args)) => {
1446 let err = std::process::Command::new(&prog).args(&args).exec();
1448 fail(format_args!("rq --open: cannot run {prog}: {err}"))
1449 }
1450 None => {
1451 println!("{loc}");
1452 ExitCode::SUCCESS
1453 }
1454 }
1455}
1456
1457fn open_command(file: &std::path::Path, line: i64, loc: &str) -> Option<(String, Vec<String>)> {
1461 let fstr = file.to_string_lossy().into_owned();
1462
1463 if let Some(t) = std::env::var_os("RQ_OPEN") {
1464 let t = t.to_string_lossy();
1465 let mut parts = t.split_whitespace().map(|p| {
1466 p.replace("{file}", &fstr)
1467 .replace("{line}", &line.to_string())
1468 .replace("{}", loc)
1469 });
1470 if let Some(prog) = parts.next() {
1471 return Some((prog, parts.collect()));
1472 }
1473 }
1474
1475 if on_path("code") {
1476 return Some(("code".into(), vec!["--goto".into(), loc.into()]));
1477 }
1478
1479 if let Some(ed) = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR")) {
1480 let ed = ed.to_string_lossy().into_owned();
1481 let l = ed.to_ascii_lowercase();
1482 if ["vim", "nvim", "vi", "nano", "emacs", "kak", "micro"]
1484 .iter()
1485 .any(|e| l.contains(e))
1486 {
1487 return Some((ed, vec![format!("+{line}"), fstr]));
1488 }
1489 return Some((ed, vec![fstr]));
1490 }
1491
1492 None
1493}
1494
1495fn on_path(prog: &str) -> bool {
1497 std::env::var_os("PATH")
1498 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(prog).is_file()))
1499}
1500
1501fn unix_now() -> i64 {
1511 std::time::SystemTime::now()
1512 .duration_since(std::time::UNIX_EPOCH)
1513 .map(|d| d.as_secs() as i64)
1514 .unwrap_or(0)
1515}
1516
1517const BRANCH_FILES_TTL_SECS: i64 = 15;
1523
1524struct BranchRefresh {
1528 handle: std::thread::JoinHandle<Vec<String>>,
1529 identity: String,
1530 stamp: String,
1531}
1532
1533impl BranchRefresh {
1534 fn store(self, store: &Store) {
1536 let Ok(files) = self.handle.join() else {
1537 return;
1538 };
1539 let _ = store.branch_files_set(&self.identity, &self.stamp, unix_now(), &files);
1540 }
1541}
1542
1543fn cached_branch_files(
1557 store: &Store,
1558 root: &std::path::Path,
1559) -> (Vec<String>, Option<BranchRefresh>) {
1560 let identity = resolve_identity(store, root);
1561 let stamp = crate::index::branch_files_stamp(root);
1562 let cached = store.branch_files_get(&identity).ok().flatten();
1563 let now = unix_now();
1564
1565 if let (Some((cached_stamp, at, files)), Some(stamp)) = (&cached, &stamp) {
1566 if cached_stamp == stamp && now.saturating_sub(*at) < BRANCH_FILES_TTL_SECS {
1567 return (files.clone(), None);
1568 }
1569 let owned_root = root.to_path_buf();
1570 let refresh = BranchRefresh {
1571 handle: std::thread::spawn(move || crate::index::branch_changed_files(&owned_root)),
1572 identity,
1573 stamp: stamp.clone(),
1574 };
1575 return (files.clone(), Some(refresh));
1576 }
1577
1578 let files = crate::index::branch_changed_files(root);
1580 if let Some(stamp) = stamp {
1581 let _ = store.branch_files_set(&identity, &stamp, now, &files);
1582 }
1583 (files, None)
1584}
1585
1586fn worktree_changed(cwd: &std::path::Path, indexed_head: Option<&str>) -> bool {
1595 let Some(head) = indexed_head else {
1596 return true;
1597 };
1598 crate::index::git_head(cwd).as_deref() != Some(head) || crate::index::is_dirty(cwd)
1599}
1600
1601#[allow(clippy::too_many_arguments)]
1609fn settle_warm(
1610 store: &Store,
1611 staleness: Option<std::thread::JoinHandle<bool>>,
1612 was_warming: bool,
1613 warming_ok: bool,
1614 root: Option<&std::path::Path>,
1615 active: &[String],
1616 query: &str,
1617 budget: Duration,
1618 no_wait: bool,
1619 identity: Option<&str>,
1620) -> bool {
1621 let changed = staleness.is_some_and(|h| h.join().unwrap_or(true));
1624 if changed
1634 && !no_wait
1635 && !warm_detach_enabled()
1636 && let Some(r) = root
1637 && let Ok(mut idx) = open_store()
1638 {
1639 crate::trace!("background warm (deferred, {budget:?}): worktree changed since index");
1640 let _ = crate::index::index_budgeted(&mut idx, r, active, budget, Some(query));
1641 }
1642 maybe_detach_warm(
1643 store,
1644 warming_ok && (was_warming || changed),
1645 changed,
1646 root,
1647 identity,
1648 );
1649 changed && warm_detach_enabled()
1653}
1654
1655fn answer_warm_budget() -> Duration {
1664 env_budget("RQ_ANSWER_BUDGET_MS", 500)
1665}
1666
1667fn deferred_warm_budget() -> Duration {
1670 env_budget("RQ_DEFERRED_BUDGET_MS", 250)
1671}
1672
1673fn live_fallback_budget() -> Duration {
1676 env_budget("RQ_FALLBACK_BUDGET_MS", 250)
1677}
1678
1679fn warm_bg_budget() -> Duration {
1682 env_budget("RQ_WARM_BUDGET_MS", 20_000)
1683}
1684
1685fn warm_detach_enabled() -> bool {
1689 std::env::var("RQ_WARM_DETACH").map_or(true, |v| v != "0")
1690}
1691
1692fn wait_budget() -> Duration {
1701 env_budget("RQ_WAIT_BUDGET_MS", 60_000)
1702}
1703
1704fn parse_wait(s: &str) -> std::result::Result<Duration, String> {
1709 let s = s.trim();
1710 let bad = || format!("invalid duration {s:?} — use e.g. 50ms, 2s, 1m, or 0");
1711 let (num, unit_ms) = if let Some(n) = s.strip_suffix("ms") {
1713 (n, 1.0)
1714 } else if let Some(n) = s.strip_suffix('s') {
1715 (n, 1_000.0)
1716 } else if let Some(n) = s.strip_suffix('m') {
1717 (n, 60_000.0)
1718 } else {
1719 (s, 1_000.0)
1721 };
1722 let val: f64 = num.trim().parse().map_err(|_| bad())?;
1723 if !val.is_finite() || val < 0.0 {
1724 return Err(bad());
1725 }
1726 Ok(Duration::from_millis((val * unit_ms).round() as u64))
1727}
1728
1729static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1733
1734extern "C" fn on_sigint(_: libc::c_int) {
1735 INTERRUPTED.store(true, std::sync::atomic::Ordering::Relaxed);
1737}
1738
1739fn install_interrupt_handler() {
1742 static ONCE: std::sync::Once = std::sync::Once::new();
1743 ONCE.call_once(|| unsafe {
1744 let mut action: libc::sigaction = std::mem::zeroed();
1745 action.sa_sigaction = on_sigint as *const () as usize;
1746 libc::sigemptyset(&mut action.sa_mask);
1747 libc::sigaction(libc::SIGINT, &action, std::ptr::null_mut());
1748 });
1749}
1750
1751fn stderr_interactive() -> bool {
1755 std::io::stderr().is_terminal() || std::env::var_os("RQ_ASSUME_INTERACTIVE").is_some()
1756}
1757
1758fn show_progress(out: Output, interactive: bool) -> bool {
1763 interactive && matches!(out, Output::Text)
1764}
1765
1766fn repo_label(root: Option<&std::path::Path>) -> String {
1769 root.and_then(|r| r.file_name())
1770 .map(|n| n.to_string_lossy().into_owned())
1771 .unwrap_or_else(|| "repo".into())
1772}
1773
1774fn draw_progress(store: &Store, identity: Option<&str>, label: &str) {
1778 let files = identity
1779 .and_then(|id| store.repository_id(id).ok().flatten())
1780 .and_then(|rid| store.repo_totals(rid).ok())
1781 .map_or(0, |(f, _)| f);
1782 eprint!("\r\x1b[Krq: indexing {label}… {files} files");
1783 let _ = std::io::stderr().flush();
1784}
1785
1786fn clear_progress() {
1788 eprint!("\r\x1b[K");
1789 let _ = std::io::stderr().flush();
1790}
1791
1792fn env_budget(var: &str, default_ms: u64) -> Duration {
1796 let ms = std::env::var(var)
1797 .ok()
1798 .and_then(|v| v.parse().ok())
1799 .unwrap_or(default_ms);
1800 Duration::from_millis(ms)
1801}
1802
1803const AGGREGATE_BATCH: usize = 256;
1806
1807const KEEP_RECENT_EVENTS: i64 = 200;
1810
1811fn deferred_maintenance(store: &mut Store) {
1814 let _ = store.aggregate_events(AGGREGATE_BATCH);
1815 let _ = store.prune_events(KEEP_RECENT_EVENTS);
1816}
1817
1818fn cmd_record(kind: &str, query: Option<&str>, file: &str, line: Option<i64>) -> ExitCode {
1821 let mut store = match open_store() {
1822 Ok(s) => s,
1823 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
1824 };
1825 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1826 let identity = crate::index::detect_identity(&cwd).to_string();
1827 let repo_id = store.repository_id(&identity).ok().flatten();
1828
1829 let rel = match repo_id.and_then(|id| store.checkout_root(id).ok().flatten()) {
1832 Some(root) => repo_relative(std::path::Path::new(&root), &cwd, file),
1833 None => file.to_string(),
1834 };
1835 let query_norm = query.map(|q| q.to_ascii_lowercase());
1836
1837 if let Err(e) = store.record_event(kind, query_norm.as_deref(), repo_id, Some(&rel), line, None)
1838 {
1839 return fail(format_args!("rq record: {e}"));
1840 }
1841 deferred_maintenance(&mut store);
1842 ExitCode::SUCCESS
1843}
1844
1845fn hit_file_roots(
1851 store: &Store,
1852 repo_identity: &str,
1853 cwd: Option<&std::path::Path>,
1854) -> Vec<PathBuf> {
1855 let mut roots: Vec<PathBuf> = store
1856 .repository_id(repo_identity)
1857 .ok()
1858 .flatten()
1859 .map(|id| store.checkout_roots(id).unwrap_or_default())
1860 .unwrap_or_default()
1861 .into_iter()
1862 .map(PathBuf::from)
1863 .collect();
1864 if let Some(c) = cwd {
1865 let c = c.to_path_buf();
1866 if !roots.contains(&c) {
1867 roots.push(c);
1868 }
1869 }
1870 roots
1871}
1872
1873fn read_signature(
1876 store: &Store,
1877 repo_identity: &str,
1878 file: &str,
1879 line: i64,
1880 cwd: Option<&std::path::Path>,
1881) -> Option<String> {
1882 hit_file_roots(store, repo_identity, cwd)
1883 .into_iter()
1884 .find_map(|root| signature_in(&std::fs::read_to_string(root.join(file)).ok()?, line))
1885}
1886
1887const SHOW_CONFIDENCE: f64 = 0.85;
1891
1892fn show_top_definition(
1901 store: &mut Store,
1902 hits: &mut [crate::search::Hit],
1903 query: &str,
1904 out: Output,
1905 cwd: Option<&std::path::Path>,
1906 current: Option<i64>,
1907 no_record: bool,
1908) -> Option<ExitCode> {
1909 let top = hits.first()?;
1910 if top.confidence < SHOW_CONFIDENCE {
1911 return None; }
1913 let end = top.end_line.unwrap_or(top.line);
1914 let body = read_span(store, &top.repo_identity, &top.file, top.line, end, cwd);
1915 hits[0].body = body;
1916 let top = &hits[0];
1917 let shown = (top.file.clone(), top.line);
1918 let code = match out {
1919 Output::Json | Output::Ndjson => {
1920 emit_json(out, top)
1922 }
1923 Output::Text => {
1924 let color = match_color();
1925 let c = color.as_deref();
1926 let name = hl(&top.name, query, c);
1927 let qualified = match &top.parent {
1928 Some(p) => format!("{name} · {p}"),
1929 None => name,
1930 };
1931 println!(
1932 "{}:{} {} {}",
1933 hl_path(&top.file, query, c),
1934 top.line,
1935 top.kind,
1936 qualified
1937 );
1938 match (&top.body, &top.signature) {
1939 (Some(body), _) => println!("{body}"),
1940 (None, Some(sig)) => println!("{sig}"),
1942 (None, None) => {}
1943 }
1944 ExitCode::SUCCESS
1945 }
1946 };
1947
1948 if !no_record {
1950 let (file, line) = shown;
1951 let _ = store.record_event(
1952 "select",
1953 Some(&query.to_ascii_lowercase()),
1954 current,
1955 Some(&file),
1956 Some(line),
1957 None,
1958 );
1959 deferred_maintenance(store);
1960 }
1961 Some(code)
1962}
1963
1964fn read_span(
1967 store: &Store,
1968 repo_identity: &str,
1969 file: &str,
1970 start: i64,
1971 end: i64,
1972 cwd: Option<&std::path::Path>,
1973) -> Option<String> {
1974 hit_file_roots(store, repo_identity, cwd)
1975 .into_iter()
1976 .find_map(|root| span_in(&std::fs::read_to_string(root.join(file)).ok()?, start, end))
1977}
1978
1979fn span_in(content: &str, start: i64, end: i64) -> Option<String> {
1982 let s = usize::try_from(start).ok()?.checked_sub(1)?;
1983 let lines: Vec<&str> = content.lines().collect();
1984 if s >= lines.len() {
1985 return None;
1986 }
1987 let e = usize::try_from(end).ok()?.clamp(s + 1, lines.len());
1988 Some(lines[s..e].join("\n"))
1989}
1990
1991fn signature_in(content: &str, line: i64) -> Option<String> {
1995 let idx = usize::try_from(line).ok()?.checked_sub(1)?;
1996 let l = content.lines().nth(idx)?.trim();
1997 (!l.is_empty()).then(|| l.to_string())
1998}
1999
2000#[derive(serde::Serialize)]
2004struct SymbolOut {
2005 name: String,
2006 kind: String,
2007 language: String,
2008 file: String,
2009 line: i64,
2010 #[serde(skip_serializing_if = "Option::is_none")]
2011 end_line: Option<i64>,
2012 #[serde(skip_serializing_if = "Option::is_none")]
2013 parent: Option<String>,
2014 #[serde(skip_serializing_if = "Option::is_none")]
2015 visibility: Option<String>,
2016 repo: String,
2017 #[serde(skip_serializing_if = "Option::is_none")]
2018 signature: Option<String>,
2019}
2020
2021fn cmd_symbols(file_arg: &str, kinds: &[String], langs: &[String], out: Output) -> ExitCode {
2026 let mut store = match open_store() {
2027 Ok(s) => s,
2028 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2029 };
2030 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
2031 let root = crate::index::repo_root(&cwd).unwrap_or_else(|| cwd.clone());
2032 let rel = repo_relative(&root, &cwd, file_arg);
2033
2034 let identity = resolve_identity(&store, &root);
2035 let coverage = store.coverage_status(&identity).ok().flatten();
2036 let warming_ok = crate::index::is_git_repo(&root) || coverage.is_some();
2037 let current = store.repository_id(&identity).ok().flatten();
2038 let indexed_head = current.and_then(|id| store.indexed_head(id).ok().flatten());
2041 let needs_warm = warming_ok
2042 && (coverage.as_deref() != Some("complete")
2043 || worktree_changed(&root, indexed_head.as_deref()));
2044 if needs_warm {
2045 let budget = answer_warm_budget() + deferred_warm_budget();
2047 let _ = crate::index::index_budgeted(&mut store, &root, &[], budget, Some(&rel));
2048 }
2049
2050 let Some(repo_id) = store.repository_id(&identity).ok().flatten() else {
2051 return emit_symbols(out, &[]); };
2053 let mut rows = match store.symbols_in_file(repo_id, &rel) {
2054 Ok(r) => r,
2055 Err(e) => return fail(format_args!("rq: {e}")),
2056 };
2057 if !kinds.is_empty() {
2058 rows.retain(|r| kinds.iter().any(|k| k == &r.kind));
2059 }
2060 if !langs.is_empty() {
2061 rows.retain(|r| langs.iter().any(|l| l == &r.language));
2062 }
2063
2064 let content = hit_file_roots(&store, &identity, Some(&root))
2068 .iter()
2069 .find_map(|r| std::fs::read_to_string(r.join(&rel)).ok());
2070 let syms: Vec<SymbolOut> = rows
2071 .into_iter()
2072 .map(|r| SymbolOut {
2073 signature: content.as_deref().and_then(|c| signature_in(c, r.line)),
2074 name: r.name,
2075 kind: r.kind,
2076 language: r.language,
2077 file: r.file,
2078 line: r.line,
2079 end_line: r.end_line,
2080 parent: r.parent,
2081 visibility: r.visibility,
2082 repo: r.repo_identity,
2083 })
2084 .collect();
2085 emit_symbols(out, &syms)
2086}
2087
2088fn emit_symbols(out: Output, syms: &[SymbolOut]) -> ExitCode {
2091 if syms.is_empty() {
2092 match out {
2093 Output::Json | Output::Ndjson => {
2094 let obj = serde_json::json!({ "status": "no_match" });
2095 let _ = emit_json(out, &obj); }
2097 Output::Text => eprintln!("no symbols"),
2098 }
2099 return ExitCode::FAILURE;
2100 }
2101 if let Some(code) = emit_rows(out, syms) {
2102 return code;
2103 }
2104 match out {
2105 Output::Json | Output::Ndjson => {}
2106 Output::Text => {
2107 for s in syms {
2108 let qualified = match &s.parent {
2109 Some(p) => format!("{} · {p}", s.name),
2110 None => s.name.clone(),
2111 };
2112 println!("{}:{} {} {}", s.file, s.line, s.kind, qualified);
2113 if let Some(sig) = &s.signature {
2114 println!(" {sig}");
2115 }
2116 }
2117 }
2118 }
2119 ExitCode::SUCCESS
2120}
2121
2122fn keyword_kind(token: &str) -> Option<&'static str> {
2127 match token.to_ascii_lowercase().as_str() {
2128 "class" => Some("class"),
2129 "module" => Some("module"),
2130 "method" => Some("method"),
2131 "function" | "fn" => Some("function"),
2132 "struct" | "type" => Some("struct"),
2133 "enum" => Some("enum"),
2134 "trait" | "interface" => Some("trait"),
2135 _ => None,
2136 }
2137}
2138
2139fn split_kind_keyword(
2145 target: String,
2146 dirs: Vec<String>,
2147) -> (Option<&'static str>, String, Vec<String>) {
2148 if let Some((head, rest)) = target.split_once(char::is_whitespace) {
2151 let rest = rest.trim();
2152 if let Some(k) = keyword_kind(head)
2153 && !rest.is_empty()
2154 {
2155 return (Some(k), rest.to_string(), dirs);
2156 }
2157 } else if let Some(k) = keyword_kind(&target)
2158 && let Some((query, extra)) = dirs.split_first()
2159 {
2160 return (Some(k), query.clone(), extra.to_vec());
2162 }
2163 (None, target, dirs)
2164}
2165
2166fn canonical_kind(s: &str) -> String {
2169 match s.to_ascii_lowercase().as_str() {
2170 "c" | "class" => "class",
2171 "m" | "method" => "method",
2172 "f" | "fn" | "func" | "function" => "function",
2173 "mod" | "module" => "module",
2174 "s" | "struct" | "type" => "struct",
2175 "e" | "enum" => "enum",
2176 "t" | "trait" | "interface" => "trait",
2177 other => return other.to_string(),
2178 }
2179 .to_string()
2180}
2181
2182fn canonical_langs(s: &str) -> Vec<String> {
2189 let t = s.to_ascii_lowercase();
2190 let alias = match t.as_str() {
2191 "rb" => Some("ruby"),
2192 "rs" => Some("rust"),
2193 "golang" => Some("go"),
2194 "ts" | "tsx" => Some("typescript"),
2195 "js" | "jsx" => Some("javascript"),
2196 _ => None,
2197 };
2198 let matched: Vec<String> = crate::lang::languages()
2199 .into_iter()
2200 .filter(|lang| alias == Some(*lang) || lang.starts_with(&t))
2201 .map(str::to_string)
2202 .collect();
2203 if matched.is_empty() { vec![t] } else { matched }
2204}
2205
2206fn match_color() -> Option<String> {
2210 if std::env::var_os("NO_COLOR").is_some() || !std::io::stdout().is_terminal() {
2211 return None;
2212 }
2213 let style = std::env::var("GREP_COLORS").ok().and_then(|gc| {
2214 gc.split(':').find_map(|e| {
2215 e.strip_prefix("mt=")
2216 .or_else(|| e.strip_prefix("ms="))
2217 .filter(|v| !v.is_empty())
2218 .map(str::to_string)
2219 })
2220 });
2221 Some(style.unwrap_or_else(|| "1;31".to_string()))
2222}
2223
2224fn hl(text: &str, query: &str, color: Option<&str>) -> String {
2227 match color {
2228 Some(c) => highlight(text, &crate::search::match_positions(query, text), c),
2229 None => text.to_string(),
2230 }
2231}
2232
2233fn hl_path(path: &str, query: &str, color: Option<&str>) -> String {
2236 let Some(c) = color else {
2237 return path.to_string();
2238 };
2239 let base_byte = path.rfind('/').map(|b| b + 1).unwrap_or(0);
2240 let base_start = path[..base_byte].chars().count();
2241 let stem = crate::search::path_stem(path);
2245 let positions: Vec<usize> = crate::search::match_positions(query, stem)
2246 .into_iter()
2247 .map(|p| p + base_start)
2248 .collect();
2249 highlight(path, &positions, c)
2250}
2251
2252fn highlight(text: &str, positions: &[usize], color: &str) -> String {
2255 if positions.is_empty() {
2256 return text.to_string();
2257 }
2258 let matched: std::collections::HashSet<usize> = positions.iter().copied().collect();
2259 let mut out = String::new();
2260 let mut on = false;
2261 for (i, c) in text.chars().enumerate() {
2262 match (matched.contains(&i), on) {
2263 (true, false) => {
2264 out.push_str("\x1b[");
2265 out.push_str(color);
2266 out.push('m');
2267 on = true;
2268 }
2269 (false, true) => {
2270 out.push_str("\x1b[0m");
2271 on = false;
2272 }
2273 _ => {}
2274 }
2275 out.push(c);
2276 }
2277 if on {
2278 out.push_str("\x1b[0m");
2279 }
2280 out
2281}
2282
2283fn under_any(file: &str, paths: &[String]) -> bool {
2287 paths.iter().any(|p| {
2288 let p = p.trim_start_matches("./").trim_end_matches('/');
2289 p.is_empty() || file == p || file.starts_with(&format!("{p}/"))
2290 })
2291}
2292
2293fn repo_relative(root: &std::path::Path, cwd: &std::path::Path, file: &str) -> String {
2295 let p = std::path::Path::new(file);
2296 let abs = if p.is_absolute() {
2297 p.to_path_buf()
2298 } else {
2299 cwd.join(p)
2300 };
2301 let abs = abs.canonicalize().unwrap_or(abs);
2302 abs.strip_prefix(root)
2303 .map(|r| r.to_string_lossy().into_owned())
2304 .unwrap_or_else(|_| file.to_string())
2305}
2306
2307fn revalidate_top(store: &mut Store, hits: &[crate::search::Hit]) -> bool {
2311 use std::collections::HashSet;
2312 let mut seen = HashSet::new();
2313 let mut changed = false;
2314 for hit in hits {
2315 if !seen.insert((hit.repo_identity.clone(), hit.file.clone())) {
2316 continue;
2317 }
2318 let Some(repo_id) = store.repository_id(&hit.repo_identity).ok().flatten() else {
2319 continue;
2320 };
2321 let Some(root) = store.checkout_root(repo_id).ok().flatten() else {
2322 continue;
2323 };
2324 if let Ok(crate::index::Refresh::Updated) =
2325 crate::index::refresh_file(store, repo_id, std::path::Path::new(&root), &hit.file)
2326 {
2327 changed = true;
2328 }
2329 }
2330 changed
2331}
2332
2333fn resolve_identity(store: &Store, cwd: &std::path::Path) -> String {
2339 if let Ok(canon) = cwd.canonicalize() {
2340 if let Ok(Some(identity)) = store.identity_for_root(&canon.to_string_lossy()) {
2341 return identity;
2342 }
2343 if crate::index::repo_root(cwd).is_none() {
2344 return crate::core::RepoIdentity::local(&canon.to_string_lossy()).to_string();
2345 }
2346 }
2347 crate::index::detect_identity(cwd).to_string()
2348}
2349
2350fn cmd_index(path: Option<PathBuf>, subdirs: &[String], out: Output) -> ExitCode {
2351 let explicit = path.is_some();
2352 let target = path.unwrap_or_else(|| PathBuf::from("."));
2353 let root = crate::index::repo_root(&target).unwrap_or_else(|| target.clone());
2358 let mut subdirs = subdirs.to_vec();
2363 if explicit
2364 && let (Ok(t), Ok(r)) = (target.canonicalize(), root.canonicalize())
2365 && t != r
2366 && let Ok(rel) = t.strip_prefix(&r)
2367 && !rel.as_os_str().is_empty()
2368 {
2369 subdirs.push(rel.to_string_lossy().into_owned());
2370 }
2371 let mut store = match open_store() {
2372 Ok(s) => s,
2373 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2374 };
2375 let identity = crate::index::detect_identity(&root).to_string();
2376 match crate::index::index_under(&mut store, &root, &subdirs) {
2377 Ok(stats) => {
2378 let subtree = !subdirs.is_empty();
2379 let totals = store
2381 .repository_id(&identity)
2382 .ok()
2383 .flatten()
2384 .and_then(|id| store.repo_totals(id).ok());
2385 match out {
2386 Output::Json | Output::Ndjson => {
2387 let (files, symbols) = match totals {
2388 Some((f, s)) => (Some(f), Some(s)),
2389 None => (None, None),
2390 };
2391 return emit_json(
2392 out,
2393 &serde_json::json!({
2394 "repo": identity,
2395 "scope": if subtree { "subtree" } else { "full" },
2396 "files_added": stats.files_indexed,
2397 "symbols_added": stats.symbols,
2398 "files": files,
2399 "symbols": symbols,
2400 }),
2401 );
2402 }
2403 Output::Text => {
2404 let scope = if subtree { " (subtree seed)" } else { "" };
2405 match totals {
2406 Some((files, symbols)) => println!(
2407 "{} file(s)/{} symbol(s) added this run; index{scope} now {files} files, {symbols} symbols",
2408 stats.files_indexed, stats.symbols
2409 ),
2410 None => println!(
2411 "{} file(s)/{} symbol(s) added this run{scope}",
2412 stats.files_indexed, stats.symbols
2413 ),
2414 }
2415 }
2416 }
2417 ExitCode::SUCCESS
2418 }
2419 Err(e) => fail(format_args!("rq --index: {e}")),
2420 }
2421}
2422
2423fn cmd_drop(target: Option<String>, out: Output) -> ExitCode {
2424 let mut store = match open_store() {
2425 Ok(s) => s,
2426 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2427 };
2428
2429 let path = PathBuf::from(target.clone().unwrap_or_else(|| ".".to_string()));
2433 let root = crate::index::repo_root(&path).unwrap_or(path);
2434 let from_path = crate::index::detect_identity(&root).to_string();
2435 let resolved = match store.repository_id(&from_path) {
2436 Ok(Some(id)) => Some((from_path.clone(), id)),
2437 Ok(None) => target.as_deref().and_then(|s| {
2438 store
2439 .repository_id(s)
2440 .ok()
2441 .flatten()
2442 .map(|id| (s.to_string(), id))
2443 }),
2444 Err(e) => return fail(format_args!("rq --drop: {e}")),
2445 };
2446
2447 let Some((identity, repo_id)) = resolved else {
2448 return match out {
2450 Output::Text => {
2451 println!("not indexed: {from_path}");
2452 ExitCode::SUCCESS
2453 }
2454 _ => emit_json(
2455 out,
2456 &serde_json::json!({"repo": from_path, "files": 0, "symbols": 0, "dropped": false}),
2457 ),
2458 };
2459 };
2460
2461 let (files, symbols) = store.repo_totals(repo_id).unwrap_or((0, 0));
2462 match store.drop_repository(repo_id) {
2463 Ok(()) => match out {
2464 Output::Text => {
2465 println!("dropped {identity} ({files} file(s), {symbols} symbol(s))");
2466 ExitCode::SUCCESS
2467 }
2468 _ => emit_json(
2469 out,
2470 &serde_json::json!({"repo": identity, "files": files, "symbols": symbols, "dropped": true}),
2471 ),
2472 },
2473 Err(e) => fail(format_args!("rq --drop: {e}")),
2474 }
2475}
2476
2477fn emit_json<T: serde::Serialize>(out: Output, value: &T) -> ExitCode {
2481 let rendered = if out == Output::Json {
2482 serde_json::to_string_pretty(value)
2483 } else {
2484 serde_json::to_string(value)
2485 };
2486 match rendered {
2487 Ok(s) => {
2488 println!("{s}");
2489 ExitCode::SUCCESS
2490 }
2491 Err(e) => fail(format_args!("rq: {e}")),
2492 }
2493}
2494
2495fn emit_rows<T: serde::Serialize>(out: Output, rows: &[T]) -> Option<ExitCode> {
2499 match out {
2500 Output::Json => match serde_json::to_string_pretty(rows) {
2501 Ok(s) => println!("{s}"),
2502 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2503 },
2504 Output::Ndjson => {
2505 for r in rows {
2506 match serde_json::to_string(r) {
2507 Ok(line) => println!("{line}"),
2508 Err(e) => return Some(fail(format_args!("rq: {e}"))),
2509 }
2510 }
2511 }
2512 Output::Text => {}
2513 }
2514 None
2515}
2516
2517fn cmd_status(out: Output) -> ExitCode {
2518 let store = match open_store() {
2519 Ok(s) => s,
2520 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2521 };
2522 let rows = match store.coverage_overview() {
2523 Ok(rows) => rows,
2524 Err(e) => return fail(format_args!("rq --status: {e}")),
2525 };
2526 if let Some(code) = emit_rows(out, &rows) {
2527 return code;
2528 }
2529 match out {
2530 Output::Json | Output::Ndjson => {}
2531 Output::Text if rows.is_empty() => {
2532 println!("no repositories indexed yet (try `rq --index`)");
2533 }
2534 Output::Text => {
2535 for r in &rows {
2536 println!(
2537 "{:<10} {:>6} files {:>7} symbols {}",
2538 r.status, r.files, r.symbols, r.identity
2539 );
2540 }
2541 }
2542 }
2543 ExitCode::SUCCESS
2544}
2545
2546fn cmd_usage(out: Output) -> ExitCode {
2549 let store = match open_store() {
2550 Ok(s) => s,
2551 Err(e) => return fail(format_args!("rq: cannot open database: {e}")),
2552 };
2553 let rows = match store.usage_overview() {
2554 Ok(rows) => rows,
2555 Err(e) => return fail(format_args!("rq --usage: {e}")),
2556 };
2557 if let Some(code) = emit_rows(out, &rows) {
2558 return code;
2559 }
2560 match out {
2561 Output::Json | Output::Ndjson => {}
2562 Output::Text if rows.is_empty() => {
2563 println!("no usage recorded yet");
2564 }
2565 Output::Text => {
2566 println!(
2569 "{:<10} {:<16} {:>8} {:>7} flags",
2570 "day", "caller", "found", "missed"
2571 );
2572 for r in &rows {
2573 let flags = if r.flags.is_empty() { "-" } else { &r.flags };
2574 println!(
2575 "{:<10} {:<16} {:>8} {:>7} {}",
2576 r.day,
2577 r.source,
2578 r.searches - r.misses,
2579 r.misses,
2580 flags
2581 );
2582 }
2583 let searches: i64 = rows.iter().map(|r| r.searches).sum();
2584 let misses: i64 = rows.iter().map(|r| r.misses).sum();
2585 let plural = if searches == 1 { "search" } else { "searches" };
2586 println!("{searches} {plural}, {misses} found nothing");
2587 }
2588 }
2589 if rows.is_empty() {
2591 return ExitCode::from(1);
2592 }
2593 ExitCode::SUCCESS
2594}
2595
2596fn open_store() -> Result<Store, Box<dyn std::error::Error>> {
2598 let path = db_path()?;
2599 if let Some(parent) = path.parent() {
2600 std::fs::create_dir_all(parent)?;
2601 }
2602 Ok(Store::open(&path)?)
2603}
2604
2605fn db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
2607 if let Ok(p) = std::env::var("RQ_DB") {
2608 return Ok(PathBuf::from(p));
2609 }
2610 let home = std::env::var("HOME")?;
2611 Ok(PathBuf::from(home).join(".local/share/rq/rq.db"))
2612}
2613
2614fn fail(args: std::fmt::Arguments) -> ExitCode {
2615 eprintln!("{args}");
2616 ExitCode::FAILURE
2617}
2618
2619#[cfg(test)]
2620mod tests {
2621 use super::*;
2622
2623 #[test]
2624 fn open_menu_choice_parsing() {
2625 assert_eq!(parse_choice("\n", 5), Some(0));
2627 assert_eq!(parse_choice(" ", 5), Some(0));
2628 assert_eq!(parse_choice("3", 5), Some(2));
2629 assert_eq!(parse_choice("5", 5), Some(4));
2630 assert_eq!(parse_choice("6", 5), None);
2632 assert_eq!(parse_choice("0", 5), None);
2633 assert_eq!(parse_choice("q", 5), None);
2634 }
2635
2636 #[test]
2637 fn wait_duration_parsing() {
2638 use std::time::Duration;
2639 assert_eq!(parse_wait("50ms"), Ok(Duration::from_millis(50)));
2641 assert_eq!(parse_wait("2s"), Ok(Duration::from_secs(2)));
2642 assert_eq!(parse_wait("1m"), Ok(Duration::from_secs(60)));
2643 assert_eq!(parse_wait("250"), Ok(Duration::from_secs(250)));
2644 assert_eq!(parse_wait("1.5s"), Ok(Duration::from_millis(1500)));
2646 assert_eq!(parse_wait("0"), Ok(Duration::ZERO));
2647 assert!(parse_wait("0s").unwrap().is_zero());
2648 assert_eq!(parse_wait(" 2s "), Ok(Duration::from_secs(2)));
2650 assert!(parse_wait("2x").is_err());
2652 assert!(parse_wait("").is_err());
2653 assert!(parse_wait("s").is_err());
2654 assert!(parse_wait("-1s").is_err());
2655 }
2656
2657 #[test]
2658 fn leading_kind_keyword_becomes_a_kind_filter() {
2659 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2660 assert_eq!(
2662 split_kind_keyword("class".into(), d(&["Widget"])),
2663 (Some("class"), "Widget".into(), vec![])
2664 );
2665 assert_eq!(
2667 split_kind_keyword("method zoom".into(), vec![]),
2668 (Some("method"), "zoom".into(), vec![])
2669 );
2670 assert_eq!(
2672 split_kind_keyword("fn".into(), d(&["Foo::run"])),
2673 (Some("function"), "Foo::run".into(), vec![])
2674 );
2675 assert_eq!(
2677 split_kind_keyword("struct".into(), d(&["Gadget", "src"])),
2678 (Some("struct"), "Gadget".into(), d(&["src"]))
2679 );
2680 }
2681
2682 #[test]
2683 fn a_bare_or_non_keyword_query_is_left_alone() {
2684 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2685 assert_eq!(
2687 split_kind_keyword("class".into(), vec![]),
2688 (None, "class".into(), vec![])
2689 );
2690 assert_eq!(
2692 split_kind_keyword("Widget".into(), d(&["app"])),
2693 (None, "Widget".into(), d(&["app"]))
2694 );
2695 assert_eq!(
2697 split_kind_keyword("c".into(), d(&["Foo"])),
2698 (None, "c".into(), d(&["Foo"]))
2699 );
2700 }
2701
2702 #[test]
2703 fn a_language_selects_by_prefix_or_alias() {
2704 assert_eq!(canonical_langs("r"), ["ruby", "rust"]);
2706 assert_eq!(canonical_langs("t"), ["typescript"]);
2707 assert_eq!(canonical_langs("ts"), ["typescript"]);
2709 assert_eq!(canonical_langs("jsx"), ["javascript"]);
2710 assert_eq!(canonical_langs("rb"), ["ruby"]);
2711 assert_eq!(canonical_langs("COBOL"), ["cobol"]);
2713 }
2714
2715 #[test]
2716 fn a_kind_normalizes_language_specific_spellings() {
2717 assert_eq!(canonical_kind("f"), "function");
2718 assert_eq!(canonical_kind("interface"), "trait");
2720 assert_eq!(canonical_kind("type"), "struct");
2721 let d = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::<Vec<_>>();
2723 assert_eq!(
2724 split_kind_keyword("interface".into(), d(&["Renderer"])),
2725 (Some("trait"), "Renderer".into(), vec![])
2726 );
2727 }
2728
2729 #[test]
2730 fn highlight_wraps_matched_runs() {
2731 assert_eq!(
2732 highlight("FooThing", &[0, 1, 2], "1;31"),
2733 "\u{1b}[1;31mFoo\u{1b}[0mThing"
2734 );
2735 assert_eq!(
2737 highlight("FooThing", &[0, 3], "1"),
2738 "\u{1b}[1mF\u{1b}[0moo\u{1b}[1mT\u{1b}[0mhing"
2739 );
2740 assert_eq!(highlight("FooThing", &[], "1;31"), "FooThing");
2742 }
2743
2744 #[test]
2745 fn progress_ui_only_for_an_interactive_text_terminal() {
2746 assert!(show_progress(Output::Text, true));
2748
2749 assert!(!show_progress(Output::Json, true));
2751 assert!(!show_progress(Output::Ndjson, true));
2752
2753 assert!(!show_progress(Output::Text, false));
2755 }
2756
2757 #[test]
2758 fn repo_label_uses_the_directory_name() {
2759 assert_eq!(
2760 repo_label(Some(std::path::Path::new("/src/widgets"))),
2761 "widgets"
2762 );
2763 assert_eq!(repo_label(None), "repo");
2764 }
2765
2766 #[test]
2767 fn hl_path_highlights_the_stem_not_the_extension() {
2768 let out = hl_path(
2771 "app/employees_controller.rb",
2772 "employeescontroller",
2773 Some("1;31"),
2774 );
2775 assert!(
2776 out.starts_with("app/\u{1b}[1;31memployees"),
2777 "stem highlighted: {out:?}"
2778 );
2779 assert!(
2780 out.ends_with("controller\u{1b}[0m.rb"),
2781 "`.rb` left un-highlighted: {out:?}"
2782 );
2783 }
2784}