xref: /netbsd-src/external/mpl/bind/dist/doc/misc/checkgrammar.py (revision 8aaca124c0ad52af9550477f296b63debc7b4c98)
1############################################################################
2# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
3#
4# SPDX-License-Identifier: MPL-2.0
5#
6# This Source Code Form is subject to the terms of the Mozilla Public
7# License, v. 2.0. If a copy of the MPL was not distributed with this
8# file, you can obtain one at https://mozilla.org/MPL/2.0/.
9#
10# See the COPYRIGHT file distributed with this work for additional
11# information regarding copyright ownership.
12############################################################################
13
14"""
15Utility to check ISC config grammar consistency. It detects statement names
16which use different grammar depending on position in the configuration file.
17E.g. "max-zone-ttl" in dnssec-policy uses '<duration>'
18vs. '( unlimited | <duration> ) used in options.
19"""
20
21from collections import namedtuple
22from itertools import groupby
23import fileinput
24
25import parsegrammar
26
27
28def statement2block(grammar, path):
29    """Return mapping statement name to "path" where it is allowed.
30    _top is placeholder name for the namesless topmost context.
31
32    E.g. {
33        'options: [('_top',)],
34        'server': [('_top', 'view'), ('_top',)],
35        'rate-limit': [('_top', 'options'), ('_top', 'view')],
36        'slip': [('_top', 'options', 'rate-limit'), ('_top', 'view', 'rate-limit')]
37    }
38    """
39    key2place = {}
40
41    for key in grammar:
42        assert not key.startswith("_")
43        key2place.setdefault(key, []).append(tuple(path))
44        if "_mapbody" in grammar[key]:
45            nested2block = statement2block(grammar[key]["_mapbody"], path + [key])
46            # merge to uppermost output dictionary
47            for nested_key, nested_path in nested2block.items():
48                key2place.setdefault(nested_key, []).extend(nested_path)
49    return key2place
50
51
52def get_statement_grammar(grammar, path, name):
53    """Descend into grammar dict using provided path
54    and return final dict found there.
55
56    Intermediate steps into "_mapbody" subkeys are done automatically.
57    """
58    assert path[0] == "_top"
59    path = list(path) + [name]
60    for step in path[1:]:
61        if "_mapbody" in grammar:
62            grammar = grammar["_mapbody"]
63        grammar = grammar[step]
64    return grammar
65
66
67Statement = namedtuple("Statement", ["path", "name", "subgrammar"])
68
69
70def groupby_grammar(statements):
71    """
72    Return groups of Statement tuples with identical grammars and flags.
73    See itertools.groupby.
74    """
75
76    def keyfunc(statement):
77        return sorted(statement.subgrammar.items())
78
79    groups = []
80    statements = sorted(statements, key=keyfunc)
81    for _key, group in groupby(statements, keyfunc):
82        groups.append(list(group))  # Store group iterator as a list
83    return groups
84
85
86def diff_statements(whole_grammar, places):
87    """
88    Return map {statement name: [groups of [Statement]s with identical grammar].
89    """
90    out = {}
91    for statement_name, paths in places.items():
92        grammars = []
93        for path in paths:
94            statement_grammar = get_statement_grammar(
95                whole_grammar, path, statement_name
96            )
97            grammars.append(Statement(path, statement_name, statement_grammar))
98        groups = groupby_grammar(grammars)
99        out[statement_name] = groups
100    return out
101
102
103def pformat_grammar(node, level=1):
104    """Pretty print a given grammar node in the same way as cfg_test would"""
105
106    def sortkey(item):
107        """Treat 'type' specially and always put it first, for zone types"""
108        key, _ = item
109        if key == "type":
110            return ""
111        return key
112
113    if "_grammar" in node:  # no nesting
114        assert "_id" not in node
115        assert "_mapbody" not in node
116        out = node["_grammar"] + ";"
117        if "_flags" in node:
118            out += " // " + ", ".join(node["_flags"])
119        return out + "\n"
120
121    # a nested map
122    out = ""
123    indent = level * "\t"
124    if not node.get("_ignore_this_level"):
125        if "_id" in node:
126            out += node["_id"] + " "
127        out += "{\n"
128
129    for key, subnode in sorted(node["_mapbody"].items(), key=sortkey):
130        if not subnode.get("_ignore_this_level"):
131            out += f"{indent}{subnode.get('_pprint_name', key)}"
132            inner_grammar = pformat_grammar(node["_mapbody"][key], level=level + 1)
133        else:  # act as if we were not in a map
134            inner_grammar = pformat_grammar(node["_mapbody"][key], level=level)
135        if inner_grammar[0] != ";":  # we _did_ find some arguments
136            out += " "
137        out += inner_grammar
138
139    if not node.get("_ignore_this_level"):
140        out += indent[:-1] + "};"  # unindent the closing bracket
141        if "_flags" in node:
142            out += " // " + ", ".join(node["_flags"])
143    return out + "\n"
144
145
146def main():
147    """
148    Ingest output from cfg_test --grammar and print out statements which use
149    different grammar in different contexts.
150    """
151    with fileinput.input() as filein:
152        grammar = parsegrammar.parse_mapbody(filein)
153    places = statement2block(grammar, ["_top"])
154
155    for statementname, groups in diff_statements(grammar, places).items():
156        if len(groups) > 1:
157            print(f'statement "{statementname}" is inconsistent across blocks')
158            for group in groups:
159                print(
160                    "- path:", ", ".join(" -> ".join(variant.path) for variant in group)
161                )
162                print(" ", pformat_grammar(group[0].subgrammar, level=1))
163            print()
164
165
166if __name__ == "__main__":
167    main()
168