| # Copyright 2020 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Unit tests for tricium_clang_tidy.py.""" |
| |
| import io |
| import json |
| import multiprocessing |
| import os |
| from pathlib import Path |
| import subprocess |
| import sys |
| import tempfile |
| from typing import NamedTuple |
| from unittest import mock |
| |
| from chromite.lib import cros_test_lib |
| from chromite.lib import osutils |
| from chromite.scripts import tricium_clang_tidy |
| |
| assert sys.version_info >= (3, 6), 'This module requires Python 3.6+' |
| |
| |
| class Replacement(NamedTuple): |
| """A YAML `tricium_clang_tidy.TidyReplacement`. |
| |
| The data contained in YAML is slightly different than what `TidyReplacement`s |
| carry. |
| """ |
| file_path: str |
| text: str |
| offset: int |
| length: int |
| |
| |
| class Note(NamedTuple): |
| """A clang-tidy `note` from the YAML file.""" |
| message: str |
| file_path: str |
| file_offset: int |
| |
| |
| def default_tidy_diagnostic(file_path='/tidy/file.c', |
| line_number=1, |
| diag_name='${diag_name}', |
| message='${message}', |
| replacements=(), |
| expansion_locs=()): |
| """Creates a TidyDiagnostic with reasonable defaults. |
| |
| Defaults here and yaml_diagnostic are generally intended to match where |
| possible. |
| """ |
| return tricium_clang_tidy.TidyDiagnostic( |
| file_path=file_path, |
| line_number=line_number, |
| diag_name=diag_name, |
| message=message, |
| replacements=replacements, |
| expansion_locs=expansion_locs) |
| |
| |
| def yaml_diagnostic(name='${diag_name}', |
| message='${message}', |
| file_path='/tidy/file.c', |
| file_offset=1, |
| replacements=(), |
| notes=()): |
| """Creates a diagnostic serializable as YAML with reasonable defaults.""" |
| result = { |
| 'DiagnosticName': name, |
| 'DiagnosticMessage': { |
| 'Message': message, |
| 'FilePath': file_path, |
| 'FileOffset': file_offset, |
| }, |
| } |
| |
| if replacements: |
| result['DiagnosticMessage']['Replacements'] = [{ |
| 'FilePath': x.file_path, |
| 'Offset': x.offset, |
| 'Length': x.length, |
| 'ReplacementText': x.text, |
| } for x in replacements] |
| |
| if notes: |
| result['Notes'] = [{ |
| 'Message': x.message, |
| 'FilePath': x.file_path, |
| 'FileOffset': x.file_offset, |
| } for x in notes] |
| |
| return result |
| |
| |
| def mocked_nop_realpath(f): |
| """Mocks os.path.realpath to just return its argument.""" |
| |
| @mock.patch.object(os.path, 'realpath') |
| @mock.patch.object(Path, 'resolve') |
| def inner(self, replace_mock, realpath_mock, *args, **kwargs): |
| """Mocker for realpath.""" |
| identity = lambda x: x |
| realpath_mock.side_effect = identity |
| replace_mock.side_effect = identity |
| return f(self, *args, **kwargs) |
| |
| return inner |
| |
| |
| def mocked_readonly_open(contents=None, default=None): |
| """Mocks out open() so it always returns things from |contents|. |
| |
| Writing to open'ed files is not supported. |
| |
| Args: |
| contents: a |dict| mapping |file_path| => file_contents. |
| default: a default string to return if the given |file_path| doesn't |
| exist in |contents|. |
| |
| Returns: |
| |contents[file_path]| if it exists; otherwise, |default|. |
| |
| Raises: |
| If |default| is None and |contents[file_path]| does not exist, this will |
| raise a |ValueError|. |
| """ |
| |
| if contents is None: |
| contents = {} |
| |
| def inner(f): |
| """mocked_open impl.""" |
| |
| @mock.mock_open() |
| def inner_inner(self, open_mock, *args, **kwargs): |
| """the impl of mocked_readonly_open's impl!""" |
| |
| def get_data(file_path, mode='r', encoding=None): |
| """the impl of the impl of mocked_readonly_open's impl!!""" |
| data = contents.get(file_path, default) |
| if data is None: |
| raise ValueError('No %r file was found; options were %r' % |
| (file_path, sorted(contents.keys()))) |
| |
| assert mode == 'r', f"File mode {mode} isn't supported." |
| if encoding is None: |
| return io.BytesIO(data) |
| return io.StringIO(data) |
| |
| open_mock.side_effect = get_data |
| |
| def get_data_stream(file_path): |
| return io.StringIO(get_data(file_path)) |
| |
| open_mock.side_effect = get_data_stream |
| return f(self, *args, **kwargs) |
| |
| return inner_inner |
| |
| return inner |
| |
| |
| class TriciumClangTidyTests(cros_test_lib.MockTestCase): |
| """Various tests for tricium support.""" |
| |
| def test_tidy_diagnostic_path_normalization(self): |
| expanded_from = tricium_clang_tidy.TidyExpandedFrom( |
| file_path=Path('/old2/foo'), |
| line_number=2, |
| ) |
| diag = default_tidy_diagnostic( |
| file_path=Path('/old/foo'), |
| expansion_locs=(expanded_from,), |
| ) |
| |
| normalized = diag.normalize_paths_to('/new') |
| self.assertEqual( |
| normalized, |
| diag._replace( |
| file_path=Path('../old/foo'), |
| expansion_locs=(expanded_from._replace( |
| file_path=Path('../old2/foo')),), |
| ), |
| ) |
| |
| def test_line_offest_map_works(self): |
| # (input_char, line_number_of_char, line_offset_of_char) |
| line_offset_pairs = [ |
| ('a', 1, 0), |
| ('b', 1, 1), |
| ('\n', 1, 2), |
| ('c', 2, 0), |
| ('\n', 2, 1), |
| ('\n', 3, 0), |
| ('d', 4, 0), |
| ('', 4, 1), |
| ('', 4, 2), |
| ] |
| text = tricium_clang_tidy.LineOffsetMap.for_text(''.join( |
| x for x, _, _ in line_offset_pairs)) |
| for offset, (_, line_number, line_offset) in enumerate(line_offset_pairs): |
| self.assertEqual(text.get_line_number(offset), line_number) |
| self.assertEqual(text.get_line_offset(offset), line_offset) |
| |
| def test_package_ebuild_resolution(self): |
| run_mock = self.StartPatcher(cros_test_lib.RunCommandMock()) |
| run_mock.SetDefaultCmdResult(stdout='${package1_ebuild}\n') |
| ebuilds = tricium_clang_tidy.resolve_package_ebuilds( |
| '${board}', |
| [ |
| 'package1', |
| 'package2.ebuild', |
| ], |
| ) |
| |
| run_mock.assertCommandContains(['equery-${board}', 'w', 'package1'], |
| check=True, |
| stdout=subprocess.PIPE, |
| encoding='utf-8') |
| self.assertEqual(ebuilds, ['${package1_ebuild}', 'package2.ebuild']) |
| |
| @mocked_readonly_open(default='') |
| def test_parse_tidy_invocation_returns_exception_on_error( |
| self, read_file_mock): |
| oh_no = ValueError('${oh_no}!') |
| read_file_mock.side_effect = oh_no |
| result = tricium_clang_tidy.parse_tidy_invocation( |
| Path('/some/file/that/doesnt/exist.json')) |
| self.assertIn(str(oh_no), str(result)) |
| |
| @mocked_readonly_open({ |
| '/file/path.json': |
| json.dumps({ |
| 'exit_code': 1, |
| 'executable': '${clang_tidy}', |
| 'args': ['foo', 'bar'], |
| 'lint_target': '${target}', |
| 'stdstreams': 'brrrrrrr', |
| 'wd': '/path/to/wd', |
| }), |
| # |yaml.dumps| doesn't exist, but json parses cleanly as yaml, so... |
| '/file/path.yaml': |
| json.dumps({ |
| 'Diagnostics': [ |
| yaml_diagnostic( |
| name='some-diag', |
| message='${message}', |
| file_path='', |
| ), |
| ] |
| }), |
| }) |
| def test_parse_tidy_invocation_functions_on_success(self): |
| result = tricium_clang_tidy.parse_tidy_invocation('/file/path.json') |
| # If we got an |Exception|, print it out. |
| self.assertFalse(isinstance(result, tricium_clang_tidy.Error), result) |
| meta, info = result |
| self.assertEqual( |
| meta, |
| tricium_clang_tidy.InvocationMetadata( |
| exit_code=1, |
| invocation=['${clang_tidy}', 'foo', 'bar'], |
| lint_target='${target}', |
| stdstreams='brrrrrrr', |
| wd='/path/to/wd', |
| ), |
| ) |
| |
| self.assertEqual( |
| info, |
| [ |
| default_tidy_diagnostic( |
| diag_name='some-diag', |
| message='${message}', |
| file_path='', |
| ), |
| ], |
| ) |
| |
| @mocked_nop_realpath |
| @mocked_readonly_open(default='') |
| def test_parse_fixes_file_absolutizes_paths(self): |
| results = tricium_clang_tidy.parse_tidy_fixes_file( |
| '/tidy', { |
| 'Diagnostics': [ |
| yaml_diagnostic(file_path='foo.c'), |
| yaml_diagnostic(file_path='/tidy/bar.c'), |
| yaml_diagnostic(file_path=''), |
| ], |
| }) |
| file_paths = [x.file_path for x in results] |
| self.assertEqual(file_paths, ['/tidy/foo.c', '/tidy/bar.c', '']) |
| |
| @mocked_nop_realpath |
| @mocked_readonly_open({ |
| '/tidy/foo.c': '', |
| '/tidy/foo.h': 'a\n\n', |
| }) |
| def test_parse_fixes_file_interprets_offsets_correctly(self): |
| results = tricium_clang_tidy.parse_tidy_fixes_file( |
| '/tidy', { |
| 'Diagnostics': [ |
| yaml_diagnostic(file_path='/tidy/foo.c', file_offset=1), |
| yaml_diagnostic(file_path='/tidy/foo.c', file_offset=2), |
| yaml_diagnostic(file_path='/tidy/foo.h', file_offset=1), |
| yaml_diagnostic(file_path='/tidy/foo.h', file_offset=2), |
| yaml_diagnostic(file_path='/tidy/foo.h', file_offset=3), |
| ], |
| }) |
| file_locations = [(x.file_path, x.line_number) for x in results] |
| self.assertEqual(file_locations, [ |
| ('/tidy/foo.c', 1), |
| ('/tidy/foo.c', 1), |
| ('/tidy/foo.h', 1), |
| ('/tidy/foo.h', 2), |
| ('/tidy/foo.h', 3), |
| ]) |
| |
| @mocked_nop_realpath |
| @mocked_readonly_open({'/tidy/foo.c': 'a \n\n'}) |
| def test_parse_fixes_file_handles_replacements(self): |
| results = list( |
| tricium_clang_tidy.parse_tidy_fixes_file( |
| '/tidy', { |
| 'Diagnostics': [ |
| yaml_diagnostic( |
| file_path='/tidy/foo.c', |
| file_offset=1, |
| replacements=[ |
| Replacement( |
| file_path='foo.c', |
| text='whee', |
| offset=2, |
| length=2, |
| ), |
| ], |
| ), |
| ], |
| })) |
| self.assertEqual(len(results), 1, results) |
| self.assertEqual( |
| results[0].replacements, |
| (tricium_clang_tidy.TidyReplacement( |
| new_text='whee', |
| start_line=1, |
| end_line=3, |
| start_char=2, |
| end_char=0, |
| ),), |
| ) |
| |
| @mocked_nop_realpath |
| @mocked_readonly_open({'/whee.c': '', '/whee.h': '\n\n'}) |
| def test_parse_fixes_file_handles_macro_expansions(self): |
| results = list( |
| tricium_clang_tidy.parse_tidy_fixes_file( |
| '/tidy', { |
| 'Diagnostics': [ |
| yaml_diagnostic( |
| file_path='/whee.c', |
| file_offset=1, |
| notes=[ |
| Note( |
| message='not relevant', |
| file_path='/whee.c', |
| file_offset=1, |
| ), |
| Note( |
| message='expanded from macro "Foo"', |
| file_path='/whee.h', |
| file_offset=9, |
| ), |
| ], |
| ), |
| ], |
| })) |
| self.assertEqual(len(results), 1, results) |
| self.assertEqual( |
| results[0].expansion_locs, |
| (tricium_clang_tidy.TidyExpandedFrom( |
| file_path='/whee.h', |
| line_number=3, |
| ),), |
| ) |
| |
| @mock.patch.object(Path, 'glob') |
| @mock.patch.object(tricium_clang_tidy, 'parse_tidy_invocation') |
| def test_collect_lints_functions(self, parse_invocation_mock, glob_mock): |
| glob_mock.return_value = ('/lint/foo.json', '/lint/bar.json') |
| |
| diag_1 = default_tidy_diagnostic() |
| diag_2 = diag_1._replace(line_number=diag_1.line_number + 1) |
| diag_3 = diag_2._replace(line_number=diag_2.line_number + 1) |
| |
| # Because we want to test unique'ing, ensure these aren't equal. |
| all_diags = [diag_1, diag_2, diag_3] |
| self.assertEqual(sorted(all_diags), sorted(set(all_diags))) |
| |
| per_file_lints = { |
| '/lint/foo.json': {diag_1, diag_2}, |
| '/lint/bar.json': {diag_2, diag_3}, |
| } |
| |
| def parse_invocation_side_effect(json_file): |
| self.assertIn(json_file, per_file_lints) |
| meta = mock.Mock() |
| meta.exit_code = 0 |
| return meta, per_file_lints[json_file] |
| |
| parse_invocation_mock.side_effect = parse_invocation_side_effect |
| |
| with multiprocessing.pool.ThreadPool(1) as yaml_pool: |
| lints = tricium_clang_tidy.collect_lints(Path('/lint'), yaml_pool) |
| |
| self.assertEqual(set(all_diags), lints) |
| |
| def test_filter_tidy_lints_filters_nothing_by_default(self): |
| basis = default_tidy_diagnostic() |
| diag2 = default_tidy_diagnostic(line_number=basis.line_number + 1) |
| diags = [basis, diag2] |
| diags.sort() |
| |
| self.assertEqual( |
| diags, |
| tricium_clang_tidy.filter_tidy_lints( |
| only_files=None, |
| git_repo_base=None, |
| diags=diags, |
| ), |
| ) |
| |
| def test_filter_tidy_lints_filters_paths_outside_of_only_files(self): |
| in_only_files = default_tidy_diagnostic(file_path='foo.c') |
| out_of_only_files = default_tidy_diagnostic(file_path='bar.c') |
| self.assertEqual( |
| [in_only_files], |
| tricium_clang_tidy.filter_tidy_lints( |
| only_files={Path('foo.c')}, |
| git_repo_base=None, |
| diags=[in_only_files, out_of_only_files], |
| ), |
| ) |
| |
| def test_filter_tidy_lints_normalizes_to_git_repo_baes(self): |
| git = default_tidy_diagnostic(file_path='/git/foo.c') |
| nogit = default_tidy_diagnostic(file_path='/nogit/bar.c') |
| self.assertEqual( |
| [git.normalize_paths_to('/git')], |
| tricium_clang_tidy.filter_tidy_lints( |
| only_files=None, |
| git_repo_base=Path('/git'), |
| diags=[git, nogit], |
| ), |
| ) |
| |
| def test_filter_tidy_lints_normalizes_and_restricts_properly(self): |
| git_and_only = default_tidy_diagnostic(file_path='/git/foo.c') |
| git_and_noonly = default_tidy_diagnostic(file_path='/git/bar.c') |
| self.assertEqual( |
| [git_and_only.normalize_paths_to('/git')], |
| tricium_clang_tidy.filter_tidy_lints( |
| only_files={Path('/git/foo.c')}, |
| git_repo_base=Path('/git'), |
| diags=[git_and_only, git_and_noonly], |
| ), |
| ) |
| |
| @mock.patch.object(osutils, 'CopyDirContents') |
| @mock.patch.object(osutils, 'SafeMakedirs') |
| @mock.patch.object(osutils, 'RmDir') |
| def test_lint_generation_functions(self, rmdir_mock, safe_makedirs_mock, |
| copy_dir_contents_mock): |
| run_mock = self.StartPatcher(cros_test_lib.PopenMock()) |
| run_mock.SetDefaultCmdResult() |
| |
| # Mock mkdtemp last, since PopenMock() makes a tempdir. |
| mkdtemp_mock = self.PatchObject(tempfile, 'mkdtemp') |
| mkdtemp_path = '/path/to/temp/dir' |
| mkdtemp_mock.return_value = mkdtemp_path |
| dir_name = str( |
| tricium_clang_tidy.generate_lints('${board}', '/path/to/the.ebuild')) |
| self.assertEqual(mkdtemp_path, dir_name) |
| |
| rmdir_mock.assert_called_with( |
| tricium_clang_tidy.LINT_BASE, ignore_missing=True, sudo=True) |
| safe_makedirs_mock.assert_called_with( |
| tricium_clang_tidy.LINT_BASE, 0o777, sudo=True) |
| |
| desired_env = dict(os.environ) |
| desired_env['WITH_TIDY'] = 'tricium' |
| run_mock.assertCommandContains( |
| ['ebuild-${board}', '/path/to/the.ebuild', 'clean', 'compile'], |
| env=desired_env) |
| |
| copy_dir_contents_mock.assert_called_with(tricium_clang_tidy.LINT_BASE, |
| dir_name) |