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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ apple-docs types search Button --technology SwiftUI

Search deliberately does not crawl individual symbol pages, which keeps requests bounded. Collection pages can directly reference some nested members, so those may appear, but search is not an exhaustive nested-member index. A missing search result does not necessarily mean the API is undocumented. If you know the exact type or DocC path, try `types view` directly.

No matches is a successful empty result, rendered as an empty array in JSON. If some collection pages cannot be fetched, available matches are still returned and an incomplete-coverage warning is written to stderr. Cancellation stops the search instead of returning partial results.

### Type documentation

Pass the exact type and technology names:
Expand Down
15 changes: 1 addition & 14 deletions Sources/CLI/client/AppleDocumentationClient+Error.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,11 @@ extension DefaultAppleDocumentationClient {
suggestion: DocumentationType?,
technologyURL: String
)
case typeSearchNoResults(
query: String,
technology: String,
technologyURL: String
)
case unsupportedTechnology(name: String, url: String)

var isExpected: Bool {
switch self {
case .technologyNotFound, .typeNotFound, .typeSearchNoResults, .unsupportedTechnology:
case .technologyNotFound, .typeNotFound, .unsupportedTechnology:
return true
case .httpStatus, .invalidResponse:
return false
Expand Down Expand Up @@ -59,14 +54,6 @@ extension DefaultAppleDocumentationClient {
"""
)
return sections.joined(separator: "\n\n")
case .typeSearchNoResults(let query, let technology, let technologyURL):
return """
No types matching '\(query)' found in \(technology).

Browse available types:
apple-docs types list --technology "\(technology)"
\(technologyURL)
"""
case .unsupportedTechnology(let name, let url):
return """
Type retrieval is unavailable for \(name).
Expand Down
92 changes: 52 additions & 40 deletions Sources/CLI/client/AppleDocumentationClient+Search.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,17 @@
import Foundation

extension DefaultAppleDocumentationClient {
func searchTypes(query: String, technology: String) async throws -> [DocumentationType] {
logger.debug(
"Searching documentation types",
metadata: [
"query": .string(query), "apple_docs.technology": .string(technology),
])
func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult {
let root = try await fetchDocumentationRoot(technology: technology)
let matches = try await searchTypes(query: query, root: root)
logger.info(
"Documentation search completed",
metadata: [
"apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count),
])
return matches
return try await searchDocumentation(query: query, root: root)
}

private func searchTypes(query: String, root: DocumentationRoot) async throws -> [DocumentationType] {
private func searchDocumentation(query: String, root: DocumentationRoot) async throws -> DocumentationSearchResult {
try Task.checkCancellation()
let rootPath = "/documentation/\(root.slug.lowercased())"
logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)])
var typesByPath: [String: DocumentationType] = [:]
var unavailablePaths: [String] = []
for type in documentationTypes(in: root.page, technology: root.slug) {
typesByPath[type.path] = type
}
Expand All @@ -32,16 +23,21 @@ extension DefaultAppleDocumentationClient {
// Collection groups form a small curated graph. Batching limits pressure on Apple's service
// while avoiding the thousands of requests required to crawl every individual symbol page.
while !pendingPaths.isEmpty {
try Task.checkCancellation()
let batch = Array(pendingPaths.prefix(6))
pendingPaths.removeFirst(batch.count)
logger.trace(
"Dequeued collection group batch",
metadata: [
"batch_size": .stringConvertible(batch.count), "pending": .stringConvertible(pendingPaths.count),
])
let pages = await fetchDocumentationPages(paths: batch)
let pages = try await fetchDocumentationPages(paths: batch)

for page in pages {
for result in pages {
guard case .page(let page) = result else {
if case .unavailable(let path) = result { unavailablePaths.append(path) }
continue
}
for type in documentationTypes(in: page, technology: root.slug) {
typesByPath[type.path] = type
}
Expand All @@ -52,58 +48,74 @@ extension DefaultAppleDocumentationClient {
}
}

try Task.checkCancellation()
return searchResult(
query: query, root: root, types: Array(typesByPath.values), unavailablePaths: unavailablePaths)
}

private func searchResult(
query: String, root: DocumentationRoot, types: [DocumentationType], unavailablePaths: [String]
) -> DocumentationSearchResult {
logger.debug(
"Documentation search coverage",
metadata: [
"apple_docs.technology": .string(root.slug), "candidates": .stringConvertible(types.count),
"unavailable": .stringConvertible(unavailablePaths.count),
])
let normalizedQuery = query.lowercased()
let matches = sortTypes(
typesByPath.values.filter {
types.filter {
$0.name.lowercased().contains(normalizedQuery)
|| $0.path.lowercased().contains(normalizedQuery)
}
)
guard !matches.isEmpty else {
if matches.isEmpty {
logger.notice(
"No matching documentation types",
metadata: [
"query": .string(query), "apple_docs.technology": .string(root.name),
"candidates": .stringConvertible(typesByPath.count),
"candidates": .stringConvertible(types.count),
])
throw Error.typeSearchNoResults(
query: query,
technology: root.name,
technologyURL: root.url
)
}
return matches
logger.info(
"Documentation search completed",
metadata: [
"apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count),
])
return DocumentationSearchResult(types: matches, unavailableCollectionPaths: unavailablePaths.sorted())
}

private enum CollectionResult: Sendable {
case page(TechnologyDocumentationPageDTO)
case unavailable(String)
}

private func fetchDocumentationPages(
paths: [String]
) async -> [TechnologyDocumentationPageDTO] {
) async throws -> [CollectionResult] {
logger.debug("Fetching collection group batch", metadata: ["count": .stringConvertible(paths.count)])
return await withTaskGroup(
of: TechnologyDocumentationPageDTO?.self,
returning: [TechnologyDocumentationPageDTO].self
) { group in
return try await withThrowingTaskGroup(of: CollectionResult.self) { group in
for path in paths {
group.addTask {
do {
return try await fetchDocumentationPage(path: path)
return .page(try await fetchDocumentationPage(path: path))
} catch {
let cancelled = error is CancellationError || (error as? URLError)?.code == .cancelled
logger.log(
level: cancelled ? .debug : .warning, "Skipping unavailable collection group",
if error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
logger.warning(
"Skipping unavailable collection group",
metadata: [
"path": .string(path), "error_type": .string(String(reflecting: type(of: error))),
])
return nil
return .unavailable(path)
}
}
}

var pages: [TechnologyDocumentationPageDTO] = []
for await page in group {
if let page {
pages.append(page)
}
var pages: [CollectionResult] = []
for try await page in group {
pages.append(page)
}
logger.debug(
"Fetched collection group batch",
Expand Down
7 changes: 6 additions & 1 deletion Sources/CLI/client/DocumentationTypeSearchClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import Foundation
import FoundationNetworking
#endif

struct DocumentationSearchResult: Equatable, Sendable {
let types: [DocumentationType]
let unavailableCollectionPaths: [String]
}

#if DEBUG
protocol DocumentationTypeSearchClient: Sendable {
func searchTypes(query: String, technology: String) async throws -> [DocumentationType]
func searchTypes(query: String, technology: String) async throws -> DocumentationSearchResult
}

extension DefaultAppleDocumentationClient: DocumentationTypeSearchClient {}
Expand Down
7 changes: 7 additions & 0 deletions Sources/CLI/cmd/types/TypesSearchCommand.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ArgumentParser
import Foundation

struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
@OptionGroup var global: GlobalOptions
Expand Down Expand Up @@ -33,6 +34,12 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
renderer: Dependencies.documentationTypeListRenderer(json: json)
).run(query: query, technology: technology)
telemetry.record(.typeSearch(matches: result.matchCount), context: context)
if result.unavailableCollectionCount > 0 {
let warning =
"Warning: search results are incomplete. "
+ "\(result.unavailableCollectionCount) collections were unavailable.\n"
FileHandle.standardError.write(Data(warning.utf8))
}
print(result.output)
}
}
8 changes: 5 additions & 3 deletions Sources/CLI/cmd/types/TypesSearchCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ struct TypesSearchCommandRunner: Sendable {
struct Result: Sendable {
let output: String
let matchCount: Int
let unavailableCollectionCount: Int
}

private let client: DocumentationTypeSearchClient
Expand All @@ -16,10 +17,11 @@ struct TypesSearchCommandRunner: Sendable {
}

func run(query: String, technology: String) async throws -> Result {
let types = try await client.searchTypes(query: query, technology: technology)
let result = try await client.searchTypes(query: query, technology: technology)
return Result(
output: try renderer.render(types),
matchCount: types.count
output: try renderer.render(result.types),
matchCount: result.types.count,
unavailableCollectionCount: result.unavailableCollectionPaths.count
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct DefaultDocumentationTypeListRenderer: Sendable {
}

private func renderTable(_ types: [DocumentationType]) -> String {
guard !types.isEmpty else { return "No symbols found." }
let nameWidth = max("SYMBOL".count, types.map(\.name.count).max() ?? 0)
let kindWidth = max("KIND".count, types.map(\.kind.count).max() ?? 0)
let pathWidth = max("PATH".count, types.map(\.path.count).max() ?? 0)
Expand Down
11 changes: 3 additions & 8 deletions Tests/CLITests/client/AppleDocumentationClientLoggingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,10 @@ struct AppleDocumentationClientLoggingTests {
)

// -- Act --
await #expect(
throws: DefaultAppleDocumentationClient<HTTPTestTransport>.Error.typeSearchNoResults(
query: "Missing", technology: "Swift", technologyURL: "https://developer.apple.com/documentation/swift"
)
) {
try await client.searchTypes(query: "Missing", technology: "Swift")
}
let result = try await client.searchTypes(query: "Missing", technology: "Swift")

// -- Assert --
#expect(result.types.isEmpty)
#expect(
recorder.events.contains {
$0.level == .notice && $0.message.description == "No matching documentation types"
Expand Down Expand Up @@ -205,7 +200,7 @@ struct AppleDocumentationClientLoggingTests {
let types = try await client.searchTypes(query: "button", technology: "SwiftUI")

// -- Assert --
#expect(types.map(\.name) == ["Button"])
#expect(types.types.map(\.name) == ["Button"])
let skipped = try #require(
recorder.events.first { $0.message.description == "Skipping unavailable collection group" })
#expect(skipped.level == .warning)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ struct AppleDocumentationClientRootTests {
// -- Act --
let types =
try await search
? client.searchTypes(query: "AES", technology: "Apple CryptoKit")
? client.searchTypes(query: "AES", technology: "Apple CryptoKit").types
: client.fetchTypes(technology: "Apple CryptoKit")

// -- Assert --
Expand Down
Loading
Loading