Skip to main content

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}