From 96247fa261057f3c0a6f70ac09d0b4d1fffea732 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 15 Aug 2026 14:54:42 +0200 Subject: [PATCH 1/4] [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/4] [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. From d1ea39211b18bdc9c8f80b2a34ce28692510268c Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 15 Aug 2026 14:57:30 +0200 Subject: [PATCH 3/4] [FEATURE] Support :lines: in literalinclude Add the Sphinx option `:lines:` to `literalinclude`, so a region can also be selected by line number, for example `:lines: 1,3-5,20-`. Line numbers are 1 based, ranges are inclusive, an omitted end means "up to the last line", and overlapping ranges are included once, in document order. The accepted spelling is the one already used by `:emphasize-lines:`, so both options are written the same way. Like Sphinx, `:lines:` is applied after `:start-after:` and `:end-before:`, therefore the numbers count within the marked region rather than within the file. Markers stay the more robust choice, because line numbers break on every edit above the selected region. Both ends of a range are clamped to the lines that exist, so a range far beyond the end of the file selects nothing instead of iterating over the numbers the author wrote down. Each range that selects no line is warned about on its own, naming the range, while the remaining ranges are still included. A missing value, or a value that is not a list of line numbers, logs a warning and includes nothing. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss Signed-off-by: Sebastian Mendel --- .../restructuredtext/literalinclude.rst | 27 ++++++ .../Directives/LiteralincludeDirective.php | 95 +++++++++++++++++++ .../expected/index.html | 21 ++++ .../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 | 6 ++ .../expected/index.html | 7 ++ .../expected/logs/warning.log | 1 + .../input/_code/_Example.php | 14 +++ .../input/index.rst | 6 ++ .../expected/index.html | 14 +++ .../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 | 6 ++ .../expected/index.html | 8 ++ .../expected/logs/warning.log | 1 + .../input/_code/_Example.php | 14 +++ .../input/index.rst | 6 ++ .../expected/index.html | 10 ++ .../input/_code/_Example.php | 14 +++ .../input/index.rst | 8 ++ .../literalinclude-lines/expected/index.html | 10 ++ .../input/_code/_Example.php | 14 +++ .../code/literalinclude-lines/input/index.rst | 6 ++ 30 files changed, 372 insertions(+) create mode 100644 tests/Integration/tests/code/literalinclude-lines-huge-range/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-huge-range/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-huge-range/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-invalid/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-invalid/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-lines-invalid/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-invalid/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-no-value/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-no-value/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-lines-no-value/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-no-value/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-open-end/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-open-end/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-open-end/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-out-of-range/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-out-of-range/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-lines-out-of-range/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-out-of-range/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/expected/logs/warning.log create mode 100644 tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines-within-markers/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines-within-markers/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines-within-markers/input/index.rst create mode 100644 tests/Integration/tests/code/literalinclude-lines/expected/index.html create mode 100644 tests/Integration/tests/code/literalinclude-lines/input/_code/_Example.php create mode 100644 tests/Integration/tests/code/literalinclude-lines/input/index.rst diff --git a/docs/reference/restructuredtext/literalinclude.rst b/docs/reference/restructuredtext/literalinclude.rst index 34172275a..d72548bcb 100644 --- a/docs/reference/restructuredtext/literalinclude.rst +++ b/docs/reference/restructuredtext/literalinclude.rst @@ -47,6 +47,29 @@ publish the very content it was meant to exclude. The same happens when the mark 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. +Selecting lines by number +========================= + +A region can also be selected by line number, which is useful for files that have no natural marker: + +.. code-block:: + + .. literalinclude:: Example.php + :language: php + :lines: 1,3-5,20- + +Line numbers start at 1, ranges are inclusive, and an omitted end means "down to the last line". Overlapping ranges +are included once, in the order of the file. + +``:lines:`` is applied *after* ``:start-after:`` and ``:end-before:``, so when they are combined the numbers count +within the marked region rather than within the whole file. + +Prefer markers where a file has them: line numbers change with every edit above the selected region, and nothing in +the source file records that the documentation depends on them. + +If the value cannot be read as a list of line numbers, or if it selects no line at all, a warning is logged and +nothing is included. + Options ======= @@ -62,6 +85,10 @@ Options ``:end-before:`` Text of the line the included region ends before. The line itself is not included. +``:lines:`` + Line numbers to be included, for example ``1,3-5,20-``. Counted within the region selected by ``start-after`` + and ``end-before``. + ``:emphasize-lines:`` Line numbers to be highlighted, for example ``3,5-6``. Counted within the included region. diff --git a/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php b/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php index a367e8c40..119da1116 100644 --- a/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php +++ b/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php @@ -16,17 +16,24 @@ use phpDocumentor\Guides\Nodes\CodeNode; use phpDocumentor\Guides\Nodes\Node; use phpDocumentor\Guides\RestructuredText\Directives\OptionMapper\CodeNodeOptionMapper; +use phpDocumentor\Guides\RestructuredText\Directives\OptionMapper\DefaultCodeNodeOptionMapper; use phpDocumentor\Guides\RestructuredText\Parser\BlockContext; use phpDocumentor\Guides\RestructuredText\Parser\Directive; use Psr\Log\LoggerInterface; use RuntimeException; use function array_slice; +use function array_values; use function count; use function explode; use function is_string; +use function ksort; +use function max; +use function min; +use function preg_match; use function sprintf; use function str_contains; +use function trim; final class LiteralincludeDirective extends BaseDirective { @@ -140,11 +147,99 @@ private function selectLines(array $lines, Directive $directive, BlockContext $b ), $blockContext->getLoggerInformation(), ); + + return []; + } + + if ($directive->hasOption('lines')) { + $selection = $this->selectLineRanges($selection, $directive, $blockContext); } return $selection; } + /** + * Reduces the included region to the line numbers listed in ``lines``, for example ``1,3-5,20-``. + * + * Line numbers are 1 based, ranges are inclusive and an omitted end means "up to the last line". + * They count within the region selected by ``start-after`` and ``end-before``, not within the file. + * + * @param string[] $lines + * + * @return string[] + */ + private function selectLineRanges(array $lines, Directive $directive, BlockContext $blockContext): array + { + $specification = $this->optionValue($directive, 'lines', $blockContext); + if ($specification === null) { + return []; + } + + if (preg_match(DefaultCodeNodeOptionMapper::LINE_NUMBER_RANGES_REGEX, $specification) !== 1) { + $this->logger?->warning( + sprintf( + 'Invalid value for option ":lines:" of directive "literalinclude": "%s". Expected format: \'1-5, 7, 33\'. Nothing was included.', + $specification, + ), + $blockContext->getLoggerInformation(), + ); + + return []; + } + + $selected = []; + foreach (explode(',', $specification) as $range) { + $range = trim($range); + [$first, $last] = $this->parseRange($range, count($lines)); + + if ($first > $last) { + $this->logger?->warning( + sprintf( + 'Option ":lines:" of directive "literalinclude": the range "%s" selects no line of "%s".', + $range, + $directive->getData(), + ), + $blockContext->getLoggerInformation(), + ); + + continue; + } + + for ($lineNumber = $first; $lineNumber <= $last; $lineNumber++) { + $selected[$lineNumber] = $lines[$lineNumber - 1]; + } + } + + ksort($selected); + + return array_values($selected); + } + + /** + * Splits a single entry of the ``lines`` option into its first and last line number. + * + * Both are clamped to the lines actually available, so that a range far beyond the end of the file + * does not turn into a loop over the numbers the author wrote down. A first line greater than the + * last one means the range selects nothing. + * + * @return array{int, int} + */ + private function parseRange(string $range, int $lineCount): array + { + if (!str_contains($range, '-')) { + $lineNumber = (int) $range; + + return [max(1, $lineNumber), min($lineNumber, $lineCount)]; + } + + [$first, $last] = explode('-', $range, 2); + + return [ + max(1, (int) $first), + trim($last) === '' ? $lineCount : min((int) $last, $lineCount), + ]; + } + /** 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 { diff --git a/tests/Integration/tests/code/literalinclude-lines-huge-range/expected/index.html b/tests/Integration/tests/code/literalinclude-lines-huge-range/expected/index.html new file mode 100644 index 000000000..d9f0310fe --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-huge-range/expected/index.html @@ -0,0 +1,21 @@ + +
+

index

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

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-lines-invalid/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-lines-invalid/expected/logs/warning.log new file mode 100644 index 000000000..e09ab030c --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-invalid/expected/logs/warning.log @@ -0,0 +1 @@ +Invalid value for option ":lines:" of directive "literalinclude": "not a range". Expected format: '1-5, 7, 33'. Nothing was included. diff --git a/tests/Integration/tests/code/literalinclude-lines-invalid/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-invalid/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-invalid/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-lines-no-value/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-lines-no-value/expected/logs/warning.log new file mode 100644 index 000000000..961e0dfcd --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-no-value/expected/logs/warning.log @@ -0,0 +1 @@ +Option ":lines:" of directive "literalinclude" requires a value, nothing was included. diff --git a/tests/Integration/tests/code/literalinclude-lines-no-value/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-no-value/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-no-value/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-lines-open-end/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-open-end/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-open-end/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-lines-out-of-range/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-lines-out-of-range/expected/logs/warning.log new file mode 100644 index 000000000..299709102 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-out-of-range/expected/logs/warning.log @@ -0,0 +1 @@ +Option ":lines:" of directive "literalinclude": the range "100-200" selects no line of "_code/_Example.php". diff --git a/tests/Integration/tests/code/literalinclude-lines-out-of-range/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-out-of-range/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-out-of-range/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
<?php
+
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/expected/logs/warning.log b/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/expected/logs/warning.log new file mode 100644 index 000000000..763cba5c8 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/expected/logs/warning.log @@ -0,0 +1 @@ +Option ":lines:" of directive "literalinclude": the range "100" selects no line of "_code/_Example.php". diff --git a/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-partly-out-of-range/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-lines-within-markers/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines-within-markers/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines-within-markers/input/_code/_Example.php @@ -0,0 +1,14 @@ + +
+

index

+
<?php
+declare(strict_types=1);
+class Example
+{
+ +
+ diff --git a/tests/Integration/tests/code/literalinclude-lines/input/_code/_Example.php b/tests/Integration/tests/code/literalinclude-lines/input/_code/_Example.php new file mode 100644 index 000000000..064044f94 --- /dev/null +++ b/tests/Integration/tests/code/literalinclude-lines/input/_code/_Example.php @@ -0,0 +1,14 @@ + Date: Sat, 15 Aug 2026 14:57:34 +0200 Subject: [PATCH 4/4] [DOCS] Document the :lines: option of literalinclude Add the `:lines:` option to the reference page: syntax, that overlapping ranges are included once in the order of the file, that it is applied after the markers and therefore counts within the marked region, and what happens when the value selects nothing. Each of these was rendered and read back before being written down. Assisted-by: claude-code:claude-opus-5 Agent-Session: https://claude.ai/code/session_015QXXkquh2eQNBiTYA39Wss Signed-off-by: Sebastian Mendel --- docs/reference/restructuredtext/literalinclude.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/restructuredtext/literalinclude.rst b/docs/reference/restructuredtext/literalinclude.rst index d72548bcb..71013aff9 100644 --- a/docs/reference/restructuredtext/literalinclude.rst +++ b/docs/reference/restructuredtext/literalinclude.rst @@ -67,8 +67,9 @@ within the marked region rather than within the whole file. Prefer markers where a file has them: line numbers change with every edit above the selected region, and nothing in the source file records that the documentation depends on them. -If the value cannot be read as a list of line numbers, or if it selects no line at all, a warning is logged and -nothing is included. +Every range is warned about separately when it selects no line, for example because it lies beyond the end of the +region; the remaining ranges are still included. If the option is used without a value, or its value cannot be read +as a list of line numbers, a warning is logged and nothing is included. Options =======