Reacting to File Changes
Reacting to file changes is a common technique in many applications. If you build for iOS or macOS with SwiftUI, you enjoy the benefits of SwiftUI previews to shorten the feedback loop and build faster. This happens because Xcode constantly watches the file being edited (probably even before it is saved!). The concept of hot reloading is very popular in web development as well - thanks to some executable in the toolchain that observes the file system and rebuilds whatever is needed.
In this post, we’ll show how you can build your own tool that monitors a specific file, using the FileMonitor package, by Kris Simon. This package does the heavy lifting of integrating with the file system, and providing a more modern and Swift-like API. Another advantage of this package, is that it is compatible with Linux as well, so you don’t have to worry about manually differentiating between the file system platforms APIs.
Setting Up the Sample Project
The goal of the sample project is to print a message whenever a given file is changed. To achieve this, we’ll start with a tool using the Swift Argument Parser to handle the command line arguments. In the directory of your choice, you can run the following command to create a new Swift executable that depends on the argument parser:
swift package init --type executable
After that, you can add the FileMonitor package as a dependency of the package. To help manipulating file paths, we’ll also add the PathKit package. In the Package.swift file, add the following lines:
let package = Package(
// existing code...
dependencies: [
// argument parser...
.package(url: "https://github.com/aus-der-Technik/FileMonitor.git", from: "1.2.0"),
.package(url: "https://github.com/kylef/PathKit.git", from: "1.0.1")
],
The FileMonitor package requires macOS 13 or later, so make sure to add it to your Package.swift file as well. We’re calling it swatcher, short for swift watcher:
let package = Package(
name: "swatcher",
platforms: [
.macOS(.v13),
],
// ...
)
Finally, add the dependencies to the executable target as well:
let package = Package(
// ...
targets: [
.executableTarget(
name: "swatcher",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "FileMonitor", package: "FileMonitor"),
.product(name: "PathKit", package: "PathKit")
]
),
]
)
Configuring the Path Argument
By default, the starting point of an argument parser tool is a struct that conforms to the ParsableCommand
protocol, with a run()
method. The program will accept a path argument - we’ll choose an @Option
instead, to make it a named argument. Don’t forget to import PathKit:
import PathKit
@main
struct Swatcher: ParsableCommand {
@Option(help: "The path to the file to watch")
var path: Path
mutating func run() throws {
...
}
}
To make PathKit play nicely with the argument parser, conforming Path
to ExpressibleByArgument
is necessary:
extension Path: @retroactive ExpressibleByArgument {
public init?(argument: String) {
self.init(argument)
}
}
Enforcing a File Path
The FileMonitor
struct is the starting point of the package. It requires a path to monitor: it must be an existing directory, not a file. However, in this case, we want the tool to monitor a specific file instead. Use the validate()
method from the argument parser inside the main struct to throw an error if the path is a directory, or if it doesn’t exist:
func validate() throws {
guard path.isFile else {
throw ValidationError("(path) is not a file")
}
guard path.exists else {
throw ValidationError("(path) does not exist")
}
}
Integrating FileMonitor
Begin by importing the package, and creating a new instance of FileMonitor
with the path provided by the user, with a slight modification. We are interested in the file’s parent directory, not the file itself. As we’ve already validated the path, we can safely use the parent()
method from PathKit to get the parent directory:
import FileMonitor
// Inside the run() method:
let monitor = try FileMonitor(directory: path.parent().url)
Delegate vs. AsyncStream
FileMonitor provides two ways to react to file changes. The first one is a well known pattern if you come from Objective-C or Swift: delegates. You can pass a type that confroms to WatcherDelegate
when initializing your FileMonitor
instance.
The second option is a more modern approach, using Swift’s AsyncStream
. After initializing the FileMonitor
instance, you can use the for await
loop to iterate over a stream of events. This is the approach we’ll use in this post.
To begin, change the type of the struct from ParsableCommand
to AsyncParsableCommand
, and add the async
keyword to the run()
method. Then, inside the run()
method, we can use the for await
loop to iterate over the stream of events:
@main
struct Swatcher: AsyncParsableCommand { // not ParsableCommand anymore
// ...
mutating func run() async throws {
let monitor = try FileMonitor(directory: path.parent().url)
for await event in monitor.stream {
// ...
}
}
}
Filtering Events
FileMonitor
will emit an event for each change to a file inside the directory. In this sample project, however, we are only interested in changes to the file passed by the user.
The event type is an enum with an associated URL
value in all of its cases: changed, added, or deleted. To easily get the event from any one of these cases, one simple extension property is enough:
extension FileChange {
var file: URL {
switch self {
case let .added(file),
let .changed(file),
let .deleted(file):
file.standardizedFileURL
}
}
}
Then, we can filter the event’s file path by comparing it to the file path passed in by the user. PathKit uses URLs prefixed with the file://
scheme, so we have to compare the path components of the two URLs, instead of the URLs themselves:
for await event in monitor.stream {
guard event.file.pathComponents == path.url.pathComponents else {
continue
}
switch event {
case let .changed(file):
print("Changed: (file)")
case let .added(file):
print("Added (file)")
case let .deleted(file):
print("Deleted file: (file)")
}
}
And this is how it looks like in action, watching changes to the Package.swift file of the sample project itself:
Explore Further
This article just scratches the surface of what you can do with FileMonitor! Although we focused on a specific file, you can use the same approach to monitor changes to a directory, or to multiple files.A very common use case is to run another shell command when a file changes. You can add, for example, another argument to the tool specifying a command or a script to run, and use Command or Subprocess to execute it.
Another thing you might want to do, is to debounce the file change events, so multiple sequential events do not trigger the command multiple times. You can use the Async Algorithms package to debounce on AsyncStream
events.
Thanks to Kris Simon for contributing to the community with this package! If you have any questions or suggestions, feel free to reach out on Mastodon or X.
See you at the next post. Have a good one!