chrisjenkinson / structured-document-parser
A structured document parser.
Package info
github.com/chrisjenkinson/StructuredDocumentParser
pkg:composer/chrisjenkinson/structured-document-parser
Requires
- php: 8.2.* || 8.3.* || 8.4.* || 8.5.*
- ext-mbstring: *
Requires (Dev)
- friends-of-phpspec/phpspec-code-coverage: 7.0.0
- friendsofphp/php-cs-fixer: 3.95.*
- jangregor/phpstan-prophecy: 1.0.*
- phpspec/phpspec: 8.3.1
- phpstan/extension-installer: 1.3.* || 1.4.*
- phpstan/phpstan: 1.10.* || 1.11.* || 1.12.*
- phpunit/phpunit: 9.6.* || 10.5.*
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 10:47:00 UTC
README
A PHP library for turning semi-structured text into a tree you can work with. It gives you the pieces for each stage and leaves the grammar to you:
- Lexing. A
Lexerruns your matchers against the text and produces aTokenStream. Matchers are grouped into states, and a matcher's callback can push or pop states. Lookahead matches that consume nothing are allowed when they switch state. Each token records its line and column. - Parsing. You implement
ParserInterfaceto turn tokens into a tree of your own node classes, built onAbstractNode. A node holds attributes, and a series of child nodes (such as a document's blocks) goes in an array attribute. - Transforming. A
NodeTraverserwalks the tree and calls yourNodeVisitorInterfacevisitors, which can replace or remove nodes. - Rendering.
VisitorInterface,VisitableInterfaceandRendererInterfaceare extension points for double-dispatch visitors, for example a renderer with avisitHeading()method per node type.
Requirements
PHP 8.2 or later with the mbstring extension.
Installation
composer require chrisjenkinson/structured-document-parser
Usage
This example parses a small Markdown-like document with headings and paragraphs, then gives each heading an id.
require __DIR__ . '/vendor/autoload.php'; use chrisjenkinson\StructuredDocumentParser\Finder\RegexFinder; use chrisjenkinson\StructuredDocumentParser\Lexer\Lexer; use chrisjenkinson\StructuredDocumentParser\Matcher\AbstractMatcher; use chrisjenkinson\StructuredDocumentParser\Matcher\MatchedText; use chrisjenkinson\StructuredDocumentParser\Node\AbstractNode; use chrisjenkinson\StructuredDocumentParser\Node\NodeInterface; use chrisjenkinson\StructuredDocumentParser\NodeTraverser\NodeTraverser; use chrisjenkinson\StructuredDocumentParser\NodeVisitor\AbstractNodeVisitor; use chrisjenkinson\StructuredDocumentParser\Parser\ParserInterface; use chrisjenkinson\StructuredDocumentParser\State\InitialState; use chrisjenkinson\StructuredDocumentParser\Token\TokenStream; // 1. Matchers recognise pieces of text. The token type is the matcher name without "Matcher". final class RegexMatcher extends AbstractMatcher { private readonly RegexFinder $finder; public function __construct(private readonly string $type, string $pattern) { $this->finder = new RegexFinder($pattern); } public function getName(): string { return $this->type . 'Matcher'; } public function match(string $text): ?MatchedText { if (!$this->finder->find($text)) { return null; } return new MatchedText($this->finder->getMatches(['all', 'level', 'text'])); } } // 2. A state holds the matchers that apply at a point in the document. $state = new InitialState(); $state->registerMatcher(new RegexMatcher('Heading', '/(?<all>(?<level>#{1,6}) (?<text>[^\n]*)\n?)/A')); $state->registerMatcher(new RegexMatcher('Paragraph', '/(?<all>(?<text>[^#\n][^\n]*)\n?)/A')); $state->registerMatcher(new RegexMatcher('BlankLine', '/(?<all>\n)/A')); $tokens = (new Lexer($state))->tokenise("# Title\nSome text.\n\n## Section\nMore text.\n"); echo $tokens, "\n\n"; // 3. Your own node classes and parser turn tokens into a tree. final class Document extends AbstractNode {} final class Heading extends AbstractNode {} final class Paragraph extends AbstractNode {} final class DocumentParser implements ParserInterface { public function parse(TokenStream $tokens): NodeInterface { $blocks = []; while (null !== $token = $tokens->getCurrentToken()) { $tokens->consumeToken(); $block = match ($token->getType()) { 'Heading' => new Heading(), 'Paragraph' => new Paragraph(), default => null, }; if (null === $block) { continue; } $block->setAttribute('text', $token->getValue('text')); if ($token->hasKey('level')) { $block->setAttribute('level', mb_strlen($token->getValue('level'))); } $blocks[] = $block; } $document = new Document(); $document->setAttribute('blocks', $blocks); return $document; } } $document = (new DocumentParser())->parse($tokens); // 4. Visitors transform the tree: here, giving each heading an id. final class HeadingIdVisitor extends AbstractNodeVisitor { public function enterNode(NodeInterface $node): ?NodeInterface { if ($node instanceof Heading) { $node->setAttribute('id', mb_strtolower(str_replace(' ', '-', $node->getAttribute('text')))); } return null; } } $traverser = new NodeTraverser(); $traverser->addVisitor(new HeadingIdVisitor()); echo $traverser->traverse($document), "\n";
The lexer produces these tokens:
Heading (# Title)
Paragraph (Some text.)
BlankLine ()
Heading (## Section)
Paragraph (More text.)
and the traversed document contains the headings with their ids:
{
"attributes": {
"blocks": [
{
"attributes": {
"text": "Title",
"level": 1,
"id": "title"
},
"nodes": []
},
...
]
},
"nodes": []
}
If no matcher matches, or more than one does, the lexer throws NoTokenFoundException or AmbiguousTokenFoundException, with the line, column and the text at that point.
Development
composer install composer test # phpspec, PHPUnit, PHPStan and the code style check composer cs-fix # fix code style
Licence
GPL-3.0-or-later. See LICENSE.