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///
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}