From 092cb01bb30f3dd1fd31aebc6a8fc776567854e3 Mon Sep 17 00:00:00 2001 From: Aparajita Fishman Date: Fri, 20 Apr 2012 11:57:18 +0200 Subject: [PATCH] Added capp_lint to Tools --- Tools/capp_lint/README | 7 + Tools/capp_lint/capp_lint | 1012 +++++++++++++++++++++++++++++++++++ Tools/capp_lint/capp_lint.1 | 181 +++++++ Tools/capp_lint/install.sh | 15 + Tools/capp_lint/pre-commit | 107 ++++ 5 files changed, 1322 insertions(+) create mode 100644 Tools/capp_lint/README create mode 100755 Tools/capp_lint/capp_lint create mode 100644 Tools/capp_lint/capp_lint.1 create mode 100755 Tools/capp_lint/install.sh create mode 100644 Tools/capp_lint/pre-commit diff --git a/Tools/capp_lint/README b/Tools/capp_lint/README new file mode 100644 index 000000000..47a2ca97a --- /dev/null +++ b/Tools/capp_lint/README @@ -0,0 +1,7 @@ +capp_lint is a formatting checker for Objective-J. It performs a number of checks that catch +common formatting mistakes, as defined in the Cappuccino coding style guidelines: + +http://cappuccino.org/contribute/coding-style.php + +To install capp_lint and its man page, execute install.sh in Terminal. You must have sudo access +to run the install script. diff --git a/Tools/capp_lint/capp_lint b/Tools/capp_lint/capp_lint new file mode 100755 index 000000000..4801b81db --- /dev/null +++ b/Tools/capp_lint/capp_lint @@ -0,0 +1,1012 @@ +#!/usr/bin/env python +# +# capp_lint.py - Check Objective-J source code formatting, +# according to Cappuccino standards: +# +# http://cappuccino.org/contribute/coding-style.php +# +# Copyright (C) 2011 Aparajita Fishman + +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import with_statement +from optparse import OptionParser +from string import Template +import cgi +import cStringIO +import os +import os.path +import re +import sys + + +EXIT_CODE_SHOW_HTML = 205 +EXIT_CODE_SHOW_TOOLTIP = 206 + + +def exit_show_html(html): + sys.stdout.write(html.encode('utf-8')) + sys.exit(EXIT_CODE_SHOW_HTML) + + +def exit_show_tooltip(text): + sys.stdout.write(text) + sys.exit(EXIT_CODE_SHOW_TOOLTIP) + + +def within_textmate(): + return os.getenv('TM_APP_PATH') is not None + + +def tabs2spaces(text, positions=None): + while True: + index = text.find(u'\t') + + if index < 0: + return text + + spaces = u' ' * (4 - (index % 4)) + text = text[0:index] + spaces + text[index + 1:] + + if positions is not None: + positions.append(index) + + +def relative_path(basedir, filename): + if filename.find(basedir) == 0: + filename = filename[len(basedir) + 1:] + + return filename + + +class LintChecker(object): + + VAR_BLOCK_START_RE = re.compile(ur'''(?x) + (?P\s*) # indent before a var keyword + (?Pvar\s+) # var keyword and whitespace after + (?P[a-zA-Z_$]\w*)\s* + (?: + (?P=)\s* + (?P.*) + | + (?P[,;+\-/*%^&|=\\]) + ) + ''') + + SEPARATOR_RE = re.compile(ur'''(?x) + (?P.*) # Everything up to the line separator + (?P[,;+\-/*%^&|=\\]) # The line separator + \s* # Optional whitespace after + $ # End of expression + ''') + + INDENTED_EXPRESSION_RE_TEMPLATE = ur'''(?x) + [ ]{%d} # Placeholder for indent of first identifier that started block + (?P.+) # Expression + ''' + + VAR_BLOCK_RE_TEMPLATE = ur'''(?x) + [ ]{%d} # Placeholder for indent of first identifier that started block + (?P\s*) # Capture any further indent + (?: + (?P[\[\{].*) + | + (?P[a-zA-Z_$]\w*)\s* + (?: + (?P=)\s* + (?P.*) + | + (?P[,;+\-/*%%^&|=\\]) + ) + | + (?P.+) + ) + ''' + + STATEMENT_RE = re.compile(ur'''(?x) + \s*((continue|do|for|function|if|else|return|switch|while|with)\b|\[+\s*[a-zA-Z_$]\w*\s+[a-zA-Z_$]\w*\s*[:\]]) + ''') + + TRAILING_WHITESPACE_RE = re.compile(ur'^.*(\s+)$') + STRIP_LINE_COMMENT_RE = re.compile(ur'(.*)\s*(?://.*|/\*.*\*/\s*)$') + LINE_COMMENT_RE = re.compile(ur'\s*(?:/\*.*\*/\s*|//.*)$') + BLOCK_COMMENT_START_RE = re.compile(ur'\s*/\*.*(?!\*/\s*)$') + BLOCK_COMMENT_END_RE = re.compile(ur'.*?\*/') + METHOD_RE = ur'[-+]\s*\([a-zA-Z_$]\w*\)\s*[a-zA-Z_$]\w*' + FUNCTION_RE = re.compile(ur'\s*function\s*(?P[a-zA-Z_$]\w*)?\(.*\)\s*\{?') + STRING_LITERAL_RE = re.compile(ur'(?]*\)\s*\w+|[a-zA-Z_$]\w*(\+\+|--)|([ -+*/%^&|<>!]=?|&&|\|\||<<|>>>|={1,3}|!==?)\s*[-+][\w(\[])'), 'pass': False}, + + 'preprocess': STD_IGNORES, + 'regex': re.compile(ur'(?<=[\w)\]"\']|([ ]))([-+*/%^]|&&?|\|\|?|<<|>>>?)(?=[\w({\["\']|(?(1)\b\b|[ ]))'), + 'error': 'binary operator without surrounding spaces', + 'showPositionForGroup': 2, + 'type': ERROR_TYPE_WARNING + }, + { + # Filter out @import statements, method declarations + 'filter': {'regex': re.compile(ur'^(@import\b|\s*' + METHOD_RE + ')'), 'pass': False}, + + 'preprocess': STD_IGNORES, + 'regex': re.compile(ur'(?:[-*/%^&|<>!]=?|&&|\|\||<<|>>>|={1,3}|!==?)\s*(?>>?=)(?=[\w({\["\']|(?(1)\b\b|[ ]))'), + 'error': 'assignment operator without surrounding spaces', + 'showPositionForGroup': 2, + 'type': ERROR_TYPE_WARNING + }, + { + # Filter out @import statements and @implementation/method declarations + 'filter': {'regex': re.compile(ur'^(@import\b|@implementation\b|\s*' + METHOD_RE + ')'), 'pass': False}, + + 'preprocess': STD_IGNORES, + 'regex': re.compile(ur'(?<=[\w)\]"\']|([ ]))(===?|!==?|[<>]=?)(?=[\w({\["\']|(?(1)\b\b|[ ]))'), + 'error': 'comparison operator without surrounding spaces', + 'showPositionForGroup': 2, + 'type': ERROR_TYPE_WARNING + }, + { + 'regex': re.compile(ur'^(\s+)' + METHOD_RE + '|^\s*[-+](\()[a-zA-Z_$][\w]*\)\s*[a-zA-Z_$]\w*|^\s*[-+]\s*\([a-zA-Z_$][\w]*\)(\s+)[a-zA-Z_$]\w*'), + 'error': 'extra or missing space in a method declaration', + 'showPositionForGroup': 0, + 'type': ERROR_TYPE_WARNING + }, + { + # Check for brace following a class or method declaration + 'regex': re.compile(ur'^(?:\s*[-+]\s*\([a-zA-Z_$]\w*\)|@implementation)\s*[a-zA-Z_$][\w]*.*?\s*(\{)\s*(?:$|//.*$)'), + 'error': 'braces should be on their own line', + 'showPositionForGroup': 0, + 'type': ERROR_TYPE_ILLEGAL + }, + { + 'regex': re.compile(ur'^\s*var\s+[a-zA-Z_$]\w*\s*=\s*function\s+([a-zA-Z_$]\w*)\s*\('), + 'error': 'function name is ignored', + 'showPositionForGroup': 1, + 'skip': True, + 'type': ERROR_TYPE_WARNING + }, + ) + + VAR_DECLARATIONS = ['none', 'single', 'strict'] + VAR_DECLARATIONS_NONE = 0 + VAR_DECLARATIONS_SINGLE = 1 + VAR_DECLARATIONS_STRICT = 2 + + DIRS_TO_SKIP = ('.git', 'Frameworks', 'Build', 'Resources', 'CommonJS', 'Objective-J') + + ERROR_FORMATS = ('text', 'html') + TEXT_ERROR_SINGLE_FILE_TEMPLATE = Template(u'$lineNum: $message.\n+$line\n') + TEXT_ERROR_MULTI_FILE_TEMPLATE = Template(u'$filename:$lineNum: $message.\n+$line\n') + + def __init__(self, view, basedir='', var_declarations=VAR_DECLARATIONS_SINGLE, verbose=False): + self.view = view + self.basedir = unicode(basedir, 'utf-8') + self.errors = [] + self.errorFiles = [] + self.filesToCheck = [] + self.varDeclarations = var_declarations + self.verbose = verbose + self.sourcefile = None + self.filename = u'' + self.line = u'' + self.lineNum = 0 + self.varIndent = u'' + self.identifierIndent = u'' + + self.fileChecklist = ( + {'title': 'Check variable blocks', 'action': self.check_var_blocks}, + ) + + def run_line_checks(self): + for check in self.LINE_CHECKLIST: + option = check.get('option') + + if option: + default = check.get('optionDefault', False) + + if not self.view.settings().get(option, default): + continue + + line = self.line + lineFilter = check.get('filter') + + if lineFilter: + match = lineFilter['regex'].search(line) + + if (match and not lineFilter['pass']) or (not match and lineFilter['pass']): + continue + + preprocess = check.get('preprocess') + + if preprocess: + if not isinstance(preprocess, (list, tuple)): + preprocess = (preprocess,) + + for processor in preprocess: + regex = processor.get('regex') + + if regex: + line = regex.sub(processor.get('replace', ''), line) + + regex = check.get('regex') + + if not regex: + continue + + match = regex.search(line) + + if not match: + continue + + positions = [] + group = check.get('showPositionForGroup') + + if (check.get('id') == 'tabs'): + line = tabs2spaces(line, positions=positions) + elif group is not None: + line = tabs2spaces(line) + + for match in regex.finditer(line): + if group > 0: + positions.append(match.start(group)) + else: + # group 0 means show the first non-empty match + for i in range(1, len(match.groups()) + 1): + if match.start(i) >= 0: + positions.append(match.start(i)) + break + + self.error(check['error'], line=line, positions=positions, type=check['type']) + + def next_statement(self, expect_line=False, check_line=True): + try: + while True: + raw_line = self.sourcefile.next()[:-1] # strip EOL + + try: + self.line = unicode(raw_line, 'utf-8', 'strict') # convert to Unicode + self.lineNum += 1 + except UnicodeDecodeError: + self.line = unicode(raw_line, 'utf-8', 'replace') + self.lineNum += 1 + self.error('line contains invalid unicode character(s)', type=self.ERROR_TYPE_ILLEGAL) + + if self.verbose: + print u'%d: %s' % (self.lineNum, tabs2spaces(self.line)) + + if check_line: + self.run_line_checks() + + if not self.is_statement(): + continue + + return True + except StopIteration: + if expect_line: + self.error('unexpected EOF', type=self.ERROR_TYPE_ILLEGAL) + raise + + def is_statement(self): + # Skip empty lines + if len(self.line.strip()) == 0: + return False + + # See if we have a line comment, skip that + match = self.LINE_COMMENT_RE.match(self.line) + + if match: + return False + + # Match a block comment start next so we can find its end, + # otherwise we might get false matches on the contents of the block comment. + match = self.BLOCK_COMMENT_START_RE.match(self.line) + + if match: + self.block_comment() + return False + + return True + + def is_expression(self): + match = self.STATEMENT_RE.match(self.line) + return match is None + + def strip_comment(self): + match = self.STRIP_LINE_COMMENT_RE.match(self.expression) + + if match: + self.expression = match.group(1) + + def get_expression(self, lineMatch): + groupdict = lineMatch.groupdict() + + self.expression = groupdict.get('expression') + + if self.expression is None: + self.expression = groupdict.get('bracket') + + if self.expression is None: + self.expression = groupdict.get('indented_expression') + + if self.expression is None: + self.expression = '' + return + + # Remove all quoted strings from the expression so that we don't + # count unmatched pairs inside the strings. + self.expression = self.STRING_LITERAL_RE.sub(self.EMPTY_SELF_STRING_LITERAL_FUNCTION, self.expression) + + self.strip_comment() + self.expression = self.expression.strip() + + def block_comment(self): + 'Find the end of a block comment' + + commentOpenCount = self.line.count('/*') + commentOpenCount -= self.line.count('*/') + + # If there is an open comment block, eat it + if commentOpenCount: + if self.verbose: + print u'%d: BLOCK COMMENT START' % self.lineNum + else: + return + + match = None + + while not match and self.next_statement(expect_line=True, check_line=False): + match = self.BLOCK_COMMENT_END_RE.match(self.line) + + if self.verbose: + print u'%d: BLOCK COMMENT END' % self.lineNum + + def balance_pairs(self, squareOpenCount, curlyOpenCount, parenOpenCount): + # The following lines have to be indented at least as much as the first identifier + # after the var keyword at the start of the block. + if self.verbose: + print "%d: BALANCE BRACKETS: '['=%d, '{'=%d, '('=%d" % (self.lineNum, squareOpenCount, curlyOpenCount, parenOpenCount) + + lineRE = re.compile(self.INDENTED_EXPRESSION_RE_TEMPLATE % len(self.identifierIndent)) + + while True: + # If the expression has open brackets and is terminated, it's an error + match = self.SEPARATOR_RE.match(self.expression) + + if match and match.group('separator') == ';': + unterminated = [] + + if squareOpenCount: + unterminated.append('[') + + if curlyOpenCount: + unterminated.append('{') + + if parenOpenCount: + unterminated.append('(') + + self.error('unbalanced %s' % ' and '.join(unterminated), type=self.ERROR_TYPE_ILLEGAL) + return False + + self.next_statement(expect_line=True) + match = lineRE.match(self.line) + + if not match: + # If it doesn't match, the indent is wrong check the whole line + self.error('incorrect indentation') + self.expression = self.line + self.strip_comment() + else: + # It matches, extract the expression + self.get_expression(match) + + # Update the bracket counts + squareOpenCount += self.expression.count('[') + squareOpenCount -= self.expression.count(']') + curlyOpenCount += self.expression.count('{') + curlyOpenCount -= self.expression.count('}') + parenOpenCount += self.expression.count('(') + parenOpenCount -= self.expression.count(')') + + if squareOpenCount == 0 and curlyOpenCount == 0 and parenOpenCount == 0: + if self.verbose: + print u'%d: BRACKETS BALANCED' % self.lineNum + + # The brackets are closed, this line must be separated + match = self.SEPARATOR_RE.match(self.expression) + + if not match: + self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL) + return False + + return True + + def pairs_balanced(self, lineMatchOrBlockMatch): + + groups = lineMatchOrBlockMatch.groupdict() + + if 'assignment' in groups or 'bracket' in groups: + squareOpenCount = self.expression.count('[') + squareOpenCount -= self.expression.count(']') + + curlyOpenCount = self.expression.count('{') + curlyOpenCount -= self.expression.count('}') + + parenOpenCount = self.expression.count('(') + parenOpenCount -= self.expression.count(')') + + if squareOpenCount or curlyOpenCount or parenOpenCount: + # If the brackets were not properly closed or the statement was + # missing a separator, skip the rest of the var block. + if not self.balance_pairs(squareOpenCount, curlyOpenCount, parenOpenCount): + return False + + return True + + def var_block(self, blockMatch): + """ + Parse a var block, return a tuple (haveLine, isSingleVar), where haveLine + indicates whether self.line is the next line to be parsed. + """ + + # Keep track of whether this var block has multiple declarations + isSingleVar = True + + # Keep track of the indent of the var keyword to compare with following lines + self.varIndent = blockMatch.group('indent') + + # Keep track of how far the first variable name is indented to make sure + # following lines line up with that + self.identifierIndent = self.varIndent + blockMatch.group('var') + + # Check the expression to see if we have any open [ or { or /* + self.get_expression(blockMatch) + + if not self.pairs_balanced(blockMatch): + return (False, False) + + separator = '' + + if self.expression: + match = self.SEPARATOR_RE.match(self.expression) + + if not match: + self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL) + else: + separator = match.group('separator') + elif blockMatch.group('separator'): + separator = blockMatch.group('separator') + + # If the block has a semicolon, there should be no more lines in the block + blockHasSemicolon = separator == ';' + + # We may not catch an error till after the line that is wrong, so keep + # the most recent declaration and its line number. + lastBlockLine = self.line + lastBlockLineNum = self.lineNum + + # Now construct an RE that will match any lines indented at least as much + # as the var keyword that started the block. + blockRE = re.compile(self.VAR_BLOCK_RE_TEMPLATE % len(self.identifierIndent)) + + while self.next_statement(expect_line=not blockHasSemicolon): + + if not self.is_statement(): + continue + + # Is the line indented at least as much as the var keyword that started the block? + match = blockRE.match(self.line) + + if match: + if self.is_expression(): + lastBlockLine = self.line + lastBlockLineNum = self.lineNum + + # If the line is indented farther than the first identifier in the block, + # it is considered a formatting error. + if match.group('indent') and not match.group('indented_expression'): + self.error('incorrect indentation') + + self.get_expression(match) + + if not self.pairs_balanced(match): + return (False, isSingleVar) + + if self.expression: + separatorMatch = self.SEPARATOR_RE.match(self.expression) + + if separatorMatch is None: + # If the assignment does not have a separator, it's an error + self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL) + else: + separator = separatorMatch.group('separator') + + if blockHasSemicolon: + # If the block already has a semicolon, we have an accidental global declaration + self.error('accidental global variable', type=self.ERROR_TYPE_ILLEGAL) + elif (separator == ';'): + blockHasSemicolon = True + elif match.group('separator'): + separator = match.group('separator') + + isSingleVar = False + else: + # If the line is a control statement of some kind, then it should not be indented this far. + self.error('statement should be outdented from preceding var block') + return (True, False) + + else: + # If the line does not match, it is not an assignment or is outdented from the block. + # In either case, the block is considered closed. If the most recent separator was not ';', + # the block was not properly terminated. + if separator != ';': + self.error('unterminated var block', lineNum=lastBlockLineNum, line=lastBlockLine, type=self.ERROR_TYPE_ILLEGAL) + + return (True, isSingleVar) + + def check_var_blocks(self): + lastStatementWasVar = False + lastVarWasSingle = False + haveLine = True + + while True: + if not haveLine: + haveLine = self.next_statement() + + if not self.is_statement(): + haveLine = False + continue + + match = self.VAR_BLOCK_START_RE.match(self.line) + + if match is None: + lastStatementWasVar = False + haveLine = False + continue + + # It might be a function definition, in which case we continue + expression = match.group('expression') + + if expression: + functionMatch = self.FUNCTION_RE.match(expression) + + if functionMatch: + lastStatementWasVar = False + haveLine = False + continue + + # Now we have the start of a variable block + if self.verbose: + print u'%d: VAR BLOCK' % self.lineNum + + varLineNum = self.lineNum + varLine = self.line + + haveLine, isSingleVar = self.var_block(match) + + if self.verbose: + print u'%d: END VAR BLOCK:' % self.lineNum, + + if isSingleVar: + print u'SINGLE' + else: + print u'MULTIPLE' + + if lastStatementWasVar and self.varDeclarations != self.VAR_DECLARATIONS_NONE: + if (self.varDeclarations == self.VAR_DECLARATIONS_SINGLE and lastVarWasSingle and isSingleVar) or \ + (self.varDeclarations == self.VAR_DECLARATIONS_STRICT and (lastVarWasSingle or isSingleVar)): + self.error('consecutive var declarations', lineNum=varLineNum, line=varLine) + + lastStatementWasVar = True + lastVarWasSingle = isSingleVar + + def run_file_checks(self): + for check in self.fileChecklist: + self.sourcefile.seek(0) + self.lineNum = 0 + + if self.verbose: + print u'%s: %s' % (check['title'], self.sourcefile.name) + + check['action']() + + def lint(self, filesToCheck): + # Recursively walk any directories and eliminate duplicates + self.filesToCheck = [] + + for filename in filesToCheck: + filename = unicode(filename, 'utf-8') + fullpath = os.path.join(self.basedir, filename) + + if fullpath not in self.filesToCheck: + if os.path.isdir(fullpath): + for root, dirs, files in os.walk(fullpath): + for skipDir in self.DIRS_TO_SKIP: + if skipDir in dirs: + dirs.remove(skipDir) + + for filename in files: + if not filename.endswith('.j'): + continue + + fullpath = os.path.join(root, filename) + + if fullpath not in self.filesToCheck: + self.filesToCheck.append(fullpath) + else: + self.filesToCheck.append(fullpath) + + for filename in self.filesToCheck: + try: + with open(filename) as self.sourcefile: + self.filename = relative_path(self.basedir, filename) + self.run_file_checks() + + except IOError: + self.lineNum = 0 + self.line = None + self.error('file not found', type=self.ERROR_TYPE_ILLEGAL) + + except StopIteration: + if self.verbose: + print u'EOF\n' + pass + + def lint_text(self, text, filename): + self.filename = filename + self.filesToCheck = [] + + try: + self.sourcefile = cStringIO.StringIO(text) + self.run_file_checks() + except StopIteration: + if self.verbose: + print u'EOF\n' + pass + + def count_files_checked(self): + return len(self.filesToCheck) + + def error(self, message, **kwargs): + info = { + 'filename': self.filename, + 'message': message, + 'type': kwargs.get('type', self.ERROR_TYPE_WARNING) + } + + line = kwargs.get('line', self.line) + lineNum = kwargs.get('lineNum', self.lineNum) + + if line and lineNum: + info['line'] = tabs2spaces(line) + info['lineNum'] = lineNum + + positions = kwargs.get('positions') + + if positions: + info['positions'] = positions + + self.errors.append(info) + + if self.filename not in self.errorFiles: + self.errorFiles.append(self.filename) + + def has_errors(self): + return len(self.errors) != 0 + + def print_errors(self, format='text'): + if not self.errors: + return + + if format == 'text': + self.print_text_errors() + elif format == 'html': + self.print_textmate_html_errors() + elif format == 'tooltip': + self.print_tooltip_errors() + + def print_text_errors(self): + sys.stdout.write('%d error' % len(self.errors)) + + if len(self.errors) > 1: + sys.stdout.write('s') + + if len(self.filesToCheck) == 1: + template = self.TEXT_ERROR_SINGLE_FILE_TEMPLATE + else: + sys.stdout.write(' in %d files' % len(self.errorFiles)) + template = self.TEXT_ERROR_MULTI_FILE_TEMPLATE + + sys.stdout.write(':\n\n') + + for error in self.errors: + if 'lineNum' in error and 'line' in error: + sys.stdout.write(template.substitute(error).encode('utf-8')) + + if error.get('positions'): + markers = ' ' * len(error['line']) + + for position in error['positions']: + markers = markers[:position] + '^' + markers[position + 1:] + + # Add a space at the beginning of the markers to account for the '+' at the beginning + # of the source line. + sys.stdout.write(' %s\n' % markers) + else: + sys.stdout.write('%s: %s.\n' % (error['filename'], error['message'])) + + sys.stdout.write('\n') + + def print_textmate_html_errors(self): + html = """ + + + Cappuccino Lint Report + + + + """ + + html += '

Results: %d error' % len(self.errors) + + if len(self.errors) > 1: + html += 's' + + if len(self.filesToCheck) > 1: + html += ' in %d files' % len(self.errorFiles) + + html += '

' + + for error in self.errors: + message = cgi.escape(error['message']) + + if len(self.filesToCheck) > 1: + filename = cgi.escape(error['filename']) + ':' + else: + filename = '' + + html += '

' + + if 'line' in error and 'lineNum' in error: + filepath = cgi.escape(os.path.join(self.basedir, error['filename'])) + lineNum = error['lineNum'] + line = error['line'] + positions = error.get('positions') + firstPos = -1 + source = '' + + if positions: + firstPos = positions[0] + 1 + lastPos = 0 + + for pos in error.get('positions'): + if pos < len(line): + charToHighlight = line[pos] + else: + charToHighlight = '' + + source += '%s%s' % (cgi.escape(line[lastPos:pos]), cgi.escape(charToHighlight)) + lastPos = pos + 1 + + if lastPos <= len(line): + source += cgi.escape(line[lastPos:]) + else: + source = line + + link = '' % (filepath, lineNum, firstPos) + + if len(self.filesToCheck) > 1: + errorMsg = '%s%d: %s' % (filename, lineNum, message) + else: + errorMsg = '%d: %s' % (lineNum, message) + + html += '%(link)s%(errorMsg)s

\n

%(link)s%(source)s

\n' % {'link': link, 'errorMsg': errorMsg, 'source': source} + else: + html += '%s%s

\n' % (filename, message) + + html += """ + + +""" + exit_show_html(html) + + +if __name__ == '__main__': + usage = 'usage: %prog [options] [file ... | -]' + parser = OptionParser(usage=usage, version='1.02') + parser.add_option('-f', '--format', action='store', type='string', dest='format', default='text', help='the format to use for the report: text (default) or html (HTML in which errors can be clicked on to view in TextMate)') + parser.add_option('-b', '--basedir', action='store', type='string', dest='basedir', help='the base directory relative to which filenames are resolved, defaults to the current working directory') + parser.add_option('-d', '--var-declarations', action='store', type='string', dest='var_declarations', default='single', help='set the policy for flagging consecutive var declarations (%s)' % ', '.join(LintChecker.VAR_DECLARATIONS)) + parser.add_option('-v', '--verbose', action='store_true', dest='verbose', default=False, help='show what lint is doing') + parser.add_option('-q', '--quiet', action='store_true', dest='quiet', default=False, help='do not display errors, only return an exit code') + + (options, args) = parser.parse_args() + + if options.var_declarations not in LintChecker.VAR_DECLARATIONS: + parser.error('--var-declarations must be one of [' + ', '.join(LintChecker.VAR_DECLARATIONS) + ']') + + if options.verbose and options.quiet: + parser.error('options -v/--verbose and -q/--quiet are mutually exclusive') + + options.format = options.format.lower() + + if not options.format in LintChecker.ERROR_FORMATS: + parser.error('format must be one of ' + '/'.join(LintChecker.ERROR_FORMATS)) + + if options.format == 'html' and not within_textmate(): + parser.error('html format can only be used within TextMate.') + + if options.basedir: + basedir = options.basedir + + if basedir[-1] == '/': + basedir = basedir[:-1] + else: + basedir = os.getcwd() + + # We accept a list of filenames (relative to the cwd) either from the command line or from stdin + filenames = args + + if args and args[0] == '-': + filenames = [name.rstrip() for name in sys.stdin.readlines()] + + if not filenames: + print usage.replace('%prog', os.path.basename(sys.argv[0])) + sys.exit(0) + + checker = LintChecker(basedir=basedir, var_declarations=LintChecker.VAR_DECLARATIONS.index(options.var_declarations), verbose=options.verbose) + pathsToCheck = [] + + for filename in filenames: + filename = filename.strip('"\'') + path = os.path.join(basedir, filename) + + if (os.path.isdir(path) and not path.endswith('Frameworks')) or filename.endswith('.j'): + pathsToCheck.append(relative_path(basedir, filename)) + + if len(pathsToCheck) == 0: + if within_textmate(): + exit_show_tooltip('No Objective-J files found.') + + sys.exit(0) + + checker.lint(pathsToCheck) + + if checker.has_errors(): + if not options.quiet: + checker.print_errors(options.format) + + sys.exit(1) + else: + if within_textmate(): + exit_show_tooltip('Everything looks clean.') + + sys.exit(0) diff --git a/Tools/capp_lint/capp_lint.1 b/Tools/capp_lint/capp_lint.1 new file mode 100644 index 000000000..e0e6c0b3c --- /dev/null +++ b/Tools/capp_lint/capp_lint.1 @@ -0,0 +1,181 @@ +.Dd April 20, 2012 +.Os "Cappuccino" +.Dt CAPP_LINT 1 "PRM" +.\"----------------------------------------------------------------------------------------- +.Sh NAME +.\"----------------------------------------------------------------------------------------- +.Nm capp_lint +.Nd check formatting of Objective-J files +.\"----------------------------------------------------------------------------------------- +.Sh SYNOPSIS +.\"----------------------------------------------------------------------------------------- +.Nm +.Op options +.Op Ar +.Nm +.Op --version | -h | --help +.\"----------------------------------------------------------------------------------------- +.Sh "DESCRIPTION" +.\"----------------------------------------------------------------------------------------- +.Nm +is a formatting checker for Objective-J. It performs a number of checks that catch +common formatting mistakes, as defined in the Cappuccino coding style guidelines: +.Pp +http://cappuccino.org/contribute/coding-style.php +.Pp +.Nm +operates on a list of files and/or directory. You may pass one or more paths (relative to the +current working directory) as arguments to +.Nm , +or if the first path is "-", the +list of paths is taken from stdin, in which case everything after the "-" is ignored. +.Pp +If a path is a directory, the directory is recursively scanned for *.j files to check. Symbolic links +are not followed. +.Pp +The following options are available: +.Bl -tag -width 4n +.It Fl b, \-basedir=BASEDIR +The base directory relative to which filenames will be resolved. If not passed, +it defaults to the current working directory. +.It Fl d, \-var-declarations=[none|single|strict] +Determines the policy in flagging consecutive var declarations. The choices are: +.Bl -tag -indent -width 8n +.It none +No checking is done. +.It single +If two var blocks appear consecutively and one of them is +a single variable, the second var block will be flagged. +.It strict +Any consecutive var declarations will be flagged. +.El +.Pp +Given the following code: +.Pp + var foo = "bar", + bar = "foo"; + + var bow = "foobar", + wow = "woof"; +.Pp +The +.Ar none +policy will obviously not flag this. The +.Ar single +policy will not flag +this because both blocks contain more than one declaration. The +.Ar strict +policy will flag this. Given the following code: +.Pp + var foo = "bar", + bar = "foo"; + + var bow = "foobar"; +.Pp +The +.Ar single +policy will flag this because the second block is a single declaration. +.It Fl f, \-format=[text|html] +Determines the format of the error output. See the OUTPUT section below for more information +on the different formats. +.It Fl q, \-quiet +Normally +.Nm +will print a list of the errors it finds. If this option is passed, +the error list is suppressed. This option is mutually exclusive with the -v option. +.It Fl v, \-verbose +Displays each line as it is processed by +.Nm +with a line number, along with some information about syntactic information it finds. +.It Fl \-version +Displays the current version and exits. +.It Fl h, \-help +Shows a synopsis of usage and options and exits. +.El +.Ss Errors flagged +The following formatting errors are flagged by +.Nm : +.Bl -bullet -width 0n +.It +Lines which contain one or more tabs. +.It +Lines which contain non-ASCII characters not within quoted strings. +.It +Lines which contain malformed UTF-8. +.It +Missing space between control statements and their opening parentheses. +.It +Trailing whitespace. +.It +Binary operators without surrounding space. +.It +Assignment operators without surrounding space. +.It +Comparison operators without surrounding space. +.It +Useless unary + operators. +.It +Extra or missing space in a method declaration. +.It +Superflous function names. +.It +Unterminated variable blocks. +.It +Consecutive var statements. +.It +Incorrect indentation in a var block. +.It +Missing statement separators in a var block. +.It +Inadvertent global variables in a var block. +.It +Unbalanced [, { or ( in a variable block. +.It +Opening braces on the same line as a statement. +.It +Code following a var block that is not outdented from the var block. +.El +.\"----------------------------------------------------------------------------------------- +.Sh EXIT STATUS +.\"----------------------------------------------------------------------------------------- +.Nm +exits with a return status of one if it finds any errors. If no errors are found, +it exits with a return status of zero. +.\"----------------------------------------------------------------------------------------- +.Sh OUTPUT +.\"----------------------------------------------------------------------------------------- +For each error that +.Nm +finds, if the -q/--quiet option was not passed, +.Nm +will output an error like the following if the error format is text: +.Pp +Foo.j:482: assignment operator without surrounding spaces. +.br ++ for (var i=0, count=[_observationProxies count]; i&2 + echo 'pre-commit check failure' 1>&2 + echo '-------------------------' 1>&2 + echo "$@" 1>&2 + + if [ -f /tmp/capp_lint.tmp ]; then + rm -f /tmp/capp_lint.tmp + fi + + exit 1 +} + +#----------------------------------------------------------------------------- +# Check for committer identity. +advice=' +Use the commands: + + git config --global user.name '\''Your Name'\'' + git config --global user.email '\''you@yourdomain.com'\'' + +to introduce yourself to Git before committing.' + +# Ensure name and email are available. +git config --get user.name > /dev/null && +git config --get user.email > /dev/null || +die 'Identity not configured!' "$advice" + +# Validate the name and email. +git config --get user.name | grep ' ' > /dev/null || +die 'Please set user.name to your Real Name (with a space), not a userid.' "$advice" +git config --get user.email | grep '^[^@]*@[^@]*$' > /dev/null || +die 'Please set user.email to an email address (userid@validdomain.com).' "$advice" + +#----------------------------------------------------------------------------- +# Check content that will be added by this commit. + +if git rev-parse --verify -q HEAD > /dev/null; then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# Disallow non-ascii file names. The printable range starts at the +# space character and ends with tilde. +if test "$(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0')"; then + die 'Non-ascii file names may not be added: +'"$(git diff --cached --name-only --diff-filter=A $against)" +fi + +# Check for trailing whitespaces, indents that uses a space before a tab and leftover conflict markers +if test "$(git diff-index --check --cached $against --)"; then + die 'The following whitespace errors or unresolved conflicts have been found: +'"$(git diff-index --check --cached $against --)" +fi + +#----------------------------------------------------------------------------- +# Check for inadvertent globals and dangling variable declarations. + +fileCount=`git diff --cached --name-only --diff-filter=AM $against | wc -l` + +if [[ fileCount -ne "0" ]]; then + lintPath='' + + if [[ `which capp_lint.py` ]]; then + lintPath="`which capp_lint.py`" + fi + + if [[ -z "$lintPath" && `which capp_lint` ]]; then + lintPath="`which capp_lint`" + fi + + if [[ -z "$lintPath" ]]; then + path=`dirname $0`/capp_lint.py + + if [[ -x "$path" ]]; then + lintPath="$path" + fi + fi + + if [[ -z "$lintPath" ]]; then + die 'Could not find capp_lint or capp_lint.py in your PATH or in .git/hooks.' + fi + + git diff --cached --name-only --diff-filter=AM $against | "$lintPath" - > /tmp/capp_lint.tmp + + if [ -s /tmp/capp_lint.tmp ]; then + die 'The following lint errors were found: + + '"$(cat /tmp/capp_lint.tmp)" + else + rm -f /tmp/capp_lint.tmp + fi +fi + +exit 0