blob: f841354da5813b494982ee8a8391314ba8205975 [file] [log] [blame]
// Copyright 2016 The Tulsi Authors. All rights reserved.
//
// 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
/// Models a configuration which can be used to generate an Xcode project directly.
public class TulsiGeneratorConfig {
public enum ConfigError: Error {
/// The give input file does not exist or cannot be read.
case badInputFilePath
/// A per-user config was found but could not be read.
case failedToReadAdditionalOptionsData(String)
/// Deserialization failed with the given debug info.
case deserializationFailed(String)
/// Serialization failed with the given debug info.
case serializationFailed(String)
}
/// The file extension used when saving generator configs.
public static let FileExtension = "tulsigen"
/// Filename to be used when writing out user-specific values.
public static var perUserFilename: String {
return "\(NSUserName()).tulsigen-user"
}
/// The name of the Xcode project.
public let projectName: String
public var defaultFilename: String {
return TulsiGeneratorConfig.sanitizeFilename("\(projectName).\(TulsiGeneratorConfig.FileExtension)")
}
/// The name of the Xcode project that will be generated by this config.
public var xcodeProjectFilename: String {
return TulsiGeneratorConfig.sanitizeFilename("\(projectName).xcodeproj")
}
/// The Bazel targets to generate Xcode build targets for.
public let buildTargetLabels: [BuildLabel]
/// The directory paths for which source files should be included in the generated Xcode project.
public let pathFilters: Set<String>
/// Additional file paths to add to the Xcode project (e.g., BUILD file paths).
public let additionalFilePaths: [String]?
/// The options for this config.
public let options: TulsiOptionSet
/// URL to the Bazel binary.
private let bazelURLValue: TulsiParameter<URL>
/// URL to the Bazel binary, computed from bazelURLValue.
public var bazelURL: URL {
return bazelURLValue.value
}
static let ProjectNameKey = "projectName"
static let BuildTargetsKey = "buildTargets"
static let PathFiltersKey = "sourceFilters"
static let AdditionalFilePathsKey = "additionalFilePaths"
/// Returns a copy of the given filename sanitized by replacing path separators and spaces.
public static func sanitizeFilename(_ filename: String) -> String {
return filename.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: " ", with: "_")
}
// Tries to resolve a URL to the Bazel exectuable via:
// 1. Passed in bazelURL argument.
// 2. BazelPath set on the TulsiOptionSet.
// 3. BazelLocator's findBazelForWorkspaceRoot.
//
// If none of these succeed, this returns nil.
public static func resolveBazelURL(_ bazelURL: URL?,
options: TulsiOptionSet) -> TulsiParameter<URL>? {
if let bazelURL = bazelURL {
return TulsiParameter(value: bazelURL, source: .explicitlyProvided)
} else if let savedBazelPath = options[.BazelPath].commonValue {
return TulsiParameter(value: URL(fileURLWithPath: savedBazelPath), source: .options)
} else if let locatedURL = BazelLocator.bazelURL {
return TulsiParameter(value: locatedURL, source: .fallback)
}
return nil
}
public static func load(_ inputFile: URL, bazelURL: URL? = nil) throws -> TulsiGeneratorConfig {
let fileManager = FileManager.default
guard let data = fileManager.contents(atPath: inputFile.path) else {
throw ConfigError.badInputFilePath
}
let additionalOptionData: Data?
let optionsFolderURL = inputFile.deletingLastPathComponent()
let additionalOptionsFileURL = optionsFolderURL.appendingPathComponent(TulsiGeneratorConfig.perUserFilename)
let perUserPath = additionalOptionsFileURL.path
if fileManager.isReadableFile(atPath: perUserPath) {
additionalOptionData = fileManager.contents(atPath: perUserPath)
if additionalOptionData == nil {
throw ConfigError.failedToReadAdditionalOptionsData("Could not read file at path \(perUserPath)")
}
} else {
additionalOptionData = nil
}
return try TulsiGeneratorConfig(data: data,
additionalOptionData: additionalOptionData,
bazelURL: bazelURL)
}
public init(projectName: String,
buildTargetLabels: [BuildLabel],
pathFilters: Set<String>,
additionalFilePaths: [String]?,
options: TulsiOptionSet,
bazelURL: TulsiParameter<URL>) {
self.projectName = projectName
self.buildTargetLabels = buildTargetLabels
self.pathFilters = pathFilters
self.additionalFilePaths = additionalFilePaths
self.options = options
self.bazelURLValue = bazelURL
}
public convenience init(projectName: String,
buildTargets: [RuleInfo],
pathFilters: Set<String>,
additionalFilePaths: [String]?,
options: TulsiOptionSet,
bazelURL: TulsiParameter<URL>) {
self.init(projectName: projectName,
buildTargetLabels: buildTargets.map({ $0.label }),
pathFilters: pathFilters,
additionalFilePaths: additionalFilePaths,
options: options,
bazelURL: bazelURL)
}
public convenience init(data: Data,
additionalOptionData: Data? = nil,
bazelURL: URL? = nil) throws {
func extractJSONDict(_ data: Data, errorBuilder: (String) -> ConfigError) throws -> [String: AnyObject] {
do {
guard let jsonDict = try JSONSerialization.jsonObject(with: data,
options: JSONSerialization.ReadingOptions()) as? [String: AnyObject] else {
throw errorBuilder("Config file contents are invalid")
}
return jsonDict
} catch let e as ConfigError {
throw e
} catch let e as NSError {
throw errorBuilder(e.localizedDescription)
} catch {
assertionFailure("Unexpected exception")
throw errorBuilder("Unexpected exception")
}
}
let dict = try extractJSONDict(data) { ConfigError.deserializationFailed($0)}
let projectName = dict[TulsiGeneratorConfig.ProjectNameKey] as? String ?? "Unnamed Tulsi Project"
let buildTargetLabels = dict[TulsiGeneratorConfig.BuildTargetsKey] as? [String] ?? []
let additionalFilePaths = dict[TulsiGeneratorConfig.AdditionalFilePathsKey] as? [String]
let rawPathFilters = Set<String>(dict[TulsiGeneratorConfig.PathFiltersKey] as? [String] ?? [])
// While //foo/bar is a valid filesystem path, Xcode won't open a project with such a path in
// its structures, leading to arduous debug process.
if let badPath = additionalFilePaths?.first(where: { $0.hasPrefix("//") }) {
throw ConfigError.deserializationFailed("Invalid additional file path: \(badPath)")
}
// Convert any path filters specified as build labels to their package paths.
var pathFilters = Set<String>()
for sourceTarget in rawPathFilters {
// Remap legacy tulsi includes path to the new one to preserve behavior for older projects.
let sourceTarget = sourceTarget.replacingOccurrences(
of: PBXTargetGenerator.legacyTulsiIncludesPath, with: PBXTargetGenerator.tulsiIncludesPath)
if let packageName = BuildLabel(sourceTarget).packageName {
pathFilters.insert(packageName)
}
}
var optionsDict = TulsiOptionSet.getOptionsFromContainerDictionary(dict) ?? [:]
if let additionalOptionData = additionalOptionData {
let additionalOptions = try extractJSONDict(additionalOptionData) {
ConfigError.failedToReadAdditionalOptionsData($0)
}
guard let newOptions = TulsiOptionSet.getOptionsFromContainerDictionary(additionalOptions) else {
throw ConfigError.failedToReadAdditionalOptionsData("Invalid per-user options file")
}
for (key, value) in newOptions {
optionsDict[key] = value
}
}
let options = TulsiOptionSet(fromDictionary: optionsDict)
guard let bazelURL = TulsiGeneratorConfig.resolveBazelURL(bazelURL, options: options) else {
throw ConfigError.deserializationFailed("Unable to find Bazel Path")
}
self.init(projectName: projectName,
buildTargetLabels: buildTargetLabels.map({ BuildLabel($0) }),
pathFilters: pathFilters,
additionalFilePaths: additionalFilePaths,
options: options,
bazelURL: bazelURL)
}
public func save() throws -> NSData {
let sortedBuildTargetLabels = buildTargetLabels.map({ $0.value }).sorted()
let sortedPathFilters = [String](pathFilters).sorted()
var dict: [String: Any] = [
TulsiGeneratorConfig.ProjectNameKey: projectName as AnyObject,
TulsiGeneratorConfig.BuildTargetsKey: sortedBuildTargetLabels as AnyObject,
TulsiGeneratorConfig.PathFiltersKey: sortedPathFilters as AnyObject,
]
if let additionalFilePaths = additionalFilePaths {
dict[TulsiGeneratorConfig.AdditionalFilePathsKey] = additionalFilePaths as AnyObject?
}
options.saveShareableOptionsIntoDictionary(&dict)
do {
return try JSONSerialization.tulsi_newlineTerminatedUnescapedData(jsonObject: dict,
options: [.prettyPrinted, .sortedKeys])
} catch let e as NSError {
throw ConfigError.serializationFailed(e.localizedDescription)
} catch {
throw ConfigError.serializationFailed("Unexpected exception")
}
}
public func savePerUserSettings() throws -> NSData? {
var dict = [String: Any]()
options.savePerUserOptionsIntoDictionary(&dict)
if dict.isEmpty { return nil }
do {
return try JSONSerialization.tulsi_newlineTerminatedUnescapedData(jsonObject: dict,
options: [.prettyPrinted, .sortedKeys])
} catch let e as NSError {
throw ConfigError.serializationFailed(e.localizedDescription)
} catch {
throw ConfigError.serializationFailed("Unexpected exception")
}
}
public func configByResolvingInheritedSettingsFromProject(_ project: TulsiProject) -> TulsiGeneratorConfig {
let resolvedOptions = options.optionSetByInheritingFrom(project.options)
let newBazelURL = bazelURLValue.reduce(TulsiParameter(value: project.bazelURL, source: .project))
return TulsiGeneratorConfig(projectName: projectName,
buildTargetLabels: buildTargetLabels,
pathFilters: pathFilters,
additionalFilePaths: additionalFilePaths,
options: resolvedOptions,
bazelURL: newBazelURL)
}
public func configByAppendingPathFilters(_ additionalPathFilters: Set<String>) -> TulsiGeneratorConfig {
let newPathFilters = pathFilters.union(additionalPathFilters)
return TulsiGeneratorConfig(projectName: projectName,
buildTargetLabels: buildTargetLabels,
pathFilters: newPathFilters,
additionalFilePaths: additionalFilePaths,
options: options,
bazelURL: bazelURLValue)
}
}