尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Rust实战:从零构建Markdown编译器,掌握编译原理与工程实践

Rust实战:从零构建Markdown编译器,掌握编译原理与工程实践 如果你正在寻找一个既能系统学习 Rust 语言又能亲手做出一个实用工具的项目那么“写一个 Markdown 到 HTML 的编译器”可能是你当前能找到的最佳路径。这听起来像是一个简单的文本转换器但它的价值远不止于此它本质上是一个微型编译器项目能让你在 1000 行左右的代码里完整地体验 Rust 的核心特性、工程实践和编译原理的简化版流程。很多 Rust 新手会陷入“学语法-写算法题-迷茫”的循环或者被复杂的异步、生命周期直接劝退。这个项目巧妙地绕开了这些障碍它不涉及复杂的网络和并发却强制你使用Result处理错误、用enum和struct设计数据结构、用模式匹配match进行解析并用迭代器Iterator处理字符流——这些都是 Rust 的精华所在。当你完成时你不仅拥有了一个能用的md2html命令行工具更重要的是你理解了如何用 Rust 的思维方式去解决一个具体的、有明确输入输出的工程问题。本文将带你从零开始用 Rust 实现一个支持基础语法的 Markdown 编译器。我们会从最核心的词法分析和语法分析入手构建一个简单的抽象语法树AST最后将其渲染为 HTML。整个过程你会清晰地看到 Rust 如何保障内存安全、如何处理错误、如何利用强大的类型系统来构建健壮的程序。无论你是想入门 Rust还是想深入理解编译器前端的工作机制这篇文章都将提供一条清晰的实践路线。1. 为什么选择“Markdown 编译器”作为 Rust 入门项目在众多练手项目中编写 Markdown 编译器脱颖而出因为它完美平衡了学习价值和可实现性。学习价值层面它覆盖了 Rust 新手的核心痛点所有权与借用检查在解析字符串时你需要频繁地切片slice和创建新字符串这是理解 Rust 所有权机制的绝佳场景。编译器会时刻提醒你哪些数据可以借用哪些需要克隆clone。枚举Enum与模式匹配MatchMarkdown 的语法元素如标题、粗体、列表天然适合用枚举来表示。解析过程就是不断地匹配字符模式这让你不得不熟练掌握match表达式这是 Rust 最强大的控制流工具之一。错误处理文件可能不存在Markdown 格式可能错误。你需要使用ResultT, E和?操作符来优雅地传播和处理错误而不是到处使用panic!。迭代器Iterator逐字符或逐行处理文本是迭代器的典型用例。你将学会使用chars()、peekable()等方法并理解迭代器的惰性求值特性。模块化与测试你可以将词法分析、语法分析、渲染等逻辑拆分成不同的模块module并为每个部分编写单元测试体验 Rust 出色的工程化支持。可实现性层面它的目标明确且可控范围清晰我们首先实现一个子集如标题#、粗体**、斜体*、代码块、列表-或1.和链接[]()。这足以构建一个可用的工具。输入输出明确输入是.md文件输出是.html文件验证结果只需用浏览器打开即可反馈直观。算法适中核心是状态机或递归下降解析复杂度适中不会让初学者望而却步。相比之下写一个 Web 服务器可能过早陷入异步编程的复杂性而写一个算法库又过于抽象缺乏“造出一个东西”的成就感。这个项目正好填补了中间地带。2. 核心概念编译器前端的三步流程在动手之前我们需要建立一个简单的心理模型。一个完整的编译器很复杂但我们的 Markdown 编译器可以简化为经典的三步流程这同样是任何编程语言编译器前端的核心。Markdown 文本 (字符串) ↓ [词法分析 Lexical Analysis] ↓ 令牌流 (VecToken) ↓ [语法分析 Syntactic Analysis] ↓ 抽象语法树 (AST - Document) ↓ [渲染 Rendering] ↓ HTML 文本 (字符串)1. 词法分析Lexing / Tokenization任务将原始的字符流String切割成一个个有意义的“单词”称为令牌Token。类比就像英文阅读时将连续的字母分割成一个个独立的单词如 “The”, “quick”, “brown”。我们的实现扫描 Markdown 字符串识别出#、*、**、、-、[、]、(、)等特殊符号以及普通的文本内容并为它们打上标签生成一个Token的列表VecToken。2. 语法分析Parsing任务根据一组规则语法将扁平的令牌流组织成具有层次结构的树形表示即抽象语法树Abstract Syntax Tree, AST。类比根据语法规则将单词组合成短语、从句和句子。例如“名词 动词” 构成一个简单句。我们的实现读取Token流判断何时开始一个标题遇到#令牌何时结束如何将**文本**识别为一个加粗节点如何将连续的- 项目组织成一个列表节点。最终输出一个代表整个文档的Document根节点其下包含Heading、Paragraph、List等子节点。3. 渲染Rendering任务遍历 AST根据每个节点的类型生成目标格式HTML的字符串。类比将句子的语法结构翻译成另一种语言的对应表达。我们的实现为每种 AST 节点如Heading、Bold实现一个to_html()方法递归地拼接出最终的 HTML 字符串。理解这个流程你就掌握了编译器设计的核心思想。接下来我们用 Rust 将其实现。3. 环境准备与项目初始化首先确保你有一个可用的 Rust 开发环境。1. 安装 Rust如果你还没有安装 Rust请访问 rust-lang.org 并按照指示安装rustup。安装完成后在终端验证rustc --version cargo --version2. 创建新项目使用 CargoRust 的包管理和构建工具来创建我们的项目cargo new md2html --bin cd md2html这会创建一个名为md2html的二进制项目目录结构如下md2html/ ├── Cargo.toml # 项目配置和依赖声明 └── src/ └── main.rs # 程序入口3. 初始Cargo.toml配置我们暂时不需要额外的依赖库crateRust 的标准库std已经足够强大。但为了更好的错误信息我们稍后可能会引入thiserror或anyhow。现在保持Cargo.toml简洁[package] name md2html version 0.1.0 edition 2021 [dependencies] # 后续根据需要添加例如thiserror 1.0我们的核心逻辑将放在src/目录下的多个模块文件中。4. 定义数据结构Token 与 AST在开始解析之前我们必须先定义好“语言”。我们要用 Rust 的类型系统来精确描述 Markdown 的构成元素。1. 定义令牌Token在src/token.rs中定义// src/token.rs #[derive(Debug, PartialEq, Clone)] pub enum Token { // 标题 Hash, // # // 强调 Asterisk, // * (可能用于斜体或列表) DoubleAsterisk, // ** (用于粗体) // 代码 Backtick, // // 列表 Dash, // - // 链接和图片 LeftBracket, // [ RightBracket, // ] LeftParen, // ( RightParen, // ) // 其他 Newline, // \n // 文本内容非特殊符号的普通字符 Text(String), // 文件结束 Eof, }#[derive(Debug)]让我们可以打印调试信息PartialEq允许比较Clone允许复制。Token枚举清晰地列出了我们需要识别的所有基本符号。2. 定义抽象语法树AST节点AST 需要表示嵌套结构。我们在src/ast.rs中定义// src/ast.rs #[derive(Debug)] pub enum Node { // 文档根节点包含多个块级元素 Document(VecBlock), // 块级元素 Heading(usize, VecInline), // 级别1-6和内联内容 Paragraph(VecInline), List(ListType, VecListItem), // 列表类型列表项集合 CodeBlock(String, String), // 语言可选代码内容 // 内联元素可以嵌套在块级元素内 Text(String), Bold(VecInline), Italic(VecInline), CodeInline(String), Link { text: VecInline, url: String }, } // 列表类型 #[derive(Debug)] pub enum ListType { Ordered, Unordered, } // 列表项 #[derive(Debug)] pub struct ListItem(pub VecBlock); // 一个列表项可以包含多个块如段落、子列表 // 为方便起见定义类型别名 pub type Block Node; pub type Inline Node;这里的关键设计是Node枚举的递归结构Bold内部包含VecInline意味着粗体里面可以再有斜体、链接等。这完美对应了 Markdown 的嵌套特性例如**这是 *粗斜体* 文本**。3. 在main.rs中声明模块在src/main.rs开头声明我们创建的模块// src/main.rs mod token; mod ast; fn main() { println!(Hello, md2html!); }现在运行cargo run应该能成功打印 “Hello, md2html!”。我们的项目骨架已经搭好。5. 实现词法分析器Lexer词法分析器负责“读字符产令牌”。我们将实现一个简单的、基于迭代器的 Lexer。1. 创建 Lexer 结构体在src/lexer.rs中// src/lexer.rs use crate::token::Token; pub struct Lexer { chars: Vecchar, // 将输入字符串转换为字符向量 position: usize, // 当前读取位置 } impl Lexer { pub fn new(input: str) - Self { Lexer { chars: input.chars().collect(), position: 0, } } // 查看下一个字符但不移动位置 fn peek(self) - Optionchar { self.chars.get(self.position).copied() } // 消费当前字符并移动位置 fn consume(mut self) - Optionchar { let ch self.chars.get(self.position).copied(); if ch.is_some() { self.position 1; } ch } // 主函数获取下一个 Token pub fn next_token(mut self) - Token { // 跳过空白字符不包括换行因为换行是重要的 Token while let Some(ch) self.peek() { if ch.is_whitespace() ch ! \n { self.consume(); } else { break; } } match self.peek() { Some(#) { self.consume(); Token::Hash } Some(*) { self.consume(); // 检查是否是两个连续的 * if let Some(*) self.peek() { self.consume(); Token::DoubleAsterisk } else { Token::Asterisk } } Some() { self.consume(); Token::Backtick } Some(-) { self.consume(); Token::Dash } Some([) { self.consume(); Token::LeftBracket } Some(]) { self.consume(); Token::RightBracket } Some(() { self.consume(); Token::LeftParen } Some()) { self.consume(); Token::RightParen } Some(\n) { self.consume(); Token::Newline } Some(_) { // 处理文本一直读取直到遇到特殊字符或空白换行除外 let mut text String::new(); while let Some(ch) self.peek() { if !ch.is_whitespace() !Self::is_special_char(ch) { text.push(ch); self.consume(); } else { // 遇到特殊字符或空白包括换行文本结束 break; } } // 如果因为空白非换行停下需要把空白跳过已在循环前统一处理 Token::Text(text) } None Token::Eof, } } fn is_special_char(ch: char) - bool { matches!(ch, # | * | | - | [ | ] | ( | ) | \n) } }2. 为 Lexer 实现 Iterator为了方便我们可以让Lexer实现Iteratortrait这样就能用for token in lexer来遍历了。// 在 impl Lexer 块内或之后 impl Iterator for Lexer { type Item Token; fn next(mut self) - OptionSelf::Item { let token self.next_token(); match token { Token::Eof None, _ Some(token), } } }3. 测试 Lexer在src/lexer.rs末尾或单独的测试文件中添加测试#[cfg(test)] mod tests { use super::*; #[test] fn test_lexer_basic() { let input # Hello **World**; let mut lexer Lexer::new(input); assert_eq!(lexer.next_token(), Token::Hash); assert_eq!(lexer.next_token(), Token::Text(Hello.to_string())); assert_eq!(lexer.next_token(), Token::DoubleAsterisk); assert_eq!(lexer.next_token(), Token::Text(World.to_string())); assert_eq!(lexer.next_token(), Token::DoubleAsterisk); assert_eq!(lexer.next_token(), Token::Eof); } #[test] fn test_lexer_iterator() { let input - Item; let tokens: VecToken Lexer::new(input).collect(); assert_eq!( tokens, vec![ Token::Dash, Token::Text(Item.to_string()), Token::Eof, // 注意我们的 Iterator 实现在遇到 Eof 时返回 None所以 collect 不会包含 Eof。 // 需要调整 next() 逻辑或者直接使用 next_token 测试。这里为了简单先这样写。 ] ); } }运行cargo test来验证你的词法分析器是否正确工作。这个 Lexer 虽然简单但已经能识别我们定义的基础符号了。在实际项目中你可能需要处理转义字符\、行内代码的多个反引号等更复杂情况但当前版本足以让我们进入下一步。6. 实现语法分析器Parser语法分析器是项目的核心它需要理解令牌之间的关系并构建出 AST。我们将实现一个递归下降解析器这是手工编写解析器最直观的方法。1. 创建 Parser 结构体在src/parser.rs中// src/parser.rs use crate::ast::{Block, Document, Inline, ListItem, ListType, Node}; use crate::token::Token; pub struct Parser { tokens: VecToken, // 令牌流 position: usize, // 当前令牌索引 } impl Parser { pub fn new(tokens: VecToken) - Self { Parser { tokens, position: 0 } } fn peek(self) - OptionToken { self.tokens.get(self.position) } fn consume(mut self) - OptionToken { let token self.tokens.get(self.position); if token.is_some() { self.position 1; } token } // 期待某个特定的 Token如果匹配则消费否则报错或返回 None fn expect(mut self, expected: Token) - Result(), String { if let Some(token) self.peek() { if *token expected { self.consume(); Ok(()) } else { Err(format!(Expected {:?}, found {:?}, expected, token)) } } else { Err(format!(Expected {:?}, but reached EOF, expected)) } } }2. 解析入口文档Document文档由一系列块级元素Block组成以 EOF 结束。impl Parser { pub fn parse_document(mut self) - ResultNode, String { let mut blocks Vec::new(); while self.peek() ! Some(Token::Eof) { if let Some(block) self.parse_block()? { blocks.push(block); } else { // 无法解析为块可能是换行或未知内容消费掉继续 self.consume(); } } Ok(Node::Document(blocks)) } fn parse_block(mut self) - ResultOptionBlock, String { match self.peek() { Some(Token::Hash) self.parse_heading().map(Some), Some(Token::Dash) self.parse_list().map(Some), Some(Token::Backtick) { // 可能是行内代码或代码块简化处理如果连续三个 Backtick 则是代码块 // 这里先跳过实现一个简单的段落解析 self.parse_paragraph().map(Some) } _ self.parse_paragraph().map(Some), // 默认当作段落处理 } } }3. 解析标题标题以 1-6 个#开头后跟空格和内容。impl Parser { fn parse_heading(mut self) - ResultBlock, String { let mut level 0; // 计算 # 的数量 while let Some(Token::Hash) self.peek() { level 1; self.consume(); if level 6 { break; // Markdown 规范最多 6 级标题 } } // 期待一个空格或制表符这里简化 // 实际应跳过空白这里简单处理 // 解析标题文本直到换行或文件结束 let inlines self.parse_inline_until([Token::Newline, Token::Eof])?; // 消费掉换行符如果存在 if self.peek() Some(Token::Newline) { self.consume(); } Ok(Node::Heading(level, inlines)) } // 一个辅助函数解析内联元素直到遇到指定的终止 Token fn parse_inline_until(mut self, terminators: [Token]) - ResultVecInline, String { let mut inlines Vec::new(); while !terminators.contains(self.peek().unwrap_or(Token::Eof)) { if let Some(inline_node) self.parse_inline()? { inlines.push(inline_node); } else { // 无法解析为内联元素可能是普通文本或未知 Token这里简化处理为文本 if let Some(Token::Text(s)) self.consume() { inlines.push(Node::Text(s)); } else { // 消费其他非终止 Token如标点继续 self.consume(); } } } Ok(inlines) } }4. 解析段落和内联元素粗体、斜体、文本段落由一系列内联元素组成直到遇到两个连续换行或块级元素的开始。impl Parser { fn parse_paragraph(mut self) - ResultBlock, String { let inlines self.parse_inline_until([Token::Newline, Token::Eof])?; // 简单处理一个换行可能只是软换行我们这里遇到一个换行就结束段落。 // 更复杂的实现需要查看下一个 Token 是否是另一个块级元素的开始。 if self.peek() Some(Token::Newline) { self.consume(); } Ok(Node::Paragraph(inlines)) } fn parse_inline(mut self) - ResultOptionInline, String { match self.peek() { Some(Token::DoubleAsterisk) self.parse_bold().map(Some), Some(Token::Asterisk) self.parse_italic().map(Some), Some(Token::Text(_)) { // 普通文本 if let Some(Token::Text(s)) self.consume() { Ok(Some(Node::Text(s))) } else { Ok(None) } } _ Ok(None), // 其他 Token 不属于当前内联元素返回 None 让上层处理 } } fn parse_bold(mut self) - ResultInline, String { self.expect(Token::DoubleAsterisk)?; let content self.parse_inline_until([Token::DoubleAsterisk])?; self.expect(Token::DoubleAsterisk)?; Ok(Node::Bold(content)) } fn parse_italic(mut self) - ResultInline, String { self.expect(Token::Asterisk)?; let content self.parse_inline_until([Token::Asterisk])?; self.expect(Token::Asterisk)?; Ok(Node::Italic(content)) } }注意这个内联解析器是简化的它无法处理嵌套如粗体中的斜体和*作为普通文本的情况。一个健壮的解析器需要更复杂的状态管理例如查看*后面是否有空格来决定是列表还是强调。5. 解析列表列表解析相对复杂因为它可能跨越多行并且支持嵌套。我们实现一个基础的无序列表。impl Parser { fn parse_list(mut self) - ResultBlock, String { let mut items Vec::new(); // 持续解析列表项直到遇到非 Dash Token或后跟空格的行 while let Some(Token::Dash) self.peek() { items.push(self.parse_list_item()?); // 消费列表项后的换行 if self.peek() Some(Token::Newline) { self.consume(); } else { break; } } Ok(Node::List(ListType::Unordered, items)) } fn parse_list_item(mut self) - ResultListItem, String { self.expect(Token::Dash)?; // 列表项内容可以包含多个内联元素直到换行 // 简化将列表项内容视为一个段落 let inlines self.parse_inline_until([Token::Newline, Token::Eof])?; // 一个列表项在 AST 中通常包含一个块如段落 let paragraph Node::Paragraph(inlines); Ok(ListItem(vec![paragraph])) } }6. 测试 Parser添加测试来验证解析逻辑#[cfg(test)] mod tests { use super::*; use crate::lexer::Lexer; #[test] fn test_parse_heading() { let input # Hello World\n; let tokens: VecToken Lexer::new(input).collect(); // 注意我们的 Lexer Iterator 不包含 Eof需要手动添加或调整 Parser // 这里简化直接使用 next_token 逻辑。为了测试我们调整 Parser 使其能处理没有 Eof 的情况。 let mut parser Parser::new(tokens); let doc parser.parse_document().unwrap(); // 应能解析出一个 Document里面有一个 Heading(1, [Text(Hello World)]) println!({:?}, doc); // 此处应有具体的 assert 断言篇幅所限省略细节 } }运行cargo test来确保解析器能处理基本用例。解析器是编译器中最容易出 bug 的部分需要大量的测试用例覆盖边界情况。7. 实现 HTML 渲染器Renderer有了 AST渲染成 HTML 就相对直接了深度优先遍历树为每种节点生成对应的 HTML 标签。1. 创建 Renderer在src/render.rs中// src/render.rs use crate::ast::{Block, Document, Inline, ListItem, ListType, Node}; pub struct HtmlRenderer; impl HtmlRenderer { pub fn render(document: Node) - String { let mut output String::new(); Self::render_node(document, mut output); output } fn render_node(node: Node, output: mut String) { match node { Node::Document(blocks) { for block in blocks { Self::render_node(block, output); } } Node::Heading(level, inlines) { let tag format!(h{}, level.min(6).max(1)); // 确保级别在 1-6 output.push_str(format!({}, tag)); Self::render_inlines(inlines, output); output.push_str(format!(/{}\n, tag)); } Node::Paragraph(inlines) { output.push_str(p); Self::render_inlines(inlines, output); output.push_str(/p\n); } Node::List(list_type, items) { let tag match list_type { ListType::Ordered ol, ListType::Unordered ul, }; output.push_str(format!({}\n, tag)); for item in items { output.push_str(li); // ListItem 包含 VecBlock for block in item.0 { Self::render_node(block, output); } output.push_str(/li\n); } output.push_str(format!(/{}\n, tag)); } Node::Bold(inlines) { output.push_str(strong); Self::render_inlines(inlines, output); output.push_str(/strong); } Node::Italic(inlines) { output.push_str(em); Self::render_inlines(inlines, output); output.push_str(/em); } Node::Text(text) { // 需要对 HTML 特殊字符进行转义例如 , , 等 let escaped Self::escape_html(text); output.push_str(escaped); } // 处理其他节点类型CodeBlock, CodeInline, Link 等 _ { // 暂未实现输出占位符或忽略 output.push_str(!-- unsupported node --); } } } fn render_inlines(inlines: [Inline], output: mut String) { for inline in inlines { Self::render_node(inline, output); } } fn escape_html(text: str) - String { text.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(, quot;) .replace(\, #39;) } }2. 生成完整的 HTML 文档目前render函数只渲染了 body 部分的内容。一个完整的 HTML 页面还需要!DOCTYPE html、html、head、body等标签。我们可以提供一个包装函数impl HtmlRenderer { pub fn render_full_html(document: Node) - String { let body_content Self::render(document); format!( r#!DOCTYPE html html langen head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleConverted Markdown/title /head body {} /body /html#, body_content ) } }8. 整合与命令行接口最后我们需要将词法分析、语法分析和渲染串联起来并提供一个命令行接口。1. 更新src/main.rs// src/main.rs mod ast; mod lexer; mod parser; mod render; mod token; use std::fs; use std::path::Path; fn main() - Result(), Boxdyn std::error::Error { // 1. 读取命令行参数 let args: VecString std::env::args().collect(); if args.len() ! 3 { eprintln!(Usage: {} input.md output.html, args[0]); std::process::exit(1); } let input_path args[1]; let output_path args[2]; // 2. 读取 Markdown 文件 let markdown_content fs::read_to_string(input_path)?; // 3. 词法分析 let lexer lexer::Lexer::new(markdown_content); let tokens: Vectoken::Token lexer.collect(); // 注意我们的 Lexer Iterator 没有包含 Eof需要手动添加以便 Parser 知道结束 // 简化处理Parser 可以以 tokens 为空作为结束。这里我们调整 Parser 逻辑使其不依赖 Eof。 // 为了简单我们在 tokens 末尾 push 一个 Eof。 // let mut tokens_with_eof tokens; // tokens_with_eof.push(token::Token::Eof); // 4. 语法分析 let mut parser parser::Parser::new(tokens); let ast parser.parse_document().map_err(|e| { eprintln!(Parse error: {}, e); std::process::exit(1); })?; // 5. 渲染为 HTML let html_output render::HtmlRenderer::render_full_html(ast); // 6. 写入输出文件 fs::write(output_path, html_output)?; println!(Successfully converted {} to {}, input_path, output_path); Ok(()) }2. 创建示例 Markdown 文件在项目根目录创建example.md# My First Markdown Document This is a **bold statement** and this is *italic text*. ## A List of Items - First item - Second item with **bold** - Third item Thats all for now.3. 运行并测试在终端中执行cargo run -- example.md output.html如果一切顺利你会看到Successfully converted example.md to output.html的消息。用浏览器打开output.html你应该能看到渲染后的 HTML 页面。9. 常见问题与排查思路在实现和运行过程中你可能会遇到以下典型问题问题现象可能原因排查方式解决方案cargo run编译失败提示未定义模块或类型1. 模块文件未在main.rs中声明。2. 文件路径或命名错误。3. 使用了未导入的 crate。1. 检查src/main.rs开头的mod声明。2. 确认src/目录下是否存在对应的.rs文件。3. 检查Cargo.toml的[dependencies]和代码中的use语句。1. 添加mod your_module;。2. 确保文件名和模块名一致Rust 模块名通常为蛇形命名如lexer.rs。3. 添加依赖或导入。解析结果不符合预期例如粗体文本未被识别1. 词法分析器未正确识别连续的**。2. 语法分析器的parse_inline_until终止条件过早或过晚。3. Token 流在解析过程中被意外消费。1. 编写单元测试输入**text**打印出 Token 序列。2. 在解析函数中添加调试打印输出当前 Token 和状态。3. 使用dbg!()宏或 IDE 调试器逐步跟踪。1. 检查 Lexer 的next_token中对*和**的处理逻辑。2. 调整parse_inline_until的终止 Token 列表。3. 确保expect和consume的调用符合预期。生成的 HTML 标签未正确闭合或嵌套错误1. 渲染器递归逻辑错误导致标签顺序错乱。2. AST 结构本身有误例如列表项内包含了错误的节点类型。1. 输出 AST 的调试表示 (println!({:?}, ast))检查树形结构是否正确。2. 对比简单输入如# Title的预期 HTML 和实际 HTML。1. 修正render_node中对应节点的 HTML 标签生成和闭合顺序。2. 修正语法分析器确保生成的 AST 符合设计。处理包含特殊字符如,的文本时HTML 显示异常未对文本内容进行 HTML 转义。检查浏览器中显示的 HTML 源代码看是否被直接输出。在render.rs的escape_html函数中确保转义了所有 HTML 特殊字符 (,,,,)。程序 panic提示索引越界在Parser或Lexer中访问tokens或chars向量时未检查position是否越界。查看 panic 的堆栈信息定位到具体文件和行号。在peek()和consume()等方法中使用self.tokens.get(self.position)而不是self.tokens[self.position]后者会 panic。返回Option并让调用者处理None情况。调试建议多写单元测试为Lexer、Parser的每个函数编写小型测试这是 Rust 项目稳健的基础。使用dbg!()在关键位置插入dbg!(variable)来打印变量的值这是快速调试的利器。检查 AST在解析完成后立即打印整个 AST (println!({:#?}, ast))直观地查看程序是如何理解你的 Markdown 的。10. 项目扩展与最佳实践完成基础版本后你可以从以下几个方向深化这个项目这能让你学到更多 Rust 和编译器的知识1. 支持更完整的 Markdown 语法链接与图片解析[text](url)和![alt](src)。这需要 Lexer 识别!Parser 能处理[、]、(、)的配对。代码块与行内代码处理 包裹的代码块可指定语言和包裹的行内代码。注意代码块内的内容应原样输出不进行 Markdown 解析。引用块解析开头的行。水平线解析---或***。转义字符处理\*、\#等使其不被解释为特殊符号。2. 提升解析器的健壮性错误恢复当前的解析器在遇到意外 Token 时会直接返回错误。一个成熟的编译器应能尝试恢复如跳过当前 token 或行并继续解析报告多个错误。更精确的语法定义使用pub struct和impl为每种语法规则如HeadingRule、ListRule定义独立的解析方法提高代码可读性和可测试性。处理空白和换行Markdown 中换行和空格的含义很微妙如两个空格加换行是硬换行。需要更精细地处理Token::Newline和空白。3. 工程化改进使用thiserror或anyhow用thiserror定义丰富的错误类型或用anyhow简化错误传播替代简单的String错误。性能优化使用str切片而非String克隆来减少内存分配。考虑使用PeekableChars迭代器而不是Vecchar。编写集成测试创建tests/目录添加.md文件作为输入并对比生成的.html文件与预期输出是否一致。添加命令行参数使用clap或structopt库来支持更丰富的命令行选项如指定输出格式、是否生成完整 HTML 文档、递归转换目录等。4. 学习编译器理论了解更正式的解析技术如 Pratt 解析器适用于表达式解析或使用解析器组合子库如nom或pest来重写你的解析器。尝试其他输出格式除了 HTML可以尝试渲染为 ANSI 彩色终端输出、PDF通过其他库或另一种标记语言如 AsciiDoc。思考语义分析虽然 Markdown 简单但你可以思考如何为文档添加“目录提取”、“内部链接验证”等需要理解文档结构的功能。通过这个项目你实践了 Rust 的核心语法、模块化设计、错误处理和测试。更重要的是你亲手实现了一个真实可用的工具理解了从字符到令牌再到树最后到目标代码的完整编译流程。这种从理论到实践的闭环是单纯看书或做练习题难以获得的。建议你将代码托管到 GitHub并撰写详细的 README这不仅是学习的记录也是你技术 portfolio 中一个扎实的项目。
返回列表