Skip to content

Commit f995b97

Browse files
authored
Don't parse query items for authored documentation links (#1159) (#1161)
* Don't parse query items for authored documentation links rdar://144230958 * Make the fallback implementation more robust * Elaborate on code comment about doc links not having query items.
1 parent f742a13 commit f995b97

File tree

2 files changed

+118
-9
lines changed

2 files changed

+118
-9
lines changed

Sources/SwiftDocC/Utility/ValidatedURL.swift

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
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+
}

Tests/SwiftDocCTests/Utility/ValidatedURLTests.swift

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 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
@@ -79,4 +79,66 @@ class ValidatedURLTests: XCTestCase {
7979
XCTAssertNotNil(ValidatedURL(parsingExact: url)?.components.fragment)
8080
}
8181
}
82+
83+
func testQueryIsPartOfPathForAuthoredLinks() throws {
84+
// Test return type disambiguation
85+
for linkText in [
86+
"SymbolName/memberName()->Int?",
87+
"doc:SymbolName/memberName()->Int?",
88+
"doc://com.example.test/SymbolName/memberName()->Int?",
89+
] {
90+
let expectedPath = linkText.hasPrefix("doc://")
91+
? "/SymbolName/memberName()->Int?"
92+
: "SymbolName/memberName()->Int?"
93+
94+
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
95+
XCTAssertNil(validated.components.queryItems, "Authored documentation links don't include query items")
96+
XCTAssertEqual(validated.components.path, expectedPath)
97+
98+
let validatedWithHeading = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText + "#Heading-Name"), "Failed to parse '\(linkText)#Heading-Name' as authored link")
99+
XCTAssertNil(validatedWithHeading.components.queryItems, "Authored documentation links don't include query items")
100+
XCTAssertEqual(validatedWithHeading.components.path, expectedPath)
101+
XCTAssertEqual(validatedWithHeading.components.fragment, "Heading-Name")
102+
}
103+
104+
// Test parameter type disambiguation
105+
for linkText in [
106+
"SymbolName/memberName(with:and:)-(Int?,_)",
107+
"doc:SymbolName/memberName(with:and:)-(Int?,_)",
108+
"doc://com.example.test/SymbolName/memberName(with:and:)-(Int?,_)",
109+
] {
110+
let expectedPath = linkText.hasPrefix("doc://")
111+
? "/SymbolName/memberName(with:and:)-(Int?,_)"
112+
: "SymbolName/memberName(with:and:)-(Int?,_)"
113+
114+
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
115+
XCTAssertNil(validated.components.queryItems, "Authored documentation links don't include query items")
116+
XCTAssertEqual(validated.components.path, expectedPath)
117+
118+
let validatedWithHeading = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText + "#Heading-Name"), "Failed to parse '\(linkText)#Heading-Name' as authored link")
119+
XCTAssertNil(validatedWithHeading.components.queryItems, "Authored documentation links don't include query items")
120+
XCTAssertEqual(validatedWithHeading.components.path, expectedPath)
121+
XCTAssertEqual(validatedWithHeading.components.fragment, "Heading-Name")
122+
}
123+
}
124+
125+
func testEscapedFragment() throws {
126+
let escapedFragment = try XCTUnwrap("💻".addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed))
127+
XCTAssertEqual(escapedFragment, "%F0%9F%92%BB")
128+
129+
for linkText in [
130+
"SymbolName#\(escapedFragment)",
131+
"doc:SymbolName#\(escapedFragment)",
132+
"doc://com.example.test/SymbolName#\(escapedFragment)",
133+
] {
134+
let expectedPath = linkText.hasPrefix("doc://")
135+
? "/SymbolName"
136+
: "SymbolName"
137+
138+
let validated = try XCTUnwrap(ValidatedURL(parsingAuthoredLink: linkText), "Failed to parse \(linkText.singleQuoted) as authored link")
139+
140+
XCTAssertEqual(validated.components.path, expectedPath)
141+
XCTAssertEqual(validated.components.fragment, "💻")
142+
}
143+
}
82144
}

0 commit comments

Comments
 (0)