diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 7b0be08..ac5b334 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['7.3', '7.4', '8.0', '8.1', '8.2', '8.3'] + php-versions: ['8.1', '8.2', '8.3'] experimental: [false] env: code-coverage-version: '8.3' # Most recent stable PHP version. diff --git a/composer.json b/composer.json index 384df3c..ae3e2d4 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "phpmd": "phpmd src,tests ansi phpmd.ruleset.xml" }, "require": { - "php": "^7.3 || ^8.0", + "php": "^8.1", "ext-json": "*" }, "require-dev": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0cc958d..a91c363 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,15 +7,8 @@ parameters: paths: - src - tests - + treatPhpDocTypesAsCertain: false universalObjectCratesClasses: - gapple\StructuredFields\Dictionary - gapple\StructuredFields\Parameters - featureToggles: - readOnlyByPhpDoc: true - - ignoreErrors: - - - message: '#Unreachable statement - code above always terminates.#' - path: src/TupleTrait.php diff --git a/src/Bytes.php b/src/Bytes.php index 8e796db..33141bc 100644 --- a/src/Bytes.php +++ b/src/Bytes.php @@ -6,15 +6,8 @@ class Bytes { - /** - * @var string - * @readonly - */ - private $value; - - public function __construct(string $value) + public function __construct(private readonly string $value) { - $this->value = $value; } public function __toString(): string diff --git a/src/Date.php b/src/Date.php index 03ca610..fb4193b 100644 --- a/src/Date.php +++ b/src/Date.php @@ -6,15 +6,8 @@ class Date { - /** - * @var int - * @readonly - */ - private $value; - - public function __construct(int $value) + public function __construct(private readonly int $value) { - $this->value = $value; } public function toInt(): int diff --git a/src/Dictionary.php b/src/Dictionary.php index 24406e6..c07259b 100644 --- a/src/Dictionary.php +++ b/src/Dictionary.php @@ -12,13 +12,13 @@ class Dictionary implements \IteratorAggregate /** * @var array */ - protected $value = []; + protected array $value = []; /** * @param array $array * @return Dictionary */ - public static function fromArray(array $array): Dictionary + public static function fromArray(array $array): self { $dictionary = new self(); @@ -40,7 +40,7 @@ public static function fromArray(array $array): Dictionary * @param string $name * @return TupleInterface|array{mixed, object}|null */ - public function __get(string $name) + public function __get(string $name): mixed { return $this->value[$name] ?? null; } @@ -50,7 +50,7 @@ public function __get(string $name) * @param TupleInterface|array{mixed, object} $value * @return void */ - public function __set(string $name, $value) + public function __set(string $name, mixed $value): void { $this->value[$name] = $value; } diff --git a/src/DisplayString.php b/src/DisplayString.php index 622c4e4..ecc6cb0 100644 --- a/src/DisplayString.php +++ b/src/DisplayString.php @@ -6,15 +6,8 @@ class DisplayString { - /** - * @var string - * @readonly - */ - private $value; - - public function __construct(string $value) + public function __construct(private readonly string $value) { - $this->value = $value; } public function __toString(): string diff --git a/src/InnerList.php b/src/InnerList.php index 05b59bc..02a8edf 100644 --- a/src/InnerList.php +++ b/src/InnerList.php @@ -17,12 +17,7 @@ public function __construct(array $value, ?object $parameters = null) array_walk($value, [$this, 'validateItemType']); $this->value = $value; - - if (is_null($parameters)) { - $this->parameters = new Parameters(); - } else { - $this->parameters = $parameters; - } + $this->parameters = $parameters ?? new Parameters(); } /** @@ -37,8 +32,9 @@ public static function fromArray(array $array): InnerList array_walk($array, function (&$item) { if (!$item instanceof TupleInterface) { $item = new Item($item); + } elseif ($item instanceof InnerList) { + throw new \InvalidArgumentException('InnerList objects cannot be nested'); } - self::validateItemType($item); }); /** @var TupleInterface[] $array */ @@ -49,7 +45,7 @@ public static function fromArray(array $array): InnerList * @param TupleInterface|array{mixed, object} $value * @return void */ - private static function validateItemType($value): void + private static function validateItemType(mixed $value): void { if (is_object($value)) { if (!($value instanceof TupleInterface)) { @@ -61,7 +57,7 @@ private static function validateItemType($value): void throw new \InvalidArgumentException('InnerList objects cannot be nested'); } } elseif (is_array($value)) { - if (count($value) != 2) { // @phpstan-ignore-line + if (count($value) != 2) { throw new \InvalidArgumentException(); } } else { diff --git a/src/Item.php b/src/Item.php index 28a81e6..a89cda9 100644 --- a/src/Item.php +++ b/src/Item.php @@ -12,14 +12,9 @@ class Item implements TupleInterface * @param mixed $value * @param object|null $parameters */ - public function __construct($value, ?object $parameters = null) + public function __construct(mixed $value, ?object $parameters = null) { $this->value = $value; - - if (is_null($parameters)) { - $this->parameters = new Parameters(); - } else { - $this->parameters = $parameters; - } + $this->parameters = $parameters ?? new Parameters(); } } diff --git a/src/OuterList.php b/src/OuterList.php index a23503c..8ff731c 100644 --- a/src/OuterList.php +++ b/src/OuterList.php @@ -15,7 +15,7 @@ class OuterList implements \IteratorAggregate, \ArrayAccess * * @var array */ - public $value; + public array $value; /** * @param array $value @@ -35,26 +35,25 @@ public function __construct(array $value = []) */ public static function fromArray(array $array): OuterList { - $list = new self(); - foreach ($array as $value) { - if (!$value instanceof TupleInterface) { - if (is_array($value)) { - $value = InnerList::fromArray($value); + array_walk($array, function (&$item) { + if (!$item instanceof TupleInterface) { + if (is_array($item)) { + $item = InnerList::fromArray($item); } else { - $value = new Item($value); + $item = new Item($item); } } - $list[] = $value; - } + }); - return $list; + /** @var TupleInterface[] $array */ + return new self($array); } /** * @param TupleInterface|array{mixed, object} $value * @return void */ - private static function validateItemType($value): void + private static function validateItemType(mixed $value): void { if (is_object($value)) { if (!($value instanceof TupleInterface)) { @@ -63,7 +62,7 @@ private static function validateItemType($value): void ); } } elseif (is_array($value)) { - if (count($value) != 2) { // @phpstan-ignore-line + if (count($value) != 2) { throw new \InvalidArgumentException(); } } else { @@ -87,10 +86,10 @@ public function offsetExists($offset): bool /** * @param int $offset - * @return TupleInterface|array{mixed, object}|null + * @return mixed + * @phpstan-return TupleInterface|array{mixed, object}|null */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet($offset): mixed { return $this->value[$offset] ?? null; } diff --git a/src/Parameters.php b/src/Parameters.php index 1e1cc4e..63aefb7 100644 --- a/src/Parameters.php +++ b/src/Parameters.php @@ -12,13 +12,13 @@ class Parameters implements \IteratorAggregate /** * @var array */ - protected $value = []; + protected array $value = []; /** - * @param array $array + * @param array $array * @return Parameters */ - public static function fromArray(array $array): Parameters + public static function fromArray(array $array): self { $parameters = new self(); $parameters->value = $array; @@ -30,7 +30,7 @@ public static function fromArray(array $array): Parameters * @param string $name * @return mixed|null */ - public function __get(string $name) + public function __get(string $name): mixed { return $this->value[$name] ?? null; } @@ -40,7 +40,7 @@ public function __get(string $name) * @param mixed $value * @return void */ - public function __set(string $name, $value) + public function __set(string $name, mixed $value): void { $this->value[$name] = $value; } diff --git a/src/Parser.php b/src/Parser.php index 13a59f5..cf95674 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -13,108 +13,119 @@ public static function parseDictionary(string $string): Dictionary { $value = new Dictionary(); - $string = ltrim($string, ' '); + $input = new ParsingInput($string); + $input->trim(); - while (!empty($string)) { - $key = self::parseKey($string); + if ($input->empty()) { + return $value; + } - if (!empty($string) && $string[0] === '=') { - $string = substr($string, 1); - $value->{$key} = self::parseItemOrInnerList($string); + while (true) { + $key = self::parseKey($input); + + if ($input->isChar('=')) { + $input->consumeChar(); + $value->{$key} = self::parseItemOrInnerList($input); } else { // Bare boolean true value. - $value->{$key} = new Item(true, self::parseParameters($string)); + $value->{$key} = new Item(true, self::parseParameters($input)); } - // OWS (optional whitespace) before comma. - // @see https://tools.ietf.org/html/rfc7230#section-3.2.3 - $string = ltrim($string, " \t"); + // Optional whitespace before comma or at end of string. + $input->trim(true); - if (empty($string)) { + if ($input->empty()) { return $value; } - // OWS (optional whitespace) after comma. - if (!preg_match('/^(,[ \t]*)/', $string, $comma_matches)) { - throw new ParseException('Expected comma'); + try { + $input->consumeChar(','); + } catch (\RuntimeException) { + throw new ParseException('Expected comma at position ' . $input->position()); } + // Optional whitespace after comma. + $input->trim(true); - $string = substr($string, strlen($comma_matches[1])); - - if (empty($string)) { + if ($input->empty()) { throw new ParseException('Unexpected end of input'); } } - - return $value; } public static function parseList(string $string): OuterList { $value = new OuterList(); + $input = new ParsingInput($string); + $input->trim(); - $string = ltrim($string, ' '); + if ($input->empty()) { + return $value; + } - while (!empty($string)) { - $value[] = self::parseItemOrInnerList($string); + while (true) { + $value[] = self::parseItemOrInnerList($input); - // OWS (optional whitespace) before comma. - // @see https://tools.ietf.org/html/rfc7230#section-3.2.3 - $string = ltrim($string, " \t"); + // Optional whitespace before comma or at end of string. + $input->trim(true); - if (empty($string)) { + if ($input->empty()) { return $value; } - // OWS (optional whitespace) after comma. - if (!preg_match('/^(,[ \t]*)/', $string, $comma_matches)) { - throw new ParseException('Expected comma'); + try { + $input->consumeChar(','); + } catch (\RuntimeException) { + throw new ParseException('Expected comma at position ' . $input->position()); } + // Optional whitespace after comma. + $input->trim(true); - $string = substr($string, strlen($comma_matches[1])); - - if (empty($string)) { + if ($input->empty()) { throw new ParseException('Unexpected end of input'); } } - - return $value; } - private static function parseItemOrInnerList(string &$string): TupleInterface + private static function parseItemOrInnerList(ParsingInput $input): TupleInterface { - if ($string[0] === '(') { - return self::parseInnerList($string); + if ($input->isChar('(')) { + return self::parseInnerList($input); } else { - return self::doParseItem($string); + return self::doParseItem($input); } } - private static function parseInnerList(string &$string): InnerList + /** + * @phpstan-impure + */ + private static function parseInnerList(ParsingInput $input): InnerList { + $startPosition = $input->position(); $value = []; - $string = substr($string, 1); - - while (!empty($string)) { - $string = ltrim($string, ' '); + $input->consumeChar('('); + while (!$input->empty()) { + $input->trim(); - if ($string[0] === ')') { - $string = substr($string, 1); + if ($input->isChar(')')) { + $input->consumeChar(); return new InnerList( $value, - self::parseParameters($string) + self::parseParameters($input) ); } - $value[] = self::doParseItem($string); + $value[] = self::doParseItem($input); - if (!empty($string) && !in_array($string[0], [' ', ')'])) { - throw new ParseException('Unexpected character in inner list'); + if (!($input->isChar(' ') || $input->isChar(')'))) { + if ($input->empty()) { + break; + } + throw new ParseException('Unexpected character in inner list at position ' . $input->position()); } } - throw new ParseException('Unexpected end of input'); + throw new ParseException('Unexpected end of list started at position ' . $startPosition); } /** @@ -125,197 +136,193 @@ private static function parseInnerList(string &$string): InnerList */ public static function parseItem(string $string): Item { - $string = ltrim($string, ' '); + $input = new ParsingInput($string); + + $input->trim(); + if ($input->empty()) { + throw new ParseException('Unexpected empty input'); + } - $value = self::doParseItem($string); + $value = self::doParseItem($input); + $input->trim(); - if (empty(ltrim($string, ' '))) { + if ($input->empty()) { return $value; } - throw new ParseException('Unexpected characters at end of input'); + throw new ParseException('Unexpected characters at position ' . $input->position()); } /** * Internal implementation of parseItem that doesn't fail if input string - * has unparsed characters after parsing. - * - * @param string $string + * has remaining characters after parsing. * - * @return Item - * A [value, parameters] tuple. + * @phpstan-impure */ - private static function doParseItem(string &$string): Item + private static function doParseItem(ParsingInput $input): Item { return new Item( - self::parseBareItem($string), - self::parseParameters($string) + self::parseBareItem($input), + self::parseParameters($input), ); } /** - * @param string $string - * * @return bool|float|int|string|Bytes|Date|DisplayString|Token + * + * @phpstan-impure */ - private static function parseBareItem(string &$string) + private static function parseBareItem(ParsingInput $input): mixed { - if ($string === "") { - throw new ParseException('Unexpected empty input'); - } elseif (preg_match('/^(-|\d)/', $string)) { - return self::parseNumber($string); - } elseif ($string[0] == '"') { - return self::parseString($string); - } elseif ($string[0] == ':') { - return self::parseByteSequence($string); - } elseif ($string[0] == '?') { - return self::parseBoolean($string); - } elseif ($string[0] == '@') { - return self::parseDate($string); - } elseif ($string[0] == '%') { - return self::parseDisplayString($string); - } elseif (preg_match('/^([a-z*])/i', $string)) { - return self::parseToken($string); - } - - throw new ParseException('Unknown item type'); + $char = $input->getChar(); + return match (true) { + preg_match('/(-|\d)/', $char) === 1 => self::parseNumber($input), + '"' === $char => self::parseString($input), + preg_match('/[a-z*]/i', $char) === 1 => self::parseToken($input), + ':' === $char => self::parseByteSequence($input), + '?' === $char => self::parseBoolean($input), + '@' === $char => self::parseDate($input), + '%' === $char => self::parseDisplayString($input), + default => throw new ParseException('Unknown item type at position ' . $input->position()), + }; } - private static function parseParameters(string &$string): Parameters + /** + * @phpstan-impure + */ + private static function parseParameters(ParsingInput $input): Parameters { $parameters = new Parameters(); + while ($input->isChar(';')) { + $input->consumeChar(); + $input->trim(); - while (!empty($string) && $string[0] === ';') { - $string = ltrim(substr($string, 1), ' '); - - $key = self::parseKey($string); + $key = self::parseKey($input); $parameters->{$key} = true; - if (!empty($string) && $string[0] === '=') { - $string = substr($string, 1); - $parameters->{$key} = self::parseBareItem($string); + if ($input->isChar('=')) { + $input->consumeChar(); + $parameters->{$key} = self::parseBareItem($input); } } return $parameters; } - private static function parseKey(string &$string): string + /** + * @phpstan-impure + */ + private static function parseKey(ParsingInput $input): string { - if (preg_match('/^[a-z*][a-z0-9.*_-]*/', $string, $matches)) { - $string = substr($string, strlen($matches[0])); - - return $matches[0]; + try { + return $input->consumeRegex('/^[a-z*][a-z0-9.*_-]*/'); + } catch (\RuntimeException) { + throw new ParseException('Invalid key at position ' . $input->position()); } - - throw new ParseException('Invalid character in key'); } - private static function parseBoolean(string &$string): bool + /** + * @phpstan-impure + */ + private static function parseBoolean(ParsingInput $input): bool { - if (!preg_match('/^\?[01]/', $string)) { - throw new ParseException('Invalid character in boolean'); - } - - $value = $string[1] === '1'; - - $string = substr($string, 2); - - return $value; + $input->consumeChar('?'); + return match ($input->consumeChar()) { + '0' => false, + '1' => true, + default => throw new ParseException('Invalid boolean at position ' . $input->position()), + }; } /** - * @param string $string - * @return int|float + * @phpstan-impure */ - private static function parseNumber(string &$string) + private static function parseNumber(ParsingInput $input): int|float { - if (preg_match('/^(-?\d+(?:\.\d+)?)(?:[^\d.]|$)/', $string, $number_matches)) { - $input_number = $number_matches[1]; - $string = substr($string, strlen($input_number)); - - if (preg_match('/^-?\d{1,12}\.\d{1,3}$/', $input_number)) { - return (float) $input_number; - } elseif (preg_match('/^-?\d{1,15}$/', $input_number)) { - return (int) $input_number; - } - throw new ParseException('Number contains too many digits'); + $startPosition = $input->position(); + try { + $number = $input->consumeRegex('/^(-?\d+(?:\.\d+)?)/'); + } catch (\RuntimeException) { + throw new ParseException('Invalid number format at position ' . $startPosition); } - throw new ParseException('Invalid number format'); + if (preg_match('/^-?\d{1,12}\.\d{1,3}$/', $number)) { + return (float) $number; + } elseif (preg_match('/^-?\d{1,15}$/', $number)) { + return (int) $number; + } + throw new ParseException('Number contains too many digits at position ' . $startPosition); } - private static function parseString(string &$string): string + /** + * @phpstan-impure + */ + private static function parseString(ParsingInput $input): string { - // parseString is only called if first character is a double quote, so - // don't need to validate it here. - $string = substr($string, 1); - - $output_string = ''; + $output = ''; - while (strlen($string)) { - $char = $string[0]; - $string = substr($string, 1); + $input->consumeChar('"'); + while (!$input->empty()) { + $char = $input->consumeChar(); - if ($char == '\\') { - if ($string == '') { + if ($char === '\\') { + if ($input->empty()) { throw new ParseException("Invalid end of string"); } - $char = $string[0]; - $string = substr($string, 1); - if ($char != '"' && $char != '\\') { - throw new ParseException('Invalid escaped character in string'); + $char = $input->consumeChar(); + if ($char !== '"' && $char !== '\\') { + throw new ParseException( + 'Invalid escaped character in string at position ' . ($input->position() - 1) + ); } - } elseif ($char == '"') { - return $output_string; + } elseif ($char === '"') { + return $output; } elseif (ord($char) <= 0x1f || ord($char) >= 0x7f) { - throw new ParseException('Invalid character in string'); + throw new ParseException('Invalid character in string at position ' . ($input->position() - 1)); } - $output_string .= $char; + $output .= $char; } throw new ParseException("Invalid end of string"); } /** + * @phpstan-impure * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private static function parseDisplayString(string &$string): DisplayString + private static function parseDisplayString(ParsingInput $string): DisplayString { - if (strpos($string, '%"') !== 0) { - throw new ParseException("Invalid start of display string"); + $startPosition = $string->position(); + try { + $string->consumeString('%"'); + } catch (\RuntimeException) { + throw new ParseException('Invalid start of display string at position ' . $startPosition); } - $string = substr($string, 2); - $encoded_string = ''; - while (strlen($string)) { - $char = $string[0]; - $string = substr($string, 1); + while (!$string->empty()) { + $char = $string->consumeChar(); if (ord($char) <= 0x1f || ord($char) >= 0x7f) { - throw new ParseException('Invalid character in display string'); - } elseif ($char == '%') { - if (strlen($string) < 2) { - throw new ParseException("Invalid end of display string"); - } - - $hex = substr($string, 0, 2); - $string = substr($string, 2); - - if (!preg_match('/^[0-9a-f]{2}$/', $hex)) { - throw new ParseException('Invalid hex values in display string'); + throw new ParseException( + 'Invalid character in display string at position ' . ($string->position() - 1) + ); + } elseif ($char === '%') { + try { + $encoded_string .= '%' . $string->consumeRegex('/^[0-9a-f]{2}/'); + } catch (\RuntimeException) { + throw new ParseException( + 'Invalid hex values in display string at position ' . ($string->position() - 1) + ); } - - $encoded_string .= $char . $hex; - } elseif ($char == '"') { + } elseif ($char === '"') { $display_string = new DisplayString(rawurldecode($encoded_string)); // An invalid UTF-8 subject will cause the preg_* function to match nothing. // @see https://www.php.net/manual/en/reference.pcre.pattern.modifiers.php if (!preg_match('/^\X*$/u', (string) $display_string)) { - throw new ParseException("Invalid byte sequence in display string"); + throw new ParseException('Invalid byte sequence in display string at position ' . $startPosition); } return $display_string; } else { @@ -323,52 +330,60 @@ private static function parseDisplayString(string &$string): DisplayString } } - throw new ParseException("Invalid end of display string"); + throw new ParseException('Invalid end of display string started at position ' . $startPosition); } - private static function parseToken(string &$string): Token + /** + * @phpstan-impure + */ + private static function parseToken(ParsingInput $input): Token { // Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing // 3.2.6. Field Value Components // @see https://tools.ietf.org/html/rfc7230#section-3.2.6 $tchar = preg_quote("!#$%&'*+-.^_`|~"); - preg_match('/^([a-z*][a-z0-9:\/' . $tchar . ']*)/i', $string, $matches); - $string = substr($string, strlen($matches[1])); - // parseToken is only called by parseBareItem if the initial character // is valid, so a Token object is always returned. If there is an // invalid character in the token, the public function that was called // will detect that the remainder of the input string is invalid. - return new Token($matches[1]); + return new Token($input->consumeRegex('/^([a-z*][a-z0-9:\/' . $tchar . ']*)/i')); } /** * Parse Base64-encoded data. * - * @param string $string - * - * @return Bytes + * @phpstan-impure */ - private static function parseByteSequence(string &$string): Bytes + private static function parseByteSequence(ParsingInput $input): Bytes { - if (preg_match('/^:([a-z0-9+\/=]*):/i', $string, $matches)) { - $string = substr($string, strlen($matches[0])); - return new Bytes(base64_decode($matches[1])); + $startPosition = $input->position(); + $input->consumeChar(':'); + try { + $bytes = $input->consumeRegex('/^([a-z0-9+\/=]*)/i'); + $input->consumeChar(':'); + return new Bytes(base64_decode($bytes)); + } catch (\RuntimeException) { + throw new ParseException('Invalid byte sequence at position ' . $startPosition); } - - throw new ParseException('Invalid character in byte sequence'); } - private static function parseDate(string &$string): Date + /** + * @phpstan-impure + */ + private static function parseDate(ParsingInput $input): Date { - $string = substr($string, 1); - $value = self::parseNumber($string); + $startPosition = $input->position(); + $input->consumeChar('@'); + try { + $value = self::parseNumber($input); - if (is_int($value)) { - return new Date($value); + if (is_int($value)) { + return new Date($value); + } + } catch (ParseException) { } - throw new ParseException("Invalid Date format"); + throw new ParseException('Invalid Date format at position ' . $startPosition); } } diff --git a/src/ParsingInput.php b/src/ParsingInput.php new file mode 100644 index 0000000..cdbc093 --- /dev/null +++ b/src/ParsingInput.php @@ -0,0 +1,125 @@ +length = strlen($this->value); + } + + public function position(): int + { + return $this->position; + } + + public function empty(): bool + { + return $this->position >= $this->length; + } + + public function remaining(): string + { + return substr($this->value, $this->position); + } + + /** + * Trim whitespace from beginning of string. + * + * @param bool $ows + * Whether all Optional Whitespace characters should be trimmed. If false, only space characters are trimmed. + * @see https://tools.ietf.org/html/rfc7230#section-3.2.3 + * @return void + */ + public function trim(bool $ows = false): void + { + while ( + $this->position < $this->length + && ( + $this->value[$this->position] === ' ' + || ($ows && $this->value[$this->position] === "\t") + ) + ) { + $this->position++; + } + } + + public function isChar(string $char): bool + { + assert(strlen($char) === 1); + + return $this->position < $this->length + && $this->value[$this->position] === $char; + } + + public function getChar(): string + { + if ($this->position >= $this->length) { + throw new \RuntimeException('Reached end of value'); + } + return $this->value[$this->position]; + } + + /** + * @phpstan-impure + */ + public function consume(int $length, string $expected = null): string + { + assert($length > 0); + assert($expected === null || strlen($expected) === $length); + + if ($length > $this->length - $this->position) { + throw new \RuntimeException('Reached end of value'); + } + + $output = substr($this->value, $this->position, $length); + if (!is_null($expected) && $expected !== $output) { + throw new \RuntimeException('Unexpected character'); + } + $this->position += $length; + return $output; + } + + /** + * @phpstan-impure + */ + public function consumeChar(string $value = null): string + { + assert($value === null || strlen($value) === 1); + + return $this->consume(1, $value); + } + + /** + * @phpstan-impure + */ + public function consumeString(string $value): void + { + $this->consume(strlen($value), $value); + } + + /** + * @phpstan-impure + */ + public function consumeRegex(string $pattern): string + { + assert(str_starts_with($pattern, '/^')); + + if (preg_match($pattern, $this->remaining(), $matches)) { + $this->position += strlen($matches[0]); + return $matches[0]; + } + + throw new \RuntimeException('Expression did not match'); + } +} diff --git a/src/Serializer.php b/src/Serializer.php index aa1677a..acf33d1 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -20,7 +20,7 @@ class Serializer * @return string * The serialized value. */ - public static function serializeItem($value, ?object $parameters = null): string + public static function serializeItem(mixed $value, ?object $parameters = null): string { if ($value instanceof Item) { if (!is_null($parameters)) { @@ -153,11 +153,9 @@ private static function serializeInnerList(array $value, ?object $parameters = n } /** - * @param mixed $value - * * @SuppressWarnings(PHPMD.CyclomaticComplexity) */ - private static function serializeBareItem($value): string + private static function serializeBareItem(mixed $value): string { if (is_int($value)) { return self::serializeInteger($value); @@ -173,7 +171,7 @@ private static function serializeBareItem($value): string return self::serializeDate($value); } elseif ($value instanceof DisplayString) { return self::serializeDisplayString($value); - } elseif (is_string($value) || (is_object($value) && method_exists($value, '__toString'))) { + } elseif (is_string($value) || $value instanceof \Stringable) { return self::serializeString((string) $value); } @@ -204,7 +202,7 @@ private static function serializeDecimal(float $value): string /** @var string $result */ $result = json_encode(round($value, 3, PHP_ROUND_HALF_EVEN)); - if (strpos($result, '.') === false) { + if (!str_contains($result, '.')) { $result .= '.0'; } diff --git a/src/Token.php b/src/Token.php index c8ba98e..9083357 100644 --- a/src/Token.php +++ b/src/Token.php @@ -6,15 +6,8 @@ class Token { - /** - * @var string - * @readonly - */ - private $value; - - public function __construct(string $value) + public function __construct(private readonly string $value) { - $this->value = $value; } public function __toString(): string diff --git a/src/TupleInterface.php b/src/TupleInterface.php index dac9aba..91c8ee6 100644 --- a/src/TupleInterface.php +++ b/src/TupleInterface.php @@ -12,9 +12,6 @@ */ interface TupleInterface extends \ArrayAccess { - /** - * @return mixed - */ - public function getValue(); + public function getValue(): mixed; public function getParameters(): object; } diff --git a/src/TupleTrait.php b/src/TupleTrait.php index 352efb5..86c3329 100644 --- a/src/TupleTrait.php +++ b/src/TupleTrait.php @@ -4,26 +4,22 @@ namespace gapple\StructuredFields; +/** + * Trait for implementing TupleInterface, including ArrayAccess methods. + */ trait TupleTrait { /** * The tuple's value. - * - * @var mixed */ - protected $value; + protected mixed $value; /** * The tuple's parameters - * - * @var object */ - protected $parameters; + protected object $parameters; - /** - * @return mixed - */ - public function getValue() + public function getValue(): mixed { return $this->value; } @@ -43,17 +39,16 @@ public function offsetExists($offset): bool /** * @param 0|1 $offset - * @return ($offset is 0 ? mixed : $offset is 1 ? object : null) + * @return mixed + * @phpstan-return ($offset is 0 ? mixed : $offset is 1 ? object : null) */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet($offset): mixed { - if ($offset === 0) { - return $this->value; - } elseif ($offset === 1) { - return $this->parameters; - } - return null; + return match ($offset) { + 0 => $this->value, + 1 => $this->parameters, + default => null, + }; } /** diff --git a/tests/InnerListTest.php b/tests/InnerListTest.php index 328dc86..76c7e7c 100644 --- a/tests/InnerListTest.php +++ b/tests/InnerListTest.php @@ -49,14 +49,15 @@ public function invalidItemProvider(): array $items['array1'] = [[1]]; $items['array3'] = [[1,2,3]]; + $items['nested inner list'] = [InnerList::fromArray(['test'])]; + return $items; } /** * @dataProvider invalidItemProvider - * @param mixed $value */ - public function testConstructInvalidItem($value): void + public function testConstructInvalidItem(mixed $value): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/ItemTest.php b/tests/ItemTest.php index 47f305c..0924554 100644 --- a/tests/ItemTest.php +++ b/tests/ItemTest.php @@ -54,7 +54,6 @@ public function testArrayIndexIsset(): void public function testArrayOutOfBounds(): void { $item = new Item(true); - $this->assertEmpty($item[2]); // @phpstan-ignore-line } diff --git a/tests/OuterListTest.php b/tests/OuterListTest.php index 66c0863..4be9bb0 100644 --- a/tests/OuterListTest.php +++ b/tests/OuterListTest.php @@ -116,7 +116,7 @@ public function invalidItemProvider(): array * @dataProvider invalidItemProvider * @param mixed $value */ - public function testConstructInvalidItem($value): void + public function testConstructInvalidItem(mixed $value): void { $this->expectException(\InvalidArgumentException::class); @@ -127,7 +127,7 @@ public function testConstructInvalidItem($value): void * @dataProvider invalidItemProvider * @param mixed $value */ - public function testAppendInvalidItem($value): void + public function testAppendInvalidItem(mixed $value): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/ParsingInputTest.php b/tests/ParsingInputTest.php new file mode 100644 index 0000000..cce6575 --- /dev/null +++ b/tests/ParsingInputTest.php @@ -0,0 +1,55 @@ +expectException(\RuntimeException::class); + $input->getChar(); + } + + /** + * @return array + */ + public static function trimProvider(): array + { + return [ + 'space' => [' test ', false, 'test '], + 'ows' => [" \t test ", true, 'test '], + 'non-ows' => [" \t test ", false, "\t test "], + ]; + } + + /** + * @dataProvider trimProvider + */ + public function testTrim(string $value, bool $ows, string $expected): void + { + $input = new ParsingInput($value); + $input->trim($ows); + + $this->assertEquals($expected, $input->remaining()); + } + + public function testConsumeExcess(): void + { + $input = new ParsingInput('test'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Reached end of value'); + $input->consume(5); + } + + public function testConsumeNotMatched(): void + { + $input = new ParsingInput('test'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unexpected character'); + $input->consumeString('foo'); + } +}