Skip to content

Commit ecbb8fe

Browse files
committed
Fix false unused terminal warnings
1 parent 9a4fb9c commit ecbb8fe

3 files changed

Lines changed: 80 additions & 8 deletions

File tree

lark/load_grammar.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -681,13 +681,22 @@ class Grammar(Serialize):
681681
term_defs: List[Tuple[str, Tuple[Tree, int]]]
682682
rule_defs: List[Tuple[str, Tuple[str, ...], Tree, RuleOptions]]
683683
ignore: List[str]
684+
term_references: Dict[str, List[str]]
684685

685-
def __init__(self, rule_defs: List[Tuple[str, Tuple[str, ...], Tree, RuleOptions]], term_defs: List[Tuple[str, Tuple[Tree, int]]], ignore: List[str]) -> None:
686+
def __init__(self, rule_defs: List[Tuple[str, Tuple[str, ...], Tree, RuleOptions]], term_defs: List[Tuple[str, Tuple[Tree, int]]], ignore: List[str], term_references: Optional[Dict[str, List[str]]] = None) -> None:
686687
self.term_defs = term_defs
687688
self.rule_defs = rule_defs
688689
self.ignore = ignore
690+
self.term_references = term_references or {}
689691

690-
__serialize_fields__ = 'term_defs', 'rule_defs', 'ignore'
692+
__serialize_fields__ = 'term_defs', 'rule_defs', 'ignore', 'term_references'
693+
694+
@classmethod
695+
def deserialize(cls, data, memo):
696+
if 'term_references' not in data:
697+
data = dict(data)
698+
data['term_references'] = {}
699+
return super().deserialize(data, memo)
691700

692701
def compile(self, start, terminals_to_keep) -> Tuple[List[TerminalDef], List[Rule], List[str]]:
693702
# We change the trees in-place (to support huge grammars)
@@ -810,9 +819,12 @@ def compile(self, start, terminals_to_keep) -> Tuple[List[TerminalDef], List[Rul
810819
used_terms = {t.name for r in compiled_rules
811820
for t in r.expansion
812821
if isinstance(t, Terminal)}
822+
kept_terms = used_terms | set(self.ignore) | set(terminals_to_keep)
823+
reachable_terms = set(bfs(kept_terms, lambda name: self.term_references.get(name, ())))
813824
terminals, unused = classify_bool(terminals, lambda t: t.name in used_terms or t.name in self.ignore or t.name in terminals_to_keep)
814-
if unused:
815-
logger.debug("Unused terminals: %s", [t.name for t in unused])
825+
unused_names = [t.name for t in unused if t.name not in reachable_terms]
826+
if unused_names:
827+
logger.debug("Unused terminals: %s", unused_names)
816828

817829
return terminals, compiled_rules, self.ignore
818830

@@ -1084,6 +1096,7 @@ def __init__(self, is_term, tree, params=(), options=None):
10841096
self.tree = tree
10851097
self.params = tuple(params)
10861098
self.options = options
1099+
self.term_references = _find_used_symbols(tree) if is_term and tree is not None else set()
10871100

10881101
class GrammarBuilder:
10891102

@@ -1155,6 +1168,8 @@ def _extend(self, name, is_term, exp, params=(), options=None):
11551168

11561169
# TODO: think about what to do with 'options'
11571170
base = d.tree
1171+
if is_term:
1172+
d.term_references.update(_find_used_symbols(exp))
11581173

11591174
assert isinstance(base, Tree) and base.data == 'expansions'
11601175
base.children.insert(0, exp)
@@ -1380,15 +1395,17 @@ def build(self) -> Grammar:
13801395
self.validate()
13811396
rule_defs = []
13821397
term_defs = []
1398+
term_references = {}
13831399
for name, d in self._definitions.items():
13841400
(params, exp, options) = d.params, d.tree, d.options
13851401
if d.is_term:
13861402
assert len(params) == 0
13871403
term_defs.append((name, (exp, options)))
1404+
term_references[name] = sorted(d.term_references)
13881405
else:
13891406
rule_defs.append((name, params, exp, options))
13901407
# resolve_term_references(term_defs)
1391-
return Grammar(rule_defs, term_defs, self._ignore_names)
1408+
return Grammar(rule_defs, term_defs, self._ignore_names, term_references)
13921409

13931410

13941411
def verify_used_files(file_hashes):

tests/test_cache.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import absolute_import
22

33
import logging
4+
import pickle
45
from unittest import TestCase, main, skipIf
56

67
from lark import Lark, Tree, Transformer, UnexpectedInput
@@ -70,6 +71,11 @@ def append_zero(t):
7071

7172
class TestCache(TestCase):
7273
g = '''start: "a"'''
74+
g_with_terminal_reference = '''
75+
start: A
76+
A: B
77+
B: "a"
78+
'''
7379

7480

7581
def setUp(self):
@@ -194,9 +200,9 @@ def test_cache_grammar(self):
194200
Lark(self.g, parser='lalr', cache=False, cache_grammar=True)
195201

196202
assert len(self.mock_fs.files) == 0
197-
parser1 = Lark(self.g, parser='lalr', cache=True, cache_grammar=True)
198-
parser2 = Lark(self.g, parser='lalr', cache=True, cache_grammar=True)
199-
assert parser2.parse('a') == Tree('start', [])
203+
parser1 = Lark(self.g_with_terminal_reference, parser='lalr', cache=True, cache_grammar=True)
204+
parser2 = Lark(self.g_with_terminal_reference, parser='lalr', cache=True, cache_grammar=True)
205+
assert parser2.parse('a') == Tree('start', ['a'])
200206

201207
# Assert that the cache file was created, and uses a different name than regular cache
202208
assert len(self.mock_fs.files) == 1
@@ -207,6 +213,23 @@ def test_cache_grammar(self):
207213
assert parser1.grammar.term_defs == parser2.grammar.term_defs
208214
# Using repr() because RuleOptions doesn't implement __eq__
209215
assert repr(parser1.grammar.rule_defs) == repr(parser2.grammar.rule_defs)
216+
assert parser1.grammar.term_references == parser2.grammar.term_references
217+
assert parser2.grammar.term_references == {'A': ['B'], 'B': []}
218+
219+
def test_cache_grammar_backward_compatibility(self):
220+
parser = Lark(self.g_with_terminal_reference, parser='lalr', cache=True, cache_grammar=True)
221+
serialized = BytesIO()
222+
parser.save(serialized)
223+
payload = pickle.loads(serialized.getvalue())
224+
del payload['data']['grammar']['term_references']
225+
226+
serialized = BytesIO()
227+
pickle.dump(payload, serialized)
228+
serialized.seek(0)
229+
loaded = Lark.load(serialized)
230+
231+
assert loaded.parse('a') == Tree('start', ['a'])
232+
assert loaded.grammar.term_references == {}
210233

211234
def test_reconstruct(self):
212235
# Test that Reconstructor works with cached parsers (using cache_grammar)

tests/test_grammar.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,38 @@ def test_lexer_callbacks_with_keyword_terminals(self):
5555
list(p.lex("abc 456"))
5656
assert sorted(set(fired)) == ['A', 'B']
5757

58+
def test_terminal_references_are_not_reported_as_unused(self):
59+
grammar = r"""
60+
start: IDENTIFIER
61+
_IDENT_LETTER: "A".."Z"
62+
DECIMAL_DIGIT: "0".."9"
63+
ALPHANUMERIC: _IDENT_LETTER | DECIMAL_DIGIT
64+
IDENTIFIER: _IDENT_LETTER ALPHANUMERIC+
65+
UNUSED_FRAGMENT: "unused"
66+
UNUSED: UNUSED_FRAGMENT
67+
"""
68+
69+
with self.assertLogs("lark", level="DEBUG") as logs:
70+
parser = Lark(grammar, parser="lalr")
71+
72+
self.assertEqual(logs.output, ["DEBUG:lark:Unused terminals: ['UNUSED_FRAGMENT', 'UNUSED']"])
73+
self.assertEqual([terminal.name for terminal in parser.terminals], ['IDENTIFIER'])
74+
self.assertEqual(parser.parse("ANSWER42"), Tree('start', ['ANSWER42']))
75+
76+
def test_overridden_terminal_does_not_keep_imported_references(self):
77+
grammar = r"""
78+
%import common (CNAME, LETTER, DIGIT)
79+
%override CNAME: "x"
80+
start: CNAME
81+
"""
82+
83+
with self.assertLogs("lark", level="DEBUG") as logs:
84+
parser = Lark(grammar, parser="lalr")
85+
86+
self.assertEqual(len(logs.records), 1)
87+
self.assertEqual(set(logs.records[0].args[0]), {'DIGIT', 'LETTER'})
88+
self.assertEqual(parser.parse("x"), Tree('start', ['x']))
89+
5890

5991
def test_override_rule(self):
6092
# Overrides the 'sep' template in existing grammar to add an optional terminating delimiter

0 commit comments

Comments
 (0)