// Copyright © 2017 Schibsted. All rights reserved. import Foundation /// An enumeration of the types of error that may be thrown by LayoutTool enum FormatError: Error, CustomStringConvertible { case reading(String) case writing(String) case parsing(String) case options(String) case generic(String) init(_ error: Error) { switch error { case let error as FormatError: self = error case let error as XMLParser.Error: self = .parsing(error.description) default: self = .generic(error.localizedDescription) } } var description: String { switch self { case let .reading(string), let .writing(string), let .parsing(string), let .options(string), let .generic(string): return string } } /// Converts error thrown by the wrapped closure to a LayoutError static func wrap(_ closure: () throws -> T) throws -> T { do { return try closure() } catch { throw self.init(error) } } } /// File enumeration options struct FileOptions { var followSymlinks: Bool var supportedFileExtensions: [String] init(followSymlinks: Bool = false, supportedFileExtensions: [String] = ["xml"]) { self.followSymlinks = followSymlinks self.supportedFileExtensions = supportedFileExtensions } } /// Enumerate all xml files at the specified location and (optionally) calculate an output file URL for each. /// Ignores the file if any of the excluded file URLs is a prefix of the input file URL. /// /// Files are enumerated concurrently. For convenience, the enumeration block returns a completion block, which /// will be executed synchronously on the calling thread once enumeration is complete. /// /// Errors may be thrown by either the enumeration block or the completion block, and are gathered into an /// array and returned after enumeration is complete, along with any errors generated by the function itself. /// Throwing an error from inside either block does *not* terminate the enumeration. func enumerateFiles(withInputURL inputURL: URL, excluding excludedURLs: [URL] = [], outputURL: URL? = nil, options: FileOptions = FileOptions(), concurrent: Bool = true, block: @escaping (URL, URL) throws -> () throws -> Void) -> [Error] { guard let resourceValues = try? inputURL.resourceValues( forKeys: Set([.isDirectoryKey, .isAliasFileKey, .isSymbolicLinkKey]) ) else { if FileManager.default.fileExists(atPath: inputURL.path) { return [FormatError.reading("failed to read attributes for \(inputURL.path)")] } return [FormatError.options("file not found at \(inputURL.path)")] } if !options.followSymlinks, (resourceValues.isAliasFile ?? resourceValues.isSymbolicLink) == true { return [FormatError.options("symbolic link or alias was skipped: \(inputURL.path)")] } if resourceValues.isDirectory == false, !options.supportedFileExtensions.contains(inputURL.pathExtension) { return [FormatError.options("unsupported file type: \(inputURL.path)")] } let group = DispatchGroup() var completionBlocks = [() throws -> Void]() let completionQueue = DispatchQueue(label: "layout.enumeration") func onComplete(_ block: @escaping () throws -> Void) { completionQueue.async(group: group) { completionBlocks.append(block) } } let manager = FileManager.default let keys: [URLResourceKey] = [.isRegularFileKey, .isDirectoryKey, .isAliasFileKey, .isSymbolicLinkKey] let queue = concurrent ? DispatchQueue.global(qos: .userInitiated) : completionQueue func enumerate(inputURL: URL, excluding excludedURLs: [URL], outputURL: URL?, options: FileOptions, block: @escaping (URL, URL) throws -> () throws -> Void) { let inputURL = inputURL.standardizedFileURL for excludedURL in excludedURLs { if inputURL.absoluteString.hasPrefix(excludedURL.standardizedFileURL.absoluteString) { return } } guard let resourceValues = try? inputURL.resourceValues(forKeys: Set(keys)) else { onComplete { throw FormatError.reading("failed to read attributes for \(inputURL.path)") } return } if resourceValues.isRegularFile == true { if options.supportedFileExtensions.contains(inputURL.pathExtension) { do { try onComplete(block(inputURL, outputURL ?? inputURL)) } catch { onComplete { throw error } } } } else if resourceValues.isDirectory == true { var excludedURLs = excludedURLs let ignoreFile = inputURL.appendingPathComponent(layoutIgnoreFile) if manager.fileExists(atPath: ignoreFile.path) { do { excludedURLs += try parseIgnoreFile(ignoreFile) } catch { onComplete { throw error } } } guard let files = try? manager.contentsOfDirectory( at: inputURL, includingPropertiesForKeys: keys, options: .skipsHiddenFiles ) else { onComplete { throw FormatError.reading("failed to read contents of directory at \(inputURL.path)") } return } for url in files { queue.async(group: group) { let outputURL = outputURL.map { URL(fileURLWithPath: $0.path + String(url.path[inputURL.path.endIndex ..< url.path.endIndex])) } enumerate(inputURL: url, excluding: excludedURLs, outputURL: outputURL, options: options, block: block) } } } else if options.followSymlinks, resourceValues.isSymbolicLink == true || resourceValues.isAliasFile == true { let resolvedURL = inputURL.resolvingSymlinksInPath() enumerate(inputURL: resolvedURL, excluding: excludedURLs, outputURL: outputURL, options: options, block: block) } } queue.async(group: group) { if !manager.fileExists(atPath: inputURL.path) { onComplete { throw FormatError.options("file not found at \(inputURL.path)") } return } enumerate(inputURL: inputURL, excluding: excludedURLs, outputURL: outputURL, options: options, block: block) } group.wait() var errors = [Error]() for block in completionBlocks { do { try block() } catch { errors.append(error) } } return errors } func expandPath(_ path: String) -> URL { let path = NSString(string: path).expandingTildeInPath let directoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) return URL(fileURLWithPath: path, relativeTo: directoryURL) } func parseLayoutXML(_ data: Data, for fileURL: URL) throws -> [XMLNode]? { do { let xml = try XMLParser.parse(data: data) return xml.isLayout ? xml : nil } catch { switch error { case let error as XMLParser.Error: throw FormatError.parsing("\(error) in \(fileURL.path)") case let error as FileError: throw FormatError.parsing(error.description) default: throw FormatError.reading(error.localizedDescription) } } } func parseLayoutXML(_ fileURL: URL) throws -> [XMLNode]? { let data = try Data(contentsOf: fileURL) return try parseLayoutXML(data, for: fileURL) } /// Currently only used for testing func parseXML(_ xml: String) throws -> [XMLNode] { guard let data = xml.data(using: .utf8, allowLossyConversion: true) else { throw FormatError.parsing("Invalid xml string") } return try FormatError.wrap { try XMLParser.parse(data: data) } } func list(_ files: [String]) -> [FormatError] { var errors = [Error]() for path in files { let url = expandPath(path) errors += enumerateFiles(withInputURL: url, concurrent: false) { inputURL, _ in do { guard try parseLayoutXML(inputURL) != nil else { return {} } return { print(inputURL.path[url.path.endIndex ..< inputURL.path.endIndex]) } } catch { return { throw error } } } } return errors.map(FormatError.init) } /// Determines if given type should be treated as a string expression func isStringType(_ name: String) -> Bool { return [ "String", "NSString", "Selector", "NSAttributedString", "URL", "NSURL", "UIImage", "CGImage", "UIColor", "CGColor", // NOTE: special case handling "UIFont", ].contains(name) } /// Returns the type name of an attribute in a node, or nil if uncertain func typeOfAttribute(_ key: String, inNode node: XMLNode) -> String? { func typeForClass(_ className: String) -> String? { switch key { case "outlet", "id": return "String" case "xml", "template": return "URL" case "center": return "CGPoint" case _ where layoutSymbols.contains(key): return "CGFloat" default: // Look up the type if let props = UIKitSymbols[className] { if let type = props[key] { return type } else if let superclass = props["superclass"], let type = typeForClass(superclass) { return type } } if className.hasSuffix("Controller"), let type = UIKitSymbols["UIViewController"]![key] { return type } if let type = UIKitSymbols["UIView"]![key] { return type } // Guess the type from the name switch key.components(separatedBy: ".").last! { case "left", "right", "x", "width", "top", "bottom", "y", "height": return "CGFloat" case _ where key.hasPrefix("is") || key.hasPrefix("has"): return "Bool" case _ where key.hasSuffix("Color"), "color": return "UIColor" case _ where key.hasSuffix("Size"), "size": return "CGSize" case _ where key.hasSuffix("Delegate"), "delegate", _ where key.hasSuffix("DataSource"), "dataSource": return "Protocol" default: return nil } } } if let type = node.parameters[key] { return type } guard let className = node.name else { preconditionFailure() } return typeForClass(className) } /// Determines if given attribute should be treated as a string expression /// Returns true or false if reasonably certain, otherwise returns nil func attributeIsString(_ key: String, inNode node: XMLNode) -> Bool? { guard let type = typeOfAttribute(key, inNode: node) else { return nil } switch type { case "UIColor", "CGColor": if let expression = node.attributes[key], !expression.contains("{"), expression.contains("rgb(") || expression.contains("rgba(") { return false } return true default: return isStringType(type) } } /// Check that the expression symbols are valid (or at least plausible) func validateLayoutExpression(_ parsedExpression: ParsedLayoutExpression) throws { if let error = parsedExpression.error, error != .unexpectedToken("") { throw error } for symbol in parsedExpression.symbols { switch symbol { case .variable, .array: break case .prefix, .infix, .postfix: guard standardSymbols.contains(symbol) else { throw Expression.Error.undefinedSymbol(symbol) } case let .function(called, arity): for case let .function(name, requiredArity) in standardSymbols where name == called && arity != requiredArity { throw Expression.Error.arityMismatch(.function(called, arity: requiredArity)) } } } }