xref: /aosp_15_r20/external/fonttools/Tests/feaLib/parser_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1# -*- coding: utf-8 -*-
2from fontTools.misc.loggingTools import CapturingLogHandler
3from fontTools.feaLib.error import FeatureLibError
4from fontTools.feaLib.parser import Parser, SymbolTable
5from io import StringIO
6import warnings
7import fontTools.feaLib.ast as ast
8import os
9import unittest
10
11
12def glyphstr(glyphs):
13    def f(x):
14        if len(x) == 1:
15            return list(x)[0]
16        else:
17            return "[%s]" % " ".join(sorted(list(x)))
18
19    return " ".join(f(g.glyphSet()) for g in glyphs)
20
21
22def mapping(s):
23    b = []
24    for a in s.glyphs:
25        b.extend(a.glyphSet())
26    c = []
27    for a in s.replacements:
28        c.extend(a.glyphSet())
29    if len(c) == 1:
30        c = c * len(b)
31    return dict(zip(b, c))
32
33
34GLYPHNAMES = (
35    (
36        """
37    .notdef space A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
38    A.sc B.sc C.sc D.sc E.sc F.sc G.sc H.sc I.sc J.sc K.sc L.sc M.sc
39    N.sc O.sc P.sc Q.sc R.sc S.sc T.sc U.sc V.sc W.sc X.sc Y.sc Z.sc
40    A.swash B.swash X.swash Y.swash Z.swash
41    a b c d e f g h i j k l m n o p q r s t u v w x y z
42    a.sc b.sc c.sc d.sc e.sc f.sc g.sc h.sc i.sc j.sc k.sc l.sc m.sc
43    n.sc o.sc p.sc q.sc r.sc s.sc t.sc u.sc v.sc w.sc x.sc y.sc z.sc
44    a.swash b.swash x.swash y.swash z.swash
45    foobar foo.09 foo.1234 foo.9876
46    one two five six acute grave dieresis umlaut cedilla ogonek macron
47    a_f_f_i o_f_f_i f_i f_l f_f_i one.fitted one.oldstyle a.1 a.2 a.3 c_t
48    PRE SUF FIX BACK TRACK LOOK AHEAD ampersand ampersand.1 ampersand.2
49    cid00001 cid00002 cid00003 cid00004 cid00005 cid00006 cid00007
50    cid12345 cid78987 cid00999 cid01000 cid01001 cid00998 cid00995
51    cid00111 cid00222
52    comma endash emdash figuredash damma hamza
53    c_d d.alt n.end s.end f_f
54"""
55    ).split()
56    + ["foo.%d" % i for i in range(1, 200)]
57    + ["G" * 600]
58)
59
60
61class ParserTest(unittest.TestCase):
62    def __init__(self, methodName):
63        unittest.TestCase.__init__(self, methodName)
64        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
65        # and fires deprecation warnings if a program uses the old name.
66        if not hasattr(self, "assertRaisesRegex"):
67            self.assertRaisesRegex = self.assertRaisesRegexp
68
69    def test_glyphMap_deprecated(self):
70        glyphMap = {"a": 0, "b": 1, "c": 2}
71        with warnings.catch_warnings(record=True) as w:
72            warnings.simplefilter("always")
73            parser = Parser(StringIO(), glyphMap=glyphMap)
74
75            self.assertEqual(len(w), 1)
76            self.assertEqual(w[-1].category, UserWarning)
77            self.assertIn("deprecated", str(w[-1].message))
78            self.assertEqual(parser.glyphNames_, {"a", "b", "c"})
79
80            self.assertRaisesRegex(
81                TypeError,
82                "mutually exclusive",
83                Parser,
84                StringIO(),
85                ("a",),
86                glyphMap={"a": 0},
87            )
88
89            self.assertRaisesRegex(
90                TypeError, "unsupported keyword argument", Parser, StringIO(), foo="bar"
91            )
92
93    def test_comments(self):
94        doc = self.parse(
95            """ # Initial
96                feature test {
97                    sub A by B; # simple
98                } test;"""
99        )
100        c1 = doc.statements[0]
101        c2 = doc.statements[1].statements[1]
102        self.assertEqual(type(c1), ast.Comment)
103        self.assertEqual(c1.text, "# Initial")
104        self.assertEqual(str(c1), "# Initial")
105        self.assertEqual(type(c2), ast.Comment)
106        self.assertEqual(c2.text, "# simple")
107        self.assertEqual(doc.statements[1].name, "test")
108
109    def test_only_comments(self):
110        doc = self.parse(
111            """\
112            # Initial
113        """
114        )
115        c1 = doc.statements[0]
116        self.assertEqual(type(c1), ast.Comment)
117        self.assertEqual(c1.text, "# Initial")
118        self.assertEqual(str(c1), "# Initial")
119
120    def test_anchor_format_a(self):
121        doc = self.parse(
122            "feature test {"
123            "    pos cursive A <anchor 120 -20> <anchor NULL>;"
124            "} test;"
125        )
126        anchor = doc.statements[0].statements[0].entryAnchor
127        self.assertEqual(type(anchor), ast.Anchor)
128        self.assertEqual(anchor.x, 120)
129        self.assertEqual(anchor.y, -20)
130        self.assertIsNone(anchor.contourpoint)
131        self.assertIsNone(anchor.xDeviceTable)
132        self.assertIsNone(anchor.yDeviceTable)
133
134    def test_anchor_format_b(self):
135        doc = self.parse(
136            "feature test {"
137            "    pos cursive A <anchor 120 -20 contourpoint 5> <anchor NULL>;"
138            "} test;"
139        )
140        anchor = doc.statements[0].statements[0].entryAnchor
141        self.assertEqual(type(anchor), ast.Anchor)
142        self.assertEqual(anchor.x, 120)
143        self.assertEqual(anchor.y, -20)
144        self.assertEqual(anchor.contourpoint, 5)
145        self.assertIsNone(anchor.xDeviceTable)
146        self.assertIsNone(anchor.yDeviceTable)
147
148    def test_anchor_format_c(self):
149        doc = self.parse(
150            "feature test {"
151            "    pos cursive A "
152            "        <anchor 120 -20 <device 11 111, 12 112> <device NULL>>"
153            "        <anchor NULL>;"
154            "} test;"
155        )
156        anchor = doc.statements[0].statements[0].entryAnchor
157        self.assertEqual(type(anchor), ast.Anchor)
158        self.assertEqual(anchor.x, 120)
159        self.assertEqual(anchor.y, -20)
160        self.assertIsNone(anchor.contourpoint)
161        self.assertEqual(anchor.xDeviceTable, ((11, 111), (12, 112)))
162        self.assertIsNone(anchor.yDeviceTable)
163
164    def test_anchor_format_d(self):
165        doc = self.parse(
166            "feature test {"
167            "    pos cursive A <anchor 120 -20> <anchor NULL>;"
168            "} test;"
169        )
170        anchor = doc.statements[0].statements[0].exitAnchor
171        self.assertIsNone(anchor)
172
173    def test_anchor_format_e(self):
174        doc = self.parse(
175            "feature test {"
176            "    anchorDef 120 -20 contourpoint 7 Foo;"
177            "    pos cursive A <anchor Foo> <anchor NULL>;"
178            "} test;"
179        )
180        anchor = doc.statements[0].statements[1].entryAnchor
181        self.assertEqual(type(anchor), ast.Anchor)
182        self.assertEqual(anchor.x, 120)
183        self.assertEqual(anchor.y, -20)
184        self.assertEqual(anchor.contourpoint, 7)
185        self.assertIsNone(anchor.xDeviceTable)
186        self.assertIsNone(anchor.yDeviceTable)
187
188    def test_anchor_format_e_undefined(self):
189        self.assertRaisesRegex(
190            FeatureLibError,
191            'Unknown anchor "UnknownName"',
192            self.parse,
193            "feature test {"
194            "    position cursive A <anchor UnknownName> <anchor NULL>;"
195            "} test;",
196        )
197
198    def test_anchor_variable_scalar(self):
199        doc = self.parse(
200            "feature test {"
201            "    pos cursive A <anchor (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) -20> <anchor NULL>;"
202            "} test;"
203        )
204        anchor = doc.statements[0].statements[0].entryAnchor
205        self.assertEqual(
206            anchor.asFea(),
207            "<anchor (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) -20>",
208        )
209
210    def test_anchordef(self):
211        [foo] = self.parse("anchorDef 123 456 foo;").statements
212        self.assertEqual(type(foo), ast.AnchorDefinition)
213        self.assertEqual(foo.name, "foo")
214        self.assertEqual(foo.x, 123)
215        self.assertEqual(foo.y, 456)
216        self.assertEqual(foo.contourpoint, None)
217
218    def test_anchordef_contourpoint(self):
219        [foo] = self.parse("anchorDef 123 456 contourpoint 5 foo;").statements
220        self.assertEqual(type(foo), ast.AnchorDefinition)
221        self.assertEqual(foo.name, "foo")
222        self.assertEqual(foo.x, 123)
223        self.assertEqual(foo.y, 456)
224        self.assertEqual(foo.contourpoint, 5)
225
226    def test_anon(self):
227        anon = self.parse("anon TEST { # a\nfoo\n } TEST; # qux").statements[0]
228        self.assertIsInstance(anon, ast.AnonymousBlock)
229        self.assertEqual(anon.tag, "TEST")
230        self.assertEqual(anon.content, "foo\n ")
231
232    def test_anonymous(self):
233        anon = self.parse("anonymous TEST {\nbar\n} TEST;").statements[0]
234        self.assertIsInstance(anon, ast.AnonymousBlock)
235        self.assertEqual(anon.tag, "TEST")
236        # feature file spec requires passing the final end-of-line
237        self.assertEqual(anon.content, "bar\n")
238
239    def test_anon_missingBrace(self):
240        self.assertRaisesRegex(
241            FeatureLibError,
242            "Expected '} TEST;' to terminate anonymous block",
243            self.parse,
244            "anon TEST { \n no end in sight",
245        )
246
247    def test_attach(self):
248        doc = self.parse("table GDEF {Attach [a e] 2;} GDEF;")
249        s = doc.statements[0].statements[0]
250        self.assertIsInstance(s, ast.AttachStatement)
251        self.assertEqual(glyphstr([s.glyphs]), "[a e]")
252        self.assertEqual(s.contourPoints, {2})
253
254    def test_feature_block(self):
255        [liga] = self.parse("feature liga {} liga;").statements
256        self.assertEqual(liga.name, "liga")
257        self.assertFalse(liga.use_extension)
258
259    def test_feature_block_useExtension(self):
260        [liga] = self.parse("feature liga useExtension {} liga;").statements
261        self.assertEqual(liga.name, "liga")
262        self.assertTrue(liga.use_extension)
263        self.assertEqual(liga.asFea(), "feature liga useExtension {\n    \n} liga;\n")
264
265    def test_feature_comment(self):
266        [liga] = self.parse("feature liga { # Comment\n } liga;").statements
267        [comment] = liga.statements
268        self.assertIsInstance(comment, ast.Comment)
269        self.assertEqual(comment.text, "# Comment")
270
271    def test_feature_reference(self):
272        doc = self.parse("feature aalt { feature salt; } aalt;")
273        ref = doc.statements[0].statements[0]
274        self.assertIsInstance(ref, ast.FeatureReferenceStatement)
275        self.assertEqual(ref.featureName, "salt")
276
277    def test_FeatureNames_bad(self):
278        self.assertRaisesRegex(
279            FeatureLibError,
280            'Expected "name"',
281            self.parse,
282            "feature ss01 { featureNames { feature test; } ss01;",
283        )
284
285    def test_FeatureNames_comment(self):
286        [feature] = self.parse(
287            "feature ss01 { featureNames { # Comment\n }; } ss01;"
288        ).statements
289        [featureNames] = feature.statements
290        self.assertIsInstance(featureNames, ast.NestedBlock)
291        [comment] = featureNames.statements
292        self.assertIsInstance(comment, ast.Comment)
293        self.assertEqual(comment.text, "# Comment")
294
295    def test_FeatureNames_emptyStatements(self):
296        [feature] = self.parse(
297            "feature ss01 { featureNames { ;;; }; } ss01;"
298        ).statements
299        [featureNames] = feature.statements
300        self.assertIsInstance(featureNames, ast.NestedBlock)
301        self.assertEqual(featureNames.statements, [])
302
303    def test_FontRevision(self):
304        doc = self.parse("table head {FontRevision 2.5;} head;")
305        s = doc.statements[0].statements[0]
306        self.assertIsInstance(s, ast.FontRevisionStatement)
307        self.assertEqual(s.revision, 2.5)
308
309    def test_FontRevision_negative(self):
310        self.assertRaisesRegex(
311            FeatureLibError,
312            "Font revision numbers must be positive",
313            self.parse,
314            "table head {FontRevision -17.2;} head;",
315        )
316
317    def test_strict_glyph_name_check(self):
318        self.parse("@bad = [a b ccc];", glyphNames=("a", "b", "ccc"))
319
320        with self.assertRaisesRegex(
321            FeatureLibError, "(?s)missing from the glyph set:.*ccc"
322        ):
323            self.parse("@bad = [a b ccc];", glyphNames=("a", "b"))
324
325    def test_glyphclass(self):
326        [gc] = self.parse("@dash = [endash emdash figuredash];").statements
327        self.assertEqual(gc.name, "dash")
328        self.assertEqual(gc.glyphSet(), ("endash", "emdash", "figuredash"))
329
330    def test_glyphclass_glyphNameTooLong(self):
331        gname = "G" * 600
332        [gc] = self.parse(f"@GlyphClass = [{gname}];").statements
333        self.assertEqual(gc.name, "GlyphClass")
334        self.assertEqual(gc.glyphSet(), (gname,))
335
336    def test_glyphclass_bad(self):
337        self.assertRaisesRegex(
338            FeatureLibError,
339            "Expected glyph name, glyph range, or glyph class reference",
340            self.parse,
341            "@bad = [a 123];",
342        )
343
344    def test_glyphclass_duplicate(self):
345        # makeotf accepts this, so we should too
346        ab, xy = self.parse("@dup = [a b]; @dup = [x y];").statements
347        self.assertEqual(glyphstr([ab]), "[a b]")
348        self.assertEqual(glyphstr([xy]), "[x y]")
349
350    def test_glyphclass_empty(self):
351        [gc] = self.parse("@empty_set = [];").statements
352        self.assertEqual(gc.name, "empty_set")
353        self.assertEqual(gc.glyphSet(), tuple())
354
355    def test_glyphclass_equality(self):
356        [foo, bar] = self.parse("@foo = [a b]; @bar = @foo;").statements
357        self.assertEqual(foo.glyphSet(), ("a", "b"))
358        self.assertEqual(bar.glyphSet(), ("a", "b"))
359
360    def test_glyphclass_from_markClass(self):
361        doc = self.parse(
362            "markClass [acute grave] <anchor 500 800> @TOP_MARKS;"
363            "markClass cedilla <anchor 500 -100> @BOTTOM_MARKS;"
364            "@MARKS = [@TOP_MARKS @BOTTOM_MARKS ogonek];"
365            "@ALL = @MARKS;"
366        )
367        self.assertEqual(
368            doc.statements[-1].glyphSet(), ("acute", "grave", "cedilla", "ogonek")
369        )
370
371    def test_glyphclass_range_cid(self):
372        [gc] = self.parse(r"@GlyphClass = [\999-\1001];").statements
373        self.assertEqual(gc.name, "GlyphClass")
374        self.assertEqual(gc.glyphSet(), ("cid00999", "cid01000", "cid01001"))
375
376    def test_glyphclass_range_cid_bad(self):
377        self.assertRaisesRegex(
378            FeatureLibError,
379            "Bad range: start should be less than limit",
380            self.parse,
381            r"@bad = [\998-\995];",
382        )
383
384    def test_glyphclass_range_uppercase(self):
385        [gc] = self.parse("@swashes = [X.swash-Z.swash];").statements
386        self.assertEqual(gc.name, "swashes")
387        self.assertEqual(gc.glyphSet(), ("X.swash", "Y.swash", "Z.swash"))
388
389    def test_glyphclass_range_lowercase(self):
390        [gc] = self.parse("@defg.sc = [d.sc-g.sc];").statements
391        self.assertEqual(gc.name, "defg.sc")
392        self.assertEqual(gc.glyphSet(), ("d.sc", "e.sc", "f.sc", "g.sc"))
393
394    def test_glyphclass_range_dash(self):
395        glyphNames = "A-foo.sc B-foo.sc C-foo.sc".split()
396        [gc] = self.parse("@range = [A-foo.sc-C-foo.sc];", glyphNames).statements
397        self.assertEqual(gc.glyphSet(), ("A-foo.sc", "B-foo.sc", "C-foo.sc"))
398
399    def test_glyphclass_range_dash_with_space(self):
400        gn = "A-foo.sc B-foo.sc C-foo.sc".split()
401        [gc] = self.parse("@range = [A-foo.sc - C-foo.sc];", gn).statements
402        self.assertEqual(gc.glyphSet(), ("A-foo.sc", "B-foo.sc", "C-foo.sc"))
403
404    def test_glyphclass_ambiguous_dash_no_glyph_names(self):
405        # If Parser is initialized without a glyphNames parameter (or with empty one)
406        # it cannot distinguish between a glyph name containing an hyphen, or a
407        # range of glyph names; thus it will interpret them as literal glyph names
408        # while also outputting a logging warning to alert user about the ambiguity.
409        # https://github.com/fonttools/fonttools/issues/1768
410        glyphNames = ()
411        with CapturingLogHandler("fontTools.feaLib.parser", level="WARNING") as caplog:
412            [gc] = self.parse(
413                "@class = [A-foo.sc B-foo.sc C D];", glyphNames
414            ).statements
415        self.assertEqual(gc.glyphSet(), ("A-foo.sc", "B-foo.sc", "C", "D"))
416        self.assertEqual(len(caplog.records), 2)
417        caplog.assertRegex("Ambiguous glyph name that looks like a range:")
418
419    def test_glyphclass_glyph_name_should_win_over_range(self):
420        # The OpenType Feature File Specification v1.20 makes it clear
421        # that if a dashed name could be interpreted either as a glyph name
422        # or as a range, then the semantics should be the single dashed name.
423        glyphNames = "A-foo.sc-C-foo.sc A-foo.sc B-foo.sc C-foo.sc".split()
424        [gc] = self.parse("@range = [A-foo.sc-C-foo.sc];", glyphNames).statements
425        self.assertEqual(gc.glyphSet(), ("A-foo.sc-C-foo.sc",))
426
427    def test_glyphclass_range_dash_ambiguous(self):
428        glyphNames = "A B C A-B B-C".split()
429        self.assertRaisesRegex(
430            FeatureLibError,
431            'Ambiguous glyph range "A-B-C"; '
432            'please use "A - B-C" or "A-B - C" to clarify what you mean',
433            self.parse,
434            r"@bad = [A-B-C];",
435            glyphNames,
436        )
437
438    def test_glyphclass_range_digit1(self):
439        [gc] = self.parse("@range = [foo.2-foo.5];").statements
440        self.assertEqual(gc.glyphSet(), ("foo.2", "foo.3", "foo.4", "foo.5"))
441
442    def test_glyphclass_range_digit2(self):
443        [gc] = self.parse("@range = [foo.09-foo.11];").statements
444        self.assertEqual(gc.glyphSet(), ("foo.09", "foo.10", "foo.11"))
445
446    def test_glyphclass_range_digit3(self):
447        [gc] = self.parse("@range = [foo.123-foo.125];").statements
448        self.assertEqual(gc.glyphSet(), ("foo.123", "foo.124", "foo.125"))
449
450    def test_glyphclass_range_bad(self):
451        self.assertRaisesRegex(
452            FeatureLibError,
453            'Bad range: "a" and "foobar" should have the same length',
454            self.parse,
455            "@bad = [a-foobar];",
456        )
457        self.assertRaisesRegex(
458            FeatureLibError,
459            'Bad range: "A.swash-z.swash"',
460            self.parse,
461            "@bad = [A.swash-z.swash];",
462        )
463        self.assertRaisesRegex(
464            FeatureLibError,
465            "Start of range must be smaller than its end",
466            self.parse,
467            "@bad = [B.swash-A.swash];",
468        )
469        self.assertRaisesRegex(
470            FeatureLibError,
471            'Bad range: "foo.1234-foo.9876"',
472            self.parse,
473            "@bad = [foo.1234-foo.9876];",
474        )
475
476    def test_glyphclass_range_mixed(self):
477        [gc] = self.parse("@range = [a foo.09-foo.11 X.sc-Z.sc];").statements
478        self.assertEqual(
479            gc.glyphSet(), ("a", "foo.09", "foo.10", "foo.11", "X.sc", "Y.sc", "Z.sc")
480        )
481
482    def test_glyphclass_reference(self):
483        [vowels_lc, vowels_uc, vowels] = self.parse(
484            "@Vowels.lc = [a e i o u]; @Vowels.uc = [A E I O U];"
485            "@Vowels = [@Vowels.lc @Vowels.uc y Y];"
486        ).statements
487        self.assertEqual(vowels_lc.glyphSet(), tuple("aeiou"))
488        self.assertEqual(vowels_uc.glyphSet(), tuple("AEIOU"))
489        self.assertEqual(vowels.glyphSet(), tuple("aeiouAEIOUyY"))
490        self.assertEqual(vowels.asFea(), "@Vowels = [@Vowels.lc @Vowels.uc y Y];")
491        self.assertRaisesRegex(
492            FeatureLibError,
493            "Unknown glyph class @unknown",
494            self.parse,
495            "@bad = [@unknown];",
496        )
497
498    def test_glyphclass_scoping(self):
499        [foo, liga, smcp] = self.parse(
500            "@foo = [a b];"
501            "feature liga { @bar = [@foo l]; } liga;"
502            "feature smcp { @bar = [@foo s]; } smcp;"
503        ).statements
504        self.assertEqual(foo.glyphSet(), ("a", "b"))
505        self.assertEqual(liga.statements[0].glyphSet(), ("a", "b", "l"))
506        self.assertEqual(smcp.statements[0].glyphSet(), ("a", "b", "s"))
507
508    def test_glyphclass_scoping_bug496(self):
509        # https://github.com/fonttools/fonttools/issues/496
510        f1, f2 = self.parse(
511            "feature F1 { lookup L { @GLYPHCLASS = [A B C];} L; } F1;"
512            "feature F2 { sub @GLYPHCLASS by D; } F2;"
513        ).statements
514        self.assertEqual(list(f2.statements[0].glyphs[0].glyphSet()), ["A", "B", "C"])
515
516    def test_GlyphClassDef(self):
517        doc = self.parse("table GDEF {GlyphClassDef [b],[l],[m],[C c];} GDEF;")
518        s = doc.statements[0].statements[0]
519        self.assertIsInstance(s, ast.GlyphClassDefStatement)
520        self.assertEqual(glyphstr([s.baseGlyphs]), "b")
521        self.assertEqual(glyphstr([s.ligatureGlyphs]), "l")
522        self.assertEqual(glyphstr([s.markGlyphs]), "m")
523        self.assertEqual(glyphstr([s.componentGlyphs]), "[C c]")
524
525    def test_GlyphClassDef_noCLassesSpecified(self):
526        doc = self.parse("table GDEF {GlyphClassDef ,,,;} GDEF;")
527        s = doc.statements[0].statements[0]
528        self.assertIsNone(s.baseGlyphs)
529        self.assertIsNone(s.ligatureGlyphs)
530        self.assertIsNone(s.markGlyphs)
531        self.assertIsNone(s.componentGlyphs)
532
533    def test_ignore_pos(self):
534        doc = self.parse("feature test {ignore pos e t' c, q u' u' x;} test;")
535        sub = doc.statements[0].statements[0]
536        self.assertIsInstance(sub, ast.IgnorePosStatement)
537        [(pref1, glyphs1, suff1), (pref2, glyphs2, suff2)] = sub.chainContexts
538        self.assertEqual(glyphstr(pref1), "e")
539        self.assertEqual(glyphstr(glyphs1), "t")
540        self.assertEqual(glyphstr(suff1), "c")
541        self.assertEqual(glyphstr(pref2), "q")
542        self.assertEqual(glyphstr(glyphs2), "u u")
543        self.assertEqual(glyphstr(suff2), "x")
544
545    def test_ignore_position(self):
546        doc = self.parse(
547            "feature test {" "    ignore position f [a e] d' [a u]' [e y];" "} test;"
548        )
549        sub = doc.statements[0].statements[0]
550        self.assertIsInstance(sub, ast.IgnorePosStatement)
551        [(prefix, glyphs, suffix)] = sub.chainContexts
552        self.assertEqual(glyphstr(prefix), "f [a e]")
553        self.assertEqual(glyphstr(glyphs), "d [a u]")
554        self.assertEqual(glyphstr(suffix), "[e y]")
555
556    def test_ignore_position_with_lookup(self):
557        self.assertRaisesRegex(
558            FeatureLibError,
559            'No lookups can be specified for "ignore pos"',
560            self.parse,
561            "lookup L { pos [A A.sc] -100; } L;"
562            "feature test { ignore pos f' i', A' lookup L; } test;",
563        )
564
565    def test_ignore_sub(self):
566        doc = self.parse("feature test {ignore sub e t' c, q u' u' x;} test;")
567        sub = doc.statements[0].statements[0]
568        self.assertIsInstance(sub, ast.IgnoreSubstStatement)
569        [(pref1, glyphs1, suff1), (pref2, glyphs2, suff2)] = sub.chainContexts
570        self.assertEqual(glyphstr(pref1), "e")
571        self.assertEqual(glyphstr(glyphs1), "t")
572        self.assertEqual(glyphstr(suff1), "c")
573        self.assertEqual(glyphstr(pref2), "q")
574        self.assertEqual(glyphstr(glyphs2), "u u")
575        self.assertEqual(glyphstr(suff2), "x")
576
577    def test_ignore_substitute(self):
578        doc = self.parse(
579            "feature test {" "    ignore substitute f [a e] d' [a u]' [e y];" "} test;"
580        )
581        sub = doc.statements[0].statements[0]
582        self.assertIsInstance(sub, ast.IgnoreSubstStatement)
583        [(prefix, glyphs, suffix)] = sub.chainContexts
584        self.assertEqual(glyphstr(prefix), "f [a e]")
585        self.assertEqual(glyphstr(glyphs), "d [a u]")
586        self.assertEqual(glyphstr(suffix), "[e y]")
587
588    def test_ignore_substitute_with_lookup(self):
589        self.assertRaisesRegex(
590            FeatureLibError,
591            'No lookups can be specified for "ignore sub"',
592            self.parse,
593            "lookup L { sub [A A.sc] by a; } L;"
594            "feature test { ignore sub f' i', A' lookup L; } test;",
595        )
596
597    def test_include_statement(self):
598        doc = self.parse(
599            """\
600            include(../family.fea);
601            include # Comment
602                (foo)
603                  ;
604            """,
605            followIncludes=False,
606        )
607        s1, s2, s3 = doc.statements
608        self.assertEqual(type(s1), ast.IncludeStatement)
609        self.assertEqual(s1.filename, "../family.fea")
610        self.assertEqual(s1.asFea(), "include(../family.fea);")
611        self.assertEqual(type(s2), ast.IncludeStatement)
612        self.assertEqual(s2.filename, "foo")
613        self.assertEqual(s2.asFea(), "include(foo);")
614        self.assertEqual(type(s3), ast.Comment)
615        self.assertEqual(s3.text, "# Comment")
616
617    def test_include_statement_no_semicolon(self):
618        doc = self.parse(
619            """\
620            include(../family.fea)
621            """,
622            followIncludes=False,
623        )
624        s1 = doc.statements[0]
625        self.assertEqual(type(s1), ast.IncludeStatement)
626        self.assertEqual(s1.filename, "../family.fea")
627        self.assertEqual(s1.asFea(), "include(../family.fea);")
628
629    def test_language(self):
630        doc = self.parse("feature test {language DEU;} test;")
631        s = doc.statements[0].statements[0]
632        self.assertEqual(type(s), ast.LanguageStatement)
633        self.assertEqual(s.language, "DEU ")
634        self.assertTrue(s.include_default)
635        self.assertFalse(s.required)
636
637    def test_language_exclude_dflt(self):
638        doc = self.parse("feature test {language DEU exclude_dflt;} test;")
639        s = doc.statements[0].statements[0]
640        self.assertEqual(type(s), ast.LanguageStatement)
641        self.assertEqual(s.language, "DEU ")
642        self.assertFalse(s.include_default)
643        self.assertFalse(s.required)
644
645    def test_language_exclude_dflt_required(self):
646        doc = self.parse(
647            "feature test {" "  language DEU exclude_dflt required;" "} test;"
648        )
649        s = doc.statements[0].statements[0]
650        self.assertEqual(type(s), ast.LanguageStatement)
651        self.assertEqual(s.language, "DEU ")
652        self.assertFalse(s.include_default)
653        self.assertTrue(s.required)
654
655    def test_language_include_dflt(self):
656        doc = self.parse("feature test {language DEU include_dflt;} test;")
657        s = doc.statements[0].statements[0]
658        self.assertEqual(type(s), ast.LanguageStatement)
659        self.assertEqual(s.language, "DEU ")
660        self.assertTrue(s.include_default)
661        self.assertFalse(s.required)
662
663    def test_language_include_dflt_required(self):
664        doc = self.parse(
665            "feature test {" "  language DEU include_dflt required;" "} test;"
666        )
667        s = doc.statements[0].statements[0]
668        self.assertEqual(type(s), ast.LanguageStatement)
669        self.assertEqual(s.language, "DEU ")
670        self.assertTrue(s.include_default)
671        self.assertTrue(s.required)
672
673    def test_language_DFLT(self):
674        self.assertRaisesRegex(
675            FeatureLibError,
676            '"DFLT" is not a valid language tag; use "dflt" instead',
677            self.parse,
678            "feature test { language DFLT; } test;",
679        )
680
681    def test_ligatureCaretByIndex_glyphClass(self):
682        doc = self.parse("table GDEF{LigatureCaretByIndex [c_t f_i] 2;}GDEF;")
683        s = doc.statements[0].statements[0]
684        self.assertIsInstance(s, ast.LigatureCaretByIndexStatement)
685        self.assertEqual(glyphstr([s.glyphs]), "[c_t f_i]")
686        self.assertEqual(s.carets, [2])
687
688    def test_ligatureCaretByIndex_singleGlyph(self):
689        doc = self.parse("table GDEF{LigatureCaretByIndex f_f_i 3 7;}GDEF;")
690        s = doc.statements[0].statements[0]
691        self.assertIsInstance(s, ast.LigatureCaretByIndexStatement)
692        self.assertEqual(glyphstr([s.glyphs]), "f_f_i")
693        self.assertEqual(s.carets, [3, 7])
694
695    def test_ligatureCaretByPos_glyphClass(self):
696        doc = self.parse("table GDEF {LigatureCaretByPos [c_t f_i] 7;} GDEF;")
697        s = doc.statements[0].statements[0]
698        self.assertIsInstance(s, ast.LigatureCaretByPosStatement)
699        self.assertEqual(glyphstr([s.glyphs]), "[c_t f_i]")
700        self.assertEqual(s.carets, [7])
701
702    def test_ligatureCaretByPos_singleGlyph(self):
703        doc = self.parse("table GDEF {LigatureCaretByPos f_i 400 380;} GDEF;")
704        s = doc.statements[0].statements[0]
705        self.assertIsInstance(s, ast.LigatureCaretByPosStatement)
706        self.assertEqual(glyphstr([s.glyphs]), "f_i")
707        self.assertEqual(s.carets, [400, 380])
708
709    def test_ligatureCaretByPos_variable_scalar(self):
710        doc = self.parse(
711            "table GDEF {LigatureCaretByPos f_i (wght=200:400 wght=900:1000) 380;} GDEF;"
712        )
713        s = doc.statements[0].statements[0]
714        self.assertIsInstance(s, ast.LigatureCaretByPosStatement)
715        self.assertEqual(glyphstr([s.glyphs]), "f_i")
716        self.assertEqual(len(s.carets), 2)
717        self.assertEqual(str(s.carets[0]), "(wght=200:400 wght=900:1000)")
718        self.assertEqual(s.carets[1], 380)
719
720    def test_lookup_block(self):
721        [lookup] = self.parse("lookup Ligatures {} Ligatures;").statements
722        self.assertEqual(lookup.name, "Ligatures")
723        self.assertFalse(lookup.use_extension)
724
725    def test_lookup_block_useExtension(self):
726        [lookup] = self.parse("lookup Foo useExtension {} Foo;").statements
727        self.assertEqual(lookup.name, "Foo")
728        self.assertTrue(lookup.use_extension)
729        self.assertEqual(lookup.asFea(), "lookup Foo useExtension {\n    \n} Foo;\n")
730
731    def test_lookup_block_name_mismatch(self):
732        self.assertRaisesRegex(
733            FeatureLibError, 'Expected "Foo"', self.parse, "lookup Foo {} Bar;"
734        )
735
736    def test_lookup_block_with_horizontal_valueRecordDef(self):
737        doc = self.parse(
738            "feature liga {"
739            "  lookup look {"
740            "    valueRecordDef 123 foo;"
741            "  } look;"
742            "} liga;"
743        )
744        [liga] = doc.statements
745        [look] = liga.statements
746        [foo] = look.statements
747        self.assertEqual(foo.value.xAdvance, 123)
748        self.assertIsNone(foo.value.yAdvance)
749
750    def test_lookup_block_with_vertical_valueRecordDef(self):
751        doc = self.parse(
752            "feature vkrn {"
753            "  lookup look {"
754            "    valueRecordDef 123 foo;"
755            "  } look;"
756            "} vkrn;"
757        )
758        [vkrn] = doc.statements
759        [look] = vkrn.statements
760        [foo] = look.statements
761        self.assertIsNone(foo.value.xAdvance)
762        self.assertEqual(foo.value.yAdvance, 123)
763
764    def test_lookup_comment(self):
765        [lookup] = self.parse("lookup L { # Comment\n } L;").statements
766        [comment] = lookup.statements
767        self.assertIsInstance(comment, ast.Comment)
768        self.assertEqual(comment.text, "# Comment")
769
770    def test_lookup_reference(self):
771        [foo, bar] = self.parse(
772            "lookup Foo {} Foo;" "feature Bar {lookup Foo;} Bar;"
773        ).statements
774        [ref] = bar.statements
775        self.assertEqual(type(ref), ast.LookupReferenceStatement)
776        self.assertEqual(ref.lookup, foo)
777
778    def test_lookup_reference_to_lookup_inside_feature(self):
779        [qux, bar] = self.parse(
780            "feature Qux {lookup Foo {} Foo;} Qux;" "feature Bar {lookup Foo;} Bar;"
781        ).statements
782        [foo] = qux.statements
783        [ref] = bar.statements
784        self.assertIsInstance(ref, ast.LookupReferenceStatement)
785        self.assertEqual(ref.lookup, foo)
786
787    def test_lookup_reference_unknown(self):
788        self.assertRaisesRegex(
789            FeatureLibError,
790            'Unknown lookup "Huh"',
791            self.parse,
792            "feature liga {lookup Huh;} liga;",
793        )
794
795    def parse_lookupflag_(self, s):
796        return self.parse("lookup L {%s} L;" % s).statements[0].statements[-1]
797
798    def test_lookupflag_format_A(self):
799        flag = self.parse_lookupflag_("lookupflag RightToLeft IgnoreMarks;")
800        self.assertIsInstance(flag, ast.LookupFlagStatement)
801        self.assertEqual(flag.value, 9)
802        self.assertIsNone(flag.markAttachment)
803        self.assertIsNone(flag.markFilteringSet)
804        self.assertEqual(flag.asFea(), "lookupflag RightToLeft IgnoreMarks;")
805
806    def test_lookupflag_format_A_MarkAttachmentType(self):
807        flag = self.parse_lookupflag_(
808            "@TOP_MARKS = [acute grave macron];"
809            "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;"
810        )
811        self.assertIsInstance(flag, ast.LookupFlagStatement)
812        self.assertEqual(flag.value, 1)
813        self.assertIsInstance(flag.markAttachment, ast.GlyphClassName)
814        self.assertEqual(flag.markAttachment.glyphSet(), ("acute", "grave", "macron"))
815        self.assertIsNone(flag.markFilteringSet)
816        self.assertEqual(
817            flag.asFea(), "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;"
818        )
819
820    def test_lookupflag_format_A_MarkAttachmentType_glyphClass(self):
821        flag = self.parse_lookupflag_(
822            "lookupflag RightToLeft MarkAttachmentType [acute grave macron];"
823        )
824        self.assertIsInstance(flag, ast.LookupFlagStatement)
825        self.assertEqual(flag.value, 1)
826        self.assertIsInstance(flag.markAttachment, ast.GlyphClass)
827        self.assertEqual(flag.markAttachment.glyphSet(), ("acute", "grave", "macron"))
828        self.assertIsNone(flag.markFilteringSet)
829        self.assertEqual(
830            flag.asFea(),
831            "lookupflag RightToLeft MarkAttachmentType [acute grave macron];",
832        )
833
834    def test_lookupflag_format_A_UseMarkFilteringSet(self):
835        flag = self.parse_lookupflag_(
836            "@BOTTOM_MARKS = [cedilla ogonek];"
837            "lookupflag UseMarkFilteringSet @BOTTOM_MARKS IgnoreLigatures;"
838        )
839        self.assertIsInstance(flag, ast.LookupFlagStatement)
840        self.assertEqual(flag.value, 4)
841        self.assertIsNone(flag.markAttachment)
842        self.assertIsInstance(flag.markFilteringSet, ast.GlyphClassName)
843        self.assertEqual(flag.markFilteringSet.glyphSet(), ("cedilla", "ogonek"))
844        self.assertEqual(
845            flag.asFea(),
846            "lookupflag IgnoreLigatures UseMarkFilteringSet @BOTTOM_MARKS;",
847        )
848
849    def test_lookupflag_format_A_UseMarkFilteringSet_glyphClass(self):
850        flag = self.parse_lookupflag_(
851            "lookupflag UseMarkFilteringSet [cedilla ogonek] IgnoreLigatures;"
852        )
853        self.assertIsInstance(flag, ast.LookupFlagStatement)
854        self.assertEqual(flag.value, 4)
855        self.assertIsNone(flag.markAttachment)
856        self.assertIsInstance(flag.markFilteringSet, ast.GlyphClass)
857        self.assertEqual(flag.markFilteringSet.glyphSet(), ("cedilla", "ogonek"))
858        self.assertEqual(
859            flag.asFea(),
860            "lookupflag IgnoreLigatures UseMarkFilteringSet [cedilla ogonek];",
861        )
862
863    def test_lookupflag_format_B(self):
864        flag = self.parse_lookupflag_("lookupflag 7;")
865        self.assertIsInstance(flag, ast.LookupFlagStatement)
866        self.assertEqual(flag.value, 7)
867        self.assertIsNone(flag.markAttachment)
868        self.assertIsNone(flag.markFilteringSet)
869        self.assertEqual(
870            flag.asFea(), "lookupflag RightToLeft IgnoreBaseGlyphs IgnoreLigatures;"
871        )
872
873    def test_lookupflag_format_B_zero(self):
874        flag = self.parse_lookupflag_("lookupflag 0;")
875        self.assertIsInstance(flag, ast.LookupFlagStatement)
876        self.assertEqual(flag.value, 0)
877        self.assertIsNone(flag.markAttachment)
878        self.assertIsNone(flag.markFilteringSet)
879        self.assertEqual(flag.asFea(), "lookupflag 0;")
880
881    def test_lookupflag_no_value(self):
882        self.assertRaisesRegex(
883            FeatureLibError,
884            "lookupflag must have a value",
885            self.parse,
886            "feature test {lookupflag;} test;",
887        )
888
889    def test_lookupflag_repeated(self):
890        self.assertRaisesRegex(
891            FeatureLibError,
892            "RightToLeft can be specified only once",
893            self.parse,
894            "feature test {lookupflag RightToLeft RightToLeft;} test;",
895        )
896
897    def test_lookupflag_unrecognized(self):
898        self.assertRaisesRegex(
899            FeatureLibError,
900            '"IgnoreCookies" is not a recognized lookupflag',
901            self.parse,
902            "feature test {lookupflag IgnoreCookies;} test;",
903        )
904
905    def test_gpos_type_1_glyph(self):
906        doc = self.parse("feature kern {pos one <1 2 3 4>;} kern;")
907        pos = doc.statements[0].statements[0]
908        self.assertIsInstance(pos, ast.SinglePosStatement)
909        [(glyphs, value)] = pos.pos
910        self.assertEqual(glyphstr([glyphs]), "one")
911        self.assertEqual(value.asFea(), "<1 2 3 4>")
912
913    def test_gpos_type_1_glyphclass_horizontal(self):
914        doc = self.parse("feature kern {pos [one two] -300;} kern;")
915        pos = doc.statements[0].statements[0]
916        self.assertIsInstance(pos, ast.SinglePosStatement)
917        [(glyphs, value)] = pos.pos
918        self.assertEqual(glyphstr([glyphs]), "[one two]")
919        self.assertEqual(value.asFea(), "-300")
920
921    def test_gpos_type_1_glyphclass_vertical(self):
922        doc = self.parse("feature vkrn {pos [one two] -300;} vkrn;")
923        pos = doc.statements[0].statements[0]
924        self.assertIsInstance(pos, ast.SinglePosStatement)
925        [(glyphs, value)] = pos.pos
926        self.assertEqual(glyphstr([glyphs]), "[one two]")
927        self.assertEqual(value.asFea(), "-300")
928
929    def test_gpos_type_1_multiple(self):
930        doc = self.parse("feature f {pos one'1 two'2 [five six]'56;} f;")
931        pos = doc.statements[0].statements[0]
932        self.assertIsInstance(pos, ast.SinglePosStatement)
933        [(glyphs1, val1), (glyphs2, val2), (glyphs3, val3)] = pos.pos
934        self.assertEqual(glyphstr([glyphs1]), "one")
935        self.assertEqual(val1.asFea(), "1")
936        self.assertEqual(glyphstr([glyphs2]), "two")
937        self.assertEqual(val2.asFea(), "2")
938        self.assertEqual(glyphstr([glyphs3]), "[five six]")
939        self.assertEqual(val3.asFea(), "56")
940        self.assertEqual(pos.prefix, [])
941        self.assertEqual(pos.suffix, [])
942
943    def test_gpos_type_1_enumerated(self):
944        self.assertRaisesRegex(
945            FeatureLibError,
946            '"enumerate" is only allowed with pair positionings',
947            self.parse,
948            "feature test {enum pos T 100;} test;",
949        )
950        self.assertRaisesRegex(
951            FeatureLibError,
952            '"enumerate" is only allowed with pair positionings',
953            self.parse,
954            "feature test {enumerate pos T 100;} test;",
955        )
956
957    def test_gpos_type_1_chained(self):
958        doc = self.parse("feature kern {pos [A B] [T Y]' 20 comma;} kern;")
959        pos = doc.statements[0].statements[0]
960        self.assertIsInstance(pos, ast.SinglePosStatement)
961        [(glyphs, value)] = pos.pos
962        self.assertEqual(glyphstr([glyphs]), "[T Y]")
963        self.assertEqual(value.asFea(), "20")
964        self.assertEqual(glyphstr(pos.prefix), "[A B]")
965        self.assertEqual(glyphstr(pos.suffix), "comma")
966
967    def test_gpos_type_1_chained_special_kern_format_valuerecord_format_a(self):
968        doc = self.parse("feature kern {pos [A B] [T Y]' comma 20;} kern;")
969        pos = doc.statements[0].statements[0]
970        self.assertIsInstance(pos, ast.SinglePosStatement)
971        [(glyphs, value)] = pos.pos
972        self.assertEqual(glyphstr([glyphs]), "[T Y]")
973        self.assertEqual(value.asFea(), "20")
974        self.assertEqual(glyphstr(pos.prefix), "[A B]")
975        self.assertEqual(glyphstr(pos.suffix), "comma")
976
977    def test_gpos_type_1_chained_special_kern_format_valuerecord_format_b(self):
978        doc = self.parse("feature kern {pos [A B] [T Y]' comma <0 0 0 0>;} kern;")
979        pos = doc.statements[0].statements[0]
980        self.assertIsInstance(pos, ast.SinglePosStatement)
981        [(glyphs, value)] = pos.pos
982        self.assertEqual(glyphstr([glyphs]), "[T Y]")
983        self.assertEqual(value.asFea(), "<0 0 0 0>")
984        self.assertEqual(glyphstr(pos.prefix), "[A B]")
985        self.assertEqual(glyphstr(pos.suffix), "comma")
986
987    def test_gpos_type_1_chained_special_kern_format_valuerecord_format_b_bug2293(self):
988        # https://github.com/fonttools/fonttools/issues/2293
989        doc = self.parse("feature kern {pos [A B] [T Y]' comma a <0 0 0 0>;} kern;")
990        pos = doc.statements[0].statements[0]
991        self.assertIsInstance(pos, ast.SinglePosStatement)
992        [(glyphs, value)] = pos.pos
993        self.assertEqual(glyphstr([glyphs]), "[T Y]")
994        self.assertEqual(value.asFea(), "<0 0 0 0>")
995        self.assertEqual(glyphstr(pos.prefix), "[A B]")
996        self.assertEqual(glyphstr(pos.suffix), "comma a")
997
998    def test_gpos_type_1_chained_exception1(self):
999        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
1000            doc = self.parse(
1001                "feature kern {" "    pos [A B]' [T Y]' comma a <0 0 0 0>;" "} kern;"
1002            )
1003
1004    def test_gpos_type_1_chained_exception2(self):
1005        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
1006            doc = self.parse(
1007                "feature kern {"
1008                "    pos [A B]' <0 0 0 0> [T Y]' comma a <0 0 0 0>;"
1009                "} kern;"
1010            )
1011
1012    def test_gpos_type_1_chained_exception3(self):
1013        with self.assertRaisesRegex(FeatureLibError, "Positioning cannot be applied"):
1014            doc = self.parse(
1015                "feature kern {"
1016                "    pos [A B] <0 0 0 0> [T Y]' comma a <0 0 0 0>;"
1017                "} kern;"
1018            )
1019
1020    def test_gpos_type_1_chained_exception4(self):
1021        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
1022            doc = self.parse("feature kern {" "    pos a' b c 123 d;" "} kern;")
1023
1024    def test_gpos_type_2_format_a(self):
1025        doc = self.parse(
1026            "feature kern {" "    pos [T V] -60 [a b c] <1 2 3 4>;" "} kern;"
1027        )
1028        pos = doc.statements[0].statements[0]
1029        self.assertEqual(type(pos), ast.PairPosStatement)
1030        self.assertFalse(pos.enumerated)
1031        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1032        self.assertEqual(pos.valuerecord1.asFea(), "-60")
1033        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1034        self.assertEqual(pos.valuerecord2.asFea(), "<1 2 3 4>")
1035
1036    def test_gpos_type_2_format_a_enumerated(self):
1037        doc = self.parse(
1038            "feature kern {" "    enum pos [T V] -60 [a b c] <1 2 3 4>;" "} kern;"
1039        )
1040        pos = doc.statements[0].statements[0]
1041        self.assertEqual(type(pos), ast.PairPosStatement)
1042        self.assertTrue(pos.enumerated)
1043        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1044        self.assertEqual(pos.valuerecord1.asFea(), "-60")
1045        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1046        self.assertEqual(pos.valuerecord2.asFea(), "<1 2 3 4>")
1047
1048    def test_gpos_type_2_format_a_with_null_first(self):
1049        doc = self.parse(
1050            "feature kern {" "    pos [T V] <NULL> [a b c] <1 2 3 4>;" "} kern;"
1051        )
1052        pos = doc.statements[0].statements[0]
1053        self.assertEqual(type(pos), ast.PairPosStatement)
1054        self.assertFalse(pos.enumerated)
1055        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1056        self.assertFalse(pos.valuerecord1)
1057        self.assertEqual(pos.valuerecord1.asFea(), "<NULL>")
1058        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1059        self.assertEqual(pos.valuerecord2.asFea(), "<1 2 3 4>")
1060        self.assertEqual(pos.asFea(), "pos [T V] <NULL> [a b c] <1 2 3 4>;")
1061
1062    def test_gpos_type_2_format_a_with_null_second(self):
1063        doc = self.parse(
1064            "feature kern {" "    pos [T V] <1 2 3 4> [a b c] <NULL>;" "} kern;"
1065        )
1066        pos = doc.statements[0].statements[0]
1067        self.assertEqual(type(pos), ast.PairPosStatement)
1068        self.assertFalse(pos.enumerated)
1069        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1070        self.assertEqual(pos.valuerecord1.asFea(), "<1 2 3 4>")
1071        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1072        self.assertFalse(pos.valuerecord2)
1073        self.assertEqual(pos.asFea(), "pos [T V] [a b c] <1 2 3 4>;")
1074
1075    def test_gpos_type_2_format_b(self):
1076        doc = self.parse("feature kern {" "    pos [T V] [a b c] <1 2 3 4>;" "} kern;")
1077        pos = doc.statements[0].statements[0]
1078        self.assertEqual(type(pos), ast.PairPosStatement)
1079        self.assertFalse(pos.enumerated)
1080        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1081        self.assertEqual(pos.valuerecord1.asFea(), "<1 2 3 4>")
1082        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1083        self.assertIsNone(pos.valuerecord2)
1084
1085    def test_gpos_type_2_format_b_enumerated(self):
1086        doc = self.parse(
1087            "feature kern {" "    enumerate position [T V] [a b c] <1 2 3 4>;" "} kern;"
1088        )
1089        pos = doc.statements[0].statements[0]
1090        self.assertEqual(type(pos), ast.PairPosStatement)
1091        self.assertTrue(pos.enumerated)
1092        self.assertEqual(glyphstr([pos.glyphs1]), "[T V]")
1093        self.assertEqual(pos.valuerecord1.asFea(), "<1 2 3 4>")
1094        self.assertEqual(glyphstr([pos.glyphs2]), "[a b c]")
1095        self.assertIsNone(pos.valuerecord2)
1096
1097    def test_gpos_type_3(self):
1098        doc = self.parse(
1099            "feature kern {"
1100            "    position cursive A <anchor 12 -2> <anchor 2 3>;"
1101            "} kern;"
1102        )
1103        pos = doc.statements[0].statements[0]
1104        self.assertEqual(type(pos), ast.CursivePosStatement)
1105        self.assertEqual(pos.glyphclass.glyphSet(), ("A",))
1106        self.assertEqual((pos.entryAnchor.x, pos.entryAnchor.y), (12, -2))
1107        self.assertEqual((pos.exitAnchor.x, pos.exitAnchor.y), (2, 3))
1108
1109    def test_gpos_type_3_enumerated(self):
1110        self.assertRaisesRegex(
1111            FeatureLibError,
1112            '"enumerate" is not allowed with cursive attachment positioning',
1113            self.parse,
1114            "feature kern {"
1115            "    enumerate position cursive A <anchor 12 -2> <anchor 2 3>;"
1116            "} kern;",
1117        )
1118
1119    def test_gpos_type_4(self):
1120        doc = self.parse(
1121            "markClass [acute grave] <anchor 150 -10> @TOP_MARKS;"
1122            "markClass [dieresis umlaut] <anchor 300 -10> @TOP_MARKS;"
1123            "markClass [cedilla] <anchor 300 600> @BOTTOM_MARKS;"
1124            "feature test {"
1125            "    position base [a e o u] "
1126            "        <anchor 250 450> mark @TOP_MARKS "
1127            "        <anchor 210 -10> mark @BOTTOM_MARKS;"
1128            "} test;"
1129        )
1130        pos = doc.statements[-1].statements[0]
1131        self.assertEqual(type(pos), ast.MarkBasePosStatement)
1132        self.assertEqual(pos.base.glyphSet(), ("a", "e", "o", "u"))
1133        (a1, m1), (a2, m2) = pos.marks
1134        self.assertEqual((a1.x, a1.y, m1.name), (250, 450, "TOP_MARKS"))
1135        self.assertEqual((a2.x, a2.y, m2.name), (210, -10, "BOTTOM_MARKS"))
1136
1137    def test_gpos_type_4_enumerated(self):
1138        self.assertRaisesRegex(
1139            FeatureLibError,
1140            '"enumerate" is not allowed with ' "mark-to-base attachment positioning",
1141            self.parse,
1142            "feature kern {"
1143            "    markClass cedilla <anchor 300 600> @BOTTOM_MARKS;"
1144            "    enumerate position base A <anchor 12 -2> mark @BOTTOM_MARKS;"
1145            "} kern;",
1146        )
1147
1148    def test_gpos_type_4_not_markClass(self):
1149        self.assertRaisesRegex(
1150            FeatureLibError,
1151            "@MARKS is not a markClass",
1152            self.parse,
1153            "@MARKS = [acute grave];"
1154            "feature test {"
1155            "    position base [a e o u] <anchor 250 450> mark @MARKS;"
1156            "} test;",
1157        )
1158
1159    def test_gpos_type_5(self):
1160        doc = self.parse(
1161            "markClass [grave acute] <anchor 150 500> @TOP_MARKS;"
1162            "markClass [cedilla] <anchor 300 -100> @BOTTOM_MARKS;"
1163            "feature test {"
1164            "    position "
1165            "        ligature [a_f_f_i o_f_f_i] "
1166            "            <anchor 50 600> mark @TOP_MARKS "
1167            "            <anchor 50 -10> mark @BOTTOM_MARKS "
1168            "        ligComponent "
1169            "            <anchor 30 800> mark @TOP_MARKS "
1170            "        ligComponent "
1171            "            <anchor NULL> "
1172            "        ligComponent "
1173            "            <anchor 30 -10> mark @BOTTOM_MARKS;"
1174            "} test;"
1175        )
1176        pos = doc.statements[-1].statements[0]
1177        self.assertEqual(type(pos), ast.MarkLigPosStatement)
1178        self.assertEqual(pos.ligatures.glyphSet(), ("a_f_f_i", "o_f_f_i"))
1179        [(a11, m11), (a12, m12)], [(a2, m2)], [], [(a4, m4)] = pos.marks
1180        self.assertEqual((a11.x, a11.y, m11.name), (50, 600, "TOP_MARKS"))
1181        self.assertEqual((a12.x, a12.y, m12.name), (50, -10, "BOTTOM_MARKS"))
1182        self.assertEqual((a2.x, a2.y, m2.name), (30, 800, "TOP_MARKS"))
1183        self.assertEqual((a4.x, a4.y, m4.name), (30, -10, "BOTTOM_MARKS"))
1184
1185    def test_gpos_type_5_enumerated(self):
1186        self.assertRaisesRegex(
1187            FeatureLibError,
1188            '"enumerate" is not allowed with '
1189            "mark-to-ligature attachment positioning",
1190            self.parse,
1191            "feature test {"
1192            "    markClass cedilla <anchor 300 600> @MARKS;"
1193            "    enumerate position "
1194            "        ligature f_i <anchor 100 0> mark @MARKS"
1195            "        ligComponent <anchor NULL>;"
1196            "} test;",
1197        )
1198
1199    def test_gpos_type_5_not_markClass(self):
1200        self.assertRaisesRegex(
1201            FeatureLibError,
1202            "@MARKS is not a markClass",
1203            self.parse,
1204            "@MARKS = [acute grave];"
1205            "feature test {"
1206            "    position ligature f_i <anchor 250 450> mark @MARKS;"
1207            "} test;",
1208        )
1209
1210    def test_gpos_type_6(self):
1211        doc = self.parse(
1212            "markClass damma <anchor 189 -103> @MARK_CLASS_1;"
1213            "feature test {"
1214            "    position mark hamza <anchor 221 301> mark @MARK_CLASS_1;"
1215            "} test;"
1216        )
1217        pos = doc.statements[-1].statements[0]
1218        self.assertEqual(type(pos), ast.MarkMarkPosStatement)
1219        self.assertEqual(pos.baseMarks.glyphSet(), ("hamza",))
1220        [(a1, m1)] = pos.marks
1221        self.assertEqual((a1.x, a1.y, m1.name), (221, 301, "MARK_CLASS_1"))
1222
1223    def test_gpos_type_6_enumerated(self):
1224        self.assertRaisesRegex(
1225            FeatureLibError,
1226            '"enumerate" is not allowed with ' "mark-to-mark attachment positioning",
1227            self.parse,
1228            "markClass damma <anchor 189 -103> @MARK_CLASS_1;"
1229            "feature test {"
1230            "    enum pos mark hamza <anchor 221 301> mark @MARK_CLASS_1;"
1231            "} test;",
1232        )
1233
1234    def test_gpos_type_6_not_markClass(self):
1235        self.assertRaisesRegex(
1236            FeatureLibError,
1237            "@MARKS is not a markClass",
1238            self.parse,
1239            "@MARKS = [acute grave];"
1240            "feature test {"
1241            "    position mark cedilla <anchor 250 450> mark @MARKS;"
1242            "} test;",
1243        )
1244
1245    def test_gpos_type_8(self):
1246        doc = self.parse(
1247            "lookup L1 {pos one 100;} L1; lookup L2 {pos two 200;} L2;"
1248            "feature test {"
1249            "    pos [A a] [B b] I' lookup L1 [N n]' lookup L2 P' [Y y] [Z z];"
1250            "} test;"
1251        )
1252        lookup1, lookup2 = doc.statements[0:2]
1253        pos = doc.statements[-1].statements[0]
1254        self.assertEqual(type(pos), ast.ChainContextPosStatement)
1255        self.assertEqual(glyphstr(pos.prefix), "[A a] [B b]")
1256        self.assertEqual(glyphstr(pos.glyphs), "I [N n] P")
1257        self.assertEqual(glyphstr(pos.suffix), "[Y y] [Z z]")
1258        self.assertEqual(pos.lookups, [[lookup1], [lookup2], None])
1259
1260    def test_gpos_type_8_lookup_with_values(self):
1261        self.assertRaisesRegex(
1262            FeatureLibError,
1263            'If "lookup" is present, no values must be specified',
1264            self.parse,
1265            "lookup L1 {pos one 100;} L1;"
1266            "feature test {"
1267            "    pos A' lookup L1 B' 20;"
1268            "} test;",
1269        )
1270
1271    def test_markClass(self):
1272        doc = self.parse("markClass [acute grave] <anchor 350 3> @MARKS;")
1273        mc = doc.statements[0]
1274        self.assertIsInstance(mc, ast.MarkClassDefinition)
1275        self.assertEqual(mc.markClass.name, "MARKS")
1276        self.assertEqual(mc.glyphSet(), ("acute", "grave"))
1277        self.assertEqual((mc.anchor.x, mc.anchor.y), (350, 3))
1278
1279    def test_nameid_windows_utf16(self):
1280        doc = self.parse(r'table name { nameid 9 "M\00fcller-Lanc\00e9"; } name;')
1281        name = doc.statements[0].statements[0]
1282        self.assertIsInstance(name, ast.NameRecord)
1283        self.assertEqual(name.nameID, 9)
1284        self.assertEqual(name.platformID, 3)
1285        self.assertEqual(name.platEncID, 1)
1286        self.assertEqual(name.langID, 0x0409)
1287        self.assertEqual(name.string, "Müller-Lancé")
1288        self.assertEqual(name.asFea(), r'nameid 9 "M\00fcller-Lanc\00e9";')
1289
1290    def test_nameid_windows_utf16_backslash(self):
1291        doc = self.parse(r'table name { nameid 9 "Back\005cslash"; } name;')
1292        name = doc.statements[0].statements[0]
1293        self.assertEqual(name.string, r"Back\slash")
1294        self.assertEqual(name.asFea(), r'nameid 9 "Back\005cslash";')
1295
1296    def test_nameid_windows_utf16_quotation_mark(self):
1297        doc = self.parse(r'table name { nameid 9 "Quotation \0022Mark\0022"; } name;')
1298        name = doc.statements[0].statements[0]
1299        self.assertEqual(name.string, 'Quotation "Mark"')
1300        self.assertEqual(name.asFea(), r'nameid 9 "Quotation \0022Mark\0022";')
1301
1302    def test_nameid_windows_utf16_surroates(self):
1303        doc = self.parse(r'table name { nameid 9 "Carrot \D83E\DD55"; } name;')
1304        name = doc.statements[0].statements[0]
1305        self.assertEqual(name.string, r"Carrot ��")
1306        self.assertEqual(name.asFea(), r'nameid 9 "Carrot \d83e\dd55";')
1307
1308    def test_nameid_mac_roman(self):
1309        doc = self.parse(r'table name { nameid 9 1 "Joachim M\9fller-Lanc\8e"; } name;')
1310        name = doc.statements[0].statements[0]
1311        self.assertIsInstance(name, ast.NameRecord)
1312        self.assertEqual(name.nameID, 9)
1313        self.assertEqual(name.platformID, 1)
1314        self.assertEqual(name.platEncID, 0)
1315        self.assertEqual(name.langID, 0)
1316        self.assertEqual(name.string, "Joachim Müller-Lancé")
1317        self.assertEqual(name.asFea(), r'nameid 9 1 "Joachim M\9fller-Lanc\8e";')
1318
1319    def test_nameid_mac_croatian(self):
1320        doc = self.parse(r'table name { nameid 9 1 0 18 "Jovica Veljovi\e6"; } name;')
1321        name = doc.statements[0].statements[0]
1322        self.assertEqual(name.nameID, 9)
1323        self.assertEqual(name.platformID, 1)
1324        self.assertEqual(name.platEncID, 0)
1325        self.assertEqual(name.langID, 18)
1326        self.assertEqual(name.string, "Jovica Veljović")
1327        self.assertEqual(name.asFea(), r'nameid 9 1 0 18 "Jovica Veljovi\e6";')
1328
1329    def test_nameid_unsupported_platform(self):
1330        self.assertRaisesRegex(
1331            FeatureLibError,
1332            "Expected platform id 1 or 3",
1333            self.parse,
1334            'table name { nameid 9 666 "Foo"; } name;',
1335        )
1336
1337    def test_nameid_hexadecimal(self):
1338        doc = self.parse(r'table name { nameid 0x9 0x3 0x1 0x0409 "Test"; } name;')
1339        name = doc.statements[0].statements[0]
1340        self.assertEqual(name.nameID, 9)
1341        self.assertEqual(name.platformID, 3)
1342        self.assertEqual(name.platEncID, 1)
1343        self.assertEqual(name.langID, 0x0409)
1344
1345    def test_nameid_octal(self):
1346        doc = self.parse(r'table name { nameid 011 03 012 02011 "Test"; } name;')
1347        name = doc.statements[0].statements[0]
1348        self.assertEqual(name.nameID, 9)
1349        self.assertEqual(name.platformID, 3)
1350        self.assertEqual(name.platEncID, 10)
1351        self.assertEqual(name.langID, 0o2011)
1352
1353    def test_cv_hexadecimal(self):
1354        doc = self.parse(r"feature cv01 { cvParameters { Character 0x5DDE; }; } cv01;")
1355        cv = doc.statements[0].statements[0].statements[0]
1356        self.assertEqual(cv.character, 0x5DDE)
1357
1358    def test_cv_octal(self):
1359        doc = self.parse(r"feature cv01 { cvParameters { Character 056736; }; } cv01;")
1360        cv = doc.statements[0].statements[0].statements[0]
1361        self.assertEqual(cv.character, 0o56736)
1362
1363    def test_rsub_format_a(self):
1364        doc = self.parse("feature test {rsub a [b B] c' d [e E] by C;} test;")
1365        rsub = doc.statements[0].statements[0]
1366        self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement)
1367        self.assertEqual(glyphstr(rsub.old_prefix), "a [B b]")
1368        self.assertEqual(rsub.glyphs[0].glyphSet(), ("c",))
1369        self.assertEqual(rsub.replacements[0].glyphSet(), ("C",))
1370        self.assertEqual(glyphstr(rsub.old_suffix), "d [E e]")
1371
1372    def test_rsub_format_a_cid(self):
1373        doc = self.parse(r"feature test {rsub \1 [\2 \3] \4' \5 by \6;} test;")
1374        rsub = doc.statements[0].statements[0]
1375        self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement)
1376        self.assertEqual(glyphstr(rsub.old_prefix), "cid00001 [cid00002 cid00003]")
1377        self.assertEqual(rsub.glyphs[0].glyphSet(), ("cid00004",))
1378        self.assertEqual(rsub.replacements[0].glyphSet(), ("cid00006",))
1379        self.assertEqual(glyphstr(rsub.old_suffix), "cid00005")
1380
1381    def test_rsub_format_b(self):
1382        doc = self.parse(
1383            "feature smcp {"
1384            "    reversesub A B [one.fitted one.oldstyle]' C [d D] by one;"
1385            "} smcp;"
1386        )
1387        rsub = doc.statements[0].statements[0]
1388        self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement)
1389        self.assertEqual(glyphstr(rsub.old_prefix), "A B")
1390        self.assertEqual(glyphstr(rsub.old_suffix), "C [D d]")
1391        self.assertEqual(mapping(rsub), {"one.fitted": "one", "one.oldstyle": "one"})
1392
1393    def test_rsub_format_c(self):
1394        doc = self.parse(
1395            "feature test {"
1396            "    reversesub BACK TRACK [a-d]' LOOK AHEAD by [A.sc-D.sc];"
1397            "} test;"
1398        )
1399        rsub = doc.statements[0].statements[0]
1400        self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement)
1401        self.assertEqual(glyphstr(rsub.old_prefix), "BACK TRACK")
1402        self.assertEqual(glyphstr(rsub.old_suffix), "LOOK AHEAD")
1403        self.assertEqual(
1404            mapping(rsub), {"a": "A.sc", "b": "B.sc", "c": "C.sc", "d": "D.sc"}
1405        )
1406
1407    def test_rsub_from(self):
1408        self.assertRaisesRegex(
1409            FeatureLibError,
1410            'Reverse chaining substitutions do not support "from"',
1411            self.parse,
1412            "feature test {rsub a from [a.1 a.2 a.3];} test;",
1413        )
1414
1415    def test_rsub_nonsingle(self):
1416        self.assertRaisesRegex(
1417            FeatureLibError,
1418            "In reverse chaining single substitutions, only a single glyph "
1419            "or glyph class can be replaced",
1420            self.parse,
1421            "feature test {rsub c d by c_d;} test;",
1422        )
1423
1424    def test_rsub_multiple_replacement_glyphs(self):
1425        self.assertRaisesRegex(
1426            FeatureLibError,
1427            "In reverse chaining single substitutions, the replacement "
1428            r'\(after "by"\) must be a single glyph or glyph class',
1429            self.parse,
1430            "feature test {rsub f_i by f i;} test;",
1431        )
1432
1433    def test_script(self):
1434        doc = self.parse("feature test {script cyrl;} test;")
1435        s = doc.statements[0].statements[0]
1436        self.assertEqual(type(s), ast.ScriptStatement)
1437        self.assertEqual(s.script, "cyrl")
1438
1439    def test_script_dflt(self):
1440        self.assertRaisesRegex(
1441            FeatureLibError,
1442            '"dflt" is not a valid script tag; use "DFLT" instead',
1443            self.parse,
1444            "feature test {script dflt;} test;",
1445        )
1446
1447    def test_stat_design_axis(self):  # STAT DesignAxis
1448        doc = self.parse(
1449            "table STAT { DesignAxis opsz 0 " '{name "Optical Size";}; } STAT;'
1450        )
1451        da = doc.statements[0].statements[0]
1452        self.assertIsInstance(da, ast.STATDesignAxisStatement)
1453        self.assertEqual(da.tag, "opsz")
1454        self.assertEqual(da.axisOrder, 0)
1455        self.assertEqual(da.names[0].string, "Optical Size")
1456
1457    def test_stat_axis_value_format1(self):  # STAT AxisValue
1458        doc = self.parse(
1459            "table STAT { DesignAxis opsz 0 "
1460            '{name "Optical Size";}; '
1461            'AxisValue {location opsz 8; name "Caption";}; } '
1462            "STAT;"
1463        )
1464        avr = doc.statements[0].statements[1]
1465        self.assertIsInstance(avr, ast.STATAxisValueStatement)
1466        self.assertEqual(avr.locations[0].tag, "opsz")
1467        self.assertEqual(avr.locations[0].values[0], 8)
1468        self.assertEqual(avr.names[0].string, "Caption")
1469
1470    def test_stat_axis_value_format2(self):  # STAT AxisValue
1471        doc = self.parse(
1472            "table STAT { DesignAxis opsz 0 "
1473            '{name "Optical Size";}; '
1474            'AxisValue {location opsz 8 6 10; name "Caption";}; } '
1475            "STAT;"
1476        )
1477        avr = doc.statements[0].statements[1]
1478        self.assertIsInstance(avr, ast.STATAxisValueStatement)
1479        self.assertEqual(avr.locations[0].tag, "opsz")
1480        self.assertEqual(avr.locations[0].values, [8, 6, 10])
1481        self.assertEqual(avr.names[0].string, "Caption")
1482
1483    def test_stat_axis_value_format2_bad_range(self):  # STAT AxisValue
1484        self.assertRaisesRegex(
1485            FeatureLibError,
1486            "Default value 5 is outside of specified range 6-10.",
1487            self.parse,
1488            "table STAT { DesignAxis opsz 0 "
1489            '{name "Optical Size";}; '
1490            'AxisValue {location opsz 5 6 10; name "Caption";}; } '
1491            "STAT;",
1492        )
1493
1494    def test_stat_axis_value_format4(self):  # STAT AxisValue
1495        self.assertRaisesRegex(
1496            FeatureLibError,
1497            "Only one value is allowed in a Format 4 Axis Value Record, but 3 were found.",
1498            self.parse,
1499            "table STAT { "
1500            'DesignAxis opsz 0 {name "Optical Size";}; '
1501            'DesignAxis wdth 0 {name "Width";}; '
1502            "AxisValue {"
1503            "location opsz 8 6 10; "
1504            "location wdth 400; "
1505            'name "Caption";}; } '
1506            "STAT;",
1507        )
1508
1509    def test_stat_elidedfallbackname(self):  # STAT ElidedFallbackName
1510        doc = self.parse(
1511            'table STAT { ElidedFallbackName {name "Roman"; '
1512            'name 3 1 0x0411 "ローマン"; }; '
1513            "} STAT;"
1514        )
1515        nameRecord = doc.statements[0].statements[0]
1516        self.assertIsInstance(nameRecord, ast.ElidedFallbackName)
1517        self.assertEqual(nameRecord.names[0].string, "Roman")
1518        self.assertEqual(nameRecord.names[1].string, "ローマン")
1519
1520    def test_stat_elidedfallbacknameid(self):  # STAT ElidedFallbackNameID
1521        doc = self.parse(
1522            'table name { nameid 278 "Roman"; } name; '
1523            "table STAT { ElidedFallbackNameID 278; "
1524            "} STAT;"
1525        )
1526        nameRecord = doc.statements[0].statements[0]
1527        self.assertIsInstance(nameRecord, ast.NameRecord)
1528        self.assertEqual(nameRecord.string, "Roman")
1529
1530    def test_sub_single_format_a(self):  # GSUB LookupType 1
1531        doc = self.parse("feature smcp {substitute a by a.sc;} smcp;")
1532        sub = doc.statements[0].statements[0]
1533        self.assertIsInstance(sub, ast.SingleSubstStatement)
1534        self.assertEqual(glyphstr(sub.prefix), "")
1535        self.assertEqual(mapping(sub), {"a": "a.sc"})
1536        self.assertEqual(glyphstr(sub.suffix), "")
1537
1538    def test_sub_single_format_a_chained(self):  # chain to GSUB LookupType 1
1539        doc = self.parse("feature test {sub [A a] d' [C] by d.alt;} test;")
1540        sub = doc.statements[0].statements[0]
1541        self.assertIsInstance(sub, ast.SingleSubstStatement)
1542        self.assertEqual(mapping(sub), {"d": "d.alt"})
1543        self.assertEqual(glyphstr(sub.prefix), "[A a]")
1544        self.assertEqual(glyphstr(sub.suffix), "C")
1545
1546    def test_sub_single_format_a_cid(self):  # GSUB LookupType 1
1547        doc = self.parse(r"feature smcp {substitute \12345 by \78987;} smcp;")
1548        sub = doc.statements[0].statements[0]
1549        self.assertIsInstance(sub, ast.SingleSubstStatement)
1550        self.assertEqual(glyphstr(sub.prefix), "")
1551        self.assertEqual(mapping(sub), {"cid12345": "cid78987"})
1552        self.assertEqual(glyphstr(sub.suffix), "")
1553
1554    def test_sub_single_format_b(self):  # GSUB LookupType 1
1555        doc = self.parse(
1556            "feature smcp {"
1557            "    substitute [one.fitted one.oldstyle] by one;"
1558            "} smcp;"
1559        )
1560        sub = doc.statements[0].statements[0]
1561        self.assertIsInstance(sub, ast.SingleSubstStatement)
1562        self.assertEqual(mapping(sub), {"one.fitted": "one", "one.oldstyle": "one"})
1563        self.assertEqual(glyphstr(sub.prefix), "")
1564        self.assertEqual(glyphstr(sub.suffix), "")
1565
1566    def test_sub_single_format_b_chained(self):  # chain to GSUB LookupType 1
1567        doc = self.parse(
1568            "feature smcp {"
1569            "    substitute PRE FIX [one.fitted one.oldstyle]' SUF FIX by one;"
1570            "} smcp;"
1571        )
1572        sub = doc.statements[0].statements[0]
1573        self.assertIsInstance(sub, ast.SingleSubstStatement)
1574        self.assertEqual(mapping(sub), {"one.fitted": "one", "one.oldstyle": "one"})
1575        self.assertEqual(glyphstr(sub.prefix), "PRE FIX")
1576        self.assertEqual(glyphstr(sub.suffix), "SUF FIX")
1577
1578    def test_sub_single_format_c(self):  # GSUB LookupType 1
1579        doc = self.parse(
1580            "feature smcp {" "    substitute [a-d] by [A.sc-D.sc];" "} smcp;"
1581        )
1582        sub = doc.statements[0].statements[0]
1583        self.assertIsInstance(sub, ast.SingleSubstStatement)
1584        self.assertEqual(
1585            mapping(sub), {"a": "A.sc", "b": "B.sc", "c": "C.sc", "d": "D.sc"}
1586        )
1587        self.assertEqual(glyphstr(sub.prefix), "")
1588        self.assertEqual(glyphstr(sub.suffix), "")
1589
1590    def test_sub_single_format_c_chained(self):  # chain to GSUB LookupType 1
1591        doc = self.parse(
1592            "feature smcp {" "    substitute [a-d]' X Y [Z z] by [A.sc-D.sc];" "} smcp;"
1593        )
1594        sub = doc.statements[0].statements[0]
1595        self.assertIsInstance(sub, ast.SingleSubstStatement)
1596        self.assertEqual(
1597            mapping(sub), {"a": "A.sc", "b": "B.sc", "c": "C.sc", "d": "D.sc"}
1598        )
1599        self.assertEqual(glyphstr(sub.prefix), "")
1600        self.assertEqual(glyphstr(sub.suffix), "X Y [Z z]")
1601
1602    def test_sub_single_format_c_different_num_elements(self):
1603        self.assertRaisesRegex(
1604            FeatureLibError,
1605            'Expected a glyph class with 4 elements after "by", '
1606            "but found a glyph class with 26 elements",
1607            self.parse,
1608            "feature smcp {sub [a-d] by [A.sc-Z.sc];} smcp;",
1609        )
1610
1611    def test_sub_with_values(self):
1612        self.assertRaisesRegex(
1613            FeatureLibError,
1614            "Substitution statements cannot contain values",
1615            self.parse,
1616            "feature smcp {sub A' 20 by A.sc;} smcp;",
1617        )
1618
1619    def test_substitute_multiple(self):  # GSUB LookupType 2
1620        doc = self.parse("lookup Look {substitute f_f_i by f f i;} Look;")
1621        sub = doc.statements[0].statements[0]
1622        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1623        self.assertEqual(glyphstr([sub.glyph]), "f_f_i")
1624        self.assertEqual(glyphstr(sub.replacement), "f f i")
1625
1626    def test_substitute_multiple_chained(self):  # chain to GSUB LookupType 2
1627        doc = self.parse("lookup L {sub [A-C] f_f_i' [X-Z] by f f i;} L;")
1628        sub = doc.statements[0].statements[0]
1629        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1630        self.assertEqual(glyphstr([sub.glyph]), "f_f_i")
1631        self.assertEqual(glyphstr(sub.replacement), "f f i")
1632
1633    def test_substitute_multiple_force_chained(self):
1634        doc = self.parse("lookup L {sub f_f_i' by f f i;} L;")
1635        sub = doc.statements[0].statements[0]
1636        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1637        self.assertEqual(glyphstr([sub.glyph]), "f_f_i")
1638        self.assertEqual(glyphstr(sub.replacement), "f f i")
1639        self.assertEqual(sub.asFea(), "sub f_f_i' by f f i;")
1640
1641    def test_substitute_multiple_classes(self):
1642        doc = self.parse("lookup Look {substitute [f_i f_l] by [f f] [i l];} Look;")
1643        sub = doc.statements[0].statements[0]
1644        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1645        self.assertEqual(glyphstr([sub.glyph]), "[f_i f_l]")
1646        self.assertEqual(glyphstr(sub.replacement), "[f f] [i l]")
1647
1648    def test_substitute_multiple_classes_mixed(self):
1649        doc = self.parse("lookup Look {substitute [f_i f_l] by f [i l];} Look;")
1650        sub = doc.statements[0].statements[0]
1651        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1652        self.assertEqual(glyphstr([sub.glyph]), "[f_i f_l]")
1653        self.assertEqual(glyphstr(sub.replacement), "f [i l]")
1654
1655    def test_substitute_multiple_classes_mixed_singleton(self):
1656        doc = self.parse("lookup Look {substitute [f_i f_l] by [f] [i l];} Look;")
1657        sub = doc.statements[0].statements[0]
1658        self.assertIsInstance(sub, ast.MultipleSubstStatement)
1659        self.assertEqual(glyphstr([sub.glyph]), "[f_i f_l]")
1660        self.assertEqual(glyphstr(sub.replacement), "f [i l]")
1661
1662    def test_substitute_multiple_classes_mismatch(self):
1663        self.assertRaisesRegex(
1664            FeatureLibError,
1665            'Expected a glyph class with 1 or 3 elements after "by", '
1666            "but found a glyph class with 2 elements",
1667            self.parse,
1668            "lookup Look {substitute [f_i f_l f_f_i] by [f f_f] [i l i];} Look;",
1669        )
1670
1671    def test_substitute_multiple_by_mutliple(self):
1672        self.assertRaisesRegex(
1673            FeatureLibError,
1674            "Direct substitution of multiple glyphs by multiple glyphs "
1675            "is not supported",
1676            self.parse,
1677            "lookup MxM {sub a b c by d e f;} MxM;",
1678        )
1679
1680    def test_split_marked_glyphs_runs(self):
1681        self.assertRaisesRegex(
1682            FeatureLibError,
1683            "Unsupported contextual target sequence",
1684            self.parse,
1685            "feature test{" "    ignore pos a' x x A';" "} test;",
1686        )
1687        self.assertRaisesRegex(
1688            FeatureLibError,
1689            "Unsupported contextual target sequence",
1690            self.parse,
1691            "lookup shift {"
1692            "    pos a <0 -10 0 0>;"
1693            "    pos A <0 10 0 0>;"
1694            "} shift;"
1695            "feature test {"
1696            "    sub a' lookup shift x x A' lookup shift;"
1697            "} test;",
1698        )
1699        self.assertRaisesRegex(
1700            FeatureLibError,
1701            "Unsupported contextual target sequence",
1702            self.parse,
1703            "feature test {" "    ignore sub a' x x A';" "} test;",
1704        )
1705        self.assertRaisesRegex(
1706            FeatureLibError,
1707            "Unsupported contextual target sequence",
1708            self.parse,
1709            "lookup upper {"
1710            "    sub a by A;"
1711            "} upper;"
1712            "lookup lower {"
1713            "    sub A by a;"
1714            "} lower;"
1715            "feature test {"
1716            "    sub a' lookup upper x x A' lookup lower;"
1717            "} test;",
1718        )
1719
1720    def test_substitute_mix_single_multiple(self):
1721        doc = self.parse(
1722            "lookup Look {"
1723            "  sub f_f   by f f;"
1724            "  sub f     by f;"
1725            "  sub f_f_i by f f i;"
1726            "  sub [a a.sc] by a;"
1727            "  sub [a a.sc] by [b b.sc];"
1728            "} Look;"
1729        )
1730        statements = doc.statements[0].statements
1731        for sub in statements:
1732            self.assertIsInstance(sub, ast.MultipleSubstStatement)
1733        self.assertEqual(statements[1].glyph, "f")
1734        self.assertEqual(statements[1].replacement, ["f"])
1735        self.assertEqual(statements[3].glyph, "a")
1736        self.assertEqual(statements[3].replacement, ["a"])
1737        self.assertEqual(statements[4].glyph, "a.sc")
1738        self.assertEqual(statements[4].replacement, ["a"])
1739        self.assertEqual(statements[5].glyph, "a")
1740        self.assertEqual(statements[5].replacement, ["b"])
1741        self.assertEqual(statements[6].glyph, "a.sc")
1742        self.assertEqual(statements[6].replacement, ["b.sc"])
1743
1744    def test_substitute_from(self):  # GSUB LookupType 3
1745        doc = self.parse(
1746            "feature test {" "  substitute a from [a.1 a.2 a.3];" "} test;"
1747        )
1748        sub = doc.statements[0].statements[0]
1749        self.assertIsInstance(sub, ast.AlternateSubstStatement)
1750        self.assertEqual(glyphstr(sub.prefix), "")
1751        self.assertEqual(glyphstr([sub.glyph]), "a")
1752        self.assertEqual(glyphstr(sub.suffix), "")
1753        self.assertEqual(glyphstr([sub.replacement]), "[a.1 a.2 a.3]")
1754
1755    def test_substitute_from_chained(self):  # chain to GSUB LookupType 3
1756        doc = self.parse(
1757            "feature test {" "  substitute A B a' [Y y] Z from [a.1 a.2 a.3];" "} test;"
1758        )
1759        sub = doc.statements[0].statements[0]
1760        self.assertIsInstance(sub, ast.AlternateSubstStatement)
1761        self.assertEqual(glyphstr(sub.prefix), "A B")
1762        self.assertEqual(glyphstr([sub.glyph]), "a")
1763        self.assertEqual(glyphstr(sub.suffix), "[Y y] Z")
1764        self.assertEqual(glyphstr([sub.replacement]), "[a.1 a.2 a.3]")
1765
1766    def test_substitute_from_cid(self):  # GSUB LookupType 3
1767        doc = self.parse(
1768            r"feature test {" r"  substitute \7 from [\111 \222];" r"} test;"
1769        )
1770        sub = doc.statements[0].statements[0]
1771        self.assertIsInstance(sub, ast.AlternateSubstStatement)
1772        self.assertEqual(glyphstr(sub.prefix), "")
1773        self.assertEqual(glyphstr([sub.glyph]), "cid00007")
1774        self.assertEqual(glyphstr(sub.suffix), "")
1775        self.assertEqual(glyphstr([sub.replacement]), "[cid00111 cid00222]")
1776
1777    def test_substitute_from_glyphclass(self):  # GSUB LookupType 3
1778        doc = self.parse(
1779            "feature test {"
1780            "  @Ampersands = [ampersand.1 ampersand.2];"
1781            "  substitute ampersand from @Ampersands;"
1782            "} test;"
1783        )
1784        [glyphclass, sub] = doc.statements[0].statements
1785        self.assertIsInstance(sub, ast.AlternateSubstStatement)
1786        self.assertEqual(glyphstr(sub.prefix), "")
1787        self.assertEqual(glyphstr([sub.glyph]), "ampersand")
1788        self.assertEqual(glyphstr(sub.suffix), "")
1789        self.assertEqual(glyphstr([sub.replacement]), "[ampersand.1 ampersand.2]")
1790
1791    def test_substitute_ligature(self):  # GSUB LookupType 4
1792        doc = self.parse("feature liga {substitute f f i by f_f_i;} liga;")
1793        sub = doc.statements[0].statements[0]
1794        self.assertIsInstance(sub, ast.LigatureSubstStatement)
1795        self.assertEqual(glyphstr(sub.glyphs), "f f i")
1796        self.assertEqual(sub.replacement, "f_f_i")
1797        self.assertEqual(glyphstr(sub.prefix), "")
1798        self.assertEqual(glyphstr(sub.suffix), "")
1799
1800    def test_substitute_ligature_chained(self):  # chain to GSUB LookupType 4
1801        doc = self.parse("feature F {substitute A B f' i' Z by f_i;} F;")
1802        sub = doc.statements[0].statements[0]
1803        self.assertIsInstance(sub, ast.LigatureSubstStatement)
1804        self.assertEqual(glyphstr(sub.glyphs), "f i")
1805        self.assertEqual(sub.replacement, "f_i")
1806        self.assertEqual(glyphstr(sub.prefix), "A B")
1807        self.assertEqual(glyphstr(sub.suffix), "Z")
1808
1809    def test_substitute_lookups(self):  # GSUB LookupType 6
1810        doc = Parser(self.getpath("spec5fi1.fea"), GLYPHNAMES).parse()
1811        [_, _, _, langsys, ligs, sub, feature] = doc.statements
1812        self.assertEqual(feature.statements[0].lookups, [[ligs], None, [sub]])
1813        self.assertEqual(feature.statements[1].lookups, [[ligs], None, [sub]])
1814
1815    def test_substitute_missing_by(self):
1816        self.assertRaisesRegex(
1817            FeatureLibError,
1818            'Expected "by", "from" or explicit lookup references',
1819            self.parse,
1820            "feature liga {substitute f f i;} liga;",
1821        )
1822
1823    def test_substitute_invalid_statement(self):
1824        self.assertRaisesRegex(
1825            FeatureLibError,
1826            "Invalid substitution statement",
1827            Parser(self.getpath("GSUB_error.fea"), GLYPHNAMES).parse,
1828        )
1829
1830    def test_subtable(self):
1831        doc = self.parse("feature test {subtable;} test;")
1832        s = doc.statements[0].statements[0]
1833        self.assertIsInstance(s, ast.SubtableStatement)
1834
1835    def test_table_badEnd(self):
1836        self.assertRaisesRegex(
1837            FeatureLibError,
1838            'Expected "GDEF"',
1839            self.parse,
1840            "table GDEF {LigatureCaretByPos f_i 400;} ABCD;",
1841        )
1842
1843    def test_table_comment(self):
1844        for table in "BASE GDEF OS/2 head hhea name vhea".split():
1845            doc = self.parse("table %s { # Comment\n } %s;" % (table, table))
1846            comment = doc.statements[0].statements[0]
1847            self.assertIsInstance(comment, ast.Comment)
1848            self.assertEqual(comment.text, "# Comment")
1849
1850    def test_table_unsupported(self):
1851        self.assertRaisesRegex(
1852            FeatureLibError,
1853            '"table Foo" is not supported',
1854            self.parse,
1855            "table Foo {LigatureCaretByPos f_i 400;} Foo;",
1856        )
1857
1858    def test_valuerecord_format_a_horizontal(self):
1859        doc = self.parse("feature liga {valueRecordDef 123 foo;} liga;")
1860        valuedef = doc.statements[0].statements[0]
1861        value = valuedef.value
1862        self.assertIsNone(value.xPlacement)
1863        self.assertIsNone(value.yPlacement)
1864        self.assertEqual(value.xAdvance, 123)
1865        self.assertIsNone(value.yAdvance)
1866        self.assertIsNone(value.xPlaDevice)
1867        self.assertIsNone(value.yPlaDevice)
1868        self.assertIsNone(value.xAdvDevice)
1869        self.assertIsNone(value.yAdvDevice)
1870        self.assertEqual(valuedef.asFea(), "valueRecordDef 123 foo;")
1871        self.assertEqual(value.asFea(), "123")
1872
1873    def test_valuerecord_format_a_vertical(self):
1874        doc = self.parse("feature vkrn {valueRecordDef 123 foo;} vkrn;")
1875        valuedef = doc.statements[0].statements[0]
1876        value = valuedef.value
1877        self.assertIsNone(value.xPlacement)
1878        self.assertIsNone(value.yPlacement)
1879        self.assertIsNone(value.xAdvance)
1880        self.assertEqual(value.yAdvance, 123)
1881        self.assertIsNone(value.xPlaDevice)
1882        self.assertIsNone(value.yPlaDevice)
1883        self.assertIsNone(value.xAdvDevice)
1884        self.assertIsNone(value.yAdvDevice)
1885        self.assertEqual(valuedef.asFea(), "valueRecordDef 123 foo;")
1886        self.assertEqual(value.asFea(), "123")
1887
1888    def test_valuerecord_format_a_zero_horizontal(self):
1889        doc = self.parse("feature liga {valueRecordDef 0 foo;} liga;")
1890        valuedef = doc.statements[0].statements[0]
1891        value = valuedef.value
1892        self.assertIsNone(value.xPlacement)
1893        self.assertIsNone(value.yPlacement)
1894        self.assertEqual(value.xAdvance, 0)
1895        self.assertIsNone(value.yAdvance)
1896        self.assertIsNone(value.xPlaDevice)
1897        self.assertIsNone(value.yPlaDevice)
1898        self.assertIsNone(value.xAdvDevice)
1899        self.assertIsNone(value.yAdvDevice)
1900        self.assertEqual(valuedef.asFea(), "valueRecordDef 0 foo;")
1901        self.assertEqual(value.asFea(), "0")
1902
1903    def test_valuerecord_format_a_zero_vertical(self):
1904        doc = self.parse("feature vkrn {valueRecordDef 0 foo;} vkrn;")
1905        valuedef = doc.statements[0].statements[0]
1906        value = valuedef.value
1907        self.assertIsNone(value.xPlacement)
1908        self.assertIsNone(value.yPlacement)
1909        self.assertIsNone(value.xAdvance)
1910        self.assertEqual(value.yAdvance, 0)
1911        self.assertIsNone(value.xPlaDevice)
1912        self.assertIsNone(value.yPlaDevice)
1913        self.assertIsNone(value.xAdvDevice)
1914        self.assertIsNone(value.yAdvDevice)
1915        self.assertEqual(valuedef.asFea(), "valueRecordDef 0 foo;")
1916        self.assertEqual(value.asFea(), "0")
1917
1918    def test_valuerecord_format_a_vertical_contexts_(self):
1919        for tag in "vkrn vpal vhal valt".split():
1920            doc = self.parse("feature %s {valueRecordDef 77 foo;} %s;" % (tag, tag))
1921            value = doc.statements[0].statements[0].value
1922            if value.yAdvance != 77:
1923                self.fail(
1924                    msg="feature %s should be a vertical context "
1925                    "for ValueRecord format A" % tag
1926                )
1927
1928    def test_valuerecord_format_b(self):
1929        doc = self.parse("feature liga {valueRecordDef <1 2 3 4> foo;} liga;")
1930        valuedef = doc.statements[0].statements[0]
1931        value = valuedef.value
1932        self.assertEqual(value.xPlacement, 1)
1933        self.assertEqual(value.yPlacement, 2)
1934        self.assertEqual(value.xAdvance, 3)
1935        self.assertEqual(value.yAdvance, 4)
1936        self.assertIsNone(value.xPlaDevice)
1937        self.assertIsNone(value.yPlaDevice)
1938        self.assertIsNone(value.xAdvDevice)
1939        self.assertIsNone(value.yAdvDevice)
1940        self.assertEqual(valuedef.asFea(), "valueRecordDef <1 2 3 4> foo;")
1941        self.assertEqual(value.asFea(), "<1 2 3 4>")
1942
1943    def test_valuerecord_format_b_zero(self):
1944        doc = self.parse("feature liga {valueRecordDef <0 0 0 0> foo;} liga;")
1945        valuedef = doc.statements[0].statements[0]
1946        value = valuedef.value
1947        self.assertEqual(value.xPlacement, 0)
1948        self.assertEqual(value.yPlacement, 0)
1949        self.assertEqual(value.xAdvance, 0)
1950        self.assertEqual(value.yAdvance, 0)
1951        self.assertIsNone(value.xPlaDevice)
1952        self.assertIsNone(value.yPlaDevice)
1953        self.assertIsNone(value.xAdvDevice)
1954        self.assertIsNone(value.yAdvDevice)
1955        self.assertEqual(valuedef.asFea(), "valueRecordDef <0 0 0 0> foo;")
1956        self.assertEqual(value.asFea(), "<0 0 0 0>")
1957
1958    def test_valuerecord_format_c(self):
1959        doc = self.parse(
1960            "feature liga {"
1961            "    valueRecordDef <"
1962            "        1 2 3 4"
1963            "        <device 8 88>"
1964            "        <device 11 111, 12 112>"
1965            "        <device NULL>"
1966            "        <device 33 -113, 44 -114, 55 115>"
1967            "    > foo;"
1968            "} liga;"
1969        )
1970        value = doc.statements[0].statements[0].value
1971        self.assertEqual(value.xPlacement, 1)
1972        self.assertEqual(value.yPlacement, 2)
1973        self.assertEqual(value.xAdvance, 3)
1974        self.assertEqual(value.yAdvance, 4)
1975        self.assertEqual(value.xPlaDevice, ((8, 88),))
1976        self.assertEqual(value.yPlaDevice, ((11, 111), (12, 112)))
1977        self.assertIsNone(value.xAdvDevice)
1978        self.assertEqual(value.yAdvDevice, ((33, -113), (44, -114), (55, 115)))
1979        self.assertEqual(
1980            value.asFea(),
1981            "<1 2 3 4 <device 8 88> <device 11 111, 12 112>"
1982            " <device NULL> <device 33 -113, 44 -114, 55 115>>",
1983        )
1984
1985    def test_valuerecord_format_d(self):
1986        doc = self.parse("feature test {valueRecordDef <NULL> foo;} test;")
1987        value = doc.statements[0].statements[0].value
1988        self.assertFalse(value)
1989        self.assertEqual(value.asFea(), "<NULL>")
1990
1991    def test_valuerecord_variable_scalar(self):
1992        doc = self.parse(
1993            "feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0> foo;} test;"
1994        )
1995        value = doc.statements[0].statements[0].value
1996        self.assertEqual(
1997            value.asFea(),
1998            "<0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0>",
1999        )
2000
2001    def test_valuerecord_named(self):
2002        doc = self.parse(
2003            "valueRecordDef <1 2 3 4> foo;"
2004            "feature liga {valueRecordDef <foo> bar;} liga;"
2005        )
2006        value = doc.statements[1].statements[0].value
2007        self.assertEqual(value.xPlacement, 1)
2008        self.assertEqual(value.yPlacement, 2)
2009        self.assertEqual(value.xAdvance, 3)
2010        self.assertEqual(value.yAdvance, 4)
2011
2012    def test_valuerecord_named_unknown(self):
2013        self.assertRaisesRegex(
2014            FeatureLibError,
2015            'Unknown valueRecordDef "unknown"',
2016            self.parse,
2017            "valueRecordDef <unknown> foo;",
2018        )
2019
2020    def test_valuerecord_scoping(self):
2021        [foo, liga, smcp] = self.parse(
2022            "valueRecordDef 789 foo;"
2023            "feature liga {valueRecordDef <foo> bar;} liga;"
2024            "feature smcp {valueRecordDef <foo> bar;} smcp;"
2025        ).statements
2026        self.assertEqual(foo.value.xAdvance, 789)
2027        self.assertEqual(liga.statements[0].value.xAdvance, 789)
2028        self.assertEqual(smcp.statements[0].value.xAdvance, 789)
2029
2030    def test_valuerecord_device_value_out_of_range(self):
2031        self.assertRaisesRegex(
2032            FeatureLibError,
2033            r"Device value out of valid range \(-128..127\)",
2034            self.parse,
2035            "valueRecordDef <1 2 3 4 <device NULL> <device NULL> "
2036            "<device NULL> <device 11 128>> foo;",
2037        )
2038
2039    def test_conditionset(self):
2040        doc = self.parse("conditionset heavy { wght 700 900; } heavy;")
2041        value = doc.statements[0]
2042        self.assertEqual(value.conditions["wght"], (700, 900))
2043        self.assertEqual(
2044            value.asFea(), "conditionset heavy {\n    wght 700 900;\n} heavy;\n"
2045        )
2046
2047        doc = self.parse("conditionset heavy { wght 700 900; opsz 17 18;} heavy;")
2048        value = doc.statements[0]
2049        self.assertEqual(value.conditions["wght"], (700, 900))
2050        self.assertEqual(value.conditions["opsz"], (17, 18))
2051        self.assertEqual(
2052            value.asFea(),
2053            "conditionset heavy {\n    wght 700 900;\n    opsz 17 18;\n} heavy;\n",
2054        )
2055
2056    def test_conditionset_same_axis(self):
2057        self.assertRaisesRegex(
2058            FeatureLibError,
2059            r"Repeated condition for axis wght",
2060            self.parse,
2061            "conditionset heavy { wght 700 900; wght 100 200; } heavy;",
2062        )
2063
2064    def test_conditionset_float(self):
2065        doc = self.parse("conditionset heavy { wght 700.0 900.0; } heavy;")
2066        value = doc.statements[0]
2067        self.assertEqual(value.conditions["wght"], (700.0, 900.0))
2068        self.assertEqual(
2069            value.asFea(), "conditionset heavy {\n    wght 700.0 900.0;\n} heavy;\n"
2070        )
2071
2072    def test_variation(self):
2073        doc = self.parse("variation rvrn heavy { sub a by b; } rvrn;")
2074        value = doc.statements[0]
2075
2076    def test_languagesystem(self):
2077        [langsys] = self.parse("languagesystem latn DEU;").statements
2078        self.assertEqual(langsys.script, "latn")
2079        self.assertEqual(langsys.language, "DEU ")
2080        [langsys] = self.parse("languagesystem DFLT DEU;").statements
2081        self.assertEqual(langsys.script, "DFLT")
2082        self.assertEqual(langsys.language, "DEU ")
2083        self.assertRaisesRegex(
2084            FeatureLibError,
2085            '"dflt" is not a valid script tag; use "DFLT" instead',
2086            self.parse,
2087            "languagesystem dflt dflt;",
2088        )
2089        self.assertRaisesRegex(
2090            FeatureLibError,
2091            '"DFLT" is not a valid language tag; use "dflt" instead',
2092            self.parse,
2093            "languagesystem latn DFLT;",
2094        )
2095        self.assertRaisesRegex(
2096            FeatureLibError, "Expected ';'", self.parse, "languagesystem latn DEU"
2097        )
2098        self.assertRaisesRegex(
2099            FeatureLibError,
2100            "longer than 4 characters",
2101            self.parse,
2102            "languagesystem foobar DEU;",
2103        )
2104        self.assertRaisesRegex(
2105            FeatureLibError,
2106            "longer than 4 characters",
2107            self.parse,
2108            "languagesystem latn FOOBAR;",
2109        )
2110
2111    def test_empty_statement_ignored(self):
2112        doc = self.parse("feature test {;} test;")
2113        self.assertFalse(doc.statements[0].statements)
2114        doc = self.parse(";;;")
2115        self.assertFalse(doc.statements)
2116        for table in "BASE GDEF OS/2 head hhea name vhea".split():
2117            doc = self.parse("table %s { ;;; } %s;" % (table, table))
2118            self.assertEqual(doc.statements[0].statements, [])
2119
2120    def test_ufo_features_parse_include_dir(self):
2121        fea_path = self.getpath("include/test.ufo/features.fea")
2122        include_dir = os.path.dirname(os.path.dirname(fea_path))
2123        doc = Parser(fea_path, includeDir=include_dir).parse()
2124        assert len(doc.statements) == 1 and doc.statements[0].text == "# Nothing"
2125
2126    def test_unmarked_ignore_statement(self):
2127        with CapturingLogHandler("fontTools.feaLib.parser", level="WARNING") as caplog:
2128            doc = self.parse("lookup foo { ignore sub A; } foo;")
2129        self.assertEqual(doc.statements[0].statements[0].asFea(), "ignore sub A';")
2130        self.assertEqual(len(caplog.records), 1)
2131        caplog.assertRegex(
2132            'Ambiguous "ignore sub", there should be least one marked glyph'
2133        )
2134
2135    def parse(self, text, glyphNames=GLYPHNAMES, followIncludes=True):
2136        featurefile = StringIO(text)
2137        p = Parser(featurefile, glyphNames, followIncludes=followIncludes)
2138        return p.parse()
2139
2140    @staticmethod
2141    def getpath(testfile):
2142        path, _ = os.path.split(__file__)
2143        return os.path.join(path, "data", testfile)
2144
2145
2146class SymbolTableTest(unittest.TestCase):
2147    def test_scopes(self):
2148        symtab = SymbolTable()
2149        symtab.define("foo", 23)
2150        self.assertEqual(symtab.resolve("foo"), 23)
2151        symtab.enter_scope()
2152        self.assertEqual(symtab.resolve("foo"), 23)
2153        symtab.define("foo", 42)
2154        self.assertEqual(symtab.resolve("foo"), 42)
2155        symtab.exit_scope()
2156        self.assertEqual(symtab.resolve("foo"), 23)
2157
2158    def test_resolve_undefined(self):
2159        self.assertEqual(SymbolTable().resolve("abc"), None)
2160
2161
2162if __name__ == "__main__":
2163    import sys
2164
2165    sys.exit(unittest.main())
2166