Lines 98.30% 58 / 59
Functions and Methods 90.00% 9 / 10
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
LinkListParser 98.30% 58 / 59 90.00% 9 / 10 38 0.00% 0 / 1
 __construct 100.00% 3 / 3 100.00% 1 / 1 1
 parse 100.00% 14 / 14 100.00% 1 / 1 5
 entries 100.00% 5 / 5 100.00% 1 / 1 4
 listEntries 100.00% 7 / 7 100.00% 1 / 1 4
 region 100.00% 6 / 6 100.00% 1 / 1 4
 link 100.00% 5 / 5 100.00% 1 / 1 6
 firstLink 80.00% 4 / 5 0.00% 0 / 1 5.20
 description 100.00% 6 / 6 100.00% 1 / 1 3
 text 100.00% 7 / 7 100.00% 1 / 1 4
 line 100.00% 1 / 1 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace App\LinkList;
6
7use App\ApiResource\LinkList;
8use League\CommonMark\Environment\Environment;
9use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
10use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
11use League\CommonMark\Extension\CommonMark\Node\Block\HtmlBlock;
12use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
13use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
14use League\CommonMark\Extension\CommonMark\Node\Inline\Link as LinkNode;
15use League\CommonMark\Node\Block\AbstractBlock;
16use League\CommonMark\Node\Block\Paragraph;
17use League\CommonMark\Node\Inline\Newline;
18use League\CommonMark\Node\Node;
19use League\CommonMark\Node\StringContainerInterface;
20use League\CommonMark\Parser\MarkdownParser;
21
22/**
23 * Reads the link list of docs/links.md: the part between "<!-- #region list -->" and
24 * "<!-- #endregion list -->" (the whole file without them), with "## " sections of
25 * "- [Title](https://…) — Description" entries. Entries and text in another format are left out
26 * and reported as problems (LinkListFileTest fails on them, so pull requests keep the format).
27 */
28final readonly class LinkListParser
29{
30    private const string REGION_START = '<!-- #region list -->';
31
32    private const string REGION_END = '<!-- #endregion list -->';
33
34    /** The characters between the link and the description. */
35    private const string SEPARATORS = " \t-–—:";
36
37    private MarkdownParser $parser;
38
39    public function __construct()
40    {
41        $environment = new Environment();
42        $environment->addExtension(new CommonMarkCoreExtension());
43
44        $this->parser = new MarkdownParser($environment);
45    }
46
47    public function parse(string $markdown): LinkList
48    {
49        [$markdown, $lineOffset] = $this->region($markdown);
50
51        $titles = [];
52        /** @var list<list<Link>> $links per section */
53        $links = [];
54        $problems = [];
55
56        foreach ($this->parser->parse($markdown)->children() as $block) {
57            if ($block instanceof Heading && 2 === $block->getLevel()) {
58                $titles[] = $this->text($block);
59                $links[] = [];
60            } elseif ([] !== $entries = $this->entries($block, $lineOffset, [] !== $titles, $problems)) {
61                $links[\count($links) - 1] = [...$links[\count($links) - 1], ...$entries];
62            }
63        }
64
65        return new LinkList(
66            \array_map(static fn (string $title, array $sectionLinks): LinkSection => new LinkSection($title, $sectionLinks), $titles, $links),
67            $problems,
68        );
69    }
70
71    /**
72     * The entries of a list in a section; other blocks are problems, except HTML comments.
73     *
74     * @param list<string> $problems
75     *
76     * @return list<Link>
77     */
78    private function entries(Node $block, int $lineOffset, bool $inSection, array &$problems): array
79    {
80        if ($block instanceof ListBlock && $inSection) {
81            return $this->listEntries($block, $lineOffset, $problems);
82        }
83
84        if (!$block instanceof HtmlBlock) {
85            $problems[] = \sprintf('Line %d: only "## " sections with list entries are allowed', $this->line($block, $lineOffset));
86        }
87
88        return [];
89    }
90
91    /**
92     * @param list<string> $problems
93     *
94     * @return list<Link>
95     */
96    private function listEntries(ListBlock $list, int $lineOffset, array &$problems): array
97    {
98        $links = [];
99
100        foreach ($list->children() as $item) {
101            $link = $item instanceof ListItem ? $this->link($item) : null;
102
103            if ($link instanceof Link) {
104                $links[] = $link;
105            } else {
106                $problems[] = \sprintf('Line %d: not a "- [Title](https://…) — Description" entry', $this->line($item, $lineOffset));
107            }
108        }
109
110        return $links;
111    }
112
113    /**
114     * @return array{string, int} the list's Markdown and the lines before it
115     */
116    private function region(string $markdown): array
117    {
118        $start = \strpos($markdown, self::REGION_START);
119        $end = \strpos($markdown, self::REGION_END);
120
121        if (false === $start || false === $end || $end < $start) {
122            return [$markdown, 0];
123        }
124
125        $before = \substr($markdown, 0, $start + \strlen(self::REGION_START));
126
127        return [\substr($markdown, \strlen($before), $end - \strlen($before)), \substr_count($before, "\n")];
128    }
129
130    /**
131     * An entry: one paragraph with one https link first and plain text after it.
132     */
133    private function link(ListItem $item): ?Link
134    {
135        $link = $this->firstLink($item);
136        $description = $link instanceof LinkNode ? $this->description($link) : false;
137
138        if (!$link instanceof LinkNode || false === $description || '' === $this->text($link)) {
139            return null;
140        }
141
142        return new Link($this->text($link), $link->getUrl(), '' === $description ? null : $description);
143    }
144
145    /** The https link the entry starts with, null if it has more than one paragraph. */
146    private function firstLink(ListItem $item): ?LinkNode
147    {
148        $paragraph = $item->firstChild();
149
150        if (!$paragraph instanceof Paragraph || $paragraph->next() instanceof Node) {
151            return null;
152        }
153
154        $first = $paragraph->firstChild();
155
156        return $first instanceof LinkNode && \str_starts_with($first->getUrl(), 'https://') ? $first : null;
157    }
158
159    /** The text after the link without the separator, false with another link. */
160    private function description(LinkNode $link): string|false
161    {
162        $description = '';
163
164        for ($node = $link->next(); $node instanceof Node; $node = $node->next()) {
165            if ($node instanceof LinkNode) {
166                return false;
167            }
168
169            $description .= $this->text($node).' ';
170        }
171
172        return \trim(\ltrim((string) \preg_replace('/\s+/', ' ', $description), self::SEPARATORS));
173    }
174
175    /** The plain text of a node: formatting left out, line breaks as spaces. */
176    private function text(Node $node): string
177    {
178        $text = '';
179
180        foreach ($node->iterator() as $child) {
181            if ($child instanceof StringContainerInterface) {
182                $text .= $child->getLiteral();
183            } elseif ($child instanceof Newline) {
184                $text .= ' ';
185            }
186        }
187
188        return \trim((string) \preg_replace('/\s+/', ' ', $text));
189    }
190
191    private function line(Node $node, int $lineOffset): int
192    {
193        return ($node instanceof AbstractBlock ? (int) $node->getStartLine() : 0) + $lineOffset;
194    }
195}