16

是否有本地“PHP 方式”来解析来自的命令参数string?例如,给定以下内容string

foo "bar \"baz\"" '\'quux\''

我想创建以下内容array

array(3) {
  [0] =>
  string(3) "foo"
  [1] =>
  string(7) "bar "baz""
  [2] =>
  string(6) "'quux'"
}

我已经尝试过利用token_get_all(),但 PHP 的变量插值语法(例如"foo ${bar} baz")在我的游行中几乎如雨后春笋般涌现。

我很清楚我可以编写自己的解析器。命令参数语法非常简单,但如果有一种现有的本地方法可以做到这一点,我更喜欢滚动我自己的方法。

编辑:请注意,我正在寻找从 a 解析参数string,而不是从 shell/命令行。


编辑#2:下面是参数的预期输入->输出的更全面示例:

foo -> foo
"foo" -> foo
'foo' -> foo
"foo'foo" -> foo'foo
'foo"foo' -> foo"foo
"foo\"foo" -> foo"foo
'foo\'foo' -> foo'foo
"foo\foo" -> foo\foo
"foo\\foo" -> foo\foo
"foo foo" -> foo foo
'foo foo' -> foo foo
4

11 回答 11

12

正则表达式非常强大:(?s)(?<!\\)("|')(?:[^\\]|\\.)*?\1|\S+. 那么这个表达是什么意思呢?

  • (?s): 设置s修饰符以匹配带点的换行符.
  • (?<!\\): 否定后视,检查下一个标记之前是否没有反斜杠
  • ("|'): 匹配单引号或双引号并将其放入第 1 组
  • (?:[^\\]|\\.)*?: 匹配所有不匹配\,或匹配\与紧随其后的(转义)字符
  • \1:匹配第一组中匹配的内容
  • |: 或者
  • \S+: 匹配除空格以外的任何内容一次或多次。

这个想法是捕获一个引用并将其分组以记住它是单引号还是双引号。负面的lookbehinds 是为了确保我们不匹配转义的引号。\1用于匹配第二对引号。最后,我们使用交替来匹配任何不是空格的东西。这个解决方案很方便,几乎适用于任何支持后向引用和反向引用的语言/风格。当然,这个解决方案期望引号是关闭的。结果在第 0 组中。

让我们在 PHP 中实现它:

$string = <<<INPUT
foo "bar \"baz\"" '\'quux\''
'foo"bar' "baz'boz"
hello "regex

world\""
"escaped escape\\\\"
INPUT;

preg_match_all('#(?<!\\\\)("|\')(?:[^\\\\]|\\\\.)*?\1|\S+#s', $string, $matches);
print_r($matches[0]);

如果你想知道为什么我使用 4 个反斜杠。然后看看我之前的回答

输出

Array
(
    [0] => foo
    [1] => "bar \"baz\""
    [2] => '\'quux\''
    [3] => 'foo"bar'
    [4] => "baz'boz"
    [5] => hello
    [6] => "regex

world\""
    [7] => "escaped escape\\"
)

                                       Online regex demo                                 Online php demo


删除引号

使用命名组和一个简单的循环非常简单:

preg_match_all('#(?<!\\\\)("|\')(?<escaped>(?:[^\\\\]|\\\\.)*?)\1|(?<unescaped>\S+)#s', $string, $matches, PREG_SET_ORDER);

$results = array();
foreach($matches as $array){
   if(!empty($array['escaped'])){
      $results[] = $array['escaped'];
   }else{
      $results[] = $array['unescaped'];
   }
}
print_r($results);

Online php demo

于 2013-08-13T19:25:50.857 回答
11

我制定了以下表达式来匹配各种外壳和擒纵机构:

$pattern = <<<REGEX
/
(?:
  " ((?:(?<=\\\\)"|[^"])*) "
|
  ' ((?:(?<=\\\\)'|[^'])*) '
|
  (\S+)
)
/x
REGEX;

preg_match_all($pattern, $input, $matches, PREG_SET_ORDER);

它匹配:

  1. 两个双引号,其中一个双引号可以被转义
  2. 与 #1 相同,但用于单引号
  3. 不带引号的字符串

之后,您需要(小心地)删除转义字符:

$args = array();
foreach ($matches as $match) {
    if (isset($match[3])) {
        $args[] = $match[3];
    } elseif (isset($match[2])) {
        $args[] = str_replace(['\\\'', '\\\\'], ["'", '\\'], $match[2]);
    } else {
        $args[] = str_replace(['\\"', '\\\\'], ['"', '\\'], $match[1]);
    }
}
print_r($args);

更新

为了好玩,我编写了一个更正式的解析器,概述如下。它不会给你更好的性能,它比正则表达式慢大约三倍,主要是因为它面向对象的性质。我认为优势是学术性而非实用性:

class ArgvParser2 extends StringIterator
{
    const TOKEN_DOUBLE_QUOTE = '"';
    const TOKEN_SINGLE_QUOTE = "'";
    const TOKEN_SPACE = ' ';
    const TOKEN_ESCAPE = '\\';

    public function parse()
    {
        $this->rewind();

        $args = [];

        while ($this->valid()) {
            switch ($this->current()) {
                case self::TOKEN_DOUBLE_QUOTE:
                case self::TOKEN_SINGLE_QUOTE:
                    $args[] = $this->QUOTED($this->current());
                    break;

                case self::TOKEN_SPACE:
                    $this->next();
                    break;

                default:
                    $args[] = $this->UNQUOTED();
            }
        }

        return $args;
    }

    private function QUOTED($enclosure)
    {
        $this->next();
        $result = '';

        while ($this->valid()) {
            if ($this->current() == self::TOKEN_ESCAPE) {
                $this->next();
                if ($this->valid() && $this->current() == $enclosure) {
                    $result .= $enclosure;
                } elseif ($this->valid()) {
                    $result .= self::TOKEN_ESCAPE;
                    if ($this->current() != self::TOKEN_ESCAPE) {
                        $result .= $this->current();
                    }
                }
            } elseif ($this->current() == $enclosure) {
                $this->next();
                break;
            } else {
                $result .= $this->current();
            }
            $this->next();
        }

        return $result;
    }

    private function UNQUOTED()
    {
        $result = '';

        while ($this->valid()) {
            if ($this->current() == self::TOKEN_SPACE) {
                $this->next();
                break;
            } else {
                $result .= $this->current();
            }
            $this->next();
        }

        return $result;
    }

    public static function parseString($input)
    {
        $parser = new self($input);

        return $parser->parse();
    }
}

它基于StringIterator一次遍历一个字符的字符串:

class StringIterator implements Iterator
{
    private $string;

    private $current;

    public function __construct($string)
    {
        $this->string = $string;
    }

    public function current()
    {
        return $this->string[$this->current];
    }

    public function next()
    {
        ++$this->current;
    }

    public function key()
    {
        return $this->current;
    }

    public function valid()
    {
        return $this->current < strlen($this->string);
    }

    public function rewind()
    {
        $this->current = 0;
    }
}
于 2013-08-14T10:36:59.930 回答
8

好吧,您也可以使用递归正则表达式构建此解析器:

$regex = "([a-zA-Z0-9.-]+|\"([^\"\\\\]+(?1)|\\\\.(?1)|)\"|'([^'\\\\]+(?2)|\\\\.(?2)|)')s";

现在这有点长,所以让我们把它分解一下:

$identifier = '[a-zA-Z0-9.-]+';
$doubleQuotedString = "\"([^\"\\\\]+(?1)|\\\\.(?1)|)\"";
$singleQuotedString = "'([^'\\\\]+(?2)|\\\\.(?2)|)'";
$regex = "($identifier|$doubleQuotedString|$singleQuotedString)s";

那么这是如何工作的呢?好吧,标识符应该很明显......

两个带引号的子模式基本上是一样的,我们看一下单引号字符串:

'([^'\\\\]+(?2)|\\\\.(?2)|)'

实际上,这是一个引号字符,后跟一个递归子模式,然后是一个结束引号。

魔术发生在子模式中。

[^'\\\\]+(?2)

该部分基本上使用任何非引号和非转义字符。我们不在乎它们,所以吃掉它们。然后,如果我们遇到引号或反斜杠,则触发再次匹配整个子模式的尝试。

\\\\.(?2)

如果我们可以消费一个反斜杠,那么消费下一个字符(不关心它是什么),并再次递归。

最后,我们有一个空组件(如果转义字符是最后一个,或者没有转义字符)。

在@HamZa 提供的测试输入上运行它会返回相同的结果:

array(8) {
  [0]=>
  string(3) "foo"
  [1]=>
  string(13) ""bar \"baz\"""
  [2]=>
  string(10) "'\'quux\''"
  [3]=>
  string(9) "'foo"bar'"
  [4]=>
  string(9) ""baz'boz""
  [5]=>
  string(5) "hello"
  [6]=>
  string(16) ""regex

world\"""
  [7]=>
  string(18) ""escaped escape\\""
}

发生的主要区别在于效率。这种模式应该更少回溯(因为它是递归模式,所以对于格式良好的字符串应该几乎没有回溯),其中另一个正则表达式是非递归正则表达式,并且将回溯每个字符(这就是力量?之后*,非贪婪模式消费)。

对于短输入,这无关紧要。提供的测试用例,它们彼此相差几个百分点(误差幅度大于差异)。但是对于没有转义序列的单个长字符串:

"with a really long escape sequence match that will force a large backtrack loop"

差异很大(100 次运行):

  • 递归: float(0.00030398368835449)
  • 回溯:float(0.00055909156799316)

当然,我们可以通过大量转义序列部分失去这个优势:

"This is \" A long string \" With a\lot \of \"escape \sequences"
  • 递归:float(0.00040411949157715)
  • 回溯:float(0.00045490264892578)

但请注意,长度仍然占主导地位。这是因为回溯器在 处缩放O(n^2),而递归解决方案在 处缩放O(n)。然而,由于递归模式总是需要至少递归一次,它比短字符串的回溯解决方案要慢:

"1"
  • 递归:float(0.0002598762512207)
  • 回溯:float(0.00017595291137695)

权衡似乎发生在 15 个字符左右...但是两者都足够快,除非您解析几 KB 或 MB 的数据,否则不会有任何区别...但是值得讨论...

在理智的输入上,它不会产生重大影响。但是如果你匹配的字节数超过几百个,它可能会开始显着增加......

编辑

如果您需要处理任意“裸词”(未引用的字符串),则可以将原始正则表达式更改为:

$regex = "([^\s'\"]\S*|\"([^\"\\\\]+(?1)|\\\\.(?1)|)\"|'([^'\\\\]+(?2)|\\\\.(?2)|)')s";

但是,这实际上取决于您的语法以及您是否认为命令。我建议将您期望的语法形式化...

于 2013-08-13T20:58:16.820 回答
5

如果你想遵循这样的解析规则,那么在 shell 中,有一些我认为不容易用正则表达式覆盖的边缘情况,因此你可能想要编写一个方法来做到这一点:

$string = 'foo "bar \"baz\"" \'\\\'quux\\\'\'';
echo $string, "\n";
print_r(StringUtil::separate_quoted($string));

输出:

foo "bar \"baz\"" '\'quux\''
Array
(
    [0] => foo
    [1] => bar "baz"
    [2] => 'quux'
)

我想这与您正在寻找的内容非常匹配。示例中使用的函数可以配置为转义字符以及引号,[ ]如果您愿意,甚至可以使用括号来形成“引号”。

要允许每个字节一个字符的原生字节安全字符串以外的其他字符,您可以传递一个数组而不是字符串。数组需要每个值包含一个字符作为二进制安全字符串。例如,将 NFC 形式的 unicode 作为 UTF-8 传递,每个数组值有一个代码点,这应该为 unicode 完成工作。

于 2013-08-13T21:26:23.457 回答
5

您可以简单地使用str_getcsv并使用stripslashestrim进行一些整容手术

例子 :

$str =<<<DATA
"bar \"baz\"" '\'quux\''
"foo"
'foo'
"foo'foo"
'foo"foo'
"foo\"foo"
'foo\'foo'
"foo\foo"
"foo\\foo"
"foo foo"
'foo foo' "foo\\foo" \'quux\' \"baz\" "foo'foo"
DATA;


$str = explode("\n", $str);

foreach($str as $line) {
    $line = array_map("stripslashes",str_getcsv($line," "));
    print_r($line);
}

输出

Array
(
    [0] => bar "baz"
    [1] => ''quux''
)
Array
(
    [0] => foo
)
Array
(
    [0] => 'foo'
)
Array
(
    [0] => foo'foo
)
Array
(
    [0] => 'foo"foo'
)
Array
(
    [0] => foo"foo
)
Array
(
    [0] => 'foo'foo'
)
Array
(
    [0] => foooo
)
Array
(
    [0] => foofoo
)
Array
(
    [0] => foo foo
)
Array
(
    [0] => 'foo
    [1] => foo'
    [2] => foofoo
    [3] => 'quux'
    [4] => "baz"
    [5] => foo'foo
)

警告

没有什么比通用的参数格式更好的了,你最好指定特定的格式,最容易看到的是 CSV

例子

 app.php arg1 "arg 2" "'arg 3'" > 4 

使用 CSV,您可以简单地获得此输出

Array
(
    [0] => app.php
    [1] => arg1
    [2] => arg 2
    [3] => 'arg 3'
    [4] => >
    [5] => 4
)
于 2013-08-15T07:54:12.017 回答
2

由于您要求使用本机方式来执行此操作,并且 PHP 不提供任何可以映射 $argv 创建的函数,您可以像这样解决这个不足:

创建一个可执行的 PHP 脚本foo.php

<?php

// Skip this file name
array_shift( $argv );

// output an valid PHP code
echo 'return '. var_export( $argv, 1 ).';';

?>

并使用它来检索参数,如果您执行$command,PHP 将实际执行的方式:

function parseCommand( $command )
{
    return eval(
        shell_exec( "php foo.php ".$command )
    );
}


$command = <<<CMD
foo "bar \"baz\"" '\'quux\''
CMD;


$args = parseCommand( $command );

var_dump( $args );

优点 :

  • 非常简单的代码
  • 应该比任何正则表达式都快
  • 100% 接近 PHP 行为

缺点 :

  • 需要主机上的执行权限
  • Shell exec + eval 在同一个 $var 上,让我们狂欢吧!您必须信任输入或进行如此多的过滤,以至于简单的正则表达式可能会更快(我没有深入研究)。
于 2013-08-09T19:51:13.987 回答
1

我会推荐另一种方式。已经有一种“标准”的方式来处理命令行参数。它被称为 get_opts:

http://php.net/manual/en/function.getopt.php

我建议您更改脚本以使用 get_opts,然后使用您的脚本的任何人都将以他们熟悉的方式和“行业标准”传递参数,而不必学习您的做事方式。

于 2013-08-12T21:00:39.483 回答
0

基于HamZa 的回答

function parse_cli_args($cmd) {
    preg_match_all('#(?<!\\\\)("|\')(?<escaped>(?:[^\\\\]|\\\\.)*?)\1|(?<unescaped>\S+)#s', $cmd, $matches, PREG_SET_ORDER);
    $results = [];
    foreach($matches as $array){
        $results[] = !empty($array['escaped']) ? $array['escaped'] : $array['unescaped'];
    }
    return $results;
}
于 2014-04-15T19:06:09.617 回答
0

我为控制台交互写了一些包:

链接到新包:weew/console-arguments

还有一个围绕该包构建的 cli 应用程序脚手架:weew/console

链接到 cli 输出格式化程序:weew/console-formatter

参数解析

有一个包可以解析 weew/console-arguments的整个参数

例子:

$parser = new ArgumentsParser();
$args = $parser->parse('command:name arg1 arg2 --flag="custom \"value" -f="1+1=2" -vvv');

$args将是一个数组:

['command:name', 'arg1', 'arg2', '--flag', 'custom "value', '-f', '1+1=2', '-v', '-v', '-v']

参数可以分组:

$args = $parser->group($args);

$args会变成:

['arguments' => ['command:name', 'arg1', 'arg2'], 'options' => ['--flag' => 1, '-f' => 1, '-v' => 1], '--flag' => ['custom "value'], '-f' => ['1+1=2'], '-v' => []]

注意:此解决方案不是本地解决方案,但可能对某些人仍然有用。

于 2016-05-17T15:05:17.887 回答
-1

据我所知,确实没有用于解析命令的本机函数。但是,我创建了一个函数,它在 PHP 中原生地完成了这个技巧。通过多次使用 str_replace,您可以将字符串转换为可转换的数组。我不知道你认为有多快,但是当运行查询 400 次时,最慢的查询不到 34 微秒。

function get_array_from_commands($string) {
    /*
    **  Turns a command string into a field
    **  of arrays through multiple lines of 
    **  str_replace, until we have a single
    **  string to split using explode().
    **  Returns an array.
    */

    // replace single quotes with their related
    // ASCII escape character
    $string = str_replace("\'","&#x27;",$string);
    // Do the same with double quotes
    $string = str_replace("\\\"","&quot;",$string);
    // Now turn all remaining single quotes into double quotes
    $string = str_replace("'","\"",$string);
    // Turn " " into " so we don't replace it too many times
    $string = str_replace("\" \"","\"",$string);
    // Turn the remaining double quotes into @@@ or some other value
    $string = str_replace("\"","@@@",$string);
    // Explode by @@@ or value listed above
    $string = explode("@@@",$string);
    return $string;
}
于 2013-08-10T04:13:25.810 回答
-1

我建议类似:

$str = <<<EOD
foo "bar \"baz\"" '\'quux\''
EOD;

$match = preg_split("/('(?:.*)(?<!\\\\)(?>\\\\\\\\)*'|\"(?:.*)(?<!\\\\)(?>\\\\\\\\)*\")/U", $str, null, PREG_SPLIT_DELIM_CAPTURE);

var_dump(array_filter(array_map('trim', $match)));

在以下方面的帮助下:字符串到数组,正则表达式用单引号和双引号分隔

之后您仍然必须对数组中的字符串进行转义。

array(3) {
  [0]=>
  string(3) "foo"
  [1]=>
  string(13) ""bar \"baz\"""
  [3]=>
  string(10) "'\'quux\''"
}

但你明白了。

于 2013-07-25T04:57:19.953 回答