1from fontTools.feaLib.error import FeatureLibError, IncludedFeaNotFound 2from fontTools.feaLib.lexer import IncludingLexer, Lexer 3from fontTools.misc.textTools import tobytes 4from io import StringIO 5import os 6import shutil 7import tempfile 8import unittest 9 10 11def lex(s): 12 return [(typ, tok) for (typ, tok, _) in Lexer(s, "test.fea")] 13 14 15class LexerTest(unittest.TestCase): 16 def __init__(self, methodName): 17 unittest.TestCase.__init__(self, methodName) 18 # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, 19 # and fires deprecation warnings if a program uses the old name. 20 if not hasattr(self, "assertRaisesRegex"): 21 self.assertRaisesRegex = self.assertRaisesRegexp 22 23 def test_empty(self): 24 self.assertEqual(lex(""), []) 25 self.assertEqual(lex(" \t "), []) 26 27 def test_name(self): 28 self.assertEqual(lex("a17"), [(Lexer.NAME, "a17")]) 29 self.assertEqual(lex(".notdef"), [(Lexer.NAME, ".notdef")]) 30 self.assertEqual(lex("two.oldstyle"), [(Lexer.NAME, "two.oldstyle")]) 31 self.assertEqual(lex("_"), [(Lexer.NAME, "_")]) 32 self.assertEqual(lex("\\table"), [(Lexer.NAME, "\\table")]) 33 self.assertEqual(lex("a+*:^~!"), [(Lexer.NAME, "a+*:^~!")]) 34 self.assertEqual(lex("with-dash"), [(Lexer.NAME, "with-dash")]) 35 36 def test_cid(self): 37 self.assertEqual(lex("\\0 \\987"), [(Lexer.CID, 0), (Lexer.CID, 987)]) 38 39 def test_glyphclass(self): 40 self.assertEqual(lex("@Vowel.sc"), [(Lexer.GLYPHCLASS, "Vowel.sc")]) 41 self.assertEqual(lex("@Vowel-sc"), [(Lexer.GLYPHCLASS, "Vowel-sc")]) 42 self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@(a)") 43 self.assertRaisesRegex(FeatureLibError, "Expected glyph class", lex, "@ A") 44 self.assertEqual(lex("@" + ("A" * 600)), [(Lexer.GLYPHCLASS, "A" * 600)]) 45 self.assertRaisesRegex( 46 FeatureLibError, "Glyph class names must consist of", lex, "@Ab:c" 47 ) 48 49 def test_include(self): 50 self.assertEqual( 51 lex("include (~/foo/bar baz.fea);"), 52 [ 53 (Lexer.NAME, "include"), 54 (Lexer.FILENAME, "~/foo/bar baz.fea"), 55 (Lexer.SYMBOL, ";"), 56 ], 57 ) 58 self.assertEqual( 59 lex("include # Comment\n (foo) \n;"), 60 [ 61 (Lexer.NAME, "include"), 62 (Lexer.COMMENT, "# Comment"), 63 (Lexer.FILENAME, "foo"), 64 (Lexer.SYMBOL, ";"), 65 ], 66 ) 67 self.assertRaises(FeatureLibError, lex, "include blah") 68 self.assertRaises(FeatureLibError, lex, "include (blah") 69 70 def test_number(self): 71 self.assertEqual(lex("123 -456"), [(Lexer.NUMBER, 123), (Lexer.NUMBER, -456)]) 72 self.assertEqual(lex("0xCAFED00D"), [(Lexer.HEXADECIMAL, 0xCAFED00D)]) 73 self.assertEqual(lex("0xcafed00d"), [(Lexer.HEXADECIMAL, 0xCAFED00D)]) 74 self.assertEqual(lex("010"), [(Lexer.OCTAL, 0o10)]) 75 76 def test_float(self): 77 self.assertEqual(lex("1.23 -4.5"), [(Lexer.FLOAT, 1.23), (Lexer.FLOAT, -4.5)]) 78 79 def test_symbol(self): 80 self.assertEqual(lex("a'"), [(Lexer.NAME, "a"), (Lexer.SYMBOL, "'")]) 81 self.assertEqual(lex("-A-B"), [(Lexer.SYMBOL, "-"), (Lexer.NAME, "A-B")]) 82 self.assertEqual( 83 lex("foo - -2"), 84 [(Lexer.NAME, "foo"), (Lexer.SYMBOL, "-"), (Lexer.NUMBER, -2)], 85 ) 86 87 def test_comment(self): 88 self.assertEqual( 89 lex("# Comment\n#"), [(Lexer.COMMENT, "# Comment"), (Lexer.COMMENT, "#")] 90 ) 91 92 def test_string(self): 93 self.assertEqual( 94 lex('"foo" "bar"'), [(Lexer.STRING, "foo"), (Lexer.STRING, "bar")] 95 ) 96 self.assertEqual( 97 lex('"foo \nbar\r baz \r\nqux\n\n "'), [(Lexer.STRING, "foo bar baz qux ")] 98 ) 99 # The lexer should preserve escape sequences because they have 100 # different interpretations depending on context. For better 101 # or for worse, that is how the OpenType Feature File Syntax 102 # has been specified; see section 9.e (name table) for examples. 103 self.assertEqual( 104 lex(r'"M\00fcller-Lanc\00e9"'), # 'nameid 9' 105 [(Lexer.STRING, r"M\00fcller-Lanc\00e9")], 106 ) 107 self.assertEqual( 108 lex(r'"M\9fller-Lanc\8e"'), # 'nameid 9 1' 109 [(Lexer.STRING, r"M\9fller-Lanc\8e")], 110 ) 111 self.assertRaises(FeatureLibError, lex, '"foo\n bar') 112 113 def test_bad_character(self): 114 self.assertRaises(FeatureLibError, lambda: lex("123 \u0001")) 115 116 def test_newline(self): 117 def lines(s): 118 return [loc.line for (_, _, loc) in Lexer(s, "test.fea")] 119 120 self.assertEqual(lines("FOO\n\nBAR\nBAZ"), [1, 3, 4]) # Unix 121 self.assertEqual(lines("FOO\r\rBAR\rBAZ"), [1, 3, 4]) # Macintosh 122 self.assertEqual(lines("FOO\r\n\r\n BAR\r\nBAZ"), [1, 3, 4]) # Windows 123 self.assertEqual(lines("FOO\n\rBAR\r\nBAZ"), [1, 3, 4]) # mixed 124 125 def test_location(self): 126 def locs(s): 127 return [str(loc) for (_, _, loc) in Lexer(s, "test.fea")] 128 129 self.assertEqual( 130 locs("a b # Comment\n12 @x"), 131 [ 132 "test.fea:1:1", 133 "test.fea:1:3", 134 "test.fea:1:5", 135 "test.fea:2:1", 136 "test.fea:2:4", 137 ], 138 ) 139 140 def test_scan_over_(self): 141 lexer = Lexer("abbacabba12", "test.fea") 142 self.assertEqual(lexer.pos_, 0) 143 lexer.scan_over_("xyz") 144 self.assertEqual(lexer.pos_, 0) 145 lexer.scan_over_("abc") 146 self.assertEqual(lexer.pos_, 9) 147 lexer.scan_over_("abc") 148 self.assertEqual(lexer.pos_, 9) 149 lexer.scan_over_("0123456789") 150 self.assertEqual(lexer.pos_, 11) 151 152 def test_scan_until_(self): 153 lexer = Lexer("foo'bar", "test.fea") 154 self.assertEqual(lexer.pos_, 0) 155 lexer.scan_until_("'") 156 self.assertEqual(lexer.pos_, 3) 157 lexer.scan_until_("'") 158 self.assertEqual(lexer.pos_, 3) 159 160 161class IncludingLexerTest(unittest.TestCase): 162 @staticmethod 163 def getpath(filename): 164 path, _ = os.path.split(__file__) 165 return os.path.join(path, "data", filename) 166 167 def test_include(self): 168 lexer = IncludingLexer(self.getpath("include/include4.fea")) 169 result = [ 170 "%s %s:%d" % (token, os.path.split(loc.file)[1], loc.line) 171 for _, token, loc in lexer 172 ] 173 self.assertEqual( 174 result, 175 [ 176 "I4a include4.fea:1", 177 "I3a include3.fea:1", 178 "I2a include2.fea:1", 179 "I1a include1.fea:1", 180 "I0 include0.fea:1", 181 "I1b include1.fea:3", 182 "; include2.fea:2", 183 "I2b include2.fea:3", 184 "; include3.fea:2", 185 "I3b include3.fea:3", 186 "; include4.fea:2", 187 "I4b include4.fea:3", 188 ], 189 ) 190 191 def test_include_limit(self): 192 lexer = IncludingLexer(self.getpath("include/include6.fea")) 193 self.assertRaises(FeatureLibError, lambda: list(lexer)) 194 195 def test_include_self(self): 196 lexer = IncludingLexer(self.getpath("include/includeself.fea")) 197 self.assertRaises(FeatureLibError, lambda: list(lexer)) 198 199 def test_include_missing_file(self): 200 lexer = IncludingLexer(self.getpath("include/includemissingfile.fea")) 201 self.assertRaisesRegex( 202 IncludedFeaNotFound, 203 "includemissingfile.fea:1:8: The following feature file " 204 "should be included but cannot be found: " 205 "missingfile.fea", 206 lambda: list(lexer), 207 ) 208 209 def test_featurefilepath_None(self): 210 lexer = IncludingLexer(StringIO("# foobar")) 211 self.assertIsNone(lexer.featurefilepath) 212 files = set(loc.file for _, _, loc in lexer) 213 self.assertIn("<features>", files) 214 215 def test_include_absolute_path(self): 216 with tempfile.NamedTemporaryFile(delete=False) as included: 217 included.write( 218 tobytes( 219 """ 220 feature kern { 221 pos A B -40; 222 } kern; 223 """, 224 encoding="utf-8", 225 ) 226 ) 227 including = StringIO("include(%s);" % included.name) 228 try: 229 lexer = IncludingLexer(including) 230 files = set(loc.file for _, _, loc in lexer) 231 self.assertIn(included.name, files) 232 finally: 233 os.remove(included.name) 234 235 def test_include_relative_to_cwd(self): 236 # save current working directory, to be restored later 237 cwd = os.getcwd() 238 tmpdir = tempfile.mkdtemp() 239 try: 240 # create new feature file in a temporary directory 241 with open( 242 os.path.join(tmpdir, "included.fea"), "w", encoding="utf-8" 243 ) as included: 244 included.write( 245 """ 246 feature kern { 247 pos A B -40; 248 } kern; 249 """ 250 ) 251 # change current folder to the temporary dir 252 os.chdir(tmpdir) 253 # instantiate a new lexer that includes the above file 254 # using a relative path; the IncludingLexer does not 255 # itself have a path, because it was initialized from 256 # an in-memory stream, so it will use the current working 257 # directory to resolve relative include statements 258 lexer = IncludingLexer(StringIO("include(included.fea);")) 259 files = set(os.path.realpath(loc.file) for _, _, loc in lexer) 260 expected = os.path.realpath(included.name) 261 self.assertIn(expected, files) 262 finally: 263 # remove temporary folder and restore previous working directory 264 os.chdir(cwd) 265 shutil.rmtree(tmpdir) 266 267 268if __name__ == "__main__": 269 import sys 270 271 sys.exit(unittest.main()) 272