做一个记住位置的词法分析器
目标
做出一个词法分析器,把 mini 语言的源码切分成词法单元列表。给每个词法单元附上种类、文本、行和列,对不认识的字符、没有闭合的注释、过大的整数,都按起始位置报告。
为什么重要
后面所有阶段都继承词法分析器给出的位置。语法分析器的“这里应该是 ;”、语义分析的“没有这个名字”、运行中的“除以 0”,全都是用这套坐标标出来的。如果列按字节数,或者从 0 开始数,含有韩文注释的那一行里所有错误都会指向莫名其妙的地方。而且词法分析器是唯一把整个输入扫描一遍的阶段,这里慢,整体就慢。
词法单元的规则
Token(kind, text, line, col) namedtuple. line·col 은 1부터, col 은 글자(코드 포인트) 단위.
탭도 한 칸, \r 도 한 칸. 줄바꿈(\n)을 지나면 줄 +1, 칸 1.
공백 ' ' '\t' '\r' '\n' 은 버린다
주석 // 부터 줄 끝까지 · /* 부터 */ 까지(겹치지 않는다: /* /* */ 에서 끝난다)
정수 ASCII 숫자 하나 이상. kind "INT", text 는 쓴 그대로("007"). 2^63-1 을 넘으면 오류
이름 ASCII 글자나 _ 로 시작, 그 뒤 ASCII 글자·숫자·_. 키워드면 kind 가 그 키워드, 아니면 "IDENT"
키워드 let fn return if else while print true false
기호 두 글자 == != <= >= && || 를 먼저 보고, 한 글자 + - * / % ^ ( ) { } , ; = < > !
끝 마지막에 늘 Token("EOF", "", 줄, 칸) — 마지막 글자 바로 뒤의 위치
오류 LexError(줄, 칸, 메시지). str() 은 "줄:칸: 메시지"
unexpected character '@' (repr 로 감싼 그 글자, 그 글자의 위치)
unterminated comment (그 /* 의 위치)
integer literal too large (그 숫자의 첫 칸)
步骤
- 补全
/root/mini/lexer.py中的Token、LexError(line, col, message)和Cursor(src)。游标持有src、i(下一个字符的位置)、line和col,并提供peek(ahead=0)(越过末尾则返回空字符'')、advance()(返回一个字符并移动行和列)以及at_end()。 - 补全
skip_trivia(cur)——跳过空白和注释,在第一个不是注释的字符之前停下。没有闭合的/*以它的起始位置抛出unterminated comment。 - 补全
read_number(cur)和read_word(cur)——在游标位于数字或名字的第一个字符时调用,返回词法单元,并把游标移到它的末尾。同时补全KEYWORDS。 - 补全
read_operator(cur)以及TWO_CHAR和ONE_CHAR——先看两个字符的符号,遇到不认识的字符就在那个位置抛出错误。 - 补全
tokenize(src)——跳过空白,用第一个字符来选择读数字、名字还是符号,如此重复直到结束,最后附上 EOF。评分器会对照固定程序的完整词法单元列表。 - 在错误程序(
/opt/fixtures/mini/errors/lex-*.mini)中,确认错误文字与参考逐字符一致。其中有夹杂韩文的注释之后的列号,还有韩文名字(不是 ASCII,所以是不认识的字符)。 - 评分器会把 300 份随机词法单元杂烩(混有空白、换行、\r\n 和韩文注释)与参考对照。
- 评分器会把输入放大到四倍来测量时间。如果词法分析器只扫描一遍,应该在 4 倍上下。
参考
- 用
python3 /root/mini/mini.py tokens /opt/fixtures/mini/programs/hello.mini可以看到你的词法分析器输出的词法单元。 - 常见错误:列从 0 开始数,换行之后没有把列恢复为 1,忘记
str.isalnum()对韩文和 é 也为真,把letter切成let,把 EOF 位置设为 (0, 0)。 - 会话从 60 分钟开始,可以用+时间延长,结束后
/root/mini会消失。
数行和列的游标
游标只移动一个位置编号 i。在 advance 中,如果刚刚经过的字符是换行,就把行加一、列置为 1,否则只把列加一。Python 字符串本来就按字符计,直接使用 len 和下标,列就是按字符计数的。
跳过空白和注释
同时看当前字符和下一个字符(peek(1))。遇到 /* 时,先记下那个位置的行和列,再往前走,直到找到 */。如果走到末尾,就用记下的位置报错。只有一个 “/” 就不是注释,要停下。
整数、名字和关键字
记下起始位置,在条件成立期间一直 advance。名字要读完之后,再看它是否在 KEYWORDS 中。要排除韩文和 é,就同时使用 isascii() 和 isalnum()。整数如果 int(text) 大于 2 ** 63 - 1,就以第一列报错。
先切最长的符号
如果 peek() + peek(1) 在两个字符的符号列表中,就 advance 两次。否则查看一个字符的列表。两者都不是,就用 “unexpected character %r” 报告,把那个字符用 repr 括起来(单独一个 & 或单独一个 | 也是不认识的字符)。
用 tokenize 连起来
每次循环先调用 skip_trivia,到了末尾就附上 EOF 并返回。第一个字符是 ASCII 数字就用 read_number,是 ASCII 字母或 _ 就用 read_word,其余的用 read_operator。EOF 的位置就是 skip_trivia 停下的那个位置。
把错误标在原位
没有新代码。如果没通过,看消息里的行:列。如果在韩文注释之后列号错位,说明是按字节数的;如果接受了韩文名字,说明只检查了 isalpha。
用随机词法单元杂烩对照
没有新代码。随机输入里混有 \r\n 换行。\r 不是换行,而是占一列的空白——如果在 \r 处把行加一,行号就会翻倍。
测量是否只扫描了一遍
没有新代码。输入是 4 倍时,如果时间接近 16 倍,说明某处在每个字符上都复制了全部剩余字符(src[i:]),或者每次都从头重新数(src[:i].count('\n'))。