From 8e4433a487d3abe2a450dc97dd646a4461b8ed1b Mon Sep 17 00:00:00 2001 From: James Westman Date: Wed, 24 Nov 2021 15:57:15 -0600 Subject: [PATCH] Create an interactive porting tool `blueprint-compiler port` interactively ports a project to blueprint. It will create the subproject wrap file, add it to .gitignore, decompile your GtkBuilder XML files, emit code to copy and paste into your meson.build file, and update POTFILES.in. It can't quite handle all of the features the forward compiler can, so it will skip those files. --- .gitignore | 1 + blueprintcompiler/decompiler.py | 387 +++++++++++++++++++++++ blueprintcompiler/errors.py | 25 +- blueprintcompiler/extensions/gtk_a11y.py | 28 +- blueprintcompiler/gir.py | 73 ++++- blueprintcompiler/interactive_port.py | 285 +++++++++++++++++ blueprintcompiler/main.py | 17 +- blueprintcompiler/utils.py | 12 + blueprintcompiler/xml_reader.py | 17 +- docs/setup.rst | 29 +- tests/samples/accessibility_dec.blp | 12 + tests/samples/id_prop.blp | 3 +- tests/samples/layout_dec.blp | 10 + tests/samples/menu.ui | 2 +- tests/samples/menu_dec.blp | 31 ++ tests/samples/style_dec.blp | 8 + tests/samples/translated.blp | 1 + tests/samples/using.blp | 3 +- tests/test_samples.py | 38 ++- 19 files changed, 921 insertions(+), 61 deletions(-) create mode 100644 blueprintcompiler/decompiler.py create mode 100644 blueprintcompiler/interactive_port.py create mode 100644 tests/samples/accessibility_dec.blp create mode 100644 tests/samples/layout_dec.blp create mode 100644 tests/samples/menu_dec.blp create mode 100644 tests/samples/style_dec.blp diff --git a/.gitignore b/.gitignore index 29e31a3..fc013fb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ blueprint-compiler.pc /htmlcov coverage.xml .mypy_cache +/subprojects/gtk-blueprint-tool diff --git a/blueprintcompiler/decompiler.py b/blueprintcompiler/decompiler.py new file mode 100644 index 0000000..59735bb --- /dev/null +++ b/blueprintcompiler/decompiler.py @@ -0,0 +1,387 @@ +# decompiler.py +# +# Copyright 2021 James Westman +# +# This file is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see . +# +# SPDX-License-Identifier: LGPL-3.0-or-later + +import re +from enum import Enum +import typing as T +from dataclasses import dataclass + +from .extensions import gtk_a11y +from .xml_reader import Element, parse +from .gir import * +from .utils import Colors + + +__all__ = ["decompile"] + + +_DECOMPILERS: T.Dict = {} +_CLOSING = { + "{": "}", + "[": "]", +} +_NAMESPACES = [ + ("GLib", "2.0"), + ("GObject", "2.0"), + ("Gio", "2.0"), + ("Adw", "1.0"), +] + + +class LineType(Enum): + NONE = 1 + STMT = 2 + BLOCK_START = 3 + BLOCK_END = 4 + + +class DecompileCtx: + def __init__(self): + self._result = "" + self.gir = GirContext() + self._indent = 0 + self._blocks_need_end = [] + self._last_line_type = LineType.NONE + + self.gir.add_namespace(get_namespace("Gtk", "4.0")) + + + @property + def result(self): + imports = "\n".join([ + f"using {ns} {namespace.version};" + for ns, namespace in self.gir.namespaces.items() + ]) + return imports + "\n" + self._result + + + def type_by_cname(self, cname): + if type := self.gir.get_type_by_cname(cname): + return type + + for ns, version in _NAMESPACES: + try: + namespace = get_namespace(ns, version) + if type := namespace.get_type_by_cname(cname): + self.gir.add_namespace(namespace) + return type + except: + pass + + + def start_block(self): + self._blocks_need_end.append(None) + + def end_block(self): + if close := self._blocks_need_end.pop(): + self.print(close) + + def end_block_with(self, text): + self._blocks_need_end[-1] = text + + + def print(self, line, newline=True): + if line == "}" or line == "]": + self._indent -= 1 + + # Add blank lines between different types of lines, for neatness + if newline: + if line == "}" or line == "]": + line_type = LineType.BLOCK_END + elif line.endswith("{") or line.endswith("]"): + line_type = LineType.BLOCK_START + elif line.endswith(";"): + line_type = LineType.STMT + else: + line_type = LineType.NONE + if line_type != self._last_line_type and self._last_line_type != LineType.BLOCK_START and line_type != LineType.BLOCK_END: + self._result += "\n" + self._last_line_type = line_type + + self._result += (" " * self._indent) + line + if newline: + self._result += "\n" + + if line.endswith("{") or line.endswith("["): + if len(self._blocks_need_end): + self._blocks_need_end[-1] = _CLOSING[line[-1]] + self._indent += 1 + + + def print_attribute(self, name, value, type): + if type is None: + self.print(f"{name}: \"{_escape_quote(value)}\";") + elif type.assignable_to(FloatType()): + self.print(f"{name}: {value};") + elif type.assignable_to(BoolType()): + val = _truthy(value) + self.print(f"{name}: {'true' if val else 'false'};") + elif ( + type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("Gdk.Pixbuf")) + or type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("Gdk.Texture")) + or type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("Gdk.Paintable")) + or type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("Gtk.ShortcutAction")) + or type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("Gtk.ShortcutTrigger")) + ): + self.print(f"{name}: \"{_escape_quote(value)}\";") + elif type.assignable_to(self.gir.namespaces["Gtk"].lookup_type("GObject.Object")): + self.print(f"{name}: {value};") + elif isinstance(type, Enumeration): + for member in type.members.values(): + if member.nick == value or member.c_ident == value: + self.print(f"{name}: {member.name};") + break + else: + self.print(f"{name}: {value.replace('-', '_')};") + elif isinstance(type, Bitfield): + flags = re.sub(r"\s*\|\s*", " | ", value).replace("-", "_") + self.print(f"{name}: {flags};") + else: + self.print(f"{name}: \"{_escape_quote(value)}\";") + + +def _decompile_element(ctx: DecompileCtx, gir, xml): + try: + decompiler = _DECOMPILERS.get(xml.tag) + if decompiler is None: + raise UnsupportedError(f"unsupported XML tag: <{xml.tag}>") + + args = {_canon(name): value for name, value in xml.attrs.items()} + if decompiler._cdata: + if len(xml.children): + args["cdata"] = None + else: + args["cdata"] = xml.cdata + + ctx.start_block() + gir = decompiler(ctx, gir, **args) + + for child_type in xml.children.values(): + for child in child_type: + _decompile_element(ctx, gir, child) + + ctx.end_block() + + except UnsupportedError as e: + raise e + except TypeError as e: + raise UnsupportedError(tag=xml.tag) + + +def decompile(data): + ctx = DecompileCtx() + + xml = parse(data) + _decompile_element(ctx, None, xml) + + return ctx.result + + + +def _canon(string: str) -> str: + if string == "class": + return "klass" + else: + return string.replace("-", "_").lower() + +def _truthy(string: str) -> bool: + return string.lower() in ["yes", "true", "t", "y", "1"] + +def _full_name(gir): + return gir.name if gir.full_name.startswith("Gtk.") else gir.full_name + +def _lookup_by_cname(gir, cname: str): + if isinstance(gir, GirContext): + return gir.get_type_by_cname(cname) + else: + return gir.get_containing(Repository).get_type_by_cname(cname) + + +def decompiler(tag, cdata=False): + def decorator(func): + func._cdata = cdata + _DECOMPILERS[tag] = func + return func + return decorator + + +def _escape_quote(string: str) -> str: + return (string + .replace("\\", "\\\\") + .replace("\'", "\\'") + .replace("\"", "\\\"") + .replace("\n", "\\n")) + + +@decompiler("interface") +def decompile_interface(ctx, gir): + return gir + + +@decompiler("requires") +def decompile_requires(ctx, gir, lib=None, version=None): + return gir + + +@decompiler("template") +def decompile_template(ctx, gir, klass, parent="Widget"): + gir_class = ctx.type_by_cname(parent) + if gir_class is None: + ctx.print(f"template {klass} : .{parent} {{") + else: + ctx.print(f"template {klass} : {_full_name(gir_class)} {{") + return gir_class + + +@decompiler("object") +def decompile_object(ctx, gir, klass, id=None): + gir_class = ctx.type_by_cname(klass) + klass_name = _full_name(gir_class) if gir_class is not None else "." + klass + if id is None: + ctx.print(f"{klass_name} {{") + else: + ctx.print(f"{klass_name} {id} {{") + return gir_class + + +@decompiler("child") +def decompile_child(ctx, gir, type=None): + if type is not None: + ctx.print(f"[{type}]") + return gir + + +@decompiler("property", cdata=True) +def decompile_property(ctx, gir, name, cdata, bind_source=None, bind_property=None, bind_flags=None, translatable="false", comments=None, context=None): + name = name.replace("_", "-") + if comments is not None: + ctx.print(f"/* Translators: {comments} */") + + if cdata is None: + ctx.print(f"{name}: ", False) + ctx.end_block_with(";") + elif bind_source: + flags = "" + if bind_flags: + if "sync-create" in bind_flags: + flags += " sync-create" + if "after" in bind_flags: + flags += " after" + ctx.print(f"{name}: bind {bind_source}.{bind_property}{flags};") + elif _truthy(translatable): + if context is not None: + ctx.print(f"{name}: C_(\"{_escape_quote(context)}\", \"{_escape_quote(cdata)}\");") + else: + ctx.print(f"{name}: _(\"{_escape_quote(cdata)}\");") + elif gir is None or gir.properties.get(name) is None: + ctx.print(f"{name}: \"{_escape_quote(cdata)}\";") + else: + ctx.print_attribute(name, cdata, gir.properties.get(name).type) + return gir + + +@decompiler("signal") +def decompile_signal(ctx, gir, name, handler, swapped="false"): + name = name.replace("_", "-") + if _truthy(swapped): + ctx.print(f"{name} => {handler}() swapped;") + else: + ctx.print(f"{name} => {handler}();") + return gir + + +@decompiler("style") +def decompile_style(ctx, gir): + ctx.print(f"styles [") + + +@decompiler("class") +def decompile_class(ctx, gir, name): + ctx.print(f'"{name}",') + + +@decompiler("layout") +def decompile_layout(ctx, gir): + ctx.print("layout {") + + +@decompiler("menu") +def decompile_menu(ctx, gir, id=None): + if id: + ctx.print(f"menu {id} {{") + else: + ctx.print("menu {") + +@decompiler("submenu") +def decompile_submenu(ctx, gir, id=None): + if id: + ctx.print(f"submenu {id} {{") + else: + ctx.print("submenu {") + +@decompiler("item") +def decompile_item(ctx, gir, id=None): + if id: + ctx.print(f"item {id} {{") + else: + ctx.print("item {") + +@decompiler("section") +def decompile_section(ctx, gir, id=None): + if id: + ctx.print(f"section {id} {{") + else: + ctx.print("section {") + +@decompiler("attribute", cdata=True) +def decompile_attribute(ctx, gir, name, cdata, translatable="false", comments=None, context=None): + decompile_property(ctx, gir, name, cdata, translatable=translatable, comments=comments, context=context) + +@decompiler("accessibility") +def decompile_accessibility(ctx, gir): + ctx.print("accessibility {") + +@decompiler("attributes") +def decompile_attributes(ctx, gir): + ctx.print("attributes {") + +@decompiler("relation", cdata=True) +def decompile_relation(ctx, gir, name, cdata): + ctx.print_attribute(name, cdata, gtk_a11y.get_types(ctx.gir).get(name)) + +@decompiler("state", cdata=True) +def decompile_state(ctx, gir, name, cdata, translatable="false"): + if _truthy(translatable): + ctx.print(f"{name}: _(\"{_escape_quote(cdata)}\");") + else: + ctx.print_attribute(name, cdata, gtk_a11y.get_types(ctx.gir).get(name)) + + +@dataclass +class UnsupportedError(Exception): + message: str = "unsupported feature" + tag: T.Optional[str] = None + + def print(self, filename: str): + print(f"\n{Colors.RED}{Colors.BOLD}error: {self.message}{Colors.CLEAR}") + print(f"in {Colors.UNDERLINE}{filename}{Colors.NO_UNDERLINE}") + if self.tag: + print(f"in tag {Colors.BLUE}{self.tag}{Colors.CLEAR}") + print(f"""{Colors.FAINT}The gtk-blueprint-tool compiler might support this feature, but the +porting tool does not. You probably need to port this file manually.{Colors.CLEAR}\n""") diff --git a/blueprintcompiler/errors.py b/blueprintcompiler/errors.py index 3a859da..e3fee43 100644 --- a/blueprintcompiler/errors.py +++ b/blueprintcompiler/errors.py @@ -21,16 +21,7 @@ from dataclasses import dataclass import typing as T import sys, traceback from . import utils - - -class _colors: - RED = '\033[91m' - YELLOW = '\033[33m' - FAINT = '\033[2m' - BOLD = '\033[1m' - BLUE = '\033[34m' - UNDERLINE = '\033[4m' - CLEAR = '\033[0m' +from .utils import Colors class PrintableError(Exception): """ Parent class for errors that can be pretty-printed for the user, e.g. @@ -85,12 +76,12 @@ class CompileError(PrintableError): # Display 1-based line numbers line_num += 1 - print(f"""{_colors.RED}{_colors.BOLD}{self.category}: {self.message}{_colors.CLEAR} + print(f"""{Colors.RED}{Colors.BOLD}{self.category}: {self.message}{Colors.CLEAR} at {filename} line {line_num} column {col_num}: -{_colors.FAINT}{line_num :>4} |{_colors.CLEAR}{line.rstrip()}\n {_colors.FAINT}|{" "*(col_num-1)}^{_colors.CLEAR}""") +{Colors.FAINT}{line_num :>4} |{Colors.CLEAR}{line.rstrip()}\n {Colors.FAINT}|{" "*(col_num-1)}^{Colors.CLEAR}""") for hint in self.hints: - print(f"{_colors.FAINT}hint: {hint}{_colors.CLEAR}") + print(f"{Colors.FAINT}hint: {hint}{Colors.CLEAR}") print() @@ -130,14 +121,14 @@ def assert_true(truth: bool, message:str=None): raise CompilerBugError(message) -def report_compile_error(): +def report_bug(): """ Report an error and ask people to report it. """ print(traceback.format_exc()) print(f"Arguments: {sys.argv}\n") - print(f"""{_colors.BOLD}{_colors.RED}***** COMPILER BUG ***** + print(f"""{Colors.BOLD}{Colors.RED}***** COMPILER BUG ***** The blueprint-compiler program has crashed. Please report the above stacktrace, along with the input file(s) if possible, on GitLab: -{_colors.BOLD}{_colors.BLUE}{_colors.UNDERLINE}https://gitlab.gnome.org/jwestman/blueprint-compiler/-/issues/new?issue -{_colors.CLEAR}""") +{Colors.BOLD}{Colors.BLUE}{Colors.UNDERLINE}https://gitlab.gnome.org/jwestman/blueprint-compiler/-/issues/new?issue +{Colors.CLEAR}""") diff --git a/blueprintcompiler/extensions/gtk_a11y.py b/blueprintcompiler/extensions/gtk_a11y.py index d5ea1ad..3ffbc2e 100644 --- a/blueprintcompiler/extensions/gtk_a11y.py +++ b/blueprintcompiler/extensions/gtk_a11y.py @@ -27,7 +27,7 @@ from ..parser_utils import * from ..xml_emitter import XmlEmitter -def _get_property_types(gir): +def get_property_types(gir): # from return { "autocomplete": gir.get_type("AccessibleAutocomplete", "Gtk"), @@ -52,7 +52,7 @@ def _get_property_types(gir): } -def _get_relation_types(gir): +def get_relation_types(gir): # from widget = gir.get_type("Widget", "Gtk") return { @@ -77,7 +77,7 @@ def _get_relation_types(gir): } -def _get_state_types(gir): +def get_state_types(gir): # from return { "busy": BoolType(), @@ -90,11 +90,11 @@ def _get_state_types(gir): "selected": BoolType(), } -def _get_types(gir): +def get_types(gir): return { - **_get_property_types(gir), - **_get_relation_types(gir), - **_get_state_types(gir), + **get_property_types(gir), + **get_relation_types(gir), + **get_state_types(gir), } def _get_docs(gir, name): @@ -123,22 +123,22 @@ class A11yProperty(BaseTypedAttribute): def tag_name(self): name = self.tokens["name"] gir = self.root.gir - if name in _get_property_types(gir): + if name in get_property_types(gir): return "property" - elif name in _get_relation_types(gir): + elif name in get_relation_types(gir): return "relation" - elif name in _get_state_types(gir): + elif name in get_state_types(gir): return "state" else: raise CompilerBugError() @property def value_type(self) -> GirType: - return _get_types(self.root.gir).get(self.tokens["name"]) + return get_types(self.root.gir).get(self.tokens["name"]) @validate("name") def is_valid_property(self): - types = _get_types(self.root.gir) + types = get_types(self.root.gir) if self.tokens["name"] not in types: raise CompileError( f"'{self.tokens['name']}' is not an accessibility property, relation, or state", @@ -147,7 +147,7 @@ class A11yProperty(BaseTypedAttribute): @docs("name") def prop_docs(self): - if self.tokens["name"] in _get_types(self.root.gir): + if self.tokens["name"] in get_types(self.root.gir): return _get_docs(self.root.gir, self.tokens["name"]) @@ -186,5 +186,5 @@ def a11y_completer(ast_node, match_variables): matches=new_statement_patterns, ) def a11y_name_completer(ast_node, match_variables): - for name, type in _get_types(ast_node.root.gir).items(): + for name, type in get_types(ast_node.root.gir).items(): yield Completion(name, CompletionItemKind.Property, docs=_get_docs(ast_node.root.gir, type)) diff --git a/blueprintcompiler/gir.py b/blueprintcompiler/gir.py index 2eb8ff0..7c5a67d 100644 --- a/blueprintcompiler/gir.py +++ b/blueprintcompiler/gir.py @@ -142,6 +142,10 @@ class GirNode: def name(self) -> str: return self.xml["name"] + @lazy_prop + def cname(self) -> str: + return self.xml["c:type"] + @lazy_prop def available_in(self) -> str: return self.xml.get("version") @@ -176,10 +180,6 @@ class Property(GirNode): def __init__(self, klass, xml: xml_reader.Element): super().__init__(klass, xml) - @property - def type_name(self): - return self.xml.get_elements('type')[0]['name'] - @property def signature(self): return f"{self.type_name} {self.container.name}.{self.name}" @@ -295,12 +295,16 @@ class EnumMember(GirNode): def nick(self): return self.xml["glib:nick"] + @property + def c_ident(self): + return self.xml["c:identifier"] + @property def signature(self): return f"enum member {self.full_name} = {self.value}" -class Enumeration(GirNode): +class Enumeration(GirNode, GirType): def __init__(self, ns, xml: xml_reader.Element): super().__init__(ns, xml) self.members = { child["name"]: EnumMember(self, child) for child in xml.get_elements("member") } @@ -309,6 +313,36 @@ class Enumeration(GirNode): def signature(self): return f"enum {self.full_name}" + def assignable_to(self, type): + return type == self + + +class BitfieldMember(GirNode): + def __init__(self, ns, xml: xml_reader.Element): + super().__init__(ns, xml) + self._value = xml["value"] + + @property + def value(self): + return self._value + + @property + def signature(self): + return f"bitfield member {self.full_name} = {bin(self.value)}" + + +class Bitfield(GirNode, GirType): + def __init__(self, ns, xml: xml_reader.Element): + super().__init__(ns, xml) + self.members = { child["name"]: EnumMember(self, child) for child in xml.get_elements("member") } + + @property + def signature(self): + return f"bitfield {self.full_name}" + + def assignable_to(self, type): + return type == self + class Namespace(GirNode): def __init__(self, repo, xml: xml_reader.Element): @@ -316,6 +350,7 @@ class Namespace(GirNode): self.classes = { child["name"]: Class(self, child) for child in xml.get_elements("class") } self.interfaces = { child["name"]: Interface(self, child) for child in xml.get_elements("interface") } self.enumerations = { child["name"]: Enumeration(self, child) for child in xml.get_elements("enumeration") } + self.bitfields = { child["name"]: Bitfield(self, child) for child in xml.get_elements("bitfield") } self.version = xml["version"] @property @@ -325,7 +360,19 @@ class Namespace(GirNode): def get_type(self, name): """ Gets a type (class, interface, enum, etc.) from this namespace. """ - return self.classes.get(name) or self.interfaces.get(name) or self.enumerations.get(name) + return ( + self.classes.get(name) + or self.interfaces.get(name) + or self.enumerations.get(name) + or self.bitfields.get(name) + ) + + + def get_type_by_cname(self, cname: str): + """ Gets a type from this namespace by its C name. """ + for item in [*self.classes.values(), *self.interfaces.values(), *self.enumerations.values()]: + if item.cname == cname: + return item def lookup_type(self, type_name: str): @@ -359,6 +406,13 @@ class Repository(GirNode): return self.lookup_namespace(ns).get_type(name) + def get_type_by_cname(self, name: str) -> T.Optional[GirNode]: + for ns in self.namespaces.values(): + if type := ns.get_type_by_cname(name): + return type + return None + + def lookup_namespace(self, ns: str): """ Finds a namespace among this namespace's dependencies. """ if namespace := self.namespaces.get(ns): @@ -382,6 +436,13 @@ class GirContext: self.namespaces[namespace.name] = namespace + def get_type_by_cname(self, name: str) -> T.Optional[GirNode]: + for ns in self.namespaces.values(): + if type := ns.get_type_by_cname(name): + return type + return None + + def get_type(self, name: str, ns: str) -> T.Optional[GirNode]: ns = ns or "Gtk" diff --git a/blueprintcompiler/interactive_port.py b/blueprintcompiler/interactive_port.py new file mode 100644 index 0000000..37aef93 --- /dev/null +++ b/blueprintcompiler/interactive_port.py @@ -0,0 +1,285 @@ +# interactive_port.py +# +# Copyright 2021 James Westman +# +# This file is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as +# published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# This file is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this program. If not, see . +# +# SPDX-License-Identifier: LGPL-3.0-or-later + + +import typing as T +import difflib +import os + +from . import decompiler, tokenizer, parser +from .errors import MultipleErrors, PrintableError +from .utils import Colors + + +# A tool to interactively port projects to blueprints. + + +class CouldNotPort: + def __init__(self, message): + self.message = message + +def change_suffix(f): + return f.removesuffix(".ui") + ".blp" + +def decompile_file(in_file, out_file) -> T.Union[str, CouldNotPort]: + if os.path.exists(out_file): + return CouldNotPort("already exists") + + try: + decompiled = decompiler.decompile(in_file) + + try: + # make sure the output compiles + tokens = tokenizer.tokenize(decompiled) + ast, errors = parser.parse(tokens) + + if errors: + raise errors + if len(ast.errors): + raise MultipleErrors(ast.errors) + + ast.generate() + except PrintableError as e: + e.pretty_print(out_file, decompiled) + + print(f"{Colors.RED}{Colors.BOLD}error: the generated file does not compile{Colors.CLEAR}") + print(f"in {Colors.UNDERLINE}{out_file}{Colors.NO_UNDERLINE}") + print( +f"""{Colors.FAINT}Either the original XML file had an error, or there is a bug in the +porting tool. If you think it's a bug (which is likely), please file an issue on GitLab: +{Colors.BLUE}{Colors.UNDERLINE}https://gitlab.gnome.org/jwestman/blueprint-compiler/-/issues/new?issue{Colors.CLEAR}\n""") + + return CouldNotPort("does not compile") + + return decompiled + + except decompiler.UnsupportedError as e: + e.print(in_file) + return CouldNotPort("could not convert") + + +def listdir_recursive(subdir): + files = os.listdir(subdir) + for file in files: + full = os.path.join(subdir, file) + if full == "./subprojects": + # skip the subprojects directory + return + if os.path.isfile(full): + yield full + elif os.path.isdir(full): + yield from listdir_recursive(full) + + +def yesno(prompt): + while True: + response = input(f"{Colors.BOLD}{prompt} [y/n] {Colors.CLEAR}") + if response.lower() in ["yes", "y"]: + return True + elif response.lower() in ["no", "n"]: + return False + + +def enter(): + input(f"{Colors.BOLD}Press Enter when you have done that: {Colors.CLEAR}") + + +def step1(): + print(f"{Colors.BOLD}STEP 1: Create subprojects/blueprint-compiler.wrap{Colors.CLEAR}") + + if os.path.exists("subprojects/blueprint-compiler.wrap"): + print("subprojects/blueprint-compiler.wrap already exists, skipping\n") + return + + if yesno("Create subprojects/blueprint-compiler.wrap?"): + try: + os.mkdir("subprojects") + except: + pass + + with open("subprojects/blueprint-compiler.wrap", "w") as wrap: + wrap.write("""[wrap-git] +directory = blueprint-compiler +url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git +revision = main +depth = 1 + +[provide] +program_names = blueprint-compiler""") + + print() + + +def step2(): + print(f"{Colors.BOLD}STEP 2: Set up .gitignore{Colors.CLEAR}") + + if os.path.exists(".gitignore"): + with open(".gitignore", "r+") as gitignore: + ignored = [line.strip() for line in gitignore.readlines()] + if "/subprojects/blueprint-compiler" not in ignored: + if yesno("Add '/subprojects/blueprint-compiler' to .gitignore?"): + gitignore.write("\n/subprojects/blueprint-compiler\n") + else: + print("'/subprojects/blueprint-compiler' already in .gitignore, skipping") + else: + if yesno("Create .gitignore with '/subprojects/blueprint-compiler'?"): + with open(".gitignore", "w") as gitignore: + gitignore.write("/subprojects/blueprint-compiler\n") + + print() + + +def step3(): + print(f"{Colors.BOLD}STEP 3: Convert UI files{Colors.CLEAR}") + + files = [ + (file, change_suffix(file), decompile_file(file, change_suffix(file))) + for file in listdir_recursive(".") + if file.endswith(".ui") + ] + + success = 0 + for in_file, out_file, result in files: + if isinstance(result, CouldNotPort): + if result.message == "already exists": + print(Colors.FAINT, end="") + print(f"{Colors.RED}will not port {Colors.UNDERLINE}{in_file}{Colors.NO_UNDERLINE} -> {Colors.UNDERLINE}{out_file}{Colors.NO_UNDERLINE} [{result.message}]{Colors.CLEAR}") + else: + print(f"will port {Colors.UNDERLINE}{in_file}{Colors.CLEAR} -> {Colors.UNDERLINE}{out_file}{Colors.CLEAR}") + success += 1 + + print() + if len(files) == 0: + print(f"{Colors.RED}No UI files found.{Colors.CLEAR}") + elif success == len(files): + print(f"{Colors.GREEN}All files were converted.{Colors.CLEAR}") + elif success > 0: + print(f"{Colors.RED}{success} file(s) were converted, {len(files) - success} were not.{Colors.CLEAR}") + else: + print(f"{Colors.RED}None of the files could be converted.{Colors.CLEAR}") + + if success > 0 and yesno("Save these changes?"): + for in_file, out_file, result in files: + if not isinstance(result, CouldNotPort): + with open(out_file, "x") as file: + file.write(result) + + print() + results = [ + (in_file, out_file) + for in_file, out_file, result in files + if not isinstance(result, CouldNotPort) or result.message == "already exists" + ] + if len(results): + return zip(*results) + else: + return ([], []) + + +def step4(ported): + print(f"{Colors.BOLD}STEP 4: Set up meson.build{Colors.CLEAR}") + print(f"{Colors.BOLD}{Colors.YELLOW}NOTE: Depending on your build system setup, you may need to make some adjustments to this step.{Colors.CLEAR}") + + meson_files = [file for file in listdir_recursive(".") if os.path.basename(file) == "meson.build"] + for meson_file in meson_files: + with open(meson_file, "r") as f: + if "gnome.compile_resources" in f.read(): + parent = os.path.dirname(meson_file) + file_list = "\n ".join([ + f"'{os.path.relpath(file, parent)}'," + for file in ported + if file.startswith(parent) + ]) + + if len(file_list): + print(f"{Colors.BOLD}Paste the following into {Colors.UNDERLINE}{meson_file}{Colors.NO_UNDERLINE}:{Colors.CLEAR}") + print(f""" +blueprints = custom_target('blueprints', + input: files( + {file_list} + ), + output: '.', + command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], +) +""") + enter() + + print(f"""{Colors.BOLD}Paste the following into the 'gnome.compile_resources()' +arguments in {Colors.UNDERLINE}{meson_file}{Colors.NO_UNDERLINE}:{Colors.CLEAR} + +dependencies: blueprints, + """) + enter() + + print() + + +def step5(in_files): + print(f"{Colors.BOLD}STEP 5: Update POTFILES.in{Colors.CLEAR}") + + if not os.path.exists("po/POTFILES.in"): + print(f"{Colors.UNDERLINE}po/POTFILES.in{Colors.NO_UNDERLINE} does not exist, skipping\n") + return + + with open("po/POTFILES.in", "r") as potfiles: + old_lines = potfiles.readlines() + lines = old_lines.copy() + for in_file in in_files: + for i, line in enumerate(lines): + if line.strip() == in_file.removeprefix("./"): + lines[i] = change_suffix(line.strip()) + "\n" + + new_data = "".join(lines) + + print(f"{Colors.BOLD}Will make the following changes to {Colors.UNDERLINE}po/POTFILES.in{Colors.CLEAR}") + print( + "".join([ + (Colors.GREEN if line.startswith('+') else Colors.RED + Colors.FAINT if line.startswith('-') else '') + line + Colors.CLEAR + for line in difflib.unified_diff(old_lines, lines) + ]) + ) + + if yesno("Is this ok?"): + with open("po/POTFILES.in", "w") as potfiles: + potfiles.writelines(lines) + + print() + + +def step6(in_files): + print(f"{Colors.BOLD}STEP 6: Clean up{Colors.CLEAR}") + + if yesno("Delete old XML files?"): + for file in in_files: + try: + os.remove(file) + except: + pass + + +def run(opts): + step1() + step2() + in_files, out_files = step3() + step4(out_files) + step5(in_files) + step6(in_files) + + print(f"{Colors.BOLD}STEP 6: Done! Make sure your app still builds and runs correctly.{Colors.CLEAR}") + diff --git a/blueprintcompiler/main.py b/blueprintcompiler/main.py index 9e73ecd..91bac7a 100644 --- a/blueprintcompiler/main.py +++ b/blueprintcompiler/main.py @@ -20,9 +20,10 @@ import argparse, json, os, sys -from .errors import PrintableError, report_compile_error, MultipleErrors +from .errors import PrintableError, report_bug, MultipleErrors from .lsp import LanguageServer -from . import parser, tokenizer +from . import parser, tokenizer, decompiler, interactive_port +from .utils import Colors from .xml_emitter import XmlEmitter @@ -44,6 +45,8 @@ class BlueprintApp: batch_compile.add_argument("input_dir", metavar="input-dir") batch_compile.add_argument("inputs", nargs="+", metavar="filenames", default=sys.stdin, type=argparse.FileType('r')) + port = self.add_subcommand("port", "Interactive porting tool", self.cmd_port) + lsp = self.add_subcommand("lsp", "Run the language server (for internal use by IDEs)", self.cmd_lsp) lsp.add_argument("--logfile", dest="logfile", default=None, type=argparse.FileType('a')) @@ -54,8 +57,12 @@ class BlueprintApp: opts.func(opts) except SystemExit as e: raise e + except KeyboardInterrupt: + print(f"\n\n{Colors.RED}{Colors.BOLD}Interrupted.{Colors.CLEAR}") + except EOFError: + print(f"\n\n{Colors.RED}{Colors.BOLD}Interrupted.{Colors.CLEAR}") except: - report_compile_error() + report_bug() def add_subcommand(self, name, help, func): @@ -114,6 +121,10 @@ class BlueprintApp: langserv.run() + def cmd_port(self, opts): + interactive_port.run(opts) + + def _compile(self, data: str) -> str: tokens = tokenizer.tokenize(data) ast, errors = parser.parse(tokens) diff --git a/blueprintcompiler/utils.py b/blueprintcompiler/utils.py index 0954bc4..96a22b0 100644 --- a/blueprintcompiler/utils.py +++ b/blueprintcompiler/utils.py @@ -20,6 +20,18 @@ import typing as T +class Colors: + RED = '\033[91m' + GREEN = '\033[92m' + YELLOW = '\033[33m' + FAINT = '\033[2m' + BOLD = '\033[1m' + BLUE = '\033[34m' + UNDERLINE = '\033[4m' + NO_UNDERLINE = '\033[24m' + CLEAR = '\033[0m' + + def lazy_prop(func): key = "_lazy_prop_" + func.__name__ diff --git a/blueprintcompiler/xml_reader.py b/blueprintcompiler/xml_reader.py index 57b945a..4bc628a 100644 --- a/blueprintcompiler/xml_reader.py +++ b/blueprintcompiler/xml_reader.py @@ -19,6 +19,7 @@ from collections import defaultdict +import typing as T from xml import sax from .utils import lazy_prop @@ -28,22 +29,22 @@ from .utils import lazy_prop PARSE_GIR = set([ "repository", "namespace", "class", "interface", "property", "glib:signal", "include", "implements", "type", "parameter", "parameters", "enumeration", - "member", + "member", "bitfield", ]) class Element: - def __init__(self, tag, attrs): + def __init__(self, tag, attrs: T.Dict[str, str]): self.tag = tag self.attrs = attrs - self.children = defaultdict(list) - self.cdata_chunks = [] + self.children: T.Dict[str, T.List["Element"]] = defaultdict(list) + self.cdata_chunks: T.List[str] = [] @lazy_prop def cdata(self): return ''.join(self.cdata_chunks) - def get_elements(self, name): + def get_elements(self, name) -> T.List["Element"]: return self.children.get(name, []) def __getitem__(self, key): @@ -58,7 +59,7 @@ class Handler(sax.handler.ContentHandler): self._interesting_elements = parse_type def startElement(self, name, attrs): - if name not in self._interesting_elements: + if self._interesting_elements is not None and name not in self._interesting_elements: self.skipping += 1 if self.skipping > 0: return @@ -77,7 +78,7 @@ class Handler(sax.handler.ContentHandler): def endElement(self, name): if self.skipping == 0: self.stack.pop() - if name not in self._interesting_elements: + if self._interesting_elements is not None and name not in self._interesting_elements: self.skipping -= 1 def characters(self, content): @@ -85,7 +86,7 @@ class Handler(sax.handler.ContentHandler): self.stack[-1].cdata_chunks.append(content) -def parse(filename, parse_type): +def parse(filename, parse_type=None): parser = sax.make_parser() handler = Handler(parse_type) parser.setContentHandler(handler) diff --git a/docs/setup.rst b/docs/setup.rst index 9c827d9..477c3dc 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -2,8 +2,24 @@ Setup ===== -Use Blueprint in your project ------------------------------ +Setting up Blueprint on a new or existing project +------------------------------------------------- + +Using the porting tool +~~~~~~~~~~~~~~~~~~~~~~ + +Clone [blueprint-compiler](https://gitlab.gnome.org/jwestman/blueprint-compiler) +from source. You can install it using `meson _build` and `ninja -C _build install`, +or you can leave it uninstalled. + +In your project's directory, run `blueprint-compiler port` (or ` port`) +to start the porting process. It will walk you through the steps outlined below. +It should work for most projects, but if something goes wrong you may need to +follow the manual steps instead. + + +Manually +~~~~~~~~ blueprint-compiler works as a meson subproject. @@ -26,6 +42,8 @@ blueprint-compiler works as a meson subproject. /subprojects/blueprint-compiler +#. Rewrite your .ui XML files in blueprint format. + #. Add this to the ``meson.build`` file where you build your GResources: .. code-block:: meson.build @@ -44,10 +62,3 @@ blueprint-compiler works as a meson subproject. dependencies: blueprints, -#. Convert your .ui XML files to blueprint format. In the future, an automatic - porting tool is planned. - - -.. warning:: - The blueprint compiler flattens the directory structure of the resulting XML - files. You may need to update your ``.gresource.xml`` file to match. diff --git a/tests/samples/accessibility_dec.blp b/tests/samples/accessibility_dec.blp new file mode 100644 index 0000000..9daacb4 --- /dev/null +++ b/tests/samples/accessibility_dec.blp @@ -0,0 +1,12 @@ +using Gtk 4.0; + +Widget { + accessibility { + label: _("Hello, world!"); + labelled_by: my_label; + checked: true; + } +} + +Label my_label { +} diff --git a/tests/samples/id_prop.blp b/tests/samples/id_prop.blp index 8200868..07b19a3 100644 --- a/tests/samples/id_prop.blp +++ b/tests/samples/id_prop.blp @@ -4,4 +4,5 @@ Scale { adjustment: adj; } -Adjustment adj {} +Adjustment adj { +} diff --git a/tests/samples/layout_dec.blp b/tests/samples/layout_dec.blp new file mode 100644 index 0000000..b0e66e0 --- /dev/null +++ b/tests/samples/layout_dec.blp @@ -0,0 +1,10 @@ +using Gtk 4.0; + +Grid { + Label { + layout { + column: "0"; + row: "1"; + } + } +} diff --git a/tests/samples/menu.ui b/tests/samples/menu.ui index f8ce953..a84fa57 100644 --- a/tests/samples/menu.ui +++ b/tests/samples/menu.ui @@ -25,4 +25,4 @@ - + \ No newline at end of file diff --git a/tests/samples/menu_dec.blp b/tests/samples/menu_dec.blp new file mode 100644 index 0000000..bc4ddf1 --- /dev/null +++ b/tests/samples/menu_dec.blp @@ -0,0 +1,31 @@ +using Gtk 4.0; + +menu { + label: _("menu label"); + test-custom-attribute: "3.1415"; + + submenu { + section { + label: "test section"; + } + + item { + label: "test item"; + } + + item { + label: "test item shorthand 1"; + } + + item { + label: "test item shorthand 2"; + action: "app.test-action"; + } + + item { + label: "test item shorthand 3"; + action: "app.test-action"; + icon: "test-symbolic"; + } + } +} diff --git a/tests/samples/style_dec.blp b/tests/samples/style_dec.blp new file mode 100644 index 0000000..63720de --- /dev/null +++ b/tests/samples/style_dec.blp @@ -0,0 +1,8 @@ +using Gtk 4.0; + +Label { + styles [ + "class-1", + "class-2", + ] +} diff --git a/tests/samples/translated.blp b/tests/samples/translated.blp index d926754..c7d4cd7 100644 --- a/tests/samples/translated.blp +++ b/tests/samples/translated.blp @@ -3,6 +3,7 @@ using Gtk 4.0; Label { label: _("Hello, world!"); } + Label { label: C_("translation context", "Hello"); } diff --git a/tests/samples/using.blp b/tests/samples/using.blp index 8b11013..87d62d1 100644 --- a/tests/samples/using.blp +++ b/tests/samples/using.blp @@ -1,4 +1,5 @@ using Gtk 4.0; using GObject 2.0; -GObject.Object {} +GObject.Object { +} diff --git a/tests/test_samples.py b/tests/test_samples.py index 6ceee2b..ce3d700 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -23,7 +23,7 @@ from pathlib import Path import traceback import unittest -from blueprintcompiler import tokenizer, parser +from blueprintcompiler import tokenizer, parser, decompiler from blueprintcompiler.errors import PrintableError, MultipleErrors, CompileError from blueprintcompiler.tokenizer import Token, TokenType, tokenize from blueprintcompiler import utils @@ -91,6 +91,25 @@ class TestSamples(unittest.TestCase): raise AssertionError() + def assert_decompile(self, name): + try: + with open((Path(__file__).parent / f"samples/{name}.blp").resolve()) as f: + expected = f.read() + + name = name.removesuffix("_dec") + ui_path = (Path(__file__).parent / f"samples/{name}.ui").resolve() + + actual = decompiler.decompile(ui_path) + + if actual.strip() != expected.strip(): + diff = difflib.unified_diff(expected.splitlines(), actual.splitlines()) + print("\n".join(diff)) + raise AssertionError() + except PrintableError as e: + e.pretty_print(name + ".blp", blueprint) + raise AssertionError() + + def test_samples(self): self.assert_sample("accessibility") self.assert_sample("binding") @@ -143,3 +162,20 @@ class TestSamples(unittest.TestCase): self.assert_sample_error("uint") self.assert_sample_error("using_invalid_namespace") self.assert_sample_error("widgets_in_non_size_group") + + + def test_decompiler(self): + self.assert_decompile("accessibility_dec") + self.assert_decompile("binding") + self.assert_decompile("child_type") + self.assert_decompile("flags") + self.assert_decompile("id_prop") + self.assert_decompile("layout_dec") + self.assert_decompile("menu_dec") + self.assert_decompile("property") + self.assert_decompile("signal") + self.assert_decompile("strings") + self.assert_decompile("style_dec") + self.assert_decompile("template") + self.assert_decompile("translated") + self.assert_decompile("using")