朱金辉 преди 2 години
родител
ревизия
567fc7c9f3
променени са 6 файла, в които са добавени 760 реда и са изтрити 5 реда
  1. 53 0
      src/Config/Namespace.php
  2. 8 0
      src/Driver/Migration.php
  3. 1 2
      src/Exceptions/Error.php
  4. 3 3
      src/Exceptions/Errors.php
  5. 415 0
      src/Http/Cookie.php
  6. 280 0
      src/Http/HeaderUtils.php

+ 53 - 0
src/Config/Namespace.php

@@ -0,0 +1,53 @@
+<?php
+/**
+ * Qii 名称空间配置
+ * @author zjh
+ * @version 1.3
+ */
+return array(
+    //设置是否使用名称空间
+    'setUseNamespace' => array(
+        array('Qii\\', true),
+        array('Qii\Action', true),
+        array('Qii\Autoloader', true),
+        array('Qii\Bootstrap', true),
+        array('Qii\Config', true),
+        array('Qii\Contracts', true),
+        array('Qii\Controller', true),
+        array('Qii\Exceptions', true),
+        array('Qii\Language', true),
+        array('Qii\Library', true),
+        array('Qii\Logger', true),
+        array('Qii\Plugin', true),
+        array('Qii\Request', false),
+        array('Qii\Router', true),
+        array('Qii\View', true),
+        array('WhichBrowser', true),
+        array('BigPipe', true),
+        array('Smarty\\', false),
+        array('Smarty\\Internal', false),
+    ),
+    //设置指定名称空间的文件路径,如按照namespace的不用指定
+    'addNamespace' => array(
+        array('Qii\\', Qii_DIR . DS),
+        array('Qii\Action', Qii_DIR . DS . 'Action'),
+        array('Qii\Autoloader', Qii_DIR . DS . 'Autoloader'),
+        array('Qii\Controller', Qii_DIR . DS . 'Controller'),
+        array('Qii\Bootstrap', Qii_DIR . DS . 'Bootstrap'),
+        array('Qii\Config', Qii_DIR . DS . 'Config'),
+        array('Qii\Contracts', Qii_DIR . DS . 'Contracts'),
+        array('Qii\Exceptions', Qii_DIR . DS . 'Exceptions'),
+        array('Qii\Language', Qii_DIR . DS . 'Language'),
+        array('Qii\Library', Qii_DIR . DS . 'Library'),
+        array('Qii\Logger', Qii_DIR . DS . 'Logger'),
+        array('Qii\Plugin', Qii_DIR . DS . 'Plugin'),
+        array('Qii\Request', Qii_DIR . DS . 'Request'),
+        array('Qii\Response', Qii_DIR . DS . 'Response'),
+        array('Qii\Router', Qii_DIR . DS . 'Router'),
+        array('Qii\View', Qii_DIR . DS . 'View'),
+        array('Smarty', Qii_DIR . DS . 'View' . DS . 'smarty'),
+        array('Smarty', Qii_DIR . DS . 'View' . DS . 'smarty' . DS . 'sysplugins'),
+        array('WhichBrowser', Qii_DIR . DS . 'Library'. DS . 'Third'. DS . 'WhichBrowser'),
+        array('BigPipe', Qii_DIR . DS . 'Library'. DS .'BigPipe'. DS .'BigPipe')
+    )
+);

+ 8 - 0
src/Driver/Migration.php

@@ -0,0 +1,8 @@
+<?php
+namespace Qii\Driver;
+
+interface Migration {
+    public function up();
+    public function down();
+    public function truncate();
+}

+ 1 - 2
src/Exceptions/Error.php

@@ -25,7 +25,7 @@ class Error
     public static function index()
     {
         $args = func_get_args();
-        echo \Qii::i('1108', $args[0], $args[1]);
+        echo \Qii::i('1108', $args[0], $args[1], debug_backtrace());
     }
     
     /**
@@ -116,7 +116,6 @@ class Error
             if(substr($controller, 0, 1) != '\\') {
                 $controllerCls = Register::get(\Qii\Config\Consts::APP_DEFAULT_CONTROLLER_PREFIX) . '\\' . $controller;
             }
-            $action = preg_replace('/(Action)$/i', "", $action);
             $filePath = Psr4::getInstance()->searchMappedFile($controllerCls);
             if (!is_file($filePath)) {
                 if ($env == 'product') return '';

+ 3 - 3
src/Exceptions/Errors.php

@@ -4,6 +4,7 @@ namespace Qii\Exceptions;
 
 use Qii\Autoloader\Import;
 use Qii\Autoloader\Psr4;
+use Qii\Config\Consts;
 use Qii\Config\Register;
 
 if (class_exists('\Qii\Exceptions\Errors', false)) {
@@ -73,14 +74,13 @@ class Errors extends \Exception
         }
         $appConfigure = (array)\Qii::appConfigure();
         
-        $env = Register::get(\Qii\Config\Consts::APP_ENVIRON, 'dev');
+        $env = Register::get(Consts::APP_ENVIRON, 'dev');
         if ($env == 'product' || (isset($appConfigure['errorPage']) && $appConfigure['errorPage'] && (isset($appConfigure['debug']) && $appConfigure['debug'] == 0))) {
             list($controller, $action) = explode(':', $appConfigure['errorPage']);
             $controllerCls = $controller;
             if(substr($controller, 0, 1) != '\\') {
-                $controllerCls = Register::get(\Qii\Config\Consts::APP_DEFAULT_CONTROLLER_PREFIX) . '\\' . $controller;
+                $controllerCls = Register::get(Consts::APP_DEFAULT_CONTROLLER_PREFIX) . '\\' . $controller;
             }
-            $action = preg_replace('/(Action)$/i', "", $action);
             $filePath = Psr4::getInstance()->searchMappedFile($controllerCls);
             if (!is_file($filePath)) {
                 if ($env == 'product') return '';

+ 415 - 0
src/Http/Cookie.php

@@ -0,0 +1,415 @@
+<?php
+namespace Qii\Http;
+
+use Qii\Exceptions\InvalidParams;
+
+/**
+ * Represents a cookie.
+ * 使用方法:
+ * controller中使用
+ *     $this->response->setHeader("Set-Cookie", new Cookie($name, $value = null, $expire = 0, $path = '/', $domain = null, $secure = null, $httpOnly = true, $raw = false, $sameSite = 'lax'))->response()
+ */
+class Cookie
+{
+    const SAMESITE_NONE = 'none';
+    const SAMESITE_LAX = 'lax';
+    const SAMESITE_STRICT = 'strict';
+
+    protected $name;
+    protected $value;
+    protected $domain;
+    protected $expire;
+    protected $path;
+    protected $secure;
+    protected $httpOnly;
+
+    private $raw;
+    private $sameSite;
+    private $secureDefault = false;
+
+    const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f";
+    const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
+    const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
+
+    /**
+     * Creates cookie from raw header string.
+     *
+     * @return static
+     */
+    public static function fromString($cookie, $decode = false)
+    {
+        $data = [
+            'expires' => 0,
+            'path' => '/',
+            'domain' => null,
+            'secure' => false,
+            'httponly' => false,
+            'raw' => !$decode,
+            'samesite' => null,
+        ];
+
+        $parts = HeaderUtils::split($cookie, ';=');
+        $part = array_shift($parts);
+
+        $name = $decode ? urldecode($part[0]) : $part[0];
+        $value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
+
+        $data = HeaderUtils::combine($parts) + $data;
+        $data['expires'] = self::expiresTimestamp($data['expires']);
+
+        if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) {
+            $data['expires'] = time() + (int) $data['max-age'];
+        }
+
+        return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
+    }
+
+    public static function create(string $name, string $value = null, $expire = 0, $path = '/', $domain = null, $secure = null, $httpOnly = true, $raw = false, $sameSite = self::SAMESITE_LAX): self
+    {
+        return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite);
+    }
+
+    /**
+     * @param string                        $name     The name of the cookie
+     * @param string|null                   $value    The value of the cookie
+     * @param int|string|\DateTimeInterface $expire   The time the cookie expires
+     * @param string                        $path     The path on the server in which the cookie will be available on
+     * @param string|null                   $domain   The domain that the cookie is available to
+     * @param bool|null                     $secure   Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS
+     * @param bool                          $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
+     * @param bool                          $raw      Whether the cookie value should be sent with no url encoding
+     * @param string|null                   $sameSite Whether the cookie will be available for cross-site requests
+     *
+     * @throws InvalidParams
+     */
+    public function __construct($name, $value = null, $expire = 0, $path = '/', $domain = null, $secure = null, $httpOnly = true, $raw = false, $sameSite = 'lax')
+    {
+        // from PHP source code
+        if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) {
+            throw new InvalidParams(sprintf('The cookie name "%s" contains invalid characters.', $name));
+        }
+
+        if (empty($name)) {
+            throw new InvalidParams('The cookie name cannot be empty.');
+        }
+
+        $this->name = $name;
+        $this->value = $value;
+        $this->domain = $domain;
+        $this->expire = self::expiresTimestamp($expire);
+        $this->path = empty($path) ? '/' : $path;
+        $this->secure = $secure;
+        $this->httpOnly = $httpOnly;
+        $this->raw = $raw;
+        $this->sameSite = $this->withSameSite($sameSite)->sameSite;
+    }
+
+    /**
+     * Creates a cookie copy with a new value.
+     *
+     * @return static
+     */
+    public function withValue($value): self
+    {
+        $cookie = clone $this;
+        $cookie->value = $value;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy with a new domain that the cookie is available to.
+     *
+     * @return static
+     */
+    public function withDomain($domain): self
+    {
+        $cookie = clone $this;
+        $cookie->domain = $domain;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy with a new time the cookie expires.
+     *
+     * @param int|string|\DateTimeInterface $expire
+     *
+     * @return static
+     */
+    public function withExpires($expire = 0): self
+    {
+        $cookie = clone $this;
+        $cookie->expire = self::expiresTimestamp($expire);
+
+        return $cookie;
+    }
+
+    /**
+     * Converts expires formats to a unix timestamp.
+     *
+     * @param int|string|\DateTimeInterface $expire
+     */
+    private static function expiresTimestamp($expire = 0): int
+    {
+        // convert expiration time to a Unix timestamp
+        if ($expire instanceof \DateTimeInterface) {
+            $expire = $expire->format('U');
+        } elseif (!is_numeric($expire)) {
+            $expire = strtotime($expire);
+
+            if (false === $expire) {
+                throw new InvalidParams('The cookie expiration time is not valid.');
+            }
+        }
+
+        return 0 < $expire ? (int) $expire : 0;
+    }
+
+    /**
+     * Creates a cookie copy with a new path on the server in which the cookie will be available on.
+     *
+     * @return static
+     */
+    public function withPath(string $path): self
+    {
+        $cookie = clone $this;
+        $cookie->path = '' === $path ? '/' : $path;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client.
+     *
+     * @return static
+     */
+    public function withSecure(bool $secure = true): self
+    {
+        $cookie = clone $this;
+        $cookie->secure = $secure;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy that be accessible only through the HTTP protocol.
+     *
+     * @return static
+     */
+    public function withHttpOnly(bool $httpOnly = true): self
+    {
+        $cookie = clone $this;
+        $cookie->httpOnly = $httpOnly;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy that uses no url encoding.
+     *
+     * @return static
+     */
+    public function withRaw(bool $raw = true): self
+    {
+        if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) {
+            throw new InvalidParams(sprintf('The cookie name "%s" contains invalid characters.', $this->name));
+        }
+
+        $cookie = clone $this;
+        $cookie->raw = $raw;
+
+        return $cookie;
+    }
+
+    /**
+     * Creates a cookie copy with SameSite attribute.
+     *
+     * @return static
+     */
+    public function withSameSite($sameSite): self
+    {
+        if ('' === $sameSite) {
+            $sameSite = null;
+        } elseif (null !== $sameSite) {
+            $sameSite = strtolower($sameSite);
+        }
+
+        if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) {
+            throw new InvalidParams('The "sameSite" parameter value is not valid.');
+        }
+
+        $cookie = clone $this;
+        $cookie->sameSite = $sameSite;
+
+        return $cookie;
+    }
+
+    /**
+     * Returns the cookie as a string.
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        if ($this->isRaw()) {
+            $str = $this->getName();
+        } else {
+            $str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName());
+        }
+
+        $str .= '=';
+
+        if ('' === (string) $this->getValue()) {
+            $str .= 'deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; Max-Age=0';
+        } else {
+            $str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue());
+
+            if (0 !== $this->getExpiresTime()) {
+                $str .= '; expires='.gmdate('D, d-M-Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge();
+            }
+        }
+
+        if ($this->getPath()) {
+            $str .= '; path='.$this->getPath();
+        }
+
+        if ($this->getDomain()) {
+            $str .= '; domain='.$this->getDomain();
+        }
+
+        if (true === $this->isSecure()) {
+            $str .= '; secure';
+        }
+
+        if (true === $this->isHttpOnly()) {
+            $str .= '; httponly';
+        }
+
+        if (null !== $this->getSameSite()) {
+            $str .= '; samesite='.$this->getSameSite();
+        }
+
+        return $str;
+    }
+
+    /**
+     * Gets the name of the cookie.
+     *
+     * @return string
+     */
+    public function getName()
+    {
+        return $this->name;
+    }
+
+    /**
+     * Gets the value of the cookie.
+     *
+     * @return string|null
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * Gets the domain that the cookie is available to.
+     *
+     * @return string|null
+     */
+    public function getDomain()
+    {
+        return $this->domain;
+    }
+
+    /**
+     * Gets the time the cookie expires.
+     *
+     * @return int
+     */
+    public function getExpiresTime()
+    {
+        return $this->expire;
+    }
+
+    /**
+     * Gets the max-age attribute.
+     *
+     * @return int
+     */
+    public function getMaxAge()
+    {
+        $maxAge = $this->expire - time();
+
+        return 0 >= $maxAge ? 0 : $maxAge;
+    }
+
+    /**
+     * Gets the path on the server in which the cookie will be available on.
+     *
+     * @return string
+     */
+    public function getPath()
+    {
+        return $this->path;
+    }
+
+    /**
+     * Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
+     *
+     * @return bool
+     */
+    public function isSecure()
+    {
+        return $this->secure ?? $this->secureDefault;
+    }
+
+    /**
+     * Checks whether the cookie will be made accessible only through the HTTP protocol.
+     *
+     * @return bool
+     */
+    public function isHttpOnly()
+    {
+        return $this->httpOnly;
+    }
+
+    /**
+     * Whether this cookie is about to be cleared.
+     *
+     * @return bool
+     */
+    public function isCleared()
+    {
+        return 0 !== $this->expire && $this->expire < time();
+    }
+
+    /**
+     * Checks if the cookie value should be sent with no url encoding.
+     *
+     * @return bool
+     */
+    public function isRaw()
+    {
+        return $this->raw;
+    }
+
+    /**
+     * Gets the SameSite attribute.
+     *
+     * @return string|null
+     */
+    public function getSameSite()
+    {
+        return $this->sameSite;
+    }
+
+    /**
+     * @param bool $default The default value of the "secure" flag when it is set to null
+     */
+    public function setSecureDefault(bool $default)
+    {
+        $this->secureDefault = $default;
+    }
+}

+ 280 - 0
src/Http/HeaderUtils.php

@@ -0,0 +1,280 @@
+<?php
+namespace Qii\Http;
+
+use Qii\Exceptions\InvalidParams;
+
+class HeaderUtils
+{
+    const DISPOSITION_ATTACHMENT = 'attachment';
+    const DISPOSITION_INLINE = 'inline';
+
+    /**
+     * This class should not be instantiated.
+     */
+    private function __construct()
+    {
+    }
+
+    /**
+     * Splits an HTTP header by one or more separators.
+     *
+     * Example:
+     *
+     *     HeaderUtils::split("da, en-gb;q=0.8", ",;")
+     *     // => ['da'], ['en-gb', 'q=0.8']]
+     *
+     * @param $separators List of characters to split on, ordered by
+     *                           precedence, e.g. ",", ";=", or ",;="
+     *
+     * @return array Nested array with as many levels as there are characters in
+     *               $separators
+     */
+    public static function split($header, $separators)
+    {
+        $quotedSeparators = preg_quote($separators, '/');
+
+        preg_match_all('
+            /
+                (?!\s)
+                    (?:
+                        # quoted-string
+                        "(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$)
+                    |
+                        # token
+                        [^"'.$quotedSeparators.']+
+                    )+
+                (?<!\s)
+            |
+                # separator
+                \s*
+                (?<separator>['.$quotedSeparators.'])
+                \s*
+            /x', trim($header), $matches, \PREG_SET_ORDER);
+
+        return self::groupParts($matches, $separators);
+    }
+
+    /**
+     * Combines an array of arrays into one associative array.
+     *
+     * Each of the nested arrays should have one or two elements. The first
+     * value will be used as the keys in the associative array, and the second
+     * will be used as the values, or true if the nested array only contains one
+     * element. Array keys are lowercased.
+     *
+     * Example:
+     *
+     *     HeaderUtils::combine([["foo", "abc"], ["bar"]])
+     *     // => ["foo" => "abc", "bar" => true]
+     */
+    public static function combine(array $parts): array
+    {
+        $assoc = [];
+        foreach ($parts as $part) {
+            $name = strtolower($part[0]);
+            $value = $part[1] ?? true;
+            $assoc[$name] = $value;
+        }
+
+        return $assoc;
+    }
+
+    /**
+     * Joins an associative array into a for use in an HTTP header.
+     *
+     * The key and value of each entry are joined with "=", and all entries
+     * are joined with the specified separator and an additional space (for
+     * readability). Values are quoted if necessary.
+     *
+     * Example:
+     *
+     *     HeaderUtils::toString(["foo" => "abc", "bar" => true, "baz" => "a b c"], ",")
+     *     // => 'foo=abc, bar, baz="a b c"'
+     */
+    public static function toString(array $assoc, $separator)
+    {
+        $parts = [];
+        foreach ($assoc as $name => $value) {
+            if (true === $value) {
+                $parts[] = $name;
+            } else {
+                $parts[] = $name.'='.self::quote($value);
+            }
+        }
+
+        return implode($separator.' ', $parts);
+    }
+
+    /**
+     * Encodes a as a quoted string, if necessary.
+     *
+     * If a contains characters not allowed by the "token" construct in
+     * the HTTP specification, it is backslash-escaped and enclosed in quotes
+     * to match the "quoted-string" construct.
+     */
+    public static function quote($s)
+    {
+        if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) {
+            return $s;
+        }
+
+        return '"'.addcslashes($s, '"\\"').'"';
+    }
+
+    /**
+     * Decodes a quoted string.
+     *
+     * If passed an unquoted that matches the "token" construct (as
+     * defined in the HTTP specification), it is passed through verbatimly.
+     */
+    public static function unquote($s)
+    {
+        return preg_replace('/\\\\(.)|"/', '$1', $s);
+    }
+
+    /**
+     * Generates an HTTP Content-Disposition field-value.
+     *
+     * @param $disposition      One of "inline" or "attachment"
+     * @param $filename         A unicode string
+     * @param $filenameFallback A containing only ASCII characters that
+     *                                 is semantically equivalent to $filename. If the filename is already ASCII,
+     *                                 it can be omitted, or just copied from $filename
+     *
+     * @throws InvalidParams
+     *
+     * @see RFC 6266
+     */
+    public static function makeDisposition($disposition, $filename, $filenameFallback = '')
+    {
+        if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) {
+            throw new InvalidParams(sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE));
+        }
+
+        if ('' === $filenameFallback) {
+            $filenameFallback = $filename;
+        }
+
+        // filenameFallback is not ASCII.
+        if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) {
+            throw new InvalidParams('The filename fallback must only contain ASCII characters.');
+        }
+
+        // percent characters aren't safe in fallback.
+        if (str_contains($filenameFallback, '%')) {
+            throw new InvalidParams('The filename fallback cannot contain the "%" character.');
+        }
+
+        // path separators aren't allowed in either.
+        if (str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filenameFallback, '/') || str_contains($filenameFallback, '\\')) {
+            throw new InvalidParams('The filename and the fallback cannot contain the "/" and "\\" characters.');
+        }
+
+        $params = ['filename' => $filenameFallback];
+        if ($filename !== $filenameFallback) {
+            $params['filename*'] = "utf-8''".rawurlencode($filename);
+        }
+
+        return $disposition.'; '.self::toString($params, ';');
+    }
+
+    /**
+     * Like parse_str(), but preserves dots in variable names.
+     */
+    public static function parseQuery($query, bool $ignoreBrackets = false, $separator = '&'): array
+    {
+        $q = [];
+
+        foreach (explode($separator, $query) as $v) {
+            if (false !== $i = strpos($v, "\0")) {
+                $v = substr($v, 0, $i);
+            }
+
+            if (false === $i = strpos($v, '=')) {
+                $k = urldecode($v);
+                $v = '';
+            } else {
+                $k = urldecode(substr($v, 0, $i));
+                $v = substr($v, $i);
+            }
+
+            if (false !== $i = strpos($k, "\0")) {
+                $k = substr($k, 0, $i);
+            }
+
+            $k = ltrim($k, ' ');
+
+            if ($ignoreBrackets) {
+                $q[$k][] = urldecode(substr($v, 1));
+
+                continue;
+            }
+
+            if (false === $i = strpos($k, '[')) {
+                $q[] = bin2hex($k).$v;
+            } else {
+                $q[] = bin2hex(substr($k, 0, $i)).rawurlencode(substr($k, $i)).$v;
+            }
+        }
+
+        if ($ignoreBrackets) {
+            return $q;
+        }
+
+        parse_str(implode('&', $q), $q);
+
+        $query = [];
+
+        foreach ($q as $k => $v) {
+            if (false !== $i = strpos($k, '_')) {
+                $query[substr_replace($k, hex2bin(substr($k, 0, $i)).'[', 0, 1 + $i)] = $v;
+            } else {
+                $query[hex2bin($k)] = $v;
+            }
+        }
+
+        return $query;
+    }
+
+    private static function groupParts($matches, $separators, $first = true)
+    {
+        $separator = $separators[0];
+        $partSeparators = substr($separators, 1);
+
+        $i = 0;
+        $partMatches = [];
+        $previousMatchWasSeparator = false;
+        foreach ($matches as $match) {
+            if (!$first && $previousMatchWasSeparator && isset($match['separator']) && $match['separator'] === $separator) {
+                $previousMatchWasSeparator = true;
+                $partMatches[$i][] = $match;
+            } elseif (isset($match['separator']) && $match['separator'] === $separator) {
+                $previousMatchWasSeparator = true;
+                ++$i;
+            } else {
+                $previousMatchWasSeparator = false;
+                $partMatches[$i][] = $match;
+            }
+        }
+
+        $parts = [];
+        if ($partSeparators) {
+            foreach ($partMatches as $matches) {
+                $parts[] = self::groupParts($matches, $partSeparators, false);
+            }
+        } else {
+            foreach ($partMatches as $matches) {
+                $parts[] = self::unquote($matches[0][0]);
+            }
+
+            if (!$first && 2 < \count($parts)) {
+                $parts = [
+                    $parts[0],
+                    implode($separator, \array_slice($parts, 1)),
+                ];
+            }
+        }
+
+        return $parts;
+    }
+}