Compare commits

..

4 Commits

8 changed files with 324 additions and 4 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

View File

@@ -1,8 +1,10 @@
[package]
name = "strata"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
publish = ["crates-io"]
publish = ["gitea"]
[dependencies]
clap = { version = "4.6.0", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

10
LICENSE Normal file
View File

@@ -0,0 +1,10 @@
Copyright 2026 Emily M. Boudreaux
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -24,12 +24,13 @@ strata <input_file> --mode <MODE> [OPTIONS]
- `flat-symbol`: Aggregates the observed weights by the resolved symbol name, irrespective of the call path context, yielding a flat percentage summary.
- `namespace`: Aggregates observed weights by namespace exclusively.
### Filtering Options
Strata supports the refinement of the call graph through three primary filtering mechanisms. When nodes are filtered from the reporting view, their weights are correctly collapsed into the nearest visible ancestor to ensure strict weight conservation.
### Filtering and Output Options
Strata supports the refinement of the call graph through three primary filtering mechanisms, as well as an interactive HTML output capability.
- `--whitelist <namespaces>`: Only the specified namespaces remain visible in the output hierarchy.
- `--blacklist <namespaces>`: The specified namespaces are removed from explicit representation, with their weights folded upward.
- `--fold <substrings>`: Halts traversal at the first node whose literal frame matches the provided substring. The internal operations of that subtree are hidden, and all inclusive weights descending from that node are strictly rolled up into its exclusive weight.
- `--graph <FILE_PATH.html>`: Generates a self-contained, interactive `D3.js` HTML report. If the mode is set to `tree`, it produces a panning, zooming Node-Link Collapsible Tree visualizer. For `flat-symbol` or `namespace` modes, it generates an interactive bar chart.
## Development Context and Methodology
This software was constructed through iterative interactions with a generative artificial intelligence coding assistant. It serves primarily as an experimental test case for the author to evaluate the feasibility of AI-driven software engineering in systems tooling.

View File

@@ -22,6 +22,10 @@ pub struct Cli {
/// Comma-separated list of substrings to match for folding subtrees
#[arg(short = 'f', long)]
pub fold: Option<String>,
/// Path to output an interactive HTML graph
#[arg(short = 'g', long)]
pub graph: Option<String>,
}
#[derive(ValueEnum, Clone, Debug)]

293
src/html.rs Normal file
View File

@@ -0,0 +1,293 @@
use crate::filter::ReportGraph;
use crate::cli::ReportMode;
use serde::Serialize;
use std::fs::File;
use std::io::Write;
#[derive(Serialize)]
struct TreeNode {
name: String,
inclusive: u64,
exclusive: u64,
children: Vec<TreeNode>,
}
fn build_tree_node(graph: &ReportGraph, id: usize) -> TreeNode {
let node = &graph.nodes[id];
let mut children = Vec::new();
for child_id in node.children.values() {
children.push(build_tree_node(graph, *child_id));
}
children.sort_by(|a, b| b.inclusive.cmp(&a.inclusive));
TreeNode {
name: node.display_label.clone(),
inclusive: node.inclusive_weight,
exclusive: node.exclusive_weight,
children,
}
}
pub fn generate_html_report(graph: &ReportGraph, mode: &ReportMode, out_path: &str) -> std::io::Result<()> {
let mut f = File::create(out_path)?;
match mode {
ReportMode::Tree => {
let root_node = build_tree_node(graph, graph.root);
let json_data = serde_json::to_string(&root_node).unwrap();
let html = format!(r#"<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>Strata Call Graph</title>
<script src='https://d3js.org/d3.v7.min.js'></script>
<style>
body {{ font-family: sans-serif; margin: 0; padding: 0; background: #fafafa; }}
svg {{ width: 100vw; height: 100vh; display: block; }}
.node circle {{ fill: #fff; stroke: steelblue; stroke-width: 3px; cursor: pointer; }}
.node text {{ font: 12px monospace; }}
.link {{ fill: none; stroke: #ccc; stroke-width: 2px; }}
#tooltip {{
position: absolute; text-align: left; padding: 10px;
font: 13px sans-serif; background: #fff; border: 1px solid #ccc;
border-radius: 8px; pointer-events: none; opacity: 0; box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
}}
</style>
</head>
<body>
<div id='tooltip'></div>
<svg id='chart'></svg>
<script>
const treeData = {0};
const svg = d3.select('#chart'),
width = document.body.clientWidth,
height = document.body.clientHeight;
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', (event) => {{
g.attr('transform', event.transform);
}}));
const treeLayout = d3.tree().nodeSize([30, 300]);
let root = d3.hierarchy(treeData, d => d.children);
root.x0 = height / 2;
root.y0 = 0;
root.descendants().forEach((d, i) => {{
if (d.depth > 1 && d.children) {{
d._children = d.children;
d.children = null;
}}
}});
let i = 0;
const duration = 500;
const tooltip = d3.select('#tooltip');
function update(source) {{
const treeData = treeLayout(root);
const nodes = treeData.descendants(),
links = treeData.descendants().slice(1);
nodes.forEach(d => {{ d.y = d.depth * 300; }});
const node = g.selectAll('g.node')
.data(nodes, d => d.id || (d.id = ++i));
const nodeEnter = node.enter().append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${{source.y0}},${{source.x0}})`)
.on('click', (event, d) => {{
if (d.children) {{ d._children = d.children; d.children = null; }}
else {{ d.children = d._children; d._children = null; }}
update(d);
}})
.on('mouseover', (event, d) => {{
tooltip.transition().duration(200).style('opacity', .9);
tooltip.html(`<b>${{d.data.name}}</b><br/>Inclusive: ${{d.data.inclusive}}<br/>Exclusive: ${{d.data.exclusive}}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px');
}})
.on('mouseout', d => {{
tooltip.transition().duration(500).style('opacity', 0);
}});
nodeEnter.append('circle')
.attr('r', 1e-6)
.style('fill', d => d._children ? 'lightsteelblue' : '#fff');
nodeEnter.append('text')
.attr('dy', '.35em')
.attr('x', d => d.children || d._children ? -13 : 13)
.attr('text-anchor', d => d.children || d._children ? 'end' : 'start')
.text(d => d.data.name.substring(0, 50) + (d.data.name.length > 50 ? '...' : ''));
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition()
.duration(duration)
.attr('transform', d => `translate(${{d.y}},${{d.x}})`);
nodeUpdate.select('circle')
.attr('r', 6)
.style('fill', d => d._children ? 'lightsteelblue' : '#fff');
const nodeExit = node.exit().transition()
.duration(duration)
.attr('transform', d => `translate(${{source.y}},${{source.x}})`)
.remove();
nodeExit.select('circle').attr('r', 1e-6);
nodeExit.select('text').style('fill-opacity', 1e-6);
const link = g.selectAll('path.link')
.data(links, d => d.id);
const linkEnter = link.enter().insert('path', 'g')
.attr('class', 'link')
.attr('d', d => {{
const o = {{x: source.x0, y: source.y0}};
return diagonal(o, o);
}});
const linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(duration)
.attr('d', d => diagonal(d.parent, d));
link.exit().transition()
.duration(duration)
.attr('d', d => {{
const o = {{x: source.x, y: source.y}};
return diagonal(o, o);
}})
.remove();
nodes.forEach(d => {{
d.x0 = d.x;
d.y0 = d.y;
}});
function diagonal(s, d) {{
return `M ${{s.y}} ${{s.x}}
C ${{ (s.y + d.y) / 2 }} ${{s.x}},
${{ (s.y + d.y) / 2 }} ${{d.x}},
${{d.y}} ${{d.x}}`;
}}
}}
g.attr('transform', `translate(100, ${{height/2}})`);
update(root);
</script>
</body>
</html>"#, json_data);
f.write_all(html.as_bytes())?;
}
ReportMode::FlatSymbol | ReportMode::Namespace => {
let data = match mode {
ReportMode::FlatSymbol => crate::report::report_flat_symbol(graph),
ReportMode::Namespace => crate::report::report_namespace(graph),
_ => unreachable!(),
};
let top_data: Vec<_> = data.into_iter().take(150).collect();
let json_data = serde_json::to_string(&top_data).unwrap();
let title = match mode {
ReportMode::FlatSymbol => "Flat Symbol Report",
ReportMode::Namespace => "Namespace Report",
_ => "",
};
let html = format!(r#"<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>{1}</title>
<script src='https://d3js.org/d3.v7.min.js'></script>
<style>
body {{ font-family: sans-serif; margin: 20px; }}
.bar {{ fill: steelblue; }}
.bar:hover {{ fill: orange; }}
.axis-label {{ font: 12px sans-serif; }}
#tooltip {{
position: absolute; text-align: left; padding: 10px;
font: 13px sans-serif; background: #fff; border: 1px solid #ccc;
border-radius: 8px; pointer-events: none; opacity: 0; box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
}}
</style>
</head>
<body>
<h2>{1}</h2>
<div id='tooltip'></div>
<div id='chart'></div>
<script>
const data = {0};
const margin = {{top: 20, right: 30, bottom: 40, left: 350}},
width = 1200 - margin.left - margin.right,
height = Math.max(800, data.length * 20) - margin.top - margin.bottom;
const svg = d3.select('#chart')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${{margin.left}},${{margin.top}})`);
const x = d3.scaleLinear()
.domain([0, d3.max(data, d => d[1])])
.range([0, width]);
svg.append('g')
.attr('transform', `translate(0,${{height}})`)
.call(d3.axisBottom(x))
.selectAll('text')
.attr('transform', 'translate(-10,0)rotate(-45)')
.style('text-anchor', 'end');
const y = d3.scaleBand()
.range([0, height])
.domain(data.map(d => d[0]))
.padding(.1);
svg.append('g')
.call(d3.axisLeft(y))
.selectAll('text')
.style('font-family', 'monospace')
.text(d => d.length > 45 ? d.substring(0, 42) + '...' : d);
const tooltip = d3.select('#tooltip');
svg.selectAll('myRect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', x(0) )
.attr('y', d => y(d[0]))
.attr('width', d => x(d[1]))
.attr('height', y.bandwidth())
.on('mouseover', (event, d) => {{
tooltip.transition().duration(200).style('opacity', .9);
tooltip.html(`<b>${{d[0]}}</b><br/>Weight: ${{d[1]}}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px');
}})
.on('mouseout', d => {{
tooltip.transition().duration(500).style('opacity', 0);
}});
</script>
</body>
</html>"#, json_data, title);
f.write_all(html.as_bytes())?;
}
}
Ok(())
}

View File

@@ -4,3 +4,4 @@ pub mod model;
pub mod parser;
pub mod cli;
pub mod report;
pub mod html;

View File

@@ -4,6 +4,7 @@ use strata::builder::SourceGraphBuilder;
use strata::cli::{Cli, ReportMode};
use strata::filter::{collapse_graph, FilterMode, FilterOptions};
use strata::report::{report_flat_symbol, report_namespace, report_tree};
use strata::html::generate_html_report;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse();
@@ -53,6 +54,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
// 5. Build HTML Graph if requested
if let Some(graph_file) = &args.graph {
generate_html_report(&report_graph, &args.mode, graph_file)?;
println!("Interactive graph written to {}", graph_file);
}
Ok(())
}