diff options
Diffstat (limited to 'utils/checkstyle.py')
-rwxr-xr-x | utils/checkstyle.py | 884 |
1 files changed, 606 insertions, 278 deletions
diff --git a/utils/checkstyle.py b/utils/checkstyle.py index b594a19a..f6229bbd 100755 --- a/utils/checkstyle.py +++ b/utils/checkstyle.py @@ -1,10 +1,10 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2018, Google Inc. # # Author: Laurent Pinchart <laurent.pinchart@ideasonboard.com> # -# checkstyle.py - A patch style checker script based on astyle or clang-format +# A patch style checker script based on clang-format # # TODO: # @@ -22,22 +22,7 @@ import shutil import subprocess import sys -astyle_options = ( - '-n', - '--style=linux', - '--indent=force-tab=8', - '--attach-namespaces', - '--attach-extern-c', - '--pad-oper', - '--align-pointer=name', - '--align-reference=name', - '--keep-one-line-blocks', - '--max-code-length=120' -) - dependencies = { - 'astyle': False, - 'clang-format': False, 'git': True, } @@ -182,6 +167,12 @@ def parse_diff(diff): hunk = DiffHunk(line) elif hunk is not None: + # Work around https://github.com/python/cpython/issues/46395 + # See https://www.gnu.org/software/diffutils/manual/html_node/Incomplete-Lines.html + if line[-1] != '\n': + hunk.append(line + '\n') + line = '\\ No newline at end of file\n' + hunk.append(line) if hunk: @@ -191,73 +182,490 @@ def parse_diff(diff): # ------------------------------------------------------------------------------ -# Style Checkers +# Commit, Staged Changes & Amendments # -_style_checkers = [] +class CommitFile: + def __init__(self, name): + info = name.split() + self.__status = info[0][0] -class StyleCheckerRegistry(type): - def __new__(cls, clsname, bases, attrs): - newclass = super(StyleCheckerRegistry, cls).__new__(cls, clsname, bases, attrs) - if clsname != 'StyleChecker': - _style_checkers.append(newclass) - return newclass + # For renamed files, store the new name + if self.__status == 'R': + self.__filename = info[2] + else: + self.__filename = info[1] + + def __repr__(self): + return f'{self.__status} {self.__filename}' + + @property + def filename(self): + return self.__filename + + @property + def status(self): + return self.__status -class StyleChecker(metaclass=StyleCheckerRegistry): +class Commit: + def __init__(self, commit): + self._commit = commit + self._author = None + self._trailers = [] + self._parse() + + def _parse_commit(self): + # Get and parse the commit message. + ret = subprocess.run(['git', 'show', '--format=%H%n%an <%ae>%n%s%n%b', + '--no-patch', self.commit], + stdout=subprocess.PIPE).stdout.decode('utf-8') + lines = ret.splitlines() + + self._commit = lines[0] + self._author = lines[1] + self._title = lines[2] + self._body = lines[3:] + + # Parse the trailers. Feed git-interpret-trailers with a full commit + # message that includes both the title and the body, as it otherwise + # fails to find trailers when the body contains trailers only. + message = self._title + '\n\n' + '\n'.join(self._body) + trailers = subprocess.run(['git', 'interpret-trailers', '--parse'], + input=message.encode('utf-8'), + stdout=subprocess.PIPE).stdout.decode('utf-8') + + self._trailers = trailers.splitlines() + + def _parse(self): + self._parse_commit() + + # Get the list of files. Use an empty format specifier to suppress the + # commit message completely. + ret = subprocess.run(['git', 'show', '--format=', '--name-status', + self.commit], + stdout=subprocess.PIPE).stdout.decode('utf-8') + self._files = [CommitFile(f) for f in ret.splitlines()] + + def __repr__(self): + return '\n'.join([ + f'commit {self.commit}', + f'Author: {self.author}', + f'', + f' {self.title}', + '', + '\n'.join([line and f' {line}' or '' for line in self._body]), + 'Trailers:', + ] + self.trailers) + + def files(self, filter='AMR'): + return [f.filename for f in self._files if f.status in filter] + + @property + def author(self): + return self._author + + @property + def commit(self): + return self._commit + + @property + def title(self): + return self._title + + @property + def trailers(self): + return self._trailers + + def get_diff(self, top_level, filename): + diff = subprocess.run(['git', 'diff', '%s~..%s' % (self.commit, self.commit), + '--', '%s/%s' % (top_level, filename)], + stdout=subprocess.PIPE).stdout.decode('utf-8') + return parse_diff(diff.splitlines(True)) + + def get_file(self, filename): + return subprocess.run(['git', 'show', '%s:%s' % (self.commit, filename)], + stdout=subprocess.PIPE).stdout.decode('utf-8') + + +class StagedChanges(Commit): def __init__(self): - pass + Commit.__init__(self, '') + + def _parse(self): + ret = subprocess.run(['git', 'diff', '--staged', '--name-status'], + stdout=subprocess.PIPE).stdout.decode('utf-8') + self._title = 'Staged changes' + self._files = [CommitFile(f) for f in ret.splitlines()] - # - # Class methods - # + def get_diff(self, top_level, filename): + diff = subprocess.run(['git', 'diff', '--staged', '--', + '%s/%s' % (top_level, filename)], + stdout=subprocess.PIPE).stdout.decode('utf-8') + return parse_diff(diff.splitlines(True)) + + +class Amendment(Commit): + def __init__(self): + Commit.__init__(self, 'HEAD') + + def _parse(self): + self._parse_commit() + + self._title = f'Amendment of "{self.title}"' + + # Extract the list of modified files. + ret = subprocess.run(['git', 'diff', '--staged', '--name-status', 'HEAD~'], + stdout=subprocess.PIPE).stdout.decode('utf-8') + self._files = [CommitFile(f) for f in ret.splitlines()] + + def get_diff(self, top_level, filename): + diff = subprocess.run(['git', 'diff', '--staged', 'HEAD~', '--', + '%s/%s' % (top_level, filename)], + stdout=subprocess.PIPE).stdout.decode('utf-8') + return parse_diff(diff.splitlines(True)) + + +# ------------------------------------------------------------------------------ +# Helpers +# + +class ClassRegistry(type): + def __new__(cls, clsname, bases, attrs): + newclass = super().__new__(cls, clsname, bases, attrs) + if bases and bases[0] != CheckerBase: + base = bases[0] + + if not hasattr(base, 'subclasses'): + base.subclasses = [] + base.subclasses.append(newclass) + base.subclasses.sort(key=lambda x: getattr(x, 'priority', 0), + reverse=True) + return newclass + + +class CheckerBase(metaclass=ClassRegistry): @classmethod - def checkers(cls, filename): - for checker in _style_checkers: - if checker.supports(filename): - yield checker + def instances(cls, obj, names): + for instance in cls.subclasses: + if names and instance.__name__ not in names: + continue + if instance.supports(obj): + yield instance @classmethod - def supports(cls, filename): - for pattern in cls.patterns: - if fnmatch.fnmatch(os.path.basename(filename), pattern): - return True + def supports(cls, obj): + if hasattr(cls, 'commit_types'): + return type(obj) in cls.commit_types + + if hasattr(cls, 'patterns'): + for pattern in cls.patterns: + if fnmatch.fnmatch(os.path.basename(obj), pattern): + return True + return False @classmethod def all_patterns(cls): patterns = set() - for checker in _style_checkers: - patterns.update(checker.patterns) + for instance in cls.subclasses: + if hasattr(instance, 'patterns'): + patterns.update(instance.patterns) return patterns + @classmethod + def check_dependencies(cls): + if not hasattr(cls, 'dependencies'): + return [] + + issues = [] + + for command in cls.dependencies: + if command not in dependencies: + dependencies[command] = shutil.which(command) + + if not dependencies[command]: + issues.append(CommitIssue(f'Missing {command} to run {cls.__name__}')) + + return issues + + +# ------------------------------------------------------------------------------ +# Commit Checkers +# + +class CommitChecker(CheckerBase): + pass + + +class CommitIssue(object): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return f'{Colours.fg(Colours.Yellow)}{self.msg}{Colours.reset()}' + + +class HeaderAddChecker(CommitChecker): + commit_types = (Commit, StagedChanges, Amendment) + + @classmethod + def check(cls, commit, top_level): + issues = [] + + meson_files = [f for f in commit.files() + if os.path.basename(f) == 'meson.build'] + + for filename in commit.files('AR'): + if not filename.startswith('include/libcamera/') or \ + not filename.endswith('.h'): + continue + + meson = os.path.dirname(filename) + '/meson.build' + header = os.path.basename(filename) + + issue = CommitIssue('Header %s added without corresponding update to %s' % + (filename, meson)) + + if meson not in meson_files: + issues.append(issue) + continue + + diff = commit.get_diff(top_level, meson) + found = False + + for hunk in diff: + for line in hunk.lines: + if line[0] != '+': + continue + + if line.find("'%s'" % header) != -1: + found = True + break + + if found: + break + + if not found: + issues.append(issue) + + return issues + + +class TitleChecker(CommitChecker): + commit_types = (Commit,) + + prefix_regex = re.compile(r'^([a-zA-Z0-9_.-]+: )+') + release_regex = re.compile(r'libcamera v[0-9]+\.[0-9]+\.[0-9]+') + + @classmethod + def check(cls, commit, top_level): + title = commit.title + + # Ignore release commits, they don't need a prefix. + if TitleChecker.release_regex.fullmatch(title): + return [] + + prefix_pos = title.find(': ') + if prefix_pos != -1 and prefix_pos != len(title) - 2: + return [] + + # Find prefix candidates by searching the git history + msgs = subprocess.run(['git', 'log', '--no-decorate', '--oneline', '-n100', '--'] + commit.files(), + stdout=subprocess.PIPE).stdout.decode('utf-8') + prefixes = {} + prefixes_count = 0 + for msg in msgs.splitlines(): + prefix = TitleChecker.prefix_regex.match(msg) + if not prefix: + continue + + prefix = prefix.group(0) + if prefix in prefixes: + prefixes[prefix] += 1 + else: + prefixes[prefix] = 1 + + prefixes_count += 1 + + if not prefixes: + return [CommitIssue('Commit title is missing prefix')] + + # Sort the candidates by number of occurrences and pick the best ones. + # When multiple prefixes are possible without a clear winner, we want to + # display the most common options to the user, but without the most + # unlikely options to avoid too long messages. As a heuristic, select + # enough candidates to cover at least 2/3 of the possible prefixes, but + # never more than 4 candidates. + prefixes = list(prefixes.items()) + prefixes.sort(key=lambda x: x[1], reverse=True) + + candidates = [] + candidates_count = 0 + for prefix in prefixes: + candidates.append(f"`{prefix[0]}'") + candidates_count += prefix[1] + if candidates_count >= prefixes_count * 2 / 3 or \ + len(candidates) == 4: + break + + candidates = candidates[:-2] + [' or '.join(candidates[-2:])] + candidates = ', '.join(candidates) + + return [CommitIssue('Commit title is missing prefix, ' + 'possible candidates are ' + candidates)] + + +class TrailersChecker(CommitChecker): + commit_types = (Commit,) + + commit_regex = re.compile(r'[0-9a-f]{12}[0-9a-f]* \(".*"\)') + + coverity_regex = re.compile(r'Coverity CID=.*') + + # Simple e-mail address validator regex, with an additional trailing + # comment. The complexity of a full RFC6531 validator isn't worth the + # additional invalid addresses it would reject. + email_regex = re.compile(r'[^<]+ <[^@>]+@[^>]+>( # .*)?') + + link_regex = re.compile(r'https?://.*') + + @staticmethod + def validate_reported_by(value): + if TrailersChecker.email_regex.fullmatch(value): + return True + if TrailersChecker.coverity_regex.fullmatch(value): + return True + return False + + known_trailers = { + 'Acked-by': email_regex, + 'Bug': link_regex, + 'Co-developed-by': email_regex, + 'Fixes': commit_regex, + 'Link': link_regex, + 'Reported-by': validate_reported_by, + 'Reviewed-by': email_regex, + 'Signed-off-by': email_regex, + 'Suggested-by': email_regex, + 'Tested-by': email_regex, + } + + trailer_regex = re.compile(r'([A-Z][a-zA-Z-]*)\s*:\s*(.*)') + + @classmethod + def check(cls, commit, top_level): + issues = [] + + sob_found = False + + for trailer in commit.trailers: + match = TrailersChecker.trailer_regex.fullmatch(trailer) + if not match: + issues.append(CommitIssue(f"Malformed commit trailer '{trailer}'")) + continue + + key, value = match.groups() + + validator = TrailersChecker.known_trailers.get(key) + if not validator: + issues.append(CommitIssue(f"Invalid commit trailer key '{key}'")) + continue + + if isinstance(validator, re.Pattern): + valid = bool(validator.fullmatch(value)) + else: + valid = validator(value) + + if not valid: + issues.append(CommitIssue(f"Malformed value '{value}' for commit trailer '{key}'")) + continue + + if key == 'Signed-off-by': + if value == commit.author: + sob_found = True + + if not sob_found: + issues.append(CommitIssue(f"No 'Signed-off-by' trailer matching author '{commit.author}', see Documentation/contributing.rst")) + + return issues + + +# ------------------------------------------------------------------------------ +# Style Checkers +# + +class StyleChecker(CheckerBase): + pass + class StyleIssue(object): - def __init__(self, line_number, line, msg): + def __init__(self, line_number, position, line, msg): self.line_number = line_number + self.position = position self.line = line self.msg = msg + def __str__(self): + s = [] + s.append(f'{Colours.fg(Colours.Yellow)}#{self.line_number}: {self.msg}{Colours.reset()}') + if self.line is not None: + s.append(f'{Colours.fg(Colours.Yellow)}+{self.line.rstrip()}{Colours.reset()}') + + if self.position is not None: + # Align the position marker by using the original line with + # all characters except for tabs replaced with spaces. This + # ensures proper alignment regardless of how the code is + # indented. + start = self.position[0] + prefix = ''.join([c if c == '\t' else ' ' for c in self.line[:start]]) + length = self.position[1] - start - 1 + s.append(f' {prefix}^{"~" * length}') + + return '\n'.join(s) + + +class HexValueChecker(StyleChecker): + patterns = ('*.c', '*.cpp', '*.h') + + regex = re.compile(r'\b0[xX][0-9a-fA-F]+\b') + + @classmethod + def check(cls, content, line_numbers): + issues = [] + + for line_number in line_numbers: + line = content[line_number - 1] + match = HexValueChecker.regex.search(line) + if not match: + continue + + value = match.group(0) + if value == value.lower(): + continue + + issues.append(StyleIssue(line_number, match.span(0), line, + f'Use lowercase hex constant {value.lower()}')) + + return issues + class IncludeChecker(StyleChecker): patterns = ('*.cpp', '*.h') - headers = ('assert', 'ctype', 'errno', 'fenv', 'float', 'inttypes', - 'limits', 'locale', 'math', 'setjmp', 'signal', 'stdarg', - 'stddef', 'stdint', 'stdio', 'stdlib', 'string', 'time', 'uchar', - 'wchar', 'wctype') - include_regex = re.compile('^#include <c([a-z]*)>') - - def __init__(self, content): - super().__init__() - self.__content = content + headers = ('cassert', 'cctype', 'cerrno', 'cfenv', 'cfloat', 'cinttypes', + 'climits', 'clocale', 'csetjmp', 'csignal', 'cstdarg', 'cstddef', + 'cstdint', 'cstdio', 'cstdlib', 'cstring', 'ctime', 'cuchar', + 'cwchar', 'cwctype', 'math.h') + include_regex = re.compile(r'^#include <([a-z.]*)>') - def check(self, line_numbers): + @classmethod + def check(self, content, line_numbers): issues = [] for line_number in line_numbers: - line = self.__content[line_number - 1] + line = content[line_number - 1] match = IncludeChecker.include_regex.match(line) if not match: continue @@ -266,28 +674,34 @@ class IncludeChecker(StyleChecker): if header not in IncludeChecker.headers: continue - issues.append(StyleIssue(line_number, line, - 'C compatibility header <%s.h> is preferred' % header)) + if header.endswith('.h'): + header_type = 'C++' + header = 'c' + header[:-2] + else: + header_type = 'C compatibility' + header = header[1:] + '.h' + + issues.append(StyleIssue(line_number, match.span(1), line, + f'{header_type} header <{header}> is preferred')) return issues class LogCategoryChecker(StyleChecker): - log_regex = re.compile('\\bLOG\((Debug|Info|Warning|Error|Fatal)\)') + log_regex = re.compile(r'\bLOG\((Debug|Info|Warning|Error|Fatal)\)') patterns = ('*.cpp',) - def __init__(self, content): - super().__init__() - self.__content = content - - def check(self, line_numbers): + @classmethod + def check(cls, content, line_numbers): issues = [] for line_number in line_numbers: - line = self.__content[line_number-1] - if not LogCategoryChecker.log_regex.search(line): + line = content[line_number - 1] + match = LogCategoryChecker.log_regex.search(line) + if not match: continue - issues.append(StyleIssue(line_number, line, 'LOG() should use categories')) + issues.append(StyleIssue(line_number, match.span(1), line, + 'LOG() should use categories')) return issues @@ -295,70 +709,30 @@ class LogCategoryChecker(StyleChecker): class MesonChecker(StyleChecker): patterns = ('meson.build',) - def __init__(self, content): - super().__init__() - self.__content = content - - def check(self, line_numbers): + @classmethod + def check(cls, content, line_numbers): issues = [] for line_number in line_numbers: - line = self.__content[line_number-1] - if line.find('\t') != -1: - issues.append(StyleIssue(line_number, line, 'meson.build should use spaces for indentation')) - return issues - - -class Pep8Checker(StyleChecker): - patterns = ('*.py',) - results_regex = re.compile('stdin:([0-9]+):([0-9]+)(.*)') - - def __init__(self, content): - super().__init__() - self.__content = content - - def check(self, line_numbers): - issues = [] - data = ''.join(self.__content).encode('utf-8') - - try: - ret = subprocess.run(['pycodestyle', '--ignore=E501', '-'], - input=data, stdout=subprocess.PIPE) - except FileNotFoundError: - issues.append(StyleIssue(0, None, "Please install pycodestyle to validate python additions")) - return issues - - results = ret.stdout.decode('utf-8').splitlines() - for item in results: - search = re.search(Pep8Checker.results_regex, item) - line_number = int(search.group(1)) - position = int(search.group(2)) - msg = search.group(3) - - if line_number in line_numbers: - line = self.__content[line_number - 1] - issues.append(StyleIssue(line_number, line, msg)) - + line = content[line_number - 1] + pos = line.find('\t') + if pos != -1: + issues.append(StyleIssue(line_number, [pos, pos], line, + 'meson.build should use spaces for indentation')) return issues class ShellChecker(StyleChecker): + dependencies = ('shellcheck',) patterns = ('*.sh',) - results_line_regex = re.compile('In - line ([0-9]+):') - - def __init__(self, content): - super().__init__() - self.__content = content + results_line_regex = re.compile(r'In - line ([0-9]+):') - def check(self, line_numbers): + @classmethod + def check(cls, content, line_numbers): issues = [] - data = ''.join(self.__content).encode('utf-8') + data = ''.join(content).encode('utf-8') - try: - ret = subprocess.run(['shellcheck', '-Cnever', '-'], - input=data, stdout=subprocess.PIPE) - except FileNotFoundError: - issues.append(StyleIssue(0, None, "Please install shellcheck to validate shell script additions")) - return issues + ret = subprocess.run(['shellcheck', '-Cnever', '-'], + input=data, stdout=subprocess.PIPE) results = ret.stdout.decode('utf-8').splitlines() for nr, item in enumerate(results): @@ -370,11 +744,8 @@ class ShellChecker(StyleChecker): line = results[nr + 1] msg = results[nr + 2] - # Determined, but not yet used - position = msg.find('^') + 1 - if line_number in line_numbers: - issues.append(StyleIssue(line_number, line, msg)) + issues.append(StyleIssue(line_number, None, line, msg)) return issues @@ -383,67 +754,14 @@ class ShellChecker(StyleChecker): # Formatters # -_formatters = [] - -class FormatterRegistry(type): - def __new__(cls, clsname, bases, attrs): - newclass = super(FormatterRegistry, cls).__new__(cls, clsname, bases, attrs) - if clsname != 'Formatter': - _formatters.append(newclass) - return newclass - - -class Formatter(metaclass=FormatterRegistry): - enabled = True - - def __init__(self): - pass - - # - # Class methods - # - @classmethod - def formatters(cls, filename): - for formatter in _formatters: - if not cls.enabled: - continue - if formatter.supports(filename): - yield formatter - - @classmethod - def supports(cls, filename): - if not cls.enabled: - return False - for pattern in cls.patterns: - if fnmatch.fnmatch(os.path.basename(filename), pattern): - return True - return False - - @classmethod - def all_patterns(cls): - patterns = set() - for formatter in _formatters: - if not cls.enabled: - continue - patterns.update(formatter.patterns) - - return patterns - - -class AStyleFormatter(Formatter): - enabled = False - patterns = ('*.c', '*.cpp', '*.h') - - @classmethod - def format(cls, filename, data): - ret = subprocess.run(['astyle', *astyle_options], - input=data.encode('utf-8'), stdout=subprocess.PIPE) - return ret.stdout.decode('utf-8') +class Formatter(CheckerBase): + pass class CLangFormatter(Formatter): - enabled = False + dependencies = ('clang-format',) patterns = ('*.c', '*.cpp', '*.h') + priority = -1 @classmethod def format(cls, filename, data): @@ -456,7 +774,8 @@ class CLangFormatter(Formatter): class DoxygenFormatter(Formatter): patterns = ('*.c', '*.cpp') - return_regex = re.compile(' +\\* +\\\\return +[a-z]') + oneliner_regex = re.compile(r'^ +\* +\\(brief|param|return)\b.*\.$') + return_regex = re.compile(r' +\* +\\return +[a-z]') @classmethod def format(cls, filename, data): @@ -471,6 +790,7 @@ class DoxygenFormatter(Formatter): lines.append(line) continue + line = cls.oneliner_regex.sub(lambda m: m.group(0)[:-1], line) line = cls.return_regex.sub(lambda m: m.group(0)[:-1] + m.group(0)[-1].upper(), line) if line.find('*/') != -1: @@ -481,10 +801,42 @@ class DoxygenFormatter(Formatter): return '\n'.join(lines) +class DPointerFormatter(Formatter): + # Ensure consistent naming of variables related to the d-pointer design + # pattern. + patterns = ('*.cpp', '*.h') + + # The clang formatter runs first, we can thus rely on appropriate coding + # style. + declare_regex = re.compile(r'^(\t*)(const )?([a-zA-Z0-9_]+) \*( ?const )?([a-zA-Z0-9_]+) = (LIBCAMERA_[DO]_PTR)\(([a-zA-Z0-9_]+)\);$') + + @classmethod + def format(cls, filename, data): + lines = [] + + for line in data.split('\n'): + match = cls.declare_regex.match(line) + if match: + indent = match.group(1) or '' + const = match.group(2) or '' + macro = match.group(6) + klass = match.group(7) + if macro == 'LIBCAMERA_D_PTR': + var = 'Private *const d' + else: + var = f'{klass} *const o' + + line = f'{indent}{const}{var} = {macro}({klass});' + + lines.append(line) + + return '\n'.join(lines) + + class IncludeOrderFormatter(Formatter): patterns = ('*.cpp', '*.h') - include_regex = re.compile('^#include ["<]([^">]*)[">]') + include_regex = re.compile(r'^#include (["<])([^">]*)([">])') @classmethod def format(cls, filename, data): @@ -498,7 +850,21 @@ class IncludeOrderFormatter(Formatter): if match: # If the current line is an #include statement, add it to the # includes group and continue to the next line. - includes.append((line, match.group(1))) + open_token = match.group(1) + file_name = match.group(2) + close_token = match.group(3) + + # Ensure the "..." include style for internal headers and the + # <...> style for all other libcamera headers. + if (file_name.startswith('libcamera/internal')): + open_token = '"' + close_token = '"' + elif (file_name.startswith('libcamera/')): + open_token = '<' + close_token = '>' + + line = f'#include {open_token}{file_name}{close_token}' + includes.append((line, file_name)) continue # The current line is not an #include statement, output the sorted @@ -522,6 +888,17 @@ class IncludeOrderFormatter(Formatter): return '\n'.join(lines) +class Pep8Formatter(Formatter): + dependencies = ('autopep8',) + patterns = ('*.py',) + + @classmethod + def format(cls, filename, data): + ret = subprocess.run(['autopep8', '--ignore=E501', '-'], + input=data.encode('utf-8'), stdout=subprocess.PIPE) + return ret.stdout.decode('utf-8') + + class StripTrailingSpaceFormatter(Formatter): patterns = ('*.c', '*.cpp', '*.h', '*.py', 'meson.build') @@ -537,69 +914,27 @@ class StripTrailingSpaceFormatter(Formatter): # Style checking # -class Commit: - def __init__(self, commit): - self.commit = commit - - def get_info(self): - # Get the commit title and list of files. - ret = subprocess.run(['git', 'show', '--pretty=oneline', '--name-only', - self.commit], - stdout=subprocess.PIPE).stdout.decode('utf-8') - files = ret.splitlines() - # Returning title and files list as a tuple - return files[0], files[1:] - - def get_diff(self, top_level, filename): - return subprocess.run(['git', 'diff', '%s~..%s' % (self.commit, self.commit), - '--', '%s/%s' % (top_level, filename)], - stdout=subprocess.PIPE).stdout.decode('utf-8') - - def get_file(self, filename): - return subprocess.run(['git', 'show', '%s:%s' % (self.commit, filename)], - stdout=subprocess.PIPE).stdout.decode('utf-8') - - -class StagedChanges(Commit): - def __init__(self): - Commit.__init__(self, '') - - def get_info(self): - ret = subprocess.run(['git', 'diff', '--staged', '--name-only'], - stdout=subprocess.PIPE).stdout.decode('utf-8') - return "Staged changes", ret.splitlines() - - def get_diff(self, top_level, filename): - return subprocess.run(['git', 'diff', '--staged', '--', - '%s/%s' % (top_level, filename)], - stdout=subprocess.PIPE).stdout.decode('utf-8') +def check_commit(top_level, commit, checkers): + issues = [] + # Apply the commit checkers first. + for checker in CommitChecker.instances(commit, checkers): + issues_ = checker.check_dependencies() + if issues_: + issues += issues_ + continue -class Amendment(StagedChanges): - def __init__(self): - StagedChanges.__init__(self) + issues += checker.check(commit, top_level) - def get_info(self): - # Create a title using HEAD commit - ret = subprocess.run(['git', 'show', '--pretty=oneline', '--no-patch'], - stdout=subprocess.PIPE).stdout.decode('utf-8') - title = 'Amendment of ' + ret.strip() - # Extract the list of modified files - ret = subprocess.run(['git', 'diff', '--staged', '--name-only', 'HEAD~'], - stdout=subprocess.PIPE).stdout.decode('utf-8') - return title, ret.splitlines() + for issue in issues: + print(issue) - def get_diff(self, top_level, filename): - return subprocess.run(['git', 'diff', '--staged', 'HEAD~', '--', - '%s/%s' % (top_level, filename)], - stdout=subprocess.PIPE).stdout.decode('utf-8') + return len(issues) -def check_file(top_level, commit, filename): +def check_file(top_level, commit, filename, checkers): # Extract the line numbers touched by the commit. - diff = commit.get_diff(top_level, filename) - diff = diff.splitlines(True) - commit_diff = parse_diff(diff) + commit_diff = commit.get_diff(top_level, filename) lines = [] for hunk in commit_diff: @@ -612,9 +947,15 @@ def check_file(top_level, commit, filename): # Format the file after the commit with all formatters and compute the diff # between the unformatted and formatted contents. after = commit.get_file(filename) + issues = [] formatted = after - for formatter in Formatter.formatters(filename): + for formatter in Formatter.instances(filename, checkers): + issues_ = formatter.check_dependencies() + if issues_: + issues += issues_ + continue + formatted = formatter.format(filename, formatted) after = after.splitlines(True) @@ -627,11 +968,14 @@ def check_file(top_level, commit, filename): formatted_diff = [hunk for hunk in formatted_diff if hunk.intersects(lines)] # Check for code issues not related to formatting. - issues = [] - for checker in StyleChecker.checkers(filename): - checker = checker(after) + for checker in StyleChecker.instances(filename, checkers): + issues_ = checker.check_dependencies() + if issues_: + issues += issues_ + continue + for hunk in commit_diff: - issues += checker.check(hunk.side('to').touched) + issues += checker.check(after, hunk.side('to').touched) # Print the detected issues. if len(issues) == 0 and len(formatted_diff) == 0: @@ -645,42 +989,38 @@ def check_file(top_level, commit, filename): print(hunk) if len(issues): - issues = sorted(issues, key=lambda i: i.line_number) + issues = sorted(issues, key=lambda i: getattr(i, 'line_number', -1)) for issue in issues: - print('%s#%u: %s' % (Colours.fg(Colours.Yellow), issue.line_number, issue.msg)) - if issue.line is not None: - print('+%s%s' % (issue.line.rstrip(), Colours.reset())) + print(issue) return len(formatted_diff) + len(issues) -def check_style(top_level, commit): - title, files = commit.get_info() - +def check_style(top_level, commit, checkers): + title = commit.commit + ' ' + commit.title separator = '-' * len(title) print(separator) print(title) print(separator) + # Apply the commit checkers first. + issues = check_commit(top_level, commit, checkers) + # Filter out files we have no checker for. patterns = set() patterns.update(StyleChecker.all_patterns()) patterns.update(Formatter.all_patterns()) - files = [f for f in files if len([p for p in patterns if fnmatch.fnmatch(os.path.basename(f), p)])] - if len(files) == 0: - print("Commit doesn't touch source files, skipping") - return 0 + files = [f for f in commit.files() if len([p for p in patterns if fnmatch.fnmatch(os.path.basename(f), p)])] - issues = 0 for f in files: - issues += check_file(top_level, commit, f) + issues += check_file(top_level, commit, f, checkers) if issues == 0: - print("No style issue detected") + print('No issue detected') else: print('---') - print("%u potential style %s detected, please review" % \ - (issues, 'issue' if issues == 1 else 'issues')) + print('%u potential %s detected, please review' % + (issues, 'issue' if issues == 1 else 'issues')) return issues @@ -723,8 +1063,8 @@ def main(argv): # Parse command line arguments parser = argparse.ArgumentParser() - parser.add_argument('--formatter', '-f', type=str, choices=['astyle', 'clang-format'], - help='Code formatter. Default to clang-format if not specified.') + parser.add_argument('--checkers', '-c', type=str, + help='Specify which checkers to run as a comma-separated list. Defaults to all checkers') parser.add_argument('--staged', '-s', action='store_true', help='Include the changes in the index. Defaults to False') parser.add_argument('--amend', '-a', action='store_true', @@ -733,30 +1073,18 @@ def main(argv): help='Revision range (as defined by git rev-parse). Defaults to HEAD if not specified.') args = parser.parse_args(argv[1:]) - # Check for required dependencies. + if args.checkers: + args.checkers = args.checkers.split(',') + + # Check for required common dependencies. for command, mandatory in dependencies.items(): found = shutil.which(command) if mandatory and not found: - print("Executable %s not found" % command) + print('Executable %s not found' % command) return 1 dependencies[command] = found - if args.formatter: - if not args.formatter in dependencies or \ - not dependencies[args.formatter]: - print("Formatter %s not available" % args.formatter) - return 1 - formatter = args.formatter - else: - if dependencies['clang-format']: - CLangFormatter.enabled = True - elif dependencies['astyle']: - AStyleFormatter.enabled = True - else: - print("No formatter found, please install clang-format or astyle") - return 1 - # Get the top level directory to pass absolute file names to git diff # commands, in order to support execution from subdirectories of the git # tree. @@ -781,7 +1109,7 @@ def main(argv): issues = 0 for commit in commits: - issues += check_style(top_level, commit) + issues += check_style(top_level, commit, args.checkers) print('') if issues: |