Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Add Key coding strategy #92

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
51 changes: 44 additions & 7 deletions Sources/Yams/Decoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
import Foundation

public class YAMLDecoder {
public var options = Options()
public init() {}
public func decode<T>(_ type: T.Type = T.self,
from yaml: String,
userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable {
do {
let node = try Yams.compose(yaml: yaml, .basic) ?? ""
let decoder = _Decoder(referencing: node, userInfo: userInfo)
let decoder = _Decoder(referencing: node, options: options, userInfo: userInfo)
let container = try decoder.singleValueContainer()
return try container.decode(T.self)
} catch let error as DecodingError {
Expand All @@ -26,14 +27,39 @@ public class YAMLDecoder {
underlyingError: error))
}
}

public struct Options {
/// The strategy to use for decoding keys. Defaults to `.useDefaultKeys`.
public var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys
}

/// The strategy to use for automatically changing the value of keys before decoding.
public enum KeyDecodingStrategy {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Convert from "snake_case_keys" to "camelCaseKeys" before attempting to match a key with the one specified
/// by each type.
case convertFromSnakeCase

/// Provide a custom conversion from the key in the encoded JSON to the keys specified by the decoded types.
/// The full path to the current decoding position is provided for context (in case you need to locate this
/// key within the payload). The returned key is used in place of the last component in the coding path before
/// decoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the container
/// for the type to decode from.
case custom((_ codingPath: [CodingKey]) -> CodingKey)
}
}

struct _Decoder: Decoder { // swiftlint:disable:this type_name

fileprivate let node: Node
typealias Options = YAMLDecoder.Options
var options: Options

init(referencing node: Node, userInfo: [CodingUserInfoKey: Any], codingPath: [CodingKey] = []) {
init(referencing node: Node, options: Options, userInfo: [CodingUserInfoKey: Any], codingPath: [CodingKey] = []) {
self.node = node
self.options = options
self.userInfo = userInfo
self.codingPath = codingPath
}
Expand Down Expand Up @@ -69,9 +95,19 @@ struct _Decoder: Decoder { // swiftlint:disable:this type_name
return constructed
}


/// Returns `String` applied `KeyDecodingStrategy`
fileprivate func convert(_ key: CodingKey) -> String {
switch options.keyDecodingStrategy {
case .useDefaultKeys: return key.stringValue
case .convertFromSnakeCase: return key.stringValue.snakecased
case let .custom(converter): return converter(codingPath + [key]).stringValue
}
}

/// create a new `_Decoder` instance referencing `node` as `key` inheriting `userInfo`
fileprivate func decoder(referencing node: Node, `as` key: CodingKey) -> _Decoder {
return .init(referencing: node, userInfo: userInfo, codingPath: codingPath + [key])
return .init(referencing: node, options: options, userInfo: userInfo, codingPath: codingPath + [key])
}
}

Expand All @@ -92,7 +128,7 @@ struct _KeyedDecodingContainer<K: CodingKey> : KeyedDecodingContainerProtocol {

var codingPath: [CodingKey] { return decoder.codingPath }
var allKeys: [Key] { return mapping.keys.flatMap { $0.string.flatMap(Key.init(stringValue:)) } }
func contains(_ key: Key) -> Bool { return mapping[key.stringValue] != nil }
func contains(_ key: Key) -> Bool { return mapping[decoder.convert(key)] != nil }

func decodeNil(forKey key: Key) throws -> Bool {
return try node(for: key) == Node("null", Tag(.null))
Expand Down Expand Up @@ -132,8 +168,9 @@ struct _KeyedDecodingContainer<K: CodingKey> : KeyedDecodingContainerProtocol {
// MARK: -

private func node(for key: CodingKey) throws -> Node {
guard let node = mapping[key.stringValue] else {
throw _keyNotFound(at: codingPath, key, "No value associated with key \(key) (\"\(key.stringValue)\").")
let convertedKey = decoder.convert(key)
guard let node = mapping[convertedKey] else {
throw _keyNotFound(at: codingPath, key, "No value associated with key \(key) (\"\(convertedKey)\").")
}
return node
}
Expand Down
95 changes: 86 additions & 9 deletions Sources/Yams/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
import Foundation

public class YAMLEncoder {
public typealias Options = Emitter.Options
public var options = Options()
public init() {}
public func encode<T: Swift.Encodable>(_ value: T, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> String {
do {
let encoder = _Encoder(userInfo: userInfo)
let encoder = _Encoder(options: options, userInfo: userInfo)
var container = encoder.singleValueContainer()
try container.encode(value)
return try serialize(node: encoder.node, options: options)
return try serialize(node: encoder.node, options: options.emitterOptions)
} catch let error as EncodingError {
throw error
} catch {
Expand All @@ -28,12 +27,78 @@ public class YAMLEncoder {
throw EncodingError.invalidValue(value, context)
}
}

public struct Options {
/// Set if the output should be in the "canonical" format as in the YAML specification.
public var canonical: Bool = false
/// Set the intendation increment.
public var indent: Int = 0
/// Set the preferred line width. -1 means unlimited.
public var width: Int = 0
/// Set if unescaped non-ASCII characters are allowed.
public var allowUnicode: Bool = false
/// Set the preferred line break.
public var lineBreak: Emitter.LineBreak = .ln

// internal since we don't know if these should be exposed.
var explicitStart: Bool = false
var explicitEnd: Bool = false

/// The %YAML directive value or nil
public var version: (major: Int, minor: Int)?

/// Set if emitter should sort keys in lexicographic order.
public var sortKeys: Bool = false

/// The strategy to use for encoding keys. Defaults to `.useDefaultKeys`.
public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys

fileprivate var emitterOptions: Emitter.Options {
return .init(canonical: canonical, indent: indent, width: width, allowUnicode: allowUnicode,
lineBreak: lineBreak, version: version, sortKeys: sortKeys)
}
}

/// The strategy to use for automatically changing the value of keys before encoding.
public enum KeyEncodingStrategy {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Convert from "camelCaseKeys" to "snake_case_keys" before writing a key to YAML payload.
case convertToSnakeCase

/// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types.
/// The full path to the current encoding position is provided for context (in case you need to locate this
/// key within the payload). The returned key is used in place of the last component in the coding path before
/// encoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the result.
case custom((_ codingPath: [CodingKey]) -> CodingKey)
}
}

extension YAMLEncoder.Options {
// initializer without exposing internal properties
public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, allowUnicode: Bool = false,
lineBreak: Emitter.LineBreak = .ln, version: (major: Int, minor: Int)? = nil, sortKeys: Bool = false,
keyEncodingStrategy: YAMLEncoder.KeyEncodingStrategy = .useDefaultKeys) {
self.canonical = canonical
self.indent = indent
self.width = width
self.allowUnicode = allowUnicode
self.lineBreak = lineBreak
self.version = version
self.sortKeys = sortKeys
self.keyEncodingStrategy = keyEncodingStrategy
}
}

class _Encoder: Swift.Encoder { // swiftlint:disable:this type_name
var node: Node = .unused
typealias Options = YAMLEncoder.Options
let options: Options

init(userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [CodingKey] = []) {
init(options: Options, userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [CodingKey] = []) {
self.options = options
self.userInfo = userInfo
self.codingPath = codingPath
}
Expand Down Expand Up @@ -116,6 +181,15 @@ class _Encoder: Swift.Encoder { // swiftlint:disable:this type_name
}

fileprivate var canEncodeNewValue: Bool { return node == .unused }

/// Returns `String` applied `KeyEncodingStrategy`
fileprivate func convert(_ key: CodingKey) -> String {
switch options.keyEncodingStrategy {
case .useDefaultKeys: return key.stringValue
case .convertToSnakeCase: return key.stringValue.snakecased
case let .custom(converter): return converter(codingPath + [key]).stringValue
}
}
}

class _ReferencingEncoder: _Encoder { // swiftlint:disable:this type_name
Expand All @@ -126,14 +200,16 @@ class _ReferencingEncoder: _Encoder { // swiftlint:disable:this type_name

fileprivate init(referencing encoder: _Encoder, key: CodingKey) {
self.encoder = encoder
reference = .mapping(key.stringValue)
super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key])
reference = .mapping(encoder.convert(key))
super.init(options: encoder.options, userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key])
}

fileprivate init(referencing encoder: _Encoder, at index: Int) {
self.encoder = encoder
reference = .sequence(index)
super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)])
super.init(options: encoder.options,
userInfo: encoder.userInfo,
codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)])
}

deinit {
Expand All @@ -158,7 +234,7 @@ struct _KeyedEncodingContainer<K: CodingKey> : KeyedEncodingContainerProtocol {
// MARK: - Swift.KeyedEncodingContainerProtocol Methods

var codingPath: [CodingKey] { return encoder.codingPath }
func encodeNil(forKey key: Key) throws { encoder.mapping[key.stringValue] = .null }
func encodeNil(forKey key: Key) throws { encoder.mapping[encoder.convert(key)] = .null }
func encode(_ value: Bool, forKey key: Key) throws { try encoder(for: key).represent(value) }
func encode(_ value: Int, forKey key: Key) throws { try encoder(for: key).represent(value) }
func encode(_ value: Int8, forKey key: Key) throws { try encoder(for: key).represent(value) }
Expand All @@ -172,7 +248,7 @@ struct _KeyedEncodingContainer<K: CodingKey> : KeyedEncodingContainerProtocol {
func encode(_ value: UInt64, forKey key: Key) throws { try encoder(for: key).represent(value) }
func encode(_ value: Float, forKey key: Key) throws { try encoder(for: key).represent(value) }
func encode(_ value: Double, forKey key: Key) throws { try encoder(for: key).represent(value) }
func encode(_ value: String, forKey key: Key) throws { encoder.mapping[key.stringValue] = Node(value) }
func encode(_ value: String, forKey key: Key) throws { encoder.mapping[encoder.convert(key)] = Node(value) }
func encode<T>(_ value: T, forKey key: Key) throws where T: Encodable { try encoder(for: key).encode(value) }

func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type,
Expand Down Expand Up @@ -339,3 +415,4 @@ private func serialize(node: Node, options: Emitter.Options) throws -> String {
version: options.version,
sortKeys: options.sortKeys)
}
// swiftlint:disable:this file_length
26 changes: 26 additions & 0 deletions Sources/Yams/String+Yams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,30 @@ extension String {
return self + "\n"
}
}

/// Returns snake cased string
/// - example: "myURLProperty".snakecased() == "my_url_property"
var snakecased: String {
guard !isEmpty else { return self }
var words = [Range<Index>](), wordStart = startIndex, searchStart = startIndex
while let upperCaseRange = rangeOfCharacter(from: .uppercaseLetters, range: searchStart..<endIndex) {
if upperCaseRange.lowerBound != startIndex {
words.append(wordStart..<upperCaseRange.lowerBound)
}
guard let lowerCaseRange = rangeOfCharacter(from: .lowercaseLetters,
range: upperCaseRange.upperBound..<endIndex) else {
wordStart = upperCaseRange.lowerBound
break
}
if upperCaseRange.upperBound == lowerCaseRange.lowerBound {
wordStart = upperCaseRange.lowerBound
} else {
wordStart = index(before: lowerCaseRange.lowerBound)
words.append(upperCaseRange.lowerBound..<wordStart)
}
searchStart = lowerCaseRange.upperBound
}
words.append(wordStart..<endIndex)
return words.map({ self[$0] }).joined(separator: "_").lowercased()
}
}