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