通过构建Markdown编译器深入理解Rust编程与编译原理
最近在社区里看到一个很有意思的项目,标题叫“通过写一个 Markdown 到 HTML 的编译器来学习 Rust”。这个想法本身并不新鲜,很多教程都会让你写个计算器、Todo 应用或者简单的 Web 服务器来入门。但“写一个编译器”这个目标,听起来就有点不一样了。它不像 Todo 应用那样,写完增删改查就结束了;也不像 Web 服务器,更多是在处理网络和并发。编译器,尤其是 Markdown 这种文本转换器,它要求你思考的层次更深:如何把一段结构化的文本(Markdown)解析成另一种结构化的文本(HTML),这个过程天然就包含了词法分析、语法解析、抽象语法树构建和代码生成这几个经典的编译步骤。
很多人学 Rust 时,会陷入语法细节的泥潭:所有权、生命周期、模式匹配、Trait……这些概念固然重要,但如果没有一个具体的、有挑战性的项目去承载,很容易学完就忘,或者感觉“不知道能用 Rust 做什么”。而一个 Markdown 转 HTML 的工具,恰好是一个复杂度适中、目标明确、且能完整串联起 Rust 核心特性的练手项目。它不需要你懂复杂的算法,但能逼着你去理解字符串处理、枚举和模式匹配、递归数据结构、错误处理,甚至是简单的泛型和 Trait 使用。
更重要的是,当你真正动手去实现时,你会发现,你学到的不仅仅是 Rust 语法,更是一种从问题定义到系统分解,再到模块化实现的工程化思维。这篇文章,我们就来聊聊,如何通过“造轮子”——亲手打造一个 Markdown 到 HTML 的编译器——来真正地、深入地学习 Rust。我会带你走过从零开始的完整思考路径,而不仅仅是贴出一份可以运行的代码。
1. 为什么是 Markdown 编译器?一个被低估的“完美”练手项目
在决定用某个项目学习一门语言前,我们得先想清楚,这个项目能覆盖多少这门语言的核心特性,以及它能否带来足够的成就感。一个 Markdown 编译器,在这两方面都表现得相当出色。
首先,从技术覆盖面来看,这个项目几乎是为 Rust 量身定做的:
- 字符串处理 (
&str,String): Markdown 是纯文本,你需要频繁地切片、拼接、查找和替换。这会让你深刻理解 Rust 中字符串的所有权、借用和切片。 - 枚举 (
enum) 和模式匹配 (match): Markdown 的语法元素(标题、列表、代码块、加粗、链接等)是天然的分类。用enum来定义这些元素,再用match来处理不同的情况,是 Rust 最优雅、最安全的表达方式。 - 递归数据结构 (
struct和Box): 一个文档可以看作是一棵由各种块级元素(如段落、列表项)和行内元素(如加粗文本)组成的树。定义这样的抽象语法树(AST)必然会用到自引用的结构,这会引导你接触Box、Rc等智能指针,理解 Rust 如何管理复杂数据的所有权。 - 错误处理 (
Result,Option): 解析过程中,可能会遇到格式错误的 Markdown(比如未闭合的代码块)。如何优雅地报告错误并尝试恢复或跳过,是学习 Rust 错误处理哲学(显式、可组合)的好机会。 - 迭代器 (
Iterator) 和函数式编程: 逐行或逐字符扫描文本,非常适合用迭代器来表达。你可以练习使用map,filter,collect,peekable等适配器,写出既高效又易读的代码。 - 模块化与测试: 你可以将词法分析(分词)、语法分析(构建 AST)、渲染(生成 HTML)拆分成不同的模块(
mod)。这能练习如何组织 Rust 项目结构,并为每个模块编写单元测试和集成测试。
其次,从成就感与实用性来看:
- 目标清晰可见: 输入是
.md文件,输出是.html文件。你可以立刻用浏览器打开看到成果,这种即时反馈非常鼓舞人心。 - 挑战梯度合理: 你可以从实现最简单的功能开始(比如解析
# 标题),逐步增加复杂度(支持嵌套列表、代码块高亮、内联 HTML 等)。每一步的成功都能带来正向激励。 - 理解底层原理: 用过无数 Markdown 编辑器,但你知道一个
**加粗**是如何变成<strong>加粗</strong>的吗?自己实现一遍,你会对日常使用的工具有全新的认识。 - 可无限扩展: 基础版本完成后,你可以挑战自己:支持 GFM(GitHub Flavored Markdown)的表格和任务列表,集成语法高亮库,甚至写一个简单的 HTTP 服务器,让它成为一个在线的转换工具。
所以,选择这个项目,你不仅仅是在学习 Rust,更是在亲手构建一个理解“编译原理”和“文本处理”的微型实验室。
2. 第一步:别急着写代码,先想清楚“编译”的流程
很多教程一上来就让你写struct和enum,但如果没有一个清晰的蓝图,很容易写着写着就乱了。对于编译器(哪怕是微型编译器),一个经典的处理流程是:
原始文本 (Markdown) -> 词法分析器 (Lexer) -> 令牌流 (Tokens) -> 语法分析器 (Parser) -> 抽象语法树 (AST) -> 代码生成器 (Generator) -> 目标代码 (HTML)让我们把这个流程对应到我们的 Markdown 编译器上,并思考每个阶段在 Rust 中可能如何实现。
2.1 词法分析:把文本切成有意义的“单词”
词法分析器(Lexer)的任务是扫描字符流,将其组合成一个个有意义的“令牌”(Token)。对于 Markdown 来说,令牌就是语法的最小单元。
例如,对于文本# 这是一个标题\n\n这是一段**加粗**的文字。,Lexer 应该产出类似这样的令牌序列:HeadingStart(1),Text("这是一个标题"),Newline,Newline,Text("这是一段"),BoldStart,Text("加粗"),BoldEnd,Text("的文字。")
在 Rust 中,我们首先需要定义这些令牌的类型。这几乎必然要用到enum。
#[derive(Debug, Clone, PartialEq)] pub enum Token { // 块级令牌 HeadingStart(u8), // #, ##, ###,u8 表示级别 CodeBlockStart(String), // ```后面可能跟语言,如 ```rust CodeBlockEnd, UnorderedListStart, // `-`, `*`, `+` OrderedListStart(u64), // `1.`, `2.`,u64 表示起始数字 ListItemStart, BlockquoteStart, // `>` HorizontalRule, // `---` 或 `***` // 行内令牌 Text(String), BoldStart, // `**` 或 `__` BoldEnd, ItalicStart, // `*` 或 `_` ItalicEnd, CodeStart, // `\`` CodeEnd, LinkStart, // `[` LinkEnd, // `]` ImageStart, // `![` ImageEnd, // `]` LeftParen, // `(` RightParen, // `)` // 控制令牌 Newline, // 换行符,在 Markdown 中有特殊意义(可能结束一个段落) EOF, // 文件结束 }有了Token的定义,Lexer 的实现就是一个状态机,它逐个字符读取输入,根据当前字符和状态决定生成什么令牌。这里非常适合使用Iterator模式,让 Lexer 成为一个可以不断产出Token的迭代器。
注意:Markdown 的 Lexer 比编程语言的简单,因为它的“关键字”(如
#,*)通常也是文本内容的一部分。难点在于处理边界情况,比如*到底是斜体开始,还是普通星号?这需要 Lexer 有一定的“前瞻”能力,或者将歧义留给 Parser 解决。一个常见的策略是,Lexer 只做简单的、无状态的识别,复杂的结构由 Parser 通过组合令牌来判定。
2.2 语法分析:从令牌流到树形结构
语法分析器(Parser)接收来自 Lexer 的令牌流,并根据 Markdown 的语法规则,将其组织成一棵结构化的树——抽象语法树(AST)。
AST 的节点类型也需要用enum来定义,但这次是嵌套的,因为一个节点可能包含子节点。
#[derive(Debug)] pub enum Node { // 文档根节点,包含多个块级元素 Document(Vec<Block>), // 块级元素 Heading(Level, Vec<Inline>), // 级别和行内内容 Paragraph(Vec<Inline>), CodeBlock(Option<String>, String), // 语言和代码内容 UnorderedList(Vec<ListItem>), OrderedList(u64, Vec<ListItem>), // 起始数字和列表项 ListItem(Vec<Block>), // 列表项内可以包含多个块(如段落、子列表) Blockquote(Vec<Block>), HorizontalRule, // 行内元素 Text(String), Bold(Vec<Inline>), Italic(Vec<Inline>), Code(String), Link(String, String), // 链接文本和 URL Image(String, String), // 替代文本和图片 URL } // 为了方便,可以定义一些类型别名 pub type Level = u8; pub type Block = Node; pub type Inline = Node; pub type ListItem = Node;Parser 的实现是项目的核心挑战之一。它需要处理嵌套结构(如列表中的列表),并且要能“前瞻”多个令牌来决定当前的结构。例如,连续的两个Newline令牌可能意味着一个段落的结束。
一个典型的递归下降 Parser 会有一系列的方法,每个方法负责解析一种语法结构:
parse_document() -> Node::Documentparse_block() -> Option<Block>parse_heading() -> Option<Block>parse_paragraph() -> Blockparse_inline() -> Inline- ...
Parser 会不断地从 Lexer 迭代器中peek(查看下一个令牌)或consume(消耗当前令牌),并根据令牌类型调用相应的方法。
2.3 代码生成:遍历 AST,输出 HTML
这是最后一步,也相对直观。我们需要为 AST 中的每种Node定义如何将其转换为 HTML 字符串。
我们可以为Node实现一个render_html方法,或者写一个独立的HtmlRenderer结构体来遍历 AST。
impl Node { pub fn render_html(&self) -> String { match self { Node::Document(children) => { let inner: String = children.iter().map(|c| c.render_html()).collect(); format!("<!DOCTYPE html><html><body>{}</body></html>", inner) } Node::Heading(level, content) => { let inner: String = content.iter().map(|c| c.render_html()).collect(); format!("<h{}>{}</h{}>", level, inner, level) } Node::Paragraph(children) => { let inner: String = children.iter().map(|c| c.render_html()).collect(); format!("<p>{}</p>", inner) } Node::Bold(children) => { let inner: String = children.iter().map(|c| c.render_html()).collect(); format!("<strong>{}</strong>", inner) } Node::Text(s) => html_escape::escape_text(s).to_string(), // 注意转义 HTML 特殊字符! // ... 处理其他节点类型 _ => String::new(), // 简化处理 } } }关键点:在输出 HTML 时,必须对纯文本内容进行转义(如将
<转成<),否则会引入安全漏洞(XSS)或破坏 HTML 结构。可以使用html_escape这类库。
至此,我们已经从概念上拆解了整个项目。接下来,我们进入更具体的 Rust 实现细节和那些容易踩坑的地方。
3. 核心实现:用 Rust 的强类型系统为编译器建模
有了清晰的流程,我们就可以开始用 Rust 的特性来优雅地实现它。这一部分,我们会看到 Rust 的枚举、模式匹配、迭代器和错误处理如何让编译器代码变得清晰且安全。
3.1 设计 Lexer:状态机与迭代器的结合
Lexer 需要遍历输入字符串。我们可以将其实现为一个结构体,内部保存输入字符串的字符迭代器和当前状态。
pub struct Lexer<'a> { input: std::str::Chars<'a>, // 字符迭代器 current_char: Option<char>, // 当前查看的字符 // 可能还需要一个缓冲区来暂存正在构建的文本令牌 buffer: String, } impl<'a> Lexer<'a> { pub fn new(input: &'a str) -> Self { let mut chars = input.chars(); let current_char = chars.next(); // 预读第一个字符 Lexer { input: chars, current_char, buffer: String::new(), } } // 核心方法:获取下一个令牌 pub fn next_token(&mut self) -> Option<Token> { self.skip_whitespace_except_newline(); // 跳过空格,但保留换行符 let c = self.current_char?; // 如果没字符了,返回 None match c { '#' => self.parse_heading(), '*' | '-' | '+' => self.parse_list_or_emphasis(c), '`' => self.parse_code(), '[' => Some(Token::LinkStart), '!' => self.parse_image(), '\n' => { self.consume_char(); Some(Token::Newline) } // ... 处理其他特殊字符 _ => self.parse_text(), // 默认情况是解析普通文本 } } // 辅助方法:消费当前字符,并预读下一个 fn consume_char(&mut self) -> Option<char> { let current = self.current_char; self.current_char = self.input.next(); current } // 解析文本,直到遇到特殊字符或换行 fn parse_text(&mut self) -> Option<Token> { self.buffer.clear(); while let Some(c) = self.current_char { if is_special_char(c) || c == '\n' { // `is_special_char` 需要你定义 break; } self.buffer.push(c); self.consume_char(); } if self.buffer.is_empty() { None } else { Some(Token::Text(self.buffer.clone())) } } // ... 其他 parse_xxx 方法 } // 为了让 Lexer 可以用在 for 循环里,可以实现 Iterator trait impl<'a> Iterator for Lexer<'a> { type Item = Token; fn next(&mut self) -> Option<Self::Item> { self.next_token() } }3.2 设计 Parser:递归下降与Result类型
Parser 会消费 Lexer 产出的令牌流。我们同样可以实现一个Parser结构体,内部包装一个Peekable<Lexer>,这样我们就可以方便地查看下一个令牌而不消耗它。
pub struct Parser<'a> { tokens: std::iter::Peekable<Lexer<'a>>, // 可能还需要一些状态,比如当前是否在段落内 } impl<'a> Parser<'a> { pub fn new(lexer: Lexer<'a>) -> Self { Parser { tokens: lexer.peekable(), } } pub fn parse_document(&mut self) -> Result<Node, ParseError> { let mut blocks = Vec::new(); while self.tokens.peek().is_some() { // 跳过连续的换行符 self.consume_while(|t| t == &Token::Newline); if let Some(block) = self.parse_block()? { blocks.push(block); } } Ok(Node::Document(blocks)) } fn parse_block(&mut self) -> Result<Option<Block>, ParseError> { let token = match self.tokens.peek() { Some(t) => t, None => return Ok(None), }; match token { Token::HeadingStart(_) => self.parse_heading().map(Some), Token::UnorderedListStart => self.parse_unordered_list().map(Some), Token::CodeBlockStart(_) => self.parse_code_block().map(Some), // ... 其他块级元素 _ => self.parse_paragraph().map(Some), // 默认按段落处理 } } fn parse_heading(&mut self) -> Result<Block, ParseError> { // 确认是 HeadingStart 令牌 if let Some(Token::HeadingStart(level)) = self.tokens.next() { // 消耗令牌后,解析行内内容,直到遇到换行或文件结束 let inlines = self.parse_inline_until(Token::Newline)?; Ok(Node::Heading(level, inlines)) } else { Err(ParseError::UnexpectedToken) } } fn parse_paragraph(&mut self) -> Result<Block, ParseError> { let mut inlines = Vec::new(); // 持续解析行内元素,直到遇到两个连续的换行符(或文件结束) while let Some(token) = self.tokens.peek() { if *token == Token::Newline { // 偷看下一个是不是也是换行符 let mut iter = self.tokens.clone(); // 克隆迭代器来偷看 iter.next(); // 跳过当前换行符 if iter.peek() == Some(&Token::Newline) { // 是连续两个换行,段落结束 break; } } // 否则,解析一个行内元素 if let Some(inline_node) = self.parse_inline()? { inlines.push(inline_node); } } // 消耗掉段落末尾的换行符 self.consume_while(|t| t == &Token::Newline); Ok(Node::Paragraph(inlines)) } // ... 其他 parse_xxx 方法 }错误处理:parse_xxx方法返回Result<T, ParseError>。ParseError可以是一个自定义的枚举,包含各种错误类型,如UnexpectedToken、UnclosedDelimiter等。这强迫调用者处理可能的错误,而不是让程序 panic。
3.3 渲染与集成:组合一切
最后,我们将所有部分组合起来。主函数可能像这样:
use std::fs; fn main() -> Result<(), Box<dyn std::error::Error>> { // 1. 读取 Markdown 文件 let markdown_input = fs::read_to_string("input.md")?; // 2. 词法分析 let lexer = Lexer::new(&markdown_input); // 3. 语法分析 let mut parser = Parser::new(lexer); let ast = parser.parse_document()?; // 4. 生成 HTML let html_output = ast.render_html(); // 5. 写入文件 fs::write("output.html", html_output)?; println!("转换成功!"); Ok(()) }4. 从“能跑”到“好用”:工程化与进阶思考
让一个基础版本运行起来,只是第一步。要让这个项目真正具有学习价值和实用价值,我们还需要考虑以下几个层面。
4.1 处理边缘情况与错误恢复
一个健壮的编译器不能因为一点格式问题就崩溃。我们的 Parser 需要有一定的错误恢复能力。
- 未闭合的标记:比如
**加粗没有闭合。简单的策略是,在段落或文档结束时,强制闭合所有未闭合的行内标记(并记录一个警告),或者将其视为普通文本。 - 嵌套冲突:Markdown 的嵌套规则有时模糊。例如,
*italic **bold** italic*是合理的,但**bold *italic** bold*则可能产生歧义。CommonMark 等规范有明确定义。实现时,可以遵循一个简单规则(如“先开始的后结束”),或者直接采用 CommonMark 的测试套件来验证。 - 性能考虑:对于大文件,频繁的字符串克隆(
clone())可能成为瓶颈。可以考虑使用&str切片来引用原始输入,而不是到处使用String。但这会使得生命周期管理变得复杂,对于学习项目,初期使用String换取简单性是值得的。
4.2 测试驱动开发
Rust 对测试的支持一流。为你的 Lexer、Parser 和 Renderer 编写单元测试,是保证代码质量、方便后续重构的最佳实践。
#[cfg(test)] mod tests { use super::*; #[test] fn test_lexer_heading() { let input = "# Hello"; let mut lexer = Lexer::new(input); assert_eq!(lexer.next(), Some(Token::HeadingStart(1))); assert_eq!(lexer.next(), Some(Token::Text("Hello".to_string()))); assert_eq!(lexer.next(), None); } #[test] fn test_parser_simple_paragraph() { let input = "Hello world."; let lexer = Lexer::new(input); let mut parser = Parser::new(lexer); let ast = parser.parse_document().unwrap(); // 断言 ast 的结构符合预期 match ast { Node::Document(blocks) => { assert_eq!(blocks.len(), 1); if let Node::Paragraph(inlines) = &blocks[0] { assert_eq!(inlines.len(), 1); if let Node::Text(text) = &inlines[0] { assert_eq!(text, "Hello world."); } else { panic!("Expected Text node"); } } else { panic!("Expected Paragraph node"); } } } } }你可以从 CommonMark 的官方示例中选取一些标准用例作为集成测试。
4.3 扩展功能:迈向“真正”的编译器
当基础功能稳定后,你可以尝试以下扩展,它们会带你接触更广泛的 Rust 生态和更复杂的设计模式:
- 支持 Front Matter:解析 Markdown 文件头部的 YAML 或 TOML 元数据。这会引入对
serde_yaml或toml库的使用。 - 集成语法高亮:对于代码块,使用
syntect或tree-sitter库进行语法高亮,生成带 CSS 类的 HTML。这会教你如何集成外部 crate 并处理其复杂的 API。 - 开发 CLI 工具:使用
clap或structopt库构建一个命令行工具,支持指定输入/输出文件、自定义模板等选项。 - 构建 Web 服务:使用
actix-web或warp框架,创建一个简单的 HTTP 服务,接受 Markdown 文本并返回 HTML。这会涉及异步编程和 Web 开发的基础。 - 编写 IDE 插件:虽然更复杂,但你可以尝试用 Rust 为 VS Code 等编辑器编写一个 LSP(Language Server Protocol)服务器,提供 Markdown 的实时预览或语法检查。
4.4 反思与收获:你真正学到了什么?
完成这个项目后,回过头看,你收获的远不止一份代码。你实践了:
- 问题分解:将“转换 Markdown”这个大问题,拆解为词法、语法、渲染等可解决的小问题。
- 类型驱动设计:用
enum和struct精确地描述领域模型(令牌、AST 节点),让非法状态无法表示。 - 错误处理哲学:使用
Result和自定义错误类型,让错误成为 API 的一部分,并被强制处理。 - 迭代器模式:用
Iterator抽象数据流,使 Lexer 和 Parser 的接口清晰且可组合。 - 测试的重要性:为解析逻辑编写测试,确保核心功能的正确性,并为重构提供安全保障。
这个“轮子”可能性能不如pulldown-cmark,功能不如comrak,但它的价值在于,它完全属于你。你清楚地知道每一行代码为何存在,每一个设计决策背后的权衡。这种通过实践获得的、对系统底层运作的理解,是任何现成库都无法给予的。
所以,如果你正在学习 Rust,并且已经厌倦了书本上的孤立的例子,不妨就从今天开始,动手实现你的 Markdown 编译器。从解析一个#号开始,一步步构建起你的系统。当你第一次看到自己编写的程序将一篇简单的 Markdown 转换成正确的 HTML 时,你会对“编程”和“Rust”有全新的、更坚实的认识。这不仅仅是学习一门语言,更是在学习如何思考、如何构建。
