refactor: Cleanup git state - commit all staged changes

Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
This commit is contained in:
Dwindi Ramadhana
2026-06-17 05:27:58 +07:00
parent d3f142222c
commit 690991c526
7963 changed files with 941566 additions and 67372 deletions

View File

@@ -0,0 +1,21 @@
<?php declare(strict_types=1);
/**
* This code is forked from https://github.com/matteosister/php-curry, which is abandoned. It could be integrated into
* the rest of Parsica.
*/
namespace Parsica\Parsica\Curry;
/**
* This class is created simply to define a special type
* for the placeholder. As defining a constant, even
* a random one, could collide with other values.
* @psalm-immutable
*/
final class Placeholder
{
public function __toString() : string
{
return '__';
}
}

View File

@@ -0,0 +1,87 @@
# php-curry
An implementation for currying in PHP
Currying a function means the ability to pass a subset of arguments to a function, and receive back another function that accepts the rest of the arguments. As soon as the last one is passed it gets back the final result.
Like this:
``` php
$adder = function ($a, $b, $c, $d) {
return $a + $b + $c + $d;
};
$firstTwo = C\curry($adder, 1, 2);
echo $firstTwo(3, 4); // output 10
$firstThree = $firstTwo(3);
echo $firstThree(14); // output 20
```
Currying is a powerful (yet simple) concept, very popular in other, more purely functional languages. In haskell for example, currying is the default behavior for every function.
In PHP we still need to rely on a wrapper to simulate the behavior
### Right to left
It's possible to curry a function from left (default) or from right.
``` php
$divider = function ($a, $b) {
return $a / $b;
};
$divide10By = C\curry($divider, 10);
$divideBy10 = C\curry_right($divider, 10);
echo $divide10By(10); // output 1
echo $divideBy10(100); // output 10
```
### Optional parameters
Optional parameters and currying do not play very nicely together. This library excludes optional parameters by default.
``` php
$haystack = "haystack";
$searches = ['h', 'a', 'z'];
$strpos = C\curry('strpos', $haystack); // You can pass function as string too!
var_dump(array_map($strpos, $searches)); // output [0, 1, false]
```
But strpos has an optional $offset parameter that by default has not been considered.
If you want to take this optional $offset parameter into account you should "fix" the curry to a given length.
``` php
$haystack = "haystack";
$searches = ['h', 'a', 'z'];
$strpos = C\curry_fixed(3, 'strpos', $haystack);
$finders = array_map($strpos, $searches);
var_dump(array_map(function ($finder) {
return $finder(2);
}, $finders)); // output [false, 5, false]
```
*curry_right* has its own fixed version named *curry_right_fixed*
### Placeholders
The function `__()` gets a special placeholder value used to specify "gaps" within curried functions, allowing partial application of any combination of arguments, regardless of their positions.
```php
$add = function($x, $y)
{
return $x + $y;
};
$reduce = C\curry('array_reduce');
$sum = $reduce(C\__(), $add);
echo $sum([1, 2, 3, 4], 0); // output 10
```
**Notes**:
- Placeholders should be used only for required arguments.
- When used, optional arguments must be at the end of the arguments list.

View File

@@ -0,0 +1,214 @@
<?php declare(strict_types=1);
/**
* This code is forked from https://github.com/matteosister/php-curry, which is abandoned. It could be integrated into
* the rest of Parsica.
*/
namespace Parsica\Parsica\Curry;
use Closure;
use Exception;
use ReflectionClass;
use ReflectionFunction;
/**
* @psalm-param pure-callable $callable
*
* @psalm-return pure-callable
* @throws Exception
* @psalm-pure
*/
function curry(callable $callable) : callable
{
return _number_of_required_params($callable) === 0
? _make_function($callable)
: _curry_array_args($callable, _rest(func_get_args()));
}
/**
* @psalm-param pure-callable $callable
*
* @psalm-return pure-callable
* @psalm-pure
*/
function curry_right(callable $callable) : callable
{
return _number_of_required_params($callable) < 2
? _make_function($callable)
: _curry_array_args($callable, _rest(func_get_args()), false);
}
/**
* @psalm-param pure-callable $callable
* @psalm-param array $args
* @psalm-param bool $left
*
* @psalm-return pure-callable
* @psalm-pure
*/
function _curry_array_args(callable $callable, array $args, bool $left = true) : callable
{
return function () use ($callable, $args, $left) {
if (_is_fullfilled($callable, $args)) {
return _execute($callable, $args, $left);
}
$newArgs = array_merge($args, func_get_args());
if (_is_fullfilled($callable, $newArgs)) {
return _execute($callable, $newArgs, $left);
}
return _curry_array_args($callable, $newArgs, $left);
};
}
/**
* @psalm-param pure-callable $callable
* @param array<mixed> $args
* @param mixed $left
*
* @return mixed
* @internal
* @psalm-pure
*/
function _execute(callable $callable, array $args, bool $left = true)
{
if (!$left) {
$args = array_reverse($args);
}
$placeholderPositions = _placeholder_positions($args);
if (0 < count($placeholderPositions)) {
$reqdParams = _number_of_required_params($callable);
if ($reqdParams <= _last($placeholderPositions)) {
// This means that we have more placeholderPositions than needed
// I know that throwing exceptions is not really the
// functional way, but this case should not happen.
throw new Exception("Argument Placeholder found on unexpected position!");
}
foreach ($placeholderPositions as $placeholderPosition) {
/** @psalm-suppress MixedAssignment */
$args[$placeholderPosition] = $args[$reqdParams];
array_splice($args, $reqdParams, 1);
}
}
return call_user_func_array($callable, $args);
}
/**
* @param array $args
*
* @return array
* @internal
* @psalm-pure
*/
function _rest(array $args) : array
{
return array_slice($args, 1);
}
/**
* @psalm-param pure-callable $callable
* @param array $args
*
* @return bool
* @throws Exception
* @internal
* @psalm-pure
*/
function _is_fullfilled(callable $callable, array $args) : bool
{
$nonPlaceholderArgs = array_filter(
$args,
fn($arg) => !($arg instanceof Placeholder)
);
return count($nonPlaceholderArgs) >= _number_of_required_params($callable);
}
/**
* @psalm-param pure-callable $callable
* @internal
* @psalm-pure
*/
function _number_of_required_params(callable $callable) : int
{
if (is_array($callable)) {
/** @psalm-suppress ImpureMethodCall */
$refl = new ReflectionClass($callable[0]);
/** @psalm-suppress ImpureMethodCall */
$method = $refl->getMethod($callable[1]);
/** @psalm-suppress ImpureMethodCall */
return $method->getNumberOfRequiredParameters();
} elseif (is_string($callable) || $callable instanceof Closure) {
/** @psalm-suppress ImpureMethodCall */
$refl = new ReflectionFunction($callable);
/** @psalm-suppress ImpureMethodCall */
return $refl->getNumberOfRequiredParameters();
}
throw new Exception("Unexpected other type of callable");
}
/**
* if the callback is an array(instance, method),
* it returns an equivalent function for PHP 5.3 compatibility.
*
* @psalm-param pure-callable $callable
*
* @psalm-return pure-callable
* @internal
* @psalm-pure
*/
function _make_function(callable $callable) : callable
{
if (is_array($callable)) {
return /** @return mixed */ fn() => call_user_func_array($callable, func_get_args());
}
return $callable;
}
/**
* Gets an array of placeholders positions in the given arguments.
*
* @param array $args
*
* @return list<int|string>
* @internal
* @psalm-pure
*/
function _placeholder_positions(array $args) : array
{
return array_keys(
array_filter(
$args,
fn($arg) : bool => $arg instanceof Placeholder
)
);
}
/**
* Get the last element in an array.
*
* @psalm-param array<T> $array
*
* @psalm-return null|T
* @template T
* @internal
* @psalm-pure
*/
function _last(array $array)
{
$lastKey = array_key_last($array);
return is_null($lastKey) ? null : $array[$lastKey];
}
/**
* Gets a special placeholder value used to specify "gaps" within curried
* functions, allowing partial application of any combination of arguments,
* regardless of their positions. Should be used only for required arguments.
* When used, optional arguments must be at the end of the argument list.
* @psalm-pure
*/
function __() : Placeholder
{
return new Placeholder;
}

View File

@@ -0,0 +1,75 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class BinaryOperator
{
/**
* @psalm-var Parser<TSymbol>
*/
private Parser $symbol;
/**
* @psalm-var pure-callable(TExpressionAST, TExpressionAST):TExpressionAST
*/
private $transform;
private string $label;
/**
* @psalm-param Parser<TSymbol> $symbol
* @psalm-param pure-callable(TExpressionAST, TExpressionAST):TExpressionAST $transform
* @psalm-param string $label
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(Parser $symbol, callable $transform, string $label = "")
{
$this->symbol = $symbol;
$this->transform = $transform;
$this->label = $label ?: $symbol->getLabel() . " operator";
}
/**
* @psalm-return Parser<TSymbol>
* @psalm-mutation-free
*/
function symbol(): Parser
{
return $this->symbol;
}
/**
* @psalm-return pure-callable(TExpressionAST, TExpressionAST):TExpressionAST
* @psalm-mutation-free
*/
function transform(): callable
{
return $this->transform;
}
/**
* @psalm-mutation-free
*/
function label(): string
{
return $this->label;
}
}

View File

@@ -0,0 +1,27 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
/**
* @internal
* @template TExpressionAST
* @psalm-immutable
*/
interface ExpressionType
{
/**
* @psalm-param Parser<TExpressionAST> $previousPrecedenceLevel
* @psalm-return Parser<TExpressionAST>
*/
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser;
}

View File

@@ -0,0 +1,90 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\Curry\curry;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\collect;
use function Parsica\Parsica\Internal\FP\flip;
use function Parsica\Parsica\Internal\FP\foldl;
use function Parsica\Parsica\many;
use function Parsica\Parsica\map;
use function Parsica\Parsica\pure;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class LeftAssoc implements ExpressionType
{
/** @psalm-var non-empty-list<BinaryOperator<TSymbol, TExpressionAST>> */
private array $operators;
/**
* @internal
* @psalm-param non-empty-list<BinaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(array $operators)
{
$this->operators = $operators;
}
/**
* @psalm-param Parser<TExpressionAST> $previousPrecedenceLevel
* @psalm-return Parser<TExpressionAST>
* @psalm-mutation-free
*/
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser
{
/**
* @psalm-var list<Parser<pure-callable(Parser<TExpressionAST>):Parser<TExpressionAST>>> $operatorParsers
*/
$operatorParsers = [];
// @todo use folds?
foreach ($this->operators as $operator) {
$operatorParsers[] =
pure(curry(flip($operator->transform())))
->apply($operator->symbol()->followedBy($previousPrecedenceLevel))
->label($operator->label());
}
return map(
collect(
$previousPrecedenceLevel,
many(choice(...$operatorParsers))
),
/**
* @psalm-param array{0: TExpressionAST, 1: list<pure-callable(TExpressionAST):TExpressionAST>} $o
* @psalm-return TExpressionAST
* @psalm-pure
*/
fn(array $o) => foldl(
$o[1],
/**
* @psalm-param TExpressionAST $acc
* @psalm-param pure-callable(TExpressionAST):TExpressionAST $appl
* @psalm-return TExpressionAST
* @psalm-pure
*/
fn($acc, callable $appl) => $appl($acc),
$o[0]
)
);
}
}

View File

@@ -0,0 +1,65 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\collect;
use function Parsica\Parsica\map;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class NonAssoc implements ExpressionType
{
/**
* @psalm-var BinaryOperator<TSymbol, TExpressionAST>
*/
private BinaryOperator $operator;
/**
* @psalm-param BinaryOperator<TSymbol, TExpressionAST> $operator
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(BinaryOperator $operator)
{
$this->operator = $operator;
}
/**
* @psalm-param Parser<TExpressionAST> $previousPrecedenceLevel
* @psalm-return Parser<TExpressionAST>
*/
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser
{
return choice(
map(
collect(
$previousPrecedenceLevel,
$this->operator->symbol(),
$previousPrecedenceLevel
),
/**
* @psalm-param array{0: TExpressionAST, 1: TSymbol, 2: TExpressionAST} $o
* @psalm-return TExpressionAST
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
fn(array $o) => $this->operator->transform()($o[0], $o[2])),
$previousPrecedenceLevel
);
}
}

View File

@@ -0,0 +1,51 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\keepFirst;
use function Parsica\Parsica\pure;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class Postfix implements ExpressionType
{
/** @psalm-var non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> */
private array $operators;
/**
* @psalm-param non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(array $operators)
{
$this->operators = $operators;
}
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser
{
$operatorParsers = [];
foreach ($this->operators as $operator) {
$operatorParsers[] =
pure($operator->transform())
->apply(keepFirst($previousPrecedenceLevel, $operator->symbol()))
->label($operator->label());
}
return choice(...$operatorParsers)->or($previousPrecedenceLevel);
}
}

View File

@@ -0,0 +1,51 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\pure;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class Prefix implements ExpressionType
{
/** @psalm-var non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> */
private array $operators;
/**
* @psalm-param non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(array $operators)
{
$this->operators = $operators;
}
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser
{
$operatorParsers = [];
foreach ($this->operators as $operator) {
$operatorParsers[] =
pure($operator->transform())
->apply($operator->symbol()->followedBy($previousPrecedenceLevel))
->label($operator->label());
}
return choice(...$operatorParsers)->or($previousPrecedenceLevel);
}
}

View File

@@ -0,0 +1,86 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\Curry\curry;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\collect;
use function Parsica\Parsica\Internal\FP\foldr;
use function Parsica\Parsica\keepFirst;
use function Parsica\Parsica\many;
use function Parsica\Parsica\map;
use function Parsica\Parsica\pure;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class RightAssoc implements ExpressionType
{
/** @var non-empty-list<BinaryOperator<TSymbol, TExpressionAST>> */
private array $operators;
/**
* @internal
* @psalm-param non-empty-list<BinaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(array $operators)
{
$this->operators = $operators;
}
/**
* @psalm-param Parser<TExpressionAST> $previousPrecedenceLevel
* @psalm-return Parser<TExpressionAST>
*/
public function buildPrecedenceLevel(Parser $previousPrecedenceLevel): Parser
{
/**
* @psalm-var list<Parser<pure-callable(Parser<TExpressionAST>):Parser<TExpressionAST>>> $operatorParsers
*/
$operatorParsers = [];
foreach ($this->operators as $operator) {
$operatorParsers[] =
pure(curry($operator->transform()))
->apply(keepFirst($previousPrecedenceLevel, $operator->symbol()))
->label($operator->label());
}
return map(
collect(
many(choice(...$operatorParsers)),
$previousPrecedenceLevel
),
/**
* @psalm-param array{0: list<pure-callable(TExpressionAST):TExpressionAST>, 1: TExpressionAST} $o
* @psalm-return TExpressionAST
*/
fn(array $o) => foldr(
$o[0],
/**
* @psalm-param pure-callable(TExpressionAST):TExpressionAST $appl
* @psalm-param TExpressionAST $acc
* @psalm-return TExpressionAST
*/
fn(callable $appl, $acc) => $appl($acc),
$o[1]
)
);
}
}

View File

@@ -0,0 +1,69 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Parser;
/**
* @internal
* @template TSymbol
* @template TExpressionAST
* @psalm-immutable
*/
final class UnaryOperator
{
/**
* @psalm-var Parser<TSymbol>
*/
private Parser $symbol;
/**
* @psalm-var callable(TExpressionAST):TExpressionAST
*/
private $transform;
private string $label;
/**
* @psalm-param Parser<TSymbol> $symbol
* @psalm-param callable(TExpressionAST):TExpressionAST $transform
* @psalm-param string $label
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
function __construct(Parser $symbol, callable $transform, string $label = "")
{
$this->symbol = $symbol;
$this->transform = $transform;
$this->label = $label ?: $symbol->getLabel() . " operator";
}
/**
* @psalm-return Parser<TSymbol>
*/
function symbol(): Parser
{
return $this->symbol;
}
/**
* @psalm-return callable(TExpressionAST):TExpressionAST
*/
function transform(): callable
{
return $this->transform;
}
function label(): string
{
return $this->label;
}
}

View File

@@ -0,0 +1,159 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Expression;
use Parsica\Parsica\Internal\Assert;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\Internal\FP\foldl;
/**
* Build an expression parser from a term parser and an expression table.
*
* @api
*
* @template TTerm
* @template TExpressionAST
*
* @psalm-param Parser<TTerm> $term
* @psalm-param list<ExpressionType> $expressionTable
*
* @psalm-return Parser<TExpressionAST>
* @psalm-pure
*/
function expression(Parser $term, array $expressionTable): Parser
{
/**
* @psalm-var Parser<TExpressionAST> $parser
*/
$parser = foldl(
$expressionTable,
fn(Parser $previous, ExpressionType $next) => $next->buildPrecedenceLevel($previous),
$term
);
return $parser;
}
/**
* A binary operator in an expression. The operands of the expression will be passed into $transform to produce the
* output of the expression parser.
*
* @api
*
* @template TSymbol
* @template TExpressionAST
* @psalm-param Parser<TSymbol> $symbol
* @psalm-param pure-callable(TExpressionAST, TExpressionAST):TExpressionAST $transform
* @psalm-param string $label
*
* @psalm-return BinaryOperator<TSymbol, TExpressionAST>
* @psalm-pure
*/
function binaryOperator(Parser $symbol, callable $transform, string $label = ""): BinaryOperator
{
return new BinaryOperator($symbol, $transform, $label);
}
/**
* A unary operator in an expression. The operands of the expression will be passed into $transform to produce the
* output of the expression parser.
*
* @api
*
* @template TSymbol
* @template TExpressionAST
* @psalm-param Parser<TSymbol> $symbol
* @psalm-param callable(TExpressionAST):TExpressionAST $transform
* @psalm-param string $label
*
* @return UnaryOperator<TSymbol, TExpressionAST>
* @psalm-pure
*/
function unaryOperator(Parser $symbol, callable $transform, string $label = ""): UnaryOperator
{
return new UnaryOperator($symbol, $transform, $label);
}
/**
* @api
* @template TSymbol
* @template TExpressionAST
* @psalm-param non-empty-list<BinaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-return LeftAssoc<TSymbol, TExpressionAST>
* @psalm-pure
*/
function leftAssoc(BinaryOperator ...$operators): LeftAssoc
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmptyList($operators, "LeftAssoc expects at least one Operator");
return new LeftAssoc($operators);
}
/**
* @api
* @template TSymbol
* @template TExpressionAST
* @psalm-param non-empty-list<BinaryOperator<TSymbol,TExpressionAST>> $operators
* @psalm-return RightAssoc<TSymbol, TExpressionAST>
* @psalm-pure
*/
function rightAssoc(BinaryOperator ...$operators): RightAssoc
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmptyList($operators, "RightAssoc expects at least one Operator");
return new RightAssoc($operators);
}
/**
* @api
*
* @template TSymbol
* @template TExpressionAST
* @psalm-param BinaryOperator<TSymbol, TExpressionAST> $operator
* @psalm-return NonAssoc<TSymbol, TExpressionAST>
* @psalm-pure
*/
function nonAssoc(BinaryOperator $operator): NonAssoc
{
return new NonAssoc($operator);
}
/**
* @api
*
* @template TSymbol
* @template TExpressionAST
*
* @psalm-param non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-return Prefix<TSymbol, TExpressionAST>
* @psalm-pure
*/
function prefix(UnaryOperator ...$operators): Prefix
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmptyList($operators, "Prefix expects at least one Operator");
return new Prefix($operators);
}
/**
* @api
*
* @template TSymbol
* @template TExpressionAST
* @psalm-param non-empty-list<UnaryOperator<TSymbol, TExpressionAST>> $operators
* @psalm-return Postfix<TSymbol, TExpressionAST>
* @psalm-pure
*/
function postfix(UnaryOperator ...$operators): Postfix
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmptyList($operators, "Postfix expects at least one Operator");
return new Postfix($operators);
}

View File

@@ -0,0 +1,125 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
/**
* @internal
* @psalm-immutable
*/
final class Ascii
{
private function __construct()
{
}
/**
* @psalm-pure
*/
public static function printable(string $char): string
{
switch (mb_ord($char)) {
case 0:
return "<null>";
case 1:
return "<start of header>";
case 2:
return "<start of text>";
case 3:
return "<end of text>";
case 4:
return "<end of transmission>";
case 5:
return "<enquiry>";
case 6:
return "<acknowledge>";
case 7:
return "<bell>";
case 8:
return "<backspace>";
case 9:
return "<horizontal tab>";
case 10:
return "<line feed>";
case 11:
return "<vertical tab>";
case 12:
return "<form feed>";
case 13:
return "<carriage return>";
case 14:
return "<shift out>";
case 15:
return "<shift in>";
case 16:
return "<data link escape>";
case 17:
return "<device control 1>";
case 18:
return "<device control 2>";
case 19:
return "<device control 3>";
case 20:
return "<device control 4>";
case 21:
return "<negative acknowledge>";
case 22:
return "<synchronize>";
case 23:
return "<end of transmission block>";
case 24:
return "<cancel>";
case 25:
return "<end of medium>";
case 26:
return "<substitute>";
case 27:
return "<escape>";
case 28:
return "<file separator>";
case 29:
return "<group separator>";
case 30:
return "<record separator>";
case 31:
return "<unit separator>";
case 32:
return "<space>";
case 34:
return "<double quote>";
case 39:
return "<single quote>";
case 47:
return "<slash>";
case 92:
return "<backslash>";
case 96:
return "<accent>";
case 127:
return "<delete>";
case 130:
return "<single low-9 quotation mark>";
case 132:
return "<double low-9 quotation mark>";
case 145:
return "<left single quotation mark>";
case 146:
return "<right single quotation mark>";
case 147:
return "<left double quotation mark>";
case 148:
return "<right double quotation mark>";
case 160:
return "<non-breaking space>";
default:
return "'$char'";
}
}
}

View File

@@ -0,0 +1,111 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
use InvalidArgumentException;
/**
* @internal
* @psalm-immutable
*/
final class Assert
{
private function __construct()
{
}
/**
* @throws InvalidArgumentException
* @internal
*/
public static function nonEmpty(string $str): void
{
Assert::minLength($str, 1, "The string must not be empty.");
}
/**
* @psalm-assert list $l
* @psalm-assert !empty $l
* @throws InvalidArgumentException
*/
public static function nonEmptyList(array $l, string $message): void
{
if (empty($l)) {
throw new InvalidArgumentException($message);
}
}
/**
* @throws InvalidArgumentException
* @internal
*/
public static function minLength(string $value, int $length, string $message): void
{
if (mb_strlen($value) < $length) {
throw new InvalidArgumentException($message);
}
}
/**
* @psalm-param list<string> $chars
*
* @throws InvalidArgumentException
* @internal
*/
public static function singleChars(array $chars): void
{
foreach ($chars as $char) {
Assert::singleChar($char);
}
}
/**
* @throws InvalidArgumentException
* @internal
*/
public static function singleChar(string $char): void
{
Assert::length($char, 1, "The argument must be a single character");
}
/**
* @throws InvalidArgumentException
* @internal
*/
public static function length(string $value, int $length, string $message): void
{
if ($length !== mb_strlen($value)) {
throw new InvalidArgumentException($message);
}
}
/**
* @psalm-param mixed $f
* @internal
* @param callable|mixed $f
*/
public static function isCallable($f, string $message) : void
{
if (!is_callable($f)) {
throw new InvalidArgumentException($message);
}
}
/**
* @throws InvalidArgumentException
* @internal
*/
public static function atLeastOneArg(array $args, string $source): void
{
if (0 == count($args)) {
throw new InvalidArgumentException("$source expects at least one Parser");
}
}
}

View File

@@ -0,0 +1,16 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
final class EndOfStream extends \Exception
{
}

View File

@@ -0,0 +1,70 @@
<?php declare(strict_types=1);
/*
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal\FP;
/**
* Swaps the arguments of the callable, returning a callable.
*
* @internal
* @template Ta
* @template Tb
* @template Tc
* @psalm-param pure-callable(Ta, Tb):Tc $f
* @psalm-return pure-callable(Tb, Ta):Tc
* @psalm-pure
*/
function flip(callable $f): callable
{
/**
* @psalm-param Ta $x
* @psalm-param Tb $y
* @psalm-return Tc
*/
return fn($x, $y) => $f($y, $x);
}
/**
* @template TA
* @template TB
*
* @psalm-param list<TA> $input
* @psalm-param callable(TB, TA):TB $function
* @psalm-param TB $initial
* @psalm-return TB
*
* @internal
* @psalm-pure
*/
function foldl(array $input, callable $function, $initial) {
/** @psalm-suppress ImpureFunctionCall */
return array_reduce($input, $function, $initial);
}
/**
* @template TA
* @template TB
*
* @psalm-param list<TA> $input
* @psalm-param pure-callable(TA, TB):TB $function
* @psalm-param TB $initial
* @psalm-return TB
*
* @internal
* @psalm-pure
*/
function foldr(array $input, callable $function, $initial) {
while($head = array_pop($input))
{
$initial = $function($head, $initial);
}
return $initial;
}

View File

@@ -0,0 +1,162 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
use BadMethodCallException;
use Parsica\Parsica\Parser;
use Parsica\Parsica\ParseResult;
use Parsica\Parsica\ParserHasFailed;
use Parsica\Parsica\Stream;
use function Parsica\Parsica\isEqual;
use function Parsica\Parsica\notPred;
/**
* The return value of a failed parser.
*
* @template T
* @internal
* @psalm-immutable
*/
final class Fail implements ParseResult
{
private string $expected;
private Stream $got;
/**
* @internal
*/
public function __construct(string $expected, Stream $got)
{
$this->expected = $expected;
$this->got = $got;
}
/**
* @api
*/
public function errorMessage(): string
{
try {
$firstChar = $this->got->take1()->chunk();
$unexpected = Ascii::printable($firstChar);
$body = $this->got()->takeWhile(notPred(isEqual("\n")))->chunk();
} catch (EndOfStream $e) {
$unexpected = $body = "<EOF>";
}
$lineNumber = $this->got->position()->line();
$spaceLength = str_repeat(" ", strlen((string)$lineNumber));
$expecting = $this->expected;
$position = $this->got->position()->pretty();
$columnNumber = $this->got->position()->column();
$leftDots = $columnNumber == 1 ? "" : "...";
$leftSpace = $columnNumber == 1 ? "" : " ";
$bodyLine = "$lineNumber | $leftDots$body";
$bodyLine = strlen($bodyLine) > 80 ? (substr($bodyLine, 0, 77) . "...") : $bodyLine;
return
"$position\n"
. "$spaceLength |\n"
. "$bodyLine\n"
. "$spaceLength | $leftSpace^— column $columnNumber\n"
. "Unexpected $unexpected\n"
. "Expecting $expecting";
}
public function got(): Stream
{
return $this->got;
}
public function expected(): string
{
return $this->expected;
}
public function isSuccess(): bool
{
return false;
}
public function isFail(): bool
{
return !$this->isSuccess();
}
/**
* @psalm-return T
*/
public function output()
{
throw new BadMethodCallException("Can't read the output of a failed ParseResult.");
}
/**
* @psalm-param ParseResult<T> $other
*
* @psalm-return ParseResult<T>
*/
public function append(ParseResult $other): ParseResult
{
return $this;
}
/**
* Map a function over the output
*
* @template T2
*
* @psalm-param callable(T) : T2 $transform
*
* @psalm-return ParseResult<T2>
*/
public function map(callable $transform): ParseResult
{
return $this;
}
/**
* @template T2
*
* @psalm-param Parser<T2> $parser
*
* @psalm-return ParseResult<T2>
*/
public function continueWith(Parser $parser): ParseResult
{
return $this;
}
/**
* @inheritDoc
*/
public function remainder(): Stream
{
throw new BadMethodCallException("Can't read the remainder of a failed ParseResult.");
}
/**
* @inheritDoc
*/
public function position(): Position
{
return $this->got->position();
}
/**
* @inheritDoc
*/
public function throw() : void
{
throw new ParserHasFailed($this);
}
}

View File

@@ -0,0 +1,88 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
/**
* File, line, and column position of the parser.
*
* @psalm-immutable
* @psalm-external-mutation-free
*/
final class Position
{
/** @psalm-readonly */
private string $filename;
/** @psalm-readonly */
private int $line;
/** @psalm-readonly */
private int $column;
function __construct(string $filename, int $line, int $column)
{
$this->filename = $filename;
$this->line = $line;
$this->column = $column;
}
/**
* Initial position (line 1, column 1). The optional filename is the source of the input, and is really just a label
* to make more useful error messages.
*/
public static function initial(string $filename = "<input>"): Position
{
return new Position($filename, 1, 1);
}
/**
* Pretty print as "filename:line:column"
*/
public function pretty(): string
{
return $this->filename . ":" . $this->line . ":" . $this->column;
}
public function advance(string $parsed): Position
{
$column = $this->column;
$line = $this->line;
foreach (mb_str_split($parsed, 1) as $char) {
switch ($char) {
case "\n":
case "\r":
$line++;
$column = 1;
break;
case "\t":
$column = $column + 4 - (($column - 1) % 4);
break;
default:
$column++;
}
}
return new Position($this->filename, $line, $column);
}
public function filename(): string
{
return $this->filename;
}
public function line(): int
{
return $this->line;
}
public function column(): int
{
return $this->column;
}
}

View File

@@ -0,0 +1,192 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
use BadMethodCallException;
use Exception;
use Parsica\Parsica\Parser;
use Parsica\Parsica\ParseResult;
use Parsica\Parsica\Stream;
/**
* @internal
*
* @template T
* @psalm-immutable
*/
final class Succeed implements ParseResult
{
/**
* @psalm-var T
*/
private $output;
private Stream $remainder;
/**
* @psalm-param T $output
*
* @internal
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
public function __construct($output, Stream $remainder)
{
$this->output = $output;
$this->remainder = $remainder;
}
/**
* @psalm-return T
* @psalm-mutation-free
*/
public function output()
{
return $this->output;
}
/**
* @psalm-mutation-free
*/
public function remainder(): Stream
{
return $this->remainder;
}
/**
* @psalm-mutation-free
*/
public function isSuccess(): bool
{
return true;
}
/**
* @psalm-mutation-free
*/
public function isFail(): bool
{
return !$this->isSuccess();
}
/**
* @psalm-mutation-free
*/
public function expected(): string
{
throw new BadMethodCallException("Can't read the expectation of a succeeded ParseResult.");
}
/**
* @psalm-mutation-free
*/
public function got(): Stream
{
throw new BadMethodCallException("Can't read the expectation of a succeeded ParseResult.");
}
/**
* @inheritDoc
*
* @psalm-param ParseResult<T> $other
* @psalm-return ParseResult<T>
* @psalm-mutation-free
*/
public function append(ParseResult $other): ParseResult
{
if ($other->isFail()) {
return $other;
} else {
/** @psalm-suppress ArgumentTypeCoercion */
return $this->appendSuccess($other);
}
}
/**
* @TODO This is hardcoded to only deal with certain types. We need an interface with a append() for arbitrary types.
*/
private function appendSuccess(Succeed $other): ParseResult
{
$type1isNull = is_null($this->output);
$type2isNull = is_null($other->output);
// Ignore nulls
if ($type1isNull && $type2isNull) {
return new Succeed(null, $other->remainder);
} elseif(!$type1isNull && $type2isNull) {
return new Succeed($this->output, $other->remainder);
} elseif($type1isNull) {
return new Succeed($other->output, $other->remainder);
}
if (is_string($this->output) && is_string($other->output)) {
return new Succeed($this->output . $other->output, $other->remainder);
} elseif (is_array($this->output) && is_array($other->output)) {
return new Succeed(
array_merge($this->output, $other->output),
$other->remainder
);
}
$type1 = gettype($this->output);
$type2 = gettype($other->output);
throw new Exception("Append only works for ParseResult<T> instances with the same type T, got ParseResult<$type1> and ParseResult<$type2>.");
}
/**
* Map a function over the output
*
* @template T2
*
* @psalm-param pure-callable(T):T2 $transform
*
* @psalm-return ParseResult<T2>
* @psalm-mutation-free
*/
public function map(callable $transform): ParseResult
{
return new Succeed($transform($this->output), $this->remainder);
}
/**
* @template T2
*
* @psalm-param Parser<T2> $parser
*
* @psalm-return ParseResult<T2>
*/
public function continueWith(Parser $parser): ParseResult
{
return $parser->run($this->remainder);
}
public function errorMessage(): string
{
throw new BadMethodCallException("A succeeded ParseResult has no error message.");
}
/**
* @inheritDoc
*/
public function position(): Position
{
return $this->remainder->position();
}
/**
* @inheritDoc
*/
public function throw() : void
{
throw new BadMethodCallException("You can't throw a successful ParseResult.");
}
}

View File

@@ -0,0 +1,41 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\Internal;
use Parsica\Parsica\Stream;
/**
* The result of Stream::take*() functions
*
* @internal
* @psalm-immutable
*/
final class TakeResult
{
private string $chunk;
private Stream $stream;
function __construct(string $chunk, Stream $stream)
{
$this->chunk = $chunk;
$this->stream = $stream;
}
function chunk(): string
{
return $this->chunk;
}
function stream(): Stream
{
return $this->stream;
}
}

View File

@@ -0,0 +1,228 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\JSON;
use Parsica\Parsica\Parser;
use function Parsica\Parsica\{any,
between,
char,
choice,
collect,
float,
hexDigitChar,
isCharCode,
keepFirst,
map,
recursive,
repeat,
satisfy,
sepBy,
string,
takeWhile,
zeroOrMore};
/**
* JSON parser and utility parsers
*
* @TODO fix psalm annotations
* @psalm-immutable
*/
final class JSON
{
private function __construct()
{
}
/**
* Fully compliant JSON parser, built entirely in Parsica. The output is compatible with PHP's native json_decode().
*
* It was built to illustrate the usage of Parsica on a real world format, and to benchmark Parsica against
* json_decode(). It will probably never reach the same performance as a C extension, so it shouldn't be used for
* typical production JSON parsing.
*
* It could however be useful as a basis to expand into a custom JSON parser, for example to expand JSON with custom
* notations or comments, or to return a custom AST instead of json_decode()'s plain PHP objects & arrays.
*
* To understand the terminology and the structure, have a peak at {@see https://www.json.org/json-en.html}
*
* @api
* @psalm-return Parser<mixed>
*/
public static function json(): Parser
{
return JSON::ws()->sequence(JSON::element());
}
/**
* @template T
* @psalm-return Parser<mixed>
* @psalm-suppress DocblockTypeContradiction
*/
public static function element(): Parser
{
// Memoize $element so we can keep reusing it for recursion.
/** @psalm-var Parser<mixed> $element */
static $element;
if (!isset($element)) {
$element = recursive();
$element->recurse(
any(
JSON::object(),
JSON::array(),
JSON::stringLiteral(),
JSON::number(),
JSON::true(),
JSON::false(),
JSON::null(),
)
);
}
return $element;
}
/**
* @psalm-return Parser<object>
*/
public static function object(): Parser
{
return map(
between(
JSON::token(char('{')),
JSON::token(char('}')),
sepBy(
JSON::token(char(',')),
JSON::member()
)
),
/**
* @psalm-param list<array{string:mixed}> $members
* @psalm-return object
*/
fn(array $members):object => (object)array_merge(...$members));
}
/**
* @psalm-return Parser<list<mixed>>
*/
public static function array(): Parser
{
return between(
JSON::token(char('[')),
JSON::token(char(']')),
sepBy(
JSON::token(char(',')), JSON::element()
)
);
}
/**
* @psalm-return Parser<bool>
*/
public static function true(): Parser
{
return JSON::token(string('true'))->map(fn($_) => true)->label('true');
}
/**
* @psalm-return Parser<bool>
*/
public static function false(): Parser
{
return JSON::token(string('false'))->map(fn($_) => false)->label('false');
}
/**
* @psalm-return Parser<null>
*/
public static function null(): Parser
{
return JSON::token(string('null'))->map(fn($_) => null)->label('null');
}
/**
* Whitespace
*
* @psalm-return Parser<null>
*/
public static function ws(): Parser
{
return takeWhile(isCharCode([0x20, 0x0A, 0x0D, 0x09]))->voidLeft(null)
->label('whitespace');
}
/**
* Apply $parser and consume all the following whitespace.
*
* @template T
* @psalm-param Parser<T> $parser
* @psalm-return Parser<T>
*/
public static function token(Parser $parser): Parser
{
return keepFirst($parser, JSON::ws());
}
public static function number(): Parser
{
return JSON::token(float())->map('floatval')->label("number");
}
/**
* @psalm-return Parser<string>
*/
public static function stringLiteral(): Parser
{
return JSON::token(
between(
char('"'),
char('"'),
zeroOrMore(
choice(
satisfy(fn(string $char): bool => !in_array($char, ['"', '\\'])),
char("\\")->followedBy(
choice(
char("\"")->map(fn($_) => '"'),
char("\\")->map(fn($_) => '\\'),
char("/")->map(fn($_) => '/'),
char("b")->map(fn($_) => mb_chr(8)),
char("f")->map(fn($_) => mb_chr(12)),
char("n")->map(fn($_) => "\n"),
char("r")->map(fn($_) => "\r"),
char("t")->map(fn($_) => "\t"),
char("u")->sequence(repeat(4, hexDigitChar()))->map(fn($o) => mb_chr(hexdec($o))),
)
)
)
)
)->map(fn($o): string => (string)$o) // because the empty json string returns null
)->label("string literal");
}
/**
* @return Parser<array{string:mixed}>
*/
public static function member(): Parser
{
return map(
collect(
JSON::stringLiteral(),
JSON::token(char(':')),
JSON::token(JSON::element())
),
/**
* @psalm-param array{0:string, 1:string, 2:mixed} $o
* @psalm-return array{string:mixed}
* @psalm-suppress MoreSpecificReturnType
* @psalm-suppress LessSpecificReturnStatement
*/
fn(array $o): array => [$o[0] => $o[2]]);
}
}

View File

@@ -0,0 +1,167 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica\PHPUnit;
use Exception;
use Parsica\Parsica\Parser;
use Parsica\Parsica\StringStream;
/**
* Convenience assertion methods. When writing tests for your own parsers, extend from this instead of PHPUnit's TestCase.
*
* @TODO move to standalone package
* @api
*/
trait ParserAssertions
{
/**
* @psalm-param mixed $expectedOutput
*
* @api
*/
protected function assertParses(string $input, Parser $parser, $expectedOutput, string $message = ""): void
{
$input = new StringStream($input);
$actualResult = $parser->run($input);
if ($actualResult->isSuccess()) {
$this->assertStrictlyEquals(
$expectedOutput,
$actualResult->output(),
$message . "\n" . "The parser succeeded but the output doesn't match your expected output."
);
} else {
$this->fail(
$message . "\n"
."The parser failed with the following error message:\n"
.$actualResult->errorMessage()."\n"
);
}
}
/**
* Behaves like assertSame for primitives, behaves like assertEquals for objects of the same type, and fails
* for everything else.
*
* @psalm-param mixed $expected
* @psalm-param mixed $actual
* @psalm-param string $message
*
* @throws Exception
* @api
*
* @psalm-suppress MixedArgument
* @psalm-suppress MixedAssignment
* @psalm-suppress MixedArrayAccess
*/
protected function assertStrictlyEquals($expected, $actual, string $message = ''): void
{
if (is_null($expected) || is_scalar($expected)) {
$this->assertSame($expected, $actual, $message);
} elseif (is_object($expected)) {
$this->assertEquals(get_class($expected), get_class($actual),
"Expected type didn't match actual type");
$this->assertEquals($expected, $actual, $message);
} elseif (is_array($expected)) {
foreach ($expected as $k => $v) {
$this->assertStrictlyEquals($expected[$k], $actual[$k], "Item $k from the actual array differs from item $k in the expected array");
}
$this->assertSame(count($expected), count($actual), "The length of the actual array differs from the length of the expected array.");
} else {
throw new Exception("@todo Not implemented");
}
}
abstract public static function assertSame($expected, $actual, string $message = ''): void;
abstract public static function assertEquals($expected, $actual, string $message = ''): void;
abstract public static function fail(string $message = ''): void;
/**
* @param string $input
* @param Parser $parser
* @param string $expectedRemaining
* @param string $message
*
* @api
*/
protected function assertRemainder(string $input, Parser $parser, string $expectedRemaining, string $message = ""): void
{
$input = new StringStream($input);
$actualResult = $parser->run($input);
if ($actualResult->isSuccess()) {
$this->assertEquals(
$expectedRemaining,
$actualResult->remainder(),
$message . "\n" . "The parser succeeded but the expected remaining input doesn't match."
);
} else {
$this->fail(
$message . "\n"
. "The parser failed with the following error message:\n"
.$actualResult->errorMessage()."\n"
);
}
}
/**
* @param string $input
* @param Parser $parser
* @param string|null $expectedFailure
* @param string $message
*
* @api
*/
protected function assertParseFails(string $input, Parser $parser, ?string $expectedFailure = null, string $message = ""): void
{
$input = new StringStream($input);
$actualResult = $parser->run($input);
$this->assertTrue(
$actualResult->isFail(),
$message . "\n" . "The parser succeeded but expected a failure."
);
if (isset($expectedFailure)) {
$this->assertEquals(
$expectedFailure,
$actualResult->expected(),
$message . "\n" . "The expected failure message is not the same as the actual one."
);
}
}
abstract public static function assertTrue($condition, string $message = ''): void;
/**
* @api
*/
protected function assertFailOnEOF(Parser $parser, string $message = ""): void
{
$actualResult = $parser->run(new StringStream(""));
$this->assertTrue(
$actualResult->isFail(),
$message . "\n" . "Expected the parser to fail on EOL."
);
}
/**
* @api
*/
protected function assertSucceedOnEOF(Parser $parser, string $message = ""): void
{
$actualResult = $parser->run(new StringStream(""));
$this->assertTrue(
$actualResult->isSuccess(),
$message . "\n" . "Expected the parser to succeed on EOL."
);
$this->assertSame("", $actualResult->output());
}
}

View File

@@ -0,0 +1,136 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use BadMethodCallException;
use Parsica\Parsica\Internal\Position;
/**
* @template T
* @psalm-immutable
*/
interface ParseResult
{
/**
* True if the parser was successful.
*
* @api
* @psalm-mutation-free
*/
public function isSuccess(): bool;
/**
* True if the parser has failed.
*
* @api
* @psalm-mutation-free
*/
public function isFail(): bool;
/**
* The output of the parser.
*
* @psalm-return T
* @api
* @psalm-mutation-free
*/
public function output();
/**
* The part of the input that did not get parsed.
*
* @api
* @psalm-mutation-free
*/
public function remainder(): Stream;
/**
* A message that indicates what the failed parser expected to find at its position in the input. It contains the
* label that was attached to the parser.
*
* @see Parser::label()
*
* @api
* @psalm-mutation-free
*/
public function expected(): string;
/**
* A message indicating the input that the failed parser got at the point where it failed. It's only informational,
* so don't use this for processing. A future version might change this behaviour.
*
* @api
* @psalm-mutation-free
*/
public function got(): Stream;
/**
* Append the output of two successful ParseResults. If one or both have failed, it returns the first failed
* ParseResult.
*
* @psalm-param ParseResult<T> $other
*
* @psalm-return ParseResult<T>
*
* @api
* @psalm-mutation-free
*/
public function append(ParseResult $other): ParseResult;
/**
* Map a function over the output
*
* @template T2
*
* @psalm-param pure-callable(T):T2 $transform
*
* @psalm-return ParseResult<T2>
*
* @api
* @psalm-mutation-free
*/
public function map(callable $transform): ParseResult;
/**
* Use the remainder of this ParseResult as the input for a parser.
*
* @template T2
*
* @psalm-param Parser<T2> $parser
*
* @psalm-return ParseResult<T2>
*
* @api
* @psalm-mutation-free
*/
public function continueWith(Parser $parser): ParseResult;
/**
* @psalm-mutation-free
*/
public function errorMessage() : string;
/**
* Get the last position of where the parser ended up when producing this result.
* @psalm-mutation-free
*/
public function position(): Position;
/**
* Throw a ParserFailure exception if the Parser failed, or complain if you're trying to throw a successful
* ParseResult.
*
* @api
* @throws ParserHasFailed
* @throws BadMethodCallException
*/
public function throw() : void;
}

View File

@@ -0,0 +1,469 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Exception;
use Parsica\Parsica\Internal\Fail;
/**
* A parser is any function that takes a string input and returns a {@see ParseResult}. The Parser class is a wrapper
* around such functions. The {@see Parser::make()} static constructor takes a callable that does the actual parsing.
* Usually you don't need to instantiate this class directly. Instead, build your parser from existing parsers and
* combinators.
*
* At the moment, there is no Parser interface, and no Parser abstract class to extend from. This is intentional, but
* will be changed if we find use cases where those would be the best solutions.
*
* The type is Parser<T>, where T is the type of the output that the parser will produce after completing successfully.
*
* @template T
* @api
*/
final class Parser
{
/**
* @psalm-var pure-callable(Stream) : ParseResult<T> $parserF
*/
private $parserFunction;
/** @psalm-var 'non-recursive'|'awaiting-recurse'|'recursion-was-setup' */
private string $recursionStatus;
private string $label;
/**
* @psalm-param pure-callable(Stream) : ParseResult<T> $parserFunction
* @psalm-param 'non-recursive'|'awaiting-recurse'|'recursion-was-setup' $recursionStatus
* @psalm-pure
* @psalm-suppress ImpureVariable
*/
private function __construct(callable $parserFunction, string $recursionStatus, string $label)
{
$this->parserFunction = $parserFunction;
$this->recursionStatus = $recursionStatus;
$this->label = $label;
}
/**
* Create a recursive parser. Used in combination with recurse(Parser).
*
* @see recursive()
*
* @psalm-return Parser<mixed>
* @api
* @psalm-pure
*/
public static function recursive(): Parser
{
return new Parser(
// Make a placeholder parser that will throw when you try to run it.
static function (Stream $_): ParseResult {
throw new Exception(
"Can't run a recursive parser that hasn't been setup properly yet. "
. "A parser created by recursive(), must then be called with ->recurse(Parser) "
. "before it can be used."
);
}, 'awaiting-recurse', "<recursive>");
}
/**
* Make a new parser.
*
* @internal
*
* @template T2
*
* @psalm-param pure-callable(Stream):ParseResult<T2> $parserFunction
*
* @psalm-return Parser<T2>
* @psalm-pure
*/
public static function make(string $label, callable $parserFunction): Parser
{
return new Parser($parserFunction, 'non-recursive', $label);
}
/**
* Recurse on a parser. Used in combination with {@see recursive()}. After calling this method, this parser behaves
* like a regular parser.
*
* @psalm-param Parser<mixed> $parser
*
* @api
*/
public function recurse(Parser $parser): void
{
switch ($this->recursionStatus) {
case 'non-recursive':
throw new Exception(
"You can't recurse on a non-recursive parser. Create a recursive parser first using recursive(), "
. "then call ->recurse() on it."
);
case 'recursion-was-setup':
throw new Exception("You can only call recurse() once on a recursive parser.");
case 'awaiting-recurse':
// Replace the placeholder parser from recursive() with a call to the inner parser. This must be dynamic,
// because it's possible that the inner parser is also a recursive parser that has not been set up yet.
$this->parserFunction = fn(Stream $input): ParseResult => $parser->run($input);
$this->recursionStatus = 'recursion-was-setup';
$this->label = $parser->getLabel();
break;
default:
throw new Exception("Unexpected recursionStatus value");
}
}
/**
* Run the parser on an input
*
* @psalm-return ParseResult<T>
* @api
* @psalm-mutation-free
*/
public function run(Stream $input): ParseResult
{
return ($this->parserFunction)($input);
}
/**
* Optionally parse something, but still succeed if the thing is not there.
*
*
* @psalm-return Parser<T|null>
* @see optional()
* @api
* @psalm-mutation-free
*/
public function optional(): Parser
{
return optional($this);
}
/**
* Try the first parser, and failing that, try the second parser. Returns the first succeeding result, or the first
* failing result.
*
* Caveat: The order matters!
* string('http')->or(string('https')
*
* @psalm-param Parser<T> $other
*
* @psalm-return Parser<T>
* @api
* @psalm-mutation-free
*/
public function or(Parser $other): Parser
{
return either($this, $other);
}
/**
* Parse something, then follow by something else. Ignore the result of the first parser and return the result of
* the second parser.
*
* @template T2
* @psalm-param Parser<T2> $second
* @psalm-return Parser<T2>
* @api
* @see sequence()
* @psalm-mutation-free
*/
public function followedBy(Parser $second): Parser
{
return sequence($this, $second);
}
/**
* Parse something, then follow by something else. Ignore the result of the first parser and return the result of
* the second parser.
*
* @template T2
* @psalm-param Parser<T2> $second
* @psalm-return Parser<T2>
* @api
* @see sequence()
* @psalm-mutation-free
*/
public function sequence(Parser $second): Parser
{
return sequence($this, $second);
}
/**
* Parse something, then follow by something else. Ignore the result of the first parser and return the result of
* the second parser. Alias for sequence().
*
* @template T2
* @psalm-param Parser<T2> $second
* @psalm-return Parser<T2>
* @api
* @see sequence()
* @psalm-mutation-free
*/
public function then(Parser $second): Parser
{
return sequence($this, $second);
}
/**
* Create a parser that takes the output from the first parser (if successful) and feeds it to the callable. The
* callable must return another parser. If the first parser fails, the first parser is returned.
*
* @template T2
*
* @psalm-param pure-callable(T) : Parser<T2> $f
*
* @psalm-return Parser<T2>
* @see bind()
* @api
* @psalm-mutation-free
*/
public function bind(callable $f): Parser
{
return bind($this, $f);
}
/**
* Map a function over the parser (which in turn maps it over the result).
*
* @template T2
*
* @psalm-param pure-callable(T) : T2 $transform
*
* @psalm-return Parser<T2>
* @api
* @psalm-mutation-free
*/
public function map(callable $transform): Parser
{
return map($this, $transform);
}
/**
* Take the remaining input from the result and parse it.
*
* @api
* @psalm-mutation-free
*/
public function continueFrom(ParseResult $result): ParseResult
{
return $this->run($result->remainder());
}
/**
* Combine the parser with another parser of the same type, which will cause the results to be appended.
*
* @psalm-param Parser<T|null> $other
* @psalm-return Parser<T|null>
* @api
* @psalm-mutation-free
*/
public function append(Parser $other): Parser
{
return append($this, $other);
}
/**
* Combine the parser with another parser of the same type, which will cause the results to be appended.
*
* @psalm-param Parser<T|null> $other
* @psalm-return Parser<T|null>
* @api
* @psalm-mutation-free
*/
public function and(Parser $other): Parser
{
return append($this, $other);
}
/**
* Try to parse a string. Alias of `try(new StringStream($string))`.
*
* @TODO Try should fail when it doesn't consume the whole input.
*
* @psalm-param string $input
*
* @psalm-return ParseResult<T>
*
* @throws ParserHasFailed
* @api
*/
public function tryString(string $input): ParseResult
{
return $this->try(new StringStream($input));
}
/**
* Try to parse the input, or throw an exception.
*
* @TODO Try should fail when it doesn't consume the whole input.
*
* @psalm-return ParseResult<T>
*
* @throws ParserHasFailed
* @api
*/
public function try(Stream $input): ParseResult
{
$result = $this->run($input);
if ($result->isFail()) {
$result->throw();
}
return $result;
}
/**
* Sequential application. Given a parser which outputs a callable, return a new parser that applies the callable on the
* output of the second parser.
*
* The first parser must be of type Parser<callable(T1):T2>. {@see pure()} can be used to wrap a callable in a Parser.
*
* Callables with more than 1 argument need to be curried: pure(curry(fn($x, $y)))->apply($parser2)->apply($parser3)
*
* @template T2
* @template T3
* @psalm-param Parser<T2> $parser
* @psalm-return Parser<T3>
* @psalm-suppress MixedArgumentTypeCoercion
*
* @api
* @psalm-mutation-free
*/
public function apply(Parser $parser): Parser
{
return apply($this, $parser);
}
/**
* Sequence two parsers, and return the output of the first one, ignore the second.
*
* @api
* @psalm-mutation-free
*/
public function thenIgnore(Parser $other): Parser
{
return keepFirst($this, $other);
}
/**
* notFollowedBy only succeeds when $second fails. It never consumes any input.
*
* Example:
*
* `string("print")` will also match "printXYZ"
*
* `string("print")->notFollowedBy(alphaNumChar()))` will match "print something" but not "printXYZ something"
*
* @psalm-param Parser<T2> $parser
*
* @psalm-return Parser<T>
* @see notFollowedBy()
*
* @template T2
* @api
* @psalm-mutation-free
*/
public function notFollowedBy(Parser $second): Parser
{
return keepFirst($this, notFollowedBy($second));
}
/**
* The parser's label.
*
* @internal
* @psalm-mutation-free
*/
public function getLabel(): string
{
return $this->label;
}
/**
* Label a parser. When a parser fails, you'll see your label as the "expected" value. As a best practice, the
* labels should make sense to the person who provides the input for your parser. That's often an end user or a
* third party, so keep them in mind.
*
* @psalm-return Parser<T>
* @api
* @psalm-mutation-free
*/
public function label(string $label): Parser
{
$parserFn = $this->parserFunction;
$newParserFunction = static function (Stream $input) use ($parserFn, $label) : ParseResult {
/** @psalm-var ParseResult $result */
$result = ($parserFn)($input);
return ($result->isSuccess())
? $result
: new Fail($label, $result->got());
};
return new Parser($newParserFunction, $this->recursionStatus, $label);
}
/**
* If the parser is successful, call the $receiver function with the output of the parser. The resulting parser
* behaves identical to the original one. This combinator is useful for expressing side effects during the parsing
* process. It can be hooked into existing event publishing libraries by using $receiver as an adapter for those.
* Other use cases are logging, caching, performing an action whenever a value is matched in a long running input
* stream, ...
*
* @psalm-param callable(T): void $receiver
*
* @psalm-return Parser<T>
* @api
*/
public function emit(callable $receiver): Parser
{
return emit($this, $receiver);
}
/**
* Ignore the output of the parser and return the new output instead.
*
* @template T2
* @psalm-param T2 $output
* @psalm-return Parser<T2>
*
* @deprecated @TODO needs test
* @psalm-mutation-free
*/
public function voidLeft($output): Parser
{
return $this->map(
/**
* @psalm-param T $_
* @psalm-return T2
*/
fn($_) => $output
);
}
/**
* Make sure that the input ends after the parser has successfully completed. The output is the output of the
* original parser.
*
* Also useful in unit tests to make sure a parser doesn't consume more than you intended.
*
* Alias for $parser->thenIgnore(eof()).
*
* @api
* @psalm-return Parser<T>
* @psalm-mutation-free
*/
public function thenEof(): Parser
{
return keepFirst($this, eof());
// aka $this->thenIgnore(eof());
}
}

View File

@@ -0,0 +1,38 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Exception;
use Parsica\Parsica\Internal\Fail;
/**
* @api
*/
final class ParserHasFailed extends Exception
{
private Fail $parseResult;
/**
* @inheritDoc
*/
function __construct(Fail $parseResult)
{
$this->parseResult = $parseResult;
parent::__construct($this->parseResult->errorMessage());
}
function parseResult() : Fail
{
return $this->parseResult;
}
}

View File

@@ -0,0 +1,77 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\Position;
use Parsica\Parsica\Internal\TakeResult;
/**
* Represents an input stream. This allows us to have different types of input, each with their own optimizations.
*
* @psalm-immutable
*/
interface Stream
{
/**
* Extract a single token from the stream. Throw if the stream is empty.
*
* @throw EndOfStream
* @psalm-mutation-free
*/
public function take1(): TakeResult;
/**
* Try to extract a chunk of length $n, or if the stream is too short, the rest of the stream.
*
* Valid implementation should follow the rules:
*
* 1. If the requested length <= 0, the empty token and the original stream should be returned.
* 2. If the requested length > 0 and the stream is empty, throw EndOfStream.
* 3. In other cases, take a chunk of length $n (or shorter if the stream is not long enough) from the input stream
* and return the chunk along with the rest of the stream.
*
* @throw EndOfStream
* @psalm-mutation-free
*/
public function takeN(int $n): TakeResult;
/**
* Extract a chunk of the stream, by taking tokens as long as the predicate holds. Return the chunk and the rest of
* the stream.
*
* @TODO This method isn't strictly necessary but let's see.
*
* @psalm-param pure-callable(string):bool $predicate
* @psalm-mutation-free
*/
public function takeWhile(callable $predicate) : TakeResult;
/**
* @deprecated We will need to get rid of this again at some point, we can't assume all streams will be strings
* @psalm-mutation-free
*/
public function __toString(): string;
/**
* Test if the stream is at its end.
* @psalm-mutation-free
*/
public function isEOF(): bool;
/**
* The position of the parser in the stream.
*
* @internal
* @psalm-mutation-free
*/
public function position() : Position;
}

View File

@@ -0,0 +1,133 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\EndOfStream;
use Parsica\Parsica\Internal\Position;
use Parsica\Parsica\Internal\TakeResult;
/**
* @psalm-immutable
*/
final class StringStream implements Stream
{
private string $string;
private Position $position;
/**
* @api
*/
public function __construct(string $string, ?Position $position = null)
{
$this->string = $string;
$this->position = $position ?? Position::initial();
}
/**
* @inheritDoc
* @internal
* @psalm-mutation-free
*/
public function take1(): TakeResult
{
if ($this->string === '') {
throw new EndOfStream("End of stream was reached in " . $this->position->pretty());
}
$token = mb_substr($this->string, 0, 1);
$position = $this->position->advance($token);
return new TakeResult(
$token,
new StringStream(mb_substr($this->string, 1), $position)
);
}
/**
* @inheritDoc
* @psalm-mutation-free
*/
public function isEOF(): bool
{
return $this->string === '';
}
/**
* @inheritDoc
* @psalm-mutation-free
*/
public function takeN(int $n): TakeResult
{
if ($n <= 0) {
return new TakeResult("", $this);
}
if ($this->string === '') {
throw new EndOfStream("End of stream was reached in " . $this->position->pretty());
}
$chunk = mb_substr($this->string, 0, $n);
return new TakeResult(
$chunk,
new StringStream(
mb_substr($this->string, $n),
$this->position->advance($chunk)
)
);
}
/**
* @psalm-param pure-callable(string) : bool $predicate
* @psalm-mutation-free
* @inheritDoc
*/
public function takeWhile(callable $predicate): TakeResult
{
if ($this->string === '') {
return new TakeResult("", $this);
}
$remaining = $this->string;
$nextToken = mb_substr($remaining, 0, 1);
$chunk = "";
while ($predicate($nextToken)) {
$chunk .= $nextToken;
$remaining = mb_substr($remaining, 1);
if ($remaining !== '') {
$nextToken = mb_substr($remaining, 0, 1);
} else {
break;
}
}
return new TakeResult(
$chunk,
new StringStream($remaining, $this->position->advance($chunk))
);
}
/**
* @psalm-mutation-free
*/
public function __toString(): string
{
return $this->string;
}
/**
* @inheritDoc
* @psalm-mutation-free
*/
public function position(): Position
{
return $this->position;
}
}

View File

@@ -0,0 +1,190 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\Assert;
/**
* Parse a single character.
*
* @psalm-param string $c A single character
*
* @psalm-return Parser<string>
* @api
* @see charI()
* @psalm-pure
*/
function char(string $c): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::singleChar($c);
return satisfy(isEqual($c))->label("'$c'");
}
/**
* Parse a single character, case-insensitive and case-preserving. On success, it returns the string cased as the
* actually parsed input.
*
* eg charI('a'')->run("ABC") will succeed with "A", not "a".
*
* @psalm-param string $c A single character
*
* @psalm-return Parser<string>
* @api
*
* @see char()
* @psalm-pure
*/
function charI(string $c): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::singleChar($c);
$lower = mb_strtolower($c);
$upper = mb_strtoupper($c);
$label = $lower==$upper ? "'$c'" : "'$lower' or '$upper'";
return satisfy(orPred(isEqual($lower), isEqual($upper)))->label($label);
}
/**
* Parse a control character (a non-printing character of the Latin-1 subset of Unicode).
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function controlChar(): Parser
{
return satisfy(isControl())->label("<controlChar>");
}
/**
* Parse an uppercase character A-Z.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function upperChar(): Parser
{
return satisfy(isUpper())->label("A-Z");
}
/**
* Parse a lowercase character a-z.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function lowerChar(): Parser
{
return satisfy(isLower())->label("a-z");
}
/**
* Parse an uppercase or lowercase character A-Z, a-z.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function alphaChar(): Parser
{
return satisfy(isAlpha())->label("A-Z or a-z");
}
/**
* Parse an alpha or numeric character A-Z, a-z, 0-9.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function alphaNumChar(): Parser
{
return satisfy(isAlphaNum())->label("A-Z or a-z or 0-9");
}
/**
* Parse a printable ASCII char.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function printChar(): Parser
{
return satisfy(isPrintable())->label("<printChar>");
}
/**
* Parse a single punctuation character !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function punctuationChar(): Parser
{
return satisfy(isPunctuation())->label("<punctuation>");
}
/**
* Parse 0-9. Returns the digit as a string. Use ->map('intval')
* or similar to cast it to a numeric type.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function digitChar(): Parser
{
return satisfy(isDigit())->label('0-9');
}
/**
* Parse a binary character 0 or 1.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function binDigitChar(): Parser
{
return satisfy(isCharCode([0x30, 0x31]))->label("'0' or '1'");
}
/**
* Parse an octodecimal character 0-7.
*
* @psalm-return Parser<string>
*
* @api
* @psalm-pure
*/
function octDigitChar(): Parser
{
return satisfy(isCharCode(range(0x30, 0x37)))->label("0-7");
}
/**
* Parse a hexadecimal numeric character 0123456789abcdefABCDEF.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function hexDigitChar(): Parser
{
return satisfy(isHexDigit())->label("<hexadecimal>");
}

View File

@@ -0,0 +1,679 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use InvalidArgumentException;
use Parsica\Parsica\Internal\Assert;
use Parsica\Parsica\Internal\Fail;
use Parsica\Parsica\Internal\Succeed;
use function Parsica\Parsica\Internal\FP\foldl;
/**
* Identity parser, returns the Parser as is.
*
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<T>
* @api
*
* @template T
* @psalm-pure
*/
function identity(Parser $parser): Parser
{
return $parser;
}
/**
* A parser that will have the argument as its output, no matter what the input was. It doesn't consume any input.
*
* @psalm-param T $output
*
* @psalm-return Parser<T>
* @api
*
* @template T
* @psalm-pure
*/
function pure($output): Parser
{
return Parser::make("<pure>", fn(Stream $input) => new Succeed($output, $input));
}
/**
* Optionally parse something, but still succeed if the thing is not there
*
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<T|null>
* @api
* @template T
* @psalm-pure
*/
function optional(Parser $parser): Parser
{
return either($parser, succeed())->label("optional " . $parser->getLabel());
}
/**
* Create a parser that takes the output from the first parser (if successful) and feeds it to the callable. The callable
* must return another parser. If the first parser fails, the first parser is returned.
*
* This is a monadic bind aka flatmap.
*
* @psalm-param Parser<T1> $parser
* @psalm-param pure-callable(T1) : Parser<T2> $f
*
* @psalm-return Parser<T2>
* @api
* @template T1
* @template T2
* @psalm-pure
*/
function bind(Parser $parser, callable $f): Parser
{
/**
* @psalm-var pure-callable(Stream) : ParseResult<T2> $parserFunction
*/
$parserFunction = static function (Stream $input) use ($parser, $f): ParseResult {
$result = $parser->run($input)->map($f);
if ($result->isFail()) {
return $result;
}
$p2 = $result->output();
return $result->continueWith($p2);
};
$finalParser = Parser::make($parser->getLabel(), $parserFunction);
return $finalParser;
}
/**
* Sequential application. Given a parser which outputs a callable, return a new parser that applies the callable on the
* output of the second parser.
*
* The first parser must be of type Parser<callable(T1):T2>. {@see pure()} can be used to wrap a callable in a Parser.
*
* Callables with more than 1 argument need to be curried: pure(curry(fn($x, $y)))->apply($parser2)->apply($parser3)
*
* @template T1
* @template T2
* @psalm-param Parser<pure-callable(T1):T2> $parser1
* @psalm-param Parser<T1> $parser2
* @psalm-return Parser<T2>
* @api
* @psalm-pure
*/
function apply(Parser $parser1, Parser $parser2): Parser
{
/**
* @psalm-var pure-callable(Stream): ParseResult<T2>
*/
$parserFunction = static function (Stream $input) use ($parser2, $parser1): ParseResult {
$r1 = $parser1->run($input);
if ($r1->isFail()) {
return $r1;
}
$f = $r1->output();
Assert::isCallable($f, "apply() can only be used when the output of the first parser is a callable with 1 argument. Use currying for functions with more than 1 argument.");
// @todo assert that the arity of $f == 1
return $r1->continueWith($parser2)->map($f);
};
$parser = Parser::make($parser1->getLabel(), $parserFunction);
return $parser;
}
/**
* Parse something, then follow by something else. Ignore the result of the first parser and return the result of the
* second parser.
*
* @psalm-param Parser<T1> $first
* @psalm-param Parser<T2> $second
*
* @psalm-return Parser<T2>
* @template T1
* @template T2
* @api
* @see Parser::sequence()
* @psalm-pure
*/
function sequence(Parser $first, Parser $second): Parser
{
return bind($first, /** @psalm-param mixed $_ */ fn($_) => $second);
}
/**
* Sequence two parsers, and return the output of the first one.
*
* @template T1
* @template T2
* @psalm-param Parser<T1> $first
* @psalm-param Parser<T2> $second
* @psalm-return Parser<T1>
* @api
* @psalm-pure
*/
function keepFirst(Parser $first, Parser $second): Parser
{
return bind(
$first,
/** @psalm-suppress MissingClosureParamType */
fn($a): Parser => sequence($second, pure($a))
);
}
/**
* Sequence two parsers, and return the output of the second one.
*
* @template T1
* @template T2
* @psalm-param Parser<T1> $first
* @psalm-param Parser<T2> $second
* @psalm-return Parser<T2>
* @api
* @psalm-pure
*/
function keepSecond(Parser $first, Parser $second): Parser
{
return sequence($first, $second);
}
/**
* Either parse the first thing or the second thing
*
* @psalm-param Parser<T1> $first
* @psalm-param Parser<T2> $second
*
* @psalm-return Parser<T1|T2>
* @api
*
* @see Parser::or()
*
* @template T1
* @template T2
* @psalm-pure
*/
function either(Parser $first, Parser $second): Parser
{
$label = $first->getLabel() . " or " . $second->getLabel();
/**
* @psalm-var pure-callable(Stream): ParseResult<T1|T2> $parserFunction
*/
$parserFunction = static function (Stream $input) use ($second, $first, $label): ParseResult {
// @todo Megaparsec doesn't do automatic rollback, for performance reasons, and requires the user to add try
// combinators. We could mimic that behaviour as it is probably more performant
$r1 = $first->run($input);
if ($r1->isSuccess()) {
return $r1;
}
$r2 = $second->run($input);
if ($r2->isSuccess()) {
return $r2;
}
return new Fail($label, $r2->got());
};
return Parser::make($label, $parserFunction);
}
/**
* Combine the parser with another parser of the same type, which will cause the results to be appended.
*
* @psalm-param Parser<T|null> $left
* @psalm-param Parser<T|null> $right
*
* @psalm-return Parser<T|null>
* @api
* @template T
* @psalm-pure
*/
function append(Parser $left, Parser $right): Parser
{
return Parser::make($right->getLabel(), static function (Stream $input) use ($left, $right): ParseResult {
$r1 = $left->run($input);
$r2 = $r1->continueWith($right);
return $r1->append($r2);
});
}
/**
* Append all the passed parsers.
*
* @psalm-param list<Parser<T|null>> $parsers
* @psalm-return Parser<T|null>
* @api
* @template T
* @psalm-suppress MixedReturnStatement
* @psalm-suppress MixedInferredReturnType
* @psalm-pure
*/
function assemble(Parser ...$parsers): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::atLeastOneArg($parsers, "assemble()");
$first = array_shift($parsers);
/** @psalm-suppress InvalidArgument */
return array_reduce($parsers, fn(Parser $p1, Parser $p2): Parser => append($p1, $p2), $first);
}
/**
* Parse into an array that consists of the results of all parsers.
*
* @psalm-param list<Parser<mixed>> $parsers
* @psalm-return Parser<mixed>
* @api
* @psalm-pure
*/
function collect(Parser ...$parsers): Parser
{
$toArray =
/**
* @psalm-param mixed $v
* @psalm-return list<mixed>
*/
fn($v): array => [$v];
$arrayParsers = array_map(
fn(Parser $parser): Parser => map($parser, $toArray),
$parsers
);
return assemble(...$arrayParsers);
}
/**
* Tries each parser one by one, returning the result of the first one that succeeds.
*
* @no-named-arguments
* @psalm-param non-empty-list<Parser<mixed>> $parsers
* @psalm-return Parser<mixed>
* @api
* @psalm-pure
*/
function any(Parser ...$parsers): Parser
{
if (empty($parsers)) {
throw new InvalidArgumentException("any() expects at least one parser");
}
$labels = array_map(fn(Parser $p): string => $p->getLabel(), $parsers);
$label = implode(' or ', $labels);
return foldl(
$parsers,
fn(Parser $first, Parser $second): Parser => either($first, $second),
fail("")
)->label($label);
}
/**
* Tries each parser one by one, returning the result of the first one that succeeds.
*
* Alias for {@see any()}
*
* @no-named-arguments
* @psalm-param non-empty-list<Parser<mixed>> $parsers
* @psalm-return Parser<mixed>
* @api
* @psalm-pure
*/
function choice(Parser ...$parsers): Parser
{
return any(...$parsers);
}
/**
* One or more repetitions of Parser, with the outputs appended.
*
* @api
* @psalm-param Parser<T> $parser
* @psalm-return Parser<T>
* @template T
* @psalm-suppress MixedArgumentTypeCoercion
* @psalm-pure
*/
function atLeastOne(Parser $parser): Parser
{
/**
* @psalm-var pure-callable(Stream): ParseResult<T> $parserFunction
*/
$parserFunction = static function (Stream $input) use ($parser): ParseResult {
$result = $parser->run($input);
if ($result->isFail()) {
return $result;
}
$final = new Succeed(null, $result->remainder());
while ($result->isSuccess()) {
$final = $final->append($result);
$result = $parser->continueFrom($result);
}
return $final;
};
return Parser::make(
"at least one " . $parser->getLabel(), $parserFunction
);
}
/**
* Zero or more repetitions of Parser, with the outputs appended.
*
* @TODO Untested
*
* @api
* @psalm-param Parser<T> $parser
* @psalm-return Parser<T>
* @template T
* @psalm-suppress MixedArgumentTypeCoercion
* @psalm-pure
*/
function zeroOrMore(Parser $parser): Parser
{
/** @var pure-callable(Stream):ParseResult<T> $parserFunction */
$parserFunction = static function (Stream $input) use ($parser): ParseResult {
$result = new Succeed(null, $input);
$final = $result;
while ($result->isSuccess()) {
$final = $final->append($result);
$result = $parser->continueFrom($result);
}
return $final;
};
return Parser::make(
"zero or more " . $parser->getLabel(), $parserFunction
);
}
/**
* Parse something exactly n times
*
* @template T
*
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<T>
* @api
* @psalm-pure
*/
function repeat(int $n, Parser $parser): Parser
{
return foldl(
array_fill(0, $n - 1, $parser),
fn(Parser $l, Parser $r): Parser => append($l, $r),
$parser
)->label("$n times " . $parser->getLabel());
}
/**
* Parse something exactly n times and return as an array
*
* @TODO This doesn't feel very elegant.
*
* @template T
*
* @psalm-param positive-int $n
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<list<T>>
* @api
* @psalm-pure
*/
function repeatList(int $n, Parser $parser): Parser
{
/** @palm-var Parser<list<T>> $parser */
$parser = map(
$parser,
/**
* @psalm-param T $output
* @psalm-return list<T>
*/
fn($output): array => [$output]
);
$parsers = array_fill(0, $n - 1, $parser);
return foldl(
$parsers,
/**
* @psalm-param Parser<list<T>> $l
* @psalm-param Parser<list<T>> $r
* @psalm-return Parser<list<T>>
*
* @psalm-suppress InvalidReturnType
* @psalm-suppress InvalidReturnStatement
* @psalm-pure
*/
fn(Parser $l, Parser $r): Parser => append($l, $r),
$parser
)->label("$n times " . $parser->getLabel());
}
/**
* Parse something one or more times, and output an array of the successful outputs.
*
* @template T
*
* @psalm-param Parser<T> $parser
* @psalm-return Parser<list<T>>
*
* @api
* @psalm-pure
*/
function some(Parser $parser): Parser
{
return map(
collect($parser, many($parser)),
/**
* @psalm-param array{0: T, 1: list<T>} $o
* @psalm-return list<T>
*/
fn(array $o):array => array_merge([$o[0]], $o[1])
);
}
/**
* Parse something zero or more times, and output an array of the successful outputs.
*
* @template T
*
* @psalm-param Parser<T> $parser
* @psalm-return Parser<list<T>>
*
* @api
* @psalm-pure
*/
function many(Parser $parser): Parser
{
return Parser::make(
"many {$parser->getLabel()}",
function (Stream $remainder) use ($parser): ParseResult {
$result = [];
while (true) {
$lastResult = $parser->run($remainder);
if ($lastResult->isFail()) {
break;
}
$remainder = $lastResult->remainder();
$result[] = $lastResult->output();
}
/** @psalm-var ParseResult<list<T>> $succeed */
$succeed = new Succeed($result, $remainder);
return $succeed;
}
);
}
/**
* Parse $open, followed by $middle, followed by $close, and return the result of $middle. Useful for eg. "(value)".
*
* @template TO
* @template TM
* @template TC
*
* @psalm-param Parser<TO> $open
* @psalm-param Parser<TC> $close
* @psalm-param Parser<TM> $middle
*
* @psalm-return Parser<TM>
* @api
* @psalm-pure
*/
function between(Parser $open, Parser $close, Parser $middle): Parser
{
return keepSecond($open, keepFirst($middle, $close));
}
/**
* Parses zero or more occurrences of $parser, separated by $separator. Returns a list of values.
*
* The sepBy parser always succeed, even if it doesn't find anything. Use {@see sepBy1()} if you want it to find at
* least one value.
*
* @template TSeparator
* @template T
*
* @psalm-param Parser<TSeparator> $separator
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<list<T>>
*
* @api
* @psalm-pure
*/
function sepBy(Parser $separator, Parser $parser): Parser
{
return sepBy1($separator, $parser)->or(pure([]));
}
/**
* Parses one or more occurrences of $parser, separated by $separator. Returns a list of values.
*
* @template TS
* @template T
*
* @psalm-param Parser<TS> $separator
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<list<T>>
*
* @psalm-suppress MissingClosureReturnType
*
* @api
* @psalm-pure
*/
function sepBy1(Parser $separator, Parser $parser): Parser
{
/** @psalm-suppress MissingClosureParamType */
$prepend = fn($x) => fn(array $xs): array => array_merge([$x], $xs);
$label = $parser->getLabel() . ", separated by " . $separator->getLabel();
return pure($prepend)->apply($parser)->apply(many($separator->sequence($parser)))->label($label);
}
/**
* Parses 2 or more occurrences of $parser, separated by $separator. Returns a list of values.
*
* @template TS
* @template T
*
* @psalm-param Parser<TS> $separator
* @psalm-param Parser<T> $parser
*
* @psalm-return Parser<list<T>>
*
* @psalm-suppress MissingClosureReturnType
*
* @api
* @psalm-pure
*/
function sepBy2(Parser $separator, Parser $parser): Parser
{
/** @psalm-suppress MissingClosureParamType */
$prepend = fn($x) => fn(array $xs): array => array_merge([$x], $xs);
$label = "at least two of (" . $parser->getLabel() . "), separated by " . $separator->getLabel();
return pure($prepend)->apply(keepFirst($parser, $separator))->apply(sepBy1($separator, $parser))->label($label);
}
/**
* notFollowedBy only succeeds when $parser fails. It never consumes any input.
*
* Example:
*
* `string("print")` will also match "printXYZ"
*
* `keepFirst(string("print"), notFollowedBy(alphaNumChar()))` will match "print something" but not "printXYZ something"
*
* @template T
* @psalm-param Parser<T> $parser
* @psalm-return Parser<T>
* @see Parser::notFollowedBy()
*
* @api
* @psalm-pure
*/
function notFollowedBy(Parser $parser): Parser
{
/** @psalm-var Parser<string> $p */
$label = "notFollowedBy({$parser->getLabel()})";
$p = Parser::make($label, static function (Stream $input) use ($label, $parser): ParseResult {
$result = $parser->run($input);
return $result->isSuccess()
? new Fail($label, $input)
: new Succeed("", $input);
});
return $p;
}
/**
* Map a function over the parser (which in turn maps it over the result).
*
* @template T1
* @template T2
* @psalm-param pure-callable(T1) : T2 $transform
* @psalm-return Parser<T2>
* @api
* @psalm-pure
*/
function map(Parser $parser, callable $transform): Parser
{
return Parser::make($parser->getLabel(), fn(Stream $input): ParseResult => $parser->run($input)->map($transform));
}
/**
* If $parser succeeds (either consuming input or not), lookAhead behaves like $parser succeeded without consuming
* anything. If $parser fails, lookAhead has no effect, i.e. it will fail to consume input if $parser fails consuming
* input.
*
* @template T
* @psalm-param Parser<T> $parser
* @psalm-return Parser<T>
*
* @api
* @psalm-pure
*/
function lookAhead(Parser $parser): Parser
{
return Parser::make(
$parser->getLabel(),
static function (Stream $input) use ($parser): ParseResult {
$parseResult = $parser->run($input);
return $parseResult->isSuccess()
? new Succeed($parseResult->output(), $input)
: new Fail("lookAhead", $input);
}
);
}

View File

@@ -0,0 +1,66 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
/**
* Parse an integer and return it as a string. Use ->map('intval')
* or similar to cast it to a numeric type.
*
* Example: "-123"
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function integer(): Parser
{
$zeroNine = digitChar();
$oneNine = oneOfS("123456789");
$minus = char('-');
$digits = takeWhile1(isDigit())->label('at least one 0-9');
/** @var Parser<string> $parser */
$parser = choice(
$minus->append($oneNine)->append($digits),
$minus->append($zeroNine),
$oneNine->append($digits),
$zeroNine
);
return $parser;
}
/**
* Parse a float and return it as a string. Use ->map('floatval')
* or similar to cast it to a numeric type.
*
* Example: -123.456E-789
*
* @psalm-return Parser<string>
* @psalm-suppress InvalidReturnType
* @psalm-suppress InvalidReturnStatement
* @api
* @psalm-pure
*/
function float(): Parser
{
$digits = takeWhile1(isDigit())->label('at least one 0-9');
$fraction = char('.')->append($digits);
$sign = char('+')->or(char('-'))->or(pure('+'));
$exponent = assemble(
charI('e')->map(fn(string $s) : string => strtoupper($s)),
$sign,
$digits
);
return assemble(
integer(),
optional($fraction),
optional($exponent)
)->label("float");
}

View File

@@ -0,0 +1,249 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\Assert;
/**
* Creates an equality predicate
*
* @psalm-return pure-callable(string) : bool
*
* @api
* @psalm-pure
*/
function isEqual(string $x): callable
{
/** @psalm-suppress ImpureMethodCall */
Assert::singleChar($x);
return fn(string $y): bool => $x === $y;
}
/**
* Negates a predicate.
*
* @psalm-param pure-callable(string) : bool $predicate
*
* @psalm-return pure-callable(string) : bool
*
* @api
* @psalm-pure
*/
function notPred(callable $predicate): callable
{
return fn(string $x): bool => !$predicate($x);
}
/**
* Boolean And predicate.
*
* @psalm-param pure-callable(string) : bool $first
* @psalm-param pure-callable(string) : bool $second
*
* @psalm-return pure-callable(string) : bool
*
* @api
* @psalm-pure
*/
function andPred(callable $first, callable $second): callable
{
return fn(string $x): bool => $first($x) && $second($x);
}
/**
* Boolean Or predicate.
*
* @psalm-param pure-callable(string) : bool $first
* @psalm-param pure-callable(string) : bool $second
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function orPred(callable $first, callable $second): callable
{
return fn(string $x): bool => $first($x) || $second($x);
}
/**
* Predicate that checks if a character is in an array of character codes.
*
* @psalm-param list<int> $chars
*
* @psalm-return pure-callable(string) : bool
* @api
*
* @link https://doc.bccnsoft.com/docs/cppreference2018/en/c/string/wide/iswcntrl.html
* @psalm-pure
*/
function isCharCode(array $chars): callable
{
return fn(string $x): bool => in_array(mb_ord($x), $chars);
}
/**
* Returns true for a space character, and the control characters \t, \n, \r, \f, \v.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isSpace(): callable
{
return isCharCode([9, 10, 11, 12, 13, 32, 160]);
}
/**
* Like 'isSpace', but does not accept newlines and carriage returns.
*
* @psalm-return pure-callable(string) : bool
* @api
* @see isSpace
* @psalm-pure
*/
function isHSpace(): callable
{
return isCharCode([9, 11, 12, 32, 160]);
}
/**
* True for 0-9
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isDigit(): callable
{
return isCharCode(range(0x30, 0x39));
}
/**
* Control character predicate (a non-printing character of the Latin-1 subset of Unicode).
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isControl(): callable
{
return isCharCode(range(0x00, 0x1F) + [0x7F]);
}
/**
* Returns true for a space or a tab character
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isBlank() : callable
{
return isCharCode([0x9, 0x20]);
}
/**
* Returns true for a space character, and \t, \n, \r, \f, \v.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isWhitespace() : callable
{
return isCharCode([0x20, 0x9, 0xA, 0xB, 0xC, 0xD]);
}
/**
* Returns true for an uppercase character A-Z.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isUpper() : callable
{
return isCharCode(range(0x41, 0x5A));
}
/**
* Returns true for a lowercase character a-z.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isLower()
{
return isCharCode(range(0x61, 0x7A));
}
/**
* Returns true for an uppercase or lowercase character A-Z, a-z.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isAlpha() : callable
{
return isCharCode(array_merge(range(0x41, 0x5A), range(0x61, 0x7A)));
}
/**
* Returns true for an alpha or numeric character A-Z, a-z, 0-9.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isAlphaNum() : callable
{
return isCharCode(array_merge(range(0x30, 0x39), range(0x41, 0x5A), range(0x61, 0x7A)));
}
/**
* Returns true if the given character is a hexadecimal numeric character 0123456789abcdefABCDEF.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isHexDigit() : callable
{
return isCharCode(array_merge(range(0x30, 0x39), range(0x41, 0x46), range(0x61, 0x66)));
}
/**
* Returns true if the given character is a printable ASCII char.
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isPrintable() : callable
{
return isCharCode(range(0x20, 0x7E));
}
/**
* Returns true if the given character is a punctuation character !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~
*
* @psalm-return pure-callable(string) : bool
* @api
* @psalm-pure
*/
function isPunctuation() : callable
{
return isCharCode(array_merge(range(0x21, 0x2F), range(0x3A, 0x40), range(0x5B, 0x60), range(0x7B, 0x7E)));
}

View File

@@ -0,0 +1,320 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\Assert;
use Parsica\Parsica\Internal\EndOfStream;
use Parsica\Parsica\Internal\Fail;
use Parsica\Parsica\Internal\Succeed;
/**
* A parser that satisfies a predicate on a single token. Useful as a building block for writing things like char(),
* digit()...
*
* @template T
*
* @psalm-param callable(string) : bool $predicate
*
* @psalm-return Parser<T>
* @psalm-pure
*/
function satisfy(callable $predicate): Parser
{
$label = "satisfy(predicate)";
/** @psalm-var pure-callable(Stream) : ParseResult $parserFunction */
$parserFunction = static function (Stream $input) use ($label, $predicate): ParseResult {
try {
$t = $input->take1();
} catch (EndOfStream $e) {
return new Fail($label, $input);
}
return $predicate($t->chunk()) ? new Succeed($t->chunk(), $t->stream()) : new Fail($label, $input);
};
return Parser::make($label, $parserFunction);
}
/**
* Skip 0 or more characters as long as the predicate holds.
*
* @template T
*
* @psalm-param pure-callable(string) : bool $predicate
* @psalm-return Parser<null>
* @psalm-pure
*/
function skipWhile(callable $predicate): Parser
{
return takeWhile($predicate)->followedBy(pure(null));
}
/**
* Skip 1 or more characters as long as the predicate holds.
*
* @template T
*
* @psalm-param pure-callable(string) : bool $predicate
*
* @psalm-return Parser<null>
* @psalm-pure
*/
function skipWhile1(callable $predicate): Parser
{
return takeWhile1($predicate)->followedBy(pure(null));
}
/**
* Keep parsing 0 or more characters as long as the predicate holds.
*
* @template T
* @psalm-param pure-callable(string) : bool $predicate
* @psalm-return Parser<T>
* @psalm-pure
*/
function takeWhile(callable $predicate): Parser
{
/** @psalm-pure */
$parserFunction = static function (Stream $input) use ($predicate): ParseResult {
$t = $input->takeWhile($predicate);
return new Succeed($t->chunk(), $t->stream());
};
return Parser::make(
"takeWhile(predicate)", $parserFunction
);
}
/**
* Keep parsing 1 or more characters as long as the predicate holds.
*
* @template T
*
* @psalm-param pure-callable(string) : bool $predicate
*
* @psalm-return Parser<T>
* @psalm-pure
*/
function takeWhile1(callable $predicate): Parser
{
$label = "takeWhile1(predicate)";
return Parser::make($label, static function (Stream $input) use ($label, $predicate): ParseResult {
try {
$t = $input->take1();
} catch (EndOfStream $e) {
return new Fail($label, $input);
}
if (!$predicate($t->chunk())) {
return new Fail($label, $input);
}
$t = $input->takeWhile($predicate);
return new Succeed($t->chunk(), $t->stream());
}
);
}
/**
* Parse and return a single character of anything.
*
* @template T
*
* @psalm-return Parser<T>
* @psalm-pure
*/
function anySingle(): Parser
{
return satisfy(
/** @psalm-param mixed $_ */
fn($_) => true
)->label("anySingle");
}
/**
* Parse and return a single character of anything.
*
* @TODO This is an alias of anySingle. Should we get rid of one of them?
* @psalm-return Parser<string>
* @psalm-pure
*/
function anything(): Parser
{
return satisfy(fn(string $_) => true)->label("anything");
}
/**
* Match any character but the given one.
*
* @psalm-return Parser<string>
* @api
* @template T
* @psalm-pure
*/
function anySingleBut(string $x): Parser
{
return satisfy(notPred(isEqual($x)))->label("anySingleBut($x)");
}
/**
* Succeeds if the current character is in the supplied list of characters. Returns the parsed character.
*
* @psalm-param list<string> $chars
*
* @psalm-return Parser<string>
* @api
* @template T
* @psalm-pure
*/
function oneOf(array $chars): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::singleChars($chars);
return satisfy(fn(string $x) => in_array($x, $chars))->label("one of " . implode('', $chars));
}
/**
* A compact form of 'oneOf'.
* oneOfS("abc") == oneOf(['a', 'b', 'c'])
*
* @psalm-param string $chars
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function oneOfS(string $chars): Parser
{
/** @psalm-var list<string> $split */
$split = mb_str_split($chars);
return oneOf($split);
}
/**
* The dual of 'oneOf'. Succeeds if the current character is not in the supplied list of characters. Returns the
* parsed character.
*
* @psalm-param list<string> $chars
*
* @psalm-return Parser<string>
* @api
* @template T
* @psalm-pure
*/
function noneOf(array $chars): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::singleChars($chars);
return satisfy(fn(string $x) => !in_array($x, $chars))
->label("noneOf(" . implode('', $chars) . ")");
}
/**
* A compact form of 'noneOf'.
* noneOfS("abc") == noneOf(['a', 'b', 'c'])
*
* @psalm-param string $chars
*
* @psalm-return Parser<string>
* @api
* @template T
* @psalm-pure
*/
function noneOfS(string $chars): Parser
{
/** @psalm-var list<string> $split */
$split = mb_str_split($chars);
return noneOf($split);
}
/**
* Consume the rest of the input and return it as a string. This parser never fails, but may return the empty string.
*
* @psalm-return Parser<string>
* @api
* @template T
* @psalm-pure
*/
function takeRest(): Parser
{
return takeWhile(fn(string $_): bool => true);
}
/**
* Parse nothing, but still succeed.
*
* This serves as the zero parser in `append()` operations.
*
* @psalm-return Parser<null>
*
* @api
* @psalm-pure
*/
function nothing(): Parser
{
/** @psalm-var pure-callable(Stream):ParseResult<null> $result */
$result = fn(Stream $input) : ParseResult => new Succeed(null, $input);
$parser = Parser::make("<nothing>", $result);
return $parser;
}
/**
* Parse everything; that is, consume the rest of the input until the end.
*
* @api
* @psalm-pure
*/
function everything(): Parser
{
return Parser::make("<everything>", fn(Stream $input) => new Succeed((string)$input, new StringStream("")));
}
/**
* Always succeed, no matter what the input was.
*
* @api
* @psalm-pure
*/
function succeed(): Parser
{
return Parser::make("<always succeed>", fn(Stream $input) => new Succeed(null, $input));
}
/**
* Always fail, no matter what the input was.
*
* @return Parser
* @api
* @psalm-pure
*/
function fail(string $label): Parser
{
return Parser::make($label, fn(Stream $input) => new Fail($label, $input));
}
/**
* Parse the end of the input
*
* @psalm-return Parser<T>
* @api
* @template T
* @psalm-pure
*/
function eof(): Parser
{
$label = "<EOF>";
return Parser::make($label, fn(Stream $input): ParseResult => $input->isEOF()
? new Succeed("", $input)
: new Fail($label, $input)
);
}

View File

@@ -0,0 +1,25 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
/**
* Create a recursive parser. Used in combination with Parser#recurse().
*
* @psalm-return Parser<T>
* @api
*
* @template T
* @psalm-pure
*/
function recursive(): Parser
{
return Parser::recursive();
}

View File

@@ -0,0 +1,36 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
/**
* If the parser is successful, call the $receiver function with the output of the parser. The resulting parser
* behaves identical to the original one. This combinator is useful for expressing side effects during the parsing
* process. It can be hooked into existing event publishing libraries by using $receiver as an adapter for those. Other
* use cases are logging, caching, performing an action whenever a value is matched in a long-running input stream, ...
*
* @template T
* @psalm-param Parser<T> $parser
* @psalm-param callable(T): void $receiver
* @psalm-return Parser<T>
* @api
*/
function emit(Parser $parser, callable $receiver): Parser
{
/** @psalm-var pure-callable(Stream):ParseResult $parserFunction */
$parserFunction = static function (Stream $input) use ($receiver, $parser): ParseResult {
$result = $parser->run($input);
if ($result->isSuccess()) {
$receiver($result->output());
}
return $result;
};
return Parser::make("emit", $parserFunction);
}

146
vendor/parsica-php/parsica/src/space.php vendored Normal file
View File

@@ -0,0 +1,146 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
/**
* Parse a single space character.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function space(): Parser
{
return char(' ')->label("<space>");
}
/**
* Parse a single tab character.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function tab(): Parser
{
return char("\t")->label("<tab>");
}
/**
* Parse a space or tab.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function blank(): Parser
{
return satisfy(isBlank())->label("<blank>");
}
/**
* Parse a space character, and \t, \n, \r, \f, \v.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function whitespace(): Parser
{
return satisfy(isWhitespace())->label("<whitespace>");
}
/**
* Parse a newline character.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function newline(): Parser
{
return char("\n")->label("<newline>");
}
/**
* Parse a carriage return character and a newline character. Return the two characters. {\r\n}
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function crlf(): Parser
{
return string("\r\n")->label("<crlf>");
}
/**
* Parse a newline or a crlf.
*
* @psalm-return Parser<string>
* @api
* @psalm-pure
*/
function eol(): Parser
{
return either(newline(), crlf())->label("<EOL>");
}
/**
* Skip zero or more white space characters.
*
* @psalm-return Parser<null>
* @api
* @psalm-pure
*/
function skipSpace(): Parser
{
return skipWhile(isSpace());
}
/**
* Like 'skipSpace', but does not accept newlines and carriage returns.
*
* @psalm-return Parser<null>
* @api
* @see skipSpace
* @psalm-pure
*/
function skipHSpace(): Parser
{
return skipWhile(isHSpace());
}
/**
* Skip one or more white space characters.
*
* @psalm-return Parser<null>
* @api
* @psalm-pure
*/
function skipSpace1(): Parser
{
return skipWhile1(isSpace())->label("<space>");
}
/**
* Like 'skipSpace1', but does not accept newlines and carriage returns.
*
* @psalm-return Parser<null>
* @api
* @see skipSpace1
* @psalm-pure
*/
function skipHSpace1(): Parser
{
return skipWhile1(isHSpace())->label("<space>");
}

View File

@@ -0,0 +1,79 @@
<?php declare(strict_types=1);
/**
* This file is part of the Parsica library.
*
* Copyright (c) 2020 Mathias Verraes <mathias@verraes.net>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Parsica\Parsica;
use Parsica\Parsica\Internal\Assert;
use Parsica\Parsica\Internal\EndOfStream;
use Parsica\Parsica\Internal\Fail;
use Parsica\Parsica\Internal\Succeed;
use function Parsica\Parsica\Internal\FP\foldl;
/**
* Parse a non-empty string.
*
* @psalm-return Parser<string>
* @api
* @see stringI()
* @psalm-pure
*/
function string(string $str): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmpty($str);
$len = mb_strlen($str);
$label = "'$str'";
/** @psalm-var Parser<string> $parser */
$parser = Parser::make($label, static function (Stream $input) use ($label, $len, $str): ParseResult {
try {
$t = $input->takeN($len);
} catch (EndOfStream $e) {
return new Fail($label, $input);
}
return $t->chunk() === $str
? new Succeed($str, $t->stream())
: new Fail($label, $input);
}
);
return $parser;
}
/**
* Parse a non-empty string, case-insensitive, and case-preserving. On success, it returns the string cased as the
* actually parsed input.
* eg stringI("foobar")->tryString("foObAr") will succeed with "foObAr"
*
* @TODO The implementation could be replaced using Stream::takeWhile
*
* @psalm-return Parser<string>
* @api
* @see string()
* @psalm-pure
*/
function stringI(string $str): Parser
{
/** @psalm-suppress ImpureMethodCall */
Assert::nonEmpty($str);
/** @psalm-var list<string> $split */
$split = mb_str_split($str);
$chars = array_map(
fn(string $c): Parser => charI($c),
$split
);
/** @psalm-var Parser<string> $parser */
$parser = foldl(
$chars,
/** @psalm-pure */
fn(Parser $l, Parser $r): Parser => append($l, $r),
succeed()
)->label("'$str'");
return $parser;
}