Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ let package = Package(
.testTarget(
name: "xcparseTests",
dependencies: ["xcparse", "testUtility"]),
.testTarget(
name: "XCParseCoreTests",
dependencies: ["XCParseCore"]),
.testTarget(
name: "appThinningConverterTests",
dependencies: ["Converter", "testUtility"]),
Expand Down
98 changes: 78 additions & 20 deletions Sources/XCParseCore/Version+XCPTooling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,90 @@ public extension Version {
return Version(23028, 0, 0)
}

static func xcresulttool() -> Version? {
guard let xcresulttoolVersionResult = XCResultToolCommand.Version().run() else {
/// Xcode 16+ changed xcresulttool version output from large build numbers
/// (e.g. 15500, 23028) to semantic versions (e.g. 26.0). This helper detects
/// the new scheme so threshold comparisons work correctly.
static func usesSemanticVersioning(_ version: Version) -> Bool {
return version.major < 100
}

static func parseXcresulttoolVersionOutput(_ xcresultVersionString: String) -> Version? {
let components = xcresultVersionString.components(separatedBy: CharacterSet(charactersIn: ",\n"))
for string in components {
let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedString.hasPrefix("xcresulttool version ") {
let xcresulttoolVersionString = trimmedString.replacingOccurrences(of: "xcresulttool version ", with: "")

if let xcresulttoolVersionInt = Int(xcresulttoolVersionString) {
return Version(xcresulttoolVersionInt, 0, 0)
}

if let parsedVersion = Version(string: xcresulttoolVersionString) {
return parsedVersion
}

let dotComponents = xcresulttoolVersionString.components(separatedBy: ".")
if let majorString = dotComponents.first, let major = Int(majorString) {
let minor = dotComponents.count > 1 ? Int(dotComponents[1]) ?? 0 : 0
let patch = dotComponents.count > 2 ? Int(dotComponents[2]) ?? 0 : 0
return Version(major, minor, patch)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

return nil
}
}

// Fallback: scan for any "version X.Y.Z" or "version X" pattern
guard let versionPattern = try? NSRegularExpression(pattern: #"version\s+(\d+(?:\.\d+)*)"#, options: .caseInsensitive) else {
return nil
}
do {
let xcresultVersionString = try xcresulttoolVersionResult.utf8Output()
let fullOutput = xcresultVersionString.trimmingCharacters(in: .whitespacesAndNewlines)
if let match = versionPattern.firstMatch(in: fullOutput, range: NSRange(fullOutput.startIndex..., in: fullOutput)),
let versionRange = Range(match.range(at: 1), in: fullOutput) {
let versionString = String(fullOutput[versionRange])

let components = xcresultVersionString.components(separatedBy: CharacterSet(charactersIn: ",\n"))
for string in components {
let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedString.hasPrefix("xcresulttool version ") {
let xcresulttoolVersionString = trimmedString.replacingOccurrences(of: "xcresulttool version ", with: "")
// Check to see if we can convert it to a number
var xcresulttoolVersion: Version?

if let xcresulttoolVersionInt = Int(xcresulttoolVersionString) {
xcresulttoolVersion = Version(xcresulttoolVersionInt, 0, 0)
} else {
xcresulttoolVersion = Version(string: xcresulttoolVersionString)
}

return xcresulttoolVersion
}
if let versionInt = Int(versionString) {
return Version(versionInt, 0, 0)
}

if let parsedVersion = Version(string: versionString) {
return parsedVersion
}

let dotComponents = versionString.components(separatedBy: ".")
if let majorString = dotComponents.first, let major = Int(majorString) {
let minor = dotComponents.count > 1 ? Int(dotComponents[1]) ?? 0 : 0
let patch = dotComponents.count > 2 ? Int(dotComponents[2]) ?? 0 : 0
return Version(major, minor, patch)
}
}

return nil
}

/// Whether `version` needs the `--legacy` flag for xcresulttool commands.
static func needsLegacyFlag(_ version: Version) -> Bool {
if usesSemanticVersioning(version) {
return true
}
return version >= xcresulttoolWithDeprecatedAPIs()
}

/// Whether `version` supports Unicode export paths.
static func supportsUnicodeExportPaths(_ version: Version) -> Bool {
if usesSemanticVersioning(version) {
return true
}
return version >= xcresulttoolCompatibleWithUnicodeExportPath()
}

static func xcresulttool() -> Version? {
guard let xcresulttoolVersionResult = XCResultToolCommand.Version().run() else {
return nil
}
do {
let xcresultVersionString = try xcresulttoolVersionResult.utf8Output()
return parseXcresulttoolVersionOutput(xcresultVersionString)
} catch {
print("Failed to parse xcresulttool version with error: \(error)")
return nil
Expand Down
6 changes: 2 additions & 4 deletions Sources/XCParseCore/XCResultToolCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,10 @@ open class XCResultToolCommand {

private let shouldAddLegacyFlag: Bool = {
guard let xcresulttoolVersion = Version.xcresulttool() else {
return false
return true
}

let versionWithDeprecatedAPIs = Version.xcresulttoolWithDeprecatedAPIs()

return xcresulttoolVersion >= versionWithDeprecatedAPIs
return Version.needsLegacyFlag(xcresulttoolVersion)
}()

private extension Array where Element: StringProtocol {
Expand Down
4 changes: 2 additions & 2 deletions Sources/xcparse/XCPParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ class XCPParser {
return compatability
}

let unicodeExport = Version.xcresulttoolCompatibleWithUnicodeExportPath()
if xcresulttoolVersion < unicodeExport {
if !Version.supportsUnicodeExportPaths(xcresulttoolVersion) {
let unicodeExport = Version.xcresulttoolCompatibleWithUnicodeExportPath()
// For explaination, see https://github.com/ChargePoint/xcparse/issues/30
let asciiDestinationPath = destination.lossyASCIIString() ?? destination
if asciiDestinationPath != destination {
Expand Down
181 changes: 181 additions & 0 deletions Tests/XCParseCoreTests/VersionParsingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
//
// VersionParsingTests.swift
// xcparseTests
//
// Tests for Version+XCPTooling.swift parsing logic
//

import XCTest
import TSCUtility
@testable import XCParseCore

final class VersionParsingTests: XCTestCase {

// MARK: - Plain integer version (pre-Xcode 26 format)

func testPlainIntegerVersion() {
let output = "xcresulttool version 23028, format version 3.53 (current)\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(23028, 0, 0))
}

func testPlainIntegerVersionWithoutTrailingInfo() {
let output = "xcresulttool version 23028\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(23028, 0, 0))
}

// MARK: - Dotted version (Xcode 26 beta format)

func testDottedVersionTwoComponents() {
let output = "xcresulttool version 26.0, format version 3.53 (current)\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 0, 0))
}

func testDottedVersionThreeComponents() {
let output = "xcresulttool version 26.0.0, format version 3.53 (current)\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 0, 0))
}

func testDottedVersionWithNonZeroPatch() {
let output = "xcresulttool version 26.1.2, format version 3.53 (current)\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 1, 2))
}

func testDottedVersionWithNonZeroMinor() {
let output = "xcresulttool version 26.3\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 3, 0))
}

// MARK: - Unparseable output

func testEmptyStringReturnsNil() {
let output = ""
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNil(version)
}

func testGarbageOutputReturnsNil() {
let output = "some completely unrelated output\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNil(version)
}

func testNoVersionNumberReturnsNil() {
let output = "xcresulttool version abc\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNil(version)
}

// MARK: - Fallback regex parsing

func testFallbackRegexWithDifferentPrefix() {
let output = "Tool version 23028\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(23028, 0, 0))
}

func testFallbackRegexWithDottedVersion() {
let output = "Tool version 26.0.1\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 0, 1))
}

// MARK: - Version scheme detection

func testSemanticVersioningDetected() {
XCTAssertTrue(Version.usesSemanticVersioning(Version(26, 0, 0)))
XCTAssertTrue(Version.usesSemanticVersioning(Version(16, 0, 0)))
XCTAssertFalse(Version.usesSemanticVersioning(Version(15500, 0, 0)))
XCTAssertFalse(Version.usesSemanticVersioning(Version(23028, 0, 0)))
}

// MARK: - needsLegacyFlag behavior

func testOldSchemeAboveThresholdNeedsLegacy() {
XCTAssertTrue(Version.needsLegacyFlag(Version(23028, 0, 0)))
}

func testOldSchemeBelowThresholdDoesNotNeedLegacy() {
XCTAssertFalse(Version.needsLegacyFlag(Version(15500, 0, 0)))
}

func testXcode26NeedsLegacy() {
// Xcode 26 uses new scheme — always needs legacy flag
XCTAssertTrue(Version.needsLegacyFlag(Version(26, 0, 0)))
}

// MARK: - supportsUnicodeExportPaths behavior

func testOldSchemeAboveThresholdSupportsUnicode() {
XCTAssertTrue(Version.supportsUnicodeExportPaths(Version(15500, 0, 0)))
}

func testOldSchemeBelowThresholdDoesNotSupportUnicode() {
XCTAssertFalse(Version.supportsUnicodeExportPaths(Version(10000, 0, 0)))
}

func testXcode26SupportsUnicode() {
// Xcode 26 uses new scheme — always supports Unicode
XCTAssertTrue(Version.supportsUnicodeExportPaths(Version(26, 0, 0)))
}

// MARK: - Edge cases

func testMultilineOutputWithVersionOnSecondLine() {
let output = "some header info\nxcresulttool version 23028, format version 3.53\n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(23028, 0, 0))
}

func testOutputWithExtraWhitespace() {
let output = " xcresulttool version 23028 \n"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(23028, 0, 0))
}

func testOutputWithCommasSeparatingComponents() {
let output = "xcresulttool version 26.0, format version 3.53 (current)"
let version = Version.parseXcresulttoolVersionOutput(output)
XCTAssertNotNil(version)
XCTAssertEqual(version, Version(26, 0, 0))
}

static var allTests = [
("testPlainIntegerVersion", testPlainIntegerVersion),
("testPlainIntegerVersionWithoutTrailingInfo", testPlainIntegerVersionWithoutTrailingInfo),
("testDottedVersionTwoComponents", testDottedVersionTwoComponents),
("testDottedVersionThreeComponents", testDottedVersionThreeComponents),
("testDottedVersionWithNonZeroPatch", testDottedVersionWithNonZeroPatch),
("testDottedVersionWithNonZeroMinor", testDottedVersionWithNonZeroMinor),
("testEmptyStringReturnsNil", testEmptyStringReturnsNil),
("testGarbageOutputReturnsNil", testGarbageOutputReturnsNil),
("testNoVersionNumberReturnsNil", testNoVersionNumberReturnsNil),
("testFallbackRegexWithDifferentPrefix", testFallbackRegexWithDifferentPrefix),
("testFallbackRegexWithDottedVersion", testFallbackRegexWithDottedVersion),
("testSemanticVersioningDetected", testSemanticVersioningDetected),
("testOldSchemeAboveThresholdNeedsLegacy", testOldSchemeAboveThresholdNeedsLegacy),
("testOldSchemeBelowThresholdDoesNotNeedLegacy", testOldSchemeBelowThresholdDoesNotNeedLegacy),
("testXcode26NeedsLegacy", testXcode26NeedsLegacy),
("testOldSchemeAboveThresholdSupportsUnicode", testOldSchemeAboveThresholdSupportsUnicode),
("testOldSchemeBelowThresholdDoesNotSupportUnicode", testOldSchemeBelowThresholdDoesNotSupportUnicode),
("testXcode26SupportsUnicode", testXcode26SupportsUnicode),
("testMultilineOutputWithVersionOnSecondLine", testMultilineOutputWithVersionOnSecondLine),
("testOutputWithExtraWhitespace", testOutputWithExtraWhitespace),
("testOutputWithCommasSeparatingComponents", testOutputWithCommasSeparatingComponents),
]
}