Repository: sphinx-contrib/sphinx-lint

Issue:
Unnecessary-parenthesis only catches a single error per line
A few other rules do this too (like `check_missing_colon_in_role`), but take unnecessary-parenthesis as an example: because unnecessary-parenthesis runs a regex that catches a single instance, it will only catch a single instance per line.

This isn't a bug, but still makes it annoying to fix issues as things pop up one after another. Feel free to close this though.

I'd be happy to contribute this if this is something to be fixed.

Discussion:
@AlexWaygood: I'd be willing to accept a fix for this, but it also sounds like the kind of thing that could lead to a big performance regression if done in a suboptimal way, so I'd definitely like to keep an eye on that. (sphinx-lint is one of the slowest tools in CPython's pre-commit setup, so I wouldn't want it getting slower!)

Solution patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 71777ee..1cad23e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -19,14 +19,12 @@ jobs:
       matrix:
         # when adding new versions, update the one used to test
         # friend projects below to the latest one
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
+        python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
         os: [ubuntu-latest, macos-latest, windows-latest]
         test-set: [base]
         include:
           - {python-version: "3.13", os: ubuntu-latest, test-set: friend-projects}
-        exclude:
-          # TODO Add Windows when regex wheel available for 3.13
-          - {os: windows-latest, python-version: "3.13"}
+          - {python-version: "3.13", os: windows-latest, test-set: friend-projects}
 
     steps:
       - uses: actions/checkout@v4
diff --git a/pyproject.toml b/pyproject.toml
index a686e81..31898e6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,14 +14,13 @@ authors = [
     {name = "Georg Brandl", email = "georg@python.org"},
     {name = "Julien Palard", email = "julien@palard.fr"},
 ]
-requires-python = ">= 3.8"
+requires-python = ">= 3.9"
 classifiers = [
   "Development Status :: 5 - Production/Stable",
   "Intended Audience :: Developers",
   "License :: OSI Approved :: Python Software Foundation License",
   "Natural Language :: English",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.8",
   "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
diff --git a/sphinxlint/checkers.py b/sphinxlint/checkers.py
index d64e7ef..1844697 100644
--- a/sphinxlint/checkers.py
+++ b/sphinxlint/checkers.py
@@ -56,8 +56,7 @@ def check_missing_backtick_after_role(file, lines, options=None):
     for paragraph_lno, paragraph in paragraphs(lines):
         if paragraph.count("|") > 4:
             return  # we don't handle tables yet.
-        error = rst.ROLE_MISSING_CLOSING_BACKTICK_RE.search(paragraph)
-        if error:
+        for error in rst.ROLE_MISSING_CLOSING_BACKTICK_RE.finditer(paragraph):
             error_offset = paragraph[: error.start()].count("\n")
             yield (
                 paragraph_lno + error_offset,
@@ -126,8 +125,7 @@ def check_default_role(file, lines, options=None):
     for lno, line in enumerate(lines, start=1):
         line = clean_paragraph(line)
         line = escape2null(line)
-        match = rst.INTERPRETED_TEXT_RE.search(line)
-        if match:
+        for match in rst.INTERPRETED_TEXT_RE.finditer(line):
             before_match = line[: match.start()]
             after_match = line[match.end() :]
             stripped_line = line.strip()
@@ -201,8 +199,7 @@ def check_missing_space_after_role(file, lines, options=None):
     """
     for lno, line in enumerate(lines, start=1):
         line = clean_paragraph(line)
-        role = _SUSPICIOUS_ROLE.search(line)
-        if role:
+        for role in _SUSPICIOUS_ROLE.finditer(line):
             yield lno, f"role missing (escaped) space after role: {role.group(0)!r}"
 
 
@@ -214,8 +211,7 @@ def check_role_without_backticks(file, lines, options=None):
     Good: :func:`pdb.main`
     """
     for lno, line in enumerate(lines, start=1):
-        no_backticks = rst.ROLE_WITH_NO_BACKTICKS_RE.search(line)
-        if no_backticks:
+        for no_backticks in rst.ROLE_WITH_NO_BACKTICKS_RE.finditer(line):
             yield lno, f"role with no backticks: {no_backticks.group(0)!r}"
 
 
@@ -316,8 +312,7 @@ def check_missing_space_before_role(file, lines, options=None):
         if paragraph.count("|") > 4:
             return  # we don't handle tables yet.
         paragraph = clean_paragraph(paragraph)
-        match = rst.ROLE_GLUED_WITH_WORD_RE.search(paragraph)
-        if match:
+        for match in rst.ROLE_GLUED_WITH_WORD_RE.finditer(paragraph):
             error_offset = paragraph[: match.start()].count("\n")
             if looks_like_glued(match):
                 yield (
@@ -386,8 +381,7 @@ def check_missing_colon_in_role(file, lines, options=None):
     Good: :issue:`123`
     """
     for lno, line in enumerate(lines, start=1):
-        match = rst.ROLE_MISSING_RIGHT_COLON_RE.search(line)
-        if match:
+        for match in rst.ROLE_MISSING_RIGHT_COLON_RE.finditer(line):
             yield lno, f"role missing colon before first backtick ({match.group(0)})."
 
 
@@ -471,8 +465,7 @@ def check_triple_backticks(file, lines, options=None):
     syntax, but it's really uncommon.
     """
     for lno, line in enumerate(lines):
-        match = rst.TRIPLE_BACKTICKS_RE.search(line)
-        if match:
+        for match in rst.TRIPLE_BACKTICKS_RE.finditer(line):
             yield lno + 1, "There's no rst syntax using triple backticks"
 
 
@@ -523,5 +516,5 @@ def check_unnecessary_parentheses(filename, lines, options):
     Good: :func:`test`
     """
     for lno, line in enumerate(lines, start=1):
-        if match := rst.ROLE_WITH_UNNECESSARY_PARENTHESES_RE.search(line):
+        for match in rst.ROLE_WITH_UNNECESSARY_PARENTHESES_RE.finditer(line):
             yield lno, f"Unnecessary parentheses in {match.group(0).strip()!r}"
diff --git a/sphinxlint/rst.py b/sphinxlint/rst.py
index 2b35a9b..4c8c026 100644
--- a/sphinxlint/rst.py
+++ b/sphinxlint/rst.py
@@ -5,7 +5,7 @@ In this file:
 - All compiled regexes are suffixed by _RE
 """
 
-from functools import lru_cache
+from functools import cache
 
 import regex as re
 
@@ -153,7 +153,7 @@ ASCII_ALLOWED_AFTER_INLINE_MARKUP = r"""-.,:;!?/'")\]}>"""
 UNICODE_ALLOWED_AFTER_INLINE_MARKUP = r"\p{Pe}\p{Pi}\p{Pf}\p{Pd}\p{Po}"
 
 
-@lru_cache(maxsize=None)
+@cache
 def inline_markup_gen(start_string, end_string, extra_allowed_before=""):
     """Generate a regex matching an inline markup.
 
diff --git a/tox.ini b/tox.ini
index e616fc7..b69aaa8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,7 +3,7 @@ requires =
     tox>=4.2
 env_list =
     lint
-    py{py3, 313, 312, 311, 310, 39, 38}
+    py{py3, 313, 312, 311, 310, 39}
 
 [testenv]
 extras =


Test patch:
diff --git a/tests/fixtures/paragraphs.rst b/tests/fixtures/paragraphs.rst
index a178f5f..9ccb669 100644
--- a/tests/fixtures/paragraphs.rst
+++ b/tests/fixtures/paragraphs.rst
@@ -72,9 +72,10 @@ i.e. at :line:``70``!
 
 Note that the errors report the exact
 line, not the first line of the paragraph
-so for example an error like
-:foo`missing colon` will be reported
-at line 76 and not at line 73!
+so for example two errors like
+:foo`missing colon` and :blah`other`
+will both be reported at line 76
+and not at line 73!
 
 
 .. note:
diff --git a/tests/fixtures/xfail/default-role-in-tables.rst b/tests/fixtures/xfail/default-role-in-tables.rst
index d85631c..2c08020 100644
--- a/tests/fixtures/xfail/default-role-in-tables.rst
+++ b/tests/fixtures/xfail/default-role-in-tables.rst
@@ -1,4 +1,5 @@
 .. expect: default role used (hint: for inline literals, use double backticks) (default-role)
+.. expect: default role used (hint: for inline literals, use double backticks) (default-role)
 
 In the following table there are a couple of default roles that should fail:
 
diff --git a/tests/fixtures/xfail/default-role-separated-by-commas.rst b/tests/fixtures/xfail/default-role-separated-by-commas.rst
index 510c79b..7263c55 100644
--- a/tests/fixtures/xfail/default-role-separated-by-commas.rst
+++ b/tests/fixtures/xfail/default-role-separated-by-commas.rst
@@ -1,3 +1,4 @@
 .. expect: default role used (hint: for inline literals, use double backticks) (default-role)
+.. expect: default role used (hint: for inline literals, use double backticks) (default-role)
 
 This is not detected: `foo`, `bar`.
diff --git a/tests/test_sphinxlint.py b/tests/test_sphinxlint.py
index 4e68ed5..377b2e1 100644
--- a/tests/test_sphinxlint.py
+++ b/tests/test_sphinxlint.py
@@ -86,7 +86,7 @@ def test_line_no_in_error_msg(file, capsys):
     has_errors = main(["sphinxlint.py", file])
     out, err = capsys.readouterr()
     assert out == ""
-    assert "paragraphs.rst:76: role missing colon before" in err
+    assert err.count("paragraphs.rst:76: role missing colon before") == 2
     assert "paragraphs.rst:70: role use a single backtick" in err
     assert "paragraphs.rst:65: inline literal missing (escaped) space" in err
     assert has_errors