| # 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 b"\f" not 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 <indentSpaceCount> 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 <indentSpaceCount>, |
| # 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 expected 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) |