使用 Rust 构建 SCSS 依赖分析器
Source: Dev.to

介绍
作为一名学习 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. 使用 anyhow 与 thiserror 进行错误处理
从 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 生态系统非常出色。像 petgraph、nom 和 clap 这样的库文档完善,且非常易于使用。

