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")