mod assets;
mod changes;
mod classify;
mod compress;
mod config;
mod delta;
mod extract;
mod fields;
mod filter;
mod localization;
mod mcp;
mod progress;
mod query;
mod recall;
mod render;
mod rust_plugin;
mod search;
mod serve;
mod snippet;
mod store;
mod watch;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{Context, anyhow};
use clap::{Parser, Subcommand, ValueEnum};
use serde::Serialize;
use store::Store;
#[derive(Parser)]
#[command(
name = "grapha",
version,
about = "Structural code graph for LLM consumption"
)]
struct Cli {
#[arg(long, global = true, value_enum, default_value_t = ColorMode::Auto)]
color: ColorMode,
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum ColorMode {
Auto,
Always,
Never,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum QueryOutputFormat {
Json,
Tree,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum TraceDirection {
Forward,
Reverse,
}
#[derive(Subcommand)]
enum Commands {
Analyze {
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
compact: bool,
},
Index {
path: PathBuf,
#[arg(long, default_value = "sqlite")]
format: String,
#[arg(long)]
store_dir: Option<PathBuf>,
#[arg(long)]
full_rebuild: bool,
#[arg(long)]
timing: bool,
},
Serve {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, default_value = "8080")]
port: u16,
#[arg(long)]
mcp: bool,
#[arg(long)]
watch: bool,
},
Symbol {
#[command(subcommand)]
command: SymbolCommands,
},
Flow {
#[command(subcommand)]
command: FlowCommands,
},
#[command(name = "l10n")]
L10n {
#[command(subcommand)]
command: L10nCommands,
},
Asset {
#[command(subcommand)]
command: AssetCommands,
},
Repo {
#[command(subcommand)]
command: RepoCommands,
},
}
#[derive(Subcommand)]
enum SymbolCommands {
Search {
query: String,
#[arg(long, default_value = "20")]
limit: usize,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
module: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(long)]
role: Option<String>,
#[arg(long)]
fuzzy: bool,
#[arg(long)]
context: bool,
#[arg(long)]
fields: Option<String>,
},
Context {
symbol: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
#[arg(long)]
fields: Option<String>,
},
Impact {
symbol: String,
#[arg(long, default_value = "3")]
depth: usize,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
#[arg(long)]
fields: Option<String>,
},
Complexity {
symbol: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
File {
file: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
}
#[derive(Subcommand)]
enum FlowCommands {
Trace {
symbol: String,
#[arg(long, value_enum, default_value_t = TraceDirection::Forward)]
direction: TraceDirection,
#[arg(long)]
depth: Option<usize>,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
Graph {
symbol: String,
#[arg(long, default_value = "10")]
depth: usize,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
Entries {
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
}
#[derive(Subcommand)]
enum L10nCommands {
Symbol {
symbol: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
Usages {
key: String,
#[arg(long)]
table: Option<String>,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
}
#[derive(Subcommand)]
enum AssetCommands {
List {
#[arg(long)]
unused: bool,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Usages {
name: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(long, value_enum, default_value_t = QueryOutputFormat::Json)]
format: QueryOutputFormat,
},
}
#[derive(Subcommand)]
enum RepoCommands {
Changes {
#[arg(default_value = "all")]
scope: String,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Map {
#[arg(long)]
module: Option<String>,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Smells {
#[arg(long)]
module: Option<String>,
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
Modules {
#[arg(short, long, default_value = ".")]
path: PathBuf,
},
}
fn builtin_registry() -> anyhow::Result<grapha_core::LanguageRegistry> {
let mut registry = grapha_core::LanguageRegistry::new();
rust_plugin::register_builtin(&mut registry)?;
grapha_swift::register_builtin(&mut registry)?;
Ok(registry)
}
fn run_pipeline(
path: &Path,
verbose: bool,
timing: bool,
) -> anyhow::Result<grapha_core::graph::Graph> {
let t = Instant::now();
let registry = builtin_registry()?;
let project_context = grapha_core::project_context(path);
let cfg = config::load_config(path);
if !cfg.swift.index_store {
unsafe { std::env::set_var("GRAPHA_SKIP_INDEX_STORE", "1") };
}
let (files, _) = std::thread::scope(|scope| {
let files_handle = scope.spawn(|| {
grapha_core::pipeline::discover_files(path, ®istry)
.context("failed to discover files")
});
let plugin_handle =
scope.spawn(|| grapha_core::prepare_plugins(®istry, &project_context));
let files = files_handle.join().expect("discover thread panicked")?;
plugin_handle.join().expect("plugin thread panicked")?;
Ok::<_, anyhow::Error>((files, ()))
})?;
let mut external_files: Vec<PathBuf> = Vec::new();
let mut external_repo_count = 0usize;
for ext in &cfg.external {
let ext_path = Path::new(&ext.path);
if !ext_path.exists() {
if verbose {
eprintln!(
" \x1b[33m!\x1b[0m external repo '{}' not found at {}, skipping",
ext.name, ext.path
);
}
continue;
}
match grapha_core::pipeline::discover_files(ext_path, ®istry) {
Ok(ext_discovered) => {
external_files.extend(ext_discovered);
external_repo_count += 1;
}
Err(e) => {
if verbose {
eprintln!(
" \x1b[33m!\x1b[0m failed to discover files in '{}': {e}",
ext.name
);
}
}
}
}
let external_file_count = external_files.len();
let all_files: Vec<PathBuf> = files.into_iter().chain(external_files).collect();
if verbose {
let msg = if external_file_count > 0 {
format!(
"discovered {} files + {} external ({} repos)",
all_files.len() - external_file_count,
external_file_count,
external_repo_count
)
} else {
format!("discovered {} files", all_files.len())
};
progress::done(&msg, t);
if let Some(store) = grapha_swift::index_store_path(&project_context.project_root) {
progress::done(&format!("index store: {}", store.display()), t);
}
}
let mut module_map = grapha_core::discover_modules(®istry, &project_context)?;
for ext in &cfg.external {
let ext_path = Path::new(&ext.path);
if !ext_path.exists() {
continue;
}
let ext_context = grapha_core::project_context(ext_path);
if let Ok(ext_modules) = grapha_core::discover_modules(®istry, &ext_context) {
module_map.merge(ext_modules);
}
}
let t = Instant::now();
let pb = if verbose && all_files.len() > 1 {
Some(progress::bar(all_files.len() as u64, "extracting"))
} else {
None
};
use rayon::prelude::*;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
let skipped = AtomicUsize::new(0);
let t_read_ns = AtomicU64::new(0);
let t_extract_ns = AtomicU64::new(0);
let t_snippet_ns = AtomicU64::new(0);
let results: Vec<_> = all_files
.par_iter()
.filter_map(|file| {
let t0 = Instant::now();
let source = match std::fs::read(file) {
Ok(s) => s,
Err(_) => {
skipped.fetch_add(1, Ordering::Relaxed);
if let Some(ref pb) = pb {
pb.inc(1);
}
return None;
}
};
t_read_ns.fetch_add(t0.elapsed().as_nanos() as u64, Ordering::Relaxed);
let t1 = Instant::now();
let file_context = grapha_core::file_context(&project_context, &module_map, file);
let extraction_result =
grapha_core::extract_with_registry(®istry, &source, &file_context);
t_extract_ns.fetch_add(t1.elapsed().as_nanos() as u64, Ordering::Relaxed);
if let Some(ref pb) = pb {
pb.inc(1);
}
match extraction_result {
Ok(mut result) => {
let t2 = Instant::now();
if result
.nodes
.iter()
.any(|n| snippet::should_extract_snippet(n.kind))
{
let source_str: std::borrow::Cow<'_, str> =
match std::str::from_utf8(&source) {
Ok(s) => std::borrow::Cow::Borrowed(s),
Err(_) => String::from_utf8_lossy(&source),
};
let line_idx = snippet::LineIndex::new(&source_str);
for node in &mut result.nodes {
if snippet::should_extract_snippet(node.kind) {
node.snippet = line_idx.extract_snippet(&node.span, 600);
}
}
}
t_snippet_ns.fetch_add(t2.elapsed().as_nanos() as u64, Ordering::Relaxed);
Some(result)
}
Err(e) => {
skipped.fetch_add(1, Ordering::Relaxed);
if verbose && let Some(ref pb) = pb {
pb.suspend(|| {
eprintln!(" \x1b[33m!\x1b[0m skipping {}: {e}", file.display())
});
}
None
}
}
})
.collect();
let skipped = skipped.load(Ordering::Relaxed);
if let Some(pb) = pb {
pb.finish_and_clear();
}
if timing {
let read_ms = t_read_ns.load(Ordering::Relaxed) as f64 / 1_000_000.0;
let extract_ms = t_extract_ns.load(Ordering::Relaxed) as f64 / 1_000_000.0;
let snippet_ms = t_snippet_ns.load(Ordering::Relaxed) as f64 / 1_000_000.0;
let is_ms = grapha_swift::TIMING_INDEXSTORE_NS.load(std::sync::atomic::Ordering::Relaxed)
as f64
/ 1_000_000.0;
let ts_parse_ms = grapha_swift::TIMING_TS_PARSE_NS
.load(std::sync::atomic::Ordering::Relaxed) as f64
/ 1_000_000.0;
let doc_ms = grapha_swift::TIMING_TS_DOC_NS.load(std::sync::atomic::Ordering::Relaxed)
as f64
/ 1_000_000.0;
let swiftui_ms = grapha_swift::TIMING_TS_SWIFTUI_NS
.load(std::sync::atomic::Ordering::Relaxed) as f64
/ 1_000_000.0;
let l10n_ms = grapha_swift::TIMING_TS_L10N_NS.load(std::sync::atomic::Ordering::Relaxed)
as f64
/ 1_000_000.0;
let asset_ms = grapha_swift::TIMING_TS_ASSET_NS.load(std::sync::atomic::Ordering::Relaxed)
as f64
/ 1_000_000.0;
let ss_ms = grapha_swift::TIMING_SWIFTSYNTAX_NS.load(std::sync::atomic::Ordering::Relaxed)
as f64
/ 1_000_000.0;
let ts_fb_ms = grapha_swift::TIMING_TS_FALLBACK_NS
.load(std::sync::atomic::Ordering::Relaxed) as f64
/ 1_000_000.0;
eprintln!(
" thread-summed: read {:.0}ms, extract {:.0}ms, snippet {:.0}ms",
read_ms, extract_ms, snippet_ms
);
eprintln!(
" swift: indexstore {:.0}ms, ts-parse {:.0}ms, doc {:.0}ms, swiftui {:.0}ms, l10n {:.0}ms, asset {:.0}ms, swiftsyntax {:.0}ms, ts-fallback {:.0}ms",
is_ms, ts_parse_ms, doc_ms, swiftui_ms, l10n_ms, asset_ms, ss_ms, ts_fb_ms
);
}
if verbose {
let msg = if skipped > 0 {
format!("extracted {} files ({} skipped)", results.len(), skipped)
} else {
format!("extracted {} files", results.len())
};
progress::done(&msg, t);
}
let mut classifiers = registry.collect_classifiers();
classifiers.insert(
0,
Box::new(classify::toml_rules::TomlRulesClassifier::new(
&cfg.classifiers,
)),
);
let composite = grapha_core::CompositeClassifier::new(classifiers);
let preclassified_results: Vec<_> = results
.into_iter()
.map(|result| grapha_core::classify_extraction_result(result, &composite))
.collect();
let t = Instant::now();
let merged = grapha_core::merge(preclassified_results);
if verbose {
progress::done(
&format!(
"merged → {} nodes, {} edges",
merged.nodes.len(),
merged.edges.len()
),
t,
);
}
let t = Instant::now();
let mut graph = grapha_core::classify_graph(&merged, &composite);
for pass in registry.collect_graph_passes() {
graph = pass.apply(graph);
}
let graph = grapha_core::normalize_graph(graph);
if verbose {
let terminal_count = graph
.nodes
.iter()
.filter(|n| matches!(n.role, Some(grapha_core::graph::NodeRole::Terminal { .. })))
.count();
let entry_count = graph
.nodes
.iter()
.filter(|n| matches!(n.role, Some(grapha_core::graph::NodeRole::EntryPoint)))
.count();
progress::done(
&format!(
"classified → {} entries, {} terminals",
entry_count, terminal_count
),
t,
);
}
Ok(graph)
}
fn load_graph(path: &Path) -> anyhow::Result<grapha_core::graph::Graph> {
let db_path = path.join(".grapha/grapha.db");
let s = store::sqlite::SqliteStore::new(db_path);
s.load()
.context("no index found — run `grapha index` first")
}
fn store_file_path(format: &str, store_path: &Path) -> anyhow::Result<PathBuf> {
match format {
"json" => Ok(store_path.join("graph.json")),
"sqlite" => Ok(store_path.join("grapha.db")),
other => Err(anyhow!("unknown store format: {other}")),
}
}
fn build_store(format: &str, store_path: &Path) -> anyhow::Result<Box<dyn store::Store + Send>> {
Ok(match format {
"json" => Box::new(store::json::JsonStore::new(store_path.join("graph.json"))),
"sqlite" => Box::new(store::sqlite::SqliteStore::new(
store_path.join("grapha.db"),
)),
other => anyhow::bail!("unknown store format: {other}"),
})
}
fn load_existing_graph(
format: &str,
store_path: &Path,
) -> anyhow::Result<Option<grapha_core::graph::Graph>> {
let store_file = store_file_path(format, store_path)?;
if !store_file.exists() {
return Ok(None);
}
let store = build_store(format, store_path)?;
match store.load() {
Ok(graph) => Ok(Some(graph)),
Err(error) => {
eprintln!(
" \x1b[33m!\x1b[0m failed to load existing store, falling back to full rebuild: {error}"
);
Ok(None)
}
}
}
fn kind_label(kind: grapha_core::graph::NodeKind) -> String {
serde_json::to_string(&kind)
.unwrap_or_else(|_| format!("{kind:?}"))
.trim_matches('"')
.to_string()
}
fn format_ambiguity_error(query: &str, candidates: &[query::QueryCandidate]) -> String {
let mut message = format!("ambiguous query: {query}\n");
for candidate in candidates {
message.push_str(&format!(
" - {} [{}] in {} ({})\n",
candidate.name,
kind_label(candidate.kind),
candidate.file,
candidate.id
));
}
message.push_str(&format!("hint: {}", query::ambiguity_hint()));
message
}
fn resolve_query_result<T>(
result: Result<T, query::QueryResolveError>,
missing_label: &str,
) -> anyhow::Result<T> {
match result {
Ok(value) => Ok(value),
Err(query::QueryResolveError::NotFound { query }) => {
Err(anyhow!("{missing_label} not found: {query}"))
}
Err(query::QueryResolveError::Ambiguous { query, candidates }) => {
Err(anyhow!(format_ambiguity_error(&query, &candidates)))
}
Err(query::QueryResolveError::NotFunction { hint }) => Err(anyhow!(hint)),
}
}
fn resolve_field_set(fields_flag: &Option<String>, path: &Path) -> fields::FieldSet {
match fields_flag {
Some(f) => fields::FieldSet::parse(f),
None => {
let cfg = config::load_config(path);
if cfg.output.default_fields.is_empty() {
fields::FieldSet::default()
} else {
fields::FieldSet::from_config(&cfg.output.default_fields)
}
}
}
}
fn tree_render_options(color: ColorMode) -> render::RenderOptions {
use std::io::IsTerminal;
match color {
ColorMode::Always => render::RenderOptions::color(),
ColorMode::Never => render::RenderOptions::plain(),
ColorMode::Auto => {
if std::io::stdout().is_terminal() {
render::RenderOptions::color()
} else {
render::RenderOptions::plain()
}
}
}
}
fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn print_query_result<T, R>(
result: &T,
format: QueryOutputFormat,
render_options: render::RenderOptions,
tree_renderer: R,
) -> anyhow::Result<()>
where
T: Serialize,
R: FnOnce(&T, render::RenderOptions) -> String,
{
match format {
QueryOutputFormat::Json => print_json(result),
QueryOutputFormat::Tree => {
println!("{}", tree_renderer(result, render_options));
Ok(())
}
}
}
fn handle_resolved_graph_query<T, Q, R>(
path: &Path,
format: QueryOutputFormat,
render_options: render::RenderOptions,
missing_label: &str,
query_fn: Q,
tree_renderer: R,
) -> anyhow::Result<()>
where
T: Serialize,
Q: FnOnce(&grapha_core::graph::Graph) -> Result<T, query::QueryResolveError>,
R: FnOnce(&T, render::RenderOptions) -> String,
{
let graph = load_graph(path)?;
let result = resolve_query_result(query_fn(&graph), missing_label)?;
print_query_result(&result, format, render_options, tree_renderer)
}
fn handle_graph_query<T, Q, R>(
path: &Path,
format: QueryOutputFormat,
render_options: render::RenderOptions,
query_fn: Q,
tree_renderer: R,
) -> anyhow::Result<()>
where
T: Serialize,
Q: FnOnce(&grapha_core::graph::Graph) -> T,
R: FnOnce(&T, render::RenderOptions) -> String,
{
let graph = load_graph(path)?;
let result = query_fn(&graph);
print_query_result(&result, format, render_options, tree_renderer)
}
fn open_search_index(path: &Path) -> anyhow::Result<tantivy::Index> {
let search_index_path = path.join(".grapha/search_index");
if search_index_path.exists() {
Ok(tantivy::Index::open_in_dir(&search_index_path)?)
} else {
let graph = load_graph(path)?;
eprintln!(" building search index...");
Ok(search::build_index(&graph, &search_index_path)?)
}
}
fn handle_analyze(
path: PathBuf,
output: Option<PathBuf>,
filter: Option<String>,
compact: bool,
) -> anyhow::Result<()> {
let verbose = output.is_some();
let mut graph = run_pipeline(&path, verbose, false)?;
if let Some(ref filter_str) = filter {
let kinds = filter::parse_filter(filter_str)?;
graph = filter::filter_graph(graph, &kinds);
}
let json = if compact {
let pruned = compress::prune::prune(graph, false);
let grouped = compress::group::group(&pruned);
match &output {
Some(_) => serde_json::to_string(&grouped)?,
None => serde_json::to_string_pretty(&grouped)?,
}
} else {
match &output {
Some(_) => serde_json::to_string(&graph)?,
None => serde_json::to_string_pretty(&graph)?,
}
};
match output {
Some(p) => {
std::fs::write(&p, &json)
.with_context(|| format!("failed to write {}", p.display()))?;
eprintln!(" \x1b[32m✓\x1b[0m wrote {}", p.display());
}
None => println!("{json}"),
}
Ok(())
}
fn handle_index(
path: PathBuf,
format: String,
store_dir: Option<PathBuf>,
full_rebuild: bool,
timing: bool,
) -> anyhow::Result<()> {
let total_start = Instant::now();
let store_path = store_dir.unwrap_or_else(|| path.join(".grapha"));
let graph = run_pipeline(&path, true, timing)?;
std::fs::create_dir_all(&store_path)
.with_context(|| format!("failed to create store dir {}", store_path.display()))?;
let previous_graph = if full_rebuild {
None
} else {
load_existing_graph(&format, &store_path)?
};
let delta = if full_rebuild {
None
} else {
previous_graph
.as_ref()
.map(|prev| delta::GraphDelta::between(prev, &graph))
};
let search_index_path = store_path.join("search_index");
let index_root = path.clone();
let save_result = std::thread::scope(|scope| {
let save_handle = scope.spawn(|| {
let t = Instant::now();
let s = build_store(&format, &store_path)?;
let stats = if full_rebuild {
let stats = store::StoreWriteStats::from_graphs(
previous_graph.as_ref(),
&graph,
delta::SyncMode::FullRebuild,
);
s.save(&graph)?;
stats
} else {
s.save_incremental(previous_graph.as_ref(), &graph)?
};
Ok::<_, anyhow::Error>((t.elapsed(), stats))
});
let search_handle = scope.spawn(|| {
let t = Instant::now();
let stats = search::sync_index(
previous_graph.as_ref(),
&graph,
&search_index_path,
full_rebuild,
delta.as_ref(),
)?;
Ok::<_, anyhow::Error>((t.elapsed(), stats))
});
let localization_handle = scope.spawn(|| {
let t = Instant::now();
let stats = localization::build_and_save_catalog_snapshot(&index_root, &store_path)?;
Ok::<_, anyhow::Error>((t.elapsed(), stats))
});
let assets_handle = scope.spawn(|| {
let t = Instant::now();
let stats = assets::build_and_save_snapshot(&index_root, &store_path)?;
Ok::<_, anyhow::Error>((t.elapsed(), stats))
});
let save = save_handle.join().expect("save thread panicked")?;
let search = search_handle.join().expect("search thread panicked")?;
let localization = localization_handle
.join()
.expect("localization thread panicked")?;
let assets = assets_handle.join().expect("assets thread panicked")?;
Ok::<_, anyhow::Error>((save, search, localization, assets))
});
let (
(save_elapsed, save_stats),
(search_elapsed, search_stats),
(localize_elapsed, localize_stats),
(assets_elapsed, assets_stats),
) = save_result?;
progress::done_elapsed(
&format!(
"saved to {} ({}; {})",
store_path.display(),
format,
save_stats.summary()
),
save_elapsed,
);
progress::done_elapsed(
&format!("built search index ({})", search_stats.summary()),
search_elapsed,
);
progress::done_elapsed(
&format!(
"saved localization snapshot ({} records)",
localize_stats.record_count
),
localize_elapsed,
);
for warning in &localize_stats.warnings {
eprintln!(
" \x1b[33m!\x1b[0m skipped invalid localization catalog {}: {}",
warning.catalog_file, warning.reason
);
}
progress::done_elapsed(
&format!(
"saved asset snapshot ({} images)",
assets_stats.record_count
),
assets_elapsed,
);
for warning in &assets_stats.warnings {
eprintln!(
" \x1b[33m!\x1b[0m skipped invalid asset catalog {}: {}",
warning.catalog_path, warning.reason
);
}
progress::summary(&format!(
"\n {} nodes, {} edges indexed in {:.1}s",
graph.nodes.len(),
graph.edges.len(),
total_start.elapsed().as_secs_f64(),
));
Ok(())
}
fn handle_symbol_command(
command: SymbolCommands,
render_options: render::RenderOptions,
) -> anyhow::Result<()> {
match command {
SymbolCommands::Search {
query,
limit,
path,
kind,
module,
file,
role,
fuzzy,
context,
fields,
} => {
let field_set = resolve_field_set(&fields, &path);
let _render_options = render_options.with_fields(field_set);
let index = open_search_index(&path)?;
let options = search::SearchOptions {
kind,
module,
file_glob: file,
role,
fuzzy,
};
let t = Instant::now();
let results = search::search_filtered(&index, &query, limit, &options)?;
let elapsed = t.elapsed();
if context {
let graph = load_graph(&path)?;
let enriched = search::enrich_with_context(&results, &graph);
print_json(&enriched)?;
} else {
print_json(&results)?;
}
eprintln!(
"\n {} results in {:.1}ms",
results.len(),
elapsed.as_secs_f64() * 1000.0,
);
Ok(())
}
SymbolCommands::Context {
symbol,
path,
format,
fields,
} => {
let field_set = resolve_field_set(&fields, &path);
let render_options = render_options.with_fields(field_set);
handle_resolved_graph_query(
&path,
format,
render_options,
"symbol",
|graph| query::context::query_context(graph, &symbol),
render::render_context_with_options,
)
}
SymbolCommands::Impact {
symbol,
depth,
path,
format,
fields,
} => {
let field_set = resolve_field_set(&fields, &path);
let render_options = render_options.with_fields(field_set);
handle_resolved_graph_query(
&path,
format,
render_options,
"symbol",
|graph| query::impact::query_impact(graph, &symbol, depth),
render::render_impact_with_options,
)
}
SymbolCommands::Complexity { symbol, path } => {
let graph = load_graph(&path)?;
let result =
query::complexity::query_complexity(&graph, &symbol).map_err(|e| anyhow!("{e}"))?;
print_json(&result)
}
SymbolCommands::File { file, path } => {
let graph = load_graph(&path)?;
let result = query::file_symbols::query_file_symbols(&graph, &file);
if result.total == 0 {
anyhow::bail!("no symbols found in file matching: {file}");
}
print_json(&result)
}
}
}
fn handle_flow_command(
command: FlowCommands,
render_options: render::RenderOptions,
) -> anyhow::Result<()> {
match command {
FlowCommands::Trace {
symbol,
direction,
depth,
path,
format,
} => match direction {
TraceDirection::Forward => handle_resolved_graph_query(
&path,
format,
render_options,
"symbol",
|graph| query::trace::query_trace(graph, &symbol, depth.unwrap_or(10)),
render::render_trace_with_options,
),
TraceDirection::Reverse => handle_resolved_graph_query(
&path,
format,
render_options,
"symbol",
|graph| query::reverse::query_reverse(graph, &symbol, depth),
render::render_reverse_with_options,
),
},
FlowCommands::Graph {
symbol,
depth,
path,
format,
} => handle_resolved_graph_query(
&path,
format,
render_options,
"symbol",
|graph| query::dataflow::query_dataflow(graph, &symbol, depth),
render::render_dataflow_with_options,
),
FlowCommands::Entries { path, format } => handle_graph_query(
&path,
format,
render_options,
query::entries::query_entries,
render::render_entries_with_options,
),
}
}
fn handle_l10n_command(
command: L10nCommands,
render_options: render::RenderOptions,
) -> anyhow::Result<()> {
match command {
L10nCommands::Symbol {
symbol,
path,
format,
} => {
let graph = load_graph(&path)?;
let catalogs = localization::load_catalog_index(&path)?;
let result = resolve_query_result(
query::localize::query_localize(&graph, &catalogs, &symbol),
"symbol",
)?;
print_query_result(
&result,
format,
render_options,
render::render_localize_with_options,
)
}
L10nCommands::Usages {
key,
table,
path,
format,
} => {
let graph = load_graph(&path)?;
let catalogs = localization::load_catalog_index(&path)?;
let result = query::usages::query_usages(&graph, &catalogs, &key, table.as_deref());
print_query_result(
&result,
format,
render_options,
render::render_usages_with_options,
)
}
}
}
fn handle_asset_command(command: AssetCommands) -> anyhow::Result<()> {
match command {
AssetCommands::List { unused, path } => {
if unused {
let graph = load_graph(&path)?;
let index = assets::load_asset_index(&path)?;
let unused = assets::find_unused(&index, &graph);
print_json(&unused)
} else {
let index = assets::load_asset_index(&path)?;
let records = index.all_records().to_vec();
print_json(&records)
}
}
AssetCommands::Usages { name, path, format } => {
let graph = load_graph(&path)?;
let usages = assets::find_usages(&graph, &name);
match format {
QueryOutputFormat::Json => print_json(&usages),
QueryOutputFormat::Tree => {
if usages.is_empty() {
eprintln!(" no usages found for asset '{name}'");
} else {
for usage in &usages {
println!(
" {} ({}) — {}",
usage.node_name, usage.file, usage.asset_name
);
}
}
Ok(())
}
}
}
}
}
fn handle_repo_command(command: RepoCommands) -> anyhow::Result<()> {
match command {
RepoCommands::Changes { scope, path } => {
let graph = load_graph(&path)?;
let report = changes::detect_changes(&path, &graph, &scope)?;
print_json(&report)
}
RepoCommands::Map { module, path } => {
let graph = load_graph(&path)?;
let map = query::map::file_map(&graph, module.as_deref());
print_json(&map)
}
RepoCommands::Smells { module, path } => {
let graph = load_graph(&path)?;
let mut result = query::smells::detect_smells(&graph);
if let Some(ref module_name) = module {
let module_lower = module_name.to_lowercase();
result.smells.retain(|smell| {
graph.nodes.iter().any(|n| {
n.id == smell.symbol.id
&& n.module
.as_ref()
.is_some_and(|m| m.to_lowercase() == module_lower)
})
});
result.total = result.smells.len();
result.by_severity.clear();
for smell in &result.smells {
*result
.by_severity
.entry(smell.severity.clone())
.or_default() += 1;
}
}
print_json(&result)
}
RepoCommands::Modules { path } => {
let graph = load_graph(&path)?;
let result = query::module_summary::query_module_summary(&graph);
print_json(&result)
}
}
}
fn handle_serve(path: PathBuf, port: u16, mcp_mode: bool, watch_mode: bool) -> anyhow::Result<()> {
let graph = load_graph(&path)?;
let search_index = open_search_index(&path)?;
if mcp_mode {
let state = mcp::handler::McpState {
graph,
search_index,
store_path: path.join(".grapha"),
recall: recall::Recall::new(),
};
let _watcher_guard = if watch_mode {
let (rx, _guard) =
watch::start_watcher(&path, &["swift", "rs", "ts", "tsx", "js", "jsx", "vue"])?;
let store_path = path.join(".grapha");
let project_path = path.clone();
let (state_tx, state_rx) =
std::sync::mpsc::channel::<(grapha_core::graph::Graph, tantivy::Index)>();
std::thread::Builder::new()
.name("grapha-watch-reindex".into())
.spawn(move || {
for event in rx {
match event {
watch::WatchEvent::FilesChanged(files) => {
eprintln!("watch: {} file(s) changed, re-indexing...", files.len());
match run_pipeline(&project_path, false, false) {
Ok(graph) => {
let store_file = store_path.join("grapha.db");
let store = store::sqlite::SqliteStore::new(store_file);
if let Err(e) = store.save(&graph) {
eprintln!("watch: failed to save graph: {e}");
continue;
}
let search_path = store_path.join("search_index");
match search::build_index(&graph, &search_path) {
Ok(index) => {
if state_tx.send((graph, index)).is_err() {
break;
}
eprintln!("watch: re-index complete");
}
Err(e) => {
eprintln!(
"watch: failed to build search index: {e}"
);
}
}
}
Err(e) => {
eprintln!("watch: re-index failed: {e}");
}
}
}
}
}
})?;
mcp::run_mcp_server_with_watch(state, state_rx)?;
return Ok(());
} else {
None::<watch::WatcherGuard>
};
mcp::run_mcp_server(state)
} else {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(serve::run(graph, search_index, port))?;
Ok(())
}
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let render_options = tree_render_options(cli.color);
match cli.command {
Commands::Analyze {
path,
output,
filter,
compact,
} => handle_analyze(path, output, filter, compact)?,
Commands::Index {
path,
format,
store_dir,
full_rebuild,
timing,
} => handle_index(path, format, store_dir, full_rebuild, timing)?,
Commands::Serve {
path,
port,
mcp,
watch,
} => handle_serve(path, port, mcp, watch)?,
Commands::Symbol { command } => handle_symbol_command(command, render_options)?,
Commands::Flow { command } => handle_flow_command(command, render_options)?,
Commands::L10n { command } => handle_l10n_command(command, render_options)?,
Commands::Asset { command } => handle_asset_command(command)?,
Commands::Repo { command } => handle_repo_command(command)?,
}
Ok(())
}