diff --git a/docs/reference/restructuredtext/literalinclude.rst b/docs/reference/restructuredtext/literalinclude.rst new file mode 100644 index 000000000..71013aff9 --- /dev/null +++ b/docs/reference/restructuredtext/literalinclude.rst @@ -0,0 +1,103 @@ +.. 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. + +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. + +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 +======= + +``: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. + +``: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. + +``: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. diff --git a/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php b/packages/guides-restructured-text/src/RestructuredText/Directives/LiteralincludeDirective.php index 56e54ccdf..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,31 @@ 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 { - 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 +70,223 @@ 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 []; + } + + 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 + { + $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

+
<?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 @@ + +
+

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 @@ +