できない.dev

パスからファイル名を取り出すには

パスの最後の要素であるファイル名を取り出す基本形を各言語で示す。
拡張子を除いた名前の求め方に加え、末尾に / が付いたパスで空文字が返るかどうかと、Windows 形式のパスや URL から取り出すときの注意までを扱う。

公開:

各言語見出しの横のバッジは検証状態を表す。実行確認済みはコードを実際に実行して確認したもの、静的確認は構文と公式 API ドキュメントで確認したものである。

Python 実行確認済み

from pathlib import Path, PureWindowsPath
import os
 
p = Path("data/logs/app.log")
print(p.name)                      # app.log
print(p.stem)                      # app
print(Path("archive.tar.gz").stem)  # archive.tar
 
# 末尾に / があると、os.path.basename は空文字を返す
print(Path("data/logs/").name)                # logs
print(repr(os.path.basename("data/logs/")))  # ''
 
# Windows 形式のパスは PureWindowsPath で区切る
print(PureWindowsPath(r"C:\Users\taro\report.xlsx").name)  # report.xlsx
 
# このスクリプト自身のファイル名
print(Path(__file__).name)  # main.py

Path.name はパスの最後の要素を返し、拡張子を除いた名前は stem で得られる。
stem が取り除くのは最後の拡張子だけなので、archive.tar.gz は archive.tar になる。
os.path.basename は最後の / より後ろをそのまま返すため、data/logs/ のように末尾に / が付いたパスでは空文字になる。

JavaScript 実行確認済み

import path from "node:path";
 
console.log(path.basename("data/logs/app.log"));         // app.log
console.log(path.basename("data/logs/app.log", ".log")); // app
console.log(path.basename("data/logs/"));                // logs
 
// 拡張子が決まっていないときは extname で求めてから落とす
const p = "archive.tar.gz";
console.log(path.basename(p, path.extname(p)));          // archive.tar
 
// Windows 形式のパスは path.win32 で区切る
console.log(path.win32.basename("C:\\Users\\taro\\report.xlsx")); // report.xlsx
 
// ESM でこのファイル自身の名前を得る(Node.js 20.11 以降)
console.log(path.basename(import.meta.filename));        // main.mjs

path.basename は最後の要素を返し、第 2 引数に拡張子を渡すとそれを取り除いた名前になる。
第 2 引数は大文字小文字を区別して末尾と比べるので、photo.JPG に .jpg を渡しても何も削られず、拡張子が決まっていないときは path.extname の結果を渡す。
ESM では __filename が定義されないため、自分自身のファイル名は import.meta.filename(Node.js 20.11 以降)から取り出す。

うまくいかない時: Node.js の ESM で「__dirname is not defined」が解決できない

TypeScript 実行確認済み

// node:path を使わないので、ブラウザでもそのまま動く
function fileNameOf(p: string): string {
  // 末尾の区切り文字を落としてから、/ と \ のどちらでも区切る
  const trimmed: string = p.replace(/[\\/]+$/, "");
  return trimmed.split(/[\\/]/).pop() ?? "";
}
 
// URL のときは、クエリを除いたパスから取り出して %XX を元の文字に戻す
function fileNameOfUrl(url: string): string {
  return decodeURIComponent(fileNameOf(new URL(url).pathname));
}
 
console.log(fileNameOf("data/logs/app.log"));            // app.log
console.log(fileNameOf("data/logs/"));                   // logs
console.log(fileNameOf("C:\\Users\\taro\\report.xlsx")); // report.xlsx
console.log(fileNameOfUrl("https://example.com/files/%E8%A6%8B%E7%A9%8D.pdf?v=2")); // 見積.pdf

node:path を使わずに文字列を区切るので、同じ関数をブラウザでも使える。
/ と \ の両方を区切り文字として扱うため Windows 形式のパスも分けられるが、Linux のファイル名に含まれた \ まで区切ってしまう点には注意する。
pop() の戻り値は string | undefined 型なので、?? で空文字に置き換えてから返す。
URL では new URL の pathname でクエリを外し、decodeURIComponent で %XX を元の文字に戻すが、壊れた %XX があると URIError を投げる。

Go 実行確認済み

package main
 
import (
	"fmt"
	"path"
	"path/filepath"
	"strings"
)
 
func main() {
	name := filepath.Base("data/logs/app.log")
	fmt.Println(name)                                         // app.log
	fmt.Println(strings.TrimSuffix(name, filepath.Ext(name))) // app
 
	// 末尾の / は無視され、空文字には . が、ルートには / が返る
	fmt.Println(filepath.Base("data/logs/")) // logs
	fmt.Println(filepath.Base(""))           // .
	fmt.Println(filepath.Base("/"))          // /
 
	// URL のパスは OS に関係なく / 区切りなので path.Base を使う
	fmt.Println(path.Base("/files/report.pdf")) // report.pdf
}

filepath.Base は最後の要素を返し、末尾の / は取り除いてから判定する。
空文字のパスには . を、ルートには / を返すので、空文字が返ることを前提にした判定は書かない。
拡張子を除いた名前を返す関数は無いため、filepath.Ext で求めた拡張子を strings.TrimSuffix で取り除く。
URL のパスのように常に / で区切られる文字列には、OS の区切り文字に従う filepath ではなく path.Base を使う。

Rust 実行確認済み

use std::path::Path;
 
fn main() {
    let p = Path::new("data/logs/app.log");
    println!("{:?}", p.file_name()); // Some("app.log")
    println!("{:?}", p.file_stem()); // Some("app")
    println!("{:?}", Path::new("data/logs/").file_name()); // Some("logs")
 
    // ルートと .. で終わるパスでは None になる
    println!("{:?}", Path::new("/").file_name()); // None
    println!("{:?}", Path::new("data/..").file_name()); // None
 
    // 文字列として使うときは to_str で &str にする(UTF-8 でない名前では None)
    let name: &str = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
    println!("{name}"); // app.log
}

file_name は最後の要素を Option<&OsStr> で返し、ルートや .. で終わるパスでは None になる。
拡張子を除いた名前は file_stem で得られる。
OsStr は OS のファイル名そのものなので、文字列として使う前に to_str で &str に変換し、UTF-8 でない名前で None になる場合に備えて unwrap_or などで既定値を決めておく。

Java 実行確認済み

import java.nio.file.Path;
 
public class Main {
    public static void main(String[] args) {
        Path p = Path.of("data/logs/app.log");
        String name = p.getFileName().toString();
        System.out.println(name); // app.log
 
        // 拡張子を除いた名前は、最後のドットより前を切り出す
        int dot = name.lastIndexOf('.');
        System.out.println(dot > 0 ? name.substring(0, dot) : name); // app
 
        System.out.println(Path.of("data/logs/").getFileName()); // logs
        // ルートでは null が返る
        System.out.println(Path.of("/").getFileName()); // null
    }
}

getFileName は最後の要素を Path で返すので、文字列として使うときは toString を呼ぶ。
ルートの / では null が返り、そのまま toString を呼ぶと NullPointerException になる。
拡張子を除いた名前を返すメソッドは標準 API に無いため、最後のドットより前を切り出す。
条件を dot > 0 にしておくと、.bashrc のようにドットで始まる名前が空文字にならない。

C# 実行確認済み

using System.IO;
 
Console.WriteLine(Path.GetFileName("data/logs/app.log"));                 // app.log
Console.WriteLine(Path.GetFileNameWithoutExtension("data/logs/app.log")); // app
 
// 末尾に / があると空文字が返るので、先に区切り文字を落とす
Console.WriteLine(Path.GetFileName("data/logs/") == "");                  // True
Console.WriteLine(Path.GetFileName(Path.TrimEndingDirectorySeparator("data/logs/"))); // logs
 
// Linux では \ が区切り文字にならず、全体が 1 つの名前として返る
Console.WriteLine(Path.GetFileName(@"C:\Users\taro\report.xlsx")); // C:\Users\taro\report.xlsx

Path.GetFileName は最後の区切り文字より後ろを返す文字列操作で、末尾に / が付いたパスでは空文字になる。
ディレクトリのパスを受け取る場面では、Path.TrimEndingDirectorySeparator(.NET Core 3.0 以降)で末尾の区切り文字を落としてから渡す。
拡張子を除いた名前は GetFileNameWithoutExtension で得られる。
Linux では \ が区切り文字として扱われないため、Windows 形式のパスは全体が 1 つの名前として返る。

つまずき

ディレクトリのパスの末尾に / が付いているときの結果が言語で割れる。
Python の pathlib、Node.js の path.basename、Go の filepath.Base、Rust の file_name、Java の getFileName は末尾の区切り文字を無視して data/logs/ から logs を返すが、Python の os.path.basename と C# の Path.GetFileName は最後の区切り文字より後ろをそのまま返すので空文字になる。
設定値やコマンドライン引数で受け取ったディレクトリの名前を取り出すと、末尾に / を付けたかどうかで結果が変わる。
後者の関数を使うときは、先に os.path.normpath や Path.TrimEndingDirectorySeparator で末尾の区切り文字を取り除く。

ルート・空文字・.. のときに返る値

ファイル名に当たる部分が無いパスでは、返る値が言語ごとに違う。
ルートの / に対しては、Python・Node.js・C# は空文字、Go は / 自身、Rust は None、Java は null を返す。
空文字のパスでは Go だけが . を返し、Rust は None になる。
data/.. のように .. で終わるパスでは Rust だけが None を返し、ほかの言語は .. をそのまま名前として返す(いずれも本記事の作成時に Linux で確認した)。
取り出した名前でファイルを作ったり比べたりする前に、空文字・null・None・.. を弾いておく。

拡張子を除いた名前は、ドットで始まる名前で結果が割れる

どの言語のサンプルも、拡張子として取り除くのは最後のドット以降だけなので、archive.tar.gz は archive.tar になる。
.bashrc のようにドットで始まるだけの名前では、Python の stem、Node.js の basename と extname の組み合わせ、Rust の file_stem、サンプルの Java の書き方は .bashrc をそのまま返す。
一方で Go の TrimSuffix と Ext の組み合わせと C# の GetFileNameWithoutExtension は名前全体を拡張子とみなすので、空文字になる。
ドットで始まる設定ファイルを扱う処理では、空文字になったときに元の名前を使うようにしておく。

出力例と、Windows 形式のパスや URL

コード中のコメントに書いた出力は、Linux でカレントディレクトリを /work にして実行したものである。
Linux では \ が区切り文字として扱われないため、C:\Users\taro\report.xlsx のような Windows 形式のパスを Python の Path、Node.js の path.basename、Go の filepath.Base、Rust の file_name、Java の getFileName、C# の Path.GetFileName に渡すと、どれも文字列全体が 1 つのファイル名として返る。
Windows で作られたパスを Linux のサーバーで扱うときは、Python の PureWindowsPath や Node.js の path.win32 のように Windows の規則を明示するか、TypeScript のサンプルのように両方の区切り文字で分ける。
URL から取り出すときはクエリ文字列(?v=2 など)と %XX のエンコードが残るので、パスの部分だけを取り出してからデコードする。

この記事は役立ちましたか?