Repository: adamtheturtle/doccmd

Issue:
Add support for running j2lint over sphinx-jinja2 blocks
- [x] Support sphinx-jinja2 blocks in sybil-extras (myst, rst)
- [x] Support grouped sphinx-jinja2 blocks in sybil-extras (myst, rst)
- [ ] Support some way of saying in doccmd "I want to look at jinja2 blocks"
    - [ ] Allow having no "language" option given
    - [ ] Warn if there's no language and no `--jinja2` option given <- Maybe
- [ ] Document using j2lint

Discussion:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index cf1f621..ad7e64c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,6 +4,8 @@ Changelog
 Next
 ----
 
+* Add a ``--sphinx-jinja2`` option to evaluate `sphinx-jinja2 <https://sphinx-jinja2.readthedocs.io/en/latest/>`_ blocks.
+
 2025.03.18
 ----------
 
diff --git a/README.rst b/README.rst
index 13def64..0315dd5 100644
--- a/README.rst
+++ b/README.rst
@@ -77,6 +77,9 @@ Usage example
    # See the documentation about groups for more information.
    $ doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format" README.md
 
+   # Run j2lint against the sphinx-jinja2 code blocks in a MyST file
+   $ doccmd --sphinx-jinja2 --no-pad-file --command="j2lint" README.md
+
 What does it work on?
 ---------------------
 
diff --git a/docs/source/usage-example.rst b/docs/source/usage-example.rst
index 702bed8..447de8d 100644
--- a/docs/source/usage-example.rst
+++ b/docs/source/usage-example.rst
@@ -17,3 +17,6 @@ Usage example
    # Don't "pad" the code blocks with newlines - the formatter wouldn't like that.
    # See the documentation about groups for more information.
    $ doccmd --language=python --no-pad-file --no-pad-groups --command="ruff format" README.md
+
+   # Run j2lint against the sphinx-jinja2 code blocks in a MyST file
+   $ doccmd --sphinx-jinja2 --no-pad-file --no-pad-groups --command="j2lint" README.md
diff --git a/pyproject.toml b/pyproject.toml
index b5d6b18..eda2030 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -147,7 +147,9 @@ lint.ignore = [
     "ISC001",
     # Ignore "too-many-*" errors as they seem to get in the way more than
     # helping.
+    "PLR0912",
     "PLR0913",
+    "PLR0915",
 ]
 
 lint.per-file-ignores."tests/*.py" = [
@@ -234,13 +236,15 @@ enable = [
 # --disable=W"
 
 disable = [
+    "too-many-branches",
+    "too-many-statements",
     'too-complex',
     'too-few-public-methods',
-    'too-many-locals',
     'too-many-arguments',
     'too-many-instance-attributes',
-    'too-many-return-statements',
     'too-many-lines',
+    'too-many-locals',
+    'too-many-return-statements',
     'locally-disabled',
     # Let ruff handle long lines
     'line-too-long',
diff --git a/src/doccmd/__init__.py b/src/doccmd/__init__.py
index 942643f..97a940f 100644
--- a/src/doccmd/__init__.py
+++ b/src/doccmd/__init__.py
@@ -530,8 +530,8 @@ def _get_sybil(
     *,
     encoding: str,
     args: Sequence[str | Path],
-    code_block_language: str,
-    given_temporary_file_extension: str | None,
+    code_block_languages: Sequence[str],
+    temporary_file_extension: str,
     temporary_file_name_prefix: str,
     pad_temporary_file: bool,
     pad_groups: bool,
@@ -541,15 +541,11 @@ def _get_sybil(
     markup_language: MarkupLanguage,
     log_command_evaluators: Sequence[_LogCommandEvaluator],
     newline: str | None,
+    parse_sphinx_jinja2: bool,
 ) -> Sybil:
     """
     Get a Sybil for running commands on the given file.
     """
-    temporary_file_extension = _get_temporary_file_extension(
-        language=code_block_language,
-        given_file_extension=given_temporary_file_extension,
-    )
-
     tempfile_suffixes = (temporary_file_extension,)
 
     shell_command_evaluator = ShellCommandEvaluator(
@@ -594,6 +590,7 @@ def _get_sybil(
             language=code_block_language,
             evaluator=evaluator,
         )
+        for code_block_language in code_block_languages
     ]
 
     group_parsers = [
@@ -604,8 +601,24 @@ def _get_sybil(
         )
         for group_directive in group_directives
     ]
+
+    sphinx_jinja2_parsers = (
+        [
+            markup_language.sphinx_jinja_parser_cls(
+                evaluator=evaluator,
+            )
+        ]
+        if markup_language.sphinx_jinja_parser_cls and parse_sphinx_jinja2
+        else []
+    )
+
     return Sybil(
-        parsers=(*code_block_parsers, *skip_parsers, *group_parsers),
+        parsers=(
+            *code_block_parsers,
+            *sphinx_jinja2_parsers,
+            *skip_parsers,
+            *group_parsers,
+        ),
         encoding=encoding,
     )
 
@@ -616,10 +629,12 @@ def _get_sybil(
     "-l",
     "--language",
     type=str,
-    required=True,
+    required=False,
     help=(
         "Run `command` against code blocks for this language. "
-        "Give multiple times for multiple languages."
+        "Give multiple times for multiple languages. "
+        "If this is not given, no code blocks are run, unless "
+        "`--sphinx-jinja2` is given."
     ),
     multiple=True,
     callback=_click_multi_callback(
@@ -883,6 +898,18 @@ def _get_sybil(
         "``doccmd`` does not support writing to grouped code blocks."
     ),
 )
+@click.option(
+    "--sphinx-jinja2/--no-sphinx-jinja2",
+    "sphinx_jinja2",
+    default=False,
+    show_default=True,
+    help=(
+        "Whether to parse `sphinx-jinja2` blocks. "
+        "This is useful for evaluating code blocks with Jinja2 "
+        "templates used in Sphinx documentation. "
+        "This is supported for MyST and reStructuredText files only."
+    ),
+)
 @beartype
 def main(
     *,
@@ -904,6 +931,7 @@ def main(
     exclude_patterns: Sequence[str],
     fail_on_parse_error: bool,
     fail_on_group_write: bool,
+    sphinx_jinja2: bool,
 ) -> None:
     """Run commands against code blocks in the given documentation files.
 
@@ -955,6 +983,8 @@ def main(
     group_markers = {*group_markers, "all"}
     group_directives = _get_group_directives(markers=group_markers)
 
+    given_temporary_file_extension = temporary_file_extension
+
     for file_path in file_paths:
         markup_language = suffix_map[file_path.suffix]
         encoding = _get_encoding(document_path=file_path)
@@ -971,13 +1001,40 @@ def main(
         newline = (
             newline_bytes.decode(encoding=encoding) if newline_bytes else None
         )
+        sybils: Sequence[Sybil] = []
         for code_block_language in languages:
+            temporary_file_extension = _get_temporary_file_extension(
+                language=code_block_language,
+                given_file_extension=given_temporary_file_extension,
+            )
+            sybil = _get_sybil(
+                args=args,
+                code_block_languages=[code_block_language],
+                pad_temporary_file=pad_file,
+                pad_groups=pad_groups,
+                temporary_file_extension=temporary_file_extension,
+                temporary_file_name_prefix=temporary_file_name_prefix,
+                skip_directives=skip_directives,
+                group_directives=group_directives,
+                use_pty=use_pty,
+                markup_language=markup_language,
+                encoding=encoding,
+                log_command_evaluators=log_command_evaluators,
+                newline=newline,
+                parse_sphinx_jinja2=False,
+            )
+            sybils = [*sybils, sybil]
+
+        if sphinx_jinja2:
+            temporary_file_extension = (
+                given_temporary_file_extension or ".jinja"
+            )
             sybil = _get_sybil(
                 args=args,
-                code_block_language=code_block_language,
+                code_block_languages=[],
                 pad_temporary_file=pad_file,
                 pad_groups=pad_groups,
-                given_temporary_file_extension=temporary_file_extension,
+                temporary_file_extension=temporary_file_extension,
                 temporary_file_name_prefix=temporary_file_name_prefix,
                 skip_directives=skip_directives,
                 group_directives=group_directives,
@@ -986,8 +1043,11 @@ def main(
                 encoding=encoding,
                 log_command_evaluators=log_command_evaluators,
                 newline=newline,
+                parse_sphinx_jinja2=True,
             )
+            sybils = [*sybils, sybil]
 
+        for sybil in sybils:
             try:
                 document = sybil.parse(path=file_path)
             except (LexingException, ValueError) as exc:
diff --git a/src/doccmd/_languages.py b/src/doccmd/_languages.py
index 2d0caed..72530a3 100644
--- a/src/doccmd/_languages.py
+++ b/src/doccmd/_languages.py
@@ -13,13 +13,38 @@ import sybil_extras.parsers.markdown.custom_directive_skip
 import sybil_extras.parsers.markdown.grouped_source
 import sybil_extras.parsers.myst.custom_directive_skip
 import sybil_extras.parsers.myst.grouped_source
+import sybil_extras.parsers.myst.sphinx_jinja2
 import sybil_extras.parsers.rest.custom_directive_skip
 import sybil_extras.parsers.rest.grouped_source
+import sybil_extras.parsers.rest.sphinx_jinja2
 from beartype import beartype
 from sybil import Document, Region
 from sybil.typing import Evaluator
 
 
+@runtime_checkable
+class _SphinxJinja2Parser(Protocol):
+    """
+    A parser for sphinx-jinja2 blocks.
+    """
+
+    def __init__(self, *, evaluator: Evaluator) -> None:
+        """
+        Construct a sphinx-jinja2 parser.
+        """
+        # We disable a pylint warning here because the ellipsis is required
+        # for pyright to recognize this as a protocol.
+        ...  # pylint: disable=unnecessary-ellipsis
+
+    def __call__(self, document: Document) -> Iterable[Region]:
+        """
+        Call the sphinx-jinja2 parser.
+        """
+        # We disable a pylint warning here because the ellipsis is required
+        # for pyright to recognize this as a protocol.
+        ...  # pylint: disable=unnecessary-ellipsis
+
+
 @runtime_checkable
 class _SkipParser(Protocol):
     """
@@ -110,6 +135,7 @@ class MarkupLanguage:
     skip_parser_cls: type[_SkipParser]
     code_block_parser_cls: type[_CodeBlockParser]
     group_parser_cls: type[_GroupedSourceParser]
+    sphinx_jinja_parser_cls: type[_SphinxJinja2Parser] | None
 
 
 MyST = MarkupLanguage(
@@ -119,6 +145,7 @@ MyST = MarkupLanguage(
     ),
     code_block_parser_cls=sybil.parsers.myst.CodeBlockParser,
     group_parser_cls=sybil_extras.parsers.myst.grouped_source.GroupedSourceParser,
+    sphinx_jinja_parser_cls=sybil_extras.parsers.myst.sphinx_jinja2.SphinxJinja2Parser,
 )
 
 ReStructuredText = MarkupLanguage(
@@ -126,6 +153,7 @@ ReStructuredText = MarkupLanguage(
     skip_parser_cls=sybil_extras.parsers.rest.custom_directive_skip.CustomDirectiveSkipParser,
     code_block_parser_cls=sybil.parsers.rest.CodeBlockParser,
     group_parser_cls=sybil_extras.parsers.rest.grouped_source.GroupedSourceParser,
+    sphinx_jinja_parser_cls=sybil_extras.parsers.rest.sphinx_jinja2.SphinxJinja2Parser,
 )
 
 Markdown = MarkupLanguage(
@@ -133,4 +161,5 @@ Markdown = MarkupLanguage(
     skip_parser_cls=sybil_extras.parsers.markdown.custom_directive_skip.CustomDirectiveSkipParser,
     code_block_parser_cls=sybil.parsers.markdown.CodeBlockParser,
     group_parser_cls=sybil_extras.parsers.markdown.grouped_source.GroupedSourceParser,
+    sphinx_jinja_parser_cls=None,
 )


Test patch:
diff --git a/tests/test_doccmd.py b/tests/test_doccmd.py
index bf69e58..1defb0c 100644
--- a/tests/test_doccmd.py
+++ b/tests/test_doccmd.py
@@ -3087,6 +3087,60 @@ def test_modify_file_multiple_group_blocks(
     assert result.stderr == expected_stderr
 
 
+def test_jinja2(*, tmp_path: Path) -> None:
+    """
+    It is possible to run commands against sphinx-jinja2 blocks.
+    """
+    runner = CliRunner(mix_stderr=False)
+    source_file = tmp_path / "example.rst"
+    content = textwrap.dedent(
+        text="""\
+        .. jinja::
+
+            {% set x = 1 %}
+            {{ x }}
+
+            .. Nested code block
+
+            .. code-block:: python
+
+               x = 2
+               print(x)
+        """,
+    )
+    source_file.write_text(data=content, encoding="utf-8")
+    arguments = [
+        "--sphinx-jinja2",
+        "--command",
+        "cat",
+        str(object=source_file),
+    ]
+    result = runner.invoke(
+        cli=main,
+        args=arguments,
+        catch_exceptions=False,
+        color=True,
+    )
+    assert result.exit_code == 0, (result.stdout, result.stderr)
+    expected_output = textwrap.dedent(
+        text="""\
+
+
+        {% set x = 1 %}
+        {{ x }}
+
+        .. Nested code block
+
+        .. code-block:: python
+
+           x = 2
+           print(x)
+        """
+    )
+    assert result.stdout == expected_output
+    assert result.stderr == ""
+
+
 def test_empty_language_given(*, tmp_path: Path) -> None:
     """
     An error is shown when an empty language is given.
diff --git a/tests/test_doccmd/test_help.txt b/tests/test_doccmd/test_help.txt
index 41ec150..b08dff2 100644
--- a/tests/test_doccmd/test_help.txt
+++ b/tests/test_doccmd/test_help.txt
@@ -7,7 +7,9 @@ Usage: doccmd [OPTIONS] [DOCUMENT_PATHS]...
 Options:
   -l, --language TEXT             Run `command` against code blocks for this
                                   language. Give multiple times for multiple
-                                  languages.  [required]
+                                  languages. If this is not given, no code
+                                  blocks are run, unless `--sphinx-jinja2` is
+                                  given.
   -c, --command TEXT              [required]
   --temporary-file-extension TEXT
                                   The file extension to give to the temporary
@@ -126,4 +128,11 @@ Options:
                                   code within a grouped code block. ``doccmd``
                                   does not support writing to grouped code
                                   blocks.  [default: fail-on-group-write]
+  --sphinx-jinja2 / --no-sphinx-jinja2
+                                  Whether to parse `sphinx-jinja2` blocks. This
+                                  is useful for evaluating code blocks with
+                                  Jinja2 templates used in Sphinx documentation.
+                                  This is supported for MyST and
+                                  reStructuredText files only.  [default: no-
+                                  sphinx-jinja2]
   --help                          Show this message and exit.