From ec9f655cc6a8ee9b82d0c166736a778806bdbb0a Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 28 Jan 2021 17:00:04 -0700 Subject: [PATCH] Align installed command output into columns for interactive terminal --- Sources/XcodesKit/Environment.swift | 2 + Sources/XcodesKit/XcodeInstaller.swift | 32 ++++-- Tests/XcodesKitTests/Environment+Mock.swift | 3 +- Tests/XcodesKitTests/XcodesKitTests.swift | 111 ++++++++++++++++++++ 4 files changed, 140 insertions(+), 8 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 1a1b2fb..fec957d 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -190,6 +190,8 @@ public struct Shell { } public var exit: (Int32) -> Void = { Darwin.exit($0) } + + public var isatty: () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 } } public struct Files { diff --git a/Sources/XcodesKit/XcodeInstaller.swift b/Sources/XcodesKit/XcodeInstaller.swift index d6f6167..5c1af02 100644 --- a/Sources/XcodesKit/XcodeInstaller.swift +++ b/Sources/XcodesKit/XcodeInstaller.swift @@ -639,16 +639,34 @@ public final class XcodeInstaller { public func printInstalledXcodes(directory: Path) -> Promise { Current.shell.xcodeSelectPrintPath() .done { pathOutput in - Current.files.installedXcodes(directory) + let installedXcodes = Current.files.installedXcodes(directory) .sorted { $0.version < $1.version } - .forEach { installedXcode in - var output = installedXcode.version.appleDescriptionWithBuildIdentifier - if pathOutput.out.hasPrefix(installedXcode.path.string) { - output += " (Selected)" - } + + // Add one so there's always at least one space between columns + let maxWidthOfFirstColumn = (installedXcodes.map(\.version.appleDescriptionWithBuildIdentifier.count).max() ?? 0) + 1 + + for installedXcode in installedXcodes { + let widthOfFirstColumnInThisRow = installedXcode.version.appleDescriptionWithBuildIdentifier.count + var spaceBetweenFirstAndSecondColumns = maxWidthOfFirstColumn - widthOfFirstColumnInThisRow + + var output = installedXcode.version.appleDescriptionWithBuildIdentifier + let selectedString = " (Selected)" + if pathOutput.out.hasPrefix(installedXcode.path.string) { + output += selectedString + spaceBetweenFirstAndSecondColumns -= selectedString.count + } + + // If outputting to an interactive terminal, align the columns so they're easier for a human to read + // Otherwise, separate columns by a tab character so it's easier for a computer to split up + if Current.shell.isatty() { + output += Array(repeating: " ", count: max(spaceBetweenFirstAndSecondColumns, 0)) + output += "\(installedXcode.path.string)" + } else { output += "\t\(installedXcode.path.string)" - Current.logging.log(output) } + + Current.logging.log(output) + } } } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 8555c1c..860cce1 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -34,7 +34,8 @@ extension Shell { readLine: { _ in return nil }, readSecureLine: { _, _ in return nil }, env: { _ in nil }, - exit: { _ in } + exit: { _ in }, + isatty: { true } ) } diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 13d742e..5d37914 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -906,4 +906,115 @@ final class XcodesKitTests: XCTestCase { """) } + + func test_Installed_InteractiveTerminal() { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + + // There are installed Xcodes + Current.files.contentsAtPath = { path in + if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path == "/Applications/Xcode-2.0.0.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-2.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path == "/Applications/Xcode-2.0.1-Release.Candidate.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-2.0.1.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else { + return nil + } + } + let installedXcodes = [ + InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, + InstalledXcode(path: Path("/Applications/Xcode-2.0.0.app")!)!, + InstalledXcode(path: Path("/Applications/Xcode-2.0.1-Release.Candidate.app")!)! + ] + Current.files.installedXcodes = { _ in installedXcodes } + + // One is selected + Current.shell.xcodeSelectPrintPath = { + Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) + } + + // Standard output is an interactive terminal + Current.shell.isatty = { true } + + installer.printInstalledXcodes(directory: Path.root/"Applications") + .cauterize() + + XCTAssertEqual( + log, + """ + 0.0 (ABC123) /Applications/Xcode-0.0.0.app + 2.0 (ABC123) (Selected) /Applications/Xcode-2.0.0.app + 2.0.1 Release Candidate (ABC123) /Applications/Xcode-2.0.1-Release.Candidate.app + + """ + ) + } + + func test_Installed_NonInteractiveTerminal() { + var log = "" + XcodesKit.Current.logging.log = { log.append($0 + "\n") } + + // There are installed Xcodes + Current.files.contentsAtPath = { path in + if path == "/Applications/Xcode-0.0.0.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-0.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path == "/Applications/Xcode-2.0.0.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-2.0.0.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path == "/Applications/Xcode-2.0.1-Release.Candidate.app/Contents/Info.plist" { + let url = Bundle.module.url(forResource: "Stub-2.0.1.Info", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else if path.contains("version.plist") { + let url = Bundle.module.url(forResource: "Stub.version", withExtension: "plist", subdirectory: "Fixtures")! + return try? Data(contentsOf: url) + } + else { + return nil + } + } + let installedXcodes = [ + InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!, + InstalledXcode(path: Path("/Applications/Xcode-2.0.0.app")!)!, + InstalledXcode(path: Path("/Applications/Xcode-2.0.1-Release.Candidate.app")!)! + ] + Current.files.installedXcodes = { _ in installedXcodes } + + // One is selected + Current.shell.xcodeSelectPrintPath = { + Promise.value((status: 0, out: "/Applications/Xcode-2.0.0.app/Contents/Developer", err: "")) + } + + // Standard output is not an interactive terminal + Current.shell.isatty = { false } + + installer.printInstalledXcodes(directory: Path.root/"Applications") + .cauterize() + + XCTAssertEqual( + log, + """ + 0.0 (ABC123)\t/Applications/Xcode-0.0.0.app + 2.0 (ABC123) (Selected)\t/Applications/Xcode-2.0.0.app + 2.0.1 Release Candidate (ABC123)\t/Applications/Xcode-2.0.1-Release.Candidate.app + + """ + ) + } + }