Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

[lint] Add linter.

  • Loading branch information...
commit 6f7c85344e1d3034dc6a8fca2fa3669fa49030b5 1 parent 20b2106
@jverkoey jverkoey authored
Showing with 398 additions and 1 deletion.
  1. +81 −1 src/scripts/Pbxproj.py
  2. +317 −0 src/scripts/lint
View
82 src/scripts/Pbxproj.py
@@ -78,7 +78,7 @@ def get_pbxproj_by_name(name):
# Three20
# Three20:Three20-Xcode3.2.5
# /path/to/project.xcodeproj/project.pbxproj
- def __init__(self, name):
+ def __init__(self, name):
self._project_data = None
parts = name.split(':')
@@ -503,6 +503,86 @@ def add_bundle(self):
return True
+ # Get the PBXFileReference from the given PBXBuildFile guid.
+ def get_filerefguid_from_buildfileguid(self, buildfileguid):
+ project_data = self.get_project_data()
+ match = re.search(buildfileguid+' \/\* .+ \*\/ = {isa = PBXBuildFile; fileRef = ([A-Z0-9]+) \/\* .+ \*\/;', project_data)
+
+ if not match:
+ logging.error("Couldn't find PBXBuildFile row.")
+ return None
+
+ (filerefguid, ) = match.groups()
+
+ return filerefguid
+
+ def get_filepath_from_filerefguid(self, filerefguid):
+ project_data = self.get_project_data()
+ match = re.search(filerefguid+' \/\* .+ \*\/ = {isa = PBXFileReference; .+ path = (.+); .+ };', project_data)
+
+ if not match:
+ logging.error("Couldn't find PBXFileReference row.")
+ return None
+
+ (path, ) = match.groups()
+
+ return path
+
+
+ # Get all source files that are "built" in this project. This includes files built for
+ # libraries, executables, and unit testing.
+ def get_built_sources(self):
+ project_data = self.get_project_data()
+ match = re.search('\/\* Begin PBXSourcesBuildPhase section \*\/\n((?:.|\n)+?)\/\* End PBXSourcesBuildPhase section \*\/', project_data)
+
+ if not match:
+ logging.error("Couldn't find PBXSourcesBuildPhase section.")
+ return None
+
+ (buildphasedata, ) = match.groups()
+
+ buildfileguids = re.findall('[ \t]+([A-Z0-9]+) \/\* .+ \*\/,\n', buildphasedata)
+
+ project_path = os.path.dirname(os.path.abspath(self.xcodeprojpath()))
+
+ filenames = []
+
+ for buildfileguid in buildfileguids:
+ filerefguid = self.get_filerefguid_from_buildfileguid(buildfileguid)
+ filepath = self.get_filepath_from_filerefguid(filerefguid)
+
+ filenames.append(os.path.join(project_path, filepath.strip('"')))
+
+ return filenames
+
+
+ # Get all header files that are "built" in this project. This includes files built for
+ # libraries, executables, and unit testing.
+ def get_built_headers(self):
+ project_data = self.get_project_data()
+ match = re.search('\/\* Begin PBXHeadersBuildPhase section \*\/\n((?:.|\n)+?)\/\* End PBXHeadersBuildPhase section \*\/', project_data)
+
+ if not match:
+ logging.error("Couldn't find PBXHeadersBuildPhase section.")
+ return None
+
+ (buildphasedata, ) = match.groups()
+
+ buildfileguids = re.findall('[ \t]+([A-Z0-9]+) \/\* .+ \*\/,\n', buildphasedata)
+
+ project_path = os.path.dirname(os.path.abspath(self.xcodeprojpath()))
+
+ filenames = []
+
+ for buildfileguid in buildfileguids:
+ filerefguid = self.get_filerefguid_from_buildfileguid(buildfileguid)
+ filepath = self.get_filepath_from_filerefguid(filerefguid)
+
+ filenames.append(os.path.join(project_path, filepath.strip('"')))
+
+ return filenames
+
+
def add_dependency(self, dep):
project_data = self.get_project_data()
dep_data = dep.get_project_data()
View
317 src/scripts/lint
@@ -0,0 +1,317 @@
+#!/usr/bin/env python
+# encoding: utf-8
+"""
+lint.py
+
+Validate style guidelines for a given source file.
+
+When run from Xcode, the linter will automatically lint all of the built source files
+and headers.
+
+Version 1.0
+
+History:
+1.0 - February 27, 2011: Includes a set of simple linters and a delinter for most lints.
+
+Created by Jeff Verkoeyen on 2011-02-27.
+Copyright 2009-2011 Facebook
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+import logging
+import os
+import Paths
+import pickle
+import re
+import string
+import sys
+from optparse import OptionParser
+
+from Pbxproj import Pbxproj
+from Pbxproj import relpath
+
+gcopyrightyears = '2009-2011'
+gdivider = '///////////////////////////////////////////////////////////////////////////////////////////////////'
+
+# Program entry. The meat of this script happens in the lint() method below.
+def main():
+ usage = '''%prog filename
+
+The Three20 Linter.
+Verify Three20 style guidelines for source code.'''
+
+ parser = OptionParser(usage = usage)
+ parser.add_option("-d", "--delint", dest="delint",
+ help="Delint the source",
+ action="store_true")
+
+ (options, args) = parser.parse_args()
+
+ if len(args) == 0:
+ parser.print_help()
+
+ # If we're running the linter from Xcode, let's just process the project.
+ if 'PROJECT_FILE_PATH' in os.environ:
+ lint_project(os.environ['PROJECT_FILE_PATH'], options)
+ else:
+ for filename in args:
+ lint(filename, options)
+
+
+# This filter makes it possible to set the line number on logging.error calls.
+class FilenameFilter(logging.Filter):
+ def __init__(self):
+ self.lineno = -1
+
+ def filter(self, record):
+ record.linenumber = self.lineno
+ return True
+
+
+def lint_project(project_path, options):
+ project = Pbxproj.get_pbxproj_by_name(project_path)
+
+ tempdir = None
+ if os.environ['TEMP_FILES_DIR']:
+ tempdir = os.environ['TEMP_FILES_DIR']
+
+ # We avoid relinting the same file over and over again by maintaining a mapping of filenames
+ # to modified times on disk. We store this information in the project's build directory and
+ # load it each time we run the linter for this project.
+ # Because we store the mtimes on a per project basis, we shouldn't run into any performance
+ # issues with a lint.dat file that's becoming completely massive.
+ mtimes = {}
+
+ # Read the lint.dat file and unpickle it if we find it.
+ if tempdir:
+ lintdatpath = os.path.join(os.path.abspath(tempdir), 'lint.dat')
+ if os.path.exists(lintdatpath):
+ lintdatfile = open(lintdatpath, 'rb')
+ mtimes = pickle.load(lintdatfile)
+
+ # The linter script may have changed since we last ran this project, so we might have to
+ # force lint every file to update them because there may be new linters.
+
+ # Assume that the linter hasn't been run for this project.
+ forcelint = True
+
+ # Get this script's path
+ lintfilename = os.path.realpath(__file__)
+
+ # Check the mtime.
+ mtime = os.path.getmtime(lintfilename)
+ if lintfilename in mtimes:
+ if mtime <= mtimes[lintfilename]:
+ # The lint script hasn't changed since we last ran this, so we don't have to force
+ # lint.
+ forcelint = False
+
+ # Store the linter's mtime for future runs.
+ mtimes[lintfilename] = mtime
+
+ #
+ # Get all of the "built" filenames in this project.
+
+ # The "Compile sources" phase files
+ filenames = project.get_built_sources()
+
+ # The "Copy headers" phase files
+ filenames = filenames + project.get_built_headers()
+
+ # Iterate through and lint each of the files that have been modified since we last ran
+ # the linter, unless we're forcelinting, in which case we lint everything.
+ for filename in filenames:
+ mtime = os.path.getmtime(filename)
+
+ # If the filename isn't in the lint data, we have no idea when it was last modified so
+ # we'll run the linter anyway.
+ if not forcelint and filename in mtimes:
+ # Is it older or unchanged?
+ if mtime <= mtimes[filename]:
+ # Yeah, let's skip it then.
+ continue
+
+ # The beef.
+ if lint(filename, options):
+ # Only update the last known modification time if there weren't any errors.
+ mtimes[filename] = mtime
+ elif filename in mtimes:
+ del mtimes[filename]
+
+ # Write out the lint data once we're done with this project. Thanks, pickle!
+ if tempdir:
+ lintdatfile = open(lintdatpath, 'wb')
+ pickle.dump(mtimes, lintdatfile)
+
+
+# Lint the given filename.
+def lint(filename, options):
+ logger = logging.getLogger()
+
+ f = FilenameFilter()
+ logger.addFilter(f)
+
+ # Set up the warning logger format.
+ ch = logging.StreamHandler()
+ if 'PROJECT_FILE_PATH' in os.environ:
+ formatter = logging.Formatter(filename+":%(linenumber)s: warning: "+relpath(os.getcwd(), filename)+":%(linenumber)s: %(message)s")
+ else:
+ formatter = logging.Formatter("warning: "+filename+":%(linenumber)s: %(message)s")
+ ch.setFormatter(formatter)
+ logger.addHandler(ch)
+
+ file = open(filename, 'r')
+ filedata = file.read()
+
+ did_lint_cleanly = True
+
+ # Everything is set up now, let's run through the linters!
+ if not lint_basics(filedata, filename, f, options.delint):
+ did_lint_cleanly = False
+
+ logger.removeFilter(f)
+ logger.removeHandler(ch)
+
+ return did_lint_cleanly
+
+
+# Basic lint tests that only look at one line's information.
+# If isdelinting is True, this method will try to fix as many lint issues as it can and then
+# write the results out to disk.
+def lint_basics(filedata, filename, linenofilter, isdelinting = False):
+ logger = logging.getLogger()
+
+ lines = string.split(filedata, "\n")
+ linenofilter.lineno = 1
+
+ prevline = None
+
+ did_lint_cleanly = True
+
+ nwarningsfixed = 0
+ nwarnings = 0
+ if isdelinting:
+ newfilelines = []
+
+ for line in lines:
+ # Check line lengths.
+ if len(line) > 100:
+ did_lint_cleanly = False
+ nwarnings = nwarnings + 1
+
+ # This is not something we can fix with the delinter.
+ if isdelinting:
+ logger.error('I don\'t know how to split this line up.')
+ else:
+ logger.error('Line length > 100')
+
+ # Check method dividers.
+ if not re.search(r'.h$', filename) and re.search(r'^[-+][ ]*\([\w\s*]+\)', line):
+ if prevline != gdivider and prevline != ' */':
+ did_lint_cleanly = False
+ nwarnings = nwarnings + 1
+
+ # This is not something we can fix with the delinter.
+ if isdelinting:
+ if re.match(r'/+', prevline):
+ newfilelines.pop()
+ newfilelines.append(gdivider)
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ logger.error('This method is missing a correct divider before it')
+
+ # Properties
+ if re.search(r'^@property', line):
+ if re.search(r'(NSString|NSArray|NSDictionary|NSSet)[ ]*\*', line) and not re.search(r'copy|readonly', line):
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ line = re.sub(r'\bretain\b', r'copy', line)
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('Objects that have mutable subclasses, such as NSString, should be copied, not retained')
+
+ if re.search(r'^@property\(', line):
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ line = line.rstrip(' \t')
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('Must be a space after the @property declarator')
+
+ # Trailing whitespace
+ if re.search('[ \t]+$', line):
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ line = line.rstrip(' \t')
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('Trailing whitespace')
+
+ # Spaces after logical constructs
+ if re.search('(if|while|for)\(', line, re.IGNORECASE):
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ line = re.sub(r'(if|while|for)\(', r'\1 (', line)
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('Missing space after logical construct')
+
+ # Boolean checks against non-boolean objects
+ # This test is really hard to do without knowing the type of the object.
+ #if re.search('[^!]!(?!TTIs|[a-z0-9_.]*\.is|is|_is|has|_has|\[|self\.is|[a-z0-9_]+\.)[a-z0-9_]+', line, re.IGNORECASE):
+ # did_lint_cleanly = False
+ # logger.error('Use if (nil == value) instead of boolean checks for pointers')
+
+ # Else statements must have one empty line before them
+ if re.search('}[ ]+else', line, re.IGNORECASE) and prevline != '' and not re.search(r'^[ ]*//', prevline):
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ newfilelines.append('')
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('There must be one empty line before an else statement')
+
+ # Copyright statement for Facebook
+ match = re.match('\/\/ Copyright ([0-9]+-[0-9]+) Facebook', line, re.IGNORECASE)
+ if match:
+ (copyrightyears, ) = match.groups()
+ if copyrightyears != gcopyrightyears:
+ nwarnings = nwarnings + 1
+ if isdelinting:
+ line = re.sub(r'([0-9]+-[0-9]+)', gcopyrightyears, line)
+ nwarningsfixed = nwarningsfixed + 1
+ else:
+ did_lint_cleanly = False
+ logger.error('The copyright statement on this file is outdated. Should be 2009-2011')
+
+ if isdelinting:
+ newfilelines.append(line)
+
+ prevline = line
+ linenofilter.lineno = linenofilter.lineno + 1
+
+ if isdelinting and nwarnings > 0:
+ newfiledata = '\n'.join(newfilelines)
+ file = open(filename, 'w')
+ file.write(newfiledata)
+
+ return did_lint_cleanly
+
+if __name__ == "__main__":
+ sys.exit(main())
Please sign in to comment.
Something went wrong with that request. Please try again.