diff --git a/regexlint/checkers.py b/regexlint/checkers.py index 1550d8e..36f477f 100644 --- a/regexlint/checkers.py +++ b/regexlint/checkers.py @@ -507,6 +507,61 @@ def check_redundant_repetition(reg, errs): errs.append((num, level, repeat.start, "should be +")) +def _whitespace_atom(branch): + """Return the single atom of an alternation branch when it matches only + whitespace that ``\\s`` already covers (unwrapping a repetition such as + ``\\s+``), else None. + """ + if len(branch.children) != 1: + return None + atom = branch.children[0] + if atom.type in Other.Repetition and len(atom.children) == 1: + atom = atom.children[0] + if ( + (atom.type is Other.BuiltinCharclass and atom.data == "\\s") + or atom.type in (Other.Newline, Other.Tab) + or (atom.type is Other.Suspicious and atom.data == "\\r") + or (atom.type is Other.Literal and atom.data in " \t\n\r\x0b\x0c") + ): + return atom + return None + + +def check_redundant_whitespace_alternation(reg, errs): + # https://github.com/pygments/pygments/pull/3186 + num = "126" + level = logging.WARNING + msg = "Redundant whitespace alternation, simplify to \\s+ (\\s already matches \\n)" + + outer = ( + Other.Repetition.Plus, + Other.Repetition.NongreedyPlus, + Other.Repetition.Star, + Other.Repetition.NongreedyStar, + ) + for rep in find_all_by_type(reg, Other.Repetition): + if rep.type not in outer or len(rep.children) != 1: + continue + group = rep.children[0] + if group.type not in Other.Open or len(group.children) != 1: + continue + alt = group.children[0] + if alt.type is not Other.Alternation: + continue + # Every branch must be \s-subsumed whitespace, and at least one must be + # \s itself (which is what makes the other branches redundant). + has_s = False + for branch in alt.children: + atom = _whitespace_atom(branch) + if atom is None: + break + if atom.type is Other.BuiltinCharclass: + has_s = True + else: + if has_s: + errs.append((num, level, rep.start or 0, msg)) + + def manual_check_for_empty_string_match(reg, errs, raw_pat): # Skip the check in the following conditions: # * Rules that use a callback, since they're used for indentation diff --git a/tests/test_checkers.py b/tests/test_checkers.py index f0ae425..a6278a9 100644 --- a/tests/test_checkers.py +++ b/tests/test_checkers.py @@ -39,6 +39,7 @@ check_no_nulls, check_prefix_ordering, check_redundant_repetition, + check_redundant_whitespace_alternation, check_single_character_classes, check_suspicious_anchors, check_unescaped_braces, @@ -758,6 +759,29 @@ def test_redundant_repetition_star(self): print(errs) self.assertEqual(len(errs), 1) + def test_redundant_whitespace_alternation(self): + for pat in (r"(\n|\s+)+", r"(\n|\s)+", r"(\s|\n)+", r"(\s|\n)*"): + r = Regex.get_parse_tree(pat) + errs = [] + check_redundant_whitespace_alternation(r, errs) + print(pat, errs) + self.assertEqual(len(errs), 1, pat) + + def test_redundant_whitespace_alternation_noncapturing(self): + r = Regex.get_parse_tree(r"(?:\n|\s)+") + errs = [] + check_redundant_whitespace_alternation(r, errs) + self.assertEqual(len(errs), 1) + + def test_redundant_whitespace_alternation_ok(self): + # No \s branch, a non-whitespace branch, or no outer +/* repetition. + for pat in (r"(\n|\t)+", r"(\d|\s)+", r"(\n|\s)?", r"(\n|\s)", r"\s+"): + r = Regex.get_parse_tree(pat) + errs = [] + check_redundant_whitespace_alternation(r, errs) + print(pat, errs) + self.assertEqual(len(errs), 0, pat) + def test_manual_empty_string(self): r = Regex.get_parse_tree("") errs = []