Skip to content

Commit 6409262

Browse files
committed
gh-153568: Make FIRST-set dispatch conservative
1 parent 456eb6f commit 6409262

5 files changed

Lines changed: 815 additions & 3489 deletions

File tree

Lib/test/test_peg_generator/test_c_parser.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,100 @@ def test_prefix_respects_cut(self) -> None:
174174
)
175175
""")
176176

177+
def test_first_set_compound_negative_lookahead(self) -> None:
178+
self.run_test("""
179+
start: choice NEWLINE ENDMARKER
180+
choice: !('a' 'b') ('a' | 'c') | 'd'
181+
""", """
182+
self.check_input_strings_for_grammar(
183+
valid_cases=['a', 'c', 'd'], invalid_cases=['a b'],
184+
)
185+
""")
186+
187+
def test_first_set_mutual_recursion(self) -> None:
188+
self.run_test("""
189+
start: a NEWLINE ENDMARKER
190+
a: b 'x' | 'a'
191+
b: a 'y' | 'b'
192+
""", """
193+
self.check_input_strings_for_grammar(
194+
valid_cases=['a', 'b x', 'a y x', 'b x y x'],
195+
invalid_cases=['a y', 'b'],
196+
)
197+
""")
198+
199+
def test_first_set_preserves_leading_cut(self) -> None:
200+
self.run_test("""
201+
start: choice NEWLINE ENDMARKER
202+
choice: ~ NAME | NUMBER
203+
""", """
204+
self.check_input_strings_for_grammar(
205+
valid_cases=['name'], invalid_cases=['42'],
206+
)
207+
""")
208+
209+
def test_first_set_preserves_forced_parse(self) -> None:
210+
self.run_test("""
211+
start: choice NEWLINE ENDMARKER
212+
choice: &&'+' | NUMBER
213+
""", """
214+
self.check_input_strings_for_grammar(
215+
valid_cases=['+'], invalid_cases=['42'],
216+
)
217+
""")
218+
219+
def test_first_set_nullable_prefix_action(self) -> None:
220+
self.run_test("""
221+
start: choice NEWLINE ENDMARKER
222+
choice: guard NAME | NUMBER
223+
guard: &NUMBER { RAISE_SYNTAX_ERROR("guard reached") }
224+
""", """
225+
with self.assertRaisesRegex(SyntaxError, 'guard reached'):
226+
parse.parse_string('42', mode=0)
227+
""")
228+
229+
def check_first_set_lookahead_forced_parse(self, predicate: str) -> None:
230+
self.run_test(f"""
231+
start: choice NEWLINE ENDMARKER
232+
choice: {predicate}(NAME &&'+') STRING | NAME
233+
""", """
234+
with self.assertRaises(SyntaxError):
235+
parse.parse_string('name', mode=0)
236+
""")
237+
238+
def test_first_set_positive_lookahead_forced_parse(self) -> None:
239+
self.check_first_set_lookahead_forced_parse('&')
240+
241+
def test_first_set_negative_lookahead_forced_parse(self) -> None:
242+
self.check_first_set_lookahead_forced_parse('!')
243+
244+
def check_first_set_lookahead_action(self, predicate: str) -> None:
245+
self.run_test(f"""
246+
start: choice NEWLINE ENDMARKER
247+
choice: {predicate}guard NAME | NUMBER
248+
guard: NUMBER {{ RAISE_SYNTAX_ERROR("guard reached") }}
249+
""", """
250+
with self.assertRaisesRegex(SyntaxError, 'guard reached'):
251+
parse.parse_string('42', mode=0)
252+
""")
253+
254+
def test_first_set_positive_lookahead_action(self) -> None:
255+
self.check_first_set_lookahead_action('&')
256+
257+
def test_first_set_negative_lookahead_action(self) -> None:
258+
self.check_first_set_lookahead_action('!')
259+
260+
def test_first_set_named_soft_keyword(self) -> None:
261+
self.run_test('''
262+
start: choice NEWLINE ENDMARKER
263+
choice: SOFT_KEYWORD ':' | NUMBER
264+
spelling: "soft"
265+
''', """
266+
self.check_input_strings_for_grammar(
267+
valid_cases=['soft :', '42'], invalid_cases=['other :'],
268+
)
269+
""")
270+
177271
def test_c_parser(self) -> None:
178272
grammar_source = """
179273
start[mod_ty]: a[asdl_stmt_seq*]=stmt* $ { _PyAST_Module(a, NULL, p->arena) }

Lib/test/test_peg_generator/test_first_sets.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import unittest
22

33
from test import test_tools
4-
from typing import Dict, Set
54

65
test_tools.skip_if_missing("peg_generator")
76
with test_tools.imports_under_tool("peg_generator"):
@@ -12,7 +11,7 @@
1211

1312

1413
class TestFirstSets(unittest.TestCase):
15-
def calculate_first_sets(self, grammar_source: str) -> Dict[str, Set[str]]:
14+
def calculate_first_sets(self, grammar_source: str) -> dict[str, set[str | None]]:
1615
grammar: Grammar = parse_string(grammar_source, GrammarParser)
1716
return FirstSetCalculator(grammar.rules).calculate()
1817

@@ -164,8 +163,8 @@ def test_positive_lookahead(self) -> None:
164163
self.assertEqual(
165164
self.calculate_first_sets(grammar),
166165
{
167-
"expr": {"'a'"},
168-
"start": {"'a'"},
166+
"expr": {None, "'a'", "'b'", "'c'"},
167+
"start": {None, "'a'", "'b'", "'c'"},
169168
"opt": {"'b'", "'c'", "'a'"},
170169
},
171170
)
@@ -180,8 +179,8 @@ def test_negative_lookahead(self) -> None:
180179
self.calculate_first_sets(grammar),
181180
{
182181
"opt": {"'b'", "'a'", "'c'"},
183-
"expr": {"'b'", "'c'"},
184-
"start": {"'b'", "'c'"},
182+
"expr": {None, "'a'", "'b'", "'c'"},
183+
"start": {None, "'a'", "'b'", "'c'"},
185184
},
186185
)
187186

@@ -226,21 +225,20 @@ def test_mutual_left_recursion(self) -> None:
226225
self.calculate_first_sets(grammar),
227226
{
228227
"foo": {"'D'", "'B'"},
229-
"bar": {"'D'"},
228+
"bar": {"'B'", "'D'"},
230229
"start": {"'D'", "'B'"},
231230
},
232231
)
233232

234233
def test_nasty_left_recursion(self) -> None:
235-
# TODO: Validate this
236234
grammar = """
237235
start: target '='
238236
target: maybe '+' | NAME
239237
maybe: maybe '-' | target
240238
"""
241239
self.assertEqual(
242240
self.calculate_first_sets(grammar),
243-
{"maybe": set(), "target": {"NAME"}, "start": {"NAME"}},
241+
{"maybe": {"NAME"}, "target": {"NAME"}, "start": {"NAME"}},
244242
)
245243

246244
def test_nullable_rule(self) -> None:
@@ -284,3 +282,43 @@ def test_multiple_nullable_rules(self) -> None:
284282
"another": {"'/'"},
285283
},
286284
)
285+
286+
def test_compound_negative_lookahead(self) -> None:
287+
sets = self.calculate_first_sets("""
288+
start: choice NEWLINE ENDMARKER
289+
choice: !('a' 'b') ('a' | 'c') | 'd'
290+
""")
291+
self.assertEqual(sets['choice'], {None, "'a'", "'c'", "'d'"})
292+
293+
def test_nullable_recursive_rules(self) -> None:
294+
sets = self.calculate_first_sets("""
295+
start: a NUMBER ENDMARKER
296+
a: b | NAME
297+
b: a | ['+']
298+
""")
299+
self.assertEqual(sets['a'], {'', 'NAME', "'+'"})
300+
self.assertEqual(sets['b'], {'', 'NAME', "'+'"})
301+
self.assertEqual(sets['start'], {'NUMBER', 'NAME', "'+'"})
302+
303+
def test_control_flow_before_first_token(self) -> None:
304+
sets = self.calculate_first_sets("""
305+
start: NAME ENDMARKER
306+
cut: ~ NAME
307+
forced: &&'+'
308+
guarded: &forced NUMBER
309+
action: [NAME] { _PyPegen_dummy_name(p) }
310+
""")
311+
self.assertEqual(sets['cut'], {None, 'NAME'})
312+
self.assertEqual(sets['forced'], {None, "'+'"})
313+
self.assertEqual(sets['guarded'], {None, 'NUMBER'})
314+
self.assertEqual(sets['action'], {None, '', 'NAME'})
315+
316+
def test_nullable_repeat_and_gather(self) -> None:
317+
sets = self.calculate_first_sets("""
318+
start: NAME ENDMARKER
319+
optional: [NAME]
320+
repeat: optional+ NUMBER
321+
gather: ','.optional+ NUMBER
322+
""")
323+
self.assertEqual(sets['repeat'], {'NAME', 'NUMBER'})
324+
self.assertEqual(sets['gather'], {'NAME', "','", 'NUMBER'})

0 commit comments

Comments
 (0)