使用 Rust 构建 SCSS 依赖分析器

发布: (2026年1月10日 GMT+8 00:13)
9 min read
原文: Dev.to

Source: Dev.to

构建 Rust 中的 SCSS 依赖分析器的封面图片

Emilio

介绍

作为一名学习 Rust 的开发者,我想要挑战一个既实用又足够有挑战性的项目,以提升我对这门语言的理解。于是诞生了 sass‑dep,这是一款分析 SCSS/Sass 代码库并可视化其依赖图的 CLI 工具。

问题

如果你曾在大型 SCSS 代码库上工作过,你会知道它们很快就会变成一团纠结的 @use@forward@import 语句。诸如 “哪个文件依赖于此文件?”“我们是否存在循环依赖?” 之类的问题会变得出乎意料地难以回答。

sass‑dep 解决了这个问题:只需指向你的入口 SCSS 文件,它就会构建完整的依赖图,提供度量信息、循环检测以及交互式可视化工具。

我构建的内容

命令行工具

# 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

可视化工具

网页 UI(使用 React + TypeScript 构建)让你能够:

  • 交互式浏览依赖图,支持平移/缩放
  • 按节点标记(入口点、叶子节点、孤立节点、循环)进行过滤
  • 使用 / 键盘快捷键搜索文件
  • 按住 Shift 并点击两个节点以高亮它们之间的路径
  • 导出为 PNG、SVG 或过滤后的 JSON
  • 切换明暗主题

网页可视化工具截图

技术栈

Rust 后端

  • clap – 使用派生宏的 CLI 参数解析
  • nom – 用于 SCSS 指令解析的解析组合子
  • petgraph – 图数据结构和算法
  • serde + serde_json – JSON 序列化
  • axum + tokio – 异步 Web 服务器
  • rust-embed – 将 React 构建嵌入二进制文件
  • walkdir – 用于孤儿发现的文件系统遍历
  • indexmap – 输出中的确定性顺序
  • anyhow + thiserror – 错误处理

React 前端

  • React 19 与 TypeScript
  • @xyflow/react – 图形可视化(React Flow)
  • @dagrejs/dagre – 图布局算法
  • html-to-image – PNG/SVG 导出
  • Vite – 构建工具
  • SCSS 模块 – 样式

Rust Lessons Learned

1. 使用 nom 的解析组合子

这是我第一次接触解析组合子,nom 让它变得易于上手。与其编写传统的词法分析器/解析器,不如组合小的解析函数。下面是一个简化的指令解析示例:

use nom::{
    branch::alt,
    bytes::complete::tag_no_case,
    character::complete::multispace1,
    // …
};

/// 尝试解析 `@use`、`@forward` 或 `@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)
}

解析器会扫描 @ 符号,尝试匹配指令,并跟踪行/列位置以便错误报告。它在可能的情况下实现零拷贝,直接使用原始字符串的切片。

2. 使用 petgraph 的图算法

petgraph 在图操作方面非常强大。循环检测使用 Tarjan 算法 来寻找强连通分量:

use petgraph::algo::tarjan_scc;

pub fn detect_cycles(graph: &DependencyGraph) -> Vec> {
    let sccs = tarjan_scc(graph.inner());

    // 只保留包含多个节点的 SCC(实际的循环)
    sccs.into_iter()
        .filter(|scc| scc.len() > 1)
        .map(|scc| {
            // 将索引转换为文件 ID
            scc.iter()
                .map(|idx| graph.node_weight(*idx).unwrap().file_path.clone())
                .collect()
        })
        .collect()
}

3. 递归构建依赖图

构建依赖图时需要递归跟踪 @import,同时避免无限循环。关键的思路是直接在图中记录已处理的文件:

fn process_directive(&mut self, directive: &Directive, /* … */) -> Result {
    for target in directive.paths() {
        // 跳过 Sass 内置模块,如 `sass:math`
        if target.starts_with("sass:") {
            continue;
        }

        // 解析并加入图中
        let resolved = resolver.resolve(from_path, target)?;
        let to_id = self.add_file(&resolved, root)?;

        // 该文件是否已经处理过?
        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(())
}

在递归之前检查 already_processed,即可防止因循环的 @use/@forward 链导致的无限循环。

4. 使用 anyhowthiserror 进行错误处理

从 JavaScript 转到 Rust 时,错误处理起初显得有些冗长。但在应用代码中使用 anyhow,在库代码中使用 thiserror,就变得相当舒适:

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),
}

anyhow::Context trait 在错误向上传递时添加上下文信息非常有用:

let entry = entry
    .canonicalize()
    .context("Failed to canonicalize entry path")?;

5. 使用 rust-embed 嵌入资源

我最喜欢的发现之一是 rust-embed。它在编译时把整个 React 构建产物编进二进制文件:

#[derive(RustEmbed)]
#[folder = "web/dist/"]
struct Assets;

结果如何?得到一个无需外部依赖的单一二进制文件。用户不必安装 Node.js 就能运行可视化工具。

架构决策

为什么仅限文件级?

sass-dep 有意只跟踪文件之间的依赖关系,而不涉及变量、mixins 或函数。这使得工具保持专注且运行快速。README 中明确列出了这点作为非目标:

该工具有意

  • 跟踪变量、mixins 或函数
  • 分析 CSS 输出
  • 支持监视模式

符合 Sass 的路径解析

正确实现路径解析相当棘手。Sass 对文件查找有特定规则:

  • 尝试使用 .scss.sass 扩展名的精确路径
  • 尝试使用下划线前缀(_partial.scss
  • 将其视为目录并尝试 index.scss_index.scss
  • 对每个加载路径重复上述步骤

src/resolver/path.rs 中的解析器严格实现了这些规范。

确定性输出

使用 IndexMap 而非 HashMap 可确保 JSON 输出在不同运行之间保持确定性(相同输入始终产生相同输出)。这对 CI 缓存和结果差异比对非常重要。

JSON 架构

输出遵循版本化的架构(v1.0.0),包括:

  • Nodes – 每个文件的度量(fan‑in、fan‑out、depth、transitive deps)以及标记
  • Edges – 依赖关系,包含指令类型、位置和命名空间信息
  • Analysis – 检测到的循环以及聚合统计

标记会自动分配:

  • entry_point – 明确指定的入口
  • leaf – 没有依赖(fan‑out = 0
  • orphan – 从入口点不可达
  • high_fan_in / high_fan_out – 超过阈值
  • in_cycle – 属于循环依赖

如果让我重新做的话

  • 更早开始编写测试 – 我在完成初始实现后才编写了带有 fixture 的集成测试,若从一开始就有测试,能够更早捕获边缘情况。
  • 从一开始就考虑异步 – Web 服务器使用 Tokio/Axum,但核心分析是同步的。实际使用中并未造成问题,但在设计时可以考虑异步实现。

试一试

该项目是开源的:

# 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

结论

构建 sass-dep 让我对 Rust 的了解超过了任何教程。如果你正在学习 Rust,我强烈建议选择一个项目,它:

  • 解决你实际遇到的问题
  • 包含解析(nom 是学习函数式 Rust 模式的优秀教材)
  • 拥有明确的成功标准,便于进行测试

Rust 生态系统非常出色。像 petgraphnomclap 这样的库文档完善,且非常易于使用。

Back to Blog

相关文章

阅读更多 »