Sources/SourceParsingFramework/Utilities/FileEnumerator.swift (107 lines of code) (raw):
//
// Copyright (c) 2018. Uber Technologies
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// The supported formats for the sources list file.
public enum SourcesListFileFormat {
/// Newline format, where paths are separated by the newline character.
case newline
/// Minimum escaping format, where paths that contain spaces are
/// escaped with single quotes while paths that don't contain any
/// spaces are not wrapped with any quotes. Paths are separated by
/// a single space character.
case minEscaping
/// Parse the given `String` value into the format enumeration.
///
/// - parameter value: The `String` value to parse.
/// - returns: The `SourcesListFileFormat` case. `nil` if the given
/// string does not match any supported formats.
public static func format(with value: String) -> SourcesListFileFormat? {
switch value.lowercased() {
case "newline": return .newline
case "minescaping": return .minEscaping
default: return nil
}
}
}
/// A utility class that provides file enumeration from a root directory.
public class FileEnumerator {
/// Initializer.
public init() {}
/// Enumerate all the files in the root URL. If the given URL is a
/// directory, it is traversed recursively to surface all file URLs.
/// If the given URL is a file, it is treated as a text file where
/// each line is assumed to be a path to a file.
///
/// - parameter rootUrl: The root URL to enumerate from.
/// - parameter sourcesListFormatValue: The optional `String` value of
/// the format used by the sources list file. If `nil` and the the given
/// `rootUrl` is a file containing a list of Swift source paths, the
/// `SourcesListFileFormat.newline` format is used. If the given `rootUrl`
/// is not a file containing a list of Swift source paths, this value is
/// ignored.
/// - parameter handler: The closure to invoke when a file URL is found.
/// - throws: `FileEnumerationError` if any errors occurred.
public func enumerate(from rootUrl: URL, withSourcesListFormat sourcesListFormatValue: String?, handler: (URL) -> Void) throws {
if rootUrl.isFileURL {
if rootUrl.isSwiftSource {
handler(rootUrl)
} else {
let format = try sourcesListFileFormat(from: sourcesListFormatValue, withDefault: .newline)
let fileUrls = try self.fileUrls(fromSourcesList: rootUrl, with: format)
for fileUrl in fileUrls {
handler(fileUrl)
}
}
} else {
let enumerator = try newFileEnumerator(for: rootUrl)
while let nextObjc = enumerator.nextObject() {
if let fileUrl = nextObjc as? URL {
handler(fileUrl)
}
}
}
}
// MARK: - Private
private func sourcesListFileFormat(from stringValue: String?, withDefault defaultFormat: SourcesListFileFormat) throws -> SourcesListFileFormat {
if let stringValue = stringValue {
if let parsedFormat = SourcesListFileFormat.format(with: stringValue) {
return parsedFormat
} else {
throw GenericError.withMessage("Failed to parse sources list format \(stringValue)")
}
} else {
return defaultFormat
}
}
private func fileUrls(fromSourcesList listUrl: URL, with format: SourcesListFileFormat) throws -> [URL] {
do {
let content = try CachedFileReader.instance.content(forUrl: listUrl)
let paths: [String]
switch format {
case .newline:
paths = perLineFilePaths(from: content)
case .minEscaping:
paths = minEscapingFilePaths(from: content)
}
return paths
.map { (path: String) -> URL in
URL(fileURLWithPath: path)
}
} catch {
throw GenericError.withMessage("Failed to read source paths from list file at \(listUrl) \(error)")
}
}
private func perLineFilePaths(from content: String) -> [String] {
return content
.components(separatedBy: "\n")
.compactMap { (string: String) -> String? in
let trimmedString = string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
return trimmedString.isEmpty ? nil : trimmedString
}
}
private func minEscapingFilePaths(from content: String) -> [String] {
// Mixed lines where each line is either a single-quoted minimally escaped paths or a
// non-escaped path.
let mixedLines = content
.replacingOccurrences(of: " '", with: "\n'")
.replacingOccurrences(of: "' ", with: "'\n")
.components(separatedBy: "\n")
var paths = [String]()
for line in mixedLines {
// If a line starts with a single quote, then it at least contains a minimally escaped path.
if line.starts(with: "'") {
let path = line.replacingOccurrences(of: "'", with: "").trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if !path.isEmpty {
paths.append(path)
}
}
// Otherwise the line is a set of paths separated by spaces.
else {
let nonEscapedPaths = line
.components(separatedBy: " ")
.compactMap { (string: String) -> String? in
let path = string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
return path.isEmpty ? nil : path
}
paths.append(contentsOf: nonEscapedPaths)
}
}
return paths
}
private func newFileEnumerator(for rootUrl: URL) throws -> FileManager.DirectoryEnumerator {
let errorHandler = { (url: URL, error: Error) -> Bool in
fatalError("Failed to traverse \(url) with error \(error).")
}
if let enumerator = FileManager.default.enumerator(at: rootUrl, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles], errorHandler: errorHandler) {
return enumerator
} else {
throw GenericError.withMessage("Failed traverse \(rootUrl)")
}
}
}