From cbc30ae0929a636cf041d80b578f6e7f1e6333eb Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Thu, 24 Sep 2026 20:15:10 +0200 Subject: [PATCH] feat(docs): Normalize documentation and enrich text rendering Resolve documentation links and exact fetch destinations while retaining raw response data. Render sparse pages, all supplied declarations, availability qualifiers, and safe reference text without terminal UI dependencies. --- README.md | 6 +- .../client/AppleDocumentationClient+DTO.swift | 35 +++- .../CLI/client/AppleDocumentationClient.swift | 71 ++++---- .../client/TypeDocumentationDocument.swift | 1 + .../DocumentationDestination.swift | 79 +++++++++ .../CLI/documentation/DocumentationPage.swift | 63 +++++++ .../DocumentationPageDecoder.swift | 90 ++++++++++ .../DefaultTypeDocumentationRenderer.swift | 4 +- .../DocumentationContentRenderer.swift | 98 ++++++----- .../TextTypeDocumentationRenderer.swift | 144 ++++------------ .../AppleDocumentationClientPageTests.swift | 63 +++++++ .../types/TypesViewCommandRunnerTests.swift | 3 +- .../DocumentationDestinationTests.swift | 115 ++++++++++++ .../documentation/DocumentationFixtures.swift | 56 ++++++ .../DocumentationPageDecoderTests.swift | 163 ++++++++++++++++++ ...efaultTypeDocumentationRendererTests.swift | 34 +++- ...alizedTextDocumentationRendererTests.swift | 86 +++++++++ ...ypeDocumentationRendererContentTests.swift | 21 ++- ...eDocumentationRendererReferenceTests.swift | 8 +- .../TextTypeDocumentationRendererTests.swift | 42 +++-- 20 files changed, 977 insertions(+), 205 deletions(-) create mode 100644 Sources/CLI/documentation/DocumentationDestination.swift create mode 100644 Sources/CLI/documentation/DocumentationPage.swift create mode 100644 Sources/CLI/documentation/DocumentationPageDecoder.swift create mode 100644 Tests/CLITests/client/AppleDocumentationClientPageTests.swift create mode 100644 Tests/CLITests/documentation/DocumentationDestinationTests.swift create mode 100644 Tests/CLITests/documentation/DocumentationFixtures.swift create mode 100644 Tests/CLITests/documentation/DocumentationPageDecoderTests.swift create mode 100644 Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift diff --git a/README.md b/README.md index 11766c0..5ddf990 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,14 @@ apple-docs types view MXHangDiagnostic --technology MetricKit The terminal output includes available information such as: -- Summary and declaration -- Platform availability and deprecation +- Summary and declarations in the languages supplied by Apple +- Platform availability, deprecation, obsoleted versions, beta status, and unavailability - Inheritance and protocol conformances - Documented members and related APIs - Canonical Apple Developer URL +Text rendering also supports sparse article and collection pages, resolves reference links, and strips remote terminal control characters. It uses normalized documentation content, which does not cover every upstream DocC field. Use `--json` when you need the original document. + Every `types` command requires `--technology`. The CLI does not persist a selected framework. Nested symbols accept either dotted Swift spelling or slash-separated DocC paths: ```bash diff --git a/Sources/CLI/client/AppleDocumentationClient+DTO.swift b/Sources/CLI/client/AppleDocumentationClient+DTO.swift index e5dbf44..6c7626d 100644 --- a/Sources/CLI/client/AppleDocumentationClient+DTO.swift +++ b/Sources/CLI/client/AppleDocumentationClient+DTO.swift @@ -8,6 +8,32 @@ struct TypeDocumentationPageDTO: Decodable, Sendable { let seeAlsoSections: [DocumentationReferenceSectionDTO]? let topicSections: [DocumentationReferenceSectionDTO]? let variants: [DocumentationVariantDTO]? + let kind: String? + + private enum CodingKeys: CodingKey { + case abstract, deprecationSummary, metadata, primaryContentSections, references + case relationshipsSections, seeAlsoSections, topicSections, variants, kind + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + metadata = try container.decode(DocumentationMetadataDTO.self, forKey: .metadata) + abstract = try container.decodeIfPresent([DocumentationTextDTO].self, forKey: .abstract) ?? [] + deprecationSummary = try container.decodeIfPresent([DocumentationBlockDTO].self, forKey: .deprecationSummary) + primaryContentSections = + try container.decodeIfPresent( + [DocumentationContentSectionDTO].self, forKey: .primaryContentSections + ) ?? [] + references = try container.decodeIfPresent([String: DocumentationReferenceDTO].self, forKey: .references) ?? [:] + relationshipsSections = try container.decodeIfPresent( + [DocumentationReferenceSectionDTO].self, forKey: .relationshipsSections + ) + seeAlsoSections = try container.decodeIfPresent( + [DocumentationReferenceSectionDTO].self, forKey: .seeAlsoSections) + topicSections = try container.decodeIfPresent([DocumentationReferenceSectionDTO].self, forKey: .topicSections) + variants = try container.decodeIfPresent([DocumentationVariantDTO].self, forKey: .variants) + kind = try container.decodeIfPresent(String.self, forKey: .kind) + } } struct DocumentationVariantDTO: Decodable, Sendable { @@ -18,6 +44,7 @@ struct DocumentationTextDTO: Decodable, Sendable { let code: String? let identifier: String? let inlineContent: [DocumentationTextDTO]? + let overridingTitle: String? let text: String? } @@ -111,6 +138,7 @@ struct DocumentationMetadataDTO: Decodable, Sendable { let modules: [DocumentationModule] let platforms: [DocumentationPlatform] let roleHeading: String + let role: String? let symbolKind: String? let title: String @@ -118,6 +146,7 @@ struct DocumentationMetadataDTO: Decodable, Sendable { case modules case platforms case roleHeading + case role case symbolKind case title } @@ -126,7 +155,8 @@ struct DocumentationMetadataDTO: Decodable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) modules = try container.decodeIfPresent([DocumentationModule].self, forKey: .modules) ?? [] platforms = try container.decodeIfPresent([DocumentationPlatform].self, forKey: .platforms) ?? [] - roleHeading = try container.decode(String.self, forKey: .roleHeading) + role = try container.decodeIfPresent(String.self, forKey: .role) + roleHeading = try container.decodeIfPresent(String.self, forKey: .roleHeading) ?? "" symbolKind = try container.decodeIfPresent(String.self, forKey: .symbolKind) title = try container.decode(String.self, forKey: .title) } @@ -140,4 +170,7 @@ struct DocumentationPlatform: Decodable, Sendable { let deprecatedAt: String? let introducedAt: String? let name: String + let obsoletedAt: String? + let beta: Bool? + let unavailable: Bool? } diff --git a/Sources/CLI/client/AppleDocumentationClient.swift b/Sources/CLI/client/AppleDocumentationClient.swift index acdf83c..ec1a6e3 100644 --- a/Sources/CLI/client/AppleDocumentationClient.swift +++ b/Sources/CLI/client/AppleDocumentationClient.swift @@ -46,9 +46,7 @@ struct DefaultAppleDocumentationClient TechnologyDocumentationPageDTO { + func fetchDocument(at destination: DocumentationDestination) async throws -> TypeDocumentationDocument { + TypeDocumentationDocument( + data: try await fetchDocumentationData(path: destination.path), destination: destination + ) + } + + func fetchRootDocument(technology: String) async throws -> TypeDocumentationDocument { + try await fetchDocumentationRoot(technology: technology).document + } + + private func fetchDocumentationData(path: String) async throws -> Data { logger.debug("Fetching documentation page", metadata: ["path": .string(path)]) var url = baseURL for component in path.split(separator: "/") { url.append(component: component) } url.appendPathExtension("json") - let data = try await fetchData(from: url) + return try await fetchData(from: url) + } + + func fetchDocumentationPage(path: String) async throws -> TechnologyDocumentationPageDTO { + try decodeDiscoveryPage(try await fetchDocumentationData(path: path), path: path) + } + + private func decodeDiscoveryPage(_ data: Data, path: String) throws -> TechnologyDocumentationPageDTO { do { let page = try JSONDecoder().decode(TechnologyDocumentationPageDTO.self, from: data) logger.trace( "Decoded documentation page", metadata: ["references": .stringConvertible(page.references.count)]) return page } catch { - logger.error("Failed to decode documentation page", metadata: ["path": .string(url.path)]) + logger.error("Failed to decode documentation page", metadata: ["path": .string(path)]) throw error } } @@ -199,6 +212,7 @@ struct DefaultAppleDocumentationClient DocumentationRoot { do { - return DocumentationRoot( - page: try await fetchDocumentationPage(path: "/documentation/\(technology.lowercased())"), - slug: technology, - name: technology, + return try await loadRoot( + slug: technology, name: technology, url: "https://developer.apple.com/documentation/\(technology.lowercased())" ) } catch Error.httpStatus(404) { @@ -226,12 +238,7 @@ extension DefaultAppleDocumentationClient { } logger.debug("Retrying documentation root with canonical technology", metadata: ["slug": .string(slug)]) do { - return DocumentationRoot( - page: try await fetchDocumentationPage(path: "/documentation/\(slug.lowercased())"), - slug: slug, - name: resolved.name, - url: resolved.url - ) + return try await loadRoot(slug: slug, name: resolved.name, url: resolved.url) } catch Error.httpStatus(404) { logger.notice("Canonical documentation root unavailable", metadata: ["slug": .string(slug)]) throw Error.unsupportedTechnology(name: resolved.name, url: resolved.url) @@ -239,6 +246,17 @@ extension DefaultAppleDocumentationClient { } } + private func loadRoot(slug: String, name: String, url: String) async throws -> DocumentationRoot { + let destination = DocumentationDestination( + technology: slug.lowercased(), path: "/documentation/\(slug.lowercased())" + ) + let document = try await fetchDocument(at: destination) + return DocumentationRoot( + page: try decodeDiscoveryPage(document.data, path: destination.path), document: document, + slug: slug, name: name, url: url + ) + } + private func fetchData(from url: URL) async throws -> Data { let started = ContinuousClock.now logger.debug("Requesting documentation data", metadata: ["path": .string(url.path)]) @@ -282,16 +300,11 @@ extension DefaultAppleDocumentationClient { return data } - private func typeURL(name: String, technology: String) -> URL { - var url = baseURL.appending(component: "documentation") - .appending(component: technology.lowercased()) - // DocC uses path components for nested symbols while Swift spelling uses dots. - for component in name.replacingOccurrences(of: ".", with: "/").split(separator: "/") { - url.append(component: component.lowercased()) - } - url.appendPathExtension("json") - logger.trace("Resolved type documentation path", metadata: ["path": .string(url.path)]) - return url + private func typeDestination(name: String, technology: String) -> DocumentationDestination { + // Only CLI Swift names use dots as hierarchy separators. Resolved links retain their exact paths. + let components = name.replacingOccurrences(of: ".", with: "/").split(separator: "/") + let path = "/documentation/\(technology.lowercased())/" + components.joined(separator: "/").lowercased() + return DocumentationDestination(technology: technology.lowercased(), path: path) } func resolveTechnology(named requestedName: String) async throws -> ResolvedTechnology { diff --git a/Sources/CLI/client/TypeDocumentationDocument.swift b/Sources/CLI/client/TypeDocumentationDocument.swift index 68eab9f..0b7d972 100644 --- a/Sources/CLI/client/TypeDocumentationDocument.swift +++ b/Sources/CLI/client/TypeDocumentationDocument.swift @@ -2,4 +2,5 @@ import Foundation struct TypeDocumentationDocument: Sendable { let data: Data + let destination: DocumentationDestination } diff --git a/Sources/CLI/documentation/DocumentationDestination.swift b/Sources/CLI/documentation/DocumentationDestination.swift new file mode 100644 index 0000000..6c6b5d2 --- /dev/null +++ b/Sources/CLI/documentation/DocumentationDestination.swift @@ -0,0 +1,79 @@ +import Foundation + +struct DocumentationDestination: Hashable, Sendable { + let technology: String + let path: String + var fragment: String? + + init(technology: String, path: String, fragment: String? = nil) { + self.technology = technology + self.path = path + self.fragment = fragment + } + + var url: URL { + var components = URLComponents() + components.scheme = "https" + components.host = "developer.apple.com" + components.path = path + components.fragment = fragment + // Internal destinations have already passed boundary validation. + return components.url! + } + + static func resolve( + _ raw: String, relativeTo current: DocumentationDestination + ) throws -> DocumentationLinkTarget { + guard !raw.isEmpty, !raw.hasPrefix("//") else { throw DestinationError.invalidURL } + try validate(raw) + guard let input = URLComponents(string: raw), input.user == nil, input.password == nil else { + throw DestinationError.invalidURL + } + if input.scheme?.lowercased() == "doc" { return .unavailable(raw) } + if let scheme = input.scheme, !["https", "http"].contains(scheme.lowercased()) { + throw DestinationError.invalidURL + } + guard let url = URL(string: raw, relativeTo: current.url)?.absoluteURL, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let host = components.host, !host.isEmpty + else { throw DestinationError.invalidURL } + + guard components.scheme?.lowercased() == "https", host.lowercased() == "developer.apple.com" else { + return .external(url) + } + guard components.port == nil || components.port == 443 else { throw DestinationError.invalidURL } + let parts = components.path.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count >= 3, parts[1] == "documentation", !parts[2].isEmpty else { + return .external(url) + } + guard components.query == nil else { throw DestinationError.invalidURL } + return .documentation( + DocumentationDestination( + technology: parts[2].lowercased(), path: components.path, fragment: components.fragment + )) + } + + private static func validate(_ raw: String) throws { + var value = raw + // Check each decoding layer before URL resolution can erase traversal components. + while true { + guard !value.contains("\\"), value.rangeOfCharacter(from: .controlCharacters) == nil, + let components = URLComponents(string: value), + !components.path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) + else { throw DestinationError.invalidURL } + guard let decoded = value.removingPercentEncoding else { throw DestinationError.invalidURL } + if decoded == value { return } + value = decoded + } + } + + enum DestinationError: Error { + case invalidURL + } +} + +enum DocumentationLinkTarget: Equatable, Sendable { + case documentation(DocumentationDestination) + case external(URL) + case unavailable(String) +} diff --git a/Sources/CLI/documentation/DocumentationPage.swift b/Sources/CLI/documentation/DocumentationPage.swift new file mode 100644 index 0000000..154fe71 --- /dev/null +++ b/Sources/CLI/documentation/DocumentationPage.swift @@ -0,0 +1,63 @@ +import Foundation + +struct DocumentationPage: Equatable, Sendable { + let destination: DocumentationDestination + let title: String + let kind: String + var symbolKind: String? + var roleHeading: String? + var modules: [String] = [] + var abstract: [DocumentationInline] = [] + var deprecation: [DocumentationBlock] = [] + var declarations: [DocumentationDeclaration] = [] + var availability: [DocumentationAvailability] = [] + var content: [DocumentationBlock] = [] + var relationships: [DocumentationGroup] = [] + var topics: [DocumentationGroup] = [] + var seeAlso: [DocumentationGroup] = [] + + var url: URL { destination.url } +} + +indirect enum DocumentationInline: Equatable, Sendable { + case text(String) + case code(String) + case link(label: [DocumentationInline], target: DocumentationLinkTarget) +} + +indirect enum DocumentationBlock: Equatable, Sendable { + case paragraph([DocumentationInline]) + case heading(String) + case codeListing(code: [String], syntax: String?) + case orderedList(items: [[DocumentationBlock]], startIndex: Int) + case unorderedList([[DocumentationBlock]]) + case aside(content: [DocumentationBlock], style: String, name: String?) +} + +struct DocumentationDeclaration: Equatable, Sendable { + let languages: [String] + let text: String +} + +struct DocumentationAvailability: Equatable, Sendable { + let name: String + let introducedAt: String? + let deprecatedAt: String? + var obsoletedAt: String? + var isBeta = false + var isUnavailable = false +} + +struct DocumentationReference: Equatable, Sendable { + let id: String + let title: String + let kind: String + var abstract: [DocumentationInline] = [] + let target: DocumentationLinkTarget +} + +struct DocumentationGroup: Equatable, Sendable { + let id: String + let title: String + let references: [DocumentationReference] +} diff --git a/Sources/CLI/documentation/DocumentationPageDecoder.swift b/Sources/CLI/documentation/DocumentationPageDecoder.swift new file mode 100644 index 0000000..219e335 --- /dev/null +++ b/Sources/CLI/documentation/DocumentationPageDecoder.swift @@ -0,0 +1,90 @@ +import Foundation + +struct DocumentationPageDecoder: Sendable { + func decode(_ data: Data, destination: DocumentationDestination) throws -> DocumentationPage { + let document = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let kind = [document.metadata.symbolKind ?? "", document.metadata.role ?? "", document.kind ?? ""] + .first { !$0.isEmpty } + guard !document.metadata.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let kind else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing page title or kind")) + } + let context = Context(references: document.references, destination: destination) + return DocumentationPage( + destination: destination, + title: document.metadata.title, + kind: kind, + symbolKind: document.metadata.symbolKind, + roleHeading: document.metadata.roleHeading.isEmpty ? nil : document.metadata.roleHeading, + modules: document.metadata.modules.map(\.name), + abstract: context.inline(document.abstract), + deprecation: context.blocks(document.deprecationSummary ?? []), + declarations: document.primaryContentSections.flatMap { $0.declarations ?? [] }.map { + DocumentationDeclaration(languages: $0.languages, text: $0.tokens.map(\.text).joined()) + }, + availability: document.metadata.platforms.map { + DocumentationAvailability( + name: $0.name, introducedAt: $0.introducedAt, deprecatedAt: $0.deprecatedAt, + obsoletedAt: $0.obsoletedAt, isBeta: $0.beta ?? false, isUnavailable: $0.unavailable ?? false + ) + }, + content: context.blocks(document.primaryContentSections.flatMap { $0.content ?? [] }), + relationships: context.groups(document.relationshipsSections ?? [], section: "relationships"), + topics: context.groups(document.topicSections ?? [], section: "topics"), + seeAlso: context.groups(document.seeAlsoSections ?? [], section: "seeAlso") + ) + } + + private struct Context { + let references: [String: DocumentationReferenceDTO] + let destination: DocumentationDestination + + func inline(_ content: [DocumentationTextDTO]) -> [DocumentationInline] { + content.flatMap { item -> [DocumentationInline] in + if let identifier = item.identifier { + let label = item.overridingTitle ?? item.text ?? references[identifier]?.title ?? identifier + return [.link(label: [.text(label)], target: target(identifier))] + } + if let code = item.code { return [.code(code)] } + if let text = item.text { return [.text(text)] } + return inline(item.inlineContent ?? []) + } + } + + func blocks(_ content: [DocumentationBlockDTO]) -> [DocumentationBlock] { + content.compactMap { block in + switch block { + case .paragraph(let content): return .paragraph(inline(content)) + case .heading(let text): return .heading(text) + case .codeListing(let code, let syntax): return .codeListing(code: code, syntax: syntax) + case .orderedList(let items, let startIndex): + return .orderedList(items: items.map { blocks($0.content) }, startIndex: startIndex) + case .unorderedList(let items): return .unorderedList(items.map { blocks($0.content) }) + case .aside(let content, let style, let name): + return .aside(content: blocks(content), style: style, name: name) + case .unsupported: return nil + } + } + } + + func groups(_ groups: [DocumentationReferenceSectionDTO], section: String) -> [DocumentationGroup] { + groups.enumerated().map { index, group in + DocumentationGroup( + id: "\(section)/\(index)", title: group.title, + references: group.identifiers.compactMap { identifier in + guard let reference = references[identifier], let title = reference.title else { return nil } + return DocumentationReference( + id: identifier, title: title, kind: reference.kind ?? reference.role ?? "", + abstract: inline(reference.abstract ?? []), target: target(identifier) + ) + } + ) + } + } + + private func target(_ identifier: String) -> DocumentationLinkTarget { + // DocC identifiers are meaningful only in this page's reference dictionary. + let raw = references[identifier]?.url ?? identifier + return (try? DocumentationDestination.resolve(raw, relativeTo: destination)) ?? .unavailable(identifier) + } + } +} diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 71e20f6..98412e5 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -1,5 +1,3 @@ -import Foundation - struct DefaultTypeDocumentationRenderer: Sendable { enum Output: Sendable { case text @@ -15,7 +13,7 @@ struct DefaultTypeDocumentationRenderer: Sendable { func render(_ document: TypeDocumentationDocument) throws -> String { switch output { case .text: - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: document.data) + let page = try DocumentationPageDecoder().decode(document.data, destination: document.destination) return TextTypeDocumentationRenderer().render(page) case .json: return RawJSONTypeDocumentationRenderer().render(document) diff --git a/Sources/CLI/renderer/DocumentationContentRenderer.swift b/Sources/CLI/renderer/DocumentationContentRenderer.swift index bc454fb..b90d556 100644 --- a/Sources/CLI/renderer/DocumentationContentRenderer.swift +++ b/Sources/CLI/renderer/DocumentationContentRenderer.swift @@ -1,65 +1,75 @@ import Foundation -struct DocumentationContentRenderer: Sendable { - let references: [String: DocumentationReferenceDTO] +struct DocumentationContentRenderer { private let layout = DocumentationTextLayout() - func inlineText(_ content: [DocumentationTextDTO]) -> String { + func inline(_ content: [DocumentationInline]) -> String { content.map { item in - if let text = item.text { - return text + switch item { + case .text(let text): return text + case .code(let code): return "`\(code)`" + case .link(let label, _): return inline(label) } - if let code = item.code { - return "`\(code)`" - } - if let identifier = item.identifier { - return references[identifier]?.title ?? identifier - } - return inlineText(item.inlineContent ?? []) }.joined() } - func render(_ blocks: [DocumentationBlockDTO], indent: String = " ") -> String { - blocks.map { render($0, indent: indent) } - .filter { !$0.isEmpty } - .joined(separator: "\n\n") + func blocks(_ content: [DocumentationBlock], indent: String = "") -> String { + content.map { block($0, indent: indent) }.filter { !$0.isEmpty }.joined(separator: "\n\n") + } + + func code(_ lines: [String], language: String?, indent: String = "") -> String { + layout.codeBlock(lines, language: language, indent: indent) + } + + func url(for target: DocumentationLinkTarget) -> String? { + switch target { + case .documentation(let destination): return destination.url.absoluteString + case .external(let url): return url.absoluteString + case .unavailable: return nil + } } - private func render(_ block: DocumentationBlockDTO, indent: String) -> String { + func availability(_ platform: DocumentationAvailability) -> String { + var parts: [String] = [] + switch (platform.introducedAt, platform.deprecatedAt) { + case (let introduced?, let deprecated?): parts.append("\(introduced)–\(deprecated)") + case (let introduced?, nil): parts.append("\(introduced)+") + case (nil, let deprecated?): parts.append("Until \(deprecated)") + case (nil, nil): break + } + if let version = platform.obsoletedAt { parts.append("obsoleted \(version)") } + if platform.isBeta { parts.append("beta") } + if platform.isUnavailable { parts.append("unavailable") } + return parts.isEmpty ? "Available" : parts.joined(separator: ", ") + } + + private func block(_ block: DocumentationBlock, indent: String) -> String { switch block { - case .paragraph(let content): - return layout.paragraph(inlineText(content), indent: indent) - case .heading(let text): - return text.isEmpty ? "" : layout.heading(text) - case .codeListing(let code, let syntax): - return layout.codeBlock(code, language: syntax, indent: indent) - case .orderedList(let items, let startIndex): - return renderList(items, startIndex: startIndex, indent: indent) - case .unorderedList(let items): - return renderList(items, indent: indent) + case .paragraph(let content): return layout.paragraph(inline(content), indent: indent) + case .heading(let text): return layout.heading(text) + case .codeListing(let lines, let language): return code(lines, language: language, indent: indent) + case .orderedList(let items, let start): return list(items, start: start, indent: indent) + case .unorderedList(let items): return list(items, start: nil, indent: indent) case .aside(let content, let style, let name): - let body = render(content, indent: indent + "│ ") - guard !body.isEmpty else { return "" } - return indent + (name ?? style.capitalized) + "\n" + body - case .unsupported: - return "" + return indent + (name ?? style.capitalized) + "\n" + blocks(content, indent: indent + "│ ") } } - private func renderList( - _ items: [DocumentationListItemDTO], - startIndex: Int? = nil, - indent: String - ) -> String { - items.enumerated().compactMap { index, item -> String? in - let marker = startIndex.map { "\($0 + index). " } ?? "• " + private func list(_ items: [[DocumentationBlock]], start: Int?, indent: String) -> String { + items.enumerated().map { index, content in + let marker = start.map { "\($0 + index). " } ?? "• " let continuation = indent + String(repeating: " ", count: marker.count) - let body = render(item.content, indent: continuation) - guard !body.isEmpty else { return nil } - if body.hasPrefix(continuation) { - return indent + marker + body.dropFirst(continuation.count) - } + let body = blocks(content, indent: continuation) + if body.hasPrefix(continuation) { return indent + marker + body.dropFirst(continuation.count) } return indent + marker + "\n" + body }.joined(separator: "\n") } } + +func terminalSafeText(_ text: String) -> String { + String( + String.UnicodeScalarView( + text.unicodeScalars.filter { + $0 == "\n" || $0 == "\t" || !CharacterSet.controlCharacters.contains($0) + })) +} diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift index 94d0de4..becdbe2 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift @@ -1,124 +1,56 @@ +import Foundation + struct TextTypeDocumentationRenderer: Sendable { private let layout = DocumentationTextLayout() + private let content = DocumentationContentRenderer() - func render(_ page: TypeDocumentationPageDTO) -> String { - let content = DocumentationContentRenderer(references: page.references) - let metadata = ([page.metadata.roleHeading] + page.metadata.modules.map(\.name)) + func render(_ page: DocumentationPage) -> String { + let metadata = ([page.roleHeading ?? page.kind.capitalized] + page.modules) .filter { !$0.isEmpty }.joined(separator: " · ") - var header = layout.heading(page.metadata.title, prominent: true) + "\n" + metadata - if let symbolKind = page.metadata.symbolKind { + var header = layout.heading(page.title, prominent: true) + "\n" + metadata + if let symbolKind = page.symbolKind { header += "\nSymbol kind: " + symbolKind } var sections = [header] - - let abstract = content.inlineText(page.abstract) - if !abstract.isEmpty { - sections.append(layout.paragraph(abstract)) - } - - let deprecation = content.render(page.deprecationSummary ?? []) - if !deprecation.isEmpty { - sections.append(layout.heading("Deprecated") + "\n" + deprecation) - } - - let availability = page.metadata.platforms.map { platform in - (platform.name, availabilityRange(for: platform)) - } - if !availability.isEmpty { - sections.append(layout.heading("Availability") + "\n" + layout.table(availability)) - } - - let declarations = swiftDeclarations(in: page) - if !declarations.isEmpty { - sections.append(layout.heading("Declaration") + "\n" + declarations.joined(separator: "\n\n")) - } - - let overview = content.render( - page.primaryContentSections.filter { $0.kind == "content" }.flatMap { $0.content ?? [] } - ) - if !overview.isEmpty { - sections.append(overview) - } - - sections += referenceSections(page.relationshipsSections ?? [], content: content) - appendGroup("Topics", page.topicSections ?? [], content: content, includesAbstract: true, to: §ions) - appendGroup("See Also", page.seeAlsoSections ?? [], content: content, to: §ions) - - if let path = page.variants?.lazy.flatMap(\.paths).first { - sections.append(layout.heading("Documentation") + "\n " + documentationURL(for: path)) - } - - return sections.joined(separator: "\n\n") + let summary = content.inline(page.abstract) + if !summary.isEmpty { sections.append(layout.paragraph(summary)) } + append("Deprecated", content.blocks(page.deprecation, indent: " "), to: §ions) + append( + "Availability", layout.table(page.availability.map { ($0.name, content.availability($0)) }), to: §ions) + append( + "Declaration", + page.declarations.map { + content.code($0.text.components(separatedBy: "\n"), language: $0.languages.first, indent: " ") + }.joined(separator: "\n\n"), to: §ions) + let body = content.blocks(page.content, indent: " ") + if !body.isEmpty { sections.append(body) } + sections += groups(page.relationships) + appendGroups("Topics", page.topics, to: §ions) + appendGroups("See Also", page.seeAlso, to: §ions) + sections.append(layout.heading("Documentation") + "\n " + page.url.absoluteString) + return terminalSafeText(sections.joined(separator: "\n\n")) } - private func swiftDeclarations(in page: TypeDocumentationPageDTO) -> [String] { - page.primaryContentSections - .filter { $0.kind == "declarations" } - .flatMap { $0.declarations ?? [] } - .filter { $0.languages.contains("swift") } - .map { - let lines = $0.tokens.map(\.text).joined().split(separator: "\n", omittingEmptySubsequences: false) - return layout.codeBlock(lines.map(String.init), language: "swift") - } + private func append(_ title: String, _ body: String, to sections: inout [String]) { + if !body.isEmpty { sections.append(layout.heading(title) + "\n" + body) } } - private func appendGroup( - _ title: String, - _ groups: [DocumentationReferenceSectionDTO], - content: DocumentationContentRenderer, - includesAbstract: Bool = false, - to sections: inout [String] - ) { - let rendered = referenceSections(groups, content: content, includesAbstract: includesAbstract) - if !rendered.isEmpty { - sections.append(layout.heading(title, prominent: true)) - sections += rendered - } + private func appendGroups(_ title: String, _ entries: [DocumentationGroup], to sections: inout [String]) { + let rendered = groups(entries) + if !rendered.isEmpty { sections += [layout.heading(title, prominent: true)] + rendered } } - private func referenceSections( - _ groups: [DocumentationReferenceSectionDTO], - content: DocumentationContentRenderer, - includesAbstract: Bool = false - ) -> [String] { + private func groups(_ groups: [DocumentationGroup]) -> [String] { groups.compactMap { group in - let items = group.identifiers.compactMap { identifier -> String? in - guard let reference = content.references[identifier], let title = reference.title else { - return nil - } - var item = layout.paragraph(title, indent: " ", firstPrefix: " • ") - let abstract = content.inlineText(reference.abstract ?? []) - if includesAbstract && !abstract.isEmpty { - item += "\n" + layout.paragraph(abstract, indent: " ") - } - if let url = reference.url { - // Keep URLs intact so terminal link detection and copy/paste still work. - item += "\n " + documentationURL(for: url) - } - return item + let entries = group.references.map { reference in + var text = layout.paragraph(reference.title, indent: " ", firstPrefix: " • ") + let abstract = content.inline(reference.abstract) + if !abstract.isEmpty { text += "\n" + layout.paragraph(abstract, indent: " ") } + if let url = content.url(for: reference.target) { text += "\n " + url } + return text } - guard !items.isEmpty else { return nil } - return layout.heading(group.title) + "\n" + items.joined(separator: "\n\n") - } - } - - private func availabilityRange(for platform: DocumentationPlatform) -> String { - switch (platform.introducedAt, platform.deprecatedAt) { - case (let introduced?, let deprecated?): - return "\(introduced)–\(deprecated)" - case (let introduced?, nil): - return "\(introduced)+" - case (nil, let deprecated?): - return "Until \(deprecated)" - case (nil, nil): - return "Available" - } - } - - private func documentationURL(for path: String) -> String { - if path.hasPrefix("http://") || path.hasPrefix("https://") { - return path + guard !entries.isEmpty else { return nil } + return layout.heading(group.title) + "\n" + entries.joined(separator: "\n\n") } - return "https://developer.apple.com\(path)" } } diff --git a/Tests/CLITests/client/AppleDocumentationClientPageTests.swift b/Tests/CLITests/client/AppleDocumentationClientPageTests.swift new file mode 100644 index 0000000..e31fb37 --- /dev/null +++ b/Tests/CLITests/client/AppleDocumentationClientPageTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Logging +import Testing + +@testable import CLI + +@Suite("Documentation page requests") +struct AppleDocumentationClientPageTests { + @Test("preserves exact path spelling and response bytes without fetching fragments") + func fetchesExactPage() async throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "swift", path: "/documentation/swift/Member.with.dots(_:)", fragment: "Overview") + let url = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/swift/Member.with.dots(_:).json")) + let data = Data("{ \"newUpstreamField\": true }\n".utf8) + let transport = HTTPTestTransport(responses: [url: .http(data: data)]) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) + + // -- Act -- + let document = try await client.fetchDocument(at: destination) + + // -- Assert -- + #expect(document.destination == destination) + #expect(document.data == data) + #expect(await transport.requestedURLs == [url]) + } + + @Test("canonical root discovery retains bytes without downloading the document twice") + func retainsCanonicalRoot() async throws { + // -- Arrange -- + let requestedURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/apple%20cryptokit.json")) + let catalogURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/technologies.json")) + let canonicalURL = try #require( + URL(string: "https://developer.apple.com/tutorials/data/documentation/cryptokit.json")) + let catalog = Data( + #""" + {"sections":[{"groups":[{"technologies":[{ + "title":"Apple CryptoKit", + "destination":{"identifier":"doc://com.apple.documentation/documentation/CryptoKit"} + }]}]}]} + """#.utf8) + let data = Data(#"{"metadata":{"title":"Apple CryptoKit","role":"collection"},"references":{}}"#.utf8) + let transport = HTTPTestTransport(responses: [ + requestedURL: .http(statusCode: 404, data: Data()), catalogURL: .http(data: catalog), + canonicalURL: .http(data: data), + ]) + let client = DefaultAppleDocumentationClient( + logger: Logger(label: "test") { _ in SwiftLogNoOpLogHandler() }, dependencies: transport) + + // -- Act -- + let document = try await client.fetchRootDocument(technology: "Apple CryptoKit") + + // -- Assert -- + #expect( + document.destination == DocumentationDestination(technology: "cryptokit", path: "/documentation/cryptokit")) + #expect(document.data == data) + #expect(await transport.requestedURLs == [requestedURL, catalogURL, canonicalURL]) + } +} diff --git a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift index dd4f438..5c8f5e8 100644 --- a/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift +++ b/Tests/CLITests/cmd/types/TypesViewCommandRunnerTests.swift @@ -24,7 +24,8 @@ struct TypesViewCommandRunnerTests { } """.utf8 ) - let document = TypeDocumentationDocument(data: data) + let document = TypeDocumentationDocument( + data: data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) let client = RequestedTypeClient( expectedName: "MXHangDiagnostic", expectedTechnology: "MetricKit", diff --git a/Tests/CLITests/documentation/DocumentationDestinationTests.swift b/Tests/CLITests/documentation/DocumentationDestinationTests.swift new file mode 100644 index 0000000..1e3603d --- /dev/null +++ b/Tests/CLITests/documentation/DocumentationDestinationTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Documentation destinations") +struct DocumentationDestinationTests { + private let current = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic" + ) + + @Test("preserves overload punctuation and fragments across technologies") + func resolvesCrossTechnologyLink() throws { + // -- Arrange -- + let raw = "https://developer.apple.com/documentation/swift/array/append(_:)-7x2#Discussion" + + // -- Act -- + let target = try DocumentationDestination.resolve(raw, relativeTo: current) + + // -- Assert -- + #expect( + target + == .documentation( + DocumentationDestination( + technology: "swift", path: "/documentation/swift/array/append(_:)-7x2", fragment: "Discussion" + ))) + } + + @Test("resolves relative paths without changing exact symbol spelling") + func resolvesRelativePath() throws { + // -- Arrange -- + let raw = "mxhangdiagnostic/member.with.dots(_:)" + + // -- Act -- + let target = try DocumentationDestination.resolve(raw, relativeTo: current) + + // -- Assert -- + #expect( + target + == .documentation( + DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member.with.dots(_:)" + ))) + } + + @Test("fragment-only navigation keeps the page") + func resolvesFragment() throws { + // -- Arrange -- + let raw = "#Overview" + + // -- Act -- + let target = try DocumentationDestination.resolve(raw, relativeTo: current) + + // -- Assert -- + #expect( + target + == .documentation( + DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic", fragment: "Overview" + ))) + } + + @Test( + "website links never become documentation fetches", + arguments: [ + "https://example.com/documentation/swift", "http://developer.apple.com/documentation/swift", + "https://developer.apple.com/videos/play/wwdc2026/123", + "https://developer.apple.com.evil.test/documentation/swift", + ]) + func resolvesExternalLink(raw: String) throws { + // -- Arrange -- + let expectedURL = try #require(URL(string: raw)) + + // -- Act -- + let target = try DocumentationDestination.resolve(raw, relativeTo: current) + + // -- Assert -- + #expect(target == .external(expectedURL)) + } + + @Test("DocC identifiers need the page reference dictionary") + func unresolvedIdentifier() throws { + // -- Arrange -- + let raw = "doc://com.apple.metrickit/documentation/MetricKit/MXHangDiagnostic" + + // -- Act -- + let target = try DocumentationDestination.resolve(raw, relativeTo: current) + + // -- Assert -- + #expect(target == .unavailable(raw)) + } + + @Test( + "rejects unsafe destinations", + arguments: [ + "javascript:alert(1)", "file:///etc/passwd", "data:text/plain,hello", + "https://user:password@developer.apple.com/documentation/swift", + "https://example.com/../secret", "/documentation/swift/../secret", + "/documentation/swift/%2e%2e/secret", "/documentation/swift/%252e%252e/secret", + "/documentation/swift/foo%2f..%2fsecret", "/documentation/swift/foo\\bar", + "/documentation/swift/foo%5cbar", "/documentation/swift/foo\u{001B}[2J", + "/documentation/swift/foo%0a", "https://developer.apple.com:444/documentation/swift", + "//evil.test/documentation/swift", + ]) + func rejectsUnsafeDestination(raw: String) { + // -- Arrange -- + let destination = current + + // -- Act -- + let resolve = { try DocumentationDestination.resolve(raw, relativeTo: destination) } + + // -- Assert -- + #expect(throws: (any Error).self) { try resolve() } + } +} diff --git a/Tests/CLITests/documentation/DocumentationFixtures.swift b/Tests/CLITests/documentation/DocumentationFixtures.swift new file mode 100644 index 0000000..d595d5a --- /dev/null +++ b/Tests/CLITests/documentation/DocumentationFixtures.swift @@ -0,0 +1,56 @@ +import Foundation + +enum DocumentationFixtures { + static let member = Data( + #""" + { + "kind": "symbol", + "metadata": { + "title": "member(_:)", "role": "symbol", "symbolKind": "method", "roleHeading": "Method", + "modules": [{"name": "MetricKit"}], + "platforms": [{"name": "macOS", "introducedAt": "15.0", "deprecatedAt": "26.0", + "obsoletedAt": "27.0", "beta": true, "unavailable": true}] + }, + "abstract": [ + {"type": "text", "text": "Read "}, {"type": "codeVoice", "code": "value"}, + {"type": "text", "text": " with "}, + {"type": "reference", "identifier": "doc://string", "overridingTitle": "a string"}, + {"type": "emphasis", "inlineContent": [{"text": "."}]} + ], + "deprecationSummary": [{"type": "paragraph", "inlineContent": [{"text": "Use the replacement."}]}], + "primaryContentSections": [ + {"kind": "declarations", "declarations": [ + {"languages": ["swift"], "tokens": [{"text": "func "}, {"text": "member(_ value: String)"}]}, + {"languages": ["occ"], "tokens": [{"text": "- (void)member;"}]} + ]}, + {"kind": "content", "content": [ + {"type": "heading", "text": "Overview"}, + {"type": "paragraph", "inlineContent": [ + {"type": "reference", "identifier": "https://example.com/guide"}, + {"type": "reference", "identifier": "doc://missing", "overridingTitle": "Missing"}, + {"type": "reference", "identifier": "doc://unsafe"} + ]}, + {"type": "codeListing", "syntax": "swift", "code": ["member(\"value\")", ""]}, + {"type": "orderedList", "startIndex": 3, "items": [{"content": [ + {"type": "paragraph", "inlineContent": [{"text": "First"}]}, + {"type": "unorderedList", "items": [{"content": [ + {"type": "paragraph", "inlineContent": [{"text": "Nested"}]} + ]}]} + ]}]}, + {"type": "aside", "style": "warning", "name": "Important", "content": [ + {"type": "paragraph", "inlineContent": [{"text": "Take care."}]} + ]} + ]} + ], + "relationshipsSections": [{"title": "Conforms To", "identifiers": ["doc://string"]}], + "topicSections": [{"title": "Children", "identifiers": ["doc://missing", "doc://string"]}], + "seeAlsoSections": [{"title": "Guides", "identifiers": ["https://example.com/guide"]}], + "references": { + "doc://string": {"title": "String", "kind": "symbol", "role": "symbol", + "url": "/documentation/swift/string", "abstract": [{"text": "Text storage."}]}, + "https://example.com/guide": {"title": "Guide", "kind": "link", "url": "https://example.com/guide"}, + "doc://unsafe": {"title": "Unsafe", "url": "javascript:alert(1)"} + } + } + """#.utf8) +} diff --git a/Tests/CLITests/documentation/DocumentationPageDecoderTests.swift b/Tests/CLITests/documentation/DocumentationPageDecoderTests.swift new file mode 100644 index 0000000..be42076 --- /dev/null +++ b/Tests/CLITests/documentation/DocumentationPageDecoderTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Documentation page decoding") +struct DocumentationPageDecoderTests { + private let memberDestination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" + ) + + @Test( + "decodes technology and collection roles without symbol metadata", arguments: ["collection", "collectionGroup"]) + func decodesNonSymbolPage(role: String) throws { + // -- Arrange -- + let data = Data( + """ + {"kind":"article","metadata":{"title":"MetricKit","role":"\(role)"}} + """.utf8) + let destination = DocumentationDestination(technology: "metrickit", path: "/documentation/metrickit") + + // -- Act -- + let page = try DocumentationPageDecoder().decode(data, destination: destination) + + // -- Assert -- + #expect(page.title == "MetricKit") + #expect(page.kind == role) + #expect(page.modules.isEmpty) + #expect(page.content.isEmpty) + #expect(page.topics.isEmpty) + #expect(page.url.absoluteString == "https://developer.apple.com/documentation/metrickit") + } + + @Test("retains declarations, availability, deprecation, and labeled inline links") + func retainsMemberMetadata() throws { + // -- Arrange -- + let data = DocumentationFixtures.member + + // -- Act -- + let page = try DocumentationPageDecoder().decode(data, destination: memberDestination) + + // -- Assert -- + #expect(page.title == "member(_:)") + #expect(page.kind == "method") + #expect(page.modules == ["MetricKit"]) + #expect( + page.abstract == [ + .text("Read "), .code("value"), .text(" with "), + .link( + label: [.text("a string")], + target: .documentation( + DocumentationDestination( + technology: "swift", path: "/documentation/swift/string" + ))), .text("."), + ]) + #expect( + page.declarations == [ + DocumentationDeclaration(languages: ["swift"], text: "func member(_ value: String)"), + DocumentationDeclaration(languages: ["occ"], text: "- (void)member;"), + ]) + #expect( + page.availability == [ + DocumentationAvailability( + name: "macOS", introducedAt: "15.0", deprecatedAt: "26.0", obsoletedAt: "27.0", + isBeta: true, isUnavailable: true + ) + ]) + #expect(page.deprecation == [.paragraph([.text("Use the replacement.")])]) + } + + @Test("retains nested semantic blocks and unavailable links without inventing destinations") + func retainsSemanticContent() throws { + // -- Arrange -- + let data = DocumentationFixtures.member + let externalURL = try #require(URL(string: "https://example.com/guide")) + + // -- Act -- + let page = try DocumentationPageDecoder().decode(data, destination: memberDestination) + + // -- Assert -- + #expect( + page.content == [ + .heading("Overview"), + .paragraph([ + .link(label: [.text("Guide")], target: .external(externalURL)), + .link(label: [.text("Missing")], target: .unavailable("doc://missing")), + .link(label: [.text("Unsafe")], target: .unavailable("doc://unsafe")), + ]), + .codeListing(code: ["member(\"value\")", ""], syntax: "swift"), + .orderedList( + items: [ + [ + .paragraph([.text("First")]), .unorderedList([[.paragraph([.text("Nested")])]]), + ] + ], startIndex: 3), + .aside(content: [.paragraph([.text("Take care.")])], style: "warning", name: "Important"), + ]) + #expect(page.relationships.map(\.title) == ["Conforms To"]) + #expect(page.topics[0].references.map(\.id) == ["doc://string"]) + #expect(page.topics[0].references[0].abstract == [.text("Text storage.")]) + #expect(page.seeAlso[0].references[0].target == .external(externalURL)) + } + + @Test( + "rejects malformed required metadata and known content", + arguments: [ + "{}", "not json", #"{"metadata":{"role":"collection"}}"#, + #"{"metadata":{"title":"","role":"collection"}}"#, + #"{"metadata":{"title":"Example"}}"#, + #"{"metadata":{"title":42,"role":"collection"}}"#, + #"{"metadata":{"title":"Example","role":"collection"},"topicSections":{}}"#, + #""" + {"metadata":{"title":"Example","role":"collection"}, + "primaryContentSections":[{"kind":"content","content":[{"type":"paragraph"}]}]} + """#, + ]) + func rejectsMalformedPage(json: String) { + // -- Arrange -- + let data = Data(json.utf8) + + // -- Act -- + let decode = { try DocumentationPageDecoder().decode(data, destination: memberDestination) } + + // -- Assert -- + #expect(throws: (any Error).self) { try decode() } + } + + @Test("retains topic membership and order, not every reference") + func retainsTopicMembership() throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic" + ) + let data = Data( + """ + {"metadata":{"title":"MXHangDiagnostic","modules":[{"name":"MetricKit"}], + "roleHeading":"Class","symbolKind":"class"},"abstract":[], + "primaryContentSections":[], + "topicSections":[{"title":"Details","identifiers":["doc://member","doc://other"]}], + "seeAlsoSections":[{"title":"Related","identifiers":["doc://string"]}], + "references":{ + "doc://other":{"title":"Other member","kind":"symbol","role":"symbol", + "url":"/documentation/metrickit/mxhangdiagnostic/other"}, + "doc://string":{"title":"String","kind":"symbol","role":"symbol", + "url":"/documentation/swift/string"}, + "doc://member":{"title":"Example member","kind":"symbol","role":"symbol", + "url":"/documentation/metrickit/mxhangdiagnostic/member"}}} + """.utf8) + + // -- Act -- + let page = try DocumentationPageDecoder().decode(data, destination: destination) + + // -- Assert -- + #expect(page.topics.map(\.title) == ["Details"]) + #expect(page.topics[0].references.map(\.title) == ["Example member", "Other member"]) + #expect( + page.seeAlso[0].references[0].target + == .documentation( + DocumentationDestination( + technology: "swift", path: "/documentation/swift/string" + ))) + } +} diff --git a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift index 9110a80..d3ab01a 100644 --- a/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTypeDocumentationRendererTests.swift @@ -5,6 +5,22 @@ import Testing @Suite("Default type documentation renderer") struct DefaultTypeDocumentationRendererTests { + @Test("renders sparse collection pages") + func rendersSparseCollection() throws { + // -- Arrange -- + let document = TypeDocumentationDocument( + data: Data(#"{"kind":"article","metadata":{"title":"SwiftUI","role":"collection"}}"#.utf8), + destination: .init(technology: "swiftui", path: "/documentation/swiftui")) + let renderer = DefaultTypeDocumentationRenderer(output: .text) + + // -- Act -- + let output = try renderer.render(document) + + // -- Assert -- + #expect(output.hasPrefix("SwiftUI\n")) + #expect(output.contains("Collection")) + } + @Test("renders text output") func rendersText() throws { // -- Arrange -- @@ -37,6 +53,10 @@ struct DefaultTypeDocumentationRendererTests { Symbol kind: class A diagnostic report. + + Documentation + ───────────── + https://developer.apple.com/documentation/metrickit/mxhangdiagnostic """ ) } @@ -57,7 +77,7 @@ struct DefaultTypeDocumentationRendererTests { "references": {} } """ - let document = try makeDocument(rawJSON) + let document = try makeDocument(rawJSON, technology: "packagedescription", path: "package") let renderer = DefaultTypeDocumentationRenderer(output: .text) // -- Act -- @@ -72,6 +92,10 @@ struct DefaultTypeDocumentationRendererTests { Symbol kind: struct The Swift package manifest representation. + + Documentation + ───────────── + https://developer.apple.com/documentation/packagedescription/package """ ) } @@ -131,7 +155,11 @@ struct DefaultTypeDocumentationRendererTests { #expect(output == rawJSON) } - private func makeDocument(_ rawJSON: String) throws -> TypeDocumentationDocument { - TypeDocumentationDocument(data: Data(rawJSON.utf8)) + private func makeDocument( + _ rawJSON: String, technology: String = "metrickit", path: String = "mxhangdiagnostic" + ) throws -> TypeDocumentationDocument { + TypeDocumentationDocument( + data: Data(rawJSON.utf8), + destination: .init(technology: technology, path: "/documentation/\(technology)/\(path)")) } } diff --git a/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift b/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift new file mode 100644 index 0000000..5d92cf0 --- /dev/null +++ b/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import CLI + +@Suite("Normalized human documentation text") +struct NormalizedTextDocumentationRendererTests { + @Test("retains readable sections and every normalized declaration without agent chrome") + func rendersPage() throws { + // -- Arrange -- + let destination = DocumentationDestination( + technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" + ) + let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) + let presentation = page + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(presentation) + + // -- Assert -- + #expect(output.hasPrefix("member(_:)\n━━━━━━━━━━\nMethod · MetricKit\nSymbol kind: method")) + #expect(output.contains(" Read `value` with a string.")) + #expect(output.contains("func member(_ value: String)")) + #expect(output.contains("- (void)member;")) + #expect(output.contains("Availability\n────────────")) + #expect(output.contains("Deprecated\n──────────")) + #expect(output.contains(" 3. First\n\n • Nested")) + #expect(output.contains(" Important\n │ Take care.")) + #expect(output.contains("Text storage.")) + #expect(output.contains("https://developer.apple.com/documentation/swift/string")) + #expect(output.contains("https://example.com/guide")) + #expect(output.hasSuffix("https://developer.apple.com/documentation/metrickit/mxhangdiagnostic/member(_:)")) + #expect(!output.contains("apple-docs types")) + #expect(!output.contains("javascript:")) + } + + @Test("preserves Apple's role heading rather than guessing from symbol kind") + func preservesRoleHeading() throws { + // -- Arrange -- + let data = Data( + #""" + {"metadata":{"title":"value","symbolKind":"property","roleHeading":"Instance Property"}} + """#.utf8) + let destination = DocumentationDestination(technology: "swift", path: "/documentation/swift/value") + let page = try DocumentationPageDecoder().decode(data, destination: destination) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("Instance Property\nSymbol kind: property")) + } + + @Test("retains human availability ranges") + func rendersAvailabilityRange() { + // -- Arrange -- + let page = DocumentationPage( + destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/string"), + title: "String", kind: "struct", + availability: [DocumentationAvailability(name: "macOS", introducedAt: "15.0", deprecatedAt: "26.0")] + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("15.0–26.0")) + } + + @Test("does not emit remote terminal controls") + func sanitizesTerminalText() { + // -- Arrange -- + let page = DocumentationPage( + destination: DocumentationDestination(technology: "swift", path: "/documentation/swift/string"), + title: "String\u{001B}[2J", kind: "struct", abstract: [.text("A\u{009B}31m string.")] + ) + + // -- Act -- + let output = TextTypeDocumentationRenderer().render(page) + + // -- Assert -- + #expect(output.contains("string.")) + #expect(!output.contains("\u{001B}")) + #expect(!output.contains("\u{009B}")) + } +} diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift index 6b7c488..df964a5 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift @@ -129,7 +129,19 @@ struct TextTypeDocumentationRendererContentTests { let output = TextTypeDocumentationRenderer().render(page) // -- Assert -- - #expect(output == "View\n━━━━\nProtocol · SwiftUI\nSymbol kind: protocol") + #expect( + output + == """ + View + ━━━━ + Protocol · SwiftUI + Symbol kind: protocol + + Documentation + ───────────── + https://developer.apple.com/documentation/swiftui/view + """ + ) } @Test( @@ -154,7 +166,7 @@ struct TextTypeDocumentationRendererContentTests { #expect(throws: DecodingError.self) { try decode() } } - private func makePage(abstract: String = "", content: String = "") throws -> TypeDocumentationPageDTO { + private func makePage(abstract: String = "", content: String = "") throws -> DocumentationPage { let data = Data( """ { @@ -169,6 +181,9 @@ struct TextTypeDocumentationRendererContentTests { } """.utf8 ) - return try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + return try DocumentationPageDecoder().decode( + data, + destination: .init( + technology: "swiftui", path: "/documentation/swiftui/view")) } } diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift index e4954d8..0137654 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift @@ -28,7 +28,8 @@ struct TextTypeDocumentationRendererReferenceTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) @@ -77,7 +78,10 @@ struct TextTypeDocumentationRendererReferenceTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, + destination: .init( + technology: "swiftui", path: "/documentation/swiftui/button")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift index 66f78c4..4f95f7c 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift @@ -35,7 +35,8 @@ struct TextTypeDocumentationRendererTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) @@ -55,6 +56,10 @@ struct TextTypeDocumentationRendererTests { ╭─ Swift ────────────────╮ │ class MXHangDiagnostic │ ╰────────────────────────╯ + + Documentation + ───────────── + https://developer.apple.com/documentation/metrickit/mxhangdiagnostic """ ) } @@ -98,7 +103,8 @@ struct TextTypeDocumentationRendererTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) @@ -129,7 +135,8 @@ struct TextTypeDocumentationRendererTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) @@ -207,7 +214,8 @@ struct TextTypeDocumentationRendererTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) @@ -237,11 +245,8 @@ struct TextTypeDocumentationRendererTests { { "abstract": [], "metadata": { - "modules": [{"name": "Swift"}], - "platforms": [], - "roleHeading": "Structure", - "symbolKind": "struct", - "title": "String" + "modules": [{"name": "Swift"}], "platforms": [], + "roleHeading": "Structure", "symbolKind": "struct", "title": "String" }, "primaryContentSections": [], "references": { @@ -257,12 +262,27 @@ struct TextTypeDocumentationRendererTests { } """.utf8 ) - let page = try JSONDecoder().decode(TypeDocumentationPageDTO.self, from: data) + let page = try DocumentationPageDecoder().decode( + data, + destination: .init( + technology: "swift", path: "/documentation/swift/string")) // -- Act -- let output = TextTypeDocumentationRenderer().render(page) // -- Assert -- - #expect(output == "String\n━━━━━━\nStructure · Swift\nSymbol kind: struct") + #expect( + output + == """ + String + ━━━━━━ + Structure · Swift + Symbol kind: struct + + Documentation + ───────────── + https://developer.apple.com/documentation/swift/string + """ + ) } }