记一次 Qt5 Language Server 开发
目标平台:Qt5.7(低性能 ARM Linux)嵌入式项目的 QML 开发支持
应用场景:VSCode 中编辑 Qt5 项目(qmake、版本化 import)的 QML 文件
技术栈:Qt Creator 19.0.2 源码(qmljs 库)→ 独立 LSP 可执行文件
qmlls5.exe开发环境:Windows + WSL(
cmd.exe调 MSVC 交叉编译)+ VSCode代码规模:
qmllsserver.cpp从零到 3269 行,28 次功能迭代提交

一、背景与目标
1.1 问题
VSCode 生态中,Qt 官方 QML Language Server(qmlls)只支持 Qt6:
qmlls是 Qt6 自带的工具,基于 Qt6 的 qmlcompiler 类型系统构建;- 官方文档明确承认其对 qmake 项目(即 Qt5 项目)产生误报;
- Qt5 的版本化 import(
import QtQuick 2.0)、QtQuick.Controls 1.x等旧模块在 Qt6 类型系统中不存在,导致打开 Qt5 项目满屏红色波浪线。
我们维护的嵌入式项目(ARM Linux + Qt5.7)全部是 Qt5 QML,编辑体验极差。
1.2 目标
在 VSCode 中实现一个兼容 Qt5 QML 语法的语言服务器,提供现代编辑器的完整能力:
| 能力 | LSP 方法 | 价值 |
|---|---|---|
| 诊断 | publishDiagnostics | 消除 Qt5 语法误报 |
| 补全 | textDocument/completion | 代码补全 |
| 跳转 | textDocument/definition | 跳转到定义 |
| 大纲 | textDocument/documentSymbol | 文件结构 |
| 语义着色 | textDocument/semanticTokens/full | 语法高亮 |
| 格式化 | textDocument/formatting | 一键排版 |
1.3 技术选型
| 方案 | 说明 | 结论 |
|---|---|---|
| A. 修改 Qt6 qmlls | 需下载 qtdeclarative 源码,改造其 qmlcompiler 类型系统 | 工作量巨大,不可行 |
| B. 复用 qmljs 库构建独立 LSP | QtCreator 内置 qmljs 库源自 Qt5 时代,完整支持 Qt5 语法 | 采用 |
| C. 直接用 Qt Creator | 零成本,但不满足 VSCode 需求 | 备用 |
关键发现:QtCreator 自带 qmljs 库 (src/libs/qmljs),方言枚举含 QmlQtQuick2、JavaScript 等,提供补全、跳转、引用查找、语义检查全套能力------它就是 Qt5 时代 QML 代码模型的完整实现。我们只需要把它封装成 LSP 服务。
二、环境与构建
2.1 编译环境
Qt Creator 19.0.2 最低要求 Qt 6.5.3,我们用本机 Qt 6.10.3(MSVC2022 + Ninja)编译:
bat
call vcvars64.bat
set CMAKE_PREFIX_PATH=H:\Qt\Qt6\6.10.3\msvc2022_64
cmake -S . -B build -G "Ninja" -DCMAKE_BUILD_TYPE=Release -DWITH_TESTS=OFF
cmake --build build --target qmlls5
2.2 在 QtCreator 源码树内新增独立工具
参考 sdktool 的 standalone 构建模式,在 src/tools/qmlls5/ 下创建独立可执行目标:
cmake
add_qtc_executable(qmlls5
DEPENDS QmlJS Utils LanguageUtils
SOURCES
main.cpp
qmllsprotocol.cpp qmllsprotocol.h
qmllsserver.cpp qmllsserver.h
)
好处:复用 QtCreator 的 CMake 基础设施,依赖自动解析;不修改 qmljs 库本体,后续上游升级可直接拉取。
2.3 踩坑一:VSCode 的 --help 校验超时
症状:VSCode 的 Qt QML 扩展启动 server 后报 ETIMEDOUT,server 起不来。
定位 :扩展启动逻辑是 spawnSync(exe, ["--help"], {timeout:1000}) ------先同步调用 --help 校验可执行性,要求 1 秒内退出且 exit code 0。
根因 :在 Windows 上 QCoreApplication 构造要加载 Qt 运行库,耗时可能超过 1 秒。
修复 :把 --help/--version 处理提前到 QCoreApplication 构造之前,直接 return 0:
cpp
for (int i = 1; i < argc; ++i) {
if (argv[i] && (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0
|| strcmp(argv[i], "--version") == 0)) {
fprintf(stderr, "qmlls5 - Qt5 compatible QML Language Server\n");
return 0; // 立即退出,exit code 0
}
}
2.4 踩坑二:std::cin 缓冲阻塞
症状:LSP 消息处理不及时,客户端频繁超时。
根因 :Windows 匿名管道下,std::cin 的 iostream 缓冲会尝试填满内部缓冲区才返回,导致消息堆积。
修复 :改用 CRT 底层 _read() 逐块读取,数据到达即返回;同时 _setmode 把 stdin/stdout 切到二进制模式,避免 \r\n 破坏 LSP 帧:
cpp
#ifdef Q_OS_WIN
_setmode(_fileno(stdout), _O_BINARY);
_setmode(_fileno(stdin), _O_BINARY);
#endif
三、LSP 协议层与文档管理
3.1 架构
┌───────────────────────── VSCode ─────────────────────────┐
│ QML 扩展 (qt-qml) │
└──────────────┬──────────────────────────────────────────┘
│ LSP (JSON-RPC over stdio)
┌──────────────▼──────────────────────────────────────────┐
│ qmlls5.exe │
│ ┌───────────────────────────────────────────────────┐ │
│ │ LSP 协议层 (initialize/didOpen/completion/...) │ │
│ └───────────────────┬───────────────────────────────┘ │
│ ┌───────────────────▼───────────────────────────────┐ │
│ │ qmljs 语义引擎(复用 QtCreator 源码) │ │
│ │ Document/Snapshot/Link/Context/ScopeChain/Check │ │
│ └───────────────────┬───────────────────────────────┘ │
│ ┌───────────────────▼───────────────────────────────┐ │
│ │ Qt5 类型信息(builtins.qmltypes + 安装目录) │ │
│ └───────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
stdin 读取放在独立工作线程,逐帧解析后通过信号交给主线程的 Server,避免阻塞事件循环。
3.2 踩坑三:UTF-8 BOM 解析失败
症状 :Windows 上保存的 QML 文件带 BOM(\uFEFF),qmljs 解析器把它当作非法字符,isParsedCorrectly() 恒为 false。
修复 :setSource 前剥掉 BOM:
cpp
QString src = text;
if (!src.isEmpty() && src.at(0) == QChar(0xFEFF))
src = src.mid(1);
3.3 踩坑四:CRLF 行号偏移
症状:跨文件跳转在 CRLF 文件上定位不准,跳到错行。
定位:对比 LF/CRLF 文件表现不同,怀疑行号换算。
根因 :VSCode 的行号按 \n 计算;若用 QTextDocument 处理,它会把 CRLF 规范化为 LF,导致 offset 错位。
修复 :所有位置换算改用纯文本扫描 ,绝不经过 QTextDocument:
cpp
// 0-based offset -> (0-based line, 0-based column),按 '\n' 累计
QPair<int, int> offsetToPosition(const QString &text, int offset)
{
int line = 0, lineStart = 0;
offset = qBound(0, offset, text.size());
for (int i = 0; i < offset; ++i)
if (text.at(i) == QLatin1Char('\n')) { ++line; lineStart = i + 1; }
return qMakePair(line, offset - lineStart);
}
四、语义分析:接入 qmljs
4.1 踩坑五:ModelManagerInterface 单例硬依赖
症状:补全/跳转时程序直接崩溃(段错误)。
定位 :崩溃栈指向 qmljslink.cpp:693 ------Link 直接调用 ModelManagerInterface::instance()->qrcPathsForFile(...) 且无空检查。
根因 :qmljs 库是为 QtCreator 插件环境设计的,ModelManagerInterface 作为单例假定存在;独立 LSP 进程里它从未被创建。
修复 :必须主动创建 ModelManagerInterface 实例并保活,否则崩溃于锁未初始化:
cpp
void Server::ensureModelManager()
{
if (!m_modelManager)
m_modelManager = QmlJS::ModelManagerInterface::instance();
...
}
4.2 Qt5 类型信息注入
qmljs 的 ModelManager::builtins() 默认指向编译期 Qt6 路径,Qt5 项目需要注入:
- QtCreator 自带
qml-type-descriptions/builtins.qmltypes,正好是 qmljs 加载的资源; - Qt5 安装目录自带的
.qmltypes(本机 Qt5.14.2 有 69 个、Qt5.9.1 有 95 个); - 用户通过
-I <Qt5 qml 目录>指定(兼容官方 qmlls 命令行)。
命令行兼容两种格式:-I <path>(分开)和 -I<path>(qt-qml 扩展拼接)。
五、跳转与文档大纲
5.1 组件 + 符号双路径跳转
参考 QtCreator 原始设计,跳转分两条路径:
- 组件路径 :光标在类型名/组件实例上 →
FindTypeAtPositionVisitor 定位UiObjectDefinition; - 符号路径 :光标在
id:、属性名、函数名上 → 查找声明位置。
cpp
class FindTypeAtPosition : public AST::Visitor {
public:
quint32 targetOffset = 0;
SourceLocation result;
bool visit(AST::UiObjectDefinition *node) override {
if (targetOffset >= quint32(node->firstSourceLocation().offset)
&& targetOffset <= quint32(node->lastSourceLocation().end()))
result = node->firstSourceLocation();
return true;
}
};
5.2 跨文件跳转
早期只支持单文档,后扩展为按 URI 索引的多文档表 + Snapshot:
- 已打开(didOpen)的文档全部入 Snapshot;
- 扫描当前文档目录及相对 import 目录的
.qml文件入 Snapshot,使未打开的组件也能 Link; - 用
id引用跨文件跳转(onIdChanged、函数、信号)。
5.3 踩坑六:AST::cast 的 kind 严格匹配
症状 :AST::cast<FunctionExpression*>(node) 对 function foo() {} 返回 nullptr,跳转落空。
根因 :qmljs 的 AST::cast 按 kind 匹配,FunctionDeclaration(kind=31)与 FunctionExpression(kind=47)是不同 kind,强转不匹配就返回空。
修复:两种都要分别 cast:
cpp
if (AST::cast<AST::FunctionDeclaration *>(node) || AST::cast<AST::FunctionExpression *>(node))
...
六、代码补全(最耗时)
补全是整个开发中迭代最多的部分,也是踩坑最密集的地方。
6.1 作用域链成员补全
参考 QtCreator 的 QmlJSCompletionAssist 设计:CompletionContextFinder 定位补全点 → ScopeChain 求值 → MemberProcessor 收集成员。
cpp
// 收集 ObjectValue 上所有可补全成员名 + LSP CompletionItemKind
class CompletionMemberProcessor : public MemberProcessor
{
public:
struct Item { QString name; int kind; };
QList<Item> items;
void operator()(const QmlJS::Value *value, const QmlJS::ObjectValue *object) override
{ ... }
};
6.2 易用性:LHS 场景自动追加冒号
需求 :输入 width 补全后,直接变成 width: (冒号+空格),省去手动输入。
实现 :判断补全点前面是"属性绑定左侧"(行前缀不含冒号/左括号)时,insertText 追加 ": "。后来发现函数体内也会误加冒号(x = func| 场景),专门修复:JS 块内不追加。
6.3 修复文本技术(repaired text)------本项目的核心技巧
问题 :qmljs 的 parse 在文本不完整时失败。而补全恰恰发生在输入到一半的瞬间:
xxx.(点号结尾)→ 语法错误;onIndica(独立标识符行)→ 不是合法的属性绑定或语句。
parse 失败则 AST 为 null,补全、语义着色全部失效。
方案 :检测到补全点附近语法不完整时,先修补文本让 parse 成功,修补结果只用于提取语义,不影响真实文本:
cpp
if (linePrefix.endsWith(QLatin1Char('.'))) {
// xxx. 结尾补一个 'x',使成员访问表达式合法
completionText.insert(tmpOffset, "x");
needRepair = true;
} else if (trimmed.isEmpty() == false && 首字母是字母/数字
&& !linePrefix.contains(':') && !linePrefix.contains('(')) {
// 独立标识符行补 ": x":属性名位置→合法属性绑定;JS 块内→合法 label 语句
completionText.insert(tmpOffset, ": x");
needRepair = true;
}
补 ": x" 的妙处在于:无论光标在 QML 对象体内(属性名位置)还是 JS 块内(语句位置),都能构造出合法语法。
6.4 import 相对路径补全
需求 :import "../Control/function.js" as Fun 场景,输入 import "../ 时自动补全文件/目录。
实现 :ImportCompletionFinder Visitor 检测光标是否落在 UiImport::fileNameToken 范围内,若在则基于当前文档目录解析相对路径,用 QDir 列出 .qml/.js/.qmltypes/.mjs 文件与子目录,textEdit 精确替换:
cpp
class ImportCompletionFinder : public AST::Visitor {
public:
quint32 targetOffset = 0;
bool inImportString = false;
SourceLocation fileNameToken;
bool visit(AST::UiImport *node) override {
if (targetOffset >= quint32(node->fileNameToken.offset)
&& targetOffset <= quint32(node->fileNameToken.end())) {
inImportString = true;
fileNameToken = node->fileNameToken;
return false;
}
return true;
}
};
目录项补 name + "/" 继续往下走,文件项补全后直接可用。
6.5 on 属性变化补全
需求 :property bool indicatorLight: false 后输入 onIndica 应补全 onIndicatorLightChanged。
实现 :根因还是独立标识符行 parse 失败。修复文本补 ": x" 使 parse 成功后,qmljs 原生就生成 on<Property>Changed 信号处理器补全项(含 { 后缀)。一行修复文本,换来原生语义支持。
6.6 自定义组件类型名与属性补全
需求:
- 未打开的自定义组件(如
HintPop)类型名也要能补全; HintPop { hintText: }场景应补全hintText等自定义属性。
实现:
- 类型名:文件系统扫描------当前目录 + 父目录 + import 目录递归搜索
.qml文件,首字母大写的文件名作 Class 类型补全项(不依赖 Link 解析,未打开也能补); - 属性:LHS 补全时按类型名
findQmlFileByType找到组件文件 → parse →collectComponentMembers提取 property/signal 加入补全:
cpp
static void collectComponentMembers(const Document::Ptr &compDoc,
QStringList *propertyNames,
QStringList *signalNames);
6.7 踩坑八:signals 宏冲突
症状 :编译报 qmllsserver.cpp(858): error C2059: 语法错误: ")",位置莫名其妙。
根因 :Qt 宏 #define signals Q_SIGNALS,把 signals 当参数名直接展开成 Q_SIGNALS,语法崩溃。
修复 :参数名改 signalNames。Qt 项目里 signals/slots 都是宏,不能用作标识符。
6.8 踩坑九:QStringBuilder 编译错误
症状 :item["insertText"] = name + QLatin1Char('/'); 编译不过。
根因 :Qt6 的 operator+ 模板返回 QStringBuilder(_Ty=QLatin1Char),不能直接赋给 QJsonValue。
修复 :显式构造 QString(name + QLatin1Char('/'))。QLatin1String 同理。
七、语义着色
7.1 踩坑十:Scanner 注释吞行
症状 :// 行注释之后的整段文本颜色全乱。
定位:对照 QtCreator 语义着色实现,发现问题在扫描器行为。
根因 :qmljs Scanner 的 // 注释会一直吞到文件末尾------它是设计给编辑器"逐行+跨行状态"用的,不适合整文本一次性扫描。
修复:先手动提取注释区间,把注释替换为**等长空格(保留换行)**得到 sanitized 文本,再交给 Scanner 识别关键字/字符串/数字;注释 token 由手动扫描直接生成:
cpp
// 手动提取 // 与 /* */ 区间
QList<QPair<int, int>> commentRanges;
QString sanitized = text;
... // 替换为等长空格
// Scanner 在 sanitized 上识别非注释 token
// 注释 token 单独生成,合并进语义 token 列表
7.2 双通道 token
- AST 语义 token :Visitor 遍历
UiObjectDefinition(类型/修饰符)、属性绑定、函数等; - 词法 token:sanitized 文本上 Scanner 识别关键字/字符串/数字。
两者合并后按 LSP delta 编码(semanticTokens/full)输出。
八、格式化
8.1 直接复用 qmljs 的格式化器
发现 qmljs 库自带完整格式化能力,无需自己写:
QmlJS::reformat(doc, indentSize, tabSize, lineLength)------基于 AST 重写的整体格式化(重排缩进、空行、行宽 80,保留注释);QtStyleCodeFormatter------逐行缩进状态机(编辑器交互式缩进用)。
调用范式参考 QtCreator texteditorview.cpp 的 reformatFile:
cpp
QJsonArray Server::handleFormatting(const QJsonObject ¶ms)
{
... // 取 didOpen 的 Document
if (!doc || !doc->isParsedCorrectly())
return QJsonArray(); // 解析失败不格式化,客户端忽略
const int tabSize = options.value("tabSize").toInt(4);
QString newText = QmlJS::reformat(doc, tabSize, tabSize, 80);
if (newText == source)
return QJsonArray(); // 已符合格式
// 保持原文档 EOL:reformat 输出 LF,原文 CRLF 则转回
if (source.contains(QLatin1String("\r\n")))
newText.replace(QLatin1Char('\n'), QLatin1String("\r\n"));
// 全文替换 TextEdit
return QJsonArray{{"range": fullRange, "newText": newText}};
}
8.2 格式化结果(实测)
输入乱缩进:
qml
Rectangle {
id: root
width:640
Text {
text:"Hello"
}
}
输出(4 空格缩进、冒号后空格、import 与根对象间空行、CRLF 保留):
qml
import QtQuick 2.12
Rectangle {
id: root
width: 640
Text {
text: "Hello"
}
}
九、测试方法论
LSP 无法像普通程序那样单测,整个开发过程沉淀出一套黑盒交互测试:
9.1 Python 实时交互测试
tests/test_lsp.py:启动 dist 下的 qmlls5.exe,不关闭 stdin ,模拟真实 VSCode 逐条发消息,select 轮询读取响应并断言:
python
def send(msg):
proc.stdin.write(encode(msg)); proc.stdin.flush()
def pump(timeout=3):
# 解析 stdout 所有完整 Content-Length 帧
...
# initialize -> 检查 capabilities
# didOpen -> 检查诊断数(Qt5 语法无误报)
# completion -> 检查补全项数
# definition -> 检查定义位置数
# formatting -> 检查格式化结果
9.2 边界场景验证
| 场景 | 验证点 |
|---|---|
| CRLF 文档格式化 | 输出保持 \r\n,无裸 \n |
| 未闭合字符串(解析失败) | 返回空数组,客户端忽略,不破坏用户代码 |
| 乱缩进文档 | 重排为 4 空格、冒号后空格 |
| 未打开的自定义组件 | 类型名/属性均能补全 |
| 亿赛通加密组件 | 跳转命中明文副本 |
9.3 调试日志
E:/qt5_qmlls.log 记录每个 LSP 消息、parse 结果(parsed=0/1)、补全上下文,VSCode 场景下排查问题全靠它。
十、部署与 VSCode 集成
10.1 部署策略:抓进程切换间隙
症状 :VSCode 开着时,dist/qmlls5.exe 被占用,copy 报 Permission denied。
方案 :VSCode 检测到 exe 变化会自动重启 server,存在进程切换间隙 。循环 taskkill + 立即 copy,抓住间隙:
bash
for i in 1 2 3 4 5; do
taskkill.exe /F /IM qmlls5.exe >/dev/null 2>&1
sleep 0.3
cp build/bin/qmlls5.exe dist/qmlls5.exe && break
sleep 0.3
done
10.2 与 qt-qml 扩展的兼容
--help1 秒内退出校验(见 2.3);-I<path>拼接格式参数解析;- Windows 二进制模式 stdio(见 2.4);
- 发布时
windeployqt打包 Qt6 DLL 到 dist 自包含目录。
十一、最终成果与经验总结
11.1 能力清单
| 能力 | 状态 | 关键实现 |
|---|---|---|
| 诊断 | ✅ | 解析级诊断,Qt5 语法无误报 |
| 补全 | ✅ | 作用域成员/关键字/import 路径/on 变化/自定义组件/类型名 |
| 跳转 | ✅ | 组件/符号/id 引用/跨文件/加密文件兜底 |
| 文档大纲 | ✅ | documentSymbol |
| 语义着色 | ✅ | semanticTokens/full,注释/字符串/关键字 |
| 格式化 | ✅ | QmlJS::reformat,CRLF 保留 |
11.2 经验教训
- 复用比改造更划算:改造 Qt6 qmlls 需要动整个类型系统;复用 QtCreator 的 qmljs 库,一行基础设施都没改,就拿到了完整的 Qt5 语义引擎。
- parse 失败不代表无法工作 :补全发生在文本不完整时,**修复文本(repaired text)**让 qmljs 在"不完整输入"下也能 parse,一行
": x"换来原生语义支持------这是本项目最有价值的技巧。 - 行号/offset 换算永远用纯文本 :
QTextDocument对 CRLF 的规范化会毁掉所有位置换算,VSCode 行号只认\n。 - Qt 的宏是暗雷 :
signals/slots都是宏;QStringBuilder不能直接赋给QJsonValue。编译报错位置越奇怪,越要先怀疑宏展开。 - 真实环境问题要靠日志 :加密文件、VSCode 进程占用这类问题,靠
qt5_qmlls.log的parsed=0和部署循环逐一定位。 - 黑盒交互测试是 LSP 的正确测法:不关 stdin 逐条发消息,模拟真实客户端,才能验证状态机行为。
11.3 产物清单
qt-creator-opensource-src-19.0.2/src/tools/qmlls5/
main.cpp # 入口:stdin 线程 + --help 兼容
qmllsprotocol.* # LSP 帧编解码
qmllsserver.cpp # 服务器核心(3269 行)
qmllsserver.h
Qt5_Qmlls/
dist/qmlls5.exe # 部署产物(自包含,windeployqt 打包)
tests/test_lsp.py # LSP 黑盒交互测试
Docs/方案设计.md # 架构与决策记录
build_qmlls5.bat / deploy.bat / update_exe.bat