feat: add QML language support via Qt's qmlls (#1635)

This commit is contained in:
weiconghe authored and GitHub committed 2026-07-13 13:54:23 +02:00
1 parent 9bbbfea8d6
commit 9825316c23
14 files changed
+377 -3

No files matched your search

+26 -3
View File
@@ -64,9 +64,10 @@ jobs:
env:
MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl"
MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard)
# other-langs: grab-bag + scripting languages + haskell. Haskell (GHC/HLS) is installed only on
# Linux -- it's too slow/expensive to build on the macOS runner -- and skips on Windows+macOS via the guard.
MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al"
# other-langs: grab-bag + scripting languages + haskell + qml. Haskell (GHC/HLS) and qml (qmlls) are
# installed only on Linux -- too slow/expensive to build on the macOS runner (Haskell), or not worth
# the cost of a cross-OS Qt pull (qmlls) -- and skip on Windows+macOS via the central conftest guard.
MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or qml"
# niche: slow, mostly-cached toolchains. ocaml + haskell are the two slowest -- kept in separate
# batches (ocaml here, haskell in other-langs). nix + perl skip on Windows via the guard.
MARKERS_NICHE: "julia or r or perl or lean4 or nix or ocaml"
@@ -418,6 +419,28 @@ jobs:
mv regal.exe "$HOME/bin/"
echo "$HOME/bin" >> $GITHUB_PATH
fi
- name: Install qmlls (QML Language Server)
# qmlls needs Qt 6.6+ for textDocument/documentSymbol (the feature these tests assert); Ubuntu
# noble's apt only has Qt 6.4.2, whose qmlls returns -32601 "method not found" for it. So install
# the official standalone build (TheQtCompanyRnD/qmlls-workflow, linked from the Qt docs) -- an 8 MB
# single binary built from recent Qt dev. Restricted to Linux (a cross-OS Qt pull isn't worth it);
# QML skips on Windows/macOS via the conftest guard. libodbc2 is its only non-bundled runtime dep.
if: matrix.batch == 'other-langs' && runner.os == 'Linux'
shell: bash
run: |
QMLLS_VERSION="0.7"
QMLLS_DIR="$HOME/.qmlls"
mkdir -p "$QMLLS_DIR"
curl -fsSL -o /tmp/qmlls.zip "https://github.com/TheQtCompanyRnD/qmlls-workflow/releases/download/${QMLLS_VERSION}/qmllanguageserver-linux-x64-${QMLLS_VERSION}.zip"
unzip -o /tmp/qmlls.zip -d "$QMLLS_DIR" >/dev/null
rm /tmp/qmlls.zip
chmod +x "$QMLLS_DIR/qmlls"
# libodbc.so.2 is qmlls's only non-bundled runtime dep; install only if absent on the image.
ldconfig -p | grep -q 'libodbc.so.2' || { sudo apt-get update && sudo apt-get install -y libodbc2; }
# Fail loudly if any shared lib is still unresolved (e.g. a missing runtime dep).
missing="$(ldd "$QMLLS_DIR/qmlls" | grep 'not found' || true)"
[ -z "$missing" ] || { echo "ERROR: qmlls has unresolved runtime libs:"; echo "$missing"; exit 1; }
echo "$QMLLS_DIR" >> "$GITHUB_PATH"
- name: Cache Free Pascal Compiler
if: matrix.batch == 'native'
id: cache-fpc
+2
View File
@@ -93,6 +93,8 @@ Status of the `main` branch. Changes prior to the next official version change w
build output is already excluded via `.gitignore`. #1645
- Improve quoting of arguments in shell executions
- Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab).
- Add **QML** support via Qt's [`qmlls`](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html) language
server (requires Qt 6 with `qmlls`/`qmlls6` on PATH). #1381
- PHP: add support for PHPantom as alternative to the already supported PHP LS #1554.
- Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd`
- Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified
@@ -130,6 +130,8 @@ Some languages require additional installations or setup steps, as noted.
[pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`),
[Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`);
ty and pyrefly require `uv`/`uvx` in PATH)
* **QML**
(requires Qt 6, provides `qmlls` or `qmlls6` on PATH; see the [Qt qmlls documentation](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html))
* **R**
(requires installation of the `languageserver` R package)
* **Ruby**
+1
View File
@@ -378,6 +378,7 @@ markers = [
"scss: language server running for SCSS / Sass / CSS (some-sass-language-server)",
"angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)",
"ada: language server running for Ada / SPARK (uses AdaCore Ada Language Server)",
"qml: language server running for QML (uses Qt's qmlls)",
]
[tool.codespell]
@@ -0,0 +1,128 @@
"""
Provides QML specific instantiation of the LanguageServer class using Qt's qmlls.
"""
import logging
import shutil
from solidlsp.ls import (
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class QmlLanguageServer(SolidLanguageServer):
"""
Provides QML specific instantiation of the LanguageServer class using qmlls.
qmlls is the official QML language server shipped with Qt 6.
It must be installed separately; see https://doc.qt.io/qt-6/qtqml-tool-qmlls.html
for installation instructions.
The dependency provider looks for ``qmlls6`` first, then falls back to ``qmlls``.
Users can override the executable via the ``ls_path`` entry in ``ls_specific_settings.qml``.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
super().__init__(config, repository_root_path, None, "qml", solidlsp_settings)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
"""
Discover the qmlls executable on PATH.
Tries ``qmlls6`` first (Qt 6+), then falls back to ``qmlls``.
:return: path to the qmlls executable
:raises FileNotFoundError: if qmlls is neither on PATH nor provided via ``ls_path``
"""
qmlls_binary = shutil.which("qmlls6") or shutil.which("qmlls")
if qmlls_binary is None:
raise FileNotFoundError(
"qmlls (QML language server) is not installed or not in PATH.\n"
"Please install Qt 6 and ensure 'qmlls' (or 'qmlls6') is available on your PATH.\n"
"See: https://doc.qt.io/qt-6/qtqml-tool-qmlls.html"
)
return qmlls_binary
def _create_launch_command(self, core_path: str) -> list[str]:
# qmlls communicates via stdio by default; no extra flags are required.
return [core_path]
def _create_base_initialize_params(self) -> dict:
"""
Return the language-specific initialize params for the QML language server.
``processId``, ``rootPath``, ``rootUri`` and ``workspaceFolders`` are populated by the
default ``InitializeParamsBuilder`` and must not be set here.
"""
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"completion": {
"dynamicRegistration": True,
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
},
},
"hover": {
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"configuration": True,
},
},
}
return initialize_params
def _start_server(self) -> None:
"""Start the QML language server process."""
def register_capability_handler(_params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def do_nothing(_params: dict) -> None:
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting QML language server (qmlls) process")
self.server.start()
initialize_params = self._create_initialize_params()
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
# verify server capabilities
capabilities = init_response["capabilities"]
log.info(f"QML language server capabilities: {list(capabilities.keys())}")
self.server.notify.initialized({})
+11
View File
@@ -173,6 +173,11 @@ class Language(str, Enum):
The editor must already be running with its built-in LSP enabled (default).
Supports .gd and .gdscript files.
"""
QML = "qml"
"""QML language server using Qt's qmlls (or qmlls6).
Supports .qml files. Requires Qt 6 installation providing qmlls on PATH.
See https://doc.qt.io/qt-6/qtqml-tool-qmlls.html
"""
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -557,6 +562,8 @@ class Language(str, Enum):
return FilenameMatcher(".ads", ".adb", ".ada", case_sensitive=False)
case self.GDSCRIPT:
return FilenameMatcher(".gd", ".gdscript")
case self.QML:
return FilenameMatcher(".qml")
case self.HTML:
return FilenameMatcher(".html", ".htm")
case self.SCSS:
@@ -836,6 +843,10 @@ class Language(str, Enum):
from solidlsp.language_servers.godot_language_server import GodotLanguageServer
return GodotLanguageServer
case self.QML:
from solidlsp.language_servers.qml_language_server import QmlLanguageServer
return QmlLanguageServer
case self.HTML:
from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer
+6
View File
@@ -254,6 +254,7 @@ Flag indicating whether the tests are running in the GitHub CI environment.
is_windows = platform.system() == "Windows"
is_macos = platform.system() == "Darwin"
is_linux = platform.system() == "Linux"
_LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
@@ -407,6 +408,11 @@ def _determine_disabled_languages() -> list[Language]:
result.append(Language.REGO)
if _sh.which("elm") is None and not is_ci:
result.append(Language.ELM)
# qmlls is installed (standalone build; see pytest.yml) only on the Ubuntu other-langs CI batch. It is
# expected there, so a missing binary on Linux CI is NOT skipped here -- the test runs and fails loudly,
# catching a CI setup regression. On Windows/macOS CI (never installed) and off-CI without the binary it skips.
if (_sh.which("qmlls6") is None and _sh.which("qmlls") is None) and not (is_ci and is_linux):
result.append(Language.QML)
# === 3. Disabled wherever the precondition is missing (including on CI) ===
# 3a. Platform precondition: these language servers have no native Windows support.
@@ -0,0 +1,14 @@
import QtQuick 2.15
Rectangle {
id: root
width: 200
height: root.width
color: "green"
Text {
id: label
text: root.width + "x" + root.height
anchors.centerIn: parent
}
}
@@ -0,0 +1,23 @@
import QtQuick 2.15
Item {
width: 400
height: 300
CustomComponent {
x: 50
y: 50
}
Rectangle {
y: 180
width: 200
height: 100
color: "yellow"
Text {
text: "Another shape"
anchors.centerIn: parent
}
}
}
@@ -0,0 +1,5 @@
import QtQuick 2.15
Item {
width: 100
height: 100
@@ -0,0 +1,20 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
ApplicationWindow {
id: mainWindow
title: "Test Application"
width: 800
height: 600
Button {
id: mainButton
text: "Click Me"
width: 100
height: 50
onClicked: {
console.log("Button clicked")
}
}
}
@@ -0,0 +1,14 @@
import QtQuick 2.15
Rectangle {
id: customRect
width: 200
height: 100
color: "lightblue"
Text {
id: label
text: "Hello QML"
anchors.centerIn: parent
}
}
View File
Whitespace-only changes.
+125
View File
@@ -0,0 +1,125 @@
"""
Basic integration tests for the QML language server (qmlls) functionality.
These tests validate document symbols, reference search, and diagnostics
using the QML test repository.
Requires ``qmlls6`` or ``qmlls`` on PATH (shipped with Qt 6).
"""
import os
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import language_tests_enabled
from test.solidlsp.conftest import read_repo_file
from test.solidlsp.util.diagnostics import assert_file_diagnostics
pytestmark = [
pytest.mark.qml,
pytest.mark.skipif(not language_tests_enabled(Language.QML), reason="QML tests are disabled (qmlls/qmlls6 not available)"),
]
class TestQmlLanguageServer:
"""Test QML language server startup and basic features."""
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer) -> None:
"""Test that the language server starts successfully."""
assert language_server.is_running()
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None:
"""Test that document symbols are returned for the main file.
qmlls names QML objects by their type (ApplicationWindow, Button); properties such as
``id`` and ``width`` appear as child symbols, so we assert on the component type names.
"""
file_path = os.path.join("src", "main.qml")
doc_symbols = language_server.request_document_symbols(file_path)
all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots()
root_names = [s.get("name") for s in root_symbols if s.get("name")]
symbol_names = [s.get("name") for s in all_symbols if s.get("name")]
assert "ApplicationWindow" in root_names, f"ApplicationWindow root missing. Roots: {root_names}"
assert "Button" in symbol_names, f"Button component missing. Symbols: {symbol_names}"
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
def test_document_symbols_shapes(self, language_server: SolidLanguageServer) -> None:
"""Test that document symbols are returned for the shapes file."""
file_path = os.path.join("src", "shapes.qml")
doc_symbols = language_server.request_document_symbols(file_path)
all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots()
root_names = [s.get("name") for s in root_symbols if s.get("name")]
symbol_names = [s.get("name") for s in all_symbols if s.get("name")]
assert "Rectangle" in root_names, f"Rectangle root missing. Roots: {root_names}"
assert "Text" in symbol_names, f"Text component missing. Symbols: {symbol_names}"
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.QML], indirect=True)
def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test that references are found within the same file.
CustomComponent.qml references ``root.width`` on multiple lines (declaration via
``id: root`` on line 3 and usages on lines 5 and 10). We click on a usage of
``root`` and verify that the other usages are found.
"""
file_path = str(repo_path / "src" / "CustomComponent.qml")
content = read_repo_file(language_server, os.path.join("src", "CustomComponent.qml"))
# Probe at the `root` identifier in `root.width` on line 10 (0-indexed) —
# the occurrence inside the Text block. `find_text_coordinates` uses a
# capturing group around the target text so `coords` lands on `root`.
coords = find_text_coordinates(content, r"(root)\.width")
assert coords is not None, "Could not locate root.width in CustomComponent.qml"
references = language_server.request_references(file_path, coords.line, coords.col + 1)
assert references, f"Expected non-empty references for root, got {references=}"
# All references should be within CustomComponent.qml (no cross-file
# references to ``root``, which is scoped to this component).
ref_files = {loc["uri"].split("/")[-1] for loc in references}
assert "CustomComponent.qml" in ref_files, f"All references should be in CustomComponent.qml, got {ref_files}"
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.QML], indirect=True)
def test_find_references_cross_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test that references are found across files.
CustomComponent.qml defines a QML component type that is instantiated in
UserComponent.qml. Clicking on the ``CustomComponent`` type usage in
UserComponent.qml should return at least one reference, ideally spanning
both files.
"""
file_path = str(repo_path / "src" / "UserComponent.qml")
content = read_repo_file(language_server, os.path.join("src", "UserComponent.qml"))
# Locate the `CustomComponent` type usage in UserComponent.qml.
coords = find_text_coordinates(content, r"(CustomComponent)")
assert coords is not None, "Could not locate CustomComponent type in UserComponent.qml"
references = language_server.request_references(file_path, coords.line, coords.col + 1)
assert references, f"Expected non-empty references for CustomComponent, got {references=}"
ref_files = {loc["uri"].split("/")[-1] for loc in references}
assert "UserComponent.qml" in ref_files, f"Expected at least one reference in UserComponent.qml, got {ref_files}"
@pytest.mark.parametrize("language_server", [Language.QML], indirect=True)
def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""Test that diagnostics are reported for a QML file with errors.
diagnostics_sample.qml contains an unknown property (``colorr`` instead of
``color``) which qmlls should flag as an error or warning.
"""
assert_file_diagnostics(
language_server,
os.path.join("src", "diagnostics_sample.qml"),
(),
min_count=1,
)