icingaweb2/modules/doc/library/Doc/DocParser.php

236 lines
7.4 KiB
PHP

<?php
/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
namespace Icinga\Module\Doc;
use CachingIterator;
use RecursiveIteratorIterator;
use SplFileObject;
use SplStack;
use Icinga\Data\Tree\SimpleTree;
use Icinga\Exception\NotReadableError;
use Icinga\Util\DirectoryIterator;
use Icinga\Module\Doc\Exception\DocException;
/**
* Parser for documentation written in Markdown
*/
class DocParser
{
/**
* Internal identifier for Atx-style headers
*
* @var int
*/
const HEADER_ATX = 1;
/**
* Internal identifier for Setext-style headers
*
* @var int
*/
const HEADER_SETEXT = 2;
/**
* Path to the documentation
*
* @var string
*/
protected $path;
/**
* Iterator over documentation files
*
* @var DirectoryIterator
*/
protected $docIterator;
/**
* Create a new documentation parser for the given path
*
* @param string $path Path to the documentation
*
* @throws DocException If the documentation directory does not exist
* @throws NotReadableError If the documentation directory is not readable
*/
public function __construct($path)
{
if (! DirectoryIterator::isReadable($path)) {
throw new DocException(
mt('doc', 'Documentation directory \'%s\' is not readable'),
$path
);
}
$this->path = $path;
$this->docIterator = new DirectoryIterator($path, 'md', DirectoryIterator::FILES_FIRST);
}
/**
* Extract atx- or setext-style headers from the given lines
*
* @param string $line
* @param string $nextLine
*
* @return array|null An array containing the header and the header level or null if there's nothing to extract
*/
protected function extractHeader($line, $nextLine)
{
if (! $line) {
return null;
}
$header = null;
if ($line
&& $line[0] === '#'
&& preg_match('/^#+/', $line, $match) === 1
) {
// Atx
$level = strlen($match[0]);
$header = trim(substr($line, $level));
if (! $header) {
return null;
}
$headerStyle = static::HEADER_ATX;
} elseif ($nextLine
&& ($nextLine[0] === '=' || $nextLine[0] === '-')
&& preg_match('/^[=-]+\s*$/', $nextLine, $match) === 1
) {
// Setext
$header = trim($line);
if (! $header) {
return null;
}
if ($match[0][0] === '=') {
$level = 1;
} else {
$level = 2;
}
$headerStyle = static::HEADER_SETEXT;
}
if ($header === null) {
return null;
}
if (strpos($header, '<') !== false
&& preg_match('#(?:<(?P<tag>a|span) (?:id|name)="(?P<id>.+)"></(?P=tag)>)\s*#u', $header, $match)
) {
$header = str_replace($match[0], '', $header);
$id = $match['id'];
} else {
$id = null;
}
/** @noinspection PhpUndefinedVariableInspection */
return array($header, $id, $level, $headerStyle);
}
/**
* Generate unique section ID
*
* @param string $id
* @param string $filename
* @param SimpleTree $tree
*
* @return string
*/
protected function uuid($id, $filename, SimpleTree $tree)
{
$id = str_replace(' ', '-', $id);
if ($tree->getNode($id) === null) {
return $id;
}
$id = $id . '-' . md5($filename);
$offset = 0;
while ($tree->getNode($id)) {
if ($offset++ === 0) {
$id .= '-' . $offset;
} else {
$id = substr($id, 0, -1) . $offset;
}
}
return $id;
}
/**
* Get the documentation tree
*
* @return SimpleTree
*/
public function getDocTree()
{
$tree = new SimpleTree();
foreach (new RecursiveIteratorIterator($this->docIterator) as $filename) {
$file = new SplFileObject($filename);
$file->setFlags(SplFileObject::READ_AHEAD);
$stack = new SplStack();
$cachingIterator = new CachingIterator($file);
$insideFencedCodeBlock = false;
for ($cachingIterator->rewind(); $cachingIterator->valid(); $cachingIterator->next()) {
$line = $cachingIterator->current();
$header = null;
if (substr($line, 0, 3) === '```') {
$insideFencedCodeBlock = ! $insideFencedCodeBlock;
} elseif (! $insideFencedCodeBlock) {
$fileIterator = $cachingIterator->getInnerIterator();
$header = $this->extractHeader($line, $fileIterator->valid() ? $fileIterator->current() : null);
}
if ($header !== null) {
list($title, $id, $level, $headerStyle) = $header;
while (! $stack->isEmpty() && $stack->top()->getLevel() >= $level) {
$stack->pop();
}
if ($id === null) {
$path = array();
foreach ($stack as $section) {
/** @var $section DocSection */
$path[] = $section->getTitle();
}
$path[] = $title;
$id = implode('-', $path);
$noFollow = true;
} else {
$noFollow = false;
}
$id = $this->uuid($id, $filename, $tree);
$section = new DocSection();
$section
->setId($id)
->setTitle($title)
->setLevel($level)
->setNoFollow($noFollow);
if ($stack->isEmpty()) {
$section->setChapter($section);
$tree->addChild($section);
} else {
$section->setChapter($stack->bottom());
$tree->addChild($section, $stack->top());
}
$stack->push($section);
if ($headerStyle === static::HEADER_SETEXT) {
$cachingIterator->next();
continue;
}
} else {
if ($stack->isEmpty()) {
$title = ucfirst($file->getBasename('.' . pathinfo($file->getFilename(), PATHINFO_EXTENSION)));
$id = $this->uuid($title, $filename, $tree);
$section = new DocSection();
$section
->setId($id)
->setTitle($title)
->setLevel(1)
->setNoFollow(true);
$section->setChapter($section);
$tree->addChild($section);
$stack->push($section);
}
$stack->top()->appendContent($line);
}
}
}
return $tree;
}
}