Skip to content

Commit 7cf3034

Browse files
committed
feat(rfc-5321): accept StringProtocol in public APIs
Upgrade public init and function parameters from String to StringProtocol. Enables zero-copy parsing from Substring and other StringProtocol types. - Updated 4 functions to accept StringProtocol: * RFC_5321.EmailAddress.LocalPart.init(_:) * RFC_5321.EmailAddress.init(displayName:localPart:domain:) * RFC_5321.EmailAddress.init(_:) * RFC_5321.EmailAddress.init?(rawValue:) - All tests pass
1 parent d1c8069 commit 7cf3034

File tree

6 files changed

+49
-34
lines changed

6 files changed

+49
-34
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ let package = Package(
2121
.target(
2222
name: "RFC 5321",
2323
dependencies: [
24-
.product(name: "RFC 1123", package: "swift-rfc-1123"),
24+
.product(name: "RFC_1123", package: "swift-rfc-1123"),
2525
.product(name: "INCITS 4 1986", package: "swift-incits-4-1986")
2626
]
2727
),

Sources/RFC 5321/RFC_5321.EmailAddress.Error.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ extension RFC_5321.EmailAddress {
3434
case invalidLocalPart(_ error: LocalPart.Error)
3535

3636
/// Domain validation failed
37-
case invalidDomain(_ error: RFC_1123.Domain.Error)
37+
case invalidDomain(_ error: Domain.ValidationError)
3838
}
3939
}
4040

@@ -48,9 +48,9 @@ extension RFC_5321.EmailAddress.Error: CustomStringConvertible {
4848
case .totalLengthExceeded(let length):
4949
return "Email address is too long (\(length) bytes, maximum 254)"
5050
case .invalidLocalPart(let error):
51-
return "Invalid local-part: \(error.description)"
51+
return "Invalid local-part: \(error)"
5252
case .invalidDomain(let error):
53-
return "Invalid domain: \(error.description)"
53+
return "Invalid domain: \(error)"
5454
}
5555
}
5656
}

Sources/RFC 5321/RFC_5321.EmailAddress.LocalPart.swift

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,38 +28,40 @@ extension RFC_5321.EmailAddress {
2828
/// Initialize a local-part from a string, validating RFC 5321 rules
2929
///
3030
/// This is the canonical initializer that performs validation.
31-
public init(_ string: String) throws(Error) {
31+
public init(_ string: some StringProtocol) throws(Error) {
32+
let stringValue = String(string)
33+
3234
// Check emptiness
33-
guard !string.isEmpty else {
35+
guard !stringValue.isEmpty else {
3436
throw Error.empty
3537
}
3638

3739
// RFC 5321 is ASCII-only - validate before processing
38-
guard string.allSatisfy({ $0.isASCII }) else {
40+
guard stringValue.allSatisfy({ $0.isASCII }) else {
3941
throw Error.nonASCII
4042
}
4143

4244
// Check overall length
43-
guard string.count <= Limits.maxLength else {
44-
throw Error.tooLong(string.count)
45+
guard stringValue.count <= Limits.maxLength else {
46+
throw Error.tooLong(stringValue.count)
4547
}
4648

4749
// Handle quoted string format
48-
if string.hasPrefix("\"") && string.hasSuffix("\"") {
49-
let quoted = String(string.dropFirst().dropLast())
50+
if stringValue.hasPrefix("\"") && stringValue.hasSuffix("\"") {
51+
let quoted = String(stringValue.dropFirst().dropLast())
5052
guard (try? RFC_5321.EmailAddress.quotedRegex.wholeMatch(in: quoted)) != nil else {
51-
throw Error.invalidQuotedString(string)
53+
throw Error.invalidQuotedString(stringValue)
5254
}
5355
self.format = .quoted
54-
self._value = [UInt8](utf8: string)
56+
self._value = [UInt8](utf8: stringValue)
5557
}
5658
// Handle dot-atom format
5759
else {
58-
guard (try? RFC_5321.EmailAddress.dotAtomRegex.wholeMatch(in: string)) != nil else {
59-
throw Error.invalidDotAtom(string)
60+
guard (try? RFC_5321.EmailAddress.dotAtomRegex.wholeMatch(in: stringValue)) != nil else {
61+
throw Error.invalidDotAtom(stringValue)
6062
}
6163
self.format = .dotAtom
62-
self._value = [UInt8](utf8: string)
64+
self._value = [UInt8](utf8: stringValue)
6365
}
6466
}
6567

Sources/RFC 5321/RFC_5321.EmailAddress.swift

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ extension RFC_5321 {
2828
/// Initialize with validated components
2929
///
3030
/// This is the canonical initializer. Components are already validated.
31-
public init(displayName: String? = nil, localPart: LocalPart, domain: RFC_1123.Domain) throws(Error) {
32-
self.displayName = displayName?.trimming(.whitespaces)
31+
public init(displayName: (some StringProtocol)? = nil, localPart: LocalPart, domain: RFC_1123.Domain) throws(Error) {
32+
self.displayName = displayName.map { String($0).trimming(.ascii.whitespaces) }
3333
self.localPart = localPart
3434
self.domain = domain
3535

@@ -48,7 +48,8 @@ extension RFC_5321.EmailAddress {
4848
/// Initialize from string representation ("Name <local@domain>" or "local@domain")
4949
///
5050
/// Convenience initializer that parses and validates the email address.
51-
public init(_ string: String) throws(Error) {
51+
public init(_ string: some StringProtocol) throws(Error) {
52+
let stringValue = String(string)
5253
let displayNameCapture = /(?:((?:\"(?:[^\"\\]|\\.)*\"|[^<]+?))\s*)/
5354
let emailCapture = /<([^@]+)@([^>]+)>/
5455

@@ -60,12 +61,12 @@ extension RFC_5321.EmailAddress {
6061
}
6162

6263
// Try matching the full address format first (with angle brackets)
63-
if let match = try? fullRegex.wholeMatch(in: string) {
64+
if let match = try? fullRegex.wholeMatch(in: stringValue) {
6465
let captures = match.output
6566

6667
// Extract display name if present and normalize spaces
6768
let displayName = captures.1.map { name in
68-
let trimmedName = name.trimming(.whitespaces)
69+
let trimmedName = name.trimming(.ascii.whitespaces)
6970
if trimmedName.hasPrefix("\"") && trimmedName.hasSuffix("\"") {
7071
let withoutQuotes = String(trimmedName.dropFirst().dropLast())
7172
return withoutQuotes.replacing("\\\"", with: "\"")
@@ -81,48 +82,52 @@ extension RFC_5321.EmailAddress {
8182
let localPart: LocalPart
8283
do {
8384
localPart = try LocalPart(localPartString)
84-
} catch {
85-
throw Error.invalidLocalPart(error)
85+
} catch let localError {
86+
throw Error.invalidLocalPart(localError)
8687
}
8788

8889
let domain: RFC_1123.Domain
8990
do {
9091
domain = try RFC_1123.Domain(domainString)
92+
} catch let domainError as Domain.ValidationError {
93+
throw Error.invalidDomain(domainError)
9194
} catch {
92-
throw Error.invalidDomain(error)
95+
fatalError("Unexpected error type from RFC_1123.Domain.init: \(error)")
9396
}
9497

9598
try self.init(
96-
displayName: displayName,
99+
displayName: displayName as String?,
97100
localPart: localPart,
98101
domain: domain
99102
)
100103
} else {
101104
// Try parsing as bare email address
102-
guard let atIndex = string.firstIndex(of: "@") else {
105+
guard let atIndex = stringValue.firstIndex(of: "@") else {
103106
throw Error.missingAtSign
104107
}
105108

106-
let localString = String(string[..<atIndex])
107-
let domainString = String(string[string.index(after: atIndex)...])
109+
let localString = String(stringValue[..<atIndex])
110+
let domainString = String(stringValue[stringValue.index(after: atIndex)...])
108111

109112
// Validate and construct components with error wrapping
110113
let localPart: LocalPart
111114
do {
112115
localPart = try LocalPart(localString)
113-
} catch {
114-
throw Error.invalidLocalPart(error)
116+
} catch let localError {
117+
throw Error.invalidLocalPart(localError)
115118
}
116119

117120
let domain: RFC_1123.Domain
118121
do {
119122
domain = try RFC_1123.Domain(domainString)
123+
} catch let domainError as Domain.ValidationError {
124+
throw Error.invalidDomain(domainError)
120125
} catch {
121-
throw Error.invalidDomain(error)
126+
fatalError("Unexpected error type from RFC_1123.Domain.init: \(error)")
122127
}
123128

124129
try self.init(
125-
displayName: nil,
130+
displayName: nil as String?,
126131
localPart: localPart,
127132
domain: domain
128133
)
@@ -182,5 +187,5 @@ extension RFC_5321.EmailAddress: Codable {
182187

183188
extension RFC_5321.EmailAddress: RawRepresentable {
184189
public var rawValue: String { String(self) }
185-
public init?(rawValue: String) { try? self.init(rawValue) }
190+
public init?(rawValue: some StringProtocol) { try? self.init(rawValue) }
186191
}

Sources/RFC 5321/exports.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//
2+
// File.swift
3+
// swift-rfc-5321
4+
//
5+
// Created by Coen ten Thije Boonkkamp on 21/11/2025.
6+
//
7+
8+
@_exported import struct RFC_1123.Domain

Tests/RFC 5321 Tests/RFC 5321 Tests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ struct `RFC 5321 Domain Tests` {
2020

2121
@Test
2222
func `Fails with empty address literal`() throws {
23-
#expect(throws: RFC_1123.Domain.Error.self) {
23+
#expect(throws: Domain.ValidationError.self) {
2424
_ = try RFC_1123.Domain("[]")
2525
}
2626
}

0 commit comments

Comments
 (0)