use super::common::{
collect_source_files, display_path, filter_symbols, get_git_file_content, lang_from_str,
parse_kind_filter, print_symbols_compact, print_symbols_human, print_symbols_json,
resolve_lang, resolve_target_paths, setup_multi_file, setup_single_file,
};
use crate::ast::symbols::{self, SymbolDef};
use crate::cli::global::GlobalFlags;
use crate::exit;
use anyhow::Context;
use clap::Args;
#[derive(Debug, Args)]
pub struct ListArgs {
pub path: String,
#[arg(long)]
pub kind: Option<String>,
#[arg(long)]
pub compact: bool,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_list(args: ListArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, [args.path.as_str()])?;
let target = cwd.join(&args.path);
let kind_filter = parse_kind_filter(&args.kind);
let lang_hint = args.lang.as_deref();
crate::verbose!(
"ast list: target={}, kind_filter={:?}",
args.path,
kind_filter
);
let mut any_output = false;
if target.is_file() {
let lang = resolve_lang(lang_hint, &target);
crate::verbose!("ast list: detected language={lang} for {}", args.path);
if !lang.has_grammar() {
let msg = format!(
"Unsupported language: {} (detected from {}). \
Supported: Rust, Python, TypeScript, JavaScript, Go, Java, \
C#, Ruby, PHP, Swift, Kotlin, C, C++, HCL, XML, Protobuf, \
TOML, YAML, JSON, Shell.",
lang, args.path,
);
global.emit_error_json(&msg)?;
return Ok(exit::NO_MATCHES);
}
let symbols = symbols::extract_symbols_from_file(&target, Some(lang));
let filtered = filter_symbols(&symbols, &kind_filter);
if !filtered.is_empty() {
any_output = true;
if global.json || global.jsonl {
print_symbols_json(&args.path, &filtered, global)?;
} else if args.compact {
print_symbols_compact(&args.path, &filtered);
} else {
print_symbols_human(&args.path, &filtered);
}
}
} else if target.is_dir() {
let paths = collect_source_files(&target, global)?;
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let glob_roots = vec![target.clone()];
crate::verbose!("ast list: scanning {} files in {}", paths.len(), args.path);
struct ListFileResult {
display: String,
symbols: Vec<SymbolDef>,
}
let results: Vec<ListFileResult> =
crate::par_process_files(&paths, glob_matcher.as_ref(), &glob_roots, |path| {
let lang = resolve_lang(lang_hint, path);
let symbols = symbols::extract_symbols_from_file(path, Some(lang));
if symbols.is_empty() {
return None;
}
let display = display_path(path, &cwd);
Some(ListFileResult { display, symbols })
});
for result in &results {
let filtered = filter_symbols(&result.symbols, &kind_filter);
if filtered.is_empty() {
continue;
}
any_output = true;
if global.json || global.jsonl {
print_symbols_json(&result.display, &filtered, global)?;
} else if args.compact {
print_symbols_compact(&result.display, &filtered);
} else {
print_symbols_human(&result.display, &filtered);
}
}
} else {
anyhow::bail!("path not found: {}", args.path);
}
if any_output {
Ok(exit::SUCCESS)
} else {
let msg = format!("no symbols found in {}", args.path);
global.emit_error_json(&msg)?;
Ok(exit::NO_MATCHES)
}
}
#[derive(Debug, Args)]
pub struct ReadArgs {
pub path: String,
pub symbol: String,
#[arg(long, short, default_value = "0")]
pub context: usize,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_read(args: ReadArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let (_cwd, _target, lang, source) =
setup_single_file(&args.path, args.lang.as_deref(), global)?;
crate::verbose!(
"ast read: file={}, symbol={}, lang={lang}",
args.path,
args.symbol
);
let all_symbols = symbols::extract_symbols(&source, lang);
let sym = match symbols::find_symbol(&all_symbols, &args.symbol) {
Some(s) => s,
None => {
let msg = format!("symbol '{}' not found in {}", args.symbol, args.path);
global.emit_error_json(&msg)?;
return Ok(exit::NO_MATCHES);
}
};
let lines: Vec<&str> = source.lines().collect();
let start = sym
.start_line
.saturating_sub(args.context.saturating_add(1));
let end = sym.end_line.saturating_add(args.context).min(lines.len());
let content: String = lines[start..end].iter().map(|l| format!("{l}\n")).collect();
if !global.emit_json(&serde_json::json!({
"file": args.path,
"symbol": sym.name,
"kind": sym.kind.to_string(),
"start_line": sym.start_line,
"end_line": sym.end_line,
"signature": sym.signature,
"content": content,
}))? {
for (i, line) in lines[start..end].iter().enumerate() {
let line_num = start + i + 1;
println!("{line_num:>4} | {line}");
}
}
Ok(exit::SUCCESS)
}
#[derive(Debug, Args)]
pub struct ValidateArgs {
pub path: String,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_validate(args: ValidateArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let (cwd, paths) = setup_multi_file(&args.path, global)?;
let lang_hint = args.lang.as_deref();
crate::verbose!("ast validate: target={}", args.path);
let mut all_valid = true;
crate::verbose!("ast validate: checking {} files", paths.len());
struct ValidateFileResult {
display: String,
result: crate::ast::validate::ValidationResult,
}
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let glob_roots = vec![cwd.join(&args.path)];
let results: Vec<ValidateFileResult> =
crate::par_process_files(&paths, glob_matcher.as_ref(), &glob_roots, |path| {
let lang = resolve_lang(lang_hint, path);
if !lang.has_grammar() {
return None;
}
let result = crate::ast::validate::validate_file(path, Some(lang)).ok()?;
let display = display_path(path, &cwd);
Some(ValidateFileResult { display, result })
});
for vr in &results {
if !vr.result.valid {
all_valid = false;
}
if !global.emit_json(&serde_json::json!({
"file": vr.display,
"valid": vr.result.valid,
"language": vr.result.language,
"errors": vr.result.errors,
}))? && !global.quiet
{
if !vr.result.valid {
eprintln!("{}: INVALID ({})", vr.display, vr.result.language);
for err in &vr.result.errors {
eprintln!(" line {}:{}: {}", err.line, err.column, err.text.trim());
}
} else {
eprintln!("{}: OK ({})", vr.display, vr.result.language);
}
}
}
if all_valid {
Ok(exit::SUCCESS)
} else {
Ok(exit::FAILURE)
}
}
#[derive(Debug, Args)]
pub struct SearchArgs {
pub query: String,
pub path: String,
#[arg(long)]
pub pattern: bool,
#[arg(long)]
pub lang: Option<String>,
#[arg(long)]
pub max_results: Option<usize>,
}
pub(super) fn run_search(args: SearchArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let (cwd, paths) = setup_multi_file(&args.path, global)?;
let lang_hint = args.lang.as_deref();
crate::verbose!(
"ast search: query={}, pattern={}, target={}",
args.query,
args.pattern,
args.path
);
let mut total_matches = 0usize;
crate::verbose!("ast search: scanning {} files", paths.len());
struct SearchFileResult {
display: String,
matches: Vec<crate::ast::search::SearchMatch>,
}
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let glob_roots = vec![cwd.join(&args.path)];
let file_results: Vec<SearchFileResult> =
crate::par_process_files(&paths, glob_matcher.as_ref(), &glob_roots, |path| {
let lang = resolve_lang(lang_hint, path);
let query_str = if args.pattern {
match crate::ast::search::compile_pattern_query(&args.query, lang) {
Ok(q) => q,
Err(e) => {
if !global.quiet {
eprintln!(
"patchloom: pattern compile error for {}: {e}",
path.display()
);
}
return None;
}
}
} else {
args.query.clone()
};
let matches =
crate::ast::search::search_file(path, &query_str, Some(lang), args.max_results)
.ok()?;
if matches.is_empty() {
return None;
}
let display = display_path(path, &cwd);
Some(SearchFileResult { display, matches })
});
'outer: for result in &file_results {
for m in &result.matches {
total_matches += 1;
if !global.emit_json(&serde_json::json!({
"file": result.display,
"line": m.line,
"column": m.column,
"text": m.text,
"captures": m.captures,
}))? {
println!(
"{}:{}:{}: {}",
result.display,
m.line,
m.column,
m.text.lines().next().unwrap_or("")
);
for cap in &m.captures {
println!(" @{} = \"{}\"", cap.name, cap.text);
}
}
if let Some(max) = args.max_results
&& total_matches >= max
{
break 'outer;
}
}
}
if total_matches == 0 {
let msg = format!("no matches for pattern in {}", args.path);
global.emit_error_json(&msg)?;
Ok(exit::NO_MATCHES)
} else {
Ok(exit::SUCCESS)
}
}
#[derive(Debug, Args)]
pub struct RefsArgs {
pub symbol: String,
pub path: String,
#[arg(long)]
pub include_def: bool,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_refs(args: RefsArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let (cwd, paths) = setup_multi_file(&args.path, global)?;
let lang_hint = args.lang.as_deref();
crate::verbose!("ast refs: symbol={}, target={}", args.symbol, args.path);
crate::verbose!("ast refs: scanning {} files", paths.len());
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let glob_roots = vec![cwd.join(&args.path)];
let per_file_refs: Vec<Vec<crate::ast::refs::SymbolRef>> =
crate::par_process_files(&paths, glob_matcher.as_ref(), &glob_roots, |path| {
let display = display_path(path, &cwd);
let lang = lang_hint.map(lang_from_str);
let refs = crate::ast::refs::find_refs_in_file(path, &args.symbol, lang, &display);
if refs.is_empty() { None } else { Some(refs) }
});
let mut all_refs: Vec<_> = per_file_refs.into_iter().flatten().collect();
if !args.include_def {
all_refs.retain(|r| r.kind != crate::ast::refs::RefKind::Definition);
}
if all_refs.is_empty() {
let msg = format!("no references found for '{}' in {}", args.symbol, args.path);
global.emit_error_json(&msg)?;
return Ok(exit::NO_MATCHES);
}
if !global.emit_json(&serde_json::json!({
"symbol": args.symbol,
"references": all_refs,
"count": all_refs.len(),
}))? {
for r in &all_refs {
let kind_label = match r.kind {
crate::ast::refs::RefKind::Definition => "def",
crate::ast::refs::RefKind::Reference => "ref",
};
println!("{}:{}: [{}] {}", r.file, r.line, kind_label, r.context);
}
}
Ok(exit::SUCCESS)
}
#[derive(Debug, Args)]
pub struct DepsArgs {
pub path: String,
#[arg(long)]
pub reverse: bool,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_deps(args: DepsArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, [args.path.as_str()])?;
let target = cwd.join(&args.path);
let lang_hint = args.lang.as_deref().map(lang_from_str);
crate::verbose!("ast deps: target={}, reverse={}", args.path, args.reverse);
let paths = resolve_target_paths(&target, &args.path, global)?;
crate::verbose!("ast deps: scanning {} files", paths.len());
let mut any_output = false;
if args.reverse {
let target_name = target
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default();
let all_files = collect_source_files(&cwd, global)?;
for path in &all_files {
let imports = crate::ast::deps::extract_imports_from_file(path, lang_hint);
let matching: Vec<_> = imports
.iter()
.filter(|i| {
i.path
.split(['/', '.'])
.flat_map(|seg| seg.split("::"))
.any(|seg| seg == target_name)
})
.collect();
if matching.is_empty() {
continue;
}
any_output = true;
let display = display_path(path, &cwd);
if global.json || global.jsonl {
for imp in &matching {
global.emit_json(&serde_json::json!({
"file": display,
"imports": imp.path,
"line": imp.line,
"raw": imp.raw,
}))?;
}
} else {
for imp in &matching {
println!("{}:{}: {}", display, imp.line, imp.raw);
}
}
}
} else {
struct DepsFileResult {
display: String,
imports: Vec<crate::ast::deps::Import>,
}
let glob_matcher = crate::build_glob_matcher_from_global(global)?;
let glob_roots = vec![cwd.join(&args.path)];
let results: Vec<DepsFileResult> =
crate::par_process_files(&paths, glob_matcher.as_ref(), &glob_roots, |path| {
let imports = crate::ast::deps::extract_imports_from_file(path, lang_hint);
if imports.is_empty() {
return None;
}
let display = display_path(path, &cwd);
Some(DepsFileResult { display, imports })
});
for result in &results {
any_output = true;
if !global.emit_json(&serde_json::json!({
"file": result.display,
"imports": result.imports,
}))? {
println!("{}", result.display);
println!(" imports:");
for imp in &result.imports {
println!(" {}", imp.path);
}
println!();
}
}
}
if any_output {
Ok(exit::SUCCESS)
} else {
let msg = format!("no imports found in {}", args.path);
global.emit_error_json(&msg)?;
Ok(exit::NO_MATCHES)
}
}
#[derive(Debug, Args)]
pub struct MapArgs {
pub path: String,
#[arg(long, default_value = "1024")]
pub max_tokens: usize,
#[arg(long, value_delimiter = ',')]
pub focus: Vec<String>,
#[arg(long, value_delimiter = ',')]
pub boost: Vec<String>,
}
pub(super) fn run_map(args: MapArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, [args.path.as_str()])?;
let target = cwd.join(&args.path);
crate::verbose!(
"ast map: target={}, max_tokens={}",
args.path,
args.max_tokens
);
if !target.is_dir() {
anyhow::bail!("path must be a directory: {}", args.path);
}
let paths = collect_source_files(&target, global)?;
crate::verbose!("ast map: collected {} source files", paths.len());
let file_pairs: Vec<(std::path::PathBuf, String)> = paths
.iter()
.map(|p| {
let display = display_path(p, &cwd);
(p.clone(), display)
})
.collect();
let opts = crate::ast::map::MapOptions {
max_tokens: args.max_tokens,
focus: &args.focus,
boost: &args.boost,
};
let entries = crate::ast::map::generate_map(&file_pairs, &opts);
if entries.is_empty() {
let msg = format!("no symbols found in {}", args.path);
global.emit_error_json(&msg)?;
return Ok(exit::NO_MATCHES);
}
if !global.emit_json_items(&entries)? {
print!("{}", crate::ast::map::render_tree(&entries));
}
Ok(exit::SUCCESS)
}
#[derive(Debug, Args)]
pub struct ImpactArgs {
pub symbol: String,
pub path: String,
#[arg(long, default_value = "3")]
pub depth: usize,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_impact(args: ImpactArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let (cwd, paths) = setup_multi_file(&args.path, global)?;
crate::verbose!("ast impact: symbol={}, depth={}", args.symbol, args.depth);
crate::verbose!("ast impact: scanning {} files", paths.len());
let file_pairs: Vec<(std::path::PathBuf, String)> = paths
.iter()
.map(|p| {
let display = display_path(p, &cwd);
(p.clone(), display)
})
.collect();
let nodes = crate::ast::impact::compute_impact(&args.symbol, &file_pairs, args.depth);
if nodes.is_empty() {
global.emit_error_json(&format!("no references found for '{}'", args.symbol))?;
return Ok(exit::NO_MATCHES);
}
if !global.emit_json(&serde_json::json!({
"symbol": args.symbol,
"depth": args.depth,
"impact": nodes,
"direct_count": nodes.len(),
}))? {
print!(
"{}",
crate::ast::impact::render_impact_tree(&args.symbol, &nodes, 0)
);
}
Ok(exit::SUCCESS)
}
#[derive(Debug, Args)]
pub struct DiffArgs {
pub path: String,
#[arg(long, default_value = "HEAD")]
pub from: String,
#[arg(long)]
pub to: Option<String>,
#[arg(long)]
pub lang: Option<String>,
}
pub(super) fn run_diff(args: DiffArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
let cwd = global.resolve_cwd()?;
global.check_paths_contained(&cwd, [args.path.as_str()])?;
let target = cwd.join(&args.path);
let lang = resolve_lang(args.lang.as_deref(), &target);
crate::verbose!(
"ast diff: file={}, from={}, lang={lang}",
args.path,
args.from
);
let old_source = get_git_file_content(&cwd, &args.path, &args.from)?;
let new_source = if let Some(ref to_ref) = args.to {
get_git_file_content(&cwd, &args.path, to_ref)?
} else {
std::fs::read_to_string(&target).with_context(|| format!("reading {}", args.path))?
};
let changes = crate::ast::diff::structural_diff(&old_source, &new_source, lang);
if changes.is_empty() {
global.emit_error_json("no structural changes")?;
return Ok(exit::NO_MATCHES);
}
if !global.emit_json(&serde_json::json!({
"file": args.path,
"from": args.from,
"to": args.to.as_deref().unwrap_or("working tree"),
"changes": changes,
}))? {
print!("{}", crate::ast::diff::render_changes(&args.path, &changes));
}
Ok(exit::SUCCESS)
}