From 3fbf1d9fdbe01a83494b2b45b0bbe9097e907387 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Fri, 25 Sep 2026 13:58:16 +0200 Subject: [PATCH] fix(output): Consolidate human rendering and align sanitized tables --- ...DefaultDocumentationTypeListRenderer.swift | 31 ++------------- .../DefaultTechnologyListRenderer.swift | 22 ++--------- .../DefaultTypeDocumentationRenderer.swift | 2 +- ...swift => HumanDocumentationRenderer.swift} | 39 +++++++++++++++---- .../AppleDocsCommandIntegrationTests.swift | 4 +- ...ltDocumentationTypeListRendererTests.swift | 23 +++++++++++ .../DefaultTechnologyListRendererTests.swift | 35 +++++++++++++++++ ...anDocumentationRendererContentTests.swift} | 14 +++---- ...DocumentationRendererReferenceTests.swift} | 8 ++-- ... => HumanDocumentationRendererTests.swift} | 14 +++---- ...alizedTextDocumentationRendererTests.swift | 10 ++--- .../skills/BundledAgentSkillsTests.swift | 17 ++++++++ 12 files changed, 141 insertions(+), 78 deletions(-) rename Sources/CLI/renderer/{TextTypeDocumentationRenderer.swift => HumanDocumentationRenderer.swift} (58%) rename Tests/CLITests/renderer/{TextTypeDocumentationRendererContentTests.swift => HumanDocumentationRendererContentTests.swift} (90%) rename Tests/CLITests/renderer/{TextTypeDocumentationRendererReferenceTests.swift => HumanDocumentationRendererReferenceTests.swift} (90%) rename Tests/CLITests/renderer/{TextTypeDocumentationRendererTests.swift => HumanDocumentationRendererTests.swift} (93%) diff --git a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift index 8779d21..a7b449f 100644 --- a/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift +++ b/Sources/CLI/renderer/DefaultDocumentationTypeListRenderer.swift @@ -1,5 +1,3 @@ -import Foundation - struct DefaultDocumentationTypeListRenderer: Sendable { enum Output: Sendable { case table @@ -17,35 +15,14 @@ struct DefaultDocumentationTypeListRenderer: Sendable { } func render(_ types: [DocumentationType]) throws -> String { + let presentation = DocumentationPresenter().symbols(types, technology: technology, audience: audience) switch output { case .table: - if audience == .agent { - let presentation = DocumentationPresenter().symbols(types, technology: technology, audience: .agent) - return AgentDocumentationRenderer().render(presentation, technology: technology) - } - return terminalSafeText(renderTable(types)) + return audience == .agent + ? AgentDocumentationRenderer().render(presentation, technology: technology) + : HumanDocumentationRenderer().render(presentation) case .json: - let presentation = DocumentationPresenter().symbols(types, technology: technology, audience: audience) return try StructuredDocumentationRenderer().render(presentation) } } - - private func renderTable(_ types: [DocumentationType]) -> String { - guard !types.isEmpty else { return "No symbols found." } - let nameWidth = max("SYMBOL".count, types.map(\.name.count).max() ?? 0) - let kindWidth = max("KIND".count, types.map(\.kind.count).max() ?? 0) - let pathWidth = max("PATH".count, types.map(\.path.count).max() ?? 0) - - let heading = - "SYMBOL".padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " - + "KIND".padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " - + "PATH".padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " URL" - let rows = types.map { type in - type.name.padding(toLength: nameWidth, withPad: " ", startingAt: 0) + " " - + type.kind.padding(toLength: kindWidth, withPad: " ", startingAt: 0) + " " - + type.path.padding(toLength: pathWidth, withPad: " ", startingAt: 0) + " " - + type.url - } - return ([heading] + rows).joined(separator: "\n") - } } diff --git a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift index 685ed41..71976bc 100644 --- a/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift +++ b/Sources/CLI/renderer/DefaultTechnologyListRenderer.swift @@ -1,5 +1,3 @@ -import Foundation - struct DefaultTechnologyListRenderer: Sendable { enum Output: Sendable { case table @@ -15,26 +13,14 @@ struct DefaultTechnologyListRenderer: Sendable { } func render(_ technologies: [Technology]) throws -> String { + let presentation = DocumentationPresenter().technologies(technologies, audience: audience) switch output { case .table: - if audience == .agent { - let presentation = DocumentationPresenter().technologies(technologies, audience: .agent) - return AgentDocumentationRenderer().render(presentation) - } - return terminalSafeText(renderTable(technologies)) + return audience == .agent + ? AgentDocumentationRenderer().render(presentation) + : HumanDocumentationRenderer().render(presentation) case .json: - let presentation = DocumentationPresenter().technologies(technologies, audience: audience) return try StructuredDocumentationRenderer().render(presentation) } } - - private func renderTable(_ technologies: [Technology]) -> String { - let heading = "TECHNOLOGY" - let width = max(heading.count, technologies.map(\.name.count).max() ?? 0) - let rows = technologies.map { - $0.name + String(repeating: " ", count: width - $0.name.count) + " " + $0.identifier - } - return ([heading + String(repeating: " ", count: width - heading.count) + " IDENTIFIER"] + rows) - .joined(separator: "\n") - } } diff --git a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift index 429df21..d625c48 100644 --- a/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/DefaultTypeDocumentationRenderer.swift @@ -13,7 +13,7 @@ struct DefaultTypeDocumentationRenderer: Sendable { case .text: return audience == .agent ? AgentDocumentationRenderer().render(presentation) - : TextTypeDocumentationRenderer().render(page) + : HumanDocumentationRenderer().render(presentation) } } } diff --git a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift b/Sources/CLI/renderer/HumanDocumentationRenderer.swift similarity index 58% rename from Sources/CLI/renderer/TextTypeDocumentationRenderer.swift rename to Sources/CLI/renderer/HumanDocumentationRenderer.swift index 06c22aa..5d7c60d 100644 --- a/Sources/CLI/renderer/TextTypeDocumentationRenderer.swift +++ b/Sources/CLI/renderer/HumanDocumentationRenderer.swift @@ -1,10 +1,11 @@ import Foundation -struct TextTypeDocumentationRenderer: Sendable { +struct HumanDocumentationRenderer: Sendable { private let layout = DocumentationTextLayout() private let content = DocumentationContentRenderer(audience: .human) - func render(_ page: DocumentationPage) -> String { + func render(_ presentation: PagePresentation) -> String { + let page = presentation.document let metadata = ([page.roleHeading ?? page.kind.capitalized] + page.modules) .filter { !$0.isEmpty }.joined(separator: " · ") var header = layout.heading(page.title, prominent: true) + "\n" + metadata @@ -24,25 +25,47 @@ struct TextTypeDocumentationRenderer: Sendable { }.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 += groups(presentation.relationships) + appendGroups("Topics", presentation.topics, to: §ions) + appendGroups("See Also", presentation.seeAlso, to: §ions) sections.append(layout.heading("Documentation") + "\n " + page.url.absoluteString) return terminalSafeText(sections.joined(separator: "\n\n")) } + func render(_ symbols: [SymbolPresentation]) -> String { + guard !symbols.isEmpty else { return "No symbols found." } + return table( + headers: ["SYMBOL", "KIND", "PATH", "URL"], + rows: symbols.map { [$0.name, $0.kind, $0.path, $0.url] }) + } + + func render(_ technologies: [TechnologyPresentation]) -> String { + table(headers: ["TECHNOLOGY", "IDENTIFIER"], rows: technologies.map { [$0.name, $0.identifier] }) + } + + private func table(headers: [String], rows: [[String]]) -> String { + let values = ([headers] + rows).map { $0.map(terminalSafeText) } + let widths = headers.indices.map { column in values.map { $0[column].count }.max() ?? 0 } + return values.map { row in + row.enumerated().map { column, value in + column == row.count - 1 ? value : value + String(repeating: " ", count: widths[column] - value.count) + }.joined(separator: " ") + }.joined(separator: "\n") + } + private func append(_ title: String, _ body: String, to sections: inout [String]) { if !body.isEmpty { sections.append(layout.heading(title) + "\n" + body) } } - private func appendGroups(_ title: String, _ entries: [DocumentationGroup], to sections: inout [String]) { + private func appendGroups(_ title: String, _ entries: [GroupPresentation], to sections: inout [String]) { let rendered = groups(entries) if !rendered.isEmpty { sections += [layout.heading(title, prominent: true)] + rendered } } - private func groups(_ groups: [DocumentationGroup]) -> [String] { + private func groups(_ groups: [GroupPresentation]) -> [String] { groups.compactMap { group in - let entries = group.references.map { reference in + let entries = group.references.map { item in + let reference = item.reference var text = layout.paragraph(reference.title, indent: " ", firstPrefix: " • ") let abstract = content.inline(reference.abstract) if !abstract.isEmpty { text += "\n" + layout.paragraph(abstract, indent: " ") } diff --git a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift index f4dc2ff..fadac2d 100644 --- a/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift +++ b/Tests/CLIIntegrationTests/AppleDocsCommandIntegrationTests.swift @@ -50,9 +50,10 @@ struct AppleDocsCommandIntegrationTests { func rendersAgentJSON() throws { // -- Arrange -- let arguments = ["types", "view", "String", "--technology", "Swift", "--agent", "--json", "--verbose"] + var diagnostics = "" // -- Act -- - let output = try runAppleDocs(arguments) + let output = try runAppleDocs(arguments, captureStandardError: { diagnostics = $0 }) let value = try #require(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [String: Any]) // -- Assert -- @@ -60,6 +61,7 @@ struct AppleDocsCommandIntegrationTests { #expect(value["navigation"] != nil) #expect(value["metadata"] == nil) #expect(!output.contains("\u{1B}")) + #expect(diagnostics.contains("debug")) } @Test("returns Foundation URL documentation as JSON") diff --git a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift index f35109e..ee3aa83 100644 --- a/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultDocumentationTypeListRendererTests.swift @@ -41,6 +41,29 @@ struct DefaultDocumentationTypeListRendererTests { ) } + @Test("aligns table columns after removing remote control characters") + func alignsSanitizedTable() throws { + // -- Arrange -- + let types = [ + DocumentationType( + name: "Model()\u{0007}", kind: "macro\u{0007}", path: "model()\u{0007}", + url: "https://developer.apple.com/documentation/swiftdata/model()\u{0007}" + ) + ] + let renderer = DefaultDocumentationTypeListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render(types) + + // -- Assert -- + #expect( + output == """ + SYMBOL KIND PATH URL + Model() macro model() https://developer.apple.com/documentation/swiftdata/model() + """ + ) + } + @Test("renders documentation types as JSON") func rendersJSON() throws { // -- Arrange -- diff --git a/Tests/CLITests/renderer/DefaultTechnologyListRendererTests.swift b/Tests/CLITests/renderer/DefaultTechnologyListRendererTests.swift index f9c0297..4aa5c64 100644 --- a/Tests/CLITests/renderer/DefaultTechnologyListRendererTests.swift +++ b/Tests/CLITests/renderer/DefaultTechnologyListRendererTests.swift @@ -33,6 +33,41 @@ struct DefaultTechnologyListRendererTests { ) } + @Test("aligns table columns after removing remote control characters") + func alignsSanitizedTable() throws { + // -- Arrange -- + let technologies = [ + Technology( + name: "MetricKit\u{0007}", identifier: "doc://com.apple.documentation/documentation/MetricKit\u{0007}"), + Technology(name: "Swift", identifier: "doc://com.apple.documentation/documentation/Swift"), + ] + let renderer = DefaultTechnologyListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render(technologies) + + // -- Assert -- + #expect( + output == """ + TECHNOLOGY IDENTIFIER + MetricKit doc://com.apple.documentation/documentation/MetricKit + Swift doc://com.apple.documentation/documentation/Swift + """ + ) + } + + @Test("renders table headers for an empty catalog") + func rendersEmptyTable() throws { + // -- Arrange -- + let renderer = DefaultTechnologyListRenderer(output: .table) + + // -- Act -- + let output = try renderer.render([]) + + // -- Assert -- + #expect(output == "TECHNOLOGY IDENTIFIER") + } + @Test("renders a JSON array of technology objects") func rendersJSON() throws { // -- Arrange -- diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift b/Tests/CLITests/renderer/HumanDocumentationRendererContentTests.swift similarity index 90% rename from Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift rename to Tests/CLITests/renderer/HumanDocumentationRendererContentTests.swift index df964a5..498bbdb 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererContentTests.swift +++ b/Tests/CLITests/renderer/HumanDocumentationRendererContentTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CLI -@Suite("Text type documentation content rendering") -struct TextTypeDocumentationRendererContentTests { +@Suite("Human documentation content rendering") +struct HumanDocumentationRendererContentTests { @Test("renders overview prose, inline symbols, and indented code examples") func rendersOverview() throws { // -- Arrange -- @@ -25,7 +25,7 @@ struct TextTypeDocumentationRendererContentTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("Overview\n────────\n\n Implement body using `Text`.")) @@ -56,7 +56,7 @@ struct TextTypeDocumentationRendererContentTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( @@ -90,7 +90,7 @@ struct TextTypeDocumentationRendererContentTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains(" 3. Create a view.\n\n • Add a body.\n 4. Preview it.")) @@ -110,7 +110,7 @@ struct TextTypeDocumentationRendererContentTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains(" A `View` with body and style.")) @@ -126,7 +126,7 @@ struct TextTypeDocumentationRendererContentTests { """) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift b/Tests/CLITests/renderer/HumanDocumentationRendererReferenceTests.swift similarity index 90% rename from Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift rename to Tests/CLITests/renderer/HumanDocumentationRendererReferenceTests.swift index 0137654..424d738 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererReferenceTests.swift +++ b/Tests/CLITests/renderer/HumanDocumentationRendererReferenceTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CLI -@Suite("Text type documentation reference rendering") -struct TextTypeDocumentationRendererReferenceTests { +@Suite("Human documentation reference rendering") +struct HumanDocumentationRendererReferenceTests { @Test("renders the canonical documentation URL") func rendersCanonicalURL() throws { // -- Arrange -- @@ -32,7 +32,7 @@ struct TextTypeDocumentationRendererReferenceTests { data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( @@ -84,7 +84,7 @@ struct TextTypeDocumentationRendererReferenceTests { technology: "swiftui", path: "/documentation/swiftui/button")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains(" • init(intent:label:)\n Creates a button that performs an `AppIntent`.")) diff --git a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift b/Tests/CLITests/renderer/HumanDocumentationRendererTests.swift similarity index 93% rename from Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift rename to Tests/CLITests/renderer/HumanDocumentationRendererTests.swift index 4f95f7c..787bce3 100644 --- a/Tests/CLITests/renderer/TextTypeDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/HumanDocumentationRendererTests.swift @@ -3,8 +3,8 @@ import Testing @testable import CLI -@Suite("Text type documentation renderer") -struct TextTypeDocumentationRendererTests { +@Suite("Human documentation renderer") +struct HumanDocumentationRendererTests { @Test("renders the type summary and declaration") func rendersSummaryAndDeclaration() throws { // -- Arrange -- @@ -39,7 +39,7 @@ struct TextTypeDocumentationRendererTests { data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( @@ -107,7 +107,7 @@ struct TextTypeDocumentationRendererTests { data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("Deprecated\n──────────\n Use HangDiagnostic instead.")) @@ -139,7 +139,7 @@ struct TextTypeDocumentationRendererTests { data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( @@ -218,7 +218,7 @@ struct TextTypeDocumentationRendererTests { data, destination: .init(technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("Inherits From\n─────────────\n • MXDiagnostic")) @@ -268,7 +268,7 @@ struct TextTypeDocumentationRendererTests { technology: "swift", path: "/documentation/swift/string")) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect( diff --git a/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift b/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift index 5d92cf0..55ffd8a 100644 --- a/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift +++ b/Tests/CLITests/renderer/NormalizedTextDocumentationRendererTests.swift @@ -12,10 +12,10 @@ struct NormalizedTextDocumentationRendererTests { technology: "metrickit", path: "/documentation/metrickit/mxhangdiagnostic/member(_:)" ) let page = try DocumentationPageDecoder().decode(DocumentationFixtures.member, destination: destination) - let presentation = page + let presentation = DocumentationPresenter().page(page, audience: .human) // -- Act -- - let output = TextTypeDocumentationRenderer().render(presentation) + let output = HumanDocumentationRenderer().render(presentation) // -- Assert -- #expect(output.hasPrefix("member(_:)\n━━━━━━━━━━\nMethod · MetricKit\nSymbol kind: method")) @@ -45,7 +45,7 @@ struct NormalizedTextDocumentationRendererTests { let page = try DocumentationPageDecoder().decode(data, destination: destination) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("Instance Property\nSymbol kind: property")) @@ -61,7 +61,7 @@ struct NormalizedTextDocumentationRendererTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("15.0–26.0")) @@ -76,7 +76,7 @@ struct NormalizedTextDocumentationRendererTests { ) // -- Act -- - let output = TextTypeDocumentationRenderer().render(page) + let output = HumanDocumentationRenderer().render(DocumentationPresenter().page(page, audience: .human)) // -- Assert -- #expect(output.contains("string.")) diff --git a/Tests/CLITests/skills/BundledAgentSkillsTests.swift b/Tests/CLITests/skills/BundledAgentSkillsTests.swift index bd1330f..ccd1feb 100644 --- a/Tests/CLITests/skills/BundledAgentSkillsTests.swift +++ b/Tests/CLITests/skills/BundledAgentSkillsTests.swift @@ -29,6 +29,23 @@ struct BundledAgentSkillsTests { #expect(content.hasPrefix("---\nname: apple-docs\n")) } + @Test("research examples select the agent audience and structured evidence uses both flags") + func researchExamplesUseAgentMode() { + // -- Arrange -- + let skills = BundledAgentSkills.all + + // -- Act -- + let examples = skills.flatMap { $0.content.split(separator: "\n") }.filter { + $0.hasPrefix("apple-docs types ") || $0.hasPrefix("apple-docs technologies ") + } + + // -- Assert -- + #expect(!examples.isEmpty) + #expect(examples.allSatisfy { $0.contains("--agent") }) + #expect(examples.contains { $0.contains("--agent --json") }) + #expect(skills.allSatisfy { !$0.content.contains("aliases `--json`") }) + } + @Test("returns no skill for an unknown name") func rejectsUnknownSkill() { // -- Arrange --