1 <?php
2
3 /*
4 * This file is part of the ICanBoogie package.
5 *
6 * (c) Olivier Laviale <olivier.laviale@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace ICanBoogie\Routing;
13
14 use ICanBoogie\PropertyNotReadable;
15
16 /**
17 * Representation of a route pattern.
18 *
19 * <pre>
20 * <?php
21 *
22 * use ICanBoogie\Routing\Pattern;
23 *
24 * $pattern = Pattern::from("/blog/<year:\d{4}>-<month:\d{2}>-:slug.html");
25 * echo $pattern; // "/blog/<year:\d{4}>-<month:\d{2}>-:slug.html"
26 *
27 * $pathname = $pattern->format(array('year' => "2013", 'month' => "07", 'slug' => "test-is-a-test"));
28 * echo $pathname; // "/blog/2013-07-this-is-a-test.html"
29 *
30 * $matching = $pattern->match($pathname, $captured);
31 *
32 * var_dump($matching); // true
33 * var_dump($captured); // array('year' => "2013", 'month' => "07", 'slug' => "test-is-a-test")
34 * </pre>
35 *
36 * @property-read string $pattern The pattern.
37 * @property-read array $interleaved The interleaved parts of the pattern.
38 * @property-read array $params The names of the pattern params.
39 * @property-read string $regex The regex of the pattern.
40 */
41 class Pattern
42 {
43 /**
44 * Parses a route pattern and returns an array of interleaved paths and parameters, the
45 * parameter names and the regular expression for the specified pattern.
46 *
47 * @param string $pattern A pattern.
48 *
49 * @return array
50 */
51 static private function parse($pattern)
52 {
53 $regex = '#^';
54 $interleaved = array();
55 $params = array();
56 $n = 0;
57 $catchall = false;
58
59 if ($pattern{strlen($pattern) - 1} == '*')
60 {
61 $catchall = true;
62 $pattern = substr($pattern, 0, -1);
63 }
64
65 $parts = preg_split('#(:\w+|<(\w+:)?([^>]+)>)#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE);
66
67 for ($i = 0, $j = count($parts); $i < $j ;)
68 {
69 $part = $parts[$i++];
70
71 $regex .= preg_quote($part, '#');
72 $interleaved[] = $part;
73
74 if ($i == $j)
75 {
76 break;
77 }
78
79 $part = $parts[$i++];
80
81 if ($part{0} == ':')
82 {
83 $identifier = substr($part, 1);
84 $separator = $parts[$i];
85 $selector = $separator ? '[^/\\' . $separator{0} . ']+' : '[^/]+';
86 }
87 else
88 {
89 $identifier = substr($parts[$i++], 0, -1);
90
91 if (!$identifier)
92 {
93 $identifier = $n++;
94 }
95
96 $selector = $parts[$i++];
97 }
98
99 $regex .= '(' . $selector . ')';
100 $interleaved[] = array($identifier, $selector);
101 $params[] = $identifier;
102 }
103
104 if (!$catchall)
105 {
106 $regex .= '$';
107 }
108
109 $regex .= '#';
110
111 return array($interleaved, $params, $regex);
112 }
113
114 /**
115 * Checks if the given string is a route pattern.
116 *
117 * @param string $pattern
118 *
119 * @return bool `true` if the given pattern is a route pattern, `false` otherwise.
120 */
121 static public function is_pattern($pattern)
122 {
123 return (strpos($pattern, '<') !== false) || (strpos($pattern, ':') !== false);
124 }
125
126 static private $instances;
127
128 /**
129 * Creates a {@link Pattern} instance from the specified pattern.
130 *
131 * @param mixed $pattern
132 *
133 * @return \ICanBoogie\Pattern
134 */
135 static public function from($pattern)
136 {
137 if ($pattern instanceof static)
138 {
139 return $pattern;
140 }
141
142 if (isset(self::$instances[$pattern]))
143 {
144 return self::$instances[$pattern];
145 }
146
147 return self::$instances[$pattern] = new static($pattern);
148 }
149
150 /**
151 * Pattern.
152 *
153 * @var string
154 */
155 protected $pattern;
156
157 /**
158 * Interleaved pattern.
159 *
160 * @var array
161 */
162 protected $interleaved;
163
164 /**
165 * Params of the pattern.
166 *
167 * @var array
168 */
169 protected $params;
170
171 /**
172 * Regex of the pattern.
173 *
174 * @var string
175 */
176 protected $regex;
177
178 /**
179 * Initializes the {@link $pattern}, {@link $interleaved}, {@link $params} and {@link $regex}
180 * properties.
181 *
182 * @param string $pattern A route pattern.
183 */
184 protected function __construct($pattern)
185 {
186 list($interleaved, $params, $regex) = self::parse($pattern);
187
188 $this->pattern = $pattern;
189 $this->interleaved = $interleaved;
190 $this->params = $params;
191 $this->regex = $regex;
192 }
193
194 /**
195 * Returns the route pattern specified during construct.
196 *
197 * @return string
198 */
199 public function __toString()
200 {
201 return $this->pattern;
202 }
203
204 public function __get($property)
205 {
206 static $gettable = array('pattern', 'interleaved', 'params', 'regex');
207
208 if (!in_array($property, $gettable))
209 {
210 throw new PropertyNotReadable(array($property, $this));
211 }
212
213 return $this->$property;
214 }
215
216 /**
217 * Formats a pattern with the specified values.
218 *
219 * @param mixed $values The values to format the pattern, either as an array or an object.
220 *
221 * @return string
222 */
223 public function format($values=null)
224 {
225 $url = '';
226
227 if (is_array($values))
228 {
229 foreach ($this->interleaved as $i => $value)
230 {
231 $url .= ($i % 2) ? urlencode($values[$value[0]]) : $value;
232 }
233 }
234 else
235 {
236 foreach ($this->interleaved as $i => $value)
237 {
238 $url .= ($i % 2) ? urlencode($values->$value[0]) : $value;
239 }
240 }
241
242 return $url;
243 }
244
245 /**
246 * Checks if a pathname matches the pattern.
247 *
248 * @param string $pathname The pathname.
249 * @param array $captured The parameters captured from the pathname.
250 *
251 * @return boolean `true` if the pathname matches the pattern, `false` otherwise.
252 */
253 public function match($pathname, &$captured=null)
254 {
255 #
256 # `params` is empty if the pattern is a plain string,
257 # thus we can simply compare strings.
258 #
259
260 if (!$this->params)
261 {
262 return $pathname === $this->pattern;
263 }
264
265 if (!preg_match($this->regex, $pathname, $matches))
266 {
267 return false;
268 }
269
270 array_shift($matches);
271
272 $captured = array_combine($this->params, $matches);
273
274 return true;
275 }
276 }