| // 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) |
| } |
| |
| var infoExtractor: TulsiProjectInfoExtractor! = 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 |
| } else { |
| assertionFailure("Failed to load accessory view for error alert.") |
| } |
| } |
| } |
| alert.runModal() |
| } |
| } |