├── tests ├── fixtures │ ├── xpass │ │ ├── should-not-trigger-trailing-whistespace.rst │ │ ├── role-inside-literal-role.rst │ │ ├── two-inline-literals.rst │ │ ├── link-with-no-title.rst │ │ ├── roles-may-not-be-hardcoded.rst │ │ ├── default-role-escaped.rst │ │ ├── inline-literal-containing-newline.rst │ │ ├── role-tags-in-inline-literals.rst │ │ ├── not-a-default-role-in-comment.rst │ │ ├── synopsis.rst │ │ ├── code-sample-not-glued-with-plural.rst │ │ ├── hyperlink-reference-followed-by-role.rst │ │ ├── backslash-space.rst │ │ ├── inline-literal-inside-role.rst │ │ ├── this-is-just-a-title.rst │ │ ├── fixed-bad-directive.rst │ │ ├── inline-internal-targets.rst │ │ ├── fixed-missing-space.rst │ │ ├── surrogate-space-inside-role.rst │ │ ├── column-in-inline-literal.rst │ │ ├── multiple-inline-literals.rst │ │ ├── colon-all-colon-in-inline-literal.rst │ │ ├── link-in-literal.rst │ │ ├── fixed-missing-column.rst │ │ ├── role-in-code-sample.rst │ │ ├── role-in-inline-literal.rst │ │ ├── no_missing_space_after_literal.rst │ │ ├── role-inside-inline-literal.rst │ │ ├── splitted-role-in-a-table.rst │ │ ├── multiline-links-in-table.rst │ │ ├── multiline-role-in-table.rst │ │ ├── role-in-table.rst │ │ ├── download-directive-with-url.rst │ │ ├── dangling-hyphen.rst │ │ ├── inline-literals-in-tables.rst │ │ ├── rst-in-code-blocks.rst │ │ ├── multiline-inline-literal.rst │ │ ├── long-inline-link.rst │ │ └── sections.rst │ ├── xfail │ │ ├── missing-newline-at-end-of-file.rst │ │ ├── missing-space-1.rst │ │ ├── role-missing-colon.rst │ │ ├── missing-colon-1.rst │ │ ├── missing-space-2.rst │ │ ├── missing-space.rst │ │ ├── trailing-whitespaces.rst │ │ ├── default-role-glued.rst │ │ ├── missing-space-after-role.rst │ │ ├── default-role-double-quoted.rst │ │ ├── default-role-parenthesed.rst │ │ ├── role-with-double-backticks.rst │ │ ├── default-role-between-brackets.rst │ │ ├── default-role-can-end-with-backslash.rst │ │ ├── error-hidden-in-note.rst │ │ ├── role-without-backticks.rst │ │ ├── default-role-no-alpha.rst │ │ ├── default-role-quote.rst │ │ ├── triple-dot-directive.rst │ │ ├── hyperlink-missing-space.rst │ │ ├── trailing-double-backticks.rst │ │ ├── missing-surrogate-space.rst │ │ ├── default-role.rst │ │ ├── bad-directive.rst │ │ ├── default-role-separated-by-commas.rst │ │ ├── role-inside-literal-role-missing-surrogate-escape.rst │ │ ├── default-role-hidden-in-function.rst │ │ ├── missing-colon-2.rst │ │ ├── missing-colon.rst │ │ ├── dangling-hyphen.rst │ │ ├── space-inside-inline-literal.rst │ │ ├── missing-backtick-on-role.rst │ │ ├── download-hyperlink-unnecessary-underscore.rst │ │ ├── missing-right-role-colon.rst │ │ ├── hyperlink-missing-backtick.rst │ │ ├── hyperlink-missing-underscore.rst │ │ ├── missing-space-before-default-role.rst │ │ ├── code-sample-glued-with-plural.rst │ │ ├── backslash-space.rst │ │ ├── hyperlink-missing-underscore-and-space.rst │ │ ├── missing-backtick-after-role-after-table.rst │ │ ├── hyperlinks-several.rst │ │ ├── default-role-in-c-type.rst │ │ ├── role-with-exclamation-and-tilde.rst │ │ ├── default-role-in-tables.rst │ │ ├── superfluous-backtick-in-front-of-role.rst │ │ ├── bad-dedent-in-code-block.rst │ │ ├── missing-space-around-inline-literals.rst │ │ ├── role-with-extra-backtick.rst │ │ └── zero-width-no-break-space-before-inline-literal.rst │ ├── triggers-false-positive │ │ └── triple-backticks.rst │ └── paragraphs.rst ├── test_po2rst.py ├── test_default_role_re.py ├── test_xpass_friends.py ├── test_enable_disable.py ├── test_sphinxlint.py └── test_filter_out_literal.py ├── .github ├── FUNDING.yml └── workflows │ ├── lint.yml │ ├── tests.yml │ └── deploy.yml ├── sphinxlint ├── __main__.py ├── __init__.py ├── sphinxlint.py ├── utils.py ├── cli.py ├── rst.py └── checkers.py ├── .gitignore ├── .coveragerc ├── .pre-commit-hooks.yaml ├── tox.ini ├── .pre-commit-config.yaml ├── pyproject.toml ├── download-more-tests.sh ├── LICENSE ├── sphinx-lint.1 └── README.md /tests/fixtures/xpass/should-not-trigger-trailing-whistespace.rst: -------------------------------------------------------------------------------- 1 | Hell o 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | github: [JulienPalard] 4 | liberapay: JulienPalard 5 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-inside-literal-role.rst: -------------------------------------------------------------------------------- 1 | This too :literal:`:shall:`pass``. 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/two-inline-literals.rst: -------------------------------------------------------------------------------- 1 | Yes, ``'a' + u'bc'`` is ``u'abc'``. 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/link-with-no-title.rst: -------------------------------------------------------------------------------- 1 | This is a valid link: ``_. 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/roles-may-not-be-hardcoded.rst: -------------------------------------------------------------------------------- 1 | such as :std:doc:`PyPA build ` 2 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-newline-at-end-of-file.rst: -------------------------------------------------------------------------------- 1 | .. expect: No newline at end of file. 2 | 3 | Hell o -------------------------------------------------------------------------------- /tests/fixtures/xpass/default-role-escaped.rst: -------------------------------------------------------------------------------- 1 | This will not be a default role due to the escaping: \`foo`. 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/inline-literal-containing-newline.rst: -------------------------------------------------------------------------------- 1 | An ``inline literal 2 | can contain a newline``. 3 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-tags-in-inline-literals.rst: -------------------------------------------------------------------------------- 1 | Use a Sphinx ``:func:``, ``:meth:``, or ``:class:``. 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/not-a-default-role-in-comment.rst: -------------------------------------------------------------------------------- 1 | .. this is a comment 2 | this is not a `default role` 3 | -------------------------------------------------------------------------------- /tests/fixtures/triggers-false-positive/triple-backticks.rst: -------------------------------------------------------------------------------- 1 | Those triple backticks are not a thing in rst: 2 | ```here```. 3 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/synopsis.rst: -------------------------------------------------------------------------------- 1 | .. module:: distutils.command.check 2 | :synopsis: Check the meta-data of a package 3 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/code-sample-not-glued-with-plural.rst: -------------------------------------------------------------------------------- 1 | all 4 ``'a'``\ s, but, when the final ``'a'`` is encountered, the 2 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/hyperlink-reference-followed-by-role.rst: -------------------------------------------------------------------------------- 1 | `issue tracker`_ and submit a :ref:`pull request `. 2 | -------------------------------------------------------------------------------- /sphinxlint/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from sphinxlint import cli 4 | 5 | if __name__ == "__main__": 6 | sys.exit(cli.main()) 7 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/backslash-space.rst: -------------------------------------------------------------------------------- 1 | Here ``Callable``\ s the "s" is separated from ``Callable`` using 2 | a "backslash-space". 3 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space-1.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before role 2 | 3 | by the:c:func:`PyThreadState_EnterTracing` function. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-missing-colon.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing opening tag colon 2 | 3 | The c:macro:`PY_VERSION_HEX` miss a colon. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/inline-literal-inside-role.rst: -------------------------------------------------------------------------------- 1 | :emphasis:`This ``Too Shall\`\` Pass` 2 | even ``followed`` by ``inline literals``. 3 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/this-is-just-a-title.rst: -------------------------------------------------------------------------------- 1 | Computation of Minimal Uncollected Subword 2 | `````````````````````````````````````````` 3 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-colon-1.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing opening tag colon 2 | 3 | The same format as the c:macro:`PY_VERSION_HEX` macro. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space-2.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before role 2 | 3 | Resume them using the:c:func:`PyThreadState_LeaveTracing` function. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before role 2 | 3 | Resume them using the:c:func:`PyThreadState_LeaveTracing` function. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/trailing-whitespaces.rst: -------------------------------------------------------------------------------- 1 | .. expect: trailing whitespace (trailing-whitespace) 2 | 3 | This line should have a trailing whitespace and a newline: 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-glued.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before default role 2 | 3 | Lines containing`'RESTART'` mean that the user execution process has been. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space-after-role.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing (escaped) space 2 | 3 | :ref:`"What's new in Python 3.12" `for more details. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/fixed-bad-directive.rst: -------------------------------------------------------------------------------- 1 | .. versionchanged:: 3.11 2 | Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm* 3 | is not a context manager. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/inline-internal-targets.rst: -------------------------------------------------------------------------------- 1 | Oh yes, the _`Norwegian Blue`. What's, um, what's wrong with it? 2 | 3 | 4 | Even when there's _`many`, _`of`, _`them`. 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | __pycache__/ 4 | *.egg-info/ 5 | .coverage 6 | .coverage.* 7 | .envrc 8 | .tox/ 9 | .venv/ 10 | tests/fixtures/friends/ 11 | coverage.xml 12 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/fixed-missing-space.rst: -------------------------------------------------------------------------------- 1 | Resume them using the :c:func:`PyThreadState_LeaveTracing` function. 2 | 3 | by the :c:func:`PyThreadState_EnterTracing` function. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/surrogate-space-inside-role.rst: -------------------------------------------------------------------------------- 1 | ``setuptools`` therefore provides two convenient tools to ease the 2 | burden: :literal:`find:\ ` and :literal:`find_namespace:\ `. 3 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-double-quoted.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | The only supported backend is `"perf"`. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-parenthesed.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | This is really a default role: (`foo`). 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/column-in-inline-literal.rst: -------------------------------------------------------------------------------- 1 | Columns may be found in inline-literals, like ``find_namespace:``. 2 | 3 | management protocol, so you can write ``with bz2.BZ2File(...) as f:``. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/multiple-inline-literals.rst: -------------------------------------------------------------------------------- 1 | ``_PyUnicode_Name_CAPI`` de l'API PyCapsule ``unicodedata.ucnhash_CAPI`` 2 | a été déplacée dans l'API C interne. Contribution par Victor Stinner. 3 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-with-double-backticks.rst: -------------------------------------------------------------------------------- 1 | .. expect: role use a single backtick, double backtick found. (role-with-double-backticks) 2 | 3 | :const:``None`` should be written :const:`None`. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-between-brackets.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | Even between brackets, this is a [`default role`] 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-can-end-with-backslash.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | This is a legitimate default role: `item`\ s. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/error-hidden-in-note.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used 2 | 3 | .. note:: 4 | This is a note, but it's still parsed as rst, so errors should be spotted. 5 | Like using a `default role`. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-without-backticks.rst: -------------------------------------------------------------------------------- 1 | .. expect: role with no backticks: ':func:pdb.main\n' (role-without-backticks) 2 | 3 | This role has no backticks at all: 4 | 5 | :func:pdb.main 6 | 7 | That's no good. 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-no-alpha.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | Assignment expressions using the walrus operator `:=` assign a variable. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-quote.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | Lines containing `'RESTART'` mean that the user execution process has been. 4 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/triple-dot-directive.rst: -------------------------------------------------------------------------------- 1 | .. expect: directive should start with two dots, not three. (directive-with-three-dots) 2 | 3 | ... versionchanged:: 3.11 4 | This directive has three dots instead of two. 5 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | 3 | [report] 4 | # Regexes for lines to exclude from consideration 5 | exclude_also = 6 | # Don't complain if non-runnable code isn't run: 7 | if __name__ == .__main__.: 8 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/colon-all-colon-in-inline-literal.rst: -------------------------------------------------------------------------------- 1 | This is a legit pip option: ``--only-binary :all:`` in an inline 2 | literal. 3 | 4 | Here's the long version: 5 | ``pip install --require-hashes --no-deps --only-binary :all:``. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/hyperlink-missing-space.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before < in hyperlink (missing-space-in-hyperlink) 2 | 3 | A hyperlink needs whitespace before the opening bracket. Wrong: 4 | 5 | `Link text`_ 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/trailing-double-backticks.rst: -------------------------------------------------------------------------------- 1 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 2 | 3 | The ``double backticks`` at the end of this line `` 4 | should clearly ``not be there``. 5 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/link-in-literal.rst: -------------------------------------------------------------------------------- 1 | According to reStructuredText parsing rules, it's OK to have backticks 2 | inside an inline literal like this: 3 | 4 | ```Section title`_`` 5 | 6 | (In this case it is a section link inside a literal.) 7 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-surrogate-space.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing (escaped) space after role: ':exc:`ExceptionGroup`s' (missing-space-after-role) 2 | 3 | Here the ``s`` should not be prefixed by backslask space: 4 | :exc:`ExceptionGroup`s. 5 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | Unless it has been set to something explicitly, the default role is 4 | `emphasis` and normally remains unused. 5 | -------------------------------------------------------------------------------- /sphinxlint/__init__.py: -------------------------------------------------------------------------------- 1 | """Sphinx linter.""" 2 | 3 | import importlib.metadata 4 | 5 | from sphinxlint.sphinxlint import check_file, check_text 6 | 7 | __version__ = importlib.metadata.version("sphinx_lint") 8 | 9 | __all__ = ["check_text", "check_file"] 10 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/bad-directive.rst: -------------------------------------------------------------------------------- 1 | .. expect: directive should start with two dots, not three. (directive-with-three-dots) 2 | 3 | ... versionchanged:: 3.11 4 | Raises :exc:`TypeError` instead of :exc:`AttributeError` if *cm* 5 | is not a context manager. 6 | -------------------------------------------------------------------------------- /.pre-commit-hooks.yaml: -------------------------------------------------------------------------------- 1 | - id: sphinx-lint 2 | name: Sphinx Lint 3 | description: 'Searches for common problems in Sphinx-flavored reST files' 4 | types: [rst] 5 | require_serial: true # we use multiprocessing internally anyway 6 | entry: sphinx-lint 7 | language: python 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-separated-by-commas.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 3 | 4 | This is not detected: `foo`, `bar`. 5 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/fixed-missing-column.rst: -------------------------------------------------------------------------------- 1 | 2 | This attribute is to match :attr:`importlib.machinery.ModuleSpec.parent` 3 | as stored in the :attr:`__spec__` object. 4 | 5 | the same format as the :c:macro:`PY_VERSION_HEX` macro. 6 | 7 | (Contributed by Omar Sandoval, :issue:`26273`). 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-inside-literal-role-missing-surrogate-escape.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing (escaped) space after role: ':literal:`:manpage 2 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 3 | 4 | The :literal:`:manpage:`man(1)``s roles... 5 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-hidden-in-function.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | .. c:function:: void PyThread_tss_free(Py_tss_t *key) 4 | 5 | Even if hidden in a function, this default-role should be spotted: 6 | `NULL`. 7 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-colon-2.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing opening tag colon 2 | 3 | .. and yes, it looks like a default role, so: 4 | .. expect: default role used 5 | 6 | New Linux constants ``TCP_USER_TIMEOUT`` and ``TCP_CONGESTION`` were added. 7 | (Contributed by Omar Sandoval, issue:`26273`). 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-colon.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing opening tag colon 2 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 3 | 4 | This attribute is to match :attr:`importlib.machinery.ModuleSpec.loader` 5 | as stored in the attr:`__spec__` object. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/dangling-hyphen.rst: -------------------------------------------------------------------------------- 1 | .. expect: Line ends with dangling hyphen (dangling-hyphen) 2 | 3 | Additionally, this PEP requires that the default class definition 4 | namespace be ordered (e.g. ``OrderedDict``) by default. The long- 5 | lived class namespace (``__dict__``) will remain a ``dict``. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/space-inside-inline-literal.rst: -------------------------------------------------------------------------------- 1 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 2 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 3 | 4 | That space make the intended inline literal not being one: `` __repr__``. 5 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-backtick-on-role.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing closing backtick: ':class:`foo but no other issues,\nand there is other lines too.\n' (missing-backtick-after-role) 2 | 3 | In this paragraph there is a missing 4 | backtick at the end of a role, 5 | :class:`foo but no other issues, 6 | and there is other lines too. 7 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-in-code-sample.rst: -------------------------------------------------------------------------------- 1 | Found in the pandas documentation, this is valid: 2 | 3 | * A pandas class (in the form ``:class:`pandas.Series```) 4 | * A pandas method (in the form ``:meth:`pandas.Series.sum```) 5 | * A pandas function (in the form ``:func:`pandas.to_datetime```) 6 | 7 | it's documenting roles using code samples (double backticks). 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/download-hyperlink-unnecessary-underscore.rst: -------------------------------------------------------------------------------- 1 | .. expect: unnecessary underscore after closing backtick in hyperlink 2 | 3 | Download directives should not have trailing underscores after hyperlinks. 4 | 5 | :download:`Download this file `_ 6 | 7 | This is wrong because download directives don't need trailing underscores. 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-right-role-colon.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing colon before first backtick 2 | 3 | .. It also looks like: 4 | .. expect: missing space before default role 5 | .. even it's not. 6 | 7 | 8 | Default: value of the ``PLATLIBDIR`` macro which is set by the 9 | :option`configure --with-platlibdir option <--with-platlibdir>` (default: 10 | ``"lib"``). 11 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/hyperlink-missing-backtick.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing backtick before hyperlink reference: 'Misc/NEWS `_'. (hyperlink-reference-missing-backtick) 2 | 3 | In the following line, the hyperlink reference misses its opening backtick 4 | Misc/NEWS `_ 5 | that's bad. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/hyperlink-missing-underscore.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing underscore after closing backtick in hyperlink 2 | And, as it looks a lot like a default role, it also triggers: 3 | .. expect: default role used 4 | 5 | 6 | A hyperlink is marked with an underscore after the closing 7 | backtick. Wrong (this is taken as a use of the default role): 8 | 9 | `Link text ` 10 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space-before-default-role.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before default role: "ing`'RESTART'`". (missing-space-before-default-role) 2 | 3 | Lines containing`'RESTART'` mean that the user execution process has been 4 | re-started. This occurs when the user execution process has crashed, 5 | when one requests a restart on the Shell menu, or when one runs code 6 | in an editor window. 7 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-in-inline-literal.rst: -------------------------------------------------------------------------------- 1 | Instead, these security concerns should be gathered into a dedicated 2 | "Security Considerations" section within the module's documentation, and 3 | cross-referenced from the documentation of affected interfaces with a note 4 | similar to ``"Please refer to the :ref:`security-considerations` section 5 | for important information on how to avoid common mistakes."``. 6 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/code-sample-glued-with-plural.rst: -------------------------------------------------------------------------------- 1 | .. expect: inline literal missing (escaped) space after literal: "``'a'``s" (missing-space-after-literal) 2 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 3 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 4 | 5 | All 4 ``'a'``s, but, when the final ``'a'`` is encountered, the 6 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/no_missing_space_after_literal.rst: -------------------------------------------------------------------------------- 1 | See: 2 | ``_. 3 | 4 | The version of Sphinx used to build represented as a tuple of five elements. 5 | For Sphinx version 3.5.1 beta 3 this would be ``(3, 5, 1, 'beta', 3)``. 6 | The fourth element can be one of: ``alpha``, ``beta``, ``rc``, ``final``. 7 | ``final`` always has 0 as the last element. 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/backslash-space.rst: -------------------------------------------------------------------------------- 1 | .. expect: inline literal missing (escaped) space after literal: '``Callable``s' (missing-space-after-literal) 2 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 3 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 4 | 5 | Here ``Callable``s the "s" should be separated from ``Callable`` using 6 | a "backslash-space". 7 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-inside-inline-literal.rst: -------------------------------------------------------------------------------- 1 | Alone: 2 | 3 | For these, use ``:obj:`~.acos()```. 4 | 5 | Or not alone: 6 | 7 | For these, use ``:obj:`~.acos()```. The ``~`` makes it so that the 8 | text in the rendered HTML only shows ``acos`` instead of the fully 9 | qualified name. 10 | 11 | Or even when there's two of them: 12 | 13 | The first one: ``:obj:`foo```, 14 | some noise: ``.`` 15 | and the second one: ``:obj:`foo```. 16 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/splitted-role-in-a-table.rst: -------------------------------------------------------------------------------- 1 | In the following table there's a role spanning in two lines, this 2 | should not raise any error: 3 | 4 | +-----------------------------------------+ 5 | | Method | 6 | +=========================================+ 7 | | :meth:`assertEqual(a, b) | 8 | | ` | 9 | +-----------------------------------------+ 10 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/hyperlink-missing-underscore-and-space.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before < in hyperlink 2 | .. expect: missing underscore after closing backtick in hyperlink 3 | 4 | .. and yes, it very looks like a default role, so we get this too: 5 | .. expect: default role used 6 | 7 | This combines two mistakes: missing an underscore at the end and 8 | forgetting the space before the opening bracket: 9 | 10 | `Link text` 11 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-backtick-after-role-after-table.rst: -------------------------------------------------------------------------------- 1 | .. expect: role missing closing backtick: ':role:`foo\n' (missing-backtick-after-role) 2 | 3 | +--------------+----------+-----------+-----------+ 4 | | row 1, col 1 | column 2 | column 3 | column 4 | 5 | +--------------+----------+-----------+-----------+ 6 | | :role:`bar | | | | 7 | +--------------+----------+-----------+-----------+ 8 | 9 | :role:`foo 10 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/multiline-links-in-table.rst: -------------------------------------------------------------------------------- 1 | Links and default roles that span multiple lines in tables should be ignored: 2 | 3 | +-----------------+-------------------------------+----------------------------+ 4 | | `French (fr) | Julien Palard (`@JulienPalard | `GitHub `_ | 5 | | `_ | on GitHub`) | | 6 | +-----------------+-------------------------------+----------------------------+ 7 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/hyperlinks-several.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing underscore after closing backtick in hyperlink (missing-underscore-after-hyperlink) 2 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 3 | .. expect: missing space before < in hyperlink (missing-space-in-hyperlink) 4 | 5 | Several malformed hyperlinks on the same line yield several errors. 6 | 7 | `Link text`_ `Link text 2 ` 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-in-c-type.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | 3 | .. c:type:: int (*PyCode_WatchCallback)(PyCodeEvent event, PyCodeObject* co) 4 | 5 | If *event* is ``PY_CODE_EVENT_CREATE``, then the callback is invoked 6 | after `co` has been fully initialized. Otherwise, the callback is invoked 7 | before the destruction of *co* takes place, so the prior state of *co* 8 | can be inspected. 9 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/multiline-role-in-table.rst: -------------------------------------------------------------------------------- 1 | Math in Table 2 | ============= 3 | 4 | +-----------+---------------+---------------+ 5 | | Shape | Area | Perimeter | 6 | +===========+===============+===============+ 7 | | Circle | :math:`\pi | :math:`2 \pi | 8 | | | r^2` | r` | 9 | +-----------+---------------+---------------+ 10 | | Square | :math:`w^2` | :math:`4w` | 11 | +-----------+---------------+---------------+ 12 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-with-exclamation-and-tilde.rst: -------------------------------------------------------------------------------- 1 | .. expect: Found a role with both `!` and `~` in ':meth:`!~list.pop`'. (exclamation-and-tilde) 2 | .. expect: Found a role with both `!` and `~` in ':meth:`~!list.pop`'. (exclamation-and-tilde) 3 | 4 | :meth:`!~list.pop` cannot be used to display ``pop()`` and avoid 5 | reference warnings; instead, it renders as ``~list.pop()``. 6 | We should instead write :meth:`!pop` to correctly display ``pop()``. 7 | :meth:`~!list.pop` doesn’t work either. 8 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/role-in-table.rst: -------------------------------------------------------------------------------- 1 | +-------------------------+-------------------------------+-----------+ 2 | | Attribute | Meaning | | 3 | +=========================+===============================+===========+ 4 | | :attr:`~object.__dict__`| The namespace supporting | Writable | 5 | | | arbitrary function | | 6 | | | attributes. | | 7 | +-------------------------+-------------------------------+-----------+ 8 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/default-role-in-tables.rst: -------------------------------------------------------------------------------- 1 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 2 | .. expect: default role used (hint: for inline literals, use double backticks) (default-role) 3 | 4 | In the following table there are a couple of default roles that should fail: 5 | 6 | +-----------------+-------------------------------+----------------------------+ 7 | | `French` | Julien Palard `@JulienPalard` | `GitHub `_ | 8 | +-----------------+-------------------------------+----------------------------+ 9 | -------------------------------------------------------------------------------- /tests/test_po2rst.py: -------------------------------------------------------------------------------- 1 | from sphinxlint.utils import po2rst 2 | 3 | 4 | def test_po2rst(): 5 | po = """msgid "foo" 6 | msgstr "bar" 7 | 8 | msgid "test1" 9 | msgstr "test2" 10 | """ 11 | rst = """bar 12 | 13 | 14 | test2 15 | """ 16 | assert po2rst(po) == rst 17 | 18 | 19 | def test_po2rst_more(): 20 | po = """msgid "foo" 21 | msgstr "bar" 22 | 23 | msgid "test1" 24 | msgstr "" 25 | "test2" 26 | 27 | msgid "test3" 28 | msgstr "test4" 29 | """ 30 | rst = """bar 31 | 32 | 33 | test2 34 | 35 | 36 | 37 | test4 38 | """ 39 | assert po2rst(po) == rst 40 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/superfluous-backtick-in-front-of-role.rst: -------------------------------------------------------------------------------- 1 | .. expect: 15: superfluous backtick in front of role (backtick-before-role) 2 | .. expect: 23: superfluous backtick in front of role (backtick-before-role) 3 | 4 | .. and as erroneous roles may greatly looks like default roles, sphinx-lint sees: 5 | .. expect: default role used 6 | .. expect: default role used 7 | 8 | 9 | Right: 10 | 11 | :c:func:`PyFrame_GetLocals` instead. 12 | 13 | Wrong: 14 | 15 | `:c:func:`PyFrame_GetLocals` instead. 16 | 17 | Right: 18 | 19 | :func:`max` instead. 20 | 21 | Wrong: 22 | 23 | `:func:`max` instead. 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | lint 6 | py{py3, 314, 313, 312, 311, 310} 7 | 8 | [testenv] 9 | extras = 10 | tests 11 | pass_env = 12 | FORCE_COLOR 13 | commands = 14 | {envpython} -m pytest \ 15 | --cov sphinxlint \ 16 | --cov tests \ 17 | --cov-report html \ 18 | --cov-report term \ 19 | --cov-report xml \ 20 | {posargs} 21 | 22 | [testenv:lint] 23 | skip_install = true 24 | deps = 25 | pre-commit 26 | pass_env = 27 | PRE_COMMIT_COLOR 28 | commands = 29 | pre-commit run --all-files --show-diff-on-failure 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | 8 | permissions: 9 | contents: read 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | # cancel all except push to main branch to have always full results 14 | cancel-in-progress: ${{ !(github.event_name == 'push' && github.ref == 'refs/heads/main') }} 15 | 16 | jobs: 17 | lint: 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.x" 25 | - uses: pre-commit/action@v3.0.1 26 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/bad-dedent-in-code-block.rst: -------------------------------------------------------------------------------- 1 | .. expect: Bad dedent in block (bad-dedent) 2 | 3 | Here's a single big code block, but the author intent was to write two:: 4 | 5 | >>> from enum import Enum 6 | >>> class Weekday(Enum): 7 | ... MONDAY = 1 8 | ... TUESDAY = 2 9 | ... WEDNESDAY = 3 10 | ... THURSDAY = 4 11 | ... FRIDAY = 5 12 | ... SATURDAY = 6 13 | ... SUNDAY = 7 14 | 15 | Here's the line starting with a space (so it's inside the code block instead of starting another):: 16 | 17 | >>> from enum import Enum 18 | >>> class Color(Enum): 19 | ... RED = 1 20 | ... GREEN = 2 21 | ... BLUE = 3 22 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/download-directive-with-url.rst: -------------------------------------------------------------------------------- 1 | Download directives should not require underscores after URLs. 2 | 3 | A basic download directive with https: 4 | :download:`Download file ` 5 | 6 | One with http: 7 | :download:`Get the archive ` 8 | 9 | An inline download: 10 | This line contains a :download:`link to a file `. 11 | 12 | Multiple download directives in a row: 13 | First :download:`Download this file ` and 14 | then :download:`this one ` something else 15 | 16 | These should not trigger missing-underscore-after-hyperlink errors. 17 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/missing-space-around-inline-literals.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before default role: 'ace``here``'. (missing-space-before-default-role) 2 | .. expect: inline literal missing (escaped) space after literal: '``space``h' (missing-space-after-literal) 3 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 4 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 5 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 6 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 7 | 8 | A missing space``here``. 9 | 10 | Another missing ``space``here. 11 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/role-with-extra-backtick.rst: -------------------------------------------------------------------------------- 1 | .. expect: Extra backtick in role: ':mod:`!cgi``' (role-with-extra-backtick) 2 | .. expect: Extra backtick in role: ':mod:``!cgitb`' (role-with-extra-backtick) 3 | .. expect: Extra backtick in role: ':func:`foo``' (role-with-extra-backtick) 4 | .. expect: Extra backtick in role: ':class:`MyClass``' (role-with-extra-backtick) 5 | .. expect: Extra backtick in role: ':meth:``method`' (role-with-extra-backtick) 6 | 7 | This file contains roles with extra backtick that should be detected. 8 | 9 | * :pep:`594`: Remove the :mod:`!cgi`` and :mod:``!cgitb` modules 10 | 11 | * :func:`foo`` should be :func:`foo` 12 | 13 | * :class:`MyClass`` is wrong 14 | 15 | * :meth:``method` should be fixed 16 | -------------------------------------------------------------------------------- /tests/fixtures/xfail/zero-width-no-break-space-before-inline-literal.rst: -------------------------------------------------------------------------------- 1 | .. expect: missing space before default role: 'e \ufeff``i would like to be an inline literal``'. (missing-space-before-default-role) 2 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 3 | .. expect: found an unbalanced inline literal markup. (unbalanced-inline-literals-delimiters) 4 | 5 | See https://github.com/spyder-ide/spyder-docs/pull/332 for context. 6 | 7 | Some words then a ZWNBSP here ``i would like to be an inline literal`` 8 | (but it is not). 9 | 10 | This inline literal will **not** be parsed by docutils because of the 11 | ZERO WIDTH NO-BREAK SPACE that you may don't see, place right before 12 | the opening double backticks. 13 | -------------------------------------------------------------------------------- /tests/test_default_role_re.py: -------------------------------------------------------------------------------- 1 | from sphinxlint import rst 2 | 3 | 4 | def test_shall_pass(): 5 | assert rst.INTERPRETED_TEXT_RE.search("foo `bar` baz")["inline_markup"] == "`bar`" 6 | assert rst.INTERPRETED_TEXT_RE.search("foo `bar`-baz")["inline_markup"] == "`bar`" 7 | 8 | 9 | def test_shall_not_pass(): 10 | assert not rst.INTERPRETED_TEXT_RE.search("foo `bar`baz") 11 | assert not rst.INTERPRETED_TEXT_RE.search("foo '`'bar'`' baz") 12 | assert not rst.INTERPRETED_TEXT_RE.search("``") 13 | assert not rst.INTERPRETED_TEXT_RE.search("2 * x a ** b (* BOM32_* ` `` _ __ |") 14 | assert not rst.INTERPRETED_TEXT_RE.search( 15 | """"`" '|' (`) [`] {`} <`> ‘`’ ‚`‘ ‘`‚ ’`’ ‚`’ “`” „`“ “`„ ”`” „`” »`« ›`‹ «`» »`» ›`›""" # noqa: E501 16 | ) 17 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/dangling-hyphen.rst: -------------------------------------------------------------------------------- 1 | Additionally, this PEP requires that the default class definition 2 | namespace be ordered (e.g. ``OrderedDict``) by default. The 3 | long-lived class namespace (``__dict__``) will remain a ``dict``. 4 | 5 | :: 6 | 7 | Traceback (most recent call last): 8 | File "", line 1, in -toplevel- 9 | d.pop() 10 | IndexError: pop from an empty deque 11 | 12 | .. doctest:: 13 | 14 | >>> setcontext(ExtendedContext) 15 | >>> Decimal(1) / Decimal(0) 16 | Decimal('Infinity') 17 | >>> getcontext().traps[DivisionByZero] = 1 18 | >>> Decimal(1) / Decimal(0) 19 | Traceback (most recent call last): 20 | File "", line 1, in -toplevel- 21 | Decimal(1) / Decimal(0) 22 | DivisionByZero: x / 0 23 | -------------------------------------------------------------------------------- /tests/test_xpass_friends.py: -------------------------------------------------------------------------------- 1 | """This test needs `download-more-tests.sh`. 2 | 3 | This is useful to avoid a sphinx-lint release to break many CIs. 4 | """ 5 | 6 | import shlex 7 | from pathlib import Path 8 | 9 | import pytest 10 | 11 | from sphinxlint.cli import main 12 | 13 | FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" 14 | 15 | 16 | @pytest.mark.parametrize( 17 | "file", 18 | [str(f) for f in (FIXTURE_DIR / "friends").iterdir() if f.name != ".DS_Store"] 19 | if (FIXTURE_DIR / "friends").is_dir() 20 | else [], 21 | ) 22 | def test_sphinxlint_friend_projects_shall_pass(file, capsys): 23 | flags = (Path(file) / "flags").read_text(encoding="UTF-8") 24 | has_errors = main(["sphinxlint.py", file] + shlex.split(flags)) 25 | out, err = capsys.readouterr() 26 | assert err == "" 27 | assert out == "No problems found.\n" 28 | assert not has_errors 29 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/inline-literals-in-tables.rst: -------------------------------------------------------------------------------- 1 | +-----------------------+-----------------------+-----------------------+ 2 | | **Autolev** | **SymPy** | **Notes** | 3 | +=======================+=======================+=======================+ 4 | | ``Constants C+`` | ``c = sm.symbols(‘c’, | Refer to SymPy | 5 | | | real=True, | :ref:`assumptions | 6 | | | nonnegative=True)`` | ` | 7 | | | | for more information. | 8 | +-----------------------+-----------------------+-----------------------+ 9 | | ``Constants a{2:4}`` | ``a2, a3, a4 = | | 10 | | | sm.symbols('a2 a3 a4',| | 11 | | | real=True)`` | | 12 | +-----------------------+-----------------------+-----------------------+ 13 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/rst-in-code-blocks.rst: -------------------------------------------------------------------------------- 1 | Presented with the :term:`expression` ``obj[x]``, the Python interpreter 2 | follows something like the following process to decide whether 3 | :meth:`~object.__getitem__` or :meth:`~object.__class_getitem__` should be 4 | called:: 5 | 6 | from inspect import isclass 7 | 8 | def subscribe(obj, x): 9 | """Return the result of the expression `obj[x]`""" 10 | 11 | class_of_obj = type(obj) 12 | 13 | # If the class of `obj` defines `__getitem__()`, 14 | # call `type(obj).__getitem__()` 15 | if hasattr(class_of_obj, '__getitem__'): 16 | return class_of_obj.__getitem__(obj, x) 17 | 18 | # Else, if `obj` is a class and defines `__class_getitem__()`, 19 | # call `obj.__class_getitem__()` 20 | elif isclass(obj) and hasattr(obj, '__class_getitem__'): 21 | return obj.__class_getitem__(x) 22 | 23 | # Else, raise an exception 24 | else: 25 | raise TypeError( 26 | f"'{class_of_obj.__name__}' object is not subscriptable" 27 | ) 28 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/multiline-inline-literal.rst: -------------------------------------------------------------------------------- 1 | :class:`subprocess.Popen` destructor now emits a :exc:`ResourceWarning` warning 2 | if the child process is still running. Use the context manager protocol (``with 3 | proc: ...``) or explicitly call the :meth:`~subprocess.Popen.wait` method to 4 | read the exit status of the child process. (Contributed by Victor Stinner in 5 | :issue:`26741`.) 6 | 7 | A new :data:`~html.entities.html5` dictionary that maps HTML5 named character 8 | references to the equivalent Unicode character(s) (e.g. ``html5['gt;'] == 9 | '>'``) has been added to the :mod:`html.entities` module. The dictionary is 10 | now also used by :class:`~html.parser.HTMLParser`. (Contributed by Ezio 11 | Melotti in :issue:`11113` and :issue:`15156`.) 12 | 13 | ``-X importtime`` to show how long each import takes. It shows module 14 | name, cumulative time (including nested imports) and self time (excluding 15 | nested imports). Note that its output may be broken in multi-threaded 16 | application. Typical usage is ``python3 -X importtime -c 'import 17 | asyncio'``. See also :envvar:`PYTHONPROFILEIMPORTTIME`. 18 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/long-inline-link.rst: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent rhoncus 2 | volutpat felis, eu egestas sapien tristique a. Vivamus scelerisque nunc nec arcu 3 | pharetra, sit amet feugiat lorem consequat. See this extremely long link here: 4 | `A Very Long Inline Link Example 5 | `_. 6 | 7 | Donec ultrices, nisi sit amet cursus pharetra, arcu lacus tincidunt ligula, eget 8 | fringilla ex turpis vel odio. Curabitur feugiat pretium lorem a fringilla. 9 | Suspendisse eget orci eu sem tincidunt auctor. 10 | 11 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent rhoncus 12 | volutpat felis, eu egestas sapien tristique a. Vivamus scelerisque nunc nec 13 | arcu pharetra, sit amet feugiat lorem consequat. See this extremely long 14 | link here: `A Very Long Inline Link Example 15 | `_. 16 | -------------------------------------------------------------------------------- /tests/fixtures/xpass/sections.rst: -------------------------------------------------------------------------------- 1 | The following are all valid section title adornment characters 2 | according to 3 | https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#sections. 4 | 5 | 6 | Section 7 | !!!!!!! 8 | 9 | 10 | Section 11 | """"""" 12 | 13 | 14 | Section 15 | ####### 16 | 17 | 18 | Section 19 | $$$$$$$ 20 | 21 | 22 | Section 23 | %%%%%%% 24 | 25 | 26 | Section 27 | &&&&&&& 28 | 29 | 30 | Section 31 | ''''''' 32 | 33 | 34 | Section 35 | ((((((( 36 | 37 | 38 | Section 39 | ))))))) 40 | 41 | 42 | Section 43 | ******* 44 | 45 | 46 | Section 47 | +++++++ 48 | 49 | 50 | Section 51 | ,,,,,,, 52 | 53 | 54 | Section 55 | ------- 56 | 57 | 58 | Section 59 | ....... 60 | 61 | 62 | Section 63 | /////// 64 | 65 | 66 | Section 67 | ::::::: 68 | 69 | 70 | Section 71 | ;;;;;;; 72 | 73 | 74 | Section 75 | <<<<<<< 76 | 77 | 78 | Section 79 | ======= 80 | 81 | 82 | Section 83 | >>>>>>> 84 | 85 | 86 | Section 87 | ??????? 88 | 89 | 90 | Section 91 | @@@@@@@ 92 | 93 | 94 | Section 95 | [[[[[[[ 96 | 97 | 98 | Section 99 | \\\\\\\ 100 | 101 | 102 | Section 103 | ]]]]]]] 104 | 105 | 106 | Section 107 | ^^^^^^^ 108 | 109 | 110 | Section 111 | _______ 112 | 113 | 114 | Section 115 | ``````` 116 | 117 | 118 | Section 119 | {{{{{{{ 120 | 121 | 122 | Section 123 | ||||||| 124 | 125 | 126 | Section 127 | }}}}}}} 128 | 129 | 130 | Section 131 | ~~~~~~~ 132 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.6.2 4 | hooks: 5 | - id: ruff 6 | args: ["--exit-non-zero-on-fix"] 7 | - id: ruff-format 8 | 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v6.0.0 11 | hooks: 12 | - id: check-case-conflict 13 | - id: check-merge-conflict 14 | - id: check-toml 15 | - id: check-yaml 16 | - id: debug-statements 17 | - id: end-of-file-fixer 18 | exclude: tests/fixtures/xfail/missing-newline-at-end-of-file.rst 19 | - id: trailing-whitespace 20 | exclude: tests/fixtures/xfail/trailing-whitespaces.rst 21 | 22 | - repo: https://github.com/python-jsonschema/check-jsonschema 23 | rev: 0.29.2 24 | hooks: 25 | - id: check-github-workflows 26 | 27 | - repo: https://github.com/rhysd/actionlint 28 | rev: v1.7.1 29 | hooks: 30 | - id: actionlint 31 | 32 | - repo: https://github.com/tox-dev/pyproject-fmt 33 | rev: v1.8.0 34 | hooks: 35 | - id: pyproject-fmt 36 | additional_dependencies: [tox] 37 | 38 | - repo: https://github.com/abravalheri/validate-pyproject 39 | rev: v0.24.1 40 | hooks: 41 | - id: validate-pyproject 42 | 43 | - repo: https://github.com/tox-dev/tox-ini-fmt 44 | rev: 1.3.1 45 | hooks: 46 | - id: tox-ini-fmt 47 | 48 | - repo: meta 49 | hooks: 50 | - id: check-hooks-apply 51 | - id: check-useless-excludes 52 | 53 | ci: 54 | autoupdate_schedule: quarterly 55 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | 4 | on: [push, pull_request, workflow_dispatch] 5 | 6 | env: 7 | FORCE_COLOR: 1 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | # cancel all except push to main branch to have always full results 12 | cancel-in-progress: ${{ !(github.event_name == 'push' && github.ref == 'refs/heads/main') }} 13 | 14 | jobs: 15 | test: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # when adding new versions, update the one used to test 21 | # friend projects below to the latest stable 22 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 23 | os: [ubuntu-latest, macos-latest, windows-latest] 24 | test-set: [base] 25 | include: 26 | - {python-version: "3.13", os: ubuntu-latest, test-set: friend-projects} 27 | - {python-version: "3.13", os: windows-latest, test-set: friend-projects} 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | allow-prereleases: true 37 | cache: pip 38 | cache-dependency-path: .github/workflows/tests.yml 39 | 40 | - name: Install dependencies 41 | run: | 42 | pip install -U pip 43 | pip install -U tox 44 | 45 | - name: Download more tests from friend projects 46 | if: matrix.test-set == 'friend-projects' 47 | run: sh download-more-tests.sh 48 | - name: Tox tests 49 | run: | 50 | tox -e py 51 | 52 | - name: Upload coverage 53 | uses: codecov/codecov-action@v4 54 | with: 55 | flags: ${{ matrix.os }} 56 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 57 | -------------------------------------------------------------------------------- /tests/fixtures/paragraphs.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | Paragraphs test file 3 | ==================== 4 | 5 | Introduction 6 | ============ 7 | 8 | This is a test rst used to check 9 | that the ``paragraphs`` function 10 | works properly and returns the correct 11 | line numbers. 12 | 13 | This issue was initially reported in 14 | https://github.com/sphinx-contrib/sphinx-lint/issues/32 15 | by Hugo. 16 | 17 | 18 | 19 | Bug 20 | === 21 | 22 | The function was bugged because 23 | it set the line number after one 24 | paragraph ended, instead of waiting 25 | for the beginning of the new one. 26 | 27 | 28 | This is why I'm putting several 29 | empty lines between paragraphs 30 | in this test file. 31 | 32 | 33 | I also wonder if the function should 34 | skip lines that contain only whitespace 35 | since they don't belong to the paragraph 36 | and the function might fail to parse 37 | files that use `\r\n` as newlines. 38 | 39 | 40 | 41 | 42 | Fix Description 43 | =============== 44 | 45 | This fix: 46 | * adds this test file 47 | * adds a test for the 48 | `paragraphs` function 49 | * adds a test to check 50 | the lno in error msgs 51 | * fixes the code of the 52 | `paragraphs` function 53 | 54 | 55 | 56 | lno check 57 | ========= 58 | 59 | This section has a few markup errors, 60 | to ensure that the checkers now 61 | return the correct line number. 62 | 63 | 64 | 65 | This ``markup``will raise an error at line 65! 66 | 67 | 68 | This error is on the third line 69 | of this very long paragraph 70 | i.e. at :line:``70``! 71 | 72 | 73 | Note that the errors report the exact 74 | line, not the first line of the paragraph 75 | so for example two errors like 76 | :foo`missing colon` and :blah`other` 77 | will both be reported at line 76 78 | and not at line 73! 79 | 80 | 81 | .. note: 82 | One of the tests in :file:`test_sphinxlint.py` 83 | relies on exact line numbers, so if you edit 84 | this section of the file you might break the test 85 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | release: 8 | types: 9 | - published 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | # Always build & lint package. 17 | build-package: 18 | name: Build & verify package 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - uses: hynek/build-and-inspect-python-package@v2 27 | 28 | # Upload to Test PyPI on every commit on main. 29 | release-test-pypi: 30 | name: Publish in-dev package to test.pypi.org 31 | if: | 32 | github.repository_owner == 'sphinx-contrib' 33 | && github.event_name == 'push' 34 | && github.ref == 'refs/heads/main' 35 | runs-on: ubuntu-latest 36 | needs: build-package 37 | 38 | permissions: 39 | id-token: write 40 | 41 | steps: 42 | - name: Download packages built by build-and-inspect-python-package 43 | uses: actions/download-artifact@v4 44 | with: 45 | name: Packages 46 | path: dist 47 | 48 | - name: Upload package to Test PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | with: 51 | repository-url: https://test.pypi.org/legacy/ 52 | 53 | # Upload to real PyPI on GitHub Releases. 54 | release-pypi: 55 | name: Publish released package to pypi.org 56 | if: | 57 | github.repository_owner == 'sphinx-contrib' 58 | && github.event.action == 'published' 59 | runs-on: ubuntu-latest 60 | needs: build-package 61 | 62 | permissions: 63 | id-token: write 64 | 65 | steps: 66 | - name: Download packages built by build-and-inspect-python-package 67 | uses: actions/download-artifact@v4 68 | with: 69 | name: Packages 70 | path: dist 71 | 72 | - name: Upload package to PyPI 73 | uses: pypa/gh-action-pypi-publish@release/v1 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "sphinx-lint" 10 | description = "Check for stylistic and formal issues in .rst and .py files included in the documentation." 11 | readme = "README.md" 12 | license = "PSF-2.0" 13 | license-files = [ "LICENSE" ] 14 | authors = [ 15 | { name = "Georg Brandl", email = "georg@python.org" }, 16 | { name = "Julien Palard", email = "julien@palard.fr" }, 17 | ] 18 | requires-python = ">=3.10" 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Intended Audience :: Developers", 22 | "Natural Language :: English", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Programming Language :: Python :: 3.14", 29 | "Topic :: Documentation :: Sphinx", 30 | ] 31 | dynamic = [ 32 | "version", 33 | ] 34 | dependencies = [ 35 | "polib", 36 | "regex", 37 | ] 38 | optional-dependencies.tests = [ 39 | "pytest", 40 | "pytest-cov", 41 | ] 42 | urls.Changelog = "https://github.com/sphinx-contrib/sphinx-lint/releases" 43 | urls.Repository = "https://github.com/sphinx-contrib/sphinx-lint" 44 | scripts.sphinx-lint = "sphinxlint.cli:main" 45 | 46 | [tool.hatch] 47 | version.source = "vcs" 48 | 49 | [tool.hatch.build.targets.wheel] 50 | packages = [ "sphinxlint" ] 51 | 52 | [tool.hatch.version.raw-options] 53 | local_scheme = "no-local-version" 54 | 55 | [tool.ruff] 56 | fix = true 57 | 58 | format.preview = true 59 | lint.select = [ 60 | "E", # pycodestyle errors 61 | "F", # pyflakes errors 62 | "I", # isort 63 | "PGH", # pygrep-hooks 64 | "RUF100", # unused noqa (yesqa) 65 | "UP", # pyupgrade 66 | "W", # pycodestyle warnings 67 | "YTT", # flake8-2020 68 | ] 69 | lint.extend-ignore = [ 70 | "E203", # Whitespace before ':' 71 | "E221", # Multiple spaces before operator 72 | "E226", # Missing whitespace around arithmetic operator 73 | "E241", # Multiple spaces after ',' 74 | "UP038", # makes code slower and more verbose 75 | ] 76 | 77 | [tool.pylint.variables] 78 | callbacks = [ "check_" ] 79 | -------------------------------------------------------------------------------- /download-more-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Helper script to generate more tests using repos from friends. 4 | # 5 | # Once downloaded they can be tested using pytest: 6 | # 7 | # python -m pytest 8 | # 9 | # It's possible to filter by project name like: 10 | # 11 | # python -m pytest -k devguide 12 | 13 | 14 | # Repos known to pass are listed below in the following format: 15 | # 16 | # URL DOC_FOLDER SPHINXLINT_FLAGS... 17 | # 18 | # (If the doc is at the root of the repo: use a dot as a folder name.) 19 | 20 | # Yes the following comment **is** the list of repos to download, you 21 | # can edit it. 22 | 23 | # https://github.com/jazzband/django-oauth-toolkit docs 24 | # https://github.com/neo4j/neo4j-python-driver docs 25 | # https://github.com/pandas-dev/pandas doc 26 | # https://github.com/python/peps . --disable=trailing-whitespace 27 | # https://github.com/python/cpython Doc --enable default-role 28 | # https://github.com/python/devguide/ . --enable default-role 29 | # https://github.com/spyder-ide/spyder-docs doc --enable all --disable line-too-long 30 | # https://github.com/sympy/sympy doc 31 | # https://github.com/sphinx-doc/sphinx doc --enable line-too-long --max-line-length 85 32 | # https://github.com/python/python-docs-fr . --enable all --disable line-too-long 33 | 34 | grep '^# https://' "$0" | 35 | while read -r _ repo directory flags 36 | do 37 | name="$(basename "$repo")" 38 | target="tests/fixtures/friends/$name" 39 | rm -fr "$target" 40 | if [ "$directory" = "." ] 41 | then 42 | git clone --depth 1 "$repo" "tests/fixtures/friends/$name" 43 | else 44 | git clone --depth 1 --sparse --filter=blob:none "$repo" "tests/fixtures/friends/$name" && 45 | ( 46 | cd "tests/fixtures/friends/$name" || exit 47 | rm * # Removes files at root of repo (READMEs, conftest.py, ...) 48 | git sparse-checkout init --cone 49 | git sparse-checkout set "$directory" 50 | ) 51 | fi 52 | printf "%s\n" "$flags" > "tests/fixtures/friends/$name/flags" 53 | done 54 | 55 | # Remove exceptions: 56 | 57 | rm -f tests/fixtures/friends/cpython/Doc/README.rst 58 | rm -fr tests/fixtures/friends/peps/pep_sphinx_extensions 59 | find tests/fixtures/friends/ '(' -name 'test_*.py' -o -name '*_test.py' ')' -delete 60 | -------------------------------------------------------------------------------- /sphinxlint/sphinxlint.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from dataclasses import dataclass 3 | from os.path import splitext 4 | 5 | from sphinxlint.utils import PER_FILE_CACHES, hide_non_rst_blocks, po2rst 6 | 7 | 8 | @dataclass(frozen=True) 9 | class LintError: 10 | """A linting error found by one of the checkers""" 11 | 12 | filename: str 13 | line_no: int 14 | msg: str 15 | checker_name: str 16 | 17 | def __str__(self): 18 | return f"{self.filename}:{self.line_no}: {self.msg} ({self.checker_name})" 19 | 20 | 21 | class CheckersOptions: 22 | """Configuration options for checkers.""" 23 | 24 | max_line_length = 80 25 | 26 | @classmethod 27 | def from_argparse(cls, namespace): 28 | options = cls() 29 | options.max_line_length = namespace.max_line_length 30 | return options 31 | 32 | 33 | def check_text(filename, text, checkers, options=None): 34 | if options is None: 35 | options = CheckersOptions() 36 | errors = [] 37 | ext = splitext(filename)[1] 38 | checkers = {checker for checker in checkers if ext in checker.suffixes} 39 | lines = tuple(text.splitlines(keepends=True)) 40 | if any(checker.rst_only for checker in checkers): 41 | lines_with_rst_only = hide_non_rst_blocks(lines) 42 | for check in checkers: 43 | if ext not in check.suffixes: 44 | continue 45 | for lno, msg in check( 46 | filename, lines_with_rst_only if check.rst_only else lines, options 47 | ): 48 | errors.append(LintError(filename, lno, msg, check.name)) 49 | return errors 50 | 51 | 52 | def check_file(filename, checkers, options: CheckersOptions = None): 53 | try: 54 | ext = splitext(filename)[1] 55 | if not any(ext in checker.suffixes for checker in checkers): 56 | return Counter() 57 | try: 58 | with open(filename, encoding="utf-8") as f: 59 | text = f.read() 60 | if filename.endswith(".po"): 61 | text = po2rst(text) 62 | except OSError as err: 63 | return [f"{filename}: cannot open: {err}"] 64 | except UnicodeDecodeError as err: 65 | return [f"{filename}: cannot decode as UTF-8: {err}"] 66 | return check_text(filename, text, checkers, options) 67 | finally: 68 | for memoized_function in PER_FILE_CACHES: 69 | memoized_function.cache_clear() 70 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 2 | -------------------------------------------- 3 | 4 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 5 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 6 | otherwise using this software ("Python") in source or binary form and 7 | its associated documentation. 8 | 9 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 10 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 11 | analyze, test, perform and/or display publicly, prepare derivative works, 12 | distribute, and otherwise use Python alone or in any derivative version, 13 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 14 | i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 15 | 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022 Python Software Foundation; 16 | All Rights Reserved" are retained in Python alone or in any derivative version 17 | prepared by Licensee. 18 | 19 | 3. In the event Licensee prepares a derivative work that is based on 20 | or incorporates Python or any part thereof, and wants to make 21 | the derivative work available to others as provided herein, then 22 | Licensee hereby agrees to include in any such work a brief summary of 23 | the changes made to Python. 24 | 25 | 4. PSF is making Python available to Licensee on an "AS IS" 26 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 27 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 28 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 29 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 30 | INFRINGE ANY THIRD PARTY RIGHTS. 31 | 32 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 33 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 34 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 35 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 36 | 37 | 6. This License Agreement will automatically terminate upon a material 38 | breach of its terms and conditions. 39 | 40 | 7. Nothing in this License Agreement shall be deemed to create any 41 | relationship of agency, partnership, or joint venture between PSF and 42 | Licensee. This License Agreement does not grant permission to use PSF 43 | trademarks or trade name in a trademark sense to endorse or promote 44 | products or services of Licensee, or any third party. 45 | 46 | 8. By copying, installing or otherwise using Python, Licensee 47 | agrees to be bound by the terms and conditions of this License 48 | Agreement. 49 | -------------------------------------------------------------------------------- /tests/test_enable_disable.py: -------------------------------------------------------------------------------- 1 | import re 2 | from random import choice 3 | 4 | from sphinxlint.cli import main 5 | 6 | CHECKER_LINE = re.compile(r"^\s*- ([^:]+):", flags=re.MULTILINE) 7 | 8 | 9 | def parse_checkers(text): 10 | """Given a --list output, returns a list of checkers names.""" 11 | return CHECKER_LINE.findall(text) 12 | 13 | 14 | def count_checkers(text): 15 | return len(parse_checkers(text)) 16 | 17 | 18 | def random_checker(text): 19 | return choice(parse_checkers(text)) 20 | 21 | 22 | def test_default(capsys): 23 | """Ensure that the output of `--list` includes at least 10 checkers.""" 24 | main(["sphinxlint", "--list"]) 25 | out, _err = capsys.readouterr() 26 | assert count_checkers(out) > 10 27 | 28 | 29 | def test_disable_all(capsys): 30 | """Checks that disabling all checks actually disables them all.""" 31 | main(["sphinxlint", "--disable", "all", "--list"]) 32 | out, _err = capsys.readouterr() 33 | assert out == "No checkers selected.\n" 34 | 35 | 36 | def test_enable_all(capsys): 37 | """Some checks are disabled by default, so enabling them all should 38 | give more checks than the default list.""" 39 | main(["sphinxlint", "--list"]) 40 | default_out, _err = capsys.readouterr() 41 | main(["sphinxlint", "--enable", "all", "--list"]) 42 | all_out, _err = capsys.readouterr() 43 | assert count_checkers(default_out) < count_checkers(all_out) 44 | 45 | 46 | def test_disable_one(capsys): 47 | """Disabling a single check from the default set (any of them) should 48 | give one check less than the default set.""" 49 | main(["sphinxlint", "--list"]) 50 | default_out, _err = capsys.readouterr() 51 | one_to_disable = random_checker(default_out) 52 | main(["sphinxlint", "--list", "--disable", one_to_disable]) 53 | disabled_out, _err = capsys.readouterr() 54 | assert count_checkers(default_out) - 1 == count_checkers(disabled_out) 55 | 56 | 57 | def test_enable_one(capsys): 58 | """Enabling a single check not enabled by default should give one 59 | check more than the default set.""" 60 | main(["sphinxlint", "--list"]) 61 | default_out, _err = capsys.readouterr() 62 | main(["sphinxlint", "--list", "--enable", "all"]) 63 | all_out, _err = capsys.readouterr() 64 | not_enabled_by_default = list( 65 | set(parse_checkers(all_out)) - set(parse_checkers(default_out)) 66 | ) 67 | one_to_enable = choice(not_enabled_by_default) 68 | main(["sphinxlint", "--list", "--enable", one_to_enable]) 69 | enabled_out, _err = capsys.readouterr() 70 | assert count_checkers(default_out) + 1 == count_checkers(enabled_out) 71 | -------------------------------------------------------------------------------- /sphinx-lint.1: -------------------------------------------------------------------------------- 1 | .\" Hey, EMACS: -*- nroff -*- 2 | .\" (C) Copyright 2025 Julien Palard 3 | .\" 4 | .TH sphinx-lint 1 "November 16 2025" 5 | .\" Please adjust this date whenever revising the manpage. 6 | .\" 7 | .\" Some roff macros, for reference: 8 | .\" .nh disable hyphenation 9 | .\" .hy enable hyphenation 10 | .\" .ad l left justify 11 | .\" .ad b justify to both left and right margins 12 | .\" .nf disable filling 13 | .\" .fi enable filling 14 | .\" .br insert line break 15 | .\" .sp insert n+1 empty lines 16 | .\" for manpage-specific macros, see man(7) 17 | .SH NAME 18 | sphinx-lint \- proofreads \fB.rst\fP files 19 | .SH SYNOPSIS 20 | .B sphinx-lint 21 | [-h] [-v] [-i IGNORE] [-d DISABLE] [-e ENABLE] [--list] 22 | [--max-line-length MAX_LINE_LENGTH] [-s SORT_BY] [-j N] [-V] [paths ...] 23 | .br 24 | .SH DESCRIPTION 25 | \fBsphinx-lint\fP searches for stylistic and formal issues in \fB.rst\fP 26 | and \fB.py\fP files. 27 | .PP 28 | If no paths are given, \fBsphinx-lint\fP searches files starting from the 29 | current working directory. 30 | .SH OPTIONS 31 | These programs follow the usual GNU command line syntax, with long 32 | options starting with two dashes ('\-'). 33 | A summary of options is included below. 34 | .TP 35 | .B \-h, \-\-help 36 | Show a summary of options. 37 | .TP 38 | .B \-v, \-\-verbose 39 | Print all checked file names and additional information. 40 | .TP 41 | .B -i, --ignore IGNORE 42 | Ignore the specified subdirectory or file path. 43 | .TP 44 | .B -d, --disable DISABLE 45 | Comma-separated list of checks to disable. Use \fIall\fP to disable all checks. 46 | Can be used in conjunction with \fB--enable\fP (evaluated left-to-right). 47 | For example, \fB--disable all --enable trailing-whitespace\fP enables a 48 | single check while disabling all others. 49 | .TP 50 | .B -e, --enable ENABLE 51 | Comma-separated list of checks to enable. Use \fIall\fP to enable all checks. 52 | Can be used in conjunction with \fB--disable\fP (evaluated left-to-right). 53 | For example, \fB--enable all --disable trailing-whitespace\fP enables all 54 | checks except the specified one. 55 | .TP 56 | .B --list 57 | List enabled checkers and exit. Useful to see which checkers would be 58 | used with a given set of \fB--enable\fP and \fB--disable\fP options. 59 | .TP 60 | .B --max-line-length MAX_LINE_LENGTH 61 | Maximum number of characters allowed on a single line. 62 | .TP 63 | .B -s, --sort-by SORT_BY 64 | Comma-separated list of fields used to sort errors. Available fields: 65 | \fIfilename\fP, \fIline\fP, \fIerror_type\fP. 66 | .TP 67 | .B -j, --jobs N 68 | Run in parallel with \fBN\fP processes. Defaults to \fIauto\fP, which sets 69 | \fBN\fP to the number of logical CPUs. Values less than one are treated as \fB1\fP. 70 | .TP 71 | .B -V, --version 72 | Show the program's version number and exit. 73 | .SH SEE ALSO 74 | .BR sphinx-build (1) 75 | -------------------------------------------------------------------------------- /tests/test_sphinxlint.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from sphinxlint.cli import main 6 | from sphinxlint.utils import paragraphs 7 | 8 | FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" 9 | 10 | 11 | @pytest.mark.parametrize("file", [str(f) for f in (FIXTURE_DIR / "xpass").iterdir()]) 12 | def test_sphinxlint_shall_pass(file, capsys): 13 | has_errors = main(["sphinxlint.py", "--enable", "all", str(file)]) 14 | out, err = capsys.readouterr() 15 | assert err == "" 16 | assert out == "No problems found.\n" 17 | assert not has_errors 18 | 19 | 20 | @pytest.mark.parametrize( 21 | "file", [str(f) for f in (FIXTURE_DIR / "triggers-false-positive").iterdir()] 22 | ) 23 | def test_sphinxlint_shall_trigger_false_positive(file, capsys): 24 | has_errors = main(["sphinxlint.py", str(file)]) 25 | out, err = capsys.readouterr() 26 | assert out == "No problems found.\n" 27 | assert err == "" 28 | assert not has_errors 29 | has_errors = main(["sphinxlint.py", "--enable", "all", str(file)]) 30 | out, err = capsys.readouterr() 31 | assert out != "No problems found.\n" 32 | assert err != "" 33 | assert has_errors 34 | 35 | 36 | def gather_xfail(): 37 | """Find all rst files in the fixtures/xfail directory. 38 | 39 | Each file is searched for lines containing expcted errors, they 40 | are starting with `.. expect: `. 41 | """ 42 | marker = ".. expect: " 43 | for file in (FIXTURE_DIR / "xfail").iterdir(): 44 | expected_errors = [] 45 | for line in Path(file).read_text(encoding="UTF-8").splitlines(): 46 | if line.startswith(marker): 47 | expected_errors.append(line[len(marker) :]) 48 | yield str(file), expected_errors 49 | 50 | 51 | @pytest.mark.parametrize("file,expected_errors", gather_xfail()) 52 | def test_sphinxlint_shall_not_pass(file, expected_errors, capsys): 53 | has_errors = main(["sphinxlint.py", "--enable", "all", file]) 54 | out, err = capsys.readouterr() 55 | assert out != "No problems found.\n" 56 | assert err != "" 57 | assert has_errors 58 | assert expected_errors, ( 59 | "That's not OK not to tell which errors are expected, " 60 | """add one using a ".. expect: " line.""" 61 | ) 62 | for expected_error in expected_errors: 63 | assert expected_error in err 64 | number_of_expected_errors = len(expected_errors) 65 | number_of_reported_errors = len(err.splitlines()) 66 | assert ( 67 | number_of_expected_errors == number_of_reported_errors 68 | ), f"{number_of_reported_errors=}, {err=}" 69 | 70 | 71 | @pytest.mark.parametrize("file", [str(FIXTURE_DIR / "paragraphs.rst")]) 72 | def test_paragraphs(file): 73 | with open(file) as f: 74 | lines = tuple(f.readlines()) 75 | actual = paragraphs(lines) 76 | for lno, para in actual: 77 | firstpline = para.splitlines(keepends=True)[0] 78 | # check that the first line of the paragraph matches the 79 | # corresponding line in the original file -- note that 80 | # `lines` is 0-indexed but paragraphs return 1-indexed values 81 | assert firstpline == lines[lno - 1] 82 | 83 | 84 | @pytest.mark.parametrize("file", [str(FIXTURE_DIR / "paragraphs.rst")]) 85 | def test_line_no_in_error_msg(file, capsys): 86 | has_errors = main(["sphinxlint.py", file]) 87 | out, err = capsys.readouterr() 88 | assert out == "" 89 | assert err.count("paragraphs.rst:76: role missing colon before") == 2 90 | assert "paragraphs.rst:70: role use a single backtick" in err 91 | assert "paragraphs.rst:65: inline literal missing (escaped) space" in err 92 | assert has_errors 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sphinx Lint 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/sphinx-lint) 4 | ![Monthly downloads](https://img.shields.io/pypi/dm/sphinx-lint) 5 | ![Supported Python Version](https://img.shields.io/pypi/pyversions/sphinx-lint.svg) 6 | ](https://pypi.org/project/sphinx-lint) 7 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/sphinx-contrib/sphinx-lint/tests.yml?branch=main)](https://github.com/sphinx-contrib/sphinx-lint/actions) 8 | 9 | Sphinx Lint is based on [rstlint.py from 10 | CPython](https://github.com/python/cpython/blob/e0433c1e7/Doc/tools/rstlint.py). 11 | 12 | 13 | ## What is Sphinx Lint, what is it not? 14 | 15 | Sphinx Lint should: 16 | 17 | - be reasonably fast so it's comfortable to use as a linter in your editor. 18 | - be usable on a single file. 19 | - not give any false positives (probably a utopia, but let's try). 20 | - not spend too much effort finding errors that sphinx-build already finds (or can easily find). 21 | - focus on finding errors that are **not** visible to sphinx-build. 22 | 23 | 24 | ## Using Sphinx Lint 25 | 26 | Here are some example invocations of Sphinx Lint from the command line: 27 | 28 | ```sh 29 | sphinx-lint # check all dirs and files 30 | sphinx-lint file.rst # check a single file 31 | sphinx-lint docs # check a directory 32 | sphinx-lint -i venv # ignore a file/directory 33 | sphinx-lint -h # for more options 34 | ``` 35 | 36 | Sphinx Lint can also be used via [pre-commit](https://pre-commit.com). 37 | We recommend using a configuration like this: 38 | 39 | ```yaml 40 | - repo: https://github.com/sphinx-contrib/sphinx-lint 41 | rev: LATEST_SPHINXLINT_RELEASE_TAG 42 | hooks: 43 | - id: sphinx-lint 44 | ``` 45 | 46 | 47 | ## Known issues 48 | 49 | Currently Sphinx Lint can't work with tables, there's no understanding 50 | of how `linesplit` works in tables, like: 51 | 52 | ```rst 53 | +-----------------------------------------+-----------------------------+---------------+ 54 | | Method | Checks that | New in | 55 | +=========================================+=============================+===============+ 56 | | :meth:`assertEqual(a, b) | ``a == b`` | | 57 | | ` | | | 58 | +-----------------------------------------+-----------------------------+---------------+ 59 | ``` 60 | 61 | as Sphinx Lint works line by line it will inevitably think the `:meth:` role is not closed properly. 62 | 63 | To avoid false positives, some rules are skipped if we're in a table. 64 | 65 | 66 | ## Contributing 67 | 68 | A quick way to test if some syntax is valid from a pure 69 | reStructuredText point of view, one case use `docutils`'s `pseudoxml` 70 | writer, like: 71 | 72 | ```text 73 | $ docutils --writer=pseudoxml tests/fixtures/xpass/role-in-code-sample.rst 74 | 75 | 76 | Found in the pandas documentation, this is valid: 77 | 78 | 79 | 80 | A pandas class (in the form 81 | 82 | :class:`pandas.Series` 83 | ) 84 | 85 | 86 | A pandas method (in the form 87 | 88 | :meth:`pandas.Series.sum` 89 | ) 90 | 91 | 92 | A pandas function (in the form 93 | 94 | :func:`pandas.to_datetime` 95 | ) 96 | 97 | it's documenting roles using code samples (double backticks). 98 | ``` 99 | 100 | 101 | ## Releasing 102 | 103 | 1. Make sure that the [CI tests pass](https://github.com/sphinx-contrib/sphinx-lint/actions) 104 | and optionally double-check locally with "friends projects" by running: 105 | 106 | sh download-more-tests.sh 107 | python -m pytest 108 | 2. Go on the [Releases page](https://github.com/sphinx-contrib/sphinx-lint/releases) 109 | 3. Click "Draft a new release" 110 | 4. Click "Choose a tag" 111 | 5. Type the next vX.Y.Z version and select "Create new tag: vX.Y.Z on publish" 112 | 6. Leave the "Release title" blank (it will be autofilled) 113 | 7. Click "Generate release notes" and amend as required 114 | 8. Click "Publish release" 115 | 9. Check the tagged 116 | [GitHub Actions build](https://github.com/sphinx-contrib/sphinx-lint/actions/workflows/deploy.yml) 117 | has [deployed to PyPI](https://pypi.org/project/sphinx-lint/#history) 118 | 119 | 120 | ## License 121 | 122 | As this script was in the CPython repository the license is the Python 123 | Software Foundation Licence Version 2, see the LICENSE file for a full 124 | version. 125 | -------------------------------------------------------------------------------- /sphinxlint/utils.py: -------------------------------------------------------------------------------- 1 | """Just a bunch of utility functions for sphinxlint.""" 2 | 3 | from functools import lru_cache 4 | 5 | import regex as re 6 | from polib import pofile 7 | 8 | from sphinxlint import rst 9 | 10 | PER_FILE_CACHES = [] 11 | 12 | 13 | def per_file_cache(func): 14 | memoized_func = lru_cache(maxsize=None)(func) 15 | PER_FILE_CACHES.append(memoized_func) 16 | return memoized_func 17 | 18 | 19 | def match_size(re_match): 20 | return re_match.end() - re_match.start() 21 | 22 | 23 | def _clean_heuristic(paragraph, regex): 24 | """Remove the regex from the paragraph. 25 | 26 | The remove starts by most "credible" ones (here lies the dragons). 27 | 28 | To remove `(.*)` from `(abc def ghi (jkl)`, a bad move consists of 29 | removing everything (eating a lone `(`), while the most credible 30 | action to take is to remove `(jkl)`, leaving a lone `(`. 31 | """ 32 | while True: 33 | candidate = min( 34 | regex.finditer(paragraph, overlapped=True), key=match_size, default=None 35 | ) 36 | if candidate is None: 37 | return paragraph 38 | paragraph = paragraph[: candidate.start()] + paragraph[candidate.end() :] 39 | 40 | 41 | @per_file_cache 42 | def clean_paragraph(paragraph): 43 | """Removes all good constructs, so detectors can focus on bad ones. 44 | 45 | It removes all well formed inline literals, inline internal 46 | targets, and roles. 47 | """ 48 | paragraph = escape2null(paragraph) 49 | paragraph = _clean_heuristic(paragraph, rst.INLINE_LITERAL_RE) 50 | paragraph = _clean_heuristic(paragraph, rst.INLINE_INTERNAL_TARGET_RE) 51 | paragraph = _clean_heuristic(paragraph, rst.HYPERLINK_REFERENCES_RE) 52 | paragraph = _clean_heuristic(paragraph, rst.ANONYMOUS_HYPERLINK_REFERENCES_RE) 53 | paragraph = rst.NORMAL_ROLE_RE.sub("", paragraph) 54 | return paragraph.replace("\x00", "\\") 55 | 56 | 57 | @per_file_cache 58 | def escape2null(text): 59 | r"""Return a string with escape-backslashes converted to nulls. 60 | 61 | It ease telling appart escaping-backslashes and normal backslashes 62 | in regex. 63 | 64 | For example : \\\\\\` is hard to match, even with the eyes, it's 65 | hard to know which backslash escapes which backslash, and it's 66 | very hard to know if the backtick is escaped. 67 | 68 | By replacing the escaping backslashes with another character they 69 | become easy to spot: 70 | 71 | 0\0\0\` 72 | 73 | (This example uses zeros for readability but the function actually 74 | uses null bytes, \x00.) 75 | 76 | So we easily see that the backtick is **not** escaped: it's 77 | preceded by a backslash, not an escaping backslash. 78 | """ 79 | parts = [] 80 | start = 0 81 | while True: 82 | found = text.find("\\", start) 83 | if found == -1: 84 | parts.append(text[start:]) 85 | return "".join(parts) 86 | parts.append(text[start:found]) 87 | parts.append("\x00" + text[found + 1 : found + 2]) 88 | start = found + 2 # skip character after escape 89 | 90 | 91 | @per_file_cache 92 | def paragraphs(lines): 93 | """Yield (paragraph_line_no, paragraph_text) pairs describing 94 | paragraphs of the given lines. 95 | """ 96 | output = [] 97 | paragraph = [] 98 | paragraph_lno = 1 99 | for lno, line in enumerate(lines, start=1): 100 | if line != "\n": 101 | if not paragraph: 102 | # save the lno of the first line of the para 103 | paragraph_lno = lno 104 | paragraph.append(line) 105 | elif paragraph: 106 | output.append((paragraph_lno, "".join(paragraph))) 107 | paragraph = [] 108 | if paragraph: 109 | output.append((paragraph_lno, "".join(paragraph))) 110 | return tuple(output) 111 | 112 | 113 | def looks_like_glued(match): 114 | """Tell appart glued tags and tags with a missing colon. 115 | 116 | In one case we can have: 117 | 118 | the:issue:`123`, it's clearly a missing space before the role tag. 119 | 120 | should return True in this case. 121 | 122 | In another case we can have: 123 | 124 | c:func:`foo`, it's a missing colon before the tag. 125 | 126 | should return False in this case. 127 | """ 128 | match_string = match.group(0) 129 | if match_string.count(":") == 1: 130 | # With a single : there's no choice, another : is missing. 131 | return False 132 | known_start_tag = {"c", "py"} 133 | if re.match(" *(" + "|".join(known_start_tag) + "):", match_string): 134 | # Before c:anything:` or py:anything:` we can bet it's a missing colon. 135 | return False 136 | # In other cases it's probably a glued word. 137 | return True 138 | 139 | 140 | _START_OF_COMMENT_BLOCK_RE = re.compile(r"^\s*\.\.$") 141 | _PRODUCTION_LIST_DIRECTIVE_RE = re.compile(r"^ *.. productionlist::") 142 | _COMMENT_RE = re.compile(r"^ *\.\. ") 143 | 144 | 145 | def is_multiline_non_rst_block(line): 146 | """Returns True if the next lines are an indented literal block.""" 147 | if _START_OF_COMMENT_BLOCK_RE.search(line): 148 | return True 149 | if rst.DIRECTIVES_CONTAINING_RST_RE.match(line): 150 | return False 151 | if rst.DIRECTIVES_CONTAINING_ARBITRARY_CONTENT_RE.match(line): 152 | return True 153 | if _PRODUCTION_LIST_DIRECTIVE_RE.search(line): 154 | return True 155 | if _COMMENT_RE.search(line) and type_of_explicit_markup(line) == "comment": 156 | return True 157 | if line.endswith("::\n"): # It's a literal block 158 | return True 159 | return False 160 | 161 | 162 | _ZERO_OR_MORE_SPACES_RE = re.compile(" *") 163 | 164 | 165 | def hide_non_rst_blocks(lines, hidden_block_cb=None): 166 | """Filters out literal, comments, code blocks, ... 167 | 168 | The filter actually replace "removed" lines by empty lines, so the 169 | line numbering still make sense. 170 | """ 171 | in_literal = None 172 | excluded_lines = [] 173 | block_line_start = None 174 | output = [] 175 | for lineno, line in enumerate(lines, start=1): 176 | if in_literal is not None: 177 | current_indentation = len(_ZERO_OR_MORE_SPACES_RE.match(line)[0]) 178 | if current_indentation > in_literal or line == "\n": 179 | excluded_lines.append(line if line == "\n" else line[in_literal:]) 180 | line = "\n" # Hiding line 181 | else: 182 | in_literal = None 183 | if hidden_block_cb: 184 | hidden_block_cb(block_line_start, "".join(excluded_lines)) 185 | excluded_lines = [] 186 | if in_literal is None and is_multiline_non_rst_block(line): 187 | in_literal = len(_ZERO_OR_MORE_SPACES_RE.match(line)[0]) 188 | block_line_start = lineno 189 | assert not excluded_lines 190 | if type_of_explicit_markup(line) == "comment" and _COMMENT_RE.search(line): 191 | line = "\n" 192 | output.append(line) 193 | if excluded_lines and hidden_block_cb: 194 | hidden_block_cb(block_line_start, "".join(excluded_lines)) 195 | return tuple(output) 196 | 197 | 198 | _starts_with_directive_marker = re.compile(rf"\.\. {rst.ALL_DIRECTIVES}::").match 199 | _starts_with_footnote_marker = re.compile(r"\.\. \[[0-9]+\] ").match 200 | _starts_with_citation_marker = re.compile(r"\.\. \[[^\]]+\] ").match 201 | _starts_with_target = re.compile(r"\.\. _.*[^_]: ").match 202 | _starts_with_substitution_definition = re.compile(r"\.\. \|[^\|]*\| ").match 203 | 204 | 205 | @per_file_cache 206 | def type_of_explicit_markup(line): 207 | """Tell apart various explicit markup blocks.""" 208 | line = line.lstrip() 209 | if _starts_with_directive_marker(line): 210 | return "directive" 211 | if _starts_with_footnote_marker(line): 212 | return "footnote" 213 | if _starts_with_citation_marker(line): 214 | return "citation" 215 | if _starts_with_target(line): 216 | return "target" 217 | if _starts_with_substitution_definition(line): 218 | return "substitution_definition" 219 | return "comment" 220 | 221 | 222 | def po2rst(text): 223 | """Extract msgstr entries from a po content, keeping linenos.""" 224 | output = [] 225 | po = pofile(text, encoding="UTF-8") 226 | for entry in po.translated_entries(): 227 | # Don't check original msgid, assume it's checked directly. 228 | while len(output) + 1 < entry.linenum: 229 | output.append("\n") 230 | for line in entry.msgstr.splitlines(): 231 | output.append(line + "\n") 232 | return "".join(output) 233 | -------------------------------------------------------------------------------- /sphinxlint/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import multiprocessing 4 | import os 5 | import sys 6 | from itertools import chain, starmap 7 | 8 | from sphinxlint import __version__, check_file 9 | from sphinxlint.checkers import all_checkers 10 | from sphinxlint.sphinxlint import CheckersOptions 11 | 12 | 13 | class SortField(enum.Enum): 14 | """Fields available for sorting error reports""" 15 | 16 | FILENAME = 0 17 | LINE = 1 18 | ERROR_TYPE = 2 19 | 20 | @staticmethod 21 | def as_supported_options(): 22 | return ",".join(field.name.lower() for field in SortField) 23 | 24 | 25 | def parse_args(argv=None): 26 | """Parse command line argument.""" 27 | if argv is None: 28 | argv = sys.argv 29 | parser = argparse.ArgumentParser(description=__doc__) 30 | parser.color = True 31 | 32 | enabled_checkers_names = { 33 | checker.name for checker in all_checkers.values() if checker.enabled 34 | } 35 | 36 | class EnableAction(argparse.Action): 37 | def __call__(self, parser, namespace, values, option_string=None): 38 | if values == "all": 39 | enabled_checkers_names.update(set(all_checkers.keys())) 40 | else: 41 | enabled_checkers_names.update(values.split(",")) 42 | 43 | class DisableAction(argparse.Action): 44 | def __call__(self, parser, namespace, values, option_string=None): 45 | if values == "all": 46 | enabled_checkers_names.clear() 47 | else: 48 | enabled_checkers_names.difference_update(values.split(",")) 49 | 50 | class StoreSortFieldAction(argparse.Action): 51 | def __call__(self, parser, namespace, values, option_string=None): 52 | sort_fields = [] 53 | for field_name in values.split(","): 54 | try: 55 | sort_fields.append(SortField[field_name.upper()]) 56 | except KeyError: 57 | raise ValueError( 58 | f"Unsupported sort field: {field_name}, " 59 | f"supported values are {SortField.as_supported_options()}" 60 | ) from None 61 | setattr(namespace, self.dest, sort_fields) 62 | 63 | class StoreNumJobsAction(argparse.Action): 64 | def __call__(self, parser, namespace, values, option_string=None): 65 | setattr(namespace, self.dest, self.job_count(values)) 66 | 67 | @staticmethod 68 | def job_count(values): 69 | if values == "auto": 70 | return os.cpu_count() 71 | return max(int(values), 1) 72 | 73 | parser.add_argument( 74 | "-v", 75 | "--verbose", 76 | action="store_true", 77 | help="verbose (print all checked file names)", 78 | ) 79 | parser.add_argument( 80 | "-i", 81 | "--ignore", 82 | action="append", 83 | help="ignore subdir or file path", 84 | default=[], 85 | ) 86 | parser.add_argument( 87 | "-d", 88 | "--disable", 89 | action=DisableAction, 90 | help="comma-separated list of checks to disable. " 91 | 'Give "all" to disable them all. ' 92 | "Can be used in conjunction with --enable (it's evaluated left-to-right). " 93 | '"--disable all --enable trailing-whitespace" can be used to enable a ' 94 | "single check.", 95 | ) 96 | parser.add_argument( 97 | "-e", 98 | "--enable", 99 | action=EnableAction, 100 | help='comma-separated list of checks to enable. Give "all" to enable them all. ' 101 | "Can be used in conjunction with --disable (it's evaluated left-to-right). " 102 | '"--enable all --disable trailing-whitespace" can be used to enable ' 103 | "all but one check.", 104 | ) 105 | parser.add_argument( 106 | "--list", 107 | action="store_true", 108 | help="List enabled checkers and exit. " 109 | "Can be used to see which checkers would be used with a given set of " 110 | "--enable and --disable options.", 111 | ) 112 | parser.add_argument( 113 | "--max-line-length", 114 | help="Maximum number of characters on a single line.", 115 | default=80, 116 | type=int, 117 | ) 118 | parser.add_argument( 119 | "-s", 120 | "--sort-by", 121 | action=StoreSortFieldAction, 122 | help="comma-separated list of fields used to sort errors by. Available " 123 | f"fields are: {SortField.as_supported_options()}", 124 | ) 125 | parser.add_argument( 126 | "-j", 127 | "--jobs", 128 | metavar="N", 129 | action=StoreNumJobsAction, 130 | help="Run in parallel with N processes. Defaults to 'auto', " 131 | "which sets N to the number of logical CPUs. " 132 | "Values <= 1 are all considered 1.", 133 | default=StoreNumJobsAction.job_count("auto"), 134 | ) 135 | parser.add_argument( 136 | "-V", "--version", action="version", version=f"%(prog)s {__version__}" 137 | ) 138 | 139 | parser.add_argument("paths", default=".", nargs="*") 140 | args = parser.parse_args(argv[1:]) 141 | try: 142 | enabled_checkers = {all_checkers[name] for name in enabled_checkers_names} 143 | except KeyError as err: 144 | print(f"Unknown checker: {err.args[0]}.", file=sys.stderr) 145 | sys.exit(2) 146 | return enabled_checkers, args 147 | 148 | 149 | def walk(path, ignore_list): 150 | """Wrapper around os.walk with an ignore list. 151 | 152 | It also allows giving a file, thus yielding just that file. 153 | """ 154 | if os.path.isfile(path): 155 | if path in ignore_list: 156 | return 157 | yield path if path[:2] != "./" else path[2:] 158 | return 159 | for root, dirs, files in os.walk(path): 160 | # ignore subdirs in ignore list 161 | if any(ignore in root for ignore in ignore_list): 162 | del dirs[:] 163 | continue 164 | for file in files: 165 | file = os.path.join(root, file) 166 | # ignore files in ignore list 167 | if any(ignore in file for ignore in ignore_list): 168 | continue 169 | yield file if file[:2] != "./" else file[2:] 170 | 171 | 172 | def _check_file(todo): 173 | """Wrapper to call check_file with arguments given by 174 | multiprocessing.imap_unordered.""" 175 | return check_file(*todo) 176 | 177 | 178 | def sort_errors(results, sorted_by): 179 | """Flattens and potentially sorts errors based on user prefernces""" 180 | if not sorted_by: 181 | for results in results: 182 | yield from results 183 | return 184 | errors = list(error for errors in results for error in errors) 185 | # sorting is stable in python, so we can sort in reverse order to get the 186 | # ordering specified by the user 187 | for sort_field in reversed(sorted_by): 188 | if sort_field == SortField.ERROR_TYPE: 189 | errors.sort(key=lambda error: error.checker_name) 190 | elif sort_field == SortField.FILENAME: 191 | errors.sort(key=lambda error: error.filename) 192 | elif sort_field == SortField.LINE: 193 | errors.sort(key=lambda error: error.line_no) 194 | yield from errors 195 | 196 | 197 | def print_errors(errors): 198 | """Print errors (or a message if nothing is to be printed).""" 199 | qty = 0 200 | for error in errors: 201 | print(error, file=sys.stderr) 202 | qty += 1 203 | if qty == 0: 204 | print("No problems found.") 205 | return qty 206 | 207 | 208 | def main(argv=None): 209 | enabled_checkers, args = parse_args(argv) 210 | options = CheckersOptions.from_argparse(args) 211 | if args.list: 212 | if not enabled_checkers: 213 | print("No checkers selected.") 214 | return 0 215 | print(f"{len(enabled_checkers)} checkers selected:") 216 | for check in sorted(enabled_checkers, key=lambda fct: fct.name): 217 | if args.verbose: 218 | print(f"- {check.name}: {check.__doc__}") 219 | else: 220 | print(f"- {check.name}: {check.__doc__.splitlines()[0]}") 221 | if not args.verbose: 222 | print("\n(Use `--list --verbose` to know more about each check)") 223 | return 0 224 | 225 | for path in args.paths: 226 | if not os.path.exists(path): 227 | print(f"Error: path {path} does not exist", file=sys.stderr) 228 | return 2 229 | 230 | todo = [ 231 | (path, enabled_checkers, options) 232 | for path in chain.from_iterable(walk(path, args.ignore) for path in args.paths) 233 | ] 234 | 235 | if args.jobs == 1 or len(todo) < 8: 236 | count = print_errors(sort_errors(starmap(check_file, todo), args.sort_by)) 237 | else: 238 | with multiprocessing.Pool(processes=args.jobs) as pool: 239 | count = print_errors( 240 | sort_errors(pool.imap_unordered(_check_file, todo), args.sort_by) 241 | ) 242 | pool.close() 243 | pool.join() 244 | 245 | return int(bool(count)) 246 | -------------------------------------------------------------------------------- /tests/test_filter_out_literal.py: -------------------------------------------------------------------------------- 1 | from sphinxlint.utils import hide_non_rst_blocks 2 | 3 | LITERAL = r""" 4 | Hide non-RST Blocks 5 | =================== 6 | 7 | This function is intended to filter out literal blocks like this one:: 8 | 9 | def enumerate(sequence, start=0): 10 | n = start 11 | for elem in sequence: 12 | yield n, elem 13 | n += 1 14 | 15 | But even if already indented it should work, see the next example. 16 | 17 | .. function:: hide_non_rst_blocks(stream) 18 | 19 | This is an indented block, which itself contains a literal, see:: 20 | 21 | >>> float('+1.23') 22 | 1.23 23 | 24 | >>> float(' -12345') 25 | -12345.0 26 | 27 | Yet this line should not be dropped. 28 | 29 | This one neither. 30 | 31 | .. doctest:: 32 | 33 | >>> # This should be dropped 34 | >>> setcontext(ExtendedContext) 35 | """ 36 | 37 | 38 | LITERAL_EXPECTED = r""" 39 | Hide non-RST Blocks 40 | =================== 41 | 42 | This function is intended to filter out literal blocks like this one:: 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | But even if already indented it should work, see the next example. 51 | 52 | .. function:: hide_non_rst_blocks(stream) 53 | 54 | This is an indented block, which itself contains a literal, see:: 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Yet this line should not be dropped. 63 | 64 | This one neither. 65 | 66 | .. doctest:: 67 | 68 | 69 | 70 | """ 71 | 72 | 73 | def test_filter_out_literal(): 74 | out = [] 75 | excluded = [] 76 | for line in hide_non_rst_blocks( 77 | LITERAL.splitlines(True), 78 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 79 | ): 80 | out.append(line) 81 | assert "".join(out) == LITERAL_EXPECTED 82 | assert ( 83 | excluded[0][1] 84 | == """ 85 | def enumerate(sequence, start=0): 86 | n = start 87 | for elem in sequence: 88 | yield n, elem 89 | n += 1 90 | 91 | """ 92 | ) 93 | assert ( 94 | excluded[1][1] 95 | == """ 96 | >>> float('+1.23') 97 | 1.23 98 | 99 | >>> float(' -12345') 100 | -12345.0 101 | 102 | """ 103 | ) 104 | 105 | 106 | LITERAL_FUNNY_INDENT = r""" 107 | Hide non-RST Blocks 108 | =================== 109 | 110 | The case the indentation start high, still flies without really 111 | returning, it should still be skipped:: 112 | 113 | Like we start at 4... 114 | 115 | Does not mean we'll keep at 4... 116 | 117 | Maybe we get down at 1 118 | ====================== 119 | 120 | Because why not, at long as we don't get back to the indentation of 121 | the initial line with the `::`. 122 | 123 | But now we're really back out of the block. 124 | """ 125 | 126 | 127 | LITERAL_FUNNY_INDENT_EXPECTED = r""" 128 | Hide non-RST Blocks 129 | =================== 130 | 131 | The case the indentation start high, still flies without really 132 | returning, it should still be skipped:: 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | But now we're really back out of the block. 145 | """ 146 | 147 | 148 | def test_filter_out_funny_indent(): 149 | out = [] 150 | excluded = [] 151 | for line in hide_non_rst_blocks( 152 | LITERAL_FUNNY_INDENT.splitlines(True), 153 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 154 | ): 155 | out.append(line) 156 | assert "".join(out) == LITERAL_FUNNY_INDENT_EXPECTED 157 | assert ( 158 | excluded[0][1] 159 | == """ 160 | Like we start at 4... 161 | 162 | Does not mean we'll keep at 4... 163 | 164 | Maybe we get down at 1 165 | ====================== 166 | 167 | Because why not, at long as we don't get back to the indentation of 168 | the initial line with the `::`. 169 | 170 | """ 171 | ) 172 | 173 | 174 | CODE_BLOCK = """ 175 | The code blocks should also be removed, like: 176 | 177 | .. code-block:: shell-session 178 | 179 | $ cat multiple_line_file 180 | Even if there's empty lines 181 | 182 | in the code block. 183 | 184 | But not this one. 185 | """ 186 | CODE_BLOCK_EXPECTED = """ 187 | The code blocks should also be removed, like: 188 | 189 | .. code-block:: shell-session 190 | 191 | 192 | 193 | 194 | 195 | 196 | But not this one. 197 | """ 198 | 199 | 200 | def test_filter_out_code_block(): 201 | out = [] 202 | excluded = [] 203 | for line in hide_non_rst_blocks( 204 | CODE_BLOCK.splitlines(True), 205 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 206 | ): 207 | out.append(line) 208 | assert "".join(out) == CODE_BLOCK_EXPECTED 209 | assert ( 210 | excluded[0][1] 211 | == """ 212 | $ cat multiple_line_file 213 | Even if there's empty lines 214 | 215 | in the code block. 216 | 217 | """ 218 | ) 219 | 220 | 221 | PRODUCTIONLIST_BLOCK = """ 222 | The grammar for a replacement field is as follows: 223 | 224 | .. productionlist:: format-string 225 | replacement_field: "{" [`field_name`] ["!" `conversion`] [":" `format_spec`] "}" 226 | field_name: arg_name ("." `attribute_name` | "[" `element_index` "]")* 227 | arg_name: [`identifier` | `digit`+] 228 | attribute_name: `identifier` 229 | element_index: `digit`+ | `index_string` 230 | index_string: + 231 | conversion: "r" | "s" | "a" 232 | format_spec: 233 | 234 | In less formal terms, the replacement field can start with a *field_name* that specifies 235 | the object whose value is to be formatted and inserted 236 | into the output instead of the replacement field. 237 | """ 238 | PRODUCTIONLIST_BLOCK_EXPECTED = """ 239 | The grammar for a replacement field is as follows: 240 | 241 | .. productionlist:: format-string 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | In less formal terms, the replacement field can start with a *field_name* that specifies 252 | the object whose value is to be formatted and inserted 253 | into the output instead of the replacement field. 254 | """ 255 | 256 | 257 | def test_filter_out_production_list(): 258 | out = [] 259 | for line in hide_non_rst_blocks(PRODUCTIONLIST_BLOCK.splitlines(True)): 260 | out.append(line) 261 | assert "".join(out) == PRODUCTIONLIST_BLOCK_EXPECTED 262 | 263 | 264 | KEEP_THAT = """ 265 | The simpler part of :pep:`328` was implemented in Python 2.4: parentheses could now 266 | be used to enclose the names imported from a module using the ``from ... import 267 | ...`` statement, making it easier to import many different names. 268 | """ 269 | 270 | KEEP_THAT_EXPECTED = """ 271 | The simpler part of :pep:`328` was implemented in Python 2.4: parentheses could now 272 | be used to enclose the names imported from a module using the ``from ... import 273 | ...`` statement, making it easier to import many different names. 274 | """ 275 | 276 | 277 | def test_filter_out_dont_filter_out_unwanted_things(): 278 | out = [] 279 | for line in hide_non_rst_blocks(KEEP_THAT.splitlines(True)): 280 | out.append(line) 281 | assert "".join(out) == KEEP_THAT_EXPECTED 282 | 283 | 284 | CONSECUTIVE_PRODUCTION_LIST = """ 285 | .. productionlist:: python-grammar 286 | del_stmt: "del" `target_list` 287 | 288 | .. productionlist:: python-grammar 289 | del_stmt: "del" `target_list` 290 | """ 291 | 292 | CONSECUTIVE_PRODUCTION_LIST_EXPECTED = """ 293 | .. productionlist:: python-grammar 294 | 295 | 296 | .. productionlist:: python-grammar 297 | 298 | """ 299 | 300 | 301 | def test_consecutive_production_list(): 302 | out = [] 303 | for line in hide_non_rst_blocks(CONSECUTIVE_PRODUCTION_LIST.splitlines(True)): 304 | out.append(line) 305 | assert "".join(out) == CONSECUTIVE_PRODUCTION_LIST_EXPECTED 306 | 307 | 308 | ATTENTION = """ 309 | This is a test for an attention admonition. 310 | 311 | .. attention:: 312 | An admonition can contain RST so it should **NOT** be dropped. 313 | 314 | and that's it. 315 | """ 316 | 317 | 318 | def test_filter_out_attention(): 319 | out = [] 320 | excluded = [] 321 | for line in hide_non_rst_blocks( 322 | ATTENTION.splitlines(True), 323 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 324 | ): 325 | out.append(line) 326 | assert "".join(out) == ATTENTION 327 | assert not excluded 328 | 329 | 330 | NOTE = """ 331 | This is a note, it contains rst, so it should **not** be dropped: 332 | 333 | .. note:: 334 | 335 | hello I am a not **I can** contain rst. 336 | 337 | End of it. 338 | """ 339 | 340 | 341 | def test_filter_out_note(): 342 | out = [] 343 | excluded = [] 344 | for line in hide_non_rst_blocks( 345 | NOTE.splitlines(True), 346 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 347 | ): 348 | out.append(line) 349 | assert "".join(out) == NOTE 350 | assert not excluded 351 | 352 | 353 | UNKNOWN = """ 354 | This is an unknown directive, to avoid false positives, just drop its content. 355 | 356 | .. this_is_not_a_known_directive:: 357 | 358 | So this can contain rst, or arbitary text. 359 | 360 | In the face of ambiguity, refuse the temptation to guess. 361 | """ 362 | 363 | UNKNOWN_EXPECTED = """ 364 | This is an unknown directive, to avoid false positives, just drop its content. 365 | 366 | 367 | 368 | 369 | 370 | In the face of ambiguity, refuse the temptation to guess. 371 | """ 372 | 373 | 374 | def test_filter_out_unknown(): 375 | out = [] 376 | excluded = [] 377 | for line in hide_non_rst_blocks( 378 | UNKNOWN.splitlines(True), 379 | hidden_block_cb=lambda lineno, block: excluded.append((lineno, block)), 380 | ): 381 | out.append(line) 382 | assert "".join(out) == UNKNOWN_EXPECTED 383 | -------------------------------------------------------------------------------- /sphinxlint/rst.py: -------------------------------------------------------------------------------- 1 | """Constants, regexes, and function generating regexes to "parse" reStructuredText. 2 | 3 | In this file: 4 | - All constants are ALL_CAPS 5 | - All compiled regexes are suffixed by _RE 6 | """ 7 | 8 | from functools import cache 9 | 10 | import regex as re 11 | 12 | DELIMITERS = ( 13 | "\\-/:\u058a\xa1\xb7\xbf\u037e\u0387\u055a-\u055f\u0589" 14 | "\u05be\u05c0\u05c3\u05c6\u05f3\u05f4\u0609\u060a\u060c" 15 | "\u060d\u061b\u061e\u061f\u066a-\u066d\u06d4\u0700-\u070d" 16 | "\u07f7-\u07f9\u0830-\u083e\u0964\u0965\u0970\u0df4\u0e4f" 17 | "\u0e5a\u0e5b\u0f04-\u0f12\u0f85\u0fd0-\u0fd4\u104a-\u104f" 18 | "\u10fb\u1361-\u1368\u1400\u166d\u166e\u16eb-\u16ed\u1735" 19 | "\u1736\u17d4-\u17d6\u17d8-\u17da\u1800-\u180a\u1944\u1945" 20 | "\u19de\u19df\u1a1e\u1a1f\u1aa0-\u1aa6\u1aa8-\u1aad\u1b5a-" 21 | "\u1b60\u1c3b-\u1c3f\u1c7e\u1c7f\u1cd3\u2010-\u2017\u2020-" 22 | "\u2027\u2030-\u2038\u203b-\u203e\u2041-\u2043\u2047-" 23 | "\u2051\u2053\u2055-\u205e\u2cf9-\u2cfc\u2cfe\u2cff\u2e00" 24 | "\u2e01\u2e06-\u2e08\u2e0b\u2e0e-\u2e1b\u2e1e\u2e1f\u2e2a-" 25 | "\u2e2e\u2e30\u2e31\u3001-\u3003\u301c\u3030\u303d\u30a0" 26 | "\u30fb\ua4fe\ua4ff\ua60d-\ua60f\ua673\ua67e\ua6f2-\ua6f7" 27 | "\ua874-\ua877\ua8ce\ua8cf\ua8f8-\ua8fa\ua92e\ua92f\ua95f" 28 | "\ua9c1-\ua9cd\ua9de\ua9df\uaa5c-\uaa5f\uaade\uaadf\uabeb" 29 | "\ufe10-\ufe16\ufe19\ufe30-\ufe32\ufe45\ufe46\ufe49-\ufe4c" 30 | "\ufe50-\ufe52\ufe54-\ufe58\ufe5f-\ufe61\ufe63\ufe68\ufe6a" 31 | "\ufe6b\uff01-\uff03\uff05-\uff07\uff0a\uff0c-\uff0f\uff1a" 32 | "\uff1b\uff1f\uff20\uff3c\uff61\uff64\uff65" 33 | ) 34 | 35 | CLOSERS = ( 36 | "\"')>\\]}\u0f3b\u0f3d\u169c\u2046\u207e\u208e\u232a\u2769" 37 | "\u276b\u276d\u276f\u2771\u2773\u2775\u27c6\u27e7\u27e9\u27eb" 38 | "\u27ed\u27ef\u2984\u2986\u2988\u298a\u298c\u298e\u2990\u2992" 39 | "\u2994\u2996\u2998\u29d9\u29db\u29fd\u2e23\u2e25\u2e27\u2e29" 40 | "\u3009\u300b\u300d\u300f\u3011\u3015\u3017\u3019\u301b\u301e" 41 | "\u301f\ufd3f\ufe18\ufe36\ufe38\ufe3a\ufe3c\ufe3e\ufe40\ufe42" 42 | "\ufe44\ufe48\ufe5a\ufe5c\ufe5e\uff09\uff3d\uff5d\uff60\uff63" 43 | "\xbb\u2019\u201d\u203a\u2e03\u2e05\u2e0a\u2e0d\u2e1d\u2e21" 44 | "\u201b\u201f\xab\u2018\u201c\u2039\u2e02\u2e04\u2e09\u2e0c" 45 | "\u2e1c\u2e20\u201a\u201e" 46 | ) 47 | 48 | OPENERS = ( 49 | "\"'(<\\[{\u0f3a\u0f3c\u169b\u2045\u207d\u208d\u2329\u2768" 50 | "\u276a\u276c\u276e\u2770\u2772\u2774\u27c5\u27e6\u27e8\u27ea" 51 | "\u27ec\u27ee\u2983\u2985\u2987\u2989\u298b\u298d\u298f\u2991" 52 | "\u2993\u2995\u2997\u29d8\u29da\u29fc\u2e22\u2e24\u2e26\u2e28" 53 | "\u3008\u300a\u300c\u300e\u3010\u3014\u3016\u3018\u301a\u301d" 54 | "\u301d\ufd3e\ufe17\ufe35\ufe37\ufe39\ufe3b\ufe3d\ufe3f\ufe41" 55 | "\ufe43\ufe47\ufe59\ufe5b\ufe5d\uff08\uff3b\uff5b\uff5f\uff62" 56 | "\xab\u2018\u201c\u2039\u2e02\u2e04\u2e09\u2e0c\u2e1c\u2e20" 57 | "\u201a\u201e\xbb\u2019\u201d\u203a\u2e03\u2e05\u2e0a\u2e0d" 58 | "\u2e1d\u2e21\u201b\u201f" 59 | ) 60 | 61 | # fmt: off 62 | DIRECTIVES_CONTAINING_RST = [ 63 | # standard docutils ones 64 | 'admonition', 'attention', 65 | 'c:data', 'c:enum', 'c:enumerator', 'c:func', 'c:macro', 'c:member', 66 | 'c:struct', 'c:type', 'c:union', 'c:var', 67 | 'caution', 'class', 'compound', 'container', 68 | 'danger', 'epigraph', 'error', 'figure', 'footer', 'header', 'highlights', 69 | 'hint', 'image', 'important', 'include', 'line-block', 'list-table', 'meta', 70 | 'note', 'parsed-literal', 'pull-quote', 'replace', 'sidebar', 'tip', 'topic', 71 | 'warning', 72 | # Sphinx and Python docs custom ones 73 | 'acks', 'attribute', 'autoattribute', 'autoclass', 'autodata', 74 | 'autoexception', 'autofunction', 'automethod', 'automodule', 75 | 'availability', 'centered', 'cfunction', 'class', 'classmethod', 'cmacro', 76 | 'cmdoption', 'cmember', 'confval', 'cssclass', 'ctype', 77 | 'currentmodule', 'cvar', 'data', 'decorator', 'decoratormethod', 78 | 'deprecated-removed', 'deprecated(?!-removed)', 'describe', 'directive', 79 | 'envvar', 'event', 'exception', 'function', 'glossary', 80 | 'highlight', 'highlightlang', 'impl-detail', 'index', 'literalinclude', 81 | 'method', 'miscnews', 'module', 'moduleauthor', 'opcode', 'pdbcommand', 82 | 'program', 'role', 'sectionauthor', 'seealso', 83 | 'sourcecode', 'staticmethod', 'tabularcolumns', 'testcode', 'testoutput', 84 | 'testsetup', 'toctree', 'todo', 'todolist', 'versionadded', 85 | 'versionchanged', 'c:function', 'coroutinefunction' 86 | ] 87 | 88 | DIRECTIVES_CONTAINING_ARBITRARY_CONTENT = [ 89 | # standard docutils ones 90 | 'contents', 'csv-table', 'date', 'default-role', 'include', 'raw', 91 | 'restructuredtext-test-directive', 'role', 'rubric', 'sectnum', 'table', 92 | 'target-notes', 'title', 'unicode', 93 | # Sphinx and Python docs custom ones 94 | 'code-block', 'doctest', 'productionlist', 95 | ] 96 | 97 | # fmt: on 98 | 99 | DIRECTIVES_CONTAINING_ARBITRARY_CONTENT_RE = re.compile( 100 | r"^\s*\.\. (" + "|".join(DIRECTIVES_CONTAINING_ARBITRARY_CONTENT) + ")::" 101 | ) 102 | 103 | DIRECTIVES_CONTAINING_RST_RE = re.compile( 104 | r"^\s*\.\. (" + "|".join(DIRECTIVES_CONTAINING_RST) + ")::" 105 | ) 106 | 107 | ALL_DIRECTIVES = ( 108 | "(" 109 | + "|".join(DIRECTIVES_CONTAINING_RST + DIRECTIVES_CONTAINING_ARBITRARY_CONTENT) 110 | + ")" 111 | ) 112 | 113 | QUOTE_PAIRS = [ 114 | "»»", # Swedish 115 | "‘‚", # Albanian/Greek/Turkish 116 | "’’", # Swedish 117 | "‚‘", # German 118 | "‚’", # Polish 119 | "“„", # Albanian/Greek/Turkish 120 | "„“", # German 121 | "„”", # Polish 122 | "””", # Swedish 123 | "››", # Swedish 124 | "''", # ASCII 125 | '""', # ASCII 126 | "<>", # ASCII 127 | "()", # ASCII 128 | "[]", # ASCII 129 | "{}", # ASCII 130 | ] 131 | 132 | 133 | QUOTE_PAIRS_NEGATIVE_LOOKBEHIND = ( 134 | "(?""" 156 | UNICODE_ALLOWED_AFTER_INLINE_MARKUP = r"\p{Pe}\p{Pi}\p{Pf}\p{Pd}\p{Po}" 157 | 158 | 159 | @cache 160 | def inline_markup_gen(start_string, end_string, extra_allowed_before=""): 161 | """Generate a regex matching an inline markup. 162 | 163 | inline_markup_gen('**', '**') geneates a regex matching strong 164 | emphasis inline markup. 165 | """ 166 | if extra_allowed_before: 167 | extra_allowed_before = "|" + extra_allowed_before 168 | return re.compile( 169 | rf""" 170 | (? 183 | {start_string} # Inline markup start 184 | \S # Inline markup start-strings must be immediately followed by 185 | # non-whitespace. 186 | # The inline markup end-string must be separated by at least one 187 | # character from the start-string. 188 | {QUOTE_PAIRS_NEGATIVE_LOOKBEHIND} 189 | .*? 190 | (?<=\x00\ |\S)# Inline markup end-strings must be immediately preceded 191 | # by non-whitespace. 192 | {end_string} # Inline markup end 193 | ) 194 | 195 | (?= # Inline markup end-strings must 196 | $| # end a text block or 197 | \s| # be immediately followed by whitespace, 198 | \x00| 199 | [{ASCII_ALLOWED_AFTER_INLINE_MARKUP}]| # one of the ASCII characters 200 | [{UNICODE_ALLOWED_AFTER_INLINE_MARKUP}] # or a similar non-ASCII 201 | # punctuation character. 202 | ) 203 | """, 204 | flags=re.VERBOSE | re.DOTALL, 205 | ) 206 | 207 | 208 | # https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#inline-markup-recognition-rules 209 | INTERPRETED_TEXT_RE = inline_markup_gen("`", "`") 210 | INLINE_INTERNAL_TARGET_RE = inline_markup_gen("_`", "`") 211 | HYPERLINK_REFERENCES_RE = inline_markup_gen("`", "`_") 212 | ANONYMOUS_HYPERLINK_REFERENCES_RE = inline_markup_gen("`", "`__") 213 | INLINE_LITERAL_RE = inline_markup_gen("``", "``") 214 | NORMAL_ROLE_RE = re.compile( 215 | rf""" 216 | (?`(_?)") 284 | 285 | LEAKED_MARKUP_RE = re.compile(r"[a-z]::\s|`|\.\.\s*\w+:") 286 | 287 | TRIPLE_BACKTICKS_RE = re.compile( 288 | rf"(?:{START_STRING_PREFIX})```[^`]+?(? 4: 58 | continue # we don't handle tables yet. 59 | for error in rst.ROLE_MISSING_CLOSING_BACKTICK_RE.finditer(paragraph): 60 | error_offset = paragraph[: error.start()].count("\n") 61 | yield ( 62 | paragraph_lno + error_offset, 63 | f"role missing closing backtick: {error.group(0)!r}", 64 | ) 65 | 66 | 67 | _RST_ROLE_RE = re.compile("``.+?``(?!`).", flags=re.DOTALL) 68 | _END_STRING_SUFFIX_RE = re.compile(rst.END_STRING_SUFFIX) 69 | 70 | 71 | @checker(".rst", ".po") 72 | def check_missing_space_after_literal(file, lines, options=None): 73 | r"""Search for inline literals immediately followed by a character. 74 | 75 | Bad: ``items``s 76 | Good: ``items``\ s 77 | """ 78 | for paragraph_lno, paragraph in paragraphs(lines): 79 | if paragraph.count("|") > 4: 80 | continue # we don't handle tables yet. 81 | paragraph = clean_paragraph(paragraph) 82 | for role in _RST_ROLE_RE.finditer(paragraph): 83 | if not _END_STRING_SUFFIX_RE.match(role[0][-1]): 84 | error_offset = paragraph[: role.start()].count("\n") 85 | yield ( 86 | paragraph_lno + error_offset, 87 | "inline literal missing " 88 | f"(escaped) space after literal: {role.group(0)!r}", 89 | ) 90 | 91 | 92 | _LONE_DOUBLE_BACKTICK_RE = re.compile("(? 4: 104 | continue # we don't handle tables yet. 105 | paragraph = clean_paragraph(paragraph) 106 | for lone_double_backtick in _LONE_DOUBLE_BACKTICK_RE.finditer(paragraph): 107 | error_offset = paragraph[: lone_double_backtick.start()].count("\n") 108 | yield ( 109 | paragraph_lno + error_offset, 110 | "found an unbalanced inline literal markup.", 111 | ) 112 | 113 | 114 | _ends_with_role_tag = re.compile(rst.ROLE_TAG + "$").search 115 | _starts_with_role_tag = re.compile("^" + rst.ROLE_TAG).search 116 | 117 | 118 | @checker(".rst", ".po", enabled=False) 119 | def check_default_role(file, lines, options=None): 120 | """Search for default roles (but they are allowed in many projects). 121 | 122 | Bad: `print` 123 | Good: ``print`` 124 | """ 125 | for lno, line in enumerate(lines, start=1): 126 | line = clean_paragraph(line) 127 | line = escape2null(line) 128 | for match in rst.INTERPRETED_TEXT_RE.finditer(line): 129 | before_match = line[: match.start()] 130 | after_match = line[match.end() :] 131 | stripped_line = line.strip() 132 | if ( 133 | stripped_line.startswith("|") 134 | and stripped_line.endswith("|") 135 | and stripped_line.count("|") >= 4 136 | and "|" in match.group(0) 137 | ): 138 | continue # we don't handle tables yet. 139 | if _ends_with_role_tag(before_match): 140 | # It's not a default role: it ends with a tag. 141 | continue 142 | if _starts_with_role_tag(after_match): 143 | # It's not a default role: it starts with a tag. 144 | continue 145 | if match.group(0).startswith("``") and match.group(0).endswith("``"): 146 | # It's not a default role: it's an inline literal. 147 | continue 148 | yield ( 149 | lno, 150 | "default role used (hint: for inline literals, use double backticks)", 151 | ) 152 | 153 | 154 | @checker(".rst", ".po") 155 | def check_directive_with_three_dots(file, lines, options=None): 156 | """Search for directives with three dots instead of two. 157 | 158 | Bad: ... versionchanged:: 3.6 159 | Good: .. versionchanged:: 3.6 160 | """ 161 | for lno, line in enumerate(lines, start=1): 162 | if rst.THREE_DOT_DIRECTIVE_RE.search(line): 163 | yield lno, "directive should start with two dots, not three." 164 | 165 | 166 | @checker(".rst", ".po") 167 | def check_directive_missing_colons(file, lines, options=None): 168 | """Search for directive wrongly typed as comments. 169 | 170 | Bad: .. versionchanged 3.6. 171 | Good: .. versionchanged:: 3.6 172 | """ 173 | for lno, line in enumerate(lines, start=1): 174 | if rst.SEEMS_DIRECTIVE_RE.search(line): 175 | yield lno, "comment seems to be intended as a directive" 176 | 177 | 178 | # The difficulty here is that the following is valid: 179 | # The :literal:`:exc:`Exceptions`` 180 | # While this is not: 181 | # The :literal:`:exc:`Exceptions``s 182 | _ROLE_BODY = rf"([^`]|\s`+|\\`|:{rst.SIMPLENAME}:`([^`]|\s`+|\\`)+`)+" 183 | _ALLOWED_AFTER_ROLE = ( 184 | rst.ASCII_ALLOWED_AFTER_INLINE_MARKUP 185 | + rst.UNICODE_ALLOWED_AFTER_INLINE_MARKUP 186 | + r"|\s" 187 | ) 188 | _SUSPICIOUS_ROLE = re.compile( 189 | f":{rst.SIMPLENAME}:`{_ROLE_BODY}`[^{_ALLOWED_AFTER_ROLE}]" 190 | ) 191 | 192 | 193 | @checker(".rst", ".po") 194 | def check_missing_space_after_role(file, lines, options=None): 195 | r"""Search for roles immediately followed by a character. 196 | 197 | Bad: :exc:`Exception`s. 198 | Good: :exc:`Exceptions`\ s 199 | """ 200 | for paragraph_lno, paragraph in paragraphs(lines): 201 | if paragraph.count("|") > 4: 202 | continue # we don't handle tables yet. 203 | paragraph = clean_paragraph(paragraph) 204 | for role in _SUSPICIOUS_ROLE.finditer(paragraph): 205 | error_offset = paragraph[: role.start()].count("\n") 206 | yield ( 207 | paragraph_lno + error_offset, 208 | f"role missing (escaped) space after role: {role.group(0)!r}", 209 | ) 210 | 211 | 212 | @checker(".rst", ".po") 213 | def check_role_without_backticks(file, lines, options=None): 214 | """Search roles without backticks. 215 | 216 | Bad: :func:pdb.main 217 | Good: :func:`pdb.main` 218 | """ 219 | for lno, line in enumerate(lines, start=1): 220 | for no_backticks in rst.ROLE_WITH_NO_BACKTICKS_RE.finditer(line): 221 | yield lno, f"role with no backticks: {no_backticks.group(0)!r}" 222 | 223 | 224 | @checker(".rst", ".po") 225 | def check_backtick_before_role(file, lines, options=None): 226 | """Search for roles preceded by a backtick. 227 | 228 | Bad: `:fct:`sum` 229 | Good: :fct:`sum` 230 | """ 231 | for lno, line in enumerate(lines, start=1): 232 | if "`" not in line: 233 | continue 234 | if rst.BACKTICK_IN_FRONT_OF_ROLE_RE.search(line): 235 | yield lno, "superfluous backtick in front of role" 236 | 237 | 238 | @checker(".rst", ".po") 239 | def check_missing_space_in_hyperlink(file, lines, options=None): 240 | """Search for hyperlinks missing a space. 241 | 242 | Bad: `Link text`_ 243 | Good: `Link text `_ 244 | """ 245 | for lno, line in enumerate(lines, start=1): 246 | if "`" not in line: 247 | continue 248 | for match in rst.SEEMS_HYPERLINK_RE.finditer(line): 249 | if not match.group(2): 250 | yield lno, "missing space before < in hyperlink" 251 | 252 | 253 | @checker(".rst", ".po") 254 | def check_missing_underscore_after_hyperlink(file, lines, options=None): 255 | """Search for hyperlinks with incorrect underscore usage after closing backtick. 256 | 257 | For regular hyperlinks: 258 | Bad: `Link text ` 259 | Good: `Link text `_ 260 | 261 | For hyperlinks within download directives: 262 | Bad: :download:`file `_ 263 | Good: :download:`file ` 264 | 265 | Note: 266 | URLs within download directives don't need trailing underscores. 267 | https://www.sphinx-doc.org/en/master/usage/referencing.html#role-download 268 | """ 269 | for lno, line in enumerate(lines, start=1): 270 | if "`" not in line: 271 | continue 272 | for match in rst.SEEMS_HYPERLINK_RE.finditer(line): 273 | is_in_download = bool(match.group(1)) 274 | has_underscore = bool(match.group(3)) 275 | 276 | if is_in_download and has_underscore: 277 | yield lno, "unnecessary underscore after closing backtick in hyperlink" 278 | elif not is_in_download and not has_underscore: 279 | yield lno, "missing underscore after closing backtick in hyperlink" 280 | else: 281 | continue 282 | 283 | 284 | @checker(".rst", ".po") 285 | def check_role_with_double_backticks(file, lines, options=None): 286 | """Search for roles with double backticks. 287 | 288 | Bad: :fct:``sum`` 289 | Good: :fct:`sum` 290 | 291 | The hard thing is that :fct:``sum`` is a legitimate 292 | restructuredtext construction: 293 | 294 | :fct: is just plain text. 295 | ``sum`` is an inline literal. 296 | 297 | So to properly detect this one we're searching for actual inline 298 | literals that have a role tag. 299 | """ 300 | for paragraph_lno, paragraph in paragraphs(lines): 301 | if "`" not in paragraph: 302 | continue 303 | if paragraph.count("|") > 4: 304 | continue # we don't handle tables yet. 305 | paragraph = escape2null(paragraph) 306 | while True: 307 | inline_literal = min( 308 | rst.INLINE_LITERAL_RE.finditer(paragraph, overlapped=True), 309 | key=match_size, 310 | default=None, 311 | ) 312 | if inline_literal is None: 313 | break 314 | before = paragraph[: inline_literal.start()] 315 | if _ends_with_role_tag(before): 316 | error_offset = paragraph[: inline_literal.start()].count("\n") 317 | yield ( 318 | paragraph_lno + error_offset, 319 | "role use a single backtick, double backtick found.", 320 | ) 321 | paragraph = ( 322 | paragraph[: inline_literal.start()] + paragraph[inline_literal.end() :] 323 | ) 324 | 325 | 326 | @checker(".rst", ".po") 327 | def check_role_with_extra_backtick(filename, lines, options): 328 | """Check for extra backtick in roles. 329 | 330 | Bad: :func:`foo`` 331 | Bad: :func:``foo` 332 | Good: :func:`foo` 333 | """ 334 | for lno, line in enumerate(lines, start=1): 335 | for match in rst.ROLE_WITH_EXTRA_BACKTICK_RE.finditer(line): 336 | yield lno, f"Extra backtick in role: {match.group(0).strip()!r}" 337 | 338 | 339 | @checker(".rst", ".po") 340 | def check_missing_space_before_role(file, lines, options=None): 341 | """Search for missing spaces before roles. 342 | 343 | Bad: the:fct:`sum`, issue:`123`, c:func:`foo` 344 | Good: the :fct:`sum`, :issue:`123`, :c:func:`foo` 345 | """ 346 | for paragraph_lno, paragraph in paragraphs(lines): 347 | if paragraph.count("|") > 4: 348 | continue # we don't handle tables yet. 349 | paragraph = clean_paragraph(paragraph) 350 | for match in rst.ROLE_GLUED_WITH_WORD_RE.finditer(paragraph): 351 | error_offset = paragraph[: match.start()].count("\n") 352 | if looks_like_glued(match): 353 | yield ( 354 | paragraph_lno + error_offset, 355 | f"missing space before role ({match.group(0)}).", 356 | ) 357 | else: 358 | yield ( 359 | paragraph_lno + error_offset, 360 | f"role missing opening tag colon ({match.group(0)}).", 361 | ) 362 | 363 | 364 | @checker(".rst", ".po") 365 | def check_missing_space_before_default_role(file, lines, options=None): 366 | """Search for missing spaces before default role. 367 | 368 | Bad: the`sum` 369 | Good: the `sum` 370 | """ 371 | for paragraph_lno, paragraph in paragraphs(lines): 372 | if paragraph.count("|") > 4: 373 | continue # we don't handle tables yet. 374 | paragraph = clean_paragraph(paragraph) 375 | paragraph = rst.INTERPRETED_TEXT_RE.sub("", paragraph) 376 | for role in rst.inline_markup_gen( 377 | "`", "`", extra_allowed_before="[^_]" 378 | ).finditer(paragraph): 379 | error_offset = paragraph[: role.start()].count("\n") 380 | context = paragraph[role.start() - 3 : role.end()] 381 | yield ( 382 | paragraph_lno + error_offset, 383 | f"missing space before default role: {context!r}.", 384 | ) 385 | 386 | 387 | _HYPERLINK_REFERENCE_RE = re.compile(r"\S* `_") 388 | 389 | 390 | @checker(".rst", ".po") 391 | def check_hyperlink_reference_missing_backtick(file, lines, options=None): 392 | """Search for missing backticks in front of hyperlink references. 393 | 394 | Bad: Misc/NEWS `_ 395 | Good: `Misc/NEWS `_ 396 | """ 397 | for paragraph_lno, paragraph in paragraphs(lines): 398 | if paragraph.count("|") > 4: 399 | continue # we don't handle tables yet. 400 | paragraph = clean_paragraph(paragraph) 401 | paragraph = rst.INTERPRETED_TEXT_RE.sub("", paragraph) 402 | for hyperlink_reference in _HYPERLINK_REFERENCE_RE.finditer(paragraph): 403 | error_offset = paragraph[: hyperlink_reference.start()].count("\n") 404 | context = hyperlink_reference.group(0) 405 | yield ( 406 | paragraph_lno + error_offset, 407 | f"missing backtick before hyperlink reference: {context!r}.", 408 | ) 409 | 410 | 411 | @checker(".rst", ".po") 412 | def check_missing_colon_in_role(file, lines, options=None): 413 | """Search for missing colons in roles. 414 | 415 | Bad: :issue`123` 416 | Good: :issue:`123` 417 | """ 418 | for lno, line in enumerate(lines, start=1): 419 | for match in rst.ROLE_MISSING_RIGHT_COLON_RE.finditer(line): 420 | yield lno, f"role missing colon before first backtick ({match.group(0)})." 421 | 422 | 423 | @checker(".py", ".rst", ".po", rst_only=False) 424 | def check_carriage_return(file, lines, options=None): 425 | r"""Check for carriage returns (\r) in lines.""" 426 | for lno, line in enumerate(lines): 427 | if "\r" in line: 428 | yield lno + 1, "\\r in line" 429 | 430 | 431 | @checker(".py", ".rst", ".po", rst_only=False) 432 | def check_horizontal_tab(file, lines, options=None): 433 | r"""Check for horizontal tabs (\t) in lines.""" 434 | for lno, line in enumerate(lines): 435 | if "\t" in line: 436 | yield lno + 1, "OMG TABS!!!1" 437 | 438 | 439 | @checker(".py", ".rst", ".po", rst_only=False) 440 | def check_trailing_whitespace(file, lines, options=None): 441 | """Check for trailing whitespaces at end of lines.""" 442 | for lno, line in enumerate(lines): 443 | stripped_line = line.rstrip("\n") 444 | if stripped_line.rstrip(" \t") != stripped_line: 445 | yield lno + 1, "trailing whitespace" 446 | 447 | 448 | @checker(".py", ".rst", ".po", rst_only=False) 449 | def check_missing_final_newline(file, lines, options=None): 450 | """Check that the last line of the file ends with a newline.""" 451 | if lines and not lines[-1].endswith("\n"): 452 | yield len(lines), "No newline at end of file." 453 | 454 | 455 | _is_long_interpreted_text = re.compile(r"^\s*\W*(:(\w+:)+)?`.*`\W*$").match 456 | _starts_with_directive_or_hyperlink = re.compile(r"^\s*\.\. ").match 457 | _starts_with_anonymous_hyperlink = re.compile(r"^\s*__ ").match 458 | _is_very_long_string_literal = re.compile(r"^\s*``[^`]+``$").match 459 | _is_very_long_inline_link = re.compile(r"^\s*<.*(>`_).?$").match 460 | 461 | 462 | @checker(".rst", ".po", enabled=False, rst_only=True) 463 | def check_line_too_long(file, lines, options=None): 464 | """Check for line length; this checker is not run by default.""" 465 | for lno, line in enumerate(lines): 466 | # Beware, in `line` we have the trailing newline. 467 | if len(line) - 1 > options.max_line_length: 468 | if line.lstrip()[0] in "+|": 469 | continue # ignore wide tables 470 | if _is_long_interpreted_text(line): 471 | continue # ignore long interpreted text 472 | if _starts_with_directive_or_hyperlink(line): 473 | continue # ignore directives and hyperlink targets 474 | if _starts_with_anonymous_hyperlink(line): 475 | continue # ignore anonymous hyperlink targets 476 | if _is_very_long_string_literal(line): 477 | continue # ignore a very long literal string 478 | if _is_very_long_inline_link(line): 479 | continue # ignore a very long URL on its own line 480 | yield lno + 1, f"Line too long ({len(line) - 1}/{options.max_line_length})" 481 | 482 | 483 | @checker(".html", enabled=False, rst_only=False) 484 | def check_leaked_markup(file, lines, options=None): 485 | """Check HTML files for leaked reST markup. 486 | 487 | This only works if the HTML files have been built. 488 | """ 489 | for lno, line in enumerate(lines): 490 | if rst.LEAKED_MARKUP_RE.search(line): 491 | yield lno + 1, f"possibly leaked markup: {line}" 492 | 493 | 494 | @checker(".rst", ".po", enabled=False) 495 | def check_triple_backticks(file, lines, options=None): 496 | """Check for triple backticks, like ```Point``` (but it's a valid syntax). 497 | 498 | Bad: ```Point``` 499 | Good: ``Point`` 500 | 501 | In reality, triple backticks are valid: ```foo``` gets 502 | rendered as `foo`, it's at least used by Sphinx to document rst 503 | syntax, but it's really uncommon. 504 | """ 505 | for lno, line in enumerate(lines): 506 | for match in rst.TRIPLE_BACKTICKS_RE.finditer(line): 507 | yield lno + 1, "There's no rst syntax using triple backticks" 508 | 509 | 510 | _has_bad_dedent = re.compile(" [^ ].*::$").match 511 | 512 | 513 | @checker(".rst", ".po", rst_only=False) 514 | def check_bad_dedent(file, lines, options=None): 515 | """Check for mis-alignment in indentation in code blocks. 516 | 517 | |A 5 lines block:: 518 | | 519 | | Hello! 520 | | 521 | | Looks like another block:: 522 | | 523 | | But in fact it's not due to the leading space. 524 | """ 525 | 526 | errors = [] 527 | 528 | def check_block(block_lineno, block): 529 | for lineno, line in enumerate(block.splitlines()): 530 | if _has_bad_dedent(line): 531 | errors.append((block_lineno + lineno, "Bad dedent in block")) 532 | 533 | list(hide_non_rst_blocks(lines, hidden_block_cb=check_block)) 534 | yield from errors 535 | 536 | 537 | _has_dangling_hyphen = re.compile(r".*[a-z]-$").match 538 | 539 | 540 | @checker(".rst", rst_only=True) 541 | def check_dangling_hyphen(file, lines, options): 542 | """Check for lines ending in a hyphen.""" 543 | for lno, line in enumerate(lines): 544 | stripped_line = line.rstrip("\n") 545 | if _has_dangling_hyphen(stripped_line): 546 | yield lno + 1, "Line ends with dangling hyphen" 547 | 548 | 549 | @checker(".rst", ".po", rst_only=False, enabled=True) 550 | def check_unnecessary_parentheses(filename, lines, options): 551 | """Check for unnecessary parentheses in :func: and :meth: roles. 552 | 553 | Bad: :func:`test()` 554 | Good: :func:`test` 555 | """ 556 | for lno, line in enumerate(lines, start=1): 557 | for match in rst.ROLE_WITH_UNNECESSARY_PARENTHESES_RE.finditer(line): 558 | yield lno, f"Unnecessary parentheses in {match.group(0).strip()!r}" 559 | 560 | 561 | @checker(".rst", ".po") 562 | def check_exclamation_and_tilde(file, lines, options): 563 | """Check for roles that start with an exclamation mark and tilde (`!~`). 564 | 565 | Bad: :meth:`!~list.pop` 566 | Good: :meth:`!pop` 567 | """ 568 | for lno, line in enumerate(lines, start=1): 569 | if not ("~" in line and "!" in line and "`" in line): 570 | continue 571 | for match in rst.ROLE_WITH_EXCLAMATION_AND_TILDE_RE.finditer(line): 572 | yield ( 573 | lno, 574 | f"Found a role with both `!` and `~` in {match.group(0).strip()!r}.", 575 | ) 576 | --------------------------------------------------------------------------------