From a088f000eaf08fb8dd3169cc9fd17e2dafb880a6 Mon Sep 17 00:00:00 2001 From: Andrew Marshall Date: Sat, 3 Feb 2024 22:13:51 +0000 Subject: [PATCH] initial commit with support for QString and QMap --- .gitignore | 2 + .gitlab-ci.yml | 15 ++++++ .lldbinit | 1 + CMakeLists.txt | 8 ++++ CMakePresets.json | 32 +++++++++++++ README.md | 60 ++++++++++++++++++++++++ examples/CMakeLists.txt | 5 ++ examples/main.cpp | 14 ++++++ lldb_qt_formatters/__init__.py | 13 ++++++ lldb_qt_formatters/qmap.py | 83 ++++++++++++++++++++++++++++++++++ lldb_qt_formatters/qstring.py | 26 +++++++++++ test/conftest.py | 43 ++++++++++++++++++ test/test_qmap.py | 15 ++++++ test/test_qstring.py | 16 +++++++ 14 files changed, 333 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100755 .lldbinit create mode 100755 CMakeLists.txt create mode 100755 CMakePresets.json create mode 100755 README.md create mode 100755 examples/CMakeLists.txt create mode 100755 examples/main.cpp create mode 100755 lldb_qt_formatters/__init__.py create mode 100755 lldb_qt_formatters/qmap.py create mode 100755 lldb_qt_formatters/qstring.py create mode 100644 test/conftest.py create mode 100755 test/test_qmap.py create mode 100755 test/test_qstring.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ab273a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build +venv \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..523b198 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,15 @@ +stages: + - test + +test-formatters: + stage: test + variables: + Qt5_ROOT: /opt/gitlab-runner/builds$QT_SEED_PATH + CMAKE_PRESET: gitlab-linux-gcc-debug + script: + - python -m venv venv + - source venv/bin/activate + - pip install pytest + - pytest test + tags: + - entos-desktop-build-runner \ No newline at end of file diff --git a/.lldbinit b/.lldbinit new file mode 100755 index 0000000..9aeb0d7 --- /dev/null +++ b/.lldbinit @@ -0,0 +1 @@ +command script import lldb_qt_formatters diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100755 index 0000000..6b11b6e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.22.0) +project(lldb-qt-formatters LANGUAGES CXX) + +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + +add_subdirectory(examples) \ No newline at end of file diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100755 index 0000000..ac7b7fe --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,32 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 22, + "patch": 0 + }, + "configurePresets": [ + { + "name": "default-debug", + "hidden": true, + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "linux-gcc-debug", + "inherits": ["default-debug"], + "cacheVariables": { + "CMAKE_CXX_COMPILER": "g++" + } + }, + { + "name": "gitlab-linux-gcc-debug", + "inherits": ["linux-gcc-debug"], + "cacheVariables": { + "Qt5_ROOT": "$env{Qt5_ROOT}" + } + } + ] +} diff --git a/README.md b/README.md new file mode 100755 index 0000000..2463d56 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +Qt Type Formatters for LLDB +=========================== + +This project adds various [type formatters](https://lldb.llvm.org/use/variable.html#type-format) +to enable more user friendly display of Qt Core Types in LLDB and IDEs that +support it. + +Usage +----- + +Load the type formatters in LLDB by executing + +```.console +command script import lldb_qt_formatters +``` + +or alternatively place the above line in a `.lldbinit` file in the root of the project, or in your `$HOME` directory. +To use a project-specific configuration file +you will need to [enable it](https://lldb.llvm.org/man/lldb.html#configuration-files) + +### VSCode + +VSCode will not source a project `.lldbinit` file even if you enable it as above. To automatically load the +config file, add the following to your `launch.json` file. + +```.json +"configurations": [{ + "type": "lldb", + ... + "initCommands": [ + "command source ${workspaceFolder}/.lldbinit" + ] +}] + +``` + +Supported Types +--------------- + +* `QMap` +* `QMapNode` +* `QString` + + +Contribution Guidelines +----------------------- + +Contributions are welcomed. +See the guide to [variable formatting](https://lldb.llvm.org/use/variable.html) in LLDB, in addition to the +existing formatters in the [LLDB Source](https://github.com/llvm/llvm-project/tree/main/lldb/examples) + +1. Create a test for your formatter. The tests run LLDB against some sample code and examine the formatted + variable output. +2. Implement a new formatter + * Simple types can be supported with a summary string in the `__init__.py` file + (eg, see support for the `QMapNode` type) + * More complex types can be supported using the Python API (eg `QString`) + * Container types such as `QMap<>` require more elaborate use of the Python API to generate *"Synthetic Children"* + + Formatters are registered with their types in the `__init__.py` file. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100755 index 0000000..b22c244 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,5 @@ +find_package(Qt5 REQUIRED COMPONENTS Core) + +add_executable(example main.cpp) +target_link_libraries(example PRIVATE Qt5::Core) +target_compile_features(example PRIVATE cxx_std_20) diff --git a/examples/main.cpp b/examples/main.cpp new file mode 100755 index 0000000..e1a61c0 --- /dev/null +++ b/examples/main.cpp @@ -0,0 +1,14 @@ +#include +#include + +int main() { + auto hello = QString("Hello World"); + auto demosthenes = QString("Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι"); + + auto map = QMap{}; + map["one"] = 1; + map["forty-two"] = 42; + map["1.21 gigawatts"] = 1210000; + + return 0; +} \ No newline at end of file diff --git a/lldb_qt_formatters/__init__.py b/lldb_qt_formatters/__init__.py new file mode 100755 index 0000000..c8949c7 --- /dev/null +++ b/lldb_qt_formatters/__init__.py @@ -0,0 +1,13 @@ +from .qstring import * +from .qmap import * + + +def __lldb_init_module(debugger, dict): + init_commands = [ + 'type summary add --python-function lldb_qt_formatters.qstring.format_summary "QString"', + 'type summary add --summary-string "\{${var.key}: ${var.value}\}" -x "QMapNode<.+>$"', + 'type synthetic add --python-class lldb_qt_formatters.qmap.SyntheticChildrenProvider -x "QMap<.+>$"', + 'type summary add --expand --python-function lldb_qt_formatters.qmap.format_summary -x "QMap<.+>$"' + ] + for command in init_commands: + debugger.HandleCommand(command) diff --git a/lldb_qt_formatters/qmap.py b/lldb_qt_formatters/qmap.py new file mode 100755 index 0000000..2ba6a3d --- /dev/null +++ b/lldb_qt_formatters/qmap.py @@ -0,0 +1,83 @@ +from typing import List + +from lldb import SBValue + + +def format_summary(valobj: SBValue, internal_dict, options): + """ + Format a summary string for QMap values + + :param valobj: + :param internal_dict: unused + :param options: unused + """ + + provider = SyntheticChildrenProvider(valobj.GetNonSyntheticValue(), internal_dict) + return f"Size: {provider.size()}" + + +def _depth_first_traverse(node: SBValue, nodes: List[int]): + # We only need the address of each node as we're going to read its + # value directly from memory later + nodes.append(node.load_addr) + + # QMaps are Red-Black trees but we don't need to know much about the + # details to just extract the data. + left = node.GetChildMemberWithName("left") + if left.GetValueAsUnsigned() != 0: + _depth_first_traverse(left.deref, nodes) + + right = node.GetChildMemberWithName("right") + if right.GetValueAsUnsigned() != 0: + _depth_first_traverse(right.deref, nodes) + + +def _derived_node_type(map_obj: SBValue): + # The actual data in the QMap is in QMapNode<> elements, however QMaps only actually store each node as a + # pointer to a non-templated base class of QMapNode<>. We need to work out what the derived class is supposed to be + # so we can get at the data + map_type = map_obj.type.GetUnqualifiedType() + key_type = map_type.GetTemplateArgumentType(0) + value_type = map_type.GetTemplateArgumentType(1) + node_type = f"QMapNode<{key_type.name}, {value_type.name}>" + # TODO (andrew) this can fail in unusual circumstances - such as trying to use a GCC-built Qt with a Clang-built + # main, but that's only ever going to work by coincidence anyway... + return map_obj.target.FindFirstType(node_type) + + +class SyntheticChildrenProvider: + def __init__(self, valobj, internal_dict): + self.valobj = valobj + self.node_type = _derived_node_type(self.valobj) + + def size(self): + data_member_ptr = self.valobj.GetChildMemberWithName("d") + data_member = data_member_ptr.deref + return data_member.GetChildMemberWithName("size").GetValueAsSigned() + + def update(self): + data_member_ptr = self.valobj.GetChildMemberWithName("d") + data_member = data_member_ptr.deref + # The root node is also a QMapNode<> but doesn't contain any + # data we actually need. + root = data_member.GetChildMemberWithName("header") + nodes = [] + _depth_first_traverse(root, nodes) + self.size = data_member.GetChildMemberWithName("size").GetValueAsSigned() + self.nodes = [] if self.size == 0 else nodes[1:] + + def num_children(self): + return self.size + + @staticmethod + def get_child_index(self, name: str): + index = name.lstrip("[").rstrip("]") + return int(index) + + def get_child_at_index(self, index): + node_addr = self.nodes[index] + child_node = self.valobj.CreateValueFromAddress(f"[{index}]", node_addr, self.node_type) + return child_node + + def has_children(self): + return self.size > 0 diff --git a/lldb_qt_formatters/qstring.py b/lldb_qt_formatters/qstring.py new file mode 100755 index 0000000..64f92e0 --- /dev/null +++ b/lldb_qt_formatters/qstring.py @@ -0,0 +1,26 @@ +from lldb import SBError, SBValue + + +def format_summary(valobj: SBValue, internal_dict, options): + """ + Format a summary string for QString values + + :param valobj: + :param internal_dict: unused + :param options: unused + """ + if not valobj.process: + return "Unknown" + + data_member = valobj.GetChildMemberWithName("d") + array_member = data_member.deref + + offset_in_bytes = array_member.GetChildMemberWithName("offset").GetValueAsUnsigned() + string_length = array_member.GetChildMemberWithName("size").GetValueAsUnsigned() + address = data_member.GetValueAsUnsigned() + + error = SBError() + character_size = 2 + content = valobj.process.ReadMemory(address + offset_in_bytes, string_length * character_size, error) + + return f'"{bytearray(content).decode("utf-16")}"' diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..591bcca --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,43 @@ +import os +from subprocess import run +from tempfile import NamedTemporaryFile + +import pytest + + +@pytest.fixture(scope="session") +def exe(tmp_path_factory): + repository_root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + build_folder = tmp_path_factory.mktemp("build") + cmake_preset = os.environ.get("CMAKE_PRESET", "linux-gcc-debug") + cmake_configure = ["cmake", "--preset", cmake_preset, "-S", repository_root, "-B", build_folder] + cmake_build = ["cmake", "--build", build_folder] + run(cmake_configure) + run(cmake_build) + + return build_folder / "bin" / "example" + + +@pytest.fixture +def lldb(request, exe): + preamble = [ + "command script import lldb_qt_formatters", + "breakpoint set --file main.cpp --line 13", + "run" + ] + marker = request.node.get_closest_marker("lldb_script") + test_script = marker.args[0] if marker is not None else "" + + script = "\n".join(preamble + [test_script.lstrip(" \n")]) + + with NamedTemporaryFile("w") as fp: + fp.write(script) + fp.flush() + + lldb_command = ["lldb", "--batch", "--source", fp.name, exe] + result = run(lldb_command, check=True, text=True, capture_output=True) + yield result.stdout + + # TODO (andrew) work out how to just print the lldb output if the test fails + print(result.stdout) + diff --git a/test/test_qmap.py b/test/test_qmap.py new file mode 100755 index 0000000..fd99212 --- /dev/null +++ b/test/test_qmap.py @@ -0,0 +1,15 @@ +import re + +import pytest + + +@pytest.mark.lldb_script("frame variable map") +def test_qmap_of_qstring_uint_summary(lldb): + match = re.search(r"\(QMap<.+>\) map = (?P.+) {", lldb, re.MULTILINE) + assert match.group('summary') == 'Size: 3' + + +@pytest.mark.lldb_script("frame variable map") +def test_qmap_of_qstring_uint_children(lldb): + match = re.search(r"\[0] = (?P\{.+})", lldb, re.MULTILINE) + assert match.group('summary') == '{"forty-two": 42}' diff --git a/test/test_qstring.py b/test/test_qstring.py new file mode 100755 index 0000000..48cfb61 --- /dev/null +++ b/test/test_qstring.py @@ -0,0 +1,16 @@ +import re + +import pytest + + +@pytest.mark.lldb_script("frame variable hello") +def test_qstring_summary(lldb): + match = re.search(r"\(QString\) hello = (?P.+)", lldb, re.MULTILINE) + assert match.group('summary') == '"Hello World"' + + +@pytest.mark.lldb_script("frame variable demosthenes") +def test_qstring_utf8(lldb): + demosthenes = "Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι" + match = re.search(r"\(QString\) demosthenes = (?P.+)", lldb, re.MULTILINE) + assert match.group('summary') == f'"{demosthenes}"'