blob: d7a5518fec52234c6a99f2e2fd43d3079337aaf7 [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 Cocoa
import TulsiGenerator
final class TulsiProjectDocument: NSDocument,
NSWindowDelegate,
MessageLogProtocol,
OptionsEditorModelProtocol,
TulsiGeneratorConfigDocumentDelegate {
enum DocumentError: Error {
/// No config exists with the given name.
case noSuchConfig
/// The config failed to load with the given debug info.
case configLoadFailed(String)
/// The workspace used by the project is invalid due to the given debug info.
case invalidWorkspace(String)
}
/// Prefix used to access the persisted output folder for a given BUILD file path.
static let ProjectOutputPathKeyPrefix = "projectOutput_"
/// The subdirectory within a project bundle into which shareable generator configs will be
/// stored.
static let ProjectConfigsSubpath = "Configs"
/// Override for headless project generation (in which messages should not spawn alert dialogs).
static var showAlertsOnErrors = true
/// Override to prevent rule entries from being extracted immediately during loading of project
/// documents. This is only useful if the Bazel binary is expected to be set after the project
/// document is loaded but before any other actions.
static var suppressRuleEntryUpdateOnLoad = false
/// The project model.
var project: TulsiProject! = nil
/// Whether or not the document is currently performing a long running operation.
@objc dynamic var processing: Bool = false
// The number of tasks that need to complete before processing is finished. May only be mutated on
// the main queue.
private var processingTaskCount: Int = 0 {
didSet {
assert(processingTaskCount >= 0, "Processing task count may never be negative")
processing = processingTaskCount > 0
}
}
/// The display names of generator configs associated with this project.
@objc dynamic var generatorConfigNames = [String]()
/// Whether or not there are any opened generator config documents associated with this project.
var hasChildConfigDocuments: Bool {
return childConfigDocuments.count > 0
}
/// Documents controlling the generator configs associated with this project.
private var childConfigDocuments = NSHashTable<AnyObject>.weakObjects()
/// One rule per target in the BUILD files associated with this project.
var ruleInfos: [RuleInfo] {
return _ruleInfos
}
private var _ruleInfos = [RuleInfo]() {
didSet {
// Update the associated config documents.
let childDocuments = childConfigDocuments.allObjects as! [TulsiGeneratorConfigDocument]
for configDoc in childDocuments {
configDoc.projectRuleInfos = ruleInfos
}
}
}
/// The set of Bazel packages associated with this project.
@objc dynamic var bazelPackages: [String]? {
set {
project!.bazelPackages = newValue ?? [String]()
updateChangeCount(.changeDone)
updateRuleEntries()
}
get {
return project?.bazelPackages
}
}
/// Location of the bazel binary.
@objc dynamic var bazelURL: URL? {
set {
project.bazelURL = newValue
if newValue != nil && infoExtractor != nil {
infoExtractor.bazelURL = newValue!
}
updateChangeCount(.changeDone)
updateRuleEntries()
}
get {
return project?.bazelURL
}
}
/// Binding point for the directory containing the project's WORKSPACE file.
@objc dynamic var workspaceRootURL: URL? {
return project?.workspaceRootURL
}
/// URL to the folder into which generator configs are saved.
var generatorConfigFolderURL: URL? {
return fileURL?.appendingPathComponent(TulsiProjectDocument.ProjectConfigsSubpath)
}
/// Whether or not the document has finished initializing the info extractor.
@objc dynamic var infoExtractorInitialized: Bool = false
var infoExtractor: TulsiProjectInfoExtractor! = nil {
didSet {
infoExtractorInitialized = (infoExtractor != nil)
}
}
private var logEventObserver: NSObjectProtocol! = nil
/// Array of user-facing messages, generally output by the Tulsi generator.
@objc dynamic var messages = [UIMessage]()
var errors = [LogMessage]()
lazy var bundleExtension: String = {
TulsiProjectDocument.getTulsiBundleExtension()
}()
static func getTulsiBundleExtension() -> String {
let bundle = Bundle(for: self)
let documentTypes = bundle.infoDictionary!["CFBundleDocumentTypes"] as! [[String: AnyObject]]
let extensions = documentTypes.first!["CFBundleTypeExtensions"] as! [String]
return extensions.first!
}
override init() {
super.init()
logEventObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: TulsiMessageNotification),
object: nil,
queue: OperationQueue.main) {
[weak self] (notification: Notification) in
guard let item = LogMessage(notification: notification) else {
if let showModal = notification.userInfo?["displayErrors"] as? Bool, showModal {
self?.displayErrorModal()
}
return
}
self?.handleLogMessage(item)
}
}
deinit {
NotificationCenter.default.removeObserver(logEventObserver)
}
func clearMessages() {
messages.removeAll(keepingCapacity: true)
}
func addBUILDFileURL(_ buildFile: URL) -> Bool {
guard let package = packageForBUILDFile(buildFile) else {
return false
}
bazelPackages!.append(package)
return true
}
func containsBUILDFileURL(_ buildFile: URL) -> Bool {
guard let package = packageForBUILDFile(buildFile),
let concreteBazelPackages = bazelPackages else {
return false
}
return concreteBazelPackages.contains(package)
}
func createNewProject(_ projectName: String, workspaceFileURL: URL) {
willChangeValue(forKey: "bazelURL")
willChangeValue(forKey: "bazelPackages")
willChangeValue(forKey: "workspaceRootURL")
// Default the bundleURL to a sibling of the selected workspace file.
let bundleName = "\(projectName).\(bundleExtension)"
let workspaceRootURL = workspaceFileURL.deletingLastPathComponent()
let tempProjectBundleURL = workspaceRootURL.appendingPathComponent(bundleName)
project = TulsiProject(projectName: projectName,
projectBundleURL: tempProjectBundleURL,
workspaceRootURL: workspaceRootURL)
updateChangeCount(.changeDone)
LogMessage.postSyslog("Create project: \(projectName)")
didChangeValue(forKey: "bazelURL")
didChangeValue(forKey: "bazelPackages")
didChangeValue(forKey: "workspaceRootURL")
}
override func writeSafely(to url: URL,
ofType typeName: String,
for saveOperation: NSDocument.SaveOperationType) throws {
// Ensure that the project's URL is set to the location in which this document is being saved so
// that relative paths can be set properly.
project.projectBundleURL = url
try super.writeSafely(to: url, ofType: typeName, for: saveOperation)
}
override class var autosavesInPlace: Bool {
return true
}
override func prepareSavePanel(_ panel: NSSavePanel) -> Bool {
panel.message = NSLocalizedString("Document_SelectTulsiProjectOutputFolderMessage",
comment: "Message to show at the top of the Tulsi project save as panel, explaining what to do.")
panel.canCreateDirectories = true
panel.allowedFileTypes = ["com.google.tulsi.project"]
panel.nameFieldStringValue = project.projectBundleURL.lastPathComponent
return true
}
override func fileWrapper(ofType typeName: String) throws -> FileWrapper {
let contents = [String: FileWrapper]()
let bundleFileWrapper = FileWrapper(directoryWithFileWrappers: contents)
bundleFileWrapper.addRegularFile(withContents: try project.save() as Data,
preferredFilename: TulsiProject.ProjectFilename)
if let perUserData = try project.savePerUserSettings() {
bundleFileWrapper.addRegularFile(withContents: perUserData as Data,
preferredFilename: TulsiProject.perUserFilename)
}
let configsFolder: FileWrapper
let reachableError: NSErrorPointer = nil
if let existingConfigFolderURL = generatorConfigFolderURL, (existingConfigFolderURL as NSURL).checkResourceIsReachableAndReturnError(reachableError) {
// Preserve any existing config documents.
configsFolder = try FileWrapper(url: existingConfigFolderURL,
options: FileWrapper.ReadingOptions())
} else {
// Add a placeholder Configs directory.
configsFolder = FileWrapper(directoryWithFileWrappers: [:])
}
configsFolder.preferredFilename = TulsiProjectDocument.ProjectConfigsSubpath
bundleFileWrapper.addFileWrapper(configsFolder)
return bundleFileWrapper
}
override func read(from fileWrapper: FileWrapper, ofType typeName: String) throws {
guard let concreteFileURL = fileURL,
let projectFileWrapper = fileWrapper.fileWrappers?[TulsiProject.ProjectFilename],
let fileContents = projectFileWrapper.regularFileContents else {
return
}
let additionalOptionData: Data?
if let perUserDataFileWrapper = fileWrapper.fileWrappers?[TulsiProject.perUserFilename] {
additionalOptionData = perUserDataFileWrapper.regularFileContents
} else {
additionalOptionData = nil
}
project = try TulsiProject(data: fileContents,
projectBundleURL: concreteFileURL,
additionalOptionData: additionalOptionData)
if let configsDir = fileWrapper.fileWrappers?[TulsiProjectDocument.ProjectConfigsSubpath],
let configFileWrappers = configsDir.fileWrappers, configsDir.isDirectory {
var configNames = [String]()
for (_, fileWrapper) in configFileWrappers {
if let filename = fileWrapper.filename, fileWrapper.isRegularFile &&
TulsiGeneratorConfigDocument.isGeneratorConfigFilename(filename) {
let name = (filename as NSString).deletingPathExtension
configNames.append(name)
}
}
generatorConfigNames = configNames.sorted()
}
// Verify that the workspace is a valid one.
let workspaceFile = project.workspaceRootURL.appendingPathComponent("WORKSPACE",
isDirectory: false)
var isDirectory = ObjCBool(false)
if !FileManager.default.fileExists(atPath: workspaceFile.path,
isDirectory: &isDirectory) || isDirectory.boolValue {
let fmt = NSLocalizedString("Error_NoWORKSPACEFile",
comment: "Error when project does not have a valid Bazel WORKSPACE file at %1$@.")
LogMessage.postError(String(format: fmt, workspaceFile.path))
LogMessage.displayPendingErrors()
throw DocumentError.invalidWorkspace("Missing WORKSPACE file at \(workspaceFile.path)")
}
if !TulsiProjectDocument.suppressRuleEntryUpdateOnLoad {
updateRuleEntries()
}
}
override func makeWindowControllers() {
let storyboard = NSStoryboard(name: "Main", bundle: nil)
let windowController = storyboard.instantiateController(withIdentifier: "TulsiProjectDocumentWindow") as! NSWindowController
windowController.contentViewController?.representedObject = self
addWindowController(windowController)
}
override func willPresentError(_ error: Error) -> Error {
// Track errors shown to the user for bug reporting purposes.
LogMessage.postInfo("Presented error: \(error)", context: projectName)
return super.willPresentError(error)
}
/// Tracks the given document as a child of this project.
func trackChildConfigDocument(_ document: TulsiGeneratorConfigDocument) {
childConfigDocuments.add(document)
// Ensure that the child document is aware of the project-level processing tasks.
document.addProcessingTaskCount(processingTaskCount)
}
/// Closes any generator config documents associated with this project.
func closeChildConfigDocuments() {
let childDocuments = childConfigDocuments.allObjects as! [TulsiGeneratorConfigDocument]
for configDoc in childDocuments {
configDoc.close()
}
childConfigDocuments.removeAllObjects()
}
func deleteConfigsNamed(_ configNamesToRemove: [String]) {
let fileManager = FileManager.default
var nameToDoc = [String: TulsiGeneratorConfigDocument]()
for doc in childConfigDocuments.allObjects as! [TulsiGeneratorConfigDocument] {
guard let name = doc.configName else { continue }
nameToDoc[name] = doc
}
var configNames = Set<String>(generatorConfigNames)
for name in configNamesToRemove {
configNames.remove(name)
if let doc = nameToDoc[name] {
childConfigDocuments.remove(doc)
doc.close()
}
if let url = urlForConfigNamed(name, sanitized: false) {
let errorInfo: String?
do {
try fileManager.removeItem(at: url)
errorInfo = nil
} catch let e as NSError {
errorInfo = "Unexpected exception \(e.localizedDescription)"
} catch {
errorInfo = "Unexpected exception"
}
if let errorInfo = errorInfo {
let fmt = NSLocalizedString("Error_ConfigDeleteFailed",
comment: "Error when a TulsiGeneratorConfig named %1$@ could not be deleted.")
LogMessage.postError(String(format: fmt, name), details: errorInfo)
LogMessage.displayPendingErrors()
}
}
}
generatorConfigNames = configNames.sorted()
}
func urlForConfigNamed(_ name: String, sanitized: Bool = true) -> URL? {
return TulsiGeneratorConfigDocument.urlForConfigNamed(name,
inFolderURL: generatorConfigFolderURL,
sanitized: sanitized)
}
/// Asynchronously loads a previously created config with the given name, invoking the given
/// completionHandler on the main thread when the document is fully loaded.
func loadConfigDocumentNamed(_ name: String,
completionHandler: @escaping ((TulsiGeneratorConfigDocument?) -> Void)) throws -> TulsiGeneratorConfigDocument {
let doc = try loadSparseConfigDocumentNamed(name)
doc.finishLoadingDocument(completionHandler)
return doc
}
/// Sparsely loads a previously created config with the given name. The returned document may have
/// unresolved label references.
func loadSparseConfigDocumentNamed(_ name: String) throws -> TulsiGeneratorConfigDocument {
guard let configURL = urlForConfigNamed(name, sanitized: false) else {
throw DocumentError.noSuchConfig
}
let documentController = NSDocumentController.shared
if let configDocument = documentController.document(for: configURL) as? TulsiGeneratorConfigDocument {
return configDocument
}
do {
let configDocument = try TulsiGeneratorConfigDocument.makeSparseDocumentWithContentsOfURL(configURL,
infoExtractor: infoExtractor,
messageLog: self,
bazelURL: bazelURL)
configDocument.projectRuleInfos = ruleInfos
configDocument.delegate = self
trackChildConfigDocument(configDocument)
return configDocument
} catch let e as NSError {
throw DocumentError.configLoadFailed("Failed to load config from '\(configURL.path)' with error \(e.localizedDescription)")
} catch {
throw DocumentError.configLoadFailed("Unexpected exception loading config from '\(configURL.path)'")
}
}
/// Displays a generic critical error message to the user with the given debug message.
/// This should be used sparingly and only for messages that would indicate bugs in Tulsi.
func generalError(_ debugMessage: String) {
let msg = NSLocalizedString("Error_GeneralCriticalFailure",
comment: "A general, critical failure without a more fitting descriptive message.")
LogMessage.postError(msg, details: debugMessage)
}
// MARK: - NSUserInterfaceValidations
override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
let itemAction = item.action
switch itemAction {
case .some(#selector(TulsiProjectDocument.save(_:))):
return true
case .some(#selector(TulsiProjectDocument.saveAs(_:))):
return true
case .some(#selector(TulsiProjectDocument.rename(_:))):
return true
case .some(#selector(TulsiProjectDocument.move(_:))):
return true
// Unsupported actions.
case .some(#selector(TulsiProjectDocument.duplicate(_:))):
return false
default:
Swift.print("Unhandled menu action: \(String(describing: itemAction))")
}
return false
}
// MARK: - TulsiGeneratorConfigDocumentDelegate
func didNameTulsiGeneratorConfigDocument(_ document: TulsiGeneratorConfigDocument, configName: String) {
if !generatorConfigNames.contains(configName) {
let configNames = (generatorConfigNames + [configName]).sorted()
generatorConfigNames = configNames
}
}
func parentOptionSetForConfigDocument(_: TulsiGeneratorConfigDocument) -> TulsiOptionSet? {
return optionSet
}
// MARK: - OptionsEditorModelProtocol
var projectName: String? {
guard let concreteProject = project else { return nil }
return concreteProject.projectName
}
var optionSet: TulsiOptionSet? {
guard let concreteProject = project else { return nil }
return concreteProject.options
}
var projectValueColumnTitle: String {
return NSLocalizedString("OptionsEditor_ColumnTitle_Project",
comment: "Title for the options editor column used to edit per-tulsiproj values.")
}
var defaultValueColumnTitle: String {
return NSLocalizedString("OptionsEditor_ColumnTitle_Default",
comment: "Title for the options editor column used to display the built-in default values.")
}
var optionsTargetUIRuleEntries: [UIRuleInfo]? {
return nil
}
// MARK: - Private methods
// Idempotent function to gather all error messages that have been logged and create a single
// error modal to present to the user.
private func displayErrorModal() {
guard TulsiProjectDocument.showAlertsOnErrors else {
return
}
var errorMessages = [String]()
var details = [String]()
for error in errors {
errorMessages.append(error.message)
if let detail = error.details {
details.append(detail)
}
}
errors.removeAll()
if !errorMessages.isEmpty {
ErrorAlertView.displayModalError(errorMessages.joined(separator: "\n"), details: details.joined(separator: "\n"))
}
}
private func handleLogMessage(_ item: LogMessage) {
let fullMessage: String
if let details = item.details {
fullMessage = "\(item.message) [Details]: \(details)"
} else {
fullMessage = item.message
}
switch item.level {
case .Error:
messages.append(UIMessage(text: fullMessage, type: .error))
errors.append(item)
case .Warning:
messages.append(UIMessage(text: fullMessage, type: .warning))
case .Info:
messages.append(UIMessage(text: fullMessage, type: .info))
case .Syslog:
break
case .Debug:
messages.append(UIMessage(text: fullMessage, type: .debug))
}
}
private func processingTaskStarted() {
Thread.doOnMainQueue() {
self.processingTaskCount += 1
let childDocuments = self.childConfigDocuments.allObjects as! [TulsiGeneratorConfigDocument]
for configDoc in childDocuments {
configDoc.processingTaskStarted()
}
}
}
private func processingTaskFinished() {
Thread.doOnMainQueue() {
self.processingTaskCount -= 1
let childDocuments = self.childConfigDocuments.allObjects as! [TulsiGeneratorConfigDocument]
for configDoc in childDocuments {
configDoc.processingTaskFinished()
}
}
}
private func packageForBUILDFile(_ buildFile: URL) -> String? {
let packageURL = buildFile.deletingLastPathComponent()
// If the relative path is a child of the workspace root return it.
if let relativePath = project.workspaceRelativePathForURL(packageURL), !relativePath.hasPrefix("/") && !relativePath.hasPrefix("..") {
return relativePath
}
return nil
}
// Fetches target rule entries from the project's BUILD documents.
private func updateRuleEntries() {
guard let concreteBazelURL = bazelURL else {
return
}
processingTaskStarted()
Thread.doOnQOSUserInitiatedThread() {
self.infoExtractor = TulsiProjectInfoExtractor(bazelURL: concreteBazelURL,
project: self.project)
let updatedRuleEntries = self.infoExtractor.extractTargetRules()
Thread.doOnMainQueue() {
self._ruleInfos = updatedRuleEntries
self.processingTaskFinished()
}
}
}
}
/// Convenience class for displaying an error message with an optional detail accessory view.
class ErrorAlertView: NSAlert {
@objc dynamic var text = ""
static func displayModalError(_ message: String, details: String? = nil) {
let alert = ErrorAlertView()
alert.messageText = "\(message)\n\nA fatal error occurred. Please check the message window " +
"and file a bug if appropriate."
alert.alertStyle = .critical
if let details = details, !details.isEmpty {
alert.text = details
var views: NSArray?
Bundle.main.loadNibNamed("ErrorAlertDetailView",
owner: alert,
topLevelObjects: &views)
// Note: topLevelObjects will contain the accessory view and an NSApplication object in a
// non-deterministic order.
if let views = views {
let viewsFound = views.filter() { $0 is NSView } as NSArray
if let accessoryView = viewsFound.firstObject as? NSScrollView {
alert.accessoryView = accessoryView
// Fix text color for dark mode.
//
// Text color is set to nil for the text view (which defaults to black) and Interface
// Builder can't seem to correctly assign the color, so the text color needs to be
// assigned at runtime.
for view in accessoryView.subviews {
for subview in view.subviews {
if let textView = subview as? NSTextView {
textView.textColor = NSColor.textColor
}
}
}
} else {
assertionFailure("Failed to load accessory view for error alert.")
}
}
}
alert.runModal()
}
}