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