use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use dimpact::DfgBuilder;
use dimpact::EngineConfig;
use dimpact::cache;
use dimpact::compute_changed_symbols;
use dimpact::compute_impact;
use dimpact::dfg::{DataFlowGraph, PdgBuilder, RubyDfgBuilder, RustDfgBuilder};
use dimpact::engine::{EngineKind, make_engine};
use dimpact::ir::SymbolId;
use dimpact::ir::reference::{RefKind, Reference};
use dimpact::render::dfg_to_dot;
use dimpact::{ChangedOutput, LanguageMode};
use dimpact::{DiffParseError, parse_unified_diff};
use dimpact::{ImpactDirection, ImpactOptions, ImpactOutput};
use env_logger::Env;
use is_terminal::IsTerminal;
use std::fs;
use std::io::{self, Read};
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Json,
Yaml,
Dot,
Html,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum Mode {
Diff,
Changed,
Impact,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum LangOpt {
Auto,
Rust,
Ruby,
Javascript,
Typescript,
Tsx,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum DirectionOpt {
Callers,
Callees,
Both,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum EngineOpt {
Auto,
Ts,
Lsp,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum KindOpt {
#[value(alias = "function")]
Fn,
Method,
Struct,
Enum,
Trait,
#[value(alias = "module")]
Mod,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CacheScopeOpt {
Local,
Global,
}
#[derive(Debug, Parser)]
#[command(
name = "dimpact",
version,
about = "Analyze git diff and serialize changes"
)]
struct Args {
#[arg(short = 'f', long = "format", value_enum, default_value_t = OutputFormat::Json, global = true)]
format: OutputFormat,
#[arg(long = "mode", value_enum, default_value_t = Mode::Diff, hide = true)]
mode: Mode,
#[arg(long = "lang", value_enum, default_value_t = LangOpt::Auto)]
lang: LangOpt,
#[arg(long = "direction", value_enum, default_value_t = DirectionOpt::Callers)]
direction: DirectionOpt,
#[arg(long = "max-depth")]
max_depth: Option<usize>,
#[arg(long = "with-edges", default_value_t = false)]
with_edges: bool,
#[arg(long = "ignore-dir")]
ignore_dir: Vec<String>,
#[arg(long = "engine", value_enum, default_value_t = EngineOpt::Auto)]
engine: EngineOpt,
#[arg(long = "engine-lsp-strict", default_value_t = false)]
engine_lsp_strict: bool,
#[arg(long = "engine-dump-capabilities", default_value_t = false)]
engine_dump_capabilities: bool,
#[arg(long = "seed-symbol")]
seed_symbols: Vec<String>,
#[arg(long = "seed-json")]
seed_json: Option<String>,
#[command(subcommand)]
cmd: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Diff,
Changed {
#[arg(long = "lang", value_enum, default_value_t = LangOpt::Auto)]
lang: LangOpt,
#[arg(long = "engine", value_enum, default_value_t = EngineOpt::Auto)]
engine: EngineOpt,
#[arg(long = "engine-lsp-strict", default_value_t = false)]
engine_lsp_strict: bool,
#[arg(long = "engine-dump-capabilities", default_value_t = false)]
engine_dump_capabilities: bool,
},
Impact {
#[arg(long = "lang", value_enum, default_value_t = LangOpt::Auto)]
lang: LangOpt,
#[arg(long = "direction", value_enum, default_value_t = DirectionOpt::Callers)]
direction: DirectionOpt,
#[arg(long = "max-depth")]
max_depth: Option<usize>,
#[arg(long = "with-edges", default_value_t = false)]
with_edges: bool,
#[arg(long = "with-pdg", default_value_t = false)]
with_pdg: bool,
#[arg(long = "with-propagation", default_value_t = false)]
with_propagation: bool,
#[arg(long = "engine", value_enum, default_value_t = EngineOpt::Auto)]
engine: EngineOpt,
#[arg(long = "engine-lsp-strict", default_value_t = false)]
engine_lsp_strict: bool,
#[arg(long = "engine-dump-capabilities", default_value_t = false)]
engine_dump_capabilities: bool,
#[arg(long = "seed-symbol")]
seed_symbols: Vec<String>,
#[arg(long = "seed-json")]
seed_json: Option<String>,
#[arg(long = "ignore-dir")]
ignore_dir: Vec<String>,
},
Id {
#[arg(long = "path")]
path: Option<String>,
#[arg(long = "line")]
line: Option<u32>,
#[arg(long = "name")]
name: Option<String>,
#[arg(long = "lang", value_enum, default_value_t = LangOpt::Auto)]
lang: LangOpt,
#[arg(long = "kind", value_enum)]
kind: Option<KindOpt>,
#[arg(long = "raw", default_value_t = false)]
raw: bool,
},
Cache {
#[command(subcommand)]
cmd: CacheCmd,
},
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
}
#[derive(Debug, Subcommand)]
enum CacheCmd {
Build {
#[arg(long = "scope", value_enum, default_value_t = CacheScopeOpt::Local)]
scope: CacheScopeOpt,
#[arg(long = "dir")]
dir: Option<String>,
},
#[command(alias = "verify")]
Update {
#[arg(long = "scope", value_enum, default_value_t = CacheScopeOpt::Local)]
scope: CacheScopeOpt,
#[arg(long = "dir")]
dir: Option<String>,
},
Stats {
#[arg(long = "scope", value_enum, default_value_t = CacheScopeOpt::Local)]
scope: CacheScopeOpt,
#[arg(long = "dir")]
dir: Option<String>,
},
Clear {
#[arg(long = "scope", value_enum, default_value_t = CacheScopeOpt::Local)]
scope: CacheScopeOpt,
#[arg(long = "dir")]
dir: Option<String>,
},
}
fn main() -> anyhow::Result<()> {
let _ = env_logger::Builder::from_env(Env::default().default_filter_or(""))
.format_timestamp(None)
.try_init();
if let Ok(j) = std::env::var("DIMPACT_JOBS")
&& let Ok(n) = j.parse::<usize>()
{
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(n)
.build_global();
}
let args = Args::parse();
if let Some(cmd) = args.cmd {
match cmd {
Command::Diff => run_diff(args.format),
Command::Changed {
lang,
engine,
engine_lsp_strict,
engine_dump_capabilities,
} => run_changed(
args.format,
lang,
engine,
engine_lsp_strict,
engine_dump_capabilities,
),
Command::Impact {
lang,
direction,
max_depth,
with_edges,
with_pdg,
with_propagation,
engine,
engine_lsp_strict,
engine_dump_capabilities,
seed_symbols,
seed_json,
ignore_dir,
} => run_impact(
args.format,
lang,
direction,
max_depth,
with_edges,
with_pdg,
with_propagation,
engine,
engine_lsp_strict,
engine_dump_capabilities,
seed_symbols,
seed_json,
ignore_dir,
),
Command::Id {
path,
line,
name,
lang,
kind,
raw,
} => run_id(
args.format,
path.as_deref(),
line,
name.as_deref(),
lang,
kind,
raw,
),
Command::Cache { cmd } => run_cache(cmd),
Command::Completions { shell } => run_completions(shell),
}?;
return Ok(());
}
match args.mode {
Mode::Diff => {
run_diff(args.format)?;
}
Mode::Changed => {
run_changed(
args.format,
args.lang,
args.engine,
args.engine_lsp_strict,
args.engine_dump_capabilities,
)?;
}
Mode::Impact => {
run_impact(
args.format,
args.lang,
args.direction,
args.max_depth,
args.with_edges,
false,
false,
args.engine,
args.engine_lsp_strict,
args.engine_dump_capabilities,
args.seed_symbols,
args.seed_json,
args.ignore_dir,
)?;
}
}
Ok(())
}
fn run_cache(cmd: CacheCmd) -> anyhow::Result<()> {
match cmd {
CacheCmd::Build { scope, dir } => {
let scope = match scope {
CacheScopeOpt::Local => dimpact::cache::CacheScope::Local,
CacheScopeOpt::Global => dimpact::cache::CacheScope::Global,
};
let path_override = dir.as_deref().map(std::path::Path::new);
let mut db = dimpact::cache::open(scope, path_override)?;
let st = dimpact::cache::build_all(&mut db.conn)?;
eprintln!(
"cache build: files={} symbols={} edges={}",
st.files, st.symbols, st.edges
);
}
CacheCmd::Update { scope, dir } => {
let scope = match scope {
CacheScopeOpt::Local => dimpact::cache::CacheScope::Local,
CacheScopeOpt::Global => dimpact::cache::CacheScope::Global,
};
let path_override = dir.as_deref().map(std::path::Path::new);
let mut db = dimpact::cache::open(scope, path_override)?;
let st_before = dimpact::cache::stats(&db.conn)?;
let st_after = dimpact::cache::verify(&mut db.conn)?;
eprintln!(
"cache update: files={} symbols={} edges={} (was files={} symbols={} edges={})",
st_after.files,
st_after.symbols,
st_after.edges,
st_before.files,
st_before.symbols,
st_before.edges
);
}
CacheCmd::Stats { scope, dir } => {
let scope = match scope {
CacheScopeOpt::Local => dimpact::cache::CacheScope::Local,
CacheScopeOpt::Global => dimpact::cache::CacheScope::Global,
};
let path_override = dir.as_deref().map(std::path::Path::new);
let db = dimpact::cache::open(scope, path_override)?;
let st = dimpact::cache::stats(&db.conn)?;
println!(
"{{\"files\":{},\"symbols\":{},\"edges\":{}}}",
st.files, st.symbols, st.edges
);
}
CacheCmd::Clear { scope, dir } => {
let scope = match scope {
CacheScopeOpt::Local => dimpact::cache::CacheScope::Local,
CacheScopeOpt::Global => dimpact::cache::CacheScope::Global,
};
let path_override = dir.as_deref().map(std::path::Path::new);
let paths = dimpact::cache::resolve_paths(scope, path_override, None)?;
dimpact::cache::clear(&paths)?;
eprintln!("cache cleared: {}", paths.db.display());
}
}
Ok(())
}
fn run_completions(shell: CompletionShell) -> anyhow::Result<()> {
use clap_complete::{generate, shells};
let mut cmd = Args::command();
let name = cmd.get_name().to_string();
match shell {
CompletionShell::Bash => generate(shells::Bash, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::Fish => generate(shells::Fish, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::PowerShell => {
generate(shells::PowerShell, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, name, &mut std::io::stdout()),
}
Ok(())
}
fn read_diff_from_stdin() -> anyhow::Result<String> {
if std::io::stdin().is_terminal() {
anyhow::bail!("no stdin detected: please pipe `git diff` output into dimpact");
}
let mut s = String::new();
io::stdin().read_to_string(&mut s)?;
Ok(s)
}
fn parse_seed_symbol(s: &str) -> anyhow::Result<dimpact::Symbol> {
let parts: Vec<&str> = s.splitn(5, ':').collect();
if parts.len() != 5 {
anyhow::bail!("invalid seed symbol format: {}", s);
}
let lang = parts[0];
let file = parts[1];
let kind_str = parts[2];
let name = parts[3];
let line: u32 = parts[4]
.parse()
.map_err(|_| anyhow::anyhow!("invalid LINE in seed symbol: {}", parts[4]))?;
let kind = match kind_str {
"fn" | "function" => dimpact::SymbolKind::Function,
"method" => dimpact::SymbolKind::Method,
"struct" => dimpact::SymbolKind::Struct,
"enum" => dimpact::SymbolKind::Enum,
"trait" => dimpact::SymbolKind::Trait,
"mod" | "module" => dimpact::SymbolKind::Module,
other => anyhow::bail!("unknown KIND in seed symbol: {}", other),
};
let id = dimpact::SymbolId::new(lang, file, &kind, name, line);
let sym = dimpact::Symbol {
id,
name: name.to_string(),
kind,
file: file.to_string(),
range: dimpact::TextRange {
start_line: line,
end_line: line,
},
language: lang.to_string(),
};
Ok(sym)
}
fn parse_seed_json_input(arg: &str) -> anyhow::Result<Vec<dimpact::Symbol>> {
let content = if arg == "-" {
let mut s = String::new();
io::stdin().read_to_string(&mut s)?;
s
} else if std::fs::metadata(arg).map(|m| m.is_file()).unwrap_or(false) {
std::fs::read_to_string(arg)?
} else {
arg.to_string()
};
parse_seed_json(&content)
}
fn parse_seed_json(content: &str) -> anyhow::Result<Vec<dimpact::Symbol>> {
let v: serde_json::Value = serde_json::from_str(content)
.map_err(|e| anyhow::anyhow!("failed to parse seed JSON: {}", e))?;
let arr = v
.as_array()
.ok_or_else(|| anyhow::anyhow!("seed JSON must be an array"))?;
let mut out: Vec<dimpact::Symbol> = Vec::with_capacity(arr.len());
for item in arr {
if let Some(s) = item.as_str() {
out.push(parse_seed_symbol(s)?);
continue;
}
if let Some(obj) = item.as_object() {
if let Some(serde_json::Value::String(id)) = obj.get("id") {
out.push(parse_seed_symbol(id)?);
continue;
}
let lang = obj
.get("lang")
.or_else(|| obj.get("language"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("seed object missing 'lang'"))?;
let file = obj
.get("path")
.or_else(|| obj.get("file"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("seed object missing 'path' or 'file'"))?;
let kind_str = obj
.get("kind")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("seed object missing 'kind'"))?;
let name = obj
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("seed object missing 'name'"))?;
let line = obj
.get("line")
.or_else(|| obj.get("start_line"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("seed object missing 'line' or 'start_line'"))?
as u32;
let kind = match kind_str {
"fn" | "function" => dimpact::SymbolKind::Function,
"method" => dimpact::SymbolKind::Method,
"struct" => dimpact::SymbolKind::Struct,
"enum" => dimpact::SymbolKind::Enum,
"trait" => dimpact::SymbolKind::Trait,
"mod" | "module" => dimpact::SymbolKind::Module,
other => anyhow::bail!("unknown KIND in seed object: {}", other),
};
let id = dimpact::SymbolId::new(lang, file, &kind, name, line);
out.push(dimpact::Symbol {
id,
name: name.to_string(),
kind,
file: file.to_string(),
range: dimpact::TextRange {
start_line: line,
end_line: line,
},
language: lang.to_string(),
});
continue;
}
anyhow::bail!("seed JSON elements must be strings or objects");
}
Ok(out)
}
fn run_diff(fmt: OutputFormat) -> anyhow::Result<()> {
let diff_text = read_diff_from_stdin()?;
let files = match parse_unified_diff(&diff_text) {
Ok(f) => f,
Err(DiffParseError::MissingHeader) => Vec::new(),
Err(e) => return Err(anyhow::anyhow!(e)),
};
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&files)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&files)?),
OutputFormat::Dot | OutputFormat::Html => {
anyhow::bail!("format not supported for 'diff': use json|yaml")
}
}
Ok(())
}
fn run_changed(
fmt: OutputFormat,
lang_opt: LangOpt,
engine_opt: EngineOpt,
lsp_strict: bool,
dump_caps: bool,
) -> anyhow::Result<()> {
let diff_text = read_diff_from_stdin()?;
let files = match parse_unified_diff(&diff_text) {
Ok(f) => f,
Err(DiffParseError::MissingHeader) => Vec::new(),
Err(e) => return Err(anyhow::anyhow!(e)),
};
let lang = match lang_opt {
LangOpt::Auto => LanguageMode::Auto,
LangOpt::Rust => LanguageMode::Rust,
LangOpt::Ruby => LanguageMode::Ruby,
LangOpt::Javascript => LanguageMode::Javascript,
LangOpt::Typescript => LanguageMode::Typescript,
LangOpt::Tsx => LanguageMode::Tsx,
};
let ekind = match engine_opt {
EngineOpt::Auto => EngineKind::Auto,
EngineOpt::Ts => EngineKind::Ts,
EngineOpt::Lsp => EngineKind::Lsp,
};
let ecfg = EngineConfig {
lsp_strict,
dump_capabilities: dump_caps,
mock_lsp: std::env::var("DIMPACT_TEST_LSP_MOCK").ok().as_deref() == Some("1"),
mock_caps: None,
};
let engine = make_engine(ekind, ecfg);
if dump_caps && !matches!(engine_opt, EngineOpt::Lsp) {
eprintln!(
"{}",
serde_json::json!({
"document_symbol": false,
"workspace_symbol": false,
"references": false,
"definition": false,
"call_hierarchy": false,
})
);
}
log::info!(
"mode=changed engine={:?} files={} lang={:?}",
ekind,
files.len(),
lang
);
let report: ChangedOutput = engine.changed_symbols(&files, lang)?;
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&report)?),
OutputFormat::Dot | OutputFormat::Html => {
anyhow::bail!("format not supported for 'changed': use json|yaml")
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_impact(
fmt: OutputFormat,
lang_opt: LangOpt,
dir_opt: DirectionOpt,
max_depth: Option<usize>,
with_edges: bool,
with_pdg: bool,
with_propagation: bool,
engine_opt: EngineOpt,
lsp_strict: bool,
dump_caps: bool,
seed_symbols: Vec<String>,
seed_json: Option<String>,
ignore_dir: Vec<String>,
) -> anyhow::Result<()> {
let mut seeds: Vec<dimpact::Symbol> = Vec::new();
if let Some(sj) = seed_json.as_ref() {
let mut from_json = parse_seed_json_input(sj)?;
seeds.append(&mut from_json);
}
if !seed_symbols.is_empty() {
for s in &seed_symbols {
seeds.push(parse_seed_symbol(s)?);
}
}
let lang: LanguageMode = if !seeds.is_empty() {
let mut langs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for s in &seeds {
langs.insert(s.language.to_ascii_lowercase());
}
if langs.len() > 1 {
anyhow::bail!("mixed seed languages not supported: {:?}", langs);
}
let seed_lang = langs
.iter()
.next()
.cloned()
.unwrap_or_else(|| "auto".to_string());
lang_mode_from_str(&seed_lang)
.ok_or_else(|| anyhow::anyhow!("unknown seed language: {}", seed_lang))?
} else {
match lang_opt {
LangOpt::Auto => LanguageMode::Auto,
LangOpt::Rust => LanguageMode::Rust,
LangOpt::Ruby => LanguageMode::Ruby,
LangOpt::Javascript => LanguageMode::Javascript,
LangOpt::Typescript => LanguageMode::Typescript,
LangOpt::Tsx => LanguageMode::Tsx,
}
};
let direction = match dir_opt {
DirectionOpt::Callers => ImpactDirection::Callers,
DirectionOpt::Callees => ImpactDirection::Callees,
DirectionOpt::Both => ImpactDirection::Both,
};
let opts = ImpactOptions {
direction,
max_depth: max_depth.or(Some(100)),
with_edges: Some(with_edges),
ignore_dirs: ignore_dir.clone(),
};
let ekind = match engine_opt {
EngineOpt::Auto => EngineKind::Auto,
EngineOpt::Ts => EngineKind::Ts,
EngineOpt::Lsp => EngineKind::Lsp,
};
let ecfg = EngineConfig {
lsp_strict,
dump_capabilities: dump_caps,
mock_lsp: std::env::var("DIMPACT_TEST_LSP_MOCK").ok().as_deref() == Some("1"),
mock_caps: None,
};
let engine = make_engine(ekind, ecfg);
if dump_caps && !matches!(engine_opt, EngineOpt::Lsp) {
eprintln!(
"{}",
serde_json::json!({
"document_symbol": false,
"workspace_symbol": false,
"references": false,
"definition": false,
"call_hierarchy": false,
})
);
}
if seeds.is_empty() {
let diff_text = read_diff_from_stdin()?;
let files = match parse_unified_diff(&diff_text) {
Ok(f) => f,
Err(DiffParseError::MissingHeader) => Vec::new(),
Err(e) => return Err(anyhow::anyhow!(e)),
};
log::info!(
"mode=impact(diff) engine={:?} files={} lang={:?} dir={:?} max_depth={:?} with_edges={} pdg={} ignore_dirs={:?}",
ekind,
files.len(),
lang,
direction,
opts.max_depth,
with_edges,
with_pdg,
opts.ignore_dirs
);
if with_pdg || with_propagation {
let changed: ChangedOutput = compute_changed_symbols(&files, lang)?;
let (scope, dir_override) = cache::scope_from_env();
let mut db = cache::open(scope, dir_override.as_deref())?;
let st = cache::stats(&db.conn)?;
if st.symbols == 0 {
cache::build_all(&mut db.conn)?;
}
if !changed.changed_files.is_empty() {
cache::update_paths(&mut db.conn, &changed.changed_files)?;
}
let (index, refs) = cache::load_graph(&db.conn)?;
let mut combined = DataFlowGraph {
nodes: Vec::new(),
edges: Vec::new(),
};
for path in &changed.changed_files {
if path.ends_with(".rs") {
if let Ok(src) = fs::read_to_string(path) {
let dfg = RustDfgBuilder::build(path, &src);
combined.nodes.extend(dfg.nodes);
combined.edges.extend(dfg.edges);
}
} else if path.ends_with(".rb")
&& let Ok(src) = fs::read_to_string(path)
{
let dfg = RubyDfgBuilder::build(path, &src);
combined.nodes.extend(dfg.nodes);
combined.edges.extend(dfg.edges);
}
}
let mut pdg = PdgBuilder::build(&combined, &refs);
if with_propagation {
PdgBuilder::augment_symbolic_propagation(&mut pdg, &refs, &index);
}
if matches!(fmt, OutputFormat::Dot) {
println!("{}", dfg_to_dot(&pdg));
return Ok(());
}
let pdg_refs: Vec<Reference> = pdg
.edges
.into_iter()
.map(|e| Reference {
from: SymbolId(e.from),
to: SymbolId(e.to),
kind: RefKind::Call,
file: String::new(),
line: 0,
})
.collect();
let out: ImpactOutput =
compute_impact(&changed.changed_symbols, &index, &pdg_refs, &opts);
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&out)?),
OutputFormat::Dot => println!("{}", dimpact::to_dot(&out)),
OutputFormat::Html => println!("{}", dimpact::to_html(&out)),
}
return Ok(());
}
let out: ImpactOutput = engine.impact(&files, lang, &opts)?;
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&out)?),
OutputFormat::Dot => println!("{}", dimpact::to_dot(&out)),
OutputFormat::Html => println!("{}", dimpact::to_html(&out)),
}
return Ok(());
}
log::info!(
"mode=impact(seeds) engine={:?} seeds={} lang={:?} dir={:?} max_depth={:?} with_edges={} ignore_dirs={:?}",
ekind,
seeds.len(),
lang,
direction,
opts.max_depth,
with_edges,
opts.ignore_dirs
);
if with_pdg || with_propagation {
let (scope, dir_override) = cache::scope_from_env();
let mut db = cache::open(scope, dir_override.as_deref())?;
let st = cache::stats(&db.conn)?;
if st.symbols == 0 {
cache::build_all(&mut db.conn)?;
}
let (index, refs) = cache::load_graph(&db.conn)?;
let mut combined = DataFlowGraph {
nodes: Vec::new(),
edges: Vec::new(),
};
let mut fileset: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for s in &seeds {
fileset.insert(s.file.clone());
}
for path in fileset {
if path.ends_with(".rs") {
if let Ok(src) = fs::read_to_string(&path) {
let dfg = RustDfgBuilder::build(&path, &src);
combined.nodes.extend(dfg.nodes);
combined.edges.extend(dfg.edges);
}
} else if path.ends_with(".rb")
&& let Ok(src) = fs::read_to_string(&path)
{
let dfg = RubyDfgBuilder::build(&path, &src);
combined.nodes.extend(dfg.nodes);
combined.edges.extend(dfg.edges);
}
}
let mut pdg = PdgBuilder::build(&combined, &refs);
if with_propagation {
PdgBuilder::augment_symbolic_propagation(&mut pdg, &refs, &index);
}
let pdg_refs: Vec<Reference> = pdg
.edges
.into_iter()
.map(|e| Reference {
from: SymbolId(e.from),
to: SymbolId(e.to),
kind: RefKind::Call,
file: String::new(),
line: 0,
})
.collect();
let out: ImpactOutput = compute_impact(&seeds, &index, &pdg_refs, &opts);
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&out)?),
OutputFormat::Dot => println!("{}", dimpact::to_dot(&out)),
OutputFormat::Html => println!("{}", dimpact::to_html(&out)),
}
return Ok(());
}
let out: ImpactOutput = engine.impact_from_symbols(&seeds, lang, &opts)?;
match fmt {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&out)?),
OutputFormat::Dot => println!("{}", dimpact::to_dot(&out)),
OutputFormat::Html => println!("{}", dimpact::to_html(&out)),
}
Ok(())
}
fn lang_mode_from_str(s: &str) -> Option<LanguageMode> {
match s.to_ascii_lowercase().as_str() {
"rust" => Some(LanguageMode::Rust),
"ruby" => Some(LanguageMode::Ruby),
"javascript" | "js" => Some(LanguageMode::Javascript),
"typescript" | "ts" => Some(LanguageMode::Typescript),
"tsx" => Some(LanguageMode::Tsx),
"auto" => Some(LanguageMode::Auto),
_ => None,
}
}
fn run_id(
fmt: OutputFormat,
path: Option<&str>,
line: Option<u32>,
name: Option<&str>,
lang_opt: LangOpt,
kind_opt: Option<KindOpt>,
raw: bool,
) -> anyhow::Result<()> {
if line.is_some() && path.is_none() {
anyhow::bail!("--line requires --path (cannot use line without file context)");
}
let files = collect_candidate_files(path, lang_opt)?;
let mut all_syms: Vec<dimpact::Symbol> = Vec::new();
for fp in &files {
let lkind = match lang_opt {
LangOpt::Auto => dimpact::LanguageKind::Auto,
LangOpt::Rust => dimpact::LanguageKind::Rust,
LangOpt::Ruby => dimpact::LanguageKind::Ruby,
LangOpt::Javascript => dimpact::LanguageKind::Javascript,
LangOpt::Typescript => dimpact::LanguageKind::Typescript,
LangOpt::Tsx => dimpact::LanguageKind::Tsx,
};
let Some(analyzer) = dimpact::languages::analyzer_for_path(fp, lkind) else {
continue;
};
let Ok(source) = fs::read_to_string(fp) else {
continue;
};
let mut syms = analyzer.symbols_in_file(fp, &source);
all_syms.append(&mut syms);
}
if all_syms.is_empty() {
anyhow::bail!("no symbols found in search scope");
}
let mut current: Vec<dimpact::Symbol> = all_syms.clone();
if let Some(p) = path {
let subset: Vec<_> = current.iter().filter(|s| s.file == p).cloned().collect();
if !subset.is_empty() {
current = subset;
} else {
current = all_syms.clone();
}
}
if let Some(ln) = line {
let subset: Vec<_> = current
.iter()
.filter(|s| s.range.start_line <= ln && ln <= s.range.end_line)
.cloned()
.collect();
if !subset.is_empty() {
current = subset;
}
}
if let Some(nm) = name {
let subset: Vec<_> = current.iter().filter(|s| s.name == nm).cloned().collect();
if !subset.is_empty() {
current = subset;
}
}
if let Some(kopt) = kind_opt {
let want = map_kind_opt(kopt);
let subset: Vec<_> = current.iter().filter(|s| s.kind == want).cloned().collect();
if !subset.is_empty() {
current = subset;
}
}
if current.is_empty() {
anyhow::bail!("no matching symbol candidates");
}
let mut sorted = current;
sorted.sort_by_key(|s| (s.range.end_line - s.range.start_line, key_of_kind(&s.kind)));
if raw {
for s in &sorted {
println!("{}", s.id.0);
}
return Ok(());
}
match fmt {
OutputFormat::Json => {
let items: Vec<serde_json::Value> = sorted
.iter()
.map(|s| {
serde_json::json!({
"id": s.id.0,
"symbol": s,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
}
OutputFormat::Yaml => {
print!("{}", serde_yaml::to_string(&sorted)?);
}
OutputFormat::Dot | OutputFormat::Html => {
anyhow::bail!("format not supported for 'id': use json|yaml or --raw")
}
}
Ok(())
}
fn collect_candidate_files(path: Option<&str>, lang_opt: LangOpt) -> anyhow::Result<Vec<String>> {
if let Some(p) = path {
let md = fs::metadata(p);
if md.as_ref().map(|m| m.is_file()).unwrap_or(false) {
return Ok(vec![p.to_string()]);
} else {
anyhow::bail!("path is not a file: {}", p);
}
}
let mut out = Vec::new();
let exts = match lang_opt {
LangOpt::Auto => vec!["rs", "rb", "js", "ts", "tsx"],
LangOpt::Rust => vec!["rs"],
LangOpt::Ruby => vec!["rb"],
LangOpt::Javascript => vec!["js"],
LangOpt::Typescript => vec!["ts"],
LangOpt::Tsx => vec!["tsx"],
};
let root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
scan_dir(&root, &exts, &mut out)?;
Ok(out)
}
fn scan_dir(dir: &std::path::Path, exts: &[&str], out: &mut Vec<String>) -> anyhow::Result<()> {
if let Some(name) = dir.file_name().and_then(|s| s.to_str())
&& [".git", "target", "node_modules"].contains(&name)
{
return Ok(());
}
let rd = match fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return Ok(()),
};
for ent in rd {
let ent = match ent {
Ok(e) => e,
Err(_) => continue,
};
let p = ent.path();
let Ok(ft) = ent.file_type() else { continue };
if ft.is_dir() {
scan_dir(&p, exts, out)?;
continue;
}
if !ft.is_file() {
continue;
}
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or("");
if exts.contains(&ext) {
out.push(p.to_string_lossy().to_string());
}
}
Ok(())
}
#[allow(dead_code)]
fn choose_most_specific(mut v: Vec<dimpact::Symbol>) -> dimpact::Symbol {
v.sort_by_key(|s| (s.range.end_line - s.range.start_line, key_of_kind(&s.kind)));
v.into_iter().next().unwrap()
}
fn key_of_kind(k: &dimpact::SymbolKind) -> u8 {
match k {
dimpact::SymbolKind::Method => 0,
dimpact::SymbolKind::Function => 1,
dimpact::SymbolKind::Struct => 2,
dimpact::SymbolKind::Enum => 3,
dimpact::SymbolKind::Trait => 4,
dimpact::SymbolKind::Module => 5,
}
}
fn map_kind_opt(k: KindOpt) -> dimpact::SymbolKind {
match k {
KindOpt::Fn => dimpact::SymbolKind::Function,
KindOpt::Method => dimpact::SymbolKind::Method,
KindOpt::Struct => dimpact::SymbolKind::Struct,
KindOpt::Enum => dimpact::SymbolKind::Enum,
KindOpt::Trait => dimpact::SymbolKind::Trait,
KindOpt::Mod => dimpact::SymbolKind::Module,
}
}
#[allow(dead_code)]
fn impact_from_diff(args: Args, files: Vec<dimpact::FileChanges>) -> anyhow::Result<()> {
let lang = match args.lang {
LangOpt::Auto => LanguageMode::Auto,
LangOpt::Rust => LanguageMode::Rust,
LangOpt::Ruby => LanguageMode::Ruby,
LangOpt::Javascript => LanguageMode::Javascript,
LangOpt::Typescript => LanguageMode::Typescript,
LangOpt::Tsx => LanguageMode::Tsx,
};
let direction = match args.direction {
DirectionOpt::Callers => ImpactDirection::Callers,
DirectionOpt::Callees => ImpactDirection::Callees,
DirectionOpt::Both => ImpactDirection::Both,
};
let opts = ImpactOptions {
direction,
max_depth: args.max_depth.or(Some(100)),
with_edges: Some(args.with_edges),
ignore_dirs: args.ignore_dir.clone(),
};
let ekind = match args.engine {
EngineOpt::Auto => EngineKind::Auto,
EngineOpt::Ts => EngineKind::Ts,
EngineOpt::Lsp => EngineKind::Lsp,
};
let ecfg = EngineConfig {
lsp_strict: args.engine_lsp_strict,
dump_capabilities: args.engine_dump_capabilities,
mock_lsp: false,
mock_caps: None,
};
let engine = make_engine(ekind, ecfg);
log::info!(
"mode=impact(diff) engine={:?} files={} lang={:?} dir={:?} max_depth={:?} with_edges={}",
ekind,
files.len(),
lang,
direction,
opts.max_depth,
args.with_edges
);
let out: ImpactOutput = engine.impact(&files, lang, &opts)?;
match args.format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&out)?),
OutputFormat::Yaml => print!("{}", serde_yaml::to_string(&out)?),
OutputFormat::Dot => println!("{}", dimpact::to_dot(&out)),
OutputFormat::Html => println!("{}", dimpact::to_html(&out)),
}
Ok(())
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CompletionShell {
Bash,
Zsh,
Fish,
PowerShell,
Elvish,
}