From 641ab89b44045d53353759d0366b4499c836b1dd Mon Sep 17 00:00:00 2001 From: Tony Parker Date: Wed, 19 Feb 2025 14:35:26 -0800 Subject: [PATCH 1/3] Rewrite plutil for parity with all Darwin functionality --- Sources/plutil/CMakeLists.txt | 6 +- Sources/plutil/PLUContext.swift | 1173 +++++++++++++++++++++ Sources/plutil/PLUContext_Arguments.swift | 73 ++ Sources/plutil/PLUContext_KeyPaths.swift | 207 ++++ Sources/plutil/PLULiteralOutput.swift | 288 +++++ Sources/plutil/main.swift | 416 +------- 6 files changed, 1770 insertions(+), 393 deletions(-) create mode 100644 Sources/plutil/PLUContext.swift create mode 100644 Sources/plutil/PLUContext_Arguments.swift create mode 100644 Sources/plutil/PLUContext_KeyPaths.swift create mode 100644 Sources/plutil/PLULiteralOutput.swift diff --git a/Sources/plutil/CMakeLists.txt b/Sources/plutil/CMakeLists.txt index 19c18f59f0..7c5cddde07 100644 --- a/Sources/plutil/CMakeLists.txt +++ b/Sources/plutil/CMakeLists.txt @@ -13,7 +13,11 @@ ##===----------------------------------------------------------------------===## add_executable(plutil - main.swift) + main.swift + PLUContext_Arguments.swift + PLUContext_KeyPaths.swift + PLUContext.swift + PLULiteralOutput.swift) target_link_libraries(plutil PRIVATE Foundation) diff --git a/Sources/plutil/PLUContext.swift b/Sources/plutil/PLUContext.swift new file mode 100644 index 0000000000..74afd95db7 --- /dev/null +++ b/Sources/plutil/PLUContext.swift @@ -0,0 +1,1173 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import CoreFoundation + +func help(_ name: String) -> String { + name + +""" +: [command_option] [other_options] file... +The file '-' means stdin +Running in Swift mode +Command options are (-lint is the default): + -help show this message and exit + -lint check the property list files for syntax errors + -convert fmt rewrite property list files in format + fmt is one of: xml1 binary1 json swift objc + note: objc can additionally create a header by adding -header + -insert keypath -type value insert a value into the property list before writing it out + keypath is a key-value coding key path, with one extension: + a numerical path component applied to an array will act on the object at that index in the array + or insert it into the array if the numerical path component is the last one in the key path + type is one of: bool, integer, float, date, string, data, xml, json + -bool: YES if passed "YES" or "true", otherwise NO + -integer: any valid 64 bit integer + -float: any valid 64 bit float + -string: UTF8 encoded string + -date: a date in XML property list format, not supported if outputting JSON + -data: a base-64 encoded string + -xml: an XML property list, useful for inserting compound values + -json: a JSON fragment, useful for inserting compound values + -dictionary: inserts an empty dictionary, does not use value + -array: inserts an empty array, does not use value + + optionally, -append may be specified if the keypath references an array to append to the + end of the array + value YES, NO, a number, a date, or a base-64 encoded blob of data + -replace keypath -type value same as -insert, but it will overwrite an existing value + -remove keypath removes the value at 'keypath' from the property list before writing it out + -extract keypath fmt outputs the value at 'keypath' in the property list as a new plist of type 'fmt' + fmt is one of: xml1 binary1 json raw + an additional "-expect type" option can be provided to test that + the value at the specified keypath is of the specified "type", which + can be one of: bool, integer, float, string, date, data, dictionary, array + + when fmt is raw: + the following is printed to stdout for each value type: + bool: the string "true" or "false" + integer: the numeric value + float: the numeric value + string: as UTF8-encoded string + date: as RFC3339-encoded string in UTC timezone + data: as base64-encoded string + dictionary: each key on a new line + array: the count of items in the array + by default, the output is to stdout unless -o is specified + -type keypath outputs the type of the value at 'keypath' in the property list + can be one of: bool, integer, float, string, date, data, dictionary, array + -create fmt creates an empty plist of the specified format + file may be '-' for stdout + -p print property list in a human-readable fashion + (not for machine parsing! this 'format' is not stable) +There are some additional optional arguments that apply to the -convert, -insert, -remove, -replace, and -extract verbs: + -s be silent on success + -o path specify alternate file path name for result; + the -o option is used with -convert, and is only + useful with one file argument (last file overwrites); + the path '-' means stdout + -e extension specify alternate extension for converted files + -r if writing JSON, output in human-readable form + -n prevent printing a terminating newline if it is not part of the format, such as with raw + -- specifies that all further arguments are file names + +""" +} + +enum PLUCommand { + case lint(LintCommand) + case help(HelpCommand) + case convert(ConvertCommand) + case insertOrReplace(InsertCommand) + case remove(RemoveCommand) + case extractOrType(ExtractCommand) + case print(PrintCommand) + case create(CreateCommand) + + func execute() throws -> Bool { + return switch self { + case .lint(let cmd): try cmd.execute() + case .help(let cmd): try cmd.execute() + case .convert(let cmd): try cmd.execute() + case .insertOrReplace(let cmd): try cmd.execute() + case .remove(let cmd): try cmd.execute() + case .extractOrType(let cmd): try cmd.execute() + case .print(let cmd): try cmd.execute() + case .create(let cmd): try cmd.execute() + } + } + + /// Initialize a command with a set of arguments. + init(arguments inArguments: [String], outputFileHandle: FileHandle, errorFileHandle: FileHandle) throws { + // Some argument parsing is done here, then the rest is done inside each command using a combination of `PLUContextArguments` and its own custom argument handling. + // The format of the arguments is bespoke to plutil and does not follow standard argument parsing rules. + + var arguments = inArguments + + // Get the process path, for help + guard let path = arguments.popFirst() else { + throw PLUContextError.argument("No files specified.") + } + + // The command should be the second argument passed + guard let specifiedCommand = arguments.popFirst() else { + throw PLUContextError.argument("No files specified.") + } + + switch specifiedCommand { + case "-help": + let processName = path.lastComponent ?? "plutil" + self = .help(HelpCommand(name: processName, output: outputFileHandle)) + case "-lint": + self = .lint(LintCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments)) + case "-convert": + guard let inFormat = arguments.popFirst() else { + throw PLUContextError.argument("Missing format specifier for command.") + } + + let format = try PlutilEmissionFormat(argumentValue: inFormat) + + if arguments.first == "-header" { + // header isn't supported for any other convert command + guard format == .objc else { + throw PLUContextError.argument("-header is only valid for objc literal conversions.") + } + + // throw away the -header arg + arguments.removeFirst() + self = .convert(ConvertCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, specifiedFormat: format, outputObjCHeader: true)) + } else { + self = .convert(ConvertCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, specifiedFormat: format, outputObjCHeader: false)) + } + case "-p": + self = .print(PrintCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments)) + case "-insert": + self = .insertOrReplace(InsertCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, replace: false)) + case "-replace": + self = .insertOrReplace(InsertCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, replace: true)) + case "-remove": + guard let keyPath = arguments.popFirst() else { + throw PLUContextError.argument("'Remove' requires a key path.") + } + + self = .remove(RemoveCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, keyPath: keyPath)) + case "-extract": + guard let keyPath = arguments.popFirst() else { + throw PLUContextError.argument("'Extract' requires a key path and a plist format.") + } + + guard let inFormat = arguments.popFirst() else { + throw PLUContextError.argument("'Extract' requires a key path and a plist format.") + } + + let format = try PlutilEmissionFormat(argumentValue: inFormat) + + if arguments.first == "-expect" { + // Throw away -expect + arguments.removeFirst() + + guard let inExpect = arguments.popFirst() else { + throw PLUContextError.argument("-expect requires a type argument.") + } + + guard let expect = PlutilExpectType(rawValue: inExpect) else { + throw PLUContextError.argument("-expect type [\(inExpect)] not valid.") + } + + self = .extractOrType(ExtractCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, keyPath: keyPath, specifiedFormat: format, expectType: expect)) + } else { + self = .extractOrType(ExtractCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, keyPath: keyPath, specifiedFormat: format, expectType: nil)) + } + case "-type": + // This is a special case of 'extract' that verifies the type of the value at the key path + guard let keyPath = arguments.popFirst() else { + throw PLUContextError.argument("'Extract' requires a key path and a plist format.") + } + + if arguments.first == "-expect" { + // Throw away -expect + arguments.removeFirst() + + guard let inExpect = arguments.popFirst() else { + throw PLUContextError.argument("-expect requires a type argument.") + } + + guard let expect = PlutilExpectType(rawValue: inExpect) else { + throw PLUContextError.argument("-expect type [\(inExpect)] not valid.") + } + + self = .extractOrType(ExtractCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, keyPath: keyPath, specifiedFormat: .type, expectType: expect)) + } else { + self = .extractOrType(ExtractCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, keyPath: keyPath, specifiedFormat: .type, expectType: nil)) + } + case "-create": + guard let inFormat = arguments.popFirst() else { + throw PLUContextError.argument("'Create' requires a plist format.") + } + + // Historical meaning for "NoConversion" here is xml + let format = try PlutilEmissionFormat(argumentValue: inFormat) ?? .xml + + self = .create(CreateCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments, format: format)) + default: + // No other command + self = .lint(LintCommand(output: outputFileHandle, errorOutput: errorFileHandle, arguments: arguments)) + } + } +} + +/// An enum representing the possible plist emission formats. If initialized with a raw value, and the result is `nil`, treat that as no conversion. +enum PlutilEmissionFormat { + case openStep + case xml + case binary + case json + case swift + case objc + case raw + case type + + var propertyListFormat: PropertyListSerialization.PropertyListFormat { + switch self { + case .xml: .xml + default: .binary + } + } + + init?(argumentValue: String) throws { + if argumentValue == "NoConversion" { + return nil + } else { + self = switch argumentValue { + case "openStep": .openStep + case "xml1": .xml + case "binary1": .binary + case "json": .json + case "swift": .swift + case "objc": .objc + case "raw": .raw + case "type": .type // aka "EmissionFormat" + default: + throw PLUContextError.argument("Unknown format specifier: \(argumentValue)") + } + } + } +} + +extension NSNumber { + enum BetterSwiftType { + case `true` + case `false` + case signed + case unsigned + case double + case float + case other + } + + var betterSwiftType: BetterSwiftType { + if self === kCFBooleanTrue { + return .true + } else if self === kCFBooleanFalse { + return .false + } + switch UInt8(self.objCType.pointee) { + case UInt8(ascii: "c"), UInt8(ascii: "s"), UInt8(ascii: "i"), UInt8(ascii: "l"), UInt8(ascii: "q"): + return .signed + case UInt8(ascii: "C"), UInt8(ascii: "S"), UInt8(ascii: "I"), UInt8(ascii: "L"), UInt8(ascii: "Q"): + return .unsigned + case UInt8(ascii: "d"): + return .double + case UInt8(ascii: "f"): + return .float + default: + // Something else + return .other + } + } + + /// This number formatted for raw, Swift, or ObjC output (all are the same). + func propertyListFormatted() throws -> String { + // For now, use the Objective-C based formatting API for consistency with pre-Swift plutil. + return switch betterSwiftType { + case .true: + "true" + case .false: + "false" + case .signed: + String(format: "%lld", int64Value) + case .unsigned: + String(format: "%llu", uint64Value) + case .double: + // TODO: We know this should be %lf - but for now we use %f for compatibility with the ObjC implementation. + String(format: "%f", doubleValue) + case .float: + String(format: "%f", doubleValue) + case .other: + throw PLUContextError.invalidPropertyListObject("Incorrect numeric type for literal \(objCType)") + } + } +} + +enum PlutilExpectType : String { + case `any` = "(any)" + case boolean = "bool" + case integer = "integer" + case float = "float" + case string = "string" + case array = "array" + case dictionary = "dictionary" + case date = "date" + case data = "data" + + init(propertyList: Any) { + if let num = propertyList as? NSNumber { + switch num.betterSwiftType { + case .true, .false: + self = .boolean + case .signed, .unsigned: + self = .integer + case .double, .float: + self = .float + case .other: + self = .any + } + } else if propertyList is String { + self = .string + } else if propertyList is [Any] { + self = .array + } else if propertyList is [AnyHashable: Any] { + self = .dictionary + } else if propertyList is Date { + self = .date + } else if propertyList is Data { + self = .data + } else { + // Something else + self = .any + } + } + + var description: String { + rawValue + } +} + +enum PLUContextError : Error { + case argument(String) + case invalidPropertyListObject(String) + case invalidPropertyList(plistError: NSError, jsonError: NSError) + + var description: String { + switch self { + case .argument(let description): + return description + case .invalidPropertyListObject(let description): + return description + case .invalidPropertyList(let plistError, let jsonError): + let plistErrorMessage = plistError.userInfo[NSDebugDescriptionErrorKey] ?? "" + let jsonErrorMessage = jsonError.userInfo[NSDebugDescriptionErrorKey] ?? "" + + return "Property List error: \(plistErrorMessage) / JSON error: \(jsonErrorMessage)" + } + } +} + +extension Array { + // [String] does not have SubSequence == Self, so `Collection`'s `popFirst` is unavailable. + // We're dealing with tiny arrays, so just deal with the potentially not-O(1) behavior of removeFirst. + mutating func popFirst() -> Element? { + guard !isEmpty else { return nil } + return removeFirst() + } +} + +#if FOUNDATION_FRAMEWORK +// This should be Foundation API, but it's not yet. Since this is an executable and not a framework we can use the retroactive conformance until it is ready. +extension FileHandle : @retroactive TextOutputStream { + public func write(_ string: String) { + write(string.data(using: .utf8)!) + } +} +#else +extension FileHandle : TextOutputStream { + public func write(_ string: String) { + write(string.data(using: .utf8)!) + } +} +#endif + +extension FileHandle { + func write(_ string: String, aboutPath path: String) { + let fixedPath = path == "-" ? "" : path + write("\(fixedPath): \(string)") + write("\n") + } + + func write(_ error: Error, aboutPath path: String) { + let fixedPath = path == "-" ? "" : path + write("\(fixedPath): ") + if let pluError = error as? PLUContextError { + // Don't localize these + write(pluError.description) + } else { + let debugDescription = (error as NSError).userInfo[NSDebugDescriptionErrorKey] + write("(\(debugDescription ?? error.localizedDescription))") + } + write("\n") + } + +} + +// MARK: - Commands + + +struct LintCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + + func execute() throws -> Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + // lint validates some of its arguments + if let _ = parsedArguments.outputFileName { + throw PLUContextError.argument("-o is not used with -lint.") + } + + if let _ = parsedArguments.outputFileExtension { + throw PLUContextError.argument("-e is not used with -lint.") + } + + var oneError = false + for path in parsedArguments.paths { + do { + let data = try readPath(path) + let _ = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + + // FEATURE: This linting option has never linted JSON, but it could. Optionally -- since property lists are not actually JSON. + if !(parsedArguments.silent ?? false) { + output.write("OK", aboutPath: path) + } + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + + return !oneError + } +} + +struct HelpCommand { + let name: String + let output: FileHandle + func execute() throws -> Bool { + output.write(help(name)) + return true + } +} + +struct ConvertCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + /// `nil` means no conversion. + let specifiedFormat: PlutilEmissionFormat? + let outputObjCHeader: Bool + + func execute() throws -> Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + var oneError = false + for path in parsedArguments.paths { + + do { + let fileData = try readPath(path) + let (plist, existingFormat) = try readPropertyList(fileData) + + let outputFormat = specifiedFormat ?? existingFormat + + try writePropertyList(plist, path: path, standardOutput: output, outputFormat: outputFormat, outputName: parsedArguments.outputFileName, extensionName: parsedArguments.outputFileExtension, originalKeyPath: nil, readable: parsedArguments.readable, terminatingNewline: parsedArguments.terminatingNewline, outputObjCHeader: outputObjCHeader) + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + + return !oneError + } +} + +struct InsertCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + let replace: Bool + + func execute() throws -> Bool { + // First argument must be the key path + var remainingArguments = arguments + + guard let keyPath = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value.") + } + + // Second argument must be the type + guard let type = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value.") + } + + var value: Any + + switch type { + case "-bool": + guard let boolValue = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + if boolValue.lowercased() == "true" || boolValue.lowercased() == "yes" { + value = NSNumber(value: true) + } else { + value = NSNumber(value: false) + } + case "-integer": + guard let integerValue = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + value = NSNumber(value: (integerValue as NSString).integerValue) + case "-date": + guard let dateValue = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + // Hack to parse dates just like plists do + let xmlPlistDateString = "value\(dateValue)" + let xmlPlistData = xmlPlistDateString.data(using: .utf8)! + let xmlPlistDict = try? PropertyListSerialization.propertyList(from: xmlPlistData, format: nil) + value = (xmlPlistDict as! [String: Any])["value"]! + case "-data": + guard let dataValue = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + + guard let data = Data(base64Encoded: dataValue, options: .ignoreUnknownCharacters) else { + throw PLUContextError.argument("Invalid base64 data in argument") + } + + value = data + case "-float": + guard let floatValue = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + value = NSNumber(value: (floatValue as NSString).doubleValue) + case "-xml": + guard let xmlString = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + + do { + value = try PropertyListSerialization.propertyList(from: xmlString.data(using: .utf8)!, format: nil) + } catch { + throw PLUContextError.argument("Unable to parse xml property list: \(error)") + } + case "-json": + guard let jsonString = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + + do { + value = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: [.allowFragments, .mutableLeaves, .mutableContainers]) + } catch { + throw PLUContextError.argument("Unable to parse JSON: \(error)") + } + case "-string": + guard let string = remainingArguments.popFirst() else { + throw PLUContextError.argument("'Insert' and 'Replace' require a key path, a type, and a value2.") + } + value = string + case "-dictionary": + // No next argument required + value = Dictionary() + case "-array": + // No next argument required + value = Array() + default: + throw PLUContextError.argument("Unknown insert or replace type \(type)") + } + + var append = false + if let next = remainingArguments.first, next == "-append" { + remainingArguments.removeFirst() + if replace == false { + append = true + } + } + + // Remaining arguments are append and path names + let parsedArguments = try PLUContextArguments(arguments: remainingArguments) + + var oneError = false + for path in parsedArguments.paths { + do { + let data = try readPath(path) + let (inputPropertyList, outputFormat) = try readPropertyList(data) + + let propertyList = try insertValue(value, atKeyPath: keyPath, in: inputPropertyList, replacing: replace, appending: append) + + try writePropertyList(propertyList, path: path, standardOutput: output, outputFormat: outputFormat, outputName: parsedArguments.outputFileName, extensionName: parsedArguments.outputFileExtension, originalKeyPath: keyPath, readable: parsedArguments.readable, terminatingNewline: parsedArguments.terminatingNewline, outputObjCHeader: false) + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + return !oneError + } +} + +struct RemoveCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + let keyPath: String + + func execute() throws -> Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + var oneError = false + for path in parsedArguments.paths { + do { + let data = try readPath(path) + let (inputPropertyList, outputFormat) = try readPropertyList(data) + + guard let propertyList = try removeValue(atKeyPath: keyPath, in: inputPropertyList) else { + throw PLUContextError.argument("Removing value resulted in an empty property list at \(keyPath)") + } + + try writePropertyList(propertyList, path: path, standardOutput: output, outputFormat: outputFormat, outputName: parsedArguments.outputFileName, extensionName: parsedArguments.outputFileExtension, originalKeyPath: keyPath, readable: parsedArguments.readable, terminatingNewline: parsedArguments.terminatingNewline, outputObjCHeader: false) + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + return !oneError + } +} + +struct ExtractCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + let keyPath: String + let specifiedFormat: PlutilEmissionFormat? + let expectType: PlutilExpectType? + + func execute() throws -> Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + var oneError = false + for path in parsedArguments.paths { + do { + let data = try readPath(path) + let (inputPropertyList, existingFormat) = try readPropertyList(data) + + let outputFormat = specifiedFormat ?? existingFormat + + guard let propertyList = value(atKeyPath: keyPath, in: inputPropertyList) else { + throw PLUContextError.argument("Could not extract value, error: No value at that key path or invalid key path: \(keyPath)") + } + + if let expectType { + let actualType = PlutilExpectType(propertyList: propertyList) + if actualType != expectType { + throw PLUContextError.invalidPropertyListObject("Value at [\(keyPath)] expected to be \(expectType) but is \(actualType)") + } + } + + try writePropertyList(propertyList, path: path, standardOutput: output, outputFormat: outputFormat, outputName: parsedArguments.outputFileName, extensionName: parsedArguments.outputFileExtension, originalKeyPath: keyPath, readable: parsedArguments.readable, terminatingNewline: parsedArguments.terminatingNewline, outputObjCHeader: false) + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + return !oneError + } +} + +struct PrintCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + + func prettyPrint(_ value: Any, indent: Int, spacing: Int) throws -> String { + var result = "" + if let dictionary = value as? [String: Any] { + let sortedKeys = Array(dictionary.keys).sorted(by: sortDictionaryKeys) + + result.append("{\n") + for key in sortedKeys { + let value = dictionary[key]! + result.append(_indentation(forDepth: indent, numberOfSpaces: 1)) + result.append("\"\(key)\" => ") + result.append(try prettyPrint(value, indent: indent + spacing, spacing: spacing)) + } + result.append(_indentation(forDepth: indent - spacing, numberOfSpaces: 1)) + result.append("}\n") + } else if let array = value as? [Any] { + result.append("[\n") + for (index, value) in array.enumerated() { + result.append(_indentation(forDepth: indent, numberOfSpaces: 1)) + result.append("\(index) => ") + result.append(try prettyPrint(value, indent: indent + spacing, spacing: spacing)) + } + result.append(_indentation(forDepth: indent - spacing, numberOfSpaces: 1)) + result.append("]\n") + } else if let string = value as? String { + result.append("\"\(string)\"\n") + } else if let data = value as? Data { + let count = data.count + result.append("{length = \(count), bytes = 0x") + if count > 24 { + for i in stride(from: 0, to: 16, by: 2) { + result.append(String(data[i], radix: 16)) + } + result.append("... ") + for i in (count - 8).. Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + // print validates a few more of its arguments than the other commands + if let _ = parsedArguments.outputFileName { + throw PLUContextError.argument("-o is not used with -p.") + } + + if let _ = parsedArguments.outputFileExtension { + throw PLUContextError.argument("-e is not used with -p.") + } + + if let _ = parsedArguments.silent { + // We print a message but just continue on; not an error + errorOutput.write("-s doesn't make a lot of sense with -p.\n") + } + + var oneError = false + for path in parsedArguments.paths { + do { + let data = try readPath(path) + var canContainInfoPlist = false + +#if FOUNDATION_FRAMEWORK + // Only Darwin executables can contain Info.plist content in their mach-o segment. This function copies it out of the segment and parses it with CFPropertyList. + let embeddedPListResult = _CFBundleCopyInfoDictionaryForExecutableFileData(data as NSData, &canContainInfoPlist) + if let embeddedPListResult { + // consume the retain here + let cf = embeddedPListResult + if let dict = cf as? [String : Any] { + let result = try prettyPrint(dict, indent: 2, spacing: 2) + output.write(result) + } else { + // This really should not be possible, but it's difficult to prove that from all of the casting going on in the CF path + oneError = true + throw PLUContextError.argument("\(path): file was executable or library type but embedded Info.plist did not contain String keys") + } + continue + } else if canContainInfoPlist { + throw PLUContextError.invalidPropertyListObject("file was executable or library type but did not contain an embedded Info.plist") + } +#endif + let (plist, _) = try readPropertyList(data) + let result = try prettyPrint(plist, indent: 2, spacing: 2) + output.write(result) + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + + return !oneError + } +} + +struct CreateCommand { + let output: FileHandle + let errorOutput: FileHandle + let arguments: [String] + let format: PlutilEmissionFormat + + func execute() throws -> Bool { + let parsedArguments = try PLUContextArguments(arguments: arguments) + + var oneError = false + for path in parsedArguments.paths { + do { + let data: Data + + switch format { + case .json: + data = try JSONSerialization.data(withJSONObject: [:], options: parsedArguments.readable ? [.prettyPrinted, .sortedKeys] : []) + case .xml: + data = try PropertyListSerialization.data(fromPropertyList: [:], format: .xml, options: 0) + case .binary: + data = try PropertyListSerialization.data(fromPropertyList: [:], format: .binary, options: 0) + case .swift: + // Somewhat useless, but historically what plutil provides + data = + """ + /// Generated from - + let - = [ + ] + """.data(using: .utf8)! + case .objc: + // Somewhat useless, but historically what plutil provides + data = + """ + /// Generated from - + __attribute__((visibility("hidden"))) + NSDictionary * const _ = @{ + }; + """.data(using: .utf8)! + case .raw: + // Somewhat useless, but historically what plutil provides + data = "\n".data(using: .utf8)! + case .type: + let type = PlutilExpectType(propertyList: [:]) + data = type.rawValue.data(using: .utf8)! + case .openStep: + throw PLUContextError.argument("Cannot create open step property lists") + } + + if path == "-" { + // Write raw bytes. use `write` because FileDescriptor doesn't give us the count. + let len = data.withUnsafeBytes { buffer in + return write(output.fileDescriptor, buffer.baseAddress!, buffer.count) + } + let localErrno = errno + let success = len == data.count + if parsedArguments.terminatingNewline && (format == .raw || format == .type) { + output.write("\n") + } + + if !success { + throw POSIXError(POSIXError.Code(rawValue: localErrno) ?? .EIO) + } + } else { + try data.write(to: path.url) + } + } catch { + oneError = true + errorOutput.write(error, aboutPath: path) + // Continue on + } + } + + return !oneError + } +} + +// MARK: - + +/// Read from a path, or stdin. Returns the path name to use for errors, and the data. +func readPath(_ path: String) throws -> Data { + if path == "-" { + let stdinData = try FileHandle.standardInput.readToEnd() + guard let stdinData else { + throw CocoaError(.fileReadUnknown, userInfo: [NSDebugDescriptionErrorKey: "Unable to read file from standard input"]) + } + return stdinData + } else { + guard !path.isEmpty else { + throw PLUContextError.argument("Empty path specified") + } + + return try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + } +} + +func readPropertyList(_ fileData: Data) throws -> (Any, PlutilEmissionFormat) { + let plist: Any + let format: PlutilEmissionFormat + + do { + var existingFormat: PropertyListSerialization.PropertyListFormat = .xml + + plist = try PropertyListSerialization.propertyList(from: fileData, options: [], format: &existingFormat) + + switch existingFormat { + case .binary: format = .binary + case .openStep: format = .openStep + case .xml: format = .xml + @unknown default: + fatalError("Unknown property list format \(existingFormat)") + } + } catch let plistError as NSError { + // Try JSON + do { + plist = try JSONSerialization.jsonObject(with: fileData, options: [.mutableContainers, .mutableLeaves]) + + format = .json + } catch let jsonError as NSError { + throw PLUContextError.invalidPropertyList(plistError: plistError, jsonError: jsonError) + } + } + + return (plist, format) +} + +func writePropertyList(_ plist: Any, path: String, standardOutput: FileHandle, outputFormat: PlutilEmissionFormat, outputName: String?, extensionName: String?, originalKeyPath: String?, readable: Bool, terminatingNewline: Bool, outputObjCHeader: Bool) throws { + + // Verify that we have string keys in all dictionaries + guard validatePropertyListKeyType(plist) else { + throw PLUContextError.invalidPropertyListObject("Dictionaries are required to have string keys in property lists") + } + + let newPath = fixPath(path, format: outputFormat, outputName: outputName, extensionName: extensionName) + var outputData: Data? + + switch outputFormat { + case .xml, .binary: + guard PropertyListSerialization.propertyList(plist, isValidFor: outputFormat.propertyListFormat) else { + throw PLUContextError.invalidPropertyListObject("Invalid object in plist for property list format") + } + + outputData = try PropertyListSerialization.data(fromPropertyList: plist, format: outputFormat.propertyListFormat, options: 0) + + case .swift: + guard propertyListIsValidForLiteralFormat(plist, format: .swift) else { + throw PLUContextError.invalidPropertyListObject("Input contains an object that cannot be represented in Swift literal syntax") + } + + outputData = try swiftLiteralDataWithPropertyList(plist, originalFileName: path) + case .objc: + guard propertyListIsValidForLiteralFormat(plist, format: .objc) else { + throw PLUContextError.invalidPropertyListObject("Input contains an object that cannot be represented in Obj-C literal syntax") + } + + outputData = try objcLiteralDataWithPropertyList(plist, originalFileName: path, newFileName: newPath) + + case .json: + guard JSONSerialization.isValidJSONObject(plist) else { + throw PLUContextError.invalidPropertyListObject("Invalid object in plist for JSON format") + } + + outputData = try JSONSerialization.data(withJSONObject: plist, options: readable ? [.prettyPrinted, .sortedKeys] : []) + + case .raw: + guard propertyListIsValidForRawFormat(plist) else { + let actualType = PlutilExpectType(propertyList: plist) + throw PLUContextError.invalidPropertyListObject("Value at \(originalKeyPath ?? "unknown key path") is a \(actualType) type and cannot be extracted in raw format") + } + + let output = try rawStringWithPropertyList(plist) + outputData = output.data(using: .utf8) + case .type: + outputData = PlutilExpectType(propertyList: plist).description.data(using: .utf8)! + case .openStep: + throw PLUContextError.invalidPropertyListObject("Conversion to OpenStep format is not supported") + } + + guard let outputData else { + throw PLUContextError.invalidPropertyListObject("Unknown data creation error") + } + + if outputName == "-" || (outputName == nil && (outputFormat == .raw || outputFormat == .type)) { + // Write to stdout + try standardOutput.write(contentsOf: outputData) + + if terminatingNewline && (outputFormat == .raw || outputFormat == .type) { + standardOutput.write("\n") + } + } else { + try outputData.write(to: newPath.url) + + if outputFormat == .objc && outputObjCHeader { + let headerData = try objcLiteralHeaderDataWithPropertyList(plist, originalFileName: path, newFileName: newPath) + + var headerDataPath = newPath + headerDataPath.replaceExtension(with: "h") + try headerData.write(to: headerDataPath.url) + } + } +} + +// MARK: - + +func fixPath(_ path: String, format: PlutilEmissionFormat, outputName: String?, extensionName: String?) -> String { + if let outputName { + // Just use it + return outputName + } + + var result = path + if let extensionName { + result.replaceExtension(with: extensionName) + } + + // the rest of plutil expects formats to be convertable from each other... + // if a user forgot to add a `-o` option we should not overwrite the original plist + switch format { + case .objc: + result.replaceExtension(with: "m") + case .swift: + result.replaceExtension(with: "swift") + default: + break + } + + return result +} + +// MARK: - Type Output + +func validatePropertyListKeyType(_ plist: Any) -> Bool { + if plist is NSNumber { return true } + else if plist is Date { return true } + else if plist is Data { return true } + else if plist is String { return true } + else if let sub = plist as? [Any] { + for subPlist in sub { + if !validatePropertyListKeyType(subPlist) { return false } + } + return true + } else if let sub = plist as? [String: Any] { + for (_, v) in sub { + if !validatePropertyListKeyType(v) { return false } + } + return true + } else { + // Unknown property list type? + return false + } +} + +func propertyListIsValidForRawFormat(_ propertyList: Any) -> Bool { + if propertyList is NSNumber { + // Here, allow any number + return true + } else if propertyList is String { + return true + } else if propertyList is [Any] { + return true + } else if propertyList is [AnyHashable: Any] { + return true + } else if propertyList is Date { + return true + } else if propertyList is Data { + return true + } else { + return false + } +} + +/// Output the property list in "raw" string format. Does not descend into collections like array or dictionary. +func rawStringWithPropertyList(_ propertyList: Any) throws -> String { + if let num = propertyList as? NSNumber { + return try num.propertyListFormatted() + } else if let string = propertyList as? String { + return string + } else if let array = propertyList as? [Any] { + // Just outputs the number of elements + return "\(array.count)" + } else if let dictionary = propertyList as? [String: Any] { + // Just outputs keys + let sortedKeys = dictionary.keys.sorted(by: sortDictionaryKeys) + return sortedKeys.joined(separator: "\n") + } else if let date = propertyList as? Date { + return date.formatted(.iso8601) + } else if let data = propertyList as? Data { + return data.base64EncodedString() + } else { + throw PLUContextError.invalidPropertyListObject("Raw syntax does not support classes of type \(type(of: propertyList))") + } +} + +// MARK: - Standard Key Sorting + +func sortDictionaryKeys(key1: String, key2: String) -> Bool { + let locale = Locale(identifier: "") + let order = key1.compare(key2, options: [.numeric, .caseInsensitive, .forcedOrdering], range: nil, locale: locale) + return order == .orderedAscending +} + +// MARK: - + +// Adapted from FilePath, until Foundation can depend on System on all platforms +extension String { + /// e.g., `.h` -> `.m` + mutating func replaceExtension(with ext: String) { + if let r = _extensionRange() { + replaceSubrange(r, with: ext) + } + } + + var url: URL { + URL(filePath: self) + } + + // The index of the `.` denoting an extension + internal func _extensionIndex() -> Index? { + guard self != "." && self != ".." else { + return nil + } + + guard let idx = utf8.lastIndex(of: UInt8(ascii: ".")), idx != startIndex else { + return nil + } + + return idx + } + + internal func _extensionRange() -> Range? { + guard let idx = _extensionIndex() else { return nil } + return index(after: idx) ..< endIndex + } + + internal func _stemRange() -> Range { + startIndex ..< (_extensionIndex() ?? endIndex) + } + + var stem: String { + String(self[_stemRange()]) + } + + var lastComponent: String? { + // Delegate this to URL + URL(fileURLWithPath: self).lastPathComponent + } +} diff --git a/Sources/plutil/PLUContext_Arguments.swift b/Sources/plutil/PLUContext_Arguments.swift new file mode 100644 index 0000000000..f245b77f92 --- /dev/null +++ b/Sources/plutil/PLUContext_Arguments.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +/// Common arguments for create, insert, extract, etc. +struct PLUContextArguments { + var paths: [String] + var readable: Bool + var terminatingNewline: Bool + var outputFileName: String? + var outputFileExtension: String? + var silent: Bool? + + init(arguments: [String]) throws { + paths = [] + readable = false + terminatingNewline = true + + var argumentIterator = arguments.makeIterator() + var readRemainingAsPaths = false + while let arg = argumentIterator.next() { + switch arg { + case "--": + readRemainingAsPaths = true + break + case "-n": + terminatingNewline = false + case "-s": + silent = true + case "-r": + readable = true + case "-o": + guard let next = argumentIterator.next() else { + throw PLUContextError.argument("Missing argument for -o.") + } + + outputFileName = next + case "-e": + guard let next = argumentIterator.next() else { + throw PLUContextError.argument("Missing argument for -e.") + } + + outputFileExtension = next + default: + if arg.hasPrefix("-") && arg.count > 1 { + throw PLUContextError.argument("unrecognized option: \(arg)") + } + paths.append(arg) + } + } + + if readRemainingAsPaths { + while let arg = argumentIterator.next() { + paths.append(arg) + } + } + + // Make sure we have files + guard !paths.isEmpty else { + throw PLUContextError.argument("No files specified.") + } + } +} diff --git a/Sources/plutil/PLUContext_KeyPaths.swift b/Sources/plutil/PLUContext_KeyPaths.swift new file mode 100644 index 0000000000..a67bdafa34 --- /dev/null +++ b/Sources/plutil/PLUContext_KeyPaths.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +extension String { + /// Key paths can contain a `.`, but it must be escaped with a backslash `\.`. This function splits up a keypath, honoring the ability to escape a `.`. + internal func escapedKeyPathSplit() -> [String] { + let escapesReplaced = self.replacing("\\.", with: "A_DOT_WAS_HERE") + let split = escapesReplaced.split(separator: ".", omittingEmptySubsequences: false) + return split.map { $0.replacingOccurrences(of: "A_DOT_WAS_HERE", with: ".") } + } +} + +extension [String] { + /// Re-create an escaped string, if any of the components contain a `.`. + internal func escapedKeyPathJoin() -> String { + let comps = self.map { $0.replacingOccurrences(of: ".", with: "\\.") } + let joined = comps.joined(separator: ".") + return joined + } +} + +// MARK: - Get Value at Key Path + +func value(atKeyPath: String, in propertyList: Any) -> Any? { + let comps = atKeyPath.escapedKeyPathSplit() + return _value(atKeyPath: comps, in: propertyList, remainingKeyPath: comps[comps.startIndex..) -> Any? { + if remainingKeyPath.isEmpty { + // We're there + return propertyList + } + + guard let key = remainingKeyPath.first, !key.isEmpty else { + return nil + } + + if let dictionary = propertyList as? [String: Any] { + if let dictionaryValue = dictionary[key] { + return _value(atKeyPath: atKeyPath, in: dictionaryValue, remainingKeyPath: remainingKeyPath.dropFirst()) + } else { + return nil + } + } else if let array = propertyList as? [Any] { + if let lastInt = Int(key), (array.startIndex.. Any? { + let comps = atKeyPath.escapedKeyPathSplit() + return try _removeValue(atKeyPath: comps, in: propertyList, remainingKeyPath: comps[comps.startIndex..) throws -> Any? { + if remainingKeyPath.isEmpty { + // We're there + return nil + } + + guard let key = remainingKeyPath.first, !key.isEmpty else { + throw PLUContextError.argument("No value to remove at key path \(atKeyPath.escapedKeyPathJoin())") + } + + if let dictionary = propertyList as? [String: Any] { + guard let existing = dictionary[String(key)] else { + throw PLUContextError.argument("No value to remove at key path \(atKeyPath.escapedKeyPathJoin())") + } + + var new = dictionary + if let removed = try _removeValue(atKeyPath: atKeyPath, in: existing, remainingKeyPath: remainingKeyPath.dropFirst()) { + new[key] = removed + } else { + new.removeValue(forKey: key) + } + return new + } else if let array = propertyList as? [Any] { + guard let intKey = Int(key), (array.startIndex.. Any { + let comps = atKeyPath.escapedKeyPathSplit() + return try _insertValue(value, atKeyPath: comps, in: propertyList, remainingKeyPath: comps[comps.startIndex.., replacing: Bool, appending: Bool) throws -> Any { + // Are we recursing further, or is this the place where we are inserting? + guard let key = remainingKeyPath.first else { + throw PLUContextError.argument("Key path not found \(atKeyPath.escapedKeyPathJoin())") + } + + if let dictionary = propertyList as? [String : Any] { + let existingValue = dictionary[key] + if remainingKeyPath.count > 1 { + // Descend + if let existingValue { + var new = dictionary + new[key] = try _insertValue(value, atKeyPath: atKeyPath, in: existingValue, remainingKeyPath: remainingKeyPath.dropFirst(), replacing: replacing, appending: appending) + return new + } else { + throw PLUContextError.argument("Key path not found \(atKeyPath.escapedKeyPathJoin())") + } + } else { + // Insert + if replacing { + // Just slam it in + var new = dictionary + new[key] = value + return new + } else if let existingValue { + if appending { + if var existingValueArray = existingValue as? [Any] { + existingValueArray.append(value) + var new = dictionary + new[key] = existingValueArray + return new + } else { + throw PLUContextError.argument("Appending to a non-array at key path \(atKeyPath.escapedKeyPathJoin())") + } + } else { + // Not replacing, already exists, not appending to an array + throw PLUContextError.argument("Value already exists at key path \(atKeyPath.escapedKeyPathJoin())") + } + } else { + // Still just slam it in + var new = dictionary + new[key] = value + return new + } + } + } else if let array = propertyList as? [Any] { + guard let intKey = Int(key) else { + throw PLUContextError.argument("Unable to index into array with key path \(atKeyPath.escapedKeyPathJoin())") + } + + let containsKey = array.indices.contains(intKey) + + if remainingKeyPath.count > 1 { + // Descend + if containsKey { + var new = array + new[intKey] = try _insertValue(value, atKeyPath: atKeyPath, in: array[intKey], remainingKeyPath: remainingKeyPath.dropFirst(), replacing: replacing, appending: appending) + return new + } else { + throw PLUContextError.argument("Index \(intKey) out of bounds in array at key path \(atKeyPath.escapedKeyPathJoin())") + } + } else { + if appending { + // Append to the array in this array, at this index + guard let valueAtKey = array[intKey] as? [Any] else { + throw PLUContextError.argument("Attempt to append value to non-array at key path \(atKeyPath.escapedKeyPathJoin())") + } + var new = array + new[intKey] = valueAtKey + [value] + return new + } else if containsKey { + var new = array + new.insert(value, at: intKey) + return new + } else if intKey == array.count { + // note: the value of the integer can be out of bounds for the array (== the endIndex). We treat that as an append. + var new = array + new.append(value) + return new + } else { + throw PLUContextError.argument("Index \(intKey) out of bounds in array at key path \(atKeyPath.escapedKeyPathJoin())") + } + } + } else { + throw PLUContextError.argument("Unable to insert value at key path \(atKeyPath.escapedKeyPathJoin())") + } +} diff --git a/Sources/plutil/PLULiteralOutput.swift b/Sources/plutil/PLULiteralOutput.swift new file mode 100644 index 0000000000..f49b0b87f4 --- /dev/null +++ b/Sources/plutil/PLULiteralOutput.swift @@ -0,0 +1,288 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation + +internal func swiftLiteralDataWithPropertyList(_ plist: Any, originalFileName: String) throws -> Data { + let string = try _swiftLiteralDataWithPropertyList(plist, depth: 0, indent: true, originalFilename: originalFileName) + + let withNewline = string.appending("\n") + return withNewline.data(using: .utf8)! +} + +internal func objcLiteralDataWithPropertyList(_ plist: Any, originalFileName: String, newFileName: String) throws -> Data { + let string = try _objcLiteralDataWithPropertyList(plist, depth: 0, indent: true, originalFilename: originalFileName, outputFilename: newFileName) + + let withNewline = string.appending(";\n") + return withNewline.data(using: .utf8)! +} + +internal func objcLiteralHeaderDataWithPropertyList(_ plist: Any, originalFileName: String, newFileName: String) throws -> Data { + let result = try _objCLiteralVaribleWithPropertyList(plist, forHeader: true, originalFilename: originalFileName, outputFilename: newFileName) + + // Add final semi-colon + let withNewline = result.appending(";\n") + return withNewline.data(using: .utf8)! +} + +internal enum LiteralFormat { + case swift + case objc +} + +internal func propertyListIsValidForLiteralFormat(_ plist: Any, format: LiteralFormat) -> Bool { + switch format { + case .swift: + return PropertyListSerialization.propertyList(plist, isValidFor: .binary) + case .objc: + if let _ = plist as? String { + return true + } else if let _ = plist as? NSNumber { + return true + } else if let array = plist as? [Any] { + for item in array { + if !propertyListIsValidForLiteralFormat(item, format: format) { + return false + } + } + return true + } else if let dictionary = plist as? [AnyHashable: Any] { + for (key, value) in dictionary { + if !propertyListIsValidForLiteralFormat(key, format: format) { + return false + } + if !propertyListIsValidForLiteralFormat(value, format: format) { + return false + } + } + return true + } else { + return false + } + } +} + +// MARK: - Helpers + +internal func _indentation(forDepth depth: Int, numberOfSpaces: Int = 4) -> String { + var result = "" + for _ in 0.. String { + let filenameStem = file.stem + var varName = filenameStem.replacingOccurrences(of: "-", with: "_").replacingOccurrences(of: " ", with: "_") + let invalidChars = CharacterSet.symbols.union(.controlCharacters) + while let contained = varName.rangeOfCharacter(from: invalidChars) { + varName.removeSubrange(contained) + } + return varName +} + +extension String { + fileprivate var escapedForQuotesAndEscapes: String { + var result = self + let knownCommonEscapes = ["\\b", "\\s", "\"", "\\w", "\\.", "\\|", "\\*", "\\)", "\\("] + + for escape in knownCommonEscapes { + result = result.replacingOccurrences(of: escape, with: "\\\(escape)") + } + + return result + } +} + +// MARK: - ObjC + +private func _objcLiteralDataWithPropertyList(_ plist: Any, depth: Int, indent: Bool, originalFilename: String, outputFilename: String) throws -> String { + var result = "" + if depth == 0 { + result.append(try _objCLiteralVaribleWithPropertyList(plist, forHeader: false, originalFilename: originalFilename, outputFilename: outputFilename)) + } + + if indent { + result.append(_indentation(forDepth: depth)) + } + + if let num = plist as? NSNumber { + return result.appending(try num.propertyListFormatted()) + } else if let string = plist as? String { + return result.appending("@\"\(string.escapedForQuotesAndEscapes)\"") + } else if let array = plist as? [Any] { + result.append("@[\n") + for element in array { + result.append( try _objcLiteralDataWithPropertyList(element, depth: depth + 1, indent: true, originalFilename: originalFilename, outputFilename: outputFilename)) + result.append(",\n") + } + result.append(_indentation(forDepth: depth)) + result.append("]") + } else if let dictionary = plist as? [String : Any] { + result.append("@{\n") + let sortedKeys = Array(dictionary.keys).sorted(by: sortDictionaryKeys) + + for key in sortedKeys { + result.append(_indentation(forDepth: depth + 1)) + result.append("@\"\(key)\" : ") + let value = dictionary[key]! + let valueString = try _objcLiteralDataWithPropertyList(value, depth: depth + 1, indent: false, originalFilename: originalFilename, outputFilename: outputFilename) + result.append("\(valueString),\n") + } + result.append(_indentation(forDepth: depth)) + result.append("}") + } else { + throw PLUContextError.invalidPropertyListObject("Objective-C literal syntax does not support classes of type \(type(of: plist))") + } + return result +} + +private func _objCLiteralVaribleWithPropertyList(_ plist: Any, forHeader: Bool, originalFilename: String, outputFilename: String) throws -> String { + let objCName: String + if let _ = plist as? NSNumber { + objCName = "NSNumber" + } else if let _ = plist as? String { + objCName = "NSString" + } else if let _ = plist as? [Any] { + objCName = "NSArray" + } else if let _ = plist as? [AnyHashable : Any] { + objCName = "NSDictionary" + } else { + throw PLUContextError.invalidPropertyListObject("Objective-C literal syntax does not support classes of type \(type(of: plist))") + } + + var result = "" + if forHeader { + result.append("#import \n\n") + } else if outputFilename != "-" { + // Don't emit for stdout + result.append("#import \"\(outputFilename.lastComponent?.stem ?? "").h\"\n\n") + } + + + result.append("/// Generated from \(originalFilename.lastComponent ?? "a file")\n") + + // The most common usage will be to generate things that aren't exposed to others via a public header. We default to hidden visibility so as to avoid unintended exported symbols. + result.append("__attribute__((visibility(\"hidden\")))\n") + + if forHeader { + result.append("extern ") + } + + result.append("\(objCName) * const \(varName(from: originalFilename))") + + if !forHeader { + result.append(" = ") + } + + return result +} + +// MARK: - Swift + +private func _swiftLiteralDataWithPropertyList(_ plist: Any, depth: Int, indent: Bool, originalFilename: String) throws -> String { + var result = "" + if depth == 0 { + result.append("/// Generated from \(originalFilename.lastComponent ?? "a file")\n") + // Previous implementation would attempt to determine dynamically if the type annotation was by checking if there was a collection of different types. For now, this just always adds it. + result.append("let \(varName(from: originalFilename))") + + if let dictionary = plist as? [String: Any] { + var lastType: PlutilExpectType? + var needsAnnotation = false + for (_, value) in dictionary { + if let lastType { + if lastType != PlutilExpectType(propertyList: value) { + needsAnnotation = true + break + } + } else { + lastType = PlutilExpectType(propertyList: value) + } + } + + if needsAnnotation { + result.append(" : [String : Any]") + } + } else if let array = plist as? [Any] { + var lastType: PlutilExpectType? + var needsAnnotation = false + for value in array { + if let lastType { + if lastType != PlutilExpectType(propertyList: value) { + needsAnnotation = true + break + } + } else { + lastType = PlutilExpectType(propertyList: value) + } + } + + if needsAnnotation { + result.append(" : [Any]") + } + } else { + throw PLUContextError.invalidPropertyListObject("Swift literal syntax does not support classes of type \(type(of: plist))") + } + + result.append(" = ") + } + + if indent { + result.append(_indentation(forDepth: depth)) + } + + if let num = plist as? NSNumber { + result.append(try num.propertyListFormatted()) + } else if let string = plist as? String { + // FEATURE: Support triple-quote when string is multi-line. + // For now, do one simpler thing and replace newlines with literal \n + let escaped = string.escapedForQuotesAndEscapes.replacingOccurrences(of: "\n", with: "\\n") + result.append("\"\(escaped)\"") + } else if let array = plist as? [Any] { + result.append("[\n") + for element in array { + result.append( try _swiftLiteralDataWithPropertyList(element, depth: depth + 1, indent: true, originalFilename: originalFilename)) + result.append(",\n") + } + result.append(_indentation(forDepth: depth)) + result.append("]") + } else if let dictionary = plist as? [String : Any] { + result.append("[\n") + let sortedKeys = Array(dictionary.keys).sorted(by: sortDictionaryKeys) + + for key in sortedKeys { + result.append(_indentation(forDepth: depth + 1)) + result.append("\"\(key)\" : ") + let value = dictionary[key]! + let valueString = try _swiftLiteralDataWithPropertyList(value, depth: depth + 1, indent: false, originalFilename: originalFilename) + result.append("\(valueString),\n") + } + result.append(_indentation(forDepth: depth)) + result.append("]") + } else if let data = plist as? Data { + result.append("Data(bytes: [") + for byte in data { + result.append(String(format: "0x%02X", byte)) + result.append(",") + } + result.append("])") + } else if let date = plist as? Date { + result.append("Date(timeIntervalSinceReferenceDate: \(date.timeIntervalSinceReferenceDate))") + } else { + throw PLUContextError.invalidPropertyListObject("Swift literal syntax does not support classes of type \(type(of: plist))") + } + return result +} + diff --git a/Sources/plutil/main.swift b/Sources/plutil/main.swift index 29584596d3..d22ee21b98 100644 --- a/Sources/plutil/main.swift +++ b/Sources/plutil/main.swift @@ -1,410 +1,42 @@ +//===----------------------------------------------------------------------===// +// // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2016 Apple Inc. and the Swift project authors +// Copyright (c) 2024 Apple Inc. and the Swift project authors // Licensed under Apache License v2.0 with Runtime Library Exception // -// See http://swift.org/LICENSE.txt for license information -// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors // +//===----------------------------------------------------------------------===// + +import Foundation + #if canImport(Darwin) import Darwin -import SwiftFoundation #elseif canImport(Glibc) -import Foundation import Glibc #elseif canImport(Musl) -import Foundation import Musl #elseif canImport(Bionic) -import Foundation import Bionic #elseif canImport(CRT) -import Foundation import CRT #endif -func help() -> Int32 { - print("plutil: [command_option] [other_options] file...\n" + - "The file '-' means stdin\n" + - "Command options are (-lint is the default):\n" + - " -help show this message and exit\n" + - " -lint check the property list files for syntax errors\n" + - " -convert fmt rewrite property list files in format\n" + - " fmt is one of: xml1 binary1 json\n" + - " -p print property list in a human-readable fashion\n" + - " (not for machine parsing! this 'format' is not stable)\n" + - "There are some additional optional arguments that apply to -convert\n" + - " -s be silent on success\n" + - " -o path specify alternate file path name for result;\n" + - " the -o option is used with -convert, and is only\n" + - " useful with one file argument (last file overwrites);\n" + - " the path '-' means stdout\n" + - " -e extension specify alternate extension for converted files\n" + - " -r if writing JSON, output in human-readable form\n" + - " -- specifies that all further arguments are file names\n") - return EXIT_SUCCESS -} - -enum ExecutionMode { - case help - case lint - case convert - case print -} - -enum ConversionFormat { - case xml1 - case binary1 - case json -} - -struct Options { - var mode: ExecutionMode = .lint - var silent: Bool = false - var output: String? - var fileExtension: String? - var humanReadable: Bool? - var conversionFormat: ConversionFormat? - var inputs = [String]() -} - -enum OptionParseError : Swift.Error { - case unrecognizedArgument(String) - case missingArgument(String) - case invalidFormat(String) -} - -func parseArguments(_ args: [String]) throws -> Options { - var opts = Options() - var iterator = args.makeIterator() - while let arg = iterator.next() { - switch arg { - case "--": - while let path = iterator.next() { - opts.inputs.append(path) - } - case "-s": - opts.silent = true - case "-o": - if let path = iterator.next() { - opts.output = path - } else { - throw OptionParseError.missingArgument("-o requires a path argument") - } - case "-convert": - opts.mode = .convert - if let format = iterator.next() { - switch format { - case "xml1": - opts.conversionFormat = .xml1 - case "binary1": - opts.conversionFormat = .binary1 - case "json": - opts.conversionFormat = .json - default: - throw OptionParseError.invalidFormat(format) - } - } else { - throw OptionParseError.missingArgument("-convert requires a format argument of xml1 binary1 json") - } - case "-e": - if let ext = iterator.next() { - opts.fileExtension = ext - } else { - throw OptionParseError.missingArgument("-e requires an extension argument") - } - case "-help": - opts.mode = .help - case "-lint": - opts.mode = .lint - case "-p": - opts.mode = .print - default: - if arg.hasPrefix("-") && arg.utf8.count > 1 { - throw OptionParseError.unrecognizedArgument(arg) - } - } - } - - return opts +do { + let command = try PLUCommand( + arguments: ProcessInfo.processInfo.arguments, + outputFileHandle: FileHandle.standardOutput, + errorFileHandle: FileHandle.standardError) + + let success = try command.execute() + exit(success ? EXIT_SUCCESS : EXIT_FAILURE) +} catch let error as PLUContextError { + FileHandle.standardError.write(error.description + "\n") + exit(EXIT_FAILURE) +} catch { + // Some other error? + FileHandle.standardError.write(error.localizedDescription + "\n") + exit(EXIT_FAILURE) } - - -func lint(_ options: Options) -> Int32 { - if options.output != nil { - print("-o is not used with -lint") - let _ = help() - return EXIT_FAILURE - } - - if options.fileExtension != nil { - print("-e is not used with -lint") - let _ = help() - return EXIT_FAILURE - } - - if options.inputs.count < 1 { - print("No files specified.") - let _ = help() - return EXIT_FAILURE - } - - let silent = options.silent - - var doError = false - for file in options.inputs { - let data : Data? - if file == "-" { - // stdin - data = FileHandle.standardInput.readDataToEndOfFile() - } else { - data = try? Data(contentsOf: URL(fileURLWithPath: file)) - } - - if let d = data { - do { - let _ = try PropertyListSerialization.propertyList(from: d, options: [], format: nil) - if !silent { - print("\(file): OK") - } - } catch { - print("\(file): \(error)") - - } - - } else { - print("\(file) does not exists or is not readable or is not a regular file") - doError = true - continue - } - } - - if doError { - return EXIT_FAILURE - } else { - return EXIT_SUCCESS - } -} - -func convert(_ options: Options) -> Int32 { - print("Unimplemented") - return EXIT_FAILURE -} - -enum DisplayType { - case primary - case key - case value -} - -extension Dictionary { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary, .key: - print("\(indentation)[\n", terminator: "") - case .value: - print("[\n", terminator: "") - } - - forEach() { - if let key = $0.0 as? String { - key.display(indent + 1, type: .key) - } else { - fatalError("plists should have strings as keys but got a \(Swift.type(of: $0.0))") - } - print(" => ", terminator: "") - displayPlist($0.1, indent: indent + 1, type: .value) - } - - print("\(indentation)]\n", terminator: "") - } -} - -extension Array { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary, .key: - print("\(indentation)[\n", terminator: "") - case .value: - print("[\n", terminator: "") - } - - for idx in 0.. ", terminator: "") - displayPlist(self[idx], indent: indent + 1, type: .value) - } - - print("\(indentation)]\n", terminator: "") - } -} - -extension String { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary: - print("\(indentation)\"\(self)\"\n", terminator: "") - case .key: - print("\(indentation)\"\(self)\"", terminator: "") - case .value: - print("\"\(self)\"\n", terminator: "") - } - } -} - -extension Bool { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary: - print("\(indentation)\"\(self ? "1" : "0")\"\n", terminator: "") - case .key: - print("\(indentation)\"\(self ? "1" : "0")\"", terminator: "") - case .value: - print("\"\(self ? "1" : "0")\"\n", terminator: "") - } - } -} - -extension NSNumber { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary: - print("\(indentation)\"\(self)\"\n", terminator: "") - case .key: - print("\(indentation)\"\(self)\"", terminator: "") - case .value: - print("\"\(self)\"\n", terminator: "") - } - } -} - -extension NSData { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary: - print("\(indentation)\"\(self)\"\n", terminator: "") - case .key: - print("\(indentation)\"\(self)\"", terminator: "") - case .value: - print("\"\(self)\"\n", terminator: "") - } - } -} - -extension NSDate { - func display(_ indent: Int = 0, type: DisplayType = .primary) { - let indentation = String(repeating: " ", count: indent * 2) - switch type { - case .primary: - print("\(indentation)\"\(self)\"\n", terminator: "") - case .key: - print("\(indentation)\"\(self)\"", terminator: "") - case .value: - print("\"\(self)\"\n", terminator: "") - } - } -} - -func displayPlist(_ plist: Any, indent: Int = 0, type: DisplayType = .primary) { - switch plist { - case let val as [String : Any]: - val.display(indent, type: type) - case let val as [Any]: - val.display(indent, type: type) - case let val as String: - val.display(indent, type: type) - case let val as Bool: - val.display(indent, type: type) - case let val as NSNumber: - val.display(indent, type: type) - case let val as NSData: - val.display(indent, type: type) - case let val as NSDate: - val.display(indent, type: type) - default: - fatalError("unhandled type \(Swift.type(of: plist))") - } -} - -func display(_ options: Options) -> Int32 { - if options.inputs.count < 1 { - print("No files specified.") - let _ = help() - return EXIT_FAILURE - } - - var doError = false - for file in options.inputs { - let data : Data? - if file == "-" { - // stdin - data = FileHandle.standardInput.readDataToEndOfFile() - } else { - data = try? Data(contentsOf: URL(fileURLWithPath: file)) - } - - if let d = data { - do { - let plist = try PropertyListSerialization.propertyList(from: d, options: [], format: nil) - displayPlist(plist) - } catch { - print("\(file): \(error)") - } - - } else { - print("\(file) does not exists or is not readable or is not a regular file") - doError = true - continue - } - } - - if doError { - return EXIT_FAILURE - } else { - return EXIT_SUCCESS - } -} - -func main() -> Int32 { - var args = ProcessInfo.processInfo.arguments - - if args.count < 2 { - print("No files specified.") - return EXIT_FAILURE - } - - // Throw away process path - args.removeFirst() - do { - let opts = try parseArguments(args) - switch opts.mode { - case .lint: - return lint(opts) - case .convert: - return convert(opts) - case .print: - return display(opts) - case .help: - return help() - } - } catch { - switch error as! OptionParseError { - case .unrecognizedArgument(let arg): - print("unrecognized option: \(arg)") - let _ = help() - case .invalidFormat(let format): - print("unrecognized format \(format)\nformat should be one of: xml1 binary1 json") - case .missingArgument(let errorStr): - print(errorStr) - } - return EXIT_FAILURE - } -} - -exit(main()) - From 563c6c22b25c9a51668ce9d75d63837b914eb4a3 Mon Sep 17 00:00:00 2001 From: Tony Parker Date: Fri, 21 Feb 2025 09:46:12 -0800 Subject: [PATCH 2/3] Fix implementation of stem --- Sources/plutil/PLUContext.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/plutil/PLUContext.swift b/Sources/plutil/PLUContext.swift index 74afd95db7..1b74d3f773 100644 --- a/Sources/plutil/PLUContext.swift +++ b/Sources/plutil/PLUContext.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import Foundation -import CoreFoundation func help(_ name: String) -> String { name + @@ -1163,7 +1162,8 @@ extension String { } var stem: String { - String(self[_stemRange()]) + guard let last = lastComponent else { return "" } + return String(last[last._stemRange()]) } var lastComponent: String? { From a6709118e0b9f41f605d13d741158837ca4a0d4d Mon Sep 17 00:00:00 2001 From: Tony Parker Date: Fri, 28 Feb 2025 15:03:06 -0800 Subject: [PATCH 3/3] Allow non-collection types to be used as Swift output --- Sources/plutil/PLULiteralOutput.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/plutil/PLULiteralOutput.swift b/Sources/plutil/PLULiteralOutput.swift index f49b0b87f4..44713ea96c 100644 --- a/Sources/plutil/PLULiteralOutput.swift +++ b/Sources/plutil/PLULiteralOutput.swift @@ -198,6 +198,7 @@ private func _swiftLiteralDataWithPropertyList(_ plist: Any, depth: Int, indent: // Previous implementation would attempt to determine dynamically if the type annotation was by checking if there was a collection of different types. For now, this just always adds it. result.append("let \(varName(from: originalFilename))") + // Dictionaries and Arrays need to check for specific type annotation, in case they contain different types. Other types do not. if let dictionary = plist as? [String: Any] { var lastType: PlutilExpectType? var needsAnnotation = false @@ -232,8 +233,6 @@ private func _swiftLiteralDataWithPropertyList(_ plist: Any, depth: Int, indent: if needsAnnotation { result.append(" : [Any]") } - } else { - throw PLUContextError.invalidPropertyListObject("Swift literal syntax does not support classes of type \(type(of: plist))") } result.append(" = ")