1
1
/*
2
2
This source file is part of the Swift.org open source project
3
3
4
- Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
4
+ Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
5
5
Licensed under Apache License v2.0 with Runtime Library Exception
6
6
7
7
See https://swift.org/LICENSE.txt for license information
@@ -55,10 +55,21 @@ public struct ValidatedURL: Hashable, Equatable {
55
55
///
56
56
/// Use this to parse author provided documentation links that may contain links to on-page subsections. Escaping the fragment allows authors
57
57
/// to write links to subsections using characters that wouldn't otherwise be allowed in a fragment of a URL.
58
+ ///
59
+ /// - Important: Documentation links don't include query items but "?" may appear in the link's path.
58
60
init ? ( parsingAuthoredLink string: String ) {
59
61
// Try to parse the string without escaping anything
60
- if let parsed = ValidatedURL ( parsingExact: string) {
61
- self . components = parsed. components
62
+ if var parsedComponents = ValidatedURL ( parsingExact: string) ? . components {
63
+ // Documentation links don't include query items but "?" may appear in the link's path.
64
+ // If `URLComponents` decoded a `query`, that's correct from a general URL standpoint but incorrect from a documentation link standpoint.
65
+ // To create a valid documentation link, we move the `query` component and its "?" separator into the `path` component.
66
+ if let query = parsedComponents. query {
67
+ parsedComponents. path += " ? \( query) "
68
+ parsedComponents. query = nil
69
+ }
70
+
71
+ assert ( parsedComponents. string != nil , " Failed to parse authored link \( string. singleQuoted) " )
72
+ self . components = parsedComponents
62
73
return
63
74
}
64
75
@@ -85,12 +96,13 @@ public struct ValidatedURL: Hashable, Equatable {
85
96
remainder = remainder. dropFirst ( " \( ResolvedTopicReference . urlScheme) : " . count)
86
97
87
98
if remainder. hasPrefix ( " // " ) {
99
+ remainder = remainder. dropFirst ( 2 ) // Don't include the "//" prefix in the `host` component.
88
100
// The authored link includes a bundle ID
89
- guard let startOfPath = remainder. dropFirst ( 2 ) . firstIndex ( of: " / " ) else {
101
+ guard let startOfPath = remainder. firstIndex ( of: " / " ) else {
90
102
// The link started with "doc://" but didn't contain another "/" to start of the path.
91
103
return nil
92
104
}
93
- components. percentEncodedHost = String ( remainder [ ..< startOfPath] ) . addingPercentEncoding ( withAllowedCharacters: . urlHostAllowed)
105
+ components. percentEncodedHost = String ( remainder [ ..< startOfPath] ) . addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlHostAllowed)
94
106
remainder = remainder [ startOfPath... ]
95
107
}
96
108
}
@@ -100,19 +112,20 @@ public struct ValidatedURL: Hashable, Equatable {
100
112
// by documentation links and symbol links.
101
113
if let fragmentSeparatorIndex = remainder. firstIndex ( of: " # " ) {
102
114
// Encode the path substring and fragment substring separately
103
- guard let path = String ( remainder [ ..< fragmentSeparatorIndex] ) . addingPercentEncoding ( withAllowedCharacters: . urlPathAllowed) else {
115
+ guard let path = String ( remainder [ ..< fragmentSeparatorIndex] ) . addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlPathAllowed) else {
104
116
return nil
105
117
}
106
118
components. percentEncodedPath = path
107
- components. percentEncodedFragment = String ( remainder [ fragmentSeparatorIndex... ] . dropFirst ( ) ) . addingPercentEncoding ( withAllowedCharacters: . urlFragmentAllowed)
119
+ components. percentEncodedFragment = remainder [ fragmentSeparatorIndex... ] . dropFirst ( ) . addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlFragmentAllowed)
108
120
} else {
109
121
// Since the link didn't include a fragment, the rest of the string is the path.
110
- guard let path = String ( remainder) . addingPercentEncoding ( withAllowedCharacters: . urlPathAllowed) else {
122
+ guard let path = remainder. addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlPathAllowed) else {
111
123
return nil
112
124
}
113
125
components. percentEncodedPath = path
114
126
}
115
127
128
+ assert ( components. string != nil , " Failed to parse authored link \( string. singleQuoted) " )
116
129
self . components = components
117
130
}
118
131
@@ -160,3 +173,37 @@ public struct ValidatedURL: Hashable, Equatable {
160
173
return components. url!
161
174
}
162
175
}
176
+
177
+ private extension StringProtocol {
178
+ /// Returns a percent encoded version of the string or the original string if it is already percent encoded.
179
+ func addingPercentEncodingIfNeeded( withAllowedCharacters allowedCharacters: CharacterSet ) -> String ? {
180
+ var needsPercentEncoding : Bool {
181
+ for (index, character) in unicodeScalars. indexed ( ) where !allowedCharacters. contains ( character) {
182
+ if character == " % " {
183
+ // % isn't allowed in a URL fragment but it is also the escape character for percent encoding.
184
+ let firstFollowingIndex = unicodeScalars. index ( after: index)
185
+ let secondFollowingIndex = unicodeScalars. index ( after: firstFollowingIndex)
186
+
187
+ guard secondFollowingIndex < unicodeScalars. endIndex else {
188
+ // There's not two characters after the "%". This "%" can't represent a percent encoded character.
189
+ return true
190
+ }
191
+ // If either of the two following characters aren't hex digits, the "%" doesn't represent a
192
+ return !Character( unicodeScalars [ firstFollowingIndex] ) . isHexDigit
193
+ || !Character( unicodeScalars [ secondFollowingIndex] ) . isHexDigit
194
+
195
+ } else {
196
+ // Any other disallowed character is an indication that this substring needs percent encoding.
197
+ return true
198
+ }
199
+ }
200
+ return false
201
+ }
202
+
203
+ return if needsPercentEncoding {
204
+ addingPercentEncoding ( withAllowedCharacters: allowedCharacters)
205
+ } else {
206
+ String ( self )
207
+ }
208
+ }
209
+ }
0 commit comments