new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/__init__.py
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+# flake8: noqa
+
+from .roller import LintRoller
+from .result import ResultContainer
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/errors.py
@@ -0,0 +1,25 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+
+
+class LintException(Exception):
+ pass
+
+
+class LinterNotFound(LintException):
+ def __init__(self, path):
+ LintException.__init__(self, "Could not find lint file '{}'".format(path))
+
+
+class LinterParseError(LintException):
+ def __init__(self, path, message):
+ LintException.__init__(self, "{}: {}".format(os.path.basename(path), message))
+
+
+class LintersNotConfigured(LintException):
+ def __init__(self):
+ LintException.__init__(self, "No linters registered! Use `LintRoller.read` "
+ "to register a linter.")
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/formatters/__init__.py
@@ -0,0 +1,23 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import json
+
+from ..result import ResultEncoder
+from .stylish import StylishFormatter
+
+
+class JSONFormatter(object):
+ def __call__(self, results):
+ return json.dumps(results, cls=ResultEncoder)
+
+
+all_formatters = {
+ 'json': JSONFormatter,
+ 'stylish': StylishFormatter,
+}
+
+
+def get(name, **fmtargs):
+ return all_formatters[name](**fmtargs)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/formatters/stylish.py
@@ -0,0 +1,104 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+from ..result import ResultContainer
+
+try:
+ import blessings
+except ImportError:
+ blessings = None
+
+
+class NullTerminal(object):
+ """Replacement for `blessings.Terminal()` that does no formatting."""
+ class NullCallableString(unicode):
+ """A dummy callable Unicode stolen from blessings"""
+ def __new__(cls):
+ new = unicode.__new__(cls, u'')
+ return new
+
+ def __call__(self, *args):
+ if len(args) != 1 or isinstance(args[0], int):
+ return u''
+ return args[0]
+
+ def __getattr__(self, attr):
+ return self.NullCallableString()
+
+
+class StylishFormatter(object):
+ """Formatter based on the eslint default."""
+
+ fmt = " {c1}{lineno}:{column} {c2}{level}{normal} {message} {c1}{rule}({linter}){normal}"
+ fmt_summary = "{t.bold}{c}\u2716 {problem} ({error}, {warning}){t.normal}"
+
+ def __init__(self, disable_colors=None):
+ if disable_colors or not blessings:
+ self.term = NullTerminal()
+ else:
+ self.term = blessings.Terminal()
+
+ def _reset_max(self):
+ self.max_lineno = 0
+ self.max_column = 0
+ self.max_level = 0
+ self.max_message = 0
+
+ def _update_max(self, err):
+ """Calculates the longest length of each token for spacing."""
+ self.max_lineno = max(self.max_lineno, len(str(err.lineno)))
+ self.max_column = max(self.max_column, len(str(err.column)))
+ self.max_level = max(self.max_level, len(str(err.level)))
+ self.max_message = max(self.max_message, len(err.message))
+
+ def _pluralize(self, s, num):
+ if num != 1:
+ s += 's'
+ return str(num) + ' ' + s
+
+ def __call__(self, result):
+ message = []
+
+ num_errors = 0
+ num_warnings = 0
+ for path, errors in sorted(result.iteritems()):
+ self._reset_max()
+
+ message.append(self.term.underline(path))
+ # Do a first pass to calculate required padding
+ for err in errors:
+ assert isinstance(err, ResultContainer)
+ self._update_max(err)
+ if err.level == 'error':
+ num_errors += 1
+ else:
+ num_warnings += 1
+
+ for err in errors:
+ message.append(self.fmt.format(
+ normal=self.term.normal,
+ c1=self.term.color(8),
+ c2=self.term.color(1) if err.level == 'error' else self.term.color(3),
+ lineno=str(err.lineno).rjust(self.max_lineno),
+ column=str(err.column).ljust(self.max_column),
+ level=err.level.ljust(self.max_level),
+ message=err.message.ljust(self.max_message),
+ rule='{} '.format(err.rule) if err.rule else '',
+ linter=err.linter.lower(),
+ ))
+
+ message.append('') # newline
+
+ # Print a summary
+ message.append(self.fmt_summary.format(
+ t=self.term,
+ c=self.term.color(9) if num_errors else self.term.color(11),
+ problem=self._pluralize('problem', num_errors + num_warnings),
+ error=self._pluralize('error', num_errors),
+ warning=self._pluralize('warning', num_warnings),
+ ))
+
+ return '\n'.join(message)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/parser.py
@@ -0,0 +1,85 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import imp
+import os
+import sys
+import uuid
+
+from .types import supported_types
+from .errors import LinterNotFound, LinterParseError
+
+
+class Parser(object):
+ """Reads and validates `.lint` files."""
+ required_attributes = (
+ 'name',
+ 'description',
+ 'type',
+ 'payload',
+ )
+
+ def __call__(self, path):
+ return self.parse(path)
+
+ def _load_linter(self, path):
+ # Ensure parent module is present otherwise we'll (likely) get
+ # an error due to unknown parent.
+ parent_module = 'mozlint.linters'
+ if parent_module not in sys.modules:
+ mod = imp.new_module(parent_module)
+ sys.modules[parent_module] = mod
+
+ write_bytecode = sys.dont_write_bytecode
+ sys.dont_write_bytecode = True
+
+ module_name = '{}.{}'.format(parent_module, uuid.uuid1().get_hex())
+ imp.load_source(module_name, path)
+
+ sys.dont_write_bytecode = write_bytecode
+
+ mod = sys.modules[module_name]
+
+ if not hasattr(mod, 'LINTER'):
+ raise LinterParseError(path, "No LINTER definition found!")
+
+ definition = mod.LINTER
+ definition['path'] = path
+ return definition
+
+ def _validate(self, linter):
+ missing_attrs = []
+ for attr in self.required_attributes:
+ if attr not in linter:
+ missing_attrs.append(attr)
+
+ if missing_attrs:
+ raise LinterParseError(linter['path'], "Missing required attribute(s): "
+ "{}".format(','.join(missing_attrs)))
+
+ if linter['type'] not in supported_types:
+ raise LinterParseError(linter['path'], "Invalid type '{}'".format(linter['type']))
+
+ for attr in ('include', 'exclude'):
+ if attr in linter and (not isinstance(linter[attr], list) or
+ not all(isinstance(a, basestring) for a in linter[attr])):
+ raise LinterParseError(linter['path'], "The {} directive must be a "
+ "list of strings!".format(attr))
+
+ def parse(self, path):
+ """Read a linter and return its LINTER definition.
+
+ :param path: Path to the linter.
+ :returns: Linter definition (dict)
+ :raises: LinterNotFound, LinterParseError
+ """
+ if not os.path.isfile(path):
+ raise LinterNotFound(path)
+
+ if not path.endswith('.lint'):
+ raise LinterParseError(path, "Invalid filename, linters must end with '.lint'!")
+
+ linter = self._load_linter(path)
+ self._validate(linter)
+ return linter
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/result.py
@@ -0,0 +1,88 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from json import dumps, JSONEncoder
+
+
+class ResultContainer(object):
+ """Represents a single lint error and its related metadata.
+
+ :param linter: name of the linter that flagged this error
+ :param path: path to the file containing the error
+ :param message: text describing the error
+ :param lineno: line number that contains the error
+ :param column: column containing the error (default 1)
+ :param level: severity of the error, either 'warning' or 'error' (default 'error')
+ :param hint: suggestion for fixing the error (optional)
+ :param source: source code context of the error (optional)
+ :param rule: name of the rule that was violated (optional)
+ :param lineoffset: denotes an error spans multiple lines, of the form
+ (<lineno offset>, <num lines>) (optional)
+ """
+
+ __slots__ = (
+ 'linter',
+ 'path',
+ 'message',
+ 'lineno',
+ 'column',
+ 'hint',
+ 'source',
+ 'level',
+ 'rule',
+ 'lineoffset',
+ )
+
+ def __init__(self, linter, path, message, lineno, column=1, hint=None,
+ source=None, level='error', rule=None, lineoffset=None):
+ self.path = path
+ self.message = message
+ self.lineno = lineno
+ self.column = column
+ self.hint = hint
+ self.source = source
+ self.level = level
+ self.linter = linter
+ self.rule = rule
+ self.lineoffset = lineoffset
+
+ def __repr__(self):
+ s = dumps(self, cls=ResultEncoder, indent=2)
+ return "ResultContainer({})".format(s)
+
+
+class ResultEncoder(JSONEncoder):
+ """Class for encoding :class:`~result.ResultContainer`s to json.
+
+ Usage:
+
+ json.dumps(results, cls=ResultEncoder)
+ """
+ def default(self, o):
+ if isinstance(o, ResultContainer):
+ return {a: getattr(o, a) for a in o.__slots__}
+ return JSONEncoder.default(self, o)
+
+
+def from_linter(lintobj, **kwargs):
+ """Create a :class:`~result.ResultContainer` from a LINTER definition.
+
+ Convenience method that pulls defaults from a LINTER
+ definition and forwards them.
+
+ :param lintobj: LINTER obj as defined in a .lint file
+ :param kwargs: same as :class:`~result.ResultContainer`
+ :returns: :class:`~result.ResultContainer` object
+ """
+ attrs = {}
+ for attr in ResultContainer.__slots__:
+ attrs[attr] = kwargs.get(attr, lintobj.get(attr))
+
+ if not attrs['linter']:
+ attrs['linter'] = lintobj.get('name')
+
+ if not attrs['message']:
+ attrs['message'] = lintobj.get('description')
+
+ return ResultContainer(**attrs)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/roller.py
@@ -0,0 +1,111 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import signal
+import traceback
+from collections import defaultdict
+from Queue import Empty
+from multiprocessing import (
+ Manager,
+ Pool,
+ cpu_count,
+)
+
+from .errors import LintersNotConfigured
+from .types import supported_types
+from .parser import Parser
+
+
+def _run_linters(queue, paths, **lintargs):
+ parse = Parser()
+ results = defaultdict(list)
+
+ while True:
+ try:
+ linter_path = queue.get(False)
+ except Empty:
+ return results
+
+ # Ideally we would pass the entire LINTER definition as an argument
+ # to the worker instead of re-parsing it. But passing a function from
+ # a dynamically created module (with imp) does not seem to be possible
+ # with multiprocessing on Windows.
+ linter = parse(linter_path)
+ func = supported_types[linter['type']]
+ res = func(paths, linter, **lintargs) or []
+
+ for r in res:
+ results[r.path].append(r)
+
+
+def _run_worker(*args, **lintargs):
+ try:
+ return _run_linters(*args, **lintargs)
+ except:
+ traceback.print_exc()
+ raise
+
+
+class LintRoller(object):
+ """Registers and runs linters.
+
+ :param lintargs: Arguments to pass to the underlying linter(s).
+ """
+
+ def __init__(self, **lintargs):
+ self.parse = Parser()
+ self.linters = []
+ self.lintargs = lintargs
+
+ def read(self, paths):
+ """Parse one or more linters and add them to the registry.
+
+ :param paths: A path or iterable of paths to linter definitions.
+ """
+ if isinstance(paths, basestring):
+ paths = (paths,)
+
+ for path in paths:
+ self.linters.append(self.parse(path))
+
+ def roll(self, paths, num_procs=None):
+ """Run all of the registered linters against the specified file paths.
+
+ :param paths: An iterable of files and/or directories to lint.
+ :param num_procs: The number of processes to use. Default: cpu count
+ :return: A dictionary with file names as the key, and a list of
+ :class:`~result.ResultContainer`s as the value.
+ """
+ if not self.linters:
+ raise LintersNotConfigured
+
+ if isinstance(paths, basestring):
+ paths = [paths]
+
+ m = Manager()
+ queue = m.Queue()
+
+ for linter in self.linters:
+ queue.put(linter['path'])
+
+ num_procs = num_procs or cpu_count()
+ num_procs = min(num_procs, len(self.linters))
+
+ # ensure child processes ignore SIGINT so it reaches parent
+ orig = signal.signal(signal.SIGINT, signal.SIG_IGN)
+ pool = Pool(num_procs)
+ signal.signal(signal.SIGINT, orig)
+
+ all_results = defaultdict(list)
+ results = []
+ for i in range(num_procs):
+ results.append(
+ pool.apply_async(_run_worker, args=(queue, paths), kwds=self.lintargs))
+
+ for res in results:
+ # parent process blocks on res.get()
+ for k, v in res.get().iteritems():
+ all_results[k].extend(v)
+
+ return all_results
new file mode 100644
--- /dev/null
+++ b/python/mozlint/mozlint/types.py
@@ -0,0 +1,146 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import re
+from abc import ABCMeta, abstractmethod
+
+from mozpack import path as mozpath
+from mozpack.files import FileFinder
+
+from . import result
+
+
+class BaseType(object):
+ """Abstract base class for all types of linters."""
+ __metaclass__ = ABCMeta
+ batch = False
+
+ def __call__(self, paths, linter, **lintargs):
+ """Run `linter` against `paths` with `lintargs`.
+
+ :param paths: Paths to lint. Can be a file or directory.
+ :param linter: Linter definition paths are being linted against.
+ :param lintargs: External arguments to the linter not defined in
+ the definition, but passed in by a consumer.
+ :returns: A list of :class:`~result.ResultContainer` objects.
+ """
+ exclude = lintargs.get('exclude', [])
+ exclude.extend(linter.get('exclude', []))
+
+ paths = self._filter(paths, linter.get('include'), exclude)
+ if not paths:
+ return
+
+ if self.batch:
+ return self._lint(paths, linter, **lintargs)
+
+ errors = []
+ for p in paths:
+ result = self._lint(p, linter, **lintargs)
+ if result:
+ errors.extend(result)
+ return errors
+
+ def _filter(self, paths, include=None, exclude=None):
+ if not include and not exclude:
+ return paths
+
+ if include:
+ include = map(os.path.normpath, include)
+
+ if exclude:
+ exclude = map(os.path.normpath, exclude)
+
+ def match(path, patterns):
+ return any(mozpath.match(path, pattern) for pattern in patterns)
+
+ filtered = []
+ for path in paths:
+ if os.path.isfile(path):
+ if include and not match(path, include):
+ continue
+ elif exclude and match(path, exclude):
+ continue
+ filtered.append(path)
+ elif os.path.isdir(path):
+ finder = FileFinder(path, find_executables=False, ignore=exclude)
+ if self.batch:
+ # Batch means the underlying linter will be responsible for finding
+ # matching files in the directory. Return the path as is if there
+ # exists at least one matching file.
+ if any(finder.contains(pattern) for pattern in include):
+ filtered.append(path)
+ else:
+ # Convert the directory to a list of matching files.
+ for pattern in include:
+ filtered.extend([os.path.join(path, p)
+ for p, f in finder.find(pattern)])
+
+ return filtered
+
+ @abstractmethod
+ def _lint(self, path):
+ pass
+
+
+class LineType(BaseType):
+ """Abstract base class for linter types that check each line individually.
+
+ Subclasses of this linter type will read each file and check the provided
+ payload against each line one by one.
+ """
+ __metaclass__ = ABCMeta
+
+ @abstractmethod
+ def condition(payload, line):
+ pass
+
+ def _lint(self, path, linter, **lintargs):
+ payload = linter['payload']
+
+ with open(path, 'r') as fh:
+ lines = fh.readlines()
+
+ errors = []
+ for i, line in enumerate(lines):
+ if self.condition(payload, line):
+ errors.append(result.from_linter(linter, path=path, lineno=i+1))
+
+ return errors
+
+
+class StringType(LineType):
+ """Linter type that checks whether a substring is found."""
+
+ def condition(self, payload, line):
+ return payload in line
+
+
+class RegexType(LineType):
+ """Linter type that checks whether a regex match is found."""
+
+ def condition(self, payload, line):
+ return re.search(payload, line)
+
+
+class ExternalType(BaseType):
+ """Linter type that runs an external function.
+
+ The function is responsible for properly formatting the results
+ into a list of :class:`~result.ResultContainer` objects.
+ """
+ batch = True
+
+ def _lint(self, files, linter, **lintargs):
+ payload = linter['payload']
+ return payload(files, **lintargs)
+
+
+supported_types = {
+ 'string': StringType(),
+ 'regex': RegexType(),
+ 'external': ExternalType(),
+}
+"""Mapping of type string to an associated instance."""
new file mode 100644
--- /dev/null
+++ b/python/mozlint/setup.py
@@ -0,0 +1,26 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from setuptools import setup
+
+VERSION = 0.1
+DEPS = []
+
+setup(
+ name='mozlint',
+ description='Framework for registering and running micro lints',
+ license='MPL 2.0',
+ author='Andrew Halberstadt',
+ author_email='ahalberstadt@mozilla.com',
+ url='',
+ packages=['mozlint'],
+ version=VERSION,
+ classifiers=[
+ 'Environment :: Console',
+ 'Development Status :: 3 - Alpha',
+ 'License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)',
+ 'Natural Language :: English',
+ ],
+ install_requires=DEPS,
+)
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/files/foobar.js
@@ -0,0 +1,2 @@
+// Oh no.. we called this variable foobar, bad!
+var foobar = "a string";
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/files/no_foobar.js
@@ -0,0 +1,2 @@
+// What a relief
+var properlyNamed = "a string";
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/external.lint
@@ -0,0 +1,30 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from mozlint import result
+
+
+def lint(files, **lintargs):
+ results = []
+ for path in files:
+ with open(path, 'r') as fh:
+ for i, line in enumerate(fh.readlines()):
+ if 'foobar' in line:
+ results.append(result.from_linter(
+ LINTER, path=path, lineno=i+1, column=1, rule="no-foobar"))
+ return results
+
+
+LINTER = {
+ 'name': "ExternalLinter",
+ 'description': "It's bad to have the string foobar in js files.",
+ 'include': [
+ '**/*.js',
+ '**/*.jsm',
+ ],
+ 'type': 'external',
+ 'payload': lint,
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_exclude.lint
@@ -0,0 +1,10 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "BadExcludeLinter",
+ 'description': "Has an invalid exclude directive.",
+ 'exclude': [0, 1], # should be a list of strings
+ 'type': 'string',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_extension.lnt
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "BadExtensionLinter",
+ 'description': "Has an invalid file extension.",
+ 'type': 'string',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_include.lint
@@ -0,0 +1,10 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "BadIncludeLinter",
+ 'description': "Has an invalid include directive.",
+ 'include': 'should be a list',
+ 'type': 'string',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/invalid_type.lint
@@ -0,0 +1,9 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "BadTypeLinter",
+ 'description': "Has an invalid type.",
+ 'type': 'invalid',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/missing_attrs.lint
@@ -0,0 +1,7 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "MissingAttrsLinter",
+ 'description': "Missing type and payload",
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/missing_definition.lint
@@ -0,0 +1,4 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+# No LINTER variable
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/raises.lint
@@ -0,0 +1,19 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from mozlint.errors import LintException
+
+
+def lint(files, **lintargs):
+ raise LintException("Oh no something bad happened!")
+
+
+LINTER = {
+ 'name': "RaisesLinter",
+ 'description': "Raises an exception",
+ 'type': 'external',
+ 'payload': lint,
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/regex.lint
@@ -0,0 +1,14 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "RegexLinter",
+ 'description': "Make sure the string 'foobar' never appears in a js variable files because it is bad.",
+ 'rule': 'no-foobar',
+ 'include': [
+ '**/*.js',
+ '**/*.jsm',
+ ],
+ 'type': 'regex',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/linters/string.lint
@@ -0,0 +1,14 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+
+LINTER = {
+ 'name': "StringLinter",
+ 'description': "Make sure the string 'foobar' never appears in browser js files because it is bad.",
+ 'rule': 'no-foobar',
+ 'include': [
+ '**/*.js',
+ '**/*.jsm',
+ ],
+ 'type': 'string',
+ 'payload': 'foobar',
+}
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_formatters.py
@@ -0,0 +1,84 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import json
+import os
+from collections import defaultdict
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import ResultContainer
+from mozlint import formatters
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestFormatters(TestCase):
+
+ def __init__(self, *args, **kwargs):
+ TestCase.__init__(self, *args, **kwargs)
+
+ containers = (
+ ResultContainer(
+ linter='foo',
+ path='a/b/c.txt',
+ message="oh no foo",
+ lineno=1,
+ ),
+ ResultContainer(
+ linter='bar',
+ path='d/e/f.txt',
+ message="oh no bar",
+ hint="try baz instead",
+ level='warning',
+ lineno=4,
+ column=2,
+ rule="bar-not-allowed",
+ ),
+ ResultContainer(
+ linter='baz',
+ path='a/b/c.txt',
+ message="oh no baz",
+ lineno=4,
+ source="if baz:",
+ ),
+ )
+
+ self.results = defaultdict(list)
+ for c in containers:
+ self.results[c.path].append(c)
+
+ def test_stylish_formatter(self):
+ expected = """
+a/b/c.txt
+ 1:1 error oh no foo (foo)
+ 4:1 error oh no baz (baz)
+
+d/e/f.txt
+ 4:2 warning oh no bar bar-not-allowed (bar)
+
+\u2716 3 problems (2 errors, 1 warning)
+""".strip()
+
+ fmt = formatters.get('stylish', disable_colors=True)
+ self.assertEqual(expected, fmt(self.results))
+
+ def test_json_formatter(self):
+ fmt = formatters.get('json')
+ formatted = json.loads(fmt(self.results))
+
+ self.assertEqual(set(formatted.keys()), set(self.results.keys()))
+
+ slots = ResultContainer.__slots__
+ for errors in formatted.values():
+ for err in errors:
+ self.assertTrue(all(s in err for s in slots))
+
+
+if __name__ == '__main__':
+ main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_parser.py
@@ -0,0 +1,68 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint.parser import Parser
+from mozlint.errors import (
+ LinterNotFound,
+ LinterParseError,
+)
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestParser(TestCase):
+
+ def __init__(self, *args, **kwargs):
+ TestCase.__init__(self, *args, **kwargs)
+
+ self._lintdir = os.path.join(here, 'linters')
+ self._parse = Parser()
+
+ def parse(self, name):
+ return self._parse(os.path.join(self._lintdir, name))
+
+ def test_parse_valid_linter(self):
+ linter = self.parse('string.lint')
+ self.assertIsInstance(linter, dict)
+ self.assertIn('name', linter)
+ self.assertIn('description', linter)
+ self.assertIn('type', linter)
+ self.assertIn('payload', linter)
+
+ def test_parse_invalid_type(self):
+ with self.assertRaises(LinterParseError):
+ self.parse('invalid_type.lint')
+
+ def test_parse_invalid_extension(self):
+ with self.assertRaises(LinterParseError):
+ self.parse('invalid_extension.lnt')
+
+ def test_parse_invalid_include_exclude(self):
+ with self.assertRaises(LinterParseError):
+ self.parse('invalid_include.lint')
+
+ with self.assertRaises(LinterParseError):
+ self.parse('invalid_exclude.lint')
+
+ def test_parse_missing_attributes(self):
+ with self.assertRaises(LinterParseError):
+ self.parse('missing_attrs.lint')
+
+ def test_parse_missing_definition(self):
+ with self.assertRaises(LinterParseError):
+ self.parse('missing_definition.lint')
+
+ def test_parse_non_existent_linter(self):
+ with self.assertRaises(LinterNotFound):
+ self.parse('missing_file.lint')
+
+
+if __name__ == '__main__':
+ main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_roller.py
@@ -0,0 +1,75 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import sys
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import LintRoller, ResultContainer
+from mozlint.errors import LintersNotConfigured, LintException
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestLintRoller(TestCase):
+
+ def __init__(self, *args, **kwargs):
+ TestCase.__init__(self, *args, **kwargs)
+
+ filedir = os.path.join(here, 'files')
+ self.files = [os.path.join(filedir, f) for f in os.listdir(filedir)]
+ self.lintdir = os.path.join(here, 'linters')
+
+ names = ('string.lint', 'regex.lint', 'external.lint')
+ self.linters = [os.path.join(self.lintdir, n) for n in names]
+
+ def setUp(self):
+ TestCase.setUp(self)
+ self.lint = LintRoller()
+
+ def test_roll_no_linters_configured(self):
+ with self.assertRaises(LintersNotConfigured):
+ self.lint.roll(self.files)
+
+ def test_roll_successful(self):
+ self.lint.read(self.linters)
+
+ result = self.lint.roll(self.files)
+ self.assertEqual(len(result), 1)
+
+ path = result.keys()[0]
+ self.assertEqual(os.path.basename(path), 'foobar.js')
+
+ errors = result[path]
+ self.assertIsInstance(errors, list)
+ self.assertEqual(len(errors), 6)
+
+ container = errors[0]
+ self.assertIsInstance(container, ResultContainer)
+ self.assertEqual(container.rule, 'no-foobar')
+
+ def test_roll_catch_exception(self):
+ self.lint.read(os.path.join(self.lintdir, 'raises.lint'))
+
+ # suppress printed traceback from test output
+ old_stderr = sys.stderr
+ sys.stderr = open(os.devnull, 'w')
+ with self.assertRaises(LintException):
+ self.lint.roll(self.files)
+ sys.stderr = old_stderr
+
+ def test_roll_with_excluded_path(self):
+ self.lint.lintargs = {'exclude': ['**/foobar.js']}
+
+ self.lint.read(self.linters)
+ result = self.lint.roll(self.files)
+
+ self.assertEqual(len(result), 0)
+
+
+if __name__ == '__main__':
+ main()
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_types.py
@@ -0,0 +1,69 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+from unittest import TestCase
+
+from mozunit import main
+
+from mozlint import LintRoller
+from mozlint.result import ResultContainer
+
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+class TestLinterTypes(TestCase):
+
+ def __init__(self, *args, **kwargs):
+ TestCase.__init__(self, *args, **kwargs)
+
+ self.lintdir = os.path.join(here, 'linters')
+ self.filedir = os.path.join(here, 'files')
+ self.files = [os.path.join(self.filedir, f) for f in os.listdir(self.filedir)]
+
+ def setUp(self):
+ TestCase.setUp(self)
+ self.lint = LintRoller()
+
+ def path(self, name):
+ return os.path.join(self.filedir, name)
+
+ def test_string_linter(self):
+ self.lint.read(os.path.join(self.lintdir, 'string.lint'))
+ result = self.lint.roll(self.files)
+ self.assertIsInstance(result, dict)
+
+ self.assertIn(self.path('foobar.js'), result.keys())
+ self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+ result = result[self.path('foobar.js')][0]
+ self.assertIsInstance(result, ResultContainer)
+ self.assertEqual(result.linter, 'StringLinter')
+
+ def test_regex_linter(self):
+ self.lint.read(os.path.join(self.lintdir, 'regex.lint'))
+ result = self.lint.roll(self.files)
+ self.assertIsInstance(result, dict)
+ self.assertIn(self.path('foobar.js'), result.keys())
+ self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+ result = result[self.path('foobar.js')][0]
+ self.assertIsInstance(result, ResultContainer)
+ self.assertEqual(result.linter, 'RegexLinter')
+
+ def test_external_linter(self):
+ self.lint.read(os.path.join(self.lintdir, 'external.lint'))
+ result = self.lint.roll(self.files)
+ self.assertIsInstance(result, dict)
+ self.assertIn(self.path('foobar.js'), result.keys())
+ self.assertNotIn(self.path('no_foobar.js'), result.keys())
+
+ result = result[self.path('foobar.js')][0]
+ self.assertIsInstance(result, ResultContainer)
+ self.assertEqual(result.linter, 'ExternalLinter')
+
+
+if __name__ == '__main__':
+ main()