Copied from brightdigit/brightdigit.com#162, which remains open there as the consumer-side tracking issue. All file paths below are relative to this repository's root.
Problem
File and Folder are structs, but they don't behave like values. Both wrap a reference:
// Sources/File.swift:12-21, Sources/Folder.swift:12-21
public struct File: Location {
public let storage: Storage<File>
}
// Sources/Storage.swift:13-21
public final class Storage<LocationType: Location>: Sendable {
private let pathStorage: Mutex<String>
internal var path: String { pathStorage.withLock { $0 } }
}
The only reason Storage is a class is so that move/rename can mutate path through a let, letting every copy of a File "follow" the item on disk. That's a 2019-era ergonomic trick, and it is not a file-descriptor handle: Storage holds a String and nothing else. Nothing is open, there's no lifecycle, no file position, no resource to release.
Storage.move (Sources/Storage.swift:117-135) is the only mutation of path in the entire library:
pathStorage.withLock { $0 = canonical } // Storage.swift:128
pathStorage.withLock { $0 = canonical.appendingSuffixIfNeeded("/") } // Storage.swift:130
The Mutex is a symptom, not a fix
import Synchronization and Mutex<String> exist solely to make that shared mutable state Sendable under Swift 6 without an @unchecked conformance (see the comment at Storage.swift:14-18). It's the only concurrency primitive in the library, and it also drags the platform floor to macOS 15 / iOS 18 / tvOS 18 / watchOS 11 (Package.swift:13-19). If the path were immutable, Storage would be Sendable for free — and so would File/Folder.
The aliasing surprise
Two File values that compare equal (== compares paths, Location.swift:102-104) may share one Storage. A rename through one silently rewrites the other:
let a = try folder.createFile(named: "file.json")
let b = a // struct copy — looks like a value
try a.rename(to: "renamed") // mutating through a `let`, no `mutating` keyword
b.name // "renamed.json" ← b changed too
b == a // true
There is no mutating marker and no var anywhere to warn the reader. This also means File/Folder are values you cannot reason about locally: holding one is holding a reference to something another thread may rewrite between two accessor calls (path and name can disagree across a single expression).
The existing test suite actively depends on this, which is the clearest evidence it is load-bearing behavior rather than an accident — Tests/FilesTests/FilesTests+Enumeration.swift:124-126 renames items while iterating and then re-reads names from the same sequence:
for folder in sequence { try folder.rename(to: "Folder " + folder.name) }
XCTAssertEqual(sequence.names().sorted(), expectedNames)
Ecosystem comparison
Precedent is uniformly the opposite of what we do. Verified against local checkouts:
| Library |
Path type |
Move API |
Enumeration |
| swift-system (Apple, first-party) |
public struct FilePath: Sendable — immutable value (Sources/System/FilePath/FilePath.swift:41) |
none |
none |
| NIOFileSystem (apple/swift-nio) |
FilePath (reuses swift-system) |
func moveItem(at:to:) async throws → Void (Sources/_NIOFileSystem/FileSystem.swift:535) |
DirectoryEntries: AsyncSequence, Sendable (DirectoryEntries.swift:24) |
| Foundation |
URL — never rewritten in place |
FileManager.moveItem(at:to:) → Void |
contentsOfDirectory → array |
Two details worth internalizing:
- swift-system's
appending is non-mutating and consuming: public __consuming func appending(_ other: __owned String) -> FilePath (FilePathSyntax.swift:532). Deriving a new path returns a new value. There is no path type in the ecosystem that rewrites itself when the file moves.
- swift-system deliberately refuses
Sendable on the thing that actually is a handle. FileDescriptor's conformance is commented out (Sources/System/FileDescriptor.swift:641-643) pending apple/swift-system#112. So Apple's own line is: the path is a Sendable value; the open descriptor is not. We currently have it backwards — our path type is a Sendable reference.
What adopting FilePath would and would not buy
swift-system 1.7.5 is already in our dependency graph transitively (Package.resolved), pulled in by swift-configuration and swift-subprocess. So adding it as a direct dependency of Files costs no new resolution.
- Would buy: a real path type replacing
String, with correct component parsing, Windows drive-prefix handling, and normalization. That would let us delete much of Path.swift, String+Files.swift, and the branchy validatedPath/makeParentPath logic (Storage.swift:30-110) — which we currently maintain by hand, including our own canonical-vs-native separator dance.
- Would NOT buy: anything about this issue's actual problem.
FilePath does not solve mutation or Sendable — those are consequences of our class, not of String. And there is nothing to adopt for the operations we care about: Sources/System/FileSystem/ contains only FileFlags, FileMode, FileType, Identifiers, Stat. No move, no rename, no directory enumeration. We would keep calling FileManager for all of it.
Recommendation: treat FilePath adoption as a separate, optional follow-up. It is orthogonal to value semantics and should not be bundled into this change.
Proposed design
Make the path immutable and have mutating operations return new values — exactly as copy(to:) already does today (Location.swift:161-166).
Step 1 — immutable storage. Replace Mutex<String> with let path: String. Storage can then be a struct, or be deleted entirely with the path stored directly in File/Folder. I lean toward deleting it: it exists only to be a reference, and Location's storage requirement (Location.swift:15-19) is already documented as not-public-API. Removing it also removes import Synchronization, which may let us lower the platform floor (see open questions).
Step 2 — move/rename return new values.
// before
public func rename(to newName: String, keepExtension: Bool = true) throws(LocationError)
public func move(to newParent: Folder) throws(LocationError)
// after
@discardableResult public func rename(to newName: String, keepExtension: Bool = true) throws(LocationError) -> Self
@discardableResult public func move(to newParent: Folder) throws(LocationError) -> Self
This makes the three operations symmetric — move, copy, and rename all return the new location.
What breaks
In-place mutation becomes rebinding:
try file.move(to: folder) // before: file now points at the new path
let moved = try file.move(to: folder) // after: file still points at the old path
@discardableResult keeps existing call sites compiling, which is a double-edged sword: code that relied on the old value updating will silently change behavior rather than erroring. Recommend shipping without @discardableResult on the first pass so every affected site is a compile error, then adding it back if the ergonomics annoy. This is the main risk in the whole proposal and deserves a deliberate decision.
What survives (the key advantage)
Sequence conformance is untouched. ChildSequence/ChildIterator need no changes at all. loadItemNames() (Folder+Children.swift:114-118) reads folder.path synchronously during iteration; with an immutable path that read is simply now guaranteed stable instead of racy.
Equatable, CustomStringConvertible, Sendable all survive, and Sendable gets easier — no Mutex, no @unchecked.
- Free
reduce/map/lazy from Sequence keep working, so count(), names(), first, last(), recursive, includingHidden (Folder.ChildSequence.swift) are unaffected.
One correction to the framing in the original brief: ChildSequence is not Sendable today and this change does not make it so — it stores a FileManager (Folder+Children.swift:15), which isn't Sendable. That's a pre-existing, separate matter.
ChildSequence.move(to:) (Folder.ChildSequence.swift:73-75) is the one internal site needing real thought: it currently mutates each child in place. It would become -> [Child] or stay Void and simply discard the new values.
~Copyable and ownership: recommendation is no
Swift 6.4 supports the full noncopyable feature set — verified that ~Copyable structs, consuming/borrowing parameters, and ~Copyable generics all typecheck under -swift-version 6 on the pinned 6.4.x-snapshot toolchain.
Relevant proposals: SE-0390 (noncopyable structs/enums), SE-0426 (BitwiseCopyable), SE-0427 (noncopyable generics), SE-0377 (consuming/borrowing modifiers).
~Copyable is the wrong tool here, and adopting it would be a mistake. Noncopyable types model unique ownership of a resource — something with a lifecycle that must not be duplicated or must be explicitly consumed (an open file descriptor, a lock token, a buffer). A path is the opposite: it is a plain description of a location, freely and cheaply copyable by nature, and copying it is never an error. Making File noncopyable would:
- break
Equatable comparison ergonomics and every Sequence-based API (Sequence.Element is copyable; ChildSequence would need a full redesign),
- force
consuming/borrowing annotations across the whole public surface,
- deliver no safety benefit, because there is no resource to leak or double-free.
Note the ecosystem agrees: swift-system's FilePath is an ordinary copyable struct; FileDescriptor — the actual handle — is the type with the ownership concerns. If we ever add a real open-handle type to Files, that is the ~Copyable candidate. Not this.
consuming/borrowing on move/copy: also no, for now. These are performance annotations, and File after this change is a struct wrapping a single String — one retain/release at worst. There is no measured hot path here (the enumeration cost is dominated by FileManager.contentsOfDirectory, Folder+Children.swift:115). Adopting them would add API-surface complexity and ownership-diagnostic noise for no measured win. Premature. Revisit only with a benchmark that shows it matters.
Migration path
Real grep counts, not estimates:
| Repo |
move/rename call sites |
Notes |
Publish (brightdigit-com-260406) |
0 |
git grep '\.move(|\.rename(' over the whole repo returns nothing |
Root (tidy-summit/Sources) |
0 |
no import Files anywhere in Sources/ or Tests/ — confirmed, uses Files only through Publish |
| Files' own tests |
7 |
FilesTests+Operations.swift:89,95,105,115; FilesTests+MovingCopying.swift:38,66; FilesTests+Enumeration.swift:125 |
The consumer blast radius is effectively zero. Publish touches Files only through non-mutating APIs — enumeration (6 for … in folder.files/subfolders sites in PublishingStep+Files.swift:74,81, MarkdownFileHandler+Loading.swift:47,134,147, MarkdownFileHandler.swift:19), plus write/read/createFile/createSubfolder/file(at:)/copy(to:) — none of which change under this proposal. The 25 files in Publish that import Files are unaffected.
So the churn is confined to Files' own test suite: 7 call sites, of which FilesTests+Enumeration.swift:125 needs a genuine rewrite (it renames during iteration and asserts on the mutated sequence).
Staging:
- Make
Storage.path a let; drop Mutex and import Synchronization. Change move/rename to return Self. Update the 7 tests.
- Collapse
Storage into File/Folder and remove the storage requirement from Location (Location.swift:15-19). Source-breaking only for anyone who touched storage — nobody does outside the library (21 internal storage. references).
- (Optional, separate issue) Evaluate
FilePath to replace String paths and delete our hand-rolled path normalization.
- (Optional) Reconsider the platform floor once
Synchronization is gone.
Non-goals
- Not adopting the actor /
AsyncSequence route. Already evaluated and rejected: it forces async on every accessor (path, name, extension, parent, creationDate, …) and loses Sequence's free reduce/map/lazy, forcing a hand-written replacement for count(), names(), first, last(). Value semantics gets us Sendable correctness without going async — that's the whole point.
- Not adopting
~Copyable — see reasoning above.
- Not adopting
FilePath as part of this change (orthogonal; separate issue).
- Not lowering platform floors in this change, even though removing
Mutex makes it possible.
- Not doing this before the release checkpoint completes. Files is mid-branch-pin; this lands after the dependency release checkpoint and tagging finish.
Open questions
@discardableResult on move/rename? Omitting it turns every affected site into a compile error (safer migration); including it preserves source compatibility but risks silent behavior changes. I lean omit.
- Delete
Storage entirely, or keep it as an immutable struct? Deleting is cleaner but removes Location.storage and init(storage:) from the public protocol. Keeping it is a smaller diff and preserves the protocol shape.
- What should
ChildSequence.move(to:) return — [Child], or stay Void?
- Is anyone outside these two repos consuming this fork? If the fork is only ever used via Publish, the source-break budget is essentially unlimited and we can do steps 1+2 in one pass.
- Lower the platform floor once
Synchronization is gone? macOS 15 / iOS 18 exist only for Mutex; the rest of the library is plain Foundation.
- Rewrite of
FilesTests+Enumeration.swift:125 — should renaming-during-iteration remain a supported pattern at all? Under value semantics it becomes "collect, then rename", which is arguably the more honest API.
Copied from brightdigit/brightdigit.com#162, which remains open there as the consumer-side tracking issue. All file paths below are relative to this repository's root.
Problem
FileandFolderarestructs, but they don't behave like values. Both wrap a reference:The only reason
Storageis a class is so thatmove/renamecan mutatepaththrough alet, letting every copy of aFile"follow" the item on disk. That's a 2019-era ergonomic trick, and it is not a file-descriptor handle:Storageholds aStringand nothing else. Nothing is open, there's no lifecycle, no file position, no resource to release.Storage.move(Sources/Storage.swift:117-135) is the only mutation ofpathin the entire library:The
Mutexis a symptom, not a fiximport SynchronizationandMutex<String>exist solely to make that shared mutable stateSendableunder Swift 6 without an@uncheckedconformance (see the comment atStorage.swift:14-18). It's the only concurrency primitive in the library, and it also drags the platform floor to macOS 15 / iOS 18 / tvOS 18 / watchOS 11 (Package.swift:13-19). If the path were immutable,Storagewould beSendablefor free — and so wouldFile/Folder.The aliasing surprise
Two
Filevalues that compare equal (==compares paths,Location.swift:102-104) may share oneStorage. A rename through one silently rewrites the other:There is no
mutatingmarker and novaranywhere to warn the reader. This also meansFile/Folderare values you cannot reason about locally: holding one is holding a reference to something another thread may rewrite between two accessor calls (pathandnamecan disagree across a single expression).The existing test suite actively depends on this, which is the clearest evidence it is load-bearing behavior rather than an accident —
Tests/FilesTests/FilesTests+Enumeration.swift:124-126renames items while iterating and then re-reads names from the same sequence:Ecosystem comparison
Precedent is uniformly the opposite of what we do. Verified against local checkouts:
public struct FilePath: Sendable— immutable value (Sources/System/FilePath/FilePath.swift:41)FilePath(reuses swift-system)func moveItem(at:to:) async throws→Void(Sources/_NIOFileSystem/FileSystem.swift:535)DirectoryEntries: AsyncSequence, Sendable(DirectoryEntries.swift:24)URL— never rewritten in placeFileManager.moveItem(at:to:)→VoidcontentsOfDirectory→ arrayTwo details worth internalizing:
appendingis non-mutating and consuming:public __consuming func appending(_ other: __owned String) -> FilePath(FilePathSyntax.swift:532). Deriving a new path returns a new value. There is no path type in the ecosystem that rewrites itself when the file moves.Sendableon the thing that actually is a handle.FileDescriptor's conformance is commented out (Sources/System/FileDescriptor.swift:641-643) pending apple/swift-system#112. So Apple's own line is: the path is aSendablevalue; the open descriptor is not. We currently have it backwards — our path type is aSendablereference.What adopting
FilePathwould and would not buyswift-system 1.7.5 is already in our dependency graph transitively (
Package.resolved), pulled in byswift-configurationandswift-subprocess. So adding it as a direct dependency ofFilescosts no new resolution.String, with correct component parsing, Windows drive-prefix handling, and normalization. That would let us delete much ofPath.swift,String+Files.swift, and the branchyvalidatedPath/makeParentPathlogic (Storage.swift:30-110) — which we currently maintain by hand, including our own canonical-vs-native separator dance.FilePathdoes not solve mutation orSendable— those are consequences of our class, not ofString. And there is nothing to adopt for the operations we care about:Sources/System/FileSystem/contains onlyFileFlags,FileMode,FileType,Identifiers,Stat. No move, no rename, no directory enumeration. We would keep callingFileManagerfor all of it.Recommendation: treat
FilePathadoption as a separate, optional follow-up. It is orthogonal to value semantics and should not be bundled into this change.Proposed design
Make the path immutable and have mutating operations return new values — exactly as
copy(to:)already does today (Location.swift:161-166).Step 1 — immutable storage. Replace
Mutex<String>withlet path: String.Storagecan then be astruct, or be deleted entirely with the path stored directly inFile/Folder. I lean toward deleting it: it exists only to be a reference, andLocation'sstoragerequirement (Location.swift:15-19) is already documented as not-public-API. Removing it also removesimport Synchronization, which may let us lower the platform floor (see open questions).Step 2 —
move/renamereturn new values.This makes the three operations symmetric —
move,copy, andrenameall return the new location.What breaks
In-place mutation becomes rebinding:
@discardableResultkeeps existing call sites compiling, which is a double-edged sword: code that relied on the old value updating will silently change behavior rather than erroring. Recommend shipping without@discardableResulton the first pass so every affected site is a compile error, then adding it back if the ergonomics annoy. This is the main risk in the whole proposal and deserves a deliberate decision.What survives (the key advantage)
Sequenceconformance is untouched.ChildSequence/ChildIteratorneed no changes at all.loadItemNames()(Folder+Children.swift:114-118) readsfolder.pathsynchronously during iteration; with an immutable path that read is simply now guaranteed stable instead of racy.Equatable,CustomStringConvertible,Sendableall survive, andSendablegets easier — noMutex, no@unchecked.reduce/map/lazyfromSequencekeep working, socount(),names(),first,last(),recursive,includingHidden(Folder.ChildSequence.swift) are unaffected.One correction to the framing in the original brief:
ChildSequenceis notSendabletoday and this change does not make it so — it stores aFileManager(Folder+Children.swift:15), which isn'tSendable. That's a pre-existing, separate matter.ChildSequence.move(to:)(Folder.ChildSequence.swift:73-75) is the one internal site needing real thought: it currently mutates each child in place. It would become-> [Child]or stayVoidand simply discard the new values.~Copyableand ownership: recommendation is noSwift 6.4 supports the full noncopyable feature set — verified that
~Copyablestructs,consuming/borrowingparameters, and~Copyablegenerics all typecheck under-swift-version 6on the pinned6.4.x-snapshottoolchain.Relevant proposals: SE-0390 (noncopyable structs/enums), SE-0426 (
BitwiseCopyable), SE-0427 (noncopyable generics), SE-0377 (consuming/borrowingmodifiers).~Copyableis the wrong tool here, and adopting it would be a mistake. Noncopyable types model unique ownership of a resource — something with a lifecycle that must not be duplicated or must be explicitly consumed (an open file descriptor, a lock token, a buffer). A path is the opposite: it is a plain description of a location, freely and cheaply copyable by nature, and copying it is never an error. MakingFilenoncopyable would:Equatablecomparison ergonomics and everySequence-based API (Sequence.Elementis copyable;ChildSequencewould need a full redesign),consuming/borrowingannotations across the whole public surface,Note the ecosystem agrees: swift-system's
FilePathis an ordinary copyable struct;FileDescriptor— the actual handle — is the type with the ownership concerns. If we ever add a real open-handle type toFiles, that is the~Copyablecandidate. Not this.consuming/borrowingonmove/copy: also no, for now. These are performance annotations, andFileafter this change is a struct wrapping a singleString— one retain/release at worst. There is no measured hot path here (the enumeration cost is dominated byFileManager.contentsOfDirectory,Folder+Children.swift:115). Adopting them would add API-surface complexity and ownership-diagnostic noise for no measured win. Premature. Revisit only with a benchmark that shows it matters.Migration path
Real grep counts, not estimates:
move/renamecall sitesbrightdigit-com-260406)git grep '\.move(|\.rename('over the whole repo returns nothingtidy-summit/Sources)import Filesanywhere inSources/orTests/— confirmed, uses Files only through PublishFilesTests+Operations.swift:89,95,105,115;FilesTests+MovingCopying.swift:38,66;FilesTests+Enumeration.swift:125The consumer blast radius is effectively zero. Publish touches Files only through non-mutating APIs — enumeration (6
for … in folder.files/subfolderssites inPublishingStep+Files.swift:74,81,MarkdownFileHandler+Loading.swift:47,134,147,MarkdownFileHandler.swift:19), pluswrite/read/createFile/createSubfolder/file(at:)/copy(to:)— none of which change under this proposal. The 25 files in Publish thatimport Filesare unaffected.So the churn is confined to Files' own test suite: 7 call sites, of which
FilesTests+Enumeration.swift:125needs a genuine rewrite (it renames during iteration and asserts on the mutated sequence).Staging:
Storage.pathalet; dropMutexandimport Synchronization. Changemove/renameto returnSelf. Update the 7 tests.StorageintoFile/Folderand remove thestoragerequirement fromLocation(Location.swift:15-19). Source-breaking only for anyone who touchedstorage— nobody does outside the library (21 internalstorage.references).FilePathto replaceStringpaths and delete our hand-rolled path normalization.Synchronizationis gone.Non-goals
AsyncSequenceroute. Already evaluated and rejected: it forcesasyncon every accessor (path,name,extension,parent,creationDate, …) and losesSequence's freereduce/map/lazy, forcing a hand-written replacement forcount(),names(),first,last(). Value semantics gets usSendablecorrectness without going async — that's the whole point.~Copyable— see reasoning above.FilePathas part of this change (orthogonal; separate issue).Mutexmakes it possible.Open questions
@discardableResultonmove/rename? Omitting it turns every affected site into a compile error (safer migration); including it preserves source compatibility but risks silent behavior changes. I lean omit.Storageentirely, or keep it as an immutablestruct? Deleting is cleaner but removesLocation.storageandinit(storage:)from the public protocol. Keeping it is a smaller diff and preserves the protocol shape.ChildSequence.move(to:)return —[Child], or stayVoid?Synchronizationis gone? macOS 15 / iOS 18 exist only forMutex; the rest of the library is plain Foundation.FilesTests+Enumeration.swift:125— should renaming-during-iteration remain a supported pattern at all? Under value semantics it becomes "collect, then rename", which is arguably the more honest API.