diff --git a/README.md b/README.md index 11f291c..be1f92a 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Use `query` to inspect dependency paths through the same source graph: codescythe query somepath src/main.ts src/module.ts codescythe query somepaths src/main.ts src/features/ codescythe query allpaths src/main.ts src/runtime.ts:initRuntime --json +codescythe query allpaths src/main.ts src/runtime.ts:initRuntime --output mermaid ``` Selectors can point at files, directories, or exported symbols written as @@ -148,7 +149,8 @@ Selectors can point at files, directories, or exported symbols written as shortest path per reachable matched target, and `allpaths` returns the subgraph of every node and edge that lies on a path from the source selector to the target selector. JSON output includes stable file/export nodes and typed import or -re-export edges. +re-export edges, and Mermaid output renders the same query graph as a +`flowchart LR` diagram. ## Contributing diff --git a/crates/codescythe/analyze.rs b/crates/codescythe/analyze.rs index 6dc1f69..7ead54e 100644 --- a/crates/codescythe/analyze.rs +++ b/crates/codescythe/analyze.rs @@ -16,6 +16,7 @@ pub use explain::ignored_unresolved_patterns_for_file; pub use query::{ QueryEdge, QueryEdgeKind, QueryGraph, QueryKind, QueryNode, QueryNodeKind, QueryPath, QueryRequest, QueryResult, QuerySelector, QuerySelectorKind, QueryUnresolvedImport, query_path, + render_query_mermaid, }; pub use resolver::{ source_alias_fix_blocking_ignore_warnings_for_config, source_alias_ignore_warnings_for_config, diff --git a/crates/codescythe/analyze/query.rs b/crates/codescythe/analyze/query.rs index 02cfa32..3b0d80c 100644 --- a/crates/codescythe/analyze/query.rs +++ b/crates/codescythe/analyze/query.rs @@ -277,6 +277,94 @@ pub fn query_path( }) } +pub fn render_query_mermaid(result: &QueryResult) -> String { + let graph = query_result_graph(result); + let mut node_ids = BTreeMap::::new(); + let mut lines = vec!["flowchart LR".to_string()]; + + for (index, node) in graph.nodes.iter().enumerate() { + let id = format!("n{index}"); + node_ids.insert(node.id.clone(), id.clone()); + lines.push(format!( + " {id}[\"{}\"]", + escape_mermaid_label(&query_node_label(node)) + )); + } + + for edge in &graph.edges { + let Some(from) = node_ids.get(&edge.from) else { + continue; + }; + let Some(to) = node_ids.get(&edge.to) else { + continue; + }; + lines.push(format!( + " {from} -->|\"{}\"| {to}", + escape_mermaid_label(&query_edge_label(edge)) + )); + } + + lines.push(String::new()); + lines.join("\n") +} + +fn query_result_graph(result: &QueryResult) -> QueryGraph { + if let Some(graph) = &result.graph { + return graph.clone(); + } + + let mut nodes = BTreeMap::::new(); + let mut edges = BTreeSet::::new(); + for path in &result.paths { + for node in &path.nodes { + nodes.insert(node.id.clone(), node.clone()); + } + for edge in &path.edges { + edges.insert(edge.clone()); + } + } + + QueryGraph { + nodes: nodes.into_values().collect(), + edges: edges.into_iter().collect(), + } +} + +fn query_node_label(node: &QueryNode) -> String { + if let Some(symbol) = &node.symbol { + format!("{}:{symbol}", node.path) + } else { + node.path.clone() + } +} + +fn query_edge_label(edge: &QueryEdge) -> String { + let kind = match edge.kind { + QueryEdgeKind::NamedImport => "named import", + QueryEdgeKind::SideEffectImport => "side-effect import", + QueryEdgeKind::DynamicImport => "dynamic import", + QueryEdgeKind::GlobImport => "glob import", + QueryEdgeKind::ReExport => "re-export", + QueryEdgeKind::ReExportSource => "re-export source", + QueryEdgeKind::NamespaceExport => "namespace export", + QueryEdgeKind::NamespaceMember => "namespace member", + QueryEdgeKind::ExportDefinition => "defined in file", + }; + match (&edge.specifier, &edge.imported) { + (Some(specifier), Some(imported)) => format!("{kind} {specifier}:{imported}"), + (Some(specifier), None) => format!("{kind} {specifier}"), + (None, Some(imported)) => format!("{kind} {imported}"), + (None, None) => kind.to_string(), + } +} + +fn escape_mermaid_label(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + fn build_query_graph( files: &mut FileCache, resolver: &ModuleResolver, diff --git a/crates/codescythe/analyze/tests.rs b/crates/codescythe/analyze/tests.rs index 76b76ad..c8efd92 100644 --- a/crates/codescythe/analyze/tests.rs +++ b/crates/codescythe/analyze/tests.rs @@ -1506,6 +1506,12 @@ fn query_somepath_tracks_named_export_edges() { vec!["src/main.ts", "src/module.ts:used"] ); assert_eq!(result.paths[0].edges[0].kind, QueryEdgeKind::NamedImport); + + let mermaid = render_query_mermaid(&result); + assert!(mermaid.contains("flowchart LR")); + assert!(mermaid.contains("src/main.ts")); + assert!(mermaid.contains("src/module.ts:used")); + assert!(mermaid.contains("named import ./module:used")); } #[test] @@ -1588,6 +1594,90 @@ fn query_allpaths_returns_path_subgraph_without_dead_edges() { ); } +#[test] +fn query_somepath_variants_handle_circular_dependencies() { + for kind in [QueryKind::Somepath, QueryKind::Somepaths] { + let result = query_inline_project( + cycle_query_files(), + QueryRequest { + kind, + from: "src/root.ts".to_string(), + to: "src/targets/sink.ts".to_string(), + }, + ); + + assert_eq!(result.paths.len(), 1); + assert_eq!( + result.paths[0] + .nodes + .iter() + .map(query_node_label) + .collect::>(), + vec![ + "src/root.ts", + "src/cycle/a.ts:a", + "src/cycle/a.ts", + "src/cycle/b.ts:b", + "src/cycle/b.ts", + "src/targets/sink.ts:sink", + "src/targets/sink.ts", + ] + ); + assert!( + result.paths[0].edges.len() < 10, + "{kind:?} should terminate after a finite acyclic shortest path" + ); + } +} + +#[test] +fn query_allpaths_handles_circular_dependencies() { + let result = query_inline_project( + cycle_query_files(), + QueryRequest { + kind: QueryKind::Allpaths, + from: "src/root.ts".to_string(), + to: "src/targets/sink.ts".to_string(), + }, + ); + + let graph = result.graph.expect("allpaths should return a graph"); + let node_labels = graph + .nodes + .iter() + .map(query_node_label) + .collect::>(); + assert_eq!( + node_labels, + BTreeSet::from([ + "src/cycle/a.ts".to_string(), + "src/cycle/a.ts:a".to_string(), + "src/cycle/b.ts".to_string(), + "src/cycle/b.ts:b".to_string(), + "src/root.ts".to_string(), + "src/targets/sink.ts".to_string(), + "src/targets/sink.ts:sink".to_string(), + ]) + ); + assert!( + graph.edges.iter().any(|edge| { + edge.from == "file:src/cycle/b.ts" && edge.to == "export:src/cycle/a.ts:a" + }), + "path subgraph should preserve the cycle edge when it is on a route to the target" + ); + assert!( + graph + .nodes + .iter() + .all(|node| node.path != "src/targets/dead.ts"), + "unreachable files should not be part of the path subgraph" + ); + assert!( + graph.edges.len() < 16, + "cycle handling should produce a finite path subgraph" + ); +} + fn analyze_missing_import(mode: Option<&str>) -> Result { let tempdir = tempfile::tempdir().unwrap(); let cwd = tempdir.path(); @@ -1652,6 +1742,25 @@ fn query_inline_project(files: &[(&str, &str)], request: QueryRequest) -> QueryR query_path(cwd, &config, request).unwrap() } +fn cycle_query_files() -> &'static [(&'static str, &'static str)] { + &[ + ( + "src/root.ts", + "import { a } from './cycle/a';\nconsole.log(a);\n", + ), + ( + "src/cycle/a.ts", + "import { b } from './b';\nexport const a = b;\n", + ), + ( + "src/cycle/b.ts", + "import { a } from './a';\nimport { sink } from '../targets/sink';\nexport const b = a + sink;\n", + ), + ("src/targets/sink.ts", "export const sink = 1;\n"), + ("src/targets/dead.ts", "export const dead = 1;\n"), + ] +} + fn query_node_label(node: &QueryNode) -> String { if let Some(symbol) = &node.symbol { format!("{}:{symbol}", node.path) diff --git a/crates/codescythe/lib.rs b/crates/codescythe/lib.rs index 92c1ef2..5317f94 100644 --- a/crates/codescythe/lib.rs +++ b/crates/codescythe/lib.rs @@ -10,8 +10,8 @@ pub use analyze::{ QueryGraph, QueryKind, QueryNode, QueryNodeKind, QueryPath, QueryRequest, QueryResult, QuerySelector, QuerySelectorKind, QueryUnresolvedImport, SourceAliasIgnoreWarning, SymbolIssue, UnresolvedImportCandidateFile, UnresolvedImportExplanation, UnresolvedImportMatchedAlias, - analyze_path, doctor_config, query_path, source_alias_fix_blocking_ignore_warnings_for_config, - source_alias_ignore_warnings_for_config, + analyze_path, doctor_config, query_path, render_query_mermaid, + source_alias_fix_blocking_ignore_warnings_for_config, source_alias_ignore_warnings_for_config, }; pub use config::{ CodescytheConfig, LoadedConfig, UnresolvedImportsConfig, UnresolvedImportsMode, load_config, diff --git a/crates/codescythe_cli/e2e.rs b/crates/codescythe_cli/e2e.rs index 823fb3c..cd5899e 100644 --- a/crates/codescythe_cli/e2e.rs +++ b/crates/codescythe_cli/e2e.rs @@ -267,6 +267,30 @@ fn cli_queries_dependency_paths() { assert_eq!(query["paths"][0]["nodes"][1]["path"], "src/module.ts"); assert_eq!(query["paths"][0]["nodes"][1]["symbol"], "used"); assert_eq!(query["paths"][0]["edges"][0]["kind"], "namedImport"); + + let mermaid_output = Command::new(runfile("crates/codescythe_cli/codescythe")) + .args([ + "query", + "somepath", + "-C", + path_arg(&runfile("tests/fixtures/test-file-usage")), + "--output", + "mermaid", + "src/main.ts", + "src/module.ts:used", + ]) + .output() + .expect("failed to run codescythe query with mermaid output"); + + assert!( + mermaid_output.status.success(), + "{}", + output_text(&mermaid_output) + ); + let mermaid = String::from_utf8_lossy(&mermaid_output.stdout); + assert!(mermaid.contains("flowchart LR"), "{mermaid}"); + assert!(mermaid.contains("src/module.ts:used"), "{mermaid}"); + assert!(mermaid.contains("named import ./module:used"), "{mermaid}"); } #[test] diff --git a/crates/codescythe_cli/main.rs b/crates/codescythe_cli/main.rs index f56f249..16282f3 100644 --- a/crates/codescythe_cli/main.rs +++ b/crates/codescythe_cli/main.rs @@ -8,7 +8,7 @@ use std::{ use std::time::{Duration, Instant}; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; #[cfg(feature = "profiling")] const PROFILE_ENV: &str = "CODESCYTHE_PROFILE"; @@ -85,6 +85,16 @@ struct QueryPathArgs { #[arg(long)] json: bool, + + #[arg(long, value_enum, default_value_t = QueryOutputFormat::Text)] + output: QueryOutputFormat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum QueryOutputFormat { + Text, + Json, + Mermaid, } fn main() -> ExitCode { @@ -217,12 +227,23 @@ fn run_query_command(args: QueryArgs) -> Result { to: args.to, }, )?; - if args.json { - let started = start_profile_timer(); - println!("{}", serde_json::to_string(&result)?); - print_profile_stage("json serialization", started); + let output = if args.json { + QueryOutputFormat::Json } else { - print_query_report(&result); + args.output + }; + match output { + QueryOutputFormat::Json => { + let started = start_profile_timer(); + println!("{}", serde_json::to_string(&result)?); + print_profile_stage("json serialization", started); + } + QueryOutputFormat::Mermaid => { + print!("{}", codescythe::render_query_mermaid(&result)); + } + QueryOutputFormat::Text => { + print_query_report(&result); + } } Ok(false) }