#!/usr/bin/env python3 """Simple C++ Linter.""" import argparse import bisect import io import logging import re import sys def _make_polyfill_regex(): polyfill_required_names = [ '_', 'adopt_lock', 'async', 'chrono', 'condition_variable', 'condition_variable_any', 'cv_status', 'defer_lock', 'future', 'future_status', 'get_terminate', 'launch', 'lock_guard', 'mutex', 'notify_all_at_thread_exit', 'packaged_task', 'promise', 'recursive_mutex', 'set_terminate', 'shared_lock', 'shared_mutex', 'shared_timed_mutex', 'this_thread(?!::at_thread_exit)', 'thread', 'timed_mutex', 'try_to_lock', 'unique_lock', 'unordered_map', 'unordered_multimap', 'unordered_multiset', 'unordered_set', ] qualified_names = ['boost::' + name + "\\b" for name in polyfill_required_names] qualified_names.extend('std::' + name + "\\b" for name in polyfill_required_names) qualified_names_regex = '|'.join(qualified_names) return re.compile('(' + qualified_names_regex + ')') _RE_LINT = re.compile("//.*NOLINT") _RE_COMMENT_STRIP = re.compile("//.*") _RE_PATTERN_MONGO_POLYFILL = _make_polyfill_regex() _RE_COLLECTION_SHARDING_RUNTIME = re.compile(r'\bCollectionShardingRuntime\b') _RE_RAND = re.compile(r'\b(srand\(|rand\(\))') _RE_GENERIC_FCV_COMMENT = re.compile(r'\(Generic FCV reference\):') GENERIC_FCV = [ r'::kLatest', r'::kLastContinuous', r'::kLastLTS', r'::kUpgradingFromLastLTSToLatest', r'::kUpgradingFromLastContinuousToLatest', r'::kDowngradingFromLatestToLastLTS', r'::kDowngradingFromLatestToLastContinuous', r'\.isUpgradingOrDowngrading', r'::kDowngradingFromLatestToLastContinuous', r'::kUpgradingFromLastLTSToLastContinuous', ] _RE_GENERIC_FCV_REF = re.compile(r'(' + '|'.join(GENERIC_FCV) + r')\b') _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_REF = re.compile(r'isEnabledAndIgnoreFCVUnsafe\(\)') _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_COMMENT = re.compile(r'\(Ignore FCV check\)') _RE_HEADER = re.compile(r'\.(h|hpp)$') _CXX_COMPAT_HEADERS = [ "assert", "ctype", "errno", "fenv", "float", "inttypes", "limits", "locale", "math", "setjmp", "signal", "stdarg", "stddef", "stdint", "stdio", "stdlib", "string", "time", "uchar", "wchar", "wctype" ] # Successful matches `m` have a `m["base"]`, the basename of the file that was included. _RE_CXX_COMPAT_HEADERS = re.compile( rf'# *include *((<)|("))(?P{"|".join(_CXX_COMPAT_HEADERS)})\.h(?(2)>|")') class Linter: """Simple C++ Linter.""" _license_header = '''\ /** * Copyright (C) {year}-present MongoDB, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the Server Side Public License, version 1, * as published by MongoDB, Inc. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * Server Side Public License for more details. * * You should have received a copy of the Server Side Public License * along with this program. If not, see * . * * As a special exception, the copyright holders give permission to link the * code of portions of this program with the OpenSSL library under certain * conditions as described in each individual source file and distribute * linked combinations including the program with the OpenSSL library. You * must comply with the Server Side Public License in all respects for * all of the code used other than as permitted herein. If you modify file(s) * with this exception, you may extend this exception to your version of the * file(s), but you are not obligated to do so. If you do not wish to do so, * delete this exception statement from your version. If you delete this * exception statement from all source files in the program, then also delete * it in the license file. */'''.splitlines() def __init__(self, file_name, raw_lines): """Create new linter.""" self.file_name = file_name self.raw_lines = raw_lines self.clean_lines = [] self.nolint_suppression = [] self.generic_fcv_comments = [] self.feature_flag_ignore_fcv_check_comments = [] self._error_count = 0 def lint(self): """Run linter, returning error count.""" # steps: # - Check for header # - Check for NOLINT and Strip multi line comments # - Run file-level checks # - Run per-line checks start_line = self._check_for_server_side_public_license() self._check_newlines() self._check_and_strip_comments() # File-level checks self._check_macro_definition_leaks() # Line-level checks for linenum in range(start_line, len(self.clean_lines)): if not self.clean_lines[linenum]: continue self._check_for_mongo_polyfill(linenum) self._check_for_collection_sharding_runtime(linenum) self._check_for_rand(linenum) self._check_for_c_stdlib_headers(linenum) # Relax the rule of commenting generic FCV references for files directly related to FCV # implementations. if not "feature_compatibility_version" in self.file_name: self._check_for_generic_fcv(linenum) # Don't check feature_flag.h/cpp where the function is defined and test files. if not "feature_flag" in self.file_name and not "test" in self.file_name: self._check_for_feature_flag_ignore_fcv(linenum) return self._error_count def _check_newlines(self): """Check that each source file ends with a newline character.""" if self.raw_lines and self.raw_lines[-1][-1:] != '\n': self._error( len(self.raw_lines), 'mongo/final_newline', 'Files must end with a newline character.') def _check_and_strip_comments(self): in_multi_line_comment = False for linenum in range(len(self.raw_lines)): clean_line = self.raw_lines[linenum] # Users can write NOLINT different ways # // NOLINT # // Some explanation NOLINT # so we need a regular expression if _RE_LINT.search(clean_line): self.nolint_suppression.append(linenum) if _RE_GENERIC_FCV_COMMENT.search(clean_line): self.generic_fcv_comments.append(linenum) if _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_COMMENT.search(clean_line): self.feature_flag_ignore_fcv_check_comments.append(linenum) if not in_multi_line_comment: if "/*" in clean_line and not "*/" in clean_line: in_multi_line_comment = True clean_line = "" # Trim comments - approximately # Note, this does not understand if // is in a string # i.e. it will think URLs are also comments but this should be good enough to find # violators of the coding convention if "//" in clean_line: clean_line = _RE_COMMENT_STRIP.sub("", clean_line) else: if "*/" in clean_line: in_multi_line_comment = False clean_line = "" self.clean_lines.append(clean_line) def _check_macro_definition_leaks(self): """Some header macros should appear in define/undef pairs.""" if not _RE_HEADER.search(self.file_name): return # Naive check: doesn't consider `#if` scoping. # Assumes an #undef matches the nearest #define. for macro in ['MONGO_LOGV2_DEFAULT_COMPONENT']: re_define = re.compile(fr"^\s*#\s*define\s+{macro}\b") re_undef = re.compile(fr"^\s*#\s*undef\s+{macro}\b") def_line = None for idx, line in enumerate(self.clean_lines): if def_line is None: if re_define.match(line): def_line = idx else: if re_undef.match(line): def_line = None if def_line is not None: self._error(def_line, 'mongodb/undefmacro', f'Missing "#undef {macro}"') def _check_for_mongo_polyfill(self, linenum): line = self.clean_lines[linenum] match = _RE_PATTERN_MONGO_POLYFILL.search(line) if match: self._error( linenum, 'mongodb/polyfill', 'Illegal use of banned name from std::/boost:: for "%s", use mongo::stdx:: variant instead' % (match.group(0))) def _check_for_collection_sharding_runtime(self, linenum): line = self.clean_lines[linenum] if _RE_COLLECTION_SHARDING_RUNTIME.search( line ) and "/src/mongo/db/s/" not in self.file_name and "_test.cpp" not in self.file_name: self._error( linenum, 'mongodb/collection_sharding_runtime', 'Illegal use of ' 'CollectionShardingRuntime outside of mongo/db/s/; use CollectionShardingState ' 'instead; see src/mongo/db/s/collection_sharding_state.h for details.') def _check_for_rand(self, linenum): line = self.clean_lines[linenum] if _RE_RAND.search(line): self._error(linenum, 'mongodb/rand', 'Use of rand or srand, use or PseudoRandom instead.') def _license_error(self, linenum, msg, category='legal/license'): style_url = 'https://github.com/mongodb/mongo/wiki/Server-Code-Style' self._error(linenum, category, '{} See {}'.format(msg, style_url)) return (False, linenum) def _check_for_server_side_public_license(self): """Return the number of the line at which the check ended.""" src_iter = (x.rstrip() for x in self.raw_lines) linenum = 0 for linenum, lic_line in enumerate(self._license_header): src_line = next(src_iter, None) if src_line is None: self._license_error(linenum, 'Missing or incomplete license header.') return linenum lic_re = re.escape(lic_line).replace(r'\{year\}', r'\d{4}') if not re.fullmatch(lic_re, src_line): self._license_error( linenum, 'Incorrect license header.\n' ' Expected: "{}"\n' ' Received: "{}"\n'.format(lic_line, src_line)) return linenum # Warn if SSPL appears in Enterprise code, which has a different license. expect_sspl_license = "enterprise" not in self.file_name if not expect_sspl_license: self._license_error(linenum, 'Incorrect license header found. Expected Enterprise license.', category='legal/enterprise_license') return linenum return linenum def _check_for_generic_fcv(self, linenum): line = self.clean_lines[linenum] if _RE_GENERIC_FCV_REF.search(line): # Find the first generic FCV comment preceding the current line. i = bisect.bisect_right(self.generic_fcv_comments, linenum) if not i or self.generic_fcv_comments[i - 1] < (linenum - 10): self._error( linenum, 'mongodb/fcv', 'Please add a comment containing "(Generic FCV reference):" within 10 lines ' + 'before the generic FCV reference.') def _check_for_c_stdlib_headers(self, linenum): line = self.clean_lines[linenum] if match := _RE_CXX_COMPAT_HEADERS.match(line): self._error( linenum, 'mongodb/headers', f"Prohibited include of C header '<{match['base']}.h>'. " \ f"Include C++ header '' instead.") def _check_for_feature_flag_ignore_fcv(self, linenum): line = self.clean_lines[linenum] if _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_REF.search(line): # Find the first ignore FCV check comment preceding the current line. i = bisect.bisect_right(self.feature_flag_ignore_fcv_check_comments, linenum) if not i or self.feature_flag_ignore_fcv_check_comments[i - 1] < (linenum - 10): self._error( linenum, 'mongodb/fcv', 'Please add a comment containing "(Ignore FCV check)":" within 10 lines ' + 'before the isEnabledAndIgnoreFCVUnsafe() function call explaining why ' + 'the FCV check is ignored.') def _error(self, linenum, category, message): if linenum in self.nolint_suppression: return norm_file_name = self.file_name.replace('\\', '/') # Custom clang-tidy check tests purposefully produce errors for # tests to find. They should be ignored. if "mongo_tidy_checks/tests/" in norm_file_name: return if category == "legal/license": # Enterprise module does not have the SSPL license if "enterprise" in self.file_name: return # The following files are in the src/mongo/ directory but technically belong # in src/third_party/ because their copyright does not belong to MongoDB. files_to_ignore = set([ 'src/mongo/scripting/mozjs/PosixNSPR.cpp', 'src/mongo/shell/linenoise.cpp', 'src/mongo/shell/linenoise.h', 'src/mongo/shell/mk_wcwidth.cpp', 'src/mongo/shell/mk_wcwidth.h', 'src/mongo/util/md5.cpp', 'src/mongo/util/md5.h', 'src/mongo/util/md5main.cpp', 'src/mongo/util/net/ssl_stream.cpp', 'src/mongo/util/scopeguard.h', ]) for file_to_ignore in files_to_ignore: if file_to_ignore in norm_file_name: return # We count internally from 0 but users count from 1 for line numbers print("Error: %s:%d - %s - %s" % (self.file_name, linenum + 1, category, message)) self._error_count += 1 def lint_file(file_name): """Lint file and print errors to console.""" with io.open(file_name, encoding='utf-8') as file_stream: raw_lines = file_stream.readlines() linter = Linter(file_name, raw_lines) return linter.lint() def main(): # type: () -> int """Execute Main Entry point.""" parser = argparse.ArgumentParser(description='MongoDB Simple C++ Linter.') parser.add_argument('file', type=str, help="C++ input file") parser.add_argument('-v', '--verbose', action='count', help="Enable verbose tracing") args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) try: error_count = lint_file(args.file) if error_count != 0: print('File "{}" failed with {} errors.'.format(args.file, error_count)) return 1 return 0 except Exception as ex: # pylint: disable=broad-except print('Exception while checking file "{}": {}'.format(args.file, ex)) return 2 if __name__ == '__main__': sys.exit(main())