diff --git a/pyproject.toml b/pyproject.toml index 55869ff23..f66fd965c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ requires-python = ">= 3.7" dependencies = [ "regex", "polib", + "tomli>=2; python_version < '3.11'", ] dynamic = ["version"] diff --git a/sphinxlint/__main__.py b/sphinxlint/__main__.py index 45463457e..795b5bdca 100644 --- a/sphinxlint/__main__.py +++ b/sphinxlint/__main__.py @@ -7,6 +7,8 @@ from itertools import chain, starmap from sphinxlint import check_file +from sphinxlint import rst +from sphinxlint.config import get_config from sphinxlint.checkers import all_checkers from sphinxlint.sphinxlint import CheckersOptions @@ -15,6 +17,11 @@ def parse_args(argv=None): """Parse command line argument.""" if argv is None: argv = sys.argv + if argv[1:2] == ["init", "directives"]: + from directivegetter import collect_directives + + raise SystemExit(collect_directives(argv[2:])) + parser = argparse.ArgumentParser(description=__doc__) enabled_checkers_names = { @@ -113,6 +120,11 @@ def walk(path, ignore_list): def main(argv=None): + config = get_config() + + # Append extra directives + rst.DIRECTIVES_CONTAINING_ARBITRARY_CONTENT.extend(config.get("known_directives", [])) + enabled_checkers, args = parse_args(argv) options = CheckersOptions.from_argparse(args) if args.list: diff --git a/sphinxlint/checkers.py b/sphinxlint/checkers.py index 4df610e36..ec208afc0 100644 --- a/sphinxlint/checkers.py +++ b/sphinxlint/checkers.py @@ -136,8 +136,9 @@ def check_directive_with_three_dots(file, lines, options=None): Bad: ... versionchanged:: 3.6 Good: .. versionchanged:: 3.6 """ + three_dot_directive_re = rst.three_dot_directive_re() for lno, line in enumerate(lines, start=1): - if rst.THREE_DOT_DIRECTIVE_RE.search(line): + if three_dot_directive_re.search(line): yield lno, "directive should start with two dots, not three." @@ -148,8 +149,9 @@ def check_directive_missing_colons(file, lines, options=None): Bad: .. versionchanged 3.6. Good: .. versionchanged:: 3.6 """ + seems_directive_re = rst.seems_directive_re() for lno, line in enumerate(lines, start=1): - if rst.SEEMS_DIRECTIVE_RE.search(line): + if seems_directive_re.search(line): yield lno, "comment seems to be intended as a directive" diff --git a/sphinxlint/config.py b/sphinxlint/config.py new file mode 100644 index 000000000..56c7ee56f --- /dev/null +++ b/sphinxlint/config.py @@ -0,0 +1,28 @@ +import sys +from os.path import isfile +from typing import Any + +if sys.version_info[:2] >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib + except ImportError: + tomllib = None + + +def _read_toml(filename: str) -> dict[str, Any]: + if tomllib is None: + return {} + with open(filename, "rb") as f: + return tomllib.load(f) + + +def get_config() -> dict[str, Any]: + if isfile("sphinx.toml"): + table = _read_toml("sphinx.toml") + elif isfile("pyproject.toml"): + table = _read_toml("pyproject.toml") + else: + table = {} + return table.get("tool", {}).get("sphinx-lint", {}) diff --git a/sphinxlint/directivegetter.py b/sphinxlint/directivegetter.py new file mode 100644 index 000000000..e5c346eca --- /dev/null +++ b/sphinxlint/directivegetter.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from docutils.parsers.rst.directives import _directive_registry, _directives +from docutils.parsers.rst.languages import en +from sphinx.builders.dummy import DummyBuilder + +if TYPE_CHECKING: + from collections.abc import Iterator, Iterable + + from sphinx.application import Sphinx + +DOCUTILS_DIRECTIVES = frozenset(_directive_registry.keys() | en.directives.keys()) +SPHINX_DIRECTIVES = frozenset({ + # reStructuredText content: + # ~~~~~~~~~~~~~~~~~~~~~~~~~ + # Added by Sphinx: + 'acks', 'centered', 'codeauthor', 'default-domain', 'deprecated(?!-removed)', + 'describe', 'highlight', 'hlist', 'index', 'literalinclude', 'moduleauthor', + 'object', 'only', 'rst-class', 'sectionauthor', 'seealso', 'tabularcolumns', + 'toctree', 'versionadded', 'versionchanged', + # Added by Sphinx (since removed): + 'highlightlang', # removed in Sphinx 4.0 + # Added by Sphinx (Standard domain): + 'cmdoption', 'envvar', 'glossary', 'option', 'productionlist', 'program', + # Added by Sphinx (Python domain): + 'py:attribute', 'py:class', 'py:classmethod', 'py:currentmodule', 'py:data', + 'py:decorator', 'py:decoratormethod', 'py:exception', 'py:function', + 'py:method', 'py:module', 'py:property', 'py:staticmethod', + 'attribute', 'class', 'classmethod', 'currentmodule', 'data', + 'decorator', 'decoratormethod', 'exception', 'function', + 'method', 'module', 'property', 'staticmethod', + # Added by Sphinx (C domain): + 'c:alias', 'c:enum', 'c:enumerator', 'c:function', 'c:macro', 'c:member', + 'c:struct', 'c:type', 'c:union', 'c:var', + 'cfunction', 'cmacro', 'cmember', 'ctype', 'cvar', + # Added by Sphinx (sphinx.ext.todo): + 'todo', 'todolist', + # Added in Sphinx's own documentation only: + 'confval', 'event', + + # Arbitrary content: + # ~~~~~~~~~~~~~~~~~~ + # Added by Sphinx (core): + 'cssclass', + # Added by Sphinx (Standard domain): + 'productionlist', + # Added by Sphinx (C domain): + 'c:namespace', 'c:namespace-pop', 'c:namespace-push', + # Added by Sphinx (sphinx.ext.autodoc): + 'autoattribute', 'autoclass', 'autodata', 'autodecorator', 'autoexception', + 'autofunction', 'automethod', 'automodule', 'autonewtypedata', + 'autonewvarattribute', 'autoproperty', + # Added by Sphinx (sphinx.ext.doctest): + 'doctest', 'testcleanup', 'testcode', 'testoutput', 'testsetup', + +}) +CORE_DIRECTIVES = DOCUTILS_DIRECTIVES | SPHINX_DIRECTIVES + + +def tomlify_directives(directives: Iterable[str], comment: str) -> Iterator[str]: + yield f" # {comment}:" + yield from (f' "{directive}",' for directive in sorted(directives)) + + +def write_directives(directives: Iterable[str]): + lines = [ + "[tool.sphinx-lint]", + "known_directives = [", + tomlify_directives(directives, "Added by extensions or in conf.py"), + "]", + "", # final blank line + ] + with open("sphinx.toml", "w", encoding="utf-8") as file: + file.write("\n".join(lines)) + + +class DirectiveCollectorBuilder(DummyBuilder): + name = "directive_collector" + + def get_outdated_docs(self) -> str: + return "nothing, just getting list of directives" + + def read(self) -> list[str]: + write_directives({*_directives} - CORE_DIRECTIVES) + return [] + + def write(self, *args, **kwargs) -> None: + pass + + +def setup(app: Sphinx) -> dict[str, bool]: + """Plugin for Sphinx""" + app.add_builder(DirectiveCollectorBuilder) + return {"parallel_read_safe": True, "parallel_write_safe": True} + + +def collect_directives(args=None): + from sphinx import application + from sphinx.application import Sphinx + + try: + source_dir, build_dir, *opts = args or sys.argv[1:] + except ValueError: + raise RuntimeError("Two arguments (source dir and build dir) are required.") + + application.builtin_extensions = ( + *application.builtin_extensions, + "directivegetter" # set up this file as an extension + ) + app = Sphinx( + str(Path(source_dir)), + str(Path(source_dir)), + str(Path(build_dir)), + str(Path(build_dir, "doctrees")), + "directive_collector", + ) + app.build(force_all=True) + raise SystemExit(app.statuscode) + + +if __name__ == "__main__": + collect_directives() diff --git a/sphinxlint/rst.py b/sphinxlint/rst.py index 14d13406a..318a760e0 100644 --- a/sphinxlint/rst.py +++ b/sphinxlint/rst.py @@ -58,35 +58,68 @@ # fmt: off DIRECTIVES_CONTAINING_RST = [ - # standard docutils ones + # reStructuredText directives: 'admonition', 'attention', 'caution', 'class', 'compound', 'container', 'danger', 'epigraph', 'error', 'figure', 'footer', 'header', 'highlights', - 'hint', 'image', 'important', 'include', 'line-block', 'list-table', 'meta', - 'note', 'parsed-literal', 'pull-quote', 'replace', 'sidebar', 'tip', 'topic', - 'warning', - # Sphinx and Python docs custom ones - 'acks', 'attribute', 'autoattribute', 'autoclass', 'autodata', - 'autoexception', 'autofunction', 'automethod', 'automodule', - 'availability', 'centered', 'cfunction', 'class', 'classmethod', 'cmacro', - 'cmdoption', 'cmember', 'confval', 'cssclass', 'ctype', - 'currentmodule', 'cvar', 'data', 'decorator', 'decoratormethod', - 'deprecated-removed', 'deprecated(?!-removed)', 'describe', 'directive', - 'doctest', 'envvar', 'event', 'exception', 'function', 'glossary', - 'highlight', 'highlightlang', 'impl-detail', 'index', 'literalinclude', - 'method', 'miscnews', 'module', 'moduleauthor', 'opcode', 'pdbcommand', - 'program', 'role', 'sectionauthor', 'seealso', - 'sourcecode', 'staticmethod', 'tabularcolumns', 'testcode', 'testoutput', - 'testsetup', 'toctree', 'todo', 'todolist', 'versionadded', - 'versionchanged', 'c:function', 'coroutinefunction' + 'hint', 'image', 'important', 'line-block', 'list-table', 'math', 'meta', + 'note', 'parsed-literal', 'pull-quote', 'replace', 'sidebar', 'tip', + 'topic', 'warning', + # Added by Sphinx: + 'acks', 'centered', 'codeauthor', 'default-domain', 'deprecated(?!-removed)', + 'describe', 'highlight', 'hlist', 'index', 'literalinclude', 'moduleauthor', + 'object', 'only', 'rst-class', 'sectionauthor', 'seealso', 'tabularcolumns', + 'toctree', 'versionadded', 'versionchanged', + # Added by Sphinx (since removed): + 'highlightlang', # removed in Sphinx 4.0 + # Added by Sphinx (Standard domain): + 'cmdoption', 'envvar', 'glossary', 'option', 'productionlist', 'program', + # Added by Sphinx (Python domain): + 'py:attribute', 'py:class', 'py:classmethod', 'py:currentmodule', 'py:data', + 'py:decorator', 'py:decoratormethod', 'py:exception', 'py:function', + 'py:method', 'py:module', 'py:property', 'py:staticmethod', + 'attribute', 'class', 'classmethod', 'currentmodule', 'data', + 'decorator', 'decoratormethod', 'exception', 'function', + 'method', 'module', 'property', 'staticmethod', + # Added by Sphinx (C domain): + 'c:alias', 'c:enum', 'c:enumerator', 'c:function', 'c:macro', 'c:member', + 'c:struct', 'c:type', 'c:union', 'c:var', + 'cfunction', 'cmacro', 'cmember', 'ctype', 'cvar', + # Added by Sphinx (sphinx.ext.todo): + 'todo', 'todolist', + # Added in Sphinx's own documentation only: + 'confval', 'event', + # Added in the Python documentation (directives): + 'audit-event', 'audit-event-table', 'availability', + 'deprecated-removed', 'impl-detail', 'miscnews', + # Added in the Python documentation (objects with implicit directives): + '2to3fixer', 'opcode', 'pdbcommand', + # Added in the Python documentation (Python domain): + 'coroutinefunction', 'coroutinemethod', 'abstractmethod', + 'awaitablefunction', 'awaitablemethod', + 'py:coroutinefunction', 'py:coroutinemethod', 'py:abstractmethod', + 'py:awaitablefunction', 'py:awaitablemethod', ] DIRECTIVES_CONTAINING_ARBITRARY_CONTENT = [ - # standard docutils ones - 'contents', 'csv-table', 'date', 'default-role', 'include', 'raw', - 'restructuredtext-test-directive', 'role', 'rubric', 'sectnum', 'table', - 'target-notes', 'title', 'unicode', - # Sphinx and Python docs custom ones - 'productionlist', 'code-block', + # reStructuredText directives: + 'code', 'code-block', 'contents', 'csv-table', 'date', 'default-role', + 'include', 'raw', 'restructuredtext-test-directive', 'role', 'rubric', + 'section-numbering', 'sectnum', 'sourcecode', 'table', 'target-notes', + 'title', 'unicode', + # Added by Sphinx (core): + 'cssclass', + # Added by Sphinx (Standard domain): + 'productionlist', + # Added by Sphinx (C domain): + 'c:namespace', 'c:namespace-pop', 'c:namespace-push', + # Added by Sphinx (sphinx.ext.autodoc): + 'autoattribute', 'autoclass', 'autodata', 'autodecorator', 'autoexception', + 'autofunction', 'automethod', 'automodule', 'autonewtypedata', + 'autonewvarattribute', 'autoproperty', + # Added by Sphinx (sphinx.ext.doctest): + 'doctest', 'testcleanup', 'testcode', 'testoutput', 'testsetup', + # Added in the Python documentation: + 'limited-api-list', ] # fmt: on @@ -99,12 +132,6 @@ r"^\s*\.\. (" + "|".join(DIRECTIVES_CONTAINING_RST) + ")::" ) -ALL_DIRECTIVES = ( - "(" - + "|".join(DIRECTIVES_CONTAINING_RST + DIRECTIVES_CONTAINING_ARBITRARY_CONTENT) - + ")" -) - QUOTE_PAIRS = [ "»»", # Swedish "‘‚", # Albanian/Greek/Turkish @@ -151,6 +178,14 @@ UNICODE_ALLOWED_AFTER_INLINE_MARKUP = r"[\p{Pe}\p{Pi}\p{Pf}\p{Pd}\p{Po}]" +def get_all_directives() -> str: + return ( + "(" + + "|".join(DIRECTIVES_CONTAINING_RST + DIRECTIVES_CONTAINING_ARBITRARY_CONTENT) + + ")" + ) + + def inline_markup_gen(start_string, end_string, extra_allowed_before=""): """Generate a regex matching an inline markup. @@ -226,19 +261,24 @@ def inline_markup_gen(start_string, end_string, extra_allowed_before=""): rf"(^|\s)`:{SIMPLENAME}:{INTERPRETED_TEXT_RE.pattern}", flags=re.VERBOSE | re.DOTALL ) + # Find comments that look like a directive, like: # .. versionchanged 3.6 # or # .. versionchanged: 3.6 # as it should be: # .. versionchanged:: 3.6 -SEEMS_DIRECTIVE_RE = re.compile(rf"^\s*(? re.Pattern[str]: + return re.compile(rf"^\s*(? re.Pattern[str]: + return re.compile(rf"\.\.\. {get_all_directives()}::") + # Find role used with double backticks instead of simple backticks like: # :const:``None`` diff --git a/sphinxlint/utils.py b/sphinxlint/utils.py index f15465545..14cf6278e 100644 --- a/sphinxlint/utils.py +++ b/sphinxlint/utils.py @@ -177,7 +177,7 @@ def hide_non_rst_blocks(lines, hidden_block_cb=None): def type_of_explicit_markup(line): """Tell apart various explicit markup blocks.""" line = line.lstrip() - if re.match(rf"\.\. {rst.ALL_DIRECTIVES}::", line): + if re.match(rf"\.\. {rst.get_all_directives()}::", line): return "directive" if re.match(r"\.\. \[[0-9]+\] ", line): return "footnote"