// Copyright © 2017 Schibsted. All rights reserved. import Foundation func format(_ files: [String]) -> (filesChecked: Int, filesUpdated: Int, errors: [FormatError]) { var filesChecked = 0, filesUpdated = 0, errors = [Error]() for path in files { let url = expandPath(path) errors += enumerateFiles(withInputURL: url, concurrent: false) { inputURL, outputURL in var checked = false, updated = false do { let data = try Data(contentsOf: inputURL) if let input = String(data: data, encoding: .utf8), let xml = try parseLayoutXML(data, for: inputURL) { checked = true let output = try format(xml) if output != input { try output.write(to: outputURL, atomically: true, encoding: .utf8) updated = true } } return { if checked { filesChecked += 1 } if updated { filesUpdated += 1 } } } catch let FormatError.parsing(error) { return { if checked { filesChecked += 1 } throw FormatError.parsing("\(error) in \(inputURL.path)") } } catch { return { if checked { filesChecked += 1 } throw error } } } } return (filesChecked, filesUpdated, errors.map(FormatError.init)) } func format(_ xml: String) throws -> String { guard let data = xml.data(using: .utf8, allowLossyConversion: true) else { throw FormatError.parsing("Invalid xml string") } let xml = try FormatError.wrap { try XMLParser.parse(data: data) } return try format(xml) } func format(_ xml: [XMLNode]) throws -> String { return try xml.toString(withIndent: "") } extension Collection where Iterator.Element == XMLNode { func toString(withIndent indent: String, indentFirstLine: Bool = true, isHTML: Bool = false) throws -> String { var output = "" var previous: XMLNode? var indentNextLine = indentFirstLine var params = [XMLNode]() var macros = [XMLNode]() var nodes = Array(self) if !isHTML { for (index, node) in nodes.enumerated().reversed() { if node.isParameter { var i = index while i > 0, nodes[i - 1].isComment { i -= 1 } params = nodes[i ... index] + params nodes[i ... index] = [] } else if node.isMacro { var i = index while i > 0, nodes[i - 1].isComment { i -= 1 } macros = nodes[i ... index] + macros nodes[i ... index] = [] } else if node.isChildren || node.isHTML || node.isText { break } } } for node in params + macros + nodes { if node.isLinebreak, previous?.isHTML != true { continue } switch node { case .text("\n"): continue case .comment: if let previous = previous, !previous.isComment, !previous.isLinebreak { output += "\n" indentNextLine = true } fallthrough default: if let previous = previous { if previous.isParameterOrMacro, !node.isParameterOrMacro, !node.isComment { if !node.isHTML { output += "\n" } output += "\n" } else if !(node.isText && (previous.isHTML || previous.isText)), !(node.isHTML && previous.isText) { output += "\n" } } if output.hasSuffix("\n") { indentNextLine = true } output += try node.toString(withIndent: indent, indentFirstLine: indentNextLine) } previous = node indentNextLine = false } if !output.hasSuffix("\n") { output += "\n" } return output } } /// Threshold for min number of attributes to begin linewrapping private let attributeWrap = 2 extension XMLNode { private func formatAttribute(key: String, value: String) throws -> String { do { let description: String if attributeIsString(key, inNode: self) ?? true { // We have to treat everying we aren't sure about as a string expression, because if // we attempt to format text outside of {...} as an expression, it will get messed up let parts = try parseStringExpression(value) for part in parts { switch part { case .string, .comment: break case let .expression(expression): try validateLayoutExpression(expression) } } description = parts.description } else { let expression = try parseExpression(value) try validateLayoutExpression(expression) description = expression.description } return "\(key)=\"\(description.xmlEncoded(forAttribute: true))\"" } catch { throw FormatError.parsing("\(error) in \(key) attribute") } } func toString(withIndent indent: String, indentFirstLine: Bool = true) throws -> String { switch self { case let .node(name, attributes, children): do { var xml = indentFirstLine ? indent : "" xml += "<\(name)" let attributes = attributes.sorted(by: { a, b in a.key < b.key // sort alphabetically }) if attributes.count < attributeWrap || isParameterOrMacro || isHTML { for (key, value) in attributes { xml += try " \(formatAttribute(key: key, value: value))" } } else { for (key, value) in attributes { xml += try "\n\(indent) \(formatAttribute(key: key, value: value))" } } if isParameterOrMacro || isChildren { xml += "/>" } else if isEmpty { if !isHTML, attributes.count >= attributeWrap { xml += "\n\(indent)" } if !isHTML || emptyHTMLTags.contains(name) { xml += "/>" } else { xml += ">" } } else if children.count == 1, children[0].isComment || children[0].isText { xml += ">" var body = try children[0].toString(withIndent: indent + " ", indentFirstLine: false) if isHTML { if !body.hasPrefix("\n") { body = body.replacingOccurrences(of: "\\s*\\n\\s*", with: "\n\(indent)", options: .regularExpression) } if body.hasSuffix("\n") { body = "\(body)\(indent)" } } else if attributes.count >= attributeWrap { if !body.hasPrefix("\n") { body = "\n\(indent)\(body)" } else { body = "\(indent)\(body)" } if !body.hasSuffix("\n") { body = "\(body)\n\(indent)" } else { body = "\(body)\(indent)" } } else { body = body.trimmingCharacters(in: .whitespacesAndNewlines) } xml += "\(body)" } else { xml += ">\n" let body = try children.toString(withIndent: indent + " ", isHTML: isHTML) if (!isHTML && attributes.count >= attributeWrap) || children.first(where: { !$0.isLinebreak })?.isComment == true { xml += "\n" } xml += "\(body)\(indent)" } return xml } catch { throw FormatError.parsing("\(error) in <\(name)>") } case let .text(text): if text == "\n" { return text } var body = text .xmlEncoded(forAttribute: false) .replacingOccurrences(of: "\\s*\\n\\s*", with: "\n\(indent)", options: .regularExpression) if body.hasSuffix("\n\(indent)") { body = String(body[body.startIndex ..< body.index(body.endIndex, offsetBy: -indent.count)]) } if indentFirstLine { body = body.replacingOccurrences(of: "^\\s*", with: indent, options: .regularExpression) } return body case let .comment(comment): let body = comment .trimmingCharacters(in: .whitespacesAndNewlines) .replacingOccurrences(of: "\\s*\\n\\s*", with: "\n\(indent)", options: .regularExpression) return "\(indentFirstLine ? indent : "")" } } }