1#!/usr/bin/env python2
2
3"""Find Kconfig symbols that are referenced but not defined."""
4
5# (c) 2014-2015 Valentin Rothberg <Valentin.Rothberg@lip6.fr>
6# (c) 2014 Stefan Hengelein <stefan.hengelein@fau.de>
7#
8# Licensed under the terms of the GNU GPL License version 2
9
10
11import os
12import re
13import sys
14from subprocess import Popen, PIPE, STDOUT
15from optparse import OptionParser
16
17
18# regex expressions
19OPERATORS = r"&|\(|\)|\||\!"
20FEATURE = r"(?:\w*[A-Z0-9]\w*){2,}"
21DEF = r"^\s*(?:menu){,1}config\s+(" + FEATURE + r")\s*"
22EXPR = r"(?:" + OPERATORS + r"|\s|" + FEATURE + r")+"
23STMT = r"^\s*(?:if|select|depends\s+on)\s+" + EXPR
24SOURCE_FEATURE = r"(?:\W|\b)+[D]{,1}CONFIG_(" + FEATURE + r")"
25
26# regex objects
27REGEX_FILE_KCONFIG = re.compile(r".*Kconfig[\.\w+\-]*$")
28REGEX_FEATURE = re.compile(r"(" + FEATURE + r")")
29REGEX_SOURCE_FEATURE = re.compile(SOURCE_FEATURE)
30REGEX_KCONFIG_DEF = re.compile(DEF)
31REGEX_KCONFIG_EXPR = re.compile(EXPR)
32REGEX_KCONFIG_STMT = re.compile(STMT)
33REGEX_KCONFIG_HELP = re.compile(r"^\s+(help|---help---)\s*$")
34REGEX_FILTER_FEATURES = re.compile(r"[A-Za-z0-9]$")
35
36
37def parse_options():
38    """The user interface of this module."""
39    usage = "%prog [options]\n\n"                                              \
40            "Run this tool to detect Kconfig symbols that are referenced but " \
41            "not defined in\nKconfig.  The output of this tool has the "       \
42            "format \'Undefined symbol\\tFile list\'\n\n"                      \
43            "If no option is specified, %prog will default to check your\n"    \
44            "current tree.  Please note that specifying commits will "         \
45            "\'git reset --hard\'\nyour current tree!  You may save "          \
46            "uncommitted changes to avoid losing data."
47
48    parser = OptionParser(usage=usage)
49
50    parser.add_option('-c', '--commit', dest='commit', action='store',
51                      default="",
52                      help="Check if the specified commit (hash) introduces "
53                           "undefined Kconfig symbols.")
54
55    parser.add_option('-d', '--diff', dest='diff', action='store',
56                      default="",
57                      help="Diff undefined symbols between two commits.  The "
58                           "input format bases on Git log's "
59                           "\'commmit1..commit2\'.")
60
61    parser.add_option('-i', '--ignore', dest='ignore', action='store',
62                      default="",
63                      help="Ignore files matching this pattern.  Note that "
64                           "the pattern needs to be a Python regex.  To "
65                           "ignore defconfigs, specify -i '.*defconfig'.")
66
67    parser.add_option('', '--force', dest='force', action='store_true',
68                      default=False,
69                      help="Reset current Git tree even when it's dirty.")
70
71    (opts, _) = parser.parse_args()
72
73    if opts.commit and opts.diff:
74        sys.exit("Please specify only one option at once.")
75
76    if opts.diff and not re.match(r"^[\w\-\.]+\.\.[\w\-\.]+$", opts.diff):
77        sys.exit("Please specify valid input in the following format: "
78                 "\'commmit1..commit2\'")
79
80    if opts.commit or opts.diff:
81        if not opts.force and tree_is_dirty():
82            sys.exit("The current Git tree is dirty (see 'git status').  "
83                     "Running this script may\ndelete important data since it "
84                     "calls 'git reset --hard' for some performance\nreasons. "
85                     " Please run this script in a clean Git tree or pass "
86                     "'--force' if you\nwant to ignore this warning and "
87                     "continue.")
88
89    if opts.ignore:
90        try:
91            re.match(opts.ignore, "this/is/just/a/test.c")
92        except:
93            sys.exit("Please specify a valid Python regex.")
94
95    return opts
96
97
98def main():
99    """Main function of this module."""
100    opts = parse_options()
101
102    if opts.commit or opts.diff:
103        head = get_head()
104
105        # get commit range
106        commit_a = None
107        commit_b = None
108        if opts.commit:
109            commit_a = opts.commit + "~"
110            commit_b = opts.commit
111        elif opts.diff:
112            split = opts.diff.split("..")
113            commit_a = split[0]
114            commit_b = split[1]
115            undefined_a = {}
116            undefined_b = {}
117
118        # get undefined items before the commit
119        execute("git reset --hard %s" % commit_a)
120        undefined_a = check_symbols(opts.ignore)
121
122        # get undefined items for the commit
123        execute("git reset --hard %s" % commit_b)
124        undefined_b = check_symbols(opts.ignore)
125
126        # report cases that are present for the commit but not before
127        for feature in sorted(undefined_b):
128            # feature has not been undefined before
129            if not feature in undefined_a:
130                files = sorted(undefined_b.get(feature))
131                print "%s\t%s" % (feature, ", ".join(files))
132            # check if there are new files that reference the undefined feature
133            else:
134                files = sorted(undefined_b.get(feature) -
135                               undefined_a.get(feature))
136                if files:
137                    print "%s\t%s" % (feature, ", ".join(files))
138
139        # reset to head
140        execute("git reset --hard %s" % head)
141
142    # default to check the entire tree
143    else:
144        undefined = check_symbols(opts.ignore)
145        for feature in sorted(undefined):
146            files = sorted(undefined.get(feature))
147            print "%s\t%s" % (feature, ", ".join(files))
148
149
150def execute(cmd):
151    """Execute %cmd and return stdout.  Exit in case of error."""
152    pop = Popen(cmd, stdout=PIPE, stderr=STDOUT, shell=True)
153    (stdout, _) = pop.communicate()  # wait until finished
154    if pop.returncode != 0:
155        sys.exit(stdout)
156    return stdout
157
158
159def tree_is_dirty():
160    """Return true if the current working tree is dirty (i.e., if any file has
161    been added, deleted, modified, renamed or copied but not committed)."""
162    stdout = execute("git status --porcelain")
163    for line in stdout:
164        if re.findall(r"[URMADC]{1}", line[:2]):
165            return True
166    return False
167
168
169def get_head():
170    """Return commit hash of current HEAD."""
171    stdout = execute("git rev-parse HEAD")
172    return stdout.strip('\n')
173
174
175def check_symbols(ignore):
176    """Find undefined Kconfig symbols and return a dict with the symbol as key
177    and a list of referencing files as value.  Files matching %ignore are not
178    checked for undefined symbols."""
179    source_files = []
180    kconfig_files = []
181    defined_features = set()
182    referenced_features = dict()  # {feature: [files]}
183
184    # use 'git ls-files' to get the worklist
185    stdout = execute("git ls-files")
186    if len(stdout) > 0 and stdout[-1] == "\n":
187        stdout = stdout[:-1]
188
189    for gitfile in stdout.rsplit("\n"):
190        if ".git" in gitfile or "ChangeLog" in gitfile or      \
191                ".log" in gitfile or os.path.isdir(gitfile) or \
192                gitfile.startswith("tools/"):
193            continue
194        if REGEX_FILE_KCONFIG.match(gitfile):
195            kconfig_files.append(gitfile)
196        else:
197            # all non-Kconfig files are checked for consistency
198            source_files.append(gitfile)
199
200    for sfile in source_files:
201        if ignore and re.match(ignore, sfile):
202            # do not check files matching %ignore
203            continue
204        parse_source_file(sfile, referenced_features)
205
206    for kfile in kconfig_files:
207        if ignore and re.match(ignore, kfile):
208            # do not collect references for files matching %ignore
209            parse_kconfig_file(kfile, defined_features, dict())
210        else:
211            parse_kconfig_file(kfile, defined_features, referenced_features)
212
213    undefined = {}  # {feature: [files]}
214    for feature in sorted(referenced_features):
215        # filter some false positives
216        if feature == "FOO" or feature == "BAR" or \
217                feature == "FOO_BAR" or feature == "XXX":
218            continue
219        if feature not in defined_features:
220            if feature.endswith("_MODULE"):
221                # avoid false positives for kernel modules
222                if feature[:-len("_MODULE")] in defined_features:
223                    continue
224            undefined[feature] = referenced_features.get(feature)
225    return undefined
226
227
228def parse_source_file(sfile, referenced_features):
229    """Parse @sfile for referenced Kconfig features."""
230    lines = []
231    with open(sfile, "r") as stream:
232        lines = stream.readlines()
233
234    for line in lines:
235        if not "CONFIG_" in line:
236            continue
237        features = REGEX_SOURCE_FEATURE.findall(line)
238        for feature in features:
239            if not REGEX_FILTER_FEATURES.search(feature):
240                continue
241            sfiles = referenced_features.get(feature, set())
242            sfiles.add(sfile)
243            referenced_features[feature] = sfiles
244
245
246def get_features_in_line(line):
247    """Return mentioned Kconfig features in @line."""
248    return REGEX_FEATURE.findall(line)
249
250
251def parse_kconfig_file(kfile, defined_features, referenced_features):
252    """Parse @kfile and update feature definitions and references."""
253    lines = []
254    skip = False
255
256    with open(kfile, "r") as stream:
257        lines = stream.readlines()
258
259    for i in range(len(lines)):
260        line = lines[i]
261        line = line.strip('\n')
262        line = line.split("#")[0]  # ignore comments
263
264        if REGEX_KCONFIG_DEF.match(line):
265            feature_def = REGEX_KCONFIG_DEF.findall(line)
266            defined_features.add(feature_def[0])
267            skip = False
268        elif REGEX_KCONFIG_HELP.match(line):
269            skip = True
270        elif skip:
271            # ignore content of help messages
272            pass
273        elif REGEX_KCONFIG_STMT.match(line):
274            features = get_features_in_line(line)
275            # multi-line statements
276            while line.endswith("\\"):
277                i += 1
278                line = lines[i]
279                line = line.strip('\n')
280                features.extend(get_features_in_line(line))
281            for feature in set(features):
282                paths = referenced_features.get(feature, set())
283                paths.add(kfile)
284                referenced_features[feature] = paths
285
286
287if __name__ == "__main__":
288    main()
289