devscout_rs/query/find.rs
1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::graph;
5use crate::suggest::kind_rank;
6
7use super::refs::trim_source;
8
9// ============================================================================
10// The full name index, and the source line a hit points at.
11// ============================================================================
12
13/// The literal line a declaration sits on, ASCII-trimmed by `trim_source`.
14///
15/// Returns `""` on an unreadable file or an out-of-range line; the caller then
16/// prints `file:line` with nothing after it rather than a row ending in a
17/// dangling separator.
18pub fn source_line(root: &Path, file: &str, line: usize) -> String {
19 let Ok(body) = std::fs::read_to_string(root.join(file)) else {
20 return String::new();
21 };
22 let lines: Vec<&str> = body.split('\n').collect();
23 if line < 1 || line > lines.len() {
24 return String::new();
25 }
26 let mut raw = lines[line - 1];
27 // A UTF-8 BOM survives the read and is not in the trim set, so line 1 of a
28 // BOM'd file would otherwise print it.
29 if line == 1 {
30 raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
31 }
32 trim_source(raw).to_string()
33}
34
35/// Name search: every whitespace-split token of `query` has to appear,
36/// case-insensitively, as a substring of the declared NAME -- path and purpose
37/// are the manifest search's haystack, not this one's. The caller owns the
38/// graph (see the module header's two-phase note). Results come back in index
39/// build order; ranking is the caller's job.
40pub fn find_names<'g>(graph: &'g graph::Graph, query: &str) -> Vec<&'g graph::GraphName> {
41 let tokens: Vec<String> = query
42 .to_lowercase()
43 .split_whitespace()
44 .map(String::from)
45 .collect();
46 if tokens.is_empty() {
47 return Vec::new();
48 }
49 graph
50 .names
51 .iter()
52 .filter(|n| {
53 let hay = n.name.to_lowercase();
54 tokens.iter().all(|t| hay.contains(t.as_str()))
55 })
56 .collect()
57}
58
59/// Every FILE's own first declaration line, keyed by path: the minimum `line`
60/// across every `graph.names` entry that file carries, over the WHOLE index.
61/// Unlike [`find_names`], this is never filtered to one query's matches,
62/// because a manifest-pool row needs a line to open regardless of the query. A
63/// file the name index carries nothing for (no declared symbol at all -- a
64/// config file, a doc, an asset) has no entry here; the caller falls back to
65/// line 1.
66pub fn first_decl_line_by_file(graph: &graph::Graph) -> HashMap<String, usize> {
67 let mut lines: HashMap<String, usize> = HashMap::new();
68 for n in &graph.names {
69 lines
70 .entry(n.file.clone())
71 .and_modify(|line| *line = (*line).min(n.line))
72 .or_insert(n.line);
73 }
74 lines
75}
76
77/// The per-file precise inbound-edge count `find`'s tie-break ranks by.
78///
79/// File path -> how many PRECISE reference edges land on the definitions that
80/// file declares, keyed by the edge's target file. This direct edge count IS
81/// the whole centrality measure behind `find`'s tie-break -- it deliberately
82/// shares nothing with `impact_walk`'s weighting (no hops, no `PageRank`, no
83/// distinct-referrer folding): one precise edge, one count.
84///
85/// Counted: `inherits`/`uses-type`/`uses-member` without the guess tag, plus
86/// the three TS reference kinds `call`/`jsx-use`/`dispatch`, which on a TS repo
87/// ARE the reference graph -- leaving them out would rank every TS file at
88/// zero. NOT counted: heuristic edges (a guess never enters a table a fact is
89/// read from), `imports`/`import` (they name a namespace or module, never a
90/// definition), `ctor-di` (DI wiring, not a reference), `ambiguous` (candidates,
91/// not resolutions), and a file's references to ITSELF (`from_file ==
92/// to_file`) -- how much OTHER code pulls on a file is the measure, so a
93/// self-citation is no more centrality here than it is for `hub_indegree`.
94pub fn file_inbound_counts(graph: &graph::Graph) -> HashMap<String, usize> {
95 let mut counts: HashMap<String, usize> = HashMap::new();
96 for e in &graph.edges {
97 let (from_file, to_file) = match e {
98 graph::Edge::Inherits {
99 from_file,
100 to_file,
101 heuristic,
102 ..
103 }
104 | graph::Edge::UsesType {
105 from_file,
106 to_file,
107 heuristic,
108 ..
109 }
110 | graph::Edge::UsesMember {
111 from_file,
112 to_file,
113 heuristic,
114 ..
115 } => {
116 if *heuristic {
117 continue;
118 }
119 (from_file, to_file)
120 }
121 graph::Edge::Call {
122 from_file, to_file, ..
123 }
124 | graph::Edge::JsxUse {
125 from_file, to_file, ..
126 }
127 | graph::Edge::Dispatch {
128 from_file, to_file, ..
129 } => (from_file, to_file),
130 // Listed rather than caught by a wildcard so a future edge kind
131 // still fails the exhaustiveness check here. `implements`/
132 // `overrides` sit out for the same reason `ctor-di` does: DI/
133 // dispatch wiring, not an ordinary reference this centrality
134 // measure counts. `bus-hop` sits out the same way, undecided
135 // until the layer that reads it weighs in.
136 graph::Edge::Imports { .. }
137 | graph::Edge::Import { .. }
138 | graph::Edge::CtorDi { .. }
139 | graph::Edge::Implements { .. }
140 | graph::Edge::Overrides { .. }
141 | graph::Edge::BusHop { .. }
142 | graph::Edge::Ambiguous { .. } => continue,
143 };
144 if from_file == to_file {
145 continue;
146 }
147 *counts.entry(to_file.clone()).or_insert(0) += 1;
148 }
149 counts
150}
151
152/// Collapses `suggest::kind_rank` into three tiers for `find`'s default
153/// view: tier 1 is a code symbol (`kind_rank` 0 -- a type or top-level
154/// declaration -- or 1 -- a member), tier 2 is a markup or binding name
155/// (`kind_rank` 2), tier 3 is a resource key (`kind_rank` 3). `find` matches
156/// tier 1 then 2 by default and demotes tier 3 to a one-line trailer
157/// (`--resources` includes it).
158pub fn name_tier(kind: &str) -> u8 {
159 let rank = kind_rank(kind);
160 if rank <= 1 {
161 1
162 } else {
163 rank
164 }
165}