From 6fdb12fd5d800f0a0a4ec3f5a70e295e22dcf829 Mon Sep 17 00:00:00 2001 From: James Westman Date: Tue, 19 Jul 2022 20:25:46 -0500 Subject: [PATCH] WIP: Add gir.PartialClass --- blueprintcompiler/ast_utils.py | 78 ++++++---- blueprintcompiler/completions.py | 4 +- blueprintcompiler/gir.py | 141 ++++++++++++++---- blueprintcompiler/language/expression.py | 19 +-- blueprintcompiler/language/gobject_object.py | 11 +- .../language/gobject_property.py | 25 +--- blueprintcompiler/language/gtk_file_filter.py | 19 +-- .../language/gtkbuilder_child.py | 4 + .../language/gtkbuilder_template.py | 8 +- blueprintcompiler/language/imports.py | 23 ++- blueprintcompiler/language/types.py | 17 +-- blueprintcompiler/language/ui.py | 5 +- tests/sample_errors/expr_lookup_prop.err | 2 +- tests/sample_errors/property_dne.err | 2 +- tests/samples/template_binding.blp | 5 + tests/samples/template_binding.ui | 11 ++ tests/test_samples.py | 4 + 17 files changed, 231 insertions(+), 147 deletions(-) create mode 100644 tests/samples/template_binding.blp create mode 100644 tests/samples/template_binding.ui diff --git a/blueprintcompiler/ast_utils.py b/blueprintcompiler/ast_utils.py index 1f3eb5b..12d181d 100644 --- a/blueprintcompiler/ast_utils.py +++ b/blueprintcompiler/ast_utils.py @@ -81,7 +81,7 @@ class AstNode: def _get_errors(self): for validator in self.validators: try: - validator(self) + validator.validate(self) except CompileError as e: yield e if e.fatal: @@ -149,43 +149,61 @@ class AstNode: def validate(token_name=None, end_token_name=None, skip_incomplete=False): """ Decorator for functions that validate an AST node. Exceptions raised - during validation are marked with range information from the tokens. """ + during validation are marked with range information from the tokens. + The return value of the function can also be used as a property, and if + the function raises a CompileError, the property will be None. """ def decorator(func): - def inner(self): - if skip_incomplete and self.incomplete: - return + class Validator: + _validator = True - try: - func(self) - except CompileError as e: - # If the node is only partially complete, then an error must - # have already been reported at the parsing stage - if self.incomplete: + def __get__(self, instance, owner): + if instance is None: + return self + + try: + return self.validate(instance) + except Exception as e: + # Ignore errors and return None. This way, a diagnostic + # is only emitted once. Don't ignore CompilerBugErrors + # though, they should never happen. + if isinstance(e, CompilerBugError): + raise e + return None + + def validate(self, node): + if skip_incomplete and node.incomplete: return - # This mess of code sets the error's start and end positions - # from the tokens passed to the decorator, if they have not - # already been set - if e.start is None: - if token := self.group.tokens.get(token_name): - e.start = token.start - else: - e.start = self.group.start + try: + return func(node) + except CompileError as e: + # If the node is only partially complete, then an error must + # have already been reported at the parsing stage + if node.incomplete: + return - if e.end is None: - if token := self.group.tokens.get(end_token_name): - e.end = token.end - elif token := self.group.tokens.get(token_name): - e.end = token.end - else: - e.end = self.group.end + # This mess of code sets the error's start and end positions + # from the tokens passed to the decorator, if they have not + # already been set + if e.start is None: + if token := node.group.tokens.get(token_name): + e.start = token.start + else: + e.start = node.group.start - # Re-raise the exception - raise e + if e.end is None: + if token := node.group.tokens.get(end_token_name): + e.end = token.end + elif token := node.group.tokens.get(token_name): + e.end = token.end + else: + e.end = node.group.end - inner._validator = True - return inner + # Re-raise the exception + raise e + + return Validator() return decorator diff --git a/blueprintcompiler/completions.py b/blueprintcompiler/completions.py index 7566f18..b2c33cc 100644 --- a/blueprintcompiler/completions.py +++ b/blueprintcompiler/completions.py @@ -110,7 +110,7 @@ def gtk_object_completer(ast_node, match_variables): matches=new_statement_patterns, ) def property_completer(ast_node, match_variables): - if ast_node.gir_class: + if ast_node.gir_class and hasattr(ast_node.gir_class, "properties"): for prop in ast_node.gir_class.properties: yield Completion(prop, CompletionItemKind.Property, snippet=f"{prop}: $0;") @@ -136,7 +136,7 @@ def prop_value_completer(ast_node, match_variables): matches=new_statement_patterns, ) def signal_completer(ast_node, match_variables): - if ast_node.gir_class: + if ast_node.gir_class and hasattr(ast_node.gir_class, "signals"): for signal in ast_node.gir_class.signals: if not isinstance(ast_node.parent, language.Object): name = "on" diff --git a/blueprintcompiler/gir.py b/blueprintcompiler/gir.py index 4e3279e..59d0236 100644 --- a/blueprintcompiler/gir.py +++ b/blueprintcompiler/gir.py @@ -96,6 +96,9 @@ class GirType: def full_name(self) -> str: raise NotImplementedError() + def lookup_property(self, property: str): + raise CompileError(f"Type {self.full_name} does not have properties") + class BasicType(GirType): name: str = "unknown type" @@ -314,6 +317,17 @@ class Interface(GirNode, GirType): result.append(self.get_containing(Repository)._resolve_dir_entry(entry)) return result + def lookup_property(self, property: str): + if prop := self.properties.get(property): + return prop + elif self.is_partial: + return None + else: + raise CompileError( + f"Interface {self.full_name} does not have a property called {property}", + did_you_mean=(property, self.properties.keys()) + ) + def assignable_to(self, other) -> bool: if self == other: return True @@ -323,7 +337,82 @@ class Interface(GirNode, GirType): return False -class Class(GirNode, GirType): +class GirClass(GirType): + @property + def abstract(self): + raise NotImplementedError() + + @property + def parent(self): + raise NotImplementedError() + + @property + def is_partial(self): + return False + + @property + def implements(self): + return [] + + @property + def own_properties(self): + return {} + + @property + def own_signals(self): + return {} + + @cached_property + def properties(self): + return { p.name: p for p in self._enum_properties() } + + @cached_property + def signals(self): + return { s.name: s for s in self._enum_signals() } + + def lookup_property(self, property: str): + if prop := self.properties.get(property): + return prop + elif self.is_partial: + return None + else: + raise CompileError( + f"Class {self.full_name} does not have a property called {property}", + did_you_mean=(property, self.properties.keys()) + ) + + def _enum_properties(self): + yield from self.own_properties.values() + + if self.parent is not None: + yield from self.parent.properties.values() + + for impl in self.implements: + yield from impl.properties.values() + + def _enum_signals(self): + yield from self.own_signals.values() + + if self.parent is not None: + yield from self.parent.signals.values() + + for impl in self.implements: + yield from impl.signals.values() + + def assignable_to(self, other) -> bool: + if self == other: + return True + elif self.parent and self.parent.assignable_to(other): + return True + else: + for iface in self.implements: + if iface.assignable_to(other): + return True + + return False + + +class Class(GirNode, GirClass): def __init__(self, ns, tl: typelib.Typelib): super().__init__(ns, tl) @@ -388,43 +477,31 @@ class Class(GirNode, GirType): result += " implements " + ", ".join([impl.full_name for impl in self.implements]) return result - @cached_property - def properties(self): - return { p.name: p for p in self._enum_properties() } - @cached_property - def signals(self): - return { s.name: s for s in self._enum_signals() } +class PartialClass(GirClass): + def __init__(self, name: str, parent: GirType = None): + self._name = name + self._parent = parent - def assignable_to(self, other) -> bool: - if self == other: - return True - elif self.parent and self.parent.assignable_to(other): - return True - else: - for iface in self.implements: - if iface.assignable_to(other): - return True + @property + def full_name(self): + return self._name - return False + @property + def glib_type_name(self): + return self._name - def _enum_properties(self): - yield from self.own_properties.values() + @property + def abstract(self): + return False - if self.parent is not None: - yield from self.parent.properties.values() + @property + def parent(self): + return self._parent - for impl in self.implements: - yield from impl.properties.values() - - def _enum_signals(self): - yield from self.own_signals.values() - - if self.parent is not None: - yield from self.parent.signals.values() - - for impl in self.implements: - yield from impl.signals.values() + @property + def is_partial(self): + return True class EnumMember(GirNode): diff --git a/blueprintcompiler/language/expression.py b/blueprintcompiler/language/expression.py index 5a2c209..8bafc72 100644 --- a/blueprintcompiler/language/expression.py +++ b/blueprintcompiler/language/expression.py @@ -126,9 +126,8 @@ class LookupOp(InfixExpr): @property def gir_type(self): if parent_type := self.lhs.gir_type: - if isinstance(parent_type, gir.Class) or isinstance(parent_type, gir.Interface): - if prop := parent_type.properties.get(self.tokens["property"]): - return prop.type + if prop := parent_type.lookup_property(self.tokens["property"]): + return prop.type @property def glib_type_name(self): @@ -137,15 +136,11 @@ class LookupOp(InfixExpr): @validate("property") def property_exists(self): - if parent_type := self.lhs.gir_type: - if not (isinstance(parent_type, gir.Class) or isinstance(parent_type, gir.Interface)): - raise CompileError(f"Type {parent_type.full_name} does not have properties") - elif self.tokens["property"] not in parent_type.properties: - raise CompileError( - f"{parent_type.full_name} does not have a property called {self.tokens['property']}", - hints=["Do you need to cast the previous expression?"], - did_you_mean=(self.tokens['property'], parent_type.properties.keys()), - ) + try: + self.gir_type + except CompileError as e: + e.hints.append("Do you need to cast the previous expression?") + raise e def emit_xml(self, xml: XmlEmitter): if isinstance(self.lhs, IdentExpr) and self.lhs.is_this: diff --git a/blueprintcompiler/language/gobject_object.py b/blueprintcompiler/language/gobject_object.py index 4a3c2ef..af25904 100644 --- a/blueprintcompiler/language/gobject_object.py +++ b/blueprintcompiler/language/gobject_object.py @@ -48,14 +48,7 @@ class Object(AstNode): @property def gir_class(self): - class_names = self.children[ClassName] - if len(class_names) > 0: - if isinstance(class_names[0].gir_type, Class): - return class_names[0].gir_type - - @property - def glib_type_name(self) -> str: - return self.children[ClassName][0].glib_type_name + return self.children[ClassName][0].gir_type @docs("namespace") def namespace_docs(self): @@ -83,7 +76,7 @@ class Object(AstNode): def emit_start_tag(self, xml: XmlEmitter): xml.start_tag("object", **{ - "class": self.glib_type_name, + "class": self.gir_class, "id": self.tokens["id"], }) diff --git a/blueprintcompiler/language/gobject_property.py b/blueprintcompiler/language/gobject_property.py index 3f9dc6c..7cf9487 100644 --- a/blueprintcompiler/language/gobject_property.py +++ b/blueprintcompiler/language/gobject_property.py @@ -64,37 +64,16 @@ class Property(AstNode): def gir_class(self): return self.parent.parent.gir_class - - @property + @validate("name") def gir_property(self): if self.gir_class is not None: - return self.gir_class.properties.get(self.tokens["name"]) - + return self.gir_class.lookup_property(self.tokens["name"]) @property def value_type(self): if self.gir_property is not None: return self.gir_property.type - - @validate("name") - def property_exists(self): - if self.gir_class is None: - # Objects that we have no gir data on should not be validated - # This happens for classes defined by the app itself - return - - if isinstance(self.parent.parent, Template): - # If the property is part of a template, it might be defined by - # the application and thus not in gir - return - - if self.gir_property is None: - raise CompileError( - f"Class {self.gir_class.full_name} does not contain a property called {self.tokens['name']}", - did_you_mean=(self.tokens["name"], self.gir_class.properties.keys()) - ) - @validate("bind") def property_bindable(self): if self.tokens["bind"] and self.gir_property is not None and self.gir_property.construct_only: diff --git a/blueprintcompiler/language/gtk_file_filter.py b/blueprintcompiler/language/gtk_file_filter.py index 39e563e..cb6476f 100644 --- a/blueprintcompiler/language/gtk_file_filter.py +++ b/blueprintcompiler/language/gtk_file_filter.py @@ -27,17 +27,12 @@ class Filters(AstNode): def container_is_file_filter(self): validate_parent_type(self, "Gtk", "FileFilter", "file filter properties") - @validate() - def unique_in_parent(self): - # The token argument to validate() needs to be calculated based on - # the instance, hence wrapping it like this. - @validate(self.tokens["tag_name"]) - def wrapped_validator(self): - self.validate_unique_in_parent( - f"Duplicate {self.tokens['tag_name']} block", - check=lambda child: child.tokens["tag_name"] == self.tokens["tag_name"], - ) - wrapped_validator(self) + @validate("tag") + def wrapped_validator(self): + self.validate_unique_in_parent( + f"Duplicate {self.tokens['tag_name']} block", + check=lambda child: child.tokens["tag_name"] == self.tokens["tag_name"], + ) def emit_xml(self, xml: XmlEmitter): xml.start_tag(self.tokens["tag_name"]) @@ -57,7 +52,7 @@ def create_node(tag_name: str, singular: str): return Group( Filters, [ - Keyword(tag_name), + Keyword(tag_name, "tag"), UseLiteral("tag_name", tag_name), "[", Delimited( diff --git a/blueprintcompiler/language/gtkbuilder_child.py b/blueprintcompiler/language/gtkbuilder_child.py index 72843a1..9932627 100644 --- a/blueprintcompiler/language/gtkbuilder_child.py +++ b/blueprintcompiler/language/gtkbuilder_child.py @@ -49,6 +49,10 @@ class Child(AstNode): if gir_class.assignable_to(parent_type): break else: + if gir_class.is_partial: + # we don't know if the class implements Gtk.Buildable or not + return + hints=["only Gio.ListStore or Gtk.Buildable implementors can have children"] if "child" in gir_class.properties: hints.append("did you mean to assign this object to the 'child' property?") diff --git a/blueprintcompiler/language/gtkbuilder_template.py b/blueprintcompiler/language/gtkbuilder_template.py index 6ab6c5a..b182b41 100644 --- a/blueprintcompiler/language/gtkbuilder_template.py +++ b/blueprintcompiler/language/gtkbuilder_template.py @@ -17,6 +17,7 @@ # # SPDX-License-Identifier: LGPL-3.0-or-later +from functools import cached_property from .gobject_object import Object, ObjectContent from .common import * @@ -34,11 +35,14 @@ class Template(Object): ObjectContent, ] - @property + @cached_property def gir_class(self): # Templates might not have a parent class defined + parent = None if len(self.children[ClassName]): - return self.children[ClassName][0].gir_type + parent = self.children[ClassName][0].gir_type + + return gir.PartialClass(self.tokens["id"], parent) @validate("id") def unique_in_parent(self): diff --git a/blueprintcompiler/language/imports.py b/blueprintcompiler/language/imports.py index 4fac192..448f4d5 100644 --- a/blueprintcompiler/language/imports.py +++ b/blueprintcompiler/language/imports.py @@ -25,7 +25,7 @@ from .common import * class GtkDirective(AstNode): grammar = Statement( Match("using").err("File must start with a \"using Gtk\" directive (e.g. `using Gtk 4.0;`)"), - Match("Gtk").err("File must start with a \"using Gtk\" directive (e.g. `using Gtk 4.0;`)"), + Keyword("Gtk").err("File must start with a \"using Gtk\" directive (e.g. `using Gtk 4.0;`)"), UseNumberText("version").expected("a version number for GTK"), ) @@ -33,15 +33,23 @@ class GtkDirective(AstNode): def gtk_version(self): version = self.tokens["version"] if version not in ["4.0"]: - err = CompileError("Only GTK 4 is supported") + err = CompileError("Only GTK 4 is supported", fatal=True) if version and version.startswith("4"): err.hint("Expected the GIR version, not an exact version number. Use 'using Gtk 4.0;'.") else: err.hint("Expected 'using Gtk 4.0;'") raise err + return version + + @validate() + def gir_namespace(self): + if self.gtk_version is None: + # use that error message instead + return + try: - gir.get_namespace("Gtk", version) + return gir.get_namespace("Gtk", self.gtk_version) except CompileError as e: raise CompileError( "Could not find GTK 4 introspection files. Is gobject-introspection installed?", @@ -52,18 +60,9 @@ class GtkDirective(AstNode): ) - @property - def gir_namespace(self): - # validate the GTK version first to make sure the more specific error - # message is emitted - self.gtk_version() - return gir.get_namespace("Gtk", self.tokens["version"]) - - def emit_xml(self, xml: XmlEmitter): xml.put_self_closing("requires", lib="gtk", version=self.tokens["version"]) - class Import(AstNode): grammar = Statement( "using", diff --git a/blueprintcompiler/language/types.py b/blueprintcompiler/language/types.py index 0d6b38c..0dadb95 100644 --- a/blueprintcompiler/language/types.py +++ b/blueprintcompiler/language/types.py @@ -19,8 +19,9 @@ import typing as T +from functools import cached_property from .common import * -from ..gir import Class, Interface +from ..gir import GirClass, Class, Interface class TypeName(AstNode): @@ -53,18 +54,16 @@ class TypeName(AstNode): if not self.tokens["ignore_gir"]: return self.root.gir.namespaces.get(self.tokens["namespace"] or "Gtk") - @property + @cached_property def gir_type(self) -> T.Optional[gir.Class]: - if self.tokens["class_name"] and not self.tokens["ignore_gir"]: + if self.tokens["ignore_gir"]: + return gir.PartialClass(self.tokens["class_name"]) + else: return self.root.gir.get_type(self.tokens["class_name"], self.tokens["namespace"]) - return None @property def glib_type_name(self) -> str: - if gir_type := self.gir_type: - return gir_type.glib_type_name - else: - return self.tokens["class_name"] + return self.gir_type.glib_type_name @docs("namespace") def namespace_docs(self): @@ -83,7 +82,7 @@ class TypeName(AstNode): class ClassName(TypeName): @validate("namespace", "class_name") def gir_class_exists(self): - if self.gir_type is not None and not isinstance(self.gir_type, Class): + if self.gir_type is not None and not isinstance(self.gir_type, GirClass): if isinstance(self.gir_type, Interface): raise CompileError(f"{self.gir_type.full_name} is an interface, not a class") else: diff --git a/blueprintcompiler/language/ui.py b/blueprintcompiler/language/ui.py index b20e739..ad907fa 100644 --- a/blueprintcompiler/language/ui.py +++ b/blueprintcompiler/language/ui.py @@ -42,7 +42,8 @@ class UI(AstNode, Scope): self._gir_errors = [] try: - gir_ctx.add_namespace(self.children[GtkDirective][0].gir_namespace) + if ns := self.children[GtkDirective][0].gir_namespace: + gir_ctx.add_namespace(ns) except CompileError as e: self._gir_errors.append(e) @@ -70,7 +71,7 @@ class UI(AstNode, Scope): xml.end_tag() return { - id: ScopeVariable(id, obj.gir_class, lambda xml, id=id: emit_xml(xml, id), obj.glib_type_name) + id: ScopeVariable(id, obj.gir_class, lambda xml, id=id: emit_xml(xml, id)) for id, obj in self.objects_by_id.items() } diff --git a/tests/sample_errors/expr_lookup_prop.err b/tests/sample_errors/expr_lookup_prop.err index 70c6702..b0cac83 100644 --- a/tests/sample_errors/expr_lookup_prop.err +++ b/tests/sample_errors/expr_lookup_prop.err @@ -1,3 +1,3 @@ -5,32,9,Gtk.Widget does not have a property called something +5,32,9,Class Gtk.Widget does not have a property called something 6,43,5,Type int does not have properties 7,17,7,Could not find object with ID 'nothing' \ No newline at end of file diff --git a/tests/sample_errors/property_dne.err b/tests/sample_errors/property_dne.err index 2b6ff40..12df579 100644 --- a/tests/sample_errors/property_dne.err +++ b/tests/sample_errors/property_dne.err @@ -1 +1 @@ -4,3,19,Class Gtk.Label does not contain a property called not-a-real-property +4,3,19,Class Gtk.Label does not have a property called not-a-real-property diff --git a/tests/samples/template_binding.blp b/tests/samples/template_binding.blp new file mode 100644 index 0000000..bc65271 --- /dev/null +++ b/tests/samples/template_binding.blp @@ -0,0 +1,5 @@ +using Gtk 4.0; + +template MyWidget { + label: bind MyWidget.other-label; +} \ No newline at end of file diff --git a/tests/samples/template_binding.ui b/tests/samples/template_binding.ui new file mode 100644 index 0000000..fdba1fd --- /dev/null +++ b/tests/samples/template_binding.ui @@ -0,0 +1,11 @@ + + + + + diff --git a/tests/test_samples.py b/tests/test_samples.py index 67b4719..a43a21a 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -90,6 +90,9 @@ class TestSamples(unittest.TestCase): raise MultipleErrors(warnings) except PrintableError as e: def error_str(error): + if error.start is None: + raise Exception("Error start/end range was never set") + line, col = utils.idx_to_pos(error.start + 1, blueprint) len = error.end - error.start return ",".join([str(line + 1), str(col), str(len), error.message]) @@ -157,6 +160,7 @@ class TestSamples(unittest.TestCase): self.assert_sample("strings") self.assert_sample("style") self.assert_sample("template") + self.assert_sample("template_binding") self.assert_sample("template_no_parent") self.assert_sample("translated") self.assert_sample("uint")