blob: 835f2a9fcd719261bcb562c854990a00d13ebfd7 [file] [log] [blame] [edit]
#!/usr/bin/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 clang-format
#
# TODO:
#
# - Support other formatting tools and checkers (cppcheck, cpplint, kwstyle, ...)
# - Split large hunks to minimize context noise
# - Improve style issues counting
#
import argparse
import difflib
import fnmatch
import os.path
import re
import shutil
import subprocess
import sys
dependencies = {
'clang-format': True,
'git': True,
}
# ------------------------------------------------------------------------------
# Colour terminal handling
#
class Colours:
Default = 0
Black = 0
Red = 31
Green = 32
Yellow = 33
Blue = 34
Magenta = 35
Cyan = 36
LightGrey = 37
DarkGrey = 90
LightRed = 91
LightGreen = 92
Lightyellow = 93
LightBlue = 94
LightMagenta = 95
LightCyan = 96
White = 97
@staticmethod
def fg(colour):
if sys.stdout.isatty():
return '\033[%um' % colour
else:
return ''
@staticmethod
def bg(colour):
if sys.stdout.isatty():
return '\033[%um' % (colour + 10)
else:
return ''
@staticmethod
def reset():
if sys.stdout.isatty():
return '\033[0m'
else:
return ''
# ------------------------------------------------------------------------------
# Diff parsing, handling and printing
#
class DiffHunkSide(object):
"""A side of a diff hunk, recording line numbers"""
def __init__(self, start):
self.start = start
self.touched = []
self.untouched = []
def __len__(self):
return len(self.touched) + len(self.untouched)
class DiffHunk(object):
diff_header_regex = re.compile(r'@@ -([0-9]+),?([0-9]+)? \+([0-9]+),?([0-9]+)? @@')
def __init__(self, line):
match = DiffHunk.diff_header_regex.match(line)
if not match:
raise RuntimeError("Malformed diff hunk header '%s'" % line)
self.__from_line = int(match.group(1))
self.__to_line = int(match.group(3))
self.__from = DiffHunkSide(self.__from_line)
self.__to = DiffHunkSide(self.__to_line)
self.lines = []
def __repr__(self):
s = '%s@@ -%u,%u +%u,%u @@\n' % \
(Colours.fg(Colours.Cyan),
self.__from.start, len(self.__from),
self.__to.start, len(self.__to))
for line in self.lines:
if line[0] == '-':
s += Colours.fg(Colours.Red)
elif line[0] == '+':
s += Colours.fg(Colours.Green)
if line[0] == '-':
spaces = 0
for i in range(len(line)):
if line[-i-1].isspace():
spaces += 1
else:
break
spaces = len(line) - spaces
line = line[0:spaces] + Colours.bg(Colours.Red) + line[spaces:]
s += line
s += Colours.reset()
s += '\n'
return s[:-1]
def append(self, line):
if line[0] == ' ':
self.__from.untouched.append(self.__from_line)
self.__from_line += 1
self.__to.untouched.append(self.__to_line)
self.__to_line += 1
elif line[0] == '-':
self.__from.touched.append(self.__from_line)
self.__from_line += 1
elif line[0] == '+':
self.__to.touched.append(self.__to_line)
self.__to_line += 1
self.lines.append(line.rstrip('\n'))
def intersects(self, lines):
for line in lines:
if line in self.__from.touched:
return True
return False
def side(self, side):
if side == 'from':
return self.__from
else:
return self.__to
def parse_diff(diff):
hunks = []
hunk = None
for line in diff:
if line.startswith('@@'):
if hunk:
hunks.append(hunk)
hunk = DiffHunk(line)
elif hunk is not None:
hunk.append(line)
if hunk:
hunks.append(hunk)
return hunks
# ------------------------------------------------------------------------------
# Commit, Staged Changes & Amendments
#
class CommitFile:
def __init__(self, name):
info = name.split()
self.__status = info[0][0]
# For renamed files, store the new name
if self.__status == 'R':
self.__filename = info[2]
else:
self.__filename = info[1]
@property
def filename(self):
return self.__filename
@property
def status(self):
return self.__status
class Commit:
def __init__(self, commit):
self.commit = commit
self._parse()
def _parse(self):
# Get the commit title and list of files.
ret = subprocess.run(['git', 'show', '--pretty=oneline', '--name-status',
self.commit],
stdout=subprocess.PIPE).stdout.decode('utf-8')
files = ret.splitlines()
self._files = [CommitFile(f) for f in files[1:]]
self._title = files[0]
def files(self, filter='AMR'):
return [f.filename for f in self._files if f.status in filter]
@property
def title(self):
return self._title
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):
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()]
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(StagedChanges):
def __init__(self):
StagedChanges.__init__(self)
def _parse(self):
# Create a title using HEAD commit
ret = subprocess.run(['git', 'show', '--pretty=oneline', '--no-patch'],
stdout=subprocess.PIPE).stdout.decode('utf-8')
self._title = 'Amendment of ' + ret.strip()
# 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:
bases[0].subclasses.append(newclass)
return newclass
# ------------------------------------------------------------------------------
# Commit Checkers
#
class CommitChecker(metaclass=ClassRegistry):
subclasses = []
def __init__(self):
pass
#
# Class methods
#
@classmethod
def checkers(cls):
for checker in cls.subclasses:
yield checker
class CommitIssue(object):
def __init__(self, msg):
self.msg = msg
class HeaderAddChecker(CommitChecker):
@classmethod
def check(cls, commit, top_level):
issues = []
meson_files = [f for f in commit.files('M')
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
# ------------------------------------------------------------------------------
# Style Checkers
#
class StyleChecker(metaclass=ClassRegistry):
subclasses = []
def __init__(self):
pass
#
# Class methods
#
@classmethod
def checkers(cls, filename):
for checker in cls.subclasses:
if checker.supports(filename):
yield checker
@classmethod
def supports(cls, filename):
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 checker in cls.subclasses:
patterns.update(checker.patterns)
return patterns
class StyleIssue(object):
def __init__(self, line_number, line, msg):
self.line_number = line_number
self.line = line
self.msg = msg
class IncludeChecker(StyleChecker):
patterns = ('*.cpp', '*.h')
headers = ('assert', 'ctype', 'errno', 'fenv', 'float', 'inttypes',
'limits', 'locale', '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
def check(self, line_numbers):
issues = []
for line_number in line_numbers:
line = self.__content[line_number - 1]
match = IncludeChecker.include_regex.match(line)
if not match:
continue
header = match.group(1)
if header not in IncludeChecker.headers:
continue
issues.append(StyleIssue(line_number, line,
'C compatibility header <%s.h> is preferred' % header))
return issues
class LogCategoryChecker(StyleChecker):
log_regex = re.compile('\\bLOG\((Debug|Info|Warning|Error|Fatal)\)')
patterns = ('*.cpp',)
def __init__(self, content):
super().__init__()
self.__content = content
def check(self, line_numbers):
issues = []
for line_number in line_numbers:
line = self.__content[line_number-1]
if not LogCategoryChecker.log_regex.search(line):
continue
issues.append(StyleIssue(line_number, line, 'LOG() should use categories'))
return issues
class MesonChecker(StyleChecker):
patterns = ('meson.build',)
def __init__(self, content):
super().__init__()
self.__content = content
def check(self, 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))
return issues
class ShellChecker(StyleChecker):
patterns = ('*.sh',)
results_line_regex = re.compile('In - line ([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(['shellcheck', '-Cnever', '-'],
input=data, stdout=subprocess.PIPE)
except FileNotFoundError:
issues.append(StyleIssue(0, None, 'Please install shellcheck to validate shell script additions'))
return issues
results = ret.stdout.decode('utf-8').splitlines()
for nr, item in enumerate(results):
search = re.search(ShellChecker.results_line_regex, item)
if search is None:
continue
line_number = int(search.group(1))
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))
return issues
# ------------------------------------------------------------------------------
# Formatters
#
class Formatter(metaclass=ClassRegistry):
subclasses = []
def __init__(self):
pass
#
# Class methods
#
@classmethod
def formatters(cls, filename):
for formatter in cls.subclasses:
if formatter.supports(filename):
yield formatter
@classmethod
def supports(cls, filename):
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 cls.subclasses:
patterns.update(formatter.patterns)
return patterns
class CLangFormatter(Formatter):
patterns = ('*.c', '*.cpp', '*.h')
@classmethod
def format(cls, filename, data):
ret = subprocess.run(['clang-format', '-style=file',
'-assume-filename=' + filename],
input=data.encode('utf-8'), stdout=subprocess.PIPE)
return ret.stdout.decode('utf-8')
class DoxygenFormatter(Formatter):
patterns = ('*.c', '*.cpp')
return_regex = re.compile(' +\\* +\\\\return +[a-z]')
@classmethod
def format(cls, filename, data):
lines = []
in_doxygen = False
for line in data.split('\n'):
if line.find('/**') != -1:
in_doxygen = True
if not in_doxygen:
lines.append(line)
continue
line = cls.return_regex.sub(lambda m: m.group(0)[:-1] + m.group(0)[-1].upper(), line)
if line.find('*/') != -1:
in_doxygen = False
lines.append(line)
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 ["<]([^">]*)[">]')
@classmethod
def format(cls, filename, data):
lines = []
includes = []
# Parse blocks of #include statements, and output them as a sorted list
# when we reach a non #include statement.
for line in data.split('\n'):
match = IncludeOrderFormatter.include_regex.match(line)
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)))
continue
# The current line is not an #include statement, output the sorted
# stashed includes first, and then the current line.
if len(includes):
includes.sort(key=lambda i: i[1])
for include in includes:
lines.append(include[0])
includes = []
lines.append(line)
# In the unlikely case the file ends with an #include statement, make
# sure we output the stashed includes.
if len(includes):
includes.sort(key=lambda i: i[1])
for include in includes:
lines.append(include[0])
includes = []
return '\n'.join(lines)
class StripTrailingSpaceFormatter(Formatter):
patterns = ('*.c', '*.cpp', '*.h', '*.py', 'meson.build')
@classmethod
def format(cls, filename, data):
lines = data.split('\n')
for i in range(len(lines)):
lines[i] = lines[i].rstrip() + '\n'
return ''.join(lines)
# ------------------------------------------------------------------------------
# Style checking
#
def check_file(top_level, commit, filename):
# Extract the line numbers touched by the commit.
commit_diff = commit.get_diff(top_level, filename)
lines = []
for hunk in commit_diff:
lines.extend(hunk.side('to').touched)
# Skip commits that don't add any line.
if len(lines) == 0:
return 0
# Format the file after the commit with all formatters and compute the diff
# between the unformatted and formatted contents.
after = commit.get_file(filename)
formatted = after
for formatter in Formatter.formatters(filename):
formatted = formatter.format(filename, formatted)
after = after.splitlines(True)
formatted = formatted.splitlines(True)
diff = difflib.unified_diff(after, formatted)
# Split the diff in hunks, recording line number ranges for each hunk, and
# filter out hunks that are not touched by the commit.
formatted_diff = parse_diff(diff)
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 hunk in commit_diff:
issues += checker.check(hunk.side('to').touched)
# Print the detected issues.
if len(issues) == 0 and len(formatted_diff) == 0:
return 0
print('%s---' % Colours.fg(Colours.Red), filename)
print('%s+++' % Colours.fg(Colours.Green), filename)
if len(formatted_diff):
for hunk in formatted_diff:
print(hunk)
if len(issues):
issues = sorted(issues, key=lambda i: i.line_number)
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()))
return len(formatted_diff) + len(issues)
def check_style(top_level, commit):
separator = '-' * len(commit.title)
print(separator)
print(commit.title)
print(separator)
issues = 0
# Apply the commit checkers first.
for checker in CommitChecker.checkers():
for issue in checker.check(commit, top_level):
print('%s%s%s' % (Colours.fg(Colours.Yellow), issue.msg, Colours.reset()))
issues += 1
# 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 commit.files() if len([p for p in patterns if fnmatch.fnmatch(os.path.basename(f), p)])]
for f in files:
issues += check_file(top_level, commit, f)
if issues == 0:
print('No issue detected')
else:
print('---')
print('%u potential %s detected, please review' %
(issues, 'issue' if issues == 1 else 'issues'))
return issues
def extract_commits(revs):
"""Extract a list of commits on which to operate from a revision or revision
range.
"""
ret = subprocess.run(['git', 'rev-parse', revs], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if ret.returncode != 0:
print(ret.stderr.decode('utf-8').splitlines()[0])
return []
revlist = ret.stdout.decode('utf-8').splitlines()
# If the revlist contains more than one item, pass it to git rev-list to list
# each commit individually.
if len(revlist) > 1:
ret = subprocess.run(['git', 'rev-list', *revlist], stdout=subprocess.PIPE)
revlist = ret.stdout.decode('utf-8').splitlines()
revlist.reverse()
return [Commit(x) for x in revlist]
def git_top_level():
"""Get the absolute path of the git top-level directory."""
ret = subprocess.run(['git', 'rev-parse', '--show-toplevel'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
if ret.returncode != 0:
print(ret.stderr.decode('utf-8').splitlines()[0])
return None
return ret.stdout.decode('utf-8').strip()
def main(argv):
# Parse command line arguments
parser = argparse.ArgumentParser()
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',
help='Include changes in the index and the previous patch combined. Defaults to False')
parser.add_argument('revision_range', type=str, default=None, nargs='?',
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.
for command, mandatory in dependencies.items():
found = shutil.which(command)
if mandatory and not found:
print('Executable %s not found' % command)
return 1
dependencies[command] = found
# 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.
top_level = git_top_level()
if top_level is None:
return 1
commits = []
if args.staged:
commits.append(StagedChanges())
if args.amend:
commits.append(Amendment())
# If none of --staged or --amend was passed
if len(commits) == 0:
# And no revisions were passed, then default to HEAD
if not args.revision_range:
args.revision_range = 'HEAD'
if args.revision_range:
commits += extract_commits(args.revision_range)
issues = 0
for commit in commits:
issues += check_style(top_level, commit)
print('')
if issues:
return 1
else:
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))