Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
steps:
- uses: actions/checkout@v4

- name: Set Xcode Version 16.1.0
run: sudo xcode-select -s /Applications/Xcode_16.1.app

- name: Test URLPattern
run: swift test -v

Expand Down
78 changes: 34 additions & 44 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,51 +1,41 @@
// swift-tools-version: 6.0
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
import CompilerPluginSupport

let package = Package(
name: "URLPattern",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "URLPattern",
targets: ["URLPattern"]
),
.executable(
name: "URLPatternClient",
targets: ["URLPatternClient"]
),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
// Macro implementation that performs the source transformation of a macro.
.macro(
name: "URLPatternMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),

// Library that exposes a macro as part of its API, which is used in client programs.
.target(name: "URLPattern", dependencies: ["URLPatternMacros"]),

// A client of the library, which is able to use the macro in its own code.
.executableTarget(name: "URLPatternClient", dependencies: ["URLPattern"]),

// A test target used to develop the macro implementation.
.testTarget(
name: "URLPatternTests",
dependencies: [
"URLPatternMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),
]
name: "URLPattern",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
products: [
.library(
name: "URLPattern",
targets: ["URLPattern"]
),
.executable(
name: "URLPatternClient",
targets: ["URLPatternClient"]
),
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0-latest"),
],
targets: [
.macro(
name: "URLPatternMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
.target(name: "URLPattern", dependencies: ["URLPatternMacros"]),
.executableTarget(name: "URLPatternClient", dependencies: ["URLPattern"]),
.testTarget(
name: "URLPatternTests",
dependencies: [
"URLPatternMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),
]
)
105 changes: 104 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,104 @@
# URLPatternMacro
# URLPattern
[![Swift](https://github.com/heoblitz/URLPattern/actions/workflows/swift.yml/badge.svg?branch=main)](https://github.com/heoblitz/URLPattern/actions/workflows/swift.yml)

A Swift Macro that helps mapping URLs to Enum cases.

## Overview

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:

```swift
// Traditional approach with manual URL handling
let paths = url.pathComponents

if paths.count == 2 && paths[1] == "home" {
// Handle home
} else let match = try? url.path.firstMatch(of: /\/posts\/([^\/]+)$/) {
// Handle posts
}
```

This approach reduces code readability and scalability, and importantly, cannot validate incorrect patterns at compile-time.

URLPattern solves these issues by providing compile-time URL validation and value mapping:

```swift
@URLPattern
enum DeepLink {
@URLPath("/home")
case home

@URLPath("/posts/{postId}")
case post(postId: String)

@URLPath("/posts/{postId}/comments/{commentId}")
case postComment(postId: String, commentId: String)
}
```

## Features

- **Compile-time Validation**: Ensures URL path values and associated value names match correctly
- **Automatic Enum Generation**: Creates initializers that map URL components to enum associated values
- **Type Support**:
- Built-in support for `String`, `Int`, `Float`, and `Double`
- Non-String types (Int, Float, Double) use String-based initialization

## Usage

```swift
@URLPattern
enum DeepLink {
@URLPath("/posts/{postId}")
case post(postId: String)

@URLPath("/posts/{postId}/comments/{commentId}")
case postComment(postId: String, commentId: String)

@URLPath("/f/{first}/s/{second}")
case reverse(second: Int, first: Int)
}
```

1. Declare the `@URLPattern` macro on your enum.

2. Add `@URLPath` macro to enum cases with the desired URL pattern.

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.


```swift
// ✅ Valid URLs
DeepLink(url: URL(string: "/posts/1")!) == .post(postId: "1")
DeepLink(url: URL(string: "/posts/1/comments/2")!) == .postComment(postId: "1", commentId: "2")
DeepLink(url: URL(string: "/f/1/s/2")!) == .postComment(second: 2, first: 1)

// ❌ Invalid URLs
DeepLink(url: URL(string: "/post/1")) == nil
DeepLink(url: URL(string: "/posts/1/comments")) == nil
DeepLink(url: URL(string: "/f/string/s/string")!) == nil
```
4. Use the `Enum.init(url: URL)` generated initializer.
```
if let deepLink = DeepLink(url: incomingURL) {
switch deepLink {
case .post(let postId):
// Handle post
case .postComment(let postId, let commentId):
// Handle postComment
}
}
```
5. Implement a deep link using an enum switch statement.
- For more detailed examples, please refer to the [Example project](https://github.com/heoblitz/URLPattern/tree/feature/main/URLPatternExample).

## Rules

- **Unique Enum Case Names**: Enum case names must be unique for better readability of expanded macro code.
- **Unique Associated Value Names**: Associated value names within each case must be unique.
- **Valid URL Patterns**: Arguments passed to @URLPath macro must be in valid URL path format.
- **Supported Types**: Only String, Int, Float, and Double are supported.

## Installation
### Swift Package Manager
Project > Project Dependencies > Add   `https://github.com/heoblitz/URLPattern.git`
2 changes: 2 additions & 0 deletions Sources/URLPattern/URLPattern.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@_exported import Foundation

@attached(member, names: arbitrary)
public macro URLPattern() = #externalMacro(module: "URLPatternMacros", type: "URLPatternMacro")

Expand Down
34 changes: 0 additions & 34 deletions Sources/URLPatternClient/main.swift
Original file line number Diff line number Diff line change
@@ -1,35 +1 @@
import URLPattern
import Foundation

@URLPattern
enum Deeplink {
// @URLPath("/post/{id}")
// case post(id: String)
//
@URLPath("/home/{id}/{name}")
case name(id: String, name: String)

@URLPath("/post/{id}/{name}/hi/{good}")
case nameDetail(id: String, name: String, good: String)

@URLPath("/post/{id}")
case nameDetailHI(id: String)
}

let url1 = URL(string: "https://channel.io/post/12/12")
let url2 = URL(string: "/post/hi/hello/hi/bye")

// enumPath
// inputPath


print(url1?.pathComponents)
print(url2?.pathComponents)


let hi = URL(string: "https://post/{id}")
let paths = url1!.pathComponents

print(Deeplink(url: url1!))
print(Deeplink(url: url2!))

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation

extension String {
var isURLPathParam: Bool { self.hasPrefix("{") && self.hasSuffix("}") }
var isURLPathValue: Bool { self.hasPrefix("{") && self.hasSuffix("}") && self.utf16.count >= 3 }
}
101 changes: 75 additions & 26 deletions Sources/URLPatternMacros/URLPathMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import SwiftSyntaxMacros
import Foundation

public struct URLPathMacro: PeerMacro {
struct CaseParam: Hashable {
let index: Int
let name: String
enum SupportedType: String {
case String
case Int
case Double
case Float
}

struct PatternPathItem {

struct PatternParam: Hashable {
let name: String
let type: SupportedType
let pathIndex: Int
let caseIndex: Int
}

public static func expansion(
Expand All @@ -23,48 +28,92 @@ public struct URLPathMacro: PeerMacro {
let enumCase = declaration.as(EnumCaseDeclSyntax.self),
let element = enumCase.elements.first
else {
throw MacroError.message("URLPatternPath macro can only be applied to enum cases")
throw URLPatternError("@URLPathMacro can only be applied to enum cases")
}

guard
let argument = node.arguments?.as(LabeledExprListSyntax.self)?.first,
let pathString = argument.expression.as(StringLiteralExprSyntax.self)?.segments.first?.description
else {
throw MacroError.message("Invalid path")
throw URLPatternError("URLPath is nil")
}

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

let patternPaths = pathURL.pathComponents

let caseAssociatedTypes = try element.parameterClause?.parameters.map { param -> (name: String, type: SupportedType) in
let name = param.firstName?.text ?? ""
let type = param.type.description

guard let supportedType = SupportedType(rawValue: type) else {
throw URLPatternError("\(type) is not supported as an associated value")
}
return (name: name, type: supportedType)
} ?? []

let patternParams: [PatternParam] = try patternPaths.enumerated()
.filter { index, value in value.isURLPathValue }
.map { pathIndex, value -> PatternParam in
let name = String(value.dropFirst().dropLast())

guard let (caseIndex, caseAssociatedType) = caseAssociatedTypes.enumerated().first(where: { name == $0.element.name }) else {
throw URLPatternError("URLPath value \"\(name)\" cannot be found in the associated value")
}

return PatternParam(
name: name,
type: caseAssociatedType.type,
pathIndex: pathIndex,
caseIndex: caseIndex
)
}
.sorted(by: { $0.caseIndex < $1.caseIndex })

let patternNames = Set(patternParams.map(\.name))
let caseNames = Set(caseAssociatedTypes.map(\.name))

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

if Set(parameters).count != parameters.count {
throw MacroError.message("변수 이름은 중복되서는 안됩니다.")
guard caseNames.count == caseAssociatedTypes.count else {
throw URLPatternError("The name of an associated value cannot be duplicated")
}


guard patternNames.count == caseNames.count else {
throw URLPatternError("The number of associated values does not match URLPath")
}

guard patternNames == caseNames else {
throw URLPatternError("The name of the URLPath value does not match the associated value")
}

let staticMethod = try FunctionDeclSyntax("""
static func \(element.name)(_ url: URL) -> Self? {
let inputPaths = url.pathComponents
let patternPaths = \(raw: patternPaths)

guard isValidURLPaths(inputPaths: inputPaths, patternPaths: patternPaths) else { return nil }

\(raw: parameters.map { param in
"""
let \(param.name) = inputPaths[\(param.index)]
"""
\(raw: patternParams.map { param in
switch param.type {
case .Double, .Float, .Int:
"""
guard let \(param.name) = \(param.type.rawValue)(inputPaths[\(param.pathIndex)]) else { return nil }
"""
case .String:
"""
let \(param.name) = inputPaths[\(param.pathIndex)]
"""
}
}.joined(separator: "\n"))

return .\(raw: element.name.text)(\(raw: parameters.map { "\($0.name): \($0.name)" }.joined(separator: ", ")))
return \(raw: patternParams.isEmpty
? ".\(element.name.text)"
: ".\(element.name.text)(\(patternParams.map { "\($0.name): \($0.name)" }.joined(separator: ", ")))")
}
"""
)
""")

return [DeclSyntax(staticMethod)]
}
Expand Down
Loading