From c80265b88cc590cb59384b30a475e11edaca2585 Mon Sep 17 00:00:00 2001 From: Julian Date: Fri, 13 Mar 2026 00:32:30 -0700 Subject: [PATCH] fix: improve CLI text output formatting --- Sources/XCDocsCLI/CLIOutput.swift | 66 +++++++++--- Sources/XCDocsCLI/GetCommand.swift | 15 +-- .../SearchTextOutputTests.swift | 101 ++++++++++++++++++ 3 files changed, 155 insertions(+), 27 deletions(-) create mode 100644 Tests/XCDocsCLITests/SearchTextOutputTests.swift diff --git a/Sources/XCDocsCLI/CLIOutput.swift b/Sources/XCDocsCLI/CLIOutput.swift index e07f98a..7e77591 100644 --- a/Sources/XCDocsCLI/CLIOutput.swift +++ b/Sources/XCDocsCLI/CLIOutput.swift @@ -1,3 +1,4 @@ +import Darwin import ArgumentParser import Foundation import XCDocs @@ -15,18 +16,57 @@ func printJSON(_ value: T, prettyPrinted: Bool = true) throws { @available(macOS 26, *) func printSearchResults(_ results: [SearchResult]) { for (index, result) in results.enumerated() { - let renderedScore = result.score.isFinite ? String(format: "%.4f", result.score) : "nan" - print("\(index + 1). [\(renderedScore)] \(result.entry.id)") - - let metadata = [result.entry.framework, result.entry.kind?.rawValue, result.entry.title].compactMap { $0 } - .joined(separator: " | ") - if !metadata.isEmpty { print(" \(metadata)") } - - if let content = result.entry.content, !content.isEmpty { - let singleLineContent = content.replacingOccurrences(of: "\n", with: " ").trimmingCharacters( - in: .whitespacesAndNewlines - ) - print(" \(singleLineContent)") - } + print(renderTextEntry(result.entry, score: result.score, index: index + 1), terminator: "") } } + +@available(macOS 26, *) +func renderTextEntry(_ entry: DocumentationEntry, score: Double? = nil, index: Int? = nil) -> String { + let supportsStyling = terminalSupportsANSIStyling() + let prefix = index.map { "\($0). " } ?? "" + let indentation = String(repeating: " ", count: prefix.count) + let headline = prefix + (entry.title ?? entry.id) + + var lines = [renderBold(headline, enabled: supportsStyling)] + + if let kind = entry.kind?.rawValue { + lines.append("\(indentation)\(renderBold("Kind:", enabled: supportsStyling)) \(kind)") + } + + if let score { + let renderedScore = score.isFinite ? String(format: "%.4f", score) : "nan" + lines.append("\(indentation)\(renderBold("Relevance:", enabled: supportsStyling)) \(renderedScore)") + } + + lines.append("\(indentation)\(renderBold("ID:", enabled: supportsStyling)) \(entry.id)") + lines.append("") + + if let content = entry.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty { + lines.append(contentsOf: renderIndentedContentLines(content, indentation: indentation)) + } + + lines.append("") + lines.append("") + return lines.joined(separator: "\n") +} + +private func renderIndentedContentLines(_ content: String, indentation: String) -> [String] { + content.split(separator: "\n", omittingEmptySubsequences: false).map { "\(indentation)\($0)" } +} + +private func renderBold(_ text: String, enabled: Bool) -> String { + guard enabled else { return text } + return "\u{001B}[1m\(text)\u{001B}[22m" +} + +private func terminalSupportsANSIStyling() -> Bool { + if environmentValue(named: "CLICOLOR_FORCE") == "1" { return true } + if environmentValue(named: "NO_COLOR") != nil { return false } + guard isatty(STDOUT_FILENO) != 0 else { return false } + return environmentValue(named: "TERM") != "dumb" +} + +private func environmentValue(named name: String) -> String? { + guard let value = getenv(name) else { return nil } + return String(cString: value) +} diff --git a/Sources/XCDocsCLI/GetCommand.swift b/Sources/XCDocsCLI/GetCommand.swift index 689a2ef..6d58a96 100644 --- a/Sources/XCDocsCLI/GetCommand.swift +++ b/Sources/XCDocsCLI/GetCommand.swift @@ -24,19 +24,6 @@ struct GetCommand: AsyncParsableCommand { return } - printEntry(result) - } -} - -@available(macOS 26, *) -private func printEntry(_ result: DocumentationEntry) { - print(result.id) - - let metadata = [result.framework, result.kind?.rawValue, result.title].compactMap { $0 }.joined(separator: " | ") - if !metadata.isEmpty { print(metadata) } - - if let content = result.content, !content.isEmpty { - print("") - print(content) + print(renderTextEntry(result), terminator: "") } } diff --git a/Tests/XCDocsCLITests/SearchTextOutputTests.swift b/Tests/XCDocsCLITests/SearchTextOutputTests.swift new file mode 100644 index 0000000..29048b7 --- /dev/null +++ b/Tests/XCDocsCLITests/SearchTextOutputTests.swift @@ -0,0 +1,101 @@ +import Darwin +import Testing +import XCDocs + +@testable import XCDocsCLI + +@Suite("Search Text Output") +struct SearchTextOutputTests { + @Test + func searchResultUsesExpandedMetadataLayout() throws { + guard #available(macOS 26, *) else { return } + try assertSearchResultUsesExpandedMetadataLayout() + } + + @Test + func searchResultAlignsMetadataWithDoubleDigitIndex() throws { + guard #available(macOS 26, *) else { return } + try assertSearchResultAlignsMetadataWithDoubleDigitIndex() + } + + @Test + func getOutputUsesNoLeadingIndentation() throws { + guard #available(macOS 26, *) else { return } + try assertGetOutputUsesNoLeadingIndentation() + } +} + +@available(macOS 26, *) +private func assertSearchResultUsesExpandedMetadataLayout() throws { + let result = makeSearchResult( + score: 0.5405, + id: "/documentation/Metal/MTLClearColor/init(red:green:blue:alpha:)", + title: "init(red:green:blue:alpha:)" + ) + + let output = try withEnvironment(variable: "CLICOLOR_FORCE", value: "1") { + renderTextEntry(result.entry, score: result.score, index: 1) + } + + let expectedOutput = [ + bold("1. init(red:green:blue:alpha:)"), " \(bold("Kind:")) symbol", " \(bold("Relevance:")) 0.5405", + " \(bold("ID:")) /documentation/Metal/MTLClearColor/init(red:green:blue:alpha:)", "", " [content]", "", "", + ].joined(separator: "\n") + + #expect(output == expectedOutput) +} + +@available(macOS 26, *) +private func assertSearchResultAlignsMetadataWithDoubleDigitIndex() throws { + let result = makeSearchResult(score: 0.5120, id: "/documentation/Testing/result-12", title: "Result 12") + + let output = try withEnvironment(variable: "CLICOLOR_FORCE", value: "1") { + renderTextEntry(result.entry, score: result.score, index: 12) + } + + let expectedBlock = [ + bold("12. Result 12"), " \(bold("Kind:")) symbol", " \(bold("Relevance:")) 0.5120", + " \(bold("ID:")) /documentation/Testing/result-12", "", " [content]", "", "", + ].joined(separator: "\n") + + #expect(output.contains(expectedBlock)) +} + +@available(macOS 26, *) +private func assertGetOutputUsesNoLeadingIndentation() throws { + let entry = DocumentationEntry( + id: "/documentation/Metal/MTLClearColor/init(red:green:blue:alpha:)", + framework: "Metal", + kind: .symbol, + title: "init(red:green:blue:alpha:)", + content: "[content]" + ) + + let output = try withEnvironment(variable: "CLICOLOR_FORCE", value: "1") { renderTextEntry(entry) } + + let expectedOutput = [ + bold("init(red:green:blue:alpha:)"), "\(bold("Kind:")) symbol", + "\(bold("ID:")) /documentation/Metal/MTLClearColor/init(red:green:blue:alpha:)", "", "[content]", "", "", + ].joined(separator: "\n") + + #expect(output == expectedOutput) +} + +@available(macOS 26, *) +private func makeSearchResult(score: Double, id: String, title: String) -> SearchResult { + SearchResult( + score: score, + entry: DocumentationEntry(id: id, framework: "Metal", kind: .symbol, title: title, content: "[content]") + ) +} + +private func bold(_ string: String) -> String { "\u{001B}[1m\(string)\u{001B}[22m" } + +private func withEnvironment(variable: String, value: String, _ work: () throws -> T) throws -> T { + let previousValue = getenv(variable).map { String(cString: $0) } + setenv(variable, value, 1) + + defer { if let previousValue { setenv(variable, previousValue, 1) } else { unsetenv(variable) } } + + return try work() +}