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