├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Example ├── Package.resolved ├── Package.swift ├── README.md ├── Sources │ └── ConfigureMe │ │ ├── Configuration.swift │ │ ├── ConfigureMe.swift │ │ └── OptionOverride+ExpressibleByArgument.swift └── config.json ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── ConfigurationParser │ ├── Parsing │ ├── ConfigurationDecoder.swift │ ├── DecodableContainer.swift │ ├── OptionDefinitionProvider.swift │ ├── OverrideContainer.swift │ └── StringCodingKey.swift │ └── Types │ ├── Availability.swift │ ├── Content.swift │ ├── DataDecoder.swift │ ├── Documentation.swift │ ├── Issue.swift │ ├── Name.swift │ ├── Option.swift │ ├── OptionDefinition.swift │ ├── OptionOverride.swift │ ├── ParsableConfiguration.swift │ └── TopLevelDecoder.swift └── Tests └── ConfigurationParserTests └── ConfigurationParserTests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Continuous Integration" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | macos: 13 | name: macOS (Xcode ${{ matrix.xcode }}) 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | xcode: ["13.4.1", "13.2.1"] 18 | include: 19 | - xcode: "13.4.1" 20 | macos: macOS-12 21 | - xcode: "13.2.1" 22 | macos: macOS-11 23 | runs-on: ${{ matrix.macos }} 24 | env: 25 | DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer 26 | steps: 27 | - name: Checkout Repo 28 | uses: actions/checkout@v3 29 | - name: Run Tests 30 | run: swift test 31 | 32 | linux: 33 | name: Linux (Swift ${{ matrix.swift }}) 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | swift: ["5.5", "5.6"] 38 | runs-on: ubuntu-latest 39 | container: swift:${{ matrix.swift }} 40 | steps: 41 | - name: Checkout Repo 42 | uses: actions/checkout@v3 43 | - name: Run Tests 44 | run: swift test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Example/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser", 7 | "state" : { 8 | "revision" : "df9ee6676cd5b3bf5b330ec7568a5644f547201b", 9 | "version" : "1.1.3" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Example/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-configuration-parser-example", 8 | platforms: [ 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | .executable(name: "configure-me", targets: ["ConfigureMe"]) 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.3"), 16 | .package(path: "../") 17 | ], 18 | targets: [ 19 | .executableTarget( 20 | name: "ConfigureMe", 21 | dependencies: [ 22 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 23 | .product(name: "ConfigurationParser", package: "swift-configuration-parser") 24 | ] 25 | ) 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | This package provides a very simple example that combines Swift Argument Parser to offer some interesting features. 4 | 5 | The main configuration is defined within the `Configuration` type, but **config.json** has also been provided, run the project like so: 6 | 7 | ``` 8 | $ swift run configure-me --config config.json 9 | NOTE: Found unexpected property ‘name‘ while decoding. 10 | welcome to my example 11 | welcome to my example 12 | welcome to my example 13 | welcome to my example 14 | welcome to my example 15 | welcome to my example 16 | welcome to my example 17 | welcome to my example 18 | welcome to my example 19 | welcome to my example 20 | welcome to my example 21 | welcome to my example 22 | welcome to my example 23 | welcome to my example 24 | welcome to my example 25 | welcome to my example 26 | welcome to my example 27 | welcome to my example 28 | welcome to my example 29 | welcome to my example 30 | ``` 31 | 32 | While the `repeatCount` was set to `10` by default, the greeting was printed `20` times as per the actual configuration file. The message had also changed and you might also notice that the decoder logged an unexpected property too. 33 | 34 | Now try running the following command: 35 | 36 | ``` 37 | $ swift run configure-me --config config.json --config-option repeatCount=2 --config-option 'style="uppercase"' 38 | ``` 39 | 40 | You'll see the following 41 | 42 | ``` 43 | NOTE: Found unexpected property ‘name‘ while decoding. 44 | WELCOME TO THIS EXAMPLE 45 | WELCOME TO THIS EXAMPLE 46 | ``` 47 | 48 | Overrides are parsed as `key:value` where `key` is a dot notation path to a property and `value` is a string that can be decoded by the decoder that is configured (`JSONDecoder` by default). The result is a mechanism that is suitable for overriding the configuration when invoking the command. 49 | 50 | Finally, try one more option: 51 | 52 | ``` 53 | $ swift run configure-me --config config.json --config-option repeatCount=2 --config-option 'isUppercase=true' 54 | ``` 55 | 56 | You will see the following: 57 | 58 | ``` 59 | NOTE: Property ‘isUppercase‘ is deprecated. Renamed to ‘style‘ 60 | NOTE: Found unexpected property ‘name‘ while decoding. 61 | welcome to this example 62 | welcome to this example 63 | ``` 64 | 65 | The `isDeprecated` property was annotated as deprecated so when parsed, will produce an issue that can be processed by your own code. 66 | -------------------------------------------------------------------------------- /Example/Sources/ConfigureMe/Configuration.swift: -------------------------------------------------------------------------------- 1 | import ConfigurationParser 2 | import Foundation 3 | 4 | struct Configuration: ParsableConfiguration { 5 | enum Style: String, Codable { 6 | case lowercase, uppercase 7 | } 8 | 9 | @Option 10 | var style: Style = .lowercase 11 | 12 | @Option 13 | var repeatCount: Int = 10 14 | 15 | @Option 16 | var greeting: String = "Hello, how are you?" 17 | 18 | @Option(.deprecated("Renamed to ‘style‘")) 19 | var isUppercase: Bool? = nil 20 | } 21 | -------------------------------------------------------------------------------- /Example/Sources/ConfigureMe/ConfigureMe.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import struct ConfigurationParser.OptionOverride 3 | import Foundation 4 | 5 | @main 6 | struct ConfigureMe: ParsableCommand { 7 | static let configuration = CommandConfiguration( 8 | commandName: "configure-me", 9 | abstract: "A tool just begging to be configured", 10 | version: "0.0.1" 11 | ) 12 | 13 | @Option( 14 | help: "Path to the configuration file", 15 | completion: .file(extensions: ["json"]) 16 | ) 17 | var config: String 18 | 19 | @Option(help: "Configuration overrides") 20 | var configOption: [OptionOverride] = [] 21 | 22 | func run() throws { 23 | let fileURL = URL(fileURLWithPath: config) 24 | let configuration = try Configuration.parse(contentsOf: fileURL, overrides: configOption) { issue in 25 | print("NOTE:", issue.description) 26 | } 27 | 28 | let message: String 29 | switch configuration.style { 30 | case .lowercase: 31 | message = configuration.greeting.lowercased() 32 | case .uppercase: 33 | message = configuration.greeting.uppercased() 34 | } 35 | 36 | for _ in 1...configuration.repeatCount { 37 | print(message) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Example/Sources/ConfigureMe/OptionOverride+ExpressibleByArgument.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import ConfigurationParser 3 | 4 | extension OptionOverride: ExpressibleByArgument { 5 | public init?(argument: String) { 6 | self.init(argument) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "What is this doing there?", 3 | "repeatCount": 20, 4 | "greeting": "Welcome to this example" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Liam Nichols 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "swift-configuration-parser", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6) 13 | ], 14 | products: [ 15 | .library( 16 | name: "ConfigurationParser", 17 | targets: ["ConfigurationParser"] 18 | ) 19 | ], 20 | targets: [ 21 | .target( 22 | name: "ConfigurationParser", 23 | dependencies: [] 24 | ), 25 | .testTarget( 26 | name: "ConfigurationParserTests", 27 | dependencies: ["ConfigurationParser"] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Configuration Parser 2 | 3 | Simple configuration file parsing for Swift. 4 | 5 | ## Usage 6 | 7 | Start by declaring a type containing your configuration options: 8 | 9 | ```swift 10 | struct Configuration: ParsableConfiguration { 11 | enum Power: Int, Decodable { 12 | case low, medium, high 13 | } 14 | 15 | @Option( 16 | summary: "The power level used if not otherwise specified.", 17 | discussion: """ 18 | When setting a default power level, it is crucial that you \ 19 | are sure that the consumption will not exceed the daily \ 20 | allocated allowance. Failure to keep within your usage \ 21 | limits will result in termination of the contract. 22 | 23 | It's recommended that you do not change this value unless \ 24 | you are fully aware of the risks involved. 25 | """ 26 | ) 27 | var defaultPowerLevel: Power = .medium 28 | 29 | @Option(summary: "Array of preferred spoken languages") 30 | var preferredLanguages: [String] = ["en", "fr", "ar"] 31 | 32 | @Option(.deprecated("Replaced by ‘preferredLanguages‘"), hidden: true) 33 | var preferredLanguage: String? = nil 34 | 35 | @Option(summary: "Details of the author used when creating commits") 36 | var author: Author 37 | 38 | struct Author: ParsableConfiguration { 39 | @Option(summary: "The full name of the author") 40 | var name: String = "John Doe" 41 | 42 | @Option(summary: "The email address of the author") 43 | var email: String = "no-reply@example.com" 44 | } 45 | } 46 | ``` 47 | 48 | Then use the static methods available through the `ParsableConfiguration` protocol to load your configuration from the appropriate source: 49 | 50 | ```swift 51 | // The default configuration instance 52 | let configuration = Configuration.default 53 | ``` 54 | 55 | ```swift 56 | // Load directly from a file 57 | let fileURL = URL(fileURLWithPath: "./.options.json") 58 | let configuration = try Configuration.parse(contentsOf: fileURL) 59 | ``` 60 | 61 | ```swift 62 | // Load from data 63 | let data = try downloadConfigurationFromServer() 64 | let configuration = try Configuration.parse(data) 65 | ``` 66 | 67 | ## Features 68 | 69 | - Preserve default values when deserializing 70 | - Detect unexpected properties (typos/misconfiguration) while loading 71 | - Flexible overrides 72 | - Customizable decoding - Uses `JSONDecoder` by default but you can plug in anything 73 | 74 | For an example, see the simple [example package](./Example/) or the [usage in CreateAPI](https://github.com/CreateAPI/CreateAPI/blob/main/Sources/CreateOptions/ConfigOptions.swift). 75 | 76 | --- 77 | 78 | Heavily inspired by [swift-argument-parser](https://github.com/apple/swift-argument-parser). 79 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Parsing/ConfigurationDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct ConfigurationDecoder { 4 | let definitions: [Name: OptionDefinition] 5 | let dataContainer: DecodableContainer? 6 | let overridesContainer: OverrideContainer? 7 | let codingPath: [CodingKey] 8 | let issueHandler: IssueHandler 9 | let userInfo: [CodingUserInfoKey: Any] 10 | let allKeys: Set 11 | 12 | init( 13 | definitions: [OptionDefinition], 14 | dataContainer: DecodableContainer?, 15 | overridesContainer: OverrideContainer?, 16 | codingPath: [CodingKey], 17 | issueHandler: @escaping IssueHandler 18 | ) { 19 | let definitions = Dictionary(uniqueKeysWithValues: definitions.map({ ($0.name, $0) })) 20 | let allKeys = (dataContainer?.allKeys ?? []).union((overridesContainer?.allKeys(in: codingPath) ?? [])) 21 | 22 | self.definitions = definitions 23 | self.dataContainer = dataContainer 24 | self.overridesContainer = overridesContainer 25 | self.codingPath = codingPath 26 | self.issueHandler = issueHandler 27 | self.userInfo = [:] 28 | self.allKeys = allKeys 29 | 30 | lazy var codingPath: [Name] = codingPath.map(Name.init(_:)) 31 | for key in allKeys { 32 | if let definition = definitions[key], case .deprecated(let message) = definition.availability { 33 | let context = Issue.Context(codingPath: codingPath, description: message) 34 | issueHandler(.deprecatedOption(key, context)) 35 | } 36 | 37 | if definitions[key] == nil { 38 | let context = Issue.Context(codingPath: codingPath, description: "") 39 | issueHandler(.unexpectedOption(key, context)) 40 | } 41 | } 42 | } 43 | } 44 | 45 | // MARK: Decoder 46 | extension ConfigurationDecoder: Decoder { 47 | func container( 48 | keyedBy type: Key.Type 49 | ) throws -> KeyedDecodingContainer { 50 | let container = ConfigurationDecodingContainer(for: self, codingPath: codingPath) 51 | return KeyedDecodingContainer(container) 52 | } 53 | 54 | func unkeyedContainer() throws -> UnkeyedDecodingContainer { 55 | fatalError() 56 | } 57 | 58 | func singleValueContainer() throws -> SingleValueDecodingContainer { 59 | fatalError() 60 | } 61 | 62 | func value(forKey key: StringCodingKey) throws -> T { 63 | fatalError() 64 | } 65 | } 66 | 67 | final class ConfigurationDecodingContainer: KeyedDecodingContainerProtocol { 68 | var codingPath: [CodingKey] 69 | let decoder: ConfigurationDecoder 70 | 71 | init(for decoder: ConfigurationDecoder, codingPath: [CodingKey]) { 72 | self.codingPath = codingPath 73 | self.decoder = decoder 74 | } 75 | 76 | var allKeys: [K] { 77 | decoder.allKeys.compactMap { K(stringValue: $0.rawValue) } 78 | } 79 | 80 | func contains(_ key: K) -> Bool { 81 | decoder.allKeys.contains(Name(key)) 82 | } 83 | 84 | func decodeNil(forKey key: K) throws -> Bool { 85 | true // TODO: Figure this out 86 | } 87 | 88 | func decode(_ type: T.Type, forKey key: K) throws -> T { 89 | guard let definition = decoder.definitions[Name(key)] else { 90 | throw DecodingError.keyNotFound(key, .init(codingPath: codingPath, debugDescription: """ 91 | Trying to decode a key that is not known. Did you annotate it with @Option? 92 | """)) 93 | } 94 | 95 | let decoder = OptionDecoder( 96 | underlying: decoder, 97 | codingPath: codingPath + [key], 98 | definition: definition 99 | ) 100 | 101 | return try T(from: decoder) 102 | } 103 | 104 | func decodeIfPresent(_ type: T.Type, forKey key: K) throws -> T? { 105 | fatalError() 106 | } 107 | 108 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: K) throws -> KeyedDecodingContainer where NestedKey : CodingKey { 109 | fatalError() 110 | } 111 | 112 | func nestedUnkeyedContainer(forKey key: K) throws -> UnkeyedDecodingContainer { 113 | fatalError() 114 | } 115 | 116 | func superDecoder() throws -> Decoder { 117 | fatalError() 118 | } 119 | 120 | func superDecoder(forKey key: K) throws -> Decoder { 121 | fatalError() 122 | } 123 | } 124 | 125 | struct OptionDecoder: Decoder { 126 | var underlying: ConfigurationDecoder 127 | var codingPath: [CodingKey] 128 | var definition: OptionDefinition 129 | 130 | var userInfo: [CodingUserInfoKey : Any] { underlying.userInfo } 131 | 132 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { 133 | let container = ConfigurationDecodingContainer(for: underlying, codingPath: codingPath) 134 | return KeyedDecodingContainer(container) 135 | } 136 | 137 | func unkeyedContainer() throws -> UnkeyedDecodingContainer { 138 | fatalError() 139 | } 140 | 141 | func singleValueContainer() throws -> SingleValueDecodingContainer { 142 | OptionContainer(decoder: self, codingPath: codingPath) 143 | } 144 | 145 | func decodeIfPresent(_ type: T.Type) throws -> T? { 146 | let key = StringCodingKey(codingPath.last!) 147 | 148 | if let value = try underlying.overridesContainer?.decodeIfPresent(type, forKey: key, in: codingPath.dropLast()) { 149 | return value 150 | } 151 | 152 | return try underlying.dataContainer?.decodeIfPresent(type, forKey: key) 153 | } 154 | } 155 | 156 | struct OptionContainer: SingleValueDecodingContainer { 157 | var decoder: OptionDecoder 158 | var codingPath: [CodingKey] 159 | 160 | func decodeNil() -> Bool { 161 | fatalError() 162 | } 163 | 164 | func decode(_ type: T.Type) throws -> T { 165 | switch decoder.definition.content { 166 | case let .defaultValue(defaultValue): 167 | return try decodeOption(as: type, defaultValue: defaultValue as! T) 168 | case .container(let definitions): 169 | return try decodeContainer(as: type, definitions: definitions) 170 | } 171 | } 172 | 173 | private func decodeOption(as type: T.Type, defaultValue: T) throws -> T { 174 | // Try and decode the value if it was known to the decoder 175 | if let value = try decoder.decodeIfPresent(T.self) { 176 | return value 177 | } 178 | 179 | // Otherwise use the default value 180 | return defaultValue 181 | } 182 | 183 | private func decodeContainer(as type: T.Type, definitions: [OptionDefinition]) throws -> T { 184 | // Decode from the dataContainer if present 185 | let nestedContainer = try decoder.decodeIfPresent(DecodableContainer.self) 186 | return try T(from: ConfigurationDecoder( 187 | definitions: definitions, 188 | dataContainer: nestedContainer, 189 | overridesContainer: decoder.underlying.overridesContainer, 190 | codingPath: codingPath, 191 | issueHandler: decoder.underlying.issueHandler 192 | )) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Parsing/DecodableContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Helper for dynamically decoding keyed values from a decoded container. 4 | struct DecodableContainer: Decodable { 5 | let _container: KeyedDecodingContainer 6 | 7 | init(from decoder: Decoder) throws { 8 | self._container = try decoder.container(keyedBy: StringCodingKey.self) 9 | } 10 | 11 | var allKeys: Set { 12 | Set(_container.allKeys.map(Name.init(_:))) 13 | } 14 | 15 | func decode(_ type: T.Type, forKey key: StringCodingKey) throws -> T { 16 | try _container.decode(type, forKey: key) 17 | } 18 | 19 | func decodeIfPresent(_ type: T.Type, forKey key: StringCodingKey) throws -> T? { 20 | try _container.decodeIfPresent(type, forKey: key) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Parsing/OptionDefinitionProvider.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol OptionDefinitionProvider { 4 | func optionDefinition(for name: Name) -> OptionDefinition 5 | } 6 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Parsing/OverrideContainer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OverrideContainer { 4 | let overrides: [OptionOverride] 5 | let decoder: DataDecoder 6 | 7 | func decodeIfPresent(_ type: T.Type, forKey key: CodingKey, in codingPath: [CodingKey]) throws -> T? { 8 | let name = Name(key) 9 | let path = codingPath.map(Name.init(_:)) 10 | guard let override = overrides.first(where: { $0.name == name && $0.path == path }) else { return nil } 11 | 12 | // Decode the value 13 | do { 14 | return try decoder.decode(type, from: override.value) 15 | } catch { 16 | throw DecodingError.dataCorrupted(DecodingError.Context( 17 | codingPath: codingPath + [key], 18 | debugDescription: "Unable to decode override.", 19 | underlyingError: error 20 | )) 21 | } 22 | } 23 | 24 | func allKeys(in codingPath: [CodingKey]) -> Set { 25 | Set(overrides.filter({ $0.path == codingPath.map(Name.init(_:)) }).map(\.name)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Parsing/StringCodingKey.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct StringCodingKey: Hashable { 4 | var stringValue: String 5 | var intValue: Int? 6 | } 7 | 8 | extension StringCodingKey: CodingKey { 9 | init?(stringValue: String) { 10 | self.init(stringValue: stringValue, intValue: nil) 11 | } 12 | 13 | init?(intValue: Int) { 14 | self.init(stringValue: String(describing: intValue), intValue: intValue) 15 | } 16 | } 17 | 18 | extension StringCodingKey: ExpressibleByStringLiteral { 19 | init(stringLiteral value: String) { 20 | self.init(stringValue: value, intValue: nil) 21 | } 22 | } 23 | 24 | extension StringCodingKey { 25 | init(_ key: CodingKey) { 26 | self.init(stringValue: key.stringValue, intValue: key.intValue) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Availability.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Availability { 4 | /// Treats the option as a regular property that can be decoded without any issue. 5 | case available 6 | 7 | /// Records an issue if the option is seen in the source data during parsing. 8 | /// 9 | /// You can later use the recorded issue to log a message or throw an error that helps instruct the user how to migrate. 10 | case deprecated(String = "") 11 | 12 | /// Records an issue if the option is seen in the source data during parsing. 13 | public static var deprecated: Availability { .deprecated() } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Content.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Content { 4 | /// A simple option with a default value. 5 | case defaultValue(Any) 6 | 7 | /// A container with other child options. 8 | case container([OptionDefinition]) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/DataDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// An internal protocol that describes a top-level decoder that specialises decoding `Data` only. 4 | protocol DataDecoder { 5 | func decode(_ type: T.Type, from data: Data) throws -> T 6 | } 7 | 8 | /// A wrapper for helping to erase the `Input` type from the `TopLevelDecoder` into `TopLevelDataDecoder` 9 | struct TopLevelDataDecoder where Decoder.Input == Data { 10 | let decoder: Decoder 11 | 12 | init(_ decoder: Decoder) { 13 | self.decoder = decoder 14 | } 15 | } 16 | 17 | extension TopLevelDataDecoder: DataDecoder { 18 | func decode(_ type: T.Type, from data: Data) throws -> T { 19 | try decoder.decode(type, from: data) 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Documentation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Documentation { 4 | /// A short summary. Typically just a single sentence. 5 | public var summary: String 6 | 7 | /// Longer discussion. 8 | public var discussion: String 9 | 10 | /// If the documentation should be hidden or not. 11 | public var hidden: Bool 12 | 13 | public init(summary: String = "", discussion: String = "", hidden: Bool = true) { 14 | self.summary = summary 15 | self.discussion = discussion 16 | self.hidden = hidden 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Issue.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias IssueHandler = (Issue) -> Void 4 | 5 | public enum Issue: Equatable { 6 | public struct Context: Equatable { 7 | public let codingPath: [Name] 8 | public let description: String 9 | } 10 | 11 | /// A property that was not defined as part of the configuration options was seen during decoding. 12 | case unexpectedOption(Name, Context) 13 | 14 | /// A property that is marked as deprecated has been decoded. 15 | case deprecatedOption(Name, Context) 16 | } 17 | 18 | extension Issue: CustomStringConvertible { 19 | public var description: String { 20 | switch self { 21 | case .unexpectedOption(let name, let context): 22 | return "Found unexpected property \(name.formattedAsQuote(in: context.codingPath)) while decoding." 23 | case .deprecatedOption(let name, let context): 24 | return "Property \(name.formattedAsQuote(in: context.codingPath)) is deprecated. \(context.description)" 25 | } 26 | } 27 | } 28 | 29 | public extension Issue { 30 | static func log(_ issue: Issue) { 31 | print(issue.description) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Name.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Name: RawRepresentable, Hashable { 4 | public let rawValue: String 5 | 6 | public init(rawValue: String) { 7 | self.rawValue = rawValue 8 | } 9 | 10 | init(_ codingKey: CodingKey) { 11 | self.rawValue = codingKey.stringValue 12 | } 13 | } 14 | 15 | // MARK: - Utlis 16 | extension Name { 17 | /// Formats the name as if it was being quoted in a diagnostic message. 18 | /// 19 | /// ```swift 20 | /// let option: Name = "foo" 21 | /// option.formattedAsQuote(in: ["bar", "baz"]) // "‘foo‘ (in ‘bar.baz‘)" 22 | /// option.formattedAsQuote(in: []]) // "‘foo‘" 23 | /// ``` 24 | public func formattedAsQuote(in path: [Name]) -> String { 25 | if path.isEmpty { 26 | return "‘\(rawValue)‘" 27 | } else { 28 | let path = path.map(\.rawValue).joined(separator: ".") 29 | return "‘\(rawValue)‘ (in ‘\(path)‘)" 30 | } 31 | } 32 | } 33 | 34 | // MARK: - ExpressibleByStringLiteral 35 | extension Name: ExpressibleByStringLiteral { 36 | public init(stringLiteral value: StringLiteralType) { 37 | self.init(rawValue: value) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/Option.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum Parsed { 4 | case value(Value) 5 | case definition((Name) -> OptionDefinition) 6 | 7 | init(_ makeDefinition: @escaping (Name) -> OptionDefinition) { 8 | self = .definition(makeDefinition) 9 | } 10 | } 11 | 12 | @propertyWrapper public struct Option { 13 | var _parsedOption: Parsed 14 | 15 | init(_parsedOption: Parsed) { 16 | self._parsedOption = _parsedOption 17 | } 18 | 19 | /// This initializer works around a quirk of property wrappers, where the 20 | /// compiler will not see no-argument initializers in extensions. Explicitly 21 | /// marking this initializer unavailable means that when `Value` conforms to 22 | /// `ExpressibleByArgument`, that overload will be selected instead. 23 | /// 24 | /// ```swift 25 | /// @Argument() var foo: String // Syntax without this initializer 26 | /// @Argument var foo: String // Syntax with this initializer 27 | /// ``` 28 | @available(*, unavailable, message: "A default value must be provided unless the value type conforms to ExpressibleByArgument.") 29 | public init() { 30 | fatalError("unavailable") 31 | } 32 | 33 | public var wrappedValue: Wrapped { 34 | get { 35 | switch _parsedOption { 36 | case .value(let wrapped): 37 | return wrapped 38 | case .definition: 39 | fatalError() // TODO: Better error 40 | } 41 | } 42 | set { 43 | _parsedOption = .value(newValue) 44 | } 45 | } 46 | } 47 | 48 | public extension Option { 49 | init( 50 | wrappedValue: Wrapped, 51 | _ availability: Availability = .available, 52 | summary: String = "", 53 | discussion: String = "", 54 | hidden: Bool = false 55 | ) { 56 | self.init(_parsedOption: .init { name in 57 | OptionDefinition( 58 | name: name, 59 | content: .defaultValue(wrappedValue), 60 | availability: availability, 61 | documentation: Documentation(summary: summary, discussion: discussion, hidden: hidden) 62 | ) 63 | }) 64 | } 65 | } 66 | 67 | public extension Option where Wrapped: ParsableConfiguration { 68 | init( 69 | _ availability: Availability = .available, 70 | summary: String = "", 71 | discussion: String = "", 72 | hidden: Bool = false 73 | ) { 74 | self.init(_parsedOption: .init { name in 75 | OptionDefinition( 76 | name: name, 77 | content: .container(Wrapped.options), 78 | availability: availability, 79 | documentation: Documentation(summary: summary, discussion: discussion, hidden: hidden) 80 | ) 81 | }) 82 | } 83 | } 84 | 85 | // MARK: - Decoding 86 | 87 | extension Option: OptionDefinitionProvider { 88 | func optionDefinition(for name: Name) -> OptionDefinition { 89 | switch _parsedOption { 90 | case .value: 91 | fatalError("Trying to get option definition from a resolved property") 92 | case .definition(let provide): 93 | return provide(name) 94 | } 95 | } 96 | } 97 | 98 | extension Option: Decodable where Wrapped: Decodable { 99 | public init(from decoder: Decoder) throws { 100 | let container = try decoder.singleValueContainer() 101 | let wrapped = try container.decode(Wrapped.self) 102 | self.init(_parsedOption: .value(wrapped)) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/OptionDefinition.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct OptionDefinition { 4 | /// The name of the option that is represented by this definition. 5 | public let name: Name 6 | 7 | /// The content of the option. Either a default value (erased to `Any`) or an array of child definitions if the option is a container. 8 | public let content: Content 9 | 10 | /// The availability of the option. 11 | public let availability: Availability 12 | 13 | /// Documentation describing the option. 14 | public let documentation: Documentation 15 | } 16 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/OptionOverride.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Describes an individual override of an option 4 | public struct OptionOverride: Equatable { 5 | /// The property/option name being overridden. 6 | public var name: Name 7 | 8 | /// The path to the property/option if nested. 9 | public var path: [Name] 10 | 11 | /// The unparsed data representation of the value to be passed into the decoder when reading. 12 | public var value: Data 13 | 14 | public init(name: Name, path: [Name] = [], value: Data) { 15 | self.name = name 16 | self.path = path 17 | self.value = value 18 | } 19 | 20 | /// Returns `nil` if `pathComponents` is empty. 21 | public init?(pathComponents: [Name], value: Data) { 22 | var path = pathComponents 23 | if let name = path.popLast() { 24 | self.init(name: name, path: path, value: value) 25 | } else { 26 | return nil 27 | } 28 | } 29 | 30 | /// Attempts to parse a raw string into an `OptionOverride` by splitting name/path and value on the first seen `delimiter`. 31 | /// 32 | /// ```swift 33 | /// if let parsed = OptionOverride("nested.option=true", delimiter: "=") { 34 | /// parsed.name // "option" 35 | /// parsed.path // ["nested"] 36 | /// parsed.value // Data("true".utf8) 37 | /// } 38 | /// ``` 39 | public init?(_ string: String, delimiter: Character = "=") { 40 | // Ensure that we can split the path and the value by the assignment operator 41 | guard let split = string.firstIndex(of: delimiter) else { 42 | return nil 43 | } 44 | 45 | // Split the value into the raw key and value 46 | let key = string[..( 40 | _ data: Data, 41 | overrides: [OptionOverride] = [], 42 | decoder: T, 43 | issueHandler: @escaping IssueHandler = Issue.log(_:) 44 | ) throws -> Self where T.Input == Data { 45 | try parse(data: data, overrides: overrides, decoder: decoder, issueHandler: issueHandler) 46 | } 47 | 48 | /// Parses the configuration using the provided data with the given overrides as JSON. 49 | /// 50 | /// Each option within a configuration is resolved in the following order: 51 | /// 52 | /// 1. Overrides defined in the `overrides` array. 53 | /// 2. The value decoded from the given `data` if it was present. 54 | /// 3. The default value defined on the type. 55 | /// 56 | /// When processing values from `overrides` or `data`, they are decoded using `JSONDecoder` with a default configuration. 57 | /// To customise this behaviour, or use a different file format instead, pass a custom decoder using ``parse(_:overrides:decoder:issueHandler:)``. 58 | /// 59 | /// - Parameters: 60 | /// - data: The data representation of the configuration file to be decoded and read from. 61 | /// - overrides: An array of individual overrides that take priority over the values defined in the configuration. 62 | /// - issueHandler: A closure invoked whenever an issue is detected. By default a message will be printed to the console. 63 | /// - Returns: The configuration will all options successfully resolved to a value. 64 | static func parse( 65 | _ data: Data, 66 | overrides: [OptionOverride] = [], 67 | issueHandler: @escaping IssueHandler = Issue.log(_:) 68 | ) throws -> Self { 69 | try parse(data, overrides: overrides, decoder: JSONDecoder(), issueHandler: issueHandler) 70 | } 71 | 72 | /// Parses the configuration from the given file with the given overrides. 73 | /// 74 | /// Each option within a configuration is resolved in the following order: 75 | /// 76 | /// 1. Overrides defined in the `overrides` array. 77 | /// 2. The value decoded from the given `fileURL` if it was present. 78 | /// 3. The default value defined on the type. 79 | /// 80 | /// When processing values from `overrides` or `fileURL`, they are decoded using the given `decoder`. 81 | /// 82 | /// - Parameters: 83 | /// - data: The data representation of the configuration file to be decoded and read from. 84 | /// - overrides: An array of individual overrides that take priority over the values defined in the configuration. 85 | /// - decoder: A type used to decode input data or overrides. 86 | /// - issueHandler: A closure invoked whenever an issue is detected. By default a message will be printed to the console. 87 | /// - Returns: The configuration will all options successfully resolved to a value. 88 | static func parse( 89 | contentsOf fileURL: URL, 90 | overrides: [OptionOverride] = [], 91 | decoder: T, 92 | issueHandler: @escaping IssueHandler = Issue.log(_:) 93 | ) throws -> Self where T.Input == Data { 94 | let data = try Data(contentsOf: fileURL) 95 | return try parse(data, overrides: overrides, decoder: decoder, issueHandler: issueHandler) 96 | } 97 | 98 | /// Parses the configuration from the given file with the given overrides as JSON. 99 | /// 100 | /// Each option within a configuration is resolved in the following order: 101 | /// 102 | /// 1. Overrides defined in the `overrides` array. 103 | /// 2. The value decoded from the given `fileURL` if it was present. 104 | /// 3. The default value defined on the type. 105 | /// 106 | /// When processing values from `overrides` or `fileURL`, they are decoded using `JSONDecoder` with a default configuration. 107 | /// To customise this behaviour, or use a different file format instead, pass a custom decoder using ``parse(contentsOf:overrides:decoder:issueHandler:)``. 108 | /// 109 | /// - Parameters: 110 | /// - data: The data representation of the configuration file to be decoded and read from. 111 | /// - overrides: An array of individual overrides that take priority over the values defined in the configuration. 112 | /// - issueHandler: A closure invoked whenever an issue is detected. By default a message will be printed to the console. 113 | /// - Returns: The configuration will all options successfully resolved to a value. 114 | static func parse( 115 | contentsOf fileURL: URL, 116 | overrides: [OptionOverride] = [], 117 | issueHandler: @escaping IssueHandler = Issue.log(_:) 118 | ) throws -> Self { 119 | try parse(contentsOf: fileURL, overrides: overrides, decoder: JSONDecoder(), issueHandler: issueHandler) 120 | } 121 | 122 | /// Parses the configuration using the provided overrides. 123 | /// 124 | /// If an override was not specified for a value, it's default will be used instead. 125 | /// When processing values from `overrides`, they are decoded using the given `decoder`. 126 | /// 127 | /// - Parameters: 128 | /// - overrides: An array of individual overrides that take priority over the values defined in the configuration. 129 | /// - decoder: A type used to decode input data or overrides. 130 | /// - issueHandler: A closure invoked whenever an issue is detected. By default a message will be printed to the console. 131 | /// - Returns: The configuration will all options successfully resolved to a value. 132 | static func parse( 133 | overrides: [OptionOverride] = [], 134 | decoder: T, 135 | issueHandler: @escaping IssueHandler = Issue.log(_:) 136 | ) throws -> Self where T.Input == Data { 137 | try parse(data: nil, overrides: overrides, decoder: decoder, issueHandler: issueHandler) 138 | } 139 | 140 | /// Parses the configuration using the provided overrides as JSON. 141 | /// 142 | /// If an override was not specified for a value, it's default will be used instead. 143 | /// When processing values from `overrides`, they are decoded using `JSONDecoder` with a default configuration. 144 | /// To customise this behaviour, or use a different file format instead, pass a custom decoder using ``parse(overrides:decoder:issueHandler:)``. 145 | /// 146 | /// - Parameters: 147 | /// - overrides: An array of individual overrides that take priority over the values defined in the configuration. 148 | /// - decoder: A type used to decode input data or overrides. 149 | /// - issueHandler: A closure invoked whenever an issue is detected. By default a message will be printed to the console. 150 | /// - Returns: The configuration will all options successfully resolved to a value. 151 | static func parse( 152 | overrides: [OptionOverride] = [], 153 | issueHandler: @escaping IssueHandler = Issue.log(_:) 154 | ) throws -> Self { 155 | try parse(data: nil, overrides: overrides, decoder: JSONDecoder(), issueHandler: issueHandler) 156 | } 157 | 158 | /// Internal implementation 159 | private static func parse( 160 | data: Data?, 161 | overrides: [OptionOverride], 162 | decoder: T, 163 | issueHandler: @escaping IssueHandler 164 | ) throws -> Self where T.Input == Data { 165 | try Self(from: ConfigurationDecoder( 166 | definitions: options, 167 | dataContainer: data.flatMap { try decoder.decode(DecodableContainer.self, from: $0) }, 168 | overridesContainer: OverrideContainer(overrides: overrides, decoder: TopLevelDataDecoder(decoder)), 169 | codingPath: [], 170 | issueHandler: issueHandler 171 | )) 172 | } 173 | } 174 | 175 | public extension ParsableConfiguration { 176 | /// Returns an array of option definitions resolved by reflecting the type using `Mirror`. 177 | /// 178 | /// ``ParsableConfiguration`` uses this information when when parsing the type, but you can also use it downstream in your own tooling for things like documentation generation. 179 | static var options: [OptionDefinition] { 180 | Mirror(reflecting: Self()) 181 | .children 182 | .compactMap(definition(from:)) 183 | } 184 | 185 | private static func definition(from child: Mirror.Child) -> OptionDefinition? { 186 | guard let codingKey = child.label else { return nil } 187 | 188 | // Property wrappers have underscore-prefixed names, be sure to strip 189 | let name: Name 190 | if codingKey.hasPrefix("_") { 191 | name = Name(rawValue: String(codingKey.dropFirst())) 192 | } else { 193 | name = Name(rawValue: codingKey) 194 | } 195 | 196 | // If the property was a property wrapper, return it's definition 197 | if let provider = child.value as? OptionDefinitionProvider { 198 | return provider.optionDefinition(for: name) 199 | } 200 | 201 | // Otherwise build a basic one from the property itself 202 | return OptionDefinition( 203 | name: name, 204 | content: .defaultValue(child.value), 205 | availability: .available, 206 | documentation: Documentation() 207 | ) 208 | } 209 | } 210 | 211 | -------------------------------------------------------------------------------- /Sources/ConfigurationParser/Types/TopLevelDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(Combine) 4 | // When Combine can be imported, use it's `TopLevelDecoder`. 5 | // This is because other libraries (i.e Yams) already provide conformance 6 | @_exported import protocol Combine.TopLevelDecoder 7 | 8 | /// A type that defines methods for decoding. 9 | public typealias TopLevelDecoder = Combine.TopLevelDecoder 10 | #else 11 | // When Combine is not present, redeclare the protocol ourselves 12 | 13 | /// A type that defines methods for decoding. 14 | public protocol TopLevelDecoder { 15 | 16 | /// The type this decoder accepts. 17 | associatedtype Input 18 | 19 | /// Decodes an instance of the indicated type. 20 | func decode(_ type: T.Type, from: Self.Input) throws -> T where T : Decodable 21 | } 22 | 23 | // Add automatic conformance for JSONDecoder 24 | extension JSONDecoder: TopLevelDecoder { 25 | } 26 | #endif 27 | -------------------------------------------------------------------------------- /Tests/ConfigurationParserTests/ConfigurationParserTests.swift: -------------------------------------------------------------------------------- 1 | import ConfigurationParser 2 | import XCTest 3 | 4 | final class ConfigurationParserTests: XCTestCase { 5 | struct Configuration: ParsableConfiguration { 6 | enum Power: Int, Decodable { 7 | case low, medium, high 8 | } 9 | 10 | @Option( 11 | summary: "The power level used if not otherwise specified.", 12 | discussion: """ 13 | When setting a default power level, it is crucial that you \ 14 | are sure that the consumption will not exceed the daily \ 15 | allocated allowance. Failure to keep within your usage \ 16 | limits will result in termination of the contract. 17 | 18 | It's recommended that you do not change this value unless \ 19 | you are fully aware of the risks involved. 20 | """ 21 | ) 22 | var defaultPowerLevel: Power = .medium 23 | 24 | @Option(summary: "Array of preferred spoken languages") 25 | var preferredLanguages: [String] = ["en", "fr", "ar"] 26 | 27 | @Option( 28 | .deprecated("Replaced by ‘preferredLanguages‘"), 29 | summary: "The preferred spoken language", 30 | hidden: true 31 | ) 32 | var preferredLanguage: String? = nil 33 | 34 | var showInternalMenu: Bool = false 35 | 36 | @Option(summary: "Details of the author used when creating commits") 37 | var author: Author 38 | 39 | struct Author: ParsableConfiguration { 40 | @Option(summary: "The full name of the author") 41 | var name: String = "John Doe" 42 | 43 | @Option(summary: "The email address of the author") 44 | var email: String = "no-reply@example.com" 45 | } 46 | } 47 | 48 | 49 | func testDefaultConfiguration() { 50 | let configuration = Configuration.default 51 | 52 | XCTAssertEqual(configuration.defaultPowerLevel, .medium) 53 | XCTAssertEqual(configuration.preferredLanguages, ["en", "fr", "ar"]) 54 | XCTAssertNil(configuration.preferredLanguage) 55 | XCTAssertFalse(configuration.showInternalMenu) 56 | XCTAssertEqual(configuration.author.name, "John Doe") 57 | XCTAssertEqual(configuration.author.email, "no-reply@example.com") 58 | 59 | } 60 | 61 | func testDecode() throws { 62 | let data = Data(""" 63 | { 64 | "defaultPowerLevel": 2, 65 | "preferredLanguages": ["es", "fr"], 66 | "preferredLanguage": "es", 67 | "showInternalMenu": true, 68 | "author": { 69 | "name": "Liam Nichols", 70 | } 71 | } 72 | """.utf8) 73 | 74 | let configuration = try Configuration.parse(data) 75 | 76 | XCTAssertEqual(configuration.defaultPowerLevel, .high) 77 | XCTAssertEqual(configuration.preferredLanguages, ["es", "fr"]) 78 | XCTAssertEqual(configuration.preferredLanguage, "es") 79 | XCTAssertTrue(configuration.showInternalMenu) 80 | XCTAssertEqual(configuration.author.name, "Liam Nichols") 81 | XCTAssertEqual(configuration.author.email, "no-reply@example.com") 82 | } 83 | 84 | func testIssueDetection() throws { 85 | let data = Data(""" 86 | { 87 | "defaultPowerlevel": 0, 88 | "preferredLanguage": "es", 89 | "author": { 90 | "isActive": true 91 | } 92 | } 93 | """.utf8) 94 | 95 | var recordedIssues: [Issue] = [] 96 | _ = try Configuration.parse(data) { issue in 97 | recordedIssues.append(issue) 98 | } 99 | 100 | XCTAssertEqual(Set(recordedIssues.map(\.description)), [ 101 | "Found unexpected property ‘defaultPowerlevel‘ while decoding.", 102 | "Property ‘preferredLanguage‘ is deprecated. Replaced by ‘preferredLanguages‘", 103 | "Found unexpected property ‘isActive‘ (in ‘author‘) while decoding." 104 | ]) 105 | } 106 | 107 | func testOverride() throws { 108 | let data = Data(""" 109 | { 110 | "defaultPowerLevel": 0, 111 | "author": { 112 | "name": "In the file" 113 | } 114 | } 115 | """.utf8) 116 | 117 | let overrides: [OptionOverride] = [ 118 | #"defaultPowerLevel=2"#, 119 | #"author.name="Foo""#, 120 | #"preferredLanguages=["en"]"#, 121 | #"unknownProperty=false"# 122 | ] 123 | 124 | var recordedIssues: [Issue] = [] 125 | let configuration = try Configuration.parse(data, overrides: overrides) { issue in 126 | recordedIssues.append(issue) 127 | } 128 | 129 | XCTAssertEqual(configuration.defaultPowerLevel, .high) 130 | XCTAssertEqual(configuration.author.name, "Foo") 131 | XCTAssertEqual(configuration.preferredLanguages, ["en"]) 132 | 133 | XCTAssertEqual(Set(recordedIssues.map(\.description)), [ 134 | "Found unexpected property ‘unknownProperty‘ while decoding." 135 | ]) 136 | } 137 | } 138 | 139 | extension OptionOverride: ExpressibleByStringLiteral { 140 | public init(stringLiteral value: StaticString) { 141 | self = OptionOverride(String(describing: value))! 142 | } 143 | } 144 | --------------------------------------------------------------------------------