Bug 1379151 - Add --fix and --edit to mozlint, r?Standard8 draft
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Thu, 10 Aug 2017 09:21:17 -0400
changeset 644186 5862d91ea632818808c2233793fbe695cb5b1e14
parent 644113 4d54ac07b8c97f0e6713dab2ba694023b5b2f3b5
child 725522 9c1c2a3558ed58aea20f621b027df4dbe64bc950
push id73342
push userahalberstadt@mozilla.com
push dateThu, 10 Aug 2017 16:31:04 +0000
reviewersStandard8
bugs1379151
milestone57.0a1
Bug 1379151 - Add --fix and --edit to mozlint, r?Standard8 While --fix previously worked with eslint, it is now more official (will show up in the |mach lint --help|). ESlint is still the only thing that implements it, but we can implement it for flake8 using the `autopep8` module. --edit is a new concept that will open an editor for each failing file to let you fix the errors manually. For now it is very naive (just opens the file), and is only really useful if you have an editor integration for the linter(s). But in the future I'd like to have editor-specific implementations for this. For example, with vim, we can use -q to pass in an error file that will start the editor pre-populated with a list of all errors that can then be easily jumped to. Other editors may just open up to the line containing the error. --fix and --edit can be used in conjunction with one another. Doing that means only errors that can't be fixed automatically will show up in your editor. MozReview-Commit-ID: 5JJJhMIrMIB
python/mozlint/mozlint/cli.py
python/mozlint/test/linters/external.py
python/mozlint/test/python.ini
python/mozlint/test/test_cli.py
--- a/python/mozlint/mozlint/cli.py
+++ b/python/mozlint/mozlint/cli.py
@@ -1,18 +1,21 @@
 # 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 print_function, unicode_literals
 
 import os
+import subprocess
 import sys
 from argparse import REMAINDER, ArgumentParser
 
+from mozlint.formatters import all_formatters
+
 SEARCH_PATHS = []
 
 
 class MozlintParser(ArgumentParser):
     arguments = [
         [['paths'],
          {'nargs': '*',
           'default': None,
@@ -31,16 +34,17 @@ class MozlintParser(ArgumentParser):
          {'dest': 'list_linters',
           'default': False,
           'action': 'store_true',
           'help': "List all available linters and exit.",
           }],
         [['-f', '--format'],
          {'dest': 'fmt',
           'default': 'stylish',
+          'choices': all_formatters.keys(),
           'help': "Formatter to use. Defaults to 'stylish'.",
           }],
         [['-n', '--no-filter'],
          {'dest': 'use_filters',
           'default': True,
           'action': 'store_false',
           'help': "Ignore all filtering. This is useful for quickly "
                   "testing a directory that otherwise wouldn't be run, "
@@ -58,16 +62,28 @@ class MozlintParser(ArgumentParser):
          {'const': 'all',
           'nargs': '?',
           'choices': ['staged', 'all'],
           'help': "Lint files touched by changes in the working directory "
                   "(i.e haven't been committed yet). On git, --workdir=staged "
                   "can be used to only consider staged files. Works with "
                   "mercurial or git.",
           }],
+        [['--fix'],
+         {'action': 'store_true',
+          'default': False,
+          'help': "Fix lint errors if possible. Any errors that could not be fixed "
+                  "will be printed as normal."
+          }],
+        [['--edit'],
+         {'action': 'store_true',
+          'default': False,
+          'help': "Each file containing lint errors will be opened in $EDITOR one after "
+                  "the other."
+          }],
         [['extra_args'],
          {'nargs': REMAINDER,
           'help': "Extra arguments that will be forwarded to the underlying linter.",
           }],
     ]
 
     def __init__(self, **kwargs):
         ArgumentParser.__init__(self, usage=self.__doc__, **kwargs)
@@ -82,18 +98,24 @@ class MozlintParser(ArgumentParser):
                 i = args[0].index(token)
                 args[0].pop(i)
                 args[0][i:i] = [token[:2], '-' + token[2]]
 
         # This is here so the eslint mach command doesn't lose 'extra_args'
         # when using mach's dispatch functionality.
         args, extra = ArgumentParser.parse_known_args(self, *args, **kwargs)
         args.extra_args = extra
+
+        self.validate(args)
         return args, extra
 
+    def validate(self, args):
+        if args.edit and not os.environ.get('EDITOR'):
+            self.error("must set the $EDITOR environment variable to use --edit")
+
 
 def find_linters(linters=None):
     lints = []
     for search_path in SEARCH_PATHS:
         if not os.path.isdir(search_path):
             continue
 
         sys.path.insert(0, search_path)
@@ -108,17 +130,17 @@ def find_linters(linters=None):
 
             if linters and name not in linters:
                 continue
 
             lints.append(os.path.join(search_path, f))
     return lints
 
 
-def run(paths, linters, fmt, outgoing, workdir, list_linters=None, **lintargs):
+def run(paths, linters, fmt, outgoing, workdir, edit, list_linters=None, **lintargs):
     from mozlint import LintRoller, formatters
 
     if list_linters:
         lint_paths = find_linters(linters)
         print("Available linters: {}".format(
             [os.path.splitext(os.path.basename(l))[0] for l in lint_paths]
         ))
         return 0
@@ -128,16 +150,23 @@ def run(paths, linters, fmt, outgoing, w
     # Check if the path that is entered is a valid one.
     invalid_paths = [path for path in paths if not os.path.exists(path)]
     if invalid_paths:
         print("Error: The following paths do not exist:\n{}".format("\n".join(invalid_paths)))
         return 1
 
     # run all linters
     results = lint.roll(paths, outgoing=outgoing, workdir=workdir)
+
+    if edit:
+        editor = os.environ['EDITOR']
+        for path in results:
+            subprocess.call([editor, path])
+        return 1 if lint.failed else 0
+
     formatter = formatters.get(fmt)
 
     # Encode output with 'replace' to avoid UnicodeEncodeErrors on
     # environments that aren't using utf-8.
     out = formatter(results, failed=lint.failed).encode(
                     sys.stdout.encoding or 'ascii', 'replace')
     if out:
         print(out)
--- a/python/mozlint/test/linters/external.py
+++ b/python/mozlint/test/linters/external.py
@@ -6,16 +6,20 @@ from mozlint import result
 from mozlint.errors import LintException
 
 
 def badreturncode(files, config, **lintargs):
     return 1
 
 
 def external(files, config, **lintargs):
+    if lintargs.get('fix'):
+        # mimics no results because they got fixed
+        return []
+
     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_config(
                         config, path=path, lineno=i+1, column=1, rule="no-foobar"))
     return results
--- a/python/mozlint/test/python.ini
+++ b/python/mozlint/test/python.ini
@@ -1,11 +1,12 @@
 [DEFAULT]
 subsuite = mozlint, os == "linux"
 
+[test_cli.py]
 [test_formatters.py]
 [test_parser.py]
 [test_roller.py]
 [test_types.py]
 [test_vcs.py]
 # these tests run in the build images on non-linux, which have old
 # versions of mercurial and git installed
 skip-if = os != "linux"
new file mode 100644
--- /dev/null
+++ b/python/mozlint/test/test_cli.py
@@ -0,0 +1,55 @@
+# 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
+
+import pytest
+
+from mozlint import cli
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+@pytest.fixture
+def parser():
+    return cli.MozlintParser()
+
+
+@pytest.fixture
+def run(parser, lintdir, files):
+    if lintdir not in cli.SEARCH_PATHS:
+        cli.SEARCH_PATHS.append(lintdir)
+
+    def inner(args=None):
+        args = args or []
+        args.extend(files)
+        lintargs = vars(parser.parse_args(args))
+        lintargs['root'] = here
+        return cli.run(**lintargs)
+    return inner
+
+
+def test_cli_run_with_fix(run, capfd):
+    ret = run(['-f', 'json', '--fix', '--linter', 'external'])
+    out, err = capfd.readouterr()
+    assert ret == 0
+    assert out.endswith('{}\n')
+
+
+def test_cli_run_with_edit(run, parser, capfd):
+    os.environ['EDITOR'] = 'echo'
+
+    ret = run(['-f', 'json', '--edit', '--linter', 'external'])
+    out = capfd.readouterr()[0].strip()
+    assert ret == 0
+    assert os.path.basename(out) == 'foobar.js'
+
+    del os.environ['EDITOR']
+    with pytest.raises(SystemExit):
+        parser.parse_args(['--edit'])
+
+
+if __name__ == '__main__':
+    sys.exit(pytest.main(['--verbose', __file__]))