Skip to content

Commit 961b2c9

Browse files
authored
Feature 0.1 (#1)
* Add mvp features * Add URLPatternExample * Edit parse rule and Add Tests * Update github action * Remove host in URLPatternExampleTests * Add test xcscheme * Update readme * Add test cases * Fix test error * Edit markups
1 parent f1a7dd2 commit 961b2c9

File tree

27 files changed

+1610
-186
lines changed

27 files changed

+1610
-186
lines changed

.github/workflows/swift.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ jobs:
1616
steps:
1717
- uses: actions/checkout@v4
1818

19+
- name: Set Xcode Version 16.1.0
20+
run: sudo xcode-select -s /Applications/Xcode_16.1.app
21+
1922
- name: Test URLPattern
2023
run: swift test -v
2124

Package.swift

Lines changed: 34 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,41 @@
1-
// swift-tools-version: 6.0
1+
// swift-tools-version: 5.9
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
55
import CompilerPluginSupport
66

77
let package = Package(
8-
name: "URLPattern",
9-
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
10-
products: [
11-
// Products define the executables and libraries a package produces, making them visible to other packages.
12-
.library(
13-
name: "URLPattern",
14-
targets: ["URLPattern"]
15-
),
16-
.executable(
17-
name: "URLPatternClient",
18-
targets: ["URLPatternClient"]
19-
),
20-
],
21-
dependencies: [
22-
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"),
23-
],
24-
targets: [
25-
// Targets are the basic building blocks of a package, defining a module or a test suite.
26-
// Targets can depend on other targets in this package and products from dependencies.
27-
// Macro implementation that performs the source transformation of a macro.
28-
.macro(
29-
name: "URLPatternMacros",
30-
dependencies: [
31-
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
32-
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
33-
]
34-
),
35-
36-
// Library that exposes a macro as part of its API, which is used in client programs.
37-
.target(name: "URLPattern", dependencies: ["URLPatternMacros"]),
38-
39-
// A client of the library, which is able to use the macro in its own code.
40-
.executableTarget(name: "URLPatternClient", dependencies: ["URLPattern"]),
41-
42-
// A test target used to develop the macro implementation.
43-
.testTarget(
44-
name: "URLPatternTests",
45-
dependencies: [
46-
"URLPatternMacros",
47-
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
48-
]
49-
),
50-
]
8+
name: "URLPattern",
9+
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
10+
products: [
11+
.library(
12+
name: "URLPattern",
13+
targets: ["URLPattern"]
14+
),
15+
.executable(
16+
name: "URLPatternClient",
17+
targets: ["URLPatternClient"]
18+
),
19+
],
20+
dependencies: [
21+
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"),
22+
],
23+
targets: [
24+
.macro(
25+
name: "URLPatternMacros",
26+
dependencies: [
27+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
28+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
29+
]
30+
),
31+
.target(name: "URLPattern", dependencies: ["URLPatternMacros"]),
32+
.executableTarget(name: "URLPatternClient", dependencies: ["URLPattern"]),
33+
.testTarget(
34+
name: "URLPatternTests",
35+
dependencies: [
36+
"URLPatternMacros",
37+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
38+
]
39+
),
40+
]
5141
)

README.md

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,104 @@
1-
# URLPatternMacro
1+
# URLPattern
2+
[![Swift](https://github.com/heoblitz/URLPattern/actions/workflows/swift.yml/badge.svg?branch=main)](https://github.com/heoblitz/URLPattern/actions/workflows/swift.yml)
3+
4+
A Swift Macro that helps mapping URLs to Enum cases.
5+
6+
## Overview
7+
8+
URL deep linking is a fundamental technology widely used in most services today. However, in Swift environments, implementing deep linking typically requires direct URL path manipulation or regex usage:
9+
10+
```swift
11+
// Traditional approach with manual URL handling
12+
let paths = url.pathComponents
13+
14+
if paths.count == 2 && paths[1] == "home" {
15+
// Handle home
16+
} else let match = try? url.path.firstMatch(of: /\/posts\/([^\/]+)$/) {
17+
// Handle posts
18+
}
19+
```
20+
21+
This approach reduces code readability and scalability, and importantly, cannot validate incorrect patterns at compile-time.
22+
23+
URLPattern solves these issues by providing compile-time URL validation and value mapping:
24+
25+
```swift
26+
@URLPattern
27+
enum DeepLink {
28+
@URLPath("/home")
29+
case home
30+
31+
@URLPath("/posts/{postId}")
32+
case post(postId: String)
33+
34+
@URLPath("/posts/{postId}/comments/{commentId}")
35+
case postComment(postId: String, commentId: String)
36+
}
37+
```
38+
39+
## Features
40+
41+
- **Compile-time Validation**: Ensures URL path values and associated value names match correctly
42+
- **Automatic Enum Generation**: Creates initializers that map URL components to enum associated values
43+
- **Type Support**:
44+
- Built-in support for `String`, `Int`, `Float`, and `Double`
45+
- Non-String types (Int, Float, Double) use String-based initialization
46+
47+
## Usage
48+
49+
```swift
50+
@URLPattern
51+
enum DeepLink {
52+
@URLPath("/posts/{postId}")
53+
case post(postId: String)
54+
55+
@URLPath("/posts/{postId}/comments/{commentId}")
56+
case postComment(postId: String, commentId: String)
57+
58+
@URLPath("/f/{first}/s/{second}")
59+
case reverse(second: Int, first: Int)
60+
}
61+
```
62+
63+
1. Declare the `@URLPattern` macro on your enum.
64+
65+
2. Add `@URLPath` macro to enum cases with the desired URL pattern.
66+
67+
3. Use path values with `{associated_value_name}` syntax to map URL components to associated value names. If mapping code is duplicated, the topmost enum case takes precedence.
68+
69+
70+
```swift
71+
// ✅ Valid URLs
72+
DeepLink(url: URL(string: "/posts/1")!) == .post(postId: "1")
73+
DeepLink(url: URL(string: "/posts/1/comments/2")!) == .postComment(postId: "1", commentId: "2")
74+
DeepLink(url: URL(string: "/f/1/s/2")!) == .postComment(second: 2, first: 1)
75+
76+
// ❌ Invalid URLs
77+
DeepLink(url: URL(string: "/post/1")) == nil
78+
DeepLink(url: URL(string: "/posts/1/comments")) == nil
79+
DeepLink(url: URL(string: "/f/string/s/string")!) == nil
80+
```
81+
4. Use the `Enum.init(url: URL)` generated initializer.
82+
```
83+
if let deepLink = DeepLink(url: incomingURL) {
84+
switch deepLink {
85+
case .post(let postId):
86+
// Handle post
87+
case .postComment(let postId, let commentId):
88+
// Handle postComment
89+
}
90+
}
91+
```
92+
5. Implement a deep link using an enum switch statement.
93+
- For more detailed examples, please refer to the [Example project](https://github.com/heoblitz/URLPattern/tree/feature/main/URLPatternExample).
94+
95+
## Rules
96+
97+
- **Unique Enum Case Names**: Enum case names must be unique for better readability of expanded macro code.
98+
- **Unique Associated Value Names**: Associated value names within each case must be unique.
99+
- **Valid URL Patterns**: Arguments passed to @URLPath macro must be in valid URL path format.
100+
- **Supported Types**: Only String, Int, Float, and Double are supported.
101+
102+
## Installation
103+
### Swift Package Manager
104+
Project > Project Dependencies > Add   `https://github.com/heoblitz/URLPattern.git`

Sources/URLPattern/URLPattern.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@_exported import Foundation
2+
13
@attached(member, names: arbitrary)
24
public macro URLPattern() = #externalMacro(module: "URLPatternMacros", type: "URLPatternMacro")
35

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1 @@
11
import URLPattern
2-
import Foundation
3-
4-
@URLPattern
5-
enum Deeplink {
6-
// @URLPath("/post/{id}")
7-
// case post(id: String)
8-
//
9-
@URLPath("/home/{id}/{name}")
10-
case name(id: String, name: String)
11-
12-
@URLPath("/post/{id}/{name}/hi/{good}")
13-
case nameDetail(id: String, name: String, good: String)
14-
15-
@URLPath("/post/{id}")
16-
case nameDetailHI(id: String)
17-
}
18-
19-
let url1 = URL(string: "https://channel.io/post/12/12")
20-
let url2 = URL(string: "/post/hi/hello/hi/bye")
21-
22-
// enumPath
23-
// inputPath
24-
25-
26-
print(url1?.pathComponents)
27-
print(url2?.pathComponents)
28-
29-
30-
let hi = URL(string: "https://post/{id}")
31-
let paths = url1!.pathComponents
32-
33-
print(Deeplink(url: url1!))
34-
print(Deeplink(url: url2!))
35-
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Foundation
22

33
extension String {
4-
var isURLPathParam: Bool { self.hasPrefix("{") && self.hasSuffix("}") }
4+
var isURLPathValue: Bool { self.hasPrefix("{") && self.hasSuffix("}") && self.utf16.count >= 3 }
55
}

Sources/URLPatternMacros/URLPathMacro.swift

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@ import SwiftSyntaxMacros
55
import Foundation
66

77
public struct URLPathMacro: PeerMacro {
8-
struct CaseParam: Hashable {
9-
let index: Int
10-
let name: String
8+
enum SupportedType: String {
9+
case String
10+
case Int
11+
case Double
12+
case Float
1113
}
1214

13-
struct PatternPathItem {
14-
15+
struct PatternParam: Hashable {
16+
let name: String
17+
let type: SupportedType
18+
let pathIndex: Int
19+
let caseIndex: Int
1520
}
1621

1722
public static func expansion(
@@ -23,48 +28,92 @@ public struct URLPathMacro: PeerMacro {
2328
let enumCase = declaration.as(EnumCaseDeclSyntax.self),
2429
let element = enumCase.elements.first
2530
else {
26-
throw MacroError.message("URLPatternPath macro can only be applied to enum cases")
31+
throw URLPatternError("@URLPathMacro can only be applied to enum cases")
2732
}
28-
33+
2934
guard
3035
let argument = node.arguments?.as(LabeledExprListSyntax.self)?.first,
3136
let pathString = argument.expression.as(StringLiteralExprSyntax.self)?.segments.first?.description
3237
else {
33-
throw MacroError.message("Invalid path")
38+
throw URLPatternError("URLPath is nil")
3439
}
3540

3641
guard let pathURL = URL(string: pathString) else {
37-
throw MacroError.message("URLPatternPath macro requires a string literal path")
42+
throw URLPatternError("URLPath is not in a valid URL format")
3843
}
3944

4045
let patternPaths = pathURL.pathComponents
46+
47+
let caseAssociatedTypes = try element.parameterClause?.parameters.map { param -> (name: String, type: SupportedType) in
48+
let name = param.firstName?.text ?? ""
49+
let type = param.type.description
50+
51+
guard let supportedType = SupportedType(rawValue: type) else {
52+
throw URLPatternError("\(type) is not supported as an associated value")
53+
}
54+
return (name: name, type: supportedType)
55+
} ?? []
56+
57+
let patternParams: [PatternParam] = try patternPaths.enumerated()
58+
.filter { index, value in value.isURLPathValue }
59+
.map { pathIndex, value -> PatternParam in
60+
let name = String(value.dropFirst().dropLast())
61+
62+
guard let (caseIndex, caseAssociatedType) = caseAssociatedTypes.enumerated().first(where: { name == $0.element.name }) else {
63+
throw URLPatternError("URLPath value \"\(name)\" cannot be found in the associated value")
64+
}
65+
66+
return PatternParam(
67+
name: name,
68+
type: caseAssociatedType.type,
69+
pathIndex: pathIndex,
70+
caseIndex: caseIndex
71+
)
72+
}
73+
.sorted(by: { $0.caseIndex < $1.caseIndex })
74+
75+
let patternNames = Set(patternParams.map(\.name))
76+
let caseNames = Set(caseAssociatedTypes.map(\.name))
4177

42-
let pathComponents = pathURL.pathComponents
43-
let parameters = pathComponents.enumerated()
44-
.filter { index, value in value.isURLPathParam }
45-
.map { CaseParam(index: $0.offset, name: String($0.element.dropFirst().dropLast())) }
78+
guard patternNames.count == patternParams.count else {
79+
throw URLPatternError("The name of an URLPath value cannot be duplicated")
80+
}
4681

47-
if Set(parameters).count != parameters.count {
48-
throw MacroError.message("변수 이름은 중복되서는 안됩니다.")
82+
guard caseNames.count == caseAssociatedTypes.count else {
83+
throw URLPatternError("The name of an associated value cannot be duplicated")
4984
}
50-
85+
86+
guard patternNames.count == caseNames.count else {
87+
throw URLPatternError("The number of associated values does not match URLPath")
88+
}
89+
90+
guard patternNames == caseNames else {
91+
throw URLPatternError("The name of the URLPath value does not match the associated value")
92+
}
93+
5194
let staticMethod = try FunctionDeclSyntax("""
5295
static func \(element.name)(_ url: URL) -> Self? {
5396
let inputPaths = url.pathComponents
5497
let patternPaths = \(raw: patternPaths)
55-
98+
5699
guard isValidURLPaths(inputPaths: inputPaths, patternPaths: patternPaths) else { return nil }
57-
58-
\(raw: parameters.map { param in
59-
"""
60-
let \(param.name) = inputPaths[\(param.index)]
61-
"""
100+
\(raw: patternParams.map { param in
101+
switch param.type {
102+
case .Double, .Float, .Int:
103+
"""
104+
guard let \(param.name) = \(param.type.rawValue)(inputPaths[\(param.pathIndex)]) else { return nil }
105+
"""
106+
case .String:
107+
"""
108+
let \(param.name) = inputPaths[\(param.pathIndex)]
109+
"""
110+
}
62111
}.joined(separator: "\n"))
63-
64-
return .\(raw: element.name.text)(\(raw: parameters.map { "\($0.name): \($0.name)" }.joined(separator: ", ")))
112+
return \(raw: patternParams.isEmpty
113+
? ".\(element.name.text)"
114+
: ".\(element.name.text)(\(patternParams.map { "\($0.name): \($0.name)" }.joined(separator: ", ")))")
65115
}
66-
"""
67-
)
116+
""")
68117

69118
return [DeclSyntax(staticMethod)]
70119
}

0 commit comments

Comments
 (0)