Merge 0.3: Major code restructuring, new features, more documentation

Merge branch 'dev/0.3'
This commit is contained in:
dana 2018-06-02 02:00:14 -05:00
commit 532b078376
16 changed files with 2170 additions and 1416 deletions

9
.gitignore vendored
View file

@ -1,17 +1,15 @@
# PHP-related files
.idea/
.phpintel/
/vendor/
/phpunit.xml
/twigc.phar
# OS X-generated files
# macOS-generated files
._*
.DS_Store
.DS_Store?
.Spotlight-V100
.Trashes
Icon\?
Icon?
# Windows-generated files
Desktop.ini
@ -19,7 +17,8 @@ ehthumbs.db
Thumbs.db
# Editor-generated files
.idea/
.phpintel/
*~
.*.s[a-w][a-z]
*.sublime-workspace

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) dana geier <dana@dana.is>
Copyright (c) dana <dana@dana.is>
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
@ -18,4 +18,3 @@ 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.

View file

@ -1,20 +1,45 @@
##
# This file is part of twigc.
#
# @author dana geier <dana@dana.is>
# @author dana <dana@dana.is>
# @license MIT
all: build
all: build
build: phar
phar: clean twigc.phar
test: test-unit test-integration
help:
@echo 'Available targets:'
@echo 'all ................ Equivalent to `build`'
@echo 'build .............. Equivalent to `vendor`'
@echo 'install ............ Install phar to `/usr/local/bin/twigc`'
@echo 'clean .............. Remove phar'
@echo 'distclean .......... Remove phar and vendor directory'
@echo 'phar ............... Equivalent to `twigc.phar`'
@echo 'test ............... Run unit and integration tests'
@echo 'test-integration ... Run integration tests against phar'
@echo 'test-unit .......... Run unit tests against source'
@echo 'twigc.phar ......... Build phar'
@echo 'vendor ............. Install vendor directory via Composer'
vendor:
composer install
twigc.phar: vendor
twigc.phar:
composer install -q --no-dev
php -d phar.readonly=0 bin/compile
composer install -q
build: twigc.phar
test-unit: vendor
vendor/bin/phpunit
install: build
test-integration: twigc.phar
./twigc.phar --help | grep -q -- --version
echo 'hello {{ name }}' | ./twigc.phar -j '{ "name": "foo" }' | grep -qF 'hello foo'
echo 'hello {{ name }}' | ./twigc.phar -p name=foo | grep -qF 'hello foo'
install: twigc.phar
cp twigc.phar /usr/local/bin/twigc
clean:
@ -23,5 +48,4 @@ clean:
distclean: clean
rm -rf vendor/
.PHONY: all build install clean distclean
.PHONY: all clean distclean install test test-integration test-unit

226
README.md
View file

@ -5,99 +5,201 @@
for interacting with Twig through shell scripts and other command-line
applications.
## Usage overview
```
Usage:
twigc [options] [--] [<template>]
Arguments:
template Twig template file to render (use `-` for STDIN)
Options:
-h, --help Display this usage help
-V, --version Display version information
--credits Display dependency credits (including Twig version)
-d, --dir=DIR Add search directory to loader (multiple values allowed)
--env Treat environment variables as input data
-e, --escape=ESCAPE Set autoescape environment option
-j, --json=JSON Pass variables as JSON (dictionary string or file path)
-p, --pair=PAIR Pass variable as key=value pair (multiple values allowed)
--query=QUERY Pass variables as URL query string
-s, --strict Enable strict_variables environment option
```
## Usage
**twigc** can render Twig templates supplied via either standard input or a file
path. Input data can be passed to the template using a simple key=value syntax:
path.
```
Usage: twigc [options] [<template>]
Options:
-h, --help Display this usage help and exit
-V, --version Display version information and exit
--credits Display dependency information and exit
--cache <dir> Enable caching to specified directory
-d, --dir <dir> Add specified search directory to loader
-e, --escape <strategy> Specify default auto-escaping strategy
-E, --env Derive input data from environment
-j, --json <dict/file> Derive input data from specified JSON file or
dictionary string
-p, --pair <input> Derive input data from specified key=value pair
--query <input> Derive input data from specified URL query string
-s, --strict Throw exception when undefined variable is referenced
```
### Passing input data
Input data can be passed to the template using a simple key=value syntax with
`-p`:
```
% twigc -p 'name=dana' <<< 'Hello, {{ name }}!'
Hello, dana!
```
Of course, only simple string values can be provided this way. For more complex
data, you can use the JSON option:
Of course, only basic string values can be provided this way. For more complex
data, you can use the JSON option `-j`:
```
% twigc -j '{ "numbers": [1, 2, 3] }' <<< '{{ numbers|join("... ") }}!'
% twigc -j '{"numbers": [1, 2, 3]}' <<< '{{ numbers|join("... ") }}!'
1... 2... 3!
```
JSON data can also be provided by file path or on standard input:
```
# JSON from file, template from standard input
% cat numbers.json
{ "numbers": [1, 2, 3] }
{"numbers": [1, 2, 3]}
% twigc -j numbers.json <<< '{{ numbers|join("... ") }}!'
1... 2... 3!
# JSON from standard input, template from file
% cat numbers.twig
{{ numbers|join("... ") }}!
% twigc -j - numbers.twig <<< '{ "numbers": [1, 2, 3] }'
% twigc -j - numbers.twig <<< '{"numbers": [1, 2, 3]}'
1... 2... 3!
```
(**twigc** determines whether the argument to `-j` is a dictionary string or a
file name based on whether the first character is a `{`; if you have a file name
that looks like that, use the absolute path or put `./` in front of it — for
example, `twigc -j './{myfile}.json'`.)
If
[`variables_order`](http://php.net/manual/en/ini.core.php#ini.variables-order)
is configured appropriately in your `php.ini`, you can use the `-E` option to
inherit input data from the environment:
```
% NAME=dana twigc -E <<< 'Hello, {{ NAME }}!'
Hello, dana!
```
(If you *don't* have `E` in `variables_order`, you'll get an error.)
Lastly, there's a `--query` option in case you want to pass input as a URL query
string:
```
% twigc --query '?foo=&name=dana&bar=' <<< 'Hello, {{ name }}!'
Hello, dana!
```
All of the aforementioned input options can be given multiple times and in any
combination, but the values associated with each input type override other
values with the same name using the following order of precedence (from lowest
to highest): environment, query, JSON, pair. In other words:
```
# Pair has higher precedence than environment
% name=foo twigc -p name=bar -E <<< '{{ name }} wins'
bar wins
# JSON has higher precedence than query
% twigc -j '{"name": "foo"}' --query name=bar <<< '{{ name }} wins'
foo wins
# Inputs of the same type have equal precedence and are taken in the order given
% twigc -j '{"name": "foo"}' -p name=bar -p name=baz <<< '{{ name }} wins'
baz wins
```
### Configuring auto-escaping
Normally, input data is [auto-escaped](http://twig.sensiolabs.org/doc/api.html)
based on the template file extension (or disabled by default if using standard
input), but this is configurable:
during rendering based on the template file extension (or disabled by default if
using standard input), but this is configurable:
```
# No auto-escaping by default on standard input
% twigc -p 'html=<p>Hello!</p>' <<< '{{ html }}'
<p>Hello!</p>
% twigc -p 'html=<p>Hello!</p>' -e 'html' <<< '{{ html }}'
# Explicit HTML auto-escaping
% twigc -e html -p 'html=<p>Hello!</p>' <<< '{{ html }}'
&lt;p&gt;Hello!&lt;/p&gt;
% twigc -p 'html=<p>Hello!</p>' -e 'js' <<< '{{ html }}'
# Explicit JavaScript auto-escaping
% twigc -e js -p 'html=<p>Hello!</p>' <<< '{{ html }}'
\x3Cp\x3EHello\x21\x3C\x2Fp\x3E
% twigc -p 'html=<p>Hello!</p>' -e 'url' <<< '{{ html }}'
# Explicit URL auto-escaping
% twigc -e url -p 'html=<p>Hello!</p>' <<< '{{ html }}'
%3Cp%3EHello%21%3C%2Fp%3E
```
Of course, you can always control escaping from within the template using the
[`escape`](https://twig.symfony.com/doc/2.x/filters/escape.html) filter.
The following auto-escape methods are available:
* **`none`** (aka **`false`**, **`no`**, **`never`**, &c.) —
No escaping is performed; the input is rendered as-is. This is the default for
templates taken from standard input and for files with unrecognised
extensions.
* **`html`** (aka **`true`**, **`yes`**, **`always`**, &c.) —
Ampersand-escaping as suitable for inclusion in an HTML body. This is the most
common escaping method used for rendering Web pages with Twig, and the default
method used by the `escape` filter.
* **`css`** —
Hex-escaping as suitable for inclusion in a CSS value or identifier.
* **`html_attr`** —
Ampersand-escaping as suitable for inclusion in an HTML attribute value. This
is similar to the `html` method, but more characters are escaped.
* **`js`** —
Hex-escaping as suitable for inclusion in a JavaScript string or identifier.
* **`json`** —
Serialisation according to JSON rules. Strings are quoted and escaped,
integers are left bare, &c. JSON escaping can often be used to produce strings
for config files and even languages like C (though incompatibilities do exist
 be careful).
* **`sh`** —
Double-quoting and meta-character escaping according to shell rules. This
method uses double-quoted strings rather than the single-quote method used by
e.g. `escapeshellarg()` because it is more compatible with software that
supports only a sub-set of the shell's string syntax (such as 'dotenv'
libraries).
* **`url`** —
Percent-escaping as suitable for inclusion in a URL path segment or query
parameter.
### Enabling strict mode
By default, references in the template to undefined variables are silently
ignored; you can make Twig return an error instead:
ignored, but you can make Twig throw an exception with the `-s` option:
```
% twigc <<< 'Hello, {{ name }}!'
Hello, !
% twigc -s <<< 'Hello, {{ name }}!'
[Twig_Error_Runtime]
Variable "name" does not exist in "-" at line 1
twigc: Variable "name" does not exist in "-" at line 1.
```
If a template file name was provided, the file's parent directory is used for
Twig's include search path; if standard input was used, no search path is set at
all by default. In either case, one or more additional search paths can be
explicitly supplied on the command line:
Use of this option is recommended for reliability in scripting scenarios.
### Specifying search directories
If a template file name was provided, the file's parent directory is
automatically added as an include search path; if standard input was used, no
search path is set at all by default. In either case, one or more additional
search paths can be explicitly supplied on the command line:
```
% cat include.twig
Hello!
% twigc <<< '{% include "include.twig" %}'
[Twig_Error_Loader]
Template "include.twig" is not defined in "-" at line 1.
twigc: Template "include.twig" is not defined in "-" at line 1.
% twigc -d '.' <<< '{% include "include.twig" %}'
Hello!
@ -108,7 +210,7 @@ Hello!
**twigc** is provided as a self-contained executable archive; to download it,
see the [releases](https://github.com/okdana/twigc/releases) page.
Of course, you can also build and install it from source:
Of course, you can also build it from source:
```
% git clone https://github.com/okdana/twigc
@ -118,12 +220,40 @@ Of course, you can also build and install it from source:
## Requirements
The **twigc** executable is bundled with Twig and all of its other dependencies;
the only thing you need to run it is PHP version 5.5 or higher.
The **twigc** executable archive is bundled with Twig and all of its other
dependencies; the only thing you need to run it is PHP version 7.0 or higher.
## Limitations, todos, requests
Earlier versions of this tool were implemented in the manner of a Symfony
Console application. I like Symfony components a lot, but the way Console
handles arguments in particular leaves a great deal to be desired if you're
trying to create something that works like a traditional UNIX CLI tool — the API
is confused, the functionality is limited, and worst of all it's buggy. I ended
up switching out the Console application/command/input components for a lazier
and uglier, but ultimately better-behaved, design incorporating the GetOpt.php
library.
If you have any ideas as to how to improve on this situation, please let me
know.
I'd also like to achieve the following goals at some point:
* Add to Packagist
* Improve unit/integration tests
* Create man page
* Create zsh completion function
In the longer term, if i get really bored, maybe i'll add more input and escape
methods. One thing that occurred to me is a custom escape option that takes an
arbitrary escape character and a mask of characters to be escaped with it. For
example, `-e 'custom:%:%'` might escape `printf(3)` format strings. idk
Anyway, if you find a bug or have a request, please let me know.
## Licence and acknowledgements
**twigc** is available under the MIT licence. For information about the licences
**twigc** itself is available under the MIT licence. For information about the licences
of its dependencies, run `twigc --credits`.
The `\Dana\Twigc\PharCompiler` class used to build the executable archive is
@ -134,17 +264,21 @@ based on
* [twigphp/Twig](https://github.com/twigphp/Twig) —
The Twig project on GitHub.
* [farazdagi/twig-cli](https://github.com/farazdagi/twig-cli) —
Another project that aims to bring Twig to the command line. It's actually
quite similar in design (though no code is shared); it just didn't have the
features i wanted.
features i wanted and isn't actively developed.
* [twigjs/twig.js](https://github.com/twigjs/twig.js) —
A pure JavaScript implementation of Twig. It comes with its own command-line
tool, `twigjs`, which can be used to render Twig templates, but it's quite
limited.
* [indigojs/twig-cli](https://github.com/indigojs/twig-cli) —
Another command-line Twig renderer based on Twig.js. Its functionality is very
similar to (almost exactly the same as?) `twigjs`.
* [mattrobenolt/jinja2-cli](https://github.com/mattrobenolt/jinja2-cli) —
A command-line [Jinja2](http://jinja.pocoo.org/) renderer. Very comparable to
**twigc** in terms of features.

16
bin/compile Normal file → Executable file
View file

@ -4,21 +4,23 @@
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @author dana <dana@dana.is>
* @license MIT
*/
require_once __DIR__ . '/../src/bootstrap.php';
use Symfony\Component\Console\Output\ConsoleOutput;
use Dana\Twigc\PharCompiler;
$verbose = false;
$verboseArgs = ['v', 'vv', 'vvv', 'verbose', 'debug'];
foreach ( $argv as $arg ) {
if ( in_array(ltrim($arg, '-'), $verboseArgs, true) ) {
$verbose = true;
break;
}
if ( in_array(ltrim($arg, '-'), $verboseArgs, true) ) {
$verbose = true;
break;
}
}
(new \Dana\Twigc\PharCompiler($verbose))->compile();
(new PharCompiler(new ConsoleOutput(), $verbose))->compile('twigc.phar');

9
bin/twigc Normal file → Executable file
View file

@ -4,11 +4,16 @@
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @author dana <dana@dana.is>
* @license MIT
*/
require_once __DIR__ . '/../src/bootstrap.php';
(new \Dana\Twigc\Application())->run();
use Symfony\Component\Console\Output\ConsoleOutput;
use Dana\Twigc\Application;
$output = new ConsoleOutput();
$app = new Application();
exit($app->run($output));

View file

@ -1,25 +1,33 @@
{
"name": "dana/twigc",
"description": "A CLI utility for rendering Twig templates",
"homepage": "https://github.com/okdana/twigc",
"keywords": ["twig", "compile", "render", "template", "cli"],
"license": "MIT",
"_comments": {
"symfony/finder": [
"This is only required for the Phar build, actually!",
"I added it to the regular requires just to fix a problem with Composer.",
"Some day i'd like to put it back..."
]
},
"require": {
"php": ">=5.5.0",
"symfony/console": "^3.1",
"twig/twig": "^1.24"
},
"name": "dana/twigc",
"description": "CLI tool for rendering Twig templates",
"homepage": "https://github.com/okdana/twigc",
"keywords": ["twig", "compile", "render", "template", "cli"],
"license": "MIT",
"require-dev": {
"phpunit/phpunit": "^5.4",
"symfony/finder": "^3.1"
},
"require": {
"php": ">=7.0",
"symfony/console": "^4.0",
"symfony/finder": "^4.0",
"twig/twig": "^2.4",
"ulrichsg/getopt-php": "^3.1"
},
"autoload": {
"psr-4": {"Dana\\Twigc\\": "src/Twigc"}
},
"require-dev": {
"phpunit/phpunit": "^6.5"
},
"bin": ["bin/twigc"]
"autoload": {
"psr-4": {"Dana\\Twigc\\": "src/Twigc"}
},
"bin": ["bin/twigc"]
}

868
composer.lock generated

File diff suppressed because it is too large Load diff

29
phpunit.xml.dist Normal file
View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="src/bootstrap.php"
colors="true"
verbose="true"
convertErrorsToExceptions="true"
stopOnFailure="false"
>
<!-- Output settings -->
<logging>
<!-- Output code coverage information -->
<log type="coverage-text" target="php://stdout" showUncoveredFiles="true" />
<log type="coverage-html" target="coverage" showUncoveredFiles="true" />
</logging>
<!-- Code-coverage filter -->
<filter>
<whitelist>
<directory suffix=".php">src</directory>
</whitelist>
</filter>
<!-- Test suite -->
<testsuites>
<testsuite name="\Dana\Twigc test suite">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>

View file

@ -3,97 +3,495 @@
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @author dana <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;
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;
/**
* twigc application container.
* This class represents the entire `twigc` application.
*
* This class overrides a bunch of the default Console behaviour to make the
* application work like a more traditional UNIX CLI tool.
* 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 extends BaseApplication {
/**
* {@inheritdoc}
*/
public function __construct($name = 'UNKNOWN', $version = 'UNKNOWN') {
parent::__construct('twigc', \Dana\Twigc\Twigc::VERSION_NUMBER);
}
class Application {
const NAME = 'twigc';
const VERSION = '0.3.0';
const BUILD_DATE = '%BUILD_DATE%'; // Replaced during build
/**
* {@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);
protected $name;
protected $version;
if ( ! $name ) {
$name = $this->defaultCommand;
$input = new ArrayInput(['command' => $this->defaultCommand]);
}
/**
* 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;
}
$command = $this->find($name);
/**
* 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;
}
$this->runningCommand = $command;
$exitCode = $this->doRunCommand($command, $input, $output);
$this->runningCommand = null;
try {
return $this->doRun($output, $argv);
} catch ( \Exception $e ) {
$error->writeln(sprintf(
'%s: %s', $this->name,
rtrim($e->getMessage(), "\r\n"))
);
return 1;
}
}
return $exitCode;
}
/**
* 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();
/**
* {@inheritdoc}
*/
public function getDefinition() {
$definition = parent::getDefinition();
$definition->setArguments();
return $definition;
}
$getopt->process(array_slice($argv, 1));
/**
* {@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();
}
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;
}
/**
* {@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();
}
$inputData = [];
$template = $getopt->getOperand('template');
$dirs = $getopt->getOption('dir');
$temp = false;
/**
* {@inheritdoc}
*
* Since we're a one-command application, we always return just the default
* command.
*/
protected function getDefaultCommands() {
return [new \Dana\Twigc\DefaultCommand()];
}
// 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;
}
}

View file

@ -0,0 +1,88 @@
<?php
/**
* This file is part of twigc.
*
* @author dana <dana@dana.is>
* @license MIT
*/
namespace Dana\Twigc;
/**
* Helper class with various functions for interacting with Composer's lock
* file.
*/
class ComposerHelper {
/**
* Get an array of the installed non-dev packages listed in a Composer lock
* file.
*
* @param string|null (optional) The path to the lock file to parse.
*
* @return object[]
*/
public function getPackages(string $lockFile = null) {
$packages = $this->parseLockFile($lockFile)['packages'];
return $this->massagePackages($packages);
}
/**
* Get an array of the installed dev packages listed in a Composer lock file.
*
* @param string|null (optional) The path to the lock file to parse.
*
* @return object[]
*/
public function getDevPackages(string $lockFile = null) {
$packages = $this->parseLockFile($lockFile)['packages-dev'];
return $this->massagePackages($packages);
}
/**
* Get an array of data representing a Composer lock file.
*
* @param string $path
* (optional) The path to the lock file to parse. The default is the lock
* file associated with the current project.
*
* @return array
*
* @throws \RuntimeException if composer.lock doesn't exist
* @throws \RuntimeException if composer.lock can't be decoded
*/
public function parseLockFile(string $lockFile = null): array {
$lockFile = $lockFile ?? __DIR__ . '/../../composer.lock';
if ( ! file_exists($lockFile) ) {
throw new \RuntimeException('Missing ' . basename($lockFile));
}
$lock = json_decode(file_get_contents($lockFile), true);
if ( empty($lock) || ! isset($lock['packages']) ) {
throw new \RuntimeException('Error decoding ' . basename($lockFile));
}
return $lock;
}
/**
* Sort and object-ify an array of package data.
*
* @param array $packages Package data from composer.lock.
*
* @return object[]
*/
public function massagePackages(array $packages): array {
usort($packages, function ($a, $b) {
return strcasecmp($a['name'], $b['name']);
});
foreach ( $packages as &$package ) {
$package = (object) $package;
}
return $packages;
}
}

View file

@ -1,438 +0,0 @@
<?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(
'cache',
null,
InputOption::VALUE_REQUIRED,
'Enable caching to specified directory'
)
->addOption(
'dir',
'd',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
'Add search directory to loader'
)
->addOption(
'env',
null,
InputOption::VALUE_NONE,
'Treat environment variables as input data'
)
->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;
$cache = $input->getOption('cache');
$cache = $cache === null ? false : $cache;
$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}"
);
}
}
// If no escape option was supplied, try to auto-detect
// (we could do this with Twig's 'filename' method, but i have some
// control over this)
if ( $escape === null ) {
if ( substr($template, -5) === '.twig' ) {
$ext = pathinfo(substr($template, -5), \PATHINFO_EXTENSION);
} else {
$ext = pathinfo($template, \PATHINFO_EXTENSION);
}
switch ( strtolower($ext) ) {
case 'template':
case 'tmpl':
case 'tpl':
case 'htm':
case 'html':
case 'phtml':
case 'thtml':
case 'xhtml':
$escape = 'html';
break;
case 'css':
case 'scss':
$escape = 'css';
break;
case 'js':
$escape = 'js';
break;
case 'json':
$escape = 'json';
break;
default:
$escape = false;
break;
}
// Otherwise, try to parse the supplied method
} else {
// Normalise some boolean values
$escape = strtolower($escape);
$escape = $escape === 'f' ? 'false' : $escape;
$escape = $escape === 'n' ? 'false' : $escape;
$escape = $escape === 'none' ? 'false' : $escape;
$escape = $escape === 'never' ? 'false' : $escape;
$escape = $escape === 't' ? 'true' : $escape;
$escape = $escape === 'y' ? 'true' : $escape;
$escape = $escape === 'always' ? 'true' : $escape;
$bool = filter_var($escape, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE);
if ( $bool !== null ) {
$escape = $bool ? 'html' : false;
}
}
// 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];
}
}
if ( $input->getOption('env') ) {
$inputData = array_merge($_ENV, $inputData);
}
// 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' => $cache,
'debug' => false,
'strict_variables' => $strict,
'autoescape' => $escape,
]);
$twig->getExtension('core')->setEscaper(
'json',
function($twigEnv, $string, $charset) {
return json_encode(
$string,
\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
);
}
);
$output->writeln(
rtrim($twig->render(basename($template), $inputData), "\r\n")
);
} finally {
if ( $temp ) {
unlink($template);
rmdir(dirname($template));
}
}
return 0;
}
}

View file

@ -3,18 +3,19 @@
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @author dana <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;
use Dana\Twigc\Application;
/**
* Compiles twigc into an executable phar file.
* Compile twigc to an executable phar.
*
* This clas is heavily inspired by Composer's Compiler:
*
@ -39,407 +40,321 @@ use Symfony\Component\Finder\Finder;
* THE SOFTWARE.
*/
class PharCompiler {
protected $output;
protected $baseDir;
protected $finderSort;
protected $versionNumber;
protected $versionCommit;
protected $versionDate;
protected $output;
protected $baseDir;
protected $finderSort;
/**
* 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(), '\\', '/')
);
};
/**
* Object constructor.
*
* @param OutputInterface $output The output to write to.
* @param bool $verbose (optional) Whether to display verbose output.
*
* @return self
*/
public function __construct(OutputInterface $output, bool $verbose = false) {
$this->output = $output;
$this->baseDir = realpath(__DIR__ . '/../..');
$this->finderSort = function ($a, $b) {
return strcmp(
strtr($a->getRealPath(), '\\', '/'),
strtr($b->getRealPath(), '\\', '/')
);
};
if ( $verbose ) {
$this->output->setVerbosity(ConsoleOutput::VERBOSITY_VERBOSE);
}
}
if ( $verbose ) {
$this->output->setVerbosity(OutputInterface::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...');
/**
* Compiles the project into an executable phar file.
*
* @param string $phar
* (optional) The path (absolute or relative to the CWD) to write the
* resulting phar file to.
*
* @return void
*/
public function compile(string $phar) {
$this->output->writeln('Compiling phar...');
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->extractVersionInformation();
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
if ( file_exists($pharFile) ) {
unlink($pharFile);
}
if ( file_exists($phar) ) {
unlink($phar);
}
$phar = new \Phar($pharFile, 0, 'twigc.phar');
$phar->setSignatureAlgorithm(\Phar::SHA1);
$phar->startBuffering();
$obj = new \Phar($phar, 0, basename($phar));
$obj->setSignatureAlgorithm(\Phar::SHA1);
$obj->startBuffering();
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding src files...');
$this->addSrc($phar);
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
$this->output->writeln('Adding src files...');
$this->addSrc($obj);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding vendor files...');
$this->addVendor($phar);
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
$this->output->writeln('Adding vendor files...');
$this->addVendor($obj);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding root files...');
$this->addRoot($phar);
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
$this->output->writeln('Adding root files...');
$this->addRoot($obj);
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln('Adding bin files...');
$this->addBin($phar);
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
$this->output->writeln('Adding bin files...');
$this->addBin($obj);
$phar->setStub($this->getStub());
$phar->stopBuffering();
$obj->setStub($this->getStub());
$obj->stopBuffering();
unset($obj);
unset($phar);
chmod($phar, 0755);
chmod($pharFile, 0755);
$this->output->writeln('', OutputInterface::VERBOSITY_VERBOSE);
$this->output->writeln("Compiled to ${phar}.");
}
$this->output->writeln('', ConsoleOutput::VERBOSITY_VERBOSE);
$this->output->writeln("Compiled to ${pharFile}.");
/**
* Add a file to a phar archive.
*
* @param \Phar $phar
* The phar file to add to.
*
* @param \SplFileInfo|string $file
* The file to add, or its path.
*
* @param bool|null $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 $phar, $file, bool $strip = null) {
if ( is_string($file) ) {
$file = new \SplFileInfo($file);
}
/*
// Re-sign the phar with reproducible time stamps and signature
$util = new Timestamps($pharFile);
$util->updateTimestamps($this->versionDate);
$util->save($pharFile, \Phar::SHA1);
*/
}
// Strip the absolute base directory off the front of the path
$prefix = $this->baseDir . DIRECTORY_SEPARATOR;
$path = strtr(
str_replace($prefix, '', $file->getRealPath()),
'\\',
'/'
);
/**
* 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__);
$this->output->writeln(
"Adding file: ${path}",
OutputInterface::VERBOSITY_VERBOSE
);
// Get version number
$output = [];
exec("cd ${workDir} && git describe --tags --match='v*.*.*' --dirty='!'", $output, $ret);
$content = file_get_contents($file);
if ( $ret !== 0 || empty($output) ) {
$output = ['0.0.0'];
}
// Strip interpreter directives
if ( strpos($path, 'bin/') === 0 ) {
$content = preg_replace('%^#!/usr/bin/env php\s*%', '', $content);
$tokens = explode('-', trim($output[0]));
// Replace build-date place-holder
} elseif ( $path === 'src/Twigc/Application.php' ) {
$date = new \DateTime('now', new \DateTimeZone('UTC'));
$this->versionNumber = rtrim(ltrim($tokens[0], 'v'), '!');
$content = str_replace(
'%BUILD_DATE%',
$date->format('D Y-m-d H:i:s T'),
$content
);
}
// If we're ahead of a tag, add the number of commits
if ( count($tokens) > 1 ) {
$this->versionNumber .= '-plus' . rtrim($tokens[1], '!');
}
if ( $strip === null ) {
$strip = in_array($file->getExtension(), ['json', 'lock', 'php'], true);
}
// If the index is dirty, add that
if ( rtrim(implode('-', $tokens), '!') !== implode('-', $tokens) ) {
$this->versionNumber .= '-dirty';
}
if ( $strip ) {
$content = $this->stripWhiteSpace($content);
}
// Get version last commit hash
$output = [];
exec("cd ${workDir} && git log -1 --pretty='%H' HEAD", $output, $ret);
$phar->addFromString($path, $content);
}
if ( $ret !== 0 || empty($output) ) {
throw new \RuntimeException(
'An error occurred whilst running `git log`'
);
}
/**
* Remove 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(
string $source,
string $type = 'php'
): string {
$output = '';
$this->versionCommit = trim($output[0]);
if ( $type === 'json' ) {
$output = json_encode(json_decode($json, true));
// Get version last commit date
$output = [];
exec("cd ${workDir} && git log -1 --pretty='%ci' HEAD", $output, $ret);
return $output === null ? $source : $output . "\n";
}
if ( $ret !== 0 || empty($output) ) {
throw new \RuntimeException(
'An error occurred whilst running `git log`'
);
}
if ( ! function_exists('token_get_all') ) {
return $source;
}
$this->versionDate = new \DateTime(trim($output[0]));
$this->versionDate->setTimezone(new \DateTimeZone('UTC'));
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 ($token[0] === \T_WHITESPACE) {
// 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];
}
}
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()
);
}
}
return $output;
}
/**
* 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);
}
/**
* Add files from src directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addSrc(\Phar $phar) {
$finder = new Finder();
$finder
->files()
->in($this->baseDir . '/src')
->ignoreDotFiles(true)
->ignoreVCS(true)
->name('*.php')
->notName('PharCompiler.php')
->sort($this->finderSort)
;
// Strip the absolute base directory off the front of the path
$prefix = $this->baseDir . DIRECTORY_SEPARATOR;
$path = strtr(
str_replace($prefix, '', $file->getRealPath()),
'\\',
'/'
);
foreach ( $finder as $file ) {
$this->addFile($phar, $file);
}
}
$this->output->writeln("Adding file: ${path}", ConsoleOutput::VERBOSITY_VERBOSE);
/**
* Adds files from vendor directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addVendor($phar) {
$devPaths = (new ComposerHelper())->getDevPackages();
$devPaths = array_map(function ($x) {
return $x->name . '/';
}, $devPaths);
$content = file_get_contents($file);
$finder = new Finder();
$finder
->files()
->in($this->baseDir . '/vendor')
->ignoreDotFiles(true)
->ignoreVCS(true)
;
// Strip interpreter directives
if ( strpos($path, 'bin/') === 0 ) {
$content = preg_replace('%^#!/usr/bin/env php\s*%', '', $content);
// Exclude files from dev packages
foreach ( $devPaths as $path ) {
$finder->notPath($path);
}
// 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
);
}
$finder
->exclude('bin')
->exclude('doc')
->exclude('docs')
->exclude('test')
->exclude('tests')
->notPath('/^[^\/]+\/[^\/]+\/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)
;
if ( $strip === null ) {
$strip = in_array($file->getExtension(), ['json', 'lock', 'php'], true);
}
foreach ( $finder as $file ) {
$this->addFile($phar, $file);
}
}
if ( $strip ) {
$content = $this->stripWhiteSpace($content);
}
/**
* Adds files from project root directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addRoot(\Phar $phar) {
$this->addFile($phar, $this->baseDir . '/composer.json');
$this->addFile($phar, $this->baseDir . '/composer.lock');
}
$phar->addFromString($path, $content);
}
/**
* Adds files from bin directory to a phar.
*
* @param \Phar $phar The phar file to add to.
*
* @return void
*/
protected function addBin(\Phar $phar) {
$this->addFile($phar, $this->baseDir . '/bin/twigc');
}
/**
* 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 = '';
/**
* Returns the phar stub.
*
* @return string
*/
protected function getStub() {
$stub = "
#!/usr/bin/env php
<?php
if ( $type === 'json' ) {
$output = json_encode(json_decode($json, true));
/**
* This file is part of twigc.
*
* @author dana <dana@dana.is>
* @license MIT
*/
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')
->notPath('/^[^\/]+\/[^\/]+\/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";
}
\Phar::mapPhar('twigc.phar');
require 'phar://twigc.phar/bin/twigc';
__HALT_COMPILER();
";
return preg_replace('/^\s+/m', '', trim($stub)) . "\n";
}
}

View file

@ -1,88 +0,0 @@
<?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);
}
}

View file

@ -3,62 +3,57 @@
/**
* This file is part of twigc.
*
* @author dana geier <dana@dana.is>
* @author dana <dana@dana.is>
* @license MIT
*/
/**
* Helper function for printing an error message.
* Print an error message.
*
* Uses fprintf() to print to STDERR if available; uses echo otherwise.
* 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.
* (optional) The message to print. Is passed through rtrim(); if the result
* is an empty string, only an empty line is printed; otherwise, the text
* 'twigc: ' is appended to the beginning.
*
* @return void
*/
function twigc_puts_error($string = '') {
$string = rtrim($string);
$string = $string === '' ? '' : "twigc: ${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";
}
if ( defined('\\STDERR') ) {
fprintf(\STDERR, "%s\n", $string);
} else {
echo $string, "\n";
}
}
// Find Composer's auto-loader
$autoloaders = [
// Phar/repo path
__DIR__ . '/../vendor/autoload.php',
// Composer path
__DIR__ . '/../../../autoload.php',
];
foreach ( $autoloaders as $autoloader ) {
if ( file_exists($autoloader) ) {
define('TWIGC_AUTOLOADER', $autoloader);
break;
}
}
unset($autoloaders, $autoloader);
// 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);
twigc_puts_error("This tool must be invoked via PHP's CLI SAPI.");
exit(1);
}
// Give a meaningful error if we don't have the auto-loader
if ( ! defined('TWIGC_AUTOLOADER') ) {
twigc_puts_error('Auto-loader is missing — try running `composer install`.');
exit(1);
(function () {
$paths = [
// Phar/repo path
__DIR__ . '/../vendor/autoload.php',
// Composer path
__DIR__ . '/../../../autoload.php',
];
foreach ( $paths as $path ) {
if ( file_exists($path) ) {
define('TWIGC_AUTOLOADER', $path);
return;
}
}
})();
if ( ! defined('\\TWIGC_AUTOLOADER') ) {
twigc_puts_error('Auto-loader is missing — try running `composer install`.');
exit(1);
}
require_once TWIGC_AUTOLOADER;
require_once \TWIGC_AUTOLOADER;

462
tests/ApplicationTest.php Normal file
View file

@ -0,0 +1,462 @@
<?php
/**
* This file is part of twigc.
*
* @author dana <dana@dana.is>
* @license MIT
*/
namespace Dana\Test\Twigc;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Output\BufferedOutput;
use Dana\Twigc\Application;
/**
* Tests for twigc.
*
* This doesn't feel very 'DRY'. Would like to improve it somehow.
*/
class ApplicationTest extends TestCase {
protected $output;
protected $app;
protected $template;
protected $tempDir;
protected $tempFiles = [];
/**
* Set up before tests.
*
* @return void
*/
protected function setUp() {
$this->output = new BufferedOutput();
$this->app = new Application();
$this->template = $this->makeFile('default', 'testEnv: {{ testEnv }}');
}
/**
* Tear down after tests.
*
* @return void
*/
protected function tearDown() {
$this->output->fetch();
// This is slow, but i'm too lazy to handle recursive deletion properly
if ( ! empty($this->tempFiles) ) {
$dir = escapeshellarg($this->tempDir);
exec("rm -rf ${dir}/?* 2> /dev/null");
$this->tempFiles = [];
}
}
/**
* Create a temporary file, and optionally populate it with data.
*
* @param string $name
* The temporary file name/suffix. If the name contains an internal slash,
* all leading directories are created.
*
* @param string|null $content
* (optional) Any data to populate the file with.
*
* @return string The name of the created file.
*/
protected function makeFile(string $name, string $data = null): string {
// Create our temp directory if we don't already have it
if ( ! $this->tempDir ) {
$rand = base64_encode(random_bytes(32));
$rand = substr(str_replace(['/', '+', '='], '', $rand), 0, 10);
$this->tempDir = sys_get_temp_dir();
$this->tempDir .= "/Dana.Test.Twigc.ApplicationTest.${rand}";
if ( ! is_dir($this->tempDir) ) {
mkdir($this->tempDir, 0700);
}
}
$dir = $this->tempDir;
$name = trim($name, '/.');
if ( strlen($name) === 0 ) {
throw \RuntimeException('Expected file name');
}
if ( strpos($name, '/') !== false ) {
$dir .= '/' . dirname($name);
if ( ! is_dir($dir) ) {
mkdir($dir, 0700, true);
}
$name = basename($name);
}
$file = "${dir}/${name}";
$this->tempFiles[] = $file;
if ( $data !== null ) {
if ( file_put_contents($file, $data, \LOCK_EX) === false ) {
throw \RuntimeException("Write failed: ${file}");
}
} elseif ( touch($file) === false ) {
throw \RuntimeException("Write failed: ${file}");
}
return $file;
}
/**
* Run the application and return common test data.
*
* @param $args
* (optional) Zero or more arguments to pass to the application. This should
* NOT include argv[0].
*
* @return array
* An array containing the application return status as an integer, the raw
* output as a string, and the output as an array of lines.
*/
protected function runApp(...$args) {
$argv = ['', '-e', 'none'];
if ( is_array($args[0]) ) {
$argv = array_merge($argv, $args[0]);
} else {
$argv = array_merge($argv, $args);
}
$argv = array_filter($argv, function ($v) {
return $v !== null;
});
$ret = $this->app->run($this->output, $argv);
$buffer = $this->output->fetch();
$lines = explode("\n", rtrim($buffer, "\r\n"));
return [$ret, $buffer, $lines];
}
/**
* Provide data for testEscape().
*
* All tests assume the following input data (value is literal):
*
* testEnv="<foo$bar>"
*
* All tests assume the following template:
*
* testEnv: {{ testEnv }}
*
* @return array[]
*/
public function provideTestEscape() {
return [
// Escape method: none
['f', 'testEnv: "<foo$bar>"'],
['false', 'testEnv: "<foo$bar>"'],
['n', 'testEnv: "<foo$bar>"'],
['no', 'testEnv: "<foo$bar>"'],
['none', 'testEnv: "<foo$bar>"'],
['never', 'testEnv: "<foo$bar>"'],
// Escape method: html
['always', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
['t', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
['true', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
['y', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
['yes', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
['html', 'testEnv: &quot;&lt;foo$bar&gt;&quot;'],
// Escape method: css
['css', 'testEnv: \\22 \\3C foo\\24 bar\\3E \\22'],
// Escape method: html_attr
['html_attr', 'testEnv: &quot;&lt;foo&#x24;bar&gt;&quot;'],
// Escape method: js
['js', 'testEnv: \\x22\\x3Cfoo\\x24bar\\x3E\\x22'],
// Escape method: json
['json', 'testEnv: "\"<foo$bar>\""'],
// Escape method: sh
['sh', 'testEnv: "\"<foo\$bar>\""'],
// Escape method: url
['url', 'testEnv: %22%3Cfoo%24bar%3E%22'],
];
}
/**
* Test `-h` / `--help` function.
*
* @return void
*/
public function testHelp() {
foreach ( ['-h', '--help'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp($opt);
$this->assertSame(0, $ret);
$this->assertGreaterThan(3, count($lines));
$this->assertContains('--help', $buffer);
$this->assertContains('--version', $buffer);
}
}
/**
* Test `-V` / `--version` function.
*
* @return void
*/
public function testVersion() {
foreach ( ['-V', '--version'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp($opt);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains(' version ', $lines[0]);
}
}
/**
* Test `--credits` function.
*
* @return void
*/
public function testCredits() {
list($ret, $buffer, $lines) = $this->runApp('--credits');
$this->assertSame(0, $ret);
$this->assertGreaterThan(1, count($lines));
$this->assertContains('licence', $lines[0]);
}
/**
* Test `-d` / `--dir` function, as well as default include-directory
* functionality.
*
* @return void
*/
public function testDir() {
$templateSame = $this->makeFile('dir1/a.twig', '{% include "b.twig" %}');
$includeSame = $this->makeFile('dir1/b.twig', 'included: {{ testEnv }}');
$templateDiff = $this->makeFile('dir2/a.twig', '{% include "b.twig" %}');
$includeDiff = $this->makeFile('dir3/b.twig', 'included: {{ testEnv }}');
// $includeSame's directory should be searched by default
list($ret, $buffer, $lines) = $this->runApp(
'-p',
'testEnv=abc123',
$templateSame
);
$this->assertSame(0, $ret);
// $includeDiff's directory should have to be specified manually
list($ret, $buffer, $lines) = $this->runApp(
'-p',
'testEnv=abc123',
$templateDiff
);
$this->assertNotSame(0, $ret);
// Now we confirm that that works
foreach ( ['-d', '--dir'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp(
$opt,
dirname($includeDiff),
'-p',
'testEnv=abc123',
$templateDiff
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('included: abc123', $lines[0]);
}
}
/**
* Test `-E` / `--env` function.
*
* @return void
*/
public function testEnv() {
foreach ( ['-E', '--env'] as $opt ) {
// This option can't be set at run time; we'll just test what we have
if ( strpos(ini_get('variables_order'), 'E') === false ) {
list($ret, $buffer, $lines) = $this->runApp($opt, $this->template);
$this->assertGreaterThan(0, $ret);
$this->assertContains('variables_order', $buffer);
return;
}
$_ENV['testEnv'] = 'abc123';
list($ret, $buffer, $lines) = $this->runApp($opt, $this->template);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: abc123', $lines[0]);
}
}
/**
* Test `-j` / `--json` function (dictionary string).
*
* @return void
*/
public function testJsonDict() {
foreach ( ['-j', '--json'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp(
$opt,
'{"testEnv": "abc123"}',
$this->template
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: abc123', $lines[0]);
}
}
/**
* Test `-j` / `--json` function (file).
*
* @return void
*/
public function testJsonFile() {
$jsonFile = $this->makeFile('json', '{"testEnv": "abc123"}');
foreach ( ['-j', '--json'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp(
$opt,
$jsonFile,
$this->template
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: abc123', $lines[0]);
}
}
/**
* Test `-p` / `--pair` function.
*
* @return void
*/
public function testPair() {
foreach ( ['-p', '--pair'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp(
$opt,
'testEnv=abc123',
$this->template
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: abc123', $lines[0]);
}
}
/**
* Test `--query` function.
*
* @return void
*/
public function testQuery() {
list($ret, $buffer, $lines) = $this->runApp(
'--query',
'?testEnv=abc123&testEnv2=x&testEnv3=y',
$this->template
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: abc123', $lines[0]);
}
/**
* Test input-data precedence.
*
* Input precedence should be as follows (ascending):
*
* env -> query -> json -> pair
*
* @return void
*/
public function testInputDataPrecedence() {
list($ret, $buffer, $lines) = $this->runApp(
'--pair',
'testEnv=aaa',
'--query',
'?testEnv=bbb',
'--json',
'{ "testEnv": "ccc" }',
$this->template
);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv: aaa', $lines[0]);
}
/**
* Test handling of undefined variable WITHOUT `-s` / `--strict`.
*
* @return void
*/
public function testUndefinedNoStrict() {
list($ret, $buffer, $lines) = $this->runApp($this->template);
$this->assertSame(0, $ret);
$this->assertSame(1, count($lines));
$this->assertContains('testEnv:', $lines[0]);
$this->assertNotContains('abc123', $lines[0]);
}
/**
* Test handling of undefined variable WITHOUT `-s` / `--strict`.
*
* @return void
*/
public function testUndefinedStrict() {
foreach ( ['-s', '--strict'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp($opt, $this->template);
$this->assertSame(1, $ret);
$this->assertContains('testEnv', $lines[0]);
$this->assertNotContains('abc123', $lines[0]);
}
}
/**
* Test various escape methods with `-e` / `--escape`.
*
* @param string $method The method to test.
* @param string $expected The expected output.
*
* @dataProvider provideTestEscape
*
* @return void
*/
public function testEcape(string $method, string $expected) {
foreach ( ['-e', '--escape'] as $opt ) {
list($ret, $buffer, $lines) = $this->runApp(
$opt,
$method,
'-p',
'testEnv="<foo$bar>"',
$this->template
);
$this->assertSame(0, $ret);
$this->assertContains($expected, $lines[0] ?? '');
}
}
}