├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swift-version ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Winnie │ ├── Config │ ├── ConfigParser.swift │ ├── ConfigParserError.swift │ ├── INI │ │ ├── Bool+INIValueConvertible.swift │ │ ├── Double+INIValueConvertible.swift │ │ ├── INIValue+CustomStringConvertible.swift │ │ ├── INIValue+ExpressibleByLiteral.swift │ │ ├── INIValue+INIValueConvertible.swift │ │ ├── INIValue.swift │ │ ├── INIValueConvertible.swift │ │ ├── Int+INIValueConvertible.swift │ │ └── String+INIValueConvertible.swift │ └── SectionProxy.swift │ └── Token │ ├── Token+CustomDebugStringConvertible.swift │ ├── Token+CustomStringConvertible.swift │ ├── Token.swift │ ├── TokenError.swift │ └── Tokenizer.swift └── Tests └── WinnieTests ├── ConfigParserTests.swift └── TokenizerTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | container: 12 | image: swift:6.0 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Build 16 | run: swift build 17 | - name: Run Tests 18 | run: swift test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .vscode 10 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 6.1.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Patrick T Coakley 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.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "c1a26968d550872cd9cd6f4518db306553848597595bb2b6661d5ad68bb25296", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-collections", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-collections.git", 8 | "state" : { 9 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 10 | "version" : "1.1.4" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Winnie", 7 | products: [ 8 | .library( 9 | name: "Winnie", 10 | targets: ["Winnie"] 11 | ), 12 | ], 13 | dependencies: [ 14 | .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.1.4")), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Winnie", dependencies: [.product(name: "Collections", package: "swift-collections"), 19 | ] 20 | ), 21 | .testTarget( 22 | name: "WinnieTests", 23 | dependencies: ["Winnie"] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Winnie 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fpatricktcoakley%2FWinnie%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/patricktcoakley/Winnie) 4 | 5 | An [INI](https://en.wikipedia.org/wiki/INI_file)/config file parsing library inpsired by [ConfigParser](https://docs.python.org/3/library/configparser.html) for Swift. Currently supports the [general featureset](#features) of ConfigParser with [more features planned in the future](#roadmap). 6 | 7 | ## Features 8 | 9 | Winnie supports most of what ConfigParser does sans [interpolation](https://docs.python.org/3/library/configparser.html#interpolation-of-values) and some new features, including: 10 | 11 | * The ability to read and write INI configs from strings and from files with preservation of the original insertion order. 12 | * The creating new configs from scratch. 13 | * A customizable global fallback section when setting options without an explicit section. 14 | * The ability to take references to sections and operate on them as if they were dictionaries. 15 | * Safely-typed getters with the ability to provide default values and rely on type inference. 16 | * Basic customization for writing out the config assignment operator to use or whether or not to include leading spaces. 17 | 18 | ## Usage 19 | 20 | ```swift 21 | import Winnie 22 | 23 | let input = """ 24 | [owner] 25 | name = John Doe 26 | organization = Acme Widgets Inc. 27 | 28 | [database] 29 | server = 192.0.2.62 30 | port = 143 31 | file = payroll.dat 32 | path = /var/database 33 | """ 34 | 35 | let config = ConfigParser() 36 | 37 | // You can read from strings or files 38 | try config.read(input) // Reads from the string above 39 | try config.readFile("/path/to/settings.ini") // Reads from a file path 40 | 41 | // Winnie supports multiple ways to access sections, options, and values 42 | 43 | // The get/set methods are marked as `throws` 44 | // You can use typed getters or the generic get 45 | let name = try config.getString(section: "owner", option: "name") 46 | try config.set(section: "database", option: "file", value: "payroll_updated.data") 47 | 48 | let connectionRetries: Int = try config.get(section: "database", option: "retries", default: 3) 49 | let autoConnect: Bool = try config.getBool(section: "database", option: "auto_connect", default: true) 50 | let backupPath: String? = try? config.get(section: "archive", option: "path") // No default, will be nil if not found 51 | let defaultLogLevel: String = try config.get(option: "log_level", default: "INFO") // Option from default section 52 | 53 | // Subscripting returns an optional INIValue? for safe value extraction 54 | let portValue = config["database", "port"] // portValue is of type INIValue? 55 | if let port = portValue?.intValue { // You can attempt to extract it using a typed getter 56 | print("Port as Int: \(port)") 57 | } 58 | 59 | // Accessing a section and iterating its options and values 60 | if let ownerSection = config["database"] { 61 | for (key, value) in ownerSection { 62 | print("\(key) is \(value)") 63 | } 64 | } 65 | 66 | // You can also safely use subscripting for assignment 67 | config["database", "path"] = "/var/databasev2" // Assigns an INIValueConvertible 68 | 69 | // You can write to a string or a file 70 | let outputString = config.write() // Returns the INI content as a String (does not throw) 71 | try config.writeFile("/tmp/database.ini") // Writes the INI content to a file path (throws) 72 | 73 | // Initializing with a custom default section name 74 | let customConfig = ConfigParser(defaultSection: "GLOBAL") 75 | try customConfig.set(option: "adminUser", value: "root") // Sets 'adminUser' in [GLOBAL] instead of the standard [DEFAULT] 76 | let admin: String? = try? customConfig.get(option: "adminUser") 77 | 78 | // Overriding the default assignment with `:` and not including leading spaces 79 | let customConfigOutput = customConfig.write(assignment: .colon, leadingSpaces: false) 80 | ``` 81 | 82 | ## Roadmap 83 | 84 | At this point Winnie is pretty much feature-complete, and any of the following are more of a wishlist of things I would like to see added at some point in the future: 85 | 86 | * It is a goal to eventually fully support [Unreal Engine configuration files](https://dev.epicgames.com/documentation/en-us/unreal-engine/configuration-files-in-unreal-engine), which also has arrays, tuples, structs, and other primitives beyond what is usually available in the usual INI format (INI has no official standard). 87 | * Adding more customization like specifying tabs or spaces between entries, casing for output (PascalCase vs camelCase vs snake_case), and default values for booleans (currently "True" and "False"). 88 | * Investigate interpolation or other kinds of templating to make it easier to batch-create configs. 89 | * Being able to preserve comments is not currently a goal per-se, as many similar libraries also don't do that, but it is also something I want to explore in the future as I could see it being beneficial to the end-user. 90 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/ConfigParser.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OrderedCollections 3 | 4 | public typealias Section = OrderedDictionary 5 | public typealias Config = OrderedDictionary 6 | 7 | public enum AssignmentCharacter: String { 8 | case equals = "=" 9 | case colon = ":" 10 | } 11 | 12 | public final class ConfigParser { 13 | var config: Config = [:] 14 | private let defaultSection: String 15 | 16 | public init(defaultSection: String = "DEFAULT") { 17 | self.defaultSection = defaultSection 18 | config[self.defaultSection] = [:] 19 | } 20 | 21 | public init(file path: String, defaultSection: String = "DEFAULT") throws { 22 | self.defaultSection = defaultSection 23 | config[self.defaultSection] = [:] 24 | try readFile(path) 25 | } 26 | 27 | public init(input string: String, defaultSection: String = "DEFAULT") throws { 28 | self.defaultSection = defaultSection 29 | config[self.defaultSection] = [:] 30 | try read(string) 31 | } 32 | 33 | public var sections: SectionProxySequence { SectionProxySequence(parser: self) } 34 | public var sectionNames: [String] { Array(config.keys) } 35 | 36 | public subscript(section: String) -> SectionProxy? { 37 | guard config[section] != nil else { return nil } 38 | return SectionProxy(section: section, parser: self) 39 | } 40 | 41 | public subscript(option: String) -> INIValue? { 42 | get { 43 | config[self.defaultSection]?[option] 44 | } 45 | 46 | set { 47 | if config[self.defaultSection] == nil { 48 | config[self.defaultSection] = [:] 49 | } 50 | 51 | if let newValue { 52 | config[self.defaultSection]?[option] = newValue 53 | return 54 | } 55 | 56 | config[self.defaultSection]?.removeValue(forKey: option) 57 | } 58 | } 59 | 60 | public subscript(section: String, option: String) -> INIValue? { 61 | get { 62 | if let sectionValue = config[section]?[option] { 63 | return sectionValue 64 | } 65 | 66 | if section != self.defaultSection { 67 | return config[self.defaultSection]?[option] 68 | } 69 | 70 | return nil 71 | } 72 | 73 | set { 74 | if config[section] == nil { 75 | config[section] = [:] 76 | } 77 | 78 | if let newValue { 79 | config[section]?[option] = newValue 80 | return 81 | } 82 | 83 | config[section]?.removeValue(forKey: option) 84 | } 85 | } 86 | 87 | public func items(section: String) throws(ConfigParserError) -> any MutableCollection { 88 | if let values = config[section] { 89 | return values.values 90 | } 91 | 92 | throw .sectionNotFound(section) 93 | } 94 | 95 | public func get(section: String, option: String) throws(ConfigParserError) -> T { 96 | if let section = config[section] { 97 | guard let value = section[option] else { throw .optionNotFound(option) } 98 | return try T.from(value) 99 | } 100 | 101 | throw .sectionNotFound(section) 102 | } 103 | 104 | public func get(option: String) throws(ConfigParserError) -> T { 105 | try get(section: self.defaultSection, option: option) 106 | } 107 | 108 | public func get(section: String, option: String, default defaultValue: T) -> T { 109 | (try? get(section: section, option: option)) ?? defaultValue 110 | } 111 | 112 | public func get(option: String, default defaultValue: T) -> T { 113 | get(section: self.defaultSection, option: option, default: defaultValue) 114 | } 115 | 116 | public func getBool(section: String, option: String) throws(ConfigParserError) -> Bool { 117 | try get(section: section, option: option) 118 | } 119 | 120 | public func getString(section: String, option: String) throws(ConfigParserError) -> String { 121 | try get(section: section, option: option) 122 | } 123 | 124 | public func getInt(section: String, option: String) throws(ConfigParserError) -> Int { 125 | try get(section: section, option: option) 126 | } 127 | 128 | public func getDouble(section: String, option: String) throws(ConfigParserError) -> Double { 129 | try get(section: section, option: option) 130 | } 131 | 132 | public func set(section: String, option: String, value: some INIValueConvertible) throws(ConfigParserError) { 133 | if config[section] == nil { 134 | throw ConfigParserError.sectionNotFound(section) 135 | } 136 | 137 | config[section]![option] = value.into() 138 | } 139 | 140 | public func set(option: String, value: some INIValueConvertible) throws(ConfigParserError) { 141 | try set(section: self.defaultSection, option: option, value: value) 142 | } 143 | 144 | public func addSection(_ section: String) throws(ConfigParserError) { 145 | guard section != self.defaultSection else { 146 | throw ConfigParserError.valueError("Cannot add default section.") 147 | } 148 | 149 | guard config[section] == nil else { 150 | throw ConfigParserError.valueError("Section \(section) already exists.") 151 | } 152 | 153 | config[section] = [:] 154 | } 155 | 156 | public func removeSection(_ section: String) throws(ConfigParserError) { 157 | guard section != self.defaultSection else { 158 | throw ConfigParserError.valueError("Cannot remove default section.") 159 | } 160 | 161 | config.removeValue(forKey: section) 162 | } 163 | 164 | public func hasSection(_ section: String) -> Bool { config[section] != nil } 165 | 166 | public func readFile(_ path: String) throws { 167 | let contents = try String(contentsOfFile: path) 168 | try read(contents) 169 | } 170 | 171 | public func read(_ contents: String) throws { 172 | var tokenizer = Tokenizer(contents) 173 | let tokens = try tokenizer.tokenize() 174 | var index = tokens.startIndex 175 | var currentSection = self.defaultSection 176 | var newConfig: Config = [self.defaultSection: [:]] 177 | 178 | while index < tokens.endIndex { 179 | let token = tokens[index] 180 | 181 | switch token { 182 | case let .section(section): 183 | currentSection = section 184 | if currentSection != self.defaultSection { 185 | newConfig[currentSection] = [:] 186 | } 187 | 188 | index = tokens.index(after: index) 189 | 190 | case let .string(option): 191 | index = tokens.index(after: index) 192 | 193 | if index < tokens.endIndex, tokens[index] == .equals || tokens[index] == .colon { 194 | index = tokens.index(after: index) 195 | 196 | let value = if index < tokens.endIndex, case let .string(string) = tokens[index] { 197 | string.trimmingCharacters(in: .whitespacesAndNewlines) 198 | } else { 199 | "" 200 | } 201 | 202 | newConfig[currentSection]?[option] = value.into() 203 | 204 | if index < tokens.endIndex, case .string = tokens[index] { 205 | index = tokens.index(after: index) 206 | } 207 | } else { 208 | newConfig[currentSection]?[option] = "" 209 | } 210 | 211 | default: 212 | index = tokens.index(after: index) 213 | } 214 | } 215 | 216 | config = newConfig 217 | } 218 | 219 | public func writeFile(_ path: String, assignment: AssignmentCharacter = .equals, leadingSpaces: Bool = true) throws { 220 | try write( 221 | assignment: assignment, leadingSpaces: leadingSpaces 222 | ).write(toFile: path, atomically: true, encoding: .utf8) 223 | } 224 | 225 | public func write(assignment: AssignmentCharacter = .equals, leadingSpaces: Bool = true) -> String { 226 | let spaces = leadingSpaces ? " " : "" 227 | let assignment = "\(spaces)\(assignment.rawValue)\(spaces)" 228 | 229 | var output = "" 230 | 231 | if let defaultSection = config[self.defaultSection], !defaultSection.isEmpty { 232 | output += "[DEFAULT]\n" 233 | for (option, value) in defaultSection { 234 | output += "\(option)\(assignment)\(value.description)\n" 235 | } 236 | 237 | // Only add a newline if defaultSection isn't the only section and isnt empty 238 | if !defaultSection.isEmpty, config.keys.count > 1 { 239 | output += "\n" 240 | } 241 | } 242 | 243 | for section in sectionNames where section != self.defaultSection { 244 | output += "[\(section)]\n" 245 | 246 | if let sectionData = config[section] { 247 | for (option, value) in sectionData { 248 | output += "\(option)\(assignment)\(value.description)\n" 249 | } 250 | } 251 | output += "\n" 252 | } 253 | 254 | return output.trimmingCharacters(in: .newlines) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/ConfigParserError.swift: -------------------------------------------------------------------------------- 1 | public enum ConfigParserError: Error, Equatable { 2 | case sectionNotFound(String) 3 | case optionNotFound(String) 4 | case valueError(String) 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/Bool+INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | extension Bool: INIValueConvertible { 2 | public func into() -> INIValue { .bool(self) } 3 | 4 | public static func from(_ value: INIValue) throws(ConfigParserError) -> Bool { 5 | switch value { 6 | case let .bool(bool): bool 7 | case let .string(string): 8 | switch string.lowercased() { 9 | case "true", "yes", "1", "on": true 10 | case "false", "no", "0", "off": false 11 | default: throw ConfigParserError.valueError("Cannot convert to Bool: \(string)") 12 | } 13 | case let .double(t): t > 0.0 14 | case let .int(i): i > 0 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/Double+INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | extension Double: INIValueConvertible { 2 | public func into() -> INIValue { .double(self) } 3 | 4 | public static func from(_ value: INIValue) throws(ConfigParserError) -> Double { 5 | switch value { 6 | case let .double(double): return double 7 | case let .int(int): return Double(int) 8 | case let .bool(bool): return !bool ? 0.0 : 1.0 9 | case let .string(string): 10 | guard let result = Double(string) else { 11 | throw ConfigParserError.valueError("Cannot convert to Double: \(string)") 12 | } 13 | return result 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/INIValue+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | extension INIValue: CustomStringConvertible { 2 | public var description: String { 3 | switch self { 4 | case let .string(value): value 5 | case let .int(value): String(value) 6 | case let .double(value): String(value) 7 | case let .bool(value): value ? "True" : "False" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/INIValue+ExpressibleByLiteral.swift: -------------------------------------------------------------------------------- 1 | extension INIValue: ExpressibleByStringLiteral { 2 | public init(stringLiteral value: String) { self = .string(value) } 3 | } 4 | 5 | extension INIValue: ExpressibleByIntegerLiteral { 6 | public init(integerLiteral value: Int) { self = .int(value) } 7 | } 8 | 9 | extension INIValue: ExpressibleByBooleanLiteral { 10 | public init(booleanLiteral value: Bool) { self = .bool(value) } 11 | } 12 | 13 | extension INIValue: ExpressibleByFloatLiteral { 14 | public init(floatLiteral value: Double) { self = .double(value) } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/INIValue+INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | extension INIValue: INIValueConvertible { 2 | public func into() -> INIValue { self } 3 | public static func from(_ value: INIValue) throws(ConfigParserError) -> Self { value } 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/INIValue.swift: -------------------------------------------------------------------------------- 1 | public enum INIValue: Equatable { 2 | case string(String) 3 | case int(Int) 4 | case double(Double) 5 | case bool(Bool) 6 | 7 | public init(from string: String) { 8 | if let anInt = Int(string) { 9 | self = .int(anInt) 10 | return 11 | } 12 | 13 | if let aDouble = Double(string) { 14 | self = .double(aDouble) 15 | return 16 | } 17 | 18 | switch string.lowercased() { 19 | case "true", "yes", "1", "on": 20 | self = .bool(true) 21 | return 22 | case "false", "no", "0", "off": 23 | self = .bool(false) 24 | return 25 | default: 26 | self = .string(string) 27 | } 28 | } 29 | 30 | public var intValue: Int? { try? Int.from(self) } 31 | public var doubleValue: Double? { try? Double.from(self) } 32 | public var boolValue: Bool? { try? Bool.from(self) } 33 | public var stringValue: String? { try? String.from(self) } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | public protocol INIValueConvertible { 2 | func into() -> INIValue 3 | static func from(_ value: INIValue) throws(ConfigParserError) -> Self 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/Int+INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | extension Int: INIValueConvertible { 2 | public func into() -> INIValue { .int(self) } 3 | 4 | public static func from(_ value: INIValue) throws(ConfigParserError) -> Int { 5 | switch value { 6 | case let .int(int): return int 7 | case let .bool(bool): return !bool ? 0 : 1 8 | case let .double(double): return Int(double) 9 | case let .string(string): 10 | guard let result = Int(string) else { 11 | throw ConfigParserError.valueError("Cannot convert to Int: \(string)") 12 | } 13 | return result 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/INI/String+INIValueConvertible.swift: -------------------------------------------------------------------------------- 1 | extension String: INIValueConvertible { 2 | public func into() -> INIValue { .string(self) } 3 | 4 | public static func from(_ value: INIValue) throws(ConfigParserError) -> String { 5 | switch value { 6 | case let .string(string): string 7 | default: value.description 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Winnie/Config/SectionProxy.swift: -------------------------------------------------------------------------------- 1 | import OrderedCollections 2 | 3 | public struct SectionProxy { 4 | let section: String 5 | unowned var parser: ConfigParser 6 | 7 | init(section: String, parser: ConfigParser) { 8 | self.section = section 9 | self.parser = parser 10 | } 11 | 12 | public subscript(option: String) -> INIValue? { 13 | get { parser[section, option] } 14 | set { parser[section, option] = newValue } 15 | } 16 | 17 | public var options: OrderedSet { parser.config[section]?.keys ?? [] } 18 | 19 | public var values: [INIValue] { 20 | if let valuesCollection = parser.config[section]?.values { 21 | return Array(valuesCollection) 22 | } 23 | return [] 24 | } 25 | } 26 | 27 | public struct SectionProxyIterator: IteratorProtocol { 28 | public typealias Element = SectionProxy 29 | 30 | unowned let parser: ConfigParser 31 | var keyIterator: IndexingIterator> 32 | 33 | init(parser: ConfigParser) { 34 | self.parser = parser 35 | keyIterator = parser.config.keys.makeIterator() 36 | } 37 | 38 | public mutating func next() -> Element? { 39 | guard let sectionKey = keyIterator.next() else { return nil } 40 | return SectionProxy(section: sectionKey, parser: parser) 41 | } 42 | } 43 | 44 | public struct SectionProxySequence: Sequence { 45 | public typealias Element = SectionProxy 46 | public typealias Iterator = SectionProxyIterator 47 | 48 | unowned let parser: ConfigParser 49 | 50 | init(parser: ConfigParser) { 51 | self.parser = parser 52 | } 53 | 54 | public func makeIterator() -> Iterator { SectionProxyIterator(parser: parser) } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Winnie/Token/Token+CustomDebugStringConvertible.swift: -------------------------------------------------------------------------------- 1 | extension Token: CustomDebugStringConvertible { 2 | public var debugDescription: String { 3 | switch self { 4 | case .plus: "plus::<+>" 5 | case .bang: "bang::" 6 | case .equals: "equals::<=>" 7 | case .colon: "colon::,<:>" 8 | case .minus: "minus::<->" 9 | case .newline: "newline::<\n>" 10 | case .eof: "eof::" 11 | case let .section(value): "section::<\(value)]" 12 | case let .string(value): "string::<\(value)>" 13 | case let .comment(value): "comment::<\(value)>" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Winnie/Token/Token+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | extension Token: CustomStringConvertible { 2 | public var description: String { 3 | switch self { 4 | // Reserved for future use 5 | case .plus: "+" 6 | case .bang: "!" 7 | // Single tokens 8 | case .equals: "=" 9 | case .colon: ":" 10 | case .minus: "-" 11 | case .newline: "\n" 12 | case .eof: "EOF" 13 | // Value tokens 14 | case let .section(value): "[\(value)]" 15 | case let .string(value): value 16 | case let .comment(value): value 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /Sources/Winnie/Token/Token.swift: -------------------------------------------------------------------------------- 1 | public enum Token: Equatable { 2 | case equals, colon, plus, minus, bang, newline, eof 3 | case section(String) 4 | case string(String) 5 | case comment(String) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/Winnie/Token/TokenError.swift: -------------------------------------------------------------------------------- 1 | public enum TokenizerError: Error, Equatable { 2 | case unterminatedSection(line: Int) 3 | case unterminatedString(line: Int) 4 | case syntax(line: Int, message: String) 5 | } 6 | -------------------------------------------------------------------------------- /Sources/Winnie/Token/Tokenizer.swift: -------------------------------------------------------------------------------- 1 | public struct Tokenizer { 2 | let input: String 3 | var line = 1 4 | var currentIndex: String.Index 5 | var current: Character { input[currentIndex] } 6 | 7 | var isSupportedCharacter: Bool { 8 | current.isLetter || current.isNumber || current == "_" || current == "-" || current == "." 9 | || current == " " || current == "*" || current == "/" || current == "\\" 10 | } 11 | 12 | init(_ input: String) { 13 | self.input = input 14 | currentIndex = self.input.startIndex 15 | } 16 | 17 | public mutating func tokenize() throws(TokenizerError) -> [Token] { 18 | var tokens: [Token] = [] 19 | 20 | while true { 21 | let token = try scan() 22 | tokens.append(token) 23 | if token == .eof { break } 24 | } 25 | return tokens 26 | } 27 | 28 | public mutating func scan() throws(TokenizerError) -> Token { 29 | skipWhitespace() 30 | 31 | guard currentIndex < input.endIndex else { return .eof } 32 | 33 | switch current { 34 | case "[": 35 | return try handleSection() 36 | case "=": 37 | advance() 38 | return .equals 39 | case ":": 40 | advance() 41 | return .colon 42 | case "+": 43 | advance() 44 | return .plus 45 | case "!": 46 | advance() 47 | return .bang 48 | case "\"": 49 | return try handleQuotedString() 50 | case "#", ";": return handleComment() 51 | case "\n": 52 | advance() 53 | return .newline 54 | default: 55 | guard isSupportedCharacter else { 56 | throw TokenizerError.syntax(line: line, message: "Unexpected character: \(current)") 57 | } 58 | return handleString() 59 | } 60 | } 61 | 62 | private mutating func advance() { 63 | if current == "\n" { line += 1 } 64 | currentIndex = input.index(after: currentIndex) 65 | } 66 | 67 | private mutating func skipWhitespace() { 68 | while currentIndex < input.endIndex, current.isWhitespace, !current.isNewline { 69 | advance() 70 | } 71 | } 72 | 73 | private mutating func handleQuotedString() throws(TokenizerError) -> Token { 74 | var stringValue = "" 75 | 76 | advance() 77 | 78 | while currentIndex < input.endIndex { 79 | let char = current 80 | 81 | if char == "\"" { // End quote 82 | advance() 83 | return .string(stringValue) 84 | } 85 | 86 | if char == "\\" { 87 | advance() 88 | if currentIndex < input.endIndex { 89 | let escaped = current 90 | switch escaped { 91 | case "n": stringValue.append("\n") 92 | case "t": stringValue.append("\t") 93 | case "r": stringValue.append("\r") 94 | case "\"": stringValue.append("\"") 95 | case "\\": stringValue.append("\\") 96 | default: stringValue.append(escaped) 97 | } 98 | advance() 99 | } 100 | continue 101 | } 102 | 103 | stringValue.append(char) 104 | advance() 105 | } 106 | 107 | throw TokenizerError.unterminatedString(line: line) 108 | } 109 | 110 | private mutating func handleString() -> Token { 111 | let start = currentIndex 112 | 113 | while currentIndex < input.endIndex { 114 | if !isSupportedCharacter || current == "\n" { 115 | let value = input[start ..< currentIndex] 116 | return .string(String(value).trimmingCharacters(in: .whitespaces)) 117 | } 118 | advance() 119 | } 120 | 121 | let value = input[start ..< currentIndex] 122 | return .string(String(value).trimmingCharacters(in: .whitespaces)) 123 | } 124 | 125 | private mutating func handleComment() -> Token { 126 | let start = currentIndex 127 | advance() 128 | 129 | while currentIndex < input.endIndex, current != "\n" { 130 | advance() 131 | } 132 | 133 | let value = input[start ..< currentIndex] 134 | 135 | return .comment(String(value).trimmingCharacters(in: .whitespaces)) 136 | } 137 | 138 | private mutating func handleSection() throws(TokenizerError) -> Token { 139 | advance() 140 | 141 | let start = currentIndex 142 | var didSectionEnd = false 143 | 144 | while currentIndex < input.endIndex, current != "\n" { 145 | if current == "]" { 146 | didSectionEnd = true 147 | break 148 | } 149 | 150 | if current == "\n" { 151 | throw TokenizerError.unterminatedSection(line: line) 152 | } 153 | 154 | advance() 155 | } 156 | 157 | if !didSectionEnd { 158 | throw TokenizerError.unterminatedSection(line: line) 159 | } 160 | 161 | let value = input[start ..< currentIndex] 162 | advance() 163 | 164 | return .section(String(value)) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Tests/WinnieTests/ConfigParserTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import Winnie 5 | 6 | struct ConfigParserTests { 7 | @Test func testInitialization() { 8 | let parser = ConfigParser() 9 | #expect(parser.sectionNames.count == 1) 10 | #expect(parser.hasSection("DEFAULT")) 11 | } 12 | 13 | @Test func testAddSection() throws { 14 | let parser = ConfigParser() 15 | try parser.addSection("test") 16 | #expect(parser.hasSection("test")) 17 | #expect(parser.sectionNames.count == 2) 18 | } 19 | 20 | @Test func testAddSectionFailsForDefault() { 21 | let parser = ConfigParser() 22 | #expect(throws: ConfigParserError.valueError("Cannot add default section.")) { 23 | try parser.addSection("DEFAULT") 24 | } 25 | } 26 | 27 | @Test func testAddSectionFailsForExisting() throws { 28 | let parser = ConfigParser() 29 | try parser.addSection("test") 30 | #expect(throws: ConfigParserError.valueError("Section test already exists.")) { 31 | try parser.addSection("test") 32 | } 33 | } 34 | 35 | @Test func testGetBoolSimpleSuccess() throws { 36 | let parser = ConfigParser() 37 | try parser.read(#""" 38 | [test] 39 | flag = yes 40 | """#) 41 | #expect(try parser.getBool(section: "test", option: "flag") == true) 42 | } 43 | 44 | @Test func testGetStringSimpleSuccess() throws { 45 | let parser = ConfigParser() 46 | try parser.read(#""" 47 | [test] 48 | message = hello world 49 | """#) 50 | #expect(try parser.getString(section: "test", option: "message") == "hello world") 51 | } 52 | 53 | @Test func testGetIntSimpleSuccess() throws { 54 | let parser = ConfigParser() 55 | try parser.read(#""" 56 | [test] 57 | count = 99 58 | """#) 59 | #expect(try parser.getInt(section: "test", option: "count") == 99) 60 | } 61 | 62 | @Test func testGetDoubleSimpleSuccess() throws { 63 | let parser = ConfigParser() 64 | try parser.read(#""" 65 | [test] 66 | pi = 3.14159 67 | """#) 68 | #expect(try parser.getDouble(section: "test", option: "pi") == 3.14159) 69 | } 70 | 71 | @Test func testGetBoolSimpleTypeError() throws { 72 | let parser = ConfigParser() 73 | try parser.read(#""" 74 | [test] 75 | message = hello world 76 | """#) 77 | #expect(throws: ConfigParserError.valueError("Cannot convert to Bool: hello world")) { 78 | try parser.getBool(section: "test", option: "message") 79 | } 80 | } 81 | 82 | @Test func testGetIntSimpleTypeError() throws { 83 | let parser = ConfigParser() 84 | try parser.read(#""" 85 | [test] 86 | flag = yes 87 | """#) 88 | #expect(throws: ConfigParserError.valueError("Cannot convert to Int: yes")) { 89 | try parser.getInt(section: "test", option: "flag") 90 | } 91 | } 92 | 93 | @Test func testGetSimpleMissingOption() throws { 94 | let parser = ConfigParser() 95 | try parser.read(#""" 96 | [test] 97 | count = 99 98 | """#) 99 | #expect(throws: ConfigParserError.optionNotFound("missing")) { 100 | try parser.getInt(section: "test", option: "missing") 101 | } 102 | } 103 | 104 | @Test func testGetSimpleMissingSection() throws { 105 | let parser = ConfigParser() 106 | try parser.read(#""" 107 | [other_test] 108 | count = 99 109 | """#) 110 | #expect(throws: ConfigParserError.sectionNotFound("test")) { 111 | try parser.getInt(section: "test", option: "count") 112 | } 113 | } 114 | 115 | @Test func testGetIntAsString() throws { 116 | let parser = ConfigParser() 117 | try parser.read(#""" 118 | [data] 119 | value = 123 120 | """#) 121 | #expect(try parser.getString(section: "data", option: "value") == "123") 122 | } 123 | 124 | @Test func testGetBoolAsString() throws { 125 | let parser = ConfigParser() 126 | try parser.read(#""" 127 | [data] 128 | value = True 129 | """#) 130 | #expect(try parser.getString(section: "data", option: "value") == "True") 131 | } 132 | 133 | @Test func testSetAndGetString() throws { 134 | let parser = ConfigParser() 135 | 136 | try parser.addSection("user") 137 | try parser.set(section: "user", option: "name", value: "John") 138 | 139 | let name: String = try parser.get(section: "user", option: "name") 140 | #expect(name == "John") 141 | 142 | let age: Int = parser.get(section: "user", option: "age", default: 10) 143 | #expect(age == 10) 144 | } 145 | 146 | @Test func testSetAndGetInt() throws { 147 | let parser = ConfigParser() 148 | 149 | try parser.addSection("user") 150 | try parser.set(section: "user", option: "age", value: 30) 151 | 152 | let age: Int = try parser.get(section: "user", option: "age") 153 | #expect(age == 30) 154 | } 155 | 156 | @Test func testSetAndGetBool() throws { 157 | let parser = ConfigParser() 158 | 159 | try parser.addSection("features") 160 | try parser.set(section: "features", option: "enabled", value: true) 161 | 162 | let enabled: Bool = try parser.get(section: "features", option: "enabled") 163 | #expect(enabled) 164 | } 165 | 166 | @Test func testSetAndGetDouble() throws { 167 | let parser = ConfigParser() 168 | 169 | try parser.addSection("settings") 170 | try parser.set(section: "settings", option: "ratio", value: 3.14) 171 | 172 | let ratio: Double = try parser.get(section: "settings", option: "ratio") 173 | #expect(ratio == 3.14) 174 | } 175 | 176 | @Test func testTypeConversion() throws { 177 | let parser = ConfigParser() 178 | 179 | try parser.addSection("test") 180 | try parser.set(section: "test", option: "value", value: "42") 181 | 182 | let intValue: Int = try parser.get(section: "test", option: "value") 183 | #expect(intValue == 42) 184 | 185 | let doubleValue: Double = try parser.get(section: "test", option: "value") 186 | #expect(doubleValue == 42.0) 187 | } 188 | 189 | @Test func testBoolConversions() throws { 190 | let parser = ConfigParser() 191 | 192 | try parser.addSection("test") 193 | try parser.set(section: "test", option: "yes", value: "yes") 194 | try parser.set(section: "test", option: "no", value: "no") 195 | try parser.set(section: "test", option: "one", value: 1) 196 | try parser.set(section: "test", option: "zero", value: 0) 197 | 198 | let yes: Bool = try parser.get(section: "test", option: "yes") 199 | let no: Bool = try parser.get(section: "test", option: "no") 200 | let one: Bool = try parser.get(section: "test", option: "one") 201 | let zero: Bool = try parser.get(section: "test", option: "zero") 202 | 203 | #expect(yes) 204 | #expect(!no) 205 | #expect(one) 206 | #expect(!zero) 207 | } 208 | 209 | @Test func testSectionNotFound() { 210 | let parser = ConfigParser() 211 | #expect(throws: ConfigParserError.sectionNotFound("nonexistent")) { 212 | let _: String = try parser.get(section: "nonexistent", option: "option") 213 | } 214 | } 215 | 216 | @Test func testOptionNotFound() throws { 217 | let parser = ConfigParser() 218 | try parser.addSection("test") 219 | #expect(throws: ConfigParserError.optionNotFound("nonexistent")) { 220 | let _: String = try parser.get(section: "test", option: "nonexistent") 221 | } 222 | } 223 | 224 | @Test func testDefaultSection() throws { 225 | let parser = ConfigParser() 226 | try parser.set(option: "version", value: "1.0") 227 | 228 | let version: String = try parser.get(option: "version") 229 | #expect(version == "1.0") 230 | } 231 | 232 | @Test func testInvalidTypeConversion() throws { 233 | let parser = ConfigParser() 234 | 235 | try parser.addSection("test") 236 | try parser.set(section: "test", option: "text", value: "hello") 237 | 238 | #expect(throws: ConfigParserError.valueError("Cannot convert to Int: hello")) { 239 | let _: Int = try parser.get(section: "test", option: "text") 240 | } 241 | } 242 | 243 | @Test func readExample() throws { 244 | let contents = """ 245 | [URL] 246 | Protocol=deusex 247 | ProtocolDescription=Deus Ex Protocol 248 | Name=Player 249 | Map=Index.dx 250 | LocalMap=DX.dx 251 | Host= 252 | Portal= 253 | MapExt=dx 254 | SaveExt=dxs 255 | Port=7790 256 | Class=DeusEx.JCDentonMale 257 | 258 | [Engine.GameInfo] 259 | bLowGore=False 260 | """ 261 | 262 | let parser = ConfigParser() 263 | try parser.read(contents) 264 | #expect(parser.hasSection("URL")) 265 | #expect(try parser.get(section: "URL", option: "Host") == "") 266 | #expect(try parser.get(section: "URL", option: "Portal") == "") 267 | #expect(try parser.get(section: "URL", option: "Name") == "Player") 268 | #expect(try parser.get(section: "URL", option: "Port") == 7790) 269 | #expect( 270 | try parser.get(section: "URL", option: "ProtocolDescription") == "Deus Ex Protocol") 271 | #expect(try parser.get(section: "URL", option: "Class") == "DeusEx.JCDentonMale") 272 | #expect(try parser.get(section: "Engine.GameInfo", option: "bLowGore") == false) 273 | } 274 | 275 | @Test func writeExample() throws { 276 | let parser = ConfigParser() 277 | 278 | try parser.addSection("URL") 279 | try parser.set(section: "URL", option: "Protocol", value: "deusex") 280 | try parser.set(section: "URL", option: "Name", value: "Player") 281 | try parser.set(section: "URL", option: "Port", value: 7790) 282 | 283 | try parser.addSection("Engine.GameInfo") 284 | try parser.set(section: "Engine.GameInfo", option: "bLowGore", value: false) 285 | 286 | let result = parser.write(leadingSpaces: false) 287 | 288 | let expected = """ 289 | [URL] 290 | Protocol=deusex 291 | Name=Player 292 | Port=7790 293 | 294 | [Engine.GameInfo] 295 | bLowGore=False 296 | """ 297 | 298 | #expect(result == expected) 299 | } 300 | 301 | @Test func testMixedAssignmentStyles() throws { 302 | let input = """ 303 | [Settings] 304 | useColor=true 305 | fontSize: 12 306 | theme = "dark" 307 | """ 308 | 309 | let parser = ConfigParser() 310 | try parser.read(input) 311 | 312 | let useColor: Bool = try parser.get(section: "Settings", option: "useColor") 313 | let fontSize: Int = try parser.get(section: "Settings", option: "fontSize") 314 | let theme: String = try parser.get(section: "Settings", option: "theme") 315 | 316 | #expect(useColor == true) 317 | #expect(fontSize == 12) 318 | #expect(theme == "dark") 319 | } 320 | 321 | @Test func testMultilineValues() throws { 322 | let parser = ConfigParser() 323 | try parser.read( 324 | """ 325 | [Content] 326 | description = "This is a\\nmultiline\\nvalue" 327 | sql = "SELECT * FROM users\\nWHERE active = 1;" 328 | """) 329 | 330 | let description: String = try parser.get(section: "Content", option: "description") 331 | #expect(description == "This is a\nmultiline\nvalue") 332 | } 333 | 334 | @Test func testSectionOrderPreservation() throws { 335 | let parser = ConfigParser() 336 | 337 | try parser.addSection("Third") 338 | try parser.addSection("First") 339 | try parser.addSection("Second") 340 | 341 | let sections = parser.sectionNames.filter { $0 != "DEFAULT" } 342 | #expect(sections == ["Third", "First", "Second"]) 343 | } 344 | 345 | @Test func testOptionOrderPreservation() throws { 346 | let parser = ConfigParser() 347 | 348 | try parser.addSection("Test") 349 | try parser.set(section: "Test", option: "z", value: 1) 350 | try parser.set(section: "Test", option: "a", value: 2) 351 | try parser.set(section: "Test", option: "m", value: 3) 352 | 353 | let section = parser.config["Test"]! 354 | let keys = Array(section.keys) 355 | #expect(keys == ["z", "a", "m"]) 356 | } 357 | 358 | @Test func testEmptySections() throws { 359 | let input = """ 360 | [EmptySection] 361 | 362 | [Section] 363 | emptyValue= 364 | """ 365 | 366 | let parser = ConfigParser() 367 | try parser.read(input) 368 | 369 | #expect(parser.hasSection("EmptySection")) 370 | let emptyValue: String = try parser.get(section: "Section", option: "emptyValue") 371 | #expect(emptyValue == "") 372 | } 373 | 374 | @Test func testValueConversions() throws { 375 | let parser = ConfigParser() 376 | 377 | try parser.addSection("Test") 378 | try parser.set(section: "Test", option: "boolYes", value: "yes") 379 | try parser.set(section: "Test", option: "boolOn", value: "on") 380 | try parser.set(section: "Test", option: "boolFalse", value: false) 381 | try parser.set(section: "Test", option: "intAsString", value: "42") 382 | try parser.set(section: "Test", option: "doubleNegative", value: -3.14) 383 | 384 | let boolYes: Bool = try parser.get(section: "Test", option: "boolYes") 385 | let boolOn: Bool = try parser.get(section: "Test", option: "boolOn") 386 | let boolFalse: Bool = try parser.get(section: "Test", option: "boolFalse") 387 | let intFromString: Int = try parser.get(section: "Test", option: "intAsString") 388 | let doubleNeg: Double = try parser.get(section: "Test", option: "doubleNegative") 389 | 390 | #expect(boolYes == true) 391 | #expect(boolOn == true) 392 | #expect(boolFalse == false) 393 | #expect(intFromString == 42) 394 | #expect(doubleNeg == -3.14) 395 | } 396 | 397 | @Test func testCommentHandling() throws { 398 | let input = """ 399 | [Section] 400 | # This is a comment 401 | key1=value1 ; This is an inline comment 402 | ; Another comment 403 | key2=value2 404 | """ 405 | 406 | let parser = ConfigParser() 407 | try parser.read(input) 408 | 409 | let key1: String = try parser.get(section: "Section", option: "key1") 410 | let key2: String = try parser.get(section: "Section", option: "key2") 411 | 412 | #expect(key1 == "value1") 413 | #expect(key2 == "value2") 414 | } 415 | 416 | @Test func testRoundtrip() throws { 417 | let original = """ 418 | [Section1] 419 | key1=value1 420 | key2=True 421 | 422 | [Section2] 423 | key3=42 424 | """ 425 | 426 | let parser1 = ConfigParser() 427 | try parser1.read(original) 428 | 429 | let written = parser1.write(leadingSpaces: false) 430 | 431 | let parser2 = ConfigParser() 432 | try parser2.read(written) 433 | 434 | let key1: String = try parser2.get(section: "Section1", option: "key1") 435 | let key2: Bool = try parser2.get(section: "Section1", option: "key2") 436 | let key3: Int = try parser2.get(section: "Section2", option: "key3") 437 | 438 | #expect(key1 == "value1") 439 | #expect(key2 == true) 440 | #expect(key3 == 42) 441 | } 442 | 443 | @Test func testEndToEnd() throws { 444 | let expected = """ 445 | [DEFAULT] 446 | ServerAliveInterval = 45 447 | Compression = yes 448 | CompressionLevel = 9 449 | ForwardX11 = yes 450 | 451 | [forge.example] 452 | User = hg 453 | 454 | [topsecret.server.example] 455 | Port = 50022 456 | ForwardX11 = no 457 | """ 458 | 459 | let parser = ConfigParser() 460 | try parser.set(option: "ServerAliveInterval", value: 45) 461 | try parser.set(option: "Compression", value: "yes") 462 | try parser.set(option: "CompressionLevel", value: 9) 463 | try parser.set(option: "ForwardX11", value: "yes") 464 | 465 | try parser.addSection("forge.example") 466 | try parser.set(section: "forge.example", option: "User", value: "hg") 467 | 468 | try parser.addSection("topsecret.server.example") 469 | try parser.set(section: "topsecret.server.example", option: "Port", value: 50022) 470 | try parser.set(section: "topsecret.server.example", option: "ForwardX11", value: "no") 471 | 472 | #expect(expected == parser.write()) 473 | let parser2 = ConfigParser() 474 | try parser2.read(expected) 475 | #expect(parser2.write() == parser.write()) 476 | 477 | let expectedSections = ["DEFAULT", "forge.example", "topsecret.server.example"] 478 | #expect(expectedSections == parser.sectionNames) 479 | 480 | #expect(parser.hasSection("forge.example")) 481 | #expect(!parser.hasSection("python.org")) 482 | #expect(try parser.get(section: "forge.example", option: "User") == "hg") 483 | #expect(try parser.get(option: "Compression") == "yes") 484 | 485 | #expect(try parser.get(section: "topsecret.server.example", option: "ForwardX11") == "no") 486 | #expect(try parser.get(section: "topsecret.server.example", option: "Port") == 50022) 487 | } 488 | 489 | @Test func testEndToEndSubscript() throws { 490 | let expected = """ 491 | [DEFAULT] 492 | ServerAliveInterval = 45 493 | Compression = yes 494 | CompressionLevel = 9 495 | ForwardX11 = yes 496 | 497 | [forge.example] 498 | User = hg 499 | 500 | [topsecret.server.example] 501 | Port = 50022 502 | ForwardX11 = no 503 | """ 504 | 505 | let parser = ConfigParser() 506 | 507 | parser["ServerAliveInterval"] = 45 508 | parser["Compression"] = "yes" 509 | parser["CompressionLevel"] = 9 510 | parser["ForwardX11"] = "yes" 511 | 512 | parser["forge.example", "User"] = "hg" 513 | 514 | parser["topsecret.server.example", "Port"] = 50022 515 | parser["topsecret.server.example", "ForwardX11"] = "no" 516 | 517 | #expect(expected == parser.write()) 518 | 519 | let parser2 = ConfigParser() 520 | try parser2.read(expected) 521 | #expect(parser2.write() == parser.write()) 522 | 523 | let expectedSections = ["DEFAULT", "forge.example", "topsecret.server.example"] 524 | #expect(expectedSections == parser.sectionNames) 525 | 526 | #expect(parser.hasSection("forge.example")) 527 | #expect(!parser.hasSection("python.org")) 528 | 529 | #expect(parser["forge.example", "User"] == "hg") 530 | #expect(parser["Compression"] == "yes") 531 | 532 | #expect(parser["topsecret.server.example", "ForwardX11"] == "no") 533 | #expect(parser["topsecret.server.example", "Port"] == 50022) 534 | } 535 | 536 | @Test func readmeExample() async throws { 537 | let input = """ 538 | [owner] 539 | name = John Doe 540 | organization = Acme Widgets Inc. 541 | 542 | [database] 543 | server = 192.0.2.62 544 | port = 143 545 | file = payroll.dat 546 | path = /var/database 547 | """ 548 | 549 | let config = ConfigParser() 550 | 551 | try config.read(input) 552 | 553 | let name: String = try config.get(section: "owner", option: "name") 554 | #expect(name == "John Doe") 555 | 556 | let name2 = config["owner", "name"]?.stringValue 557 | #expect(name == name2) 558 | 559 | let server: String = try config.get(section: "database", option: "server") 560 | #expect(server == "192.0.2.62") 561 | 562 | let portValue = config["database", "port"] 563 | #expect(portValue?.intValue == 143) 564 | 565 | let nonExistent = config["database", "nonexistent"] 566 | #expect(nonExistent == nil) 567 | 568 | try config.set(section: "database", option: "file", value: "payroll_updated.data") 569 | let updatedFile: String = try config.get(section: "database", option: "file") 570 | #expect(updatedFile == "payroll_updated.data") 571 | 572 | config["database", "path"] = "/var/databasev2" 573 | let updatedPathValue = config["database", "path"] 574 | #expect(updatedPathValue?.stringValue == "/var/databasev2") 575 | 576 | let fileManager = FileManager.default 577 | let tempDirectoryURL = fileManager.temporaryDirectory 578 | .appendingPathComponent("WinnieTests-\(UUID().uuidString)", isDirectory: true) 579 | 580 | try fileManager.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) 581 | defer { 582 | try? fileManager.removeItem(at: tempDirectoryURL) 583 | } 584 | 585 | let tempFileURL = tempDirectoryURL.appendingPathComponent("config_test.ini") 586 | 587 | try config.writeFile(tempFileURL.path) 588 | 589 | #expect(fileManager.fileExists(atPath: tempFileURL.path)) 590 | 591 | let configFromFile = ConfigParser() 592 | try configFromFile.readFile(tempFileURL.path) 593 | 594 | let nameFromFile: String = try configFromFile.get(section: "owner", option: "name") 595 | #expect(nameFromFile == "John Doe") 596 | 597 | let fileFromFile: String = try configFromFile.get(section: "database", option: "file") 598 | #expect(fileFromFile == "payroll_updated.data") 599 | 600 | let pathFromFileValue = configFromFile["database", "path"] 601 | #expect(pathFromFileValue?.stringValue == "/var/databasev2") 602 | 603 | let portFromFileValue = configFromFile["database", "port"] 604 | #expect(portFromFileValue?.intValue == 143) 605 | 606 | let expectedOutput = """ 607 | [owner] 608 | name = John Doe 609 | organization = Acme Widgets Inc. 610 | 611 | [database] 612 | server = 192.0.2.62 613 | port = 143 614 | file = payroll_updated.data 615 | path = /var/databasev2 616 | """ 617 | 618 | let outputString = config.write() 619 | #expect(outputString == expectedOutput) 620 | } 621 | 622 | @Test func testCustomDefaultSection() throws { 623 | let parser = ConfigParser(defaultSection: "GLOBAL") 624 | 625 | #expect(parser.hasSection("GLOBAL")) 626 | 627 | parser["debug"] = true 628 | parser["app_name"] = "MyApp" 629 | 630 | #expect(parser["GLOBAL", "debug"] == true) 631 | #expect(parser["GLOBAL", "app_name"] == "MyApp") 632 | 633 | let debug: Bool = try parser.get(option: "debug") 634 | let appName: String = try parser.get(option: "app_name") 635 | 636 | #expect(debug == true) 637 | #expect(appName == "MyApp") 638 | } 639 | } 640 | -------------------------------------------------------------------------------- /Tests/WinnieTests/TokenizerTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | @testable import Winnie 3 | 4 | struct TokenizerTests { 5 | @Test func testSimpleString() throws { 6 | var tokenizer = Tokenizer("\"Hello, world!\"") 7 | let token = try tokenizer.scan() 8 | #expect(token == .string("Hello, world!")) 9 | } 10 | 11 | @Test func testStringWithEscapedQuote() throws { 12 | var tokenizer = Tokenizer("\"Hello, \\\"world!\\\"\"") 13 | let token = try tokenizer.scan() 14 | #expect(token == .string("Hello, \"world!\"")) 15 | } 16 | 17 | @Test func testStringWithEscapedBackslash() throws { 18 | var tokenizer = Tokenizer("\"Hello \\\\ world\"") 19 | let token = try tokenizer.scan() 20 | #expect(token == .string("Hello \\ world")) 21 | } 22 | 23 | @Test func testEmptyString() throws { 24 | var tokenizer = Tokenizer("\"\"") 25 | let token = try tokenizer.scan() 26 | #expect(token == .string("")) 27 | } 28 | 29 | @Test func testEmptyInput() throws { 30 | var tokenizer = Tokenizer("") 31 | let token = try tokenizer.scan() 32 | #expect(token == .eof) 33 | } 34 | 35 | @Test func testUnterminatedString() throws { 36 | var tokenizer = Tokenizer("\"Unfinished") 37 | #expect(throws: TokenizerError.unterminatedString(line: 1)) { 38 | try tokenizer.scan() 39 | } 40 | } 41 | 42 | @Test func testUnterminatedSection() throws { 43 | var tokenizer = Tokenizer("[section") 44 | #expect(throws: TokenizerError.unterminatedSection(line: 1)) { 45 | _ = try tokenizer.tokenize() // Or scan() depending on how deep you test 46 | } 47 | } 48 | 49 | @Test func testUnterminatedStringMultiLine() throws { 50 | let input = """ 51 | key = value 52 | key2 = "Unfinished 53 | """ 54 | var tokenizer = Tokenizer(input) 55 | #expect(throws: TokenizerError.unterminatedString(line: 2)) { 56 | _ = try tokenizer.tokenize() 57 | } 58 | } 59 | 60 | @Test func testUnterminatedSectionMultiLine() throws { 61 | let input = """ 62 | key = value 63 | [section 64 | """ 65 | var tokenizer = Tokenizer(input) 66 | #expect(throws: TokenizerError.unterminatedSection(line: 2)) { 67 | _ = try tokenizer.tokenize() 68 | } 69 | } 70 | 71 | @Test func testSimpleKeyValue() throws { 72 | var tokenizer = Tokenizer("name = John") 73 | let key = try tokenizer.scan() 74 | let equals = try tokenizer.scan() 75 | let value = try tokenizer.scan() 76 | 77 | #expect(key == .string("name")) 78 | #expect(equals == .equals) 79 | #expect(value == .string("John")) 80 | } 81 | 82 | @Test func testCommentWithHash() throws { 83 | var tokenizer = Tokenizer("# this is a comment") 84 | let token = try tokenizer.scan() 85 | 86 | #expect(token == .comment("# this is a comment")) 87 | } 88 | 89 | @Test func testCommentWithSemicolon() throws { 90 | var tokenizer = Tokenizer("; this is a comment") 91 | let token = try tokenizer.scan() 92 | 93 | #expect(token == .comment("; this is a comment")) 94 | } 95 | 96 | @Test func testInlineComments() throws { 97 | let input = """ 98 | key1=value1;comment1 99 | key2 = value2 # comment2 100 | """ 101 | var tokenizer = Tokenizer(input) 102 | let tokens = try tokenizer.tokenize() 103 | let expected: [Token] = [ 104 | .string("key1"), .equals, .string("value1"), .comment(";comment1"), .newline, 105 | .string("key2"), .equals, .string("value2"), .comment("# comment2"), 106 | .eof, 107 | ] 108 | #expect(tokens == expected) 109 | } 110 | 111 | @Test func testSectionHeader() throws { 112 | var tokenizer = Tokenizer("[section]") 113 | let section = try tokenizer.scan() 114 | 115 | #expect(section == .section("section")) 116 | } 117 | 118 | @Test func testKeyWithSpecialCharacters() throws { 119 | var tokenizer = Tokenizer(#"Paths=..\System\*.u"#) 120 | let tokens = try tokenizer.tokenize() 121 | 122 | let expected: [Token] = [ 123 | .string("Paths"), .equals, .string("..\\System\\*.u"), .eof, 124 | ] 125 | 126 | #expect(tokens == expected) 127 | } 128 | 129 | @Test func testMissingValueAfterEquals() throws { 130 | let input = "key = " 131 | var tokenizer = Tokenizer(input) 132 | 133 | let key = try tokenizer.scan() 134 | let equals = try tokenizer.scan() 135 | let value = try tokenizer.scan() 136 | 137 | #expect(key == .string("key")) 138 | #expect(equals == .equals) 139 | #expect(value == .eof) 140 | } 141 | 142 | @Test func testConsecutiveSections() throws { 143 | let input = """ 144 | [section1] 145 | [section2] 146 | """ 147 | 148 | var tokenizer = Tokenizer(input) 149 | let tokens = try tokenizer.tokenize() 150 | 151 | let expected: [Token] = [ 152 | .section("section1"), 153 | .newline, 154 | .section("section2"), 155 | .eof, 156 | ] 157 | 158 | #expect(tokens == expected) 159 | } 160 | 161 | @Test func testSections() throws { 162 | let input = #""" 163 | # Global settings 164 | [general] 165 | app_name = "TestApp" 166 | version = "1.0.0" 167 | debug = true 168 | 169 | ; User config 170 | [user] 171 | name = "Jane Doe" 172 | email = "jane@example.com" 173 | 174 | [network] 175 | host = "localhost" 176 | port = "8080" 177 | """# 178 | 179 | let expected: [Token] = [ 180 | .comment("# Global settings"), .newline, 181 | .section("general"), .newline, 182 | .string("app_name"), .equals, .string("TestApp"), .newline, 183 | .string("version"), .equals, .string("1.0.0"), .newline, 184 | .string("debug"), .equals, .string("true"), .newline, 185 | .newline, 186 | .comment("; User config"), .newline, 187 | .section("user"), .newline, 188 | .string("name"), .equals, .string("Jane Doe"), .newline, 189 | .string("email"), .equals, .string("jane@example.com"), .newline, 190 | .newline, 191 | .section("network"), .newline, 192 | .string("host"), .equals, .string("localhost"), .newline, 193 | .string("port"), .equals, .string("8080"), 194 | .eof, 195 | ] 196 | 197 | var tokenizer = Tokenizer(input) 198 | let tokens = try tokenizer.tokenize() 199 | 200 | #expect(tokens == expected) 201 | } 202 | 203 | @Test func testEmptyOption() throws { 204 | let contents = """ 205 | [URL] 206 | Protocol=deusex 207 | Host= 208 | """ 209 | 210 | let expected: [Token] = [ 211 | .section("URL"), .newline, 212 | .string("Protocol"), .equals, .string("deusex"), .newline, 213 | .string("Host"), .equals, 214 | .eof, 215 | ] 216 | 217 | var tokenizer = Tokenizer(contents) 218 | let tokens = try tokenizer.tokenize() 219 | #expect(tokens == expected) 220 | } 221 | 222 | @Test func testNewlineCharacters() throws { 223 | let input = """ 224 | key1 = value1 225 | 226 | key2 = value2 227 | """ 228 | 229 | var tokenizer = Tokenizer(input) 230 | let tokens = try tokenizer.tokenize() 231 | 232 | let expected: [Token] = [ 233 | .string("key1"), .equals, .string("value1"), 234 | .newline, 235 | .newline, 236 | .string("key2"), .equals, .string("value2"), 237 | .eof, 238 | ] 239 | 240 | #expect(tokens == expected) 241 | } 242 | 243 | @Test func testMixedContent() throws { 244 | let input = """ 245 | [section1] 246 | key1 = value1 247 | 248 | # Comment 249 | [section2] 250 | key2:value2 251 | """ 252 | 253 | var tokenizer = Tokenizer(input) 254 | let tokens = try tokenizer.tokenize() 255 | 256 | let expected: [Token] = [ 257 | .section("section1"), .newline, 258 | .string("key1"), .equals, .string("value1"), .newline, 259 | .newline, 260 | .comment("# Comment"), .newline, 261 | .section("section2"), .newline, 262 | .string("key2"), .colon, .string("value2"), 263 | .eof, 264 | ] 265 | 266 | #expect(tokens == expected) 267 | } 268 | } 269 | --------------------------------------------------------------------------------