From d1bae6c2999e83ca62641448e18ceaf30197bc76 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Sep 2026 14:01:31 +0200 Subject: [PATCH 1/3] feat(cli): Add type search Traverse technology collection groups with bounded concurrency and return matching symbol names and paths in table or JSON form. Keep queries out of telemetry and provide actionable guidance when no symbols match. --- README.md | 9 + .../AppleDocumentationClient+Error.swift | 86 ++++++ .../AppleDocumentationClient+Search.swift | 124 ++++++++ .../CLI/client/AppleDocumentationClient.swift | 105 ++----- .../DocumentationTypeSearchClient.swift | 11 + Sources/CLI/cmd/types/TypesCommand.swift | 2 +- .../CLI/cmd/types/TypesSearchCommand.swift | 71 +++++ .../cmd/types/TypesSearchCommandRunner.swift | 25 ++ Sources/CLI/skills/BundledAgentSkills.swift | 9 + .../CLI/telemetry/SentryCommandContext.swift | 12 + .../CLI/telemetry/SentryConfiguration.swift | 1 + .../AppleDocsCommandIntegrationTests.swift | 26 ++ .../AppleDocumentationClientSearchTests.swift | 281 ++++++++++++++++++ .../types/TypesSearchCommandRunnerTests.swift | 57 ++++ .../cmd/types/TypesViewCommandTests.swift | 19 ++ .../telemetry/SentryCommandContextTests.swift | 25 ++ docs/TELEMETRY.md | 4 +- 17 files changed, 787 insertions(+), 80 deletions(-) create mode 100644 Sources/CLI/client/AppleDocumentationClient+Error.swift create mode 100644 Sources/CLI/client/AppleDocumentationClient+Search.swift create mode 100644 Sources/CLI/client/DocumentationTypeSearchClient.swift create mode 100644 Sources/CLI/cmd/types/TypesSearchCommand.swift create mode 100644 Sources/CLI/cmd/types/TypesSearchCommandRunner.swift create mode 100644 Tests/CLITests/client/AppleDocumentationClientSearchTests.swift create mode 100644 Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift diff --git a/README.md b/README.md index 301834e..12f52dc 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Sources/CLI/client/AppleDocumentationClient+Error.swift b/Sources/CLI/client/AppleDocumentationClient+Error.swift new file mode 100644 index 0000000..3303264 --- /dev/null +++ b/Sources/CLI/client/AppleDocumentationClient+Error.swift @@ -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 + } +} diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift new file mode 100644 index 0000000..c04a024 --- /dev/null +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -0,0 +1,124 @@ +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 = Dictionary( + uniqueKeysWithValues: documentationTypes(in: rootPage, technology: documentationSlug).map { + ($0.path, $0) + } + ) + 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 = try 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 throws -> [TechnologyDocumentationPageDTO] { + try await withThrowingTaskGroup( + of: TechnologyDocumentationPageDTO.self, + returning: [TechnologyDocumentationPageDTO].self + ) { group in + for path in paths { + group.addTask { + try await fetchDocumentationPage(path: path) + } + } + + var pages: [TechnologyDocumentationPageDTO] = [] + for try await page in group { + 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 + } + } +} diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index eef904f..6712f07 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -9,76 +9,6 @@ import Foundation #endif struct DefaultAppleDocumentationClient: 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") @@ -168,15 +98,29 @@ struct DefaultAppleDocumentationClient [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", @@ -196,7 +140,12 @@ struct DefaultAppleDocumentationClient(_ 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 } @@ -224,7 +173,7 @@ struct DefaultAppleDocumentationClient ResolvedTechnology { + func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { let technologies = try await fetchTechnologies() guard let technology = technologies.first(where: { diff --git a/Sources/CLI/client/DocumentationTypeSearchClient.swift b/Sources/CLI/client/DocumentationTypeSearchClient.swift new file mode 100644 index 0000000..2961efa --- /dev/null +++ b/Sources/CLI/client/DocumentationTypeSearchClient.swift @@ -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 +#endif diff --git a/Sources/CLI/cmd/types/TypesCommand.swift b/Sources/CLI/cmd/types/TypesCommand.swift index 24ef78a..423be34 100644 --- a/Sources/CLI/cmd/types/TypesCommand.swift +++ b/Sources/CLI/cmd/types/TypesCommand.swift @@ -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] ) } diff --git a/Sources/CLI/cmd/types/TypesSearchCommand.swift b/Sources/CLI/cmd/types/TypesSearchCommand.swift new file mode 100644 index 0000000..4e53f01 --- /dev/null +++ b/Sources/CLI/cmd/types/TypesSearchCommand.swift @@ -0,0 +1,71 @@ +import ArgumentParser +import Logging +@preconcurrency import SentrySwift + +struct TypesSearchCommand: AsyncParsableCommand { + private static let logger = Logger( + label: "com.techprimate.apple-docs.types-search" + ) + + static let configuration = CommandConfiguration( + commandName: "search", + abstract: "Search types in an Apple documentation technology." + ) + + @Argument(help: "The type name or path to search for.") + var query: String + + @Option(help: "The framework or technology whose types to search.") + var technology: String + + @Flag(help: "Output a JSON array of matching types.") + var json = false + + mutating func run() async throws { + // Search text can be user-authored, so it is deliberately excluded from telemetry context. + let context = SentryCommandContext.typesSearch( + technology: technology, + json: json + ) + if SentrySDK.isEnabled { + let transaction = SentrySDK.startTransaction( + name: context.transactionName, + operation: "console.command", + bindToScope: true + ) + for (key, value) in context.attributes { + transaction.setData(value: value, key: key) + } + SentrySDK.configureScope { scope in + scope.setContext(value: context.attributes, key: "cli") + } + let breadcrumb = Breadcrumb( + level: .info, + category: SentryConfiguration.breadcrumbCategory + ) + breadcrumb.type = "user" + breadcrumb.message = "CLI command invoked" + for (key, value) in context.attributes { + breadcrumb.setData(value: value, key: key) + } + SentrySDK.addBreadcrumb(breadcrumb) + Self.logger.info( + "CLI command started", + metadata: context.logMetadata + ) + } + + let result = try await TypesSearchCommandRunner( + client: Dependencies.documentationClient, + renderer: Dependencies.documentationTypeListRenderer(json: json) + ).run(query: query, technology: technology) + if SentrySDK.isEnabled { + SentrySDK.metrics.distribution( + key: "apple_docs.type.search.result.count", + value: Double(result.matchCount), + attributes: context.metricAttributes + ) + } + print(result.output) + } +} diff --git a/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift new file mode 100644 index 0000000..26066e8 --- /dev/null +++ b/Sources/CLI/cmd/types/TypesSearchCommandRunner.swift @@ -0,0 +1,25 @@ +struct TypesSearchCommandRunner: Sendable { + struct Result: Sendable { + let output: String + let matchCount: Int + } + + private let client: DocumentationTypeSearchClient + private let renderer: DocumentationTypeListRenderer + + init( + client: DocumentationTypeSearchClient, + renderer: DocumentationTypeListRenderer + ) { + self.client = client + self.renderer = renderer + } + + func run(query: String, technology: String) async throws -> Result { + let types = try await client.searchTypes(query: query, technology: technology) + return Result( + output: try renderer.render(types), + matchCount: types.count + ) + } +} diff --git a/Sources/CLI/skills/BundledAgentSkills.swift b/Sources/CLI/skills/BundledAgentSkills.swift index e735ebb..12a2a33 100644 --- a/Sources/CLI/skills/BundledAgentSkills.swift +++ b/Sources/CLI/skills/BundledAgentSkills.swift @@ -56,6 +56,15 @@ enum BundledAgentSkills { Apple's root pages are curated and may link to collection pages instead of listing every API directly. + Search the root page and its recursively linked collection groups by symbol name or path: + + ```bash + apple-docs types search Button --technology SwiftUI + ``` + + Search does not crawl individual symbol pages. Collection pages may directly expose some nested members, but + search is not an exhaustive nested-member index. + ## Retrieve raw DocC JSON Use `--json` when structured data is needed or when the text renderer omits a field from Apple's response. diff --git a/Sources/CLI/telemetry/SentryCommandContext.swift b/Sources/CLI/telemetry/SentryCommandContext.swift index 781818d..c9ac6d1 100644 --- a/Sources/CLI/telemetry/SentryCommandContext.swift +++ b/Sources/CLI/telemetry/SentryCommandContext.swift @@ -41,6 +41,18 @@ struct SentryCommandContext: Equatable, Sendable { ) } + static func typesSearch( + technology: String, + json: Bool + ) -> SentryCommandContext { + SentryCommandContext( + command: "types.search", + outputJSON: json, + technology: technology, + typeName: nil + ) + } + static func typesView( name: String, technology: String, diff --git a/Sources/CLI/telemetry/SentryConfiguration.swift b/Sources/CLI/telemetry/SentryConfiguration.swift index ddc0f43..cf706f8 100644 --- a/Sources/CLI/telemetry/SentryConfiguration.swift +++ b/Sources/CLI/telemetry/SentryConfiguration.swift @@ -48,6 +48,7 @@ struct SentryConfiguration { "apple_docs.response.size", "apple_docs.technology.catalog.count", "apple_docs.type.catalog.count", + "apple_docs.type.search.result.count", "apple_docs.technology.requested", "apple_docs.type.requested", ] diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index 5268df1..f8b5065 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -75,6 +75,32 @@ struct AppleDocsCommandIntegrationTests { ) } + @Test("searches SwiftUI collection groups as JSON") + func searchesSwiftUITypes() throws { + // -- Arrange -- + let arguments = [ + "types", "search", "Button", + "--technology", "SwiftUI", + "--json", + ] + + // -- Act -- + let output = try runAppleDocs(arguments) + let types = try JSONDecoder().decode([ListedType].self, from: Data(output.utf8)) + + // -- Assert -- + #expect( + types.contains( + ListedType( + kind: "struct", + name: "Button", + path: "button", + url: "https://developer.apple.com/documentation/swiftui/button" + ) + ) + ) + } + @Test("resolves a dotted nested type as JSON") func resolvesDottedNestedType() throws { // -- Arrange -- diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift new file mode 100644 index 0000000..22002c3 --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -0,0 +1,281 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Apple documentation type search client") +struct AppleDocumentationClientSearchTests { + @Test("searches symbols across nested collection groups") + func searchesCollectionGroups() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") + ) + let controlsURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json" + ) + ) + let stylesURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" + ) + ) + let client = DefaultAppleDocumentationClient( + dependencies: SearchTestTransport( + responses: [ + rootURL: rootSearchPage, + controlsURL: controlsSearchPage, + stylesURL: stylesSearchPage, + ] + ) + ) + + // -- Act -- + let types = try await client.searchTypes(query: "button", technology: "SwiftUI") + + // -- Assert -- + #expect(types.map(\.name) == ["Button", "ButtonStyle"]) + #expect(types.map(\.path) == ["button", "buttonstyle"]) + } + + @Test("maps technology display names to DocC slugs") + func mapsTechnologyDisplayName() async throws { + // -- Arrange -- + let requestedRootURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/apple%20cryptokit.json" + ) + ) + let technologiesURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/technologies.json" + ) + ) + let resolvedRootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: SearchFallbackTransport( + responses: [ + requestedRootURL: .init(statusCode: 404, data: Data()), + technologiesURL: .init(statusCode: 200, data: cryptoKitCatalogData), + resolvedRootURL: .init(statusCode: 200, data: cryptoKitRootData), + ] + ) + ) + + // -- Act -- + let types = try await client.searchTypes(query: "AES", technology: "Apple CryptoKit") + + // -- Assert -- + #expect(types.map(\.name) == ["AES"]) + #expect(types.map(\.path) == ["aes"]) + } + + @Test("maps an empty search to discovery guidance") + func mapsEmptySearchToDiscoveryGuidance() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") + ) + let controlsURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json" + ) + ) + let stylesURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" + ) + ) + let client = DefaultAppleDocumentationClient( + dependencies: SearchTestTransport( + responses: [ + rootURL: rootSearchPage, + controlsURL: controlsSearchPage, + stylesURL: stylesSearchPage, + ] + ) + ) + + // -- Act -- + do { + _ = try await client.searchTypes(query: "Picker", technology: "SwiftUI") + Issue.record("Expected the search to fail") + } catch { + // -- Assert -- + #expect( + error.localizedDescription == """ + No types matching 'Picker' found in SwiftUI. + + Browse available types: + apple-docs types list --technology "SwiftUI" + https://developer.apple.com/documentation/swiftui + """ + ) + } + } +} + +private let rootSearchPage = Data( + """ + { + "references": { + "doc://view": { + "fragments": [{"kind": "keyword", "text": "protocol"}], + "kind": "symbol", + "role": "symbol", + "title": "View", + "url": "/documentation/swiftui/view" + }, + "doc://controls": { + "kind": "article", + "role": "collectionGroup", + "title": "Controls", + "url": "/documentation/swiftui/controls" + }, + "doc://other-framework": { + "kind": "article", + "role": "collectionGroup", + "title": "UIKit controls", + "url": "/documentation/uikit/controls" + } + } + } + """.utf8 +) + +private let controlsSearchPage = Data( + """ + { + "references": { + "doc://button": { + "fragments": [{"kind": "keyword", "text": "struct"}], + "kind": "symbol", + "role": "symbol", + "title": "Button", + "url": "/documentation/swiftui/button" + }, + "doc://text": { + "fragments": [{"kind": "keyword", "text": "struct"}], + "kind": "symbol", + "role": "symbol", + "title": "Text", + "url": "/documentation/swiftui/text" + }, + "doc://styles": { + "kind": "article", + "role": "collectionGroup", + "title": "Styles", + "url": "/documentation/swiftui/styles" + } + } + } + """.utf8 +) + +private let stylesSearchPage = Data( + """ + { + "references": { + "doc://button-duplicate": { + "fragments": [{"kind": "keyword", "text": "struct"}], + "kind": "symbol", + "role": "symbol", + "title": "Button", + "url": "/documentation/swiftui/button" + }, + "doc://button-style": { + "fragments": [{"kind": "keyword", "text": "protocol"}], + "kind": "symbol", + "role": "symbol", + "title": "ButtonStyle", + "url": "/documentation/swiftui/buttonstyle" + } + } + } + """.utf8 +) + +private struct SearchTestTransport: HTTPDataTransport { + let responses: [URL: Data] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let data = responses[url] else { + throw SearchTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (data, response) + } +} + +private let cryptoKitCatalogData = Data( + """ + { + "sections": [{ + "groups": [{ + "technologies": [{ + "destination": { + "identifier": "doc://com.apple.documentation/documentation/CryptoKit" + }, + "title": "Apple CryptoKit" + }] + }] + }] + } + """.utf8 +) + +private let cryptoKitRootData = Data( + """ + { + "references": { + "doc://aes": { + "fragments": [{"kind": "keyword", "text": "enum"}], + "kind": "symbol", + "role": "symbol", + "title": "AES", + "url": "/documentation/cryptokit/aes" + } + } + } + """.utf8 +) + +private struct SearchFallbackTransport: HTTPDataTransport { + struct Response: Sendable { + let statusCode: Int + let data: Data + } + + let responses: [URL: Response] + + func data(from url: URL) async throws -> (Data, URLResponse) { + guard let result = responses[url] else { + throw SearchTestError.unexpectedURL(url) + } + let response = HTTPURLResponse( + url: url, + statusCode: result.statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (result.data, response) + } +} + +private enum SearchTestError: Error { + case unexpectedURL(URL) +} diff --git a/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift new file mode 100644 index 0000000..f03ce7b --- /dev/null +++ b/Tests/CLITests/cmd/types/TypesSearchCommandRunnerTests.swift @@ -0,0 +1,57 @@ +import Testing + +@testable import CLI + +@Suite("Types search command runner") +struct TypesSearchCommandRunnerTests { + @Test("searches and renders types for the requested technology") + func searchesAndRendersTypes() async throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Button", + kind: "struct", + path: "button", + url: "https://developer.apple.com/documentation/swiftui/button" + ) + ] + let runner = TypesSearchCommandRunner( + client: RequestedTypeSearchClient(types: types), + renderer: SearchTypesRenderer(expectedTypes: types) + ) + + // -- Act -- + let result = try await runner.run(query: "Button", technology: "SwiftUI") + + // -- Assert -- + #expect(result.output == "rendered matches") + #expect(result.matchCount == 1) + } +} + +private struct RequestedTypeSearchClient: DocumentationTypeSearchClient { + let types: [DocumentationType] + + func searchTypes(query: String, technology: String) async throws -> [DocumentationType] { + guard query == "Button", technology == "SwiftUI" else { + throw TypesSearchRunnerTestError.unexpectedRequest + } + return types + } +} + +private struct SearchTypesRenderer: DocumentationTypeListRenderer { + let expectedTypes: [DocumentationType] + + func render(_ types: [DocumentationType]) throws -> String { + guard types == expectedTypes else { + throw TypesSearchRunnerTestError.unexpectedTypes + } + return "rendered matches" + } +} + +private enum TypesSearchRunnerTestError: Error { + case unexpectedRequest + case unexpectedTypes +} diff --git a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift index 9400f7e..e6e6a48 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandTests.swift @@ -22,6 +22,25 @@ struct TypesViewCommandTests { #expect(listCommand.json) } + @Test("accepts the types search command with a query and required technology") + func parsesTypesSearch() throws { + // -- Arrange -- + let arguments = [ + "types", "search", "Button", + "--technology", "SwiftUI", + "--json", + ] + + // -- Act -- + let command = try CLI.parseAsRoot(arguments) + + // -- Assert -- + let searchCommand = try #require(command as? TypesSearchCommand) + #expect(searchCommand.query == "Button") + #expect(searchCommand.technology == "SwiftUI") + #expect(searchCommand.json) + } + @Test("accepts a type name and required technology option") func parsesTypeNameAndTechnology() throws { // -- Arrange -- diff --git a/Tests/CLITests/telemetry/SentryCommandContextTests.swift b/Tests/CLITests/telemetry/SentryCommandContextTests.swift index 68df12d..610faba 100644 --- a/Tests/CLITests/telemetry/SentryCommandContextTests.swift +++ b/Tests/CLITests/telemetry/SentryCommandContextTests.swift @@ -4,6 +4,31 @@ import Testing @Suite("Sentry command context") struct SentryCommandContextTests { + @Test("excludes the query from types search telemetry") + func excludesTypesSearchQuery() { + // -- Arrange -- + let expectedKeys = [ + "apple_docs.technology", + "cli.command", + "cli.output_json", + ] + + // -- Act -- + let context = SentryCommandContext.typesSearch( + technology: "SwiftUI", + json: true + ) + + // -- Assert -- + #expect(context.command == "types.search") + #expect(context.typeName == nil) + #expect(context.technology == "SwiftUI") + #expect(context.outputJSON == true) + #expect(context.attributes.keys.sorted() == expectedKeys) + #expect(context.metricAttributes.keys.sorted() == expectedKeys.dropLast()) + #expect(context.logMetadata.keys.sorted() == expectedKeys) + } + @Test("opts documentation identifiers into types view telemetry") func includesTypesViewIdentifiers() { // -- Arrange -- diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 29a68e7..7c35538 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -31,6 +31,7 @@ Each leaf command starts a `console.command` transaction and opts in to a fixed | ------------------- | ------------------------------------------------------------------ | | `types view` | Command name, documentation type, technology, and JSON output mode | | `types list` | Command name, technology, and JSON output mode | +| `types search` | Command name, technology, and JSON output mode | | `technologies list` | Command name and JSON output mode | | `agent skills list` | Command name | | `agent skills get` | Command name | @@ -58,6 +59,7 @@ The CLI records: - `apple_docs.response.size` to monitor Apple documentation response sizes. - `apple_docs.technology.catalog.count` to monitor the size of the technology catalog. - `apple_docs.type.catalog.count` to monitor direct type counts in technology root documents. +- `apple_docs.type.search.result.count` to monitor the number of search matches. Metric names and attributes are allowlisted. Type and technology are the only variable popularity dimensions. @@ -65,7 +67,7 @@ Metric names and attributes are allowlisted. Type and technology are the only va The CLI does not intentionally send: -- Raw process arguments. +- Raw process arguments or type search queries. - Environment variables. - User identity or account information. - IP addresses or geographic location. From 6540aa0e0e5d40f106bd9db961e9fb2974bd1d6a Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Sep 2026 16:56:17 +0200 Subject: [PATCH 2/3] fix(cli): Skip unavailable search collection groups --- .../AppleDocumentationClient+Search.swift | 20 ++++--- .../AppleDocumentationClientSearchTests.swift | 57 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift index c04a024..305192c 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -54,7 +54,7 @@ extension DefaultAppleDocumentationClient { while !pendingPaths.isEmpty { let batch = Array(pendingPaths.prefix(6)) pendingPaths.removeFirst(batch.count) - let pages = try await fetchDocumentationPages(paths: batch) + let pages = await fetchDocumentationPages(paths: batch) for page in pages { for type in documentationTypes(in: page, technology: documentationSlug) { @@ -86,20 +86,26 @@ extension DefaultAppleDocumentationClient { private func fetchDocumentationPages( paths: [String] - ) async throws -> [TechnologyDocumentationPageDTO] { - try await withThrowingTaskGroup( - of: TechnologyDocumentationPageDTO.self, + ) async -> [TechnologyDocumentationPageDTO] { + await withTaskGroup( + of: TechnologyDocumentationPageDTO?.self, returning: [TechnologyDocumentationPageDTO].self ) { group in for path in paths { group.addTask { - try await fetchDocumentationPage(path: path) + do { + return try await fetchDocumentationPage(path: path) + } catch { + return nil + } } } var pages: [TechnologyDocumentationPageDTO] = [] - for try await page in group { - pages.append(page) + for await page in group { + if let page { + pages.append(page) + } } return pages } diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift index 22002c3..d9cb968 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -41,6 +41,42 @@ struct AppleDocumentationClientSearchTests { #expect(types.map(\.path) == ["button", "buttonstyle"]) } + @Test("continues searching when a collection group is unavailable") + func skipsUnavailableCollectionGroup() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") + ) + let controlsURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/controls.json" + ) + ) + let stylesURL = try #require( + URL( + string: + "https://developer.apple.com/tutorials/data/documentation/swiftui/styles.json" + ) + ) + let client = DefaultAppleDocumentationClient( + dependencies: SearchFallbackTransport( + responses: [ + rootURL: .init(statusCode: 200, data: partialFailureRootSearchPage), + controlsURL: .init(statusCode: 404, data: Data()), + stylesURL: .init(statusCode: 200, data: stylesSearchPage), + ] + ) + ) + + // -- Act -- + let types = try await client.searchTypes(query: "buttonstyle", technology: "SwiftUI") + + // -- Assert -- + #expect(types.map(\.name) == ["ButtonStyle"]) + #expect(types.map(\.path) == ["buttonstyle"]) + } + @Test("maps technology display names to DocC slugs") func mapsTechnologyDisplayName() async throws { // -- Arrange -- @@ -152,6 +188,27 @@ private let rootSearchPage = Data( """.utf8 ) +private let partialFailureRootSearchPage = Data( + """ + { + "references": { + "doc://controls": { + "kind": "article", + "role": "collectionGroup", + "title": "Controls", + "url": "/documentation/swiftui/controls" + }, + "doc://styles": { + "kind": "article", + "role": "collectionGroup", + "title": "Styles", + "url": "/documentation/swiftui/styles" + } + } + } + """.utf8 +) + private let controlsSearchPage = Data( """ { From 4e7f7d7cfa4a3d5e9a7966369091b68ceeae43d4 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Sep 2026 17:12:50 +0200 Subject: [PATCH 3/3] fix(cli): Deduplicate root search types --- .../AppleDocumentationClient+Search.swift | 9 ++-- .../AppleDocumentationClientSearchTests.swift | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/Sources/CLI/client/AppleDocumentationClient+Search.swift b/Sources/CLI/client/AppleDocumentationClient+Search.swift index 305192c..2bee16d 100644 --- a/Sources/CLI/client/AppleDocumentationClient+Search.swift +++ b/Sources/CLI/client/AppleDocumentationClient+Search.swift @@ -39,11 +39,10 @@ extension DefaultAppleDocumentationClient { rootPage: TechnologyDocumentationPageDTO ) async throws -> [DocumentationType] { let rootPath = "/documentation/\(documentationSlug.lowercased())" - var typesByPath = Dictionary( - uniqueKeysWithValues: documentationTypes(in: rootPage, technology: documentationSlug).map { - ($0.path, $0) - } - ) + 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 diff --git a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift index d9cb968..ac07a9c 100644 --- a/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift +++ b/Tests/CLITests/client/AppleDocumentationClientSearchTests.swift @@ -41,6 +41,26 @@ struct AppleDocumentationClientSearchTests { #expect(types.map(\.path) == ["button", "buttonstyle"]) } + @Test("deduplicates root symbols with the same path") + func deduplicatesRootSymbols() async throws { + // -- Arrange -- + let rootURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swiftui.json") + ) + let client = DefaultAppleDocumentationClient( + dependencies: SearchTestTransport( + responses: [rootURL: duplicateRootSearchPage] + ) + ) + + // -- Act -- + let types = try await client.searchTypes(query: "button", technology: "SwiftUI") + + // -- Assert -- + #expect(types.map(\.name) == ["Button"]) + #expect(types.map(\.path) == ["button"]) + } + @Test("continues searching when a collection group is unavailable") func skipsUnavailableCollectionGroup() async throws { // -- Arrange -- @@ -188,6 +208,29 @@ private let rootSearchPage = Data( """.utf8 ) +private let duplicateRootSearchPage = Data( + """ + { + "references": { + "doc://button": { + "fragments": [{"kind": "keyword", "text": "struct"}], + "kind": "symbol", + "role": "symbol", + "title": "Button", + "url": "/documentation/swiftui/button" + }, + "doc://button-duplicate": { + "fragments": [{"kind": "keyword", "text": "struct"}], + "kind": "symbol", + "role": "symbol", + "title": "Button", + "url": "/documentation/swiftui/button" + } + } + } + """.utf8 +) + private let partialFailureRootSearchPage = Data( """ {