在Bash中提取文件名和扩展名

2794

我想要分别获取文件名(不含扩展名)和扩展名。

到目前为止,我找到的最佳解决方案是:

NAME=`echo "$FILE" | cut -d'.' -f1`
EXTENSION=`echo "$FILE" | cut -d'.' -f2`
这是错误的,因为如果文件名包含多个 . 字符,则无法正常工作。例如,如果我有a.b.js,它会考虑ab.js,而不是a.bjs。可以使用以下Python代码轻松解决:
file, ext = os.path.splitext(path)

但如果可能的话,我不想为此启动Python解释器。

有更好的主意吗?


41
在应用以下的优秀答案时,不要像我曾经做过的那样,简单地将变量粘贴进去,例如:错误示例: extension="{$filename##*.}"!请将 $ 符号移动到大括号外面,示例正确的写法为: 正确示例: extension="${filename##*.}" - Krista K
4
这显然是一个不容易解决的问题,对我来说很难判断下面的答案是否完全正确。令人惊奇的是,这不是(ba)sh的内置操作(答案似乎是使用模式匹配实现该函数)。因此,我决定像上面那样使用Python的os.path.splitext... - Peter Gibson
1
由于扩展名必须代表文件的性质,因此有一个“魔法”命令可以检查文件以确定其性质并提供标准扩展名。请参见我的回答 - F. Hauri - Give Up GitHub
6
这个问题本身存在问题,因为从操作系统和Unix文件系统的角度来看,不存在所谓的文件扩展名。使用"."分隔部分是一种人类约定,只有在人类同意遵循它的情况下才能起作用。例如,在“tar”程序中,可以决定使用“tar.”前缀而不是“.tar”后缀来命名输出文件--使用“tar.somedir”代替“somedir.tar”。由于这个原因,没有“通用,总是起作用”的解决方案--您必须编写与您特定需求和预期文件名匹配的代码。 - C. M.
1
文件 xyzzy.tar.gz 的扩展名是什么?或者 plugh.cfg.saved 呢?换句话说,您是将扩展名视为简单的技术问题还是语义问题? - paxdiablo
显示剩余4条评论
38个回答

4372

首先,获取不带路径的文件名:

filename=$(basename -- "$fullfile")
extension="${filename##*.}"
filename="${filename%.*}"

或者,您可以将焦点放在路径的最后一个 '/' 上,而不是 '.',这样即使您具有不可预测的文件扩展名也可以正常工作:

filename="${fullfile##*/}"

您可以查看文档:


93
请查看http://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html#Shell-Parameter-Expansion获取完整的功能集。 - D.Shawley
27
给"$fullfile"加上引号,否则可能会破坏文件名。 - lhunath
53
你甚至可以编写filename="${fullfile##*/}",避免调用额外的basename函数。 - ephemient
54
如果文件没有扩展名,这个“解决方案”就不起作用了,相反,将输出整个文件名,考虑到没有扩展名的文件无处不在,这是相当糟糕的。 - nccc
48
处理没有文件扩展名的文件名的解决方法:extension=$([[ "$filename" = *.* ]] && echo ".${filename##*.}" || echo '')。请注意,如果存在扩展名,则将返回该扩展名,包括初始的“.”,例如“.txt”。 - mklement0
显示剩余13条评论

1114
~% FILE="example.tar.gz"

~% echo "${FILE%%.*}"
example

~% echo "${FILE%.*}"
example.tar

~% echo "${FILE#*.}"
tar.gz

~% echo "${FILE##*.}"
gz

详情请参见 Bash 手册中的 shell 参数展开


37
你(也许是无意中)提出了一个很好的问题,即如果文件名的“扩展名”部分有两个点,例如.tar.gz,该怎么办...我以前从未考虑过这个问题,我怀疑如果没有事先知道所有可能有效的文件扩展名,那么这个问题是无法解决的。 - rmeador
10
为什么不能解决?在我的例子中,应该考虑到文件包含两个扩展名,而不是一个带有两个点的扩展名。您需要分别处理这两个扩展名。 - Juliano
30
它在词汇基础上是不可解决的,你需要检查文件类型。想象一下,如果你有一个名为“dinosaurs.in.tar”的游戏文件,并将其压缩成“dinosaurs.in.tar.gz” :) - porges
13
如果你传入完整路径,情况就变得更加复杂。我的其中一个路径中的目录中间有一个点,但文件名中没有点。例如 "a/b.c/d/e/filename" 最终会变成 ".c/d/e/filename"。 - Walt Sellers
16
很明显,x.tar.gz的扩展名不是gz,文件名是x.tar。不存在双重扩展名这种情况。我很确定boost::filesystem可以处理这种情况(拆分路径、更改扩展名...),它的行为类似于Python。 - v.oddou
显示剩余5条评论

619

通常您已经知道文件扩展名,因此您可能希望使用以下方法:

basename filename .extension

例如:

basename /path/to/dir/filename.txt .txt

然后我们得到

filename

95
basename的第二个参数真是大开眼界,感谢您的好意先生/女士 :) - akaIDIOT
14
怎样使用这个技术来提取扩展名?;) 等等!实际上我们事先并不知道。 - Tomasz Gandor
5
假设你有一个以.zip.ZIP结尾的压缩目录,你是否可以像这样执行basename $file {.zip,.ZIP} - Dennis
11
虽然这只回答了提问者问题的部分内容,但它确实回答了我在谷歌上输入的问题。 :-) 非常流畅! - sudo make install
1
易于使用且符合POSIX标准。 - gpanda
显示剩余2条评论

210

您可以使用 POSIX 参数扩展的神奇功能:

bash-3.2$ FILENAME=somefile.tar.gz
bash-3.2$ echo "${FILENAME%%.*}"
somefile
bash-3.2$ echo "${FILENAME%.*}"
somefile.tar

如果你的文件名是形如./somefile.tar.gz,那么使用echo ${FILENAME%%.*}会贪婪地删除最长的匹配项到.,这样你会得到一个空字符串。

(你可以使用一个临时变量来解决这个问题:

FULL_FILENAME=$FILENAME
FILENAME=${FULL_FILENAME##*/}
echo ${FILENAME%%.*}

)


这个网站提供了更多解释。

${variable%pattern}
  Trim the shortest match from the end
${variable##pattern}
  Trim the longest match from the beginning
${variable%%pattern}
  Trim the longest match from the end
${variable#pattern}
  Trim the shortest match from the beginning

5
比Joachim的答案简单得多,但我总是不得不查找POSIX变量替换。此外,这在Max OSX上运行,在那里cut命令没有"--complement"选项,而sed命令也没有"-r"选项。 - jwadsack
适用于 Windows 上的 Git-bash - **GNU bash, version 5.2.15(1)-release (x86_64-pc-msys)**。 - Mache

91

如果文件没有扩展名或者没有文件名,这种方法似乎不起作用。这是我使用的代码; 它仅使用内置函数并处理更多(但不是全部)的异常文件名。

#!/bin/bash
for fullpath in "$@"
do
    filename="${fullpath##*/}"                      # Strip longest match of */ from start
    dir="${fullpath:0:${#fullpath} - ${#filename}}" # Substring from 0 thru pos of filename
    base="${filename%.[^.]*}"                       # Strip shortest match of . plus at least one non-dot char from end
    ext="${filename:${#base} + 1}"                  # Substring from len of base thru end
    if [[ -z "$base" && -n "$ext" ]]; then          # If we have an extension and no base, it's really the base
        base=".$ext"
        ext=""
    fi

    echo -e "$fullpath:\n\tdir  = \"$dir\"\n\tbase = \"$base\"\n\text  = \"$ext\""
done

以下是一些测试用例:

$ basename-and-extension.sh / /home/me/ /home/me/file /home/me/file.tar /home/me/file.tar.gz /home/me/.hidden /home/me/.hidden.tar /home/me/.. .
/:
    dir  = "/"
    base = ""
    ext  = ""
/home/me/:
    dir  = "/home/me/"
    base = ""
    ext  = ""
/home/me/file:
    dir  = "/home/me/"
    base = "file"
    ext  = ""
/home/me/file.tar:
    dir  = "/home/me/"
    base = "file"
    ext  = "tar"
/home/me/file.tar.gz:
    dir  = "/home/me/"
    base = "file.tar"
    ext  = "gz"
/home/me/.hidden:
    dir  = "/home/me/"
    base = ".hidden"
    ext  = ""
/home/me/.hidden.tar:
    dir  = "/home/me/"
    base = ".hidden"
    ext  = "tar"
/home/me/..:
    dir  = "/home/me/"
    base = ".."
    ext  = ""
.:
    dir  = ""
    base = "."
    ext  = ""

3
我经常看到人们使用dir="${fullpath%$filename}"来代替dir="${fullpath:0:${#fullpath} - ${#filename}}"。这种写法更简单。不确定是否有任何速度差异或潜在风险。 - dubiousjim
2
这里使用的是 #!/bin/bash,这几乎总是错误的。如果可能的话,请优先使用 #!/bin/sh 或者 #!/usr/bin/env bash。 - Good Person
3
在许多发行版中,Bash 位于 /usr/local/bin/bash。在 MacOS 上,许多人会将更新的 Bash 安装在 /opt/local/bin/bash 中。因此,使用 /bin/bash 是错误的,应该使用 env 命令来查找它。更好的选择是使用 /bin/sh 和 POSIX 构造。除了 Solaris 外,这是一个 POSIX shell。 - Good Person
2
@GoodPerson 但是如果你更喜欢使用bash,为什么要使用sh呢?这不就像说,为什么要使用sh而不是Perl一样吗? - vol7ron
1
@vol7ron 如果你知道它不具备可移植性,那么使用bash也无妨。这就像使用python/ruby等语言一样。只是bash在很多系统上并不是默认的,因此它不是一个可移植的构造。个人而言,我会在只有我自己使用的脚本中使用zsh,在共享脚本中使用POSIX sh。 - Good Person
显示剩余8条评论

80
pax> echo a.b.js | sed 's/\.[^.]*$//'
a.b
pax> echo a.b.js | sed 's/^.*\.//'
js

这个方法运行良好,所以你可以直接使用:

pax> FILE=a.b.js
pax> NAME=$(echo "$FILE" | sed 's/\.[^.]*$//')
pax> EXTENSION=$(echo "$FILE" | sed 's/^.*\.//')
pax> echo $NAME
a.b
pax> echo $EXTENSION
js

顺便说一下,这些命令的工作方式如下。

NAME 命令会将一条线路上最后一个点号到行尾的所有非点号字符替换为一个点号,然后删除它们(也就是从最后一个点号到行尾,包括最后一个点号的所有内容都被删除)。这基本上是使用正则表达式技巧来进行非贪婪替换的操作。

EXTENSION 命令会将一条线路最开始的任意数量的字符和点号替换为空(也就是从行首到最后一个点号,包括最后一个点号的所有内容都被删除)。这是一种贪婪替换,也是默认操作。


这个程序用于处理没有扩展名的文件,因为它会将名称和扩展名一起打印。所以我使用 sed 's,\.[^\.]*$,,' 处理名称,使用 sed 's,.*\.,., ;t ;g' 处理扩展名(使用了不典型的 testget 命令,以及典型的 substitute 命令)。 - hIpPy
在计算了NAME之后,您可以进行测试以查看它和FILE是否相等,如果相等,则将EXTENSION设置为空字符串。 - JCCyC
从根本上讲,对于shell本身可以完成的任务而言,使用外部进程是一种反模式。 - tripleee
tripleee:在一百行代码内,Shell 可以做很多事情,而像 awk 这样的外部进程可能只需要五行就能完成 :-) - paxdiablo
这个处理多部分扩展名,比如 .tar.gz 吗? - jubilatious1
@jubilatious1:是的 - 这是一个gzip文件(通常包含一个tar文件),因此扩展名为gz。我使用的定义是,最后一个 .之后的所有内容都是扩展名。如果您需要不同的定义,则需要进行调整,但是那样您将遇到各种边缘情况,例如user_pax.diablo_details.txt。扩展名是txt还是diablo_details.txt?在我看来,后者显然是错误的。 - paxdiablo

50
你可以使用basename
示例:
$ basename foo-bar.tar.gz .tar.gz
foo-bar

您需要提供要删除的扩展名和文件名,但是如果您总是使用-z选项运行tar命令,则知道扩展名将是.tar.gz

这样做可以达到您想要的效果:

tar -zxvf $1
cd $(basename $1 .tar.gz)

2
我猜 cd $(basename $1 .tar.gz) 对于 .gz 文件有效。但在问题中他提到 归档文件有几个扩展名:tar.gz、tat.xz、tar.bz2 - SS Hegde
Tomi Po在2年前发布了相同的内容。 - phil294
嗨Blauhirn,哇,这是一个旧问题。我认为日期上发生了一些事情。我清楚地记得在问题被提出后不久回答了这个问题,而且回答只有几个。可能问题已经与另一个问题合并了,SO会这样做吗? - Bjarke Freund-Hansen
是的,我记得很清楚。我最初回答了这个问题https://stackoverflow.com/questions/14703318/bash-script-remove-extension-from-file-name,在同一天被问出来,两年后它被合并到了这个问题中。当我的答案被这样移动时,我几乎不能因为重复回答而受到责备。 - Bjarke Freund-Hansen

44

Mellen在博客评论中写道:

使用Bash,还可以使用${file%.*}来获取没有扩展名的文件名,以及${file##*.}仅获取扩展名。也就是说,

file="thisfile.txt"
echo "filename: ${file%.*}"
echo "extension: ${file##*.}"

输出:

filename: thisfile
extension: txt

2
@REACHUS:请查看http://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html。 - mklement0

37

这里有一些替代方案(主要是在awk中),包括一些高级用例,例如提取软件包版本号。

请注意,对于稍微不同的输入,其中一些可能会失败,因此任何使用这些方案的人都应该在其预期的输入上验证,并根据需要调整正则表达式。

f='/path/to/complex/file.1.0.1.tar.gz'

# Filename : 'file.1.0.x.tar.gz'
    echo "$f" | awk -F'/' '{print $NF}'

# Extension (last): 'gz'
    echo "$f" | awk -F'[.]' '{print $NF}'
    
# Extension (all) : '1.0.1.tar.gz'
    echo "$f" | awk '{sub(/[^.]*[.]/, "", $0)} 1'
    
# Extension (last-2): 'tar.gz'
    echo "$f" | awk -F'[.]' '{print $(NF-1)"."$NF}'

# Basename : 'file'
    echo "$f" | awk '{gsub(/.*[/]|[.].*/, "", $0)} 1'

# Basename-extended : 'file.1.0.1.tar'
    echo "$f" | awk '{gsub(/.*[/]|[.]{1}[^.]+$/, "", $0)} 1'

# Path : '/path/to/complex/'
    echo "$f" | awk '{match($0, /.*[/]/, a); print a[0]}'
    # or 
    echo "$f" | grep -Eo '.*[/]'
    
# Folder (containing the file) : 'complex'
    echo "$f" | awk -F'/' '{$1=""; print $(NF-1)}'
    
# Version : '1.0.1'
    # Defined as 'number.number' or 'number.number.number'
    echo "$f" | grep -Eo '[0-9]+[.]+[0-9]+[.]?[0-9]?'

    # Version - major : '1'
    echo "$f" | grep -Eo '[0-9]+[.]+[0-9]+[.]?[0-9]?' | cut -d. -f1

    # Version - minor : '0'
    echo "$f" | grep -Eo '[0-9]+[.]+[0-9]+[.]?[0-9]?' | cut -d. -f2

    # Version - patch : '1'
    echo "$f" | grep -Eo '[0-9]+[.]+[0-9]+[.]?[0-9]?' | cut -d. -f3

# All Components : "path to complex file 1 0 1 tar gz"
    echo "$f" | awk -F'[/.]' '{$1=""; print $0}'
    
# Is absolute : True (exit-code : 0)
    # Return true if it is an absolute path (starting with '/' or '~/'
    echo "$f" | grep -q '^[/]\|^~/'
 

所有用例都使用原始完整路径作为输入,而不依赖于中间结果。


我的awk版本不喜欢 # 路径:'/path/to/complex/' echo“$f”| awk '{match($0,/.*[/]/,a); print a[0]}' - Lucas Soares

35

对于这个简单的任务,不需要费心处理 awksed,甚至 perl。有一个纯 Bash 的方案,它与 os.path.splitext() 兼容,并且只使用参数扩展。

参考实现

os.path.splitext(path) 文档:

将路径名 path 拆分为一对 (root, ext),使得 root + ext == path,并且 ext 为空或以句点开头且最多包含一个句点。文件名中开头的句点会被忽略;splitext('.cshrc') 返回 ('.cshrc', '')

Python 代码:

root, ext = os.path.splitext(path)

Bash 实现

保留前导句点

root="${path%.*}"
ext="${path#"$root"}"

忽略前导点号

root="${path#.}";root="${path%"$root"}${root%.*}"
ext="${path#"$root"}"

测试

这里是“忽略前导句点”实现的测试用例,每个输入应该与Python参考实现匹配。

|---------------|-----------|-------|
|path           |root       |ext    |
|---------------|-----------|-------|
|' .txt'        |' '        |'.txt' |
|' .txt.txt'    |' .txt'    |'.txt' |
|' txt'         |' txt'     |''     |
|'*.txt.txt'    |'*.txt'    |'.txt' |
|'.cshrc'       |'.cshrc'   |''     |
|'.txt'         |'.txt'     |''     |
|'?.txt.txt'    |'?.txt'    |'.txt' |
|'\n.txt.txt'   |'\n.txt'   |'.txt' |
|'\t.txt.txt'   |'\t.txt'   |'.txt' |
|'a b.txt.txt'  |'a b.txt'  |'.txt' |
|'a*b.txt.txt'  |'a*b.txt'  |'.txt' |
|'a?b.txt.txt'  |'a?b.txt'  |'.txt' |
|'a\nb.txt.txt' |'a\nb.txt' |'.txt' |
|'a\tb.txt.txt' |'a\tb.txt' |'.txt' |
|'txt'          |'txt'      |''     |
|'txt.pdf'      |'txt'      |'.pdf' |
|'txt.tar.gz'   |'txt.tar'  |'.gz'  |
|'txt.txt'      |'txt'      |'.txt' |
|---------------|-----------|-------|

测试结果

所有测试通过。


4
不,text.tar.gz 的基本文件名应该是 text,扩展名为 .tar.gz - frederick99
3
正如我所说,这里的解决方案与Python中os.path.splitext的实现相匹配。该实现是否适用于可能引起争议的输入是另一个话题。 - Cyker
引号在模式("$root")中的作用是什么?如果省略它们会发生什么?(我找不到任何相关文档。)此外,这如何处理文件名中带有*?的情况? - ymett
好的,测试结果表明引号使模式成为字面量,即 *? 不是特殊字符。因此,我问题的两个部分互相回答了。我正确地认为这没有记录吗?还是说这应该从引号通常禁用全局扩展的事实中理解? - ymett
1
太棒了!我只是想提供一个稍微简单一点的计算根目录的变体:root="${path#?}";root="${path::1}${root%.*}" — 然后按照相同的方式提取扩展名。 - Maëlan

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接