├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example └── main.dart ├── lib ├── path_to_regexp.dart └── src │ ├── escape.dart │ ├── extract.dart │ ├── function.dart │ ├── parse.dart │ ├── regexp.dart │ └── token.dart ├── pubspec.yaml └── test └── path_to_regexp_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | .dart_tool/ 2 | .packages 3 | doc/ 4 | pubspec.lock 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: dart 2 | 3 | branches: 4 | only: 5 | - master 6 | 7 | cache: 8 | directories: 9 | - $HOME/.pub-cache 10 | 11 | dart: 12 | - dev 13 | 14 | dart_task: 15 | - dartanalyzer: --fatal-warnings --lints . 16 | - dartfmt 17 | - test 18 | 19 | # Run tests on lowest supported SDK version. 20 | matrix: 21 | include: 22 | - dart: 2.12.0 23 | dart_task: test 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.4.0 2 | 3 | * Migrated to null safety. 4 | 5 | ## 0.3.0 6 | 7 | * Added an option to create case insensitive regular expressions. 8 | 9 | * Added a getter named `regExp` to `ParameterToken` for the regular expression 10 | used to match arguments. 11 | 12 | * Updated lower SDK constraint to 2.3.0. 13 | 14 | ## 0.2.1 15 | 16 | * Removed unnecessary code. 17 | 18 | * Added an example. 19 | 20 | * Included `package:pedantic/analysis_options.yaml`. 21 | 22 | ## 0.2.0 23 | 24 | * Removed support for optional parameters. 25 | 26 | ## 0.1.2 27 | 28 | * Updated SDK constraints to support Dart 2 stable. 29 | 30 | ## 0.1.1 31 | 32 | * Lowered SDK constraint so that Pub can analyze the package and generate 33 | documentation. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leon Senft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # path\_to\_regexp 2 | 3 | [![Pub][pub-img]][pub-url] 4 | [![Travis][travis-img]][travis-url] 5 | 6 | Converts a path such as `/user/:id` into a regular expression. 7 | 8 | ## Matching 9 | 10 | `pathToRegExp()` converts a path specification into a regular expression that 11 | matches conforming paths. 12 | 13 | ```dart 14 | final regExp = pathToRegExp('/user/:id'); 15 | regExp.hasMatch('/user/12'); // => true 16 | regExp.hasMatch('/user/alice'); // => true 17 | ``` 18 | 19 | ### Custom Parameters 20 | 21 | By default, parameters match anything until the next delimiter. This behavior 22 | can be customized by specifying a regular expression in parentheses following 23 | a parameter name. 24 | 25 | ```dart 26 | final regExp = pathToRegExp(r'/user/:id(\d+)'); 27 | regExp.hasMatch('/user/12'); // => true 28 | regExp.hasMatch('/user/alice'); // => false 29 | ``` 30 | 31 | ### Extracting Parameters 32 | 33 | Parameters can be extracted from a path specification during conversion into a 34 | regular expression. 35 | 36 | ```dart 37 | final parameters = []; 38 | final regExp = pathToRegExp('/user/:id', parameters: parameters); 39 | parameters; // => ['id'] 40 | ``` 41 | 42 | ### Extracting Arguments 43 | 44 | `extract()` maps the parameters of a path specification to their corresponding 45 | arguments in a match. 46 | 47 | ```dart 48 | final parameters = []; 49 | final regExp = pathToRegExp('/user/:id', parameters: parameters); 50 | final match = regExp.matchAsPrefix('/user/12'); 51 | extract(parameters, match); // => {'id': '12'} 52 | ``` 53 | 54 | ## Generating 55 | 56 | `pathToFunction()` converts a path specification into a function that generates 57 | matching paths. 58 | 59 | ```dart 60 | final toPath = pathToFunction('/user/:id'); 61 | toPath({'id': '12'}); // => '/user/12' 62 | ``` 63 | 64 | ## Tokens 65 | 66 | `parse()` converts a path specification into a list of tokens, which can be 67 | used to create a regular expression or path generating function. 68 | 69 | ```dart 70 | final tokens = parse('/users/:id'); 71 | final regExp = tokensToRegExp(tokens); 72 | final toPath = tokensToFunction(tokens); 73 | ``` 74 | 75 | Similar to `pathToRegExp()`, parameters can also be extracted during parsing. 76 | 77 | ```dart 78 | final parameters = []; 79 | final tokens = parse('/users/:id', parameters: parameters); 80 | ``` 81 | 82 | If you intend to match and generate paths from the same path specification, 83 | `parse()` and the token-based functions should be preferred to their path-based 84 | counterparts. This is because the token-based functions can reuse the same 85 | tokens, whereas each path-based function must parse the path specification anew. 86 | 87 | ## Options 88 | 89 | ### Prefix Matching 90 | 91 | By default, a regular expression created by `pathToRegExp` or `tokensToRegExp` 92 | matches the entire input. However, if the optional `prefix` argument is true, it 93 | may also match as a prefix until a delimiter. 94 | 95 | ```dart 96 | final regExp = pathToRegExp('/user/:id', prefix: true); 97 | regExp.hasMatch('/user/12/details'); // => true 98 | ``` 99 | 100 | ### Case Sensitivity 101 | 102 | By default, a regular expression created by `pathToRegExp` or `tokensToRegExp` 103 | is case sensitive. To create a case insensitive regular expression, set 104 | `caseSensitive` to false. 105 | 106 | ```dart 107 | final regExp = pathToRegExp('/user/:id', caseSensitive: false); 108 | regExp.hasMatch('/USER/12'); // => true 109 | ``` 110 | 111 | ## Demo 112 | 113 | Try the [path\_to\_regexp\_demo][path-to-regexp-demo] to experiment with this 114 | library. 115 | 116 | ## Credit 117 | 118 | This package is heavily inspired by its JavaScript namesake 119 | [path-to-regexp][path-to-regexp-js]. 120 | 121 | [path-to-regexp-demo]: https://path-to-regexp.web.app 122 | [path-to-regexp-js]: https://github.com/pillarjs/path-to-regexp 123 | [pub-img]: https://img.shields.io/pub/v/path_to_regexp.svg 124 | [pub-url]: https://pub.dartlang.org/packages/path_to_regexp 125 | [travis-img]: https://img.shields.io/travis/com/leonsenft/path_to_regexp.svg 126 | [travis-url]: https://travis-ci.com/leonsenft/path_to_regexp 127 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # Defines a default set of lint rules enforced for projects at Google. For 2 | # details and rationale, see https://github.com/dart-lang/pedantic. 3 | include: package:pedantic/analysis_options.yaml 4 | linter: 5 | rules: 6 | - always_declare_return_types 7 | - annotate_overrides 8 | - await_only_futures 9 | - camel_case_types 10 | - cancel_subscriptions 11 | - comment_references 12 | - constant_identifier_names 13 | - empty_catches 14 | - hash_and_equals 15 | - implementation_imports 16 | - iterable_contains_unrelated_type 17 | - library_names 18 | - library_prefixes 19 | - list_remove_unrelated_type 20 | - non_constant_identifier_names 21 | - only_throw_errors 22 | - overridden_fields 23 | - parameter_assignments 24 | - prefer_final_fields 25 | - prefer_final_locals 26 | - sort_constructors_first 27 | - sort_unnamed_constructors_first 28 | - test_types_in_equals 29 | - type_init_formals 30 | - unnecessary_brace_in_string_interps 31 | - unnecessary_getters_setters 32 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:path_to_regexp/path_to_regexp.dart'; 2 | 3 | void main() { 4 | // Parse a path into tokens, and extract parameters names. 5 | final parameters = []; 6 | final tokens = parse(r'/user/:id(\d+)', parameters: parameters); 7 | print(parameters); // [id] 8 | 9 | // Create a regular expression from tokens. 10 | final regExp = tokensToRegExp(tokens); 11 | print(regExp.hasMatch('/user/12')); // true 12 | print(regExp.hasMatch('/user/alice')); // false 13 | 14 | // Extract parameter arguments from a match. 15 | final match = regExp.matchAsPrefix('/user/12'); 16 | print(extract(parameters, match!)); // {id: 12} 17 | 18 | // Create a path function from tokens. 19 | final toPath = tokensToFunction(tokens); 20 | print(toPath({'id': '12'})); // /user/12 21 | } 22 | -------------------------------------------------------------------------------- /lib/path_to_regexp.dart: -------------------------------------------------------------------------------- 1 | export 'src/extract.dart'; 2 | export 'src/function.dart'; 3 | export 'src/parse.dart'; 4 | export 'src/regexp.dart'; 5 | export 'src/token.dart'; 6 | -------------------------------------------------------------------------------- /lib/src/escape.dart: -------------------------------------------------------------------------------- 1 | /// Matches any characters that could prevent a group from capturing. 2 | final _groupRegExp = RegExp(r'[:=!]'); 3 | 4 | /// Escapes a single character [match]. 5 | String _escape(Match match) => '\\${match[0]}'; 6 | 7 | /// Escapes a [group] to ensure it remains a capturing group. 8 | /// 9 | /// This prevents turning the group into a non-capturing group `(?:...)`, a 10 | /// lookahead `(?=...)`, or a negative lookahead `(?!...)`. Allowing these 11 | /// patterns would break the assumption used to map parameter names to match 12 | /// groups. 13 | String escapeGroup(String group) => 14 | group.replaceFirstMapped(_groupRegExp, _escape); 15 | -------------------------------------------------------------------------------- /lib/src/extract.dart: -------------------------------------------------------------------------------- 1 | /// Extracts arguments from [match] and maps them by parameter name. 2 | /// 3 | /// The [parameters] should originate from the same path specification used to 4 | /// create the [RegExp] that produced the [match]. 5 | Map extract(List parameters, Match match) { 6 | final length = parameters.length; 7 | return { 8 | // Offset the group index by one since the first group is the entire match. 9 | for (var i = 0; i < length; ++i) parameters[i]: match.group(i + 1)! 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /lib/src/function.dart: -------------------------------------------------------------------------------- 1 | import 'parse.dart'; 2 | import 'token.dart'; 3 | 4 | /// Generates a path by populating a path specification with [args]. 5 | /// 6 | /// The [args] should map parameter name to value. 7 | /// 8 | /// Throws an [ArgumentError] if any required arguments are missing, or if any 9 | /// arguments don't match their parameter's regular expression. 10 | typedef PathFunction = String Function(Map args); 11 | 12 | /// Creates a [PathFunction] from a [path] specification. 13 | PathFunction pathToFunction(String path) => tokensToFunction(parse(path)); 14 | 15 | /// Creates a [PathFunction] from [tokens]. 16 | PathFunction tokensToFunction(List tokens) { 17 | return (args) { 18 | final buffer = StringBuffer(); 19 | for (final token in tokens) { 20 | buffer.write(token.toPath(args)); 21 | } 22 | return buffer.toString(); 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/parse.dart: -------------------------------------------------------------------------------- 1 | import 'escape.dart'; 2 | import 'token.dart'; 3 | 4 | /// The default pattern used for matching parameters. 5 | const _defaultPattern = '([^/]+?)'; 6 | 7 | /// The regular expression used to extract parameters from a path specification. 8 | /// 9 | /// Capture groups: 10 | /// 1. The parameter name. 11 | /// 2. An optional pattern. 12 | final _parameterRegExp = RegExp( 13 | /* (1) */ r':(\w+)' 14 | /* (2) */ r'(\((?:\\.|[^\\()])+\))?'); 15 | 16 | /// Parses a [path] specification. 17 | /// 18 | /// Parameter names are added, in order, to [parameters] if provided. 19 | List parse(String path, {List? parameters}) { 20 | final matches = _parameterRegExp.allMatches(path); 21 | final tokens = []; 22 | var start = 0; 23 | for (final match in matches) { 24 | if (match.start > start) { 25 | tokens.add(PathToken(path.substring(start, match.start))); 26 | } 27 | final name = match[1]!; 28 | final optionalPattern = match[2]; 29 | final pattern = optionalPattern != null 30 | ? escapeGroup(optionalPattern) 31 | : _defaultPattern; 32 | tokens.add(ParameterToken(name, pattern: pattern)); 33 | parameters?.add(name); 34 | start = match.end; 35 | } 36 | if (start < path.length) { 37 | tokens.add(PathToken(path.substring(start))); 38 | } 39 | return tokens; 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/regexp.dart: -------------------------------------------------------------------------------- 1 | import 'parse.dart'; 2 | import 'token.dart'; 3 | 4 | /// Creates a [RegExp] that matches a [path] specification. 5 | /// 6 | /// See [parse] for details about the optional [parameters] parameter. 7 | /// 8 | /// See [tokensToRegExp] for details about the optional [prefix] and 9 | /// [caseSensitive] parameters and the return value. 10 | RegExp pathToRegExp( 11 | String path, { 12 | List? parameters, 13 | bool prefix = false, 14 | bool caseSensitive = true, 15 | }) => 16 | tokensToRegExp( 17 | parse(path, parameters: parameters), 18 | prefix: prefix, 19 | caseSensitive: caseSensitive, 20 | ); 21 | 22 | /// Creates a [RegExp] from [tokens]. 23 | /// 24 | /// If [prefix] is true, the returned regular expression matches the beginning 25 | /// of input until a delimiter or end of input. Otherwise it matches the entire 26 | /// input. 27 | /// 28 | /// The returned regular expression respects [caseSensitive], which is true by 29 | /// default. 30 | RegExp tokensToRegExp( 31 | List tokens, { 32 | bool prefix = false, 33 | bool caseSensitive = true, 34 | }) { 35 | final buffer = StringBuffer('^'); 36 | String? lastPattern; 37 | for (final token in tokens) { 38 | lastPattern = token.toPattern(); 39 | buffer.write(lastPattern); 40 | } 41 | if (!prefix) { 42 | buffer.write(r'$'); 43 | } else if (lastPattern != null && !lastPattern.endsWith('/')) { 44 | // Match until a delimiter or end of input, unless 45 | // (a) there are no tokens (matching the empty string), or 46 | // (b) the last token itself ends in a delimiter 47 | // in which case, anything may follow. 48 | buffer.write(r'(?=/|$)'); 49 | } 50 | return RegExp(buffer.toString(), caseSensitive: caseSensitive); 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/token.dart: -------------------------------------------------------------------------------- 1 | /// The base type of all tokens produced by a path specification. 2 | abstract class Token { 3 | /// Returns the path representation of this given [args]. 4 | String toPath(Map args); 5 | 6 | /// Returns the regular expression pattern this matches. 7 | String toPattern(); 8 | } 9 | 10 | /// Corresponds to a parameter of a path specification. 11 | class ParameterToken implements Token { 12 | /// Creates a parameter token for [name]. 13 | ParameterToken(this.name, {this.pattern = r'([^/]+?)'}); 14 | 15 | /// The parameter name. 16 | final String name; 17 | 18 | /// The regular expression pattern this matches. 19 | final String pattern; 20 | 21 | /// The regular expression compiled from [pattern]. 22 | late final regExp = RegExp('^$pattern\$'); 23 | 24 | @override 25 | String toPath(Map args) { 26 | final value = args[name]; 27 | if (value != null) { 28 | if (!regExp.hasMatch(value)) { 29 | throw ArgumentError.value('$args', 'args', 30 | 'Expected "$name" to match "$pattern", but got "$value"'); 31 | } 32 | return value; 33 | } else { 34 | throw ArgumentError.value('$args', 'args', 'Expected key "$name"'); 35 | } 36 | } 37 | 38 | @override 39 | String toPattern() => pattern; 40 | } 41 | 42 | /// Corresponds to a non-parameterized section of a path specification. 43 | class PathToken implements Token { 44 | /// Creates a path token with [value]. 45 | PathToken(this.value); 46 | 47 | /// A substring of the path specification. 48 | final String value; 49 | 50 | @override 51 | String toPath(_) => value; 52 | 53 | @override 54 | String toPattern() => RegExp.escape(value); 55 | } 56 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: path_to_regexp 2 | version: 0.4.0 3 | description: Converts a path such as '/user/:id' into a regular expression. 4 | homepage: https://github.com/leonsenft/path_to_regexp 5 | 6 | environment: 7 | sdk: ">=2.12.0 <3.0.0" 8 | 9 | dev_dependencies: 10 | # For `package:pedantic/analysis_options.yaml`. 11 | pedantic: 1.11.0 12 | test: ^1.16.5 13 | -------------------------------------------------------------------------------- /test/path_to_regexp_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:path_to_regexp/path_to_regexp.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('simple path', () { 6 | tests( 7 | '/', 8 | tokens: [ 9 | path('/'), 10 | ], 11 | regExp: [ 12 | matches('/', ['/']), 13 | mismatches('/foo'), 14 | ], 15 | toPath: [ 16 | returns('/'), 17 | returns('/', given: {'id': '12'}), 18 | ], 19 | ); 20 | tests( 21 | '/foo', 22 | tokens: [ 23 | path('/foo'), 24 | ], 25 | regExp: [ 26 | matches('/foo', ['/foo']), 27 | mismatches('/bar'), 28 | mismatches('/foo/bar'), 29 | ], 30 | toPath: [ 31 | returns('/foo'), 32 | ], 33 | ); 34 | tests( 35 | '/foo/', 36 | tokens: [ 37 | path('/foo/'), 38 | ], 39 | regExp: [ 40 | matches('/foo/', ['/foo/']), 41 | mismatches('/foo'), 42 | ], 43 | toPath: [ 44 | returns('/foo/'), 45 | ], 46 | ); 47 | tests( 48 | '/:key', 49 | tokens: [ 50 | path('/'), 51 | parameter('key'), 52 | ], 53 | regExp: [ 54 | matches('/foo', ['/foo', 'foo'], extracts: {'key': 'foo'}), 55 | matches( 56 | '/foo.json', 57 | ['/foo.json', 'foo.json'], 58 | extracts: {'key': 'foo.json'}, 59 | ), 60 | matches( 61 | '/foo%2Fbar', 62 | ['/foo%2Fbar', 'foo%2Fbar'], 63 | extracts: {'key': 'foo%2Fbar'}, 64 | ), 65 | matches( 66 | r'/;,:@&=+$-_.!~*()', 67 | [r'/;,:@&=+$-_.!~*()', r';,:@&=+$-_.!~*()'], 68 | extracts: {'key': r';,:@&=+$-_.!~*()'}, 69 | ), 70 | mismatches('/foo/bar'), 71 | ], 72 | toPath: [ 73 | returns('/foo', given: {'key': 'foo'}), 74 | ], 75 | ); 76 | }); 77 | 78 | group('prefix path', () { 79 | tests( 80 | '', 81 | prefix: true, 82 | regExp: [ 83 | matches('', ['']), 84 | matches('/', ['']), 85 | matches('foo', ['']), 86 | matches('/foo', ['']), 87 | matches('/foo/', ['']), 88 | ], 89 | toPath: [ 90 | returns(''), 91 | ], 92 | ); 93 | tests( 94 | '/foo', 95 | prefix: true, 96 | tokens: [ 97 | path('/foo'), 98 | ], 99 | regExp: [ 100 | matches('/foo', ['/foo']), 101 | matches('/foo/', ['/foo']), 102 | matches('/foo/bar', ['/foo']), 103 | mismatches('/bar'), 104 | ], 105 | toPath: [ 106 | returns('/foo'), 107 | ], 108 | ); 109 | tests( 110 | '/foo/', 111 | prefix: true, 112 | tokens: [ 113 | path('/foo/'), 114 | ], 115 | regExp: [ 116 | matches('/foo/bar', ['/foo/']), 117 | matches('/foo//', ['/foo/']), 118 | matches('/foo//bar', ['/foo/']), 119 | mismatches('/foo'), 120 | ], 121 | toPath: [ 122 | returns('/foo/'), 123 | ], 124 | ); 125 | tests( 126 | '/:key', 127 | prefix: true, 128 | tokens: [ 129 | path('/'), 130 | parameter('key'), 131 | ], 132 | regExp: [ 133 | matches('/foo', ['/foo', 'foo'], extracts: {'key': 'foo'}), 134 | matches( 135 | '/foo.json', 136 | ['/foo.json', 'foo.json'], 137 | extracts: {'key': 'foo.json'}, 138 | ), 139 | matches('/foo//', ['/foo', 'foo'], extracts: {'key': 'foo'}), 140 | ], 141 | toPath: [ 142 | returns('/foo', given: {'key': 'foo'}), 143 | throws(), 144 | ], 145 | ); 146 | tests( 147 | '/:key/', 148 | prefix: true, 149 | tokens: [ 150 | path('/'), 151 | parameter('key'), 152 | path('/'), 153 | ], 154 | regExp: [ 155 | matches('/foo/', ['/foo/', 'foo'], extracts: {'key': 'foo'}), 156 | mismatches('/foo'), 157 | ], 158 | toPath: [ 159 | returns('/foo/', given: {'key': 'foo'}), 160 | ], 161 | ); 162 | }); 163 | 164 | group('custom parameter', () { 165 | tests( 166 | r'/:key(\d+)', 167 | tokens: [ 168 | path('/'), 169 | parameter('key', pattern: r'(\d+)'), 170 | ], 171 | regExp: [ 172 | matches('/12', ['/12', '12'], extracts: {'key': '12'}), 173 | mismatches('/foo'), 174 | mismatches('/foo/12'), 175 | ], 176 | toPath: [ 177 | returns('/12', given: {'key': '12'}), 178 | throws(given: {'key': 'foo'}), 179 | ], 180 | ); 181 | tests( 182 | r'/:key(\d+)', 183 | prefix: true, 184 | tokens: [ 185 | path('/'), 186 | parameter('key', pattern: r'(\d+)'), 187 | ], 188 | regExp: [ 189 | matches('/12', ['/12', '12'], extracts: {'key': '12'}), 190 | matches('/12/foo', ['/12', '12'], extracts: {'key': '12'}), 191 | mismatches('/foo'), 192 | ], 193 | toPath: [ 194 | returns('/12', given: {'key': '12'}), 195 | ], 196 | ); 197 | tests( 198 | '/:key(.*)', 199 | tokens: [ 200 | path('/'), 201 | parameter('key', pattern: '(.*)'), 202 | ], 203 | regExp: [ 204 | matches( 205 | '/foo/bar/baz', 206 | ['/foo/bar/baz', 'foo/bar/baz'], 207 | extracts: {'key': 'foo/bar/baz'}, 208 | ), 209 | matches( 210 | r'/;,:@&=/+$-_.!/~*()', 211 | [r'/;,:@&=/+$-_.!/~*()', r';,:@&=/+$-_.!/~*()'], 212 | extracts: {'key': r';,:@&=/+$-_.!/~*()'}, 213 | ) 214 | ], 215 | toPath: [ 216 | returns('/', given: {'key': ''}), 217 | returns('/foo', given: {'key': 'foo'}), 218 | ], 219 | ); 220 | tests( 221 | '/:key([a-z]+)', 222 | tokens: [ 223 | path('/'), 224 | parameter('key', pattern: '([a-z]+)'), 225 | ], 226 | regExp: [ 227 | matches('/foo', ['/foo', 'foo'], extracts: {'key': 'foo'}), 228 | mismatches('/12'), 229 | ], 230 | toPath: [ 231 | returns('/foo', given: {'key': 'foo'}), 232 | throws(), 233 | throws(given: {'key': '12'}), 234 | ], 235 | ); 236 | tests( 237 | '/:key(foo|bar)', 238 | tokens: [ 239 | path('/'), 240 | parameter('key', pattern: '(foo|bar)'), 241 | ], 242 | regExp: [ 243 | matches('/foo', ['/foo', 'foo'], extracts: {'key': 'foo'}), 244 | matches('/bar', ['/bar', 'bar'], extracts: {'key': 'bar'}), 245 | mismatches('/baz'), 246 | ], 247 | toPath: [ 248 | returns('/foo', given: {'key': 'foo'}), 249 | returns('/bar', given: {'key': 'bar'}), 250 | throws(given: {'key': 'baz'}), 251 | ], 252 | ); 253 | }); 254 | 255 | group('relative path', () { 256 | tests( 257 | 'foo', 258 | tokens: [ 259 | path('foo'), 260 | ], 261 | regExp: [ 262 | matches('foo', ['foo']), 263 | mismatches('/foo'), 264 | ], 265 | toPath: [ 266 | returns('foo'), 267 | ], 268 | ); 269 | tests( 270 | ':key', 271 | tokens: [ 272 | parameter('key'), 273 | ], 274 | regExp: [ 275 | matches('foo', ['foo', 'foo'], extracts: {'key': 'foo'}), 276 | mismatches('/foo'), 277 | ], 278 | toPath: [ 279 | returns('foo', given: {'key': 'foo'}), 280 | throws(), 281 | throws(given: {'key': ''}), 282 | ], 283 | ); 284 | tests( 285 | ':key', 286 | prefix: true, 287 | tokens: [ 288 | parameter('key'), 289 | ], 290 | regExp: [ 291 | matches('foo', ['foo', 'foo'], extracts: {'key': 'foo'}), 292 | matches('foo/bar', ['foo', 'foo'], extracts: {'key': 'foo'}), 293 | mismatches('/foo'), 294 | ], 295 | toPath: [ 296 | returns('foo', given: {'key': 'foo'}), 297 | ], 298 | ); 299 | }); 300 | 301 | group('complex path', () { 302 | tests( 303 | '/:foo/:bar', 304 | tokens: [ 305 | path('/'), 306 | parameter('foo'), 307 | path('/'), 308 | parameter('bar'), 309 | ], 310 | regExp: [ 311 | matches( 312 | '/foo/bar', 313 | ['/foo/bar', 'foo', 'bar'], 314 | extracts: {'foo': 'foo', 'bar': 'bar'}, 315 | ), 316 | ], 317 | toPath: [ 318 | returns('/baz/qux', given: {'foo': 'baz', 'bar': 'qux'}), 319 | ], 320 | ); 321 | tests( 322 | r'/:remote([\w-.]+)/:user([\w-]+)', 323 | tokens: [ 324 | path('/'), 325 | parameter('remote', pattern: r'([\w-.]+)'), 326 | path('/'), 327 | parameter('user', pattern: r'([\w-]+)'), 328 | ], 329 | regExp: [ 330 | matches( 331 | '/endpoint/user', 332 | ['/endpoint/user', 'endpoint', 'user'], 333 | extracts: {'remote': 'endpoint', 'user': 'user'}, 334 | ), 335 | matches( 336 | '/endpoint/user-name', 337 | ['/endpoint/user-name', 'endpoint', 'user-name'], 338 | extracts: {'remote': 'endpoint', 'user': 'user-name'}, 339 | ), 340 | matches( 341 | '/foo.bar/user-name', 342 | ['/foo.bar/user-name', 'foo.bar', 'user-name'], 343 | extracts: {'remote': 'foo.bar', 'user': 'user-name'}, 344 | ), 345 | ], 346 | toPath: [ 347 | returns('/foo/bar', given: {'remote': 'foo', 'user': 'bar'}), 348 | returns('/foo.bar/baz', given: {'remote': 'foo.bar', 'user': 'baz'}), 349 | ], 350 | ); 351 | tests( 352 | r'/:type(video|audio|text):plus(\+.+)', 353 | tokens: [ 354 | path('/'), 355 | parameter('type', pattern: '(video|audio|text)'), 356 | parameter('plus', pattern: r'(\+.+)'), 357 | ], 358 | regExp: [ 359 | matches( 360 | '/video+test', 361 | ['/video+test', 'video', '+test'], 362 | extracts: {'type': 'video', 'plus': '+test'}, 363 | ), 364 | mismatches('/video'), 365 | mismatches('/video+'), 366 | ], 367 | toPath: [ 368 | returns('/audio+test', given: {'type': 'audio', 'plus': '+test'}), 369 | throws(given: {'type': 'video'}), 370 | throws(given: {'type': 'random'}), 371 | ], 372 | ); 373 | // Case insensitive path matching. 374 | tests(r'/insensitive-token/:foo', caseSensitive: false, tokens: [ 375 | path('/insensitive-token/'), 376 | parameter('foo') 377 | ], regExp: [ 378 | matches( 379 | '/insensitive-token/1', 380 | ['/insensitive-token/1', '1'], 381 | extracts: {'foo': '1'}, 382 | ), 383 | matches( 384 | '/INSENSITIVE-TOKEN/1', 385 | ['/INSENSITIVE-TOKEN/1', '1'], 386 | extracts: {'foo': '1'}, 387 | ) 388 | ], toPath: [ 389 | returns( 390 | '/insensitive-token/1', 391 | given: {'foo': '1'}, 392 | ), 393 | ]); 394 | }); 395 | } 396 | 397 | void tests( 398 | String path, { 399 | bool prefix = false, 400 | bool caseSensitive = true, 401 | List tokens = const [], 402 | List regExp = const [], 403 | List toPath = const [], 404 | }) { 405 | group('"$path"', () { 406 | final parameters = []; 407 | final parsedTokens = parse(path, parameters: parameters); 408 | test('should parse', () { 409 | expect(parsedTokens, tokens); 410 | }); 411 | 412 | final parsedRegExp = tokensToRegExp( 413 | parsedTokens, 414 | prefix: prefix, 415 | caseSensitive: caseSensitive, 416 | ); 417 | for (final matchCase in regExp) { 418 | final path = matchCase.path; 419 | final match = parsedRegExp.matchAsPrefix(path); 420 | if (matchCase.matches) { 421 | test('should match "$path"', () { 422 | expect(match, isNotNull); 423 | expect(_groupsOf(match!), matchCase.groups); 424 | expect(extract(parameters, match), matchCase.args); 425 | }); 426 | } else { 427 | test('should not match "$path"', () { 428 | expect(match, isNull); 429 | }); 430 | } 431 | } 432 | 433 | final parsedFunction = tokensToFunction(parsedTokens); 434 | for (final toPathCase in toPath) { 435 | if (toPathCase.path != null) { 436 | test('should return "${toPathCase.path}" given ${toPathCase.args}', () { 437 | expect(parsedFunction(toPathCase.args), toPathCase.path); 438 | }); 439 | } else { 440 | test('should throw given ${toPathCase.args}', () { 441 | expect(() => parsedFunction(toPathCase.args), throwsArgumentError); 442 | }); 443 | } 444 | } 445 | }); 446 | } 447 | 448 | List _groupsOf(Match match) { 449 | return List.generate( 450 | match.groupCount + 1, 451 | (i) => match.group(i)!, 452 | growable: false, 453 | ); 454 | } 455 | 456 | RegExpCase matches( 457 | String path, 458 | List groups, { 459 | Map extracts = const {}, 460 | }) => 461 | RegExpCase(path, groups, extracts); 462 | 463 | RegExpCase mismatches(String path) => RegExpCase(path, null, null); 464 | 465 | Matcher parameter(String name, {String pattern = '([^/]+?)'}) => 466 | const TypeMatcher() 467 | .having((t) => t.name, 'name', name) 468 | .having((t) => t.pattern, 'pattern', pattern); 469 | 470 | Matcher path(String value) => 471 | const TypeMatcher().having((t) => t.value, 'value', value); 472 | 473 | ToPathCase returns(String path, {Map given = const {}}) => 474 | ToPathCase(given, path); 475 | 476 | ToPathCase throws({Map given = const {}}) => 477 | ToPathCase(given, null); 478 | 479 | class RegExpCase { 480 | RegExpCase(this.path, this.groups, this.args); 481 | 482 | final Map? args; 483 | final List? groups; 484 | final String path; 485 | 486 | bool get matches => groups != null; 487 | } 488 | 489 | class ToPathCase { 490 | ToPathCase(this.args, this.path); 491 | 492 | final Map args; 493 | final String? path; 494 | } 495 | --------------------------------------------------------------------------------