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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ 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.
No matches is a successful result with an empty `types` array in JSON. Search JSON also includes the resolved `technology` and `coverage`, with `isComplete` and typed `unavailableCollections`. If some collection pages cannot be fetched, available matches are still returned and an incomplete-coverage warning is written to stderr. Unexpected failures and cancellation stop the search instead of returning partial results.

### Type documentation

Expand Down Expand Up @@ -150,11 +150,12 @@ Agent output uses the same normalized documentation content as human text. Follo

The documentation commands accept `--json` for normalized semantic output:

| Command | JSON output |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `types view` | An object with `title`, `kind`, `technology`, `path`, `url`, `modules`, `abstract`, `deprecation`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. |
| `types list` / `types search` | An array with `name`, `kind`, `path`, and `url` fields. |
| `technologies list` | A sorted array of `name` and `identifier` objects. |
| Command | JSON output |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `types view` | An object with `title`, `kind`, `technology`, `path`, `url`, `modules`, `abstract`, `deprecation`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`. |
| `types list` | An array with `name`, `kind`, `path`, and `url` fields. |
| `types search` | An object containing `technology`, a `types` array, and `coverage` with completeness and typed collection failures. |
| `technologies list` | A sorted array of `name` and `identifier` objects. |

Add `--agent` to include `navigation` commands for supported destinations. Content blocks retain semantic types and links distinguish documentation, external, and unavailable targets. Availability includes `introducedAt`, `deprecatedAt`, `obsoletedAt`, `isBeta`, and `isUnavailable` when applicable. Inapplicable optional fields are omitted.

Expand Down
4 changes: 2 additions & 2 deletions Sources/CLI/cmd/types/TypesSearchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
#if DEBUG
typealias Deps = any (
TelemetryProvider & TerminalCapabilitiesProvider & DocumentationTypeSearchRepositoryProvider
& DocumentationTypeListRendererProvider & CommandOutputWriterProvider
& DocumentationSearchRendererProvider & CommandOutputWriterProvider
)
#else
typealias Deps = Dependencies
Expand Down Expand Up @@ -35,7 +35,7 @@ struct TypesSearchCommand: AsyncParsableCommand, GlobalOptionsProviding {
deps.telemetry.startCommand(context)
let runner = TypesSearchCommandRunner(
client: deps.documentationRepository,
renderer: deps.documentationTypeListRenderer(output: output, technology: technology)
renderer: deps.documentationSearchRenderer(output: output)
)
let result = try await runner.run(
query: query, technology: technology, mode: deps.terminalCapabilities.mode(for: output))
Expand Down
8 changes: 4 additions & 4 deletions Sources/CLI/cmd/types/TypesSearchCommandRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ struct TypesSearchCommandRunner: Sendable {
}

private let client: DocumentationTypeSearchRepository
private let renderer: DocumentationTypeListRenderer
private let renderer: DocumentationSearchRenderer

init(
client: DocumentationTypeSearchRepository,
renderer: DocumentationTypeListRenderer
renderer: DocumentationSearchRenderer
) {
self.client = client
self.renderer = renderer
Expand All @@ -19,9 +19,9 @@ struct TypesSearchCommandRunner: Sendable {
func run(query: String, technology: String, mode: OutputMode) async throws -> Result {
let result = try await client.searchTypes(query: query, technology: technology)
return Result(
output: try renderer.render(result.types),
output: try renderer.render(result),
matchCount: result.types.count,
unavailableCollectionCount: result.unavailableCollectionPaths.count
unavailableCollectionCount: result.unavailableCollections.count
)
}
}
4 changes: 4 additions & 0 deletions Sources/CLI/main/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ struct Dependencies: Sendable {
output: output.json ? .json : .table, audience: output.audience, technology: technology)
}

func documentationSearchRenderer(output: OutputOptions) -> DefaultDocumentationSearchRenderer {
DefaultDocumentationSearchRenderer(output: output.format, audience: output.audience)
}

func technologyListRenderer(output: OutputOptions) -> DefaultTechnologyListRenderer {
DefaultTechnologyListRenderer(output: output.json ? .json : .table, audience: output.audience)
}
Expand Down
15 changes: 15 additions & 0 deletions Sources/CLI/renderer/DefaultDocumentationSearchRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
struct DefaultDocumentationSearchRenderer: Sendable {
let output: OutputFormat
var audience: OutputAudience = .human

func render(_ result: DocumentationSearchResult) throws -> String {
let presentation = DocumentationPresenter().search(result, audience: audience)
switch output {
case .json: return try StructuredDocumentationRenderer().render(presentation)
case .text:
return audience == .agent
? AgentDocumentationRenderer().render(presentation.types, technology: presentation.technology)
: HumanDocumentationRenderer().render(presentation.types)
}
}
}
18 changes: 18 additions & 0 deletions Sources/CLI/renderer/DocumentationPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,21 @@ extension DocumentationBlock: Encodable {
}
}
}

struct SearchPresentation: Encodable, Sendable {
let technology: String
let types: [SymbolPresentation]
let coverage: SearchCoveragePresentation
}

struct SearchCoveragePresentation: Encodable, Sendable {
let isComplete: Bool
let unavailableCollections: [CollectionFailurePresentation]
}

struct CollectionFailurePresentation: Encodable, Sendable {
let path: String
let kind: String
var status: Int?
var code: Int?
}
20 changes: 20 additions & 0 deletions Sources/CLI/renderer/DocumentationPresenter.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import Foundation

struct DocumentationPresenter: Sendable {
func search(_ result: DocumentationSearchResult, audience: OutputAudience) -> SearchPresentation {
SearchPresentation(
technology: result.technology,
types: symbols(result.types, technology: result.technology, audience: audience),
coverage: SearchCoveragePresentation(
isComplete: result.isComplete,
unavailableCollections: result.unavailableCollections.map { failure in
switch failure.reason {
case .httpStatus(let status):
return CollectionFailurePresentation(path: failure.path, kind: "http", status: status)
case .invalidResponse:
return CollectionFailurePresentation(path: failure.path, kind: "invalidResponse")
case .transport(let code):
return CollectionFailurePresentation(path: failure.path, kind: "transport", code: code)
case .decoding:
return CollectionFailurePresentation(path: failure.path, kind: "decoding")
}
}))
}

func page(_ page: DocumentationPage, audience: OutputAudience) -> PagePresentation {
PagePresentation(
document: page, audience: audience,
Expand Down
16 changes: 16 additions & 0 deletions Sources/CLI/renderer/DocumentationSearchRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#if DEBUG
protocol DocumentationSearchRenderer: Sendable {
func render(_ result: DocumentationSearchResult) throws -> String
}

extension DefaultDocumentationSearchRenderer: DocumentationSearchRenderer {}

protocol DocumentationSearchRendererProvider {
associatedtype SearchRenderer: DocumentationSearchRenderer
func documentationSearchRenderer(output: OutputOptions) -> SearchRenderer
}

extension Dependencies: DocumentationSearchRendererProvider {}
#else
typealias DocumentationSearchRenderer = DefaultDocumentationSearchRenderer
#endif
20 changes: 12 additions & 8 deletions Sources/CLI/repository/AppleDocumentationRepository+Search.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ extension DefaultAppleDocumentationRepository {
let rootPath = "/documentation/\(root.slug.lowercased())"
logger.debug("Traversing documentation collection groups", metadata: ["path": .string(rootPath)])
var typesByPath: [String: DocumentationType] = [:]
var unavailablePaths: [String] = []
var unavailableCollections: [DocumentationCollectionFailure] = []
for type in documentationTypes(in: root.page, technology: root.slug) {
typesByPath[type.path] = type
}
Expand All @@ -35,7 +35,7 @@ extension DefaultAppleDocumentationRepository {

for result in pages {
guard case .page(let page) = result else {
if case .unavailable(let path) = result { unavailablePaths.append(path) }
if case .unavailable(let failure) = result { unavailableCollections.append(failure) }
continue
}
for type in documentationTypes(in: page, technology: root.slug) {
Expand All @@ -50,17 +50,18 @@ extension DefaultAppleDocumentationRepository {

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

private func searchResult(
query: String, root: DocumentationRoot, types: [DocumentationType], unavailablePaths: [String]
query: String, root: DocumentationRoot, types: [DocumentationType],
unavailableCollections: [DocumentationCollectionFailure]
) -> DocumentationSearchResult {
logger.debug(
"Documentation search coverage",
metadata: [
"apple_docs.technology": .string(root.slug), "candidates": .stringConvertible(types.count),
"unavailable": .stringConvertible(unavailablePaths.count),
"unavailable": .stringConvertible(unavailableCollections.count),
])
let normalizedQuery = query.lowercased()
let matches = sortTypes(
Expand All @@ -82,12 +83,14 @@ extension DefaultAppleDocumentationRepository {
metadata: [
"apple_docs.technology": .string(root.name), "matches": .stringConvertible(matches.count),
])
return DocumentationSearchResult(types: matches, unavailableCollectionPaths: unavailablePaths.sorted())
return DocumentationSearchResult(
technology: root.slug.lowercased(), types: matches,
unavailableCollections: unavailableCollections.sorted { $0.path < $1.path })
}

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

private func fetchDocumentationPages(
Expand All @@ -103,12 +106,13 @@ extension DefaultAppleDocumentationRepository {
if error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
guard let reason = DocumentationCollectionFailure.reason(for: error) else { throw error }
logger.warning(
"Skipping unavailable collection group",
metadata: [
"path": .string(path), "error_type": .string(String(reflecting: type(of: error))),
])
return .unavailable(path)
return .unavailable(DocumentationCollectionFailure(path: path, reason: reason))
}
}
}
Expand Down
29 changes: 28 additions & 1 deletion Sources/CLI/repository/DocumentationTypeSearchRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,35 @@ import Foundation
#endif

struct DocumentationSearchResult: Equatable, Sendable {
let technology: String
let types: [DocumentationType]
let unavailableCollectionPaths: [String]
let unavailableCollections: [DocumentationCollectionFailure]

var isComplete: Bool { unavailableCollections.isEmpty }
}

struct DocumentationCollectionFailure: Equatable, Sendable {
enum Reason: Equatable, Sendable {
case httpStatus(Int)
case invalidResponse
case transport(Int)
case decoding
}

let path: String
let reason: Reason

static func reason(for error: any Error) -> Reason? {
if let error = error as? AppleDocumentationClientError {
switch error {
case .httpStatus(let status): return .httpStatus(status)
case .invalidResponse: return .invalidResponse
}
}
if let error = error as? URLError { return .transport(error.code.rawValue) }
if error is DecodingError { return .decoding }
return nil
}
}

#if DEBUG
Expand Down
6 changes: 3 additions & 3 deletions Sources/CLI/skills/BundledAgentSkills.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,10 @@ enum BundledAgentSkills {
apple-docs types view URLSession --technology Foundation --agent --json
```

Technology, list, and search JSON are arrays. Page JSON is a normalized semantic object, not raw DocC bytes.
Catalog JSON is arrays. Search JSON contains `technology`, `types`, and `coverage`. Check coverage before
interpreting no matches: `isComplete` and `unavailableCollections`. Page JSON is semantic, not raw DocC.
Inspect `title`, `declarations`, `availability`, `content`, `relationships`, `topics`, and `seeAlso`.
Agent output adds `navigation` commands where a destination can be represented safely on the CLI.
Follow validated targets in grouped references. A missing field is not a guarantee.
Agent `navigation` commands and grouped references retain validated targets. A missing field is not a guarantee.

## Availability and examples

Expand Down
14 changes: 13 additions & 1 deletion Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ struct AppleDocsCommandIntegrationTests {

// -- Act --
let output = try runAppleDocs(arguments)
let types = try JSONDecoder().decode([ListedType].self, from: Data(output.utf8))
let result = try JSONDecoder().decode(SearchResult.self, from: Data(output.utf8))
let types = result.types

// -- Assert --
#expect(result.technology == "swiftui")
#expect(
types.contains(
ListedType(
Expand Down Expand Up @@ -205,3 +207,13 @@ private struct Technology: Decodable, Equatable {
let identifier: String
let name: String
}

private struct SearchResult: Decodable {
let technology: String
let types: [ListedType]
let coverage: Coverage

struct Coverage: Decodable {
let isComplete: Bool
}
}
22 changes: 15 additions & 7 deletions Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import Testing

@testable import CLI
Expand Down Expand Up @@ -30,19 +31,22 @@ struct TypesSearchCommandRunnerTests {
#expect(result.unavailableCollectionCount == 1)
}

@Test("empty searches are successful JSON arrays")
@Test("empty searches are successful structured results with coverage")
func rendersEmptySearch() async throws {
// -- Arrange --
let runner = TypesSearchCommandRunner(
client: RequestedTypeSearchClient(types: []),
renderer: DefaultDocumentationTypeListRenderer(output: .json))
renderer: DefaultDocumentationSearchRenderer(output: .json))

// -- Act --
let result = try await runner.run(
query: "Button", technology: "SwiftUI", mode: .oneShot(audience: .human, format: .json))

// -- Assert --
#expect(result.output == "[\n\n]")
let json = try #require(JSONSerialization.jsonObject(with: Data(result.output.utf8)) as? [String: Any])
#expect((json["types"] as? [Any])?.isEmpty == true)
let coverage = try #require(json["coverage"] as? [String: Any])
#expect(coverage["isComplete"] as? Bool == false)
#expect(result.matchCount == 0)
#expect(result.unavailableCollectionCount == 1)
}
Expand All @@ -55,15 +59,19 @@ private struct RequestedTypeSearchClient: DocumentationTypeSearchRepository {
guard query == "Button", technology == "SwiftUI" else {
throw TypesSearchRunnerTestError.unexpectedRequest
}
return DocumentationSearchResult(types: types, unavailableCollectionPaths: ["/documentation/swiftui/styles"])
return DocumentationSearchResult(
technology: "swiftui", types: types,
unavailableCollections: [
DocumentationCollectionFailure(path: "/documentation/swiftui/styles", reason: .httpStatus(404))
])
}
}

private struct SearchTypesRenderer: DocumentationTypeListRenderer {
private struct SearchTypesRenderer: DocumentationSearchRenderer {
let expectedTypes: [DocumentationType]

func render(_ types: [DocumentationType]) throws -> String {
guard types == expectedTypes else {
func render(_ result: DocumentationSearchResult) throws -> String {
guard result.types == expectedTypes else {
throw TypesSearchRunnerTestError.unexpectedTypes
}
return "rendered matches"
Expand Down
Loading
Loading