Building a SCSS Dependency Analyzer in Rust
Source: Dev.to

Introduction
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.
sass‑dep solves 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 the
/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 macrosnom– Parser combinators for SCSS directive parsingpetgraph– Graph data structures and algorithmsserde+serde_json– JSON serializationaxum+tokio– Async web serverrust-embed– Embeds the React build into the binarywalkdir– File‑system traversal for orphan discoveryindexmap– Deterministic ordering in outputanyhow+thiserror– Error handling
React Frontend
- React 19 with TypeScript
@xyflow/react– Graph visualization (React Flow)@dagrejs/dagre– Graph layout algorithmshtml-to-image– PNG/SVG export- Vite – Build tool
- SCSS modules – 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. A simplified example of directive parsing:
use nom::{
branch::alt,
bytes::complete::tag_no_case,
character::complete::multispace1,
// …
};
/// Try to parse `@use`, `@forward`, or `@import`
fn parse_directive(input: &'a str, location: &Location) -> IResult {
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> {
let sccs = tarjan_scc(graph.inner());
// Keep only SCCs with more than one node (actual cycles)
sccs.into_iter()
.filter(|scc| scc.len() > 1)
.map(|scc| {
// Convert indices to file IDs
scc.iter()
.map(|idx| graph.node_weight(*idx).unwrap().file_path.clone())
.collect()
})
.collect()
}
3. Recursive Graph Building
Building the dependency graph requires recursively following imports while avoiding infinite loops. The key insight was tracking processed files directly in the graph:
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)?;
// Has this file already been processed?
let already_processed = self.node_index.contains_key(&to_id)
&& self.get_node(&to_id).map(|n| n.visited).unwrap_or(false);
self.add_edge(from_id, &to_id, edge);
if !already_processed {
self.process_file(&resolved, resolver, root)?;
}
}
Ok(())
}
By checking already_processed before recursing, we prevent infinite loops caused by circular @use/@forward chains.
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 a 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 entryleaf– no dependencies (fan‑out = 0)orphan– not reachable from entry pointshigh_fan_in/high_fan_out– exceeds thresholdsin_cycle– part of a 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:
# 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.

