diff --git a/blueprintcompiler/language/gobject_property.py b/blueprintcompiler/language/gobject_property.py index b553909..7137be9 100644 --- a/blueprintcompiler/language/gobject_property.py +++ b/blueprintcompiler/language/gobject_property.py @@ -22,12 +22,14 @@ from .binding import Binding from .common import * from .contexts import ValueTypeCtx from .gtkbuilder_template import Template -from .values import ArrayValue, ObjectValue, Value +from .values import ArrayValue, ObjectValue, Value, VariantValue class Property(AstNode): grammar = Statement( - UseIdent("name"), ":", AnyOf(Binding, ObjectValue, Value, ArrayValue) + UseIdent("name"), + ":", + AnyOf(Binding, VariantValue, ObjectValue, Value, ArrayValue), ) @property @@ -35,7 +37,7 @@ class Property(AstNode): return self.tokens["name"] @property - def value(self) -> T.Union[Binding, ObjectValue, Value, ArrayValue]: + def value(self) -> T.Union[Binding, VariantValue, ObjectValue, Value, ArrayValue]: return self.children[0] @property diff --git a/blueprintcompiler/language/gtk_menu.py b/blueprintcompiler/language/gtk_menu.py index c7ef5f2..5a6627a 100644 --- a/blueprintcompiler/language/gtk_menu.py +++ b/blueprintcompiler/language/gtk_menu.py @@ -19,7 +19,7 @@ import typing as T -from blueprintcompiler.language.values import StringValue +from blueprintcompiler.language.values import StringValue, VariantValue from .common import * from .contexts import ValueTypeCtx @@ -98,8 +98,12 @@ class MenuAttribute(AstNode): return self.tokens["name"] @property - def value(self) -> StringValue: - return self.children[StringValue][0] + def value(self) -> T.Union[StringValue, VariantValue]: + if len(self.children[StringValue]) > 0: + return self.children[StringValue][0] + elif len(self.children[VariantValue]) > 0: + return self.children[VariantValue][0] + raise CompilerBugError() @property def document_symbol(self) -> DocumentSymbol: @@ -133,7 +137,10 @@ menu_attribute = Group( [ UseIdent("name"), ":", - Err(StringValue, "Expected string or translated string"), + Err( + AnyOf(StringValue, VariantValue), + "Expected string, translated string, or variant", + ), Match(";").expected(), ], ) diff --git a/blueprintcompiler/language/values.py b/blueprintcompiler/language/values.py index 63cf4fc..021cf90 100644 --- a/blueprintcompiler/language/values.py +++ b/blueprintcompiler/language/values.py @@ -26,6 +26,12 @@ from .common import * from .contexts import ScopeCtx, ValueTypeCtx from .gobject_object import Object from .types import TypeName +from .variant import VarContent + +import gi + +gi.require_version("GLib", "2.0") +from gi.repository import GLib class Translated(AstNode): @@ -372,6 +378,67 @@ class IdentLiteral(AstNode): return None +class VariantValue(AstNode): + grammar = [ + "variant", + "<", + UseQuoted("type"), + ">", + "(", + Err(VarContent, "Invalid variant content!"), + ")", + ] + + @property + def var_type(self) -> str: + return self.tokens["type"] + + @property + def var_value(self) -> str: + return self.children[0].content + + @validate() + def validate_for_type(self) -> None: + expected_type = self.context[ValueTypeCtx].value_type + if expected_type is None: + pass + elif ( + isinstance(expected_type, gir.IntType) + or isinstance(expected_type, gir.UIntType) + or isinstance(expected_type, gir.FloatType) + or isinstance(expected_type, gir.FloatType) + ): + raise CompileError(f"Cannot convert variant to number") + elif isinstance(expected_type, gir.StringType): + raise CompileError("Cannot convert variant to string") + elif ( + isinstance(expected_type, gir.Boxed) + and expected_type.full_name == "GLib.Variant" + ): + pass + else: + raise CompileError(f"Cannot convert variant into {expected_type.full_name}") + pass + + @validate("type") + def validate_type(self): + if not GLib.VariantType.string_is_valid(self.var_type): + raise CompileError(f"`{self.var_type}` is not a valid variant type") + + @validate() + def validate_content(self): + if not GLib.VariantType.string_is_valid(self.var_type): + return + + try: + var_ty = GLib.VariantType.new(self.var_type) + var_val = GLib.Variant.parse(var_ty, self.var_value) + except GLib.GError as error: + raise CompileError(f"Variant did not match specified type: {error}") + + pass + + class Literal(AstNode): grammar = AnyOf( TypeLiteral, diff --git a/blueprintcompiler/language/variant.py b/blueprintcompiler/language/variant.py new file mode 100644 index 0000000..450a0c8 --- /dev/null +++ b/blueprintcompiler/language/variant.py @@ -0,0 +1,122 @@ +import typing as T + +from blueprintcompiler.gir import ArrayType +from blueprintcompiler.lsp_utils import SemanticToken + +from .common import * +from .contexts import ScopeCtx, ValueTypeCtx +from .gobject_object import Object +from .types import TypeName + +VAR_CONTENT_HOOKS: list[T.Any] = [] + + +class VarContent(AstNode): + grammar = AnyOf(*VAR_CONTENT_HOOKS) + + @property + def content(self) -> str: + return self.children[0].content + + +class VarContentBool(AstNode): + grammar = AnyOf( + [Keyword("true"), UseLiteral("value", True)], + [Keyword("false"), UseLiteral("value", False)], + ) + + @property + def content(self) -> str: + if self.tokens["value"]: + return "true" + else: + return "false" + + +class VarContentString(AstNode): + grammar = UseQuoted("value") + + @property + def content(self) -> str: + return utils.escape_quote(self.tokens["value"]) + + +class VarContentNumber(AstNode): + grammar = UseNumberText("value") + + @property + def content(self) -> str: + return self.tokens["value"] + + +class VarContentTuple(AstNode): + grammar = ["(", Delimited(VarContent, ","), ")"] + + @property + def content(self) -> str: + inner = ", ".join(child.content for child in self.children) + return f"({inner})" + + +class VarContentArray(AstNode): + grammar = ["[", Delimited(VarContent, ","), "]"] + + @property + def content(self) -> str: + inner = ", ".join(child.content for child in self.children) + return f"[{inner}]" + + +class VarContentDictEntry(AstNode): + grammar = ["{", VarContent, ",", VarContent, "}"] + + @property + def content(self): + return f"{{{self.children[0].content}, {self.children[1].content}}}" + + +class VarContentDict(AstNode): + grammar = ["{", Delimited([VarContent, ":", VarContent], ","), "}"] + + @property + def content(self) -> str: + inner = ", ".join( + f"{key.content}: {value.content}" + for (key, value) in utils.iter_batched(self.children, 2, strict=True) + ) + return f"{{{inner}}}" + + +class VarContentVariant(AstNode): + grammar = ["<", VarContent, ">"] + + @property + def content(self) -> str: + return f"<{self.children[0].content}>" + + +class VarContentMaybe(AstNode): + grammar = AnyOf( + [Keyword("just"), VarContent], + [Keyword("nothing")], + ) + + @property + def content(self) -> str: + if self.children[0] is not None: + return f"just {self.children[0].content}" + else: + return "nothing" + + +VarContent.grammar.children = [ + VarContentString, + VarContentNumber, + VarContentBool, + VarContentMaybe, + VarContentTuple, + VarContentDict, + VarContentDictEntry, + VarContentArray, + VarContentVariant, +] diff --git a/blueprintcompiler/outputs/xml/__init__.py b/blueprintcompiler/outputs/xml/__init__.py index a21b6fb..c75bb25 100644 --- a/blueprintcompiler/outputs/xml/__init__.py +++ b/blueprintcompiler/outputs/xml/__init__.py @@ -1,5 +1,7 @@ import typing as T +from blueprintcompiler.language.values import VariantValue + from ...language import * from .. import OutputFormat from .xml_emitter import XmlEmitter @@ -83,13 +85,23 @@ class XmlOutput(OutputFormat): if isinstance(child, Menu): self._emit_menu(child, xml) elif isinstance(child, MenuAttribute): - xml.start_tag( - "attribute", - name=child.name, - **self._translated_string_attrs(child.value.child), - ) - xml.put_text(child.value.string) - xml.end_tag() + if isinstance(child.value, StringValue): + xml.start_tag( + "attribute", + name=child.name, + **self._translated_string_attrs(child.value.child), + ) + xml.put_text(child.value.string) + xml.end_tag() + elif isinstance(child.value, VariantValue): + xml.start_tag( + "attribute", + name=child.name, + type=child.value.var_type, + ) + xml.put_text(child.value.var_value) + xml.end_tag() + else: raise CompilerBugError() xml.end_tag() @@ -148,6 +160,11 @@ class XmlOutput(OutputFormat): self._emit_value(values[-1], xml) xml.end_tag() + elif isinstance(value, VariantValue): + xml.start_tag("property", **props, type=value.var_type) + xml.put_text(value.var_value) + xml.end_tag() + else: raise CompilerBugError() @@ -205,6 +222,8 @@ class XmlOutput(OutputFormat): xml.put_text(self._object_id(value, value.ident)) elif isinstance(value, TypeLiteral): xml.put_text(value.type_name.glib_type_name) + elif isinstance(value, VariantValue): + xml.put_text(value.value) else: if isinstance(value.value, float) and value.value == int(value.value): xml.put_text(int(value.value)) @@ -284,6 +303,10 @@ class XmlOutput(OutputFormat): xml.start_tag(tag, **attrs) xml.put_text(value.child.value) xml.end_tag() + elif isinstance(value.child, VariantValue): + xml.start_tag(tag, **attrs, type=value.child.var_type) + xml.put_text(value.child.var_value) + xml.end_tag() else: xml.start_tag(tag, **attrs) self._emit_value(value, xml) diff --git a/blueprintcompiler/utils.py b/blueprintcompiler/utils.py index ea8102e..b05c9cd 100644 --- a/blueprintcompiler/utils.py +++ b/blueprintcompiler/utils.py @@ -19,6 +19,7 @@ import typing as T from dataclasses import dataclass +import itertools class Colors: @@ -154,3 +155,18 @@ def unescape_quote(string: str) -> str: i += 1 return result + + +def iter_batched(iterable, n, *, strict=False): + """ + Replacement for `itertools.batched()` since the testing infrastructure + uses Python 3.9 at the moment. Copied directly off of the Python docs. + """ + # batched('ABCDEFG', 3) → ABC DEF G + if n < 1: + raise ValueError("n must be at least one") + iterator = iter(iterable) + while batch := tuple(itertools.islice(iterator, n)): + if strict and len(batch) != n: + raise ValueError("batched(): incomplete batch") + yield batch diff --git a/tests/samples/variants.blp b/tests/samples/variants.blp new file mode 100644 index 0000000..a45069a --- /dev/null +++ b/tests/samples/variants.blp @@ -0,0 +1,42 @@ +using Gtk 4.0; + +$BlueprintTestObject { + // test-one: variant<"s">("one"); + test-zero: variant<"b">(true); + test-one: variant<"s">("one"); + test-two: variant<"i">(2); + test-three: variant<"(ii)">((3, 4)); + test-four: variant<"ai">([5, 6]); + test-five: variant<"{sv}">({"key", <"value">}); + test-six: variant<"a{ss}">({ + "GLib": "2.24", + "Gtk": "4.16" + }); + test-seven: variant<"ams">([just "2", nothing]); +} + +menu test_menu { + submenu { + label: "Test menu"; + item { + label: "Option 1"; + action: "app.test_menu.set_action"; + target: variant<"y">(1); + } + item { + label: "Option 2"; + action: "app.test_menu.set_action"; + target: variant<"y">(2); + } + item { + label: "Option 3"; + action: "app.test_menu.set_action"; + target: variant<"y">(3); + } + item { + label: "Option 4"; + action: "app.test_menu.set_action"; + target: variant<"y">(4); + } + } +} \ No newline at end of file diff --git a/tests/samples/variants.ui b/tests/samples/variants.ui new file mode 100644 index 0000000..4fa0a26 --- /dev/null +++ b/tests/samples/variants.ui @@ -0,0 +1,44 @@ + + + + + + true + "one" + 2 + (3, 4) + [5, 6] + {"key", <"value">} + {"GLib": "2.24", "Gtk": "4.16"} + [just "2", nothing] + + + + Test menu + + Option 1 + app.test_menu.set_action + 1 + + + Option 2 + app.test_menu.set_action + 2 + + + Option 3 + app.test_menu.set_action + 3 + + + Option 4 + app.test_menu.set_action + 4 + + + + \ No newline at end of file