summaryrefslogtreecommitdiffstats
path: root/python/mozlint/mozlint/cli.py
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--python/mozlint/mozlint/cli.py445
1 files changed, 445 insertions, 0 deletions
diff --git a/python/mozlint/mozlint/cli.py b/python/mozlint/mozlint/cli.py
new file mode 100644
index 0000000000..0262173367
--- /dev/null
+++ b/python/mozlint/mozlint/cli.py
@@ -0,0 +1,445 @@
+# 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 argparse import REMAINDER, SUPPRESS, ArgumentParser
+from pathlib import Path
+
+from mozlint.errors import NoValidLinter
+from mozlint.formatters import all_formatters
+
+
+class MozlintParser(ArgumentParser):
+ arguments = [
+ [
+ ["paths"],
+ {
+ "nargs": "*",
+ "default": None,
+ "help": "Paths to file or directories to lint, like "
+ "'browser/components/loop' or 'mobile/android'. "
+ "If not provided, defaults to the files changed according "
+ "to --outgoing and --workdir.",
+ },
+ ],
+ [
+ ["-l", "--linter"],
+ {
+ "dest": "linters",
+ "default": [],
+ "action": "append",
+ "help": "Linters to run, e.g 'eslint'. By default all linters "
+ "are run for all the appropriate files.",
+ },
+ ],
+ [
+ ["--list"],
+ {
+ "dest": "list_linters",
+ "default": False,
+ "action": "store_true",
+ "help": "List all available linters and exit.",
+ },
+ ],
+ [
+ ["-W", "--warnings"],
+ {
+ "const": True,
+ "nargs": "?",
+ "choices": ["soft"],
+ "dest": "show_warnings",
+ "help": "Display and fail on warnings in addition to errors. "
+ "--warnings=soft can be used to report warnings but only fail "
+ "on errors.",
+ },
+ ],
+ [
+ ["-v", "--verbose"],
+ {
+ "dest": "show_verbose",
+ "default": False,
+ "action": "store_true",
+ "help": "Enable verbose logging.",
+ },
+ ],
+ [
+ ["-f", "--format"],
+ {
+ "dest": "formats",
+ "action": "append",
+ "help": "Formatter to use. Defaults to 'stylish' on stdout. "
+ "You can specify an optional path as --format formatter:path "
+ "that will be used instead of stdout. "
+ "You can also use multiple formatters at the same time. "
+ "Formatters available: {}.".format(", ".join(all_formatters.keys())),
+ },
+ ],
+ [
+ ["-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, "
+ "without needing to modify the config file.",
+ },
+ ],
+ [
+ ["--include-third-party"],
+ {
+ "dest": "include_third-party",
+ "default": False,
+ "action": "store_true",
+ "help": "Also run the linter(s) on third-party code",
+ },
+ ],
+ [
+ ["-o", "--outgoing"],
+ {
+ "const": True,
+ "nargs": "?",
+ "help": "Lint files touched by commits that are not on the remote repository. "
+ "Without arguments, finds the default remote that would be pushed to. "
+ "The remote branch can also be specified manually. Works with "
+ "mercurial or git.",
+ },
+ ],
+ [
+ ["-w", "--workdir"],
+ {
+ "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.",
+ },
+ ],
+ [
+ ["-r", "--rev"],
+ {
+ "default": None,
+ "type": str,
+ "help": "Lint files touched by changes in revisions described by REV. "
+ "For mercurial, it may be any revset. For git, it is a single tree-ish.",
+ },
+ ],
+ [
+ ["--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.",
+ },
+ ],
+ [
+ ["--setup"],
+ {
+ "action": "store_true",
+ "default": False,
+ "help": "Bootstrap linter dependencies without running any of the linters.",
+ },
+ ],
+ [
+ ["-j", "--jobs"],
+ {
+ "default": None,
+ "dest": "num_procs",
+ "type": int,
+ "help": "Number of worker processes to spawn when running linters. "
+ "Defaults to the number of cores in your CPU.",
+ },
+ ],
+ # Paths to check for linter configurations.
+ # Default: tools/lint set in tools/lint/mach_commands.py
+ [
+ ["--config-path"],
+ {
+ "action": "append",
+ "default": [],
+ "dest": "config_paths",
+ "help": SUPPRESS,
+ },
+ ],
+ [
+ ["--check-exclude-list"],
+ {
+ "dest": "check_exclude_list",
+ "default": False,
+ "action": "store_true",
+ "help": "Run linters for all the paths in the exclude list.",
+ },
+ ],
+ [
+ ["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)
+
+ for cli, args in self.arguments:
+ self.add_argument(*cli, **args)
+
+ def parse_known_args(self, *args, **kwargs):
+ # Allow '-wo' or '-ow' as shorthand for both --workdir and --outgoing.
+ for token in ("-wo", "-ow"):
+ if token in args[0]:
+ 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")
+
+ if args.paths:
+ invalid = [p for p in args.paths if not os.path.exists(p)]
+ if invalid:
+ self.error(
+ "the following paths do not exist:\n{}".format("\n".join(invalid))
+ )
+
+ if args.formats:
+ formats = []
+ for fmt in args.formats:
+ if isinstance(fmt, tuple): # format is already processed
+ formats.append(fmt)
+ continue
+
+ path = None
+ if ":" in fmt:
+ # Detect optional formatter path
+ pos = fmt.index(":")
+ fmt, path = fmt[:pos], os.path.realpath(fmt[pos + 1 :])
+
+ # Check path is writable
+ fmt_dir = os.path.dirname(path)
+ if not os.access(fmt_dir, os.W_OK | os.X_OK):
+ self.error(
+ "the following directory is not writable: {}".format(
+ fmt_dir
+ )
+ )
+
+ if fmt not in all_formatters.keys():
+ self.error(
+ "the following formatter is not available: {}".format(fmt)
+ )
+
+ formats.append((fmt, path))
+ args.formats = formats
+ else:
+ # Can't use argparse default or this choice will be always present
+ args.formats = [("stylish", None)]
+
+
+def find_linters(config_paths, linters=None):
+ lints = {}
+ for search_path in config_paths:
+ if not os.path.isdir(search_path):
+ continue
+
+ sys.path.insert(0, search_path)
+ files = os.listdir(search_path)
+ for f in files:
+ name = os.path.basename(f)
+
+ if not name.endswith(".yml"):
+ continue
+
+ name = name.rsplit(".", 1)[0]
+
+ if linters and name not in linters:
+ continue
+
+ lints[name] = os.path.join(search_path, f)
+
+ linters_not_found = list(set(linters).difference(set(lints.keys())))
+ return {"lint_paths": lints.values(), "linters_not_found": linters_not_found}
+
+
+def get_exclude_list_output(result, paths):
+ # Store the paths of all the subdirectories leading to the error files
+ error_file_paths = set()
+ for issues in result.issues.values():
+ error_file = issues[0].relpath
+ error_file_paths.add(error_file)
+ parent_dir = os.path.dirname(error_file)
+ while parent_dir:
+ error_file_paths.add(parent_dir)
+ parent_dir = os.path.dirname(parent_dir)
+
+ paths = [os.path.dirname(path) if path[-1] == "/" else path for path in paths]
+ # Remove all the error paths to get the list of green paths
+ green_paths = sorted(set(paths).difference(error_file_paths))
+
+ if green_paths:
+ out = (
+ "The following list of paths are now green "
+ "and can be removed from the exclude list:\n\n"
+ )
+ out += "\n".join(green_paths)
+
+ else:
+ out = "No path in the exclude list is green."
+
+ return out
+
+
+def run(
+ paths,
+ linters,
+ formats,
+ outgoing,
+ workdir,
+ rev,
+ edit,
+ check_exclude_list,
+ setup=False,
+ list_linters=False,
+ num_procs=None,
+ virtualenv_manager=None,
+ setupargs=None,
+ **lintargs
+):
+ from mozlint import LintRoller, formatters
+ from mozlint.editor import edit_issues
+
+ lintargs["config_paths"] = [
+ os.path.join(lintargs["root"], p) for p in lintargs["config_paths"]
+ ]
+
+ # Always perform exhaustive linting for exclude list paths
+ lintargs["use_filters"] = lintargs["use_filters"] and not check_exclude_list
+
+ if list_linters:
+ lint_paths = find_linters(lintargs["config_paths"], linters)
+ linters = [
+ os.path.splitext(os.path.basename(l))[0] for l in lint_paths["lint_paths"]
+ ]
+ print("\n".join(sorted(linters)))
+ print(
+ "\nNote that clang-tidy checks are not run as part of this "
+ "command, but using the static-analysis command."
+ )
+ return 0
+
+ lint = LintRoller(setupargs=setupargs or {}, **lintargs)
+ linters_info = find_linters(lintargs["config_paths"], linters)
+
+ result = None
+
+ try:
+
+ lint.read(linters_info["lint_paths"])
+
+ if check_exclude_list:
+ if len(lint.linters) > 1:
+ print("error: specify a single linter to check with `-l/--linter`")
+ return 1
+ paths = lint.linters[0]["local_exclude"]
+
+ if (
+ not paths
+ and Path.cwd() == Path(lint.root)
+ and not (outgoing or workdir or rev)
+ ):
+ print(
+ "warning: linting the entire repo takes a long time, using --outgoing and "
+ "--workdir instead. If you want to lint the entire repo, run `./mach lint .`"
+ )
+ # Setting the default values
+ outgoing = True
+ workdir = "all"
+
+ # Always run bootstrapping, but return early if --setup was passed in.
+ ret = lint.setup(virtualenv_manager=virtualenv_manager)
+ if setup:
+ return ret
+
+ if linters_info["linters_not_found"] != []:
+ raise NoValidLinter
+
+ # run all linters
+ result = lint.roll(
+ paths, outgoing=outgoing, workdir=workdir, rev=rev, num_procs=num_procs
+ )
+ except NoValidLinter as e:
+ result = lint.result
+ print(str(e))
+
+ if edit and result.issues:
+ edit_issues(result)
+ result = lint.roll(result.issues.keys(), num_procs=num_procs)
+
+ for every in linters_info["linters_not_found"]:
+ result.failed_setup.add(every)
+
+ if check_exclude_list:
+ # Get and display all those paths in the exclude list which are
+ # now green and can be safely removed from the list
+ out = get_exclude_list_output(result, paths)
+ print(out, file=sys.stdout)
+ return result.returncode
+
+ for formatter_name, path in formats:
+ formatter = formatters.get(formatter_name)
+
+ out = formatter(result)
+ # We do this only for `json` that is mostly used in automation
+ if not out and formatter_name == "json":
+ out = "{}"
+
+ if out:
+ fh = open(path, "w") if path else sys.stdout
+
+ if not path and fh.encoding == "ascii":
+ # If sys.stdout.encoding is ascii, printing output will fail
+ # due to the stylish formatter's use of unicode characters.
+ # Ideally the user should fix their environment by setting
+ # `LC_ALL=C.UTF-8` or similar. But this is a common enough
+ # problem that we help them out a little here by manually
+ # encoding and writing to the stdout buffer directly.
+ out += "\n"
+ fh.buffer.write(out.encode("utf-8", errors="replace"))
+ fh.buffer.flush()
+ else:
+ print(out, file=fh)
+
+ if path:
+ fh.close()
+
+ return result.returncode
+
+
+if __name__ == "__main__":
+ parser = MozlintParser()
+ args = vars(parser.parse_args())
+ sys.exit(run(**args))