Swift Digest
SE-0529 | Swift Evolution

FilePathを標準ライブラリに追加する

Add FilePath to the Standard Library

Proposal
SE-0529
Authors
Michael Ilseman, Saleem Abdulrasool
Review Manager
John McCall
Status
Accepted with modifications

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Swiftにはこれまで、ファイルシステムのパスを表すための標準的な型がありませんでした。swift-system パッケージには FilePath が存在しますが、外部パッケージにあるため標準ライブラリやSwiftランタイムからは依存できず、Foundationなどツールチェイン同梱のライブラリのAPIにも登場させることができません。その結果、「ファイルパスを名前として扱う新しいAPI」を追加するたびに String を使うしかなく、たとえば SE-0513 で現在の実行ファイルパスを得るAPIを追加したときにも同じ問題が再燃しました。

String をパス表現として使うのは、いくつかの意味で不適切です。

  • パスの構造(どこまでがアンカーか、どこからがコンポーネントの並びか、末尾のセパレータの有無など)を型として表現できない
  • パスの文字列表現はプラットフォーム固有で、Linux / Darwinの / とWindowsの \ を「同じものとして扱う」コードはそもそも成立しない
  • パスのコンポーネントは必ずしも妥当なUnicodeではない(Linux / Darwinでは任意のバイト列、Windowsでは任意のUInt16列が許される)

特に厄介なのが、パス先頭の「基準点」を表す部分です。Linuxでは単純に絶対パスを示す / だけですが、Darwinでは /.nofollow/ のようなカーネルへの解決フラグや /.vol/1234/5678 のようなボリューム参照が付きうるし、Windowsではドライブレター(C:\C:)、UNCサーバ/シェア(\\server\share)、verbatim形式(\\?\C:\)、デバイス名前空間(\\.\pipe)などがあります。これらを String の先頭部分として正しく扱い分けるのは相当に困難で、各プロジェクトやライブラリごとに似たようなパース処理を再発明する状況になっていました。

swift-system の FilePath には、プラットフォームごとの正しい構文に従ってパスを扱う包括的なsyntactic API が整備されています。この Proposal の出発点は、その設計を礎にしつつ、currency type(他の基本型と同じ感覚で API の受け渡しに使える型)として機能する最小限の FilePath を標準ライブラリ側に持ち込むことです。

02 どのように解決されるのか

FilePath とその中核型(FilePath.AnchorFilePath.ComponentFilePath.ComponentView)を Swift モジュールに追加し、currency typeとしてパスを受け渡せるようにします。この Proposal では、FilePath を型として成立させるための「構築」「分解」「解決(resolution)」「C相互運用」のための最小限のAPIまでが対象で、lastComponentstem のようなシンタックス上の便利メソッドなどは後続の提案に回されます。

APIの全体像は次のようなイメージです。

var path: FilePath = "/var/www/static/index.html"

path.isAbsolute                      // true
path.hasTrailingSeparator            // false

print(path.anchor)                   // Optional(/)
for component in path.components {
    print(component, component.kind) // "var": .regular, "www": .regular, ...
}

// ファイルシステムに問い合わせて解決する
let resolved = try path.resolve()

// コンポーネント列を操作してパスを組み立てる
var config: FilePath = "/etc/nginx"
config.components.append("nginx.conf")
// config は "/etc/nginx/nginx.conf"

以降、主要な設計ポイントを順に見ていきます。

FilePath の内部表現と基本形

FilePath はSendableな値型で、プラットフォームネイティブの文字(Linux / Darwinでは CChar、Windowsでは UInt16)のnull終端されたシーケンスとして値を保持します。空のパスは FilePath() で得られ、isEmpty で判定できます。/C:\ のようなアンカーのみのパスは空ではありません。なお、NUL バイト(0x00)はどのプラットフォームでも妥当なパスバイトではないため、StringSpan から構築する失敗可能イニシャライザは NUL を含む入力に対して nil を返します。

public struct FilePath: Sendable {
  public init()
  public static var separator: FilePath.CodeUnit { get }  // Linux/Darwinは "/"、Windowsは "\" を表すコードユニット
  public var isEmpty: Bool { get }
}

separator の型はプラットフォームのコードユニット(Linux / Darwinは CChar、Windowsは UInt16)です。String 由来の Character ではなく、FilePath 内部の表現に直接対応する低レベルな型を返す形になっています。

分解モデル: アンカー + コンポーネント + サフィックス

Proposalの中心概念は、どんなパスも次の3つに分解できるという分解モデルです。

  • アンカー(anchor): パスの基準点を表す、先頭の領域
  • コンポーネント列: 基準点から辿るための相対コンポーネント(FilePath.ComponentView が正規化して提示する)
  • サフィックス: 最終コンポーネントを解決したあとの挙動をカーネルに指示する、末尾の領域

たとえばLinuxの /foo/bar は、アンカーが /、コンポーネント列が [foo, bar]、末尾のセパレータなし、となります。この3軸に分けて理解することで、プラットフォーム差のある構文を型で吸収する、というのが FilePath の設計です。

アンカー(FilePath.Anchor

アンカーはコンポーネント列に先立つ領域で、単なるルートだけでなく、ボリュームや解決方法の指定まで担います。

  • Linuxでは絶対パスの / のみ
  • Darwinではこれに加えて、カーネルへの解決フラグ(/.nofollow//.resolve/3/)やボリューム参照(/.vol/1234/5678)を含みうる
  • Windowsではドライブレター(C:\C:)、UNC名(\\server\share)、デバイスパス(\\.\pipe)、verbatimパス(\\?\C:\)、カレントドライブのルート(\)などが当たる

FilePath.AnchorisRooted プロパティを持ち、絶対パスかrootedかを問い合わせられます。さらに driveLetter / isVerbatimComponent#if os(Windows) のもとで提供され、Windowsのアンカーが持つ固有の構造を露出させます。相対パスの場合は path.anchornil です。

var winPath: FilePath = #"C:\Users\dev\project"#
winPath.anchor?.driveLetter            // Optional("C")
winPath.anchor?.isVerbatimComponent    // Optional(false)

// 「conventional」形式から「verbatim-component」形式へアンカーだけ付け替える
winPath.anchor = #"\\?\C:\"#
print(winPath)                         // \\?\C:\Users\dev\project

// Darwin上で、カーネルの解決フラグを剥がしたいときはアンカーだけ "/" に差し替える
var darwinPath: FilePath = "/.nofollow/etc/passwd"
darwinPath.anchor = "/"
print(darwinPath)                      // /etc/passwd

driveLetter の型は Unicode.Scalar? で、ドライブを示す : の直前にある1コードユニットをそのまま返します。A-Z 以外の文字も理論上はドライブレターになりうるためケース正規化は行われず、もしWindowsの UInt16 表現で対応するペアを持たないサロゲートが入っていれば U+FFFD が返ります。

Windowsのパスは大きく3つのスタイル(conventional / device-namespace / verbatim-component)に分類され、FilePath.Anchor はこの違いを型として保持します。特にverbatim-component(\\?\)では / はセパレータでなく正当なコンポーネント文字で、. / .. も特別扱いされないなど、構文そのものが変わる点が重要です。

また、Darwinのアンカーには正規化ルールがあります。/.resolve/1/ は同じXNUフラグを指す /.nofollow/ に、/.vol/NNNN/2/ は「ボリュームルート(inode 2)」を指す /.vol/NNNN/@/ に、構築時に正規化されます。

コンポーネント(FilePath.Component / FilePath.ComponentView

FilePath.Component は1つのパスコンポーネントを表し、空にはならず、ディレクトリセパレータを含みません。kind プロパティで、通常のコンポーネント(.regular)、カレントディレクトリ(.currentDirectory.)、親ディレクトリ(.parentDirectory..)を区別できます。ただしWindowsのverbatim-componentパスでは . / .. は通常の名前として扱われるため、同じ . というバイト列でも、置かれたパスのアンカー次第で kind が変わります。

FilePath.ComponentViewBidirectionalCollection かつ RangeReplaceableCollection で、パスのコンポーネント列を正規化した形で提示します。

  • 連続するセパレータは無視される(a///b[a, b]
  • 非verbatimのWindowsパスでは /\ は同じセパレータとして扱われる
  • 中間の . コンポーネントは落とされる(a/./b[a, b])。ただし rootless パスの先頭に現れた ./foo のような . は残る
  • .. コンポーネントは常に保持される(a/../b[a, .., b]
var path: FilePath = "/home/username/scripts/tree"
let scriptIdx = path.components.lastIndex(of: "scripts")!
path.components.insert("bin", at: scriptIdx)
// path は "/home/username/bin/scripts/tree"

Windows上で FilePath.Component(verbatim:) を使えば、/ を含む文字列もverbatim-componentパス向けの正当なコンポーネントとして作成できます。

サフィックス: 末尾のセパレータとリソースフォーク

サフィックスはコンポーネントではなく、「最終コンポーネントを解決したあとの挙動」をカーネルに指示する部分です。2種類あります。

  • 末尾のセパレータ(trailing separator): Linux / Darwin / Windowsすべてに存在。/tmp/foo/ のような末尾の / は、最終コンポーネントをディレクトリとして扱うよう要求し、シンボリックリンクの解決を行わせる、という意味を持つ
  • リソースフォーク サフィックス(Darwinのみ): ファイルのresource forkを指す、固定の /..namedfork/rsrc サフィックス

この2つは同時には現れず、どちらも等価性(==)に影響します。たとえば FilePath("/tmp/foo") != FilePath("/tmp/foo/") です。swift-systemの FilePath は末尾のセパレータを構築時に落としていましたが、標準ライブラリ版では保持する方針に変わります。

extension FilePath {
  public var hasTrailingSeparator: Bool { get set }
  public func withTrailingSeparator() -> FilePath
  public func withoutTrailingSeparator() -> FilePath
}

#if canImport(Darwin)
extension FilePath {
  public var isResourceFork: Bool { get set }
  public func withResourceFork() -> FilePath
  public func withoutResourceFork() -> FilePath
}
#endif

分解からの再構築

分解の逆操作として、アンカー・コンポーネント列・サフィックスからパスを組み立てるイニシャライザも用意されます。文字列リテラルで同じパスを書いたときと同じ正規化が適用されます。

extension FilePath {
  public init(
    anchor: Anchor?,
    _ components: some Sequence<Component>,
    hasTrailingSeparator: Bool = false
  )
}

#if canImport(Darwin)
extension FilePath {
  public init(
    anchor: Anchor?,
    _ components: some Sequence<Component>,
    resourceFork: Bool
  )
}
#endif

absolute / rooted

アンカーの性質から派生する概念として、isAbsolute と、Windows固有の「rooted」の区別があります。

  • absolute: 現在の作業ディレクトリや環境変数などに依存せず、名前付きボリュームのルートだけを基準に場所を一意に決められるパス
  • Linux / Darwinでは / で始まるパスが absolute
  • Windowsでは C:\\\server\share\\\?\C:\\\.\pipe のように「ドライブまたはボリュームを明示しつつルートから始まる」ものが absolute。\fooC:foo は rooted だが absolute ではない
  • ~ で始まるパスはシェル展開を行わないため、絶対パスとしては扱われない

FilePath.Anchor.isRooted は、Windowsでこの「rooted だが absolute ではない」状態を拾うためのプロパティです。Linux / Darwinでは rooted ≡ absolute なので、実質的にWindows向けの区別です。「絶対パスでない」かどうかは !path.isAbsolute で判定し、専用の isRelative は提供されません。

解決(resolve()

FilePath.resolve() は、ファイルシステムに問い合わせて絶対パス形式を得る操作です。

  • 結果のパスはabsoluteで、シンボリックリンクを含まず、. / .. も含まない
  • すべての中間コンポーネントが存在する必要があり、失敗時は throw する
  • 結果はスナップショットであり、以降のファイルシステム変更で無効になる可能性がある
extension FilePath {
  @available(*, noasync)
  public func resolve() throws -> FilePath
}

resolve() は同期的でブロッキングする操作なので、@available(*, noasync) が付与されており、async コンテキストから直接呼ぶと診断されます。非ブロッキングな async 版や非ブロッキングな sync 版は今後の課題として位置づけられています。

Windowsのverbatim-componentパス(\\?\)では . / .. は特殊扱いされないため、解決後のパスにもそのまま残ることがあります。Darwinでは、解決結果のアンカーに /.nofollow/ のような解決フラグが挿入される場合があります。シンボリックリンクを使わない「lexical resolution」や、ベースディレクトリから逃げないことを保証する「resolve-beneath」は、このProposalのスコープ外で将来の作業とされています。

表示・比較・ハッシュ

FilePath / FilePath.Anchor / FilePath.Component は、HashableComparableCustomStringConvertibleCustomDebugStringConvertibleExpressibleByStringLiteral に適合します。String からの非失敗イニシャライザが FilePath にはありますが、AnchorComponent はバリデーションのため失敗可能イニシャライザのみです(文字列リテラル経由はtrapする可能性があります)。

print(FilePath("a///b"))      // "a/b"
print(FilePath("a/./b"))      // "a/b"
print(FilePath("/./foo"))     // "/foo"
print(FilePath("/tmp/foo/"))  // "/tmp/foo/"

FilePath("a///b") == FilePath("a/b")              // true(正規化後に同じ)
FilePath("/tmp/foo") != FilePath("/tmp/foo/")     // true(trailing separatorが異なる)
FilePath("/.nofollow/foo/bar") != FilePath("/foo/bar")    // true(Darwinのアンカーが異なる)
FilePath(#"\\.\C:\foo\bar"#) != FilePath(#"C:\foo\bar"#)  // true(Windowsのアンカーが異なる)

等価性は「2つのパスが同じ綴りか(無意味な重複セパレータや中間の . を正規化したうえで)」という純粋に構文的な判定で、ファイルシステムへの問い合わせは行いません。そのため、ハードリンクやシンボリックリンクを踏まえた「同じ実体を指す」という意味での同値性は、これとは別の概念です。Comparable の順序は、正規化後のバイト表現に対する辞書順です。

FilePath.ComponentViewHashable / Comparable に適合しますが、比較対象はコンポーネント部分のみで、アンカーやサフィックスは無視されます。

文字列・バイト列との相互変換

String との相互変換は init(decoding:) / init?(validating:) で、Linux / DarwinではUTF-8、WindowsではUTF-16として解釈します。validating 版は不正なUnicodeのとき nil を返し、decoding 版は不正バイトを U+FFFD に置き換えます。FilePath.Anchor / FilePath.Component にも同様のAPIが用意されます。

extension String {
  public init(decoding path: FilePath)
  public init?(validating path: FilePath)
  // Anchor / Component 向けの同様のAPI
}

C相互運用: withCodeUnits(_:)Span

C APIへの受け渡し用に、プラットフォームの CodeUnit(Linux / Darwinは CChar、Windowsは UInt16)でアクセスするAPIが用意されます。中心となるのは、String.withCString(_:) に近い形のクロージャベースAPIで、ポインタとコードユニット数(null終端を除く)の両方を受け取れます。これにより、文字数を再計算せずにC関数へ渡せます。

extension FilePath {
  public typealias CodeUnit = /* CChar or UInt16 */

  /// path のnull終端バッファのポインタと、null終端を含まないコードユニット数を渡す
  public func withCodeUnits<Result, E: Error>(
    _ body: (UnsafePointer<FilePath.CodeUnit>, Int) throws(E) -> Result
  ) throws(E) -> Result

  /// 借用ストレージとして、null終端を含まないコードユニット列を取得
  public var codeUnits: Span<FilePath.CodeUnit> { get }

  /// 末尾のnull終端を含むコードユニット列
  public var nullTerminatedCodeUnits: Span<FilePath.CodeUnit> { get }

  /// null終端を含まないSpanから構築。`NUL` が含まれていると `nil`
  public init?(codeUnits: Span<FilePath.CodeUnit>)

  public init<E: Error>(
    capacity: Int,
    initializingCodeUnitsWith initializer: (inout OutputSpan<FilePath.CodeUnit>) throws(E) -> Void
  ) throws(E)
}

FilePath.Anchor / FilePath.Component / FilePath.ComponentView にも、それぞれ対応する codeUnits: Span<FilePath.CodeUnit> が生えます。コンポーネントは内部にセパレータやnull終端を含みません。

String 由来のイニシャライザも、NUL を含む入力を弾くために失敗可能になります。FilePath / FilePath.Anchor / FilePath.Component のすべてで、init?(_ string: String)NUL を含む文字列に対して nil を返します(FilePath についても文字列リテラル経由はtrapしますが、ランタイムの String からは失敗可能になります)。

swift-system との移行

既存の SystemPackage.FilePath / System.FilePath は、対応するツールチェインに対しては Swift.FilePath への条件付きtypealiasを通じて移行できます。

#if compiler(>=6.4) // 本提案が導入されるバージョン
public typealias FilePath = Swift.FilePath
#else
public struct FilePath { ... }
#endif

これにより、syntactic API などの既存機能は新しい FilePath 上の拡張として提供でき、ソース互換性を保ったまま移行できます。Darwinの System.FilePath にはABIコミットメントがあるため、既存バイナリ向けにはABIレベルのリダイレクトで互換性が確保されます。swift-systemの FilePath.RootFilePath.Anchor に置き換えられる形で正式にdeprecatedとなります。

03 今後の見通し

このProposalでは currency type としての土台を作ることに集中しており、以下のAPIや機能は後続の提案に回されています。あくまで方向性として挙げられているものであり、実現を約束するものではありません。

シンタックス操作

lastComponent / stem / extension / removingLastComponent() のような便利アクセサや、append / push / removePrefix のような変更メソッドが、当初のpitchで含まれていた候補として挙がっています。これらは ComponentView を通じて表現でき、後からエクステンションとして追加可能で、過去のランタイムにも back-deploy できる見込みです。

パス連結用の / 演算子も候補として残されています。右辺をどう取るか(単一のコンポーネント / コンポーネントの列 / パースされた相対パス)にトレードオフがあり、アンカーの扱いやエラー挙動も含めて将来の提案で明示的に決める必要がある、とされています。

resolve() の非ブロッキング版

本Proposalの resolve() は同期的・ブロッキングで、@available(*, noasync) が付与されています。async コンテキスト向けの非ブロッキングな async 版は、標準ライブラリのI/Oプール設計に依存する将来課題として想定されています。ブロッキングを許容できない環境向けの非ブロッキングな sync 版も二次的な候補に挙がっています。@available(*, noasync) は async コンテキストでの呼び出しを診断できるだけで、より広範にブロッキングを追跡するには言語レベルの effects system が必要になる、という議論も添えられています。

lexical resolution と resolve-beneath

ファイルシステムに問い合わせずに .. を畳み込む lexical resolution と、サブパスがベースディレクトリから逃げないことを保証する resolve-beneath は、サンドボックスやセキュリティ上重要な操作として、将来の追加が予定されています。実体を持つ resolve() を本Proposalで先に提供することで、最初から正しい道具が手に入るようにし、これらは後続で扱う方針です。

プラットフォーム固有の Anchor API

FilePath.Anchor は、プラットフォーム固有のさらなる分解や解析API(drive letter まわりの操作、UNC / device / verbatim パスへの変換など)の起点として位置づけられており、それらの追加が想定されています。

プラットフォーム文字列API と CInterop

swift-system が持つ CInterop ネームスペース(PlatformCharPlatformUnicodeEncoding などのtypealias)や init(platformString:) 系のAPIに相当するもの、たとえば PlatformChar typealias などを、本Proposalの withCodeUnits(_:)Span ベースAPIに重ねて整備していく方向性が示されています。

将来、言語側にnull終端ポインタを表す標準的な仕組み(ライフタイム付き借用ポインタなど)が導入された場合は、クロージャベースの withCodeUnits(_:) の代わりに var cString のようなプロパティを露出する形も考えられています。

degenerateなパス形式に対するより細かいバリデーション

本Proposalの失敗可能イニシャライザは、どのプラットフォームでも無条件に不正な NUL だけを弾きます。Windowsのレガシーデバイス名、コンポーネント名や全体長の上限、利用可能な文字集合などはアプリケーション固有の判定になるため、組み込みでなく将来のAPIとして、設定可能なチェックを露出する案が挙がっています。

パスのパーサと verbatim storage モード

FilePath 内部のパーサを Span のような借用ストレージ上に公開し、FilePath にコピーすることなくその場でパスを解析できるようにする案があります。さらに、ロギングや診断のように「元のバイト列をそのまま保持したい」用途のために、正規化を行わない verbatim storage モードを将来的に追加する余地も残されています。

SystemString

swift-system が内部で利用している SystemString 相当の、プラットフォーム エンコーディングのnull終端文字列を扱う公開型を、独立した有用な型として提供することも検討されています。

より多くのパス型とファイルシステム固有機能

将来的に paths ライブラリ/パッケージとして、次のような機能を追加することが視野に入っています。

  • AbsolutePath / ResolvedPath のような、型レベルで不変条件を保証するパス型
  • XNUPath / Win32Path / WinNTPath のような、プラットフォーム固有のフォーリン パス型
  • ケース変換やUnicode正規化など、ファイルシステム固有・設定固有のコンポーネント操作

Linux/macOS 上で Windows のパス挙動をテストする(あるいはその逆)ことは現状のギャップとして認識されており、共通プロトコルに適合する各プラットフォーム向けパス型がその解決策になる可能性があります。

Windows のレガシーデバイス名対応

Windows のレガシーデバイス名(CONNULCOM1 など)は Win32 層で特別扱いされ、その挙動は Windows のバージョンによって異なります。これらの検出や緩和を、resolve-beneath やサンドボックス API の一部として将来的に追加する案があります。