1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::graph;
5
6use super::index::{def_sites, symbol_refs, DefSite, GraphIndex, SymbolRefs};
7use super::member::{self, MemberCandidate};
8use super::refs_tables::{
9 ambiguous_row, build_table, cap_rows, edge_loc, loc_cmp, row_tier, AmbiguousTables, ImportRow,
10 InboundRow, InboundTables, OutboundRow, OutboundTables, Table, SOURCE_MAX,
11};
12use super::symbol::{resolve_symbol, Resolution};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct MemberRefEntry {
17 pub name: String,
19 pub count: usize,
21}
22
23#[derive(Debug, Clone, PartialEq)]
26pub struct MemberRefs {
27 pub total: usize,
29 pub member_count: usize,
31 pub members: Vec<MemberRefEntry>,
33 pub dropped: usize,
35}
36
37const MEMBER_NAME_CAP: usize = 5;
39
40fn enum_member_refs(index: &GraphIndex, def_id: &str) -> Option<MemberRefs> {
51 let prefix = format!("{def_id}.");
52 let mut members: Vec<MemberRefEntry> = Vec::new();
53 let mut total = 0usize;
54 for d in &index.graph.defs {
55 if d.kind != "enum-member" || !d.id.starts_with(&prefix) {
56 continue;
57 }
58 let count = index.inbound.get(&d.id).map_or(0, |e| e.uses_member.len())
59 + index
60 .heuristic_inbound
61 .get(&d.id)
62 .map_or(0, |e| e.uses_member.len());
63 if count == 0 {
64 continue;
65 }
66 members.push(MemberRefEntry {
67 name: d.name.clone(),
68 count,
69 });
70 total += count;
71 }
72 if total == 0 {
73 return None;
74 }
75 let member_count = members.len();
76 let dropped = member_count.saturating_sub(MEMBER_NAME_CAP);
77 members.truncate(MEMBER_NAME_CAP);
78 Some(MemberRefs {
79 total,
80 member_count,
81 members,
82 dropped,
83 })
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct RefsModel {
89 pub query: String,
91 pub id: String,
93 pub kind: String,
95 pub sites: Vec<DefSite>,
97 pub inbound: InboundTables,
99 pub outbound: Option<OutboundTables>,
103 pub ambiguous: AmbiguousTables,
105 pub manifest_gap: usize,
107 pub member_refs: Option<MemberRefs>,
110}
111
112#[derive(Debug, Clone, PartialEq)]
115pub enum RefsResult {
116 Resolved(RefsModel),
118 Ambiguous(Vec<String>),
120 Members(Vec<RefsModel>),
122 MemberAmbiguous(Vec<MemberCandidate>),
127 NotFound,
129}
130
131fn project_of(file: &str) -> &str {
136 match file.find('/') {
137 Some(i) => &file[..i],
138 None => "",
139 }
140}
141
142pub(super) fn trim_source(text: &str) -> &str {
147 text.trim_matches(|c| matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0b}' | '\u{0c}'))
148}
149
150pub type LineCache = HashMap<String, Option<Vec<String>>>;
154
155fn cached_line(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
156 let lines = cache.entry(file.to_string()).or_insert_with(|| {
157 std::fs::read_to_string(root.join(file))
158 .ok()
159 .map(|body| body.split('\n').map(str::to_string).collect())
160 });
161 let Some(lines) = lines else {
162 return String::new();
163 };
164 if line < 1 || line > lines.len() {
165 return String::new();
166 }
167 let mut raw = lines[line - 1].as_str();
168 if line == 1 {
171 raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
172 }
173 trim_source(raw).to_string()
174}
175
176fn clip_source(text: &str) -> String {
178 let text = text.replace('\t', " ");
179 let units: Vec<u16> = text.encode_utf16().collect();
183 if units.len() > SOURCE_MAX {
184 String::from_utf16_lossy(&units[..SOURCE_MAX])
185 } else {
186 text
187 }
188}
189
190fn hit_source(root: &Path, file: &str, line: usize, cache: &mut LineCache) -> String {
193 clip_source(&cached_line(root, file, line, cache))
194}
195
196fn is_word_byte(b: u8) -> bool {
210 b.is_ascii_alphanumeric() || b == b'_'
211}
212
213pub(super) fn line_has_token(line: &str, token: &str) -> bool {
214 let hay = line.as_bytes();
215 let needle = token.as_bytes();
216 if needle.is_empty() || needle.len() > hay.len() {
217 return false;
218 }
219 for at in 0..=(hay.len() - needle.len()) {
220 if &hay[at..at + needle.len()] != needle {
221 continue;
222 }
223 let after_at = at + needle.len();
224 let before_ok = at == 0 || !is_word_byte(hay[at - 1]);
225 let after_ok = after_at >= hay.len() || !is_word_byte(hay[after_at]);
226 if before_ok && after_ok {
227 return true;
228 }
229 }
230 false
231}
232
233enum MemberRefsOutcome {
237 Models(Vec<RefsModel>),
238 Ambiguous(Vec<MemberCandidate>),
239}
240
241fn verified_member_edges(
260 index: &GraphIndex,
261 edges: &[graph::Edge],
262 owner: &str,
263 name: &str,
264 cache: &mut LineCache,
265) -> Vec<(usize, bool)> {
266 let refs = symbol_refs(index, owner);
267 let owner_def = &index.graph.defs[index.by_id[owner]];
268 let owner_project = project_of(&owner_def.file).to_string();
269 let mut kept: Vec<(usize, bool)> = refs
270 .inbound_uses_member
271 .iter()
272 .map(|&e| (e, false))
273 .chain(
274 refs.heuristic_inbound_uses_member
275 .iter()
276 .map(|&e| (e, true)),
277 )
278 .collect();
279 kept.retain(|&(e, _)| {
280 let (file, line) = edge_loc(&edges[e]);
281 line_has_token(&cached_line(&index.root, file, line, cache), name)
282 });
283 let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != owner_project);
284 kept.sort_by(|a, b| {
285 a.1.cmp(&b.1)
286 .then_with(|| foreign(a.0).cmp(&foreign(b.0)))
287 .then_with(|| loc_cmp(&edges[a.0], &edges[b.0]))
288 });
289 kept
290}
291
292fn empty_table<R>() -> Table<R> {
293 Table {
294 total: 0,
295 dropped: 0,
296 rows: Vec::new(),
297 }
298}
299
300#[allow(clippy::too_many_arguments)]
304fn member_group_model(
305 index: &GraphIndex,
306 edges: &[graph::Edge],
307 seed: &str,
308 name: &str,
309 owner: String,
310 sites: Vec<DefSite>,
311 kept: Vec<(usize, bool)>,
312 budget: &mut usize,
313 cache: &mut LineCache,
314) -> RefsModel {
315 let take = (*budget).min(kept.len());
316 *budget -= take;
317 let mut rows = Vec::new();
318 for &(e, heuristic) in &kept[..take] {
319 let (file, line) = edge_loc(&edges[e]);
320 let source = clip_source(&cached_line(&index.root, file, line, cache));
321 rows.push(InboundRow {
322 file: file.to_string(),
323 line,
324 heuristic,
325 tier: row_tier(
326 heuristic,
327 edges[e].tier() == Some(graph::HeuristicTier::Ext),
328 ),
329 source,
330 });
331 }
332 let total = kept.len();
333 RefsModel {
334 query: seed.to_string(),
335 id: format!("{owner}.{name}"),
336 kind: "member".to_string(),
337 sites,
338 inbound: InboundTables {
339 inherits: empty_table(),
340 uses_type: empty_table(),
341 uses_member: Table {
342 total,
343 dropped: total - rows.len(),
344 rows,
345 },
346 },
347 outbound: None,
348 ambiguous: AmbiguousTables {
349 inbound: empty_table(),
350 outbound: empty_table(),
351 },
352 manifest_gap: index.flagged_files.len(),
353 member_refs: None,
354 }
355}
356
357fn build_member_refs_models(
358 index: &GraphIndex,
359 seed: &str,
360 inbound_cap: usize,
361) -> Option<MemberRefsOutcome> {
362 let (name, qualifier) = member::split_member_seed(seed);
363 let owners = member::qualified_member_owners(index, name, qualifier);
364 if owners.is_empty() {
365 return None;
366 }
367 let edges = &index.graph.edges;
368 let mut cache: LineCache = HashMap::new();
369 let mut groups: Vec<(String, Vec<DefSite>, Vec<(usize, bool)>)> = owners
370 .into_iter()
371 .map(|(owner, sites)| {
372 let kept = verified_member_edges(index, edges, &owner, name, &mut cache);
373 (owner, sites, kept)
374 })
375 .collect();
376 if groups.iter().any(|(_, _, kept)| !kept.is_empty()) {
377 groups.retain(|(_, _, kept)| !kept.is_empty());
378 }
379 if groups.len() > 1 {
383 return Some(MemberRefsOutcome::Ambiguous(
384 groups
385 .into_iter()
386 .map(|(owner, sites, _)| MemberCandidate {
387 owner,
388 name: name.to_string(),
389 file: sites[0].file.clone(),
390 line: sites[0].line,
391 })
392 .collect(),
393 ));
394 }
395
396 let mut budget = inbound_cap;
397 let models = groups
398 .into_iter()
399 .map(|(owner, sites, kept)| {
400 member_group_model(
401 index,
402 edges,
403 seed,
404 name,
405 owner,
406 sites,
407 kept,
408 &mut budget,
409 &mut cache,
410 )
411 })
412 .collect();
413 Some(MemberRefsOutcome::Models(models))
414}
415
416struct RankedInbound {
419 kind: usize,
420 edge: usize,
421 heuristic: bool,
422}
423
424struct RankedOutbound {
430 kind: usize,
431 edge: usize,
432 heuristic: bool,
433}
434
435fn outbound_foreign(def_project: &str, edges: &[graph::Edge], r: &RankedOutbound) -> usize {
441 if r.kind == 3 {
442 return 1;
443 }
444 let to_file = match &edges[r.edge] {
445 graph::Edge::Inherits { to_file, .. }
446 | graph::Edge::UsesType { to_file, .. }
447 | graph::Edge::UsesMember { to_file, .. } => to_file.as_str(),
448 _ => unreachable!(
449 "outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"
450 ),
451 };
452 usize::from(project_of(to_file) != def_project)
453}
454
455#[allow(
463 clippy::too_many_lines,
464 reason = "one ordered ranking pass over all four outbound kinds, sharing the same cap and tie-break rule across them"
465)]
466fn build_outbound_tables(
467 refs: &SymbolRefs,
468 def_project: &str,
469 edges: &[graph::Edge],
470 root: &Path,
471 outbound_cap: usize,
472) -> OutboundTables {
473 let mut ranked: Vec<RankedOutbound> = Vec::new();
474 for &e in &refs.outbound_inherits {
475 ranked.push(RankedOutbound {
476 kind: 0,
477 edge: e,
478 heuristic: false,
479 });
480 }
481 for &e in &refs.heuristic_outbound_inherits {
482 ranked.push(RankedOutbound {
483 kind: 0,
484 edge: e,
485 heuristic: true,
486 });
487 }
488 for &e in &refs.outbound_uses_type {
489 ranked.push(RankedOutbound {
490 kind: 1,
491 edge: e,
492 heuristic: false,
493 });
494 }
495 for &e in &refs.heuristic_outbound_uses_type {
496 ranked.push(RankedOutbound {
497 kind: 1,
498 edge: e,
499 heuristic: true,
500 });
501 }
502 for &e in &refs.outbound_uses_member {
503 ranked.push(RankedOutbound {
504 kind: 2,
505 edge: e,
506 heuristic: false,
507 });
508 }
509 for &e in &refs.heuristic_outbound_uses_member {
510 ranked.push(RankedOutbound {
511 kind: 2,
512 edge: e,
513 heuristic: true,
514 });
515 }
516 for &e in &refs.outbound_imports {
517 ranked.push(RankedOutbound {
518 kind: 3,
519 edge: e,
520 heuristic: false,
521 });
522 }
523 let mut totals = [0usize; 4];
524 for r in &ranked {
525 totals[r.kind] += 1;
526 }
527 ranked.sort_by(|a, b| {
528 a.heuristic
529 .cmp(&b.heuristic)
530 .then_with(|| {
531 outbound_foreign(def_project, edges, a).cmp(&outbound_foreign(
532 def_project,
533 edges,
534 b,
535 ))
536 })
537 .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
538 });
539 let (shown, _) = cap_rows(ranked, outbound_cap);
540
541 let mut source_cache: LineCache = HashMap::new();
542 let mut inherits = Vec::new();
543 let mut uses_type = Vec::new();
544 let mut uses_member = Vec::new();
545 let mut imports = Vec::new();
546 for r in shown {
547 let (file, line) = edge_loc(&edges[r.edge]);
548 let source = hit_source(root, file, line, &mut source_cache);
549 if r.kind == 3 {
550 let graph::Edge::Imports { target, .. } = &edges[r.edge] else {
551 unreachable!("outbound kind 3 only ever holds imports edge indices");
552 };
553 imports.push(ImportRow {
554 file: file.to_string(),
555 line,
556 target: target.clone(),
557 source,
558 });
559 continue;
560 }
561 let (to_file, to) = match &edges[r.edge] {
562 graph::Edge::Inherits { to_file, to, .. }
563 | graph::Edge::UsesType { to_file, to, .. }
564 | graph::Edge::UsesMember { to_file, to, .. } => (to_file.clone(), to.clone()),
565 _ => unreachable!("outbound ranked kinds 0-2 only ever hold inherits/uses-type/uses-member edge indices"),
566 };
567 let row = OutboundRow {
568 file: file.to_string(),
569 line,
570 to_file,
571 to,
572 heuristic: r.heuristic,
573 tier: row_tier(
574 r.heuristic,
575 edges[r.edge].tier() == Some(graph::HeuristicTier::Ext),
576 ),
577 source,
578 };
579 match r.kind {
580 0 => inherits.push(row),
581 1 => uses_type.push(row),
582 _ => uses_member.push(row),
583 }
584 }
585
586 OutboundTables {
587 inherits: Table {
588 total: totals[0],
589 dropped: totals[0] - inherits.len(),
590 rows: inherits,
591 },
592 uses_type: Table {
593 total: totals[1],
594 dropped: totals[1] - uses_type.len(),
595 rows: uses_type,
596 },
597 uses_member: Table {
598 total: totals[2],
599 dropped: totals[2] - uses_member.len(),
600 rows: uses_member,
601 },
602 imports: Table {
603 total: totals[3],
604 dropped: totals[3] - imports.len(),
605 rows: imports,
606 },
607 }
608}
609
610pub fn build_refs_model(
614 index: &GraphIndex,
615 query: &str,
616 out: bool,
617 cap: usize,
618 inbound_cap: usize,
619 outbound_cap: usize,
620 all_out: bool,
621) -> RefsResult {
622 build_refs_model_inner(
623 index,
624 query,
625 out,
626 cap,
627 inbound_cap,
628 outbound_cap,
629 all_out,
630 false,
631 )
632}
633
634#[allow(clippy::too_many_arguments)]
635#[allow(
636 clippy::too_many_lines,
637 reason = "one ordered assembly of the refs model's inbound and outbound sections, sharing the same caps and options across both"
638)]
639pub(super) fn build_refs_model_inner(
640 index: &GraphIndex,
641 query: &str,
642 out: bool,
643 cap: usize,
644 inbound_cap: usize,
645 outbound_cap: usize,
646 all_out: bool,
647 exclude_self_inbound: bool,
648) -> RefsResult {
649 let id = match resolve_symbol(index, query) {
650 Resolution::Resolved(id) => id,
651 Resolution::Ambiguous(ids) => return RefsResult::Ambiguous(ids),
652 Resolution::NotFound => {
658 return match build_member_refs_models(index, query, inbound_cap) {
659 Some(MemberRefsOutcome::Models(models)) => RefsResult::Members(models),
660 Some(MemberRefsOutcome::Ambiguous(candidates)) => {
661 RefsResult::MemberAmbiguous(candidates)
662 }
663 None => RefsResult::NotFound,
664 };
665 }
666 };
667 let def_idx = *index
668 .by_id
669 .get(&id)
670 .expect("resolved id must exist in the index it was resolved from");
671 let def = &index.graph.defs[def_idx];
672 let refs = symbol_refs(index, &id);
673 let edges = &index.graph.edges;
674
675 let def_project = project_of(&def.file);
683 let mut ranked: Vec<RankedInbound> = Vec::new();
684 let mut totals = [0usize; 3];
685 let is_self_inbound = |edge: usize| {
686 let (file, line) = edge_loc(&edges[edge]);
687 exclude_self_inbound
688 && def.end_line >= def.line
689 && file == def.file
690 && (def.line..=def.end_line).contains(&line)
691 };
692 for (kind, (precise, heuristic)) in [
693 (&refs.inbound_inherits, &refs.heuristic_inbound_inherits),
694 (&refs.inbound_uses_type, &refs.heuristic_inbound_uses_type),
695 (
696 &refs.inbound_uses_member,
697 &refs.heuristic_inbound_uses_member,
698 ),
699 ]
700 .into_iter()
701 .enumerate()
702 {
703 for &e in precise {
704 if is_self_inbound(e) {
705 continue;
706 }
707 ranked.push(RankedInbound {
708 kind,
709 edge: e,
710 heuristic: false,
711 });
712 totals[kind] += 1;
713 }
714 for &e in heuristic {
715 if is_self_inbound(e) {
716 continue;
717 }
718 ranked.push(RankedInbound {
719 kind,
720 edge: e,
721 heuristic: true,
722 });
723 totals[kind] += 1;
724 }
725 }
726 let foreign = |e: usize| usize::from(project_of(edge_loc(&edges[e]).0) != def_project);
727 ranked.sort_by(|a, b| {
728 a.heuristic
729 .cmp(&b.heuristic)
730 .then_with(|| foreign(a.edge).cmp(&foreign(b.edge)))
731 .then_with(|| loc_cmp(&edges[a.edge], &edges[b.edge]))
732 });
733 let (shown_inbound, _) = cap_rows(ranked, if all_out { usize::MAX } else { inbound_cap });
737
738 let mut source_cache: LineCache = HashMap::new();
739 let mut rows: [Vec<InboundRow>; 3] = [Vec::new(), Vec::new(), Vec::new()];
740 for r in shown_inbound {
741 let (file, line) = edge_loc(&edges[r.edge]);
742 let source = hit_source(&index.root, file, line, &mut source_cache);
743 rows[r.kind].push(InboundRow {
744 file: file.to_string(),
745 line,
746 heuristic: r.heuristic,
747 tier: row_tier(
748 r.heuristic,
749 edges[r.edge].tier() == Some(graph::HeuristicTier::Ext),
750 ),
751 source,
752 });
753 }
754 let [inherits_rows, uses_type_rows, uses_member_rows] = rows;
755 let inbound_table = |total: usize, rows: Vec<InboundRow>| Table {
756 total,
757 dropped: total - rows.len(),
758 rows,
759 };
760 let inbound = InboundTables {
761 inherits: inbound_table(totals[0], inherits_rows),
762 uses_type: inbound_table(totals[1], uses_type_rows),
763 uses_member: inbound_table(totals[2], uses_member_rows),
764 };
765
766 let outbound = out.then(|| {
767 build_outbound_tables(
768 &refs,
769 def_project,
770 edges,
771 &index.root,
772 if all_out { usize::MAX } else { outbound_cap },
773 )
774 });
775 let ambiguous = AmbiguousTables {
776 inbound: build_table(refs.ambiguous_inbound, edges, cap, ambiguous_row),
777 outbound: build_table(refs.ambiguous_outbound, edges, cap, ambiguous_row),
778 };
779
780 let member_refs = if def.kind == "enum" {
781 enum_member_refs(index, &id)
782 } else {
783 None
784 };
785
786 RefsResult::Resolved(RefsModel {
787 query: query.to_string(),
788 id: id.clone(),
789 kind: def.kind.clone(),
790 sites: def_sites(index, &id),
791 inbound,
792 outbound,
793 ambiguous,
794 manifest_gap: index.flagged_files.len(),
795 member_refs,
796 })
797}