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. 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 @@ + +
<?php
+
+declare(strict_types=1);
+
+class Example
+{
+ // begin example
+ public function test(): string
+ {
+ return 'this is a test';
+ }
+
+
+
+
+
+
+
+
+ public function test(): string
+ {
+ return 'this is a test';
+ }
+
+
+ public function test(): string
+ {
+ return 'this is a test';
+ }
+
+ // end example
+}
+
+
+