diff --git a/Package.swift b/Package.swift index fe5447e8e..acc91a1be 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription let package = Package( name: "XcodeProj", + platforms: [.macOS(.v11)], products: [ .library(name: "XcodeProj", targets: ["XcodeProj"]), ], diff --git a/Sources/XcodeProj/Utils/Collection+Extras.swift b/Sources/XcodeProj/Utils/Collection+Extras.swift new file mode 100644 index 000000000..c332d2ce3 --- /dev/null +++ b/Sources/XcodeProj/Utils/Collection+Extras.swift @@ -0,0 +1,34 @@ +extension Collection where Element: BinaryInteger, Index == Int { + @inlinable + @inline(__always) + func containsCString(_ cString: T) -> Bool where T.Element: BinaryInteger, T.Index == Int { + guard !cString.isEmpty else { return true } + + // Drop null terminator if present + let subarrayCount = cString.last == 0 + ? cString.count - 1 + : cString.count + + guard subarrayCount <= count else { return false } + + let lastSubarrayStartingPos = count - subarrayCount + var i = 0 + while i <= lastSubarrayStartingPos { + var match = true + var j = 0 + while j < subarrayCount { + if self[i + j] != cString[j] { + match = false + break + } + j += 1 + } + if match { + return true + } + + i += 1 + } + return false + } +} diff --git a/Sources/XcodeProj/Utils/CommentedString.swift b/Sources/XcodeProj/Utils/CommentedString.swift index 2420f8e05..278214517 100644 --- a/Sources/XcodeProj/Utils/CommentedString.swift +++ b/Sources/XcodeProj/Utils/CommentedString.swift @@ -1,5 +1,31 @@ import Foundation +private extension UInt8 { + static let tab: UInt8 = 9 // '\t' + static let newline: UInt8 = 10 // '\n' + static let backslash: UInt8 = 92 // '\' + static let underscore: UInt8 = 95 // '_' + static let doubleQuotes: UInt8 = 34 // '"' + static let dollar: UInt8 = 36 // '$' + static let slash: UInt8 = 47 // '/' + + static let dot: UInt8 = 46 // '.' + static let nine: UInt8 = 57 // '9' + + static let capitalA: UInt8 = 65 // 'A' + static let capitalZ: UInt8 = 90 // 'Z' + + static let smallA: UInt8 = 97 // 'a' + static let smallN: UInt8 = 110 // 'n' + static let smallT: UInt8 = 116 // 't' + static let smallZ: UInt8 = 122 // 'z' +} + +private extension ContiguousArray { + static let slashesUTF8CString = "//".utf8CString + static let threeUnderscoresUTF8CString = "___".utf8CString +} + /// String that includes a comment struct CommentedString { /// Entity string value. @@ -18,19 +44,6 @@ struct CommentedString { self.comment = comment } - /// Set of characters that are invalid. - private static let invalidCharacters: CharacterSet = { - var invalidSet = CharacterSet(charactersIn: "_$") - invalidSet.insert(charactersIn: UnicodeScalar(".") ... UnicodeScalar("9")) - invalidSet.insert(charactersIn: UnicodeScalar("A") ... UnicodeScalar("Z")) - invalidSet.insert(charactersIn: UnicodeScalar("a") ... UnicodeScalar("z")) - invalidSet.invert() - return invalidSet - }() - - /// Set of characters that are invalid. - private static let specialCheckCharacters = CharacterSet(charactersIn: "_/") - /// Returns a valid string for Xcode projects. var validString: String { switch string { @@ -40,31 +53,31 @@ struct CommentedString { default: break } - if string.rangeOfCharacter(from: CommentedString.invalidCharacters) == nil { - if string.rangeOfCharacter(from: CommentedString.specialCheckCharacters) == nil { - return string - } else if !string.contains("//"), !string.contains("___") { - return string + var str = string + return str.withUTF8 { buffer -> String in + let containsInvalidCharacters = buffer.containsInvalidCharacters + + if !containsInvalidCharacters() { + let containsSpecialCheckCharacters = buffer.containsSpecialCheckCharacters() + + if !containsSpecialCheckCharacters { + return string + } else if !buffer.containsCString(ContiguousArray.slashesUTF8CString), + !buffer.containsCString(ContiguousArray.threeUnderscoresUTF8CString) { + return string + } } - } - let escaped = string.reduce(into: "") { escaped, character in - // As an optimization, only look at the first scalar. This means we're doing a numeric comparison instead - // of comparing arbitrary-length characters. This is safe because all our cases are a single scalar. - switch character.unicodeScalars.first { - case "\\": - escaped.append("\\\\") - case "\"": - escaped.append("\\\"") - case "\t": - escaped.append("\\t") - case "\n": - escaped.append("\\n") - default: - escaped.append(character) + // calculate exact size + let escapedCapacity = buffer.escapedCommentCapacity() + + // write directly into String storage + return String(unsafeUninitializedCapacity: escapedCapacity) { stringBuffer in + stringBuffer.fillValidString(from: buffer) + + return escapedCapacity } } - return "\"\(escaped)\"" } } @@ -95,3 +108,107 @@ extension CommentedString: ExpressibleByStringLiteral { self.init(value) } } + +// MARK: - Private + +private extension UnsafeMutableBufferPointer { + /// Fills preallocated `UnsafeBufferPointer` + func fillValidString(from buffer: UnsafeBufferPointer) { + var outIndex = 0 + + self[outIndex] = .doubleQuotes + outIndex += 1 + + for character in buffer { + switch character { + case .backslash: + self[outIndex] = .backslash + self[outIndex + 1] = .backslash + outIndex += 2 + + case .doubleQuotes: + self[outIndex] = .backslash + self[outIndex + 1] = .doubleQuotes + outIndex += 2 + + case .tab: + self[outIndex] = .backslash + self[outIndex + 1] = .smallT + outIndex += 2 + + case .newline: + self[outIndex] = .backslash + self[outIndex + 1] = .smallN + outIndex += 2 + + default: + self[outIndex] = character + outIndex += 1 + } + } + + self[outIndex] = .doubleQuotes + } +} + +private extension UnsafeBufferPointer { + /// Valid characters are: + /// 1. `_` and `$` + /// 2. `.`...`9` + /// 3. `A`...`Z` + /// 4. `a`...`z` + func containsInvalidCharacters() -> Bool { + for character in self { + // character == '_' || character == '$' + if character == .underscore || character == .dollar { + continue + } + // character >= '.' && character <= '9' + if character >= .dot, character <= .nine { + continue + } + // character >= 'A' && character <= 'Z' + if character >= .capitalA, character <= .capitalZ { + continue + } + // character >= 'a' && character <= 'z' + if character >= .smallA, character <= .smallZ { + continue + } + + return true + } + + return false + } + + /// Special check characters are `_` and `/` + func containsSpecialCheckCharacters() -> Bool { + for character in self { + if character == .underscore || character == .slash { + return true + } + } + + return false + } + + /// Calculates escaped string size + /// Basically, `count + count(where: { [.backslash, .doubleQuotes, .tab, .newline].contains($0) }` + func escapedCommentCapacity() -> Int { + var escapeCount = 0 + + for character in self { + switch character { + case .backslash, .doubleQuotes, .tab, .newline: + escapeCount += 1 // each adds one extra byte + default: + break + } + } + + return count // original bytes + + escapeCount // extra escape bytes + + 2 // surrounding quotes + } +}