blob: 141586281a7ef881b5491773c980603509233af5 [file] [log] [blame]
George Burgess IV853d65b2020-02-25 13:13:15 -08001# Copyright 2020 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Unit tests for tricium_clang_tidy.py."""
6
7import io
8import json
9import multiprocessing
10import os
11from pathlib import Path
12import subprocess
13import sys
14import tempfile
15from typing import NamedTuple
16from unittest import mock
17
18from chromite.lib import cros_test_lib
19from chromite.lib import osutils
20from chromite.scripts import tricium_clang_tidy
21
22assert sys.version_info >= (3, 6), 'This module requires Python 3.6+'
23
24
25class Replacement(NamedTuple):
26 """A YAML `tricium_clang_tidy.TidyReplacement`.
27
28 The data contained in YAML is slightly different than what `TidyReplacement`s
29 carry.
30 """
31 file_path: str
32 text: str
33 offset: int
34 length: int
35
36
37class Note(NamedTuple):
38 """A clang-tidy `note` from the YAML file."""
39 message: str
40 file_path: str
41 file_offset: int
42
43
44def default_tidy_diagnostic(file_path='/tidy/file.c',
45 line_number=1,
46 diag_name='${diag_name}',
47 message='${message}',
48 replacements=(),
49 expansion_locs=()):
50 """Creates a TidyDiagnostic with reasonable defaults.
51
52 Defaults here and yaml_diagnostic are generally intended to match where
53 possible.
54 """
55 return tricium_clang_tidy.TidyDiagnostic(
56 file_path=file_path,
57 line_number=line_number,
58 diag_name=diag_name,
59 message=message,
60 replacements=replacements,
61 expansion_locs=expansion_locs)
62
63
64def yaml_diagnostic(name='${diag_name}',
65 message='${message}',
66 file_path='/tidy/file.c',
67 file_offset=1,
68 replacements=(),
69 notes=()):
70 """Creates a diagnostic serializable as YAML with reasonable defaults."""
71 result = {
72 'DiagnosticName': name,
73 'DiagnosticMessage': {
74 'Message': message,
75 'FilePath': file_path,
76 'FileOffset': file_offset,
77 },
78 }
79
80 if replacements:
81 result['DiagnosticMessage']['Replacements'] = [{
82 'FilePath': x.file_path,
83 'Offset': x.offset,
84 'Length': x.length,
85 'ReplacementText': x.text,
86 } for x in replacements]
87
88 if notes:
89 result['Notes'] = [{
90 'Message': x.message,
91 'FilePath': x.file_path,
92 'FileOffset': x.file_offset,
93 } for x in notes]
94
95 return result
96
97
98def mocked_nop_realpath(f):
99 """Mocks os.path.realpath to just return its argument."""
100
101 @mock.patch.object(os.path, 'realpath')
102 @mock.patch.object(Path, 'resolve')
103 def inner(self, replace_mock, realpath_mock, *args, **kwargs):
104 """Mocker for realpath."""
105 identity = lambda x: x
106 realpath_mock.side_effect = identity
107 replace_mock.side_effect = identity
108 return f(self, *args, **kwargs)
109
110 return inner
111
112
113def mocked_readonly_open(contents=None, default=None):
114 """Mocks out open() so it always returns things from |contents|.
115
116 Writing to open'ed files is not supported.
117
118 Args:
119 contents: a |dict| mapping |file_path| => file_contents.
120 default: a default string to return if the given |file_path| doesn't
121 exist in |contents|.
122
123 Returns:
124 |contents[file_path]| if it exists; otherwise, |default|.
125
126 Raises:
127 If |default| is None and |contents[file_path]| does not exist, this will
128 raise a |ValueError|.
129 """
130
131 if contents is None:
132 contents = {}
133
134 def inner(f):
135 """mocked_open impl."""
136
137 @mock.mock_open()
138 def inner_inner(self, open_mock, *args, **kwargs):
139 """the impl of mocked_readonly_open's impl!"""
140
141 def get_data(file_path, mode='r', encoding=None):
142 """the impl of the impl of mocked_readonly_open's impl!!"""
143 data = contents.get(file_path, default)
144 if data is None:
145 raise ValueError('No %r file was found; options were %r' %
146 (file_path, sorted(contents.keys())))
147
148 assert mode == 'r', f"File mode {mode} isn't supported."
149 if encoding is None:
150 return io.BytesIO(data)
151 return io.StringIO(data)
152
153 open_mock.side_effect = get_data
154
155 def get_data_stream(file_path):
156 return io.StringIO(get_data(file_path))
157
158 open_mock.side_effect = get_data_stream
159 return f(self, *args, **kwargs)
160
161 return inner_inner
162
163 return inner
164
165
166class TriciumClangTidyTests(cros_test_lib.MockTestCase):
167 """Various tests for tricium support."""
168
169 def test_tidy_diagnostic_path_normalization(self):
170 expanded_from = tricium_clang_tidy.TidyExpandedFrom(
171 file_path=Path('/old2/foo'),
172 line_number=2,
173 )
174 diag = default_tidy_diagnostic(
175 file_path=Path('/old/foo'),
176 expansion_locs=(expanded_from,),
177 )
178
179 normalized = diag.normalize_paths_to('/new')
180 self.assertEqual(
181 normalized,
182 diag._replace(
183 file_path=Path('../old/foo'),
184 expansion_locs=(expanded_from._replace(
185 file_path=Path('../old2/foo')),),
186 ),
187 )
188
189 def test_line_offest_map_works(self):
190 # (input_char, line_number_of_char, line_offset_of_char)
191 line_offset_pairs = [
192 ('a', 1, 0),
193 ('b', 1, 1),
194 ('\n', 1, 2),
195 ('c', 2, 0),
196 ('\n', 2, 1),
197 ('\n', 3, 0),
198 ('d', 4, 0),
199 ('', 4, 1),
200 ('', 4, 2),
201 ]
202 text = tricium_clang_tidy.LineOffsetMap.for_text(''.join(
203 x for x, _, _ in line_offset_pairs))
204 for offset, (_, line_number, line_offset) in enumerate(line_offset_pairs):
205 self.assertEqual(text.get_line_number(offset), line_number)
206 self.assertEqual(text.get_line_offset(offset), line_offset)
207
208 def test_package_ebuild_resolution(self):
209 run_mock = self.StartPatcher(cros_test_lib.RunCommandMock())
210 run_mock.SetDefaultCmdResult(stdout='${package1_ebuild}\n')
211 ebuilds = tricium_clang_tidy.resolve_package_ebuilds(
212 '${board}',
213 [
214 'package1',
215 'package2.ebuild',
216 ],
217 )
218
219 run_mock.assertCommandContains(['equery-${board}', 'w', 'package1'],
220 check=True,
221 stdout=subprocess.PIPE,
222 encoding='utf-8')
223 self.assertEqual(ebuilds, ['${package1_ebuild}', 'package2.ebuild'])
224
225 @mocked_readonly_open(default='')
226 def test_parse_tidy_invocation_returns_exception_on_error(
227 self, read_file_mock):
228 oh_no = ValueError('${oh_no}!')
229 read_file_mock.side_effect = oh_no
230 result = tricium_clang_tidy.parse_tidy_invocation(
231 Path('/some/file/that/doesnt/exist.json'))
232 self.assertIn(str(oh_no), str(result))
233
234 @mocked_readonly_open({
235 '/file/path.json':
236 json.dumps({
237 'exit_code': 1,
238 'executable': '${clang_tidy}',
239 'args': ['foo', 'bar'],
240 'lint_target': '${target}',
241 'stdstreams': 'brrrrrrr',
242 'wd': '/path/to/wd',
243 }),
244 # |yaml.dumps| doesn't exist, but json parses cleanly as yaml, so...
245 '/file/path.yaml':
246 json.dumps({
247 'Diagnostics': [
248 yaml_diagnostic(
249 name='some-diag',
250 message='${message}',
251 file_path='',
252 ),
253 ]
254 }),
255 })
256 def test_parse_tidy_invocation_functions_on_success(self):
257 result = tricium_clang_tidy.parse_tidy_invocation('/file/path.json')
258 # If we got an |Exception|, print it out.
Mike Frysinger0828e9e2021-04-20 20:50:11 -0400259 self.assertNotIsInstance(result, tricium_clang_tidy.Error)
George Burgess IV853d65b2020-02-25 13:13:15 -0800260 meta, info = result
261 self.assertEqual(
262 meta,
263 tricium_clang_tidy.InvocationMetadata(
264 exit_code=1,
265 invocation=['${clang_tidy}', 'foo', 'bar'],
266 lint_target='${target}',
267 stdstreams='brrrrrrr',
268 wd='/path/to/wd',
269 ),
270 )
271
272 self.assertEqual(
273 info,
274 [
275 default_tidy_diagnostic(
276 diag_name='some-diag',
277 message='${message}',
278 file_path='',
279 ),
280 ],
281 )
282
283 @mocked_nop_realpath
284 @mocked_readonly_open(default='')
285 def test_parse_fixes_file_absolutizes_paths(self):
286 results = tricium_clang_tidy.parse_tidy_fixes_file(
287 '/tidy', {
288 'Diagnostics': [
289 yaml_diagnostic(file_path='foo.c'),
290 yaml_diagnostic(file_path='/tidy/bar.c'),
291 yaml_diagnostic(file_path=''),
292 ],
293 })
294 file_paths = [x.file_path for x in results]
295 self.assertEqual(file_paths, ['/tidy/foo.c', '/tidy/bar.c', ''])
296
297 @mocked_nop_realpath
298 @mocked_readonly_open({
299 '/tidy/foo.c': '',
300 '/tidy/foo.h': 'a\n\n',
301 })
302 def test_parse_fixes_file_interprets_offsets_correctly(self):
303 results = tricium_clang_tidy.parse_tidy_fixes_file(
304 '/tidy', {
305 'Diagnostics': [
306 yaml_diagnostic(file_path='/tidy/foo.c', file_offset=1),
307 yaml_diagnostic(file_path='/tidy/foo.c', file_offset=2),
308 yaml_diagnostic(file_path='/tidy/foo.h', file_offset=1),
309 yaml_diagnostic(file_path='/tidy/foo.h', file_offset=2),
310 yaml_diagnostic(file_path='/tidy/foo.h', file_offset=3),
311 ],
312 })
313 file_locations = [(x.file_path, x.line_number) for x in results]
314 self.assertEqual(file_locations, [
315 ('/tidy/foo.c', 1),
316 ('/tidy/foo.c', 1),
317 ('/tidy/foo.h', 1),
318 ('/tidy/foo.h', 2),
319 ('/tidy/foo.h', 3),
320 ])
321
322 @mocked_nop_realpath
323 @mocked_readonly_open({'/tidy/foo.c': 'a \n\n'})
324 def test_parse_fixes_file_handles_replacements(self):
325 results = list(
326 tricium_clang_tidy.parse_tidy_fixes_file(
327 '/tidy', {
328 'Diagnostics': [
329 yaml_diagnostic(
330 file_path='/tidy/foo.c',
331 file_offset=1,
332 replacements=[
333 Replacement(
334 file_path='foo.c',
335 text='whee',
336 offset=2,
337 length=2,
338 ),
339 ],
340 ),
341 ],
342 }))
343 self.assertEqual(len(results), 1, results)
344 self.assertEqual(
345 results[0].replacements,
346 (tricium_clang_tidy.TidyReplacement(
347 new_text='whee',
348 start_line=1,
349 end_line=3,
350 start_char=2,
351 end_char=0,
352 ),),
353 )
354
355 @mocked_nop_realpath
356 @mocked_readonly_open({'/whee.c': '', '/whee.h': '\n\n'})
357 def test_parse_fixes_file_handles_macro_expansions(self):
358 results = list(
359 tricium_clang_tidy.parse_tidy_fixes_file(
360 '/tidy', {
361 'Diagnostics': [
362 yaml_diagnostic(
363 file_path='/whee.c',
364 file_offset=1,
365 notes=[
366 Note(
367 message='not relevant',
368 file_path='/whee.c',
369 file_offset=1,
370 ),
371 Note(
372 message='expanded from macro "Foo"',
373 file_path='/whee.h',
374 file_offset=9,
375 ),
376 ],
377 ),
378 ],
379 }))
380 self.assertEqual(len(results), 1, results)
381 self.assertEqual(
382 results[0].expansion_locs,
383 (tricium_clang_tidy.TidyExpandedFrom(
384 file_path='/whee.h',
385 line_number=3,
386 ),),
387 )
388
389 @mock.patch.object(Path, 'glob')
390 @mock.patch.object(tricium_clang_tidy, 'parse_tidy_invocation')
391 def test_collect_lints_functions(self, parse_invocation_mock, glob_mock):
392 glob_mock.return_value = ('/lint/foo.json', '/lint/bar.json')
393
394 diag_1 = default_tidy_diagnostic()
395 diag_2 = diag_1._replace(line_number=diag_1.line_number + 1)
396 diag_3 = diag_2._replace(line_number=diag_2.line_number + 1)
397
398 # Because we want to test unique'ing, ensure these aren't equal.
399 all_diags = [diag_1, diag_2, diag_3]
400 self.assertEqual(sorted(all_diags), sorted(set(all_diags)))
401
402 per_file_lints = {
403 '/lint/foo.json': {diag_1, diag_2},
404 '/lint/bar.json': {diag_2, diag_3},
405 }
406
407 def parse_invocation_side_effect(json_file):
408 self.assertIn(json_file, per_file_lints)
409 meta = mock.Mock()
410 meta.exit_code = 0
411 return meta, per_file_lints[json_file]
412
413 parse_invocation_mock.side_effect = parse_invocation_side_effect
414
415 with multiprocessing.pool.ThreadPool(1) as yaml_pool:
416 lints = tricium_clang_tidy.collect_lints(Path('/lint'), yaml_pool)
417
418 self.assertEqual(set(all_diags), lints)
419
420 def test_filter_tidy_lints_filters_nothing_by_default(self):
421 basis = default_tidy_diagnostic()
422 diag2 = default_tidy_diagnostic(line_number=basis.line_number + 1)
423 diags = [basis, diag2]
424 diags.sort()
425
426 self.assertEqual(
427 diags,
428 tricium_clang_tidy.filter_tidy_lints(
429 only_files=None,
430 git_repo_base=None,
431 diags=diags,
432 ),
433 )
434
435 def test_filter_tidy_lints_filters_paths_outside_of_only_files(self):
436 in_only_files = default_tidy_diagnostic(file_path='foo.c')
437 out_of_only_files = default_tidy_diagnostic(file_path='bar.c')
438 self.assertEqual(
439 [in_only_files],
440 tricium_clang_tidy.filter_tidy_lints(
441 only_files={Path('foo.c')},
442 git_repo_base=None,
443 diags=[in_only_files, out_of_only_files],
444 ),
445 )
446
447 def test_filter_tidy_lints_normalizes_to_git_repo_baes(self):
448 git = default_tidy_diagnostic(file_path='/git/foo.c')
449 nogit = default_tidy_diagnostic(file_path='/nogit/bar.c')
450 self.assertEqual(
451 [git.normalize_paths_to('/git')],
452 tricium_clang_tidy.filter_tidy_lints(
453 only_files=None,
454 git_repo_base=Path('/git'),
455 diags=[git, nogit],
456 ),
457 )
458
459 def test_filter_tidy_lints_normalizes_and_restricts_properly(self):
460 git_and_only = default_tidy_diagnostic(file_path='/git/foo.c')
461 git_and_noonly = default_tidy_diagnostic(file_path='/git/bar.c')
462 self.assertEqual(
463 [git_and_only.normalize_paths_to('/git')],
464 tricium_clang_tidy.filter_tidy_lints(
465 only_files={Path('/git/foo.c')},
466 git_repo_base=Path('/git'),
467 diags=[git_and_only, git_and_noonly],
468 ),
469 )
470
471 @mock.patch.object(osutils, 'CopyDirContents')
472 @mock.patch.object(osutils, 'SafeMakedirs')
473 @mock.patch.object(osutils, 'RmDir')
474 def test_lint_generation_functions(self, rmdir_mock, safe_makedirs_mock,
475 copy_dir_contents_mock):
476 run_mock = self.StartPatcher(cros_test_lib.PopenMock())
477 run_mock.SetDefaultCmdResult()
478
479 # Mock mkdtemp last, since PopenMock() makes a tempdir.
480 mkdtemp_mock = self.PatchObject(tempfile, 'mkdtemp')
481 mkdtemp_path = '/path/to/temp/dir'
482 mkdtemp_mock.return_value = mkdtemp_path
483 dir_name = str(
484 tricium_clang_tidy.generate_lints('${board}', '/path/to/the.ebuild'))
485 self.assertEqual(mkdtemp_path, dir_name)
486
487 rmdir_mock.assert_called_with(
488 tricium_clang_tidy.LINT_BASE, ignore_missing=True, sudo=True)
489 safe_makedirs_mock.assert_called_with(
490 tricium_clang_tidy.LINT_BASE, 0o777, sudo=True)
491
492 desired_env = dict(os.environ)
493 desired_env['WITH_TIDY'] = 'tricium'
494 run_mock.assertCommandContains(
495 ['ebuild-${board}', '/path/to/the.ebuild', 'clean', 'compile'],
496 env=desired_env)
497
498 copy_dir_contents_mock.assert_called_with(tricium_clang_tidy.LINT_BASE,
499 dir_name)