65.9K
CodeProject 正在变化。 阅读更多。
Home

带通配符、Glob 和 Gitignore 样式 Glob 的快速字符串匹配 - 如何不让它爆炸

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (43投票s)

2019 年 8 月 5 日

CPOL

11分钟阅读

viewsIcon

45051

downloadIcon

1453

经典的 globbing 和现代的 gitignore 样式 globbing 算法可以很快,而递归实现则以指数级爆炸而闻名;为什么有些免费提供的源代码不应使用。

引言

Jack Handy 的 通配符字符串比较 可高效地将字符串与包含 *? 通配符的模式进行比较。该算法快速且安全,但不支持 gitignore 风格的 globbing 规则。在本文中,我将说明经典的 globbing 和现代的 gitignore 风格的 globbing 算法也可以很快。我还将解释为什么会爆炸式增长的递归实现存在问题,以及为什么有些免费提供的源代码不应使用。

背景

通配符字符串匹配和 globbing 并非初看起来那么简单。事实上,过去的错误已导致严重的 漏洞,例如拒绝服务。简单的补丁,例如限制匹配数量以限制 CPU 时间,已被应用于修复执行时间呈指数级爆炸的实现。更令人不安的是,有缺陷的 globbing 实现可以在网上轻松找到:只需 搜索 wildmat.c 即可找到可能在字符类范围不完整(例如 [a-)时崩溃的实现副本。

递归的问题

为了理解 globbing 实现如何可能导致拒绝服务,我们将快速回顾一些示例。在通配符匹配 *? 的简单实现中,通常使用递归。其思想是从左到右扫描模式和字符串,同时成对匹配字符。当在模式中遇到星号时,将递归调用 do-match 函数,以将模式的其余部分与字符串的其余部分进行比较。如果递归的 do-match 调用成功,那么我们就完成了。但是,如果递归调用失败,我们将回退到递归调用之前的位置,在 string 中向前推进一个字符,然后重复递归调用。此循环是“star-loop”,它在模式的星号处消耗零个、一个或多个字符,如下面的示例所示。

// returns TRUE if text string matches wild pattern with * and ?
int naive_recursive_match(const char *text, const char *wild)
{
  while (*text != '\0')
  {
    if (*wild == '*')
    {
      // any number of stars act as one star
      while (*++wild == '*')
        continue;
      // a star at the end of the pattern matches any text
      if (*wild == '\0')
        return TRUE;
      // star-loop: match the rest of the pattern and text
      while (naive_recursive_match(text, wild) == FALSE && *text != '\0')
        text++;
      return *text != '\0' ? TRUE : FALSE;
    }
    // ? matches any character or we match the current non-NUL character
    if (*wild != '?' && *wild != *text)
      return FALSE;
    text++;
    wild++;
  }
  // ignore trailing stars
  while (*wild == '*')
    wild++;
  // at end of text means success if nothing else is left to match
  return *wild == '\0' ? TRUE : FALSE;
}

该算法相当简单,只需很少的解释:为星号调用递归,直到匹配或到达 string 文本的末尾。? 匹配任何单个字符。否则,将当前模式字符与文本中的当前字符进行匹配。我们将文本和模式向前移动一个字符,然后重复,直到到达文本末尾。请注意,当到达文本末尾时,可以忽略模式中的任何尾随星号。

这种方法对于要匹配的短字符串和星号很少的模式效果很好。但是,很容易找到由于星号上的过度回溯而导致执行时间爆炸的示例。例如,a*a*a*a*a*a*a*a*b 和包含 100 个 a 的字符串 aaa…aaa 需要几分钟才能终止并报告匹配失败。只需尝试以下两个 shell 命令

$ touch `python3 -c 'print(100*"a")'`
$ ls a*a*a*a*a*a*a*a*b

在我的 2.9GHz i7 机器上,bash-3.2 大约需要 8 分钟才能终止,性能相当不错。tcsh-6.18 则需要一个多小时。2*n 个状态对于 *n 个星号 是造成这种行为的原因

使用 ABORT

历史上最早的 globbing 算法之一,由 Rich Salz 编写的 wildmat.c(原始版)具有这种不良行为,但后来在 2000 年代初由 Lars Mathiesen 使用 ABORT 状态进行了更新。他们对更新后的 wildmat.c 源代码 的注释至今仍在,并且非常有见地。

引用

一旦 DoMatch 的一个实例的控制进入 star-loop,该实例将返回 TRUE 或 ABORT,任何调用实例因此将在(不再次递归调用后)立即返回。实际上,只有一个 star-loop 处于活动状态。可以修改代码以显式维护此上下文,从而消除所有递归调用,但会增加一些复杂性并降低清晰度(并且 ABORT 的东西本身似乎就足够不清楚了)。

显然,指数级爆炸问题在那时已经广为人知,因为我们在 wildmat.c 中发现了以下注释,演示了这个问题。

pattern: -*-*-*-*-*-*-12-*-*-*-m-*-*-*
text 1:  -adobe-courier-bold-o-normal--12-120-75-75-m-70-iso8859-1
text 2:  -adobe-courier-bold-o-normal--12-120-75-75-X-70-iso8859-1
Text 1 matches with 51 calls, while text 2 fails with 54 calls. 
Without the ABORT, then it takes 22310 calls to fail.  Ugh.

这里强大的洞察力是,只有最后一个 star-loop 应该是活动的,因为当最后一个 star-loop 失败时,推进任何之前的 star-loop 都没有成效。这个巧妙的更改使得 globbing 在典型情况下以线性时间执行。只有在最坏的情况下,该算法在模式和字符串长度上的执行时间才为二次方。

让我们将此改进应用于我们示例中的递归匹配器。

enum { FALSE = 0, TRUE = 1, ABORT = 2 };
// returns TRUE if text string matches wild pattern with * and ?
int recursive_match(const char *text, const char *wild)
{
  while (*text != '\0')
  {
    if (*wild == '*')
    {
      int ret;
      // any number of stars act as one star
      while (*++wild == '*')
        continue;
      // a star at the end of the pattern matches any text
      if (*wild == '\0')
        return TRUE;
      // star-loop: match the rest of the pattern and text
      while ((ret = recursive_match(text, wild)) == FALSE && *text != '\0')
        text++;
      // ABORT recursion when failing the last star-loop
      if (ret != TRUE)
        return ABORT;
      return *text != '\0' ? TRUE : FALSE;
    }
    // ? matches any character or we match the current non-NUL character
    if (*wild != '?' && *wild != *text)
      return FALSE;
    text++;
    wild++;
  }
  // ignore trailing stars
  while (*wild == '*')
    wild++;
  // at end of text means success if nothing else is left to match
  return *wild == '\0' ? TRUE : FALSE;
}

此改进避免了指数级爆炸,但仍然为模式中的每个星号进行递归调用。这是不必要的。我们可以保存匹配器的状态,以便从备份中恢复它,以执行最后一个 star-loop 的另一个迭代,直到完成。

非递归通配符字符串匹配

为了用迭代代替递归,我们需要两个备份位置:模式中紧跟星号的当前位置和文本字符串中的当前位置。为了迭代 star-loop,我们恢复备份的位置,在文本字符串中前进一个字符,然后重试。我们一直迭代,直到匹配成功或用完文本。如果星号出现在我们最后一个备份位置之后,我们就会有效地进入一个新的 star-loop,并移除我们之前正在迭代的那个。

// returns TRUE if text string matches wild pattern with * and ?
int match(const char *text, const char *wild)
{
  const char *text_backup = NULL;
  const char *wild_backup = NULL;
  while (*text != '\0')
  {
    if (*wild == '*')
    {
      // new star-loop: backup positions in pattern and text
      text_backup = text;
      wild_backup = ++wild;
    }
    else if (*wild == '?' || *wild == *text)
    {
      // ? matched any character or we matched the current non-NUL character
      text++;
      wild++;
    }
    else
    {
      // if no stars we fail to match
      if (wild_backup == NULL)
        return FALSE;
      // star-loop: backtrack to the last * by restoring the backup positions 
      // in the pattern and text
      text = ++text_backup;
      wild = wild_backup;
    }
  }
  // ignore trailing stars
  while (*wild == '*')
    wild++;
  // at end of text means success if nothing else is left to match
  return *wild == '\0' ? TRUE : FALSE;
}

该算法在典型情况下,执行时间与模式和字符串的长度成线性关系。在最坏的情况下,该算法的执行时间为二次方(要了解原因,请考虑模式 *aaa…aaab,其中包含 ½na,以及字符串 aaa…aaa,其中包含 *n* 个 a,这需要 ¼*n*2 次比较才能失败)。

如果您想知道是否存在线性最坏情况算法,答案是肯定的:通过从模式构建确定性有限自动机 (DFA) 进行匹配。这超出了本文的范围,并且需要考虑 (高昂的) DFA 构建成本。

非递归 Glob 匹配

现在我们回顾了字符串匹配和 globbing 的背景和问题,让我们进入本文的主要部分。Globbing 与字符串匹配的区别在于 *? 通配符在 glob 中具有微妙的含义:这些通配符不匹配路径分隔符,即不匹配 /(在 Windows 中也不匹配 \)。例如,foo*.h 不匹配 foo/bar.h

为了调整我们之前的匹配算法以执行 glob 匹配,我们添加了一些针对 / 路径分隔符的检查,这样 *? 就不能匹配它。

// returns TRUE if text string matches glob-like pattern with * and ?
int globly_match(const char *text, const char *glob)
{
  const char *text_backup = NULL;
  const char *glob_backup = NULL;
  while (*text != '\0')
  {
    if (*glob == '*')
    {
      // new star-loop: backup positions in pattern and text
      text_backup = text;
      glob_backup = ++glob;
    }
    else if ((*glob == '?' && *text != '/') || *glob == *text)
    {
      // ? matched any character except /, or we matched the current non-NUL character
      text++;
      glob++;
    }
    else
    {
      if (glob_backup == NULL || *text_backup == '/')
        return FALSE;
      // star-loop: backtrack to the last * but do not jump over /
      text = ++text_backup;
      glob = glob_backup;
    }
  }
  // ignore trailing stars
  while (*glob == '*')
    glob++;
  // at end of text means success if nothing else is left to match
  return *glob == '\0' ? TRUE : FALSE;
}

然而,对于实际的 glob 匹配应用程序,这个 glob 式(“globly”)算法还不完整。它缺乏对字符类的支持,例如 [a-zA-Z] 匹配字母,[^0-9] 匹配除数字以外的任何字符。它还缺乏一种方法来转义 *?[ 元字符的特殊含义,使用反斜杠。添加这些新功能需要重新设计主循环中的 glob 元字符测试,使用 switch 来选择 glob 元字符,并使用 continue 来启动主循环。从 switch 跳出会将控制权转移到 star-loop。

#define PATHSEP '/'  // pathname separator, we should define '\\' for Windows instead
// returns TRUE if text string matches glob pattern with * and ?
int glob_match(const char *text, const char *glob)
{
  const char *text_backup = NULL;
  const char *glob_backup = NULL;
  while (*text != '\0')
  {
    switch (*glob)
    {
      case '*':
        // new star-loop: backup positions in pattern and text
        text_backup = text;
        glob_backup = ++glob;
        continue;
      case '?':
        // match any character except /
        if (*text == PATHSEP)
          break;
        text++;
        glob++;
        continue;
      case '[':
      {
        int lastchr;
        int matched = FALSE;
        int reverse = glob[1] == '^' || glob[1] == '!' ? TRUE : FALSE;
        // match any character in [...] except /
        if (*text == PATHSEP)
          break; 
        // inverted character class
        if (reverse)
          glob++;
        // match character class
        for (lastchr = 256; *++glob != '\0' && *glob != ']'; lastchr = *glob)
          if (lastchr < 256 && *glob == '-' && glob[1] != ']' && glob[1] != '\0' ?
              *text <= *++glob && *text >= lastchr :
              *text == *glob)
            matched = TRUE;
        if (matched == reverse)
          break;
        text++;
        if (*glob != '\0')
          glob++;
        continue;
      }
      case '\\':
        // literal match \-escaped character
        glob++;
        // FALLTHROUGH
      default:
        // match the current non-NUL character
        if (*glob != *text && !(*glob == '/' && *text == PATHSEP))
          break;
        text++;
        glob++;
        continue;
    }
    if (glob_backup == NULL || *text_backup == PATHSEP)
      return FALSE;
    // star-loop: backtrack to the last * but do not jump over /
    text = ++text_backup;
    glob = glob_backup;
  }
  // ignore trailing stars
  while (*glob == '*')
    glob++;
  // at end of text means success if nothing else is left to match
  return *glob == '\0' ? TRUE : FALSE;
}

PATHSEP 字符是字符串文本中用于分隔路径名的传统 / 或 Windows \。请注意,传统 Unix 使用 ! 来反转字符类,例如 [!0-9]。在这里,我们提供 ! 和更传统的 ^ 来反转字符类,例如 [^0-9]

该算法的执行时间在典型情况下与模式和字符串的长度成线性关系,在最坏情况下为二次方。

非递归 gitignore 样式 glob 匹配

在我们到达本文的第二部分时,让我们看看现代的 gitignore 样式 globbing 规则,并设计一个高效的非递归算法。

Gitignore 样式 globbing 应用以下规则来确定文件和目录的路径名匹配。

* 匹配除 / 以外的任何内容。
? 匹配除 / 以外的任何单个字符。
[a-z] 匹配所选字符范围内的单个字符。
[^a-z] 匹配所选字符范围外的单个字符。
[!a-z] 匹配所选字符范围外的单个字符。
/ 在 glob 的开头使用时,如果路径名没有 /,则匹配。
**/ 匹配零个或多个目录。
/** 在 glob 的末尾时,匹配 / 之后的所有内容。
\? 匹配 ?(或反斜杠后指定的任何字符)。

我们还做以下假设:当 glob 包含路径分隔符 / 时,匹配完整路径名。否则,匹配文件或目录的基本名称。例如,*.h 匹配 foo.hbar/foo.hbar/*.h 匹配 bar/foo.h 但不匹配 foo.h 也不匹配 bar/bar/foo.h。Glob 中的前导 / 可用于强制 /*.h 匹配 foo.h 但不匹配 bar/foo.h。此外,路径名中的前导 .// 被忽略,因此 ./foo/bar 被匹配为 foo/bar,正如我们所预期的。

示例

* 匹配 abx/ax/y/b  
a 匹配 ax/ax/y/a 但不匹配 bx/ba/a/b
/* 匹配 ab 但不匹配 x/ax/bx/y/a
/a 匹配 a 但不匹配 x/ax/y/a
a?b 匹配 axbayb 但不匹配 ababa/b
a[xy]b 匹配 axbayb 但不匹配 abazb
a[a-z]b 匹配 aababbacbazb 但不匹配 aba3baAbaZb
a[^xy]b 匹配 aababbacbazb 但不匹配 abaxbayb
a[^a-z]b 匹配 a3baAbaZb 但不匹配 abaababbacbazb
a/*/b 匹配 a/x/ba/y/b 但不匹配 a/ba/x/y/b
**/a 匹配 ax/ax/y/a 但不匹配 bx/b
a/**/b 匹配 a/ba/x/ba/x/y/b 但不匹配 x/a/ba/b/x
a/** 匹配 a/xa/ya/x/y 但不匹配 ab/x
a\?b 匹配 a?b 但不匹配 ababaxba/b

为了实现 gitignore 风格的 globbing,我们需要两个星号循环:一个用于单个星号,“*-loop”,另一个用于双星号,“**-loop”。**-loop 优先于 *-loop,因为当我们在 glob 模式中遇到它后面的双星号时,在单个星号上回溯是没有意义的。反之则不然:当后面有一个单个星号不匹配时,我们应该在双星号上回溯。

// returns TRUE if text string matches gitignore-style glob pattern
int gitignore_glob_match(const char *text, const char *glob)
{
  const char *text1_backup = NULL;
  const char *glob1_backup = NULL;
  const char *text2_backup = NULL;
  const char *glob2_backup = NULL;
  // match pathname if glob contains a / otherwise match the basename
  if (*glob == '/')
  {
    // if pathname starts with ./ then ignore these pairs
    while (*text == '.' && text[1] == PATHSEP)
      text += 2;
    // if pathname starts with / then ignore it
    if (*text == PATHSEP)
      text++;
    glob++;
  }
  else if (strchr(glob, '/') == NULL)
  {
    const char *sep = strrchr(text, PATHSEP);
    if (sep)
      text = sep + 1;
  }
  while (*text != '\0')
  {
    switch (*glob)
    {
      case '*':
        if (*++glob == '*')
        {
          // trailing ** match everything after /
          if (*++glob == '\0')
            return TRUE;
          // ** followed by a / match zero or more directories
          if (*glob != '/')
            return FALSE;
          // new **-loop, discard *-loop
          text1_backup = NULL;
          glob1_backup = NULL;
          text2_backup = text;
          glob2_backup = ++glob;
          continue;
        }
        // trailing * matches everything except /
        text1_backup = text;
        glob1_backup = glob;
        continue;
      case '?':
        // match any character except /
        if (*text == PATHSEP)
          break;
        text++;
        glob++;
        continue;
      case '[':
      {
        int lastchr;
        int matched = FALSE;
        int reverse = glob[1] == '^' || glob[1] == '!' ? TRUE : FALSE;
        // match any character in [...] except /
        if (*text == PATHSEP)
          break;
        // inverted character class
        if (reverse)
          glob++;
        // match character class
        for (lastchr = 256; *++glob != '\0' && *glob != ']'; lastchr = *glob)
          if (lastchr < 256 && *glob == '-' && glob[1] != ']' && glob[1] != '\0' ?
              *text <= *++glob && *text >= lastchr :
              *text == *glob)
            matched = TRUE;
        if (matched == reverse)
          break;
        text++;
        if (*glob != '\0')
          glob++;
        continue;
      }
      case '\\':
        // literal match \-escaped character
        glob++;
        // FALLTHROUGH
      default:
        // match the current non-NUL character
        if (*glob != *text && !(*glob == '/' && *text == PATHSEP))
          break;
        text++;
        glob++;
        continue;
    }
    if (glob1_backup != NULL && *text1_backup != PATHSEP)
    {
      // *-loop: backtrack to the last * but do not jump over /
      text = ++text1_backup;
      glob = glob1_backup;
      continue;
    }
    if (glob2_backup != NULL)
    {
      // **-loop: backtrack to the last **
      text = ++text2_backup;
      glob = glob2_backup;
      continue;
    }
    return FALSE;
  }
  // ignore trailing stars
  while (*glob == '*')
    glob++;
  // at end of text means success if nothing else is left to match
  return *glob == '\0' ? TRUE : FALSE;
}

该算法在典型情况下,执行时间与模式和字符串的长度成线性关系,在最坏情况下为三次,当 **-loop*-loop 都处于活动状态时。

改进

以点 (.) 开头的文件的名称被 shell glob 以不同的方式处理,这意味着名称开头的点必须显式匹配,并且不能被通配符匹配。为了复制此行为,我们添加以下内容。

int glob_match(const char *text, const char *glob)
{
  int nodot = 1; // new flag to indicate that we shouldn't match a dot, e.g. after a /
  ...
  while (*text != '\0')
  {
    switch (*glob)
    {
      case '*':
        // match anything except . after /
        if (nodot && *text == '.')
          break;
        ...
      case '?':
        // match anything except . after /
        if (nodot && *text == '.')
          break;
        ...
      case '[':
      {
        ...
        // match any character in [...] except . after /
        if (nodot && *text == '.')
          break;
        ...
      }
      ...
      default:
        // match the current non-NUL character
        if (*glob != *text && !(*glob == '/' && *text == PATHSEP))
          break;
        nodot = *glob == '/';
        ...

可以通过 tolower()toupper() 实现 ASCII 中的不区分大小写的匹配,如下所示。

        // match the current non-NUL character
        if (tolower(*glob) != tolower(*text) && !(*glob == '/' && *text == PATHSEP))
          break;
        // match character class
        int chr;
        for (lastchr = 256; *++glob != '\0' && *glob != ']'; lastchr = *glob)
          if (lastchr < 256 && *glob == '-' && glob[1] != ']' && glob[1] != '\0' ?
              (tolower(*text) <= (chr = *++glob) && tolower(*text) >= lastchr) ||
              (toupper(*text) <= chr && toupper(*text) >= lastchr)
              : tolower(*text) == tolower(*glob))
            matched = TRUE;

Unicode 匹配可以通过两种方式支持:使用宽字符串,即 wchar_tstd::wstring,或者使用 UTF-8 编码的字符串。宽字符串选项不需要对算法进行任何更改。UTF-8 版本需要对 ? 和字符类进行一些更改,其他所有内容保持不变。

      case '?':
        // match anything except /
        if (*text == PATHSEP)
          break;
        utf8(&text);
        glob++;
        continue;
      case '[':
      {
        int chr = utf8(&text);
        int lastchr = 0x10ffff;
        int matched = FALSE;
        int reverse = glob[1] == '^' || glob[1] == '!' ? TRUE : FALSE;
        // match any character in [...] except /
        if (chr == PATHSEP)
          break;
        // inverted character class
        if (reverse)
          glob++;
        glob++; 
        while (*glob != '\0' && *glob != ']')
          if (lastchr < 0x10ffff && *glob == '-' && glob[1] != ']' && glob[1] != '\0' ?
              glob++, chr <= utf8(&glob) && chr >= lastchr :
              chr == (lastchr = utf8(&glob)))
            matched = TRUE;
        if (matched == reverse)
          break;
        if (*glob)
          glob++;
        continue;
      }

其中 utf8() 返回宽字符并按一个 UTF-8 字符在 glob 中前进。

int utf8(const char **s)
{
  int c1, c2, c3, c = (unsigned char)**s;
  if (c != '\0')
    (*s)++;
  if (c < 0x80)
    return c;
  c1 = (unsigned char)**s;
  if (c < 0xC0 || (c == 0xC0 && c1 != 0x80) || c == 0xC1 || (c1 & 0xC0) != 0x80)
    return 0xFFFD;
  if (c1 != '\0')
    (*s)++;
  c1 &= 0x3F;
  if (c < 0xE0)
    return (((c & 0x1F) << 6) | c1);
  c2 = (unsigned char)**s;
  if ((c == 0xE0 && c1 < 0x20) || (c2 & 0xC0) != 0x80)
    return 0xFFFD;
  if (c2 != '\0')
    (*s)++;
  c2 &= 0x3F;
  if (c < 0xF0)
    return (((c & 0x0F) << 12) | (c1 << 6) | c2);
  c3 = (unsigned char)**s;
  if (c3 != '\0')
    (*s)++;
  if ((c == 0xF0 && c1 < 0x10) || (c == 0xF4 && c1 >= 0x10) || 
       c >= 0xF5 || (c3 & 0xC0) != 0x80)
    return 0xFFFD;
  return (((c & 0x07) << 18) | (c1 << 12) | (c2 << 6) | (c3 & 0x3F));
}

结论

通过使用星号循环迭代而不是递归来实现,通配符匹配、经典 globbing 和现代 gitignore 风格的 globbing 可以很快。这些算法通常在字符串和模式长度上以线性时间运行,在最坏情况下,可能在模式和字符串长度上以二次方或三次方的速度运行,而不会导致 CPU 使用率呈指数级爆炸。

我们可以将类似 shell 的花括号展开等扩展的 globbing 功能添加到我们的匹配器中。然而,花括号展开需要递归和回溯,如果我们不小心,可能会使指数级爆炸问题变得更糟。例如,考虑 a*{b,a*{b,a*{b,a*{b,a*{b,a*{b,a*{b,a*b}}}}}}}。除了限制花括号的数量或将扩展的 glob 转换为用于匹配的正则表达式(尽管正则表达式匹配也可能成本高昂)之外,没有简单的方法来限制花括号展开的执行时间。

我在实现 gitignore 风格的 globbing 时编写了这篇文章,用于 ugrep(快速通用 grep),因为缺乏可用且可用的开源代码。我在官方存储库中找到的 wildmat.c 版本都有一个烦人的 bug,实际上在源代码中也有记录:“在处理格式不正确的模式时可能不够健壮;例如,“foo[a-”可能会导致段错误。” 哎呀。

历史

  • 2019 年 8 月 3 日:初稿
  • 2019 年 8 月 5 日:首次发布
  • 2019 年 8 月 8 日:更新
  • 2019 年 9 月 12 日:添加了 Java 下载源
  • 2019 年 9 月 19 日:添加了 shell 执行示例;[] 不匹配 /;dotglob 改进;新下载源和更新的下载源
  • 2019 年 10 月 17 日:次要更新以通过移除路径名中的前导 /(如果存在)来改进 gitignore globbing。
  • 2020 年 1 月 23 日:改进了字符类 globbing,以便在 -(连字符)是最后一个字符时匹配 shell globbing,例如 [a-],修复了 UTF-8 字符类范围匹配(即 [] 带有连字符)在“改进”部分。
  • 2023 年 7 月 22 日:次要更新以改进不区分大小写的 globbing 代码。
© . All rights reserved.