Repository: fonttools/fonttools

Issue:
[voltLib] GroupDefinition.glyphSet() is broken
Take the following simple test vtp file:
```
DEF_GROUP "test"
 ENUM
   GLYPH "a"
 END_ENUM
END_GROUP
```

Processing it with this simple program:
```python
import sys
from fontTools.voltLib.ast import GroupDefinition
from fontTools.voltLib.parser import Parser

ast = Parser(sys.argv[1]).parse()
for statement in ast.statements:
    if isinstance(statement, GroupDefinition):
        print(statement.name)
        print(statement.glyphSet())
```

This raises:
```
test
Traceback (most recent call last):
  File "test.py", line 10, in <module>
    print(statement.glyphSet())
  File "/lib/python3.7/site-packages/fontTools/voltLib/ast.py", line 75, in glyphSet
    self.glyphs_ = self.enum.glyphSet(groups)
AttributeError: 'tuple' object has no attribute 'glyphSet'
```

It looks like there is some confusion here; the code seems to expect the enum to be `Lexer.Enum` which is not used anywhere actually as `Parser.parse_coverage_()` returns a tuple. If this is indeed the intention, then `Parser.parse_coverage_()` should be fixed to return `Lexer.Enum` (I tried that quickly, but it seems to a chain reaction of other things that need to be fixed, so I’d like to have some confirmation before attempting that.

@moyogo 


Discussion:
Yes, `Parser.parse_coverage_()` isn’t done. Using tuples was a temporary hack. It should return a `ast.Enum` object, groups names should be `ast.GroupName` objects and glyph ranges should be unpacked.

Resolving patch:
diff --git a/Lib/fontTools/voltLib/ast.py b/Lib/fontTools/voltLib/ast.py
--- a/Lib/fontTools/voltLib/ast.py
+++ b/Lib/fontTools/voltLib/ast.py
@@ -83,7 +83,7 @@ def __init__(self, glyph, location=None):
         self.glyph = glyph
 
     def glyphSet(self):
-        return frozenset((self.glyph,))
+        return (self.glyph,)
 
 
 class Enum(Expression):
@@ -96,14 +96,23 @@ def __iter__(self):
         for e in self.glyphSet():
             yield e
 
+    def __len__(self):
+        return len(self.enum)
+
+    def __eq__(self, other):
+        return self.glyphSet() == other.glyphSet()
+
+    def __hash__(self):
+        return hash(self.glyphSet())
+
     def glyphSet(self, groups=None):
-        glyphs = set()
+        glyphs = []
         for element in self.enum:
             if isinstance(element, (GroupName, Enum)):
-                glyphs = glyphs.union(element.glyphSet(groups))
+                glyphs.extend(element.glyphSet(groups))
             else:
-                glyphs = glyphs.union(element.glyphSet())
-        return frozenset(glyphs)
+                glyphs.extend(element.glyphSet())
+        return tuple(glyphs)
 
 
 class GroupName(Expression):
@@ -133,8 +142,7 @@ def __init__(self, start, end, parser, location=None):
         self.parser = parser
 
     def glyphSet(self):
-        glyphs = self.parser.glyph_range(self.start, self.end)
-        return frozenset(glyphs)
+        return tuple(self.parser.glyph_range(self.start, self.end))
 
 
 class ScriptDefinition(Statement):
diff --git a/Lib/fontTools/voltLib/parser.py b/Lib/fontTools/voltLib/parser.py
--- a/Lib/fontTools/voltLib/parser.py
+++ b/Lib/fontTools/voltLib/parser.py
@@ -514,36 +514,24 @@ def parse_coverage_(self):
             elif self.next_token_ == "GLYPH":
                 self.expect_keyword_("GLYPH")
                 name = self.expect_string_()
-                coverage.append(name)
+                coverage.append(ast.GlyphName(name, location=location))
             elif self.next_token_ == "GROUP":
                 self.expect_keyword_("GROUP")
                 name = self.expect_string_()
-                # resolved_group = self.groups_.resolve(name)
-                group = (name,)
-                coverage.append(group)
-                # if resolved_group is not None:
-                #     coverage.extend(resolved_group.enum)
-                # # TODO: check that group exists after all groups are defined
-                # else:
-                #     group = (name,)
-                #     coverage.append(group)
-                #     # raise VoltLibError(
-                #     #     'Glyph group "%s" is not defined' % name,
-                #     #     location)
+                coverage.append(ast.GroupName(name, self, location=location))
             elif self.next_token_ == "RANGE":
                 self.expect_keyword_("RANGE")
                 start = self.expect_string_()
                 self.expect_keyword_("TO")
                 end = self.expect_string_()
-                coverage.append((start, end))
-        return tuple(coverage)
+                coverage.append(ast.Range(start, end, self, location=location))
+        return ast.Enum(coverage, location=location)
 
     def resolve_group(self, group_name):
         return self.groups_.resolve(group_name)
 
     def glyph_range(self, start, end):
-        rng = self.glyphs_.range(start, end)
-        return frozenset(rng)
+        return self.glyphs_.range(start, end)
 
     def parse_ppem_(self):
         location = self.cur_token_location_


Test patch:
diff --git a/Tests/voltLib/parser_test.py b/Tests/voltLib/parser_test.py
--- a/Tests/voltLib/parser_test.py
+++ b/Tests/voltLib/parser_test.py
@@ -1,5 +1,6 @@
 from __future__ import print_function, division, absolute_import
 from __future__ import unicode_literals
+from fontTools.voltLib import ast
 from fontTools.voltLib.error import VoltLibError
 from fontTools.voltLib.parser import Parser
 from io import open
@@ -106,14 +107,14 @@ def test_def_group_glyphs(self):
             'GLYPH "aogonek" GLYPH "aring" GLYPH "atilde" END_ENUM\n'
             'END_GROUP\n'
         ).statements
-        self.assertEqual((def_group.name, def_group.enum),
+        self.assertEqual((def_group.name, def_group.enum.glyphSet()),
                          ("aaccented",
                           ("aacute", "abreve", "acircumflex", "adieresis",
                            "ae", "agrave", "amacron", "aogonek", "aring",
                            "atilde")))
 
     def test_def_group_groups(self):
-        [group1, group2, test_group] = self.parse(
+        parser = self.parser(
             'DEF_GROUP "Group1"\n'
             'ENUM GLYPH "a" GLYPH "b" GLYPH "c" GLYPH "d" END_ENUM\n'
             'END_GROUP\n'
@@ -123,14 +124,16 @@ def test_def_group_groups(self):
             'DEF_GROUP "TestGroup"\n'
             'ENUM GROUP "Group1" GROUP "Group2" END_ENUM\n'
             'END_GROUP\n'
-        ).statements
+        )
+        [group1, group2, test_group] = parser.parse().statements
         self.assertEqual(
             (test_group.name, test_group.enum),
             ("TestGroup",
-             (("Group1",), ("Group2",))))
+             ast.Enum([ast.GroupName("Group1", parser),
+                       ast.GroupName("Group2", parser)])))
 
     def test_def_group_groups_not_yet_defined(self):
-        [group1, test_group1, test_group2, test_group3, group2] = self.parse(
+        parser = self.parser(
             'DEF_GROUP "Group1"\n'
             'ENUM GLYPH "a" GLYPH "b" GLYPH "c" GLYPH "d" END_ENUM\n'
             'END_GROUP\n'
@@ -146,19 +149,23 @@ def test_def_group_groups_not_yet_defined(self):
             'DEF_GROUP "Group2"\n'
             'ENUM GLYPH "e" GLYPH "f" GLYPH "g" GLYPH "h" END_ENUM\n'
             'END_GROUP\n'
-        ).statements
+        )
+        [group1, test_group1, test_group2, test_group3, group2] = \
+                parser.parse().statements
         self.assertEqual(
             (test_group1.name, test_group1.enum),
             ("TestGroup1",
-             (("Group1", ), ("Group2", ))))
+             ast.Enum([ast.GroupName("Group1", parser),
+                       ast.GroupName("Group2", parser)])))
         self.assertEqual(
             (test_group2.name, test_group2.enum),
             ("TestGroup2",
-             (("Group2", ), )))
+             ast.Enum([ast.GroupName("Group2", parser)])))
         self.assertEqual(
             (test_group3.name, test_group3.enum),
             ("TestGroup3",
-             (("Group2", ), ("Group1", ))))
+             ast.Enum([ast.GroupName("Group2", parser),
+                       ast.GroupName("Group1", parser)])))
 
     # def test_def_group_groups_undefined(self):
     #     with self.assertRaisesRegex(
@@ -184,20 +191,30 @@ def test_def_group_glyphs_and_group(self):
             'ENUM GLYPH "a" GROUP "aaccented" END_ENUM\n'
             'END_GROUP'
         ).statements
-        self.assertEqual((def_group2.name, def_group2.enum),
-                         ("KERN_lc_a_2ND",
-                          ("a", ("aaccented", ))))
+        items = def_group2.enum.enum
+        self.assertEqual((def_group2.name, items[0].glyphSet(), items[1].group),
+                         ("KERN_lc_a_2ND", ("a",), "aaccented"))
 
     def test_def_group_range(self):
-        [def_group] = self.parse(
+        def_group = self.parse(
+            'DEF_GLYPH "a" ID 163 UNICODE 97 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "agrave" ID 194 UNICODE 224 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "aacute" ID 195 UNICODE 225 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "acircumflex" ID 196 UNICODE 226 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "atilde" ID 197 UNICODE 227 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "c" ID 165 UNICODE 99 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "ccaron" ID 209 UNICODE 269 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "ccedilla" ID 210 UNICODE 231 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "cdotaccent" ID 210 UNICODE 267 TYPE BASE END_GLYPH\n'
             'DEF_GROUP "KERN_lc_a_2ND"\n'
             'ENUM RANGE "a" TO "atilde" GLYPH "b" RANGE "c" TO "cdotaccent" '
             'END_ENUM\n'
             'END_GROUP'
-        ).statements
-        self.assertEqual((def_group.name, def_group.enum),
+        ).statements[-1]
+        self.assertEqual((def_group.name, def_group.enum.glyphSet()),
                          ("KERN_lc_a_2ND",
-                          (("a", "atilde"), "b", ("c", "cdotaccent"))))
+                          ("a", "agrave", "aacute", "acircumflex", "atilde",
+                           "b", "c", "ccaron", "ccedilla", "cdotaccent")))
 
     def test_group_duplicate(self):
         self.assertRaisesRegex(
@@ -524,10 +541,11 @@ def test_substitution_single(self):
             'END_SUBSTITUTION'
         ).statements
         self.assertEqual((lookup.name, list(lookup.sub.mapping.items())),
-                         ("smcp", [(("a",), ("a.sc",)), (("b",), ("b.sc",))]))
+                         ("smcp", [(self.enum(["a"]), self.enum(["a.sc"])),
+                                   (self.enum(["b"]), self.enum(["b.sc"]))]))
 
     def test_substitution_single_in_context(self):
-        [group, lookup] = self.parse(
+        parser = self.parser(
             'DEF_GROUP "Denominators" ENUM GLYPH "one.dnom" GLYPH "two.dnom" '
             'END_ENUM END_GROUP\n'
             'DEF_LOOKUP "fracdnom" PROCESS_BASE PROCESS_MARKS ALL '
@@ -543,17 +561,22 @@ def test_substitution_single_in_context(self):
             'WITH GLYPH "two.dnom"\n'
             'END_SUB\n'
             'END_SUBSTITUTION'
-        ).statements
+        )
+        [group, lookup] = parser.parse().statements
         context = lookup.context[0]
         self.assertEqual(
             (lookup.name, list(lookup.sub.mapping.items()),
              context.ex_or_in, context.left, context.right),
-            ("fracdnom", [(("one",), ("one.dnom",)), (("two",), ("two.dnom",))],
-             "IN_CONTEXT", [((("Denominators",), "fraction"),)], [])
+            ("fracdnom",
+            [(self.enum(["one"]), self.enum(["one.dnom"])),
+             (self.enum(["two"]), self.enum(["two.dnom"]))],
+             "IN_CONTEXT", [ast.Enum([
+                            ast.GroupName("Denominators", parser=parser),
+                            ast.GlyphName("fraction")])], [])
         )
 
     def test_substitution_single_in_contexts(self):
-        [group, lookup] = self.parse(
+        parser = self.parser(
             'DEF_GROUP "Hebrew" ENUM GLYPH "uni05D0" GLYPH "uni05D1" '
             'END_ENUM END_GROUP\n'
             'DEF_LOOKUP "HebrewCurrency" PROCESS_BASE PROCESS_MARKS ALL '
@@ -571,7 +594,8 @@ def test_substitution_single_in_contexts(self):
             'WITH GLYPH "dollar.Hebr"\n'
             'END_SUB\n'
             'END_SUBSTITUTION'
-        ).statements
+        )
+        [group, lookup] = parser.parse().statements
         context1 = lookup.context[0]
         context2 = lookup.context[1]
         self.assertEqual(
@@ -579,8 +603,10 @@ def test_substitution_single_in_contexts(self):
              context1.right, context2.ex_or_in,
              context2.left, context2.right),
             ("HebrewCurrency", "IN_CONTEXT", [],
-             [(("Hebrew",),), ("one.Hebr",)], "IN_CONTEXT",
-             [(("Hebrew",),), ("one.Hebr",)], []))
+             [ast.Enum([ast.GroupName("Hebrew", parser)]),
+              self.enum(["one.Hebr"])], "IN_CONTEXT",
+             [ast.Enum([ast.GroupName("Hebrew", parser)]),
+              self.enum(["one.Hebr"])], []))
 
     def test_substitution_skip_base(self):
         [group, lookup] = self.parse(
@@ -695,7 +721,13 @@ def test_substitution_no_reversal(self):
         )
 
     def test_substitution_reversal(self):
-        [lookup] = self.parse(
+        lookup = self.parse(
+            'DEF_GROUP "DFLT_Num_standardFigures"\n'
+            'ENUM GLYPH "zero" GLYPH "one" GLYPH "two" END_ENUM\n'
+            'END_GROUP\n'
+            'DEF_GROUP "DFLT_Num_numerators"\n'
+            'ENUM GLYPH "zero.numr" GLYPH "one.numr" GLYPH "two.numr" END_ENUM\n'
+            'END_GROUP\n'
             'DEF_LOOKUP "RevLookup" PROCESS_BASE PROCESS_MARKS ALL '
             'DIRECTION LTR REVERSAL\n'
             'IN_CONTEXT\n'
@@ -706,7 +738,7 @@ def test_substitution_reversal(self):
             'WITH GROUP "DFLT_Num_numerators"\n'
             'END_SUB\n'
             'END_SUBSTITUTION'
-        ).statements
+        ).statements[-1]
         self.assertEqual(
             (lookup.name, lookup.reversal),
             ("RevLookup", True)
@@ -729,8 +761,8 @@ def test_substitution_single_to_multiple(self):
         ).statements
         self.assertEqual((lookup.name, list(lookup.sub.mapping.items())),
                          ("ccmp",
-                          [(("aacute",), ("a", "acutecomb")),
-                           (("agrave",), ("a", "gravecomb"))]
+                          [(self.enum(["aacute"]), self.enum(["a", "acutecomb"])),
+                           (self.enum(["agrave"]), self.enum(["a", "gravecomb"]))]
                           ))
 
     def test_substitution_multiple_to_single(self):
@@ -750,11 +782,31 @@ def test_substitution_multiple_to_single(self):
         ).statements
         self.assertEqual((lookup.name, list(lookup.sub.mapping.items())),
                          ("liga",
-                          [(("f", "i"), ("f_i",)),
-                           (("f", "t"), ("f_t",))]))
+                          [(self.enum(["f", "i"]), self.enum(["f_i"])),
+                           (self.enum(["f", "t"]), self.enum(["f_t"]))]))
 
     def test_substitution_reverse_chaining_single(self):
-        [lookup] = self.parse(
+        parser = self.parser(
+            'DEF_GLYPH "zero" ID 1 UNICODE 48 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "one" ID 2 UNICODE 49 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "two" ID 3 UNICODE 50 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "three" ID 4 UNICODE 51 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "four" ID 5 UNICODE 52 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "five" ID 6 UNICODE 53 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "six" ID 7 UNICODE 54 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "seven" ID 8 UNICODE 55 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "eight" ID 9 UNICODE 56 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "nine" ID 10 UNICODE 57 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "zero.numr" ID 11 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "one.numr" ID 12 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "two.numr" ID 13 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "three.numr" ID 14 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "four.numr" ID 15 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "five.numr" ID 16 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "six.numr" ID 17 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "seven.numr" ID 18 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "eight.numr" ID 19 TYPE BASE END_GLYPH\n'
+            'DEF_GLYPH "nine.numr" ID 20 TYPE BASE END_GLYPH\n'
             'DEF_LOOKUP "numr" PROCESS_BASE PROCESS_MARKS ALL '
             'DIRECTION LTR REVERSAL\n'
             'IN_CONTEXT\n'
@@ -768,12 +820,16 @@ def test_substitution_reverse_chaining_single(self):
             'WITH RANGE "zero.numr" TO "nine.numr"\n'
             'END_SUB\n'
             'END_SUBSTITUTION'
-        ).statements
+        )
+        lookup = parser.parse().statements[-1]
         self.assertEqual(
             (lookup.name, lookup.context[0].right,
              list(lookup.sub.mapping.items())),
-            ("numr", [(("fraction", ("zero.numr", "nine.numr")),)],
-             [((("zero", "nine"),), (("zero.numr", "nine.numr"),))]))
+            ("numr",
+             [(ast.Enum([ast.GlyphName("fraction"),
+                         ast.Range("zero.numr", "nine.numr", parser)]))],
+             [(ast.Enum([ast.Range("zero", "nine", parser)]),
+               ast.Enum([ast.Range("zero.numr", "nine.numr", parser)]))]))
 
     # GPOS
     #  ATTACH_CURSIVE
@@ -817,8 +873,9 @@ def test_position_attach(self):
         ).statements
         self.assertEqual(
             (lookup.name, lookup.pos.coverage, lookup.pos.coverage_to),
-            ("anchor_top", ("a", "e"), [(("acutecomb",), "top"),
-                                        (("gravecomb",), "top")])
+            ("anchor_top", self.enum(["a", "e"]),
+             [(self.enum(["acutecomb"]), "top"),
+              (self.enum(["gravecomb"]), "top")])
         )
         self.assertEqual(
             (anchor1.name, anchor1.gid, anchor1.glyph_name, anchor1.component,
@@ -858,7 +915,7 @@ def test_position_attach_cursive(self):
             (lookup.name,
              lookup.pos.coverages_exit, lookup.pos.coverages_enter),
             ("SomeLookup",
-             [("a", "b")], [("c",)])
+             [self.enum(["a", "b"])], [self.enum(["c"])])
         )
 
     def test_position_adjust_pair(self):
@@ -879,7 +936,7 @@ def test_position_adjust_pair(self):
         self.assertEqual(
             (lookup.name, lookup.pos.coverages_1, lookup.pos.coverages_2,
              lookup.pos.adjust_pair),
-            ("kern1", [("A",)], [("V",)],
+            ("kern1", [self.enum(["A"])], [self.enum(["V"])],
              {(1, 2): ((-30, None, None, {}, {}, {}),
                        (None, None, None, {}, {}, {})),
               (2, 1): ((-30, None, None, {}, {}, {}),
@@ -904,8 +961,8 @@ def test_position_adjust_single(self):
         self.assertEqual(
             (lookup.name, lookup.pos.adjust_single),
             ("TestLookup",
-             [(("glyph1",), (0, 123, None, {}, {}, {})),
-              (("glyph2",), (0, 456, None, {}, {}, {}))])
+             [(self.enum(["glyph1"]), (0, 123, None, {}, {}, {})),
+              (self.enum(["glyph2"]), (0, 456, None, {}, {}, {}))])
         )
 
     def test_def_anchor(self):
@@ -1044,14 +1101,20 @@ def tearDown(self):
         if self.tempdir:
             shutil.rmtree(self.tempdir)
 
-    def parse(self, text):
+    def parser(self, text):
         if not self.tempdir:
             self.tempdir = tempfile.mkdtemp()
         self.num_tempfiles += 1
         path = os.path.join(self.tempdir, "tmp%d.vtp" % self.num_tempfiles)
         with open(path, "w") as outfile:
             outfile.write(text)
-        return Parser(path).parse()
+        return Parser(path)
+
+    def parse(self, text):
+        return self.parser(text).parse()
+
+    def enum(self, glyphs):
+        return ast.Enum([ast.GlyphName(g) for g in glyphs])
 
 if __name__ == "__main__":
     import sys