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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ apple-docs types list --technology MetricKit --json

Each result includes the symbol name, kind, command-ready DocC path, and canonical Apple Developer URL. Apple's root pages are curated, so large frameworks may link to collection pages instead of listing every API directly.

Search a technology's root page and recursively linked collection groups by symbol name or path:

```bash
apple-docs types search Button --technology SwiftUI
apple-docs types search Button --technology SwiftUI --json
```

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.

## Technologies

List the technologies in Apple’s documentation catalog:
Expand Down
86 changes: 86 additions & 0 deletions Sources/CLI/client/AppleDocumentationClient+Error.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

extension DefaultAppleDocumentationClient {
enum Error: Swift.Error, Equatable, LocalizedError, ExpectedCommandError {
case httpStatus(Int)
case invalidResponse
case technologyNotFound(String)
case typeNotFound(
name: String,
technology: String,
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:
return true
case .httpStatus, .invalidResponse:
return false
}
}

var errorDescription: String? {
switch self {
case .httpStatus(let statusCode):
return "Apple documentation returned HTTP status \(statusCode)."
case .invalidResponse:
return "Apple documentation returned an invalid response."
case .technologyNotFound(let technology):
return """
Apple documentation technology '\(technology)' was not found.

Browse available technologies:
apple-docs technologies list
"""
case .typeNotFound(let name, let technology, let suggestion, let technologyURL):
var sections = ["No Apple documentation found for '\(name)' in \(technology)."]
if let suggestion {
sections.append(
"""
Did you mean:
\(suggestion.name)
\(suggestion.url)
"""
)
}
sections.append(
"""
Browse available types:
apple-docs types list --technology "\(technology)"
\(technologyURL)
"""
)
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).

Continue in the technology documentation:
\(url)
"""
}
}
}

struct ResolvedTechnology {
let name: String
let documentationSlug: String?
let url: String
}
}
129 changes: 129 additions & 0 deletions Sources/CLI/client/AppleDocumentationClient+Search.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
extension DefaultAppleDocumentationClient {
func searchTypes(query: String, technology: String) async throws -> [DocumentationType] {
var slug = technology
var displayName = technology
var technologyURL = "https://developer.apple.com/documentation/\(technology.lowercased())"
let rootPage: TechnologyDocumentationPageDTO

do {
rootPage = try await fetchDocumentationPage(
path: "/documentation/\(technology.lowercased())"
)
} catch Error.httpStatus(404) {
let resolved = try await resolveTechnology(named: technology)
guard let resolvedSlug = resolved.documentationSlug else {
throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url)
}
slug = resolvedSlug
displayName = resolved.name
technologyURL = resolved.url
rootPage = try await fetchDocumentationPage(
path: "/documentation/\(resolvedSlug.lowercased())"
)
}

return try await searchTypes(
query: query,
documentationSlug: slug,
displayName: displayName,
technologyURL: technologyURL,
rootPage: rootPage
)
}

private func searchTypes(
query: String,
documentationSlug: String,
displayName: String,
technologyURL: String,
rootPage: TechnologyDocumentationPageDTO
) async throws -> [DocumentationType] {
let rootPath = "/documentation/\(documentationSlug.lowercased())"
var typesByPath: [String: DocumentationType] = [:]
for type in documentationTypes(in: rootPage, technology: documentationSlug) {
typesByPath[type.path] = type
}
var visitedPaths = Set([rootPath])
var pendingPaths = collectionGroupPaths(in: rootPage, technology: documentationSlug).filter {
visitedPaths.insert($0).inserted
}

// 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 {
let batch = Array(pendingPaths.prefix(6))
pendingPaths.removeFirst(batch.count)
let pages = await fetchDocumentationPages(paths: batch)

for page in pages {
for type in documentationTypes(in: page, technology: documentationSlug) {
typesByPath[type.path] = type
}
for path in collectionGroupPaths(in: page, technology: documentationSlug)
where visitedPaths.insert(path).inserted {
pendingPaths.append(path)
}
}
}

let normalizedQuery = query.lowercased()
let matches = sortTypes(
typesByPath.values.filter {
$0.name.lowercased().contains(normalizedQuery)
|| $0.path.lowercased().contains(normalizedQuery)
}
)
guard !matches.isEmpty else {
throw Error.typeSearchNoResults(
query: query,
technology: displayName,
technologyURL: technologyURL
)
}
return matches
}

private func fetchDocumentationPages(
paths: [String]
) async -> [TechnologyDocumentationPageDTO] {
await withTaskGroup(
of: TechnologyDocumentationPageDTO?.self,
returning: [TechnologyDocumentationPageDTO].self
) { group in
for path in paths {
group.addTask {
do {
return try await fetchDocumentationPage(path: path)
} catch {
return nil
}
}
}

var pages: [TechnologyDocumentationPageDTO] = []
for await page in group {
if let page {
pages.append(page)
}
}
return pages
}
}

private func collectionGroupPaths(
in page: TechnologyDocumentationPageDTO,
technology: String
) -> [String] {
let pathPrefix = "/documentation/\(technology.lowercased())/"
return page.references.values.compactMap { reference in
guard
reference.role == "collectionGroup",
let path = reference.url,
path.lowercased().hasPrefix(pathPrefix)
else {
return nil
}
return path
}
}
}
105 changes: 27 additions & 78 deletions Sources/CLI/client/AppleDocumentationClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,76 +9,6 @@ import Foundation
#endif

struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationClientDependencies>: Sendable {
enum Error: Swift.Error, Equatable, LocalizedError, ExpectedCommandError {
case httpStatus(Int)
case invalidResponse
case technologyNotFound(String)
case typeNotFound(
name: String,
technology: String,
suggestion: DocumentationType?,
technologyURL: String
)
case unsupportedTechnology(name: String, url: String)

var isExpected: Bool {
switch self {
case .technologyNotFound, .typeNotFound, .unsupportedTechnology:
return true
case .httpStatus, .invalidResponse:
return false
}
}

var errorDescription: String? {
switch self {
case .httpStatus(let statusCode):
return "Apple documentation returned HTTP status \(statusCode)."
case .invalidResponse:
return "Apple documentation returned an invalid response."
case .technologyNotFound(let technology):
return """
Apple documentation technology '\(technology)' was not found.

Browse available technologies:
apple-docs technologies list
"""
case .typeNotFound(let name, let technology, let suggestion, let technologyURL):
var sections = ["No Apple documentation found for '\(name)' in \(technology)."]
if let suggestion {
sections.append(
"""
Did you mean:
\(suggestion.name)
\(suggestion.url)
"""
)
}
sections.append(
"""
Browse available types:
apple-docs types list --technology "\(technology)"
\(technologyURL)
"""
)
return sections.joined(separator: "\n\n")
case .unsupportedTechnology(let name, let url):
return """
Type retrieval is unavailable for \(name).

Continue in the technology documentation:
\(url)
"""
}
}
}

private struct ResolvedTechnology {
let name: String
let documentationSlug: String?
let url: String
}

private static var defaultBaseURL: URL {
guard let url = URL(string: "https://developer.apple.com/tutorials/data/") else {
preconditionFailure("Invalid base URL for documentation client")
Expand Down Expand Up @@ -168,15 +98,29 @@ struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationCl
}

private func fetchTypesDirect(technology: String) async throws -> [DocumentationType] {
let url = baseURL.appending(component: "documentation")
.appending(component: technology.lowercased())
.appendingPathExtension("json")
let path = "/documentation/\(technology.lowercased())"
let page = try await fetchDocumentationPage(path: path)
return sortTypes(documentationTypes(in: page, technology: technology))
}

func fetchDocumentationPage(path: String) async throws -> TechnologyDocumentationPageDTO {
var url = baseURL
for component in path.split(separator: "/") {
url.append(component: component)
}
url.appendPathExtension("json")
let data = try await fetchData(from: url)
let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data)
return try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data)
}

func documentationTypes(
in page: TechnologyDocumentationPageDTO,
technology: String
) -> [DocumentationType] {
let pathPrefix = "/documentation/\(technology.lowercased())/"

// Root pages also reference articles and neighboring frameworks. The symbol role and path
// prefix keep this command faithful to Apple's direct API listing for the requested technology.
// Pages may reference articles and neighboring frameworks. Role and path filtering keeps
// search results scoped to APIs in the requested technology.
return page.references.values.compactMap { reference in
guard
reference.kind == "symbol",
Expand All @@ -196,7 +140,12 @@ struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationCl
path: path,
url: "https://developer.apple.com\(referencePath)"
)
}.sorted {
}
}

func sortTypes<S: Sequence>(_ types: S) -> [DocumentationType]
where S.Element == DocumentationType {
types.sorted {
let comparison = $0.name.compare($1.name, options: .caseInsensitive)
return comparison == .orderedSame ? $0.path < $1.path : comparison == .orderedAscending
}
Expand Down Expand Up @@ -224,7 +173,7 @@ struct DefaultAppleDocumentationClient<Dependencies: DefaultAppleDocumentationCl
return url
}

private func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology {
func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology {
let technologies = try await fetchTechnologies()
guard
let technology = technologies.first(where: {
Expand Down
11 changes: 11 additions & 0 deletions Sources/CLI/client/DocumentationTypeSearchClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

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

extension DefaultAppleDocumentationClient: DocumentationTypeSearchClient {}
#else
typealias DocumentationTypeSearchClient = DefaultAppleDocumentationClient<URLSession>
#endif
2 changes: 1 addition & 1 deletion Sources/CLI/cmd/types/TypesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ struct TypesCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "types",
abstract: "Work with Apple documentation types.",
subcommands: [TypesListCommand.self, TypesViewCommand.self]
subcommands: [TypesListCommand.self, TypesSearchCommand.self, TypesViewCommand.self]
)
}
Loading
Loading