| # Copyright 2019 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from recipe_engine import recipe_api |
| |
| import os |
| |
| FORMATTING_MESSAGE = """File not formatted properly. |
| Run the following to format: |
| |
| """ |
| |
| |
| class TriciumAnalyzeApi(recipe_api.RecipeApi): |
| """API for running analyses on Tricium.""" |
| |
| def __init__(self, analyses, compdb_filtered_paths, *args, **kwargs): |
| super(TriciumAnalyzeApi, self).__init__(*args, **kwargs) |
| self._enabled = [a.lower() for a in analyses] |
| self._compdb_filtered_paths = compdb_filtered_paths |
| |
| self._ext_to_analysis = { |
| '.c': [self._ClangFormat, self._ClangTidy], |
| '.cc': [self._ClangFormat, self._ClangTidy], |
| '.cpp': [self._ClangFormat, self._ClangTidy], |
| '.dart': [self._DartFmt], |
| '.h': [self._ClangFormat, self._ClangTidy], |
| '.hh': [self._ClangFormat, self._ClangTidy], |
| '.hpp': [self._ClangFormat, self._ClangTidy], |
| '.fidl': [self._FidlFormat, self._FidlLint], |
| '.gn': [self._GNFormat], |
| '.gni': [self._GNFormat], |
| '.go': [self._GoFmt, self._GoVet], |
| '.py': [self._Yapf], |
| '.rs': [self._RustFmt], |
| '.star': [self._Yapf], |
| '.ts': [self._ClangFormat], |
| } |
| |
| # Track which analyses have been run, so that it can skip building/preprocessing. |
| self._analyses_run = [] |
| self.__gn_results = None |
| |
| # The paths to these tools may be set directly by the recipe. |
| self.go = None |
| self.gofmt = None |
| self.yapf = None |
| |
| self.checkout = None |
| # Whether to suggest the use of the fx tool. |
| # The tool only works properly when run in fuchsia.git or one of its sub-directories. |
| self.suggest_fx = True |
| |
| @property |
| def _gn_results(self): |
| if not self.__gn_results: |
| assert self.checkout |
| # If any build-based analyzers are desired on topaz, experiences, etc., |
| # we should add these build config parameters to properties. |
| self.__gn_results = self.m.build.gen( |
| checkout_root=self.checkout.root_dir, |
| fuchsia_build_dir=self.checkout.root_dir.join('out', 'default'), |
| target='x64', |
| build_type='release', |
| board='boards/x64.gni', |
| product='products/core.gni', |
| packages=['//bundles:kitchen_sink'], |
| export_compdb=True, |
| ) |
| return self.__gn_results |
| |
| def __call__(self, filename): |
| """Run the relevant language's analyses over a file and post Tricium comments |
| if errors are found. |
| |
| Args: |
| filename (Path): Path to file. |
| """ |
| with self.m.step.nest('analyze %s' % filename): |
| _, ext = self.m.path.splitext(filename) |
| if ext not in self._ext_to_analysis: |
| return |
| |
| for analysis in self._ext_to_analysis[ext]: |
| if analysis.__name__.lstrip('_').lower() in self._enabled: |
| analysis(filename) |
| |
| def _diff_format(self, |
| category, |
| filename, |
| cmd_format='fx format-code --files=%s'): |
| diff_result = self.m.git( |
| 'diff', |
| '--name-only', |
| name='check file formatting', |
| stdout=self.m.raw_io.output()).stdout.strip().split('\n') |
| |
| # Ideally we'd have a generic way to support self.suggest_fx == False in this |
| # function. However today there's only one analyzer that actually needs this, |
| # and restructuring the code around this use case would add complexity on net. |
| # If we start supporting this for many analyzers we should reconsider, perhaps |
| # by having a class per analyzer rather than just a function. |
| for filename in diff_result: |
| if filename: # ignore whitespace-only lines |
| self.m.tricium.add_comment( |
| 'Format/%s' % category, |
| '%s%s' % (FORMATTING_MESSAGE, cmd_format % filename), filename) |
| self.m.git('reset', '--hard', 'HEAD', name='reset') |
| |
| def _FidlFormat(self, filename): |
| assert self.checkout |
| # Fidl test files often purposefully formatted in unrecommended ways |
| # so they should be skipped. |
| if str(filename).endswith('.test.fidl'): |
| return |
| |
| with self.m.step.nest('fidl-format'): |
| # Both fidl-lint and fidl-format build the same thing. |
| if 'FidlLint' not in self._analyses_run and 'FidlFormat' not in self._analyses_run: |
| # Build the tool. |
| self.m.build.ninja( |
| gn_results=self._gn_results, |
| zircon_targets=['tools'], |
| build_canonical_zircon_targets=False, |
| build_fuchsia=False, |
| ) |
| |
| self.m.step('run', [ |
| self._gn_results.tool('fidl-format'), |
| '-i', |
| filename, |
| ]) |
| self._analyses_run.append('FidlFormat') |
| self._diff_format('FidlFormat', filename) |
| |
| def _GoFmt(self, filename): |
| with self.m.step.nest('gofmt'): |
| if not self.gofmt: |
| self.gofmt = self._gn_results.tool('gofmt') |
| self.m.step('run', [self.gofmt, '-w', '-s', filename]) |
| self._analyses_run.append('GoFmt') |
| self._diff_format('GoFmt', filename) |
| |
| def _GNFormat(self, filename): |
| with self.m.step.nest('gn format'): |
| self.m.step('run', [self._gn_results.tool('gn'), 'format', filename]) |
| self._analyses_run.append('GNFormat') |
| self._diff_format('GNFormat', filename) |
| |
| def _RustFmt(self, filename): |
| with self.m.step.nest('rustfmt'): |
| self.m.step('run', [ |
| self._gn_results.tool('rustfmt'), |
| filename, |
| ]) |
| self._analyses_run.append('RustFmt') |
| self._diff_format('RustFmt', filename) |
| |
| def _Yapf(self, filename): |
| with self.m.step.nest('yapf'): |
| if not self.yapf: |
| self.yapf = self._gn_results.tool('yapf') |
| self.m.step('run', [self.yapf, '--in-place', filename]) |
| self._analyses_run.append('YAPF') |
| |
| if self.suggest_fx: |
| self._diff_format('YAPF', filename) |
| else: |
| self._diff_format('YAPF', filename, cmd_format='yapf --in-place %s') |
| |
| def _DartFmt(self, filename): |
| with self.m.step.nest('dartfmt'): |
| self.m.step('run', [self._gn_results.tool('dartfmt'), '-w', filename]) |
| self._analyses_run.append('DartFmt') |
| self._diff_format('DartFmt', filename) |
| |
| def _ClangFormat(self, filename): |
| # If clang-format has been run already, it needn't be run again as it runs on the whole diff. |
| if 'ClangFormat' in self._analyses_run: |
| return |
| |
| with self.m.step.nest('clang-format'): |
| paths = self.m.git( |
| 'diff', |
| '-U0', |
| '--no-color', |
| 'HEAD^', |
| name='get changed files', |
| stdout=self.m.raw_io.output()) |
| |
| self.m.python( |
| name='clang-format-diff.py', |
| script=self._gn_results.tool('clang-format-diff'), |
| args=[ |
| '-p1', '-i', '-style=file', '-sort-includes', '-binary', |
| self._gn_results.tool('clang-format') |
| ], |
| stdin=self.m.raw_io.input_text(data=paths.stdout)) |
| |
| self._analyses_run.append('ClangFormat') |
| self._diff_format('ClangFormat', filename) |
| |
| def _capitalize_msg(self, message): |
| if not message or message[0].isupper(): |
| return message |
| return message[0].upper() + message[1:] |
| |
| def _FidlLint(self, filename): |
| assert self.checkout |
| # Fidl test files are often purposefully use syntax that does not follow |
| # linting rules so they should be skipped. |
| if str(filename).endswith('.test.fidl'): |
| return |
| |
| with self.m.step.nest('fidl-lint'): |
| # Both fidl-lint and fidl-format build the same thing. |
| if 'FidlLint' not in self._analyses_run and 'FidlFormat' not in self._analyses_run: |
| # Build the tool. |
| self.m.build.ninja( |
| gn_results=self._gn_results, |
| zircon_targets=['tools'], |
| build_canonical_zircon_targets=False, |
| build_fuchsia=False, |
| ) |
| |
| results = self.m.step( |
| 'run', [ |
| self._gn_results.tool('fidl-lint'), |
| '--format=json', |
| filename, |
| ], |
| ok_ret=(0, 1), |
| stdout=self.m.json.output()).stdout |
| |
| for result in results: |
| capitalized_msg = self._capitalize_msg(result['message']) + '.' |
| capitalized_desc = '' |
| for suggestion in result.get('suggestions', ()): |
| if 'description' in suggestion: |
| capitalized_desc += self._capitalize_msg( |
| suggestion['description']) + '. ' |
| if capitalized_desc: |
| capitalized_msg = capitalized_msg + ' ' + capitalized_desc[:-1] |
| self.m.tricium.add_comment( |
| 'Lint/FidlLint', |
| capitalized_msg, |
| # All file paths reported to tricium should be relative to the root of the git repo. |
| # The caller ensures that cwd is the root of the git repo. |
| os.path.relpath(result['path'], |
| self.m.path.abspath(self.m.context.cwd)), |
| start_line=result['start_line'], |
| start_char=result['start_char'], |
| end_line=result['end_line'], |
| end_char=result['end_char']) |
| |
| self._analyses_run.append('FidlLint') |
| |
| def _GoVet(self, filename): |
| with self.m.step.nest('go vet') as step: |
| cwd = self.m.context.cwd |
| with self.m.context(cwd=cwd.join(self.m.path.dirname(filename))): |
| if not self.go: |
| self.go = self._gn_results.tool('go') |
| stderr = self.m.step( |
| 'run', [self.go, 'vet', '-json'], |
| stderr=self.m.raw_io.output()).stderr |
| |
| stderr_lines = stderr.splitlines() |
| step.presentation.logs['stderr'] = stderr_lines |
| # Unfortunately `go vet -json` does not output only valid JSON, so |
| # we have to parse the output manually. |
| # Look at the test cases in examples/ for the expected output format. |
| result = None |
| result_lines = [] |
| for line in stderr_lines: |
| if result_lines: |
| result_lines.append(line) |
| # Ends the JSON object |
| if line == '}': |
| result = self.m.json.loads('\n'.join(result_lines)) |
| break |
| # Empty JSON object |
| elif line == '{}': |
| result = {} |
| break |
| # Start new non-empty JSON object |
| elif line == '{': |
| assert not result_lines |
| result_lines.append(line) |
| |
| assert result is not None |
| |
| for package in result: |
| for error in result[package]: |
| for warning in result[package][error]: |
| pos = warning['posn'].split(':') |
| self.m.tricium.add_comment( |
| 'Lint/GoVet', |
| warning['message'], |
| # All file paths reported to tricium should be relative to the root of the git repo. |
| # The caller ensures that cwd is the root of the git repo. |
| os.path.relpath(pos[0], self.m.path.abspath(cwd)), |
| start_line=int(pos[1]), |
| start_char=int(pos[2]), |
| end_line=int(pos[1]), |
| end_char=int(pos[2])) |
| self._analyses_run.append('GoVet') |
| |
| def _ClangTidy(self, filename): |
| del filename # unused |
| # If clang-tidy has been run already, it needn't be run again as it runs on the whole diff. |
| if 'ClangTidy' in self._analyses_run: |
| return |
| |
| assert self.checkout |
| |
| with self.m.step.nest('clang-tidy'): |
| self.m.build.ninja( |
| gn_results=self._gn_results, |
| zircon_targets=['tools'], |
| build_canonical_zircon_targets=False, |
| build_generated_sources=True, |
| ) |
| |
| compile_commands = self._gn_results.filtered_compdb( |
| self._compdb_filtered_paths) |
| |
| clang_tidy = self._gn_results.tool('clang-tidy') |
| clang_tidy_diff = self._gn_results.tool('clang-tidy-diff') |
| warnings_file = self.m.path['cleanup'].join('clang_tidy_fixes.yaml') |
| |
| with self.m.context(cwd=self.checkout.root_dir): |
| diff = self.m.git( |
| 'diff', |
| '-U0', |
| '--no-color', |
| 'HEAD^', |
| name='get changed files', |
| stdout=self.m.raw_io.output()) |
| clang_tidy_args = [ |
| '-p1', |
| '-path', |
| compile_commands, |
| '-export-fixes', |
| warnings_file, |
| '-clang-tidy-binary', |
| clang_tidy, |
| ] |
| |
| step_result = self.m.python( |
| name='clang-tidy-diff.py', |
| script=clang_tidy_diff, |
| args=clang_tidy_args, |
| stdin=self.m.raw_io.input_text(data=diff.stdout), |
| # This script may return 1 if there are compile |
| # errors -- that's okay, since this is a linter |
| # check. We'll log them below. |
| ok_ret='any', |
| venv=self.resource('clang-tidy-diff.vpython')) |
| |
| assert (step_result.retcode in (0, 1)) |
| if step_result.retcode == 1: |
| self.m.step.active_result.presentation.status = 'WARNING' |
| errors = self._parse_warnings(warnings_file) |
| |
| self.m.path.mock_add_paths('[START_DIR]/path/to/file.cpp') |
| # We iterate through all produced error sets... |
| for check in errors: |
| # ...and for each check, iterate through all the errors it produced... |
| for err in errors[check]: |
| # ...and extract the information from that error for a comment. |
| error_filepath = self.m.path.abspath( |
| self._gn_results.fuchsia_build_dir.join( |
| err['DiagnosticMessage']['FilePath'])) |
| if not self.m.path.exists( |
| error_filepath) or err['DiagnosticMessage']['FilePath'] == '': |
| continue # pragma: no cover |
| |
| # Extract the line and character for this warning. |
| sline, schar = self._get_line_from_offset( |
| error_filepath, err['DiagnosticMessage']['FileOffset']) |
| |
| # Add the comment to Tricium. |
| self.m.tricium.add_comment( |
| 'Lint/ClangTidy', |
| '%s: %s' % |
| (err['DiagnosticName'], err['DiagnosticMessage']['Message']), |
| # All file paths reported to tricium should be relative to the root of the git repo. |
| # The caller ensures that cwd is the root of the git repo. |
| os.path.relpath( |
| str(err['DiagnosticMessage']['FilePath']), |
| self.m.path.abspath(self.m.context.cwd)), |
| start_line=sline, |
| start_char=schar, |
| end_line=sline, |
| end_char=schar, |
| ) |
| |
| self._analyses_run.append('ClangTidy') |
| |
| def _parse_warnings(self, warnings_file): |
| """Parse all warnings output by clang-tidy. |
| |
| Clang-Tidy issues warnings as follows: |
| - DiagnosticName: 'check name' |
| Message: 'error message' |
| FileOffset: <offset (int)> |
| FilePath: 'file path' |
| Replacements: |
| - FilePath: 'replacement file path' |
| Offset: <replacement start offset (int)> |
| Length: <replacement length (int)> |
| ReplacementText: 'replacement text' |
| |
| Args: |
| raw_warnings (str): YAML-encoded warnings as output by the clang-tidy binary |
| |
| Returns: |
| A dict of parsed warnings by check. |
| Schema: |
| { |
| '<check name>': [ |
| { |
| 'DiagnosticName': 'check name' |
| 'Message': 'error message', |
| 'StartLine': <error start line (int)>, |
| 'StartChar': <error start char (int)>, |
| 'Replacements': [ |
| { |
| 'File': 'replacement file path', |
| 'StartLine': <replacement start line (int)>, |
| 'StartChar': <replacement start char (int)>, |
| 'EndLine': <replacement end line (int)>, |
| 'EndChar': <replacement end char (int)>, |
| 'Text': 'replacement text' |
| }, |
| ... |
| ] |
| }, |
| ... |
| ], |
| '<other check name>': [ ... ] |
| } |
| """ |
| self.m.path.mock_add_paths(warnings_file) |
| if not self.m.path.exists(warnings_file): |
| return {} # pragma: no cover |
| parsed_results = self.m.python( |
| 'load yaml', |
| self.resource('parse_yaml.py'), |
| args=[warnings_file], |
| stdout=self.m.json.output()).stdout |
| if not parsed_results: |
| return {} |
| all_warnings = {} |
| for warning in parsed_results['Diagnostics']: |
| if warning['DiagnosticName'] not in all_warnings: |
| all_warnings[warning['DiagnosticName']] = [] |
| all_warnings[warning['DiagnosticName']].append(warning) |
| return all_warnings |
| |
| def _get_line_from_offset(self, f, offset): |
| """Get the file line and char number from a file offset. |
| |
| Clang-Tidy emits warnings that mark the location of the error by the char |
| offset from the beginning of the file. This converts that number into a line |
| and char position. |
| |
| Args: |
| filename (str): Path to file. |
| offset (int): Offset to convert. |
| """ |
| file_data = self.m.file.read_text( |
| 'read %s' % f, f, test_data="""test |
| d |
| newlineoutput""") |
| line = 1 |
| char = 0 |
| for i, c in enumerate(file_data): |
| if c == '\n': |
| line += 1 |
| char = 0 |
| else: |
| char += 1 |
| if i + 1 == offset: |
| return line, char |
| return 0, 0 |