| #!/usr/bin/python |
| |
| ##===--- iwyu_test_util.py - include-what-you-use test framework -----------===## |
| # |
| # The LLVM Compiler Infrastructure |
| # |
| # This file is distributed under the University of Illinois Open Source |
| # License. See LICENSE.TXT for details. |
| # |
| ##===-----------------------------------------------------------------------===## |
| |
| """Utilities for writing tests for IWYU. |
| |
| This script has been tested with python 2.4, 2.7, 3.1.3 and 3.2. |
| In order to support all of these platforms there are a few unusual constructs: |
| * print statements require parentheses |
| * standard output must be decoded as utf-8 |
| * range() must be used in place of xrange() |
| * _PortableNext() is used to obtain next iterator value |
| |
| There is more detail on some of these issues at: |
| http://diveintopython3.org/porting-code-to-python-3-with-2to3.html |
| """ |
| |
| __author__ = 'wan@google.com (Zhanyong Wan)' |
| |
| import difflib |
| import operator |
| import os |
| import re |
| import subprocess |
| import sys |
| |
| # These are the warning/error lines that iwyu.cc produces when --verbose >= 3 |
| _EXPECTED_DIAGNOSTICS_RE = re.compile(r'^(.*?):(\d+):.*//\s*IWYU:\s*(.*)$') |
| _ACTUAL_DIAGNOSTICS_RE = re.compile(r'^(.*?):(\d+):\d+:\s*' |
| r'(?:warning|error):\s*(.*)$') |
| |
| # This is the final summary output that iwyu.cc produces when --verbose >= 1 |
| # The summary for a given source file should appear in that source file, |
| # surrounded by '/**** IWYU_SUMMARY' and '***** IWYU_SUMMARY */'. |
| _EXPECTED_SUMMARY_START_RE = re.compile(r'/\*+\s*IWYU_SUMMARY') |
| _EXPECTED_SUMMARY_END_RE = re.compile(r'\**\s*IWYU_SUMMARY\s*\*+/') |
| _ACTUAL_SUMMARY_START_RE = re.compile(r'^(.*?) should add these lines:$') |
| _ACTUAL_SUMMARY_END_RE = re.compile(r'^---$') |
| _ACTUAL_REMOVAL_LIST_START_RE = re.compile(r'.* should remove these lines:$') |
| _NODIFFS_RE = re.compile(r'^\((.*?) has correct #includes/fwd-decls\)$') |
| |
| |
| def _PortableNext(iterator): |
| if hasattr(iterator, 'next'): |
| iterator.next() # Python 2.4-2.6 |
| else: |
| next(iterator) # Python 3 |
| |
| |
| def _GetIwyuPath(iwyu_paths): |
| """Returns the path to IWYU or raises IOError if it cannot be found.""" |
| for path in iwyu_paths: |
| if sys.platform == 'win32': |
| path = os.path.normpath(path) + '.exe' |
| if os.path.exists(path): |
| return path |
| raise IOError('Failed to locate IWYU.\nSearched %s' % iwyu_paths) |
| _IWYU_PATHS = [ |
| '../../../../Debug+Asserts/bin/include-what-you-use', |
| '../../../../Release+Asserts/bin/include-what-you-use', |
| '../../../../Release/bin/include-what-you-use', |
| '../../../../build/Debug+Asserts/bin/include-what-you-use', |
| '../../../../build/Release+Asserts/bin/include-what-you-use', |
| '../../../../build/Release/bin/include-what-you-use', |
| # Linux/Mac OS X default out-of-tree paths. |
| '../../../../../build/Debug+Asserts/bin/include-what-you-use', |
| '../../../../../build/Release+Asserts/bin/include-what-you-use', |
| '../../../../../build/Release/bin/include-what-you-use', |
| # Windows default out-of-tree paths. |
| '../../../../../build/bin/Debug/include-what-you-use', |
| '../../../../../build/bin/Release/include-what-you-use', |
| '../../../../../build/bin/MinSizeRel/include-what-you-use', |
| '../../../../../build/bin/RelWithDebInfo/include-what-you-use', |
| ] |
| _IWYU_PATH = _GetIwyuPath(_IWYU_PATHS) |
| |
| |
| def _IsCppSource(file_path): |
| return (file_path.endswith('.h') or file_path.endswith('.cc') or |
| file_path.endswith('.c')) |
| |
| |
| def _GetAllCppFilesUnderDir(root_dir): |
| cpp_files = [] |
| for (dir, _, files) in os.walk(root_dir): # iterates over all dirs |
| cpp_files += [os.path.join(dir, f) for f in files if _IsCppSource(f)] |
| return cpp_files |
| |
| |
| def _GetCommandOutput(command): |
| p = subprocess.Popen(command, |
| shell=True, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT) |
| lines = [line.decode("utf-8") for line in p.stdout.readlines()] |
| return lines |
| |
| |
| def _GetExpectedDiagnosticRegexes(expected_diagnostic_specs): |
| """Returns a map: source file location => list of regexes for that line.""" |
| |
| # Maps a source file location to the warning/error message regex specified |
| # on that line. |
| spec_loc_to_regex = {} |
| for line in expected_diagnostic_specs: |
| m = _EXPECTED_DIAGNOSTICS_RE.match(line.strip()) |
| if m: |
| path, line_num, regex = m.groups() |
| if not regex: |
| # Allow the regex to be omitted if we are uninterested in the |
| # diagnostic message. |
| regex = r'.*' |
| spec_loc_to_regex[path, int(line_num)] = re.compile(regex) |
| |
| # Maps a source file line location to a list of regexes for diagnostics |
| # that should be generated for that line. |
| expected_diagnostic_regexes = {} |
| regexes = [] |
| for loc in sorted(spec_loc_to_regex.keys()): |
| regexes.append(spec_loc_to_regex[loc]) |
| # Do we have a spec on the next line? |
| path, line_num = loc |
| next_line_loc = path, line_num + 1 |
| if next_line_loc not in spec_loc_to_regex: |
| expected_diagnostic_regexes[next_line_loc] = regexes |
| regexes = [] |
| |
| return expected_diagnostic_regexes |
| |
| |
| def _GetActualDiagnostics(actual_output): |
| """Returns a map: source file location => list of diagnostics on that line. |
| |
| The elements of the list are unique and sorted.""" |
| |
| actual_diagnostics = {} |
| for line in actual_output: |
| m = _ACTUAL_DIAGNOSTICS_RE.match(line.strip()) |
| if m: |
| path, line_num, message = m.groups() |
| loc = path, int(line_num) |
| actual_diagnostics[loc] = actual_diagnostics.get(loc, []) + [message] |
| |
| locs = actual_diagnostics.keys() |
| for loc in locs: |
| actual_diagnostics[loc] = sorted(set(actual_diagnostics[loc])) |
| |
| return actual_diagnostics |
| |
| |
| def _StripCommentFromLine(line): |
| """Removes the "// ..." comment at the end of the given line.""" |
| |
| m = re.match(r'(.*)//', line) |
| if m: |
| return m.group(1).strip() + '\n' |
| else: |
| return line |
| |
| |
| def _NormalizeSummaryLineNumbers(line): |
| """Replaces the comment '// lines <number>-<number>' with '// lines XX-YY'. |
| |
| Because line numbers in the source code often change, it's a pain to |
| keep the '// lines <number>-<number>' comments accurate in our |
| 'golden' output. Instead, we normalize these iwyu comments to just |
| say how many line numbers are listed by mapping the output to |
| '// lines XX-XX' (for one-line spans) or '// lines XX-XX+<number>'. |
| For instance, '// lines 12-12' would map to '// lines XX-XX', while |
| '// lines 12-14' would map to '//lines XX-XX+2'. |
| |
| Arguments: |
| line: the line to be normalized. |
| |
| Returns: |
| A new line with the '// lines' comment, if any, normalized as |
| described above. If no '// lines' comment is present, returns |
| the original line. |
| """ |
| m = re.search('// lines ([0-9]+)-([0-9]+)', line) |
| if not m: |
| return line |
| if m.group(1) == m.group(2): |
| return line[:m.start()] + '// lines XX-XX\n' |
| else: |
| num_lines = int(m.group(2)) - int(m.group(1)) |
| return line[:m.start()] + '// lines XX-XX+%d\n' % num_lines |
| |
| |
| def _NormalizeSummaryLine(line): |
| """Alphabetically sorts the symbols in the '// for XXX, YYY, ZZZ' comments. |
| |
| Most iwyu summary lines have the form |
| #include <foo.h> // for XXX, YYY, ZZZ |
| XXX, YYY, ZZZ are symbols that this file uses from foo.h. They are |
| sorted in frequency order, but that changes so often as the test is |
| augmented, that it's impractical to test. We just sort the symbols |
| alphabetically and compare that way. This means we never test the |
| frequency ordering here, but that's a small price to pay for easier |
| testing development. |
| |
| We also always move the '// for' comment to be exactly two spaces |
| after the '#include' text. Again, this means we don't test the |
| indenting correctly (though iwyu_output_test.cc does), but allows us |
| to rename filenames without having to reformat each test. This is |
| particularly important when opensourcing, since the filenames will |
| be different in opensource-land than they are inside google. |
| |
| Arguments: |
| line: one line of the summary output |
| |
| Returns: |
| A normalized form of 'line', with the 'why' symbols sorted and |
| whitespace before the 'why' comment collapsed. |
| """ |
| m = re.match(r'(.*?)\s* // for (.*)', line) |
| if not m: |
| return line |
| symbols = m.group(2).strip().split(', ') |
| symbols.sort() |
| return '%s // for %s\n' % (m.group(1), ', '.join(symbols)) |
| |
| |
| def _GetExpectedSummaries(files): |
| """Returns a map: source file => list of iwyu summary lines.""" |
| |
| expected_summaries = {} |
| for f in files: |
| in_summary = False |
| fh = open(f) |
| for line in fh: |
| if _EXPECTED_SUMMARY_START_RE.match(line): |
| in_summary = True |
| expected_summaries[f] = [] |
| elif _EXPECTED_SUMMARY_END_RE.match(line): |
| in_summary = False |
| elif re.match(r'^\s*//', line): |
| pass # ignore comment lines |
| elif in_summary: |
| expected_summaries[f].append(line) |
| fh.close() |
| |
| # Get rid of blank lines at the beginning and end of the each summary. |
| for loc in expected_summaries: |
| while expected_summaries[loc] and expected_summaries[loc][-1] == '\n': |
| expected_summaries[loc].pop() |
| while expected_summaries[loc] and expected_summaries[loc][0] == '\n': |
| expected_summaries[loc].pop(0) |
| |
| return expected_summaries |
| |
| |
| def _GetActualSummaries(output): |
| """Returns a map: source file => list of iwyu summary lines.""" |
| |
| actual_summaries = {} |
| file_being_summarized = None |
| in_addition_section = False # Are we in the "should add these lines" section? |
| for line in output: |
| # For files with no diffs, we print a different (one-line) summary. |
| m = _NODIFFS_RE.match(line) |
| if m: |
| actual_summaries[m.group(1)] = [line] |
| continue |
| |
| m = _ACTUAL_SUMMARY_START_RE.match(line) |
| if m: |
| file_being_summarized = m.group(1) |
| in_addition_section = True |
| actual_summaries[file_being_summarized] = [line] |
| elif _ACTUAL_SUMMARY_END_RE.match(line): |
| file_being_summarized = None |
| elif file_being_summarized: |
| if _ACTUAL_REMOVAL_LIST_START_RE.match(line): |
| in_addition_section = False |
| # Replace any line numbers in comments with something more stable. |
| line = _NormalizeSummaryLineNumbers(line) |
| if in_addition_section: |
| # Each #include in the "should add" list will appear later in |
| # the full include list. There's no need to verify its symbol |
| # list twice. Therefore we remove the symbol list here for |
| # easy test maintenance. |
| line = _StripCommentFromLine(line) |
| else: |
| line = _NormalizeSummaryLine(line) |
| actual_summaries[file_being_summarized].append(line) |
| |
| return actual_summaries |
| |
| |
| def _VerifyDiagnosticsAtLoc(loc_str, regexes, diagnostics): |
| """Verify the diagnostics at the given location; return a list of failures.""" |
| |
| # Find out which regexes match a diagnostic and vice versa. |
| matching_regexes = [[] for unused_i in range(len(diagnostics))] |
| matched_diagnostics = [[] for unused_i in range(len(regexes))] |
| for (r_index, regex) in enumerate(regexes): |
| for (d_index, diagnostic) in enumerate(diagnostics): |
| if regex.search(diagnostic): |
| matching_regexes[d_index].append(r_index) |
| matched_diagnostics[r_index].append(d_index) |
| |
| failure_messages = [] |
| |
| # Collect unmatched diagnostics and multiply matched diagnostics. |
| for (d_index, r_indexes) in enumerate(matching_regexes): |
| if not r_indexes: |
| failure_messages.append('Unexpected diagnostic:\n%s\n' |
| % diagnostics[d_index]) |
| elif len(r_indexes) > 1: |
| failure_messages.append( |
| 'The diagnostic message:\n%s\n' |
| 'matches multiple regexes:\n%s' |
| % (diagnostics[d_index], |
| '\n'.join([regexes[r_index].pattern for r_index in r_indexes]))) |
| |
| # Collect unmatched regexes and regexes with multiple matches. |
| for (r_index, d_indexes) in enumerate(matched_diagnostics): |
| if not d_indexes: |
| failure_messages.append('Unmatched regex:\n%s\n' |
| % regexes[r_index].pattern) |
| elif len(d_indexes) > 1: |
| failure_messages.append( |
| 'The regex:\n%s\n' |
| 'matches multiple diagnostics:\n%s' |
| % (regexes[r_index].pattern, |
| '\n'.join([diagnostics[d_index] for d_index in d_indexes]))) |
| |
| return ['%s %s' % (loc_str, message) for message in failure_messages] |
| |
| |
| def _CompareExpectedAndActualDiagnostics(expected_diagnostic_regexes, |
| actual_diagnostics): |
| """Verify that the diagnostics are as expected; return a list of failures.""" |
| |
| failures = [] |
| for loc in sorted(set(actual_diagnostics.keys()) | |
| set(expected_diagnostic_regexes.keys())): |
| # Find all regexes and actual diagnostics for the given location. |
| regexes = expected_diagnostic_regexes.get(loc, []) |
| diagnostics = actual_diagnostics.get(loc, []) |
| failures += _VerifyDiagnosticsAtLoc('\n%s:%s:' % loc, regexes, diagnostics) |
| |
| return failures |
| |
| |
| def _CompareExpectedAndActualSummaries(expected_summaries, actual_summaries): |
| """Verify that the summaries are as expected; return a list of failures.""" |
| |
| failures = [] |
| for loc in sorted(set(actual_summaries.keys()) | |
| set(expected_summaries.keys())): |
| this_failure = difflib.unified_diff(expected_summaries.get(loc, []), |
| actual_summaries.get(loc, [])) |
| try: |
| _PortableNext(this_failure) # read past the 'what files are this' header |
| failures.append('\n') |
| failures.append('Unexpected summary diffs for %s:\n' % loc) |
| failures.extend(this_failure) |
| failures.append('---\n') |
| except StopIteration: |
| pass # empty diff |
| return failures |
| |
| |
| def TestIwyuOnRelativeFile(test_case, cc_file, cpp_files_to_check, |
| iwyu_flags=None, verbose=False): |
| """Checks running IWYU on the given .cc file. |
| |
| Args: |
| test_case: A googletest.TestCase instance. |
| cc_file: The name of the file to test, relative to the current dir. |
| cpp_files_to_check: A list of filenames for the files |
| to check the diagnostics on, relative to the current dir. |
| iwyu_flags: Extra command-line flags to pass to iwyu. |
| """ |
| iwyu_flags = iwyu_flags or [] # Make sure iwyu_flags is a list. |
| |
| # Require verbose level 3 so that we can verify the individual diagnostics. |
| # We allow the level to be overriden by the IWYU_VERBOSE environment |
| # variable, or by iwyu_flags, for easy debugging. (We put the |
| # envvar-based flag first, so user flags can override it later.) |
| iwyu_flags = ['--verbose=%s' % os.getenv('IWYU_VERBOSE', '3')] + iwyu_flags |
| |
| # clang reads iwyu flags after the -Xiwyu clang flag: '-Xiwyu --verbose=6' |
| iwyu_flags = ['-Xiwyu ' + flag for flag in iwyu_flags] |
| |
| # TODO(csilvers): verify that has exit-status 0. |
| cmd = '%s %s -I . %s' % (_IWYU_PATH, ' '.join(iwyu_flags), cc_file) |
| if verbose: |
| print('>>> Running %s' % cmd) |
| output = _GetCommandOutput(cmd) |
| print(''.join(output)) |
| sys.stdout.flush() # don't commingle this output with the failure output |
| |
| expected_diagnostics = _GetCommandOutput('grep -n -H "^ *// *IWYU" %s' % |
| (' '.join(cpp_files_to_check))) |
| failures = _CompareExpectedAndActualDiagnostics( |
| _GetExpectedDiagnosticRegexes(expected_diagnostics), |
| _GetActualDiagnostics(output)) |
| |
| # Also figure out if the end-of-parsing suggestions match up. |
| failures += _CompareExpectedAndActualSummaries( |
| _GetExpectedSummaries(cpp_files_to_check), |
| _GetActualSummaries(output)) |
| |
| test_case.assertTrue(not failures, ''.join(failures)) |
| |
| |
| # TODO(dsturtevant): Move all tests using this function to the test directory |
| # harness, then get rid of it. |
| def TestIwyuOnFile(test_case, relative_test_dir, cc_file, iwyu_flags=None): |
| """Checks running IWYU on the .cc file in the given directory.""" |
| |
| TestIwyuOnRelativeFile(test_case, |
| os.path.join(relative_test_dir, cc_file), |
| _GetAllCppFilesUnderDir(relative_test_dir), |
| iwyu_flags) |