Add source

This commit is contained in:
dana 2016-07-24 13:28:49 -05:00
parent 296717820c
commit 48fb6be6e6
8 changed files with 1426 additions and 0 deletions

24
bin/compile Normal file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env php
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
require_once __DIR__ . '/../src/bootstrap.php';
$verbose = false;
$verboseArgs = ['v', 'vv', 'vvv', 'verbose', 'debug'];
foreach ( $argv as $arg ) {
if ( in_array(ltrim($arg, '-'), $verboseArgs, true) ) {
$verbose = true;
break;
}
}
(new \Dana\Twigc\PharCompiler($verbose))->compile();

14
bin/twigc Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
require_once __DIR__ . '/../src/bootstrap.php';
(new \Dana\Twigc\Application())->run();

340
src/DefaultCommand.php Normal file
View file

@ -0,0 +1,340 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Default twigc command.
*/
class DefaultCommand extends Command {
/**
* {@inheritdoc}
*/
protected function configure() {
$this
->setName('twigc')
->setDescription('Compile a Twig template')
->addArgument(
'template',
InputArgument::OPTIONAL,
'A Twig template file to process (use `-` for STDIN)'
)
->addOption(
'help',
'h',
InputOption::VALUE_NONE,
'Display this usage help'
)
->addOption(
'version',
'V',
InputOption::VALUE_NONE,
'Display version information'
)
->addOption(
'credits',
null,
InputOption::VALUE_NONE,
'Display dependency credits (including Twig version)'
)
->addOption(
'dir',
'd',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Add search directory to loader'
)
->addOption(
'escape',
'e',
InputOption::VALUE_REQUIRED,
'Set autoescape environment option'
)
->addOption(
'json',
'j',
InputOption::VALUE_REQUIRED,
'Pass variables as JSON (dictionary string or file path)'
)
->addOption(
'pair',
'p',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Pass variable as key=value pair'
)
->addOption(
'strict',
's',
InputOption::VALUE_NONE,
'Enable strict_variables environment option'
)
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
switch ( true ) {
// Display usage help
case $input->getOption('help'):
return $this->doHelp($input, $output);
// Display version information
case $input->getOption('version'):
return $this->doVersion($input, $output);
// Display package credits
case $input->getOption('credits'):
return $this->doCredits($input, $output);
}
// Render Twig template
return $this->doRender($input, $output);
}
/**
* {@inheritdoc}
*
* Overriding this prevents TextDescriptor from displaying the Help section.
*/
public function getProcessedHelp() {
return '';
}
/**
* Displays usage help.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doHelp(InputInterface $input, OutputInterface $output) {
(new DescriptorHelper())->describe($output, $this);
return 0;
}
/**
* Displays version information.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doVersion(InputInterface $input, OutputInterface $output) {
$output->writeln(sprintf(
'twigc version %s (%s @ %s)',
\Dana\Twigc\Twigc::VERSION_NUMBER,
\Dana\Twigc\Twigc::VERSION_COMMIT,
\Dana\Twigc\Twigc::VERSION_DATE
));
return 0;
}
/**
* Displays package credits.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doCredits(InputInterface $input, OutputInterface $output) {
$installed = \Dana\Twigc\Twigc::getComposerPackages();
$table = new Table($output);
$table->setStyle('compact');
$table->getStyle()->setVerticalBorderChar('');
$table->getStyle()->setCellRowContentFormat('%s ');
$table->setHeaders(['name', 'version', 'licence']);
foreach ( $installed as $package ) {
$table->addRow([
$package->name,
ltrim($package->version, 'v'),
implode(', ', $package->license) ?: '?',
]);
}
$table->render();
}
/**
* Renders a Twig template.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doRender(InputInterface $input, OutputInterface $output) {
$inputData = [];
$template = $input->getArgument('template');
$template = $template === null ? '-' : $template;
$dirs = $template === '-' ? [] : [dirname($template)];
$dirs = array_merge($dirs, $input->getOption('dir'));
$temp = false;
$strict = (bool) $input->getOption('strict');
$escape = $input->getOption('escape');
// If we're reading from STDIN, but STDIN is a TTY, print help and die
if ( $template === '-' && posix_isatty(\STDIN) ) {
$this->doHelp($input, $output);
return 1;
}
// Validate search directories
foreach ( $dirs as $dir ) {
if ( ! is_dir($dir) ) {
throw new \InvalidArgumentException(
"Illegal search directory: ${dir}"
);
}
}
// Normalise auto-escape setting
if ( $escape === null ) {
$escape = true;
} else {
$bool = filter_var($escape, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
if ( $bool !== null ) {
$escape = $bool;
} else {
$escape = strtolower($escape);
}
}
// Because Console doesn't allow us to see the order of options supplied
// at the command line, there's no good way to handle precedence of
// JSON data vs key=value pairs... so let's just disallow using them
// together at all
if ( $input->getOption('json') !== null && ! empty($input->getOption('pair')) ) {
throw new \InvalidArgumentException(
'-j and -p options may not be used together'
);
}
// Input data supplied via JSON
if ( ($json = $input->getOption('json')) !== null ) {
$json = trim($json);
// JSON supplied via STDIN
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 ( $json && $json[0] !== '{' ) {
if ( ! is_file($json) ) {
throw new \InvalidArgumentException(
"Missing or illegal JSON file name: ${json}"
);
}
$json = file_get_contents($json);
}
// This check is here to prevent errors if the input is just empty
if ( trim($json) !== '' ) {
$inputData = json_decode($json, true);
}
if ( ! is_array($inputData) ) {
throw new \InvalidArgumentException(
'JSON input must be a dictionary'
);
}
// Input data supplied via key=value pair
} elseif ( count($input->getOption('pair')) ) {
foreach ( $input->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];
}
}
// Validate key names now
foreach ( $inputData as $key => $value ) {
if ( ! preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#', $key) ) {
throw new \InvalidArgumentException(
"Illegal variable name: ${key}"
);
}
}
// Template supplied via STDIN
if ( $template === '-' ) {
// 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('.', ['twigc', getmypid(), md5(time())]),
'-',
]);
mkdir(dirname($template));
file_put_contents($template, file_get_contents('php://stdin'), LOCK_EX);
$dirs = array_merge([dirname($template)], $dirs);
$loader = new \Twig_Loader_Filesystem($dirs);
// Otherwise, we can just use the array loader, which is a little
// faster and cleaner
} else {
$loader = new \Twig_Loader_Array([
$template => file_get_contents('php://stdin'),
]);
}
// Template supplied via file path
} else {
$loader = new \Twig_Loader_Filesystem($dirs);
}
try {
$twig = new \Twig_Environment($loader, [
'cache' => false,
'debug' => false,
'strict_variables' => $strict,
'autoescape' => $escape,
]);
$output->writeln(
rtrim($twig->render(basename($template), $inputData), "\r\n")
);
} finally {
if ( $temp ) {
unlink($template);
rmdir(dirname($template));
}
}
return 0;
}
}

99
src/Twigc/Application.php Normal file
View file

@ -0,0 +1,99 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
use Symfony\Component\Console\Application as BaseApplication;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* twigc application container.
*
* This class overrides a bunch of the default Console behaviour to make the
* application work like a more traditional UNIX CLI tool.
*/
class Application extends BaseApplication {
/**
* {@inheritdoc}
*/
public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') {
parent::__construct('twigc', \Dana\Twigc\Twigc::VERSION_NUMBER);
}
/**
* {@inheritdoc}
*
* In a normal Console application, this method handles the --version and
* --help options. In our application, the default command handles all of
* that.
*/
public function doRun(InputInterface $input, OutputInterface $output) {
$name = $this->getCommandName($input);
if ( ! $name ) {
$name = $this->defaultCommand;
$input = new ArrayInput(['command' => $this->defaultCommand]);
}
$command = $this->find($name);
$this->runningCommand = $command;
$exitCode = $this->doRunCommand($command, $input, $output);
$this->runningCommand = null;
return $exitCode;
}
/**
* {@inheritdoc}
*/
public function getDefinition() {
$definition = parent::getDefinition();
$definition->setArguments();
return $definition;
}
/**
* {@inheritdoc}
*
* Since we're a one-command application, we always use the name of the
* default command.
*/
protected function getCommandName(InputInterface $input) {
return $this->getDefaultCommands()[0]->getName();
}
/**
* {@inheritdoc}
*
* Since we're a one-command application, we always use the definition of
* the default command. This means that none of the built-in Console options
* like --help and --ansi are automatically defined the default command
* must handle all of that.
*/
protected function getDefaultInputDefinition() {
return $this->getDefaultCommands()[0]->getDefinition();
}
/**
* {@inheritdoc}
*
* Since we're a one-command application, we always return just the default
* command.
*/
protected function getDefaultCommands() {
return [new \Dana\Twigc\DefaultCommand()];
}
}

View file

@ -0,0 +1,368 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\DescriptorHelper;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Default twigc command.
*/
class DefaultCommand extends Command {
/**
* {@inheritdoc}
*/
protected function configure() {
$this
->setName('twigc')
->setDescription('Compile a Twig template')
->addArgument(
'template',
InputArgument::OPTIONAL,
'Twig template file to render (use `-` for STDIN)'
)
->addOption(
'help',
'h',
InputOption::VALUE_NONE,
'Display this usage help'
)
->addOption(
'version',
'V',
InputOption::VALUE_NONE,
'Display version information'
)
->addOption(
'credits',
null,
InputOption::VALUE_NONE,
'Display dependency credits (including Twig version)'
)
->addOption(
'dir',
'd',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Add search directory to loader'
)
->addOption(
'escape',
'e',
InputOption::VALUE_REQUIRED,
'Set autoescape environment option'
)
->addOption(
'json',
'j',
InputOption::VALUE_REQUIRED,
'Pass variables as JSON (dictionary string or file path)'
)
->addOption(
'pair',
'p',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Pass variable as key=value pair'
)
->addOption(
'query',
null,
InputOption::VALUE_REQUIRED,
'Pass variables as URL query string'
)
->addOption(
'strict',
's',
InputOption::VALUE_NONE,
'Enable strict_variables environment option'
)
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
switch ( true ) {
// Display usage help
case $input->getOption('help'):
return $this->doHelp($input, $output);
// Display version information
case $input->getOption('version'):
return $this->doVersion($input, $output);
// Display package credits
case $input->getOption('credits'):
return $this->doCredits($input, $output);
}
// Render Twig template
return $this->doRender($input, $output);
}
/**
* {@inheritdoc}
*
* Overriding this prevents TextDescriptor from displaying the Help section.
*/
public function getProcessedHelp() {
return '';
}
/**
* Displays usage help.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doHelp(InputInterface $input, OutputInterface $output) {
(new DescriptorHelper())->describe($output, $this);
return 0;
}
/**
* Displays version information.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doVersion(InputInterface $input, OutputInterface $output) {
$nameFmt = '<info>twigc</info>';
$versionFmt = '<comment>%s</comment> (<comment>%s</comment> @ <comment>%s</comment>)';
$output->writeln(sprintf(
"${nameFmt} version ${versionFmt}",
\Dana\Twigc\Twigc::VERSION_NUMBER,
\Dana\Twigc\Twigc::VERSION_COMMIT,
\Dana\Twigc\Twigc::VERSION_DATE
));
return 0;
}
/**
* Displays package credits.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doCredits(InputInterface $input, OutputInterface $output) {
$installed = \Dana\Twigc\Twigc::getComposerPackages();
$table = new Table($output);
$table->setStyle('compact');
$table->getStyle()->setVerticalBorderChar('');
$table->getStyle()->setCellRowContentFormat('%s ');
$table->setHeaders(['name', 'version', 'licence']);
foreach ( $installed as $package ) {
$table->addRow([
$package->name,
ltrim($package->version, 'v'),
implode(', ', $package->license) ?: '?',
]);
}
$table->render();
}
/**
* Renders a Twig template.
*
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
*/
public function doRender(InputInterface $input, OutputInterface $output) {
$inputData = [];
$template = $input->getArgument('template');
$template = $template === null ? '-' : $template;
$dirs = $template === '-' ? [] : [dirname($template)];
$dirs = array_merge($dirs, $input->getOption('dir'));
$temp = false;
$strict = (bool) $input->getOption('strict');
$escape = $input->getOption('escape');
$inputs = [
'json' => (int) ($input->getOption('json') !== null),
'pair' => (int) (! empty($input->getOption('pair'))),
'query' => (int) ($input->getOption('query') !== null),
];
// If we're reading from STDIN, but STDIN is a TTY, print help and die
if ( $template === '-' && posix_isatty(\STDIN) ) {
$this->doHelp($input, $output);
return 1;
}
// Validate search directories
foreach ( $dirs as $dir ) {
if ( ! is_dir($dir) ) {
throw new \InvalidArgumentException(
"Illegal search directory: ${dir}"
);
}
}
// Normalise auto-escape setting
if ( $escape === null ) {
$escape = true;
} else {
$bool = filter_var($escape, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
if ( $bool !== null ) {
$escape = $bool;
} else {
$escape = strtolower($escape);
}
}
// Because Console doesn't allow us to see the order of options supplied
// at the command line, there's no good way to sort out the precedence
// amongst the different input methods... so let's just say we can only
// use one of them at a time
if ( array_sum($inputs) > 1 ) {
throw new \InvalidArgumentException(
'-j, -p, and --query options are mutually exclusive'
);
}
// Input data supplied via query string
if ( ($query = $input->getOption('query')) !== null ) {
if ( $query && $query[0] === '?' ) {
$query = substr($query, 1);
}
parse_str($query, $inputData);
// Input data supplied via JSON
} elseif ( ($json = $input->getOption('json')) !== null ) {
$json = trim($json);
// JSON supplied via STDIN
if ( $json === '-' ) {
if ( $template === '-' ) {
throw new \InvalidArgumentException(
'Can not read both template and JSON input from STDIN'
);
}
if ( posix_isatty(\STDIN) ) {
throw new \InvalidArgumentException(
'Expected JSON input on STDIN'
);
}
$json = file_get_contents('php://stdin');
// JSON supplied via file
} elseif ( $json && $json[0] !== '{' ) {
if ( ! file_exists($json) || is_dir($json) ) {
throw new \InvalidArgumentException(
"Missing or illegal JSON file name: ${json}"
);
}
$json = file_get_contents($json);
}
// This check is here to prevent errors if the input is just empty
if ( trim($json) !== '' ) {
$inputData = json_decode($json, true);
}
if ( ! is_array($inputData) ) {
throw new \InvalidArgumentException(
'JSON input must be a dictionary'
);
}
// Input data supplied via key=value pair
} elseif ( count($input->getOption('pair')) ) {
foreach ( $input->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];
}
}
// Validate key names now
foreach ( $inputData as $key => $value ) {
if ( ! preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#', $key) ) {
throw new \InvalidArgumentException(
"Illegal variable name: ${key}"
);
}
}
// Template supplied via STDIN
if ( $template === '-' ) {
// 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('.', ['twigc', getmypid(), md5(time())]),
'-',
]);
mkdir(dirname($template));
file_put_contents($template, file_get_contents('php://stdin'), LOCK_EX);
$dirs = array_merge([dirname($template)], $dirs);
$loader = new \Twig_Loader_Filesystem($dirs);
// Otherwise, we can just use the array loader, which is a little
// faster and cleaner
} else {
$loader = new \Twig_Loader_Array([
$template => file_get_contents('php://stdin'),
]);
}
// Template supplied via file path
} else {
$loader = new \Twig_Loader_Filesystem($dirs);
}
try {
$twig = new \Twig_Environment($loader, [
'cache' => false,
'debug' => false,
'strict_variables' => $strict,
'autoescape' => $escape,
]);
$output->writeln(
rtrim($twig->render(basename($template), $inputData), "\r\n")
);
} finally {
if ( $temp ) {
unlink($template);
rmdir(dirname($template));
}
}
return 0;
}
}

446
src/Twigc/PharCompiler.php Normal file
View file

@ -0,0 +1,446 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Finder\Finder;
/**
* Compiles twigc into an executable phar file.
*
* This clas is heavily inspired by Composer's Compiler:
*
* Copyright (c) 2016 Nils Adermann, Jordi Boggiano
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
class PharCompiler {
protected $output;
protected $baseDir;
protected $finderSort;
protected $versionNumber;
protected $versionCommit;
protected $versionDate;
/**
* Object constructor.
*
* @param (bool) $verbose (optional) Whether to display verbose output.
*
* @return self
*/
public function __construct($verbose = false) {
$this->output = new ConsoleOutput();
$this->baseDir = realpath(\Dana\Twigc\Twigc::BASE_DIR);
$this->finderSort = function ($a, $b) {
return strcmp(
strtr($a->getRealPath(), '\\', '/'),
strtr($b->getRealPath(), '\\', '/')
);
};
if ( $verbose ) {
$this->output->setVerbosity(ConsoleOutput::VERBOSITY_VERBOSE);
}
}
/**
* Compiles the project into an executable phar file.
*
* @param string $pharFile
* (optional) The path (absolute or relative to the CWD) to write the
* resulting phar file to.
*
* @return void
*/
public function compile($pharFile = 'twigc.phar') {
$this->output->writeln('Compiling phar...');
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->extractVersionInformation();
if ( file_exists($pharFile) ) {
unlink($pharFile);
}
$phar = new \Phar($pharFile, 0, 'twigc.phar');
$phar->setSignatureAlgorithm(\Phar::SHA1);
$phar->startBuffering();
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding src files...');
$this->addSrc($phar);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding vendor files...');
$this->addVendor($phar);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding root files...');
$this->addRoot($phar);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding bin files...');
$this->addBin($phar);
$phar->setStub($this->getStub());
$phar->stopBuffering();
unset($phar);
chmod($pharFile, 0755);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln("Compiled to ${pharFile}.");
/*
// Re-sign the phar with reproducible time stamps and signature
$util = new Timestamps($pharFile);
$util->updateTimestamps($this->versionDate);
$util->save($pharFile, \Phar::SHA1);
*/
}
/**
* Extracts the application version information from the git repository and
* sets the associated object properties.
*
* @return void
*
* @throws \RuntimeException if `git describe` fails
* @throws \RuntimeException if `git log` fails
* @throws \RuntimeException if `git log` fails (2)
*/
protected function extractVersionInformation() {
$workDir = escapeshellarg(__DIR__);
// Get version number
$output = [];
exec("cd ${workDir} && git describe --tags --match='v*.*.*' --dirty='!'", $output, $ret);
if ( $ret !== 0 || empty($output) ) {
$output = ['0.0.0'];
}
$tokens = explode('-', trim($output[0]));
$this->versionNumber = rtrim(ltrim($tokens[0], 'v'), '!');
// If we're ahead of a tag, add the number of commits
if ( count($tokens) > 1 ) {
$this->versionNumber .= '-plus' . rtrim($tokens[1], '!');
}
// If the index is dirty, add that
if ( rtrim(implode('-', $tokens), '!') !== implode('-', $tokens) ) {
$this->versionNumber .= '-dirty';
}
// Get version last commit hash
$output = [];
exec("cd ${workDir} && git log -1 --pretty='%H' HEAD", $output, $ret);
if ( $ret !== 0 || empty($output) ) {
throw new \RuntimeException(
'An error occurred whilst running `git log`'
);
}
$this->versionCommit = trim($output[0]);
// Get version last commit date
$output = [];
exec("cd ${workDir} && git log -1 --pretty='%ci' HEAD", $output, $ret);
if ( $ret !== 0 || empty($output) ) {
throw new \RuntimeException(
'An error occurred whilst running `git log`'
);
}
$this->versionDate = new \DateTime(trim($output[0]));
$this->versionDate->setTimezone(new \DateTimeZone('UTC'));
if ( $this->output->isVerbose() ) {
$this->output->writeln(
'Got version number: ' . $this->versionNumber
);
$this->output->writeln(
'Got version commit: ' . $this->versionCommit
);
$this->output->writeln(
'Got version date: ' . $this->versionDate->getTimestamp()
);
}
}
/**
* Adds a file to a phar.
*
* @param \Phar $phar
* The phar file to add to.
*
* @param \SplFileInfo|string $file
* The file to add, or its path.
*
* @param null|bool $strip
* (optional) Whether to strip extraneous white space from the file in
* order to reduce its size. The default is to auto-detect based on file
* extension.
*
* @return void
*/
protected function addFile($phar, $file, $strip = null) {
if ( is_string($file) ) {
$file = new \SplFileInfo($file);
}
// Strip the absolute base directory off the front of the path
$prefix = $this->baseDir . DIRECTORY_SEPARATOR;
$path = strtr(
str_replace($prefix, '', $file->getRealPath()),
'\\',
'/'
);
$this->output->writeln("Adding file: ${path}", ConsoleOutput::VERBOSITY_VERBOSE);
$content = file_get_contents($file);
// Strip interpreter directives
if ( strpos($path, 'bin/') === 0 ) {
$content = preg_replace('%^#!/usr/bin/env php\s*%', '', $content);
// Replace version place-holders
} elseif ( $path === 'src/Twigc/Twigc.php' ) {
$content = str_replace(
[
'%version_number%',
'%version_commit%',
'%version_date%',
],
[
$this->versionNumber,
$this->versionCommit,
$this->versionDate->format('Y-m-d H:i:s'),
],
$content
);
}
if ( $strip === null ) {
$strip = in_array($file->getExtension(), ['json', 'lock', 'php'], true);
}
if ( $strip ) {
$content = $this->stripWhiteSpace($content);
}
$phar->addFromString($path, $content);
}
/**
* Removes extraneous white space from a string whilst preserving PHP line
* numbers.
*
* @param string $source
* The PHP or JSON string to strip white space from.
*
* @param string $type
* (optional) The type of file the string represents. Available options
* are 'php' and 'json'. The default is 'php'.
*
* @return string
*/
protected function stripWhiteSpace($source, $type = 'php') {
$output = '';
if ( $type === 'json' ) {
$output = json_encode(json_decode($json, true));
return $output === null ? $source : $output . "\n";
}
if ( ! function_exists('token_get_all') ) {
return $source;
}
foreach ( token_get_all($source) as $token ) {
// Arbitrary text, return as-is
if ( is_string($token) ) {
$output .= $token;
// Replace comments by empty lines
} elseif ( in_array($token[0], [\T_COMMENT, \T_DOC_COMMENT]) ) {
$output .= str_repeat("\n", substr_count($token[1], "\n"));
// Collapse and normalise white-space
} elseif (T_WHITESPACE === $token[0]) {
// Collapse consecutive spaces
$space = preg_replace('#[ \t]+#', ' ', $token[1]);
// Normalise new-lines to \n
$space = preg_replace('#(?:\r\n|\r|\n)#', "\n", $space);
// Trim leading spaces
$space = preg_replace('#\n[ ]+#', "\n", $space);
$output .= $space;
// Anything else, return as-is
} else {
$output .= $token[1];
}
}
return $output;
}
/**
* Adds files from src directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addSrc($phar) {
$finder = new Finder();
$finder
->files()
->in($this->baseDir . '/src')
->ignoreDotFiles(true)
->ignoreVCS(true)
->name('*.php')
->notName('PharCompiler.php')
->sort($this->finderSort)
;
foreach ( $finder as $file ) {
$this->addFile($phar, $file);
}
}
/**
* Adds files from vendor directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addVendor($phar) {
$devPaths = \Dana\Twigc\Twigc::getComposerDevPackages();
$devPaths = array_map(function ($x) {
return $x->name . '/';
}, $devPaths);
$finder = new Finder();
$finder
->files()
->in($this->baseDir . '/vendor')
->ignoreDotFiles(true)
->ignoreVCS(true)
;
// Exclude files from dev packages
foreach ( $devPaths as $path ) {
$finder->notPath($path);
}
$finder
->exclude('bin')
->exclude('doc')
->exclude('docs')
->exclude('test')
->exclude('tests')
->exclude('Test')
->exclude('Tests')
->notName('*.c')
->notName('*.h')
->notName('*.m4')
->notName('*.w32')
->notName('*.xml.dist')
->notName('build.xml')
->notName('composer.json')
->notName('composer.lock')
->notName('travis-ci.xml')
->notName('phpunit.xml')
->notName('ChangeLog*')
->notName('CHANGE*')
->notName('*CONDUCT*')
->notName('CONTRIBUT*')
->notName('README*')
->sort($this->finderSort)
;
foreach ( $finder as $file ) {
$this->addFile($phar, $file);
}
}
/**
* Adds files from project root directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addRoot($phar) {
$this->addFile($phar, $this->baseDir . '/composer.json');
$this->addFile($phar, $this->baseDir . '/composer.lock');
}
/**
* Adds files from bin directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addBin($phar) {
$this->addFile($phar, $this->baseDir . '/bin/twigc');
}
/**
* Returns the phar stub.
*
* @return string
*/
protected function getStub() {
$stub = "
#!/usr/bin/env php
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
\Phar::mapPhar('twigc.phar');
require 'phar://twigc.phar/bin/twigc';
__HALT_COMPILER();
";
return str_replace("\t", '', trim($stub)) . "\n";
}
}

88
src/Twigc/Twigc.php Normal file
View file

@ -0,0 +1,88 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
/**
* Holds various project-specific constants and methods.
*/
class Twigc {
const BASE_DIR = __DIR__ . '/../..';
const VERSION_NUMBER = '%version_number%';
const VERSION_COMMIT = '%version_commit%';
const VERSION_DATE = '%version_date%';
/**
* Returns an array of data representing the project's Composer lock file.
*
* @return array
*
* @throws \RuntimeException if composer.lock doesn't exist
* @throws \RuntimeException if composer.lock can't be decoded
*/
private static function parseComposerLock() {
$lockFile = static::BASE_DIR . '/composer.lock';
if ( ! file_exists($lockFile) ) {
throw new \RuntimeException('Missing ' . basename($lockFile));
}
$installed = json_decode(file_get_contents($lockFile), true);
if ( empty($installed) || ! isset($installed['packages']) ) {
throw new \RuntimeException('Error decoding ' . basename($lockFile));
}
return $installed;
}
/**
* Sorts and object-ifies an array of package data.
*
* @param array $packages Package data from composer.lock.
*
* @return array
*/
private static function massagePackages(array $packages) {
usort($packages, function ($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
foreach ( $packages as &$package ) {
$package = (object) $package;
}
return $packages;
}
/**
* Returns an array of installed non-dev Composer packages based on the
* project's Composer lock file.
*
* @return object[] An array of objects representing Composer packages.
*/
public static function getComposerPackages() {
$packages = static::parseComposerLock()['packages'];
return static::massagePackages($packages);
}
/**
* Returns an array of installed dev Composer packages based on the
* project's lock file.
*
* @return object[] An array of objects representing Composer packages.
*/
public static function getComposerDevPackages() {
$packages = static::parseComposerLock()['packages-dev'];
return static::massagePackages($packages);
}
}

47
src/bootstrap.php Normal file
View file

@ -0,0 +1,47 @@
<?php
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @license MIT
*/
/**
* Helper function for printing an error message.
*
* Uses fprintf() to print to STDERR if available; uses echo otherwise.
*
* @param string $string
* (optional) The message to print. Will be passed through rtrim(); if the
* result is an empty string, only an empty line will be printed; otherwise,
* the text 'twigc: ' will be appended to the beginning.
*
* @return void
*/
function twigc_puts_error($string = '') {
$string = rtrim($string);
$string = $string === '' ? '' : "twigc: ${string}";
// \STDERR only exists when we're using the CLI SAPI
if ( defined('\\STDERR') ) {
fprintf(\STDERR, "%s\n", $string);
} else {
echo $string, "\n";
}
}
// Disallow running from non-CLI SAPIs
if ( \PHP_SAPI !== 'cli' ) {
twigc_puts_error("This tool must be invoked via PHP's CLI SAPI.");
exit(1);
}
// Give a meaningful error if we don't have our vendor dependencies
if ( ! file_exists(__DIR__ . '/../vendor/autoload.php') ) {
twigc_puts_error('Auto-loader is missing — try running `composer install`.');
exit(1);
}
require_once __DIR__ . '/../vendor/autoload.php';