diff --git a/CHANGELOG.md b/CHANGELOG.md index c9aef32e..e7d80e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ ## Unreleased - - … + - Add BaseNode.equals for deep-equality testing. + + Nodes are deeply compared on a field by field basis. If possible, False is + returned early. When comparing attributes, tags and variants in + SelectExpressions, the order doesn't matter. By default, spans are not + taken into account. ## fluent 0.4.0 (June 13th, 2017) diff --git a/fluent/syntax/ast.py b/fluent/syntax/ast.py index 02cefafc..01bcbde2 100644 --- a/fluent/syntax/ast.py +++ b/fluent/syntax/ast.py @@ -27,6 +27,18 @@ def from_json(value): return value +def scalars_equal(node1, node2, with_spans=False): + """Compare two nodes which are not lists.""" + + if type(node1) != type(node2): + return False + + if isinstance(node1, BaseNode): + return node1.equals(node2, with_spans) + + return node1 == node2 + + class BaseNode(object): """Base class for all Fluent AST nodes. @@ -61,6 +73,58 @@ def visit(value): return fun(node) + def equals(self, other, with_spans=False): + """Compare two nodes. + + Nodes are deeply compared on a field by field basis. If possible, False + is returned early. When comparing attributes, tags and variants in + SelectExpressions, the order doesn't matter. By default, spans are not + taken into account. + """ + + self_keys = set(vars(self).keys()) + other_keys = set(vars(other).keys()) + + if not with_spans: + self_keys.discard('span') + other_keys.discard('span') + + if self_keys != other_keys: + return False + + for key in self_keys: + field1 = getattr(self, key) + field2 = getattr(other, key) + + # List-typed nodes are compared item-by-item. When comparing + # attributes, tags and variants, the order of items doesn't matter. + if isinstance(field1, list) and isinstance(field2, list): + if len(field1) != len(field2): + return False + + # These functions are used to sort lists of items for when + # order doesn't matter. Annotations are also lists but they + # can't be keyed on any of their fields reliably. + field_sorting = { + 'attributes': lambda elem: elem.id.name, + 'tags': lambda elem: elem.name.name, + 'variants': lambda elem: elem.key.name, + } + + if key in field_sorting: + sorting = field_sorting[key] + field1 = sorted(field1, key=sorting) + field2 = sorted(field2, key=sorting) + + for elem1, elem2 in zip(field1, field2): + if not scalars_equal(elem1, elem2, with_spans): + return False + + elif not scalars_equal(field1, field2, with_spans): + return False + + return True + def to_json(self): obj = { name: to_json(value) diff --git a/tests/syntax/test_equals.py b/tests/syntax/test_equals.py new file mode 100644 index 00000000..4c6b9f12 --- /dev/null +++ b/tests/syntax/test_equals.py @@ -0,0 +1,240 @@ +from __future__ import unicode_literals +import unittest +import sys + +sys.path.append('.') + +from tests.syntax import dedent_ftl +from fluent.syntax.parser import FluentParser + + +def identity(node): + return node + + +class TestEntryEqualToSelf(unittest.TestCase): + def setUp(self): + self.parser = FluentParser() + + def parse_ftl_entry(self, string): + return self.parser.parse_entry(dedent_ftl(string)) + + def test_same_simple_message(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_selector_message(self): + message1 = self.parse_ftl_entry("""\ + foo = + { $num -> + [one] One + [two] Two + [few] Few + [many] Many + *[other] Other + } + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_complex_placeable_message(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo { NUMBER($num, style: "decimal") } Bar + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_message_with_attribute(self): + message1 = self.parse_ftl_entry("""\ + foo + .attr = Attr + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_message_with_attributes(self): + message1 = self.parse_ftl_entry("""\ + foo + .attr1 = Attr 1 + .attr2 = Attr 2 + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_message_with_tag(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo + #tag + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_message_with_tags(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo + #tag1 + #tag2 + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + def test_same_junk(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo { + """) + + self.assertTrue(message1.equals(message1)) + self.assertTrue(message1.equals(message1.traverse(identity))) + + +class TestOrderEquals(unittest.TestCase): + def setUp(self): + self.parser = FluentParser() + + def parse_ftl_entry(self, string): + return self.parser.parse_entry(dedent_ftl(string)) + + def test_attributes(self): + message1 = self.parse_ftl_entry("""\ + foo + .attr1 = Attr1 + .attr2 = Attr2 + """) + message2 = self.parse_ftl_entry("""\ + foo + .attr2 = Attr2 + .attr1 = Attr1 + """) + + self.assertTrue(message1.equals(message2)) + self.assertTrue(message2.equals(message1)) + + def test_tags(self): + message1 = self.parse_ftl_entry("""\ + foo = Foo + #tag1 + #tag2 + """) + message2 = self.parse_ftl_entry("""\ + foo = Foo + #tag2 + #tag1 + """) + + self.assertTrue(message1.equals(message2)) + self.assertTrue(message2.equals(message1)) + + def test_variants(self): + message1 = self.parse_ftl_entry("""\ + foo = + { $num -> + [a] A + *[b] B + } + """) + message2 = self.parse_ftl_entry("""\ + foo = + { $num -> + *[b] B + [a] A + } + """) + + self.assertTrue(message1.equals(message2)) + self.assertTrue(message2.equals(message1)) + + +class TestEqualWithSpans(unittest.TestCase): + def test_default_behavior(self): + parser = FluentParser() + + strings = [ + ("foo = Foo", "foo = Foo"), + ("foo = Foo", "foo = Foo"), + ("foo = { $arg }", "foo = { $arg }"), + ] + + messages = [ + (parser.parse_entry(a), parser.parse_entry(b)) + for a, b in strings + ] + + for a, b in messages: + self.assertTrue(a.equals(b)) + + def test_parser_without_spans(self): + parser = FluentParser(with_spans=False) + + strings = [ + ("foo = Foo", "foo = Foo"), + ("foo = Foo", "foo = Foo"), + ("foo = { $arg }", "foo = { $arg }"), + ] + + messages = [ + (parser.parse_entry(a), parser.parse_entry(b)) + for a, b in strings + ] + + for a, b in messages: + self.assertTrue(a.equals(b)) + + def test_equals_with_spans(self): + parser = FluentParser() + + strings = [ + ("foo = Foo", "foo = Foo"), + ("foo = { $arg }", "foo = { $arg }"), + ] + + messages = [ + (parser.parse_entry(a), parser.parse_entry(b)) + for a, b in strings + ] + + for a, b in messages: + self.assertTrue(a.equals(b, with_spans=True)) + + def test_parser_without_spans_equals_with_spans(self): + parser = FluentParser(with_spans=False) + + strings = [ + ("foo = Foo", "foo = Foo"), + ("foo = Foo", "foo = Foo"), + ("foo = { $arg }", "foo = { $arg }"), + ("foo = { $arg }", "foo = { $arg }"), + ] + + messages = [ + (parser.parse_entry(a), parser.parse_entry(b)) + for a, b in strings + ] + + for a, b in messages: + self.assertTrue(a.equals(b, with_spans=True)) + + def test_differ_with_spans(self): + parser = FluentParser() + + strings = [ + ("foo = Foo", "foo = Foo"), + ("foo = { $arg }", "foo = { $arg }"), + ] + + messages = [ + (parser.parse_entry(a), parser.parse_entry(b)) + for a, b in strings + ] + + for a, b in messages: + self.assertFalse(a.equals(b, with_spans=True))