diff --git a/gtkblueprinttool/ast.py b/gtkblueprinttool/ast.py index 7c1f51f..4976eee 100644 --- a/gtkblueprinttool/ast.py +++ b/gtkblueprinttool/ast.py @@ -51,6 +51,11 @@ class UI(AstNode): return gir_ctx + @lazy_prop + def objects_by_id(self): + return { obj.id: obj for obj in self.iterate_children_recursive() if hasattr(obj, "id") } + + @validate() def gir_errors(self): # make sure gir is loaded @@ -66,6 +71,19 @@ class UI(AstNode): self.children[Template][1].group.start) + @validate() + def unique_ids(self): + passed = {} + for obj in self.iterate_children_recursive(): + if obj.tokens["id"] is None: + continue + + if obj.tokens["id"] in passed: + token = obj.group.tokens["id"] + raise CompileError(f"Duplicate object ID '{obj.tokens['id']}'", token.start, token.end) + passed[obj.tokens["id"]] = obj + + def emit_xml(self, xml: XmlEmitter): xml.start_tag("interface") for x in self.children: diff --git a/gtkblueprinttool/ast_utils.py b/gtkblueprinttool/ast_utils.py index aa4803f..fc0ac72 100644 --- a/gtkblueprinttool/ast_utils.py +++ b/gtkblueprinttool/ast_utils.py @@ -118,6 +118,12 @@ class AstNode: yield from child.get_semantic_tokens() + def iterate_children_recursive(self) -> T.Iterator["AstNode"]: + yield self + for child in self.children: + yield from child.iterate_children_recursive() + + 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. Also diff --git a/tests/sample_errors/duplicate_obj_id.blp b/tests/sample_errors/duplicate_obj_id.blp new file mode 100644 index 0000000..6c0b660 --- /dev/null +++ b/tests/sample_errors/duplicate_obj_id.blp @@ -0,0 +1,4 @@ +using Gtk 4.0; + +Gtk.Label label {} +Gtk.Label label {} diff --git a/tests/sample_errors/duplicate_obj_id.err b/tests/sample_errors/duplicate_obj_id.err new file mode 100644 index 0000000..e9c753c --- /dev/null +++ b/tests/sample_errors/duplicate_obj_id.err @@ -0,0 +1 @@ +4,11,5,Duplicate object ID 'label' diff --git a/tests/test_samples.py b/tests/test_samples.py index 08d376e..414a88a 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -24,8 +24,9 @@ import traceback import unittest from gtkblueprinttool import tokenizer, parser -from gtkblueprinttool.errors import PrintableError, MultipleErrors +from gtkblueprinttool.errors import PrintableError, MultipleErrors, CompileError from gtkblueprinttool.tokenizer import Token, TokenType, tokenize +from gtkblueprinttool import utils class TestSamples(unittest.TestCase): @@ -54,6 +55,39 @@ class TestSamples(unittest.TestCase): raise AssertionError() + def assert_sample_error(self, name): + try: + with open((Path(__file__).parent / f"sample_errors/{name}.blp").resolve()) as f: + blueprint = f.read() + with open((Path(__file__).parent / f"sample_errors/{name}.err").resolve()) as f: + expected = f.read() + + tokens = tokenizer.tokenize(blueprint) + ast, errors = parser.parse(tokens) + + if errors: + raise errors + if len(ast.errors): + raise MultipleErrors(ast.errors) + except PrintableError as e: + def error_str(error): + line, col = utils.idx_to_pos(error.start, blueprint) + len = error.end - error.start + return ",".join([str(line + 1), str(col), str(len), error.message]) + + if isinstance(e, CompileError): + actual = error_str(e) + elif isinstance(e, MultipleErrors): + actual = "\n".join([error_str(error) for error in e.errors]) + else: + raise AssertionError() + + if actual.strip() != expected.strip(): + diff = difflib.unified_diff(expected.splitlines(), actual.splitlines()) + print("\n".join(diff)) + raise AssertionError() + + def test_samples(self): self.assert_sample("binding") self.assert_sample("child_type") @@ -66,3 +100,7 @@ class TestSamples(unittest.TestCase): self.assert_sample("style") self.assert_sample("template") self.assert_sample("using") + + + def test_sample_errors(self): + self.assert_sample_error("duplicate_obj_id")