tech.guitarrapc.cóm

Technical updates

Zxでコマンド中の文字列をエスケープする

Zxを使うとシェルで実行するコマンドをawait "文字列"で実行できます。C#のZx(ProcessX)では文字列中に"が含まれるとエスケープが必要です。 このエスケープ挙動がWindows(cmd)とLinux(Bash)で異なるというメモです。

tl;dr;

ProcessXのZxでコマンド中にダブルクォートの文字列を渡す場合は、Linux(Bash)で二重エスケープが必要です。Windows(cmd)では二重エスケープ不要です。 OSによってエスケープ方法が異なるため、マルチOSで動作させる場合はエスケープ処理を工夫する必要があります。

// Windowsでは`git commit -m "おこのみ たこやき"`と解釈される、Linuxでは`git commit -m おこのみ`と解釈される。
await "git commit -m \"おこのみ たこやき\"";

// Windowsでは動作しない、Linuxでは`git commit -m "おこのみ たこやき"`と解釈される。
await "git commit -m \"\"おこのみ たこやき\"\"";

// Windowsでは動作しない、Linuxでは`git commit -m 'おこのみ たこやき'`と解釈される。
await "git commit -m 'おこのみ たこやき'";

C#でZxを使う

google/Zxは文字列をawaitするとシェルで実行されるツールです。C#でZx風に書く時はCysharp/ProcessXを使うと同じように動作します。

C#でgoogle/zx風にシェルスクリプトを書く | neuecc

例えば次のようにC#でかけます。

using Zx;

var tp = TimeProvider.System;
var ts = tp.GetTimestamp();
var name = await "echo FooBar";
await new[]
{
    $"sleep 1 && echo 1",
    $"sleep 2 && echo 2",
    $"sleep 3 && echo 3",
};
await $"echo Done by {name} in {tp.GetElapsedTime(ts).TotalMilliseconds}ms";

実行するとシェルで実行した結果が出力されます。await new[] {...}部分は並列実行されていることがわかります。

FooBar
1
2
3
Done by FooBar in 3114.02ms

出力をC#でいじれるので「コマンドラインにパイプをつなげる」というよりも「コマンドを単発実行してC#で実行結果をいじる」という使い方が多くなるでしょう。

"のエスケープ

本題はZxの中でecho "foobar"のように"を含む文字列をエスケープする方法です。"をエスケープする方法はOS(シェル)によって異なります。 Zx(ProcessX)は、Windowsはcmd /c、その他OS1bash -cで実行されるのですが、そのコマンド組み立てのvar cmd = shell + " \"" + command + "\"";が挙動の鍵です。

src/ProcessX/Zx/Env.cs - Cysharp//ProcessX

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
    _shell = "cmd /c";
}
else
{
    if (Which.TryGetPath("bash", out var bashPath))
    {
        _shell = bashPath + " -c";
    }
    else
    {
        throw new InvalidOperationException("shell is not found in PATH, set Env.shell manually.");
    }
}

// 省略
static async Task<(string StdOut, string StdError)> ProcessStartAsync(string command, CancellationToken cancellationToken, bool forceSilcent = false)
{
    var cmd = shell + " \"" + command + "\"";
    ...

残念ながらcmdとBashで"をエスケープする方法は異なるため、マルチOSで動作させるには工夫することになります。次のコマンドで挙動を見てみましょう。

using Zx;

await "echo \"foobar\"";

WindowsとLinuxそれぞれで実行してみます。Windowsでは"が残り、Linuxでは"が消えてしまいました。これはcmdとBashで"のエスケープ方法が異なるためです。 単純なコマンド実行では文字列を埋め込む機会が以外と少ないので平気ですが、コマンドにクォートつきで文字列を埋め込む場合は注意が必要です。2

# Windows (cmd)
"foobar"

# Linux (Bash)
foobar

トラブルになる例

git commit -m "おこのみ たこやき"というコマンドをZxで実行するとどうなるでしょうか。

using Zx;

await "git commit -m \"おこのみ たこやき\"";

Windowsはcmd /c "git commit -m \"おこのみ たこやき\""、Linuxはbash -c ""git commit -m \"おこのみ たこやき\"""と解釈されLinuxはエスケープがぐちゃっとなるため異なる実行結果になります。

# Windows: コミットメッセージはコマンドと同じ
おこのみ たこやき

# Linux: コマンド内の`"`が消えて`git commit -m おこのみ たこやき
おこのみ

対処

LinuxでWindowsと同じように動作させるためには二重エスケープします。3

using Zx;
await "git commit -m \"\"おこのみ たこやき\"\"";

これでWindowsとLinuxで同じように動作しますが、ちょっとこのままだとWindows/Linuxで書き分けるのが面倒なので文字列中のエスケープだけヘルパーをかませましょう。

using Zx;

await $"git commit -m \"{Escape("おこのみ たこやき")}\"";

static string Escape(string command)
{
    // Windowsはエスケープ不要
    if (OperatingSystem.IsWindows())
        return command;

    // Bashはエスケープする
    return "\"" + command + "\"";
}

これで少し楽に書けますね。

まとめ

Zxでコマンド中にダブルクォートの文字列を渡す場合は注意しましょう。注意が必要でも、Zxは便利でよいです。


  1. Windows以外なのでLinux/macOS/他が該当します
  2. 空白を含む文字列を1つの文字列として認識させるためクォートで囲むのが定番
  3. Linuxだけ動作させるならシングルクォート囲めばよいですが、今度はWindowsで動作しなくなるので避けたほうがいいでしょう