Reacting to File Changes

Published: July 25, 2025
Written by:
Natan Rolnik
Natan Rolnik

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!
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024-2025