diff --git a/packages/go_router_builder/CHANGELOG.md b/packages/go_router_builder/CHANGELOG.md index d200fcc72f07..98aec64cbb87 100644 --- a/packages/go_router_builder/CHANGELOG.md +++ b/packages/go_router_builder/CHANGELOG.md @@ -1,3 +1,7 @@ +## 4.4.1 + +- Fixes path parameter regex parsing to support nested parentheses, grouping constructs, and lookahead assertions in `TypedGoRoute` paths. + ## 4.4.0 - Adds `hasOverriddenOnExit` parameter to `GoRouteData.$route` and `RelativeGoRouteData.$route` helper methods for type-safe routes. When set to `true`, enables custom `onExit` callback invocation from route data classes extending `GoRouteData` or `RelativeGoRouteData` when the route is removed from the navigation stack. diff --git a/packages/go_router_builder/lib/src/path_utils.dart b/packages/go_router_builder/lib/src/path_utils.dart index e495ce78e8ee..ae6b1fea2026 100644 --- a/packages/go_router_builder/lib/src/path_utils.dart +++ b/packages/go_router_builder/lib/src/path_utils.dart @@ -1,8 +1,81 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:collection/collection.dart'; -final RegExp _parameterRegExp = RegExp(r':(\w+)(\((?:\\.|[^\\()])+\))?'); +final RegExp _parameterNameRegExp = RegExp(r':(\w+)'); + +/// A `:name` occurrence in a path pattern, with its optional constraint. +class _PathParameter { + const _PathParameter({required this.start, required this.end, required this.name}); + + /// Index of the leading `:`. + final int start; + + /// Exclusive end of the whole occurrence, constraint included. + final int end; + + final String name; +} + +/// Scans [pattern] for `:name` occurrences, each optionally followed by a +/// `(...)` constraint. +List<_PathParameter> _pathParametersOf(String pattern) { + final parameters = <_PathParameter>[]; + RegExpMatch? match = _parameterNameRegExp.firstMatch(pattern); + while (match != null) { + final int closingParen = _closingParenIndex(pattern, match.end); + final int end = closingParen != -1 ? closingParen + 1 : match.end; + final String? name = match[1]; + + if (name != null) { + parameters.add(_PathParameter(start: match.start, end: end, name: name)); + } + match = _parameterNameRegExp.allMatches(pattern, end).firstOrNull; + } + return parameters; +} + +/// The index of the `)` that closes the `(` at [start], or -1 when [start] is +/// not a `(` or the group is never closed. +/// +/// Escaped characters and character classes never open or close a group, so +/// `(\()` and `([()])` are both single groups. +int _closingParenIndex(String pattern, int start) { + if (start >= pattern.length || pattern[start] != '(') { + return -1; + } + + var depth = 0; + var inCharacterClass = false; + for (var currentIndex = start; currentIndex < pattern.length; currentIndex++) { + final String character = pattern[currentIndex]; + if (character == r'\') { + // The next character is a literal: skip it together with the backslash. + currentIndex++; + continue; + } + if (inCharacterClass) { + if (character == ']') { + inCharacterClass = false; + } + continue; + } + switch (character) { + case '[': + inCharacterClass = true; + case '(': + depth++; + case ')': + depth--; + if (depth == 0) { + return currentIndex; + } + } + } + + return -1; +} /// Extracts the path parameters from a [pattern] such as `/user/:id`. /// @@ -15,7 +88,7 @@ final RegExp _parameterRegExp = RegExp(r':(\w+)(\((?:\\.|[^\\()])+\))?'); /// final pathParameters = pathParametersFromPattern(pattern); // {'id', 'bookId'} /// ``` Set pathParametersFromPattern(String pattern) => { - for (final RegExpMatch match in _parameterRegExp.allMatches(pattern)) match[1]!, + for (final _PathParameter parameter in _pathParametersOf(pattern)) parameter.name, }; /// Reconstructs the full path from a [pattern] and path parameters. @@ -29,13 +102,12 @@ Set pathParametersFromPattern(String pattern) => { String patternToPath(String pattern, Map pathParameters) { final buffer = StringBuffer(); var start = 0; - for (final RegExpMatch match in _parameterRegExp.allMatches(pattern)) { - if (match.start > start) { - buffer.write(pattern.substring(start, match.start)); + for (final _PathParameter parameter in _pathParametersOf(pattern)) { + if (parameter.start > start) { + buffer.write(pattern.substring(start, parameter.start)); } - final String name = match[1]!; - buffer.write(pathParameters[name]); - start = match.end; + buffer.write(pathParameters[parameter.name]); + start = parameter.end; } if (start < pattern.length) { diff --git a/packages/go_router_builder/pubspec.yaml b/packages/go_router_builder/pubspec.yaml index dc3c84e4bf06..eb9cd84781f1 100644 --- a/packages/go_router_builder/pubspec.yaml +++ b/packages/go_router_builder/pubspec.yaml @@ -2,7 +2,7 @@ name: go_router_builder description: >- A builder that supports generated strongly-typed route helpers for package:go_router -version: 4.4.0 +version: 4.4.1 repository: https://github.com/flutter/packages/tree/main/packages/go_router_builder issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+go_router_builder%22 diff --git a/packages/go_router_builder/test/path_utils_test.dart b/packages/go_router_builder/test/path_utils_test.dart index ccdd256c53f3..a8ae12798402 100644 --- a/packages/go_router_builder/test/path_utils_test.dart +++ b/packages/go_router_builder/test/path_utils_test.dart @@ -14,6 +14,27 @@ void main() { expect(pathParametersFromPattern('/user/:id/book'), const {'id'}); expect(pathParametersFromPattern('/user/:id/book/:bookId'), const {'id', 'bookId'}); }); + + test('It should support a nested group in the parameter pattern', () { + expect( + pathParametersFromPattern(r'/user/:id((?!(?:0|1)(?:/|$))[^/]+)/book/:bookId'), + const {'id', 'bookId'}, + ); + }); + + test('It should support group constructs in the parameter pattern', () { + expect(pathParametersFromPattern(r'/user/:id((?:0x)?\d+)'), const {'id'}); + }); + + test('It should support parentheses in a character class', () { + expect(pathParametersFromPattern(r'/a/:x([()])/:y'), const {'x', 'y'}); + }); + + test('It should support a nested group containing an alternation', () { + expect(pathParametersFromPattern(r'/details/:id((FOO|BAR)[0-9a-zA-Z]{10})'), const { + 'id', + }); + }); }); group('patternToPath', () { @@ -33,5 +54,34 @@ void main() { '/user/user-id/book/book-id', ); }); + + test('It should support a nested group in the parameter pattern', () { + expect( + patternToPath(r'/tags/:slug((?!(?:admin|new)(?:/|$))[^/]+)', const { + 'slug': 'flutter', + }), + '/tags/flutter', + ); + }); + + test('It should support group constructs in the parameter pattern', () { + expect( + patternToPath(r'/user/:id((?:0x)?\d+)/book', const {'id': '0x42'}), + '/user/0x42/book', + ); + }); + + test('It should support parentheses in a character class', () { + expect(patternToPath(r'/a/:x([()])', const {'x': '('}), '/a/('); + }); + + test('It should support a nested group containing an alternation', () { + expect( + patternToPath(r'/details/:id((FOO|BAR)[0-9a-zA-Z]{10})', const { + 'id': 'FOO0123456789', + }), + '/details/FOO0123456789', + ); + }); }); }