As a developer learning Rust, I wanted to tackle a project that would be both practical and challenging enough to push my understanding of the language. The result: sass-dep, a CLI tool that analyzes SCSS/Sass codebases and visualizes their dependency graphs.
The Problem
If you've worked on large SCSS codebases, you know how quickly they can become a tangled web of @use, @forward, and @import statements. Questions like "what depends on this file?" or "do we have circular dependencies?" become surprisingly hard to answer.
I built sass-dep to solve this: point it at your entry SCSS file, and it builds a complete dependency graph with metrics, cycle detection, and an interactive visualizer.
What I Built
The CLI
# Analyze and open the interactive visualizer
sass-dep analyze src/main.scss --web
# CI/CD checks - fail on cycles or depth violations
sass-dep check --no-cycles --max-depth 10 src/main.scss
# Export to Graphviz DOT format
sass-dep export analysis.json --format dot | dot -Tpng -o graph.png
The Visualizer
The web UI (built with React + TypeScript) lets you:
- Explore the dependency graph interactively with pan/zoom
- Filter by node flags (entry points, leafs, orphans, cycles)
- Search for files with
/keyboard shortcut - Shift+click two nodes to highlight the path between them
- Export as PNG, SVG, or filtered JSON
- Toggle light/dark theme
Tech Stack
Rust Backend:
-
clap- CLI argument parsing with derive macros -
nom- Parser combinators for SCSS directive parsing -
petgraph- Graph data structures and algorithms -
serde+serde_json- JSON serialization -
axum+tokio- Async web server -
rust-embed- Embeds the React build into the binary -
walkdir- File system traversal for orphan discovery -
indexmap- Deterministic ordering in output -
anyhow+thiserror- Error handling
React Frontend:
- React 19 with TypeScript
-
@xyflow/react- Graph visualization (React Flow) -
@dagrejs/dagre- Graph layout algorithms -
html-to-image- PNG/SVG export - Vite for builds
- SCSS modules for styling
Rust Lessons Learned
1. Parser Combinators with nom
This was my first experience with parser combinators, and nom made it approachable. Instead of writing a traditional lexer/parser, you compose small parsing functions. Here's a simplified look at how directive parsing works:
use nom::{
branch::alt,
bytes::complete::tag_no_case,
character::complete::multispace1,
// ...
};
// Try to parse @use, @forward, or @import
fn parse_directive<'a>(input: &'a str, location: &Location) -> IResult<&'a str, Directive> {
alt((
|i| parse_use_directive(i, location),
|i| parse_forward_directive(i, location),
|i| parse_import_directive(i, location),
))(input)
}
The parser scans for @ symbols, attempts to match directives, and tracks line/column positions for error reporting. It's zero-copy where possible, working with slices into the original string.
2. Graph Algorithms with petgraph
petgraph is fantastic for graph operations. Cycle detection uses Tarjan's algorithm for finding strongly connected components:
use petgraph::algo::tarjan_scc;
pub fn detect_cycles(graph: &DependencyGraph) -> Vec<Vec<String>> {
let sccs = tarjan_scc(graph.inner());
// Filter to SCCs with more than one node (actual cycles)
sccs.into_iter()
.filter(|scc| scc.len() > 1)
.map(|scc| /* convert indices to file IDs */)
.collect()
}
3. Recursive Graph Building
Building the dependency graph requires recursively following imports while avoiding infinite loops. The key insight was tracking processed files in the graph itself:
fn process_directive(&mut self, directive: &Directive, /* ... */) -> Result<()> {
for target in directive.paths() {
// Skip Sass built-ins like sass:math
if target.starts_with("sass:") {
continue;
}
// Resolve and add to graph
let resolved = resolver.resolve(from_path, target)?;
let to_id = self.add_file(&resolved, root)?;
// Check if already processed to avoid infinite recursion
let already_processed = self.node_index.contains_key(&to_id)
&& self.get_node(&to_id).map(|n| /* has been visited */).unwrap_or(false);
self.add_edge(from_id, &to_id, edge);
if !already_processed {
self.process_file(&resolved, resolver, root)?;
}
}
Ok(())
}
4. Error Handling with anyhow and thiserror
Coming from JavaScript, Rust's error handling felt verbose at first. But anyhow for application code and thiserror for library errors made it ergonomic:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Failed to parse directive at {location}")]
InvalidDirective { location: Location },
#[error("IO error reading file: {0}")]
Io(#[from] std::io::Error),
}
The anyhow::Context trait is great for adding context to errors as they propagate:
let entry = entry.canonicalize()
.context("Failed to canonicalize entry path")?;
5. Embedding Assets with rust-embed
One of my favorite discoveries was rust-embed. It compiles the entire React build into the binary at compile time:
#[derive(RustEmbed)]
#[folder = "web/dist/"]
struct Assets;
The result? A single binary with no external dependencies. Users don't need Node.js installed to run the visualizer.
Architecture Decisions
Why File-Level Only?
sass-dep intentionally only tracks file dependencies, not variables, mixins, or functions. This keeps the tool focused and fast. The README explicitly lists this as a non-goal:
This tool intentionally does not:
- Track variables, mixins, or functions
- Analyze CSS output
- Support watch mode
Sass-Compliant Path Resolution
Getting path resolution right was tricky. Sass has specific rules for finding files:
- Try the exact path with
.scssand.sassextensions - Try with underscore prefix (
_partial.scss) - Try as directory with
index.scssor_index.scss - Repeat for each load path
The resolver in src/resolver/path.rs implements this spec carefully.
Deterministic Output
Using IndexMap instead of HashMap ensures the JSON output is deterministic across runs (same input always produces identical output). This matters for CI caching and diffing results.
The JSON Schema
The output follows a versioned schema (v1.0.0) with:
- Nodes: Each file with metrics (fan-in, fan-out, depth, transitive deps) and flags
- Edges: Dependencies with directive type, location, and namespace info
- Analysis: Detected cycles and aggregate statistics
Flags are automatically assigned:
-
entry_point- Explicitly specified entry -
leaf- No dependencies (fan-out = 0) -
orphan- Not reachable from entry points -
high_fan_in/high_fan_out- Exceeds thresholds -
in_cycle- Part of circular dependency
What I'd Do Differently
Start with tests earlier - I wrote integration tests with fixtures after the initial implementation, but having them from the start would have caught edge cases sooner.
Consider async from the start - The web server uses tokio/axum, but the core analysis is synchronous. Not a problem in practice, but something to consider.
Try It Out
The project is open source: github.com/emiliodominguez/sass-dep
# Clone and build
git clone https://github.com/emiliodominguez/sass-dep.git
cd sass-dep
cargo install --path .
# Analyze your SCSS project
sass-dep analyze your-project/src/main.scss --web
Conclusion
Building sass-dep taught me more about Rust than any tutorial could. If you're learning Rust, I highly recommend picking a project that:
- Solves a real problem you have
- Involves parsing (
nomis a great teacher for functional Rust patterns) - Has clear success criteria you can test against
The Rust ecosystem is excellent. Libraries like petgraph, nom, and clap are well-documented and really easy to use.

Top comments (0)