词法器把源码切成供语法分析使用的 token,同时保留足够的信息支持报错、格式化和编辑。设计的重点是明确输入编码、规则优先级、位置单位、错误行为,以及谁拥有被引用的源码。
Token:分类、原文与位置分工
一种适合不可变源码快照的表示如下。示例中的整数是十进制数字串,运算符只列出两种;实际语言可继续扩展。
#[derive(Clone, Copy)]
struct Span {
file: u32,
revision: u64,
start: usize,
end: usize, }
enum Operator { Add, Subtract }
enum TokenKind {
Identifier,
Integer,
Operator(Operator),
Eof,
Invalid,
}
struct Token {
kind: TokenKind,
span: Span,
}
原文可以通过 span 从仍存活的快照切片,无须每个 token 再存一份字符串。也可以直接借用文本,或把标识符驻留为 SymbolId;要保证引用不超出源缓冲生命周期。字符串的原始拼写与转义后的值应区分,数值的语法识别与目标类型溢出检查也可以分阶段。
parser 能通过 Operator(Add) 判断加号,并不要求每种运算符必须是顶层枚举变体。粒度应服务于使用方,不能由某个 DFA 状态数量反推唯一表示。
匹配规则:关键字只是分类策略之一
优先取最长匹配,再解决等长冲突。ifx 应作为完整标识符,不能先切成关键字 if 和 x。
关键字既可以直接写入生成器规则,也可以先识别标识符,再查关键字表。前者由自动机共享公共前缀,后者方便集中管理语言版本和保留字。普通哈希表、完美哈希、排序表或按长度分支都有适用场景;少量关键字不意味着必然状态爆炸。
上下文关键字可能先保留为 Identifier,再由 parser 判断其位置是否合法。原始标识符、字符串插值、缩进 token 和嵌套注释则可能要求词法状态、状态栈或计数器。lexer 与 parser 的边界是语言设计和实现的选择,并非所有规则都能单靠一个正则完成。
Span:绑定快照,使用半开区间
[start, end) 方便计算长度和切片,空区间也能表示缺失 token 的插入位置。字节偏移只在所属源码版本中有效:在前面插入一个字符,后面的偏移会变化。IDE 必须用新快照重新解析,或通过编辑映射换算,不能把旧 offset 当成跨版本稳定身份。
行首表存每一行开始的字节偏移,通常以 0 开头。查询位置 offset 所在行,应找最后一个不大于 offset 的行首,而不是要求 offset 恰好命中某个行首。
from bisect import bisect_right
def byte_line_column(line_starts, offset):
row = bisect_right(line_starts, offset) - 1
return row + 1, offset - line_starts[row] + 1
assert byte_line_column([0, 6], 4) == (1, 5)
assert byte_line_column([0, 6], 6) == (2, 1)
这是字节列,不是 Unicode 码位列、UTF-16 列或屏幕列。制表符、组合字符和宽字符还会影响显示宽度。与编辑器协议交换位置时,应按协商编码转换;LSP 的相关约定见 语言服务器协议。CRLF 如何计为一次换行、文件末尾空行如何表示,也要在源文件管理层统一。
迭代接口与前瞻缓存
基础接口可以只是 next_token()。parser 的 token 缓冲层再提供 peek、advance 和 expect。首次 peek 可以触发扫描,后续 peek 复用缓存;无须为了宣称 peek 为 O(1),强制所有扫描都提前发生。
固定 k 个 token 的前瞻可以使用小队列。支持试探解析时,checkpoint 应覆盖 token 游标和相关 parser 状态;如果 lexer 会被 parser 切换模式,回退还需恢复模式或保证缓存兼容。不能只退一个字节游标就认为全部状态已还原。
EOF 应是稳定的终止状态。上层在 EOF 反复 peek 不应触发越界;恢复循环也不能把重复收到 EOF 误当作取得进展。
输入管理:整个文件与流式窗口都合理
普通源文件常直接读入不可变缓冲,便于 span 切片、多次分析与诊断。读取整个文件是有效设计,是否占用过多内存取决于项目规模、并发文件数和保留策略。
流式扫描可减少常驻输入,但 token 完全可能跨缓冲边界。实现必须保留未完成 token 和回退区间,必要时扩容、搬移或复制;长字符串不能因为超过 4 KiB 就天然失去合法性。
双缓冲也不自动意味着 I/O 与扫描并行,并行需要明确的异步或线程机制。mmap 提供按需映射,但不免除文件生命周期、映射失效与随机访存成本的考虑。re2c 将缓冲补充与边界检查留给集成代码配置。re2c 输入处理
输入块结束不是 token 结束,也不一定是字符结束
假设输入第一块恰好是 if。现在不能立即输出关键字,因为下一块可能以 x 开头,使完整 token 成为 ifx。输入块也可能停在中这个字符的三个 UTF-8 字节之间;解码器需要等待续字节,词法器则需要等待足以确定 token 边界的字符,两层状态各有职责。
下面实现很小的批量 feed 接口:标识符限定 ASCII,关键字只有 if,整数只接受十进制数字;运算符为 = 和 ==,空白仅空格/制表/CR/LF。其他合法 Unicode 码点产出 Invalid,非法 UTF-8 则终止并抛出解码错误。不是把中或 emoji 当作“非法 UTF-8”。
import codecs
import re
class StreamingLexer:
def __init__(self):
self.decoder = codecs.getincrementaldecoder("utf-8")("strict")
self.pending = ""
self.offset = 0
self.closed = False
def feed(self, data, final=False):
if self.closed:
raise ValueError("input already finalized")
self.pending += self.decoder.decode(data, final=final)
result = []
while self.pending:
first = self.pending[0]
match = re.match(r"[A-Za-z_][A-Za-z_0-9]*|[0-9]+", self.pending)
if match:
text = match.group()
if len(text) == len(self.pending) and not final:
break kind = "If" if text == "if" else "Integer" if text[0].isdigit() else "Identifier"
elif first == "=":
if len(self.pending) == 1 and not final:
break text = "==" if self.pending.startswith("==") else "="
kind = "Equal" if text == "==" else "Assign"
else:
text = first
kind = "Whitespace" if first in " \t\r\n" else "Invalid"
end = self.offset + len(text.encode("utf-8"))
if kind != "Whitespace":
result.append((kind, text, self.offset, end))
self.offset = end
self.pending = self.pending[len(text):]
if final:
self.closed = True
result.append(("Eof", "", self.offset, self.offset))
return result
def lex_chunks(chunks):
lexer, result = StreamingLexer(), []
for chunk in chunks:
result.extend(lexer.feed(chunk))
return result + lexer.feed(b"", final=True)
raw = "ifx == 中 😀 if 12".encode("utf-8")
expected = lex_chunks([raw])
assert [(kind, text) for kind, text, start, end in expected] == [
("Identifier", "ifx"), ("Equal", "=="), ("Invalid", "中"),
("Invalid", "😀"), ("If", "if"), ("Integer", "12"), ("Eof", "")]
for cut in range(len(raw) + 1):
assert lex_chunks([raw[:cut], raw[cut:]]) == expected
assert lex_chunks([bytes([b]) for b in raw]) == expected
for kind, text, start, end in expected:
assert raw[start:end].decode("utf-8") == text
for invalid in (b"\xff", b"\xe4\xb8", b"\xc0\xaf"):
try:
lex_chunks([invalid])
except UnicodeDecodeError:
pass
else:
raise AssertionError("invalid UTF-8 accepted")
把每个可能的字节切点都试一次,会同时覆盖 if/x、=/= 以及中和 emoji 内部的切分。输出 token 的原文和字节区间应完全一致,不随 I/O 分块变化。结尾的 final=True 很重要:它既让待定 if 被分类为关键字,也要求解码器报告未完成的 UTF-8 序列。Python 增量解码接口
| 当前已收到 | 是否能立刻确定 | 等待的证据 |
| if,尚未 EOF | 不能确定 token 结束 | 后续分隔符或最终结束 |
| 单个 =,尚未 EOF | 不能确定长度 | 下一字符是否仍为 = |
| 中的前两个 UTF-8 字节 | 不能产生完整码点 | 第三个续字节或最终截断错误 |
| == | 本例可立即输出 | 没有更长的等号运算符 |
这个教学实现会复制 pending 字符串,并从头重复匹配尚未结束的长 token,因此没有线性时间或固定内存保证;真实流式词法器应保存扫描状态与游标,设置合理资源预算。它也跳过空白,没有构成无损 token 流。feed 关闭后拒绝继续提交数据;面向 parser 的 peek/next 包装层可以缓存最后的 Eof,实现稳定的终止查询。
字节到了,字符和 token 就都完整了吗?
接收进度不能直接当作扫描完成进度。下面故意把滑杆停在 if 后、单个等号后、中文字节中间和 emoji 字节中间:这些位置分别缺少 token 边界证据或完整字符。已确认 token 使用原始字节区间;尚未解码的字节不会被伪装成非法 Unicode 字符。EOF 是额外事实,提前宣告 EOF 可能得到合法短输入,也可能得到截断错误。
正在呈现知识画面
字节到了,字符和 token 就都完整了吗?字节边界、字符边界与 token 边界是三件事。if 需要后续字符才能分类,UTF-8 的半个字符需要续字节;只有真正 EOF 才能终结待定输入。
{"id":"streaming-lexer-boundaries","title":"字节到了,字符和 token 就都完整了吗?","summary":"字节边界、字符边界与 token 边界是三件事。if 需要后续字符才能分类,UTF-8 的半个字符需要续字节;只有真正 EOF 才能终结待定输入。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"received\" data-i18n=\"received\"></label><output for=\"received\" id=\"received-value\"></output><input id=\"received\" type=\"range\" min=\"0\" max=\"14\" value=\"2\" step=\"1\"></div><label class=\"check\"><input type=\"checkbox\" id=\"eof\"><span data-i18n=\"eof\"></span></label><div id=\"bytes\" class=\"tile-grid\"></div><section class=\"panel\"><h3 data-i18n=\"decoded\"></h3><div id=\"decoded\"></div></section><section class=\"panel\"><h3 data-i18n=\"pending\"></h3><div id=\"pending\"></div></section><section class=\"panel\"><h3 data-i18n=\"tokens\"></h3><div id=\"tokens\"></div></section><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"ok\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"error\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n#bytes{grid-template-columns:repeat(7,minmax(0,1fr))}#bytes .cell{font-size:10px}#decoded,#pending{font:13px/1.8 var(--viz-font-ui);white-space:pre-wrap;min-height:3.6em;overflow-wrap:anywhere}#tokens{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;min-height:12.6em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}const source='ifx == 中😀',bytes=new TextEncoder().encode(source),enc=new TextEncoder;function draw(){const n=+$('#received').value,final=$('#eof').checked;set('received-value',n);cells('bytes',[...bytes].map((b,i)=>({text:b.toString(16).padStart(2,'0'),cls:i<n?'on':'empty'})));let decoded='',error=false;try{decoded=new TextDecoder('utf-8',{fatal:true}).decode(bytes.slice(0,n),{stream:!final});}catch{error=true;decoded=new TextDecoder('utf-8',{fatal:true}).decode(bytes.slice(0,n),{stream:true});}set('decoded',decoded||'∅');let pos=0,tokens=[],pending='';while(pos<decoded.length){const rest=decoded.slice(pos);if(/^\\s/.test(rest)){pos++;continue}let match=/^[A-Za-z_][A-Za-z_0-9]*|^[0-9]+|^==|^=/.exec(rest),raw=match?match[0]:String.fromCodePoint(rest.codePointAt(0));const needs=match&&raw!=='=='&&pos+raw.length===decoded.length&&!final;if(needs){pending=raw;break}const start=enc.encode(decoded.slice(0,pos)).length,end=start+enc.encode(raw).length;tokens.push((match?raw==='if'?'If':/^[A-Za-z_]/.test(raw)?'Identifier':/^[0-9]/.test(raw)?'Integer':'Operator':'Invalid')+' '+raw+' ['+start+','+end+')');pos+=raw.length;}const incomplete=n-enc.encode(decoded).length;set('pending',(pending||'∅')+' / '+incomplete+' byte(s)');set('tokens',tokens.join('\\n')||'∅');set('explanation',t(error?'error':'ok'));$('#tokens').dataset.count=tokens.length;$('#decoded').dataset.error=String(error);}$('#received').oninput=draw;$('#eof').onchange=draw;draw();","audio":false,"strings":{"decoded":"已解码文本","eof":"输入确实已经结束(EOF)","error":"EOF 落在 UTF-8 字符内部,拒绝截断输入。","heading":"字节到了,字符和 token 就都完整了吗?","intro":"固定输入 ifx == 中😀。拖动已收到的字节数,分别观察解码文本、已确认 token 与仍需等待的内容。","ok":"已确认的 token 不会因后续输入而改变。半开区间按 UTF-8 字节计数。","pending":"等待 token 边界 / 未完成字节","received":"已收到的字节数","reset":"重置","scope":"ASCII 标识符和整数,关键字 if,运算符 =/==;其他合法 Unicode 字符输出 Invalid。UTF-8 非法或截断时报解码错误。滑杆比较独立接收快照,EOF 可模拟截断连接。","scopeLabel":"模型范围与约定","tokens":"已确认 token 与字节区间"}}
Unicode:解码规则与标识符规则分开
UTF-8 解码需要检查续字节、过长编码、代理码位及码位上限,不能只根据首字节判断长度后盲目跳过。若输入类型已经保证是有效 UTF-8,可利用标准库迭代边界;否则先决定非法字节的诊断与恢复策略。
解码得到码位后,还要依据语言规范判断标识符是否合法。Rust 使用 XID_Start、XID_Continue 相关规则,允许下划线参与,并规定 NFC 规范化等要求;单独下划线与原始标识符还有额外规则。\w 不是通用的语言标识符规范。Rust 标识符
规范化后的符号比较与原始源码拼写可以同时保留:名字解析使用规范形式,诊断仍高亮用户输入。Unicode 版本也是语言兼容性的一部分。
错误恢复:消费策略必须符合当前状态
非法字符可以生成 Invalid token 并消费一个有效码位;非法 UTF-8 则按既定字节恢复策略推进。未闭合字符串、注释或插值需要各自的终止条件,不能统一“跳过一个字符”。多行字符串合法时,换行当然不是错误边界。
一次恢复应消费输入、改变状态并保证下一步能够推进,或明确在 EOF 停止。生成占位 token 但留在原位置反复报同一错误,会导致死循环。诊断可以与 token 分开返回,让 parser 知道错误区间而不必重复报告同一个词法问题。
手写还是生成器
| 关注点 | 手写 | 生成器 |
| 规则维护 | 控制流直接,但需人工保持一致 | 正则与动作集中,生成步骤需集成 |
| 特殊上下文 | 可直接编码状态变化 | 依工具支持配合状态、用户代码 |
| 诊断质量 | 容易按分支定制,也可能遗漏恢复 | 可写定制动作,不由生成器身份决定上限 |
| 性能 | 依分支布局、扫描策略与实现 | 依生成形式、优化选项与集成方式 |
选择应由语言规则和测量结果支持。两者都要验证最长匹配、跨缓冲 token、Unicode 边界、EOF 和恢复进度。后续如何把 token 组成保留错误信息的树,见 AST 设计与错误恢复。
Keywords: lexer, token, span, source snapshot, maximal munch, lookahead, Unicode, UTF-8, line table, recovery, re2c