PageRenderTime 58ms CodeModel.GetById 26ms RepoModel.GetById 1ms app.codeStats 0ms

/Tools/capp_lint/capp_lint

http://github.com/cappuccino/cappuccino
Python | 1166 lines | 980 code | 120 blank | 66 comment | 113 complexity | 199ecc36f9ba4e6340cce7c11ccf06dc MD5 | raw file
Possible License(s): LGPL-2.1, MIT
  1. #!/usr/bin/env python
  2. #
  3. # capp_lint.py - Check Objective-J source code formatting,
  4. # according to Cappuccino standards:
  5. #
  6. # https://github.com/cappuccino/cappuccino/blob/master/CONTRIBUTING.md
  7. #
  8. # Copyright (C) 2011 Aparajita Fishman <aparajita@aparajita.com>
  9. # Permission is hereby granted, free of charge, to any person
  10. # obtaining a copy of this software and associated documentation files
  11. # (the "Software"), to deal in the Software without restriction,
  12. # including without limitation the rights to use, copy, modify, merge,
  13. # publish, distribute, sublicense, and/or sell copies of the Software,
  14. # and to permit persons to whom the Software is furnished to do so,
  15. # subject to the following conditions:
  16. #
  17. # The above copyright notice and this permission notice shall be
  18. # included in all copies or substantial portions of the Software.
  19. #
  20. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  21. # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  22. # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  23. # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  24. # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  25. # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  26. # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  27. # SOFTWARE.
  28. from __future__ import with_statement
  29. from optparse import OptionParser
  30. from string import Template
  31. import cgi
  32. import cStringIO
  33. import os
  34. import os.path
  35. import re
  36. import sys
  37. import unittest
  38. EXIT_CODE_SHOW_HTML = 205
  39. EXIT_CODE_SHOW_TOOLTIP = 206
  40. def exit_show_html(html):
  41. sys.stdout.write(html.encode('utf-8'))
  42. sys.exit(EXIT_CODE_SHOW_HTML)
  43. def exit_show_tooltip(text):
  44. sys.stdout.write(text)
  45. sys.exit(EXIT_CODE_SHOW_TOOLTIP)
  46. def within_textmate():
  47. return os.getenv('TM_APP_PATH') is not None
  48. def tabs2spaces(text, positions=None):
  49. while True:
  50. index = text.find(u'\t')
  51. if index < 0:
  52. return text
  53. spaces = u' ' * (4 - (index % 4))
  54. text = text[0:index] + spaces + text[index + 1:]
  55. if positions is not None:
  56. positions.append(index)
  57. def relative_path(basedir, filename):
  58. if filename.find(basedir) == 0:
  59. filename = filename[len(basedir) + 1:]
  60. return filename
  61. def string_replacer(line):
  62. """Take string literals like 'hello' and replace them with empty string literals, while respecting escaping."""
  63. r = []
  64. in_quote = None
  65. escapes = 0
  66. for i, c in enumerate(line):
  67. if in_quote:
  68. if not escapes and c == in_quote:
  69. in_quote = None
  70. r.append(c)
  71. continue
  72. # We're inside of a string literal. Ignore everything.
  73. else:
  74. if not escapes and (c == '"' or c == "'"):
  75. in_quote = c
  76. r.append(c)
  77. continue
  78. # Outside of a string literal, preserve everything.
  79. r.append(c)
  80. if c == '\\':
  81. escapes = (escapes + 1) % 2
  82. else:
  83. escapes = 0
  84. if in_quote:
  85. # Unterminated string literal.
  86. pass
  87. return "".join(r)
  88. class LintChecker(object):
  89. """Examine Objective-J code statically and generate warnings for possible errors and deviations from the coding-style standard.
  90. >>> LintChecker().lint_text('var b = 5+5;')
  91. [{'positions': [9], 'filename': '<stdin>', 'lineNum': 1, 'message': 'binary operator without surrounding spaces', 'type': 2, 'line': u'var b = 5+5;'}]
  92. >>> LintChecker().lint_text('''
  93. ... if( 1 ) {
  94. ... var b=7;
  95. ... c = 8;
  96. ... }
  97. ... ''')
  98. [{'positions': [2], 'filename': '<stdin>', 'lineNum': 2, 'message': 'missing space between control statement and parentheses', 'type': 2, 'line': u'if( 1 ) {'}, {'positions': [8], 'filename': '<stdin>', 'lineNum': 2, 'message': 'braces should be on their own line', 'type': 1, 'line': u'if( 1 ) {'}, {'positions': [3, 5], 'filename': '<stdin>', 'lineNum': 2, 'message': 'space inside parentheses', 'type': 1, 'line': u'if( 1 ) {'}, {'positions': [7], 'filename': '<stdin>', 'lineNum': 3, 'message': 'assignment operator without surrounding spaces', 'type': 2, 'line': u' var b=7;'}, {'lineNum': 4, 'message': 'accidental global variable', 'type': 1, 'line': u' c = 8;', 'filename': '<stdin>'}]
  99. """
  100. VAR_BLOCK_START_RE = re.compile(ur'''(?x)
  101. (?P<indent>\s*) # indent before a var keyword
  102. (?P<var>var\s+) # var keyword and whitespace after
  103. (?P<identifier>[a-zA-Z_$]\w*)\s*
  104. (?:
  105. (?P<assignment>=)\s*
  106. (?P<expression>.*)
  107. |
  108. (?P<separator>[,;+\-/*%^&|=\\])
  109. )
  110. ''')
  111. SEPARATOR_RE = re.compile(ur'''(?x)
  112. (?P<expression>.*) # Everything up to the line separator
  113. (?P<separator>[,;+\-/*%^&|=\\]) # The line separator
  114. \s* # Optional whitespace after
  115. $ # End of expression
  116. ''')
  117. INDENTED_EXPRESSION_RE_TEMPLATE = ur'''(?x)
  118. [ ]{%d} # Placeholder for indent of first identifier that started block
  119. (?P<expression>.+) # Expression
  120. '''
  121. VAR_BLOCK_RE_TEMPLATE = ur'''(?x)
  122. [ ]{%d} # Placeholder for indent of first identifier that started block
  123. (?P<indent>\s*) # Capture any further indent
  124. (?:
  125. (?P<bracket>[\[\{].*)
  126. |
  127. (?P<identifier>[a-zA-Z_$]\w*)\s*
  128. (?:
  129. (?P<assignment>=)\s*
  130. (?P<expression>.*)
  131. |
  132. (?P<separator>[,;+\-/*%%^&|=\\])
  133. )
  134. |
  135. (?P<indented_expression>.+)
  136. )
  137. '''
  138. STATEMENT_RE = re.compile(ur'''(?x)
  139. \s*((continue|do|for|function|if|else|return|switch|while|with)\b|\[+\s*[a-zA-Z_$]\w*\s+[a-zA-Z_$]\w*\s*[:\]])
  140. ''')
  141. TRAILING_WHITESPACE_RE = re.compile(ur'^.*(\s+)$')
  142. STRIP_LINE_COMMENT_RE = re.compile(ur'(.*)\s*(?://.*|/\*.*\*/\s*)$')
  143. LINE_COMMENT_RE = re.compile(ur'\s*(?:/\*.*\*/\s*|//.*)$')
  144. COMMENT_RE = re.compile(ur'/\*.*?\*/')
  145. BLOCK_COMMENT_START_RE = re.compile(ur'\s*/\*.*(?!\*/\s*)$')
  146. BLOCK_COMMENT_END_RE = re.compile(ur'.*?\*/')
  147. METHOD_RE = ur'[-+]\s*\([a-zA-Z_$]\w*\)\s*[a-zA-Z_$]\w*'
  148. FUNCTION_RE = re.compile(ur'\s*function\s*(?P<name>[a-zA-Z_$]\w*)?\(.*\)\s*\{?')
  149. RE_RE = re.compile(ur'(?<!\\)/.*?[^\\]/[gims]*')
  150. DEPRECATED_CP_RE = re.compile(ur'\b(CP(?:Point|Rect(?!Edge)|Size))')
  151. EMPTY_STRING_LITERAL_FUNCTION = lambda match: match.group(1) + (len(match.group(2)) * ' ') + match.group(1)
  152. EMPTY_SELF_STRING_LITERAL_FUNCTION = lambda self, match: match.group(1) + (len(match.group(2)) * ' ') + match.group(1)
  153. def noncapturing(regex):
  154. return ur'(?:%s)' % regex
  155. def optional(regex):
  156. return ur'(?:%s)?' % regex
  157. DECIMAL_DIGIT_RE = ur'[0-9]'
  158. NON_ZERO_DIGIT_RE = ur'[1-9]'
  159. DECIMAL_DIGITS_RE = DECIMAL_DIGIT_RE + ur'+'
  160. DECIMAL_DIGITS_OPT_RE = optional(DECIMAL_DIGIT_RE + ur'+')
  161. EXPONENT_INDICATOR_RE = ur'[eE]'
  162. SIGNED_INTEGER_RE = noncapturing(DECIMAL_DIGITS_RE) + ur'|' + noncapturing(ur'\+' + DECIMAL_DIGITS_RE) + ur'|' + noncapturing('-' + DECIMAL_DIGITS_RE)
  163. DECIMAL_INTEGER_LITERAL_RE = ur'0|' + noncapturing(NON_ZERO_DIGIT_RE + DECIMAL_DIGIT_RE + ur'*')
  164. EXPONENT_PART_RE = EXPONENT_INDICATOR_RE + noncapturing(SIGNED_INTEGER_RE)
  165. EXPONENT_PART_OPT_RE = optional(EXPONENT_PART_RE)
  166. DECIMAL_LITERAL_RE = re.compile(noncapturing(noncapturing(DECIMAL_INTEGER_LITERAL_RE) + ur'\.' + DECIMAL_DIGITS_OPT_RE + EXPONENT_PART_OPT_RE) + ur'|\.' + noncapturing(DECIMAL_DIGITS_RE + EXPONENT_PART_OPT_RE) + ur'|' + noncapturing(noncapturing(DECIMAL_INTEGER_LITERAL_RE) + EXPONENT_PART_OPT_RE))
  167. ERROR_TYPE_ILLEGAL = 1
  168. ERROR_TYPE_WARNING = 2
  169. # Replace the contents of comments, regex and string literals
  170. # with spaces so we don't get false matches within them
  171. STD_IGNORES = (
  172. {'regex': STRIP_LINE_COMMENT_RE, 'replace': ''},
  173. {'function': string_replacer},
  174. {'regex': COMMENT_RE, 'replace': ''},
  175. {'regex': RE_RE, 'replace': '/ /'},
  176. )
  177. # Convert exponential notation like 1.1e-6 to an arbitrary constant number so that the "e" notation doesn't
  178. # need to be understood by the regular matchers. Obviously this is limited by the fact that we're regexing
  179. # so this will probably catch some things which are not properly decimal literals (parts of strings or
  180. # variable names for instance).
  181. EXPONENTIAL_TO_SIMPLE = (
  182. {'regex': DECIMAL_LITERAL_RE, 'replace': '42'},
  183. )
  184. LINE_CHECKLIST = (
  185. {
  186. 'id': 'tabs',
  187. 'regex': re.compile(ur'[\t]'),
  188. 'error': 'line contains tabs',
  189. 'type': ERROR_TYPE_ILLEGAL
  190. },
  191. {
  192. 'regex': re.compile(ur'([^\t -~])'),
  193. 'error': 'line contains non-ASCII characters',
  194. 'showPositionForGroup': 1,
  195. 'type': ERROR_TYPE_ILLEGAL,
  196. 'option': 'sublimelinter_objj_check_ascii',
  197. 'optionDefault': False
  198. },
  199. {
  200. 'regex': re.compile(ur'^\s*(?:(?:else )?if|for|switch|while|with)(\()'),
  201. 'error': 'missing space between control statement and parentheses',
  202. 'showPositionForGroup': 1,
  203. 'type': ERROR_TYPE_WARNING
  204. },
  205. {
  206. 'regex': re.compile(ur'^\s*(?:(?:else )?if|for|switch|while|with)\s*\(.+\)\s*(\{)\s*(?://.*|/\*.*\*/\s*)?$'),
  207. 'error': 'braces should be on their own line',
  208. 'showPositionForGroup': 1,
  209. 'type': ERROR_TYPE_ILLEGAL
  210. },
  211. {
  212. 'regex': re.compile(ur'^\s*(?:(?:else )?if|for|switch|while|with)\s*\((\s+)?.+?(\s+)?\)\s*(?:(?:\{|//.*|/\*.*\*/)\s*)?$'),
  213. 'error': 'space inside parentheses',
  214. 'showPositionForGroup': [1, 2],
  215. 'type': ERROR_TYPE_ILLEGAL
  216. },
  217. {
  218. 'regex': re.compile(ur'^\s*(?:(?:else )?if|for|switch|while|with)\s*\(.+\)\s*(?:[\w_]|\[).+(;)\s*(?://.*|/\*.*\*/\s*)?$'),
  219. 'error': 'dependent statements must be on their own line',
  220. 'showPositionForGroup': 1,
  221. 'type': ERROR_TYPE_ILLEGAL
  222. },
  223. {
  224. 'regex': TRAILING_WHITESPACE_RE,
  225. 'error': 'trailing whitespace',
  226. 'showPositionForGroup': 1,
  227. 'type': ERROR_TYPE_ILLEGAL
  228. },
  229. {
  230. # Filter out @import statements, method declarations, method parameters, unary plus/minus/increment/decrement
  231. 'filter': {'regex': re.compile(ur'(^@import\b|^\s*' + METHOD_RE + '|^\s*[a-zA-Z_$]\w*:\s*\([a-zA-Z_$][\w<>]*\)\s*\w+|[a-zA-Z_$]\w*(\+\+|--)|([ -+*/%^&|<>!]=?|&&|\|\||<<|>>>|={1,3}|!==?)\s*[-+][\w(\[])'), 'pass': False},
  232. # Also convert literals like 1.5e+7 to 42 so that the - or + in there is ignored for purposes of this warning.
  233. 'preprocess': STD_IGNORES + EXPONENTIAL_TO_SIMPLE,
  234. 'regex': re.compile(ur'(?<=[\w)\]"\']|([ ]))([-+*/%^]|&&?|\|\|?|<<|>>>?)(?=[\w({\["\']|(?(1)\b\b|[ ]))'),
  235. 'error': 'binary operator without surrounding spaces',
  236. 'showPositionForGroup': 2,
  237. 'type': ERROR_TYPE_WARNING
  238. },
  239. {
  240. # Filter out possible = within @accessors
  241. 'filter': {'regex': re.compile(ur'^\s*(?:@outlet\s+)?[a-zA-Z_$]\w*\s+(<\s*[a-zA-Z_$]*\s*>\s+)?[a-zA-Z_$]\w*\s+@accessors\b'), 'pass': False},
  242. 'preprocess': STD_IGNORES,
  243. 'regex': re.compile(ur'(?<=[\w)\]"\']|([ ]))(=|[-+*/%^&|]=|<<=|>>>?=)(?=[\w({\["\']|(?(1)\b\b|[ ]))'),
  244. 'error': 'assignment operator without surrounding spaces',
  245. 'showPositionForGroup': 2,
  246. 'type': ERROR_TYPE_WARNING
  247. },
  248. {
  249. # Filter out @import statements and @implementation/method declarations
  250. 'filter': {'regex': re.compile(ur'^(@import\b|@implementation\b|@protocol\b|\s*' + METHOD_RE + ')|([a-zA-Z_$]*\s+<\s*[a-zA-Z_$]*\s*>)'), 'pass': False},
  251. 'preprocess': STD_IGNORES,
  252. 'regex': re.compile(ur'(?<=[\w)\]"\']|([ ]))(===?|!==?|[<>]=?)(?=[\w({\["\']|(?(1)\b\b|[ ]))'),
  253. 'error': 'comparison operator without surrounding spaces',
  254. 'showPositionForGroup': 2,
  255. 'type': ERROR_TYPE_WARNING
  256. },
  257. {
  258. 'preprocess': STD_IGNORES,
  259. 'regex': re.compile(ur'^(.*<\s+[a-zA-Z_$]*>.*)|(.*<[a-zA-Z_$]*\s+>.*)|(.*<\s+[a-zA-Z_$]*\s+>.*)'),
  260. 'error': 'bracket operator with extra spaces',
  261. 'showPositionForGroup': 0,
  262. 'type': ERROR_TYPE_WARNING
  263. },
  264. {
  265. '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*'),
  266. 'error': 'extra or missing space in a method declaration',
  267. 'showPositionForGroup': 0,
  268. 'type': ERROR_TYPE_WARNING
  269. },
  270. {
  271. # Check for brace following a class or method declaration
  272. 'regex': re.compile(ur'^(?:\s*[-+]\s*\([a-zA-Z_$]\w*\)|@implementation)\s*[a-zA-Z_$][\w]*.*?\s*(\{)\s*(?:$|//.*$)'),
  273. 'error': 'braces should be on their own line',
  274. 'showPositionForGroup': 0,
  275. 'type': ERROR_TYPE_ILLEGAL
  276. },
  277. {
  278. 'regex': re.compile(ur'^\s*var\s+[a-zA-Z_$]\w*\s*=\s*function\s+([a-zA-Z_$]\w*)\s*\('),
  279. 'error': 'function name is ignored',
  280. 'showPositionForGroup': 1,
  281. 'type': ERROR_TYPE_WARNING
  282. },
  283. {
  284. 'regex': DEPRECATED_CP_RE,
  285. 'preprocess': STD_IGNORES,
  286. 'error': 'CP types/functions have been deprecated in favor of CG types/functions',
  287. 'showPositionForGroup': 0,
  288. 'type': ERROR_TYPE_WARNING
  289. },
  290. )
  291. VAR_DECLARATIONS = ['none', 'single', 'strict']
  292. VAR_DECLARATIONS_NONE = 0
  293. VAR_DECLARATIONS_SINGLE = 1
  294. VAR_DECLARATIONS_STRICT = 2
  295. DIRS_TO_SKIP = ('.git', 'Frameworks', 'Build', 'Resources', 'CommonJS', 'Objective-J')
  296. ERROR_FORMATS = ('text', 'html')
  297. TEXT_ERROR_SINGLE_FILE_TEMPLATE = Template(u'$lineNum: $message.\n+$line\n')
  298. TEXT_ERROR_MULTI_FILE_TEMPLATE = Template(u'$filename:$lineNum: $message.\n+$line\n')
  299. def __init__(self, view=None, basedir='', var_declarations=VAR_DECLARATIONS_SINGLE, verbose=False):
  300. self.view = view
  301. self.basedir = unicode(basedir, 'utf-8')
  302. self.errors = []
  303. self.errorFiles = []
  304. self.filesToCheck = []
  305. self.varDeclarations = var_declarations
  306. self.verbose = verbose
  307. self.sourcefile = None
  308. self.filename = u''
  309. self.line = u''
  310. self.lineNum = 0
  311. self.varIndent = u''
  312. self.identifierIndent = u''
  313. self.fileChecklist = (
  314. {'title': 'Check variable blocks', 'action': self.check_var_blocks},
  315. )
  316. def run_line_checks(self):
  317. for check in self.LINE_CHECKLIST:
  318. option = check.get('option')
  319. if option:
  320. default = check.get('optionDefault', False)
  321. if self.view and not self.view.settings().get(option, default):
  322. continue
  323. line = self.line
  324. originalLine = line
  325. lineFilter = check.get('filter')
  326. if lineFilter:
  327. match = lineFilter['regex'].search(line)
  328. if (match and not lineFilter['pass']) or (not match and lineFilter['pass']):
  329. continue
  330. preprocess = check.get('preprocess')
  331. if preprocess:
  332. if not isinstance(preprocess, (list, tuple)):
  333. preprocess = (preprocess,)
  334. for processor in preprocess:
  335. regex = processor.get('regex')
  336. if regex:
  337. line = regex.sub(processor.get('replace', ''), line)
  338. fnct = processor.get('function')
  339. if fnct:
  340. line = fnct(line)
  341. regex = check.get('regex')
  342. if not regex:
  343. continue
  344. match = regex.search(line)
  345. if not match:
  346. continue
  347. positions = []
  348. groups = check.get('showPositionForGroup')
  349. if (check.get('id') == 'tabs'):
  350. line = tabs2spaces(line, positions=positions)
  351. elif groups is not None:
  352. line = tabs2spaces(line)
  353. if not isinstance(groups, (list, tuple)):
  354. groups = (groups,)
  355. for match in regex.finditer(line):
  356. for group in groups:
  357. if group > 0:
  358. start = match.start(group)
  359. if start >= 0:
  360. positions.append(start)
  361. else:
  362. # group 0 means show the first non-empty match
  363. for i in range(1, len(match.groups()) + 1):
  364. if match.start(i) >= 0:
  365. positions.append(match.start(i))
  366. break
  367. if positions:
  368. self.error(check['error'], line=originalLine, positions=positions, type=check['type'])
  369. def next_statement(self, expect_line=False, check_line=True):
  370. try:
  371. while True:
  372. raw_line = self.sourcefile.next()
  373. # strip EOL
  374. if raw_line[-1] == '\n': # ... unless this is the last line which might not have a \n.
  375. raw_line = raw_line[:-1]
  376. try:
  377. self.line = unicode(raw_line, 'utf-8', 'strict') # convert to Unicode
  378. self.lineNum += 1
  379. except UnicodeDecodeError:
  380. self.line = unicode(raw_line, 'utf-8', 'replace')
  381. self.lineNum += 1
  382. self.error('line contains invalid unicode character(s)', type=self.ERROR_TYPE_ILLEGAL)
  383. if self.verbose:
  384. print u'%d: %s' % (self.lineNum, tabs2spaces(self.line))
  385. if check_line:
  386. self.run_line_checks()
  387. if not self.is_statement():
  388. continue
  389. return True
  390. except StopIteration:
  391. if expect_line:
  392. self.error('unexpected EOF', type=self.ERROR_TYPE_ILLEGAL)
  393. raise
  394. def is_statement(self):
  395. # Skip empty lines
  396. if len(self.line.strip()) == 0:
  397. return False
  398. # See if we have a line comment, skip that
  399. match = self.LINE_COMMENT_RE.match(self.line)
  400. if match:
  401. return False
  402. # Match a block comment start next so we can find its end,
  403. # otherwise we might get false matches on the contents of the block comment.
  404. match = self.BLOCK_COMMENT_START_RE.match(self.line)
  405. if match:
  406. self.block_comment()
  407. return False
  408. return True
  409. def is_expression(self):
  410. match = self.STATEMENT_RE.match(self.line)
  411. return match is None
  412. def strip_comment(self):
  413. match = self.STRIP_LINE_COMMENT_RE.match(self.expression)
  414. if match:
  415. self.expression = match.group(1)
  416. def get_expression(self, lineMatch):
  417. groupdict = lineMatch.groupdict()
  418. self.expression = groupdict.get('expression')
  419. if self.expression is None:
  420. self.expression = groupdict.get('bracket')
  421. if self.expression is None:
  422. self.expression = groupdict.get('indented_expression')
  423. if self.expression is None:
  424. self.expression = ''
  425. return
  426. # Remove all quoted strings from the expression so that we don't
  427. # count unmatched pairs inside the strings.
  428. self.expression = string_replacer(self.expression)
  429. self.strip_comment()
  430. self.expression = self.expression.strip()
  431. def block_comment(self):
  432. 'Find the end of a block comment'
  433. commentOpenCount = self.line.count('/*')
  434. commentOpenCount -= self.line.count('*/')
  435. # If there is an open comment block, eat it
  436. if commentOpenCount:
  437. if self.verbose:
  438. print u'%d: BLOCK COMMENT START' % self.lineNum
  439. else:
  440. return
  441. match = None
  442. while not match and self.next_statement(expect_line=True, check_line=False):
  443. match = self.BLOCK_COMMENT_END_RE.match(self.line)
  444. if self.verbose:
  445. print u'%d: BLOCK COMMENT END' % self.lineNum
  446. def balance_pairs(self, squareOpenCount, curlyOpenCount, parenOpenCount):
  447. # The following lines have to be indented at least as much as the first identifier
  448. # after the var keyword at the start of the block.
  449. if self.verbose:
  450. print "%d: BALANCE BRACKETS: '['=%d, '{'=%d, '('=%d" % (self.lineNum, squareOpenCount, curlyOpenCount, parenOpenCount)
  451. lineRE = re.compile(self.INDENTED_EXPRESSION_RE_TEMPLATE % len(self.identifierIndent))
  452. while True:
  453. # If the expression has open brackets and is terminated, it's an error
  454. match = self.SEPARATOR_RE.match(self.expression)
  455. if match and match.group('separator') == ';':
  456. unterminated = []
  457. if squareOpenCount:
  458. unterminated.append('[')
  459. if curlyOpenCount:
  460. unterminated.append('{')
  461. if parenOpenCount:
  462. unterminated.append('(')
  463. self.error('unbalanced %s' % ' and '.join(unterminated), type=self.ERROR_TYPE_ILLEGAL)
  464. return False
  465. self.next_statement(expect_line=True)
  466. match = lineRE.match(self.line)
  467. if not match:
  468. # If it doesn't match, the indent is wrong check the whole line
  469. self.error('incorrect indentation')
  470. self.expression = self.line
  471. self.strip_comment()
  472. else:
  473. # It matches, extract the expression
  474. self.get_expression(match)
  475. # Update the bracket counts
  476. squareOpenCount += self.expression.count('[')
  477. squareOpenCount -= self.expression.count(']')
  478. curlyOpenCount += self.expression.count('{')
  479. curlyOpenCount -= self.expression.count('}')
  480. parenOpenCount += self.expression.count('(')
  481. parenOpenCount -= self.expression.count(')')
  482. if squareOpenCount == 0 and curlyOpenCount == 0 and parenOpenCount == 0:
  483. if self.verbose:
  484. print u'%d: BRACKETS BALANCED' % self.lineNum
  485. # The brackets are closed, this line must be separated
  486. match = self.SEPARATOR_RE.match(self.expression)
  487. if not match:
  488. self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL)
  489. return False
  490. return True
  491. def pairs_balanced(self, lineMatchOrBlockMatch):
  492. groups = lineMatchOrBlockMatch.groupdict()
  493. if 'assignment' in groups or 'bracket' in groups:
  494. squareOpenCount = self.expression.count('[')
  495. squareOpenCount -= self.expression.count(']')
  496. curlyOpenCount = self.expression.count('{')
  497. curlyOpenCount -= self.expression.count('}')
  498. parenOpenCount = self.expression.count('(')
  499. parenOpenCount -= self.expression.count(')')
  500. if squareOpenCount or curlyOpenCount or parenOpenCount:
  501. # If the brackets were not properly closed or the statement was
  502. # missing a separator, skip the rest of the var block.
  503. if not self.balance_pairs(squareOpenCount, curlyOpenCount, parenOpenCount):
  504. return False
  505. return True
  506. def var_block(self, blockMatch):
  507. """
  508. Parse a var block, return a tuple (haveLine, isSingleVar), where haveLine
  509. indicates whether self.line is the next line to be parsed.
  510. """
  511. # Keep track of whether this var block has multiple declarations
  512. isSingleVar = True
  513. # Keep track of the indent of the var keyword to compare with following lines
  514. self.varIndent = blockMatch.group('indent')
  515. # Keep track of how far the first variable name is indented to make sure
  516. # following lines line up with that
  517. self.identifierIndent = self.varIndent + blockMatch.group('var')
  518. # Check the expression to see if we have any open [ or { or /*
  519. self.get_expression(blockMatch)
  520. if not self.pairs_balanced(blockMatch):
  521. return (False, False)
  522. separator = ''
  523. if self.expression:
  524. match = self.SEPARATOR_RE.match(self.expression)
  525. if not match:
  526. self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL)
  527. else:
  528. separator = match.group('separator')
  529. elif blockMatch.group('separator'):
  530. separator = blockMatch.group('separator')
  531. # If the block has a semicolon, there should be no more lines in the block
  532. blockHasSemicolon = separator == ';'
  533. # We may not catch an error till after the line that is wrong, so keep
  534. # the most recent declaration and its line number.
  535. lastBlockLine = self.line
  536. lastBlockLineNum = self.lineNum
  537. # Now construct an RE that will match any lines indented at least as much
  538. # as the var keyword that started the block.
  539. blockRE = re.compile(self.VAR_BLOCK_RE_TEMPLATE % len(self.identifierIndent))
  540. while self.next_statement(expect_line=not blockHasSemicolon):
  541. if not self.is_statement():
  542. continue
  543. # Is the line indented at least as much as the var keyword that started the block?
  544. match = blockRE.match(self.line)
  545. if match:
  546. if self.is_expression():
  547. lastBlockLine = self.line
  548. lastBlockLineNum = self.lineNum
  549. # If the line is indented farther than the first identifier in the block,
  550. # it is considered a formatting error.
  551. if match.group('indent') and not match.group('indented_expression'):
  552. self.error('incorrect indentation')
  553. self.get_expression(match)
  554. if not self.pairs_balanced(match):
  555. return (False, isSingleVar)
  556. if self.expression:
  557. separatorMatch = self.SEPARATOR_RE.match(self.expression)
  558. if separatorMatch is None:
  559. # If the assignment does not have a separator, it's an error
  560. self.error('missing statement separator', type=self.ERROR_TYPE_ILLEGAL)
  561. else:
  562. separator = separatorMatch.group('separator')
  563. if blockHasSemicolon:
  564. # If the block already has a semicolon, we have an accidental global declaration
  565. self.error('accidental global variable', type=self.ERROR_TYPE_ILLEGAL)
  566. elif (separator == ';'):
  567. blockHasSemicolon = True
  568. elif match.group('separator'):
  569. separator = match.group('separator')
  570. isSingleVar = False
  571. else:
  572. # If the line is a control statement of some kind, then it should not be indented this far.
  573. self.error('statement should be outdented from preceding var block')
  574. return (True, False)
  575. else:
  576. # If the line does not match, it is not an assignment or is outdented from the block.
  577. # In either case, the block is considered closed. If the most recent separator was not ';',
  578. # the block was not properly terminated.
  579. if separator != ';':
  580. self.error('unterminated var block', lineNum=lastBlockLineNum, line=lastBlockLine, type=self.ERROR_TYPE_ILLEGAL)
  581. return (True, isSingleVar)
  582. def check_var_blocks(self):
  583. lastStatementWasVar = False
  584. lastVarWasSingle = False
  585. haveLine = True
  586. while True:
  587. if not haveLine:
  588. haveLine = self.next_statement()
  589. if not self.is_statement():
  590. haveLine = False
  591. continue
  592. match = self.VAR_BLOCK_START_RE.match(self.line)
  593. if match is None:
  594. lastStatementWasVar = False
  595. haveLine = False
  596. continue
  597. # It might be a function definition, in which case we continue
  598. expression = match.group('expression')
  599. if expression:
  600. functionMatch = self.FUNCTION_RE.match(expression)
  601. if functionMatch:
  602. lastStatementWasVar = False
  603. haveLine = False
  604. continue
  605. # Now we have the start of a variable block
  606. if self.verbose:
  607. print u'%d: VAR BLOCK' % self.lineNum
  608. varLineNum = self.lineNum
  609. varLine = self.line
  610. haveLine, isSingleVar = self.var_block(match)
  611. if self.verbose:
  612. print u'%d: END VAR BLOCK:' % self.lineNum,
  613. if isSingleVar:
  614. print u'SINGLE'
  615. else:
  616. print u'MULTIPLE'
  617. if lastStatementWasVar and self.varDeclarations != self.VAR_DECLARATIONS_NONE:
  618. if (self.varDeclarations == self.VAR_DECLARATIONS_SINGLE and lastVarWasSingle and isSingleVar) or \
  619. (self.varDeclarations == self.VAR_DECLARATIONS_STRICT and (lastVarWasSingle or isSingleVar)):
  620. self.error('consecutive var declarations', lineNum=varLineNum, line=varLine)
  621. lastStatementWasVar = True
  622. lastVarWasSingle = isSingleVar
  623. def run_file_checks(self):
  624. for check in self.fileChecklist:
  625. self.sourcefile.seek(0)
  626. self.lineNum = 0
  627. if self.verbose:
  628. print u'%s: %s' % (check['title'], self.sourcefile.name)
  629. check['action']()
  630. def lint(self, filesToCheck):
  631. # Recursively walk any directories and eliminate duplicates
  632. self.filesToCheck = []
  633. for filename in filesToCheck:
  634. filename = unicode(filename, 'utf-8')
  635. fullpath = os.path.join(self.basedir, filename)
  636. if fullpath not in self.filesToCheck:
  637. if os.path.isdir(fullpath):
  638. for root, dirs, files in os.walk(fullpath):
  639. for skipDir in self.DIRS_TO_SKIP:
  640. if skipDir in dirs:
  641. dirs.remove(skipDir)
  642. for filename in files:
  643. if not filename.endswith('.j'):
  644. continue
  645. fullpath = os.path.join(root, filename)
  646. if fullpath not in self.filesToCheck:
  647. self.filesToCheck.append(fullpath)
  648. else:
  649. self.filesToCheck.append(fullpath)
  650. for filename in self.filesToCheck:
  651. try:
  652. with open(filename) as self.sourcefile:
  653. self.filename = relative_path(self.basedir, filename)
  654. self.run_file_checks()
  655. except IOError:
  656. self.lineNum = 0
  657. self.line = None
  658. self.error('file not found', type=self.ERROR_TYPE_ILLEGAL)
  659. except StopIteration:
  660. if self.verbose:
  661. print u'EOF\n'
  662. pass
  663. def lint_text(self, text, filename="<stdin>"):
  664. self.filename = filename
  665. self.filesToCheck = []
  666. try:
  667. self.sourcefile = cStringIO.StringIO(text)
  668. self.run_file_checks()
  669. except StopIteration:
  670. if self.verbose:
  671. print u'EOF\n'
  672. pass
  673. return self.errors
  674. def count_files_checked(self):
  675. return len(self.filesToCheck)
  676. def error(self, message, **kwargs):
  677. info = {
  678. 'filename': self.filename,
  679. 'message': message,
  680. 'type': kwargs.get('type', self.ERROR_TYPE_WARNING)
  681. }
  682. line = kwargs.get('line', self.line)
  683. lineNum = kwargs.get('lineNum', self.lineNum)
  684. if line and lineNum:
  685. info['line'] = tabs2spaces(line)
  686. info['lineNum'] = lineNum
  687. positions = kwargs.get('positions')
  688. if positions:
  689. info['positions'] = positions
  690. self.errors.append(info)
  691. if self.filename not in self.errorFiles:
  692. self.errorFiles.append(self.filename)
  693. def has_errors(self):
  694. return len(self.errors) != 0
  695. def print_errors(self, format='text'):
  696. if not self.errors:
  697. return
  698. if format == 'text':
  699. self.print_text_errors()
  700. elif format == 'html':
  701. self.print_textmate_html_errors()
  702. elif format == 'tooltip':
  703. self.print_tooltip_errors()
  704. def print_text_errors(self):
  705. sys.stdout.write('%d error' % len(self.errors))
  706. if len(self.errors) > 1:
  707. sys.stdout.write('s')
  708. if len(self.filesToCheck) == 1:
  709. template = self.TEXT_ERROR_SINGLE_FILE_TEMPLATE
  710. else:
  711. sys.stdout.write(' in %d files' % len(self.errorFiles))
  712. template = self.TEXT_ERROR_MULTI_FILE_TEMPLATE
  713. sys.stdout.write(':\n\n')
  714. for error in self.errors:
  715. if 'lineNum' in error and 'line' in error:
  716. sys.stdout.write(template.substitute(error).encode('utf-8'))
  717. if error.get('positions'):
  718. markers = ' ' * len(error['line'])
  719. for position in error['positions']:
  720. markers = markers[:position] + '^' + markers[position + 1:]
  721. # Add a space at the beginning of the markers to account for the '+' at the beginning
  722. # of the source line.
  723. sys.stdout.write(' %s\n' % markers)
  724. else:
  725. sys.stdout.write('%s: %s.\n' % (error['filename'], error['message']))
  726. sys.stdout.write('\n')
  727. def print_textmate_html_errors(self):
  728. html = """
  729. <html>
  730. <head>
  731. <title>Cappuccino Lint Report</title>
  732. <style type="text/css">
  733. body {
  734. margin: 0px;
  735. padding: 1px;
  736. }
  737. h1 {
  738. font: bold 12pt "Lucida Grande";
  739. color: #333;
  740. background-color: #FF7880;
  741. margin: 0 0 .5em 0;
  742. padding: .25em .5em;
  743. }
  744. p, a {
  745. margin: 0px;
  746. padding: 0px;
  747. }
  748. p {
  749. font: normal 10pt "Lucida Grande";
  750. color: #000;
  751. }
  752. p.error {
  753. background-color: #E2EAFF;
  754. }
  755. p.source {
  756. font-family: Consolas, 'Bitstream Vera Sans Mono', Monoco, Courier, sans-serif;
  757. white-space: pre;
  758. background-color: #fff;
  759. padding-bottom: 1em;
  760. }
  761. a {
  762. display: block;
  763. padding: .25em .5em;
  764. text-decoration: none;
  765. color: inherit;
  766. background-color: inherit;
  767. }
  768. a:hover {
  769. background-color: #ddd;
  770. }
  771. em {
  772. font-weight: normal;
  773. font-style: normal;
  774. font-variant: normal;
  775. background-color: #FF7880;
  776. }
  777. </style>
  778. </head>
  779. <body>
  780. """
  781. html += '<h1>Results: %d error' % len(self.errors)
  782. if len(self.errors) > 1:
  783. html += 's'
  784. if len(self.filesToCheck) > 1:
  785. html += ' in %d files' % len(self.errorFiles)
  786. html += '</h1>'
  787. for error in self.errors:
  788. message = cgi.escape(error['message'])
  789. if len(self.filesToCheck) > 1:
  790. filename = cgi.escape(error['filename']) + ':'
  791. else:
  792. filename = ''
  793. html += '<p class="error">'
  794. if 'line' in error and 'lineNum' in error:
  795. filepath = cgi.escape(os.path.join(self.basedir, error['filename']))
  796. lineNum = error['lineNum']
  797. line = error['line']
  798. positions = error.get('positions')
  799. firstPos = -1
  800. source = ''
  801. if positions:
  802. firstPos = positions[0] + 1
  803. lastPos = 0
  804. for pos in error.get('positions'):
  805. if pos < len(line):
  806. charToHighlight = line[pos]
  807. else:
  808. charToHighlight = ''
  809. source += '%s<em>%s</em>' % (cgi.escape(line[lastPos:pos]), cgi.escape(charToHighlight))
  810. lastPos = pos + 1
  811. if lastPos <= len(line):
  812. source += cgi.escape(line[lastPos:])
  813. else:
  814. source = line
  815. link = '<a href="txmt://open/?url=file://%s&line=%d&column=%d">' % (filepath, lineNum, firstPos)
  816. if len(self.filesToCheck) > 1:
  817. errorMsg = '%s%d: %s' % (filename, lineNum, message)
  818. else:
  819. errorMsg = '%d: %s' % (lineNum, message)
  820. html += '%(link)s%(errorMsg)s</a></p>\n<p class="source">%(link)s%(source)s</a></p>\n' % {'link': link, 'errorMsg': errorMsg, 'source': source}
  821. else:
  822. html += '%s%s</p>\n' % (filename, message)
  823. html += """
  824. </body>
  825. </html>
  826. """
  827. exit_show_html(html)
  828. class MiscTest(unittest.TestCase):
  829. def test_string_replacer(self):
  830. self.assertEquals(string_replacer("x = 'hello';"), "x = '';")
  831. self.assertEquals(string_replacer("x = '\\' hello';"), "x = '';")
  832. self.assertEquals(string_replacer("x = '\\\\';"), "x = '';")
  833. self.assertEquals(string_replacer("""x = '"string in string"';"""), "x = '';")
  834. self.assertEquals(string_replacer('x = "hello";'), 'x = "";')
  835. self.assertEquals(string_replacer('x = "\\" hello";'), 'x = "";')
  836. self.assertEquals(string_replacer('x = "\\\\";'), 'x = "";')
  837. self.assertEquals(string_replacer('''x = "'";'''), 'x = "";')
  838. class LintCheckerTest(unittest.TestCase):
  839. def test_exponential_notation(self):
  840. """Test that exponential notation such as 1.1e-6 doesn't cause a warning about missing whitespace."""
  841. # This should not report "binary operator without surrounding spaces".
  842. self.assertEquals(LintChecker().lint_text("a = 2.1e-6;"), [])
  843. self.assertEquals(LintChecker().lint_text("a = 2.1e+6;"), [])
  844. self.assertEquals(LintChecker().lint_text("a = 2e-0;"), [])
  845. self.assertEquals(LintChecker().lint_text("a = 2e+0;"), [])
  846. # But this should.
  847. self.assertEquals(LintChecker().lint_text("a = 1.1e-6+2e2;"), [{'positions': [6], 'filename': '<stdin>', 'lineNum': 1, 'message': 'binary operator without surrounding spaces', 'type': 2, 'line': u'a = 1.1e-6+2e2;'}])
  848. def test_function_types(self):
  849. """Test that function definitions like function(/*CPString*/key) don't cause warnings about surrounding spaces."""
  850. # This should not report "binary operator without surrounding spaces".
  851. self.assertEquals(LintChecker().lint_text("var resolveMultipleValues = function(/*CPString*/key, /*CPDictionary*/bindings, /*GSBindingOperationKind*/operation)"), [])
  852. def test_unary_plus(self):
  853. """Test that = +<variable>, like in `x = +y;`, doesn't cause a warning."""
  854. # +<variable> converts number in a string to a number.
  855. self.assertEquals(LintChecker().lint_text("var y = +x;"), [])
  856. def test_string_escaping(self):
  857. """Test that string literals are not parsed as syntax, even when they end with a double backslash."""
  858. self.assertEquals(LintChecker().lint_text('var x = "(\\\\";'), [])
  859. if __name__ == '__main__':
  860. usage = 'usage: %prog [options] [file ... | -]'
  861. parser = OptionParser(usage=usage, version='1.02')
  862. 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)')
  863. 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')
  864. 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))
  865. parser.add_option('-v', '--verbose', action='store_true', dest='verbose', default=False, help='show what lint is doing')
  866. parser.add_option('-q', '--quiet', action='store_true', dest='quiet', default=False, help='do not display errors, only return an exit code')
  867. (options, args) = parser.parse_args()
  868. if options.var_declarations not in LintChecker.VAR_DECLARATIONS:
  869. parser.error('--var-declarations must be one of [' + ', '.join(LintChecker.VAR_DECLARATIONS) + ']')
  870. if options.verbose and options.quiet:
  871. parser.error('options -v/--verbose and -q/--quiet are mutually exclusive')
  872. options.format = options.format.lower()
  873. if not options.format in LintChecker.ERROR_FORMATS:
  874. parser.error('format must be one of ' + '/'.join(LintChecker.ERROR_FORMATS))
  875. if options.format == 'html' and not within_textmate():
  876. parser.error('html format can only be used within TextMate.')
  877. if options.basedir:
  878. basedir = options.basedir
  879. if basedir[-1] == '/':
  880. basedir = basedir[:-1]
  881. else:
  882. basedir = os.getcwd()
  883. # We accept a list of filenames (relative to the cwd) either from the command line or from stdin
  884. filenames = args
  885. if args and args[0] == '-':
  886. filenames = [name.rstrip() for name in sys.stdin.readlines()]
  887. if not filenames:
  888. print usage.replace('%prog', os.path.basename(sys.argv[0]))
  889. sys.exit(0)
  890. checker = LintChecker(basedir=basedir, view=None, var_declarations=LintChecker.VAR_DECLARATIONS.index(options.var_declarations), verbose=options.verbose)
  891. pathsToCheck = []
  892. for filename in filenames:
  893. filename = filename.strip('"\'')
  894. path = os.path.join(basedir, filename)
  895. if (os.path.isdir(path) and not path.endswith('Frameworks')) or filename.endswith('.j'):
  896. pathsToCheck.append(relative_path(basedir, filename))
  897. if len(pathsToCheck) == 0:
  898. if within_textmate():
  899. exit_show_tooltip('No Objective-J files found.')
  900. sys.exit(0)
  901. checker.lint(pathsToCheck)
  902. if checker.has_errors():
  903. if not options.quiet:
  904. checker.print_errors(options.format)
  905. sys.exit(1)
  906. else:
  907. if within_textmate():
  908. exit_show_tooltip('Everything looks clean.')
  909. sys.exit(0)