diff --git a/Demo.playground/Contents.swift b/Demo.playground/Contents.swift index 2d56894..01e0351 100644 --- a/Demo.playground/Contents.swift +++ b/Demo.playground/Contents.swift @@ -31,19 +31,26 @@ let router = Router(defaultUnmatchHandler: defaultUnmatchHandler) //: Register patterns, the closure is the handle when matched the pattern. // Set a route pattern, the closure is a handler that would be performed after match the pattern -router.register(pattern: pattern1) { result in - // Now, registered pattern has been matched - // Do anything you want, e.g: show a UI - print(result) - print("\n") +do { + try router.register(pattern: pattern1) { result in + // Now, registered pattern has been matched + // Do anything you want, e.g: show a UI + print(result) + print("\n") + } +} catch let error { + print("register failed, reason:\n\(error.localizedDescription)\n") } -router.register(pattern: pattern2) { _ in +do { + try router.register(pattern: pattern2) { _ in // Now, registered pattern has been matched // Do anything you want, e.g: show a UI print("call new article") } - +} catch let error { + print("register failed, reason:\n\(error.localizedDescription)\n") +} //: Let match some URI Path. // A case that should be matched diff --git a/README.md b/README.md index 138892d..c3dab51 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ See [Demo.playground](Demo.playground/Contents.swift) for now. Routing rules follows Rails flavor, see [Rails routing guide](http://guides.rubyonrails.org/routing.html#non-resourceful-routes) for now. +## Features +- [ ] globbing support +- [ ] format validation + ## Requirements - iOS 9.0+ / Mac OS X 10.10+ / tvOS 9.0+ diff --git a/RouterX.xcodeproj/project.pbxproj b/RouterX.xcodeproj/project.pbxproj index 7a4a117..10dcdb9 100644 --- a/RouterX.xcodeproj/project.pbxproj +++ b/RouterX.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 48065D3321AAB88400F33408 /* PatternScanError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48065D3221AAB88400F33408 /* PatternScanError.swift */; }; 48E62CA721A695000005838B /* MatchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E62CA621A695000005838B /* MatchResult.swift */; }; OBJ_35 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_9 /* Router.swift */; }; OBJ_36 /* RouterXCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = OBJ_10 /* RouterXCore.swift */; }; @@ -58,6 +59,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 48065D3221AAB88400F33408 /* PatternScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatternScanError.swift; sourceTree = ""; }; 48E62CA621A695000005838B /* MatchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchResult.swift; sourceTree = ""; }; OBJ_10 /* RouterXCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouterXCore.swift; sourceTree = ""; }; OBJ_11 /* RoutingGraph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutingGraph.swift; sourceTree = ""; }; @@ -163,6 +165,7 @@ OBJ_11 /* RoutingGraph.swift */, OBJ_12 /* RoutingPatternParser.swift */, OBJ_13 /* RoutingPatternScanner.swift */, + 48065D3221AAB88400F33408 /* PatternScanError.swift */, OBJ_14 /* RoutingPatternToken.swift */, OBJ_15 /* URLPathScanner.swift */, OBJ_16 /* URLPathToken.swift */, @@ -260,6 +263,7 @@ OBJ_37 /* RoutingGraph.swift in Sources */, OBJ_38 /* RoutingPatternParser.swift in Sources */, OBJ_39 /* RoutingPatternScanner.swift in Sources */, + 48065D3321AAB88400F33408 /* PatternScanError.swift in Sources */, OBJ_40 /* RoutingPatternToken.swift in Sources */, OBJ_41 /* URLPathScanner.swift in Sources */, OBJ_42 /* URLPathToken.swift in Sources */, diff --git a/Sources/RouterX/PatternScanError.swift b/Sources/RouterX/PatternScanError.swift new file mode 100644 index 0000000..2f61065 --- /dev/null +++ b/Sources/RouterX/PatternScanError.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum PatternRegisterError: LocalizedError { + case empty + case missingPrefixSlash + case invalidGlobbing(globbing: String, after: String) + case invalidSymbol(symbol: String, after: String) + case unbalanceParenthesis + case unexpectToken(after: String) +} diff --git a/Sources/RouterX/Router.swift b/Sources/RouterX/Router.swift index c00ce76..511f801 100644 --- a/Sources/RouterX/Router.swift +++ b/Sources/RouterX/Router.swift @@ -13,18 +13,14 @@ open class Router { self.defaultUnmatchHandler = defaultUnmatchHandler } - open func register(pattern: String, handler: @escaping MatchedHandler) -> Bool { - if self.core.registerRoutingPattern(pattern) { - self.handlerMappings[pattern] = handler - return true - } else { - return false - } + open func register(pattern: String, handler: @escaping MatchedHandler) throws { + try core.register(pattern: pattern) + handlerMappings[pattern] = handler } @discardableResult open func match(_ url: URL, context: Context? = nil, unmatchHandler: UnmatchHandler? = nil) -> Bool { - guard let matchedRoute = core.matchURL(url), + guard let matchedRoute = core.match(url), let matchHandler = handlerMappings[matchedRoute.patternIdentifier] else { let expectUnmatchHandler = unmatchHandler ?? defaultUnmatchHandler expectUnmatchHandler?(url, context) diff --git a/Sources/RouterX/RouterXCore.swift b/Sources/RouterX/RouterXCore.swift index 765c3ca..1ffa8aa 100644 --- a/Sources/RouterX/RouterXCore.swift +++ b/Sources/RouterX/RouterXCore.swift @@ -19,18 +19,41 @@ public class RouterXCore { self.rootRoute = RouteVertex() } - public func registerRoutingPattern(_ pattern: String) -> Bool { + public func register(pattern: String) throws { let tokens = RoutingPatternScanner.tokenize(pattern) - do { - try RoutingPatternParser.parseAndAppendTo(self.rootRoute, routingPatternTokens: tokens, patternIdentifier: pattern) - return true - } catch { - return false + guard let prefixToken = tokens.first else { throw PatternRegisterError.empty } + guard prefixToken == .slash else { throw PatternRegisterError.missingPrefixSlash } + + var previousToken: RoutingPatternToken? + var stackTokensDescription = "" + var parenthesisOffset = 0 + for token in tokens { + switch token { + case .star(let globbing): + if previousToken != .slash { throw PatternRegisterError.invalidGlobbing(globbing: globbing, after: stackTokensDescription) } + case .symbol(let symbol): + if previousToken != .slash && previousToken != .dot { throw PatternRegisterError.invalidSymbol(symbol: symbol, after: stackTokensDescription) } + case .lParen: + parenthesisOffset += 1 + case .rParen: + if parenthesisOffset <= 0 { + throw PatternRegisterError.unexpectToken(after: stackTokensDescription) + } + parenthesisOffset -= 1 + default: break + } + stackTokensDescription.append(token.description) + previousToken = token + } + + guard parenthesisOffset == 0 else { + throw PatternRegisterError.unbalanceParenthesis } + try RoutingPatternParser.parseAndAppendTo(self.rootRoute, routingPatternTokens: tokens, patternIdentifier: pattern) } - public func matchURL(_ url: URL) -> MatchedRoute? { + public func match(_ url: URL) -> MatchedRoute? { let path = url.path let tokens = URLPathScanner.tokenize(path) @@ -58,9 +81,9 @@ public class RouterXCore { return MatchedRoute(url: url, parameters: parameters, patternIdentifier: pathPatternIdentifier) } - public func matchURLPath(_ urlPath: String) -> MatchedRoute? { - guard let url = URL(string: urlPath) else { return nil } - return matchURL(url) + public func match(_ path: String) -> MatchedRoute? { + guard let url = URL(string: path) else { return nil } + return match(url) } } diff --git a/Sources/RouterX/RoutingPatternParser.swift b/Sources/RouterX/RoutingPatternParser.swift index f29ba43..1e05fa5 100644 --- a/Sources/RouterX/RoutingPatternParser.swift +++ b/Sources/RouterX/RoutingPatternParser.swift @@ -16,6 +16,11 @@ internal class RoutingPatternParser { self.patternIdentifier = patternIdentifier } + class func parseAndAppendTo(_ rootRoute: RouteVertex, routingPatternTokens: [RoutingPatternToken], patternIdentifier: PatternIdentifier) throws { + let parser = RoutingPatternParser(routingPatternTokens: routingPatternTokens, patternIdentifier: patternIdentifier) + try parser.parseAndAppendTo(rootRoute) + } + func parseAndAppendTo(_ rootRoute: RouteVertex) throws { var tokenGenerator = self.routingPatternTokens.makeIterator() if let token = tokenGenerator.next() { @@ -30,9 +35,38 @@ internal class RoutingPatternParser { } } - class func parseAndAppendTo(_ rootRoute: RouteVertex, routingPatternTokens: [RoutingPatternToken], patternIdentifier: PatternIdentifier) throws { - let parser = RoutingPatternParser(routingPatternTokens: routingPatternTokens, patternIdentifier: patternIdentifier) - try parser.parseAndAppendTo(rootRoute) + func parseSlash(_ context: RouteVertex, generator: RoutingPatternTokenGenerator) throws { + var generator = generator + + guard let nextToken = generator.next() else { + if let terminalRoute = context.namedRoutes[.slash] { + assignPatternIdentifierIfNil(terminalRoute) + } else { + context.namedRoutes[.slash] = RouteVertex(patternIdentifier: self.patternIdentifier) + } + return + } + + var nextRoute: RouteVertex! + if let route = context.namedRoutes[.slash] { + nextRoute = route + } else { + nextRoute = RouteVertex() + context.namedRoutes[.slash] = nextRoute + } + + switch nextToken { + case let .literal(value): + try parseLiteral(nextRoute, value: value, generator: generator) + case let .symbol(value): + try parseSymbol(nextRoute, value: value, generator: generator) + case let .star(value): + try parseStar(nextRoute, value: value, generator: generator) + case .lParen: + try parseLParen(nextRoute, generator: generator) + default: + throw RoutingPatternParserError.unexpectToken(got: nextToken, message: "Unexpect \(nextToken)") + } } func parseLParen(_ context: RouteVertex, isFirstEnter: Bool = true, generator: RoutingPatternTokenGenerator) throws { @@ -85,41 +119,6 @@ internal class RoutingPatternParser { } } - func parseSlash(_ context: RouteVertex, generator: RoutingPatternTokenGenerator) throws { - var generator = generator - - guard let nextToken = generator.next() else { - if let terminalRoute = context.namedRoutes[.slash] { - assignPatternIdentifierIfNil(terminalRoute) - } else { - context.namedRoutes[.slash] = RouteVertex(patternIdentifier: self.patternIdentifier) - } - - return - } - - var nextRoute: RouteVertex! - if let route = context.namedRoutes[.slash] { - nextRoute = route - } else { - nextRoute = RouteVertex() - context.namedRoutes[.slash] = nextRoute - } - - switch nextToken { - case let .literal(value): - try parseLiteral(nextRoute, value: value, generator: generator) - case let .symbol(value): - try parseSymbol(nextRoute, value: value, generator: generator) - case let .star(value): - try parseStar(nextRoute, value: value, generator: generator) - case .lParen: - try parseLParen(nextRoute, generator: generator) - default: - throw RoutingPatternParserError.unexpectToken(got: nextToken, message: "Unexpect \(nextToken)") - } - } - private func parseDot(_ context: RouteVertex, generator: RoutingPatternTokenGenerator) throws { var generator = generator diff --git a/Sources/RouterX/RoutingPatternScanner.swift b/Sources/RouterX/RoutingPatternScanner.swift index 91fceef..caced46 100755 --- a/Sources/RouterX/RoutingPatternScanner.swift +++ b/Sources/RouterX/RoutingPatternScanner.swift @@ -1,76 +1,68 @@ import Foundation -internal struct RoutingPatternScanner { - private static let stopWordsSet: Set = ["(", ")", "/"] - - let expression: String - - private(set) var position: String.Index - - private var unScannedFragment: String { - return String(expression[position.. RoutingPatternToken? { - guard !isEOF else { return nil } - - guard let firstChar = unScannedFragment.first else { return nil } +internal struct RoutingPatternScanner { - self.position = expression.index(position, offsetBy: 1) + static func tokenize(_ pattern: PatternIdentifier) -> [RoutingPatternToken] { + guard !pattern.isEmpty else { return [] } - switch firstChar { - case "/": - return .slash - case ".": - return .dot - case "(": - return .lParen - case ")": - return .rParen - default: - break - } + var appending = "" + var result: [RoutingPatternToken] = pattern.reduce(into: []) { box, char in + guard let terminator = _PatternScanTerminator(rawValue: char) else { + appending.append(char) + return + } - var fragment = "" - var stepPosition = 0 - for char in self.unScannedFragment { - if RoutingPatternScanner.stopWordsSet.contains(char) { - break + let jointFragment = terminator.jointFragment + defer { + if let token = jointFragment.token { + box.append(token) + } + appending = jointFragment.fragment } - fragment.append(char) - stepPosition += 1 + guard let jointToken = _generateToken(expression: appending) else { return } + box.append(jointToken) } - self.position = expression.index(self.position, offsetBy: stepPosition) + if let tailToken = _generateToken(expression: appending) { + result.append(tailToken) + } + return result + } + static private func _generateToken(expression: String) -> RoutingPatternToken? { + guard let firstChar = expression.first else { return nil } + let fragments = String(expression.dropFirst()) switch firstChar { case ":": - return .symbol(fragment) + return .symbol(fragments) case "*": - return .star(fragment) + return .star(fragments) default: - return .literal("\(firstChar)\(fragment)") + return .literal(expression) } } - - static func tokenize(_ expression: String) -> [RoutingPatternToken] { - var scanner = RoutingPatternScanner(expression: expression) - - var tokens: [RoutingPatternToken] = [] - while let token = scanner.nextToken() { - tokens.append(token) - } - - return tokens - } } diff --git a/Sources/RouterX/RoutingPatternToken.swift b/Sources/RouterX/RoutingPatternToken.swift index 5fc7880..e0fd6c9 100644 --- a/Sources/RouterX/RoutingPatternToken.swift +++ b/Sources/RouterX/RoutingPatternToken.swift @@ -52,7 +52,12 @@ extension RoutingPatternToken: CustomStringConvertible, CustomDebugStringConvert } } -extension RoutingPatternToken: Equatable { } +extension RoutingPatternToken: Hashable { + + func hash(into hasher: inout Hasher) { + hasher.combine(description) + } +} func == (lhs: RoutingPatternToken, rhs: RoutingPatternToken) -> Bool { switch (lhs, rhs) { diff --git a/Tests/RouterXTests/RouterTests.swift b/Tests/RouterXTests/RouterTests.swift index 48dc655..9cf40da 100644 --- a/Tests/RouterXTests/RouterTests.swift +++ b/Tests/RouterXTests/RouterTests.swift @@ -18,7 +18,8 @@ final class RouterTests: XCTestCase { XCTAssertTrue(result.context == "foo", "context must be foo") } - XCTAssertTrue(router.register(pattern: pattern1, handler: pattern1Handler)) + XCTAssertNoThrow(try router.register(pattern: pattern1, handler: pattern1Handler)) + XCTAssertTrue(router.match(pattern1Case, context: "foo")) XCTAssertTrue(isPattern1HandlerPerformed) @@ -35,7 +36,7 @@ final class RouterTests: XCTestCase { let pattern2Case2 = "/band/21" let pattern2Case3 = "/band" - XCTAssertTrue(router.register(pattern: pattern2, handler: { result in + XCTAssertNoThrow(try router.register(pattern: pattern2, handler: { result in XCTAssertEqual(result.parameters["band_id"], "20") XCTAssertEqual(result.parameters.count, 1) })) diff --git a/Tests/RouterXTests/RouterXCoreTests.swift b/Tests/RouterXTests/RouterXCoreTests.swift index 33626a9..11b1120 100644 --- a/Tests/RouterXTests/RouterXCoreTests.swift +++ b/Tests/RouterXTests/RouterXCoreTests.swift @@ -3,15 +3,15 @@ import XCTest @testable import RouterX final class RouterXCoreTests: XCTestCase { - func testIntegration() { - let router = RouterXCore() + func testIntegration() { + let core = RouterXCore() let pattern1 = "/articles(/page/:page(/per_page/:per_page))(/sort/:sort)(.:format)" let pattern1Case = URL(string: "/articles/page/2/sort/recent.json")! - XCTAssertTrue(router.registerRoutingPattern(pattern1)) + XCTAssertNoThrow(try core.register(pattern: pattern1)) - guard let pattern1Matched = router.matchURL(pattern1Case as URL) else { + guard let pattern1Matched = core.match(pattern1Case) else { XCTFail("\(pattern1Case) should be matched") return } @@ -23,6 +23,145 @@ final class RouterXCoreTests: XCTestCase { let unmatchedCase = URL(string: "/articles/2/edit")! - XCTAssertNil(router.matchURL(unmatchedCase as URL)) + XCTAssertNil(core.match(unmatchedCase)) + } + + func testPatternMustStartWithSlash() { + let core = RouterXCore() + let invalidPattern = "invalid/:id" + let validPattern = "/valid/:id" + + XCTAssertThrowsError(try core.register(pattern: invalidPattern), "Must be start with slash") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .missingPrefixSlash = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validPattern)) + } + + func testPatternCanNotRegisterEmpty() { + let core = RouterXCore() + let invalidPattern = "" + let validPattern = "/valid/:id" + + XCTAssertThrowsError(try core.register(pattern: invalidPattern), "Can not register an empty pattern") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .empty = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validPattern)) + } + + func testPatternGlobbingMustFollowSlash() { + let core = RouterXCore() + let invalidPattern1 = "/slash/body*" + let invalidPattern2 = "/slash/:id*name" + let validPattern = "/valid/:id" + + XCTAssertThrowsError(try core.register(pattern: invalidPattern1), "globbing must follow slash") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .invalidGlobbing(let globbing, let previous) = expectError { + if globbing == "" && previous == "/slash/body" { + succeed = true + } + XCTAssert(succeed, "invalid scanned result") + } + XCTAssertTrue(succeed) + } + + XCTAssertThrowsError(try core.register(pattern: invalidPattern2), "globbing must follow slash") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .invalidGlobbing(let globbing, let previous) = expectError { + if globbing == "name" && previous == "/slash/:id" { + succeed = true + } + XCTAssert(succeed, "invalid scanned result") + } + XCTAssertTrue(succeed) + } + + XCTAssertNoThrow(try core.register(pattern: validPattern)) + } + + func testPatternParenthesisMustComeInPairsAndBalance() { + var core = RouterXCore() + let invalidSingleParenthesisPattern = "/invalid(/foo/:foo" + let validSingleeParenthesisPattern = "/valid/(/foo/:foo)" + + XCTAssertThrowsError(try core.register(pattern: invalidSingleParenthesisPattern), "Parenthesis in pattern must come in pairs") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .unbalanceParenthesis = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validSingleeParenthesisPattern)) + + core = RouterXCore() + let invalidMultipleParenthesisPattern1 = "/invalid(/foo/:foo(/bar/:bar)" + let validMultipleParenthesisPattern1 = "/invalid(/foo/:foo(/bar/:bar))" + + XCTAssertThrowsError(try core.register(pattern: invalidMultipleParenthesisPattern1), "Parenthesis in pattern must come in pairs") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .unbalanceParenthesis = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validMultipleParenthesisPattern1)) + + core = RouterXCore() + let invalidMultipleParenthesisPattern2 = "/invalid(/foo/:foo(/bar/:bar(/zoo/:zoo))" + let validMultipleParenthesisPattern2 = "/invalid(/foo/:foo(/bar/:bar(/zoo/:zoo)))" + + XCTAssertThrowsError(try core.register(pattern: invalidMultipleParenthesisPattern2), "Parenthesis in pattern must come in pairs") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .unbalanceParenthesis = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + + XCTAssertNoThrow(try core.register(pattern: validMultipleParenthesisPattern2)) + + core = RouterXCore() + let invalidMultipleParenthesisPattern3 = "/invalid(/foo/:foo(/bar/:bar))(/zoo/:zoo" + let validMultipleParenthesisPattern3 = "/invalid(/foo/:foo(/bar/:bar))(/zoo/:zoo)" + + XCTAssertThrowsError(try core.register(pattern: invalidMultipleParenthesisPattern3), "Parenthesis in pattern must come in pairs") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case .unbalanceParenthesis = expectError { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validMultipleParenthesisPattern3)) + + core = RouterXCore() + let invalidMultipleParenthesisPattern4 = "/invalid)/foo/:foo(" + let validMultipleParenthesisPattern4 = "/invalid(/foo/:foo(/bar/:bar))(/zoo/:zoo)" + + XCTAssertThrowsError(try core.register(pattern: invalidMultipleParenthesisPattern4), "Parenthesis in pattern must come in pairs, and balance") { error in + var succeed = false + if let expectError = error as? PatternRegisterError, + case PatternRegisterError.unexpectToken(after: let previous) = expectError, + previous == "/invalid" { + succeed = true + } + XCTAssertTrue(succeed) + } + XCTAssertNoThrow(try core.register(pattern: validMultipleParenthesisPattern4)) } } diff --git a/Tests/RouterXTests/RoutingPatternScannerTests.swift b/Tests/RouterXTests/RoutingPatternScannerTests.swift index 9c095bf..248851c 100644 --- a/Tests/RouterXTests/RoutingPatternScannerTests.swift +++ b/Tests/RouterXTests/RoutingPatternScannerTests.swift @@ -5,58 +5,67 @@ import XCTest final class RoutingPatternScannerTests: XCTestCase { func testScanner() { - let cases: [String: Array] = [ - "/": [.slash], - "*omg": [.star("omg")], - "/page": [.slash, .literal("page")], - "/page/": [.slash, .literal("page"), .slash], - "/page!": [.slash, .literal("page!")], - "/page$": [.slash, .literal("page$")], - "/page&": [.slash, .literal("page&")], - "/page'": [.slash, .literal("page'")], - "/page*": [.slash, .literal("page*")], - "/page+": [.slash, .literal("page+")], - "/page,": [.slash, .literal("page,")], - "/page=": [.slash, .literal("page=")], - "/page@": [.slash, .literal("page@")], - "/~page": [.slash, .literal("~page")], - "/pa-ge": [.slash, .literal("pa-ge")], - "/:page": [.slash, .symbol("page")], - "/(:page)": [.slash, .lParen, .symbol("page"), .rParen], - "(/:action)": [.lParen, .slash, .symbol("action"), .rParen], - "(())": [.lParen, .lParen, .rParen, .rParen], - "(.:format)": [.lParen, .dot, .symbol("format"), .rParen] + let validCases: [String: Array] = [ + "/": [.slash], + "*omg": [.star("omg")], + "/page": [.slash, .literal("page")], + "/page/": [.slash, .literal("page"), .slash], + "/page!": [.slash, .literal("page!")], + "/page$": [.slash, .literal("page$")], + "/page&": [.slash, .literal("page&")], + "/page'": [.slash, .literal("page'")], + "/page+": [.slash, .literal("page+")], + "/page,": [.slash, .literal("page,")], + "/page=": [.slash, .literal("page=")], + "/page@": [.slash, .literal("page@")], + "/~page": [.slash, .literal("~page")], + "/pa-ge": [.slash, .literal("pa-ge")], + "/:page": [.slash, .symbol("page")], + "/(:page)": [.slash, .lParen, .symbol("page"), .rParen], + "(/:action)": [.lParen, .slash, .symbol("action"), .rParen], + "(())": [.lParen, .lParen, .rParen, .rParen], + "(.:format)": [.lParen, .dot, .symbol("format"), .rParen] ] - for (pattern, expect) in cases { + for (pattern, expect) in validCases { let tokens = RoutingPatternScanner.tokenize(pattern) XCTAssertEqual(tokens, expect) } + + let invalidCases: [String: [RoutingPatternToken]] = [ + "/page*": [.slash, .literal("page*")] + ] + + for (pattern, expect) in invalidCases { + let tokens = RoutingPatternScanner.tokenize(pattern) + + XCTAssertNotEqual(tokens, expect) + } } func testRoundTrip() { let cases = [ - "/", - "/foo", - "/foo/bar", - "/foo/:id", - "/:foo", - "(/:foo)", - "(/:foo)(/:bar)", - "(/:foo(/:bar))", - ".:format", - ".xml", - "/foo.:bar", - "/foo(/:action)", - "/foo(/:action)(/:bar)", - "/foo(/:action(/:bar))", - "*foo", - "/*foo", - "/bar/*foo", - "/bar/(*foo)", - "/sprockets.js(.:format)", - "/(:locale)(.:format)" + "/", + "/foo", + "/foo/bar", + "/foo/:id", + "/:foo", + "(/:foo)", + "(/:foo)(/:bar)", + "(/:foo(/:bar))", + ".:format", + ".xml", + "/foo.:bar", + "/foo(/:action)", + "/foo(/:action)(/:bar)", + "/foo(/:action(/:bar))", + "*foo", + "/*foo", + "/bar/*foo", + "/bar/(*foo)", + "/sprockets.js(.:format)", + "/(:locale)(.:format)" ] for pattern in cases {