diff --git a/README.md b/README.md index 70132f1fe..d41cd228b 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,19 @@ Additionally, for compatibility reasons, the legacy configurations `~/.proselint } ``` +You can also disable checks inside a file using special comments. Use +`proselint: disable` to disable *all* checks or `proselint: disable=` to +disable only specific checks. + +```tex +Here, all checks are used. +% proselint: disable=nonwords.misc,weasel_words.very +Here, the \texttt{nonwords.misc} and \texttt{weasel\_words.very} +checks are disabled. +% proselint: enable=nonwords.misc +At this point, the \texttt{nonwords.misc} check has been reenabled. +``` + | ID | Description | | ----- | --------------- | | `airlinese.misc` | Avoiding jargon of the airline industry | diff --git a/proselint/tools.py b/proselint/tools.py index e15e5047f..15d3b1f6c 100644 --- a/proselint/tools.py +++ b/proselint/tools.py @@ -1,6 +1,7 @@ """General-purpose tools shared across linting checks.""" import copy +import collections import dbm import functools import hashlib @@ -140,14 +141,14 @@ def wrapped(*args, **kwargs): def get_checks(options): """Extract the checks.""" sys.path.append(proselint_path) - checks = [] + checks = {} check_names = [key for (key, val) in options["checks"].items() if val] for check_name in check_names: module = importlib.import_module("checks." + check_name) for d in dir(module): if re.match("check", d): - checks.append(getattr(module, d)) + checks[check_name] = getattr(module, d) return checks @@ -230,6 +231,48 @@ def line_and_column(text, position): return (line_no, position - position_counter) +def find_ignored_checks(available_checks, text): + ignored_checks = collections.defaultdict(dict) + for match in re.finditer(r"^[!-/:-@[-`{-~\s]*proselint: (?Pdisable|enable)(?P(?:=(?:[\w\.,]*)))?", text, flags=re.MULTILINE): + if match.group("checks") == "=": + # The equal sign indicates that this only applies to some, but + # checks were specified. We just ignore this. + # TODO: Should this be considered an error? + continue + checks = [c for c in (match.group("checks") or "").lstrip("=").split(",") if c] + if not checks: + # This applies to all checks. + checks = available_checks + + for check in checks: + start, end = match.span() + if match.group("action") == "enable": + try: + closest_start = max(ignored_checks[check].keys()) + except ValueError: + # The check wasn't disabled previously, so this is a no-op + # FIXME: Print an error here? + pass + else: + ignored_checks[check][closest_start] = end + else: + ignored_checks[check][start] = None + return ignored_checks + + +def is_check_ignored(ignored_checks, check, start): + try: + closest_start = max(s for s in ignored_checks[check].keys() if s <= start) + except ValueError: + # The check wasn't disabled at all. + return False + + closest_end = ignored_checks[check][closest_start] + if closest_end is None or closest_end >= start: + return True + return False + + def lint(input_file, debug=False, config=config.default): """Run the linter on the input file.""" if isinstance(input_file, str): @@ -240,18 +283,23 @@ def lint(input_file, debug=False, config=config.default): # Get the checks. checks = get_checks(config) + ignored_checks = find_ignored_checks(checks.keys(), text) + # Apply all the checks. errors = [] - for check in checks: + for check in checks.values(): result = check(text) for error in result: (start, end, check, message, replacements) = error + if is_check_ignored(ignored_checks, check, start): + continue + if is_quoted(start, text): + continue (line, column) = line_and_column(text, start) - if not is_quoted(start, text): - errors += [(check, message, line, column, start, end, - end - start, "warning", replacements)] + errors += [(check, message, line, column, start, end, + end - start, "warning", replacements)] if len(errors) > config["max_errors"]: break diff --git a/tests/test_tools.py b/tests/test_tools.py index ec110d6eb..51d914bf8 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -21,6 +21,41 @@ def setUp(self): self.text = """But this is a very bad sentence. This is also a no-good sentence. +""" + self.text_with_checks_disabled_forever_tex = """ +But this is a bad sentence. +% proselint: disable +There is doubtlessly an error in this one. +This sentence is also very bad. +Proselint should also check this sentence unrelentlessly. + +""" + self.text_with_checks_disabled_and_reenabled_tex = """ +But this is a bad sentence. +% proselint: disable +There is doubtlessly an error in this one. +This sentence is also very bad. +% proselint: enable +Proselint should also check this sentence unrelentlessly. + +""" + self.text_with_checks_disabled_and_reenabled_html = """ +But this is a bad sentence. + +There is doubtlessly an error in this one. +This sentence is also very bad. + +Proselint should also check this sentence unrelentlessly. + +""" + self.text_with_specific_check_disabled_tex = """ +But this is a bad sentence. +% proselint: disable=nonwords.misc +There is doubtlessly an error in this one. +This sentence is also very bad. +% proselint: enable=nonwords.misc +Proselint should also check this sentence unrelentlessly. + """ self.text_with_no_newline = """A very bad sentence.""" @@ -37,3 +72,19 @@ def test_errors_sorted(self): def test_on_no_newlines(self): """Test that lint works on text without a terminal newline.""" assert len(lint(self.text_with_no_newline)) == 1 + + def test_checks_disabled_forever_tex(self): + """Test that disabling all checks works on a (La)TeX document.""" + assert len(lint(self.text_with_checks_disabled_forever_tex)) == 1 + + def test_checks_disabled_and_reenabled_tex(self): + """Test that disabling and reenabling all checks works on a (La)TeX document.""" + assert len(lint(self.text_with_checks_disabled_and_reenabled_tex)) == 2 + + def test_checks_disabled_and_reenabled_html(self): + """Test that disabling and reenabling all checks works on an HTML document.""" + assert len(lint(self.text_with_checks_disabled_and_reenabled_html)) == 2 + + def test_specific_check_disabled_tex(self): + """Test that disabling a specific check works on a (La)TeX document.""" + assert len(lint(self.text_with_specific_check_disabled_tex)) == 3