From e622923ab5779c6b2c7949f116c8a09e156a9cda Mon Sep 17 00:00:00 2001 From: Noah McCann Date: Sun, 18 Sep 2022 08:07:10 -0400 Subject: [PATCH 1/4] Added support for --watch command Added support for watching files for changes, then regenerating the site. Unfortunately unable to get this working with the existing "ENTER to exit" functionality, but could do so with "CTRL+C". --- Package.resolved | 9 ++ Package.swift | 8 +- Sources/PublishCLICore/CLI.swift | 32 +++--- Sources/PublishCLICore/WebsiteRunner.swift | 117 +++++++++++++++++++-- 4 files changed, 142 insertions(+), 24 deletions(-) diff --git a/Package.resolved b/Package.resolved index d3d55a8b..1b211b58 100644 --- a/Package.resolved +++ b/Package.resolved @@ -28,6 +28,15 @@ "version": "4.2.0" } }, + { + "package": "FileWatcher", + "repositoryURL": "https://github.com/eonist/FileWatcher.git", + "state": { + "branch": null, + "revision": "e67c2a99502eade343fecabeca8c57e749a55b59", + "version": "0.2.3" + } + }, { "package": "Ink", "repositoryURL": "https://github.com/johnsundell/ink.git", diff --git a/Package.swift b/Package.swift index f81064cc..443ee4a9 100644 --- a/Package.swift +++ b/Package.swift @@ -50,7 +50,13 @@ let package = Package( name: "CollectionConcurrencyKit", url: "https://github.com/johnsundell/collectionConcurrencyKit.git", from: "0.1.0" + ), + .package( + name: "FileWatcher", + url: "https://github.com/eonist/FileWatcher.git", + from: "0.2.3" ) + ], targets: [ .target( @@ -66,7 +72,7 @@ let package = Package( ), .target( name: "PublishCLICore", - dependencies: ["Publish"] + dependencies: ["Publish", "FileWatcher"] ), .testTarget( name: "PublishTests", diff --git a/Sources/PublishCLICore/CLI.swift b/Sources/PublishCLICore/CLI.swift index 128bdf26..7cd6b522 100644 --- a/Sources/PublishCLICore/CLI.swift +++ b/Sources/PublishCLICore/CLI.swift @@ -9,6 +9,7 @@ import Foundation import ShellOut public struct CLI { + private static let defaultPortNumber = 8000 private let arguments: [String] private let publishRepositoryURL: URL private let publishVersion: String @@ -44,7 +45,8 @@ public struct CLI { try deployer.deploy() case "run": let portNumber = extractPortNumber(from: arguments) - let runner = WebsiteRunner(folder: folder, portNumber: portNumber) + let shouldWatch = extractShouldWatch(from: arguments) + let runner = WebsiteRunner(folder: folder, portNumber: portNumber, shouldWatch: shouldWatch) try runner.run() default: outputHelpText() @@ -68,24 +70,30 @@ private extension CLI { - run: Generate and run a localhost server on default port 8000 for the website in the current folder. Use the "-p" or "--port" option for customizing the default port. + Use the "-w" or "--watch" option to watch for file changes. - deploy: Generate and deploy the website in the current folder, according to its deployment method. """) } private func extractPortNumber(from arguments: [String]) -> Int { - if arguments.count > 3 { - switch arguments[2] { - case "-p", "--port": - guard let portNumber = Int(arguments[3]) else { - break - } - return portNumber - default: - return 8000 // default portNumber - } + guard let index = arguments.firstIndex(of: "-p") ?? arguments.firstIndex(of: "--port") else { + return Self.defaultPortNumber } - return 8000 // default portNumber + + guard arguments.count > index + 1 else { + return Self.defaultPortNumber + } + + guard let portNumber = Int(arguments[index + 1]) else { + return Self.defaultPortNumber + } + + return portNumber + } + + private func extractShouldWatch(from arguments: [String]) -> Bool { + arguments.contains("-w") || arguments.contains("--watch") } private func resolveProjectKind(from arguments: [String]) -> ProjectKind { diff --git a/Sources/PublishCLICore/WebsiteRunner.swift b/Sources/PublishCLICore/WebsiteRunner.swift index 326a235c..97aad36a 100644 --- a/Sources/PublishCLICore/WebsiteRunner.swift +++ b/Sources/PublishCLICore/WebsiteRunner.swift @@ -1,20 +1,104 @@ /** -* Publish -* Copyright (c) John Sundell 2019 -* MIT license, see LICENSE file for details -*/ + * Publish + * Copyright (c) John Sundell 2019 + * MIT license, see LICENSE file for details + */ import Foundation import Files import ShellOut +import FileWatcher internal struct WebsiteRunner { + static let normalTerminationStatus = 15 + static let debounceInterval: TimeInterval = 3 + static let runLoopInterval: TimeInterval = 0.1 let folder: Folder - var portNumber: Int + let portNumber: Int + let shouldWatch: Bool func run() throws { + var lastModified: Date? + var watcher: FileWatcher? + let serverProcess: Process = try generateAndRun() + + if shouldWatch { + watcher = try startWatcher { + if lastModified == nil { + let file = try? File(path: $0) + print("Change detected at \(file?.name ?? "Unknown"), scheduling regeneration") + } + lastModified = Date() + } + } + + let interruptHandler = registerInterruptHandler { + watcher?.stop() + serverProcess.terminate() + exit(0) + } + + interruptHandler.resume() + + while true { + defer { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopInterval)) + } + + guard let date = lastModified, date.timeIntervalSinceNow < -Self.debounceInterval else { + continue + } + + lastModified = nil + + print("Regenerating...") + let generator = WebsiteGenerator(folder: folder) + do { + try generator.generate() + } catch { + outputErrorMessage("Regeneration failed") + } + } + } +} + +private extension WebsiteRunner { + var foldersToWatch: [Folder] { + get throws { + try ["Sources", "Resources", "Content"].map(folder.subfolder(named:)) + } + } + + func startWatcher(_ didChange: @escaping (String) -> Void) throws -> FileWatcher { + let filePaths = try foldersToWatch.map(\.path) + let watcher = FileWatcher(filePaths) + + watcher.callback = { event in + if event.isFileChanged || event.isDirectoryChanged { + didChange(event.path) + } + } + + watcher.start() + return watcher + } + + func registerInterruptHandler(_ handler: @escaping () -> Void) -> DispatchSourceSignal { + let interruptHandler = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) + + signal(SIGINT, SIG_IGN) + + interruptHandler.setEventHandler(handler: handler) + return interruptHandler + } + + func generate() throws { let generator = WebsiteGenerator(folder: folder) try generator.generate() + } + + func generateAndRun() throws -> Process { + try generate() let outputFolder = try resolveOutputFolder() @@ -24,7 +108,7 @@ internal struct WebsiteRunner { print(""" šŸŒ Starting web server at http://localhost:\(portNumber) - Press ENTER to stop the server and exit + Press CTRL+C to stop the server and exit """) serverQueue.async { @@ -44,12 +128,9 @@ internal struct WebsiteRunner { exit(1) } - _ = readLine() - serverProcess.terminate() + return serverProcess } -} -private extension WebsiteRunner { func resolveOutputFolder() throws -> Folder { do { return try folder.subfolder(named: "Output") } catch { throw CLIError.outputFolderNotFound } @@ -70,6 +151,20 @@ private extension WebsiteRunner { """ } - fputs("\nāŒ Failed to start local web server:\n\(message)\n", stderr) + outputErrorMessage("Failed to start local web server:\n\(message)") + } + + func outputErrorMessage(_ message: String) { + fputs("\nāŒ \(message)\n", stderr) + } +} + +private extension FileWatcherEvent { + var isFileChanged: Bool { + fileRenamed || fileRemoved || fileCreated || fileModified + } + + var isDirectoryChanged: Bool { + dirRenamed || dirRemoved || dirCreated || dirModified } } From 6f22274f2c0db53b0216cfc232469689b8887116 Mon Sep 17 00:00:00 2001 From: Noah McCann Date: Mon, 19 Sep 2022 08:13:49 -0400 Subject: [PATCH 2/4] Refactored to use AsyncStream to publish and debounce changes --- Sources/PublishCLICore/WebsiteRunner.swift | 95 ++++++++++++---------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/Sources/PublishCLICore/WebsiteRunner.swift b/Sources/PublishCLICore/WebsiteRunner.swift index 97aad36a..77aec862 100644 --- a/Sources/PublishCLICore/WebsiteRunner.swift +++ b/Sources/PublishCLICore/WebsiteRunner.swift @@ -11,29 +11,41 @@ import FileWatcher internal struct WebsiteRunner { static let normalTerminationStatus = 15 - static let debounceInterval: TimeInterval = 3 + static let debounceDuration = 3 * NSEC_PER_SEC static let runLoopInterval: TimeInterval = 0.1 + static let exitMessage = "Press CTRL+C to stop the server and exit" let folder: Folder let portNumber: Int let shouldWatch: Bool + var foldersToWatch: [Folder] { + get throws { + try ["Sources", "Resources", "Content"].map(folder.subfolder(named:)) + } + } + func run() throws { - var lastModified: Date? - var watcher: FileWatcher? let serverProcess: Process = try generateAndRun() + var watchTask: Task? + if shouldWatch { - watcher = try startWatcher { - if lastModified == nil { - let file = try? File(path: $0) - print("Change detected at \(file?.name ?? "Unknown"), scheduling regeneration") + watchTask = Task.detached { + for try await _ in changes(on: try foldersToWatch, debouncedBy: Self.debounceDuration) { + print("Changes detected, regenerating...") + let generator = WebsiteGenerator(folder: folder) + do { + try generator.generate() + print(Self.exitMessage) + } catch { + outputErrorMessage("Regeneration failed") + } } - lastModified = Date() } } let interruptHandler = registerInterruptHandler { - watcher?.stop() + watchTask?.cancel() serverProcess.terminate() exit(0) } @@ -41,48 +53,49 @@ internal struct WebsiteRunner { interruptHandler.resume() while true { - defer { - RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopInterval)) - } - - guard let date = lastModified, date.timeIntervalSinceNow < -Self.debounceInterval else { - continue - } - - lastModified = nil - - print("Regenerating...") - let generator = WebsiteGenerator(folder: folder) - do { - try generator.generate() - } catch { - outputErrorMessage("Regeneration failed") - } + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopInterval)) } } } private extension WebsiteRunner { - var foldersToWatch: [Folder] { - get throws { - try ["Sources", "Resources", "Content"].map(folder.subfolder(named:)) - } - } + func changes(on folders: [Folder], debouncedBy nanoseconds: UInt64?) -> AsyncThrowingStream { + .init { continuation in + let watcher = FileWatcher(folders.map(\.path)) - func startWatcher(_ didChange: @escaping (String) -> Void) throws -> FileWatcher { - let filePaths = try foldersToWatch.map(\.path) - let watcher = FileWatcher(filePaths) + var deferredTask: Task? + + watcher.callback = { event in + guard event.isFileChanged || event.isDirectoryChanged else { + return + } + + guard let nanoseconds = nanoseconds else { + continuation.yield(event.path) + return + } - watcher.callback = { event in - if event.isFileChanged || event.isDirectoryChanged { - didChange(event.path) + deferredTask?.cancel() + + deferredTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + continuation.yield(event.path) + } catch where !(error is CancellationError) { + continuation.finish() + } + } } - } - watcher.start() - return watcher + watcher.start() + + continuation.onTermination = { _ in + watcher.stop() + } + } } + func registerInterruptHandler(_ handler: @escaping () -> Void) -> DispatchSourceSignal { let interruptHandler = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) @@ -108,7 +121,7 @@ private extension WebsiteRunner { print(""" šŸŒ Starting web server at http://localhost:\(portNumber) - Press CTRL+C to stop the server and exit + \(Self.exitMessage) """) serverQueue.async { From 2fd77fb306f83e17c2219b8ac4215d37470a9b51 Mon Sep 17 00:00:00 2001 From: Noah McCann Date: Tue, 20 Sep 2022 07:05:02 -0400 Subject: [PATCH 3/4] Restored Linux Compatibility FileWatcher dependency is now conditionally included based on the supported platform. The --watch parameter is essentially ignored on unsupported parameters. --- Package.swift | 2 +- Sources/PublishCLICore/WebsiteRunner.swift | 117 +++++++++++---------- 2 files changed, 64 insertions(+), 55 deletions(-) diff --git a/Package.swift b/Package.swift index 443ee4a9..e6df55e1 100644 --- a/Package.swift +++ b/Package.swift @@ -72,7 +72,7 @@ let package = Package( ), .target( name: "PublishCLICore", - dependencies: ["Publish", "FileWatcher"] + dependencies: ["Publish", .byName(name: "FileWatcher", condition: .when(platforms: [.macOS]))] ), .testTarget( name: "PublishTests", diff --git a/Sources/PublishCLICore/WebsiteRunner.swift b/Sources/PublishCLICore/WebsiteRunner.swift index 77aec862..533f0168 100644 --- a/Sources/PublishCLICore/WebsiteRunner.swift +++ b/Sources/PublishCLICore/WebsiteRunner.swift @@ -7,7 +7,9 @@ import Foundation import Files import ShellOut +#if canImport(FileWatcher) import FileWatcher +#endif internal struct WebsiteRunner { static let normalTerminationStatus = 15 @@ -26,23 +28,7 @@ internal struct WebsiteRunner { func run() throws { let serverProcess: Process = try generateAndRun() - - var watchTask: Task? - - if shouldWatch { - watchTask = Task.detached { - for try await _ in changes(on: try foldersToWatch, debouncedBy: Self.debounceDuration) { - print("Changes detected, regenerating...") - let generator = WebsiteGenerator(folder: folder) - do { - try generator.generate() - print(Self.exitMessage) - } catch { - outputErrorMessage("Regeneration failed") - } - } - } - } + let watchTask = shouldWatch ? watch() : nil let interruptHandler = registerInterruptHandler { watchTask?.cancel() @@ -59,43 +45,6 @@ internal struct WebsiteRunner { } private extension WebsiteRunner { - func changes(on folders: [Folder], debouncedBy nanoseconds: UInt64?) -> AsyncThrowingStream { - .init { continuation in - let watcher = FileWatcher(folders.map(\.path)) - - var deferredTask: Task? - - watcher.callback = { event in - guard event.isFileChanged || event.isDirectoryChanged else { - return - } - - guard let nanoseconds = nanoseconds else { - continuation.yield(event.path) - return - } - - deferredTask?.cancel() - - deferredTask = Task { - do { - try await Task.sleep(nanoseconds: nanoseconds) - continuation.yield(event.path) - } catch where !(error is CancellationError) { - continuation.finish() - } - } - } - - watcher.start() - - continuation.onTermination = { _ in - watcher.stop() - } - } - } - - func registerInterruptHandler(_ handler: @escaping () -> Void) -> DispatchSourceSignal { let interruptHandler = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) @@ -105,6 +54,26 @@ private extension WebsiteRunner { return interruptHandler } + func watch() -> Task? { +#if canImport(FileWatcher) + return Task.detached { + for try await _ in FileWatcher.changes(on: try foldersToWatch, debouncedBy: Self.debounceDuration) { + print("Changes detected, regenerating...") + let generator = WebsiteGenerator(folder: folder) + do { + try generator.generate() + print(Self.exitMessage) + } catch { + outputErrorMessage("Regeneration failed") + } + } + } +#else + print("File watching not available") + return nil +#endif + } + func generate() throws { let generator = WebsiteGenerator(folder: folder) try generator.generate() @@ -172,6 +141,45 @@ private extension WebsiteRunner { } } +#if canImport(FileWatcher) +private extension FileWatcher { + static func changes(on folders: [Folder], debouncedBy nanoseconds: UInt64?) -> AsyncThrowingStream { + .init { continuation in + let watcher = FileWatcher(folders.map(\.path)) + + var deferredTask: Task? + + watcher.callback = { event in + guard event.isFileChanged || event.isDirectoryChanged else { + return + } + + guard let nanoseconds = nanoseconds else { + continuation.yield(event.path) + return + } + + deferredTask?.cancel() + + deferredTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + continuation.yield(event.path) + } catch where !(error is CancellationError) { + continuation.finish() + } + } + } + + watcher.start() + + continuation.onTermination = { _ in + watcher.stop() + } + } + } +} + private extension FileWatcherEvent { var isFileChanged: Bool { fileRenamed || fileRemoved || fileCreated || fileModified @@ -181,3 +189,4 @@ private extension FileWatcherEvent { dirRenamed || dirRemoved || dirCreated || dirModified } } +#endif From 7f7e05a861bf2d780d3bec1cfd9c78f12957a772 Mon Sep 17 00:00:00 2001 From: Noah McCann Date: Tue, 20 Sep 2022 07:26:50 -0400 Subject: [PATCH 4/4] Used a platform agnostic copy of NSEC_PER_SEC NSEC_PER_SEC is not available on Linux. Explicitly importing the Dispatch library (in which it is defined) does not work. --- Sources/PublishCLICore/WebsiteRunner.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/PublishCLICore/WebsiteRunner.swift b/Sources/PublishCLICore/WebsiteRunner.swift index 533f0168..6a0563ec 100644 --- a/Sources/PublishCLICore/WebsiteRunner.swift +++ b/Sources/PublishCLICore/WebsiteRunner.swift @@ -7,13 +7,15 @@ import Foundation import Files import ShellOut + #if canImport(FileWatcher) import FileWatcher #endif internal struct WebsiteRunner { + static let nanosecondsPerSecond: UInt64 = 1_000_000_000 static let normalTerminationStatus = 15 - static let debounceDuration = 3 * NSEC_PER_SEC + static let debounceDuration = 3 * nanosecondsPerSecond static let runLoopInterval: TimeInterval = 0.1 static let exitMessage = "Press CTRL+C to stop the server and exit" let folder: Folder