This function converts a string like:
/{?lang(2-5):lowercase}/{page(2+):lowercase}/{article}-{id:integer}*.html*
Into the following regular expression:
@^(?J)/(?:(?P<lang>[a-z\d\-_\W]{2,5})/)?(?P<page>[a-z\d\-_\W]{2,})/(?P<article>[\w\W]*?)\-(?P<id>[1-9]*?).*?\.html.*?$@
Ready to be used somewhere else, using preg_match()
.
The basic structure is as follows:
- Everything is between brackets (
{...}
) - The next character can be a
?
, to define it as optional - Then comes the name of the "item" to capture
- Between parenthesys, you can specify the length. Lengths can be a single number (to meant "exactly this long"), an interval (
2-5
) or an interval without upper limit (2+
). - You can say the type you want. It can be integer, 0-integer, lowercase, uppercase and (if unspecified) defaults to case insensitive.
This function converts the info there into a regular expression, usable with preg_match
.
<?php
function convertPathToRegex($path){
// (?J) -> repeated names in capturing groups (the J modifier is only for PHP 7.2+)
static $regex = '@(?J)\{
(?P<optional>\?)? #defined if it is optional
(?P<item>[a-z]\w*) #item name
(?:\(
(?P<length>[1-9]\d*) # fixed length (default), or minimum length
(?P<length_max>
\+ # no maximum length
|-[1-9]\d* # specific maximum length
)?
\))?
(?:
# types are used as :<type>
\:(?P<type>
(?:0\-)?int(?:eger)? # treats as an integer (starting from 0 or 1)
|num(?:ber)? # same as 0-int
|[lu]c(?:ase)? # [l]ower or [u]pper case
|(?:low|upp)er(?:case)? # lower, upper, lowercase, uppercase
)
)?
\}
|(?P<item>
\* # any case insensitive text
)@x';
// default options
static $default = array(
'optional' => '',
'item' => '*',
'type' => 'ci',
'length' => 0,
'length_max' => 0
);
// types to be used on {name:type}
static $types = array(
'0-int' => '\d',
'int' => '[1-9]',
'ci' => '[\w\W]',
'lc' => '[a-z\d\-_\W]',
'uc' => '[A-Z\d\-_\W]',
);
// alternative names for $types
static $types_map = array(
'' => 'ci',
'integer' => 'int',
'0-integer' => '0-int',
'num' => '0-int',
'number' => '0-int',
'lcase' => 'lc',
'lower' => 'lc',
'lowercase' => 'lc',
'ucase' => 'uc',
'upper' => 'uc',
'uppercase' => 'uc'
);
// will contain all the into about the {items}
$items = array();
$format = preg_replace_callback($regex, function($matches)use(&$default, &$types, &$types_map, &$items){
$item = array_merge($default, $matches);
// the default is to select any text
if($item['item'] === $default['item'])
{
$items[] = '.*?';
// return %s to be used later with sprintf
return '%s';
}
$regex = '(?P<' . $item['item'] . '>';
$piece = isset($types_map[$item['type']])
? $types[$types_map[$item['type']]]
: $types[$item['type']];
if($item['type'] === 'int')
{
if($item['length'] >= 2)
{
// must subtract 1 from length and length_max to compensate for the [1-9] (1 char) at the beginning
$piece .= '\d{' . ($item['length'] - 1) . (
$item['length_max']
? ',' . (
$item['length_max'] !== '+'
? abs($item['length_max'] - 1)
: ''
)
: ''
) . '}';
}
else
{
/*
if a length exists, it must be lower than 2 (1 char)
so, nothing else needs to be done ($piece contains [1-9], which matches 1 char)
if no length is provided, match all the numbers ahead
*/
$piece .= $item['length'] ? '' : '\d*';
}
}
else if($item['length'] >= 2 || ($item['length_max'] && $item['length_max'] !== '+'))
{
/*
only give it a length specification if and only if the length is 2 or higher
or if there's a maximum length
this means that (1) and (1+) are skipped, but (1-5) returns {1,5} (regex)
*/
$piece .= '{' . $item['length'] . (
$item['length_max']
? ',' . (
$item['length_max'] !== '+'
? abs($item['length_max'])
: ''
)
: ''
) . '}';
}
else if(!$item['length'] || ($item['length'] === '1' && $item['length_max'] === '+'))
{
// if no length is specified (or is 1+), it means "all"
$piece .= '+';
}
/*
length of 1 doesn't need any treatment
this is because $piece contains the specification for 1 character already
*/
$regex .= $piece . ')';
$items[] = $item['optional'] ? '(?:' . $regex . ')?' : $regex;
// returns %s to be used with sprintf
return '%s';
}, $path);
// all arguments must be in the same array, can't do $format, $items
$new_regex = call_user_func_array(
'sprintf',
array_merge(
array(preg_quote($format, '@')), // protects special chars, like periods and slashes
$items
)
);
return '@^(?J)' . str_replace(')?/', '/)?', $new_regex) . '$@';
}
It is a pretty complicated and quite massive.
I've decided to do not implement any memoization scheme, as this function is part of a larger project and this will be cached outside.
This function works as intended, as far as I could tell and from my testing.
Is there anything I can improve in this function?