1#!/usr/bin/env python3 2 3"""Find Kconfig symbols that are referenced but not defined.""" 4 5# (c) 2014-2016 Valentin Rothberg <[email protected]> 6# (c) 2014 Stefan Hengelein <[email protected]> 7# 8# Licensed under the terms of the GNU GPL License version 2 9 10 11import difflib 12import os 13import re 14import signal 15import subprocess 16import sys 17from multiprocessing import Pool, cpu_count 18from optparse import OptionParser 19from subprocess import Popen, PIPE, STDOUT 20 21 22# regex expressions 23OPERATORS = r"&|\(|\)|\||\!" 24FEATURE = r"(?:\w*[A-Z0-9]\w*){2,}" 25DEF = r"^\s*(?:menu){,1}config\s+(" + FEATURE + r")\s*" 26EXPR = r"(?:" + OPERATORS + r"|\s|" + FEATURE + r")+" 27DEFAULT = r"default\s+.*?(?:if\s.+){,1}" 28STMT = r"^\s*(?:if|select|depends\s+on|(?:" + DEFAULT + r"))\s+" + EXPR 29SOURCE_FEATURE = r"(?:\W|\b)+[D]{,1}CONFIG_(" + FEATURE + r")" 30 31# regex objects 32REGEX_FILE_KCONFIG = re.compile(r".*Kconfig[\.\w+\-]*$") 33REGEX_FEATURE = re.compile(r'(?!\B)' + FEATURE + r'(?!\B)') 34REGEX_SOURCE_FEATURE = re.compile(SOURCE_FEATURE) 35REGEX_KCONFIG_DEF = re.compile(DEF) 36REGEX_KCONFIG_EXPR = re.compile(EXPR) 37REGEX_KCONFIG_STMT = re.compile(STMT) 38REGEX_KCONFIG_HELP = re.compile(r"^\s+(help|---help---)\s*$") 39REGEX_FILTER_FEATURES = re.compile(r"[A-Za-z0-9]$") 40REGEX_NUMERIC = re.compile(r"0[xX][0-9a-fA-F]+|[0-9]+") 41REGEX_QUOTES = re.compile("(\"(.*?)\")") 42 43 44def parse_options(): 45 """The user interface of this module.""" 46 usage = "%prog [options]\n\n" \ 47 "Run this tool to detect Kconfig symbols that are referenced but " \ 48 "not defined in\nKconfig. The output of this tool has the " \ 49 "format \'Undefined symbol\\tFile list\'\n\n" \ 50 "If no option is specified, %prog will default to check your\n" \ 51 "current tree. Please note that specifying commits will " \ 52 "\'git reset --hard\'\nyour current tree! You may save " \ 53 "uncommitted changes to avoid losing data." 54 55 parser = OptionParser(usage=usage) 56 57 parser.add_option('-c', '--commit', dest='commit', action='store', 58 default="", 59 help="Check if the specified commit (hash) introduces " 60 "undefined Kconfig symbols.") 61 62 parser.add_option('-d', '--diff', dest='diff', action='store', 63 default="", 64 help="Diff undefined symbols between two commits. The " 65 "input format bases on Git log's " 66 "\'commmit1..commit2\'.") 67 68 parser.add_option('-f', '--find', dest='find', action='store_true', 69 default=False, 70 help="Find and show commits that may cause symbols to be " 71 "missing. Required to run with --diff.") 72 73 parser.add_option('-i', '--ignore', dest='ignore', action='store', 74 default="", 75 help="Ignore files matching this pattern. Note that " 76 "the pattern needs to be a Python regex. To " 77 "ignore defconfigs, specify -i '.*defconfig'.") 78 79 parser.add_option('-s', '--sim', dest='sim', action='store', default="", 80 help="Print a list of maximum 10 string-similar symbols.") 81 82 parser.add_option('', '--force', dest='force', action='store_true', 83 default=False, 84 help="Reset current Git tree even when it's dirty.") 85 86 parser.add_option('', '--no-color', dest='color', action='store_false', 87 default=True, 88 help="Don't print colored output. Default when not " 89 "outputting to a terminal.") 90 91 (opts, _) = parser.parse_args() 92 93 if opts.commit and opts.diff: 94 sys.exit("Please specify only one option at once.") 95 96 if opts.diff and not re.match(r"^[\w\-\.]+\.\.[\w\-\.]+$", opts.diff): 97 sys.exit("Please specify valid input in the following format: " 98 "\'commit1..commit2\'") 99 100 if opts.commit or opts.diff: 101 if not opts.force and tree_is_dirty(): 102 sys.exit("The current Git tree is dirty (see 'git status'). " 103 "Running this script may\ndelete important data since it " 104 "calls 'git reset --hard' for some performance\nreasons. " 105 " Please run this script in a clean Git tree or pass " 106 "'--force' if you\nwant to ignore this warning and " 107 "continue.") 108 109 if opts.commit: 110 opts.find = False 111 112 if opts.ignore: 113 try: 114 re.match(opts.ignore, "this/is/just/a/test.c") 115 except: 116 sys.exit("Please specify a valid Python regex.") 117 118 return opts 119 120 121def main(): 122 """Main function of this module.""" 123 opts = parse_options() 124 125 global color 126 color = opts.color and sys.stdout.isatty() 127 128 if opts.sim and not opts.commit and not opts.diff: 129 sims = find_sims(opts.sim, opts.ignore) 130 if sims: 131 print("%s: %s" % (yel("Similar symbols"), ', '.join(sims))) 132 else: 133 print("%s: no similar symbols found" % yel("Similar symbols")) 134 sys.exit(0) 135 136 # dictionary of (un)defined symbols 137 defined = {} 138 undefined = {} 139 140 if opts.commit or opts.diff: 141 head = get_head() 142 143 # get commit range 144 commit_a = None 145 commit_b = None 146 if opts.commit: 147 commit_a = opts.commit + "~" 148 commit_b = opts.commit 149 elif opts.diff: 150 split = opts.diff.split("..") 151 commit_a = split[0] 152 commit_b = split[1] 153 undefined_a = {} 154 undefined_b = {} 155 156 # get undefined items before the commit 157 execute("git reset --hard %s" % commit_a) 158 undefined_a, _ = check_symbols(opts.ignore) 159 160 # get undefined items for the commit 161 execute("git reset --hard %s" % commit_b) 162 undefined_b, defined = check_symbols(opts.ignore) 163 164 # report cases that are present for the commit but not before 165 for feature in sorted(undefined_b): 166 # feature has not been undefined before 167 if not feature in undefined_a: 168 files = sorted(undefined_b.get(feature)) 169 undefined[feature] = files 170 # check if there are new files that reference the undefined feature 171 else: 172 files = sorted(undefined_b.get(feature) - 173 undefined_a.get(feature)) 174 if files: 175 undefined[feature] = files 176 177 # reset to head 178 execute("git reset --hard %s" % head) 179 180 # default to check the entire tree 181 else: 182 undefined, defined = check_symbols(opts.ignore) 183 184 # now print the output 185 for feature in sorted(undefined): 186 print(red(feature)) 187 188 files = sorted(undefined.get(feature)) 189 print("%s: %s" % (yel("Referencing files"), ", ".join(files))) 190 191 sims = find_sims(feature, opts.ignore, defined) 192 sims_out = yel("Similar symbols") 193 if sims: 194 print("%s: %s" % (sims_out, ', '.join(sims))) 195 else: 196 print("%s: %s" % (sims_out, "no similar symbols found")) 197 198 if opts.find: 199 print("%s:" % yel("Commits changing symbol")) 200 commits = find_commits(feature, opts.diff) 201 if commits: 202 for commit in commits: 203 commit = commit.split(" ", 1) 204 print("\t- %s (\"%s\")" % (yel(commit[0]), commit[1])) 205 else: 206 print("\t- no commit found") 207 print() # new line 208 209 210def yel(string): 211 """ 212 Color %string yellow. 213 """ 214 return "\033[33m%s\033[0m" % string if color else string 215 216 217def red(string): 218 """ 219 Color %string red. 220 """ 221 return "\033[31m%s\033[0m" % string if color else string 222 223 224def execute(cmd): 225 """Execute %cmd and return stdout. Exit in case of error.""" 226 try: 227 cmdlist = cmd.split(" ") 228 stdout = subprocess.check_output(cmdlist, stderr=subprocess.STDOUT, shell=False) 229 stdout = stdout.decode(errors='replace') 230 except subprocess.CalledProcessError as fail: 231 exit("Failed to execute %s\n%s" % (cmd, fail)) 232 return stdout 233 234 235def find_commits(symbol, diff): 236 """Find commits changing %symbol in the given range of %diff.""" 237 commits = execute("git log --pretty=oneline --abbrev-commit -G %s %s" 238 % (symbol, diff)) 239 return [x for x in commits.split("\n") if x] 240 241 242def tree_is_dirty(): 243 """Return true if the current working tree is dirty (i.e., if any file has 244 been added, deleted, modified, renamed or copied but not committed).""" 245 stdout = execute("git status --porcelain") 246 for line in stdout: 247 if re.findall(r"[URMADC]{1}", line[:2]): 248 return True 249 return False 250 251 252def get_head(): 253 """Return commit hash of current HEAD.""" 254 stdout = execute("git rev-parse HEAD") 255 return stdout.strip('\n') 256 257 258def partition(lst, size): 259 """Partition list @lst into eveni-sized lists of size @size.""" 260 return [lst[i::size] for i in range(size)] 261 262 263def init_worker(): 264 """Set signal handler to ignore SIGINT.""" 265 signal.signal(signal.SIGINT, signal.SIG_IGN) 266 267 268def find_sims(symbol, ignore, defined = []): 269 """Return a list of max. ten Kconfig symbols that are string-similar to 270 @symbol.""" 271 if defined: 272 return sorted(difflib.get_close_matches(symbol, set(defined), 10)) 273 274 pool = Pool(cpu_count(), init_worker) 275 kfiles = [] 276 for gitfile in get_files(): 277 if REGEX_FILE_KCONFIG.match(gitfile): 278 kfiles.append(gitfile) 279 280 arglist = [] 281 for part in partition(kfiles, cpu_count()): 282 arglist.append((part, ignore)) 283 284 for res in pool.map(parse_kconfig_files, arglist): 285 defined.extend(res[0]) 286 287 return sorted(difflib.get_close_matches(symbol, set(defined), 10)) 288 289 290def get_files(): 291 """Return a list of all files in the current git directory.""" 292 # use 'git ls-files' to get the worklist 293 stdout = execute("git ls-files") 294 if len(stdout) > 0 and stdout[-1] == "\n": 295 stdout = stdout[:-1] 296 297 files = [] 298 for gitfile in stdout.rsplit("\n"): 299 if ".git" in gitfile or "ChangeLog" in gitfile or \ 300 ".log" in gitfile or os.path.isdir(gitfile) or \ 301 gitfile.startswith("tools/"): 302 continue 303 files.append(gitfile) 304 return files 305 306 307def check_symbols(ignore): 308 """Find undefined Kconfig symbols and return a dict with the symbol as key 309 and a list of referencing files as value. Files matching %ignore are not 310 checked for undefined symbols.""" 311 pool = Pool(cpu_count(), init_worker) 312 try: 313 return check_symbols_helper(pool, ignore) 314 except KeyboardInterrupt: 315 pool.terminate() 316 pool.join() 317 sys.exit(1) 318 319 320def check_symbols_helper(pool, ignore): 321 """Helper method for check_symbols(). Used to catch keyboard interrupts in 322 check_symbols() in order to properly terminate running worker processes.""" 323 source_files = [] 324 kconfig_files = [] 325 defined_features = [] 326 referenced_features = dict() # {file: [features]} 327 328 for gitfile in get_files(): 329 if REGEX_FILE_KCONFIG.match(gitfile): 330 kconfig_files.append(gitfile) 331 else: 332 if ignore and not re.match(ignore, gitfile): 333 continue 334 # add source files that do not match the ignore pattern 335 source_files.append(gitfile) 336 337 # parse source files 338 arglist = partition(source_files, cpu_count()) 339 for res in pool.map(parse_source_files, arglist): 340 referenced_features.update(res) 341 342 343 # parse kconfig files 344 arglist = [] 345 for part in partition(kconfig_files, cpu_count()): 346 arglist.append((part, ignore)) 347 for res in pool.map(parse_kconfig_files, arglist): 348 defined_features.extend(res[0]) 349 referenced_features.update(res[1]) 350 defined_features = set(defined_features) 351 352 # inverse mapping of referenced_features to dict(feature: [files]) 353 inv_map = dict() 354 for _file, features in referenced_features.items(): 355 for feature in features: 356 inv_map[feature] = inv_map.get(feature, set()) 357 inv_map[feature].add(_file) 358 referenced_features = inv_map 359 360 undefined = {} # {feature: [files]} 361 for feature in sorted(referenced_features): 362 # filter some false positives 363 if feature == "FOO" or feature == "BAR" or \ 364 feature == "FOO_BAR" or feature == "XXX": 365 continue 366 if feature not in defined_features: 367 if feature.endswith("_MODULE"): 368 # avoid false positives for kernel modules 369 if feature[:-len("_MODULE")] in defined_features: 370 continue 371 undefined[feature] = referenced_features.get(feature) 372 return undefined, defined_features 373 374 375def parse_source_files(source_files): 376 """Parse each source file in @source_files and return dictionary with source 377 files as keys and lists of references Kconfig symbols as values.""" 378 referenced_features = dict() 379 for sfile in source_files: 380 referenced_features[sfile] = parse_source_file(sfile) 381 return referenced_features 382 383 384def parse_source_file(sfile): 385 """Parse @sfile and return a list of referenced Kconfig features.""" 386 lines = [] 387 references = [] 388 389 if not os.path.exists(sfile): 390 return references 391 392 with open(sfile, "r", encoding='utf-8', errors='replace') as stream: 393 lines = stream.readlines() 394 395 for line in lines: 396 if not "CONFIG_" in line: 397 continue 398 features = REGEX_SOURCE_FEATURE.findall(line) 399 for feature in features: 400 if not REGEX_FILTER_FEATURES.search(feature): 401 continue 402 references.append(feature) 403 404 return references 405 406 407def get_features_in_line(line): 408 """Return mentioned Kconfig features in @line.""" 409 return REGEX_FEATURE.findall(line) 410 411 412def parse_kconfig_files(args): 413 """Parse kconfig files and return tuple of defined and references Kconfig 414 symbols. Note, @args is a tuple of a list of files and the @ignore 415 pattern.""" 416 kconfig_files = args[0] 417 ignore = args[1] 418 defined_features = [] 419 referenced_features = dict() 420 421 for kfile in kconfig_files: 422 defined, references = parse_kconfig_file(kfile) 423 defined_features.extend(defined) 424 if ignore and re.match(ignore, kfile): 425 # do not collect references for files that match the ignore pattern 426 continue 427 referenced_features[kfile] = references 428 return (defined_features, referenced_features) 429 430 431def parse_kconfig_file(kfile): 432 """Parse @kfile and update feature definitions and references.""" 433 lines = [] 434 defined = [] 435 references = [] 436 skip = False 437 438 if not os.path.exists(kfile): 439 return defined, references 440 441 with open(kfile, "r", encoding='utf-8', errors='replace') as stream: 442 lines = stream.readlines() 443 444 for i in range(len(lines)): 445 line = lines[i] 446 line = line.strip('\n') 447 line = line.split("#")[0] # ignore comments 448 449 if REGEX_KCONFIG_DEF.match(line): 450 feature_def = REGEX_KCONFIG_DEF.findall(line) 451 defined.append(feature_def[0]) 452 skip = False 453 elif REGEX_KCONFIG_HELP.match(line): 454 skip = True 455 elif skip: 456 # ignore content of help messages 457 pass 458 elif REGEX_KCONFIG_STMT.match(line): 459 line = REGEX_QUOTES.sub("", line) 460 features = get_features_in_line(line) 461 # multi-line statements 462 while line.endswith("\\"): 463 i += 1 464 line = lines[i] 465 line = line.strip('\n') 466 features.extend(get_features_in_line(line)) 467 for feature in set(features): 468 if REGEX_NUMERIC.match(feature): 469 # ignore numeric values 470 continue 471 references.append(feature) 472 473 return defined, references 474 475 476if __name__ == "__main__": 477 main() 478