From 131fc795cac64bfbfb4c4d3bc94f54be278041c0 Mon Sep 17 00:00:00 2001 From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com> Date: Mon, 25 May 2026 15:37:08 +0200 Subject: [PATCH 1/5] Add initial Rust analysis support via Clippy --- .../codechecker_analyzer/analyzer_context.py | 9 + .../analyzers/analyzer_types.py | 2 + .../analyzers/clippy/__init__.py | 7 + .../analyzers/clippy/analyzer.py | 253 +++++++++++++ .../analyzers/clippy/config_handler.py | 18 + .../analyzers/clippy/result_handler.py | 97 +++++ analyzer/codechecker_analyzer/cli/analyze.py | 66 +++- analyzer/tests/unit/test_clippy_analyzer.py | 244 +++++++++++++ config/labels/analyzers/clippy.json | 20 ++ config/package_layout.json | 1 + .../analyzers/clippy/__init__.py | 7 + .../analyzers/clippy/analyzer_result.py | 340 ++++++++++++++++++ .../clippy_output_test_files/Cargo.toml | 4 + .../all.expected.plist | 288 +++++++++++++++ .../clippy_output_test_files/all.json | 5 + .../clippy_output_test_files/edge-cases.json | 7 + .../missing-source.json | 1 + .../clippy_output_test_files/src/lib.rs | 8 + .../unit/analyzers/test_clippy_parser.py | 166 +++++++++ 19 files changed, 1530 insertions(+), 13 deletions(-) create mode 100644 analyzer/codechecker_analyzer/analyzers/clippy/__init__.py create mode 100644 analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py create mode 100644 analyzer/codechecker_analyzer/analyzers/clippy/config_handler.py create mode 100644 analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py create mode 100644 analyzer/tests/unit/test_clippy_analyzer.py create mode 100644 config/labels/analyzers/clippy.json create mode 100644 tools/report-converter/codechecker_report_converter/analyzers/clippy/__init__.py create mode 100644 tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/Cargo.toml create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.expected.plist create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.json create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/edge-cases.json create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/missing-source.json create mode 100644 tools/report-converter/tests/unit/analyzers/clippy_output_test_files/src/lib.rs create mode 100644 tools/report-converter/tests/unit/analyzers/test_clippy_parser.py diff --git a/analyzer/codechecker_analyzer/analyzer_context.py b/analyzer/codechecker_analyzer/analyzer_context.py index 65c78ba17a..f12a1280ff 100644 --- a/analyzer/codechecker_analyzer/analyzer_context.py +++ b/analyzer/codechecker_analyzer/analyzer_context.py @@ -268,6 +268,15 @@ def __populate_analyzers(self): self.__analyzers[name] = os.path.realpath(compiler_binary) + # Rustup installs toolchain commands (cargo, rustc, + # cargo-clippy, ...) as symlinks to the rustup executable. + # Rustup dispatches based on argv[0], so invoking the realpath + # directly would run rustup itself instead of the requested + # tool. + if self.__analyzers[name].endswith('/rustup'): + self.__analyzers[name] = compiler_binary + continue + # If the compiler binary is a simlink to ccache, use the # original compiler binary. if self.__analyzers[name].endswith("/ccache"): diff --git a/analyzer/codechecker_analyzer/analyzers/analyzer_types.py b/analyzer/codechecker_analyzer/analyzers/analyzer_types.py index ccc8946b1b..8cbcc50618 100644 --- a/analyzer/codechecker_analyzer/analyzers/analyzer_types.py +++ b/analyzer/codechecker_analyzer/analyzers/analyzer_types.py @@ -24,6 +24,7 @@ from .clangtidy.analyzer import ClangTidy from .clangsa.analyzer import ClangSA from .cppcheck.analyzer import Cppcheck +from .clippy.analyzer import Clippy from .gcc.analyzer import Gcc from .infer.analyzer import Infer @@ -31,6 +32,7 @@ supported_analyzers = {ClangSA.ANALYZER_NAME: ClangSA, ClangTidy.ANALYZER_NAME: ClangTidy, + Clippy.ANALYZER_NAME: Clippy, Cppcheck.ANALYZER_NAME: Cppcheck, Gcc.ANALYZER_NAME: Gcc, Infer.ANALYZER_NAME: Infer diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/__init__.py b/analyzer/codechecker_analyzer/analyzers/clippy/__init__.py new file mode 100644 index 0000000000..4259749345 --- /dev/null +++ b/analyzer/codechecker_analyzer/analyzers/clippy/__init__.py @@ -0,0 +1,7 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py b/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py new file mode 100644 index 0000000000..7fc5112068 --- /dev/null +++ b/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py @@ -0,0 +1,253 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +import os +import re +import shutil +import subprocess +import sys +from typing import List, Optional + +from semver.version import Version + +from codechecker_analyzer import analyzer_context +from codechecker_analyzer.buildlog.build_action import BuildAction +from codechecker_common import util +from codechecker_common.logger import get_logger + +from .. import analyzer_base + +from .config_handler import ClippyConfigHandler +from .result_handler import ClippyResultHandler + + +LOG = get_logger('analyzer.clippy') + + +def create_cargo_build_action(manifest_path: str) -> BuildAction: + """ + Create a synthetic build action for Cargo-based Rust analysis. + """ + manifest_path = os.path.abspath(manifest_path) + project_dir = os.path.dirname(manifest_path) + command = f'cargo clippy --message-format=json --manifest-path ' \ + f'{manifest_path}' + + return BuildAction( + analyzer_options=[], + compiler_includes=[], + compiler_standard=None, + analyzer_type='', + original_command=command, + directory=project_dir, + output='', + lang='rust', + target='cargo', + source=manifest_path, + arch='', + action_type=BuildAction.COMPILE) + + +class Clippy(analyzer_base.SourceAnalyzer): + """ + Construct Cargo Clippy analyzer commands. + """ + + ANALYZER_NAME = 'clippy' + + @classmethod + def analyzer_binary(cls): + return analyzer_context.get_context().analyzer_binaries[cls.ANALYZER_NAME] + + def add_checker_config(self, _): + LOG.error('Checker configuration for Clippy is not implemented yet') + + def get_analyzer_mentioned_files(self, output): + return set() + + def construct_analyzer_cmd(self, result_handler): + config = self.config_handler + + analyzer_cmd = [ + Clippy.analyzer_binary(), + 'clippy', + '--message-format=json', + '--manifest-path', + self.source_file, + ] + + analyzer_cmd.extend(config.cargo_extra_arguments) + + if config.clippy_extra_arguments: + analyzer_cmd.append('--') + analyzer_cmd.extend(config.clippy_extra_arguments) + + LOG.debug_analyzer("Running analysis command '%s'", + ' '.join(analyzer_cmd)) + return analyzer_cmd + + def analyze(self, analyzer_cmd, res_handler, proc_callback=None, env=None): + result_handler = super().analyze(analyzer_cmd, res_handler, + proc_callback, env) + + # Compilation can possibly fail, but valid diagnostics can still be emitted. + # In such cases, we want to treat the analysis as successful and report the diagnostics. + if result_handler.analyzer_returncode != 0 and \ + self.__has_compiler_message(result_handler.analyzer_stdout): + + LOG.debug( + 'Cargo exited with %d, but emitted parseable diagnostics. ' + 'Treating Clippy analysis as successful.', + result_handler.analyzer_returncode, + ) + result_handler.analyzer_returncode = 0 + + return result_handler + + @classmethod + def get_analyzer_checkers(cls): + """ + Return representative Clippy and rustc checker groups. + + Clippy's complete lint list is toolchain-dependent and large. This + integration exposes stable groups and records the exact emitted + diagnostic code on each report. + """ + return [ + ('clippy', 'Clippy lint diagnostics'), + ('rustc', 'Rust compiler diagnostics') + ] + + @classmethod + def get_analyzer_config(cls) -> List[analyzer_base.AnalyzerConfig]: + # CodeChecker analyze /repo/Cargo.toml \ + # --analyzers clippy \ + # --analyzer-config clippy:cargo-args-file=/repo/.codechecker/cargo-args.txt \ + # --analyzer-config clippy:cc-verbatim-args-file=/repo/.codechecker/clippy-args.txt \ + # -o /tmp/repo-clippy + return [ + analyzer_base.AnalyzerConfig( + 'cargo-args-file', + 'A file path containing flags that are forwarded to cargo ' + 'clippy before "--". E.g.: cargo-args-file=', + util.ExistingPath, + ), + analyzer_base.AnalyzerConfig( + 'cc-verbatim-args-file', + 'A file path containing flags forwarded verbatim to ' + 'Clippy after "--". E.g.: cc-verbatim-args-file=', + util.ExistingPath, + ), + ] + + def post_analyze(self, result_handler: ClippyResultHandler): + """ + Run immediately after the analysis. + """ + pass + + @classmethod + def resolve_missing_binary(cls, configured_binary, environ): + return shutil.which(configured_binary, path=environ.get('PATH')) + + @classmethod + def get_binary_version(cls) -> Optional[Version]: + if not cls.analyzer_binary(): + return None + + environ = analyzer_context.get_context().get_env_for_bin( + cls.analyzer_binary()) + + try: + output = subprocess.check_output( + [cls.analyzer_binary(), 'clippy', '--version'], + env=environ, + encoding='utf-8', + errors='ignore', + ) + except (subprocess.CalledProcessError, OSError) as oerr: + LOG.warning( + 'Failed to get analyzer version: %s clippy --version', + cls.analyzer_binary() + ) + LOG.warning(oerr) + return None + + version_match = re.search(r'(\d+\.\d+\.\d+)', output) + if version_match: + return Version.parse(version_match.group(1)) + + return None + + @classmethod + def is_binary_version_incompatible(cls): + if cls.get_binary_version() is None: + return 'Cargo Clippy is unavailable or has an unsupported version.' + + return None + + def construct_result_handler( + self, + buildaction, + report_output, + skiplist_handler + ): + res_handler = ClippyResultHandler( + buildaction, report_output, self.config_handler.report_hash + ) + res_handler.skiplist_handler = skiplist_handler + res_handler.check_states = self.config_handler.checks() + return res_handler + + @classmethod + def construct_config_handler(cls, args): + handler = ClippyConfigHandler() + + if 'analyzer_config' in args and isinstance(args.analyzer_config, list): + for cfg in args.analyzer_config: + if cfg.analyzer != cls.ANALYZER_NAME: + continue + + try: + if cfg.option == 'cargo-args-file': + handler.cargo_extra_arguments = \ + util.load_args_from_file(cfg.value) + elif cfg.option == 'cc-verbatim-args-file': + handler.clippy_extra_arguments = \ + util.load_args_from_file(cfg.value) + except FileNotFoundError: + LOG.error('File not found: %s', cfg.value) + sys.exit(1) + + try: + cmdline_checkers = args.ordered_checkers + except AttributeError: + LOG.debug('No checkers were defined in the command line for %s', + cls.ANALYZER_NAME) + cmdline_checkers = [] + + handler.initialize_checkers( + cls.get_analyzer_checkers(), cmdline_checkers, + 'enable_all' in args and args.enable_all + ) + + return handler + + def __has_compiler_message(self, stdout: str) -> bool: + return '"reason":"compiler-message"' in stdout or \ + '"reason": "compiler-message"' in stdout + + +def find_cargo_manifest(path: str) -> Optional[str]: + """ + Return the input path if it is a direct Cargo manifest. + """ + if os.path.isfile(path) and os.path.basename(path) == 'Cargo.toml': + return path + + return None diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/config_handler.py b/analyzer/codechecker_analyzer/analyzers/clippy/config_handler.py new file mode 100644 index 0000000000..35e20db3ac --- /dev/null +++ b/analyzer/codechecker_analyzer/analyzers/clippy/config_handler.py @@ -0,0 +1,18 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +from .. import config_handler + + +class ClippyConfigHandler(config_handler.AnalyzerConfigHandler): + """Configuration handler for Clippy analyzer.""" + + def __init__(self): + super().__init__() + self.cargo_extra_arguments = [] + self.clippy_extra_arguments = [] diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py b/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py new file mode 100644 index 0000000000..4b569f841e --- /dev/null +++ b/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py @@ -0,0 +1,97 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +from pathlib import Path +from typing import Optional + +from codechecker_report_converter.analyzers.clippy.analyzer_result import \ + AnalyzerResult +from codechecker_report_converter.report.parser.base import AnalyzerInfo +from codechecker_report_converter.report import error_file, report_file +from codechecker_report_converter.report.hash import get_report_hash, HashType + +from codechecker_common.logger import get_logger +from codechecker_common.review_status_handler import ReviewStatusHandler +from codechecker_common.skiplist_handler import SkipListHandlers + +from ..config_handler import CheckerState +from ..result_handler_base import ResultHandler + + +LOG = get_logger('analyzer.clippy') + + +class ClippyResultHandler(ResultHandler): + """ + Create analyzer result file for Cargo/Clippy JSON output. + """ + + def __init__(self, *args, **kwargs): + self.analyzer_info = AnalyzerInfo(name=AnalyzerResult.TOOL_NAME) + self.clippy_analyzer_result = AnalyzerResult() + super().__init__(*args, **kwargs) + + def postprocess_result( + self, + skip_handlers: Optional[SkipListHandlers], + rs_handler: Optional[ReviewStatusHandler] + ): + """ + Generate CodeChecker plist output from Cargo JSON diagnostics. + """ + LOG.debug_analyzer(self.analyzer_stdout) + + clippy_out_folder = Path(self.workspace, 'clippy') + clippy_out_folder.mkdir(exist_ok=True) + clippy_dest_file_name = Path( + clippy_out_folder, + f'{Path(self.analyzed_source_file).name}' + f'{self.buildaction_hash}.json') + + with open(clippy_dest_file_name, 'w', encoding='utf-8') as result: + result.write(self.analyzer_stdout) + + reports = self.clippy_analyzer_result.get_reports(clippy_dest_file_name) + reports = [r for r in reports + if self.__checker_enabled(r.checker_name) and not r.skip(skip_handlers)] + + hash_type = HashType.PATH_SENSITIVE + if self.report_hash_type == 'context-free-v2': + hash_type = HashType.CONTEXT_FREE + elif self.report_hash_type == 'diagnostic-message': + hash_type = HashType.DIAGNOSTIC_MESSAGE + + for report in reports: + report.report_hash = get_report_hash(report, hash_type) + + if rs_handler: + reports = [r for r in reports if not rs_handler.should_ignore(r)] + + report_file.create( + self.analyzer_result_file, reports, self.checker_labels, + self.analyzer_info) + + error_file.update( + self.analyzer_result_file, self.analyzer_returncode, + self.analyzer_info, self.analyzer_cmd, + self.analyzer_stdout, self.analyzer_stderr) + + def __checker_enabled(self, checker_name: str) -> bool: + check_states = getattr(self, 'check_states', None) + if check_states is None: + return True + + if checker_name == 'clippy' or checker_name.startswith('clippy-'): + state = check_states.get('clippy', (CheckerState.ENABLED, ''))[0] + return state == CheckerState.ENABLED + + if checker_name == 'rustc' or checker_name.startswith('rustc-'): + state = check_states.get('rustc', (CheckerState.ENABLED, ''))[0] + return state == CheckerState.ENABLED + + return True diff --git a/analyzer/codechecker_analyzer/cli/analyze.py b/analyzer/codechecker_analyzer/cli/analyze.py index 69467af6c6..281cd5abae 100644 --- a/analyzer/codechecker_analyzer/cli/analyze.py +++ b/analyzer/codechecker_analyzer/cli/analyze.py @@ -24,12 +24,13 @@ from codechecker_analyzer import analyzer, analyzer_context, \ compilation_database from codechecker_analyzer.analyzers import analyzer_types, clangsa +from codechecker_analyzer.analyzers.clippy.analyzer import \ + Clippy, create_cargo_build_action, find_cargo_manifest from codechecker_analyzer.arg import \ OrderedCheckersAction, OrderedConfigAction, existing_abspath, \ analyzer_config, checker_config, AnalyzerConfigArg, CheckerConfigArg from codechecker_analyzer.buildlog import log_parser - from codechecker_common import arg, logger, cmd_config, review_status_handler from codechecker_common.compatibility.multiprocessing import cpu_count from codechecker_common.skiplist_handler import SkipListHandler, \ @@ -41,6 +42,25 @@ header_file_extensions = ( '.h', '.hh', '.H', '.hp', '.hxx', '.hpp', '.HPP', '.h++', '.tcc') + +def __is_clippy_selected(args) -> bool: + """ + Return whether Clippy was selected explicitly. + """ + return bool(args.analyzers) and Clippy.ANALYZER_NAME in args.analyzers + + +def __is_clippy_selected_or_valid_to_deduce(args) -> bool: + """ + Return whether Clippy was selected explicitly or it is valid to deduce that it should be run. + + If no analyzers were specified, + we can assume it is safe to run Clippy + if the input is a Cargo manifest. + """ + return not args.analyzers or __is_clippy_selected(args) + + EPILOG_ENV_VAR = """ CC_ANALYZERS_FROM_PATH Set to `yes` or `1` to enforce taking the analyzers from the `PATH` instead of the given binaries. @@ -1332,8 +1352,27 @@ def main(args): sys.exit(1) compiler_info_file = args.compiler_info_file - compile_commands = \ - compilation_database.gather_compilation_database(args.input) + actions = None + skipped_cmp_cmd_count = 0 + cargo_manifest_path = find_cargo_manifest(args.input) \ + if __is_clippy_selected_or_valid_to_deduce(args) else None + + if cargo_manifest_path: + actions = [create_cargo_build_action(cargo_manifest_path)] + compile_commands = [actions[0].to_dict()] + + if not args.analyzers: + args.analyzers = [Clippy.ANALYZER_NAME] + else: + compile_commands = \ + compilation_database.gather_compilation_database(args.input) + + if __is_clippy_selected(args) and not cargo_manifest_path: + LOG.error( + "Clippy analysis requires direct Cargo.toml input, got '%s'.", + args.input) + sys.exit(1) + if compile_commands is None: LOG.error(f"Found no compilation commands in '{args.input}'") sys.exit(1) @@ -1402,16 +1441,17 @@ def main(args): LOG.debug("args: %s", str(args)) LOG.debug("Output will be stored to: '%s'", args.output_path) - actions, skipped_cmp_cmd_count = log_parser.parse_unique_log( - compile_commands, - args.compile_uniqueing, - compiler_info_file, - args.keep_gcc_include_fixed, - args.keep_gcc_intrin, - args.jobs, - skip_handlers, - pre_analysis_skip_handlers, - ctu_or_stats_enabled) + if actions is None: + actions, skipped_cmp_cmd_count = log_parser.parse_unique_log( + compile_commands, + args.compile_uniqueing, + compiler_info_file, + args.keep_gcc_include_fixed, + args.keep_gcc_intrin, + args.jobs, + skip_handlers, + pre_analysis_skip_handlers, + ctu_or_stats_enabled) if not actions: LOG.warning("No analysis is required.") diff --git a/analyzer/tests/unit/test_clippy_analyzer.py b/analyzer/tests/unit/test_clippy_analyzer.py new file mode 100644 index 0000000000..9e53717eaf --- /dev/null +++ b/analyzer/tests/unit/test_clippy_analyzer.py @@ -0,0 +1,244 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +""" +Test Clippy analyzer helpers. +""" + +import os +import tempfile +import unittest +from unittest import mock + +from codechecker_analyzer.analyzers import analyzer_base +from codechecker_analyzer.analyzers.clippy.analyzer import \ + Clippy, create_cargo_build_action, find_cargo_manifest +from codechecker_analyzer.analyzers.config_handler import CheckerState +from codechecker_analyzer.arg import AnalyzerConfigArg +from codechecker_analyzer.buildlog.build_action import BuildAction +from codechecker_common.checker_labels import CheckerLabels + + +class Args(dict): + """ + Test helper which behaves like CodeChecker's argparse namespace. + """ + + def __getattr__(self, attr): + return self[attr] + + +class ClippyAnalyzerTest(unittest.TestCase): + """ + Test Cargo manifest input handling. + """ + + def test_find_cargo_manifest_accepts_manifest_file(self): + """ + Cargo analysis accepts direct Cargo.toml input. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + with open(manifest, 'w', encoding='utf-8') as cargo_toml: + cargo_toml.write('[package]\nname = "sample"\n') + + self.assertEqual(find_cargo_manifest(manifest), manifest) + + def test_find_cargo_manifest_rejects_directory(self): + """ + Cargo analysis does not consume directories implicitly. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + with open(manifest, 'w', encoding='utf-8') as cargo_toml: + cargo_toml.write('[package]\nname = "sample"\n') + + self.assertIsNone(find_cargo_manifest(tmp_dir)) + + def test_construct_analyzer_cmd_orders_cargo_and_clippy_args(self): + """ + Cargo arguments are placed before Clippy verbatim arguments. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [], + 'ordered_checkers': [], + 'enable_all': False + })) + handler.cargo_extra_arguments = ['--workspace', '--all-targets'] + handler.clippy_extra_arguments = [ + '-W', 'clippy::pedantic', + '-A', 'clippy::too_many_arguments'] + + analyzer = Clippy(handler, create_cargo_build_action(manifest)) + analyzer.source_file = manifest + + with mock.patch.object( + Clippy, 'analyzer_binary', return_value='cargo'): + cmd = analyzer.construct_analyzer_cmd(None) + + self.assertEqual(cmd, [ + 'cargo', + 'clippy', + '--message-format=json', + '--manifest-path', + manifest, + '--workspace', + '--all-targets', + '--', + '-W', + 'clippy::pedantic', + '-A', + 'clippy::too_many_arguments']) + + def test_construct_analyzer_cmd_omits_separator_without_clippy_args(self): + """ + Cargo command does not contain '--' unless Clippy args are present. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [], + 'ordered_checkers': [], + 'enable_all': False + })) + analyzer = Clippy(handler, create_cargo_build_action(manifest)) + analyzer.source_file = manifest + + with mock.patch.object( + Clippy, 'analyzer_binary', return_value='cargo'): + cmd = analyzer.construct_analyzer_cmd(None) + + self.assertNotIn('--', cmd) + + def test_construct_config_handler_loads_argument_files(self): + """ + Analyzer config files populate Cargo and Clippy argument lists. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + cargo_args = os.path.join(tmp_dir, 'cargo-args.txt') + clippy_args = os.path.join(tmp_dir, 'clippy-args.txt') + + with open(cargo_args, 'w', encoding='utf-8') as args_file: + args_file.write('--features type-error --all-targets') + with open(clippy_args, 'w', encoding='utf-8') as args_file: + args_file.write('-W clippy::pedantic') + + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [ + AnalyzerConfigArg( + 'clippy', 'cargo-args-file', cargo_args), + AnalyzerConfigArg( + 'clippy', 'cc-verbatim-args-file', clippy_args), + AnalyzerConfigArg( + 'clang-tidy', 'cc-verbatim-args-file', clippy_args) + ], + 'ordered_checkers': [], + 'enable_all': False + })) + + self.assertEqual(handler.cargo_extra_arguments, [ + '--features', + 'type-error', + '--all-targets']) + self.assertEqual(handler.clippy_extra_arguments, [ + '-W', + 'clippy::pedantic']) + + def test_construct_result_handler_keeps_checker_states(self): + """ + Result handler receives group checker state for report filtering. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [], + 'ordered_checkers': [('rustc', False)], + 'enable_all': False + })) + analyzer = Clippy(handler, create_cargo_build_action(manifest)) + + result_handler = analyzer.construct_result_handler( + analyzer.buildaction, tmp_dir, None) + + self.assertEqual( + result_handler.check_states['rustc'][0], + CheckerState.DISABLED) + self.assertEqual( + result_handler.check_states['clippy'][0], + CheckerState.ENABLED) + + def test_analyze_accepts_nonzero_returncode_with_json_diagnostics(self): + """ + Cargo build errors are accepted when JSON diagnostics were emitted. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [], + 'ordered_checkers': [], + 'enable_all': False + })) + analyzer = Clippy(handler, create_cargo_build_action(manifest)) + result_handler = analyzer.construct_result_handler( + analyzer.buildaction, tmp_dir, None) + result_handler.analyzer_returncode = 101 + result_handler.analyzer_stdout = '{"reason":"compiler-message"}' + + with mock.patch.object( + analyzer_base.SourceAnalyzer, 'analyze', + return_value=result_handler): + analyzer.analyze([], result_handler) + + self.assertEqual(result_handler.analyzer_returncode, 0) + + def test_analyze_keeps_nonzero_returncode_without_json_diagnostics(self): + """ + Cargo infrastructure errors remain analyzer failures. + """ + with tempfile.TemporaryDirectory() as tmp_dir: + manifest = os.path.join(tmp_dir, 'Cargo.toml') + handler = Clippy.construct_config_handler(Args({ + 'analyzer_config': [], + 'ordered_checkers': [], + 'enable_all': False + })) + analyzer = Clippy(handler, create_cargo_build_action(manifest)) + result_handler = analyzer.construct_result_handler( + analyzer.buildaction, tmp_dir, None) + result_handler.analyzer_returncode = 101 + result_handler.analyzer_stdout = 'cargo failed before analysis' + + with mock.patch.object( + analyzer_base.SourceAnalyzer, 'analyze', + return_value=result_handler): + analyzer.analyze([], result_handler) + + self.assertEqual(result_handler.analyzer_returncode, 101) + + def test_checker_labels_cover_dynamic_diagnostics(self): + """ + Clippy labels classify dynamic Clippy and rustc checker names. + """ + labels = CheckerLabels(os.path.join( + os.environ['REPO_ROOT'], 'config', 'labels')) + + self.assertEqual( + labels.severity('clippy-bind-instead-of-map', 'clippy'), + 'LOW') + self.assertEqual( + labels.severity('rustc-unused-variables', 'clippy'), + 'LOW') + self.assertEqual( + labels.severity('rustc-E0308', 'clippy'), + 'CRITICAL') + + +if __name__ == '__main__': + unittest.main() diff --git a/config/labels/analyzers/clippy.json b/config/labels/analyzers/clippy.json new file mode 100644 index 0000000000..8942ebf5cd --- /dev/null +++ b/config/labels/analyzers/clippy.json @@ -0,0 +1,20 @@ +{ + "analyzer": "clippy", + "labels": { + "clippy": [ + "profile:default", + "severity:LOW", + "doc_url:https://rust-lang.github.io/rust-clippy/" + ], + "rustc-E": [ + "profile:default", + "severity:CRITICAL", + "doc_url:https://doc.rust-lang.org/error_codes/error-index.html" + ], + "rustc": [ + "profile:default", + "severity:LOW", + "doc_url:https://doc.rust-lang.org/rustc/lints/index.html" + ] + } +} diff --git a/config/package_layout.json b/config/package_layout.json index d38cc59509..8e12706f9c 100644 --- a/config/package_layout.json +++ b/config/package_layout.json @@ -3,6 +3,7 @@ "analyzers": { "clangsa": "clang", "clang-tidy": "clang-tidy", + "clippy": "cargo", "cppcheck": "cppcheck", "gcc": "g++", "infer": "infer" diff --git a/tools/report-converter/codechecker_report_converter/analyzers/clippy/__init__.py b/tools/report-converter/codechecker_report_converter/analyzers/clippy/__init__.py new file mode 100644 index 0000000000..4259749345 --- /dev/null +++ b/tools/report-converter/codechecker_report_converter/analyzers/clippy/__init__.py @@ -0,0 +1,7 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- diff --git a/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py b/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py new file mode 100644 index 0000000000..39f27b4fce --- /dev/null +++ b/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py @@ -0,0 +1,340 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +import json +import logging +import os + +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +from codechecker_report_converter.report import BugPathEvent, File, \ + get_or_create_file, Range, Report + +from ..analyzer_result import AnalyzerResultBase + + +LOG = logging.getLogger('report-converter') + + +CLIPPY_CHECKER = 'clippy' +RUSTC_CHECKER = 'rustc' + + +def actual_name_to_codechecker_name(checker_name: str) -> str: + """ + Normalize Rust diagnostic codes to CodeChecker checker names. + """ + if checker_name == CLIPPY_CHECKER: + return CLIPPY_CHECKER + + if checker_name.startswith('clippy::'): + return 'clippy-' + checker_name[len('clippy::'):].replace('_', '-') + + if checker_name.startswith('E') and checker_name[1:].isdigit(): + return f'{RUSTC_CHECKER}-{checker_name}' + + return f'{RUSTC_CHECKER}-' + checker_name.replace('_', '-') + + +class AnalyzerResult(AnalyzerResultBase): + """ + Transform Cargo JSON diagnostics emitted by Clippy. + """ + + TOOL_NAME = CLIPPY_CHECKER + NAME = 'Clippy' + URL = 'https://github.com/rust-lang/rust-clippy' + + def get_reports(self, file_path: str) -> List[Report]: + """ + Get reports from Cargo JSON message output. + """ + if not os.path.isfile(file_path): + LOG.error("Report file does not exist: %s", file_path) + return [] + + result_dir = os.path.dirname(os.path.abspath(file_path)) + file_cache: Dict[str, File] = {} + reports: List[Report] = [] + + for cargo_msg in self.__load_cargo_messages(file_path): + if cargo_msg.get('reason') != 'compiler-message': + continue + + diag = cargo_msg.get('message', {}) + if not isinstance(diag, dict): + LOG.debug( + "Skipping cargo message with non-object diagnostic: %s", + file_path) + continue + + report = self.__diagnostic_to_report( + diag, cargo_msg, result_dir, file_cache) + if report: + reports.append(report) + + return reports + + def __load_cargo_messages(self, file_path: str) -> Iterable[Dict]: + """ + Get JSON objects from Cargo's JSON-lines output. + """ + try: + with open( + file_path, 'r', encoding='utf-8', errors='ignore' + ) as output: + for line_no, line in enumerate(output, 1): + line = line.strip() + if not line: + continue + + try: + msg = json.loads(line) + if isinstance(msg, dict): + yield msg + else: + LOG.debug( + "Skipping non-object cargo JSON at %s:%d.", + file_path, line_no) + except json.decoder.JSONDecodeError: + LOG.debug("Skipping non-JSON cargo output at %s:%d.", + file_path, line_no) + except OSError as ex: + LOG.error("Failed to read the given analyzer result '%s'.", + file_path) + LOG.error(ex) + + def __diagnostic_to_report( + self, + diag: Dict, + cargo_msg: Dict, + result_dir: str, + file_cache: Dict[str, File] + ) -> Optional[Report]: + primary_span = self.__select_primary_span(diag.get('spans', [])) + if not primary_span: + return None + + source_file = self.__resolve_span_file(primary_span, cargo_msg, result_dir) + if not source_file: + return None + + if not os.path.isfile(source_file): + LOG.warning("Source file does not exist: %s", source_file) + return None + + checker_name = self.__checker_name(diag) + file = get_or_create_file(source_file, file_cache) + rng = self.__span_range(primary_span) + + notes = self.__collect_notes( + diag, primary_span, cargo_msg, result_dir, file_cache) + + return Report( + file, + rng.start_line, + rng.start_col, + self.__diagnostic_message(diag), + checker_name, + severity=self.__severity(diag.get('level')), + bug_path_events=[ + BugPathEvent( + self.__diagnostic_message(diag), + file, + rng.start_line, + rng.start_col, + rng) + ], + notes=notes) + + def __select_primary_span(self, spans: Any) -> Optional[Dict]: + if not isinstance(spans, list): + return None + + for span in spans: + if not isinstance(span, dict): + continue + + if span.get('is_primary') and span.get('line_start'): + return span + + for span in spans: + if not isinstance(span, dict): + continue + + if span.get('line_start'): + return span + + return None + + def __collect_notes( + self, + diag: Dict, + primary_span: Dict, + cargo_msg: Dict, + result_dir: str, + file_cache: Dict[str, File] + ) -> List[BugPathEvent]: + notes: List[BugPathEvent] = [] + seen_notes: Set[Tuple[str, int, int, str]] = set() + + def append_note(note: Optional[BugPathEvent]) -> None: + if not note: + return + + key = (note.file.original_path, note.line, + note.column, note.message) + if key not in seen_notes: + seen_notes.add(key) + notes.append(note) + + spans = diag.get('spans', []) + if not isinstance(spans, list): + spans = [] + + for span in spans: + if not isinstance(span, dict): + continue + + if span is primary_span: + continue + + note = self.__span_to_note(span, span.get('label'), + cargo_msg, result_dir, file_cache) + append_note(note) + + children = diag.get('children', []) + if not isinstance(children, list): + children = [] + + for child in children: + if not isinstance(child, dict): + continue + + child_spans = child.get('spans', []) + if not isinstance(child_spans, list): + child_spans = [] + + if child_spans: + for span in child_spans: + if not isinstance(span, dict): + continue + + note = self.__span_to_note( + span, + self.__child_message(child, span), + cargo_msg, + result_dir, + file_cache) + append_note(note) + elif child.get('message'): + primary_note = self.__span_to_note( + primary_span, + child.get('message'), + cargo_msg, + result_dir, + file_cache) + append_note(primary_note) + + return notes + + def __span_to_note( + self, + span: Dict, + msg: Optional[str], + cargo_msg: Dict, + result_dir: str, + file_cache: Dict[str, File] + ) -> Optional[BugPathEvent]: + if not msg: + return None + + source_file = self.__resolve_span_file(span, cargo_msg, result_dir) + if not source_file: + return None + + if not os.path.isfile(source_file): + LOG.debug("Source file does not exist: %s", source_file) + return None + + file = get_or_create_file(source_file, file_cache) + rng = self.__span_range(span) + + return BugPathEvent(msg, file, rng.start_line, rng.start_col, rng) + + def __child_message(self, child: Dict, span: Dict) -> str: + msg = child.get('message') + if not isinstance(msg, str): + msg = '' + + replacement = span.get('suggested_replacement') + + if isinstance(replacement, str) and replacement: + return f'{msg} "{replacement}"' + + return msg + + def __span_range(self, span: Dict) -> Range: + start_line = int(span.get('line_start') or 1) + start_col = int(span.get('column_start') or 1) + end_line = int(span.get('line_end') or start_line) + end_col = int(span.get('column_end') or start_col) + + return Range(start_line, start_col, end_line, end_col) + + def __resolve_span_file( + self, + span: Dict, + cargo_msg: Dict, + result_dir: str + ) -> Optional[str]: + file_name = span.get('file_name') + if not file_name or not isinstance(file_name, str): + return None + + if os.path.isabs(file_name): + return os.path.normpath(file_name) + + manifest_path = cargo_msg.get('manifest_path') + if isinstance(manifest_path, str) and manifest_path: + if not os.path.isabs(manifest_path): + manifest_path = os.path.join(result_dir, manifest_path) + + return os.path.normpath( + os.path.join(os.path.dirname(manifest_path), file_name)) + + return os.path.normpath(os.path.join(result_dir, file_name)) + + def __checker_name(self, diag: Dict) -> str: + code = diag.get('code') or {} + if isinstance(code, dict): + diagnostic_code = code.get('code') + if isinstance(diagnostic_code, str) and diagnostic_code: + return actual_name_to_codechecker_name(diagnostic_code) + + if diag.get('level') == 'error': + return RUSTC_CHECKER + + return CLIPPY_CHECKER + + def __diagnostic_message(self, diag: Dict) -> str: + message = diag.get('message') + + return message if isinstance(message, str) else '' + + def __severity(self, level: Optional[str]) -> str: + if level == 'error': + return 'CRITICAL' + + if level == 'warning': + return 'LOW' + + if level in ('note', 'help'): + return 'STYLE' + + return 'UNSPECIFIED' diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/Cargo.toml b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/Cargo.toml new file mode 100644 index 0000000000..f0472973c1 --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "clippy-fixture" +version = "0.1.0" +edition = "2021" diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.expected.plist b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.expected.plist new file mode 100644 index 0000000000..09ade0cd3f --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.expected.plist @@ -0,0 +1,288 @@ + + + + + diagnostics + + + category + unknown + check_name + clippy-bind-instead-of-map + description + using `Option.and_then` can be written more cleanly with `map` + issue_hash_content_of_line_in_context + a366bbb71df0dc8d4175f731fb0a2fa5 + location + + col + 18 + file + 0 + line + 3 + + notes + + + location + + col + 18 + file + 0 + line + 3 + + message + try "maybe.map(|value| value.len())" + ranges + + + + col + 18 + file + 0 + line + 3 + + + col + 57 + file + 0 + line + 3 + + + + + + path + + + depth + 0 + kind + event + location + + col + 18 + file + 0 + line + 3 + + message + using `Option.and_then` can be written more cleanly with `map` + ranges + + + + col + 18 + file + 0 + line + 3 + + + col + 57 + file + 0 + line + 3 + + + + + + type + clippy + + + category + unknown + check_name + rustc-unused-variables + description + unused variable: `unused_value` + issue_hash_content_of_line_in_context + ecd911b925ac0c8f1ec0c03f6d404814 + location + + col + 9 + file + 0 + line + 4 + + path + + + depth + 0 + kind + event + location + + col + 9 + file + 0 + line + 4 + + message + unused variable: `unused_value` + ranges + + + + col + 9 + file + 0 + line + 4 + + + col + 21 + file + 0 + line + 4 + + + + + + type + clippy + + + category + unknown + check_name + rustc-E0308 + description + mismatched types + issue_hash_content_of_line_in_context + c1f143fe7ee860d107ee080ff38a0f67 + location + + col + 27 + file + 0 + line + 5 + + notes + + + location + + col + 21 + file + 0 + line + 5 + + message + expected due to this + ranges + + + + col + 21 + file + 0 + line + 5 + + + col + 24 + file + 0 + line + 5 + + + + + + path + + + depth + 0 + kind + event + location + + col + 27 + file + 0 + line + 5 + + message + mismatched types + ranges + + + + col + 27 + file + 0 + line + 5 + + + col + 33 + file + 0 + line + 5 + + + + + + type + clippy + + + files + + src/lib.rs + + metadata + + analyzer + + name + clippy + + generated_by + + name + CodeChecker + version + x.y.z + + + + diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.json b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.json new file mode 100644 index 0000000000..02576cfb64 --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/all.json @@ -0,0 +1,5 @@ +not json from cargo +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"using `Option.and_then` can be written more cleanly with `map`","level":"warning","code":{"code":"clippy::bind_instead_of_map","explanation":null},"spans":[{"file_name":"src/lib.rs","line_start":3,"line_end":3,"column_start":18,"column_end":57,"is_primary":true,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[{"$message_type":"diagnostic","message":"try","level":"help","code":null,"spans":[{"file_name":"src/lib.rs","line_start":3,"line_end":3,"column_start":18,"column_end":57,"is_primary":true,"label":null,"suggested_replacement":"maybe.map(|value| value.len())","suggestion_applicability":"MachineApplicable","expansion":null,"text":[]}],"children":[],"rendered":null}],"rendered":null}} +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"unused variable: `unused_value`","level":"warning","code":{"code":"unused_variables","explanation":null},"spans":[{"file_name":"src/lib.rs","line_start":4,"line_end":4,"column_start":9,"column_end":21,"is_primary":true,"label":"unused variable","suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}} +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"mismatched types","level":"error","code":{"code":"E0308","explanation":null},"spans":[{"file_name":"src/lib.rs","line_start":5,"line_end":5,"column_start":27,"column_end":33,"is_primary":true,"label":"expected `i32`, found `&str`","suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]},{"file_name":"src/lib.rs","line_start":5,"line_end":5,"column_start":21,"column_end":24,"is_primary":true,"label":"expected due to this","suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}} +{"reason":"build-finished","success":false} diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/edge-cases.json b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/edge-cases.json new file mode 100644 index 0000000000..81d21d9eff --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/edge-cases.json @@ -0,0 +1,7 @@ +[] +"text" +null +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":"not a diagnostic object"} +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"could not compile fixture","level":"error","code":null,"spans":[{"file_name":"src/lib.rs","line_start":5,"line_end":5,"column_start":9,"column_end":14,"is_primary":true,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}} +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"clippy warning without a code","level":"warning","code":null,"spans":[{"file_name":"src/lib.rs","line_start":4,"line_end":4,"column_start":9,"column_end":14,"is_primary":true,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}} +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"diagnostic with duplicate notes","level":"warning","code":{"code":"clippy::manual_strip","explanation":null},"spans":[{"file_name":"src/lib.rs","line_start":3,"line_end":3,"column_start":18,"column_end":57,"is_primary":true,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]},{"file_name":"src/lib.rs","line_start":4,"line_end":4,"column_start":9,"column_end":14,"is_primary":false,"label":"duplicate note","suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[{"$message_type":"diagnostic","message":"duplicate note","level":"help","code":null,"spans":[{"file_name":"src/lib.rs","line_start":4,"line_end":4,"column_start":9,"column_end":14,"is_primary":false,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}],"rendered":null}} diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/missing-source.json b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/missing-source.json new file mode 100644 index 0000000000..adffc1acb9 --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/missing-source.json @@ -0,0 +1 @@ +{"reason":"compiler-message","manifest_path":"Cargo.toml","message":{"$message_type":"diagnostic","message":"source file is missing","level":"warning","code":{"code":"clippy::manual_strip","explanation":null},"spans":[{"file_name":"src/missing.rs","line_start":2,"line_end":2,"column_start":9,"column_end":10,"is_primary":true,"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null,"text":[]}],"children":[],"rendered":null}} diff --git a/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/src/lib.rs b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/src/lib.rs new file mode 100644 index 0000000000..bc057d602c --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/clippy_output_test_files/src/lib.rs @@ -0,0 +1,8 @@ +pub fn sample() { + let maybe = Some("value"); + let mapped = maybe.and_then(|value| Some(value.len())); + let unused_value = 42; + let wrong_type: i32 = "text"; + let _ = mapped; + let _ = wrong_type; +} diff --git a/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py b/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py new file mode 100644 index 0000000000..a98ab48031 --- /dev/null +++ b/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py @@ -0,0 +1,166 @@ +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +""" +This module tests transforming Cargo Clippy JSON output to CodeChecker plist. +""" + +import os +import plistlib +import shutil +import tempfile +import unittest + +from codechecker_report_converter.analyzers.clippy import analyzer_result +from codechecker_report_converter.report.parser import plist + + +class ClippyAnalyzerResultTestCase(unittest.TestCase): + """ + Test the output of the Clippy AnalyzerResult. + """ + + def setUp(self): + """ + Setup test files. + """ + self.analyzer_result = analyzer_result.AnalyzerResult() + self.cc_result_dir = tempfile.mkdtemp() + self.test_files = os.path.join(os.path.dirname(__file__), + 'clippy_output_test_files') + + def tearDown(self): + """ + Clean temporary directory. + """ + shutil.rmtree(self.cc_result_dir) + + def test_no_json_file(self): + """ + Test transforming a non-json file. + """ + result = os.path.join(self.test_files, 'Cargo.toml') + + ret = self.analyzer_result.transform( + [result], self.cc_result_dir, plist.EXTENSION, + file_name='{source_file}_{analyzer}') + + self.assertFalse(ret) + + def test_transform_dir(self): + """ + Test transforming a directory. + """ + ret = self.analyzer_result.transform( + [self.test_files], self.cc_result_dir, plist.EXTENSION, + file_name='{source_file}_{analyzer}') + + self.assertFalse(ret) + + def test_transform_single_file(self): + """ + Test transforming Cargo JSON diagnostics. + """ + result = os.path.join(self.test_files, 'all.json') + + self.analyzer_result.transform( + [result], self.cc_result_dir, plist.EXTENSION, + file_name='{source_file}_{analyzer}') + + plist_file = os.path.join(self.cc_result_dir, 'lib.rs_clippy.plist') + with open(plist_file, mode='rb') as plist_stream: + res = plistlib.load(plist_stream) + + res['files'][0] = os.path.join('src', 'lib.rs') + self.assertTrue(res['metadata']['generated_by']['version']) + res['metadata']['generated_by']['version'] = 'x.y.z' + + expected_plist = os.path.join(self.test_files, 'all.expected.plist') + with open(expected_plist, mode='rb') as plist_stream: + exp = plistlib.load(plist_stream) + + self.assertEqual(res, exp) + + reports = self.analyzer_result.get_reports(result) + self.assertEqual( + [report.severity for report in reports], + ['LOW', 'LOW', 'CRITICAL']) + + def test_missing_source_file_is_skipped(self): + """ + Test skipping a diagnostic that refers to a missing source file. + """ + result = os.path.join(self.test_files, 'missing-source.json') + + reports = self.analyzer_result.get_reports(result) + + self.assertEqual(reports, []) + + def test_valid_json_non_objects_are_ignored(self): + """ + Test skipping valid JSON lines that are not cargo objects. + """ + result = os.path.join(self.test_files, 'edge-cases.json') + + reports = self.analyzer_result.get_reports(result) + + self.assertEqual(len(reports), 3) + + def test_non_dict_diagnostic_message_is_skipped(self): + """ + Test skipping cargo compiler messages with malformed diagnostics. + """ + result = os.path.join(self.test_files, 'edge-cases.json') + + reports = self.analyzer_result.get_reports(result) + + self.assertNotIn('not a diagnostic object', + [report.message for report in reports]) + + def test_no_code_error_uses_rustc_group(self): + """ + Test assigning no-code errors to the rustc checker group. + """ + result = os.path.join(self.test_files, 'edge-cases.json') + + reports = self.analyzer_result.get_reports(result) + report = next(report for report in reports + if report.message == 'could not compile fixture') + + self.assertEqual(report.checker_name, 'rustc') + self.assertEqual(report.severity, 'CRITICAL') + + def test_no_code_warning_uses_clippy_group(self): + """ + Test assigning no-code warnings to the clippy checker group. + """ + result = os.path.join(self.test_files, 'edge-cases.json') + + reports = self.analyzer_result.get_reports(result) + report = next(report for report in reports + if report.message == 'clippy warning without a code') + + self.assertEqual(report.checker_name, 'clippy') + self.assertEqual(report.severity, 'LOW') + + def test_duplicate_notes_are_removed(self): + """ + Test filtering duplicate notes from span and child diagnostics. + """ + result = os.path.join(self.test_files, 'edge-cases.json') + + reports = self.analyzer_result.get_reports(result) + report = next(report for report in reports + if report.message == 'diagnostic with duplicate notes') + + self.assertEqual(len(report.notes), 1) + self.assertEqual(report.notes[0].message, 'duplicate note') + + +if __name__ == '__main__': + unittest.main() From aa815b9fcc54a73bc53fe8ff4e61f0d224a30993 Mon Sep 17 00:00:00 2001 From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com> Date: Mon, 25 May 2026 16:10:18 +0200 Subject: [PATCH 2/5] Lint to fully adhere to coding style --- .../analyzers/clippy/analyzer.py | 16 ++++++---------- .../analyzers/clippy/result_handler.py | 15 ++++++++------- analyzer/codechecker_analyzer/cli/analyze.py | 11 +++++------ analyzer/tests/unit/test_clippy_analyzer.py | 1 - .../analyzers/clippy/analyzer_result.py | 3 ++- 5 files changed, 21 insertions(+), 25 deletions(-) diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py b/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py index 7fc5112068..a2ca4e57c4 100644 --- a/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py +++ b/analyzer/codechecker_analyzer/analyzers/clippy/analyzer.py @@ -62,7 +62,8 @@ class Clippy(analyzer_base.SourceAnalyzer): @classmethod def analyzer_binary(cls): - return analyzer_context.get_context().analyzer_binaries[cls.ANALYZER_NAME] + return analyzer_context.get_context().analyzer_binaries[ + cls.ANALYZER_NAME] def add_checker_config(self, _): LOG.error('Checker configuration for Clippy is not implemented yet') @@ -95,8 +96,8 @@ def analyze(self, analyzer_cmd, res_handler, proc_callback=None, env=None): result_handler = super().analyze(analyzer_cmd, res_handler, proc_callback, env) - # Compilation can possibly fail, but valid diagnostics can still be emitted. - # In such cases, we want to treat the analysis as successful and report the diagnostics. + # Compilation can fail even if Cargo emits valid diagnostics. + # In that case, keep the diagnostics and treat analysis as successful. if result_handler.analyzer_returncode != 0 and \ self.__has_compiler_message(result_handler.analyzer_stdout): @@ -125,11 +126,6 @@ def get_analyzer_checkers(cls): @classmethod def get_analyzer_config(cls) -> List[analyzer_base.AnalyzerConfig]: - # CodeChecker analyze /repo/Cargo.toml \ - # --analyzers clippy \ - # --analyzer-config clippy:cargo-args-file=/repo/.codechecker/cargo-args.txt \ - # --analyzer-config clippy:cc-verbatim-args-file=/repo/.codechecker/clippy-args.txt \ - # -o /tmp/repo-clippy return [ analyzer_base.AnalyzerConfig( 'cargo-args-file', @@ -149,7 +145,6 @@ def post_analyze(self, result_handler: ClippyResultHandler): """ Run immediately after the analysis. """ - pass @classmethod def resolve_missing_binary(cls, configured_binary, environ): @@ -208,7 +203,8 @@ def construct_result_handler( def construct_config_handler(cls, args): handler = ClippyConfigHandler() - if 'analyzer_config' in args and isinstance(args.analyzer_config, list): + if 'analyzer_config' in args and \ + isinstance(args.analyzer_config, list): for cfg in args.analyzer_config: if cfg.analyzer != cls.ANALYZER_NAME: continue diff --git a/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py b/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py index 4b569f841e..f304f99e15 100644 --- a/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py +++ b/analyzer/codechecker_analyzer/analyzers/clippy/result_handler.py @@ -9,15 +9,14 @@ from pathlib import Path from typing import Optional +from codechecker_common.logger import get_logger +from codechecker_common.review_status_handler import ReviewStatusHandler +from codechecker_common.skiplist_handler import SkipListHandlers from codechecker_report_converter.analyzers.clippy.analyzer_result import \ AnalyzerResult -from codechecker_report_converter.report.parser.base import AnalyzerInfo from codechecker_report_converter.report import error_file, report_file from codechecker_report_converter.report.hash import get_report_hash, HashType - -from codechecker_common.logger import get_logger -from codechecker_common.review_status_handler import ReviewStatusHandler -from codechecker_common.skiplist_handler import SkipListHandlers +from codechecker_report_converter.report.parser.base import AnalyzerInfo from ..config_handler import CheckerState from ..result_handler_base import ResultHandler @@ -56,9 +55,11 @@ def postprocess_result( with open(clippy_dest_file_name, 'w', encoding='utf-8') as result: result.write(self.analyzer_stdout) - reports = self.clippy_analyzer_result.get_reports(clippy_dest_file_name) + reports = self.clippy_analyzer_result.get_reports( + clippy_dest_file_name) reports = [r for r in reports - if self.__checker_enabled(r.checker_name) and not r.skip(skip_handlers)] + if self.__checker_enabled(r.checker_name) and + not r.skip(skip_handlers)] hash_type = HashType.PATH_SENSITIVE if self.report_hash_type == 'context-free-v2': diff --git a/analyzer/codechecker_analyzer/cli/analyze.py b/analyzer/codechecker_analyzer/cli/analyze.py index 281cd5abae..02618431b8 100644 --- a/analyzer/codechecker_analyzer/cli/analyze.py +++ b/analyzer/codechecker_analyzer/cli/analyze.py @@ -11,13 +11,13 @@ import argparse import collections +from functools import partial import json import os +from pathlib import Path import shutil import sys from typing import List -from pathlib import Path -from functools import partial from tu_collector import tu_collector @@ -52,11 +52,10 @@ def __is_clippy_selected(args) -> bool: def __is_clippy_selected_or_valid_to_deduce(args) -> bool: """ - Return whether Clippy was selected explicitly or it is valid to deduce that it should be run. + Return whether Clippy is selected or may be deduced. - If no analyzers were specified, - we can assume it is safe to run Clippy - if the input is a Cargo manifest. + If no analyzers were specified, we can assume it is safe to run Clippy + when the input is a Cargo manifest. """ return not args.analyzers or __is_clippy_selected(args) diff --git a/analyzer/tests/unit/test_clippy_analyzer.py b/analyzer/tests/unit/test_clippy_analyzer.py index 9e53717eaf..f5c8049611 100644 --- a/analyzer/tests/unit/test_clippy_analyzer.py +++ b/analyzer/tests/unit/test_clippy_analyzer.py @@ -20,7 +20,6 @@ Clippy, create_cargo_build_action, find_cargo_manifest from codechecker_analyzer.analyzers.config_handler import CheckerState from codechecker_analyzer.arg import AnalyzerConfigArg -from codechecker_analyzer.buildlog.build_action import BuildAction from codechecker_common.checker_labels import CheckerLabels diff --git a/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py b/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py index 39f27b4fce..aaa39d0c7a 100644 --- a/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py +++ b/tools/report-converter/codechecker_report_converter/analyzers/clippy/analyzer_result.py @@ -120,7 +120,8 @@ def __diagnostic_to_report( if not primary_span: return None - source_file = self.__resolve_span_file(primary_span, cargo_msg, result_dir) + source_file = self.__resolve_span_file( + primary_span, cargo_msg, result_dir) if not source_file: return None From 087f50c38959bdd58d04845fcb92b27df39cccfd Mon Sep 17 00:00:00 2001 From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com> Date: Mon, 25 May 2026 23:32:32 +0200 Subject: [PATCH 3/5] Fix Clippy analyzer test --- analyzer/tests/unit/test_clippy_analyzer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/analyzer/tests/unit/test_clippy_analyzer.py b/analyzer/tests/unit/test_clippy_analyzer.py index f5c8049611..a621f7042f 100644 --- a/analyzer/tests/unit/test_clippy_analyzer.py +++ b/analyzer/tests/unit/test_clippy_analyzer.py @@ -158,7 +158,10 @@ def test_construct_result_handler_keeps_checker_states(self): manifest = os.path.join(tmp_dir, 'Cargo.toml') handler = Clippy.construct_config_handler(Args({ 'analyzer_config': [], - 'ordered_checkers': [('rustc', False)], + 'ordered_checkers': [ + ('checker:clippy', True), + ('checker:rustc', False) + ], 'enable_all': False })) analyzer = Clippy(handler, create_cargo_build_action(manifest)) From 86e5b65300e8481beb8db0c7c5715edce3e7df49 Mon Sep 17 00:00:00 2001 From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com> Date: Mon, 25 May 2026 23:44:11 +0200 Subject: [PATCH 4/5] Fix Clippy parser test metadata --- .../tests/unit/analyzers/test_clippy_parser.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py b/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py index a98ab48031..54dd623ad1 100644 --- a/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py +++ b/tools/report-converter/tests/unit/analyzers/test_clippy_parser.py @@ -77,6 +77,8 @@ def test_transform_single_file(self): res = plistlib.load(plist_stream) res['files'][0] = os.path.join('src', 'lib.rs') + self.assertTrue(res['metadata']['generated_by']['name']) + res['metadata']['generated_by']['name'] = 'tool-name' self.assertTrue(res['metadata']['generated_by']['version']) res['metadata']['generated_by']['version'] = 'x.y.z' @@ -84,6 +86,7 @@ def test_transform_single_file(self): with open(expected_plist, mode='rb') as plist_stream: exp = plistlib.load(plist_stream) + exp['metadata']['generated_by']['name'] = 'tool-name' self.assertEqual(res, exp) reports = self.analyzer_result.get_reports(result) From f377f5d63c02a883e81f93ec0ecdcb899e0cc8cf Mon Sep 17 00:00:00 2001 From: Petr Komogortsev <44289657+peterkmg@users.noreply.github.com> Date: Tue, 26 May 2026 00:29:52 +0200 Subject: [PATCH 5/5] Keep Clippy out of compile command defaults --- .../analyzers/analyzer_types.py | 21 ++++++++++++ analyzer/codechecker_analyzer/cli/analyze.py | 2 ++ analyzer/codechecker_analyzer/cli/parse.py | 6 ++-- analyzer/tests/unit/test_clippy_analyzer.py | 34 ++++++++++++++++++- 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/analyzer/codechecker_analyzer/analyzers/analyzer_types.py b/analyzer/codechecker_analyzer/analyzers/analyzer_types.py index 8cbcc50618..7bc6f31c67 100644 --- a/analyzer/codechecker_analyzer/analyzers/analyzer_types.py +++ b/analyzer/codechecker_analyzer/analyzers/analyzer_types.py @@ -38,6 +38,27 @@ Infer.ANALYZER_NAME: Infer } +compile_command_analyzers = { + analyzer_name: analyzer_class + for analyzer_name, analyzer_class in supported_analyzers.items() + if analyzer_name != Clippy.ANALYZER_NAME +} + +cargo_manifest_analyzers = {Clippy.ANALYZER_NAME: Clippy} + + +def get_analyzers_for_compile_commands(compile_commands): + """ + Return analyzers that can produce reports for the given compile commands. + """ + if compile_commands and all( + os.path.basename(c["file"]) == "Cargo.toml" + for c in compile_commands + ): + return cargo_manifest_analyzers + + return compile_command_analyzers + def is_statistics_capable(): """ Detects if the current clang is Statistics compatible. """ diff --git a/analyzer/codechecker_analyzer/cli/analyze.py b/analyzer/codechecker_analyzer/cli/analyze.py index 02618431b8..33d586293e 100644 --- a/analyzer/codechecker_analyzer/cli/analyze.py +++ b/analyzer/codechecker_analyzer/cli/analyze.py @@ -1365,6 +1365,8 @@ def main(args): else: compile_commands = \ compilation_database.gather_compilation_database(args.input) + if not args.analyzers: + args.analyzers = list(analyzer_types.compile_command_analyzers) if __is_clippy_selected(args) and not cargo_manifest_path: LOG.error( diff --git a/analyzer/codechecker_analyzer/cli/parse.py b/analyzer/codechecker_analyzer/cli/parse.py index 523426172c..ae55571c30 100644 --- a/analyzer/codechecker_analyzer/cli/parse.py +++ b/analyzer/codechecker_analyzer/cli/parse.py @@ -30,7 +30,7 @@ from codechecker_analyzer import analyzer_context, suppress_handler from codechecker_analyzer.util import analyzer_action_hash -from codechecker_analyzer.analyzers.analyzer_types import supported_analyzers +from codechecker_analyzer.analyzers import analyzer_types from codechecker_common import arg, logger, cmd_config from codechecker_common.review_status_handler import ReviewStatusHandler @@ -286,6 +286,8 @@ def get_report_dir_status(compile_commands: List[dict[str, str]], detailed_flag: bool): recent, old, failed, missing, analyzed_actions = {}, {}, {}, {}, {} + supported_analyzers = \ + analyzer_types.get_analyzers_for_compile_commands(compile_commands) for analyzer in supported_analyzers: recent[analyzer] = {} @@ -442,7 +444,7 @@ def print_status(report_dir: str, LOG.info("----==== Summary ====----") for k, v in summary_map.items(): LOG.info(v) - for analyzer in supported_analyzers: + for analyzer in status["analyzers"]: count = status["analyzers"][analyzer]["summary"][k] if count > 0: if detailed_flag: diff --git a/analyzer/tests/unit/test_clippy_analyzer.py b/analyzer/tests/unit/test_clippy_analyzer.py index a621f7042f..7b63515c48 100644 --- a/analyzer/tests/unit/test_clippy_analyzer.py +++ b/analyzer/tests/unit/test_clippy_analyzer.py @@ -15,7 +15,7 @@ import unittest from unittest import mock -from codechecker_analyzer.analyzers import analyzer_base +from codechecker_analyzer.analyzers import analyzer_base, analyzer_types from codechecker_analyzer.analyzers.clippy.analyzer import \ Clippy, create_cargo_build_action, find_cargo_manifest from codechecker_analyzer.analyzers.config_handler import CheckerState @@ -59,6 +59,38 @@ def test_find_cargo_manifest_rejects_directory(self): self.assertIsNone(find_cargo_manifest(tmp_dir)) + def test_compile_command_analyzers_exclude_clippy(self): + """ + Compile command analysis does not select Clippy by default. + """ + self.assertNotIn( + Clippy.ANALYZER_NAME, + analyzer_types.compile_command_analyzers) + + def test_get_analyzers_for_compile_commands_selects_cargo_analyzers(self): + """ + Cargo manifest commands select Clippy for status handling. + """ + analyzers = analyzer_types.get_analyzers_for_compile_commands([{ + 'file': '/sample/Cargo.toml', + 'directory': '/sample', + 'command': 'cargo clippy --manifest-path Cargo.toml' + }]) + + self.assertEqual(analyzers, analyzer_types.cargo_manifest_analyzers) + + def test_get_analyzers_for_compile_commands_selects_c_analyzers(self): + """ + C/C++ compile commands select compile-command analyzers. + """ + analyzers = analyzer_types.get_analyzers_for_compile_commands([{ + 'file': '/sample/main.c', + 'directory': '/sample', + 'command': 'gcc -c main.c' + }]) + + self.assertEqual(analyzers, analyzer_types.compile_command_analyzers) + def test_construct_analyzer_cmd_orders_cargo_and_clippy_args(self): """ Cargo arguments are placed before Clippy verbatim arguments.