// Created At 2026-07-13// P1
// Go · Compiler

从零实现词法分析器(三):让指针动起来,启动 Lexer 传送带

前两篇我们准备好了 Token 组件和统一符号映射表。这一篇,我们将正式实现整个词法分析的核心引擎——Lexer(词法分析器),让程序能够真正“阅读”文本,并将它切分成一条源源不断的 Token 流。

一、核心思想:Lexer 就像一条传送带

在动手写代码前,我们先要在脑海里建立一个物理模型。词法分析器(Lexer)的运作方式,极其类似于工厂里的扫描传送带

  1. 输入文本被平铺在传送带上,每个字符(rune)占据一个格子。
  2. 有一个光标(指针),指向当前正在观察的字符。
  3. Lexer 的工作就是:
  • 瞧一眼当前光标指着的字符是什么(peek)。
  • 如果是一个符号(比如左括号 ),把它打包成 Token,然后光标往后挪一格(advance)。
  • 如果是一串连续的普通文字(比如 贝利亚奥特曼),就把它们拼在一起打包成一个 TEXT Token,直到撞上符号再停下来。

二、定义 Lexer 结构体

internal/parser/ 目录下创建 lexer.go。我们需要三个基础字段来维护这条“传送带”的状态:

package parser

// Lexer 词法分析器结构体
type Lexer struct {
	input    []rune // 待扫描的完整文本(转为 rune 切片,完美支持中文与 Emoji)
	position int    // 当前扫描到的字符索引位置
	line     int    // 当前行号(从 1 开始,用于后续报错提示)
}

// NewLexer 创建一个词法分析器实例
func NewLexer(input string) *Lexer {
	return &Lexer{
		input:    []rune(input), // 将 string 转为 []rune 存储
		position: 0,
		line:     1,
	}
}

为什么用 []rune(input) 提前转换?

我们在上一篇强调过,Go 的 string 底层是字节流(byte)。如果每次读取都去算字节,处理中文会非常痛苦。我们在初始化时直接将它转为 []rune 数组,之后光标的 position++ 移动的每一个单位,就稳稳当当地代表一个独立的中文字符、英文字母或 Emoji

三、传送带的三驾马车:基础辅助方法

为了操纵这个光标,我们需要实现三个最基本的辅助方法:检查结束、瞅一眼、往前移。

// isEOF 检查光标是否已经走到了文件末尾 (End of File)
func (l *Lexer) isEOF() bool {
	return l.position >= len(l.input)
}

// peek 瞅一眼当前位置的字符,但【绝不挪动光标】
// 如果已经到末尾,返回 0
func (l *Lexer) peek() rune {
	if l.isEOF() {
		return 0
	}
	return l.input[l.position]
}

// advance 消费当前字符:读取并返回它,同时【将光标向后移动一位】
// 特别注意:如果消费的是换行符 '\n',顺手将行号 line++
func (l *Lexer) advance() rune {
	if l.isEOF() {
		return 0
	}
	ch := l.input[l.position]
	if ch == '\n' {
		l.line++
	}
	l.position++
	return ch
}

有了这三个基础方法,我们的光标就能安全、自由地在文本数组中穿梭了。

四、跳过无意义的空白

.meph 文件中,用户可能会在括号两边打上空格或缩进,比如 【角色名】

这些空格没有实际语义,Lexer 应该在识别下一个 Token 之前,默默把它们吞掉。

因为在同一个包(package parser)内部,我们直接越过复杂的函数封装,利用 Go 原生的 map 匹配来跳过空白:

// skipWhitespace 跳过无意义的空白字符(空格、制表符、回车符)
// 注意:千万不能跳过换行符 '\n',因为换行在我们的语法里代表“区块标题结束”
func (l *Lexer) skipWhitespace() {
	for !l.isEOF() {
		info, ok := symbolMap[l.peek()]
		// 如果在符号表里,且 Category 是 whitespace,就消费掉它
		if !ok || info.Category != "whitespace" {
			break
		}
		l.advance() // 光标无情后移
	}
}

五、核心逻辑:实现 NextToken() 分发器

现在,一切准备就绪!我们来实现上一篇预告过的、被数据驱动重构到极致的 NextToken()

它的职责是:跳过空白,盯着光标位置。如果是符号表里的符号,一律直接消费并返回;如果表里找不到,那它必然是普通文本!

// NextToken 获取下一个 Token
func (l *Lexer) NextToken() Token {
	// 1. 每次进来,先清洗掉前面的无意义空格
	l.skipWhitespace()

	// 2. 查看当前光标指着谁
	ch := l.peek()

	// 3. 边界处理:如果到文件末尾了,吐出 EOF 哨兵 Token
	if ch == 0 {
		return Token{Type: TOKEN_EOF, Literal: "", Line: l.line}
	}

	// 4. 🚀 终极查表驱动:因为在同包内,直接匹配 symbolMap!
	if info, ok := symbolMap[ch]; ok {
		l.advance() // 是符号?光标前进,消费它!
		return Token{Type: info.TokenType, Literal: string(ch), Line: l.line}
	}

	// 5. 符号表里查不到?那它一定是普通文本文字(如 "贝利亚奥特曼")
	return l.readText()
}

你看!得益于同包内直接查表的设计,整个分发核心干净得让人感动。这里没有任何复杂的条件分支,更去掉了不必要的套娃函数调用。无论未来你的语法扩充到有多少种符号,这个 NextToken() 的大框架都稳如磐石,永远不需要修改一行。

六、文本的贪婪读取:readText()

当字符不在符号表里时(比如碰到了中文汉字 ),Lexer 应该开启“贪婪模式”:只要后面接下来的字符不是符号,就一路把它们全部吞下,拼成一个长字符串。

有了上一篇建立的统一符号表,判定“什么时候该停下来”也变得不可思议的优雅:只要 peek() 到的字符能在 symbolMap 里匹配成功,就说明撞到了符号(比如换行符或括号),它天然就是分隔符,文本读取立刻停止!

// readText 读取一段连续的普通文本
// 停止条件:遇到符号表中的任意符号(如换行符、括号、冒号)或文件结束
func (l *Lexer) readText() Token {
	// 记录起始位置
	start := l.position

	for !l.isEOF() {
		// 🌟 降维打击:直接查表!只要当前字符在符号表里,说明撞墙了,立刻停下
		_, ok := symbolMap[l.peek()]
		if ok {
			break
		}
		l.advance() // 否则,继续快乐地吞噬文字
	}

	// 利用切片,把这段光标走过的 rune 范围直接转成字符串字面量
	literal := string(l.input[start:l.position])
	return Token{Type: TOKEN_TEXT, Literal: literal, Line: l.line}
}

七、大功告成:在 main.go 中验证成果

让我们把现在的 main.go 升级一下,用我们亲手写的 Lexer 去解析测试文件,看看它能不能顺利吐出我们要的零件流:

package main

import (
	"fmt"
	"mephisto/internal/parser" // 🌟 纠正为您实际的项目路径
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("用法: mephisto <文件>")
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf("读取文件失败: %v\n", err)
		os.Exit(1)
	}

	// 1. 初始化我们的传送带 Lexer
	l := parser.NewLexer(string(content))

	// 2. 循环驱动传送带,直到撞上 TOKEN_EOF
	fmt.Printf("%-5s | %-20s | %s\n", "行号", "类型", "字面量")
	fmt.Println("------+----------------------+-----------")
	for {
		tok := l.NextToken()
		// 为了防止换行符 \n 导致终端实际换行破坏表格,打印时做个转换处理
		literal := tok.Literal
		if tok.Type == parser.TOKEN_NEWLINE {
			literal = "\\n"
		}

		fmt.Printf("%-5d | %-20s | %s\n", tok.Line, tok.Type, literal)

		if tok.Type == parser.TOKEN_EOF {
			break
		}
	}
}

再次运行我们的测试文件(testdata/sample.meph):

go run main.go testdata/sample.meph

终端将会输出一行极其漂亮、对齐完美、极具工业美感的词法流结果:

行号    | 类型                 | 字面量
------+----------------------+-----------
1     | LEFT_BRACKET         | 【
1     | TEXT                 | 角色名
1     | RIGHT_BRACKET        | 】
1     | NEWLINE              | \n
2     | TEXT                 | 贝利亚奥特曼
3     | EOF                  |

看!计算机通过我们写的 Lexer,成功把一串冰冷的、毫无结构的原始字节,变成了一个个生动的、自带行号和类型的结构化 Token 块!

八、小结

到这一篇为止,我们的 Lexer 词法分析器已经完全体诞生了

做了什么 为什么
设计了光标状态机(position 建立了多字节文本遍历的底层传送带模型
实现了纯查表驱动的 NextToken() 贯彻了数据驱动思想,去掉了多余函数,让符号分发效率达到极致
实现了极其精简的 readText() 截断机制 只要 peek 字符在 symbolMap 中即自动作为边界,消灭了所有零散的判断逻辑

至此,词法分析的战役完美结束。

下一篇预告: 当 Token 流源源不断地从 Lexer 中产出时,我们要把它从“一维”的平铺结构变成“二维”的嵌套结构——用 AST(抽象语法树) 把区块之间的层级关系真正建立起来!敬请期待。

如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
© 2026 MOONGATE