Skip to main content

cargo_brief/
code.rs

1//! Pre-crafted tree-sitter code lookup by item kind and name.
2//!
3//! Bridges the gap between `search` (API shape, no source locations) and `ts`
4//! (raw S-expressions). Provides `cargo brief code <target> [kind] <name>`.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Result, bail};
9use streaming_iterator::StreamingIterator;
10use tree_sitter::{Parser, Query, QueryCursor};
11
12use crate::cli::CodeArgs;
13use crate::examples;
14
15// ── Item kinds ───────────────────────────────────────────────────────
16
17/// Supported item kinds for code lookup.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ItemKind {
20    Fn,
21    Struct,
22    Enum,
23    Trait,
24    Field,
25    Type,
26    Impl,
27    Macro,
28    ProcMacro,
29    ProcAttrMacro,
30    ProcDeriveMacro,
31    Const,
32    Use,
33}
34
35impl ItemKind {
36    /// Parse a kind keyword. Returns `None` for unrecognized strings.
37    pub fn parse(s: &str) -> Option<Self> {
38        match s {
39            "fn" => Some(Self::Fn),
40            "struct" => Some(Self::Struct),
41            "enum" => Some(Self::Enum),
42            "trait" => Some(Self::Trait),
43            "field" => Some(Self::Field),
44            "type" => Some(Self::Type),
45            "impl" => Some(Self::Impl),
46            "macro" => Some(Self::Macro),
47            "proc-macro" => Some(Self::ProcMacro),
48            "attr-macro" => Some(Self::ProcAttrMacro),
49            "derive-macro" => Some(Self::ProcDeriveMacro),
50            "const" => Some(Self::Const),
51            "use" => Some(Self::Use),
52            _ => None,
53        }
54    }
55
56    /// Display keyword (lowercase, not Debug format).
57    pub fn keyword(self) -> &'static str {
58        match self {
59            Self::Fn => "fn",
60            Self::Struct => "struct",
61            Self::Enum => "enum",
62            Self::Trait => "trait",
63            Self::Field => "field",
64            Self::Type => "type",
65            Self::Impl => "impl",
66            Self::Macro => "macro",
67            Self::ProcMacro => "proc-macro",
68            Self::ProcAttrMacro => "attr-macro",
69            Self::ProcDeriveMacro => "derive-macro",
70            Self::Const => "const",
71            Self::Use => "use",
72        }
73    }
74}
75
76// ── Argument resolution ──────────────────────────────────────────────
77
78/// Resolved positional arguments for the `code` subcommand.
79pub struct ResolvedCodeArgs {
80    /// `"self"` or a crate name / spec.
81    pub target: String,
82    pub kind: Option<ItemKind>,
83    pub name: String,
84}
85
86/// Parse the variadic positional args (`1..=3`) into target, kind, and name.
87///
88/// - **1 arg:** `code NAME` → target=`"self"`, catch-all. Errors if the single
89///   arg is a kind keyword (ambiguous).
90/// - **2 args:** `code KIND NAME` if arg[0] is a kind keyword, else
91///   `code TARGET NAME` (catch-all).
92/// - **3 args:** `code TARGET KIND NAME` — arg[1] must be a valid kind.
93/// - **0 or 4+ args:** error with usage.
94pub fn resolve_code_args(args: &CodeArgs) -> Result<ResolvedCodeArgs> {
95    match args.args.len() {
96        1 => {
97            let a = &args.args[0];
98            if ItemKind::parse(a).is_some() {
99                bail!(
100                    "'{}' is an item kind. Usage: cargo brief code [TARGET] {} <name>",
101                    a,
102                    a
103                );
104            }
105            Ok(ResolvedCodeArgs {
106                target: "self".to_string(),
107                kind: None,
108                name: a.clone(),
109            })
110        }
111        2 => {
112            let a0 = &args.args[0];
113            let a1 = &args.args[1];
114            if let Some(kind) = ItemKind::parse(a0) {
115                Ok(ResolvedCodeArgs {
116                    target: "self".to_string(),
117                    kind: Some(kind),
118                    name: a1.clone(),
119                })
120            } else {
121                Ok(ResolvedCodeArgs {
122                    target: a0.clone(),
123                    kind: None,
124                    name: a1.clone(),
125                })
126            }
127        }
128        3 => {
129            let target = &args.args[0];
130            let kind_str = &args.args[1];
131            let name = &args.args[2];
132            match ItemKind::parse(kind_str) {
133                Some(kind) => Ok(ResolvedCodeArgs {
134                    target: target.clone(),
135                    kind: Some(kind),
136                    name: name.clone(),
137                }),
138                None => bail!(
139                    "Unknown item kind '{}'. Valid kinds: fn, struct, enum, trait, field, type, impl, macro, proc-macro, attr-macro, derive-macro, const, use",
140                    kind_str
141                ),
142            }
143        }
144        _ => bail!(
145            "Expected 1–3 positional arguments: [TARGET] [KIND] NAME\n\
146             Usage: cargo brief code [TARGET] [KIND] NAME"
147        ),
148    }
149}
150
151// ── Tree-sitter queries ──────────────────────────────────────────────
152
153/// Build a tree-sitter query string for the given item kind (or all kinds).
154/// Each pattern has `@name` (the identifier to match) and `@item` (the full node).
155fn build_query(kind: Option<ItemKind>) -> String {
156    let mut parts = Vec::new();
157
158    let add = |parts: &mut Vec<&str>, k: ItemKind| match k {
159        ItemKind::Fn => {
160            parts.push("(function_item name: (identifier) @name) @item");
161            parts.push("(function_signature_item name: (identifier) @name) @item");
162        }
163        ItemKind::Struct => {
164            parts.push("(struct_item name: (type_identifier) @name) @item");
165        }
166        ItemKind::Enum => {
167            parts.push("(enum_item name: (type_identifier) @name) @item");
168        }
169        ItemKind::Trait => {
170            parts.push("(trait_item name: (type_identifier) @name) @item");
171        }
172        ItemKind::Field => {
173            parts.push("(field_declaration name: (field_identifier) @name) @item");
174        }
175        ItemKind::Type => {
176            parts.push("(type_item name: (type_identifier) @name) @item");
177        }
178        ItemKind::Impl => {
179            parts.push("(impl_item type: (type_identifier) @name) @item");
180            parts.push("(impl_item type: (generic_type type: (type_identifier) @name)) @item");
181            parts.push(
182                "(impl_item type: (scoped_type_identifier name: (type_identifier) @name)) @item",
183            );
184        }
185        ItemKind::Macro => {
186            parts.push("(macro_definition name: (identifier) @name) @item");
187        }
188        // Proc-macros are pub fn items in source, decorated with #[proc_macro*].
189        // tree-sitter-rust has no dedicated node for them; match as function_item.
190        ItemKind::ProcMacro | ItemKind::ProcAttrMacro | ItemKind::ProcDeriveMacro => {
191            parts.push("(function_item name: (identifier) @name) @item");
192            parts.push("(function_signature_item name: (identifier) @name) @item");
193        }
194        ItemKind::Const => {
195            parts.push("(const_item name: (identifier) @name) @item");
196            parts.push("(static_item name: (identifier) @name) @item");
197        }
198        ItemKind::Use => {
199            parts.push(
200                "(use_declaration argument: (use_as_clause alias: (identifier) @name)) @item",
201            );
202            parts.push(
203                "(use_declaration argument: (scoped_identifier name: (identifier) @name)) @item",
204            );
205            parts.push("(use_declaration argument: (identifier) @name) @item");
206        }
207    };
208
209    if let Some(k) = kind {
210        add(&mut parts, k);
211    } else {
212        // Catch-all: all kinds except Use (reduces noise).
213        // ProcMacro/ProcAttrMacro/ProcDeriveMacro intentionally omitted — their
214        // tree-sitter pattern (function_item) is identical to Fn and already included.
215        for k in [
216            ItemKind::Fn,
217            ItemKind::Struct,
218            ItemKind::Enum,
219            ItemKind::Trait,
220            ItemKind::Field,
221            ItemKind::Type,
222            ItemKind::Impl,
223            ItemKind::Macro,
224            ItemKind::Const,
225        ] {
226            add(&mut parts, k);
227        }
228    }
229
230    parts.join("\n")
231}
232
233// ── Name matching ────────────────────────────────────────────────────
234
235/// Smart-case: all-lowercase pattern → case-insensitive.
236fn is_case_sensitive(pattern: &str) -> bool {
237    pattern.chars().any(|c| c.is_uppercase())
238}
239
240fn name_matches(captured: &str, pattern: &str, case_sensitive: bool) -> bool {
241    if case_sensitive {
242        captured == pattern
243    } else {
244        captured.eq_ignore_ascii_case(pattern)
245    }
246}
247
248// ── File collection ──────────────────────────────────────────────────
249
250fn collect_source_files(source_root: &Path, src_only: bool) -> Vec<PathBuf> {
251    let mut files = Vec::new();
252    let dirs: &[&str] = if src_only {
253        &["src"]
254    } else {
255        &["src", "examples", "tests", "benches"]
256    };
257    for dir_name in dirs {
258        let dir = source_root.join(dir_name);
259        if dir.is_dir() {
260            files.extend(examples::collect_rs_files(&dir, 999));
261        }
262    }
263    files.sort();
264    files
265}
266
267// ── Module context derivation ────────────────────────────────────────
268
269/// Derive module path from file path relative to source root.
270/// `lib.rs`/`main.rs` → empty. `src/foo/bar.rs` → `foo::bar`.
271/// `src/foo/mod.rs` → `foo`.
272fn derive_module_path(file_path: &Path, source_root: &Path) -> String {
273    let rel = file_path.strip_prefix(source_root).unwrap_or(file_path);
274
275    // Strip leading src/ if present
276    let rel = rel.strip_prefix("src").unwrap_or(rel);
277
278    let s = rel.to_string_lossy();
279    let s = s.strip_suffix(".rs").unwrap_or(&s);
280
281    // mod.rs → use parent dir
282    let s = s
283        .strip_suffix("/mod")
284        .or_else(|| s.strip_suffix("\\mod"))
285        .unwrap_or(s);
286
287    // lib / main at root → empty
288    if s == "lib" || s == "main" || s == "/lib" || s == "/main" || s == "\\lib" || s == "\\main" {
289        return String::new();
290    }
291
292    // Strip leading separator
293    let s = s
294        .strip_prefix('/')
295        .or_else(|| s.strip_prefix('\\'))
296        .unwrap_or(s);
297
298    s.replace(['/', '\\'], "::")
299}
300
301/// Walk up from a node collecting inline `mod_item` ancestor names (reversed for top-down order).
302fn collect_inline_module_names(node: tree_sitter::Node, source: &str) -> Vec<String> {
303    let mut names = Vec::new();
304    let mut current = node.parent();
305    while let Some(parent) = current {
306        if parent.kind() == "mod_item"
307            && let Some(name_node) = parent.child_by_field_name("name")
308        {
309            names.push(source[name_node.start_byte()..name_node.end_byte()].to_string());
310        }
311        current = parent.parent();
312    }
313    names.reverse();
314    names
315}
316
317/// Build full module context: `crate_name::file_module::inline_modules`.
318fn build_module_context(
319    crate_name: &str,
320    file_path: &Path,
321    source_root: &Path,
322    node: tree_sitter::Node,
323    source: &str,
324) -> String {
325    let file_mod = derive_module_path(file_path, source_root);
326    let inline_mods = collect_inline_module_names(node, source);
327
328    let mut path = String::from(crate_name);
329    if !file_mod.is_empty() {
330        path.push_str("::");
331        path.push_str(&file_mod);
332    }
333    if !inline_mods.is_empty() {
334        path.push_str("::");
335        path.push_str(&inline_mods.join("::"));
336    }
337    path
338}
339
340// ── Parent context ───────────────────────────────────────────────────
341
342/// Walk up from node to find nearest impl/trait/struct/enum parent.
343/// Returns a display string like `impl Commands<'w, 's'>` or `trait Plugin`.
344fn find_parent_context(node: tree_sitter::Node, source: &str) -> Option<String> {
345    let mut current = node.parent();
346    while let Some(parent) = current {
347        match parent.kind() {
348            "impl_item" => {
349                // Extract: `impl [Trait for] Type`
350                let trait_part = parent
351                    .child_by_field_name("trait")
352                    .map(|t| &source[t.start_byte()..t.end_byte()]);
353                let type_part = parent
354                    .child_by_field_name("type")
355                    .map(|t| &source[t.start_byte()..t.end_byte()]);
356                return match (trait_part, type_part) {
357                    (Some(tr), Some(ty)) => Some(format!("impl {tr} for {ty}")),
358                    (None, Some(ty)) => Some(format!("impl {ty}")),
359                    _ => None,
360                };
361            }
362            "trait_item" => {
363                if let Some(name_node) = parent.child_by_field_name("name") {
364                    let name = &source[name_node.start_byte()..name_node.end_byte()];
365                    return Some(format!("trait {name}"));
366                }
367            }
368            "struct_item" => {
369                if let Some(name_node) = parent.child_by_field_name("name") {
370                    let name = &source[name_node.start_byte()..name_node.end_byte()];
371                    return Some(format!("struct {name}"));
372                }
373            }
374            "enum_item" => {
375                if let Some(name_node) = parent.child_by_field_name("name") {
376                    let name = &source[name_node.start_byte()..name_node.end_byte()];
377                    return Some(format!("enum {name}"));
378                }
379            }
380            // Skip intermediate nodes (declaration_list, etc.) and keep walking up
381            "mod_item" => return None, // stop at module boundary
382            _ => {}
383        }
384        current = parent.parent();
385    }
386    None
387}
388
389// ── Parent type extraction ────────────────────────────────────────────
390
391/// Extract the type name from the nearest parent impl/trait/struct/enum.
392/// Returns the bare type identifier (e.g., "Commands" from "impl Commands<'w>").
393fn parent_type_name(node: tree_sitter::Node, source: &str) -> Option<String> {
394    let mut current = node.parent();
395    while let Some(parent) = current {
396        match parent.kind() {
397            "impl_item" => {
398                let type_node = parent.child_by_field_name("type")?;
399                return extract_type_identifier(type_node, source);
400            }
401            "trait_item" | "struct_item" | "enum_item" => {
402                let name_node = parent.child_by_field_name("name")?;
403                return Some(source[name_node.start_byte()..name_node.end_byte()].to_string());
404            }
405            "mod_item" => return None,
406            _ => {}
407        }
408        current = parent.parent();
409    }
410    None
411}
412
413/// Extract the type_identifier from a type node, handling generic_type
414/// and scoped_type_identifier wrappers.
415fn extract_type_identifier(node: tree_sitter::Node, source: &str) -> Option<String> {
416    match node.kind() {
417        "type_identifier" => Some(source[node.start_byte()..node.end_byte()].to_string()),
418        "generic_type" => {
419            let type_node = node.child_by_field_name("type")?;
420            extract_type_identifier(type_node, source)
421        }
422        "scoped_type_identifier" => {
423            let name_node = node.child_by_field_name("name")?;
424            Some(source[name_node.start_byte()..name_node.end_byte()].to_string())
425        }
426        _ => None,
427    }
428}
429
430// ── Limit parsing ────────────────────────────────────────────────────
431
432fn parse_limit(raw: Option<&str>) -> (usize, Option<usize>) {
433    let Some(raw) = raw else {
434        return (0, None);
435    };
436    if let Some((offset_str, limit_str)) = raw.split_once(':') {
437        (
438            offset_str.parse().unwrap_or(0),
439            Some(limit_str.parse().unwrap_or(0)),
440        )
441    } else {
442        (0, Some(raw.parse().unwrap_or(0)))
443    }
444}
445
446// ── Main search function ─────────────────────────────────────────────
447
448/// Search source files for code definitions matching kind and name.
449///
450/// `sources`: list of `(crate_name, source_root)` pairs to scan.
451pub fn search_code(
452    sources: &[(String, PathBuf)],
453    name: &str,
454    kind: Option<ItemKind>,
455    args: &CodeArgs,
456    in_type: Option<&str>,
457) -> Result<String> {
458    let language: tree_sitter::Language = tree_sitter_rust::LANGUAGE.into();
459    let query_src = build_query(kind);
460    let query = Query::new(&language, &query_src)
461        .map_err(|e| anyhow::anyhow!("Failed to compile tree-sitter query: {e}"))?;
462
463    let mut parser = Parser::new();
464    parser
465        .set_language(&language)
466        .map_err(|e| anyhow::anyhow!("Failed to set tree-sitter language: {e}"))?;
467
468    let capture_names = query.capture_names().to_vec();
469    let name_idx = capture_names
470        .iter()
471        .position(|n| *n == "name")
472        .expect("query must have @name capture") as u32;
473    let item_idx = capture_names
474        .iter()
475        .position(|n| *n == "item")
476        .expect("query must have @item capture") as u32;
477
478    let case_sensitive = is_case_sensitive(name);
479    let (offset, limit) = parse_limit(args.limit.as_deref());
480
481    let mut output = String::new();
482    let mut match_count = 0usize;
483    let mut emitted = 0usize;
484
485    // Pre-compute lowercase name for insensitive grep pre-filter
486    let name_lower = name.to_ascii_lowercase();
487
488    'outer: for (crate_name, source_root) in sources {
489        let files = collect_source_files(source_root, args.src_only);
490
491        for file_path in &files {
492            let source = match std::fs::read_to_string(file_path) {
493                Ok(s) => s,
494                Err(_) => continue,
495            };
496
497            // Grep pre-filter: skip files that don't contain the name
498            let contains = if case_sensitive {
499                source.contains(name)
500            } else {
501                source.to_ascii_lowercase().contains(&name_lower)
502            };
503            if !contains {
504                continue;
505            }
506
507            let Some(tree) = parser.parse(&source, None) else {
508                continue;
509            };
510
511            let root = tree.root_node();
512            let mut cursor = QueryCursor::new();
513            let mut matches = cursor.matches(&query, root, source.as_bytes());
514
515            while let Some(query_match) = matches.next() {
516                // Find @name and @item captures
517                let name_node = query_match.captures.iter().find(|c| c.index == name_idx);
518                let item_node = query_match.captures.iter().find(|c| c.index == item_idx);
519
520                let (Some(name_cap), Some(item_cap)) = (name_node, item_node) else {
521                    continue;
522                };
523
524                let captured_name = &source[name_cap.node.start_byte()..name_cap.node.end_byte()];
525                if !name_matches(captured_name, name, case_sensitive) {
526                    continue;
527                }
528
529                // --in filter: only items inside matching parent type
530                if let Some(filter_type) = in_type {
531                    let filter_case_sensitive = is_case_sensitive(filter_type);
532                    match parent_type_name(item_cap.node, &source) {
533                        Some(ref parent_name) => {
534                            if !name_matches(parent_name, filter_type, filter_case_sensitive) {
535                                continue;
536                            }
537                        }
538                        None => continue,
539                    }
540                }
541
542                match_count += 1;
543                if match_count <= offset {
544                    continue;
545                }
546                if let Some(n) = limit
547                    && emitted >= n
548                {
549                    break 'outer;
550                }
551                emitted += 1;
552
553                let item_node = item_cap.node;
554                let start_line = item_node.start_position().row + 1;
555                let rel = file_path.strip_prefix(source_root).unwrap_or(file_path);
556
557                // Module context
558                let mod_ctx =
559                    build_module_context(crate_name, file_path, source_root, item_node, &source);
560
561                // Parent context
562                let parent_ctx = find_parent_context(item_node, &source);
563
564                // Format output
565                if !output.is_empty() {
566                    output.push('\n');
567                }
568
569                output.push_str(&format!("@{}:{}\n", rel.display(), start_line));
570
571                // Context line: `  in module[, parent]`
572                output.push_str("  in ");
573                output.push_str(&mod_ctx);
574                if let Some(ref ctx) = parent_ctx {
575                    output.push_str(", ");
576                    output.push_str(ctx);
577                }
578                output.push('\n');
579
580                if !args.quiet {
581                    output.push('\n');
582                    let text = &source[item_node.start_byte()..item_node.end_byte()];
583                    output.push_str(text);
584                    if !text.ends_with('\n') {
585                        output.push('\n');
586                    }
587                }
588            }
589        }
590    }
591
592    if match_count == 0 {
593        let kind_str = kind.map_or("", |k| k.keyword());
594        if kind_str.is_empty() {
595            output.push_str(&format!("// no definitions found for '{name}'\n"));
596        } else {
597            output.push_str(&format!(
598                "// no {kind_str} definitions found for '{name}'\n"
599            ));
600        }
601    }
602
603    Ok(output)
604}
605
606// ── Reference (grep) search ──────────────────────────────────────────
607
608fn digit_count(mut n: usize) -> usize {
609    if n == 0 {
610        return 1;
611    }
612    let mut count = 0;
613    while n > 0 {
614        count += 1;
615        n /= 10;
616    }
617    count
618}
619
620/// Grep source files for literal occurrences of `name`.
621pub fn search_references(
622    sources: &[(String, PathBuf)],
623    name: &str,
624    src_only: bool,
625    quiet: bool,
626    limit: Option<&str>,
627) -> String {
628    let case_sensitive = is_case_sensitive(name);
629    let name_lower = name.to_ascii_lowercase();
630    let (offset, limit_n) = parse_limit(limit);
631
632    let ctx_lines: usize = 2;
633    let mut output = String::new();
634    let mut total_matches = 0usize;
635    let mut emitted = 0usize;
636
637    'outer: for (_crate_name, source_root) in sources {
638        let files = collect_source_files(source_root, src_only);
639
640        for file_path in &files {
641            let content = match std::fs::read_to_string(file_path) {
642                Ok(c) => c,
643                Err(_) => continue,
644            };
645
646            let lines: Vec<&str> = content.lines().collect();
647            let total = lines.len();
648
649            // Find matching line indices (0-based)
650            let matches: Vec<usize> = lines
651                .iter()
652                .enumerate()
653                .filter(|(_, line)| {
654                    if case_sensitive {
655                        line.contains(name)
656                    } else {
657                        line.to_ascii_lowercase().contains(&name_lower)
658                    }
659                })
660                .map(|(i, _)| i)
661                .collect();
662
663            if matches.is_empty() {
664                continue;
665            }
666
667            let rel = file_path
668                .strip_prefix(source_root)
669                .unwrap_or(file_path)
670                .to_string_lossy()
671                .replace('\\', "/");
672
673            if quiet {
674                for &m in &matches {
675                    total_matches += 1;
676                    if total_matches <= offset {
677                        continue;
678                    }
679                    if let Some(n) = limit_n
680                        && emitted >= n
681                    {
682                        break 'outer;
683                    }
684                    emitted += 1;
685                    output.push_str(&format!("@{}:{}\n", rel, m + 1));
686                }
687            } else {
688                // Determine which matches survive offset/limit
689                let mut file_match_indices: Vec<usize> = Vec::new();
690                for &m in &matches {
691                    total_matches += 1;
692                    if total_matches <= offset {
693                        continue;
694                    }
695                    if let Some(n) = limit_n
696                        && emitted >= n
697                    {
698                        break;
699                    }
700                    emitted += 1;
701                    file_match_indices.push(m);
702                }
703
704                if file_match_indices.is_empty() {
705                    if let Some(n) = limit_n
706                        && emitted >= n
707                    {
708                        break 'outer;
709                    }
710                    continue;
711                }
712
713                // Compute context ranges and merge overlapping
714                let mut ranges: Vec<(usize, usize)> = Vec::new();
715                for &m in &file_match_indices {
716                    let start = m.saturating_sub(ctx_lines);
717                    let end = (m + ctx_lines).min(total.saturating_sub(1));
718                    if let Some(last) = ranges.last_mut()
719                        && start <= last.1 + 1
720                    {
721                        last.1 = last.1.max(end);
722                        continue;
723                    }
724                    ranges.push((start, end));
725                }
726
727                // Line number column width
728                let max_line_no = ranges.last().map_or(1, |r| r.1 + 1);
729                let width = digit_count(max_line_no).max(4);
730
731                output.push_str(&format!("@{rel}\n"));
732
733                let match_set: std::collections::HashSet<usize> =
734                    file_match_indices.iter().copied().collect();
735
736                for (range_idx, &(start, end)) in ranges.iter().enumerate() {
737                    if range_idx > 0 {
738                        output.push_str("  ...\n");
739                    }
740                    for (i, line) in lines.iter().enumerate().take(end + 1).skip(start) {
741                        let line_no = i + 1;
742                        let marker = if match_set.contains(&i) { '*' } else { ' ' };
743                        output.push_str(&format!(
744                            "{marker}{line_no:>width$}:  {line}\n",
745                            width = width,
746                        ));
747                    }
748                }
749
750                output.push('\n');
751
752                if let Some(n) = limit_n
753                    && emitted >= n
754                {
755                    break 'outer;
756                }
757            }
758        }
759    }
760
761    if total_matches == 0 {
762        output.push_str(&format!("// no references found for '{name}'\n"));
763    }
764
765    output
766}