1#!/usr/local/bin/python
2#
3# This script analyzes sys/conf/files*, sys/conf/options*,
4# sys/conf/NOTES, and sys/*/conf/NOTES and checks for inconsistencies
5# such as options or devices that are not specified in any NOTES files
6# or MI devices specified in MD NOTES files.
7#
8# $FreeBSD$
9
10from __future__ import print_function
11
12import glob
13import os.path
14import sys
15
16def usage():
17    print("notescheck <path>", file=sys.stderr)
18    print(file=sys.stderr)
19    print("Where 'path' is a path to a kernel source tree.", file=sys.stderr)
20
21# These files are used to determine if a path is a valid kernel source tree.
22requiredfiles = ['conf/files', 'conf/options', 'conf/NOTES']
23
24# This special platform string is used for managing MI options.
25global_platform = 'global'
26
27# This is a global string that represents the current file and line
28# being parsed.
29location = ""
30
31# Format the contents of a set into a sorted, comma-separated string
32def format_set(set):
33    l = []
34    for item in set:
35        l.append(item)
36    if len(l) == 0:
37        return "(empty)"
38    l.sort()
39    if len(l) == 2:
40        return "%s and %s" % (l[0], l[1])
41    s = "%s" % (l[0])
42    if len(l) == 1:
43        return s
44    for item in l[1:-1]:
45        s = "%s, %s" % (s, item)
46    s = "%s, and %s" % (s, l[-1])
47    return s
48
49# This class actually covers both options and devices.  For each named
50# option we maintain two different lists.  One is the list of
51# platforms that the option was defined in via an options or files
52# file.  The other is the list of platforms that the option was tested
53# in via a NOTES file.  All options are stored as lowercase since
54# config(8) treats the names as case-insensitive.
55class Option:
56    def __init__(self, name):
57        self.name = name
58        self.type = None
59        self.defines = set()
60        self.tests = set()
61
62    def set_type(self, type):
63        if self.type is None:
64            self.type = type
65            self.type_location = location
66        elif self.type != type:
67            print("WARN: Attempt to change type of %s from %s to %s%s" % \
68                (self.name, self.type, type, location))
69            print("      Previous type set%s" % (self.type_location))
70
71    def add_define(self, platform):
72        self.defines.add(platform)
73
74    def add_test(self, platform):
75        self.tests.add(platform)
76
77    def title(self):
78        if self.type == 'option':
79            return 'option %s' % (self.name.upper())
80        if self.type == None:
81            return self.name
82        return '%s %s' % (self.type, self.name)
83
84    def warn(self):
85        # If the defined and tested sets are equal, then this option
86        # is ok.
87        if self.defines == self.tests:
88            return
89
90        # If the tested set contains the global platform, then this
91        # option is ok.
92        if global_platform in self.tests:
93            return
94
95        if global_platform in self.defines:
96            # If the device is defined globally and is never tested, whine.
97            if len(self.tests) == 0:
98                print('WARN: %s is defined globally but never tested' % \
99                    (self.title()))
100                return
101
102            # If the device is defined globally and is tested on
103            # multiple MD platforms, then it is ok.  This often occurs
104            # for drivers that are shared across multiple, but not
105            # all, platforms (e.g. acpi, agp).
106            if len(self.tests) > 1:
107                return
108
109            # If a device is defined globally but is only tested on a
110            # single MD platform, then whine about this.
111            print('WARN: %s is defined globally but only tested in %s NOTES' % \
112                (self.title(), format_set(self.tests)))
113            return
114
115        # If an option or device is never tested, whine.
116        if len(self.tests) == 0:
117            print('WARN: %s is defined in %s but never tested' % \
118                (self.title(), format_set(self.defines)))
119            return
120
121        # The set of MD platforms where this option is defined, but not tested.
122        notest = self.defines - self.tests
123        if len(notest) != 0:
124            print('WARN: %s is not tested in %s NOTES' % \
125                (self.title(), format_set(notest)))
126            return
127
128        print('ERROR: bad state for %s: defined in %s, tested in %s' % \
129            (self.title(), format_set(self.defines), format_set(self.tests)))
130
131# This class maintains a dictionary of options keyed by name.
132class Options:
133    def __init__(self):
134        self.options = {}
135
136    # Look up the object for a given option by name.  If the option
137    # doesn't already exist, then add a new option.
138    def find(self, name):
139        name = name.lower()
140        if name in self.options:
141            return self.options[name]
142        option = Option(name)
143        self.options[name] = option
144        return option
145
146    # Warn about inconsistencies
147    def warn(self):
148        keys = list(self.options.keys())
149        keys.sort()
150        for key in keys:
151            option = self.options[key]
152            option.warn()
153
154# Global map of options
155options = Options()
156
157# Look for MD NOTES files to build our list of platforms.  We ignore
158# platforms that do not have a NOTES file.
159def find_platforms(tree):
160    platforms = []
161    for file in glob.glob(tree + '*/conf/NOTES'):
162        if not file.startswith(tree):
163            print("Bad MD NOTES file %s" %(file), file=sys.stderr)
164            sys.exit(1)
165        platforms.append(file[len(tree):].split('/')[0])
166    if global_platform in platforms:
167        print("Found MD NOTES file for global platform", file=sys.stderr)
168        sys.exit(1)
169    return platforms
170
171# Parse a file that has escaped newlines.  Any escaped newlines are
172# coalesced and each logical line is passed to the callback function.
173# This also skips blank lines and comments.
174def parse_file(file, callback, *args):
175    global location
176
177    f = open(file)
178    current = None
179    i = 0
180    for line in f:
181        # Update parsing location
182        i = i + 1
183        location = ' at %s:%d' % (file, i)
184
185        # Trim the newline
186        line = line[:-1]
187
188        # If the previous line had an escaped newline, append this
189        # line to that.
190        if current is not None:
191            line = current + line
192            current = None
193
194        # If the line ends in a '\', set current to the line (minus
195        # the escape) and continue.
196        if len(line) > 0 and line[-1] == '\\':
197            current = line[:-1]
198            continue
199
200        # Skip blank lines or lines with only whitespace
201        if len(line) == 0 or len(line.split()) == 0:
202            continue
203
204        # Skip comment lines.  Any line whose first non-space
205        # character is a '#' is considered a comment.
206        if line.split()[0][0] == '#':
207            continue
208
209        # Invoke the callback on this line
210        callback(line, *args)
211    if current is not None:
212        callback(current, *args)
213
214    location = ""
215
216# Split a line into words on whitespace with the exception that quoted
217# strings are always treated as a single word.
218def tokenize(line):
219    if len(line) == 0:
220        return []
221
222    # First, split the line on quote characters.
223    groups = line.split('"')
224
225    # Ensure we have an even number of quotes.  The 'groups' array
226    # will contain 'number of quotes' + 1 entries, so it should have
227    # an odd number of entries.
228    if len(groups) % 2 == 0:
229        print("Failed to tokenize: %s%s" (line, location), file=sys.stderr)
230        return []
231
232    # String split all the "odd" groups since they are not quoted strings.
233    quoted = False
234    words = []
235    for group in groups:
236        if quoted:
237            words.append(group)
238            quoted = False
239        else:
240            for word in group.split():
241                words.append(word)
242            quoted = True
243    return words
244
245# Parse a sys/conf/files* file adding defines for any options
246# encountered.  Note files does not differentiate between options and
247# devices.
248def parse_files_line(line, platform):
249    words = tokenize(line)
250
251    # Skip include lines.
252    if words[0] == 'include':
253        return
254
255    # Skip standard lines as they have no devices or options.
256    if words[1] == 'standard':
257        return
258
259    # Remaining lines better be optional or mandatory lines.
260    if words[1] != 'optional' and words[1] != 'mandatory':
261        print("Invalid files line: %s%s" % (line, location), file=sys.stderr)
262
263    # Drop the first two words and begin parsing keywords and devices.
264    skip = False
265    for word in words[2:]:
266        if skip:
267            skip = False
268            continue
269
270        # Skip keywords
271        if word == 'no-obj' or word == 'no-implicit-rule' or \
272                word == 'before-depend' or word == 'local' or \
273                word == 'no-depend' or word == 'profiling-routine' or \
274                word == 'nowerror':
275            continue
276
277        # Skip keywords and their following argument
278        if word == 'dependency' or word == 'clean' or \
279                word == 'compile-with' or word == 'warning':
280            skip = True
281            continue
282
283        # Ignore pipes
284        if word == '|':
285            continue
286
287        option = options.find(word)
288        option.add_define(platform)
289
290# Parse a sys/conf/options* file adding defines for any options
291# encountered.  Unlike a files file, options files only add options.
292def parse_options_line(line, platform):
293    # The first word is the option name.
294    name = line.split()[0]
295
296    # Ignore DEV_xxx options.  These are magic options that are
297    # aliases for 'device xxx'.
298    if name.startswith('DEV_'):
299        return
300
301    option = options.find(name)
302    option.add_define(platform)
303    option.set_type('option')
304
305# Parse a sys/conf/NOTES file adding tests for any options or devices
306# encountered.
307def parse_notes_line(line, platform):
308    words = line.split()
309
310    # Skip lines with just whitespace
311    if len(words) == 0:
312        return
313
314    if words[0] == 'device' or words[0] == 'devices':
315        option = options.find(words[1])
316        option.add_test(platform)
317        option.set_type('device')
318        return
319
320    if words[0] == 'option' or words[0] == 'options':
321        option = options.find(words[1].split('=')[0])
322        option.add_test(platform)
323        option.set_type('option')
324        return
325
326def main(argv=None):
327    if argv is None:
328        argv = sys.argv
329    if len(sys.argv) != 2:
330        usage()
331        return 2
332
333    # Ensure the path has a trailing '/'.
334    tree = sys.argv[1]
335    if tree[-1] != '/':
336        tree = tree + '/'
337    for file in requiredfiles:
338        if not os.path.exists(tree + file):
339            print("Kernel source tree missing %s" % (file), file=sys.stderr)
340            return 1
341
342    platforms = find_platforms(tree)
343
344    # First, parse global files.
345    parse_file(tree + 'conf/files', parse_files_line, global_platform)
346    parse_file(tree + 'conf/options', parse_options_line, global_platform)
347    parse_file(tree + 'conf/NOTES', parse_notes_line, global_platform)
348
349    # Next, parse MD files.
350    for platform in platforms:
351        files_file = tree + 'conf/files.' + platform
352        if os.path.exists(files_file):
353            parse_file(files_file, parse_files_line, platform)
354        options_file = tree + 'conf/options.' + platform
355        if os.path.exists(options_file):
356            parse_file(options_file, parse_options_line, platform)
357        parse_file(tree + platform + '/conf/NOTES', parse_notes_line, platform)
358
359    options.warn()
360    return 0
361
362if __name__ == "__main__":
363    sys.exit(main())
364