298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			298 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | # 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 <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 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) |