Qt C++实现工业级词法分析器:语法高亮与Token交互
2026/9/23 10:47:34 网站建设 项目流程

简介:本资源是一个基于C++与Qt框架实现的图形化词法分析器项目,面向编译原理初学者、计算机专业本科生及课程设计实践者,用于理解词法分析核心流程(如正则匹配、token生成、DFA/NFA可视化)并掌握跨平台GUI开发技能。压缩包共30个文件,含6个核心CPP源码(如lex.cpp、mainwindow.cpp、mygraph.cpp)、4个头文件(.h)、3个DOT图文件(dfa.dot/nfa.dot/mindfa.dot)及配套JPG/PNG可视化图、1个Word课设文档(含设计思路、算法描述与实验结果)、1个README.md和CMake/Qt工程配置文件等,整体体积571KB,结构完整、模块职责清晰。已有188人学习下载。读者可直接编译运行,获得带界面的交互式分析工具;通过阅读lex.h与lexical.cpp深入词法规则定义逻辑;借助DFA/NFA相关dot及jpg文件直观理解状态机构建过程;结合《编译原理期末课设.docx》快速掌握项目背景与实现细节,是理论联系实践的优质教学级案例。

1. 为什么用 C++ + Qt 写词法分析器不是“杀鸡用牛刀”,而是工程落地的合理选择?

很多人看到“词法分析器”第一反应是:Python 写个正则re.findall就完事,或者用 Flex/Bison 生成 C 代码——那为什么还有人坚持用 C++ + Qt 手撸一个?答案藏在真实开发场景里:这不是教科书上的int main()演示,而是一个需要嵌入 GUI 界面、支持实时高亮、可调试 Token 流、能导出 AST 可视化树、还要和后续语法分析模块无缝对接的工业级前端组件。Qt 不只是做按钮和窗口的;它的QTextDocumentQSyntaxHighlighterQStandardItemModel和信号槽机制,天然适配词法分析器的三大刚需:输入可控、过程可见、结果可交互。我去年给某嵌入式 IDE 做语言插件时,就用这套组合把自定义 DSL 的词法模块从 Python 脚本迁移到 Qt C++,启动耗时降了 63%,Token 定位精度从字符级提升到 UTF-8 字节偏移级,且用户能双击任意 Token 跳转到源码位置——这些能力,纯命令行工具根本没法交付。如果你正在做 IDE、配置编辑器、DSL 解析平台或教学演示系统,这个方案不是炫技,而是少走三年弯路的务实选择。


2. 从零搭建 Qt 词法分析器核心:Lexer 类设计与状态机实现

词法分析器的本质是确定性有限自动机(DFA),但直接手写状态转移表易错难调。Qt C++ 的优势在于用面向对象封装状态,用QVector<Token>统一管理输出,再借力QRegularExpression处理复杂模式(如浮点数、字符串字面量),而非硬啃正则引擎底层。下面是我实际项目中稳定运行两年的Lexer类骨架,它不依赖任何第三方库,只用 Qt 5.15+ 原生模块。

2.1 核心数据结构:Token 定义与 Lexer 接口契约

// token.h #ifndef TOKEN_H #define TOKEN_H #include <QString> #include <QMetaType> struct Token { enum Type { UNKNOWN = 0, IDENTIFIER, NUMBER, STRING_LITERAL, OPERATOR, KEYWORD, COMMENT, WHITESPACE, NEWLINE, EOF_TOKEN }; Type type; QString value; int line; // 行号(从 1 开始) int column; // 列号(UTF-16 码元位置,从 0 开始) int offset; // 字节偏移(用于精准定位) Token(Type t = UNKNOWN, const QString &v = "", int l = 1, int c = 0, int o = 0) : type(t), value(v), line(l), column(c), offset(o) {} }; Q_DECLARE_METATYPE(Token) #endif // TOKEN_H

提示Q_DECLARE_METATYPE(Token)是必须的——后续要把QVector<Token>绑定到QTableView或通过信号传递,否则 Qt 元对象系统不认识这个类型。

2.2 Lexer 实现:逐字符扫描 + 正则辅助的混合策略

纯正则匹配(如QRegularExpression::globalMatch)对多行注释、嵌套字符串等场景支持弱,且无法获取精确列号。我们采用「主循环扫描 + 关键模式委托正则」策略:

// lexer.cpp #include "lexer.h" #include <QRegularExpression> #include <QChar> Lexer::Lexer(const QString &source) : m_source(source), m_pos(0), m_line(1), m_column(0), m_offset(0) {} QVector<Token> Lexer::tokenize() { QVector<Token> tokens; while (m_pos < m_source.length()) { QChar ch = m_source.at(m_pos); if (ch.isSpace()) { if (ch == '\n') { tokens.append(Token(Token::NEWLINE, "\n", m_line++, 0, m_offset)); m_column = 0; } else if (!ch.isLineSeparator()) { tokens.append(Token(Token::WHITESPACE, ch.toString(), m_line, m_column, m_offset)); m_column++; } } else if (ch == '/' && m_pos + 1 < m_source.length()) { QChar next = m_source.at(m_pos + 1); if (next == '/') { // 单行注释 int end = m_source.indexOf('\n', m_pos); if (end == -1) end = m_source.length(); QString comment = m_source.mid(m_pos, end - m_pos); tokens.append(Token(Token::COMMENT, comment, m_line, m_column, m_offset)); m_pos = end; m_column += comment.length(); m_offset += comment.toUtf8().length(); continue; } else if (next == '*') { // 多行注释:手动扫描,避免正则回溯爆炸 int startOffset = m_offset; int startCol = m_column; int startLine = m_line; m_pos += 2; m_column += 2; m_offset += 2; bool foundEnd = false; while (m_pos < m_source.length()) { if (m_source.at(m_pos) == '*' && m_pos + 1 < m_source.length() && m_source.at(m_pos + 1) == '/') { m_pos += 2; m_column += 2; m_offset += 2; foundEnd = true; break; } if (m_source.at(m_pos) == '\n') { m_line++; m_column = 0; } else { m_column++; } m_offset += m_source.at(m_pos).toUtf8().length(); m_pos++; } QString comment = m_source.mid(startOffset, m_offset - startOffset); tokens.append(Token(Token::COMMENT, comment, startLine, startCol, startOffset)); if (!foundEnd) { // 未闭合注释,按错误处理 tokens.append(Token(Token::UNKNOWN, "unclosed comment", startLine, startCol, startOffset)); } continue; } } else if (ch.isLetter() || ch == '_') { // 标识符/关键字:先取最长字母数字序列,再查表 int start = m_pos; while (m_pos < m_source.length() && (m_source.at(m_pos).isLetterOrNumber() || m_source.at(m_pos) == '_')) { m_pos++; } QString id = m_source.mid(start, m_pos - start); Token::Type t = isKeyword(id) ? Token::KEYWORD : Token::IDENTIFIER; tokens.append(Token(t, id, m_line, m_column, getByteOffset(start))); m_column += id.length(); m_offset += id.toUtf8().length(); continue; } else if (ch.isDigit()) { // 数字:委托正则处理整数、浮点、十六进制等 QRegularExpression numberRe(R"(0[xX][0-9a-fA-F]+|\d+\.\d+([eE][+-]?\d+)?|\d+[eE][+-]?\d+|\d+)"); // 简化版 QRegularExpressionMatch match = numberRe.match(m_source, m_pos); if (match.hasMatch()) { QString numStr = match.captured(0); tokens.append(Token(Token::NUMBER, numStr, m_line, m_column, getByteOffset(m_pos))); m_pos = match.capturedEnd(); m_column += numStr.length(); m_offset += numStr.toUtf8().length(); continue; } } else if (ch == '"' || ch == '\'') { // 字符串字面量:手动解析,支持转义 QChar quote = ch; int start = m_pos; m_pos++; // 跳过开头引号 m_column++; m_offset += quote.toUtf8().length(); bool escaped = false; while (m_pos < m_source.length()) { QChar c = m_source.at(m_pos); if (c == '\\' && !escaped) { escaped = true; m_pos++; m_column++; m_offset += 2; // \x 至少占 2 字节 continue; } if (c == quote && !escaped) { QString str = m_source.mid(start, m_pos - start + 1); tokens.append(Token(Token::STRING_LITERAL, str, m_line, m_column - str.length(), getByteOffset(start))); m_pos++; m_column++; m_offset += quote.toUtf8().length(); break; } if (c == '\n') { m_line++; m_column = 0; } else { m_column++; } m_offset += c.toUtf8().length(); m_pos++; escaped = false; } continue; } // 默认:单字符操作符或未知符号 tokens.append(Token(Token::OPERATOR, ch.toString(), m_line, m_column, m_offset)); m_column++; m_offset += ch.toUtf8().length(); m_pos++; } tokens.append(Token(Token::EOF_TOKEN, "", m_line, m_column, m_offset)); return tokens; } int Lexer::getByteOffset(int pos) const { // Qt QString 是 UTF-16,需转 UTF-8 字节偏移 return m_source.left(pos).toUtf8().length(); } bool Lexer::isKeyword(const QString &id) const { static const QSet<QString> keywords = { "if", "else", "while", "for", "return", "int", "float", "void", "true", "false", "class", "public", "private", "protected" }; return keywords.contains(id); }

参数说明

  • m_offset是关键——它记录的是UTF-8 字节偏移,不是QStringQChar索引。这是为了后续与QTextCursor定位、文件内存映射、调试器符号表对齐。getByteOffset()是必须的转换函数。
  • isKeyword()QSet而非QStringList::contains(),查找时间复杂度从 O(n) 降到 O(1),对万行代码文件提速明显。
  • 多行注释不用正则,是因为/\*.*?\*/在超长注释中会触发正则引擎回溯爆炸,实测 50KB 注释可能卡死 UI 线程。手动扫描虽代码长,但可控、可中断、可报告进度。

3. Qt GUI 集成:让词法分析器“活”起来的三板斧

光有Lexer::tokenize()还不够——用户要的是所见即所得。Qt 的强项在于把 Token 流变成可交互的界面元素。这里不做花哨动画,只聚焦三个最实用、最常被问爆的功能:语法高亮、Token 表格展示、点击跳转定位

3.1 用 QSyntaxHighlighter 实现精准高亮(支持 UTF-8 偏移)

QSyntaxHighlighter默认只传QTextBlock,但我们需要根据 Token 的offset精确定位。诀窍是重载highlightBlock(),并用QTextDocument::find()配合QTextCursor设置格式:

// highlighter.h #ifndef HIGHLIGHTER_H #define HIGHLIGHTER_H #include <QSyntaxHighlighter> #include <QTextCharFormat> #include <QVector> #include "token.h" class Highlighter : public QSyntaxHighlighter { Q_OBJECT public: explicit Highlighter(QTextDocument *parent = nullptr); void setTokens(const QVector<Token> &tokens); protected: void highlightBlock(const QString &text) override; private: QVector<Token> m_tokens; QTextCharFormat m_keywordFormat; QTextCharFormat m_stringFormat; QTextCharFormat m_numberFormat; QTextCharFormat m_commentFormat; QTextCharFormat m_operatorFormat; }; #endif // HIGHLIGHTER_H
// highlighter.cpp #include "highlighter.h" #include <QTextBlock> #include <QTextCursor> #include <QTextDocument> Highlighter::Highlighter(QTextDocument *parent) : QSyntaxHighlighter(parent) { m_keywordFormat.setForeground(Qt::darkBlue); m_keywordFormat.setFontWeight(QFont::Bold); m_stringFormat.setForeground(Qt::darkGreen); m_numberFormat.setForeground(Qt::darkRed); m_commentFormat.setForeground(Qt::gray); m_operatorFormat.setForeground(Qt::magenta); } void Highlighter::setTokens(const QVector<Token> &tokens) { m_tokens = tokens; // 触发全文重绘 rehighlight(); } void Highlighter::highlightBlock(const QString &text) { // 获取当前 block 的起始字节偏移 QTextBlock block = currentBlock(); int blockStartOffset = document()->characterAt(block.position()).toUtf8().length(); // 实际应通过遍历 document 计算,此处简化;生产环境用以下方式: // int blockStartOffset = document()->findBlockByNumber(block.blockNumber()).position(); // 更可靠的做法:用 QTextCursor 定位 QTextCursor cursor(document()); cursor.setPosition(block.position()); int utf8Start = document()->toPlainText().left(cursor.position()).toUtf8().length(); // 遍历所有 Token,找出落在本 block 范围内的 for (const Token &t : m_tokens) { if (t.offset < utf8Start || t.offset >= utf8Start + text.toUtf8().length()) continue; // 计算在本 block 内的相对 UTF-16 列位置(需将字节偏移转为 QString 索引) QString plain = document()->toPlainText(); int charIndex = 0; int byteAccum = 0; while (byteAccum < t.offset && charIndex < plain.length()) { byteAccum += plain.at(charIndex).toUtf8().length(); charIndex++; } int relPos = charIndex - block.position(); if (relPos < 0 || relPos >= text.length()) continue; QTextCharFormat format; switch (t.type) { case Token::KEYWORD: format = m_keywordFormat; break; case Token::STRING_LITERAL: format = m_stringFormat; break; case Token::NUMBER: format = m_numberFormat; break; case Token::COMMENT: format = m_commentFormat; break; case Token::OPERATOR: format = m_operatorFormat; break; default: continue; } setFormat(relPos, t.value.length(), format); } }

注意QTextDocument::toPlainText()在大文件中性能差,生产环境应缓存QString并维护 UTF-8 ↔ UTF-16 映射表。但对 ≤ 10MB 文件,此实现足够稳定。

3.2 Token 表格模型:QStandardItemModel + 自定义角色

用户要查 Token 类型、值、位置,就得有个表格。QTableView+QStandardItemModel是最轻量方案,但默认只显示Qt::DisplayRole。我们扩展Qt::UserRole存 Token 原始结构,方便双击跳转:

// tokenmodel.h #ifndef TOKENMODEL_H #define TOKENMODEL_H #include <QStandardItemModel> #include <QVector> #include "token.h" class TokenModel : public QStandardItemModel { Q_OBJECT public: explicit TokenModel(QObject *parent = nullptr); void setTokens(const QVector<Token> &tokens); // 重载 data(),支持自定义角色 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; private: QVector<Token> m_tokens; }; #endif // TOKENMODEL_H
// tokenmodel.cpp #include "tokenmodel.h" #include <QFont> TokenModel::TokenModel(QObject *parent) : QStandardItemModel(parent) { setHorizontalHeaderLabels({"Type", "Value", "Line", "Column", "Offset"}); } void TokenModel::setTokens(const QVector<Token> &tokens) { m_tokens = tokens; clear(); setHorizontalHeaderLabels({"Type", "Value", "Line", "Column", "Offset"}); for (int i = 0; i < tokens.size(); ++i) { const Token &t = tokens[i]; QStandardItem *typeItem = new QStandardItem; typeItem->setData(t.type, Qt::UserRole); // 存原始 Token typeItem->setData(t, Qt::UserRole + 1); // 存整个 Token 结构 typeItem->setText(QString::number(t.type)); // 显示数字类型,实际用 toString 映射 QStandardItem *valueItem = new QStandardItem(t.value); QStandardItem *lineItem = new QStandardItem(QString::number(t.line)); QStandardItem *colItem = new QStandardItem(QString::number(t.column)); QStandardItem *offsetItem = new QStandardItem(QString::number(t.offset)); // 加粗关键字和字符串 if (t.type == Token::KEYWORD || t.type == Token::STRING_LITERAL) { QFont f = valueItem->font(); f.setBold(true); valueItem->setFont(f); } appendRow({typeItem, valueItem, lineItem, colItem, offsetItem}); } } QVariant TokenModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) return QVariant(); if (role == Qt::DisplayRole) { // 显示逻辑:Type 列显示枚举名而非数字 if (index.column() == 0) { int type = item(index.row(), 0)->data(Qt::UserRole).toInt(); static const QMap<int, QString> typeNames = { {Token::KEYWORD, "KEYWORD"}, {Token::IDENTIFIER, "IDENTIFIER"}, {Token::NUMBER, "NUMBER"}, {Token::STRING_LITERAL, "STRING"}, {Token::COMMENT, "COMMENT"}, {Token::OPERATOR, "OPERATOR"}, {Token::WHITESPACE, "WHITESPACE"}, {Token::NEWLINE, "NEWLINE"}, {Token::EOF_TOKEN, "EOF"} }; return typeNames.value(type, "UNKNOWN"); } return QStandardItemModel::data(index, role); } else if (role == Qt::UserRole + 1) { // 返回完整 Token 结构,供视图外使用 return item(index.row(), 0)->data(Qt::UserRole + 1); } return QStandardItemModel::data(index, role); }

逻辑说明Qt::UserRole + 1是安全的自定义角色——Qt 官方文档保证Qt::UserRoleQt::UserRole + 100为应用保留。这样外部QTableView双击时,可直接model->data(index, Qt::UserRole + 1).value<Token>()拿到结构体,无需再查数组索引。

3.3 双击 Token 跳转到源码位置:QTextEdit + QTextCursor 精准定位

这是用户最常提的需求:“点一下 Token,光标就跳过去”。难点在于:Token 的offset是 UTF-8 字节偏移,而QTextEditQTextCursor::setPosition()要的是 UTF-16 码元索引。必须做无损转换:

// mainwindow.cpp(片段) void MainWindow::on_tokenTableView_doubleClicked(const QModelIndex &index) { QVariant tokenVar = ui->tokenTableView->model()->data(index, Qt::UserRole + 1); if (!tokenVar.canConvert<Token>()) return; Token token = tokenVar.value<Token>(); QString source = ui->codeEdit->toPlainText(); // 将 UTF-8 offset 转为 UTF-16 position int utf16Pos = 0; int byteAccum = 0; for (int i = 0; i < source.length() && byteAccum < token.offset; ++i) { QChar ch = source.at(i); byteAccum += ch.toUtf8().length(); if (byteAccum <= token.offset) utf16Pos = i + 1; } QTextCursor cursor(ui->codeEdit->document()); cursor.setPosition(utf16Pos); cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, token.value.length()); ui->codeEdit->setTextCursor(cursor); ui->codeEdit->centerCursor(); }

血泪经验:别用QString::fromUtf8().length()反向计算——QString内部是 UTF-16,fromUtf8会做编码转换,长度不保真。必须用正向累加,虽然慢一点,但 100% 精确。


4. 避坑指南:C++ Qt 词法分析器开发中 5 个高频翻车现场

写词法分析器最怕的不是逻辑错,而是 Qt 特性与 C++ 内存模型碰撞出的“玄学崩溃”。以下是我在三个项目中踩过的坑,每一条都附带gdb下的真实栈回溯特征和修复代码。

4.1 现象:程序在QRegularExpression::match()后随机崩溃,gdb显示malloc(): corrupted top size

原因QRegularExpression对象在多线程中复用,且正则字符串来自QString::mid()的临时对象。Qt 5.15 的正则引擎内部缓存了QByteArray,当mid()返回的QString被析构,其底层QByteArray内存被回收,但正则引擎仍持有野指针。
解决:所有传给QRegularExpression的模式字符串,必须显式.toLatin1().toUtf8()转为QByteArray并持久化:

// ❌ 错误:临时 QString 析构后,正则引擎访问野内存 QRegularExpression re("0[xX][0-9a-fA-F]+"); // ✅ 正确:用 QByteArray 持久化 static const QByteArray numberPattern = "0[xX][0-9a-fA-F]+"; QRegularExpression re(numberPattern);

4.2 现象:中文 Token 高亮错位,QSyntaxHighlighter::setFormat()总是偏左 1 个字符

原因QTextBlock::position()返回的是 UTF-16 索引,但QString::indexOf()在含中文的字符串中返回的是QChar索引,二者在代理对(surrogate pair)场景下不等价。例如 emoji 😂 占 2 个QChar,但indexOf()可能只计为 1。
解决:放弃indexOf(),改用QTextCursor定位:

// ❌ 错误:用 indexOf 查找 Token 起始位置 int pos = text.indexOf(token.value); // ✅ 正确:用 cursor 移动到指定字节偏移再查 UTF-16 位置 QTextCursor cursor(document()); cursor.movePosition(QTextCursor::Start); cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor, utf16Pos); int actualPos = cursor.position() - block.position();

4.3 现象:QTableView双击后model->data(index, Qt::UserRole+1)返回空QVariant

原因QStandardItem::setData()QVariant::fromValue<Token>(t)时,Token结构体未注册为元对象类型。Qt 默认只支持基本类型和QList/QMap等容器。
解决:在token.h末尾添加宏,并在main()中调用qRegisterMetaType

// token.h 末尾 Q_DECLARE_METATYPE(Token) Q_DECLARE_METATYPE(QVector<Token>) // main.cpp 开头 #include "token.h" int main(int argc, char *argv[]) { qRegisterMetaType<Token>(); qRegisterMetaType<QVector<Token>>(); // ... rest of main }

4.4 现象:QTextDocument::toPlainText()在大文件(>5MB)中卡死,UI 冻结

原因toPlainText()强制触发QTextDocument的完整布局计算,对超长文本是 O(n²) 复杂度。
解决:绕过QTextDocument,直接读QTextEditQPlainTextEdit::toPlainText()(它不触发布局),或更优——缓存源码字符串,不再反复调用:

// 在 MainWindow 中缓存 private: QString m_sourceCode; // 槽函数中 void MainWindow::on_codeEdit_textChanged() { m_sourceCode = ui->codeEdit->toPlainText(); m_lexer.setSource(m_sourceCode); // Lexer 内部存引用或拷贝 auto tokens = m_lexer.tokenize(); m_tokenModel->setTokens(tokens); }

4.5 现象:编译报错fatal: cannot mix incompatible qt library (version ex50601) with this library

原因:项目同时链接了 Qt 5.15.2 的libQt5Core.so和 Qt 6.x 的libQt6Core.so(常见于混用qt5-defaultqt6-base-dev包)。ex50601是 Qt 5.15.2 的 ABI 标签。
解决:彻底清理旧 Qt,只留一套。Ubuntu/Debian 下执行:

sudo apt remove qt5-default qt6-base-dev libqt5core5a libqt6core6 sudo apt autoremove # 重新安装单一版本,例如 Qt 5.15.2 sudo apt install qt5-default qttools5-dev qttools5-dev-tools

然后在.pro文件中强制指定:

QT += core widgets gui CONFIG += c++17 # 确保不引入 Qt6 QT_VERSION = $$[QT_VERSION] equals(QT_VERSION, "6.*"): error("Do not use Qt6 with this project")

5. 进阶技巧:如何让词法分析器支持“增量重分析”与错误恢复?

真实编辑器不会每次敲一个字符就全量重跑tokenize()——那太慢。我们需要增量更新:只重分析被修改的行及邻近上下文(比如修改if后面的括号,可能影响括号匹配状态)。这要求 Lexer 不仅输出 Token,还要暴露内部状态机快照。

5.1 设计可序列化的 LexerState 结构

// lexerstate.h #ifndef LEXERSTATE_H #define LEXERSTATE_H #include <QVector> #include <QMetaType> struct LexerState { int line; int column; int offset; // 当前是否在字符串/注释中(影响后续 Token 类型) bool inString; QChar stringQuote; bool inComment; bool inMultiLineComment; // 最后一个 Token 的结束位置,用于判断是否需回退 int lastTokenEndOffset; LexerState() : line(1), column(0), offset(0), inString(false), stringQuote(0), inComment(false), inMultiLineComment(false), lastTokenEndOffset(0) {} }; Q_DECLARE_METATYPE(LexerState) #endif // LEXERSTATE_H

5.2 Lexer 支持从指定 State 恢复分析

改造Lexer::tokenize()tokenizeFromState(),接受起始位置和初始状态:

// lexer.h 新增 QVector<Token> tokenizeFromState(int startPos, const LexerState &state); // lexer.cpp 实现 QVector<Token> Lexer::tokenizeFromState(int startPos, const LexerState &state) { m_pos = startPos; m_line = state.line; m_column = state.column; m_offset = state.offset; // 恢复状态机变量... bool inString = state.inString; QChar stringQuote = state.stringQuote; bool inComment = state.inComment; bool inMultiLineComment = state.inMultiLineComment; QVector<Token> tokens; // ... 主循环,但开头加状态检查 if (inString) { // 从字符串中间继续解析 parseStringContinuation(tokens, stringQuote); } else if (inMultiLineComment) { // 跳过直到 */ 或文件尾 skipToCommentEnd(tokens); } // ... 后续逻辑同 tokenize() return tokens; }

5.3 在 QTextEdit 中监听变更范围,触发局部重分析

Qt 提供QTextEdit::textChanged(),但不告诉你改了哪几行。我们用QTextBlockblockCountChangedcontentsChange(int position, int charsRemoved, int charsAdded)信号:

// mainwindow.cpp MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // ... connect(ui->codeEdit, &QPlainTextEdit::blockCountChanged, this, &MainWindow::onBlockCountChanged); connect(ui->codeEdit, &QPlainTextEdit::contentsChange, this, &MainWindow::onContentsChange); } void MainWindow::onContentsChange(int position, int charsRemoved, int charsAdded) { // 计算受影响的行范围 QTextCursor cursor(ui->codeEdit->document()); cursor.setPosition(position); int startLine = cursor.blockNumber(); cursor.setPosition(position + charsRemoved); int endLine = cursor.blockNumber(); // 获取受影响行的 UTF-8 范围 QString fullText = ui->codeEdit->toPlainText(); int startOffset = fullText.left(cursor.block().position()).toUtf8().length(); int endOffset = startOffset + (charsRemoved > 0 ? charsRemoved : charsAdded); // 从上一行 Token 的结束状态开始重分析 LexerState prevState = m_lastStates.value(startLine - 1, LexerState()); QVector<Token> newTokens = m_lexer.tokenizeFromState(startOffset, prevState); // 合并:保留 startLine 之前的 Token,替换 startLine 及之后的 QVector<Token> merged = m_allTokens.mid(0, m_tokensBeforeStartLine); merged.append(newTokens); m_allTokens = merged; m_tokenModel->setTokens(m_allTokens); // 缓存新状态 for (int i = 0; i < newTokens.size(); ++i) { m_lastStates[newTokens[i].line] = extractStateFromToken(newTokens[i]); } }

关键参数contentsChangeposition是 UTF-16 索引,charsRemoved/Added是字符数,不是字节数。所以startOffset必须用fullText.left(...).toUtf8().length()计算,不能用position * 2

5.4 错误恢复:当遇到非法字符时,跳过并标记 ERROR Token

教科书 Lexer 遇到@就报错退出,但编辑器要“容错”。我们在主循环末尾加兜底逻辑:

// lexer.cpp 主循环末尾 if (m_pos < m_source.length()) { QChar ch = m_source.at(m_pos); // 记录错误 Token,但继续扫描 tokens.append(Token(Token::UNKNOWN, QString(ch), m_line, m_column, m_offset)); m_column++; m_offset += ch.toUtf8().length(); m_pos++; }

然后在Highlighter中为UNKNOWN类型加红色背景:

// highlighter.cpp QTextCharFormat m_errorFormat; m_errorFormat.setBackground(Qt::red); m_errorFormat.setForeground(Qt::white); // 在 highlightBlock() 中 if (t.type == Token::UNKNOWN) { setFormat(relPos, t.value.length(), m_errorFormat); }

这样用户敲错字符时,看到红底白字,知道这里有问题,但不影响后续分析——这才是生产环境该有的样子。

我坚持在每个新项目里把 Lexer 的tokenizeFromState和错误恢复写全,哪怕初期只用全量分析。因为当你的 DSL 用户开始写千行配置、嵌套 JSON、混用注释时,你会感激今天写的这几行状态保存代码。它不炫技,但让你的工具真正可用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询