Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/reference/restructuredtext/literalinclude.rst
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- content start -->
<div class="section" id="index">
<h1>index</h1>
<pre><code class="language-php">&lt;?php

declare(strict_types=1);

class Example
{
// begin example
public function test(): string
{
return &#039;this is a test&#039;;
}
</code></pre>

</div>
<!-- content end -->
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

class Example
{
// begin example
public function test(): string
{
return 'this is a test';
}

// end example
}
Loading
Loading