Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,16 @@ 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
`<file>:<symbol>`. `somepath` returns one shortest path, `somepaths` returns one
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

Expand Down
1 change: 1 addition & 0 deletions crates/codescythe/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions crates/codescythe/analyze/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String, String>::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::<String, QueryNode>::new();
let mut edges = BTreeSet::<QueryEdge>::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,
Expand Down
109 changes: 109 additions & 0 deletions crates/codescythe/analyze/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<_>>(),
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::<BTreeSet<_>>();
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<Analysis> {
let tempdir = tempfile::tempdir().unwrap();
let cwd = tempdir.path();
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions crates/codescythe/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions crates/codescythe_cli/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
33 changes: 27 additions & 6 deletions crates/codescythe_cli/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -217,12 +227,23 @@ fn run_query_command(args: QueryArgs) -> Result<bool> {
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)
}
Expand Down