├── 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 | [
4 | 
5 | 
6 | ](https://pypi.org/project/sphinx-lint)
7 | [](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 |
--------------------------------------------------------------------------------