From 96247fa261057f3c0a6f70ac09d0b4d1fffea732 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 15 Aug 2026 14:54:42 +0200 Subject: [PATCH 1/2] [FEATURE] Support marker based partial include in literalinclude `literalinclude` always included the complete referenced file. Authors who want to show one relevant region of a real source or configuration file had to duplicate that region into a dedicated snippet file, which then silently drifts away from the original. Add the Sphinx options `:start-after:` and `:end-before:`. The included region starts on the line following the first line containing the `start-after` text and ends on the line preceding the first line containing the `end-before` text. `end-before` is searched behind the start of the region, so the same marker text may be used more than once in a file. Markers are stable against edits outside the marked region, unlike line numbers. Nothing is included, and a warning names the reason, when a marker is not found, when an option is used without a value, when `end-before` occurs only above the line matched by `start-after`, and when the marked region is empty. Falling back to the complete file in those cases would publish exactly the content the option was meant to exclude. The selection happens before the CodeNode is created, so it stays in the directive rather than in DefaultCodeNodeOptionMapper, which is shared with `code-block` and only maps display options. The logger is an optional constructor argument, as in ConfvalDirective, so that adding it does not break consumers that build the directive themselves. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss Signed-off-by: Sebastian Mendel --- .../Directives/LiteralincludeDirective.php | 139 +++++++++++++++++- .../expected/index.html | 18 +++ .../input/_code/_Example.php | 14 ++ .../literalinclude-end-before/input/index.rst | 6 + .../expected/index.html | 7 + .../expected/logs/warning.log | 1 + .../input/_code/_Example.php | 9 ++ .../input/index.rst | 7 + .../expected/index.html | 7 + .../expected/logs/warning.log | 1 + .../input/_code/_Example.php | 14 ++ .../input/index.rst | 6 + .../expected/index.html | 7 + .../expected/logs/warning.log | 1 + .../input/_code/_Example.php | 14 ++ .../input/index.rst | 7 + .../expected/index.html | 11 ++ .../input/_code/_Example.php | 14 ++ .../input/index.rst | 7 + .../expected/index.html | 14 ++ .../input/_code/_Example.php | 14 ++ .../input/index.rst | 6 + 22 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/tests/code/literalinclude-end-before/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-end-before/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-end-before/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-marker-empty-region/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-marker-empty-region/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-marker-empty-region/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-marker-empty-region/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-marker-not-found/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-marker-not-found/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-marker-not-found/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-marker-not-found/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-marker-out-of-order/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-marker-out-of-order/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-marker-out-of-order/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-marker-out-of-order/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-start-after-end-before/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-start-after-end-before/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-start-after-end-before/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-start-after/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-start-after/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-start-after/input/index.rst diff --git a/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php b/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php index 56e54ccdf..a367e8c40 100644 --- a/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php +++ b/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php @@ -18,15 +18,22 @@ use phpDocumentor\Guides\RestructuredText\Directives\OptionMapper\CodeNodeOptionMapper; use phpDocumentor\Guides\RestructuredText\Parser\BlockContext; use phpDocumentor\Guides\RestructuredText\Parser\Directive; +use Psr\Log\LoggerInterface; use RuntimeException; +use function array_slice; +use function count; use function explode; +use function is_string; use function sprintf; +use function str_contains; final class LiteralincludeDirective extends BaseDirective { - public function __construct(private readonly CodeNodeOptionMapper $codeNodeOptionMapper) - { + public function __construct( + private readonly CodeNodeOptionMapper $codeNodeOptionMapper, + private readonly LoggerInterface|null $logger = null, + ) { } public function getName(): string @@ -56,9 +63,135 @@ public function processNode( throw new RuntimeException(sprintf('Could not load file from path %s', $path)); } - $codeNode = new CodeNode(explode("\n", $contents)); + $lines = $this->selectLines(explode("\n", $contents), $directive, $blockContext); + + $codeNode = new CodeNode($lines); $this->codeNodeOptionMapper->apply($codeNode, $directive->getOptions(), $blockContext); return $codeNode; } + + /** + * Reduces the included file to the region enclosed by the ``start-after`` and ``end-before`` markers. + * + * The region starts on the line following the first line containing the ``start-after`` marker and ends + * on the line preceding the first line containing the ``end-before`` marker. The ``end-before`` marker + * is searched behind the start of the region, so the same marker text may be used more than once in a file. + * + * @param string[] $lines + * + * @return string[] + */ + private function selectLines(array $lines, Directive $directive, BlockContext $blockContext): array + { + $start = 0; + $end = count($lines); + + if ($directive->hasOption('start-after')) { + $marker = $this->optionValue($directive, 'start-after', $blockContext); + if ($marker === null) { + return []; + } + + $lineNumber = $this->findMarker($lines, $marker, $start); + if ($lineNumber === null) { + $this->warnMarkerNotFound($directive, 'start-after', $marker, $blockContext); + + return []; + } + + $start = $lineNumber + 1; + } + + if ($directive->hasOption('end-before')) { + $marker = $this->optionValue($directive, 'end-before', $blockContext); + if ($marker === null) { + return []; + } + + $lineNumber = $this->findMarker($lines, $marker, $start); + if ($lineNumber === null) { + if ($this->findMarker($lines, $marker, 0) === null) { + $this->warnMarkerNotFound($directive, 'end-before', $marker, $blockContext); + } else { + $this->logger?->warning( + sprintf( + 'Option ":end-before:" of directive "literalinclude": "%s" occurs in "%s" only above the line matched by ":start-after:", nothing was included.', + $marker, + $directive->getData(), + ), + $blockContext->getLoggerInformation(), + ); + } + + return []; + } + + $end = $lineNumber; + } + + $selection = array_slice($lines, $start, $end - $start); + + if ($selection === []) { + $this->logger?->warning( + sprintf( + 'Directive "literalinclude": the region marked in "%s" is empty, nothing was included.', + $directive->getData(), + ), + $blockContext->getLoggerInformation(), + ); + } + + return $selection; + } + + /** Returns the text of an option, or null if the option was used without a usable value. */ + private function optionValue(Directive $directive, string $option, BlockContext $blockContext): string|null + { + $value = $directive->getOption($option)->getValue(); + if (!is_string($value) || $value === '') { + $this->logger?->warning( + sprintf('Option ":%s:" of directive "literalinclude" requires a value, nothing was included.', $option), + $blockContext->getLoggerInformation(), + ); + + return null; + } + + return $value; + } + + /** + * Returns the number of the first line at or behind $offset that contains $marker, or null if there is none. + * + * @param string[] $lines + */ + private function findMarker(array $lines, string $marker, int $offset): int|null + { + $lineCount = count($lines); + for ($lineNumber = $offset; $lineNumber < $lineCount; $lineNumber++) { + if (str_contains($lines[$lineNumber], $marker)) { + return $lineNumber; + } + } + + return null; + } + + private function warnMarkerNotFound( + Directive $directive, + string $option, + string $marker, + BlockContext $blockContext, + ): void { + $this->logger?->warning( + sprintf( + 'Option ":%s:" of directive "literalinclude": no line containing "%s" was found in "%s", nothing was included.', + $option, + $marker, + $directive->getData(), + ), + $blockContext->getLoggerInformation(), + ); + } } diff --git a/tests/Integration/tests/code/literalinclude-end-before/expected/index.html b/tests/Integration/tests/code/literalinclude-end-before/expected/index.html new file mode 100644 index 000000000..b52e1159b --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-end-before/expected/index.html @@ -0,0 +1,18 @@ + +
+

index

+
<?php
+
+declare(strict_types=1);
+
+class Example
+{
+    // begin example
+    public function test(): string
+    {
+        return 'this is a test';
+    }
+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-end-before/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-end-before/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-end-before/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-marker-empty-region/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-marker-empty-region/expected/logs/warning.log new file mode 100644 index 000000000..9576b221b --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-empty-region/expected/logs/warning.log @@ -0,0 +1 @@ +Directive "literalinclude": the region marked in "_code/_Example.php" is empty, nothing was included. diff --git a/tests/Integration/tests/code/literalinclude-marker-empty-region/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-marker-empty-region/input/_code/_Example.php new file mode 100644 index 000000000..bf1f08ae0 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-empty-region/input/_code/_Example.php @@ -0,0 +1,9 @@ + +
+

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-marker-not-found/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-marker-not-found/expected/logs/warning.log new file mode 100644 index 000000000..a8428c6e5 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-not-found/expected/logs/warning.log @@ -0,0 +1 @@ +Option ":start-after:" of directive "literalinclude": no line containing "// this marker does not exist" was found in "_code/_Example.php", nothing was included. diff --git a/tests/Integration/tests/code/literalinclude-marker-not-found/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-marker-not-found/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-not-found/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-marker-out-of-order/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-marker-out-of-order/expected/logs/warning.log new file mode 100644 index 000000000..f599c8856 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-out-of-order/expected/logs/warning.log @@ -0,0 +1 @@ +Option ":end-before:" of directive "literalinclude": "// begin example" occurs in "_code/_Example.php" only above the line matched by ":start-after:", nothing was included. diff --git a/tests/Integration/tests/code/literalinclude-marker-out-of-order/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-marker-out-of-order/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-marker-out-of-order/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
    public function test(): string
+    {
+        return 'this is a test';
+    }
+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-start-after-end-before/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-start-after-end-before/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-start-after-end-before/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
    public function test(): string
+    {
+        return 'this is a test';
+    }
+
+    // end example
+}
+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-start-after/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-start-after/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-start-after/input/_code/_Example.php @@ -0,0 +1,14 @@ + Date: Sat, 15 Aug 2026 14:54:42 +0200 Subject: [PATCH 2/2] [DOCS] Document the literalinclude directive The reference section had no page for `literalinclude`, so neither the directive itself nor its options were documented anywhere. Add one, covering the directive, the new `:start-after:` and `:end-before:` options, and the options the shared code node option mapper provides. Every documented behaviour was rendered and read back before being written down, including what happens when the file is missing (error, nothing rendered) and when a marker is not found (warning, nothing included). Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss Signed-off-by: Sebastian Mendel --- .../restructuredtext/literalinclude.rst | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/reference/restructuredtext/literalinclude.rst diff --git a/docs/reference/restructuredtext/literalinclude.rst b/docs/reference/restructuredtext/literalinclude.rst new file mode 100644 index 000000000..34172275a --- /dev/null +++ b/docs/reference/restructuredtext/literalinclude.rst @@ -0,0 +1,75 @@ +.. include:: /include.rst.txt + +.. _literalinclude: + +============== +Literalinclude +============== + +The ``literalinclude`` directive includes the content of another file as a code block. It lets the documentation +show a real, working file instead of a copy that has to be kept in sync by hand. + +.. code-block:: + + .. literalinclude:: Example.php + :language: php + +The path is resolved relative to the document that contains the directive, a path starting with ``/`` relative to +the root of the documentation. If the file cannot be read, an error is logged and nothing is rendered in its place. +Rendering with ``--fail-on-error`` turns that error into a failing build. + +Including only a part of a file +=============================== + +Often only one region of a file is worth showing. The options ``:start-after:`` and ``:end-before:`` select that +region by the text of the lines enclosing it: + +.. code-block:: + + .. literalinclude:: Example.php + :language: php + :start-after: // begin example + :end-before: // end example + +The region starts on the line *following* the first line that contains the ``start-after`` text, and ends on the +line *preceding* the first line that contains the ``end-before`` text. Both marker lines are left out. The +``end-before`` text is searched behind the start of the region, so the same marker may occur several times in one +file. + +Either option can be used on its own: ``start-after`` alone includes everything down to the end of the file, +``end-before`` alone everything from the beginning of the file. + +Markers remain correct when the file is edited above or below the selected region. That is why they are the better +choice for a file that is under active development. + +If no line contains the given text, a warning is logged and nothing is included, so that the option cannot silently +publish the very content it was meant to exclude. The same happens when the marked region turns out to be empty, or +when the ``end-before`` text occurs only above the line matched by ``start-after``. Rendering with ``--fail-on-log`` +turns any of these warnings into a failing build. + +Options +======= + +``:language:`` + Language used for syntax highlighting, for example ``php``, ``bash`` or ``yaml``. + +``:caption:`` + Caption rendered above the code block. Inline markup such as ``**bold**`` is allowed. + +``:start-after:`` + Text of the line the included region starts after. The line itself is not included. + +``:end-before:`` + Text of the line the included region ends before. The line itself is not included. + +``:emphasize-lines:`` + Line numbers to be highlighted, for example ``3,5-6``. Counted within the included region. + +``:linenos:`` + Displays line numbers, starting at 1. + +``:lineno-start:`` + First line number to display. Also switches the numbering on. + +``:number-lines:`` + Displays line numbers. Takes the first line number as an optional value.