diff --git a/README.md b/README.md index cf41a19c..4a341946 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,16 @@ Starting with [version 0.5](https://github.com/MaxDesiatov/XMLCoder/releases/tag you can now set a property `trimValueWhitespaces` to `false` (the default value is `true`) on `XMLDecoder` instance to preserve all whitespaces in decoded strings. + +### Remove whitespace elements + +When decoding pretty-printed XML while `trimValueWhitespaces` is set to `false`, it's possible +for whitespace elements to be added as child elements on an instance of `XMLCoderElement`. These +whitespace elements make it impossible to decode data structures that require custom `Decodable` logic. +Starting with [version 0.13.0](https://github.com/MaxDesiatov/XMLCoder/releases/tag/0.13.0) you can +set a property `removeWhitespaceElements` to `true` (the default value is `false`) on +`XMLDecoder` to remove these whitespace elements. + ### Choice element coding Starting with [version 0.8](https://github.com/MaxDesiatov/XMLCoder/releases/tag/0.8.0), diff --git a/Sources/XMLCoder/Auxiliaries/String+Extensions.swift b/Sources/XMLCoder/Auxiliaries/String+Extensions.swift index f7a71091..72e63c3d 100644 --- a/Sources/XMLCoder/Auxiliaries/String+Extensions.swift +++ b/Sources/XMLCoder/Auxiliaries/String+Extensions.swift @@ -44,3 +44,9 @@ extension StringProtocol { self = lowercasingFirstLetter() } } + +extension String { + func isAllWhitespace() -> Bool { + return self.trimmingCharacters(in: .whitespacesAndNewlines) == "" + } +} diff --git a/Sources/XMLCoder/Auxiliaries/XMLCoderElement.swift b/Sources/XMLCoder/Auxiliaries/XMLCoderElement.swift index db3a12f3..c3a96673 100644 --- a/Sources/XMLCoder/Auxiliaries/XMLCoderElement.swift +++ b/Sources/XMLCoder/Auxiliaries/XMLCoderElement.swift @@ -392,3 +392,10 @@ extension XMLCoderElement { } } } + +extension XMLCoderElement { + func isWhitespaceWithNoElements() -> Bool { + let stringValueIsWhitespaceOrNil = stringValue?.isAllWhitespace() ?? true + return self.key == "" && stringValueIsWhitespaceOrNil && self.elements.isEmpty + } +} diff --git a/Sources/XMLCoder/Auxiliaries/XMLStackParser.swift b/Sources/XMLCoder/Auxiliaries/XMLStackParser.swift index 41833a71..ceaceba1 100644 --- a/Sources/XMLCoder/Auxiliaries/XMLStackParser.swift +++ b/Sources/XMLCoder/Auxiliaries/XMLStackParser.swift @@ -15,9 +15,11 @@ class XMLStackParser: NSObject { var root: XMLCoderElement? private var stack: [XMLCoderElement] = [] private let trimValueWhitespaces: Bool + private let removeWhitespaceElements: Bool - init(trimValueWhitespaces: Bool = true) { + init(trimValueWhitespaces: Bool = true, removeWhitespaceElements: Bool = false) { self.trimValueWhitespaces = trimValueWhitespaces + self.removeWhitespaceElements = removeWhitespaceElements super.init() } @@ -25,9 +27,11 @@ class XMLStackParser: NSObject { with data: Data, errorContextLength length: UInt, shouldProcessNamespaces: Bool, - trimValueWhitespaces: Bool + trimValueWhitespaces: Bool, + removeWhitespaceElements: Bool ) throws -> Box { - let parser = XMLStackParser(trimValueWhitespaces: trimValueWhitespaces) + let parser = XMLStackParser(trimValueWhitespaces: trimValueWhitespaces, + removeWhitespaceElements: removeWhitespaceElements) let node = try parser.parse( with: data, @@ -141,15 +145,36 @@ extension XMLStackParser: XMLParserDelegate { return } + let updatedElement = removeWhitespaceElements ? elementWithFilteredElements(element: element) : element + withCurrentElement { currentElement in - currentElement.append(element: element, forKey: element.key) + currentElement.append(element: updatedElement, forKey: updatedElement.key) } if stack.isEmpty { - root = element + root = updatedElement } } + func elementWithFilteredElements(element: XMLCoderElement) -> XMLCoderElement { + var hasWhitespaceElements = false + var hasNonWhitespaceElements = false + var filteredElements: [XMLCoderElement] = [] + for ele in element.elements { + if ele.isWhitespaceWithNoElements() { + hasWhitespaceElements = true + } else { + hasNonWhitespaceElements = true + filteredElements.append(ele) + } + } + + if hasWhitespaceElements && hasNonWhitespaceElements { + return XMLCoderElement(key: element.key, elements: filteredElements, attributes: element.attributes) + } + return element + } + func parser(_: XMLParser, foundCharacters string: String) { let processedString = process(string: string) guard processedString.count > 0, string.count != 0 else { diff --git a/Sources/XMLCoder/Decoder/XMLDecoder.swift b/Sources/XMLCoder/Decoder/XMLDecoder.swift index 678752f8..4f479d0d 100644 --- a/Sources/XMLCoder/Decoder/XMLDecoder.swift +++ b/Sources/XMLCoder/Decoder/XMLDecoder.swift @@ -303,6 +303,12 @@ open class XMLDecoder { */ open var trimValueWhitespaces: Bool + /** A boolean value that determines whether to remove pure whitespace elements + that have sibling elements that aren't pure whitespace. The default value + is `false`. + */ + open var removeWhitespaceElements: Bool + /// Options set on the top-level encoder to pass down the decoding hierarchy. struct Options { let dateDecodingStrategy: DateDecodingStrategy @@ -328,8 +334,9 @@ open class XMLDecoder { // MARK: - Constructing a XML Decoder /// Initializes `self` with default strategies. - public init(trimValueWhitespaces: Bool = true) { + public init(trimValueWhitespaces: Bool = true, removeWhitespaceElements: Bool = false) { self.trimValueWhitespaces = trimValueWhitespaces + self.removeWhitespaceElements = removeWhitespaceElements } // MARK: - Decoding Values @@ -349,7 +356,8 @@ open class XMLDecoder { with: data, errorContextLength: errorContextLength, shouldProcessNamespaces: shouldProcessNamespaces, - trimValueWhitespaces: trimValueWhitespaces + trimValueWhitespaces: trimValueWhitespaces, + removeWhitespaceElements: removeWhitespaceElements ) let decoder = XMLDecoderImplementation( diff --git a/Tests/XMLCoderTests/Auxiliary/String+ExtensionsTests.swift b/Tests/XMLCoderTests/Auxiliary/String+ExtensionsTests.swift index efb593f7..19a3ef67 100644 --- a/Tests/XMLCoderTests/Auxiliary/String+ExtensionsTests.swift +++ b/Tests/XMLCoderTests/Auxiliary/String+ExtensionsTests.swift @@ -41,4 +41,39 @@ class StringExtensionsTests: XCTestCase { } XCTAssertEqual(expected, mutated) } + + func testIsAllWhitespace() { + let testString1 = "" + let testString2 = " " + + let testString3 = "\n" + let testString4 = "\n " + let testString5 = " \n " + let testString6 = " \n" + + let testString7 = "\r" + let testString8 = "\r " + let testString9 = " \r " + let testString10 = " \r" + + let testString11 = "\r\n" + let testString12 = "\r\n " + let testString13 = " \r\n " + let testString14 = " \r\n" + + XCTAssert(testString1.isAllWhitespace()) + XCTAssert(testString2.isAllWhitespace()) + XCTAssert(testString3.isAllWhitespace()) + XCTAssert(testString4.isAllWhitespace()) + XCTAssert(testString5.isAllWhitespace()) + XCTAssert(testString6.isAllWhitespace()) + XCTAssert(testString7.isAllWhitespace()) + XCTAssert(testString8.isAllWhitespace()) + XCTAssert(testString9.isAllWhitespace()) + XCTAssert(testString10.isAllWhitespace()) + XCTAssert(testString11.isAllWhitespace()) + XCTAssert(testString12.isAllWhitespace()) + XCTAssert(testString13.isAllWhitespace()) + XCTAssert(testString14.isAllWhitespace()) + } } diff --git a/Tests/XMLCoderTests/Auxiliary/XMLElementTests.swift b/Tests/XMLCoderTests/Auxiliary/XMLElementTests.swift index 97d5f70a..fb828246 100644 --- a/Tests/XMLCoderTests/Auxiliary/XMLElementTests.swift +++ b/Tests/XMLCoderTests/Auxiliary/XMLElementTests.swift @@ -49,4 +49,19 @@ class XMLElementTests: XCTestCase { XCTAssertEqual(keyed.elements, [element]) XCTAssertEqual(keyed.attributes, []) } + + func testWhitespaceWithNoElements_keyed() { + let keyed = XMLCoderElement(key: "foo", isStringBoxCDATA: false, box: StringBox("bar")) + XCTAssertFalse(keyed.isWhitespaceWithNoElements()) + } + + func testWhitespaceWithNoElements_whitespace() { + let whitespaceElement1 = XMLCoderElement(stringValue: "\n ") + let whitespaceElement2 = XMLCoderElement(stringValue: "\n") + let whitespaceElement3 = XMLCoderElement(stringValue: " ") + + XCTAssert(whitespaceElement1.isWhitespaceWithNoElements()) + XCTAssert(whitespaceElement2.isWhitespaceWithNoElements()) + XCTAssert(whitespaceElement3.isWhitespaceWithNoElements()) + } } diff --git a/Tests/XMLCoderTests/Auxiliary/XMLStackParserTests.swift b/Tests/XMLCoderTests/Auxiliary/XMLStackParserTests.swift index deedec91..94588b1f 100644 --- a/Tests/XMLCoderTests/Auxiliary/XMLStackParserTests.swift +++ b/Tests/XMLCoderTests/Auxiliary/XMLStackParserTests.swift @@ -56,4 +56,106 @@ class XMLStackParserTests: XCTestCase { shouldProcessNamespaces: false )) } + + func testNestedMembers_removeWhitespaceElements() throws { + let parser = XMLStackParser(trimValueWhitespaces: false, removeWhitespaceElements: true) + let xmlData = + """ + + + + foo + bar + + + baz + qux + + + + """.data(using: .utf8)! + let root = try parser.parse(with: xmlData, errorContextLength: 0, shouldProcessNamespaces: false) + + XCTAssertEqual(root.elements[0].key, "nestedStringList") + + XCTAssertEqual(root.elements[0].elements[0].key, "member") + XCTAssertEqual(root.elements[0].elements[0].elements[0].key, "member") + XCTAssertEqual(root.elements[0].elements[0].elements[0].elements[0].stringValue, "foo") + XCTAssertEqual(root.elements[0].elements[0].elements[1].elements[0].stringValue, "bar") + + XCTAssertEqual(root.elements[0].elements[1].key, "member") + XCTAssertEqual(root.elements[0].elements[1].elements[0].key, "member") + XCTAssertEqual(root.elements[0].elements[1].elements[0].elements[0].stringValue, "baz") + XCTAssertEqual(root.elements[0].elements[1].elements[1].elements[0].stringValue, "qux") + } + + func testNestedMembers() throws { + let parser = XMLStackParser(trimValueWhitespaces: false, removeWhitespaceElements: false) + let xmlData = + """ + + + + foo + bar + + + baz + qux + + + + """.data(using: .utf8)! + let root = try parser.parse(with: xmlData, errorContextLength: 0, shouldProcessNamespaces: false) + + XCTAssertEqual(root.elements[0].key, "") + XCTAssertEqual(root.elements[0].stringValue, "\n ") + + XCTAssertEqual(root.elements[1].key, "nestedStringList") + XCTAssertEqual(root.elements[1].elements[0].key, "") + XCTAssertEqual(root.elements[1].elements[0].stringValue, "\n ") + XCTAssertEqual(root.elements[1].elements[1].key, "member") + XCTAssertEqual(root.elements[1].elements[1].elements[0].stringValue, "\n ") + + XCTAssertEqual(root.elements[1].elements[1].elements[1].key, "member") + XCTAssertEqual(root.elements[1].elements[1].elements[1].elements[0].stringValue, "foo") + XCTAssertEqual(root.elements[1].elements[1].elements[3].key, "member") + XCTAssertEqual(root.elements[1].elements[1].elements[3].elements[0].stringValue, "bar") + + XCTAssertEqual(root.elements[1].elements[3].elements[1].key, "member") + XCTAssertEqual(root.elements[1].elements[3].elements[1].elements[0].stringValue, "baz") + XCTAssertEqual(root.elements[1].elements[3].elements[3].key, "member") + XCTAssertEqual(root.elements[1].elements[3].elements[3].elements[0].stringValue, "qux") + } + + func testEscapableCharacters_removeWhitespaceElements() throws { + let parser = XMLStackParser(trimValueWhitespaces: false, removeWhitespaceElements: true) + let xmlData = + """ + + escaped data: &lt; + + """.data(using: .utf8)! + let root = try parser.parse(with: xmlData, errorContextLength: 0, shouldProcessNamespaces: false) + + XCTAssertEqual(root.key, "SomeType") + XCTAssertEqual(root.elements[0].key, "strValue") + XCTAssertEqual(root.elements[0].elements[0].stringValue, "escaped data: <\r\n") + } + + func testEscapableCharacters() throws { + let parser = XMLStackParser(trimValueWhitespaces: false, removeWhitespaceElements: false) + let xmlData = + """ + + escaped data: &lt; + + """.data(using: .utf8)! + let root = try parser.parse(with: xmlData, errorContextLength: 0, shouldProcessNamespaces: false) + XCTAssertEqual(root.key, "SomeType") + XCTAssertEqual(root.elements[0].key, "") + XCTAssertEqual(root.elements[0].stringValue, "\n ") + XCTAssertEqual(root.elements[1].elements[0].stringValue, "escaped data: <\r\n") + XCTAssertEqual(root.elements[2].stringValue, "\n") + } } diff --git a/Tests/XMLCoderTests/Minimal/NestedStringList.swift b/Tests/XMLCoderTests/Minimal/NestedStringList.swift new file mode 100644 index 00000000..0dad92de --- /dev/null +++ b/Tests/XMLCoderTests/Minimal/NestedStringList.swift @@ -0,0 +1,74 @@ +// Copyright (c) 2018-2020 XMLCoder contributors +// +// This software is released under the MIT License. +// https://opensource.org/licenses/MIT +// +// Created by John Woo on 7/29/21. +// + +import Foundation + +import XCTest +@testable import XMLCoder + +class NestedStringList: XCTestCase { + + struct TypeWithNestedStringList: Decodable { + let nestedStringList: [[String]] + + enum CodingKeys: String, CodingKey { + case nestedStringList + } + + enum NestedMemberKeys: String, CodingKey { + case member + } + + public init (from decoder: Decoder) throws { + let containerValues = try decoder.container(keyedBy: CodingKeys.self) + let nestedStringListWrappedContainer = try containerValues.nestedContainer(keyedBy: NestedMemberKeys.self, forKey: .nestedStringList) + let nestedStringListContainer = try nestedStringListWrappedContainer.decodeIfPresent([[String]].self, forKey: .member) + var nestedStringListBuffer:[[String]] = [] + if let nestedStringListContainer = nestedStringListContainer { + nestedStringListBuffer = [[String]]() + var listBuffer0: [String]? = nil + for listContainer0 in nestedStringListContainer { + listBuffer0 = [String]() + for stringContainer1 in listContainer0 { + listBuffer0?.append(stringContainer1) + } + if let listBuffer0 = listBuffer0 { + nestedStringListBuffer.append(listBuffer0) + } + } + } + nestedStringList = nestedStringListBuffer + } + } + + func testRemoveWhitespaceElements() throws { + let decoder = XMLDecoder(trimValueWhitespaces: false, removeWhitespaceElements: true) + let xmlString = + """ + + + + foo: &lt; + bar: &lt; + + + baz: &lt; + qux: &lt; + + + + """ + let xmlData = xmlString.data(using: .utf8)! + + let decoded = try decoder.decode(TypeWithNestedStringList.self, from: xmlData) + XCTAssertEqual(decoded.nestedStringList[0][0], "foo: <\r\n") + XCTAssertEqual(decoded.nestedStringList[0][1], "bar: <\r\n") + XCTAssertEqual(decoded.nestedStringList[1][0], "baz: <\r\n") + XCTAssertEqual(decoded.nestedStringList[1][1], "qux: <\r\n") + } +} diff --git a/Tests/XMLCoderTests/Minimal/StringTests.swift b/Tests/XMLCoderTests/Minimal/StringTests.swift index ea5d6981..aca881f1 100644 --- a/Tests/XMLCoderTests/Minimal/StringTests.swift +++ b/Tests/XMLCoderTests/Minimal/StringTests.swift @@ -79,4 +79,18 @@ class StringTests: XCTestCase { XCTAssertEqual(String(data: encoded, encoding: .utf8)!, xmlString) } } + + func testRemoveWhitespaceElements() throws { + let decoder = XMLDecoder(trimValueWhitespaces: false) + let xmlString = + """ + + escaped data: &lt; + + """ + let xmlData = xmlString.data(using: .utf8)! + + let decoded = try decoder.decode(Container.self, from: xmlData) + XCTAssertEqual(decoded.value, "escaped data: <\r\n") + } }