From 98f8b0ab277112184ac9357599c6be7d2c696506 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Thu, 16 Jul 2026 01:10:20 +0200 Subject: [PATCH] Add check for redundant whitespace alternations Flag patterns like (\n|\s+)+, (\n|\s)+ and (\s|\n)+ where an outer +/* repetition wraps a group whose alternation branches all match only whitespace subsumed by \s, with at least one branch being \s itself. Since \s already matches \n (and others), the alternation, group and outer quantifier are all redundant and the pattern simplifies to \s+. This is the anti-pattern cleaned up in pygments/pygments#3186. New checker 126 (check_redundant_whitespace_alternation), with tests. --- regexlint/checkers.py | 55 ++++++++++++++++++++++++++++++++++++++++++ tests/test_checkers.py | 24 ++++++++++++++++++ 2 files changed, 79 insertions(+) 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 = []