#!/usr/bin/env php
<?php
/**
 * dog
 *
 * Better than cat
 *
 * Version 2.2.4, July 19, 2025
 * Copyright (c) 2015-2025, Ron Guerin <ron@vnetworx.net>
 *
 * Concatenates files, URLs, or standard input, and sends the result to STDOUT.
 * Like 'cat', but with more options.  All of the 'cat' switches are preserved
 * for compatibility.  Supported URL types: file, http, https, ftp, ftps, ssh2.sftp
 *
 * Partial PHP reimplementation of dog.c Debian Version 1.7, by Jason Cohen and Jacob Leverich
 *
 * Requires: PHP_PCRE
 *
 * dog is Free Software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 2 of the License, or (at your option)
 * any later version.
 *
 * dog is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * If you are not able to view the file COPYING, please write to the
 * Free Software Foundation, Inc.,
 * 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 * to get a copy of the GNU General Public License or to report a
 * possible license violation.
 *
 * @package dog
 * @author Ron Guerin <ron@vnetworx.net>
 * @license http://www.fsf.org/licenses/gpl.html GNU Public License
 * @copyright Copyright &copy; 2015-2025 Ron Guerin
 * @filesource
 * @link http://gothamcode.com/dog dog
 * @version 2.2.4
 *
*/

// All options in original dog 1.7 not stripped out by Debian
#   dog [-AbBeEnstTuv]  [-w[cols]]  [-l lines]  [--show-all]  [--number-non-blank]
#       [--no-blanks]  [--bind=port]  [--dos]  [--show-ends]  [--hang-up]
#       [--images]  [--links]  [--lower]  [--mac]  [--number]  [--no-header]
#       [--squeeze-blank]  [--sock=domain:port]  [--sock-test]  [--show-tabs]
#       [--raw]  [--rot=num]  [--udp]  [--unix]  [--upper]  [--show-nonprinting]
#       [--hide-nonprinting]  [--help]  [--hex]  [--skip-tags]  [--version]
#       file | URL | - ...

// All options remaining unimplemented by this implementation and version
#   dog [-l lines]  [--dos]  [--mac]  [--unix]  [--bind=port]  [--hang-up]
#       [--sock=domain:port]  [--sock-test]  [--udp]  [--skip-tags]

define('VERSION', '2.2.4');
define('MEPATH', realpath($argv[0]));
define('ME', preg_replace(chr(7).'\.php[0-9]*$'.chr(7), '', basename($argv[0])));
define('VERSIONSTAMP', date('F j, Y H:i:s', stat(MEPATH)['mtime']));
define('OS', strtolower(PHP_OS_FAMILY));
if (function_exists('cli_set_process_title')) cli_set_process_title(ME); // set proctitle

$fail = $skip = $accept = $noheaders = $upper = $lower = $hidenonprinting
	= $images = $links = $width = $noblanks = $squeeze = $number
	= $numbernonblanks = $showends = $showtabs = $hex = $shownonprinting = FALSE;
$useragent = $header = $count = $user = $pass = '';
$paths = array();
$format = 'html';
$exit = $ctr = 0;

foreach ($argv as $index => $arg) {
	if (! $index) continue; // skip $argv[0]
	if ($skip) {
		$skip = FALSE;
		continue;
	}
	$argbase = ($pos = strpos($arg, '=')) ? substr($arg, 0, $pos) : $arg;
	switch ($argbase) {
		case '-V':
		case '--version':
			echo VERSION."\n";
			exit;
			break;
		case '?':
		case '-?':
		case '-h':
		case '--help':
		case 'help':
			help();
			exit;
			break;
		case '-u':
			break; // Ignored for compatibility with cat, which also ignores -u
		case '-e':
			$showends = TRUE;
			$shownonprinting = TRUE;
			break;
		case '--show-all':
			$showends = TRUE;
			$showtabs = TRUE;
			$shownonprinting = TRUE;
			break;
		case '-t':
			$showtabs = TRUE;
			$shownonprinting = TRUE;
			break;
		case '-v':
		case '--show-nonprinting':
			$shownonprinting = TRUE;
			break;
		case '--hide-nonprinting':
			$hidenonprinting = TRUE;
			break;
		case '--hex':
			$hex = TRUE;
			break;
		case '--no-header':
			$noheaders = TRUE;
			break;
		case '-E':
		case '--show-ends':
			$showends = TRUE;
			break;
		case '-T':
		case '--show-tabs':
			$showtabs = TRUE;
			break;
		case '-B':
		case '--no-blanks':
			$noblanks = TRUE;
			break;
		case '-s':
		case '--squeeze-blank':
			$squeeze = TRUE;
			break;
		case '-b':
		case '--number-non-blank':
			$numbernonblanks = TRUE;
			break;
		case '-n':
		case '--number':
			$number = TRUE;
			break;
		case '--images':
			$images = TRUE;
			break;
		case '--links':
			$links = TRUE;
			break;
		case '--upper':
			$upper = TRUE;
			break;
		case '--lower':
			$lower = TRUE;
			break;
		case '-H':
		case '--header':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-') || (! is_numeric($argv[$index+1]))) {
				@fwrite(STDERR, 'Error: No header given for '.$argbase."\n");
				exit(1);
			}
			$header .= $argv[$index+1]."\r\n";
			$skip = TRUE;
			break;
		case '-g':
		case '--user-agent':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-') || (! is_numeric($argv[$index+1]))) {
				@fwrite(STDERR, 'Error: No User-Agent given for '.$argbase."\n");
				exit(1);
			}
			$useragent .= $argv[$index+1]."\r\n";
			$skip = TRUE;
			break;
		case '-w':
		case '--width':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-') || (! is_numeric($argv[$index+1]))) {
				@fwrite(STDERR, 'Error: No column width given for '.$argbase."\n");
				exit(1);
			}
			$width = $argv[$index+1];
			$skip = TRUE;
			break;
		case '-f':
		case '--format':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-')) {
				@fwrite(STDERR, 'Error: No format given for '.$argbase."\n");
				exit(1);
			}
			$format = $argv[$index+1];
			if (($format != 'text') && ($format != 'html') && ($format != 'json') && ($format != 'yaml') && ($format != 'xml')) {
				fwrite(STDERR, 'Error: Format must be one of: text, html, json, yaml, or xml'."\n");
				exit(1);
			}
			$skip = TRUE;
			break;
		case '-U':
		case '--user':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-')) {
				@fwrite(STDERR, 'Error: No userid given for '.$argbase."\n");
				exit(1);
			}
			$user = $argv[$index+1];
			$skip = TRUE;
			break;
		case '-P':
		case '--password':
			if ((! array_key_exists($index+1, $argv)) || (substr($argv[$index+1], 0, 1) == '-')) {
				@fwrite(STDERR, 'Error: No password given for '.$argbase."\n");
				exit(1);
			}
			$pass = $argv[$index+1];
			$skip = TRUE;
			break;
		default:
			if ($argbase == '-') {
				if (in_array('-', $paths)) fwrite(STDERR, 'Warning: STDIN already specified, ignoring.'."\n");
				$paths[] = '-';
			}
			elseif (substr($argbase, 0, 1) == '-') {
				fwrite(STDERR, 'Error: Unknown option: '.$argbase."\n");
				$fail = TRUE;
			}
			else $paths[] = trim($arg, "\n\r\t\v\x00\"'");
	}
}
if (! count($paths)) $paths[] = '-';
if ($shownonprinting && $hidenonprinting) {
	$fail = TRUE;
	fwrite(STDERR, 'Error: Ambiguous treatment of non-printing characters, '
		.'cannot specify both --shownonprinting and --hidenonprinting .'."\n");
}
if ($upper && $lower) {
	$fail = TRUE;
	fwrite(STDERR, 'Error: Ambiguous Casing, cannot specify both --upper and --lower .'."\n");
}
if ($fail) {
	fwrite(STDERR, "\n");
	help(TRUE);
	exit(1);
}
if ($links || $images) $noheaders = TRUE;

if     ($format == 'text') $accept = 'text/plain';
elseif ($format == 'html') $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
elseif ($format == 'json') $accept = 'application/json';
elseif ($format == 'yaml') $accept = 'application/yaml';
elseif ($format == 'xml')  $accept = 'application/xml';
$ua = ($useragent) ? $useragent : ME.'/'.VERSION;
$opts = array('http' => array('ignore_errors' => TRUE, 'follow_location' => 0, 'user_agent' => $ua));
if ($accept) $opts['http']['header'] = 'Accept: '.$accept."\r\n";
if ($user || $pass) $opts['http']['header'] = (array_key_exists('header', $opts['http']))
	? $opts['http']['header'] : ''.'Authorization: Basic '.base64_encode($user.':'.$pass)."\r\n";
if ($header) $opts['http']['header'] = (array_key_exists('header', $opts['http']))
	? $opts['http']['header'] : ''.$header;

foreach ($paths as $path) {
	if ($path == '-') {
		$content = '';
		while ($line = fgets(STDIN)) $content .= $line;
	}
	else $content = @file_get_contents($path, FALSE, (strstr($path, '://'))
		? stream_context_create($opts) : stream_context_create(array()));
	$headers = '';
	if ((! $noheaders) && (isset($http_response_header))) {
		foreach ($http_response_header as $header) $headers .= $header."\n";
		$headers .= "\n";
	}
	$content = $headers.$content;
	if ($content !== FALSE) {
		if ($upper) $content = strtoupper($content);
		elseif ($lower) $content = strtolower($content);
		if ($links || $images) {
			$root = trim($path, '/');
			if ($links && preg_match_all(chr(7).'<a.*href="(.*)".*>'.chr(7).'U', $content, $matches)) {
				foreach ($matches[1] as $match) {
					if (substr($match, 0, 1) == '/') $match = $root.$match;
					$matched[$match] = TRUE;
				}
				foreach ($matched as $match => $junk) echo $match."\n";
			}
			if ($links && $images) {
				echo "#\n";
				unset($matched);
			}
			if ($images && preg_match_all(chr(7).'<img.*src="(.*)".*>'.chr(7).'U', $content, $matches)) {
				foreach ($matches[1] as $match) {
					if (substr($match, 0, 1) == '/') $match = $root.$match;
					$matched[$match] = TRUE;
				}
				foreach ($matched as $match => $junk) echo $match."\n";
			}
		}
		if ($shownonprinting) {
			if (OS == 'windows') $le = 13; // carriage-return
			else $le = 10; // linefeed
			for ($i=0; $i<32; $i++) if (($i != 9) && ($i != 10)) $content = str_replace(chr($i), '^'.chr($i + 64), $content);
		}
		if ($hidenonprinting) {
			if (OS == 'windows') $le = 13; // carriage-return
			else $le = 10; // linefeed
			for ($i=0; $i<32; $i++) if (($i != 9) && ($i != 10)) $content = str_replace(chr($i), '', $content);
		}
		if ($hex) hex_dump($content); #FIXME: use appropriate line endings
		elseif ($showtabs && (! $shownonprinting)) $content = str_replace("\t", '^I', $content);
		elseif ($width || $noblanks || $squeeze || $number || $numbernonblanks || $showends) {
			$blanklast = FALSE;
			foreach (explode("\n", $content) as $line) {
				$trim = trim($line);
				if (! $trim) {
					if ($noblanks) continue;
					elseif ($squeeze && $blanklast) continue;
					$blanklast = TRUE;
				}
				if ($width) $line = substr($line, 0, $width);
				if ($number || ($numbernonblanks && trim($line))) $count = str_pad(++$ctr, 6, ' ', STR_PAD_LEFT).' ';
				else $count = '';
				echo $count.$line.(($showends)?'$':'')."\n";
			}
		}
		else echo $content;
	}
	else {
		fwrite(STDERR, 'Error retrieving '.$path."\n");
		$exit = 1;
	}
}


####################################################################################################################################
####################################################################################################################################


function hex_dump($string, $linesep="\n") {
	// Based on: https://stackoverflow.com/a/34279537
	if (! is_scalar($string)) throw new InvalidArgumentException('$string argument must be a string');
	$width = 16;
	$pad_char = '.'; # padding for non-readable characters
	$text_lines = str_split($string, $width);
	$hex_lines  = str_split(bin2hex($string), $width * 2);
	$offset = 0;
	$output = array();
	$output = '';
	foreach ($hex_lines as $i => $hex_line) {
		$text_line = $text_lines[$i];
		$output .=
		sprintf('%08X', $offset).' : '.
		str_pad(strlen($text_line) > 8
			? strtoupper(implode(' ', str_split(substr($hex_line, 0, $width), 2))).'  '.
			strtoupper(implode(' ', str_split(substr($hex_line, $width), 2)))
			: strtoupper(implode(' ', str_split($hex_line, 2))), $width * 3)
				.'  |'.preg_replace('/[^\x20-\x7E]/', $pad_char, $text_line).'|'.$linesep;
		$offset += $width;
	}
	echo $output;
}

function formatstr($str, $cols=FALSE) {
	if (defined('COLUMNS') && (! $cols)) $cols = COLUMNS;
	return ($cols) ? wordwrap($str, $cols) : $str;
}

function terminal_init(&$rows=FALSE) {
	//	'tput cols'   tput used to, but no longer returns correct value when invoked by PHP exec()
	//	'resize'      works, not commonly installed, needs to parse: COLUMNS=167;\nLINES=48;\nexport COLUMNS LINES;\n
	// 'stty -a'     works, needs to parse: speed 38400 baud; rows 49; columns 167; line = 0;
	if (defined('COLUMNS')) return COLUMNS;
	$rows = FALSE; $cols = FALSE;
	$out = ''; $return = 0;
	exec('stty -a 2>/dev/null', $out, $return);
	if ($return == 0) {
		$out = strtolower(implode("\n", $out));
		if (FALSE !== preg_match_all("/rows.([0-9]+);.columns.([0-9]+);/", $out, $matches)) {
			$rows = $matches[1][0];
			$cols = $matches[2][0];
		}
	}
	if ($cols == FALSE) {
		$cols = exec('tput cols 2>/dev/null', $out, $return);
		if ($rows) $rows = exec('tput lines 2>/dev/null', $out, $return);
	}
	if (! $cols) $cols = 80;
	if (! defined('COLUMNS')) define('COLUMNS', $cols);
	if ($rows && (! defined('ROWS'))) define('ROWS', $rows);
	return $cols;
}

function help($stderr=FALSE) {
	terminal_init();
	$out = ($stderr === FALSE) ? STDOUT : STDERR;
	$str = ME.' v. '.VERSION;
	$str .= ' Partial re-implementation of "dog - better than cat" in PHP. '
		.''."\n";
	@fwrite($out, formatstr($str, COLUMNS));
	@fwrite($out, formatstr('Reads STDIN, files, URLs, and sends the result to stdout.'."\n\n", COLUMNS));
	@fwrite($out, formatstr('Usage: '.ME.' [options] - path URL ... '."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Options:'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-h|--help] (show this help, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-V|--version] (show version number, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-] (read input from STDIN)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-f|--format html/text/json/yaml/xml] (set Accept header appropriately)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-U|--user username] (sets Authorization header appropriately)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-P|--password password] (sets Authorization header appropriately)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-w|--width cols] (print first \'cols\' characters of each line)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-A|--show-all] (show tabs (-T), control characters (-v), and line endings (-E))'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-B|--no-blanks] (only print lines with non-whitespace characters)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-E|--show-ends] (display a \'$\' at the end of each line)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-t] (display TAB characters (-T) and control characters (-v))'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-T|--show-tabs] (display TAB characters as \'^I\')'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-s|--squeeze-blank] (never print more than one single blank line)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-n|--number] (precede each line with its line number)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-b|--number-non-blank] (precede each non-blank line with its line number)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-v|--show-nonprinting] (display control characters except for LFD and TAB using \'^\' notation)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-e] (display line endings (-E) and control characters (-v))'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-u] (ignored, for compatibility with cat)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-g|--user-agent] user-agent (send specified User-Agent)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-H|--header] header (send specified header)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--hide-nonprinting] (do not display control characters except for LFD and TAB)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--no-header] (does not show HTTP headers for URLs)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--show-all] (equivalent to --show-ends and --show-tabs)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--upper] (convert all lower-case characters to upper)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--lower] (convert all upper-case characters to lower)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--links] (treats input as HTML and lists unique, absolute URL links from input data.)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--images] (treats input as HTML and lists unique, absolute image links from input data.)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--hex] (dump the input data as a hex dump, other formatting flags will not apply)'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'This version of dog supports reading from STDIN via - , pathnames, and URLs.  '
		.'URLS should start with http://user:pass@URI , https://user:pass@URI , ftp://user:pass@filepath , '
		.'ssh2.sftp://user:pass@example.com/filepath , ftps://user:password@example.com/filepath , zip://filepath '."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'--header may be specified multiple times to send multiple headers.'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'If specified, user (-U) and password (-P) will be sent to all given URLs.'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Note that dog may not be adequate for diagnosing Web server output, '
		.'as line endings are stripped from headers before dog displays them.'."\n" , COLUMNS));
}
