From 044df146163f86fe18b83770b4de82e574750118 Mon Sep 17 00:00:00 2001 From: Jules Villard Date: Thu, 11 Jun 2015 09:35:02 -0100 Subject: [PATCH] a simple text visualisation of error traces Summary: @public This adds a script `inferTraceBugs` to `infer/bin/` that 1. shows the list of bugs found by Infer to the user 2. asks which one to display 3. asks what max level of nested procedure calls to display 4. shows the error trace of that bug with some lines of context in the source code Also has some options to script more easily, for instance when calling it from inside an editor to navigate the sources. Test Plan: infer -o out -- gcc -c hello.c inferTraceBugs -o out also tested on OpenSSL. In emacs, run `M-x compile` from the directory where `infer-out` is, then enter custom compilation command: inferTraceBugs --select 0 --max-level max --no-source Then navigate the trace with `M-g n`. --- FILES.md | 2 + infer/bin/infer | 3 +- infer/bin/inferTraceBugs | 273 +++++++++++++++++++++++++++++++++++++++ infer/bin/inferlib.py | 16 +-- infer/bin/utils.py | 14 ++ 5 files changed, 293 insertions(+), 15 deletions(-) create mode 100755 infer/bin/inferTraceBugs diff --git a/FILES.md b/FILES.md index 9466f4307..11422884c 100644 --- a/FILES.md +++ b/FILES.md @@ -7,6 +7,8 @@ *inferTest* : Shell script for running Infer's tests. Uses Buck for running the tests. Usage: inferTest {c, objc, java} for the tests about the analysis of C, Objective-C, or Java files. +*inferTraceBugs* : Python script to explore the error traces in Infer reports + ## Helper commands The rest of the commands in infer/bin/ are not meant to be called directly, but are used by the top-level commands above. diff --git a/infer/bin/infer b/infer/bin/infer index e8a1bf570..b4b8bbeea 100755 --- a/infer/bin/infer +++ b/infer/bin/infer @@ -138,4 +138,5 @@ def main(): analysis.analyze_and_report() analysis.save_stats() -main() +if __name__ == '__main__': + main() diff --git a/infer/bin/inferTraceBugs b/infer/bin/inferTraceBugs new file mode 100755 index 000000000..dcbc6bfc9 --- /dev/null +++ b/infer/bin/inferTraceBugs @@ -0,0 +1,273 @@ +#!/usr/bin/env python +# +# Copyright (c) 2013-present Facebook. All rights reserved. +# + +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import argparse +import json +import os +import sys + +# Infer imports +import utils + +BASE_INDENT = 2 +JSON_REPORT = 'report.json' +# how many lines of context around each report +SOURCE_CONTEXT = 2 + + +base_parser = argparse.ArgumentParser( + description='Explore the error traces in Infer reports.') +base_parser.add_argument('-o', '--out', metavar='', + default=utils.DEFAULT_INFER_OUT, dest='infer_out', + action=utils.AbsolutePathAction, + help='Set the Infer results directory') +base_parser.add_argument('--only-show', + action='store_true', + help='Show the list of reports and exit') +base_parser.add_argument('--no-source', + action='store_true', + help='Do not print code excerpts') +base_parser.add_argument('--select', + metavar='N', + nargs=1, + help='Select bug number N. ' + 'If omitted, prompts you for input.') +base_parser.add_argument('--max-level', + metavar='N', + nargs=1, + help='Level of nested procedure calls to show. ' + 'Can be "max", in which case all levels are shown. ' + 'If omitted, prompts you for input.') + + +def describe_report(report, indent=0): + filename = report['file'] + kind = report['kind'] + line = report['line'] + error_type = report['type'] + msg = utils.remove_bucket(report['qualifier']) + return '{0}:{1}: {2}: {3}\n {4}{5}\n'.format( + filename, + line, + kind.lower(), + error_type, + ' ' * indent, + msg, + ) + + +def show_error_and_exit(err, show_help): + print(err) + if show_help: + print('') + base_parser.print_help() + exit(1) + + +class Tracer(object): + def __init__(self, args, level=sys.maxsize): + self.args = args + self.max_level = level + + self.text = '' + self.indent = [] + + def indent_get(self): + indent = '' + for i in self.indent: + indent += i + return indent + + def indent_push(self, n=1): + self.indent.append(n * BASE_INDENT * ' ') + + def indent_pop(self): + return self.indent.pop() + + def add(self, s): + self.text += self.indent_get() + s + + def newline(self): + self.text += '\n' + + def build_node_tags(self, node): + pass + + def build_source_context(self, source_name, report_line): + start_line = max(1, report_line - SOURCE_CONTEXT) + # could go beyond last line, checked in the loop + end_line = report_line + SOURCE_CONTEXT + + n_length = len(str(end_line)) + line_number = 1 + with open(source_name) as source_file: + for line in source_file: + if start_line <= line_number <= end_line: + num = str(line_number).zfill(n_length) + caret = ' ' + if line_number == report_line: + caret = '> ' + self.add(num + ' ' + caret + line) + line_number += 1 + + def build_node(self, node): + if node['level'] > self.max_level: + return + + report_line = node['line_number'] + fname = node['filename'] + + self.add('%s:%d: %s\n' % (fname, + report_line, + node['description'])) + + if not self.args.no_source: + self.indent_push(node['level']) + self.build_source_context(fname, report_line) + self.indent_pop() + self.newline() + + def build_trace(self, trace): + total_nodes = len(trace) + hidden_nodes = len([None for n in trace if n['level'] > self.max_level]) + shown_nodes = total_nodes - hidden_nodes + hidden_str = '' + all_str = 'all ' + if hidden_nodes > 0: + hidden_str = ' (%d steps too deeply nested)' % hidden_nodes + all_str = '' + self.add('Showing %s%d steps of the trace%s\n\n' + % (all_str, shown_nodes, hidden_str)) + + for node in trace: + self.build_node(node) + + def build_report(self, report): + self.add(describe_report(report)) + self.newline() + + traces = json.loads(report['trace']) + self.build_trace(traces['trace']) + + def __str__(self): + return self.text + + +class Selector(object): + def __init__(self, args, reports): + self.args = args + + def has_trace(report): + trace = json.loads(report['trace']) + return len(trace['trace']) > 0 + self.reports = [report for report in reports if has_trace(report)] + + def show_choices(self): + n = 0 + n_length = len(str(len(self))) + for report in self.reports: + print(str(n).rjust(n_length) + '. ' + + describe_report(report, n_length + 2)) + n += 1 + + def prompt_number(self): + if self.args.select is not None: + return self.parse_report_number(self.args.select[0], True) + + self.show_choices() + + report_number = 0 + if len(self) > 1: + report_number_str = raw_input( + 'Choose report to display (default=0): ') + if report_number_str == '': + report_number = 0 + else: + report_number = self.parse_report_number(report_number_str) + elif len(self) == 1: + print('Auto-selecting the only report.') + report_number = 0 + + return report_number + + def prompt_level(self): + if self.args.max_level is not None: + return self.parse_max_level(self.args.max_level[0], True) + + max_level_str = raw_input( + 'Choose maximum level of nested procedures calls (default=max): ') + if max_level_str == '': + max_level = sys.maxsize + else: + max_level = self.parse_max_level(max_level_str) + + print('') + + return max_level + + def parse_report_number(self, s, show_help=False): + try: + n = int(s) + except ValueError: + show_error_and_exit( + 'ERROR: integer report number expected', + show_help) + + if n >= len(self) or n < 0: + show_error_and_exit('ERROR: invalid report number.', show_help) + + return n + + def parse_max_level(self, s, show_help=False): + if s == 'max': + return sys.maxsize + + try: + n = int(s) + except ValueError: + show_error_and_exit( + 'ERROR: integer max level or "max" expected', + show_help) + + if n < 0: + show_error_and_exit('ERROR: invalid max level.', show_help) + + return n + + def __len__(self): + return len(self.reports) + + +def main(): + args = base_parser.parse_args() + + with open(os.path.join(args.infer_out, JSON_REPORT)) as report_file: + reports = json.load(report_file) + + sel = Selector(args, reports) + + if len(sel) == 0: + print('No issues found') + exit(0) + + if args.only_show: + sel.show_choices() + exit(0) + + report_number = sel.prompt_number() + max_level = sel.prompt_level() + + tracer = Tracer(args, max_level) + tracer.build_report(reports[report_number]) + print(tracer) + + +if __name__ == '__main__': + main() diff --git a/infer/bin/inferlib.py b/infer/bin/inferlib.py index 5b0124a38..783b84f06 100644 --- a/infer/bin/inferlib.py +++ b/infer/bin/inferlib.py @@ -14,7 +14,6 @@ import json import logging import multiprocessing import os -import re import shutil import subprocess import sys @@ -45,11 +44,6 @@ ERROR = 'ERROR' WARNING = 'WARNING' INFO = 'INFO' -class AbsolutePathAction(argparse.Action): - """Convert a path from relative to absolute in the arg parser""" - def __call__(self, parser, namespace, values, option_string=None): - setattr(namespace, self.dest, os.path.abspath(values)) - # https://github.com/python/cpython/blob/aa8ea3a6be22c92e774df90c6a6ee697915ca8ec/Lib/argparse.py class VersionAction(argparse._VersionAction): @@ -75,7 +69,7 @@ base_parser = argparse.ArgumentParser(add_help=False) base_group = base_parser.add_argument_group('global arguments') base_group.add_argument('-o', '--out', metavar='', default=utils.DEFAULT_INFER_OUT, dest='infer_out', - action=AbsolutePathAction, + action=utils.AbsolutePathAction, help='Set the Infer results directory') base_group.add_argument('-i', '--incremental', action='store_true', help='''Do not delete the results directory across @@ -271,12 +265,6 @@ def clean_csv(args, csv_report): shutil.move(temporary_file, csv_report) -def remove_bucket(bug_message): - """ Remove anything from the beginning if the message that - looks like a bucket """ - return re.sub(r'(^\[[a-zA-Z0-9]*\])', '', bug_message, 1) - - def print_and_write(file_out, message): print(message) file_out.write(message + '\n') @@ -301,7 +289,7 @@ def print_errors(csv_report, bugs_out): kind = row[utils.CSV_INDEX_KIND] line = row[utils.CSV_INDEX_LINE] error_type = row[utils.CSV_INDEX_TYPE] - msg = remove_bucket(row[utils.CSV_INDEX_QUALIFIER]) + msg = utils.remove_bucket(row[utils.CSV_INDEX_QUALIFIER]) print_and_write( file_out, '{0}:{1}: {2}: {3}\n {4}\n'.format( diff --git a/infer/bin/utils.py b/infer/bin/utils.py index 82d3fcd12..8753ec685 100644 --- a/infer/bin/utils.py +++ b/infer/bin/utils.py @@ -7,12 +7,14 @@ from __future__ import division from __future__ import print_function from __future__ import unicode_literals +import argparse import csv import fnmatch import gzip import json import logging import os +import re import subprocess import sys import tempfile @@ -99,6 +101,12 @@ def error(msg): print(msg, file=sys.stderr) +def remove_bucket(bug_message): + """ Remove anything from the beginning if the message that + looks like a bucket """ + return re.sub(r'(^\[[a-zA-Z0-9]*\])', '', bug_message, 1) + + def get_cmd_in_bin_dir(binary_name): # this relies on the fact that utils.py is located in infer/bin return os.path.join( @@ -346,4 +354,10 @@ def create_json_report(out_dir): issues = rows[1:] json.dump([dict(zip(headers, row)) for row in issues], file_out) + +class AbsolutePathAction(argparse.Action): + """Convert a path from relative to absolute in the arg parser""" + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, os.path.abspath(values)) + # vim: set sw=4 ts=4 et: