记一次 Qt5 Language Server 开发

记一次 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 自带 qmljssrc/libs/qmljs),方言枚举含 QmlQtQuick2JavaScript 等,提供补全、跳转、引用查找、语义检查全套能力------它就是 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 原始设计,跳转分两条路径:

  • 组件路径 :光标在类型名/组件实例上 → FindTypeAtPosition Visitor 定位 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::castkind 匹配,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 自定义组件类型名与属性补全

需求

  1. 未打开的自定义组件(如 HintPop)类型名也要能补全;
  2. 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,语法崩溃。

修复 :参数名改 signalNamesQt 项目里 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.cppreformatFile

cpp 复制代码
QJsonArray Server::handleFormatting(const QJsonObject &params)
{
    ... // 取 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 扩展的兼容

  • --help 1 秒内退出校验(见 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 经验教训

  1. 复用比改造更划算:改造 Qt6 qmlls 需要动整个类型系统;复用 QtCreator 的 qmljs 库,一行基础设施都没改,就拿到了完整的 Qt5 语义引擎。
  2. parse 失败不代表无法工作 :补全发生在文本不完整时,**修复文本(repaired text)**让 qmljs 在"不完整输入"下也能 parse,一行 ": x" 换来原生语义支持------这是本项目最有价值的技巧。
  3. 行号/offset 换算永远用纯文本QTextDocument 对 CRLF 的规范化会毁掉所有位置换算,VSCode 行号只认 \n
  4. Qt 的宏是暗雷signals/slots 都是宏;QStringBuilder 不能直接赋给 QJsonValue。编译报错位置越奇怪,越要先怀疑宏展开。
  5. 真实环境问题要靠日志 :加密文件、VSCode 进程占用这类问题,靠 qt5_qmlls.logparsed=0 和部署循环逐一定位。
  6. 黑盒交互测试是 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
相关推荐
烧酒同学1 小时前
【C++】记录size of std::vector的巧妙坑
开发语言·c++·图形渲染
周周哈哈哈2 小时前
线程创建、执行、退出、回收
java·开发语言
gb42152872 小时前
python中Web应用服务器
开发语言·前端·python
萧瑟余晖2 小时前
Java深入解析篇三十六之分布式系统详解
java·开发语言·分布式
ZJU_统一阿萨姆3 小时前
【推理优化进阶】调度器的数学内核:排队论、SLO 与在线决策
开发语言·人工智能·语言模型·系统架构·vllm
SomeB1oody3 小时前
【RustyML入门】5.2. 分类指标
开发语言·后端·机器学习·rust·教程
ZJU_统一阿萨姆4 小时前
【推理优化进阶】图编译与运行时:动态形状、CUDA Graph 与内存规划
开发语言·人工智能·语言模型·架构·开源
@呱呱爱学习4 小时前
C++大成之路_ STL容器_ Vector
开发语言·c++
码匠许师傅4 小时前
【C++ 面试真题】19. 聊聊 C++ 的迭代器
开发语言·c++·面试