有没有一种方法可以检查Bash脚本是否已经完成?

21
我将尝试在Bash中实现 REPL(读取-求值-输出循环)。如果已经存在这样的东西,请忽略以下内容,并使用指向它的指针回答此问题。
让我们以这个脚本为例(将其命名为test.sh):
if true
then
  echo a
else
  echo b
fi
echo c

我想做的是逐行读取此脚本,检查我已经读到的内容是否是完整的bash表达式;如果它是完整的,则eval它;否则继续读取下一行。下面的脚本希望能够说明我的想法(尽管它并不完全有效)。
x=""
while read -r line
do
  x=$x$'\n'$line  # concatenate by \n
  # the line below is certainly a bad way to go
  if eval $x 2>/dev/null; then
    eval $x  # code seems to be working, so eval it
    x=""  # empty x, and start collecting code again
  else
    echo 'incomplete expression'
  fi
done < test.sh

动机

对于一个bash脚本,我希望将其解析为语法完整的表达式,评估每个表达式,捕获输出,并最终标记源代码和输出(例如使用Markdown/HTML/LaTeX/...)。例如,对于一个脚本:

echo a
echo b

我想要实现的输出是这样的:
```bash
echo a
```

```
a
```

```bash
echo b
```

```
b
```

不要评估整个脚本并捕获所有输出:

```bash
echo a
echo b
```

```
a
b
```

4
请执行命令 sh -n secondscript.sh。祝你好运。 - shellter
3
更精确地说,尝试使用命令bash -nc "$x"并捕获stderr;如果该命令成功运行,则 $x 可能是语法上有效的。否则,如果 stderr 包含“syntax error: unexpected end of file”(在英语环境中),则该命令可能不完整,您可以添加下一行。其他语法错误不能通过添加更多标记来解决,因此您应该输出语法错误(可能是通过重新输出捕获的stderr实现)。 - rici
1
三点注意事项:首先,应该在子shell中执行第一个eval,以防第二个eval复制了副作用。其次,你可以使用x+=$'\n'$line更简洁地进行字符串拼接。第三点,请注意,你不能像这样逐行评估多行语句(例如完整的if语句或循环)。 - chepner
2
使用eval存在太多的边角案例和可能的故障,让我头晕...唯一正确的方法是使用bash的词法分析器,并通过它运行语句。例如,如果你不反对使用Python,pygments有一个针对bash的词法分析器。 - John
3
Bash本身是一个REPL。最终目标是什么?在每个命令之前做一些事情,或在每个命令之后做一些事情,还是...? - pasaba por aqui
显示剩余7条评论
4个回答

6
bash -n -c "$command_text"

该函数将确定您的$command_text是否为语法上有效的脚本,而不实际执行它。


请注意,“语法上有效”和“正确”之间有很大的差距。如果您想正确解析语言,请考虑使用类似于http://shellcheck.net/的工具。


4
以下脚本应该会生成你期望的Markdown输出。 eval "set -n; $x" 用于验证命令是否完整,检查命令中的语法错误。只有没有语法错误的命令才会被视为完整的命令,并在输出Markdown中显示。
请注意,要处理的输入脚本是在子shell中执行的,因此不会干扰处理脚本本身(即输入脚本可以使用与处理脚本相同的变量名称,并且不能更改处理脚本中变量的值)。唯一的例外是称为___internal__variable___的特殊变量。
有两种方法可以实现这一点,我在下面介绍。在版本1中,每当处理完一个新的完整命令时,都会执行它之前的所有语句以创建命令的“上下文”。这有效地多次运行输入脚本。
版本2中,在执行每个完整命令后,子shell的环境存储在一个变量中。然后,在执行下一个命令之前,将恢复上一个环境到子shell中。
#!/bin/bash

x=""  # Current
y=""  # Context
while IFS= read -r line  # Keep indentation
do
    [ -z "$line" ] && continue  # Skip empty lines

    x=$x$'\n'$line  # Build a complete command
    # Check current command for syntax errors
    if (eval "set -n; $x" 2> /dev/null)
    then
        # Run the input script up to the current command
        # Run context first and ignore the output
        ___internal_variable___="$x"
        out=$(eval "$y" &>/dev/null; eval "$___internal_variable___")
        # Generate command markdown
        echo "=================="
        echo
        echo "\`\`\`bash$x"
        echo "\`\`\`"
        echo
        # Generate output markdown
        if [ -n "$out" ]
        then
            echo "Output:"
            echo
            echo "\`\`\`"
            echo "$out"
            echo "\`\`\`"
            echo
        fi
        y=$y$'\n'$line  # Build context
        x=""  # Clear command
    fi
done < input.sh

第二版

#!/bin/bash

x=""  # Current command
y="true"  # Saved environment
while IFS= read -r line  # Keep indentation
do
    [ -z "$line" ] && continue  # Skip empty lines

    x=$x$'\n'$line  # Build a complete command
    # Check current command for syntax errors
    if (eval "set -n; $x" 2> /dev/null)
    then
        # Run the current command in the previously saved environment
        # Then store the output of the command as well as the new environment
        ___internal_variable_1___="$x"  # The current command
        ___internal_variable_2___="$y"  # Previously saved environment
        out=$(bash -c "${___internal_variable_2___}; printf '<<<BEGIN>>>'; ${___internal_variable_1___}; printf '<<<END>>>'; declare -p" 2>&1)
        # Separate the environment description from the command output
        y="${out#*<<<END>>>}"
        out="${out%%<<<END>>>*}"
        out="${out#*<<<BEGIN>>>}"

        # Generate command markdown
        echo "=================="
        echo
        echo "\`\`\`bash$x"
        echo "\`\`\`"
        echo
        # Generate output markdown
        if [ -n "$out" ]
        then
            echo "Output:"
            echo
            echo "\`\`\`"
            echo "$out"
            echo "\`\`\`"
            echo
        fi
        x=""  # Clear command
    fi
done < input.sh

例子

对于输入脚本 input.sh:

x=10
echo "$x"
y=$(($x+1))
echo "$y"


while [ "$y" -gt "0" ]
do
    echo $y
    y=$(($y-1))
done

输出结果如下:
==================

```bash
x=10
```

==================

```bash
echo "$x"
```

Output:

```
10
```

==================

```bash
y=$(($x+1))
```

==================

```bash
echo "$y"
```

Output:

```
11
```

==================

```bash
while [ "$y" -gt "0" ]
do
    echo $y
    y=$(($y-1))
done
```

Output:

```
11
10
9
8
7
6
5
4
3
2
1
```

“eval“set -n; ...”似乎运行良好,但无论如何都会在我的代码中运行子shell。关于您的第二个评论,是的,那应该是可能的,我们可以保存“set”返回的状态,而不是重新运行代码。” - Andrzej Pronobis
是的,它在子shell中,所以我们不需要担心变量的副作用,但磁盘、运行进程等方面的副作用是一个问题。 - Charles Duffy
创建了另一个实现该想法的版本。需要在如何从子shell中检索环境状态并恢复它方面有些创意,但它可以工作。 - Andrzej Pronobis
(......我仍然坚持我的协处理器建议,这种方法可以在不需要序列化和重新加载状态的情况下实现显着更好的性能。) - Charles Duffy
“declare”确实是更好的选择,感谢建议。 - Andrzej Pronobis
显示剩余4条评论

3

假设你的测试命令存储在一个名为"example"的文件中。也就是说,使用与上一个答案中相同的命令:

$ cat example
x=3
echo "$x"
y=$(($x+1))
echo "$y"


while [ "$y" -gt "0" ]
do
    echo $y
    y=$(($y-1))
done

命令:

$ (echo 'PS1=; PROMPT_COMMAND="echo -n =====; echo"'; cat example2 ) | bash -i

产生:

=====
x=3
=====
echo "$x"
3
=====
y=$(($x+1))
=====
echo "$y"
4
=====

=====

=====
while [ "$y" -gt "0" ]
> do
>     echo $y
>     y=$(($y-1))
> done
4
3
2
1
=====
exit

如果你对循环的中间结果也感兴趣,可以使用以下命令:

$ ( echo 'trap '"'"'echo; echo command: $BASH_COMMAND; echo answer:'"'"' DEBUG'; cat example ) | bash

结果为:

command: x=3
answer:

command: echo "$x"
answer:
3

command: y=$(($x+1))
answer:

command: echo "$y"
answer:
4

command: [ "$y" -gt "0" ]
answer:

command: echo $y
answer:
4

command: y=$(($y-1))
answer:

command: [ "$y" -gt "0" ]
answer:

command: echo $y
answer:
3

command: y=$(($y-1))
answer:

command: [ "$y" -gt "0" ]
answer:

command: echo $y
answer:
2

command: y=$(($y-1))
answer:

command: [ "$y" -gt "0" ]
answer:

command: echo $y
answer:
1

command: y=$(($y-1))
answer:

command: [ "$y" -gt "0" ]
answer:

附录1

将先前的结果更改为其他格式并不难。例如,这个小的Perl脚本:

$ cat formatter.pl 
#!/usr/bin/perl
#

$state=4; # 0: answer, 1: first line command, 2: more command, 4: unknown

while(<>) {
#  print $state;

  if( /^===COMMAND===/ ) {
    print "===\n";
    $state=1;
    next;
  }

  if( $state == 1 ) {
    print;
    $state=2;
    next;
  }

  if( $state == 2 && /^>+ (.*)/ ) {
    print "$1\n";
    next;
  }

  if( $state == 2 ) {
    print "---\n";
    $state=0;
    redo;
  }

  if( $state == 0 ) {
    print;
    next;
  }
}

当在命令中使用时:

( echo 'PS1="===COMMAND===\n"'; cat example ) | bash -i 2>&1 | ./formatter.pl

将会得到以下结果:

===
x=3
===
echo "$x"
---
3
===
y=$(($x+1))
===
echo "$y"
---
4
===

===

===
while [ "$y" -gt "0" ]
do
    echo $y
    y=$(($y-1))
done
---
4
3
2
1
===
exit

使用 PROMPT_COMMAND 是一个非常有趣的想法。是否有一种方法可以在每个完整命令之后附加一个字符串?我想要像 === x=3 === echo "$x" --- 3 === ... 这样的东西,即在命令和其输出之间添加分隔符。 - Yihui Xie
@Yihui:附言已写好 - pasaba por aqui
非常好。不过我能想象出一种边缘情况,例如 cat foo.txtfoo.txt 恰好包含以 > 开头的行。我认为这些行将被视为 cat 命令的延续。我在想是否有类似于 PROMPT_COMMAND 的环境变量(CONTINUE_COMMAND?)。我们希望这个字符/字符串足够独特。 - Yihui Xie
1
@Yihui:是的,有一个叫做“PS2”的东西。默认情况下,它的值为“>”,但你可以使用任何一个更复杂和不太可能的值。 - pasaba por aqui

-1

如果没有pid文件,只要您的脚本有一个唯一可识别的名称,您可以这样做:

  #!/bin/bash
COMMAND=$0
# exit if I am already running
RUNNING=`ps --no-headers -C${COMMAND} | wc -l`
if [ ${RUNNING} -gt 1 ]; then
  echo "Previous ${COMMAND} is still running."
  exit 1
fi
... rest of script ...

2
这是另一个问题的答案吗? - pasaba por aqui
我猜他们只看了标题,并把“complete”解读为“运行完成”,尽管这不是这个问题的关键。 - Charles Duffy
此外,请不要鼓励使用 ps 进行 grep。最佳实践是使用适当的锁文件,使用 flock()fcntl(LOCK_EX) 风格的锁定方式 - 这样,在重启或 SIGKILL 时,您就有了自动解锁的东西,而不必担心 PID 或名称冲突。 - Charles Duffy

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