# PortAudio Repository Whitespace Linter # # Run this script from the root of the repository using the command: # python pa_whitelint.py # # Check all source files for the following: # 1. Consistent line endings are used throughout each file. # 2. No tabs are present. Use spaces for indenting. # 3. Indenting: leading whitespace is usually a multiple of 4 spaces, # with permissive exceptions for continuation lines. # 4. Lines have no trailing whitespace. # 5. No non-ASCII or weird control characters are present. # 6. End-of-line is present at end-of-file. # 7. No empty (or whitespace) lines at end-of-file. from pathlib import Path import re import sys # Configuration: # Check these file types: sourceFileTypes = ["*.c", "*.h", "*.cpp", "*.cxx", "*.hxx"] # Scan these directories dirs = ["src", "include", "examples", "test", "qa"] # Exclude files or directories with the following names: excludePathParts = [ "ASIOSDK", "iasiothiscallresolver.cpp", "iasiothiscallresolver.h", "mingw-include", ] indentSpaceCount = 4 verbose = True checkBadIndenting = True verboseBadIndenting = True # (End configuration) class FileStatus: """Issue status for a particular file. Stores issue counts for each type of issue.""" def __init__(self, path): self.path = path issueNames = [ "has-inconsistent-line-endings", "has-tabs", "has-bad-indenting", "has-trailing-whitespace", "has-bad-character", "has-empty-line-at-end-of-file", "has-no-eol-character-at-end-of-file", ] self.issueCounts = dict.fromkeys(issueNames, 0) def incrementIssueCount(self, issueName): assert issueName in self.issueCounts # catch typos in issueName self.issueCounts[issueName] += 1 def hasIssue(self, issueName): return self.issueCounts[issueName] > 0 def hasIssues(self): return any(count > 0 for count in self.issueCounts.values()) def issueSummaryString(self): return str.join(", ", [name for name in self.issueCounts if self.issueCounts[name] > 0]) def multilineCommentIsOpenAtEol(lineText, wasOpenAtStartOfLine): isOpen = wasOpenAtStartOfLine index = 0 end = len(lineText) while index != -1 and index < end: if isOpen: index = lineText.find(b"*/", index) if index != -1: isOpen = False index += 2 else: index = lineText.find(b"/*", index) if index != -1: isOpen = True index += 2 return isOpen def allowStrangeIndentOnFollowingLine(lineText): """Compute whether a non-standard indent is allowed on the following line. A line allows an unusual indent to follow if it is the beginning of a multi-line function parameter list, an element of a function parameter list, or an incomplete expression (binary operator, etc.). """ s = lineText.strip(b" ") if len(s) == 0: return False if s.rfind(b"*/") == (len(s) - 2): # line has a trailing comment, strip it commentStart = s.rfind(b"/*") if commentStart != -1: s = s[:commentStart].strip(b" ") if len(s) == 0: return False if len(s) == 0: return False okChars = b'(,\\+-/*=&|?:"' if s[-1] in okChars: # non-comment program text has trailing okChar: '(' or ',' etc. return True return False def allowStrangeIndentOfLine(lineText): """Compute whether a non-standard indent is allowed on the line. A line is allowed an unusual indent if it is the continuation of an incomplete expression (binary operator, etc.). """ s = lineText.strip(b" ") if len(s) == 0: return False okChars = b'+-/*=&|?:)"' if s[0] in okChars: return True return False # Run the checks over all files specified by [sourceFileTypes, dirs, excludePathParts]: statusSummary = [] for dir in dirs: for ext in sourceFileTypes: for path in Path(dir).rglob(ext): if any(part in path.parts for part in excludePathParts): continue # during development, uncomment the following 2 lines and select a specific path: #if not "qa" in path.parts: # continue data = path.read_bytes() status = FileStatus(path) statusSummary.append(status) # Perform checks: # 1. Consistent line endings # check and then normalize to \n line endings for the benefit of the rest of the program if b"\r" in data and b"\n" in data: # CRLF (Windows) case: check for stray CR or LF, then convert CRLF to LF assert not b"\f" in data # we'll use \f as a sentinel during conversion d = data.replace(b"\r\n", b"\f") if b"\r" in d: status.incrementIssueCount("has-inconsistent-line-endings") if verbose: print("error: {0} stray carriage return".format(path)) if b"\n" in d: status.incrementIssueCount("has-inconsistent-line-endings") if verbose: print("error: {0} stray newline".format(path)) data = d.replace(b"\f", b"\n") # normalize line endings elif b"\r" in data: # CR (Classic Mac) case: convert CR to LF data = d.replace(b"\r", b"\n") # normalize line endings else: # LF (Unix) case: no change pass lines = data.split(b"\n") # relies on newline normalization above # 2. Absence of tabs lineNo = 1 for line in lines: if b"\t" in line: status.incrementIssueCount("has-tabs") if verbose: print("error: {0}({1}) contains tab".format(path, lineNo)) lineNo += 1 data = data.replace(b"\t", b" "*indentSpaceCount) # normalize tabs to spaces for indent algorithm below lines = data.split(b"\n") # recompute lines, relies on newline normalization above # 3. Correct leading whitespace / bad indenting if checkBadIndenting: leadingWhitespaceRe = re.compile(b"^\s*") commentIsOpen = False previousLine = b"" previousIndent = 0 lineNo = 1 for line in lines: if commentIsOpen: # don't check leading whitespace inside comments commentIsOpen = multilineCommentIsOpenAtEol(line, commentIsOpen) previousIndent = 0 else: m = leadingWhitespaceRe.search(line) indent = m.end() - m.start() if indent != len(line): # ignore whitespace lines, they are considered trailing whitespace if indent % indentSpaceCount != 0 and indent != previousIndent: # potential bad indents are not multiples of , # and are not indented the same as the previous line s = previousLine if not allowStrangeIndentOnFollowingLine(previousLine) and not allowStrangeIndentOfLine(line): status.incrementIssueCount("has-bad-indenting") if verbose or verboseBadIndenting: print("error: {0}({1}) bad indent: {2}".format(path, lineNo, indent)) print(line) commentIsOpen = multilineCommentIsOpenAtEol(line, commentIsOpen) previousIndent = indent previousLine = line lineNo += 1 # 4. No trailing whitespace trailingWhitespaceRe = re.compile(b"\s*$") lineNo = 1 for line in lines: m = trailingWhitespaceRe.search(line) trailing = m.end() - m.start() if trailing > 0: status.incrementIssueCount("has-trailing-whitespace") if verbose: print("error: {0}({1}) trailing whitespace:".format(path, lineNo)) print(line) lineNo += 1 # 5. No non-ASCII or weird control characters badCharactersRe = re.compile(b"[^\t\r\n\x20-\x7E]+") lineNo = 1 for line in lines: m = badCharactersRe.search(line) if m: bad = m.end() - m.start() if bad > 0: status.incrementIssueCount("has-bad-character") if verbose: print("error: {0}({1}) bad character:".format(path, lineNo)) print(line) lineNo += 1 # 6. Require EOL at EOF if len(data) == 0: status.incrementIssueCount("has-no-eol-character-at-end-of-file") if verbose: lineNo = 1 print("error: {0}({1}) no end-of-line at end-of-file (empty file)".format(path, lineNo)) else: lastChar = data[-1] if lastChar != b"\n"[0]: status.incrementIssueCount("has-no-eol-character-at-end-of-file") if verbose: lineNo = len(lines) print("error: {0}({1}) no end-of-line at end-of-file".format(path, lineNo)) # 7. No "empty" (or whitespace) lines at end-of-file. # Cases: # 1. There is an EOL at EOF. Since the lines array is constructed by splitting on '\n', # the final element in the lines array will be an empty string. This is expeced and allowed. # Then continue to check for earlier empty lines. # 2. There is no EOF at EOL. # Check for empty lines, including the final line. expectEmptyFinalLine = not status.hasIssue("has-no-eol-character-at-end-of-file") # i.e. we have EOL at EOF finalLineNo = len(lines) lineNo = finalLineNo for line in reversed(lines): if lineNo == finalLineNo and expectEmptyFinalLine: assert len(line) == 0 # this is guaranteed, since lines = data.split('\n') and there is an EOL at EOF else: s = line.strip(b" ") # whitespace-only-lines count as empty if len(s) == 0: status.incrementIssueCount("has-empty-line-at-end-of-file") if verbose: print("error: {0}({1}) empty line at end-of-file".format(path, lineNo)) else: break # stop checking once we encounter a non-empty line lineNo -= 1 print("SUMMARY") print("=======") issuesFound = False for s in statusSummary: if s.hasIssues(): issuesFound = True print("error: " + str(s.path) + " (" + s.issueSummaryString() + ")") if issuesFound: sys.exit(1) else: print("all good.") sys.exit(0)