mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-05-17 10:30:35 +00:00
370 lines
13 KiB
Swift
Executable File
370 lines
13 KiB
Swift
Executable File
// 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<T>(_ 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))
|
|
}
|
|
}
|
|
}
|
|
}
|