Skip to content

Commit

Permalink
Overhaul internal representation, replacing NS… with …Box types (#19
Browse files Browse the repository at this point in the history
)

This PR removes any remaining use of `NS…Array`/`NS…Dictionary`/`NSNumber`/`NSDecimalNumber`/`NSNull`/… (as storage) from the code-base.

It introduces an internal type …

```swift
internal enum Box {
    case null(NullBox)
    case bool(BoolBox)
    case decimal(DecimalBox)
    case signedInteger(SignedIntegerBox)
    case unsignedInteger(UnsignedIntegerBox)
    case floatingPoint(FloatingPointBox)
    case string(StringBox)
    case array(ArrayBox)
    case dictionary(DictionaryBox)
}
```

… as well as accompanying variant box types, replacing use of `Any`/`NS…`.

👷🏻‍♀️It improves type-safety by reducing use of `Any` (from 60 down to 19 occurrences) as well as `NSObject` (from 37 down to 1 occurrence).

🏗It further more levels the field for improvements/additions such as [support for order-preserving elements & attributes](#17).

💡Thanks to encapsulation we can now safely change the inner logic of `DictionaryBox` to retain insertion order.

**Edit**:

We ended up replacing aforementioned `enum Box` with a protocol:

```swift
protocol Box {
    var isNull: Bool { get }
    var isFragment: Bool { get }
    
    func xmlString() -> String?
}
```

* Added basic box types
* Migrated decoding logic to proper boxes
* Migrated encoding logic to proper boxes
* Moved auxiliary types into their own dedicated files and group
* Replaced use of `.description` with explicit dedicated `.xmlString`
* Made box types conform to `Equatable`
* Added basic unit tests for box types
* Removed manual `Equatable` conformance, changed fragment boxes to structs
* Removed redundant use of `internal` on internal types
* Simplified logic of `func createElement` instances
* Removed `enum Box` in favor of `protocol Box`
* Fixed error description for `.typeMismatch`
* Shortened type names of `FloatingPointBox`, `SignedIntegerBox` and `UnsignedIntegerBox`
* Moved unboxing logic into dedicated Box initializers
* Removed last remaining use of explicit `internal` related to this PR
* Changed `Unboxed` types of `IntBox` & `UIntBox` to explicit 64bit types
* Made `.xmlString` conform to W3.org’s XML-spec
* Renamed `.init(string:…)` to `.init(xmlString:…)`
* Implemented conformity to encoding strategies
* Simplified box unit tests
* Added minimalist decoding unit tests for individual box types
* Fixed bug related to `Float` encoding and simplified internal encoding/decoding locig
  • Loading branch information
regexident authored and MaxDesiatov committed Dec 20, 2018
1 parent 863b160 commit ad96c5e
Show file tree
Hide file tree
Showing 52 changed files with 3,287 additions and 1,122 deletions.
File renamed without changes.
20 changes: 20 additions & 0 deletions Sources/XMLCoder/Auxiliaries/String+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// String+Extensions.swift
// XMLCoder
//
// Created by Vincent Esche on 12/18/18.
//

import Foundation

extension String {
internal func escape(_ characterSet: [(character: String, escapedCharacter: String)]) -> String {
var string = self

for set in characterSet {
string = string.replacingOccurrences(of: set.character, with: set.escapedCharacter, options: .literal)
}

return string
}
}
211 changes: 211 additions & 0 deletions Sources/XMLCoder/Auxiliaries/XMLElement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//
// XMLElement.swift
// XMLCoder
//
// Created by Vincent Esche on 12/18/18.
//

import Foundation

internal class _XMLElement {
static let attributesKey = "___ATTRIBUTES"
static let escapedCharacterSet = [("&", "&amp"), ("<", "&lt;"), (">", "&gt;"), ("'", "&apos;"), ("\"", "&quot;")]

var key: String
var value: String?
var attributes: [String: String] = [:]
var children: [String: [_XMLElement]] = [:]

internal init(key: String, value: String? = nil, attributes: [String: String] = [:], children: [String: [_XMLElement]] = [:]) {
self.key = key
self.value = value
self.attributes = attributes
self.children = children
}

static func createRootElement(rootKey: String, object: ArrayBox) -> _XMLElement? {
let element = _XMLElement(key: rootKey)

_XMLElement.createElement(parentElement: element, key: rootKey, object: object)

return element
}

static func createRootElement(rootKey: String, object: DictionaryBox) -> _XMLElement? {
let element = _XMLElement(key: rootKey)

_XMLElement.modifyElement(element: element, parentElement: nil, key: nil, object: object)

return element
}

fileprivate static func modifyElement(element: _XMLElement, parentElement: _XMLElement?, key: String?, object: DictionaryBox) {
let attributesBox = object[_XMLElement.attributesKey] as? DictionaryBox
let uniqueAttributes: [(String, String)]? = attributesBox?.unbox().compactMap { key, box in
return box.xmlString().map { (key, $0) }
}
element.attributes = uniqueAttributes.map { Dictionary(uniqueKeysWithValues: $0) } ?? [:]

let objects = object.filter { key, _value in key != _XMLElement.attributesKey }

for (key, box) in objects {
_XMLElement.createElement(parentElement: element, key: key, object: box)
}

if let parentElement = parentElement, let key = key {
parentElement.children[key] = (parentElement.children[key] ?? []) + [element]
}
}

fileprivate static func createElement(parentElement: _XMLElement, key: String, object: Box) {
switch object {
case let box as ArrayBox:
for box in box.unbox() {
_XMLElement.createElement(parentElement: parentElement, key: key, object: box)
}
case let box as DictionaryBox:
modifyElement(element: _XMLElement(key: key), parentElement: parentElement, key: key, object: box)
case _:
let element = _XMLElement(key: key, value: object.xmlString())
parentElement.children[key, default: []].append(element)
}

}

internal func flatten() -> [String: Box] {
var node: [String: Box] = attributes.mapValues { StringBox($0) }

for childElement in children {
for child in childElement.value {
if let content = child.value {
if let oldContent = node[childElement.key] as? ArrayBox {
oldContent.append(StringBox(content))
// FIXME: Box is a reference type, so this shouldn't be necessary:
node[childElement.key] = oldContent
} else if let oldContent = node[childElement.key] {
node[childElement.key] = ArrayBox([oldContent, StringBox(content)])
} else {
node[childElement.key] = StringBox(content)
}
} else if !child.children.isEmpty || !child.attributes.isEmpty {
let newValue = child.flatten()

if let existingValue = node[childElement.key] {
if let array = existingValue as? ArrayBox {
array.append(DictionaryBox(newValue))
// FIXME: Box is a reference type, so this shouldn't be necessary:
node[childElement.key] = array
} else {
node[childElement.key] = ArrayBox([existingValue, DictionaryBox(newValue)])
}
} else {
node[childElement.key] = DictionaryBox(newValue)
}
}
}
}

return node
}

func toXMLString(with header: XMLHeader? = nil, withCDATA cdata: Bool, formatting: XMLEncoder.OutputFormatting, ignoreEscaping _: Bool = false) -> String {
if let header = header, let headerXML = header.toXML() {
return headerXML + _toXMLString(withCDATA: cdata, formatting: formatting)
}
return _toXMLString(withCDATA: cdata, formatting: formatting)
}

fileprivate func formatUnsortedXMLElements(_ string: inout String, _ level: Int, _ cdata: Bool, _ formatting: XMLEncoder.OutputFormatting, _ prettyPrinted: Bool) {
formatXMLElements(from: children.map { (key: $0, value: $1) }, into: &string, at: level, cdata: cdata, formatting: formatting, prettyPrinted: prettyPrinted)
}

fileprivate func elementString(for childElement: (key: String, value: [_XMLElement]), at level: Int, cdata: Bool, formatting: XMLEncoder.OutputFormatting, prettyPrinted: Bool) -> String {
var string = ""
for child in childElement.value {
string += child._toXMLString(indented: level + 1, withCDATA: cdata, formatting: formatting)
string += prettyPrinted ? "\n" : ""
}
return string
}

fileprivate func formatSortedXMLElements(_ string: inout String, _ level: Int, _ cdata: Bool, _ formatting: XMLEncoder.OutputFormatting, _ prettyPrinted: Bool) {
formatXMLElements(from: children.sorted { $0.key < $1.key }, into: &string, at: level, cdata: cdata, formatting: formatting, prettyPrinted: prettyPrinted)
}

fileprivate func attributeString(key: String, value: String) -> String {
return " \(key)=\"\(value.escape(_XMLElement.escapedCharacterSet))\""
}

fileprivate func formatXMLAttributes(from keyValuePairs: [(key: String, value: String)], into string: inout String) {
for (key, value) in keyValuePairs {
string += attributeString(key: key, value: value)
}
}

fileprivate func formatXMLElements(from children: [(key: String, value: [_XMLElement])], into string: inout String, at level: Int, cdata: Bool, formatting: XMLEncoder.OutputFormatting, prettyPrinted: Bool) {
for childElement in children {
string += elementString(for: childElement, at: level, cdata: cdata, formatting: formatting, prettyPrinted: prettyPrinted)
}
}

fileprivate func formatSortedXMLAttributes(_ string: inout String) {
formatXMLAttributes(from: attributes.sorted(by: { $0.key < $1.key }), into: &string)
}

fileprivate func formatUnsortedXMLAttributes(_ string: inout String) {
formatXMLAttributes(from: attributes.map { (key: $0, value: $1) }, into: &string)
}

fileprivate func formatXMLAttributes(_ formatting: XMLEncoder.OutputFormatting, _ string: inout String) {
if #available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) {
if formatting.contains(.sortedKeys) {
formatSortedXMLAttributes(&string)
return
}
formatUnsortedXMLAttributes(&string)
return
}
formatUnsortedXMLAttributes(&string)
}

fileprivate func formatXMLElements(_ formatting: XMLEncoder.OutputFormatting, _ string: inout String, _ level: Int, _ cdata: Bool, _ prettyPrinted: Bool) {
if #available(macOS 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) {
if formatting.contains(.sortedKeys) {
formatSortedXMLElements(&string, level, cdata, formatting, prettyPrinted)
return
}
formatUnsortedXMLElements(&string, level, cdata, formatting, prettyPrinted)
return
}
formatUnsortedXMLElements(&string, level, cdata, formatting, prettyPrinted)
}

fileprivate func _toXMLString(indented level: Int = 0, withCDATA cdata: Bool, formatting: XMLEncoder.OutputFormatting, ignoreEscaping: Bool = false) -> String {
let prettyPrinted = formatting.contains(.prettyPrinted)
let indentation = String(repeating: " ", count: (prettyPrinted ? level : 0) * 4)
var string = indentation
string += "<\(key)"

formatXMLAttributes(formatting, &string)

if let value = value {
string += ">"
if !ignoreEscaping {
string += (cdata == true ? "<![CDATA[\(value)]]>" : "\(value.escape(_XMLElement.escapedCharacterSet))")
} else {
string += "\(value)"
}
string += "</\(key)>"
} else if !children.isEmpty {
string += prettyPrinted ? ">\n" : ">"
formatXMLElements(formatting, &string, level, cdata, prettyPrinted)

string += indentation
string += "</\(key)>"
} else {
string += " />"
}

return string
}
}
47 changes: 47 additions & 0 deletions Sources/XMLCoder/Auxiliaries/XMLHeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// XMLHeader.swift
// XMLCoder
//
// Created by Vincent Esche on 12/18/18.
//

import Foundation

public struct XMLHeader {
/// the XML standard that the produced document conforms to.
public let version: Double?
/// the encoding standard used to represent the characters in the produced document.
public let encoding: String?
/// indicates whether a document relies on information from an external source.
public let standalone: String?

public init(version: Double? = nil, encoding: String? = nil, standalone: String? = nil) {
self.version = version
self.encoding = encoding
self.standalone = standalone
}

internal func isEmpty() -> Bool {
return version == nil && encoding == nil && standalone == nil
}

internal func toXML() -> String? {
guard !isEmpty() else { return nil }

var string = "<?xml "

if let version = version {
string += "version=\"\(version)\" "
}

if let encoding = encoding {
string += "encoding=\"\(encoding)\" "
}

if let standalone = standalone {
string += "standalone=\"\(standalone)\""
}

return string.trimmingCharacters(in: .whitespaces) + "?>\n"
}
}
File renamed without changes.
98 changes: 98 additions & 0 deletions Sources/XMLCoder/Auxiliaries/XMLStackParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// XMLStackParser.swift
// XMLCoder
//
// Created by Shawn Moore on 11/14/17.
// Copyright © 2017 Shawn Moore. All rights reserved.
//

import Foundation

internal class _XMLStackParser: NSObject, XMLParserDelegate {
var root: _XMLElement?
var stack = [_XMLElement]()
var currentNode: _XMLElement?

var currentElementName: String?
var currentElementData = ""

static func parse(with data: Data) throws -> [String: Box] {
let parser = _XMLStackParser()

do {
if let node = try parser.parse(with: data) {
return node.flatten()
} else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "The given data could not be parsed into XML."))
}
} catch {
throw error
}
}

func parse(with data: Data) throws -> _XMLElement? {
let xmlParser = XMLParser(data: data)
xmlParser.delegate = self

if xmlParser.parse() {
return root
} else if let error = xmlParser.parserError {
throw error
} else {
return nil
}
}

func parserDidStartDocument(_: XMLParser) {
root = nil
stack = [_XMLElement]()
}

func parser(_: XMLParser, didStartElement elementName: String, namespaceURI _: String?, qualifiedName _: String?, attributes attributeDict: [String: String] = [:]) {
let node = _XMLElement(key: elementName)
node.attributes = attributeDict
stack.append(node)

if let currentNode = currentNode {
if currentNode.children[elementName] != nil {
currentNode.children[elementName]?.append(node)
} else {
currentNode.children[elementName] = [node]
}
}
currentNode = node
}

func parser(_: XMLParser, didEndElement _: String, namespaceURI _: String?, qualifiedName _: String?) {
if let poppedNode = stack.popLast() {
if let content = poppedNode.value?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) {
if content.isEmpty {
poppedNode.value = nil
} else {
poppedNode.value = content
}
}

if stack.isEmpty {
root = poppedNode
currentNode = nil
} else {
currentNode = stack.last
}
}
}

func parser(_: XMLParser, foundCharacters string: String) {
currentNode?.value = (currentNode?.value ?? "") + string
}

func parser(_: XMLParser, foundCDATA CDATABlock: Data) {
if let string = String(data: CDATABlock, encoding: .utf8) {
currentNode?.value = (currentNode?.value ?? "") + string
}
}

func parser(_: XMLParser, parseErrorOccurred parseError: Error) {
print(parseError)
}
}
Loading

0 comments on commit ad96c5e

Please sign in to comment.