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