1<?php
2
3declare(strict_types=1);
4
5namespace Nyholm\Dsn;
6
7use Nyholm\Dsn\Configuration\Dsn;
8use Nyholm\Dsn\Configuration\DsnFunction;
9use Nyholm\Dsn\Configuration\Path;
10use Nyholm\Dsn\Configuration\Url;
11use Nyholm\Dsn\Exception\DsnTypeNotSupported;
12use Nyholm\Dsn\Exception\FunctionsNotAllowedException;
13use Nyholm\Dsn\Exception\SyntaxException;
14
15/**
16 * A factory class to parse a string and create a DsnFunction.
17 *
18 * @author Tobias Nyholm <tobias.nyholm@gmail.com>
19 */
20class DsnParser
21{
22    private const FUNCTION_REGEX = '#^([a-zA-Z0-9\+-]+):?\((.*)\)(?:\?(.*))?$#';
23    private const ARGUMENTS_REGEX = '#([^\s,]+\([^)]+\)(?:\?[^\s,]*)?|[^\s,]+)#';
24    private const UNRESERVED = 'a-zA-Z0-9-\._~';
25    private const SUB_DELIMS = '!\$&\'\(\}\*\+,;=';
26
27    /**
28     * Parse A DSN thay may contain functions. If no function is present in the
29     * string, then a "dsn()" function will be added.
30     *
31     * @throws SyntaxException
32     */
33    public static function parseFunc(string $dsn): DsnFunction
34    {
35        // Detect a function or add default function
36        $parameters = [];
37        if (1 === preg_match(self::FUNCTION_REGEX, $dsn, $matches)) {
38            $functionName = $matches[1];
39            $arguments = $matches[2];
40            parse_str($matches[3] ?? '', $parameters);
41        } else {
42            $functionName = 'dsn';
43            $arguments = $dsn;
44        }
45
46        if (empty($arguments)) {
47            throw new SyntaxException($dsn, 'dsn' === $functionName ? 'The DSN is empty' : 'A function must have arguments, an empty string was provided.');
48        }
49
50        // explode arguments and respect function parentheses
51        if (preg_match_all(self::ARGUMENTS_REGEX, $arguments, $matches)) {
52            $arguments = $matches[1];
53        }
54
55        return new DsnFunction($functionName, array_map(\Closure::fromCallable([self::class, 'parseArguments']), $arguments), $parameters);
56    }
57
58    /**
59     * Parse a DSN without functions.
60     *
61     * @throws FunctionsNotAllowedException if the DSN contains a function
62     * @throws SyntaxException
63     */
64    public static function parse(string $dsn): Dsn
65    {
66        if (1 === preg_match(self::FUNCTION_REGEX, $dsn, $matches)) {
67            if ('dsn' === $matches[1]) {
68                return self::parse($matches[2]);
69            }
70            throw new FunctionsNotAllowedException($dsn);
71        }
72
73        return self::getDsn($dsn);
74    }
75
76    public static function parseUrl(string $dsn): Url
77    {
78        $dsn = self::parse($dsn);
79        if (!$dsn instanceof Url) {
80            throw DsnTypeNotSupported::onlyUrl($dsn);
81        }
82
83        return $dsn;
84    }
85
86    public static function parsePath(string $dsn): Path
87    {
88        $dsn = self::parse($dsn);
89        if (!$dsn instanceof Path) {
90            throw DsnTypeNotSupported::onlyPath($dsn);
91        }
92
93        return $dsn;
94    }
95
96    /**
97     * @return DsnFunction|Dsn
98     */
99    private static function parseArguments(string $dsn)
100    {
101        // Detect a function exists
102        if (1 === preg_match(self::FUNCTION_REGEX, $dsn)) {
103            return self::parseFunc($dsn);
104        }
105
106        // Assert: $dsn does not contain any functions.
107        return self::getDsn($dsn);
108    }
109
110    /**
111     * @throws SyntaxException
112     */
113    private static function getDsn(string $dsn): Dsn
114    {
115        // Find the scheme if it exists and trim the double slash.
116        if (!preg_match('#^(?:(?<alt>['.self::UNRESERVED.self::SUB_DELIMS.'%]+:[0-9]+(?:[/?].*)?)|(?<scheme>[a-zA-Z0-9\+-\.]+):(?://)?(?<dsn>.*))$#', $dsn, $matches)) {
117            throw new SyntaxException($dsn, 'A DSN must contain a scheme [a-zA-Z0-9\+-\.]+ and a colon.');
118        }
119        $scheme = null;
120        $dsn = $matches['alt'];
121        if (!empty($matches['scheme'])) {
122            $scheme = $matches['scheme'];
123            $dsn = $matches['dsn'];
124        }
125
126        if ('' === $dsn) {
127            return new Dsn($scheme);
128        }
129
130        // Parse user info
131        if (!preg_match('#^(?:(['.self::UNRESERVED.self::SUB_DELIMS.'%]+)?(?::(['.self::UNRESERVED.self::SUB_DELIMS.'%]*))?@)?([^\s@]+)$#', $dsn, $matches)) {
132            throw new SyntaxException($dsn, 'The provided DSN is not valid. Maybe you need to url-encode the user/password?');
133        }
134
135        $authentication = [
136            'user' => empty($matches[1]) ? null : urldecode($matches[1]),
137            'password' => empty($matches[2]) ? null : urldecode($matches[2]),
138        ];
139
140        if ('?' === $matches[3][0]) {
141            $parts = self::explodeUrl('http://localhost'.$matches[3], $dsn);
142
143            return new Dsn($scheme, self::getQuery($parts));
144        }
145
146        if ('/' === $matches[3][0]) {
147            $parts = self::explodeUrl($matches[3], $dsn);
148
149            return new Path($scheme, $parts['path'], self::getQuery($parts), $authentication);
150        }
151
152        $parts = self::explodeUrl('http://'.$matches[3], $dsn);
153
154        return new Url($scheme, $parts['host'], $parts['port'] ?? null, $parts['path'] ?? null, self::getQuery($parts), $authentication);
155    }
156
157    /**
158     * Parse URL and throw exception if the URL is not valid.
159     *
160     * @throws SyntaxException
161     */
162    private static function explodeUrl(string $url, string $dsn): array
163    {
164        $url = parse_url($url);
165        if (false === $url) {
166            throw new SyntaxException($dsn, 'The provided DSN is not valid.');
167        }
168
169        return $url;
170    }
171
172    /**
173     * Parse query params into an array.
174     */
175    private static function getQuery(array $parts): array
176    {
177        $query = [];
178        if (isset($parts['query'])) {
179            parse_str($parts['query'], $query);
180        }
181
182        return $query;
183    }
184}
185