Skip to main content

ast_bro/
lib.rs

1use clap::{Parser, Subcommand};
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4
5mod adapters;
6mod calls;
7mod core;
8mod deps;
9mod file_filter;
10mod graph_cache;
11mod hook;
12mod installers;
13mod main_helpers;
14mod mcp;
15mod path_glob;
16mod project_root;
17mod prompt;
18mod run;
19mod search;
20mod squeeze;
21mod surface;
22
23use crate::core::{DigestOptions, MapOptions, ParseResult};
24
25#[derive(Parser)]
26#[command(name = "ast-bro")]
27#[command(version)]
28#[command(about = "Fast, AST-based structural outline for source files", long_about = None)]
29struct Cli {
30    #[command(subcommand)]
31    command: Commands,
32}
33
34#[derive(Subcommand)]
35enum Commands {
36    /// Map files or directories — signatures with line ranges, no method bodies.
37    Map {
38        /// Files or directories to map.
39        #[arg(num_args = 1..)]
40        paths: Vec<PathBuf>,
41
42        #[arg(long)]
43        no_private: bool,
44        #[arg(long)]
45        no_fields: bool,
46        #[arg(long)]
47        no_docs: bool,
48        #[arg(long)]
49        no_attrs: bool,
50        #[arg(long)]
51        no_lines: bool,
52        #[arg(long)]
53        glob: Option<String>,
54        /// Emit output as JSON instead of text
55        #[arg(long)]
56        json: bool,
57        /// With --json: emit compact (single-line) JSON instead of pretty-printed
58        #[arg(long)]
59        compact: bool,
60    },
61    /// Extract source of a symbol
62    Show {
63        path: PathBuf,
64        symbol: String,
65        #[arg(num_args = 0..)]
66        others: Vec<String>,
67        /// Emit output as JSON instead of text
68        #[arg(long)]
69        json: bool,
70        /// With --json: emit compact (single-line) JSON
71        #[arg(long)]
72        compact: bool,
73    },
74    /// Compress repetitive log/text into a smaller, reversible form with a legend.
75    ///
76    /// For **logs and text**, not code — for code use `map` / `digest` / `show`.
77    /// Shrinks repeated lines, tags, timestamps and token sequences; prints a legend
78    /// so the original is recoverable. Falls back to the raw input when squeezing
79    /// would make it larger.
80    Squeeze {
81        /// Path to the log/text file to read.
82        path: PathBuf,
83        /// Optional 1-indexed inclusive line range: `N`, `A:B`, `A:`, or `:B`.
84        #[arg(value_parser = parse_line_range)]
85        range: Option<LineRange>,
86        /// Skip compression — emit the raw text with a header (for diffing/inspecting).
87        #[arg(long)]
88        raw: bool,
89        /// Emit output as JSON instead of text
90        #[arg(long)]
91        json: bool,
92        /// With --json: emit compact (single-line) JSON
93        #[arg(long)]
94        compact: bool,
95    },
96    /// One-page module map
97    Digest {
98        #[arg(num_args = 1..)]
99        paths: Vec<PathBuf>,
100
101        #[arg(long)]
102        include_private: bool,
103        #[arg(long)]
104        include_fields: bool,
105        #[arg(long, default_value_t = 50)]
106        max_members: usize,
107        /// Emit output as JSON instead of text
108        #[arg(long)]
109        json: bool,
110        /// With --json: emit compact (single-line) JSON
111        #[arg(long)]
112        compact: bool,
113    },
114    /// Find subclasses / implementations
115    Implements {
116        target: String,
117        #[arg(num_args = 1..)]
118        paths: Vec<PathBuf>,
119
120        #[arg(short, long)]
121        direct: bool,
122        /// Emit output as JSON instead of text
123        #[arg(long)]
124        json: bool,
125        /// With --json: emit compact (single-line) JSON
126        #[arg(long)]
127        compact: bool,
128    },
129    /// Print the agent prompt snippet
130    Prompt,
131    /// Install ast-bro into a coding-agent CLI
132    Install {
133        #[arg(long, conflicts_with = "all")]
134        target: Option<String>,
135        #[arg(long, conflicts_with = "target")]
136        all: bool,
137        #[arg(long)]
138        local: bool,
139        #[arg(long, conflicts_with = "local")]
140        global: bool,
141        #[arg(long)]
142        always: bool,
143        #[arg(long, default_value_t = 200)]
144        min_lines: usize,
145        #[arg(long)]
146        dry_run: bool,
147        #[arg(long)]
148        force: bool,
149        /// Install ast-bro as an MCP server entry instead of the CLAUDE.md prompt.
150        /// Combine with `--skills` to install both.
151        #[arg(long)]
152        mcp: bool,
153        /// Install ast-bro as a Claude Code skill instead of the CLAUDE.md prompt.
154        /// Combine with `--mcp` to install both.
155        #[arg(long)]
156        skills: bool,
157    },
158    /// Remove ast-bro from a coding-agent CLI
159    Uninstall {
160        #[arg(long, conflicts_with = "all")]
161        target: Option<String>,
162        #[arg(long, conflicts_with = "target")]
163        all: bool,
164        #[arg(long)]
165        local: bool,
166        #[arg(long, conflicts_with = "local")]
167        global: bool,
168        #[arg(long)]
169        dry_run: bool,
170    },
171    /// Report what's installed where
172    Status {
173        #[arg(long)]
174        local: bool,
175        #[arg(long, conflicts_with = "local")]
176        global: bool,
177    },
178    /// Internal: read a tool-call event from stdin and respond
179    Hook {
180        #[arg(long)]
181        protocol: String,
182        #[arg(long, default_value_t = 200)]
183        min_lines: usize,
184        #[arg(long)]
185        always: bool,
186    },
187    /// Run as an MCP (Model Context Protocol) server over stdio
188    Mcp,
189    /// Hybrid BM25 + dense semantic search over the repo
190    Search {
191        /// Search query (free-form text or symbol name)
192        query: String,
193        /// Repository root to search in (default: ".")
194        #[arg(default_value = ".")]
195        path: PathBuf,
196        /// Number of results to return
197        #[arg(short = 'k', long = "top-k", default_value_t = 10)]
198        top_k: usize,
199        /// Override auto alpha (semantic vs. BM25 weight, 0.0–1.0)
200        #[arg(long)]
201        alpha: Option<f32>,
202        /// Filter by language (repeatable, e.g. `--lang rust --lang python`)
203        #[arg(long = "lang")]
204        languages: Vec<String>,
205        /// Force a full rebuild of the index before searching
206        #[arg(long)]
207        rebuild: bool,
208        /// Emit output as JSON instead of text
209        #[arg(long)]
210        json: bool,
211        /// With --json: emit compact (single-line) JSON
212        #[arg(long)]
213        compact: bool,
214    },
215    /// Find chunks semantically similar to a given file:line
216    ///
217    /// Pass the source location either as a positional `<FILE>:<LINE>`
218    /// (matches grep / search-result output you can paste back) or via
219    /// `--file <FILE> --line <LINE>` for scripting use.
220    FindRelated {
221        /// Source location as `<FILE>:<LINE>`. Optional when `--file` and
222        /// `--line` are passed together.
223        #[arg(required_unless_present_all = ["file", "line"], conflicts_with_all = ["file", "line"])]
224        target: Option<String>,
225        /// Repository root containing the index (default: ".")
226        #[arg(default_value = ".")]
227        path: PathBuf,
228        /// Alternative to the positional `<FILE>:<LINE>` form
229        #[arg(long, requires = "line")]
230        file: Option<String>,
231        /// 1-indexed line number when using `--file`
232        #[arg(long, requires = "file")]
233        line: Option<u32>,
234        #[arg(short = 'k', long = "top-k", default_value_t = 10)]
235        top_k: usize,
236        #[arg(long)]
237        json: bool,
238        #[arg(long)]
239        compact: bool,
240    },
241    /// True public API surface — resolves `pub use` / `__all__` re-exports.
242    Surface {
243        /// Crate root file, package init, or directory to auto-detect.
244        #[arg(default_value = ".")]
245        path: PathBuf,
246        /// Render as a hierarchical tree grouped by module.
247        #[arg(long)]
248        tree: bool,
249        /// Append the via-chain on each entry (text mode only).
250        #[arg(long)]
251        include_chain: bool,
252        /// Recursion guard for re-export chains.
253        #[arg(long, default_value_t = 16)]
254        max_depth: usize,
255        /// Include private items (only meaningful for fallback languages).
256        #[arg(long)]
257        include_private: bool,
258        /// Force a specific resolver: `rust`, `python`, or `fallback`.
259        #[arg(long)]
260        lang: Option<String>,
261        /// Emit output as JSON instead of text.
262        #[arg(long)]
263        json: bool,
264        /// With --json: emit compact (single-line) JSON.
265        #[arg(long)]
266        compact: bool,
267    },
268    /// Forward import-graph traversal: what does this file import (transitively)?
269    Deps {
270        file: PathBuf,
271        #[arg(long, default_value_t = 3)]
272        depth: usize,
273        /// Force a fresh dep-graph build.
274        #[arg(long)]
275        rebuild: bool,
276        #[arg(long)]
277        json: bool,
278        #[arg(long)]
279        compact: bool,
280    },
281    /// Reverse import-graph: who imports this file (transitively)?
282    ReverseDeps {
283        file: PathBuf,
284        #[arg(long, default_value_t = 3)]
285        depth: usize,
286        #[arg(long, default_value_t = 200)]
287        limit: usize,
288        #[arg(long)]
289        rebuild: bool,
290        #[arg(long)]
291        json: bool,
292        #[arg(long)]
293        compact: bool,
294    },
295    /// Find import cycles via Tarjan SCC.
296    Cycles {
297        #[arg(default_value = ".")]
298        path: PathBuf,
299        #[arg(long, default_value_t = 2)]
300        min_size: usize,
301        #[arg(long)]
302        rebuild: bool,
303        #[arg(long)]
304        json: bool,
305        #[arg(long)]
306        compact: bool,
307    },
308    /// Emit the dep graph (text or JSON).
309    Graph {
310        #[arg(default_value = ".")]
311        path: PathBuf,
312        #[arg(long)]
313        json: bool,
314        #[arg(long)]
315        include_external: bool,
316        #[arg(long)]
317        rebuild: bool,
318        #[arg(long)]
319        compact: bool,
320    },
321    /// Find callers of a symbol — AST-accurate, no grep noise.
322    ///
323    /// Pass the symbol either as a positional `<TARGET>` (suffix-matched
324    /// like `show`/`implements`: `TakeDamage`, `Player.TakeDamage`, or
325    /// `src/Player.cs:TakeDamage` to scope to one file), or via
326    /// `--file <FILE> --symbol <NAME>` for scripting use.
327    Callers {
328        /// Symbol to look up. Optional when `--file` and `--symbol` are passed.
329        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
330        target: Option<String>,
331        /// Repository root (default: ".").
332        #[arg(default_value = ".")]
333        path: PathBuf,
334        /// Alternative to the `<FILE>:<NAME>` positional form.
335        #[arg(long, requires = "symbol")]
336        file: Option<String>,
337        /// Symbol name when using `--file`.
338        #[arg(long, requires = "file")]
339        symbol: Option<String>,
340        /// Max BFS depth (1 = direct callers only).
341        #[arg(long, default_value_t = 1)]
342        depth: usize,
343        /// Cap result count (mirrors reverse-deps).
344        #[arg(long, default_value_t = 200)]
345        limit: usize,
346        /// Include callers whose target is `Ambiguous` (off by default — noisy).
347        #[arg(long)]
348        include_ambiguous: bool,
349        /// Force a fresh call-graph build.
350        #[arg(long)]
351        rebuild: bool,
352        #[arg(long)]
353        json: bool,
354        #[arg(long)]
355        compact: bool,
356    },
357    /// What does this symbol call? — AST-accurate forward call traversal.
358    ///
359    /// Same target-spec rules as `callers`: positional `<TARGET>` (with
360    /// optional `<FILE>:<NAME>` scoping) or `--file --symbol`.
361    Callees {
362        #[arg(required_unless_present_all = ["file", "symbol"], conflicts_with_all = ["file", "symbol"])]
363        target: Option<String>,
364        #[arg(default_value = ".")]
365        path: PathBuf,
366        #[arg(long, requires = "symbol")]
367        file: Option<String>,
368        #[arg(long, requires = "file")]
369        symbol: Option<String>,
370        #[arg(long, default_value_t = 1)]
371        depth: usize,
372        /// Include unresolved callees (the `Bare`/`External` bucket).
373        #[arg(long)]
374        external: bool,
375        #[arg(long)]
376        rebuild: bool,
377        #[arg(long)]
378        json: bool,
379        #[arg(long)]
380        compact: bool,
381    },
382    /// Trace the static call path between two symbols — "how does <FROM> reach <TO>?"
383    ///
384    /// Shortest-path BFS over the call graph, with each hop's source body
385    /// inlined so a flow question is answered in one call instead of chaining
386    /// `callees`. When no static path exists (the chain broke at dynamic
387    /// dispatch), both endpoints + the target file's siblings are shown.
388    /// Targets are suffix-matched like `callers` (`run`, `Type.method`, or
389    /// `src/f.rs:name`).
390    Trace {
391        /// Source symbol — where the call path starts.
392        from: String,
393        /// Destination symbol — where the call path should reach.
394        to: String,
395        /// Repository root (default: ".").
396        #[arg(default_value = ".")]
397        path: PathBuf,
398        /// Max path length in hops.
399        #[arg(long, default_value_t = 12)]
400        depth: usize,
401        /// Force a fresh call-graph build.
402        #[arg(long)]
403        rebuild: bool,
404        #[arg(long)]
405        json: bool,
406        #[arg(long)]
407        compact: bool,
408    },
409    /// Build, refresh, or inspect the per-repo search index
410    Index {
411        /// Repository root (default: ".")
412        #[arg(default_value = ".")]
413        path: PathBuf,
414        /// Drop any existing cache and rebuild from scratch
415        #[arg(long)]
416        rebuild: bool,
417        /// Print index stats and exit
418        #[arg(long)]
419        stats: bool,
420        /// With --stats: emit output as JSON
421        #[arg(long)]
422        json: bool,
423        /// With --json: emit compact (single-line) JSON
424        #[arg(long)]
425        compact: bool,
426    },
427    /// AST-aware search and rewrite using pattern matching with metavariables
428    Run {
429        /// Pattern to match (e.g. '$FUNC($$$)', 'if ($COND) { $$$BODY }')
430        #[arg(short, long)]
431        pattern: String,
432
433        /// Replacement template (e.g. 'bar($A)'). Omit for search-only mode.
434        #[arg(short, long)]
435        rewrite: Option<String>,
436
437        /// Language (auto-detected from file extension if omitted)
438        #[arg(short, long)]
439        lang: Option<String>,
440
441        /// Paths to search (files or directories). Defaults to current directory.
442        paths: Vec<PathBuf>,
443
444        /// Filter files by glob pattern
445        #[arg(long)]
446        glob: Option<String>,
447
448        /// Actually write changes. Without this flag, only shows matches/dry-run.
449        #[arg(long)]
450        write: bool,
451
452        /// Emit output as JSON
453        #[arg(long)]
454        json: bool,
455
456        /// With --json: compact single-line JSON
457        #[arg(long)]
458        compact: bool,
459    },
460}
461
462pub(crate) fn parse_file(path: &Path) -> Option<ParseResult> {
463    crate::main_helpers::parse_file_for_hook(path)
464}
465
466/// A 1-indexed, inclusive line range for `squeeze`. Either bound may be open:
467/// `start` defaults to line 1, `end` defaults to EOF. Maps cleanly to the
468/// `Option<(usize, usize)>` the squeeze renderer expects via [`LineRange::resolve`].
469#[derive(Clone, Debug)]
470pub struct LineRange {
471    pub start: Option<usize>,
472    pub end: Option<usize>,
473}
474
475impl LineRange {
476    /// Collapse to the `(start, end)` pair the report uses, filling open bounds
477    /// with line 1 / EOF and clamping `end` to the real line count so JSON
478    /// consumers never see an out-of-range sentinel like `usize::MAX`.
479    fn resolve(&self, line_count: usize) -> (usize, usize) {
480        clamp_line_range(self.start.unwrap_or(1), self.end, line_count)
481    }
482}
483
484/// Clamp a 1-indexed inclusive line range to a file's real line count.
485pub(crate) fn clamp_line_range(
486    start: usize,
487    end: Option<usize>,
488    line_count: usize,
489) -> (usize, usize) {
490    let end = end.unwrap_or(line_count).min(line_count).max(start);
491    (start, end)
492}
493
494/// Clap value parser for `squeeze`'s optional range argument. Accepts:
495/// `N` (single line), `A:B` (inclusive), `A:` (A to EOF), `:B` (start to B).
496/// All bounds are 1-indexed; `0` and `A > B` are rejected. Clamping to the
497/// file's real line count happens later in the slicer.
498fn parse_line_range(s: &str) -> Result<LineRange, String> {
499    let parse_bound = |part: &str| -> Result<Option<usize>, String> {
500        if part.is_empty() {
501            return Ok(None);
502        }
503        match part.parse::<usize>() {
504            Ok(0) => Err("line numbers are 1-indexed (got 0)".to_string()),
505            Ok(n) => Ok(Some(n)),
506            Err(_) => Err(format!("invalid line number: {part:?}")),
507        }
508    };
509
510    let range = match s.split_once(':') {
511        // `A:B`, `A:`, `:B`, or `:`
512        Some((a, b)) => LineRange {
513            start: parse_bound(a)?,
514            end: parse_bound(b)?,
515        },
516        // `N` — single line, both bounds equal
517        None => {
518            let n = parse_bound(s)?;
519            LineRange { start: n, end: n }
520        }
521    };
522
523    if let (Some(start), Some(end)) = (range.start, range.end) {
524        if start > end {
525            return Err(format!("range start {start} is after end {end}"));
526        }
527    }
528
529    Ok(range)
530}
531
532/// Parse `<FILE>:<LINE>` into the two parts. Returns `None` if there's no
533/// colon or the suffix doesn't parse as a u32. Used by `find-related`.
534fn parse_file_line(s: &str) -> Option<(String, u32)> {
535    let (file, line) = s.rsplit_once(':')?;
536    if file.is_empty() {
537        return None;
538    }
539    Some((file.to_string(), line.parse().ok()?))
540}
541
542/// Filter out non-existent paths, build a WalkBuilder with filters and glob overrides,
543/// and return (builder, existing_paths). Returns None if no paths exist.
544fn build_filtered_walker(
545    paths: &[PathBuf],
546    glob_str: Option<&str>,
547) -> Option<(WalkBuilder, Vec<PathBuf>)> {
548    if paths.is_empty() {
549        return None;
550    }
551
552    let existing: Vec<PathBuf> = paths
553        .iter()
554        .flat_map(|p| {
555            let expanded = path_glob::expand_existing(p);
556            if expanded.is_empty() {
557                println!("# note: path not found: {}", p.display());
558            }
559            expanded
560        })
561        .collect();
562    if existing.is_empty() {
563        return None;
564    }
565
566    let mut builder = WalkBuilder::new(&existing[0]);
567    for p in existing.iter().skip(1) {
568        builder.add(p);
569    }
570
571    builder.hidden(false);
572    file_filter::add_filters(&mut builder, &existing[0]);
573
574    if let Some(g) = glob_str {
575        if let Ok(override_builder) = ignore::overrides::OverrideBuilder::new("").add(g) {
576            if let Ok(over) = override_builder.build() {
577                builder.overrides(over);
578            }
579        }
580    }
581
582    Some((builder, existing))
583}
584
585pub(crate) fn walk_paths(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<PathBuf> {
586    let (tx, rx) = std::sync::mpsc::channel();
587    let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
588        return Vec::new();
589    };
590    let walker = builder.build_parallel();
591    let root = existing[0].clone();
592
593    walker.run(|| {
594        let tx = tx.clone();
595        let root = root.clone();
596        Box::new(move |result| {
597            if let Ok(entry) = result {
598                if entry.file_type().is_some_and(|ft| ft.is_file())
599                    && !file_filter::should_skip_path(entry.path(), &root)
600                {
601                    let _ = tx.send(entry.path().to_path_buf());
602                }
603            }
604            ignore::WalkState::Continue
605        })
606    });
607
608    drop(tx);
609    let mut results: Vec<_> = rx.into_iter().collect();
610    results.sort();
611    results
612}
613
614pub(crate) fn walk_and_parse(paths: &[PathBuf], glob_str: Option<&str>) -> Vec<ParseResult> {
615    let (tx, rx) = std::sync::mpsc::channel();
616    let Some((builder, existing)) = build_filtered_walker(paths, glob_str) else {
617        return Vec::new();
618    };
619    let walker = builder.build_parallel();
620    let root = existing[0].clone();
621
622    walker.run(|| {
623        let tx = tx.clone();
624        let root = root.clone();
625        Box::new(move |result| {
626            if let Ok(entry) = result {
627                if entry.file_type().is_some_and(|ft| ft.is_file())
628                    && !file_filter::should_skip_path(entry.path(), &root)
629                {
630                    if let Some(parsed) = parse_file(entry.path()) {
631                        let _ = tx.send(parsed);
632                    }
633                }
634            }
635            ignore::WalkState::Continue
636        })
637    });
638
639    drop(tx);
640    let mut results: Vec<_> = rx.into_iter().collect();
641    results.sort_by(|a, b| a.path.cmp(&b.path));
642    results
643}
644
645pub fn run() {
646    use clap::error::ErrorKind;
647    use clap::CommandFactory;
648
649    // Agent-friendly arg handling: instead of dying on a typo or unknown
650    // flag, print the help text so the calling agent can self-correct
651    // without a separate `--help` round-trip. `--help` / `--version` keep
652    // their normal exit-0 behaviour; everything else prints help to stdout
653    // and exits 0 too (agents see "output" rather than "error").
654    let cli = match Cli::try_parse() {
655        Ok(c) => c,
656        Err(e) => match e.kind() {
657            ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
658                e.exit();
659            }
660            _ => {
661                let mut cmd = Cli::command();
662                let _ = cmd.print_help();
663                println!();
664                println!(
665                    "# note: could not parse args ({}). Showing help instead.",
666                    e.kind()
667                );
668                std::process::exit(0);
669            }
670        },
671    };
672
673    match &cli.command {
674        Commands::Map {
675            paths,
676            no_private,
677            no_fields,
678            no_docs,
679            no_attrs,
680            no_lines,
681            glob,
682            json,
683            compact,
684        } => {
685            let results = walk_and_parse(paths, glob.as_deref());
686            let opts = MapOptions {
687                include_private: !(*no_private),
688                include_fields: !(*no_fields),
689                include_docs: !(*no_docs),
690                include_attributes: !(*no_attrs),
691                include_line_numbers: !(*no_lines),
692                max_doc_lines: 6,
693                max_members: None,
694            };
695            let json_on = *json;
696            let pretty = !(*compact);
697            if json_on {
698                println!("{}", crate::core::render_json_map(&results, &opts, pretty));
699            } else {
700                for res in results {
701                    println!("{}", crate::core::render_map(&res, &opts));
702                    println!();
703                }
704            }
705        }
706        Commands::Show {
707            path,
708            symbol,
709            others,
710            json,
711            compact,
712        } => {
713            if !path.exists() {
714                println!("# note: path not found: {}", path.display());
715            } else if let Some(res) = parse_file(path) {
716                let mut symbols = vec![symbol.as_str()];
717                symbols.extend(others.iter().map(|s| s.as_str()));
718                if *json {
719                    let mut seen = std::collections::HashSet::new();
720                    let mut all_matches = Vec::new();
721                    for sym in &symbols {
722                        for m in crate::core::find_symbols(&res, sym) {
723                            let key = (m.start_line, m.end_line, m.qualified_name.clone());
724                            if seen.insert(key) {
725                                all_matches.push(m);
726                            }
727                        }
728                    }
729                    println!(
730                        "{}",
731                        crate::core::render_json_show(&res, &all_matches, !(*compact))
732                    );
733                    if all_matches.is_empty() {
734                        // JSON consumers see [] in the payload; humans/agents
735                        // glancing at stderr-free output get a hint too.
736                        println!(
737                            "# note: no symbol matching {:?} in {}",
738                            symbol,
739                            path.display()
740                        );
741                    }
742                } else {
743                    let mut any_match = false;
744                    for sym in &symbols {
745                        let matches = crate::core::find_symbols(&res, sym);
746                        for m in matches {
747                            any_match = true;
748                            println!(
749                                "# {}:{}-{} {} ({})",
750                                res.path.display(),
751                                m.start_line,
752                                m.end_line,
753                                m.qualified_name,
754                                m.kind
755                            );
756                            if !m.ancestor_signatures.is_empty() {
757                                println!("# in: {}", m.ancestor_signatures.join(" → "));
758                            }
759                            println!("{}", m.source);
760                        }
761                    }
762                    if !any_match {
763                        let joined = symbols.join(", ");
764                        println!(
765                            "# note: no symbol matching '{}' in {}",
766                            joined,
767                            path.display()
768                        );
769                    }
770                }
771            } else {
772                println!(
773                    "# note: unsupported file type for `show`: {}",
774                    path.display()
775                );
776            }
777        }
778        Commands::Squeeze {
779            path,
780            range,
781            raw,
782            json,
783            compact,
784        } => {
785            if !path.exists() {
786                println!("# note: path not found: {}", path.display());
787                return;
788            }
789            let text = match std::fs::read_to_string(path) {
790                Ok(t) => t,
791                Err(e) => {
792                    if path.is_dir() {
793                        println!("# note: path is a directory: {}", path.display());
794                    } else {
795                        println!("# note: could not read {}: {}", path.display(), e);
796                    }
797                    return;
798                }
799            };
800            let line_count = text.lines().count();
801            let resolved: Option<(usize, usize)> = range.as_ref().map(|r| r.resolve(line_count));
802            let sliced = crate::squeeze::render::slice_lines(&text, resolved);
803            let path_str = path.display().to_string();
804            let report = crate::squeeze::render::SqueezeReport {
805                path: &path_str,
806                range: resolved,
807                raw: &sliced,
808                raw_requested: *raw,
809            };
810            if *json {
811                println!(
812                    "{}",
813                    crate::squeeze::render::render_json(&report, !(*compact))
814                );
815            } else {
816                println!("{}", crate::squeeze::render::render_text(&report));
817            }
818        }
819        Commands::Digest {
820            paths,
821            include_private,
822            include_fields,
823            max_members,
824            json,
825            compact,
826        } => {
827            let results = walk_and_parse(paths, None);
828            if *json {
829                let opts = MapOptions {
830                    include_private: *include_private,
831                    include_fields: *include_fields,
832                    include_docs: true,
833                    include_attributes: true,
834                    include_line_numbers: true,
835                    max_doc_lines: 6,
836                    max_members: Some(*max_members),
837                };
838                println!(
839                    "{}",
840                    crate::core::render_json_map(&results, &opts, !(*compact))
841                );
842            } else {
843                let opts = DigestOptions {
844                    include_private: *include_private,
845                    include_fields: *include_fields,
846                    max_members_per_type: *max_members,
847                    max_heading_depth: 3,
848                };
849                let root = if paths.len() == 1 && paths[0].is_dir() {
850                    Some(paths[0].as_path())
851                } else {
852                    None
853                };
854                println!("{}", crate::core::render_digest(&results, &opts, root));
855            }
856        }
857        Commands::Implements {
858            target,
859            paths,
860            direct,
861            json,
862            compact,
863        } => {
864            let results = walk_and_parse(paths, None);
865            let transitive = !direct;
866            let matches = crate::core::find_implementations(&results, target, transitive);
867            if *json {
868                println!(
869                    "{}",
870                    crate::core::render_json_implements(target, &matches, transitive, !(*compact),)
871                );
872            } else {
873                println!(
874                    "# {} match(es) for '{}' (incl. transitive):",
875                    matches.len(),
876                    target
877                );
878                for m in matches {
879                    let via = if m.via.is_empty() {
880                        String::new()
881                    } else {
882                        format!(" [via {}]", m.via.last().unwrap())
883                    };
884                    println!("{}:{}  {} {}{}", m.path, m.start_line, m.kind, m.name, via);
885                }
886            }
887        }
888        Commands::Prompt => {
889            println!("{}", crate::prompt::AGENT_PROMPT);
890        }
891        Commands::Install {
892            target,
893            all,
894            local,
895            global,
896            always,
897            min_lines,
898            dry_run,
899            force,
900            mcp,
901            skills,
902        } => {
903            let scope = resolve_scope(*local, *global);
904            let opts = installers::InstallOpts {
905                min_lines: *min_lines,
906                always: *always,
907                dry_run: *dry_run,
908                force: *force,
909            };
910            let exit = run_install(target.as_deref(), *all, *mcp, *skills, &scope, &opts);
911            std::process::exit(exit);
912        }
913        Commands::Uninstall {
914            target,
915            all,
916            local,
917            global,
918            dry_run,
919        } => {
920            let scope = resolve_scope(*local, *global);
921            let opts = installers::InstallOpts {
922                dry_run: *dry_run,
923                ..installers::InstallOpts::default()
924            };
925            let exit = run_uninstall(target.as_deref(), *all, &scope, &opts);
926            std::process::exit(exit);
927        }
928        Commands::Status { local, global } => {
929            let scope = resolve_scope(*local, *global);
930            run_status(&scope);
931        }
932        Commands::Hook {
933            protocol,
934            min_lines,
935            always,
936        } => {
937            let exit = hook::run(protocol, *min_lines, *always);
938            std::process::exit(exit);
939        }
940        Commands::Mcp => {
941            let exit = mcp::run();
942            std::process::exit(exit);
943        }
944        Commands::Search {
945            query,
946            path,
947            top_k,
948            alpha,
949            languages,
950            rebuild,
951            json,
952            compact,
953        } => {
954            if *rebuild {
955                let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
956                if let Err(e) = crate::search::index::Index::build(path, &cwd) {
957                    eprintln!("ast-bro: rebuild failed: {e}");
958                    std::process::exit(1);
959                }
960            }
961            let exit = crate::search::cli::run_search(
962                query,
963                path,
964                *top_k,
965                *alpha,
966                languages.clone(),
967                *json,
968                !(*compact),
969            );
970            std::process::exit(exit);
971        }
972        Commands::FindRelated {
973            target,
974            path,
975            file,
976            line,
977            top_k,
978            json,
979            compact,
980        } => {
981            // Clap guarantees one of: (target alone) or (file + line).
982            let (file_path, line_num) = match (target, file, line) {
983                (Some(t), _, _) => match parse_file_line(t) {
984                    Some(parsed) => parsed,
985                    None => {
986                        println!(
987                            "# note: expected <FILE>:<LINE>, got {t:?} \
988                                 (or use --file FILE --line N instead)"
989                        );
990                        return;
991                    }
992                },
993                (None, Some(f), Some(l)) => (f.clone(), *l),
994                _ => unreachable!("clap should have rejected this argument combination"),
995            };
996            let exit = crate::search::cli::run_find_related(
997                &file_path,
998                line_num,
999                path,
1000                *top_k,
1001                *json,
1002                !(*compact),
1003            );
1004            std::process::exit(exit);
1005        }
1006        Commands::Surface {
1007            path,
1008            tree,
1009            include_chain,
1010            max_depth,
1011            include_private,
1012            lang,
1013            json,
1014            compact,
1015        } => {
1016            let lang_override = match lang {
1017                Some(s) => match crate::surface::LangOverride::parse(s) {
1018                    Some(l) => Some(l),
1019                    None => {
1020                        println!(
1021                            "# note: unknown --lang value '{}'. Expected rust|python|fallback.",
1022                            s
1023                        );
1024                        return;
1025                    }
1026                },
1027                None => None,
1028            };
1029            let json_on = *json;
1030            let pretty = !(*compact);
1031            let output = if json_on {
1032                crate::surface::OutputMode::Json { compact: !pretty }
1033            } else if *tree {
1034                crate::surface::OutputMode::Tree
1035            } else {
1036                crate::surface::OutputMode::Flat
1037            };
1038            let opts = crate::surface::SurfaceOptions {
1039                output,
1040                include_private: *include_private,
1041                max_depth: *max_depth,
1042                include_chain: *include_chain,
1043                lang_override,
1044            };
1045            match crate::surface::resolve_surface(path, &opts) {
1046                Ok(entries) => {
1047                    let rendered =
1048                        crate::surface::render::render(&entries, opts.output, opts.include_chain);
1049                    print!("{}", rendered);
1050                }
1051                Err(e) => {
1052                    println!("# note: {e}");
1053                }
1054            }
1055        }
1056        Commands::Deps {
1057            file,
1058            depth,
1059            rebuild,
1060            json,
1061            compact,
1062        } => {
1063            let exit = crate::deps::cli::run_deps(file, *depth, *json, !(*compact), *rebuild);
1064            std::process::exit(exit);
1065        }
1066        Commands::ReverseDeps {
1067            file,
1068            depth,
1069            limit,
1070            rebuild,
1071            json,
1072            compact,
1073        } => {
1074            let exit = crate::deps::cli::run_reverse_deps(
1075                file,
1076                *depth,
1077                *limit,
1078                *json,
1079                !(*compact),
1080                *rebuild,
1081            );
1082            std::process::exit(exit);
1083        }
1084        Commands::Cycles {
1085            path,
1086            min_size,
1087            rebuild,
1088            json,
1089            compact,
1090        } => {
1091            let exit = crate::deps::cli::run_cycles(path, *min_size, *json, !(*compact), *rebuild);
1092            std::process::exit(exit);
1093        }
1094        Commands::Graph {
1095            path,
1096            json,
1097            include_external,
1098            rebuild,
1099            compact,
1100        } => {
1101            let exit =
1102                crate::deps::cli::run_graph(path, *json, *include_external, !(*compact), *rebuild);
1103            std::process::exit(exit);
1104        }
1105        Commands::Index {
1106            path,
1107            rebuild,
1108            stats,
1109            json,
1110            compact,
1111        } => {
1112            let exit = crate::search::cli::run_index(path, *rebuild, *stats, *json, !(*compact));
1113            std::process::exit(exit);
1114        }
1115        Commands::Callers {
1116            target,
1117            path,
1118            file,
1119            symbol,
1120            depth,
1121            limit,
1122            include_ambiguous,
1123            rebuild,
1124            json,
1125            compact,
1126        } => {
1127            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1128            let exit = crate::calls::cli::run_callers(
1129                &resolved,
1130                path,
1131                *depth,
1132                *limit,
1133                *include_ambiguous,
1134                *rebuild,
1135                *json,
1136                !(*compact),
1137            );
1138            std::process::exit(exit);
1139        }
1140        Commands::Callees {
1141            target,
1142            path,
1143            file,
1144            symbol,
1145            depth,
1146            external,
1147            rebuild,
1148            json,
1149            compact,
1150        } => {
1151            let resolved = compose_target(target.as_deref(), file.as_deref(), symbol.as_deref());
1152            let exit = crate::calls::cli::run_callees(
1153                &resolved,
1154                path,
1155                *depth,
1156                *external,
1157                *rebuild,
1158                *json,
1159                !(*compact),
1160            );
1161            std::process::exit(exit);
1162        }
1163        Commands::Trace {
1164            from,
1165            to,
1166            path,
1167            depth,
1168            rebuild,
1169            json,
1170            compact,
1171        } => {
1172            let exit =
1173                crate::calls::cli::run_trace(from, to, path, *depth, *rebuild, *json, !(*compact));
1174            std::process::exit(exit);
1175        }
1176        Commands::Run {
1177            pattern,
1178            rewrite,
1179            lang,
1180            paths,
1181            glob,
1182            write,
1183            json,
1184            compact,
1185        } => {
1186            let exit = crate::run::cli::run(
1187                pattern,
1188                rewrite.as_deref(),
1189                lang.as_deref(),
1190                paths,
1191                glob.as_deref(),
1192                *write,
1193                *json,
1194                !(*compact),
1195            );
1196            std::process::exit(exit);
1197        }
1198    }
1199}
1200
1201/// Fold `--file <F> --symbol <S>` into the same `<file>:<symbol>` canonical
1202/// form the positional `<TARGET>` arg uses. Clap's `required_unless_present_all`
1203/// guarantees exactly one of the two arms is populated.
1204fn compose_target(target: Option<&str>, file: Option<&str>, symbol: Option<&str>) -> String {
1205    if let Some(t) = target {
1206        return t.to_string();
1207    }
1208    match (file, symbol) {
1209        (Some(f), Some(s)) => format!("{}:{}", f, s),
1210        _ => unreachable!("clap guarantees target XOR (file && symbol)"),
1211    }
1212}
1213
1214fn resolve_scope(local: bool, _global: bool) -> installers::Scope {
1215    if local {
1216        installers::Scope::Local(std::env::current_dir().expect("cwd"))
1217    } else {
1218        installers::Scope::Global
1219    }
1220}
1221
1222fn run_install(
1223    target: Option<&str>,
1224    all: bool,
1225    mcp: bool,
1226    skills: bool,
1227    scope: &installers::Scope,
1228    opts: &installers::InstallOpts,
1229) -> i32 {
1230    let registry = installers::registry();
1231    let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1232        select_all(&registry, scope)
1233    } else if let Some(name) = target {
1234        match registry.iter().find(|i| i.name() == name) {
1235            Some(i) => vec![i],
1236            None => {
1237                eprintln!("unknown --target '{}'. Known: {}", name, names(&registry));
1238                return 2;
1239            }
1240        }
1241    } else {
1242        eprintln!(
1243            "must pass --target <name> or --all. Known: {}",
1244            names(&registry)
1245        );
1246        return 2;
1247    };
1248
1249    let exclusive_mode = mcp || skills;
1250    let mut any_installed = false;
1251    let mut any_failed = false;
1252    for inst in chosen {
1253        let label = inst.name();
1254        if !exclusive_mode {
1255            match inst.install_prompt(scope, opts) {
1256                Ok(c) => {
1257                    print_change(label, "prompt", &c);
1258                    if !matches!(
1259                        c,
1260                        installers::Change::Skipped { .. } | installers::Change::NotApplicable
1261                    ) {
1262                        any_installed = true;
1263                    }
1264                }
1265                Err(e) => {
1266                    eprintln!("{}: prompt: {}", label, e);
1267                    any_failed = true;
1268                }
1269            }
1270            match inst.install_hook(scope, opts) {
1271                Ok(c) => {
1272                    print_change(label, "hook", &c);
1273                    if !matches!(
1274                        c,
1275                        installers::Change::Skipped { .. } | installers::Change::NotApplicable
1276                    ) {
1277                        any_installed = true;
1278                    }
1279                }
1280                Err(e) => {
1281                    eprintln!("{}: hook: {}", label, e);
1282                    any_failed = true;
1283                }
1284            }
1285            match inst.install_subagents(scope, opts) {
1286                Ok(changes) => {
1287                    for c in &changes {
1288                        print_change(label, "subagent", c);
1289                        if !matches!(
1290                            c,
1291                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1292                        ) {
1293                            any_installed = true;
1294                        }
1295                    }
1296                }
1297                Err(e) => {
1298                    eprintln!("{}: subagent: {}", label, e);
1299                    any_failed = true;
1300                }
1301            }
1302        } else {
1303            if mcp {
1304                match inst.install_mcp(scope, opts) {
1305                    Ok(c) => {
1306                        print_change(label, "mcp", &c);
1307                        if !matches!(
1308                            c,
1309                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1310                        ) {
1311                            any_installed = true;
1312                        }
1313                    }
1314                    Err(e) => {
1315                        eprintln!("{}: mcp: {}", label, e);
1316                        any_failed = true;
1317                    }
1318                }
1319            }
1320            if skills {
1321                match inst.install_skills(scope, opts) {
1322                    Ok(c) => {
1323                        print_change(label, "skills", &c);
1324                        if !matches!(
1325                            c,
1326                            installers::Change::Skipped { .. } | installers::Change::NotApplicable
1327                        ) {
1328                            any_installed = true;
1329                        }
1330                    }
1331                    Err(e) => {
1332                        eprintln!("{}: skills: {}", label, e);
1333                        any_failed = true;
1334                    }
1335                }
1336            }
1337        }
1338    }
1339
1340    if any_failed && any_installed {
1341        1
1342    } else if any_failed {
1343        2
1344    } else {
1345        0
1346    }
1347}
1348
1349fn run_uninstall(
1350    target: Option<&str>,
1351    all: bool,
1352    scope: &installers::Scope,
1353    opts: &installers::InstallOpts,
1354) -> i32 {
1355    let registry = installers::registry();
1356    let chosen: Vec<&Box<dyn installers::Installer>> = if all {
1357        select_all(&registry, scope)
1358    } else if let Some(name) = target {
1359        match registry.iter().find(|i| i.name() == name) {
1360            Some(i) => vec![i],
1361            None => {
1362                eprintln!("unknown --target '{}'. Known: {}", name, names(&registry));
1363                return 2;
1364            }
1365        }
1366    } else {
1367        eprintln!(
1368            "must pass --target <name> or --all. Known: {}",
1369            names(&registry)
1370        );
1371        return 2;
1372    };
1373
1374    let mut any_failed = false;
1375    for inst in chosen {
1376        match inst.uninstall(scope, opts) {
1377            Ok(changes) => {
1378                for c in changes {
1379                    print_change(inst.name(), "uninstall", &c);
1380                }
1381            }
1382            Err(e) => {
1383                eprintln!("{}: {}", inst.name(), e);
1384                any_failed = true;
1385            }
1386        }
1387    }
1388    if any_failed {
1389        1
1390    } else {
1391        0
1392    }
1393}
1394
1395fn run_status(scope: &installers::Scope) {
1396    for inst in installers::registry() {
1397        let s = inst.status(scope);
1398        let prompt = if s.prompt_installed {
1399            format!("prompt {}", s.prompt_version.unwrap_or_else(|| "?".into()))
1400        } else {
1401            "prompt -".to_string()
1402        };
1403        let hook = if s.hook_installed {
1404            "hook ✓"
1405        } else {
1406            "hook -"
1407        };
1408        let mcp = if s.mcp_installed { "mcp ✓" } else { "mcp -" };
1409        let skills = if s.skills_installed {
1410            "skills ✓"
1411        } else {
1412            "skills -"
1413        };
1414        println!(
1415            "{:<14} {:<14} {:<8} {:<8} {}",
1416            inst.name(),
1417            prompt,
1418            hook,
1419            mcp,
1420            skills
1421        );
1422    }
1423}
1424
1425fn names(registry: &[Box<dyn installers::Installer>]) -> String {
1426    registry
1427        .iter()
1428        .map(|i| i.name())
1429        .collect::<Vec<_>>()
1430        .join(", ")
1431}
1432
1433/// Picks the adapters to act on for `--all`. For `Scope::Global`, we
1434/// skip targets whose `detect()` reports the CLI is absent (and print a
1435/// note). For `Scope::Local`, the user explicitly opted into this repo
1436/// so detection is bypassed.
1437#[allow(clippy::borrowed_box)]
1438fn select_all<'a>(
1439    registry: &'a [Box<dyn installers::Installer>],
1440    scope: &installers::Scope,
1441) -> Vec<&'a Box<dyn installers::Installer>> {
1442    let bypass_detection = matches!(scope, installers::Scope::Local(_));
1443    registry
1444        .iter()
1445        .filter(|inst| {
1446            if bypass_detection {
1447                return true;
1448            }
1449            let d = inst.detect(scope);
1450            if !d.present {
1451                println!(
1452                    "{:<14} {:<10} skipped  (not detected on this system)",
1453                    inst.name(),
1454                    "detect"
1455                );
1456            }
1457            d.present
1458        })
1459        .collect()
1460}
1461
1462fn print_change(target: &str, phase: &str, change: &installers::Change) {
1463    use installers::Change::*;
1464    match change {
1465        Created(p) => println!("{:<14} {:<10} created  {}", target, phase, p.display()),
1466        Updated(p) => println!("{:<14} {:<10} updated  {}", target, phase, p.display()),
1467        Removed(p) => println!("{:<14} {:<10} removed  {}", target, phase, p.display()),
1468        Skipped { path, reason } => {
1469            println!(
1470                "{:<14} {:<10} skipped  {} ({})",
1471                target,
1472                phase,
1473                path.display(),
1474                reason
1475            )
1476        }
1477        NotApplicable => println!("{:<14} {:<10} n/a", target, phase),
1478    }
1479}