twigc/src/Twigc/Application.php
dana 9cbe7e75f9 Significantly rework application: 0.3.0
- Get rid of Symfony Console application/command/input components

  These were causing me serious problems — not least of all was the fact that
  the Console argument parser chokes on command lines lke `twigc -j - foo.twig`.
  I am not happy with the way i've structured this, but i just needed to get it
  *done*, so here we are. If someone has any advice on to how to make this nicer
  (maybe break it up into different classes, &c.), i would be appreciative

- Add GetOpt.php for argument parsing and usage-help printing

  The usage help is really ugly, tbh. But whatever

- Support multiple input data sources at once

- Add `-E` short option for `--env`

- Display an error when `-E` is used without the appropriate `variables_order`
  configuration in place

- Add shell (`sh`) auto-escape method

- Simplify/eliminate a lot of code (handling of version numbers, validation, &c.)

- Change a lot of white space and formatting stuff (RIP `blame`)
2018-06-02 01:58:59 -05:00

498 lines
14 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* This file is part of twigc.
*
* @author dana <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
use GetOpt\{Argument,GetOpt,Operand,Option};
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\{ConsoleOutputInterface,OutputInterface};
use Twig\Environment;
use Twig\Loader\{ArrayLoader,FilesystemLoader};
use Dana\Twigc\ComposerHelper;
/**
* This class represents the entire `twigc` application.
*
* To be completely honest, this feels really shitty to me, and i don't like it.
* But after eliminating the standard Symfony\Console structure (due to
* Console's woefully inadequate argument handling, amongst other things), i
* find myself unsure of the best way to structure this, especially given how
* simple the application is, and just kind of want to be done with it. I guess
* this works for now, but i would welcome any improvements.
*/
class Application {
const NAME = 'twigc';
const VERSION = '0.3.0';
const BUILD_DATE = '%BUILD_DATE%'; // Replaced during build
protected $name;
protected $version;
/**
* Construct the object.
*
* @param string|null $name
* (optional) The name of the application, to be used in error messages and
* the like.
*
* @param string|null $version
* (optional) The version number of the application, to be used in the
* `--version` output.
*
* @return self
*/
public function __construct(string $name = null, string $version = null) {
$this->name = $name ?? static::NAME;
$this->version = $version ?? static::VERSION;
}
/**
* Run the application.
*
* This is mostly a wrapper around doRun() to handle error printing.
*
* @param OutputInterface $output
* The output to write to.
*
* @param array|null $argv
* (optional) Command-line arguments to the application (with the 0th member
* as the application name).
*
* @return int
*/
public function run(OutputInterface $output, array $argv = null): int {
if ( $output instanceof ConsoleOutputInterface ) {
$error = $output->getErrorOutput();
} else {
$error = $output;
}
try {
return $this->doRun($output, $argv);
} catch ( \Exception $e ) {
$error->writeln(sprintf(
'%s: %s', $this->name,
rtrim($e->getMessage(), "\r\n"))
);
return 1;
}
}
/**
* Run the application (for real).
*
* @param OutputInterface $output
* The output to write to.
*
* @param array|null $argv
* (optional) Command-line arguments to the application (with the 0th member
* as the application name).
*
* @return int
*/
public function doRun(OutputInterface $output, array $argv = null): int {
$argv = $argv ?? $_SERVER['argv'];
$getopt = $this->getGetOpt();
$getopt->process(array_slice($argv, 1));
if ( $getopt->getOption('help') ) {
$this->doHelp($output, $getopt);
return 0;
}
if ( $getopt->getOption('version') ) {
$this->doVersion($output);
return 0;
}
if ( $getopt->getOption('credits') ) {
$this->doCredits($output);
return 0;
}
$inputData = [];
$template = $getopt->getOperand('template');
$dirs = $getopt->getOption('dir');
$temp = false;
// If we're receiving data on standard input, and we didn't get a template,
// assume `-` — we'll make sure this doesn't conflict with `-j` below
if ( ! posix_isatty(\STDIN) ) {
$template = $template ?? '-';
}
// Add the template's parent directory if we're not using standard input
if ( ($template ?? '-') !== '-' ) {
$dirs = array_merge([dirname($template)], $dirs);
}
if ( $template === null ) {
$this->doHelp($output, $getopt, 'No template specified');
return 1;
}
// Input data via environment
if ( $getopt->getOption('env') ) {
if ( empty($_ENV) && strpos(ini_get('variables_order'), 'E') === false ) {
throw new \RuntimeException(
"INI setting 'variables_order' must include 'E' to use option 'env'"
);
}
$inputData = array_merge($inputData, $_ENV);
}
// Input data via query string
foreach ( $getopt->getOption('query') as $query ) {
$query = ltrim($query, '?');
$parsed = [];
parse_str($query, $parsed);
$inputData = array_merge($inputData, $parsed);
}
// Input data via JSON
foreach ( $getopt->getOption('json') as $json ) {
// JSON supplied via standard input
if ( $json === '-' ) {
if ( $template === '-' ) {
throw new \InvalidArgumentException(
'Can not read both template and JSON input from stdin'
);
}
$json = file_get_contents('php://stdin');
// JSON supplied via file
} elseif ( (ltrim($json)[0] ?? '') !== '{' ) {
if ( ! file_exists($json) || is_dir($json) ) {
throw new \InvalidArgumentException(
"Missing or invalid JSON file: ${json}"
);
}
$json = file_get_contents($json);
}
// This check is here to prevent errors if the input is just empty
if ( trim($json) !== '' ) {
$json = json_decode($json, true);
}
if ( ! is_array($json) ) {
throw new \InvalidArgumentException(
'JSON input must be a dictionary'
);
}
$inputData = array_merge($inputData, $json);
}
// Input data via key=value pair
foreach ( $getopt->getOption('pair') as $pair ) {
$kv = explode('=', $pair, 2);
if ( count($kv) !== 2 ) {
throw new \InvalidArgumentException(
"Illegal key=value pair: ${pair}"
);
}
$inputData[$kv[0]] = $kv[1];
}
// Template supplied via file path
if ( $template !== '-' ) {
$loader = new FilesystemLoader($dirs);
// Template supplied via standard input
} else {
// If we've been supplied one or more search directories, we'll need to
// write the template out to a temp directory so we can use the file-
// system loader
if ( $dirs ) {
$temp = true;
$template = implode('/', [
sys_get_temp_dir(),
implode('.', ['', $this->name, getmypid(), md5(time())]),
$template,
]);
mkdir(dirname($template));
file_put_contents($template, file_get_contents('php://stdin'), \LOCK_EX);
$dirs = array_merge([dirname($template)], $dirs);
$loader = new FilesystemLoader($dirs);
// Otherwise, we can just use the array loader, which is a little faster
// and cleaner
} else {
$loader = new ArrayLoader([
$template => file_get_contents('php://stdin'),
]);
}
}
// Render
try {
$twig = new Environment($loader, [
'cache' => $getopt->getOption('cache') ?? false,
'debug' => false,
'strict_variables' => (bool) $getopt->getOption('strict'),
'autoescape' => $this->getEscaper(
$getopt->getOption('escape'),
$template
),
]);
$twig->getExtension('Twig_Extension_Core')->setEscaper(
'json',
function($twigEnv, $string, $charset) {
return json_encode(
$string,
\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
);
}
);
$twig->getExtension('Twig_Extension_Core')->setEscaper(
'sh',
function($twigEnv, $string, $charset) {
return '"' . addcslashes($string, '$`\\"') . '"';
}
);
$output->writeln(
rtrim($twig->render(basename($template), $inputData), "\r\n")
);
// Clean up
} finally {
if ( $temp ) {
unlink($template);
rmdir(dirname($template));
}
}
return 0;
}
/**
* Display the application's usage help.
*
* @param OutputInterface $output
* The output to write to.
*
* @param GetOpt $getopt
* The GetOpt instance from which to derive the usage help.
*
* @param string|null $message
* (optional) An additional message to print above the usage help. This is
* intended primarily for error messages.
*
* @return int
*/
public function doHelp(
OutputInterface $output,
GetOpt $getopt,
string $message = null
): int {
if ( $message !== null && $message !== '' ) {
$output->writeln("{$this->name}: " . rtrim($message, "\r\n") . "\n");
}
$output->writeln(rtrim($getopt->getHelpText(), "\r\n"));
return 0;
}
/**
* Display the application's version information.
*
* @param OutputInterface $output The output to write to.
*
* @return int
*/
public function doVersion(OutputInterface $output): int {
$version = sprintf('%s version %s', $this->name, $this->version);
if ( strpos(static::BUILD_DATE, '%') === false ) {
$version .= sprintf(' (built %s)', static::BUILD_DATE);
}
$output->writeln($version);
return 0;
}
/**
* Display the application's dependency information.
*
* @param OutputInterface $output The output to write to.
*
* @return int
*/
public function doCredits(OutputInterface $output): int {
$packages = (new ComposerHelper())->getPackages();
$table = new Table($output);
$table->setStyle('compact');
$table->getStyle()->setVerticalBorderChar('');
$table->getStyle()->setCellRowContentFormat('%s ');
$table->setHeaders(['name', 'version', 'licence']);
foreach ( $packages as $package ) {
$table->addRow([
$package->name,
ltrim($package->version, 'v'),
implode(', ', $package->license) ?: '?',
]);
}
$table->render();
return 0;
}
/**
* Get an instance of GetOpt configured for this application.
*
* @return GetOpt
*/
public function getGetOpt(): GetOpt {
$getopt = new GetOpt(null, [
GetOpt::SETTING_SCRIPT_NAME => $this->name,
GetOpt::SETTING_STRICT_OPERANDS => true,
]);
$getopt->addOptions([
Option::create('h', 'help', GetOpt::NO_ARGUMENT)
->setDescription('Display this usage help and exit')
,
Option::create('V', 'version', GetOpt::NO_ARGUMENT)
->setDescription('Display version information and exit')
,
Option::create(null, 'credits', GetOpt::NO_ARGUMENT)
->setDescription('Display dependency information and exit')
,
Option::create(null, 'cache', GetOpt::REQUIRED_ARGUMENT)
->setDescription('Enable caching to specified directory')
->setArgumentName('dir')
->setValidation('is_dir')
,
Option::create('d', 'dir', GetOpt::MULTIPLE_ARGUMENT)
->setDescription('Add specified search directory to loader')
->setArgumentName('dir')
->setValidation('is_dir')
,
Option::create('e', 'escape', GetOpt::REQUIRED_ARGUMENT)
->setArgumentName('strategy')
->setDescription('Specify default auto-escaping strategy')
,
Option::create('E', 'env', GetOpt::NO_ARGUMENT)
->setDescription('Derive input data from environment')
,
Option::create('j', 'json', GetOpt::MULTIPLE_ARGUMENT)
->setArgumentName('dict/file')
->setDescription('Derive input data from specified JSON file or dictionary string')
,
Option::create('p', 'pair', GetOpt::MULTIPLE_ARGUMENT)
->setArgumentName('input')
->setDescription('Derive input data from specified key=value pair')
,
Option::create(null, 'query', GetOpt::MULTIPLE_ARGUMENT)
->setArgumentName('input')
->setDescription('Derive input data from specified URL query string')
,
Option::create('s', 'strict', GetOpt::NO_ARGUMENT)
->setDescription('Throw exception when undefined variable is referenced')
,
]);
$getopt->addOperands([
Operand::create('template', Operand::OPTIONAL),
]);
return $getopt;
}
/**
* Get the correct Twig escape method given the provided options.
*
* @param string|null $escape
* The user-provided escape option, or null if not provided.
*
* @param string|null $template
* The name/path of the template file, or null if not provided. This is only
* used when $escape is null or 'auto'.
*
* @return string|false
*/
public function getEscaper($escape, string $template = null) {
$escape = $escape === null ? $escape : strtolower($escape);
$template = $template ?? '';
if ( $escape === null || $escape === 'auto' ) {
if (
substr($template, -5) === '.twig'
&&
strpos(substr($template, 0, -5), '.')
) {
$ext = pathinfo(substr($template, 0, -5), \PATHINFO_EXTENSION);
} else {
$ext = pathinfo($template, \PATHINFO_EXTENSION);
}
switch ( strtolower($ext) ) {
case 'htm':
case 'html':
case 'phtml':
case 'thtml':
case 'xhtml':
case 'template':
case 'tmpl':
case 'tpl':
return 'html';
case 'css':
case 'scss':
return 'css';
case 'js':
return 'js';
case 'json':
return 'json';
case 'bash':
case 'ksh':
case 'sh':
case 'zsh':
return 'sh';
}
return false;
}
// Otherwise, try to parse the supplied method
switch ( $escape ) {
case 'f':
case 'n':
case 'none':
case 'never':
$escape = 'false';
break;
case 't':
case 'y':
case 'always':
$escape = 'true';
break;
}
$bool = filter_var(
$escape,
\FILTER_VALIDATE_BOOLEAN,
\FILTER_NULL_ON_FAILURE
);
if ( $bool !== null ) {
$escape = $bool ? 'html' : false;
}
return $escape;
}
}