feat: make test-ui checks the UI files with blueprint-compiler

The interface is hand-written GtkBuilder XML and nothing in the build can see a mistake in it: a
property a class does not have, or a child a container does not accept, is a Gtk-ERROR when the
window is built -- and Gtk-ERROR aborts the process, so a typo in a .ui takes the application down
rather than failing to open a dialog. xml-stripblanks catches malformed XML and nothing else, and
the probes catch the rest only for the windows they open. Three of those aborts happened during
this refactor.

tools/check_ui.py round-trips each file through blueprint-compiler: decompile to blueprint, then
compile that, which type-checks every property and child type against the GIR. The XML stays the
source of truth and stays hand-written -- this is a check, not a compiler, and nothing here is
needed to build the application. Which is why the target is opt-in (no ALL) and the tool is looked
for at configure time rather than required: without it the target is absent, not silently passing.

    $ make -C build test-ui
    check_ui: 5 files in src/ui
      ok    help.ui ... source-form.ui
    check_ui: 5 ok, 0 failed

Injected the exact mistake that cost a run earlier (an AdwEntryRow subtitle) and the check caught
it, with the caret and the "did you mean title?" hint, exiting non-zero. --lint prints the linter's
advice separately: it is advice, and the linter has opinions this project does not share.
This commit is contained in:
Martino Ferrari committed 2026-10-12 10:41:25 +02:00
1 parent 8073e7b3fd
commit 7a982da6c4
4 files changed
+190 -17

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""Checks the interface files: `make test-ui`.
The interface is hand-written GtkBuilder XML, and nothing in the build can see a mistake in it. A
property a class does not have, or a child a container does not accept, is a `Gtk-ERROR` when the
window is built — and `Gtk-ERROR` aborts the process, so a typo in a `.ui` takes the application
down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches malformed XML and
nothing else, and the probes catch the rest only for the windows they open.
blueprint-compiler can see it: it type-checks every property and child type against the GIR. It reads
blueprint rather than XML — but it reads XML too, because `decompile` turns a `.ui` back into
blueprint. So the check is a round trip, and the XML stays the source of truth:
$ make test-ui
check_ui: 5 files in src/ui
ok help.ui
ok main-window.ui
ok preferences.ui
ok shortcuts.ui
ok source-form.ui
check_ui: 5 ok, 0 failed
check_ui: 23 pieces of advice — not failures; --lint shows them
A file that fails prints what the compiler said, with the line, the caret and its suggestion:
FAIL preferences.ui
error: Class Adw.EntryRow does not have a property called subtitle
at preferences.blp line 24 column 9:
24 | subtitle: "empty = unicast";
| ^^^^^^^^
hint: Did you mean `title`?
`lint` runs too and its findings are printed as advice rather than failures: a linter has opinions
this project does not have to share (it wants "udpscope" marked translatable, which it is not), and a
check that fails on advice is a check people stop running.
A missing `blueprint-compiler` is a *failure* and not a skip. This target exists to be run, and
answering "fine" without having looked is the one answer it must not give.
"""
import argparse
import pathlib
import shutil
import subprocess
import sys
import tempfile
TOOL = "blueprint-compiler"
def check(tool: str, ui: pathlib.Path, scratch: pathlib.Path) -> tuple[bool, list[str]]:
"""Round-trips one `.ui` through blueprint and returns (ok, advice)."""
blp = scratch / (ui.stem + ".blp")
# `decompile` only transcribes: it is the *compile* of its output that checks the properties, so
# both exit codes matter -- a file the decompiler cannot read at all is just as broken.
round_trip = subprocess.run(
[tool, "decompile", str(ui), "--output", str(blp)], capture_output=True, text=True
)
if round_trip.returncode != 0 or not blp.exists():
return False, [line for line in round_trip.stderr.splitlines() if line]
compiled = subprocess.run(
[tool, "compile", str(blp)], capture_output=True, text=True
)
if compiled.returncode != 0:
return False, [line for line in compiled.stderr.splitlines() if line]
linted = subprocess.run([tool, "lint", str(blp)], capture_output=True, text=True)
advice = [line for line in linted.stderr.splitlines() if line]
return True, advice
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", nargs="?", default="src/ui",
help="where the .ui files are (default: src/ui)")
parser.add_argument("--lint", action="store_true",
help="print the linter's advice as well as the failures")
args = parser.parse_args()
tool = shutil.which(TOOL)
if tool is None:
print(f"check_ui: {TOOL} is not installed, so the UI files were NOT checked", file=sys.stderr)
print(" install it (Arch: pacman -S blueprint-compiler) and run this again",
file=sys.stderr)
return 2
directory = pathlib.Path(args.directory)
files = sorted(directory.glob("*.ui"))
if not files:
print(f"check_ui: no .ui files in {directory}", file=sys.stderr)
return 2
print(f"check_ui: {len(files)} files in {directory}")
failed = 0
advice_count = 0
with tempfile.TemporaryDirectory(prefix="check-ui-") as scratch_name:
scratch = pathlib.Path(scratch_name)
for ui in files:
ok, messages = check(tool, ui, scratch)
print(f" {'ok ' if ok else 'FAIL'} {ui.name}")
if ok:
advice_count += len([m for m in messages if m.startswith("warning:")])
else:
failed += 1
# A failure is always printed; advice only when it was asked for. Twenty lines of opinion
# on every run is how a check's output stops being read.
if not ok or args.lint:
for line in messages:
print(f" {line}")
print(f"check_ui: {len(files) - failed} ok, {failed} failed")
if advice_count:
print(f"check_ui: {advice_count} pieces of advice — not failures; --lint shows them")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())