DEV Community

Cover image for Building a SCSS Dependency Analyzer in Rust
Emilio
Emilio

Posted on

Building a SCSS Dependency Analyzer in Rust

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
Enter fullscreen mode Exit fullscreen mode

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

Web Visualizer Screenshot

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)
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

The anyhow::Context trait is great for adding context to errors as they propagate:

let entry = entry.canonicalize()
    .context("Failed to canonicalize entry path")?;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. Try the exact path with .scss and .sass extensions
  2. Try with underscore prefix (_partial.scss)
  3. Try as directory with index.scss or _index.scss
  4. 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

  1. 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.

  2. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Solves a real problem you have
  2. Involves parsing (nom is a great teacher for functional Rust patterns)
  3. 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)