CLI приложения #18. Конвейеры и стандартный ввод

В шестом посте мы разбирали два потока вывода — stdout и stderr. Пора рассказать про третий, входящий. Называется он стандартным вводом, stdin, и номер у него нулевой.
Обычно stdin — это клавиатура: Console.ReadLine() читает то, что набрал человек. Но точно так же, как вывод можно перенаправить в файл, ввод можно перенаправить из файла. Или из другой программы.
Вот тут и появляется вертикальная черта:

program1 | program2

Эта конструкция называется конвейером. Читается просто: всё, что первая программа напишет в stdout, попадёт во вторую на stdin. Выход одного становится входом другого, напрямую, без промежуточных файлов.
Именно на этом принципе построен весь консольный мир. Каждая утилита делает одну вещь, а сложные задачи собираются из них, как из кубиков. И чтобы ваше приложение можно было использовать в таком конвейере, оно должно уметь читать stdin.
Читается он просто:

// построчно, пока данные не кончатся
string? line;
while ((line = Console.In.ReadLine()) != null)
{
    Console.Out.WriteLine(line.ToUpper());
}

Console.In — это тот же поток, что читает Console.ReadLine(), только в виде объекта TextReader. Когда данные кончаются, метод возвращает null. Если ввод пришёл из файла — на последней строке файла. Если с клавиатуры — когда пользователь нажмёт Ctrl+D в Unix или Ctrl+Z и Enter в Windows.
Теперь как это принято оформлять. В мире CLI есть договорённость: дефис вместо имени файла означает стандартный ввод. Её поддерживают десятки утилит, и пользователи её знают.
Соберём команду, которая считает строки — хоть в файле, хоть в потоке:

using System.CommandLine;
using System.Text;

internal class Program
{
    private static int Main(string[] args)
    {
        Console.OutputEncoding = Encoding.UTF8;

        var pathArgument = new Argument<string>("path")
        {
            Description = "Файл или - для чтения из стандартного ввода",
            DefaultValueFactory = _ => "-"
        };

        var countCommand = new Command("count", "Посчитать строки и слова");
        countCommand.Arguments.Add(pathArgument);

        countCommand.SetAction(parseResult =>
        {
            var path = parseResult.GetValue(pathArgument)!;

            if (path == "-" && !Console.IsInputRedirected)
            {
                Console.Error.WriteLine(
                    "Нет данных на входе. Укажите файл или передайте данные через конвейер");
                return 1;
            }

            if (path != "-" && !File.Exists(path))
            {
                Console.Error.WriteLine($"Файл не найден: {path}");
                return 2;
            }

            using TextReader reader = path == "-"
                ? Console.In
                : new StreamReader(path);

            var lines = 0;
            var words = 0;

            string? line;
            while ((line = reader.ReadLine()) != null)
            {
                lines++;
                words += line.Split(
                    (char[]?)null,
                    StringSplitOptions.RemoveEmptyEntries).Length;
            }

            if (Console.IsOutputRedirected)
       Console.Out.WriteLine($"{lines} {words}");
   else
       Console.Out.WriteLine($"Строк: {lines}, слов: {words}");
            return 0;
        });

        var rootCommand = new RootCommand("Учебное CLI приложение");
        rootCommand.Subcommands.Add(countCommand);

        return rootCommand.Parse(args).Invoke();
    }
}

Ключевая строка здесь — выбор источника. Дальше код работает с TextReader и вообще не знает, откуда пришли данные: из файла, из конвейера или с клавиатуры.

Проверяем все три способа:

# из файла (добавьте файл в текущее расположение)
dotnet run -- count data.txt

# из конвейера
Get-Content data.txt | dotnet run -- count -

# прямо из строки
"первая строка`nвторая строка" | dotnet run -- count -

Важно!
Обратите внимание на проверку Console.IsInputRedirected. Без неё программа, запущенная как myapp count - без данных на входе, молча зависнет в ожидании клавиатуры. Пользователь решит, что она повисла, и будет прав.

Ещё один момент — чтение целиком. Соблазнительно написать так:

var text = Console.In.ReadToEnd();

Это нормально для небольших данных, но означает, что всё содержимое окажется в памяти. А через конвейер вам могут подать лог на несколько гигабайт. Построчное чтение из примера выше работает потоково: обработали строку — забыли про неё.
И отдельная засада для Windows. PowerShell передаёт данные внешним программам в своей кодировке, которая по умолчанию может не совпадать с UTF-8 — и русский текст приедет в виде вопросительных знаков. Лечится явной настройкой (пост)

В самом приложении симметрично задаём кодировку чтения:

Console.InputEncoding = Encoding.UTF8;

Поддержка stdin — это то, что превращает вашу утилиту из "вещи в себе" в деталь конструктора. Пользователи придумают такие связки, которые вам и в голову не приходили, и ничего дописывать для этого не придётся.