3 Commits

Author SHA1 Message Date
T. R. Bernstein
a3652003f9 Add GitHub workflow file to generate docs
Some checks failed
Docs / docs (push) Has been cancelled
Docs / deploy (push) Has been cancelled
2026-03-12 16:07:50 +01:00
T. R. Bernstein
80bde65a7c Add SwiftPackageIndex configuration file 2026-03-12 16:07:30 +01:00
T. R. Bernstein
39fe5c9237 Add open source documentation files 2026-03-12 15:31:10 +01:00
21 changed files with 116 additions and 561 deletions

View File

@@ -17,7 +17,7 @@ concurrency:
jobs: jobs:
docs: docs:
runs-on: ubuntu-latest runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -26,25 +26,47 @@ jobs:
- name: Set up Swift - name: Set up Swift
uses: swift-actions/setup-swift@v3 uses: swift-actions/setup-swift@v3
with: with:
skip-verify-signature: true swift-version: '6.1'
- name: Generate Docs - name: Inject DocC Plugin Dependency
run: | run: |
swift package add-dependency --from 1.4.0 "https://github.com/apple/swift-docc-plugin.git" if ! grep -q 'swift-docc-plugin' Package.swift; then
for target in Inotify InotifyTaskCLI; do awk 'BEGIN{done=0} /dependencies: \[/ && done==0 {print; print "\t\t.package(url: \"https://github.com/apple/swift-docc-plugin.git\", from: \"1.4.0\"),"; done=1; next}1' Package.swift > Package.swift.tmp
lower="${target,,}" mv Package.swift.tmp Package.swift
mkdir -p "./public/$lower" fi
swift package --allow-writing-to-directory "./public/$lower" \ - name: Generate Inotify Docs
generate-documentation --disable-indexing --transform-for-static-hosting \ uses: fwcd/swift-docc-action@v1
--target "$target" \ with:
--hosting-base-path "swift-inotify/$lower" \ target: Inotify
--output-path "./public/$lower" output: ./public/inotify
done transform-for-static-hosting: 'true'
- name: Copy Index Page disable-indexing: 'true'
hosting-base-path: swift-inotify/inotify
- name: Generate TaskCLI Docs
uses: fwcd/swift-docc-action@v1
with:
target: TaskCLI
output: ./public/taskcli
transform-for-static-hosting: 'true'
disable-indexing: 'true'
hosting-base-path: swift-inotify/taskcli
- name: Create Index Page
run: | run: |
cp ./.github/workflows/index.tpl.html public/index.html cat > ./public/index.html <<'HTML'
sed -i -e 's/{{project.name}}/Swift Inotify/g' public/index.html <!DOCTYPE html>
sed -i -e 's/{{project.tagline}}/🗂️ Monitor filesystem events on Linux using modern Swift concurrency/g' public/index.html <html lang="en">
sed -i -e 's|{{project.links}}|<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li><li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>|g' public/index.html <head>
<meta charset="utf-8">
<title>Swift Inotify Documentation</title>
</head>
<body>
<h1>Swift Inotify Documentation</h1>
<ul>
<li><a href="inotify/documentation/inotify/">Inotify: The actual library.</a></li>
<li><a href="taskcli/documentation/taskcli/">TaskCLI: The project build command.</a></li>
</ul>
</body>
</html>
HTML
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v4
with: with:

View File

@@ -1,72 +0,0 @@
<!doctype html>
<html>
<head>
<title>Astzweig | Swift Inotify</title>
<meta name="description" content="Monitor filesystem events on Linux using modern Swift concurrency.">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="utf-8">
<style>
:root {
color-scheme: light dark;
}
html, body {
font-family: system-ui, sans-serif;
background-color: ligh-dark(#ffffff, #000000);
color: ligh-dark(#000000, #ffffff);
}
main {
max-width: min(40em, 100vw);
min-height: 80vh;
margin: 1rem auto;
display: flex;
flex-direction: column;
align-items: center;
}
svg {
max-width: 12em;
margin: 1rem;
}
footer {
max-width: min(60em, 100vw);
margin: 1rem auto;
font-size: .7rem;
font-family: ui-monospace, monospace;
h1 {
font-size: 1rem;
}
}
</style>
</head>
<body>
<main>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 531 118"><g fill-rule="evenodd"><style>g { fill: #000; } @media (prefers-color-scheme: dark) { g { fill: #fff; } }</style><g fill-rule="nonzero"><path d="M147.81 96.877q-6.907 0-13.474-1.579t-10.55-4.009l5.703-12.975q3.777 2.3 8.88 3.662t10.034 1.362q4.752 0 6.621-1.01t1.87-2.77q0-1.541-1.732-2.308t-4.578-1.176a920 920 0 0 0-6.258-.874q-3.411-.465-6.859-1.35t-6.293-2.623-4.577-4.71-1.731-7.64q0-5.14 3.057-9.184t8.95-6.424 14.379-2.38q5.783 0 11.612 1.147 5.83 1.147 9.881 3.416l-5.703 12.976q-4.046-2.3-8.033-3.15-3.99-.85-7.58-.85-4.799 0-6.782 1.11-1.984 1.11-1.984 2.7 0 1.602 1.73 2.42 1.732.819 4.578 1.258 2.846.438 6.259.904 3.413.465 6.824 1.385 3.411.918 6.258 2.641t4.578 4.68 1.73 7.594q0 4.932-3.037 8.98t-9.012 6.413-14.76 2.364m59.104 0q-10.4 0-16.173-5.125-5.772-5.124-5.772-15.555V29.652h19v46.314q0 2.966 1.567 4.614t4.126 1.648q3.334 0 5.752-1.748l4.748 13.28q-2.476 1.591-5.97 2.354-3.495.763-7.278.763m-29.88-38.87V43.835h39.252v14.174zM224.539 96V84.831l30.465-35.037 3.203 6.144h-32.931V41.766h48.283v11.168l-30.466 35.038-3.265-6.144h34.739V96zM294.751 96l-19.217-54.234h17.988l15.268 45.362h-8.625l15.969-45.362h16.128l15.363 45.362h-8.417l15.768-45.362h16.697L352.425 96h-18.432l-12.957-37.528h5.543L313.184 96zM404.332 96.877q-9.597 0-16.768-3.639-7.17-3.638-11.115-9.995t-3.946-14.395q0-8.175 3.88-14.498t10.65-9.891q6.77-3.57 15.261-3.57 8.007 0 14.593 3.278t10.504 9.547q3.917 6.27 3.917 15.258 0 1.046-.084 2.34a87 87 0 0 1-.185 2.37H388.1v-9.968h32.863l-7.228 2.806q.031-3.697-1.414-6.397t-3.957-4.213-5.932-1.513-5.983 1.513-3.957 4.23-1.395 6.474v2.903q0 3.966 1.68 6.885t4.776 4.448 7.355 1.53q4.049 0 6.902-1.156t5.584-3.496l10 10.41q-3.876 4.268-9.587 6.503-5.71 2.236-13.475 2.236M439.997 96V41.8h19V96zm9.5-60.2q-5.199 0-8.4-2.9-3.2-2.9-3.2-7.2t3.2-7.2q3.201-2.9 8.4-2.9 5.2 0 8.4 2.75t3.2 7.05q0 4.5-3.15 7.45t-8.45 2.95m48.242 80.477q-7.852 0-14.92-1.779-7.07-1.778-11.985-5.301l6.888-13.26q3.324 2.655 8.413 4.215t9.893 1.559q7.651 0 11.04-3.338t3.388-9.77v-6.369l1-15.23-.087-15.263v-9.975h18.087v44.328q0 15.403-8.304 22.793t-23.413 7.39m-3.376-23.06q-7.18 0-13.226-3.25t-9.7-9.119-3.654-13.845q0-7.975 3.654-13.844 3.654-5.87 9.7-9.07t13.226-3.2q6.976 0 11.836 2.77t7.433 8.572 2.573 14.772q0 8.97-2.573 14.773-2.573 5.804-7.433 8.622-4.86 2.82-11.836 2.82m4.51-15.117q3.444 0 6.103-1.395t4.17-3.907q1.51-2.512 1.51-5.795 0-3.351-1.51-5.829-1.512-2.477-4.17-3.822-2.66-1.344-6.102-1.344-3.346 0-6.054 1.344-2.71 1.345-4.253 3.822-1.545 2.478-1.545 5.83 0 3.282 1.545 5.794 1.544 2.513 4.253 3.907 2.707 1.395 6.054 1.395"/></g><path d="M102.666 13.772h-.014v20.424q2.888.297 5.777.823l.886.167a3.66 3.66 0 0 1 2.96 3.592v53.253a3.66 3.66 0 0 1-2.96 3.595A74 74 0 0 1 95.137 97a75 75 0 0 1-14.267-1.38 3.66 3.66 0 0 1-2.963-3.59V73.025L48.733 58.685l-16.18 12.641a3.66 3.66 0 0 1-4.743-.198 89 89 0 0 1-6.423-6.61V76.41a.84.84 0 0 1-.839.84H9.625v-6.275h5.462V62.61H6.684v5.437H.383V57.175c0-.463.375-.839.838-.839h13.816a90 90 0 0 1-4.592-7.441 3.66 3.66 0 0 1 .95-4.653L55.14 10.064a3.66 3.66 0 0 1 4.743.206 92 92 0 0 1 4.114 4.076L80.529 2.338l-.011-.015c7.448-5.467 22.318-.83 22.148 11.45m-20.448 8.263-7.713 5.812a90 90 0 0 1 2.747 4.647 3.66 3.66 0 0 1-.951 4.654L63.132 47.436l14.773 7.362V38.785a3.66 3.66 0 0 1 2.969-3.596 78 78 0 0 1 4.593-.744v-9.294h-.008v-.387c-.012-1.379-.215-4.408-3.085-2.818zm.946-9.41-12.606 9.497a88 88 0 0 1 2.437 3.42l7.589-5.718-.005-.008.067-.065c1.184-1.096 7.144-4.065 7.558 4.454l.013.324q.01.303.01.622h-.01v8.984q2.065-.194 4.124-.273V19.77h-.036c-.113-7.551-3.735-10.214-8.96-7.25zm-1.004-8.07-16.292 11.83a88 88 0 0 1 3.007 3.563l12.505-9.423-.022-.028q.314-.25.662-.48l.248-.16c4.557-2.813 12.944-2.85 12.838 9.914h-.016l.001 14.038h.045q2.384 0 4.767.155V13.772h-.013c.142-7.384-9.941-14.798-17.73-9.216"/></g></svg>
<hgroup>
<h1>{{project.name}}</h1>
<p>{{project.tagline}}</p>
</hgroup>
<h2>Documentations</h2>
<ul>
{{project.links}}
</ul>
</main>
<footer vocab="https://schema.org/" typeof="Organization">
© <span property="legalName">Astzweig GmbH &amp; Co. KG</span>,
based in
<span property="location">Freiburg i. Br.</span>,
entered in the commercial register of the Freiburg Local Court under number HRA 703853 and VAT ID number:
<span property="vatID">DE297037836</span>.
The general partner is
<span property="parentOrganization" typeof="Organization">
<span property="legalName">Astzweig Verwaltungsgesellschaft mbH</span>
based in <span property="location">Freiburg i.Br.</span>,
entered in the commercial register of the same court under number HRB 718037 and with a capital of €25,000.00, represented by the managing director
<span property="employee" type="Person"><span property="givenName">Thomas R.</span> <span property="familyName">Bernstein</span>.</span>
</span>
</footer>
</body>
</html>

1
.gitignore vendored
View File

@@ -1,2 +1 @@
.build .build
public

View File

@@ -2,4 +2,3 @@ version: 1
builder: builder:
configs: configs:
- documentation_targets: [Inotify, TaskCLI] - documentation_targets: [Inotify, TaskCLI]
platform: Linux

View File

@@ -1,5 +1,5 @@
{ {
"originHash" : "17ce26ba5c862ca674cd3ceeb43a9fe8a5c5251c5561de65e632a06d79916342", "originHash" : "fd1e824e418c767633bb79b055a4e84d9c86165746bc881d5d27457ad34b0c20",
"pins" : [ "pins" : [
{ {
"identity" : "noora", "identity" : "noora",
@@ -33,8 +33,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser", "location" : "https://github.com/apple/swift-argument-parser",
"state" : { "state" : {
"revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.1" "version" : "1.7.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms",
"state" : {
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
"version" : "1.1.3"
} }
}, },
{ {

View File

@@ -8,10 +8,15 @@ let package = Package(
.library( .library(
name: "Inotify", name: "Inotify",
targets: ["Inotify"] targets: ["Inotify"]
),
.executable(
name: "task",
targets: ["TaskCLI"]
) )
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.1"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.7.0"),
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.1.3"),
.package(url: "https://github.com/apple/swift-log", from: "1.10.1"), .package(url: "https://github.com/apple/swift-log", from: "1.10.1"),
.package(url: "https://github.com/apple/swift-nio", from: "2.95.0"), .package(url: "https://github.com/apple/swift-nio", from: "2.95.0"),
.package(url: "https://github.com/apple/swift-system", from: "1.6.4"), .package(url: "https://github.com/apple/swift-system", from: "1.6.4"),
@@ -37,15 +42,14 @@ let package = Package(
], ],
), ),
.executableTarget( .executableTarget(
name: "InotifyTaskCLI", name: "TaskCLI",
dependencies: [ dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
.product(name: "Logging", package: "swift-log"), .product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
.product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Noora", package: "Noora") .product(name: "Noora", package: "Noora")
], ]
path: "Sources/TaskCLI"
) )
] ]
) )

View File

@@ -75,24 +75,6 @@ try await inotify.addWatchWithAutomaticSubtreeWatching(
This is the most convenient option when you need full coverage of a growing directory tree. This is the most convenient option when you need full coverage of a growing directory tree.
## Excluding Items
You can tell the `Inotify` actor to ignore certain file or directory names. Excluded names are skipped during recursive directory resolution (so no watch is installed on them) and silently dropped from the event stream:
```swift
let inotify = try Inotify()
// Ignore version-control and build directories
await inotify.exclude(names: ".git", "node_modules", ".build")
try await inotify.addWatchWithAutomaticSubtreeWatching(
forDirectory: "/home/user/project",
mask: [.create, .modify, .delete]
)
```
Use `isExcluded(_:)` to check whether a name is currently on the exclusion list.
## Event Masks ## Event Masks
`InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely. `InotifyEventMask` is an `OptionSet` that mirrors the native inotify flags. You can combine them freely.
@@ -131,30 +113,28 @@ try inotify.removeWatch(wd)
## Build Tool ## Build Tool
The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It automates running tests and generating documentation inside Linux Docker containers, so you can validate everything on the correct platform even when developing on macOS. The package ships with a `task` executable (the `TaskCLI` target) that serves as the project's build tool. It spins up a Docker container running `swift:latest` on Linux and executes the full test suite inside it, so you can validate everything on the correct platform even when developing on macOS.
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
### Tests
```bash ```bash
./task.sh test swift run task test
``` ```
Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation). Use `-v`, `-vv`, or `-vvv` to increase log verbosity. The command runs two passes: first all tests except `InotifyLimitTests`, then only `InotifyLimitTests` (which manipulate system-level inotify limits and need to run in isolation).
Docker must be installed and running on your machine. Docker must be installed and running on your machine.
### Documentation ## Documentation
Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with: Full API documentation is available as DocC catalogs bundled with the package. Generate them locally with:
```bash ```bash
./task.sh generate-docs # Inotify library
swift package generate-documentation --target Inotify
# Task build tool
swift package generate-documentation --target TaskCLI
``` ```
Then open the files in the newly created `public` folder.
Or preview in Xcode by selecting **Product > Build Documentation**. Or preview in Xcode by selecting **Product > Build Documentation**.
## Requirements ## Requirements

View File

@@ -3,35 +3,42 @@ import _NIOFileSystem
public struct DirectoryResolver { public struct DirectoryResolver {
static let fileManager = FileSystem.shared static let fileManager = FileSystem.shared
public static func resolve(_ paths: String..., excluding itemNames: Set<String> = []) async throws -> [FilePath] { public static func resolve(_ paths: String...) async throws -> [FilePath] {
try await Self.resolve(paths, excluding: itemNames) try await Self.resolve(paths)
} }
static func resolve(_ paths: [String], excluding itemNames: Set<String> = []) async throws -> [FilePath] { static func resolve(_ paths: [String]) async throws -> [FilePath] {
var resolved: [FilePath] = [] var resolved: [FilePath] = []
for path in paths { for path in paths {
let path = FilePath(path) let itemPath = FilePath(path)
resolved.append(path) try await Self.ensure(itemPath, is: .directory)
try await withSubdirectories(at: path, recursive: true) { subdirectoryPath in
guard let basename = subdirectoryPath.lastComponent?.description else { return } let allDirectoriesIncludingSelf = try await getAllSubdirectoriesAndSelf(at: itemPath)
guard !itemNames.contains(basename) else { return } resolved.append(contentsOf: allDirectoriesIncludingSelf)
resolved.append(subdirectoryPath)
}
} }
return resolved return resolved
} }
private static func withSubdirectories(at path: FilePath, recursive: Bool = false, body: (FilePath) async throws -> Void) async throws { private static func ensure(_ path: FilePath, is fileType: FileType) async throws {
guard let fileInfo = try await fileManager.info(forFileAt: path) else {
throw DirectoryResolverError.pathNotFound(path)
}
guard fileInfo.type == fileType else {
throw DirectoryResolverError.pathIsNoDirectory(path)
}
}
private static func getAllSubdirectoriesAndSelf(at path: FilePath) async throws -> [FilePath] {
var result: [FilePath] = []
let directoryHandle = try await fileManager.openDirectory(atPath: path) let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() { for try await childContent in directoryHandle.listContents(recursive: true) {
guard childContent.type == .directory else { continue } guard childContent.type == .directory else { continue }
try await body(childContent.path) result.append(childContent.path)
if recursive {
try await withSubdirectories(at: childContent.path, recursive: recursive, body: body)
}
} }
try await directoryHandle.close() try await directoryHandle.close()
return result
} }
} }

View File

@@ -20,8 +20,6 @@ Beyond single-directory watches, the library provides two higher-level methods f
- ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` installs watches on every existing subdirectory at setup time. - ``Inotify/Inotify/addRecursiveWatch(forDirectory:mask:)`` installs watches on every existing subdirectory at setup time.
- ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` does the same **and** automatically watches subdirectories that are created after setup. - ``Inotify/Inotify/addWatchWithAutomaticSubtreeWatching(forDirectory:mask:)`` does the same **and** automatically watches subdirectories that are created after setup.
You can also exclude certain file or directory names so that they are skipped during directory resolution and silently dropped from the event stream. See ``Inotify/Inotify/exclude(names:)`` and <doc:WatchingDirectoryTrees> for details.
All public types conform to `Sendable`, so they can be safely passed across concurrency boundaries. All public types conform to `Sendable`, so they can be safely passed across concurrency boundaries.
## Topics ## Topics
@@ -32,10 +30,6 @@ All public types conform to `Sendable`, so they can be safely passed across conc
- ``InotifyEvent`` - ``InotifyEvent``
- ``InotifyEventMask`` - ``InotifyEventMask``
### Articles
- <doc:WatchingDirectoryTrees>
### Errors ### Errors
- ``InotifyError`` - ``InotifyError``

View File

@@ -33,22 +33,6 @@ let descriptors = try await inotify.addWatchWithAutomaticSubtreeWatching(
Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears. Internally this listens for `CREATE` events carrying the ``InotifyEventMask/isDir`` flag and installs a new watch with the same mask whenever a subdirectory appears.
### Excluding Directories
When watching large trees you often want to skip certain subdirectories entirely — version-control metadata, build artefacts, dependency caches, and so on. Call ``Inotify/Inotify/exclude(names:)`` **before** adding a recursive or automatic-subtree watch:
```swift
let inotify = try Inotify()
await inotify.exclude(names: ".git", "node_modules", ".build")
try await inotify.addWatchWithAutomaticSubtreeWatching(
forDirectory: "/home/user/project",
mask: .allEvents
)
```
Excluded names are matched against the last path component of each directory during resolution and are also filtered from the event stream, so you never receive events for items whose name is on the exclusion list.
### Choosing the Right Method ### Choosing the Right Method
| Method | Covers existing subdirectories | Covers new subdirectories | | Method | Covers existing subdirectories | Covers new subdirectories |

View File

@@ -3,11 +3,10 @@ import CInotify
public actor Inotify { public actor Inotify {
private let fd: CInt private let fd: CInt
private var excludedItemNames: Set<String> = []
private var watches = InotifyWatchManager() private var watches = InotifyWatchManager()
private var eventReader: any DispatchSourceRead private var eventReader: any DispatchSourceRead
private nonisolated let eventStream: AsyncStream<RawInotifyEvent> private var eventStream: AsyncStream<RawInotifyEvent>
public nonisolated var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> { public var events: AsyncCompactMapSequence<AsyncStream<RawInotifyEvent>, InotifyEvent> {
self.eventStream.compactMap(self.transform(_:)) self.eventStream.compactMap(self.transform(_:))
} }
@@ -19,24 +18,6 @@ public actor Inotify {
(self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd) (self.eventReader, self.eventStream) = Self.createEventReader(forFileDescriptor: fd)
} }
public func isExcluded(_ name: String) -> Bool {
self.excludedItemNames.contains(name)
}
public func exclude(name: String) {
self.excludedItemNames.insert(name)
}
public func exclude(names: String...) {
self.exclude(names: names)
}
public func exclude(names: [String]) {
for name in names {
self.excludedItemNames.insert(name)
}
}
@discardableResult @discardableResult
public func addWatch(path: String, mask: InotifyEventMask) throws -> CInt { public func addWatch(path: String, mask: InotifyEventMask) throws -> CInt {
let wd = inotify_add_watch(self.fd, path, mask.rawValue) let wd = inotify_add_watch(self.fd, path, mask.rawValue)
@@ -49,7 +30,7 @@ public actor Inotify {
@discardableResult @discardableResult
public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] { public func addRecursiveWatch(forDirectory path: String, mask: InotifyEventMask) async throws -> [CInt] {
let directoryPaths = try await DirectoryResolver.resolve(path, excluding: self.excludedItemNames) let directoryPaths = try await DirectoryResolver.resolve(path)
var result: [CInt] = [] var result: [CInt] = []
for path in directoryPaths { for path in directoryPaths {
let wd = try self.addWatch(path: path.string, mask: mask) let wd = try self.addWatch(path: path.string, mask: mask)
@@ -78,7 +59,6 @@ public actor Inotify {
private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? { private func transform(_ rawEvent: RawInotifyEvent) async -> InotifyEvent? {
guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil } guard let path = self.watches.path(forId: rawEvent.watchDescriptor) else { return nil }
guard !self.excludedItemNames.contains(rawEvent.name) else { return nil }
let event = InotifyEvent.init(from: rawEvent, inDirectory: path) let event = InotifyEvent.init(from: rawEvent, inDirectory: path)
await self.addWatchInCaseOfAutomaticSubtreeWatching(event) await self.addWatchInCaseOfAutomaticSubtreeWatching(event)
return InotifyEvent.init(from: rawEvent, inDirectory: path) return InotifyEvent.init(from: rawEvent, inDirectory: path)

View File

@@ -4,6 +4,6 @@ import ArgumentParser
struct Command: AsyncParsableCommand { struct Command: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
abstract: "Project tasks of Astzweig's Swift Inotify project.", abstract: "Project tasks of Astzweig's Swift Inotify project.",
subcommands: [TestCommand.self, GenerateDocumentationCommand.self] subcommands: [TestCommand.self]
) )
} }

View File

@@ -1,25 +0,0 @@
import _NIOFileSystem
public struct DoccFinder {
static let fileManager = FileSystem.shared
public static func hasDoccFolder(at path: String) async throws -> Bool {
let itemPath = FilePath(path)
var hasDoccFolder = false
try await withSubdirectories(at: itemPath) { subdirectory in
guard subdirectory.description.hasSuffix(".docc") else { return }
hasDoccFolder = true
}
return hasDoccFolder
}
private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws {
let directoryHandle = try await fileManager.openDirectory(atPath: path)
for try await childContent in directoryHandle.listContents() {
guard childContent.type == .directory else { continue }
try await body(childContent.path)
}
try await directoryHandle.close()
}
}

View File

@@ -1,9 +0,0 @@
struct Docker {
static func getLinuxPlatformStringWithHostArchitecture() -> String {
#if arch(x86_64)
return "linux/amd64"
#else
return "linux/arm64"
#endif
}
}

View File

@@ -1,204 +0,0 @@
import ArgumentParser
import Foundation
import Logging
import Noora
import Subprocess
struct GenerateDocumentationCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "generate-documentation",
abstract: "Generate DocC documentation of all targets inside a Linux container.",
aliases: ["gd"],
)
@OptionGroup var global: GlobalOptions
private static let doccPluginURL = "https://github.com/apple/swift-docc-plugin.git"
private static let doccPluginMinVersion = "1.4.0"
private static let skipItems: Set<String> = [".git", ".build", ".swiftpm", "public"]
// MARK: - Run
func run() async throws {
let noora = Noora()
let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-documentation")
let fileManager = FileManager.default
let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath)
let targets = try await Self.targets(for: projectDirectory)
noora.info("Generating DocC documentation on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(projectDirectory.path(percentEncoded: false))", "targets": "\(targets.joined(separator: ", "))"])
let tempDirectory = try copyProject(from: projectDirectory)
logger.info("Copied project to temporary directory.", metadata: ["path": "\(tempDirectory.path(percentEncoded: false))"])
defer {
try? fileManager.removeItem(at: tempDirectory)
logger.info("Cleaned up temporary directory.")
}
try await injectDoccPluginDependency(in: tempDirectory, logger: logger)
let script = Self.makeRunScript(for: targets)
logger.debug("Container script", metadata: ["script": "\(script)"])
let dockerRunResult = try await Subprocess.run(
.name("docker"),
arguments: [
"run", "--rm",
"-v", "\(tempDirectory.path):/code",
"-v", "swift-inotify-build-cache:/code/.build",
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(),
"-w", "/code",
"swift:latest",
"/bin/bash", "-c", script
],
output: .standardOutput,
error: .standardError
)
if !dockerRunResult.terminationStatus.isSuccess {
noora.error("Documentation generation failed.")
return
}
try copyResults(from: tempDirectory, to: projectDirectory)
try Self.generateIndexHTML(
templateURL: projectDirectory.appending(path: ".github/workflows/index.tpl.html"),
outputURL: projectDirectory.appending(path: "public/index.html")
)
noora.success(
.alert("Documentation generated successfully.",
takeaways: ["Start a local web server with ./public as document root, i.e. with python3 -m http.server to browse the documentation."]
)
)
}
private static func generateIndexHTML(templateURL: URL, outputURL: URL) throws {
var content = try String(contentsOf: templateURL, encoding: .utf8)
let replacements: [(String, String)] = [
("{{project.name}}", "Swift Inotify"),
("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"),
("{{project.links}}", """
<li><a href="inotify/documentation/inotify/">Inotify</a>: The actual library.</li>\
<li><a href="inotifytaskcli/documentation/inotifytaskcli/">TaskCLI</a>: The project build command.</li>
"""),
]
for (placeholder, value) in replacements {
content = content.replacingOccurrences(of: placeholder, with: value)
}
try FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try content.write(to: outputURL, atomically: true, encoding: .utf8)
}
private static func targets(for projectDirectory: URL) async throws -> [String] {
let packages = try await Self.packageTargets()
var packagesWithDoccFolder: [(name: String, path: String)] = []
for package in packages {
guard try await DoccFinder.hasDoccFolder(at: package.path) else { continue }
packagesWithDoccFolder.append(package)
}
return packagesWithDoccFolder.map { $0.name }
}
private static func packageTargets() async throws -> [(name: String, path: String)] {
let packageDescriptionResult = try await Subprocess.run(
.name("swift"),
arguments: ["package", "describe", "--type", "json"],
output: .data(limit: 10_000),
error: .standardError
)
struct PackageDescription: Codable {
let targets: [Target]
}
struct Target: Codable {
let name: String
let path: String
}
if !packageDescriptionResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.unableToReadPackageDescription
}
let package = try JSONDecoder().decode(PackageDescription.self, from: packageDescriptionResult.standardOutput)
return package.targets.map { ($0.name, $0.path) }
}
private static func makeRunScript(for targets: [String]) -> String {
targets.map {
"mkdir -p \"./public/\($0.localizedLowercase)\" && " +
"swift package --allow-writing-to-directory \"\($0.localizedLowercase)\" " +
"generate-documentation --disable-indexing --transform-for-static-hosting " +
"--target \"\($0)\" " +
"--hosting-base-path \"\($0.localizedLowercase)\" " +
"--output-path \"./public/\($0.localizedLowercase)\""
}.joined(separator: " && ")
}
// MARK: - Project Copy
private func copyProject(from source: URL) throws -> URL {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appending(path: "swift-inotify-docs-\(UUID().uuidString)")
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
let contents = try fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
for item in contents {
guard !Self.skipItems.contains(item.lastPathComponent) else { continue }
try fileManager.copyItem(at: item, to: tempDirectory.appending(path: item.lastPathComponent))
}
return tempDirectory
}
private func copyResults(from tempDirectory: URL, to projectDirectory: URL) throws {
let fileManager = FileManager.default
let source = tempDirectory.appending(path: "public")
let destination = projectDirectory.appending(path: "public")
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
try fileManager.removeItem(at: destination)
}
try fileManager.copyItem(at: source, to: destination)
}
// MARK: - Dependency Injection
private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws {
let swiftRunResult = try await Subprocess.run(
.name("swift"),
arguments: [
"package", "--package-path", directory.path(percentEncoded: false),
"add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL
],
output: .standardOutput,
error: .standardError
)
if !swiftRunResult.terminationStatus.isSuccess {
throw GenerateDocumentationError.dependencyInjectionFailed
}
logger.info("Injected swift-docc-plugin dependency.")
}
}
enum GenerateDocumentationError: Error, CustomStringConvertible {
case dependencyInjectionFailed
case unableToReadPackageDescription
var description: String {
switch self {
case .dependencyInjectionFailed:
"Failed to add swift-docc-plugin dependency to Package.swift."
case .unableToReadPackageDescription:
"Failed to read the package description."
}
}
}

View File

@@ -1,19 +1,15 @@
# ``InotifyTaskCLI`` # ``TaskCLI``
The build tool for the Swift Inotify project. The build tool for the Swift Inotify project.
## Overview ## Overview
`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS. `TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running the integration test suite inside a Linux Docker container, so you can validate the inotify-dependent code on the correct platform even when developing on macOS.
Because of a Swift Package Manager Bug in the [package dependency resolution][swiftpm-bug], the executable needs to be run using the `task.sh` shell script.
[swiftpm-bug]: https://github.com/swiftlang/swift-package-manager/issues/8482
### Running the Tests ### Running the Tests
```bash ```bash
./task.sh test swift run task test
``` ```
This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes: This launches a `swift:latest` Docker container with the repository mounted at `/code`, then executes two test passes:
@@ -23,16 +19,6 @@ This launches a `swift:latest` Docker container with the repository mounted at `
The container is started with `--security-opt systempaths=unconfined` so that the limit tests can write to `/proc/sys/fs/inotify/*`. The container is started with `--security-opt systempaths=unconfined` so that the limit tests can write to `/proc/sys/fs/inotify/*`.
### Generating Documentation
```bash
./task.sh generate-documentation
```
This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages.
The working tree is never modified — all changes happen in the temporary copy, which is cleaned up automatically.
### Verbosity ### Verbosity
Pass one or more `-v` flags to increase log output: Pass one or more `-v` flags to increase log output:
@@ -54,12 +40,7 @@ Docker must be installed and running on the host machine. The container uses the
- ``Command`` - ``Command``
- ``TestCommand`` - ``TestCommand``
- ``GenerateDocumentationCommand``
### Configuration ### Configuration
- ``GlobalOptions`` - ``GlobalOptions``
### Errors
- ``GenerateDocumentationError``

View File

@@ -1,7 +1,8 @@
import ArgumentParser import ArgumentParser
import AsyncAlgorithms
import Foundation import Foundation
import Noora
import Subprocess import Subprocess
import Noora
struct TestCommand: AsyncParsableCommand { struct TestCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
@@ -21,21 +22,21 @@ struct TestCommand: AsyncParsableCommand {
noora.info("Running tests on Linux.") noora.info("Running tests on Linux.")
logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"]) logger.debug("Current directory", metadata: ["current-directory": "\(currentDirectory)"])
let dockerRunResult = try await Subprocess.run( async let monitorResult = Subprocess.run(
.name("docker"), .name("docker"),
arguments: [ arguments: ["run", "-v", "\(currentDirectory):/code", "--security-opt", "systempaths=unconfined", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests"],
"run", preferredBufferSize: 10,
"-v", "\(currentDirectory):/code", ) { execution, standardInput, standardOutput, standardError in
"-v", "swift-inotify-build-cache:/code/.build", print("")
"--security-opt", "systempaths=unconfined", let stdout = standardOutput.lines()
"--platform", Docker.getLinuxPlatformStringWithHostArchitecture(), let stderr = standardError.lines()
"-w", "/code", "swift:latest", for try await line in merge(stdout, stderr) {
"/bin/bash", "-c", "swift test --skip InotifyLimitTests; swift test --skip-build --filter InotifyLimitTests" noora.passthrough("\(line)")
], }
output: .standardOutput, print("")
error: .standardError }
)
if dockerRunResult.terminationStatus.isSuccess { if (try await monitorResult.terminationStatus.isSuccess) {
noora.success("All tests completed successfully.") noora.success("All tests completed successfully.")
} else { } else {
noora.error("Not all tests completed successfully.") noora.error("Not all tests completed successfully.")

View File

@@ -1,17 +0,0 @@
import Foundation
import Testing
@testable import Inotify
@Suite("Directory Resolver")
struct DirectoryResolverTests {
@Test func listsDirectoryTree() async throws {
try await withTempDir { dir in
let subDirectory = "\(dir)/Subfolder/Folder 01"
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
let directories = try await DirectoryResolver.resolve(dir)
#expect(directories.count == 3)
#expect(directories.map { $0.description } == [dir, "\(dir)/Subfolder", subDirectory])
}
}
}

View File

@@ -21,24 +21,6 @@ struct RecursiveEventTests {
} }
} }
@Test func ignoresFileCreationInIgnoredSubfolder() async throws {
try await withTempDir { dir in
let subDirectory = "\(dir)/Subfolder"
let filepath = "\(subDirectory)/modify-target.txt"
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
let events = try await getEventsForTrigger(
in: dir,
mask: [.create],
recursive: .recursive,
exclude: ["Subfolder"]
) { _ in try createFile(at: "\(filepath)", contents: "hello") }
let createEvent = events.first { $0.mask.contains(.create) && $0.path.string == filepath }
#expect(createEvent == nil, "Did not expect CREATE for '\(filepath)', got: \(events)")
}
}
@Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws { @Test func newSubfoldersOfRecursiveWatchAreAutomaticallyWatchedToo() async throws {
try await withTempDir { dir in try await withTempDir { dir in
let subDirectory = "\(dir)/Subfolder" let subDirectory = "\(dir)/Subfolder"
@@ -50,7 +32,7 @@ struct RecursiveEventTests {
recursive: .withAutomaticSubtreeWatching recursive: .withAutomaticSubtreeWatching
) { _ in ) { _ in
try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true) try FileManager.default.createDirectory(atPath: subDirectory, withIntermediateDirectories: true)
try await Task.sleep(for: .milliseconds(400)) try await Task.sleep(for: .milliseconds(200))
try createFile(at: "\(filepath)", contents: "hello") try createFile(at: "\(filepath)", contents: "hello")
} }

View File

@@ -10,11 +10,9 @@ func getEventsForTrigger(
in dir: String, in dir: String,
mask: InotifyEventMask, mask: InotifyEventMask,
recursive: RecursivKind = .nonrecursive, recursive: RecursivKind = .nonrecursive,
exclude: [String] = [],
trigger: @escaping (String) async throws -> Void, trigger: @escaping (String) async throws -> Void,
) async throws -> [InotifyEvent] { ) async throws -> [InotifyEvent] {
let watcher = try Inotify() let watcher = try Inotify()
await watcher.exclude(names: exclude)
switch recursive { switch recursive {
case .nonrecursive: case .nonrecursive:
try await watcher.addWatch(path: dir, mask: mask) try await watcher.addWatch(path: dir, mask: mask)

58
task.sh
View File

@@ -1,58 +0,0 @@
#!/usr/bin/env zsh
# task - Run the package's TaskCLI target via a transient "task" product.
#
# Works around https://github.com/swiftlang/swift-package-manager/issues/8482
# by temporarily adding an executable product named "task" to Package.swift,
# running it with `swift run`, and restoring the original manifest afterwards.
#
# Usage: task [arguments...]
#
# The script auto-detects the package name from Package.swift and expects an
# executable target named "<PackageName>TaskCLI" to exist.
set -euo pipefail
# --- Resolve the package root (search upward for Package.swift) -----------
package_root="${PWD}"
while [[ ! -f "${package_root}/Package.swift" ]]; do
package_root="${package_root:h}" # zsh dirname
if [[ "${package_root}" == "/" ]]; then
echo "error: Could not find Package.swift in any parent directory." >&2
exit 1
fi
done
manifest="${package_root}/Package.swift"
backup="${manifest}.task-backup"
# --- Extract the package name ---------------------------------------------
package_name=$(sed -n 's/^.*name:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -1)
if [[ -z "${package_name}" ]]; then
echo "error: Could not determine package name from ${manifest}." >&2
exit 1
fi
target_name="${package_name}TaskCLI"
# --- Cleanup trap (runs on EXIT — covers success, failure, signals) -------
function cleanup {
if [[ -f "${backup}" ]]; then
mv -f "${backup}" "${manifest}"
fi
}
trap cleanup EXIT
# --- Inject the transient "task" product ----------------------------------
cp -f "${manifest}" "${backup}"
swift package --package-path "${package_root}" \
add-product task --type executable --targets "${target_name}"
# --- Run it (forward all script arguments) --------------------------------
swift run --package-path "${package_root}" task "$@"