11/*
22 This source file is part of the Swift.org open source project
33
4- Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
4+ Copyright (c) 2021-2025 Apple Inc. and the Swift project authors
55 Licensed under Apache License v2.0 with Runtime Library Exception
66
77 See https://swift.org/LICENSE.txt for license information
@@ -55,10 +55,21 @@ public struct ValidatedURL: Hashable, Equatable {
5555 ///
5656 /// Use this to parse author provided documentation links that may contain links to on-page subsections. Escaping the fragment allows authors
5757 /// 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.
5860 init ? ( parsingAuthoredLink string: String ) {
5961 // 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
6273 return
6374 }
6475
@@ -85,12 +96,13 @@ public struct ValidatedURL: Hashable, Equatable {
8596 remainder = remainder. dropFirst ( " \( ResolvedTopicReference . urlScheme) : " . count)
8697
8798 if remainder. hasPrefix ( " // " ) {
99+ remainder = remainder. dropFirst ( 2 ) // Don't include the "//" prefix in the `host` component.
88100 // 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 {
90102 // The link started with "doc://" but didn't contain another "/" to start of the path.
91103 return nil
92104 }
93- components. percentEncodedHost = String ( remainder [ ..< startOfPath] ) . addingPercentEncoding ( withAllowedCharacters: . urlHostAllowed)
105+ components. percentEncodedHost = String ( remainder [ ..< startOfPath] ) . addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlHostAllowed)
94106 remainder = remainder [ startOfPath... ]
95107 }
96108 }
@@ -100,19 +112,20 @@ public struct ValidatedURL: Hashable, Equatable {
100112 // by documentation links and symbol links.
101113 if let fragmentSeparatorIndex = remainder. firstIndex ( of: " # " ) {
102114 // 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 {
104116 return nil
105117 }
106118 components. percentEncodedPath = path
107- components. percentEncodedFragment = String ( remainder [ fragmentSeparatorIndex... ] . dropFirst ( ) ) . addingPercentEncoding ( withAllowedCharacters: . urlFragmentAllowed)
119+ components. percentEncodedFragment = remainder [ fragmentSeparatorIndex... ] . dropFirst ( ) . addingPercentEncodingIfNeeded ( withAllowedCharacters: . urlFragmentAllowed)
108120 } else {
109121 // 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 {
111123 return nil
112124 }
113125 components. percentEncodedPath = path
114126 }
115127
128+ assert ( components. string != nil , " Failed to parse authored link \( string. singleQuoted) " )
116129 self . components = components
117130 }
118131
@@ -160,3 +173,37 @@ public struct ValidatedURL: Hashable, Equatable {
160173 return components. url!
161174 }
162175}
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