Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nmccann/watch files #138

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -66,7 +72,7 @@ let package = Package(
),
.target(
name: "PublishCLICore",
dependencies: ["Publish"]
dependencies: ["Publish", .byName(name: "FileWatcher", condition: .when(platforms: [.macOS]))]
),
.testTarget(
name: "PublishTests",
Expand Down
32 changes: 20 additions & 12 deletions Sources/PublishCLICore/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
141 changes: 130 additions & 11 deletions Sources/PublishCLICore/WebsiteRunner.swift
Original file line number Diff line number Diff line change
@@ -1,20 +1,88 @@
/**
* 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

#if canImport(FileWatcher)
import FileWatcher
#endif

internal struct WebsiteRunner {
static let nanosecondsPerSecond: UInt64 = 1_000_000_000
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Workaround for NSEC_PER_SEC not being available on Linux which according to this is a bug. Alternatively, I could have wrapped the NSEC_PER_SEC usage in a #if canImport(FileWatcher) condition, since it's only used alongside FileWatcher.

static let normalTerminationStatus = 15
static let debounceDuration = 3 * nanosecondsPerSecond
static let runLoopInterval: TimeInterval = 0.1
static let exitMessage = "Press CTRL+C to stop the server and exit"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was unable to get the folder watching to work while maintaining the Press ENTER to stop the server and exit behaviour - I replaced readLine() with a non-blocking implementation (checking standard input on each iteration of the while loop), but that interfered with the watching functionality for an unknown reason.

let folder: Folder
var portNumber: Int
let portNumber: Int
let shouldWatch: Bool

var foldersToWatch: [Folder] {
get throws {
try ["Sources", "Resources", "Content"].map(folder.subfolder(named:))
}
}

func run() throws {
let serverProcess: Process = try generateAndRun()
let watchTask = shouldWatch ? watch() : nil

let interruptHandler = registerInterruptHandler {
watchTask?.cancel()
serverProcess.terminate()
exit(0)
}

interruptHandler.resume()

while true {
RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopInterval))
}
}
}

private extension WebsiteRunner {
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 watch() -> Task<Void, Error>? {
#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()
}

func generateAndRun() throws -> Process {
try generate()

let outputFolder = try resolveOutputFolder()

Expand All @@ -24,7 +92,7 @@ internal struct WebsiteRunner {
print("""
🌍 Starting web server at http://localhost:\(portNumber)

Press ENTER to stop the server and exit
\(Self.exitMessage)
""")

serverQueue.async {
Expand All @@ -44,12 +112,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 }
Expand All @@ -70,6 +135,60 @@ 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)
}
}

#if canImport(FileWatcher)
private extension FileWatcher {
static func changes(on folders: [Folder], debouncedBy nanoseconds: UInt64?) -> AsyncThrowingStream<String, Error> {
.init { continuation in
let watcher = FileWatcher(folders.map(\.path))

var deferredTask: Task<Void, Error>?

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
}

var isDirectoryChanged: Bool {
dirRenamed || dirRemoved || dirCreated || dirModified
}
}
#endif