TTY.php

PHP

Path: src/CLI/TTY.php

<?php

namespace mini\CLI;

/**
 * Terminal output helper
 *
 * Provides utilities for structured terminal output including
 * markdown-style tables and formatted text.
 */
class TTY
{
    /**
     * Render data as a markdown-style table
     *
     * @param iterable $rows Rows of data (arrays or objects)
     * @param array|null $columns Column names (auto-detected from first row if null)
     * @return string Rendered table
     */
    public static function table(iterable $rows, ?array $columns = null): string
    {
        // Materialize if needed
        if (!is_array($rows)) {
            $rows = iterator_to_array($rows);
        }

        if (empty($rows)) {
            return "(empty result set)\n";
        }

        // Get columns from first row if not specified
        $first = $rows[0];
        $columns ??= array_keys((array)$first);

        // Calculate column widths
        $widths = [];
        foreach ($columns as $col) {
            $widths[$col] = strlen($col);
        }
        foreach ($rows as $row) {
            $row = (array)$row;
            foreach ($columns as $col) {
                $val = self::formatValue($row[$col] ?? null);
                $widths[$col] = max($widths[$col], strlen($val));
            }
        }

        // Build output
        $out = '';

        // Header
        $out .= '|';
        foreach ($columns as $col) {
            $out .= ' ' . str_pad($col, $widths[$col]) . ' |';
        }
        $out .= "\n";

        // Separator
        $out .= '|';
        foreach ($columns as $col) {
            $out .= str_repeat('-', $widths[$col] + 2) . '|';
        }
        $out .= "\n";

        // Rows
        foreach ($rows as $row) {
            $row = (array)$row;
            $out .= '|';
            foreach ($columns as $col) {
                $val = self::formatValue($row[$col] ?? null);
                $out .= ' ' . str_pad($val, $widths[$col]) . ' |';
            }
            $out .= "\n";
        }

        $out .= "\n" . count($rows) . " row(s)\n";

        return $out;
    }

    /**
     * Render data as CSV
     *
     * @param iterable $rows Rows of data (arrays or objects)
     * @param array|null $columns Column names (auto-detected from first row if null)
     * @param bool $header Include header row
     * @return string CSV output
     */
    public static function csv(iterable $rows, ?array $columns = null, bool $header = true): string
    {
        // Materialize if needed
        if (!is_array($rows)) {
            $rows = iterator_to_array($rows);
        }

        if (empty($rows)) {
            return "";
        }

        // Get columns from first row if not specified
        $first = $rows[0];
        $columns ??= array_keys((array)$first);

        $out = fopen('php://memory', 'r+');

        // Header
        if ($header) {
            fputcsv($out, $columns);
        }

        // Rows
        foreach ($rows as $row) {
            $row = (array)$row;
            $values = [];
            foreach ($columns as $col) {
                $values[] = self::formatValueRaw($row[$col] ?? null);
            }
            fputcsv($out, $values);
        }

        rewind($out);
        $result = stream_get_contents($out);
        fclose($out);

        return $result;
    }

    /**
     * Render data as JSON
     *
     * @param iterable $rows Rows of data (arrays or objects)
     * @param bool $pretty Pretty-print with indentation
     * @return string JSON output
     */
    public static function json(iterable $rows, bool $pretty = false): string
    {
        // Materialize if needed
        if (!is_array($rows)) {
            $rows = iterator_to_array($rows);
        }

        $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
        if ($pretty) {
            $flags |= JSON_PRETTY_PRINT;
        }

        return json_encode(array_values($rows), $flags) . "\n";
    }

    /**
     * Render data as a line-based format (one value per line, for single column results)
     *
     * @param iterable $rows Rows of data
     * @return string Line output
     */
    public static function line(iterable $rows): string
    {
        $out = '';
        foreach ($rows as $row) {
            $row = (array)$row;
            $values = array_values($row);
            $out .= self::formatValueRaw($values[0] ?? null) . "\n";
        }
        return $out;
    }

    /**
     * Format a value for display (with NULL indicator)
     */
    public static function formatValue(mixed $value): string
    {
        if ($value === null) {
            return 'NULL';
        }
        if (is_bool($value)) {
            return $value ? 'true' : 'false';
        }
        if (is_array($value) || is_object($value)) {
            return json_encode($value);
        }
        return (string)$value;
    }

    /**
     * Format a value for raw output (empty string for null)
     */
    public static function formatValueRaw(mixed $value): string
    {
        if ($value === null) {
            return '';
        }
        if (is_bool($value)) {
            return $value ? '1' : '0';
        }
        if (is_array($value) || is_object($value)) {
            return json_encode($value);
        }
        return (string)$value;
    }

    /**
     * Check if stdout is a TTY (interactive terminal)
     */
    public static function isInteractive(): bool
    {
        return function_exists('posix_isatty') && posix_isatty(STDOUT);
    }

    /**
     * Get terminal width
     */
    public static function width(): int
    {
        if (getenv('COLUMNS')) {
            return (int)getenv('COLUMNS');
        }
        if (function_exists('exec')) {
            $output = [];
            @exec('tput cols 2>/dev/null', $output);
            if (!empty($output[0]) && is_numeric($output[0])) {
                return (int)$output[0];
            }
        }
        return 80;
    }
}