Update website

This commit is contained in:
Guilhem Lavaux 2024-11-19 08:02:04 +01:00
parent 4413528994
commit 1d90fbf296
6865 changed files with 1091082 additions and 0 deletions

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use function array_pop;
use function count;
use function implode;
final class Buffer
{
/** @var string[] */
private $lines = [];
/** @param string[] $lines */
public function __construct(array $lines = [])
{
$this->lines = $lines;
}
public function isEmpty(): bool
{
return $this->lines === [];
}
public function count(): int
{
return count($this->lines);
}
public function has(int $key): bool
{
return isset($this->lines[$key]);
}
public function get(int $key): string
{
return $this->lines[$key] ?? '';
}
public function push(string $line): void
{
$this->lines[] = $line;
}
public function set(int $key, string $line): void
{
$this->lines[$key] = $line;
}
/** @return string[] */
public function getLines(): array
{
return $this->lines;
}
public function getLinesString(): string
{
return implode("\n", $this->lines);
}
public function pop(): ?string
{
return array_pop($this->lines);
}
public function getLastLine(): ?string
{
$lastLineKey = count($this->lines) - 1;
if (! isset($this->lines[$lastLineKey])) {
return null;
}
return $this->lines[$lastLineKey];
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
final class DefinitionList
{
/** @var DefinitionListTerm[] */
private $terms = [];
/** @param DefinitionListTerm[] $terms */
public function __construct(array $terms)
{
$this->terms = $terms;
}
/** @return DefinitionListTerm[] */
public function getTerms(): array
{
return $this->terms;
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\RST\Nodes\Node;
use Doctrine\RST\Nodes\SpanNode;
use RuntimeException;
final class DefinitionListTerm
{
/** @var SpanNode */
private $term;
/** @var SpanNode[] */
private $classifiers = [];
/** @var Node[] */
private $definitions = [];
/**
* @param SpanNode[] $classifiers
* @param Node[] $definitions
*/
public function __construct(SpanNode $term, array $classifiers, array $definitions)
{
$this->term = $term;
$this->classifiers = $classifiers;
$this->definitions = $definitions;
}
public function getTerm(): SpanNode
{
return $this->term;
}
/** @return SpanNode[] */
public function getClassifiers(): array
{
return $this->classifiers;
}
/** @return Node[] */
public function getDefinitions(): array
{
return $this->definitions;
}
public function getFirstDefinition(): Node
{
if (! isset($this->definitions[0])) {
throw new RuntimeException('No definitions found.');
}
return $this->definitions[0];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
final class Directive
{
/** @var string */
private $variable;
/** @var string */
private $name;
/** @var string */
private $data;
/** @var mixed[] */
private $options = [];
/** @param mixed[] $options */
public function __construct(string $variable, string $name, string $data, array $options = [])
{
$this->variable = $variable;
$this->name = $name;
$this->data = $data;
$this->options = $options;
}
public function getVariable(): string
{
return $this->variable;
}
public function getName(): string
{
return $this->name;
}
public function getData(): string
{
return $this->data;
}
/** @return mixed[] */
public function getOptions(): array
{
return $this->options;
}
/** @param mixed $value */
public function setOption(string $key, $value): void
{
$this->options[$key] = $value;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
final class DirectiveOption
{
/** @var string */
private $name;
/** @var mixed */
private $value;
/** @param mixed $value */
public function __construct(string $name, $value)
{
$this->name = $name;
$this->value = $value;
}
public function getName(): string
{
return $this->name;
}
/** @return mixed */
public function getValue()
{
return $this->value;
}
}

View file

@ -0,0 +1,754 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\Common\EventManager;
use Doctrine\RST\Directives\Directive;
use Doctrine\RST\Environment;
use Doctrine\RST\Event\PostParseDocumentEvent;
use Doctrine\RST\Event\PreParseDocumentEvent;
use Doctrine\RST\FileIncluder;
use Doctrine\RST\NodeFactory\NodeFactory;
use Doctrine\RST\Nodes\DocumentNode;
use Doctrine\RST\Nodes\Node;
use Doctrine\RST\Nodes\TableNode;
use Doctrine\RST\Nodes\TitleNode;
use Doctrine\RST\Parser;
use Doctrine\RST\Parser\Directive as ParserDirective;
use Exception;
use Throwable;
use function array_reverse;
use function array_search;
use function assert;
use function chr;
use function explode;
use function fwrite;
use function getenv;
use function ltrim;
use function max;
use function sprintf;
use function str_replace;
use function strlen;
use function substr;
use function trim;
use const PHP_SAPI;
use const STDERR;
final class DocumentParser
{
/** @var Parser */
private $parser;
/** @var Environment */
private $environment;
/** @var NodeFactory */
private $nodeFactory;
/** @var EventManager */
private $eventManager;
/** @var Directive[] */
private $directives = [];
/** @var bool */
private $includeAllowed = true;
/** @var string */
private $includeRoot = '';
/** @var DocumentNode */
private $document;
/** @var false|string|null */
private $specialLetter;
/** @var ParserDirective|null */
private $directive;
/** @var LineDataParser */
private $lineDataParser;
/** @var LineChecker */
private $lineChecker;
/** @var TableParser */
private $tableParser;
/** @var Buffer */
private $buffer;
/** @var Node|null */
private $nodeBuffer;
/** @var bool */
private $isCode = false;
/** @var Lines */
private $lines;
/** @var int|null */
private $currentLineNumber;
/** @var string */
private $state;
/** @var TitleNode */
private $lastTitleNode;
/** @var TitleNode[] */
private $openTitleNodes = [];
/** @var int */
private $listOffset = 0;
/** @var string|null */
private $listMarker = null;
/** @param Directive[] $directives */
public function __construct(
Parser $parser,
Environment $environment,
NodeFactory $nodeFactory,
EventManager $eventManager,
array $directives,
bool $includeAllowed,
string $includeRoot
) {
$this->parser = $parser;
$this->environment = $environment;
$this->nodeFactory = $nodeFactory;
$this->eventManager = $eventManager;
$this->directives = $directives;
$this->includeAllowed = $includeAllowed;
$this->includeRoot = $includeRoot;
$this->lineDataParser = new LineDataParser($this->parser, $eventManager);
$this->lineChecker = new LineChecker();
$this->tableParser = new TableParser();
$this->buffer = new Buffer();
}
public function getDocument(): DocumentNode
{
return $this->document;
}
public function parse(string $contents): DocumentNode
{
$preParseDocumentEvent = new PreParseDocumentEvent($this->parser, $contents);
$this->eventManager->dispatchEvent(
PreParseDocumentEvent::PRE_PARSE_DOCUMENT,
$preParseDocumentEvent
);
$this->document = $this->nodeFactory->createDocumentNode($this->environment);
$this->init();
$this->parseLines(trim($preParseDocumentEvent->getContents()));
foreach ($this->directives as $name => $directive) {
$directive->finalize($this->document);
}
$this->eventManager->dispatchEvent(
PostParseDocumentEvent::POST_PARSE_DOCUMENT,
new PostParseDocumentEvent($this->document)
);
return $this->document;
}
private function init(): void
{
$this->specialLetter = false;
$this->buffer = new Buffer();
$this->nodeBuffer = null;
$this->listOffset = 0;
$this->listMarker = null;
}
private function setState(string $state): void
{
$this->state = $state;
}
private function prepareDocument(string $document): string
{
$document = str_replace("\r\n", "\n", $document);
$document = sprintf("\n%s\n", $document);
$document = (new FileIncluder(
$this->environment,
$this->includeAllowed,
$this->includeRoot
))->includeFiles($document);
// Removing UTF-8 BOM
$document = str_replace("\xef\xbb\xbf", '', $document);
// Replace \u00a0 with " "
$document = str_replace(chr(194) . chr(160), ' ', $document);
return $document;
}
private function createLines(string $document): Lines
{
return new Lines(explode("\n", $document));
}
private function parseLines(string $document): void
{
$document = $this->prepareDocument($document);
$this->lines = $this->createLines($document);
$this->setState(State::BEGIN);
foreach ($this->lines as $i => $line) {
$this->currentLineNumber = $i + 1;
while (true) {
if ($this->parseLine($line)) {
break;
}
}
}
$this->currentLineNumber = null;
// DocumentNode is flushed twice to trigger the directives
$this->flush();
$this->flush();
foreach ($this->openTitleNodes as $titleNode) {
$this->endOpenSection($titleNode);
}
}
/**
* Return true if this line has completed process.
*
* If false is returned, this function will be called again with the same line.
* This is useful when you switched state and want to parse the line again
* with the new state (e.g. when the end of a list is found, you want the line
* to be parsed as "BEGIN" again).
*/
private function parseLine(string $line): bool
{
if (getenv('SHELL_VERBOSITY') >= 3 && PHP_SAPI === 'cli') {
fwrite(STDERR, sprintf("Parsing line: %s\n", $line));
}
switch ($this->state) {
case State::BEGIN:
if (trim($line) !== '') {
if ($this->lineChecker->isListLine($line, $this->listMarker, $this->listOffset, $this->lines->getNextLine())) {
$this->setState(State::LIST);
$this->buffer->push($line);
return true;
}
// Represents a literal block here the entire line is literally "::"
// Ref: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html#literal-blocks
// > If it occurs as a paragraph of its own, that paragraph is completely left out of the document.
if (trim($line) === '::') {
$this->isCode = true;
// return true to move onto the next line, this line is omitted
return true;
}
if ($this->lineChecker->isBlockLine($line)) {
if ($this->isCode) {
$this->setState(State::CODE);
} else {
$this->setState(State::BLOCK);
}
return false;
}
if ($this->lineChecker->isComment($line)) {
$this->flush();
$this->setState(State::COMMENT);
return false;
}
if ($this->parseLink($line)) {
return true;
}
if ($this->lineChecker->isDirective($line)) {
$this->setState(State::DIRECTIVE);
$this->buffer = new Buffer();
$this->flush();
$this->initDirective($line);
return true;
}
$separatorLineConfig = $this->tableParser->parseTableSeparatorLine($line);
if ($separatorLineConfig !== null) {
$this->setState(State::TABLE);
$tableNode = $this->nodeFactory->createTableNode(
$separatorLineConfig,
$this->tableParser->guessTableType($line),
$this->lineChecker
);
$this->nodeBuffer = $tableNode;
return true;
}
if (trim($this->lines->getNextLine()) !== '' && $this->lineChecker->isIndented($this->lines->getNextLine())) {
$this->setState(State::DEFINITION_LIST);
$this->buffer->push($line);
return true;
}
if ($this->getCurrentDirective() !== null && ! $this->getCurrentDirective()->appliesToNonBlockContent()) {
// If there is a directive set, it means we are the line *after* that directive
// But the state is being set to NORMAL, which means we are a non-indented line.
// Some special directives (like class) allow their content to be non-indented.
// But most do not, which means that our directive is now finished.
// We flush so that the directive can be processed. It will be passed a
// null node (We know because we are currently in a NEW state. If there
// had been legitimately-indented content, that would have matched some
// other state (e.g. BLOCK or CODE) and flushed when it finished.
$this->flush();
}
$this->setState(State::NORMAL);
return false;
}
break;
case State::LIST:
if (! $this->lineChecker->isListLine($line, $this->listMarker, $this->listOffset) && ! $this->lineChecker->isBlockLine($line, max(1, $this->listOffset))) {
if (trim($this->lines->getPreviousLine()) !== '') {
$this->environment->getErrorManager()->warning(
'List ends without a blank line; unexpected unindent',
$this->environment->getCurrentFileName(),
$this->currentLineNumber !== null ? $this->currentLineNumber - 1 : null
);
}
$this->flush();
$this->setState(State::BEGIN);
return false;
}
// the list item offset is determined by the offset of the first text.
// An offset of 1 or lower indicates that the list line didn't contain any text.
if ($this->listOffset <= 1) {
$this->listOffset = strlen($line) - strlen(ltrim($line));
}
$this->buffer->push($line);
break;
case State::DEFINITION_LIST:
if ($this->lineChecker->isDefinitionListEnded($line, $this->lines->getNextLine())) {
$this->flush();
$this->setState(State::BEGIN);
return false;
}
$this->buffer->push($line);
break;
case State::TABLE:
if (trim($line) === '') {
$this->flush();
$this->setState(State::BEGIN);
} else {
$separatorLineConfig = $this->tableParser->parseTableSeparatorLine($line);
// not sure if this is possible, being cautious
if (! $this->nodeBuffer instanceof TableNode) {
throw new Exception('Node Buffer should be a TableNode instance');
}
// push the separator or content line onto the TableNode
if ($separatorLineConfig !== null) {
$this->nodeBuffer->pushSeparatorLine($separatorLineConfig);
} else {
$this->nodeBuffer->pushContentLine($line);
}
}
break;
case State::NORMAL:
if (trim($line) !== '') {
$specialLetter = $this->lineChecker->isSpecialLine($line);
if ($specialLetter !== null) {
$this->specialLetter = $specialLetter;
$lastLine = $this->buffer->pop();
if ($lastLine !== null) {
$this->buffer = new Buffer([$lastLine]);
$this->setState(State::TITLE);
} else {
$this->buffer->push($line);
$this->setState(State::SEPARATOR);
}
$this->flush();
$this->setState(State::BEGIN);
} elseif ($this->lineChecker->isDirective($line)) {
$this->flush();
$this->setState(State::BEGIN);
return false;
} elseif ($this->lineChecker->isComment($line)) {
$this->flush();
$this->setState(State::COMMENT);
} else {
$this->buffer->push($line);
}
} else {
$this->flush();
$this->setState(State::BEGIN);
}
break;
case State::COMMENT:
if (! $this->lineChecker->isComment($line) && (trim($line) === '' || $line[0] !== ' ')) {
$this->setState(State::BEGIN);
return false;
}
break;
case State::BLOCK:
case State::CODE:
if (! $this->lineChecker->isBlockLine($line)) {
// the previous line(s) was in a block (indented), but
// this line is no longer indented
$this->flush();
$this->setState(State::BEGIN);
return false;
}
$this->buffer->push($line);
break;
case State::DIRECTIVE:
if (! $this->isDirectiveOption($line)) {
if (! $this->lineChecker->isDirective($line)) {
$directive = $this->getCurrentDirective();
$this->isCode = $directive !== null ? $directive->wantCode() : false;
$this->setState(State::BEGIN);
return false;
}
$this->flush();
$this->initDirective($line);
}
break;
default:
$this->environment->getErrorManager()->error('Parser ended in an unexcepted state');
}
return true;
}
private function flush(): void
{
$node = null;
$this->isCode = false;
if ($this->hasBuffer()) {
switch ($this->state) {
case State::TITLE:
$data = $this->buffer->getLinesString();
$level = $this->environment->getLevel((string) $this->specialLetter);
$level = $this->environment->getConfiguration()->getInitialHeaderLevel() + $level - 1;
$token = $this->environment->createTitle($level);
$node = $this->nodeFactory->createTitleNode(
$this->parser->createSpanNode($data),
$level,
$token
);
if ($this->lastTitleNode !== null) {
// current level is less than previous so we need to
// end previous open sections with a greater or equal level
if ($node->getLevel() < $this->lastTitleNode->getLevel()) {
foreach (array_reverse($this->openTitleNodes) as $titleNode) {
if ($node->getLevel() > $titleNode->getLevel()) {
break;
}
$this->endOpenSection($titleNode);
}
// same level as the last so just close the last open section
} elseif ($node->getLevel() === $this->lastTitleNode->getLevel()) {
$this->endOpenSection($this->lastTitleNode);
}
}
$this->lastTitleNode = $node;
$this->document->addNode(
$this->nodeFactory->createSectionBeginNode($node)
);
$this->openTitleNodes[] = $node;
break;
case State::SEPARATOR:
$level = $this->environment->getLevel((string) $this->specialLetter);
$node = $this->nodeFactory->createSeparatorNode($level);
break;
case State::CODE:
/** @var string[] $buffer */
$buffer = $this->buffer->getLines();
$node = $this->nodeFactory->createCodeNode($buffer);
break;
case State::BLOCK:
/** @var string[] $lines */
$lines = $this->buffer->getLines();
$node = $this->nodeFactory->createBlockNode($lines);
// This means we are in an indented area that is not a code block
// or definition list.
// If we're NOT in a directive, then this must be a blockquote.
// If we ARE in a directive, allow the directive to convert
// the BlockNode into what it needs
if ($this->directive === null) {
$document = $this->parser->getSubParser()->parseLocal($node->getValue());
$node = $this->nodeFactory->createQuoteNode($document);
}
break;
case State::LIST:
$list = $this->lineDataParser->parseList(
$this->buffer->getLines()
);
$node = $this->nodeFactory->createListNode($list, $list[0]->isOrdered());
break;
case State::DEFINITION_LIST:
$definitionList = $this->lineDataParser->parseDefinitionList(
$this->buffer->getLines()
);
$node = $this->nodeFactory->createDefinitionListNode($definitionList);
break;
case State::TABLE:
$node = $this->nodeBuffer;
assert($node instanceof TableNode);
$node->finalize($this->parser);
break;
case State::NORMAL:
$this->isCode = $this->prepareCode();
$buffer = $this->buffer->getLinesString();
$node = $this->nodeFactory->createParagraphNode($this->parser->createSpanNode($buffer));
break;
}
}
if ($this->directive !== null) {
$currentDirective = $this->getCurrentDirective();
if ($currentDirective !== null) {
try {
$currentDirective->process(
$this->parser,
$node,
$this->directive->getVariable(),
$this->directive->getData(),
$this->directive->getOptions()
);
} catch (Throwable $e) {
$this->environment->getErrorManager()->error(
sprintf('Error while processing "%s" directive: "%s"', $currentDirective->getName(), $e->getMessage()),
$this->environment->getCurrentFileName(),
$this->currentLineNumber ?? null,
$e
);
}
}
$node = null;
}
$this->directive = null;
if ($node !== null) {
$this->document->addNode($node);
}
$this->init();
}
private function hasBuffer(): bool
{
return ! $this->buffer->isEmpty() || $this->nodeBuffer !== null;
}
private function getCurrentDirective(): ?Directive
{
if ($this->directive === null) {
return null;
}
$name = $this->directive->getName();
return $this->directives[$name];
}
private function isDirectiveOption(string $line): bool
{
if ($this->directive === null) {
return false;
}
$directiveOption = $this->lineDataParser->parseDirectiveOption($line);
if ($directiveOption === null) {
return false;
}
$this->directive->setOption($directiveOption->getName(), $directiveOption->getValue());
return true;
}
private function initDirective(string $line): bool
{
$parserDirective = $this->lineDataParser->parseDirective($line);
if ($parserDirective === null) {
return false;
}
if (! isset($this->directives[$parserDirective->getName()])) {
$this->environment->getErrorManager()->error(
sprintf('Unknown directive "%s" for line "%s"', $parserDirective->getName(), $line),
$this->environment->getCurrentFileName()
);
return false;
}
$this->directive = $parserDirective;
return true;
}
/**
* Called on a NORMAL state line: it's used to determine if this
* it beginning a code block - by having a line ending in "::"
*/
private function prepareCode(): bool
{
$lastLine = $this->buffer->getLastLine();
if ($lastLine === null) {
return false;
}
$trimmedLastLine = trim($lastLine);
if (strlen($trimmedLastLine) >= 2) {
if (substr($trimmedLastLine, -2) === '::') {
if (trim($trimmedLastLine) === '::') {
$this->buffer->pop();
} else {
$this->buffer->set($this->buffer->count() - 1, substr($trimmedLastLine, 0, -1));
}
return true;
}
}
return false;
}
private function parseLink(string $line): bool
{
$link = $this->lineDataParser->parseLink($line);
if ($link === null) {
return false;
}
if ($link->getType() === Link::TYPE_ANCHOR) {
$anchorNode = $this->nodeFactory
->createAnchorNode($link->getName());
$this->document->addNode($anchorNode);
}
$this->environment->setLink($link->getName(), $link->getUrl());
return true;
}
private function endOpenSection(TitleNode $titleNode): void
{
$this->document->addNode(
$this->nodeFactory->createSectionEndNode($titleNode)
);
$key = array_search($titleNode, $this->openTitleNodes, true);
if ($key === false) {
return;
}
unset($this->openTitleNodes[$key]);
}
}

View file

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use function in_array;
use function preg_match;
use function preg_replace;
use function str_repeat;
use function strlen;
use function strpos;
use function trim;
class LineChecker
{
private const HEADER_LETTERS = ['=', '-', '~', '*', '+', '^', '"', '.', '`', "'", '_', '#', ':'];
/**
* A regex matching all bullet list markers and a subset of the enumerated list markers.
*
* @see https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#bullet-lists
* @see https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#enumerated-lists
*/
public const LIST_MARKER = '/
^(
[-+*\x{2022}\x{2023}\x{2043}] # match bullet list markers: "*", "+", "-", "•", "‣", or ""
|(?:[\d#]+\.|[\d#]+\)|\([\d#]+\)) # match arabic (1-9) or auto-enumerated ("#") lists with formats: "1.", "1)", or "(1)"
)
(?:\s+|$) # capture the spaces between marker and text to determine the list item text offset (or eol, if text starts on a new line)
/ux';
public function isSpecialLine(string $line): ?string
{
if (strlen($line) < 2) {
return null;
}
$letter = $line[0];
if (! in_array($letter, self::HEADER_LETTERS, true)) {
return null;
}
for ($i = 1; $i < strlen($line); $i++) {
if ($line[$i] !== $letter) {
return null;
}
}
return $letter;
}
/**
* Checks if this line is the start of a list item.
*
* @see self::LIST_MARKER
*
* @param string|null $listMarker if provided, this function only returns "true" if the
* same list marker format is used (e.g. all dashes).
* @param int|null $listOffset if this line is a list, this will be set to the column
* number of the start of the list item content (used to
* match multiline items)
* @param string|null $nextLine if set, this line must also be a valid list line or
* indented content for enumerated lists
*/
public function isListLine(string $line, ?string &$listMarker = null, ?int &$listOffset = 0, ?string $nextLine = null): bool
{
$isList = preg_match(self::LIST_MARKER, $line, $m) > 0;
if (! $isList) {
return false;
}
$offset = strlen($m[0]);
$normalizedMarker = preg_replace('/\d+/', 'd', $m[1]);
if (
// validate if next line can be considered part of a list for enumerated lists
$normalizedMarker !== $m[1]
&& $nextLine !== null
&& trim($nextLine) !== ''
&& ! $this->isBlockLine($nextLine, $offset)
&& ! $this->isListLine($nextLine, $normalizedMarker)
) {
return false;
}
if ($listMarker !== null) {
$isList = $normalizedMarker === $listMarker;
}
if ($isList) {
$listOffset = $offset;
$listMarker = $normalizedMarker;
}
return $isList;
}
/**
* Is this line "indented"?
*
* A blank line also counts as a "block" line, as it
* may be the empty line between, for example, a
* ".. note::" directive and the indented content on the
* next lines.
*
* @param int $minIndent can be used to require a specific level of
* indentation for non-blank lines (number of spaces)
*/
public function isBlockLine(string $line, int $minIndent = 1): bool
{
return (trim($line) === '' || $this->isIndented($line, $minIndent)) && ! $this->isComment($line);
}
public function isComment(string $line): bool
{
return preg_match('/^\.\.(?: [^_]((?:(?!::).)*))?$/mUsi', $line) > 0;
}
public function isDirective(string $line): bool
{
return preg_match('/^\.\. (\|(.+)\| |)([^\s]+)::( (.*)|)$/mUsi', $line) > 0;
}
/**
* Check if line is an indented one.
*
* This does *not* include blank lines, use {@see isBlockLine()} to check
* for blank or indented lines.
*
* @param int $minIndent can be used to require a specific level of indentation (number of spaces)
*/
public function isIndented(string $line, int $minIndent = 1): bool
{
return strpos($line, str_repeat(' ', $minIndent)) === 0;
}
/**
* Checks if the current line can be considered part of the definition list.
*
* Either the current line, or the next line must be indented to be considered
* definition.
*
* @see https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#definition-lists
*/
public function isDefinitionListEnded(string $line, string $nextLine): bool
{
if (trim($line) === '') {
return false;
}
if ($this->isIndented($line)) {
return false;
}
return ! $this->isIndented($nextLine);
}
}

View file

@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\Common\EventManager;
use Doctrine\RST\Event\OnLinkParsedEvent;
use Doctrine\RST\Nodes\ParagraphNode;
use Doctrine\RST\Nodes\SpanNode;
use Doctrine\RST\Parser;
use function array_map;
use function array_shift;
use function count;
use function explode;
use function ltrim;
use function mb_strlen;
use function preg_match;
use function strlen;
use function substr;
use function trim;
final class LineDataParser
{
/** @var Parser */
private $parser;
/** @var EventManager */
private $eventManager;
public function __construct(Parser $parser, EventManager $eventManager)
{
$this->parser = $parser;
$this->eventManager = $eventManager;
}
public function parseLink(string $line): ?Link
{
// Links
if (preg_match('/^\.\. _`(.+)`: (.+)$/mUsi', $line, $match) > 0) {
return $this->createLink($match[1], $match[2], Link::TYPE_LINK);
}
// anonymous links
if (preg_match('/^\.\. _(.+): (.+)$/mUsi', $line, $match) > 0) {
return $this->createLink($match[1], $match[2], Link::TYPE_LINK);
}
// Short anonymous links
if (preg_match('/^__ (.+)$/mUsi', trim($line), $match) > 0) {
$url = $match[1];
return $this->createLink('_', $url, Link::TYPE_LINK);
}
// Anchor links - ".. _`anchor-link`:"
if (preg_match('/^\.\. _`(.+)`:$/mUsi', trim($line), $match) > 0) {
$anchor = $match[1];
return new Link($anchor, '#' . $anchor, Link::TYPE_ANCHOR);
}
if (preg_match('/^\.\. _(.+):$/mUsi', trim($line), $match) > 0) {
$anchor = $match[1];
return $this->createLink($anchor, '#' . $anchor, Link::TYPE_ANCHOR);
}
return null;
}
private function createLink(string $name, string $url, string $type): Link
{
$this->eventManager->dispatchEvent(
OnLinkParsedEvent::ON_LINK_PARSED,
new OnLinkParsedEvent($url, $type, $this->parser->getEnvironment()->getCurrentFileName())
);
return new Link($name, $url, $type);
}
public function parseDirectiveOption(string $line): ?DirectiveOption
{
if (preg_match('/^(\s+):(.+): (.*)$/mUsi', $line, $match) > 0) {
return new DirectiveOption($match[2], trim($match[3]));
}
if (preg_match('/^(\s+):(.+):(\s*)$/mUsi', $line, $match) > 0) {
$value = trim($match[3]);
return new DirectiveOption($match[2], true);
}
return null;
}
public function parseDirective(string $line): ?Directive
{
if (preg_match('/^\.\. (\|(.+)\| |)([^\s]+)::( (.*)|)$/mUsi', $line, $match) > 0) {
return new Directive(
$match[2],
$match[3],
trim($match[4])
);
}
return null;
}
/**
* @param string[] $lines
*
* @return ListItem[]
*/
public function parseList(array $lines): array
{
$list = [];
$currentItem = null;
$currentPrefix = null;
$currentOffset = 0;
$createListItem = function (string $item, string $prefix): ListItem {
// parse any markup in the list item (e.g. sublists, directives)
$nodes = $this->parser->getSubParser()->parseLocal($item)->getNodes();
if (count($nodes) === 1 && $nodes[0] instanceof ParagraphNode) {
// if there is only one paragraph node, the value is put directly in the <li> element
$nodes = [$nodes[0]->getValue()];
}
return new ListItem($prefix, mb_strlen($prefix) > 1, $nodes);
};
foreach ($lines as $line) {
if (preg_match(LineChecker::LIST_MARKER, $line, $m) > 0) {
// a list marker indicates the start of a new list item,
// complete the previous one and start a new one
if ($currentItem !== null) {
$list[] = $createListItem($currentItem, $currentPrefix);
}
$currentOffset = strlen($m[0]);
$currentPrefix = $m[1];
$currentItem = substr($line, $currentOffset) . "\n";
continue;
}
// the list item offset is determined by the offset of the first text
if (trim($currentItem) === '') {
$currentOffset = strlen($line) - strlen(ltrim($line));
}
$currentItem .= substr($line, $currentOffset) . "\n";
}
if ($currentItem !== null) {
$list[] = $createListItem($currentItem, $currentPrefix);
}
return $list;
}
/** @param string[] $lines */
public function parseDefinitionList(array $lines): DefinitionList
{
/** @var array{term: SpanNode, classifiers: list<SpanNode>, definition: string}|null $definitionListTerm */
$definitionListTerm = null;
$definitionList = [];
$createDefinitionTerm = function (array $definitionListTerm): DefinitionListTerm {
// parse any markup in the definition (e.g. lists, directives)
$definitionNodes = $this->parser->getSubParser()->parseLocal($definitionListTerm['definition'])->getNodes();
if (count($definitionNodes) === 1 && $definitionNodes[0] instanceof ParagraphNode) {
// if there is only one paragraph node, the value is put directly in the <dd> element
$definitionNodes = [$definitionNodes[0]->getValue()];
} else {
// otherwise, .first and .last are added to the first and last nodes of the definition
$definitionNodes[0]->setClasses($definitionNodes[0]->getClasses() + ['first']);
$definitionNodes[count($definitionNodes) - 1]->setClasses($definitionNodes[count($definitionNodes) - 1]->getClasses() + ['last']);
}
return new DefinitionListTerm(
$definitionListTerm['term'],
$definitionListTerm['classifiers'],
$definitionNodes
);
};
$currentOffset = 0;
foreach ($lines as $key => $line) {
// indent or empty line = term definition line
if ($definitionListTerm !== null && (trim($line) === '') || $line[0] === ' ') {
if ($currentOffset === 0) {
// first line of a definition determines the indentation offset
$definition = ltrim($line);
$currentOffset = strlen($line) - strlen($definition);
} else {
$definition = substr($line, $currentOffset);
}
$definitionListTerm['definition'] .= $definition . "\n";
// non empty string at the start of the line = definition term
} elseif (trim($line) !== '') {
// we are starting a new term so if we have an existing
// term with definitions, add it to the definition list
if ($definitionListTerm !== null) {
$definitionList[] = $createDefinitionTerm($definitionListTerm);
}
$parts = explode(' : ', trim($line));
$term = array_shift($parts);
$classifiers = array_map(function (string $classifier): SpanNode {
return $this->parser->createSpanNode($classifier);
}, array_map('trim', $parts));
$currentOffset = 0;
$definitionListTerm = [
'term' => $this->parser->createSpanNode($term),
'classifiers' => $classifiers,
'definition' => '',
];
}
}
// append the last definition of the list
if ($definitionListTerm !== null) {
$definitionList[] = $createDefinitionTerm($definitionListTerm);
}
return new DefinitionList($definitionList);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Iterator;
/** @template-implements Iterator<array-key, string> */
final class Lines implements Iterator
{
/** @var string[] */
private $lines = [];
/** @var int */
private $position = 0;
/** @param string[] $lines */
public function __construct(array $lines)
{
$this->lines = $lines;
}
public function getPreviousLine(): string
{
return $this->lines[$this->position - 1] ?? '';
}
public function getNextLine(): string
{
return $this->lines[$this->position + 1] ?? '';
}
public function rewind(): void
{
$this->position = 0;
}
public function current(): string
{
return $this->lines[$this->position];
}
public function key(): int
{
return $this->position;
}
public function next(): void
{
++$this->position;
}
public function valid(): bool
{
return isset($this->lines[$this->position]);
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
final class Link
{
public const TYPE_LINK = 'link';
public const TYPE_ANCHOR = 'anchor';
/** @var string */
private $name;
/** @var string */
private $url;
/** @var string */
private $type;
public function __construct(string $name, string $url, string $type)
{
$this->name = $name;
$this->url = $url;
$this->type = $type;
}
public function getName(): string
{
return $this->name;
}
public function getUrl(): string
{
return $this->url;
}
public function getType(): string
{
return $this->type;
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\RST\Nodes\Node;
use function array_reduce;
use function trim;
/**
* Represents a single item of a bullet or enumerated list.
*/
final class ListItem
{
/** @var string the list marker used for this item */
private $prefix;
/** @var bool whether the list marker represents an enumerated list */
private $ordered;
/** @var Node[] */
private $contents;
/** @param Node[] $contents */
public function __construct(string $prefix, bool $ordered, array $contents)
{
$this->prefix = $prefix;
$this->ordered = $ordered;
$this->contents = $contents;
}
public function getPrefix(): string
{
return $this->prefix;
}
public function isOrdered(): bool
{
return $this->ordered;
}
/** @return Node[] */
public function getContents(): array
{
return $this->contents;
}
public function getContentsAsString(): string
{
return trim(array_reduce($this->contents, static function (string $contents, Node $node): string {
return $contents . $node->render() . "\n";
}, ''));
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
/**
* "States" for DocumentParser as it parses line-by-line.
*/
final class State
{
/**
* There is currently no state: the next line will begin a new state
*/
public const BEGIN = 'begin';
/**
* Normal, non-indented, non-table lines
*/
public const NORMAL = 'normal';
public const DIRECTIVE = 'directive';
/**
* Indented lines
*/
public const BLOCK = 'block';
public const TITLE = 'title';
public const LIST = 'list';
public const SEPARATOR = 'separator';
public const CODE = 'code';
public const TABLE = 'table';
public const COMMENT = 'comment';
public const DEFINITION_LIST = 'definition_list';
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\RST\Nodes\TableNode;
use Exception;
use function count;
use function in_array;
use function sprintf;
use function strlen;
use function trim;
final class TableParser
{
private const SIMPLE_TABLE_LETTER = '=';
// "-" is valid as a separator in a simple table, except
// on the first and last lines
private const SIMPLE_TABLE_LETTER_ALT = '-';
private const PRETTY_TABLE_LETTER = '-';
private const PRETTY_TABLE_HEADER = '=';
private const PRETTY_TABLE_JOINT = '+';
/**
* Parses a line from a table to see if it is a separator line.
*
* Returns TableSeparatorLineConfig if it *is* a separator, null otherwise.
*/
public function parseTableSeparatorLine(string $line): ?TableSeparatorLineConfig
{
$header = false;
$pretty = false;
$line = trim($line);
if ($line === '') {
return null;
}
// Finds the table chars
$chars = $this->findTableChars($line);
if ($chars === null) {
return null;
}
if ($chars[0] === self::PRETTY_TABLE_JOINT && $chars[1] === self::PRETTY_TABLE_LETTER) {
$pretty = true;
// reverse the chars: - is the line char, + is the space char
$chars = [self::PRETTY_TABLE_LETTER, self::PRETTY_TABLE_JOINT];
} elseif ($chars[0] === self::PRETTY_TABLE_JOINT && $chars[1] === self::PRETTY_TABLE_HEADER) {
$pretty = true;
$header = true;
// reverse the chars: = is the line char, + is the space char
$chars = [self::PRETTY_TABLE_HEADER, self::PRETTY_TABLE_JOINT];
} else {
// either a simple table or not a separator line
// if line char is not "=" or "-", not a separator line
if (! in_array($chars[0], [self::SIMPLE_TABLE_LETTER, self::SIMPLE_TABLE_LETTER_ALT], true)) {
return null;
}
// if space char is not a space, not a separator line
if ($chars[1] !== ' ') {
return null;
}
}
$parts = [];
$currentPartStart = null;
for ($i = 0; $i < strlen($line); $i++) {
// we found the "line char": "-" or "="
if ($line[$i] === $chars[0]) {
if ($currentPartStart === null) {
$currentPartStart = $i;
}
continue;
}
if ($line[$i] !== $chars[1]) {
throw new Exception(sprintf('Unexpected char "%s"', $line[$i]));
}
// found the "space" char
// record the part "range" if we're at the end of a range
if ($currentPartStart === null) {
continue;
}
$parts[] = [$currentPartStart, $i];
$currentPartStart = null;
}
// finish the last "part"
if ($currentPartStart !== null) {
$parts[] = [$currentPartStart, $i];
}
if (count($parts) > 1) {
return new TableSeparatorLineConfig(
$header,
$pretty ? TableNode::TYPE_PRETTY : TableNode::TYPE_SIMPLE,
$parts,
$chars[0],
$line
);
}
return null;
}
public function guessTableType(string $line): string
{
return $line[0] === self::SIMPLE_TABLE_LETTER ? TableNode::TYPE_SIMPLE : TableNode::TYPE_PRETTY;
}
/**
* A "line" separator always has only two characters.
* This method returns those two characters.
*
* This returns null if this is not a separator line
* or it's malformed in any way.
*
* @return string[]|null
* @psalm-return array{string, ?string}
*/
private function findTableChars(string $line): ?array
{
$lineChar = $line[0];
$spaceChar = null;
for ($i = 0; $i < strlen($line); $i++) {
if ($line[$i] === $lineChar) {
continue;
}
if ($spaceChar === null) {
$spaceChar = $line[$i];
continue;
}
if ($line[$i] !== $spaceChar) {
return null;
}
}
if ($spaceChar === null) {
return null;
}
return [$lineChar, $spaceChar];
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Doctrine\RST\Parser;
use Doctrine\RST\Nodes\TableNode;
use InvalidArgumentException;
use function in_array;
use function sprintf;
final class TableSeparatorLineConfig
{
/** @var bool */
private $isHeader;
/** @var string */
private $tableType;
/** @var int[][] */
private $partRanges;
/** @var string */
private $lineCharacter;
/** @var string */
private $rawContent;
/** @param int[][] $partRanges */
public function __construct(bool $isHeader, string $tableType, array $partRanges, string $lineCharacter, string $rawContent)
{
if (! in_array($tableType, [TableNode::TYPE_SIMPLE, TableNode::TYPE_PRETTY], true)) {
throw new InvalidArgumentException(sprintf('Invalid table type'));
}
if (! in_array($lineCharacter, ['=', '-'], true)) {
throw new InvalidArgumentException(sprintf('Unexpected line character "%s"', $lineCharacter));
}
$this->isHeader = $isHeader;
$this->tableType = $tableType;
$this->partRanges = $partRanges;
$this->lineCharacter = $lineCharacter;
$this->rawContent = $rawContent;
}
public function isHeader(): bool
{
return $this->isHeader;
}
public function isSimpleTableType(): bool
{
return $this->tableType === TableNode::TYPE_SIMPLE;
}
/**
* Returns an array of position "ranges" where content should exist.
*
* For example:
* === ===== === ===
*
* Would yield:
* [[0, 3], [6, 11], [14, 17], [18, 21]]
*
* @return int[][]
*/
public function getPartRanges(): array
{
return $this->partRanges;
}
/**
* Returns the "line" character used in the separator,
* either - or =
*/
public function getLineCharacter(): string
{
return $this->lineCharacter;
}
public function getRawContent(): string
{
return $this->rawContent;
}
}