dart-use-path-package について
`package:path`を使った、Dartのポータブルなファイルシステムパス処理を解説するスキルです。文字列操作による不具合を避け、ネイティブ、POSIX、URL、モックファイルシステムの各形式を扱います。
主な機能
- 01クロスプラットフォームの結合と照合
- 02パスの正規化と正規化解決
- 03URIとPOSIX形式の安全な変換
- 04拡張子と複合サフィックスの処理
- 05モックファイルシステムのパスコンテキスト
- 06パス改修の監査チェックリスト
活用シーン
- 01壊れやすいパス文字列結合の置き換え
- 02ネイティブパスからGit用パスを生成
- 03ファイルパスとURIの相互変換
- 04別のホストでWindows形式のパスをテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ flutter/ agent-plugins --skill dart-use-path-packageインストール後、Claude Code で/dart-use-path-packageを入力して読み込まれたか確認します。
同梱ファイルexamples/cross_platform_paths.dartexamples/file_system_context.dart
詳細
Dart で安全にクロスプラットフォームのパスを操作する
目次
- 1. 基本原則とクロスプラットフォームのルール
- 2. 推奨される package:path のイディオムと文字列のアンチパターン
- 3. ネイティブパスを POSIX、Git、URL のコンテキストに変換する
- 4. モック可能なファイルシステム(
package:fileとグローバルなp.*) - 5. 拡張子、複合拡張子、ステムの抽出
- 6. ワークフローと監査チェックリスト
- 参考資料と例
1. 基本原則とクロスプラットフォームのルール
ファイルパスを単なる文字列として扱わない
- Windows のネイティブファイルパスではバックスラッシュ(
\)を使用しますが、macOS と Linux ではスラッシュ(/)を使用します。 .contains('foo/')、.startsWith('foo/')、.split('/')などの文字列操作は、Windows のネイティブパスでは気付かないうちに失敗します。'$dir/$file'のような文字列補間は、Windows でスラッシュを挿入し、$dirが末尾にスラッシュを含む場合は重複したスラッシュ(//)を生成します。
ルール:ディレクトリ階層やセグメント名を調べる前に、必ず p.split(path) を使ってパスをセグメントに分解し、パスの各要素を結合する際は必ず p.join(...) を使います。
境界での実用的な結合と複数セグメントの分解(p.join)
- クロスプラットフォームライブラリ(Windows + POSIX):個別のパスセグメントを
p.join(dir, 'sub', 'file.json')に渡すと、package:pathが各要素の間に OS ネイティブの区切り文字(Windows では\、POSIX では/)を挿入します。 - POSIX 専用ツールと静的サブパスの検索性:Linux/macOS のみを対象とするコードベースでは(または動的なベースパスと既知の静的サブパスを結合する場合)、5~6 個の静的セグメントを個別の引数に分解して渡すと(
p.join(home, '.local', 'share', 'app', 'bin', 'config.json'))、dart formatによって 6~8 行に折り返され、部分文字列による検索性(.local/share/app/binをgrep/code_searchで検索すること)が損なわれます。 - POSIX を対象とする場合のルール:2 引数での境界結合(
p.join(home, '.local/share/app/bin/config.json'))を推奨します。これにより、変数の境界で重複スラッシュ(//)が発生する問題を防ぎながら、1 行で読みやすく、文字列を正確に検索できる状態を保てます。
正規化と正準化(p.normalize と p.canonicalize)
p.normalize(path)は、ファイルシステムを参照したり大文字と小文字を統一したりせず、字句的に.と..のセグメントを解決します。- ディレクトリパスの重複を排除する場合や、シンボリックリンク、相対パスのルート、大文字と小文字を区別しないファイルシステムをまたいで実際のファイルの同一性を比較する場合は、
p.canonicalize(path)を使います。
位置指定子を取り除き、URI を安全に変換する
<path>:<line>-<col>または<path>:<line>形式の文字列は、純粋なファイルパスではありません。これらをそのままp.normalizeやUri.parseに渡すと不具合が発生します(Windows ではUri.parseがC:を URI スキーム、:lineをポートと誤認します)。- ファイルパスを
package:pathに渡す前に、正規表現(RegExp(r'^(.*?):(\d+(?:-\d+)?)$'))を使って末尾の:line-colサフィックスを抽出します。 - URI 境界での変換:ファイルパスと
Uriオブジェクトを変換する際は、Uri.parse(path)や手動の文字列連結ではなく、必ずp.toUri(path)とp.fromUri(uri)を使います。
2. 推奨される package:path のイディオムと文字列のアンチパターン
パスの結合
- 推奨:
p.join(dir, file) - 避ける:
'$dir/$file'や'a/$b' - 理由:文字列補間は Windows で
/を挿入し、$dirが末尾に区切り文字を含む場合に重複した スラッシュ(//)を生成します。
セグメントの照合
- 推奨:
p.split(path).contains('foo') - 避ける:
path.contains('foo/') - 理由:文字列による照合は Windows のバックスラッシュ(
foo\bar)に対応できず、 部分文字列による名前の誤検出(例:barfoo/)も発生します。
ルートとディレクトリのプレフィックス
- 推奨:
if (p.split(path) case ['foo', ...])(末尾のセグメントを取得する場合はcase ['foo', ...final rest])、またはp.isWithin('foo', path) - 避ける:
path.startsWith('foo/')またはp.split(path).first == 'foo' - 理由: 文字列のプレフィックス照合は、Windows の区切り文字(
foo\bar)では正しく動作しません。p.split(path).firstはリストが空の場合にStateErrorをスローし、別途.skip(1)によるスライスが必要になります。一方、リストパターンなら空でないことを安全に確認し、複数セグメントのプレフィックスに一致させ、必要に応じて...final restを一度に束縛できます。./foo/barのように正規化されていない相対プレフィックスが現れる可能性がある場合は、p.isWithin('foo', path)(またはp.split(p.normalize(path)))を使用してください。
ファイル拡張子
- 推奨:
p.extension(path) == '.wasm' - 避ける:
path.endsWith('.wasm') - 理由: 部分文字列のサフィックス照合では、ディレクトリ(
foo.wasm/)や拡張子ではないサフィックスに誤って一致します。
拡張子の切り出しと複合拡張子
- 推奨:
p.withoutExtension(path)とp.extension(path, 2) - 避ける:
path.lastIndexOf('.')と手動のsubstringによる切り出し - 理由: 手動の計算では、ドットで始まるファイル(
.gitignore)や複合拡張子(.js.map、.tar.gz)を正しく扱えません。
POSIX パスと URL パスの変換
- 推奨:
p.posix.joinAll(p.split(path))またはp.url.joinAll(p.split(path)) - 避ける:
path.replaceAll(r'\', '/') - 理由: 場当たり的な区切り文字の置換では、ルートドライブを正しく扱えず、OS のコンテキストと POSIX または URL の変換先が混在します。
URI 変換
- 推奨:
p.toUri(path)とp.fromUri(uri) - 避ける:
Uri.parse(path)とuri.path - 理由: URI を直接解析すると、Windows のドライブ文字(
C:)を正しく扱えず、パーセントエンコーディング(空白を表す%20など)が漏れ出します。
ディレクトリのベース名ヘルパー
- 推奨:
String canonicalDirName(Directory d) => p.basename(p.normalize(d.absolute.path)); - 避ける: ファイル間で
p.basename(p.normalize(dir.absolute.path))を繰り返しインラインで記述すること。 - 理由: ディレクトリ名を正規化するロジックを一元化し、定型コードを減らせます。
3. ネイティブパスを POSIX、Git、URL の各コンテキストに変換する
OS ネイティブのパスを POSIX パス(Git、YAML、アーカイブマニフェスト用)や URL セグメントに変換するために、.replaceAll('\\', '/') や .replaceAll(r'\', '/') を呼び出さないでください。
ルール: p.split(...) でネイティブの相対パスを分割し、Dart 3 のリストパターンマッチングでセグメントを確認してから、p.posix.joinAll(...) または p.url.joinAll(...) で結合します。ルートの先頭セグメント(POSIX の '/' または Windows の r'C:\')が相対プレフィックスパターンに干渉しないよう、必ず最初に p.relative(filePath, from: root) を呼び出してください。
import 'package:path/path.dart' as p;
String computeWebAssetKey(String filePath, String projectRoot) {
final relative = p.relative(filePath, from: projectRoot);
final segments = p.split(relative);
return switch (segments) {
['assets', ...] => p.posix.joinAll(segments),
_ => p.posix.joinAll(['assets', ...segments]),
};
}
Git のパスとリポジトリのメタデータ
- Git リポジトリのツリーオブジェクト、
.gitignoreのパターンルール、.gitattributes、Git で追跡されるシンボリックリンクでは、Windows でも POSIX のスラッシュ(/)を厳密に使用します。 - ネイティブな Windows のバックスラッシュ(
\)を.gitignoreや Git コマンドに入れると、Git はこれをディレクトリ区切り文字ではなくエスケープ文字として扱うため、パターン照合が気付かないうちに機能しなくなります。 .gitignoreのエントリー、リポジトリマニフェスト、シンボリックリンクのターゲットをネイティブのファイルパスからプログラムで生成する場合は、相対パスをp.posix.joinAll(p.split(relativePath))またはp.posix.join(...)で変換してください。
4. モック可能なファイルシステム(package:file とグローバルな p.* の比較)
package:file を使用するコードベース(例:CLI アプリケーションや MemoryFileSystem でテストするサービス)では、File または Directory のパスに対してトップレベルの p.* 関数を呼び出さないでください。
- トップレベルの
p.*関数は、テストを実行しているホスト OS に紐付いています。 - Linux または macOS のランナーで単体テストが
MemoryFileSystem(style: FileSystemStyle.windows)を作成する場合、グローバルなp.split(file.path)は\ではなく/で分割するため、テストが機能しなくなります。
ルール: 必ず FileSystem に紐付いた Context(file.fileSystem.path)を使用してください。
import 'package:file/file.dart';
List<String> listSubdirectoryNames(Directory dir) {
final pathContext = dir.fileSystem.path;
return dir
.listSync()
.whereType<Directory>()
.map((d) => pathContext.basename(d.path))
.toList();
}
5. 拡張子、複合拡張子、ファイル名の拡張子を除いた部分の抽出
ファイル拡張子の抽出やコンテンツハッシュの挿入に、.lastIndexOf('.') や .substring() による手動計算を使わないでください。p.extension は、オプションの level パラメーターを使って複数階層の拡張子をネイティブにサポートします。
- 複数のドットを含むファイル名の注意点:
p.extension('main.dart.wasm', 2)を呼び出すと、最後のドット区切りの 2 つのセグメントをそのまま取得するため、'.dart.wasm'が返されます。複数のドットを含むファイル名のファイル(例:main.dart.wasmとmain.dart.js.map)でハッシュ化や拡張子の削除を行う場合は、p.extension(filename, 2)が既知の複合拡張子に一致するか(または.endsWith('.map')を満たすか)を確認してから、単一階層のp.extension(filename)にフォールバックしてください。
import 'package:path/path.dart' as p;
String insertContentHash(String filename, String hash) {
final compoundExt = p.extension(filename, 2);
// Only use the 2-level extension for true compound suffixes (e.g., '.js.map')
final ext = compoundExt.endsWith('.map')
? compoundExt
: p.extension(filename);
final stem = filename.substring(0, filename.length - ext.length);
return '$stem.$hash$ext';
}
6. ワークフローと監査チェックリスト
パスのリファクタリングチェックリスト
- 文字列補間(
'$dir/$file')をp.join(dir, file)に置き換えます。 -
.contains('dir/')と.startsWith('dir/')を、p.split(path)のリストパターンチェック(case ['dir', ...final rest])またはp.isWithin(parent, child)に置き換えます。 -
.replaceAll(r'\', '/')をp.posix.joinAll(p.split(path))(またはp.url.joinAll)に置き換えます。 - ファイルパスに対する
.endsWith('.ext')をp.extension(path) == '.ext'に置き換えます。 - ドット位置を使った手動のスライス処理を
p.withoutExtension(path)とp.extension(path, [level])に置き換えます。 -
package:fileを使うコードで、グローバルなp.*ではなくfileSystem.pathにアクセスしていることを確認します。 - Git のパス、
.gitignoreのエントリー、シンボリックリンクのターゲットで、p.posixのスラッシュが使われていることを確認します。
参考資料と例
- クロスプラットフォームのパスと POSIX 変換の例:examples/cross_platform_paths.dart
- モック可能な FileSystem パスコンテキストの例:examples/file_system_context.dart