From fb2cb1e8d37da40237b192c588628972647dcab1 Mon Sep 17 00:00:00 2001
From: Juan Millan
Date: Thu, 16 Jul 2026 10:10:36 +0200
Subject: [PATCH] add pesto cli to lint and compile templates
---
README.md | 95 +++++++
benchmarks/chart.html | 202 +++++++--------
bin/pesto | 25 ++
composer.json | 3 +
src/Compiler/Pass/Pass.php | 11 +
src/Compiler/Pass/ValidationPass.php | 49 ++++
src/Compiler/PestoCompiler.php | 47 +++-
src/Console/Application.php | 75 ++++++
src/Console/Command.php | 69 +++++
src/Console/CompileCommand.php | 54 ++++
src/Console/LintCommand.php | 159 ++++++++++++
src/Lint/LintResult.php | 22 ++
src/Lint/TemplateLinter.php | 243 ++++++++++++++++++
tests/TestCase.php | 1 +
.../Unit/Compiler/Pass/ValidationPassTest.php | 37 +++
tests/Unit/Compiler/PestoCompilerTest.php | 61 +++++
tests/Unit/Console/ApplicationTest.php | 63 +++++
tests/Unit/Console/CompileCommandTest.php | 105 ++++++++
tests/Unit/Console/LintCommandTest.php | 189 ++++++++++++++
tests/Unit/Lint/TemplateLinterTest.php | 167 ++++++++++++
tests/fixtures/lint-showcase.php | 26 ++
tests/fixtures/views/home.php | 13 +
tests/fixtures/views/layouts/app.php | 13 +
tests/fixtures/views/partials/nav.php | 4 +
24 files changed, 1627 insertions(+), 106 deletions(-)
create mode 100755 bin/pesto
create mode 100644 src/Compiler/Pass/ValidationPass.php
create mode 100644 src/Console/Application.php
create mode 100644 src/Console/Command.php
create mode 100644 src/Console/CompileCommand.php
create mode 100644 src/Console/LintCommand.php
create mode 100644 src/Lint/LintResult.php
create mode 100644 src/Lint/TemplateLinter.php
create mode 100644 tests/Unit/Compiler/Pass/ValidationPassTest.php
create mode 100644 tests/Unit/Compiler/PestoCompilerTest.php
create mode 100644 tests/Unit/Console/ApplicationTest.php
create mode 100644 tests/Unit/Console/CompileCommandTest.php
create mode 100644 tests/Unit/Console/LintCommandTest.php
create mode 100644 tests/Unit/Lint/TemplateLinterTest.php
create mode 100644 tests/fixtures/lint-showcase.php
create mode 100644 tests/fixtures/views/home.php
create mode 100644 tests/fixtures/views/layouts/app.php
create mode 100644 tests/fixtures/views/partials/nav.php
diff --git a/README.md b/README.md
index cdd5164..d81a885 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,10 @@ allowing you to integrate PHP code if needed.
- [If Attribute](#if-attribute)
- [Loop](#loops)
- [Inline](#inline)
+- [Short Syntax](#short-syntax)
+- [Command Line](#command-line)
+ - [Compile](#compile)
+ - [Lint](#lint)
- [Filters](#filters)
- [Chain Filters](#chain-filters)
- [Filters with Arguments](#filters-with-arguments)
@@ -163,6 +167,97 @@ Pesto also allows you to use inline control flow directives.
```
+## Short Syntax
+Every `php-*` attribute has a shorter `p:*` alias, both forms work everywhere
+and can be mixed in the same template.
+
+| Long form | Short form |
+|---------------|-------------|
+| `php-if` | `p:if` |
+| `php-elseif` | `p:elseif` |
+| `php-else` | `p:else` |
+| `php-foreach` | `p:foreach` |
+| `php-partial` | `p:partial` |
+| `php-with` | `p:with` |
+| `php-slot` | `p:slot` |
+
+```html
+
+No items
+```
+
+If an element has both forms of the same directive, the long form wins.
+Since no client-side framework claims the `p:` prefix, it is safe to combine
+with Vue, Alpine.js, or Lit bindings.
+
+## Command Line
+Pesto ships with a `pesto` binary (installed at `vendor/bin/pesto`) to validate
+and inspect templates without rendering them.
+
+```shell
+vendor/bin/pesto help
+```
+
+| Command | Description |
+|----------------------------------|----------------------------------------------------|
+| `pesto compile ` | Validate and compile a template, print the result |
+| `pesto -c ` | Shorthand for `compile` |
+| `pesto lint [...]` | Validate template files or directories |
+| `pesto help` | Show the help message |
+
+Both commands read the template from stdin when no path is given (or with `-`).
+
+### Compile
+Compiles a template and prints the resulting PHP, so you can see exactly
+what Pesto generates:
+
+```shell
+echo '{{ $item->name | title }} ' | vendor/bin/pesto compile
+```
+```php
+visible): ?>= $__pesto->output($item->name, ['title', 'escape']) ?>
+```
+
+If the template is invalid, the errors are printed and the command exits with `1`.
+
+### Lint
+Validates templates without rendering them: it checks for unclosed `{{ }}`
+expressions, orphan `php-else`/`php-elseif` directives, unprocessed directives,
+and PHP syntax errors in the compiled output.
+
+```shell
+# Single files or directories (scanned recursively for .html and .php)
+vendor/bin/pesto lint views/home.php
+vendor/bin/pesto lint views/ emails/
+
+# Or from stdin
+echo 'Guest
' | vendor/bin/pesto lint
+```
+```
+ ✗
+ - Orphan "php-else" directive on line 1: it must be an immediate sibling of a "php-if" element.
+```
+
+With `--views ` the linter also verifies that every `php-partial`
+reference exists in the templates root. Without explicit paths, it lints
+the whole directory:
+
+```shell
+vendor/bin/pesto lint --views views/
+```
+```
+ ✓ views/home.php
+ ✓ views/layouts/app.php
+ ✓ views/partials/nav.php
+
+Linted 3 templates: no errors found.
+```
+
+The exit code is `0` when all templates pass and `1` otherwise, so `lint`
+fits directly into a CI pipeline.
+
## Filters
Pesto provides a simple way to apply filters to variables using the pipe operator,
you can define your own filters.
diff --git a/benchmarks/chart.html b/benchmarks/chart.html
index 16d17ff..cda94a7 100644
--- a/benchmarks/chart.html
+++ b/benchmarks/chart.html
@@ -73,33 +73,33 @@ Pesto vs Blade vs Twig
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'PestoBench',
- y: [29.053033268101796,316.2152641878647,102.01213307240687,65.28356164383555],
+ y: [27.251859099804356,306.55107632093814,99.82191780821923,63.32270058708427],
type: 'bar',
error_y: {
type: 'data',
- array: [2.4183405467386105,19.137842590009985,11.918196172240162,5.92210469343459],
+ array: [1.6206171663906321,14.960751150928218,4.98077694742497,8.8219240531757],
visible: true,
}
},
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'BladeBench',
- y: [32.15499021526418,474.24814090019487,200.42602739726075,64.6518590998043],
+ y: [32.02641878669277,470.37514677103826,204.2344422700572,63.440704500978235],
type: 'bar',
error_y: {
type: 'data',
- array: [3.1498207885528977,19.29093763921288,12.069754719960137,7.382011920879024],
+ array: [2.3443549219348165,18.205567143047205,3.9832824404001297,5.012993117888753],
visible: true,
}
},
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'TwigBench',
- y: [15.013698630136972,784.1027397260314,224.54931506849292,24.568884540117423],
+ y: [15.336203522504906,778.9131115459948,228.6246575342464,25.12798434442279],
type: 'bar',
error_y: {
type: 'data',
- array: [1.6223328881582846,30.98146645980465,14.931513352637767,4.888627107890313],
+ array: [1.7829399877730043,12.66811157986856,9.004295419409562,1.782996074028207],
visible: true,
}
},
@@ -108,8 +108,8 @@ Pesto vs Blade vs Twig
barmode: 'group',
yaxis: {
tickmode: 'array',
- tickvals: [0,78.41027397260314,156.82054794520627,235.2308219178094,313.64109589041254,392.0513698630157,470.4616438356188,548.871917808222,627.2821917808251,705.6924657534282,784.1027397260314],
- ticktext: ["0.0\u03bcs<\/span>\n","78.4\u03bcs<\/span>\n","156.8\u03bcs<\/span>\n","235.2\u03bcs<\/span>\n","313.6\u03bcs<\/span>\n","392.1\u03bcs<\/span>\n","470.5\u03bcs<\/span>\n","548.9\u03bcs<\/span>\n","627.3\u03bcs<\/span>\n","705.7\u03bcs<\/span>\n","784.1\u03bcs<\/span>\n"] },
+ tickvals: [0,77.89131115459948,155.78262230919896,233.67393346379845,311.5652446183979,389.4565557729974,467.3478669275969,545.2391780821963,623.1304892367958,701.0218003913953,778.9131115459948],
+ ticktext: ["0.0\u03bcs<\/span>\n","77.9\u03bcs<\/span>\n","155.8\u03bcs<\/span>\n","233.7\u03bcs<\/span>\n","311.6\u03bcs<\/span>\n","389.5\u03bcs<\/span>\n","467.3\u03bcs<\/span>\n","545.2\u03bcs<\/span>\n","623.1\u03bcs<\/span>\n","701.0\u03bcs<\/span>\n","778.9\u03bcs<\/span>\n"] },
xaxis: {
tickmode: 'array',
tickvals: ["benchSimple","benchLoop","benchConditional","benchPartial"],
@@ -133,44 +133,44 @@ Pesto vs Blade vs Twig
{
x: ["PestoBench","BladeBench","TwigBench"],
name: 'benchSimple',
- y: [29.053033268101796,32.15499021526418,15.013698630136972],
+ y: [27.251859099804356,32.02641878669277,15.336203522504906],
type: 'bar',
error_y: {
type: 'data',
- array: [2.4183405467386105,3.1498207885528977,1.6223328881582846],
+ array: [1.6206171663906321,2.3443549219348165,1.7829399877730043],
visible: true,
}
},
{
x: ["PestoBench","BladeBench","TwigBench"],
name: 'benchLoop',
- y: [316.2152641878647,474.24814090019487,784.1027397260314],
+ y: [306.55107632093814,470.37514677103826,778.9131115459948],
type: 'bar',
error_y: {
type: 'data',
- array: [19.137842590009985,19.29093763921288,30.98146645980465],
+ array: [14.960751150928218,18.205567143047205,12.66811157986856],
visible: true,
}
},
{
x: ["PestoBench","BladeBench","TwigBench"],
name: 'benchConditional',
- y: [102.01213307240687,200.42602739726075,224.54931506849292],
+ y: [99.82191780821923,204.2344422700572,228.6246575342464],
type: 'bar',
error_y: {
type: 'data',
- array: [11.918196172240162,12.069754719960137,14.931513352637767],
+ array: [4.98077694742497,3.9832824404001297,9.004295419409562],
visible: true,
}
},
{
x: ["PestoBench","BladeBench","TwigBench"],
name: 'benchPartial',
- y: [65.28356164383555,64.6518590998043,24.568884540117423],
+ y: [63.32270058708427,63.440704500978235,25.12798434442279],
type: 'bar',
error_y: {
type: 'data',
- array: [5.92210469343459,7.382011920879024,4.888627107890313],
+ array: [8.8219240531757,5.012993117888753,1.782996074028207],
visible: true,
}
},
@@ -179,8 +179,8 @@ Pesto vs Blade vs Twig
barmode: 'group',
yaxis: {
tickmode: 'array',
- tickvals: [0,78.41027397260314,156.82054794520627,235.2308219178094,313.64109589041254,392.0513698630157,470.4616438356188,548.871917808222,627.2821917808251,705.6924657534282,784.1027397260314],
- ticktext: ["0.0\u03bcs<\/span>\n","78.4\u03bcs<\/span>\n","156.8\u03bcs<\/span>\n","235.2\u03bcs<\/span>\n","313.6\u03bcs<\/span>\n","392.1\u03bcs<\/span>\n","470.5\u03bcs<\/span>\n","548.9\u03bcs<\/span>\n","627.3\u03bcs<\/span>\n","705.7\u03bcs<\/span>\n","784.1\u03bcs<\/span>\n"] },
+ tickvals: [0,77.89131115459948,155.78262230919896,233.67393346379845,311.5652446183979,389.4565557729974,467.3478669275969,545.2391780821963,623.1304892367958,701.0218003913953,778.9131115459948],
+ ticktext: ["0.0\u03bcs<\/span>\n","77.9\u03bcs<\/span>\n","155.8\u03bcs<\/span>\n","233.7\u03bcs<\/span>\n","311.6\u03bcs<\/span>\n","389.5\u03bcs<\/span>\n","467.3\u03bcs<\/span>\n","545.2\u03bcs<\/span>\n","623.1\u03bcs<\/span>\n","701.0\u03bcs<\/span>\n","778.9\u03bcs<\/span>\n"] },
xaxis: {
tickmode: 'array',
tickvals: ["PestoBench","BladeBench","TwigBench"],
@@ -204,19 +204,19 @@ Pesto vs Blade vs Twig
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'PestoBench',
- y: [2234984,2234984,2235000,2234984],
+ y: [2235288,2235288,2235304,2235288],
type: 'bar',
},
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'BladeBench',
- y: [3970360,4026936,3994080,3970360],
+ y: [3970848,4027424,3994568,3970848],
type: 'bar',
},
{
x: ["benchSimple","benchLoop","benchConditional","benchPartial"],
name: 'TwigBench',
- y: [2869448,2869448,2869456,2869448],
+ y: [2869752,2869752,2869760,2869752],
type: 'bar',
},
]
@@ -224,8 +224,8 @@ Pesto vs Blade vs Twig
barmode: 'group',
yaxis: {
tickmode: 'array',
- tickvals: [0,402693.6,805387.2,1208080.7999999998,1610774.4,2013468,2416161.5999999996,2818855.1999999997,3221548.8,3624242.4,4026936],
- ticktext: ["0b<\/span>\n","402.7kb<\/span>\n","805.4kb<\/span>\n","1.2mb<\/span>\n","1.6mb<\/span>\n","2.0mb<\/span>\n","2.4mb<\/span>\n","2.8mb<\/span>\n","3.2mb<\/span>\n","3.6mb<\/span>\n","4.0mb<\/span>\n"] },
+ tickvals: [0,402742.4,805484.8,1208227.2000000002,1610969.6,2013712,2416454.4000000004,2819196.8000000003,3221939.2,3624681.6,4027424],
+ ticktext: ["0b<\/span>\n","402.7kb<\/span>\n","805.5kb<\/span>\n","1.2mb<\/span>\n","1.6mb<\/span>\n","2.0mb<\/span>\n","2.4mb<\/span>\n","2.8mb<\/span>\n","3.2mb<\/span>\n","3.6mb<\/span>\n","4.0mb<\/span>\n"] },
xaxis: {
tickmode: 'array',
tickvals: ["benchSimple","benchLoop","benchConditional","benchPartial"],
@@ -264,17 +264,17 @@ Pesto vs Blade vs Twig
PestoBench
benchSimple
- 29.053μs
+ 27.252μs
- 30.023μs
+ 28.040μs
- 28.300μs
+ 26.500μs
- 43.100μs
+ 37.800μs
-
+
±
-8.05%
+5.78%
2.235mb
@@ -283,17 +283,17 @@ Pesto vs Blade vs Twig
PestoBench
benchLoop
- 316.215μs
+ 306.551μs
- 328.491μs
+ 310.915μs
- 302.000μs
+ 293.100μs
- 392.800μs
+ 409.600μs
-
+
±
-5.83%
+4.81%
2.235mb
@@ -302,17 +302,17 @@ Pesto vs Blade vs Twig
PestoBench
benchConditional
- 102.012μs
+ 99.822μs
- 107.860μs
+ 101.331μs
- 98.800μs
+ 97.200μs
- 155.400μs
+ 129.100μs
-
+
±
-11.05%
+4.92%
2.235mb
@@ -321,17 +321,17 @@ Pesto vs Blade vs Twig
PestoBench
benchPartial
- 65.284μs
+ 63.323μs
- 68.026μs
+ 66.716μs
- 62.900μs
+ 59.700μs
- 91.900μs
+ 95.300μs
-
+
±
-8.71%
+13.22%
2.235mb
@@ -340,36 +340,36 @@ Pesto vs Blade vs Twig
BladeBench
benchSimple
- 32.155μs
+ 32.026μs
- 33.127μs
+ 32.920μs
- 30.400μs
+ 30.600μs
- 54.000μs
+ 50.300μs
-
+
±
-9.51%
+7.12%
- 3.970mb
+ 3.971mb
BladeBench
benchLoop
- 474.248μs
+ 470.375μs
- 483.595μs
+ 474.915μs
- 461.300μs
+ 456.700μs
- 581.600μs
+ 631.400μs
±
-3.99%
+3.83%
4.027mb
@@ -378,115 +378,115 @@ Pesto vs Blade vs Twig
BladeBench
benchConditional
- 200.426μs
+ 204.234μs
- 205.761μs
+ 205.581μs
- 195.100μs
+ 198.600μs
- 243.700μs
+ 223.000μs
-
+
±
-5.87%
+1.94%
- 3.994mb
+ 3.995mb
BladeBench
benchPartial
- 64.652μs
+ 63.441μs
- 66.830μs
+ 64.790μs
- 61.200μs
+ 58.700μs
- 118.100μs
+ 91.000μs
-
+
±
-11.05%
+7.74%
- 3.970mb
+ 3.971mb
TwigBench
benchSimple
- 15.014μs
+ 15.336μs
- 15.494μs
+ 15.815μs
- 14.500μs
+ 14.800μs
- 25.000μs
+ 28.500μs
-
+
±
-10.47%
+11.27%
- 2.869mb
+ 2.870mb
TwigBench
benchLoop
- 784.103μs
+ 778.913μs
- 799.156μs
+ 782.093μs
- 755.500μs
+ 756.200μs
- 929.500μs
+ 830.600μs
-
+
±
-3.88%
+1.62%
- 2.869mb
+ 2.870mb
TwigBench
benchConditional
- 224.549μs
+ 228.625μs
- 231.053μs
+ 232.208μs
- 219.100μs
+ 223.200μs
- 326.200μs
+ 289.200μs
-
+
±
-6.46%
+3.88%
- 2.869mb
+ 2.870mb
TwigBench
benchPartial
- 24.569μs
+ 25.128μs
- 25.865μs
+ 25.755μs
- 22.900μs
+ 23.600μs
- 55.700μs
+ 36.400μs
-
+
±
-18.90%
+6.92%
- 2.869mb
+ 2.870mb
diff --git a/bin/pesto b/bin/pesto
new file mode 100755
index 0000000..12eeb0c
--- /dev/null
+++ b/bin/pesto
@@ -0,0 +1,25 @@
+#!/usr/bin/env php
+run($argv));
diff --git a/composer.json b/composer.json
index d9ca743..fa6562a 100644
--- a/composer.json
+++ b/composer.json
@@ -3,6 +3,9 @@
"description": "PHP View Engine",
"type": "library",
"license": "MIT",
+ "bin": [
+ "bin/pesto"
+ ],
"authors": [
{
"name": "Juan Millan",
diff --git a/src/Compiler/Pass/Pass.php b/src/Compiler/Pass/Pass.php
index 1033e83..4fd1bda 100644
--- a/src/Compiler/Pass/Pass.php
+++ b/src/Compiler/Pass/Pass.php
@@ -35,6 +35,17 @@ protected function hasDirective(Node $node, string $directive): bool
return false;
}
+ protected function getDirectiveAttributeName(Node $node, string $directive): ?string
+ {
+ foreach (self::PREFIXES as $prefix) {
+ if ($node->hasAttribute($prefix.$directive)) {
+ return $prefix.$directive;
+ }
+ }
+
+ return null;
+ }
+
protected function getDirective(Node $node, string $directive): ?string
{
foreach (self::PREFIXES as $prefix) {
diff --git a/src/Compiler/Pass/ValidationPass.php b/src/Compiler/Pass/ValidationPass.php
new file mode 100644
index 0000000..033fc0b
--- /dev/null
+++ b/src/Compiler/Pass/ValidationPass.php
@@ -0,0 +1,49 @@
+ $this->directiveSelector($directive),
+ self::DIRECTIVES,
+ ));
+
+ $errors = [];
+
+ $pesto->find($selector)->each(function (Node $node) use (&$errors) {
+ foreach (self::DIRECTIVES as $directive) {
+ $attribute = $this->getDirectiveAttributeName($node, $directive);
+
+ if ($attribute === null) {
+ continue;
+ }
+
+ $tag = strtolower($node->getDomNode()->nodeName);
+
+ $errors[] = $directive === 'else' || $directive === 'elseif'
+ ? sprintf('Orphan "%s" directive on <%s>: it must be an immediate sibling of a "php-if" element.', $attribute, $tag)
+ : sprintf('Unprocessed "%s" directive on <%s>.', $attribute, $tag);
+ }
+ });
+
+ if ($errors !== []) {
+ throw new CompilerException(implode("\n", $errors));
+ }
+ }
+}
diff --git a/src/Compiler/PestoCompiler.php b/src/Compiler/PestoCompiler.php
index 64b01d5..0c1bd41 100644
--- a/src/Compiler/PestoCompiler.php
+++ b/src/Compiler/PestoCompiler.php
@@ -5,14 +5,20 @@
namespace Millancore\Pesto\Compiler;
use Millancore\Pesto\Contract\Compiler;
+use Millancore\Pesto\Exception\CompilerException;
class PestoCompiler implements Compiler
{
private SyntaxCompiler $syntaxCompiler;
private DomCompiler $nodeCompiler;
- public function __construct()
- {
+ /**
+ * @param bool $validate reject templates with unclosed "{{" expressions
+ * or directives no pass consumed
+ */
+ public function __construct(
+ private readonly bool $validate = true,
+ ) {
$this->syntaxCompiler = new SyntaxCompiler();
$domPasses = [
@@ -22,18 +28,49 @@ public function __construct()
new Pass\SlotPass(),
new Pass\ContextPass(),
new Pass\UnwrapPass(),
- // ...
];
+ if ($this->validate) {
+ $domPasses[] = new Pass\ValidationPass();
+ }
+
$this->nodeCompiler = new DomCompiler($domPasses);
}
+ /**
+ * @throws CompilerException
+ */
public function compile(string $source): string
{
+ if ($this->validate) {
+ $this->assertExpressionsAreClosed($source);
+ }
+
$source = $this->nodeCompiler->compile($source);
- return $source = $this->syntaxCompiler->compile($source);
+ return $this->syntaxCompiler->compile($source);
+ }
+
+ /**
+ * A "{{" without a following "}}" never gets closed. Pairs are consumed
+ * left to right, mirroring the syntax compiler's non-greedy matching.
+ *
+ * @throws CompilerException
+ */
+ private function assertExpressionsAreClosed(string $source): void
+ {
+ $pos = 0;
+
+ while (($start = strpos($source, '{{', $pos)) !== false) {
+ $end = strpos($source, '}}', $start + 2);
+
+ if ($end === false) {
+ $line = $start === 0 ? 1 : substr_count($source, "\n", 0, $start) + 1;
+
+ throw new CompilerException(sprintf('Unclosed "{{" expression on line %d: missing matching "}}".', $line));
+ }
- // return $this->nodeCompiler->compile($source);
+ $pos = $end + 2;
+ }
}
}
diff --git a/src/Console/Application.php b/src/Console/Application.php
new file mode 100644
index 0000000..c15b2dd
--- /dev/null
+++ b/src/Console/Application.php
@@ -0,0 +1,75 @@
+stdout = $stdout;
+ $this->stderr = $stderr;
+ $this->stdin = $stdin;
+ }
+
+ /**
+ * @param array $argv
+ */
+ public function run(array $argv): int
+ {
+ $command = $argv[1] ?? null;
+ $args = array_slice($argv, 2);
+
+ return match ($command) {
+ 'compile', '-c' => (new CompileCommand($this->stdout, $this->stderr, $this->stdin))->run($args),
+ 'lint' => (new LintCommand($this->stdout, $this->stderr, $this->stdin))->run($args),
+ null, 'help', '-h', '--help' => $this->showHelp(),
+ default => $this->showUnknownCommand($command),
+ };
+ }
+
+ private function showHelp(): int
+ {
+ fwrite($this->stdout, <<<'HELP'
+ Pesto - PHP View Engine
+
+ Usage:
+ pesto compile Validate and compile a template, print the result
+ pesto -c Shorthand for compile
+ pesto lint [...] Validate template files or directories
+ pesto help Show this help message
+
+ Options:
+ --views Templates root; verifies php-partial references exist.
+ Without paths, lints the whole directory.
+
+ Both commands read the template from stdin when no path is given (or with "-"):
+ echo 'Hi
' | pesto compile
+
+ HELP);
+
+ return 0;
+ }
+
+ private function showUnknownCommand(string $command): int
+ {
+ fwrite($this->stderr, sprintf('Unknown command "%s". Run "pesto help" for usage.', $command).PHP_EOL);
+
+ return 1;
+ }
+}
diff --git a/src/Console/Command.php b/src/Console/Command.php
new file mode 100644
index 0000000..f7282fc
--- /dev/null
+++ b/src/Console/Command.php
@@ -0,0 +1,69 @@
+stdout = $stdout;
+ $this->stderr = $stderr;
+ $this->stdin = $stdin;
+ }
+
+ /**
+ * @param array $args
+ */
+ abstract public function run(array $args): int;
+
+ protected function readStdin(): string
+ {
+ return (string) stream_get_contents($this->stdin);
+ }
+
+ protected function stdinIsInteractive(): bool
+ {
+ return @stream_isatty($this->stdin);
+ }
+
+ protected function line(string $message = '', ?string $color = null): void
+ {
+ fwrite($this->stdout, $this->colorize($message, $color, $this->stdout).PHP_EOL);
+ }
+
+ protected function error(string $message): void
+ {
+ fwrite($this->stderr, $this->colorize($message, self::COLOR_RED, $this->stderr).PHP_EOL);
+ }
+
+ /**
+ * @param resource $stream
+ */
+ private function colorize(string $message, ?string $color, $stream): string
+ {
+ if ($color === null || !@stream_isatty($stream)) {
+ return $message;
+ }
+
+ return "\033[".$color.'m'.$message."\033[0m";
+ }
+}
diff --git a/src/Console/CompileCommand.php b/src/Console/CompileCommand.php
new file mode 100644
index 0000000..2d1826f
--- /dev/null
+++ b/src/Console/CompileCommand.php
@@ -0,0 +1,54 @@
+ $args
+ */
+ public function run(array $args): int
+ {
+ $path = $args[0] ?? null;
+
+ if ($path !== null && $path !== '-') {
+ if (!is_file($path)) {
+ $this->error(sprintf('Template "%s" not found.', $path));
+
+ return 1;
+ }
+
+ $source = (string) file_get_contents($path);
+ } else {
+ $source = $path === null && $this->stdinIsInteractive() ? '' : $this->readStdin();
+
+ if (trim($source) === '') {
+ $this->error('Usage: pesto compile (or pipe a template via stdin)');
+
+ return 1;
+ }
+
+ $path = '';
+ }
+
+ $result = (new TemplateLinter())->lint($source);
+
+ if (!$result->isValid()) {
+ $this->error(sprintf('Template %s failed validation:', $path));
+
+ foreach ($result->errors as $error) {
+ $this->error(' - '.$error);
+ }
+
+ return 1;
+ }
+
+ fwrite($this->stdout, $result->compiled.PHP_EOL);
+
+ return 0;
+ }
+}
diff --git a/src/Console/LintCommand.php b/src/Console/LintCommand.php
new file mode 100644
index 0000000..f96fcf6
--- /dev/null
+++ b/src/Console/LintCommand.php
@@ -0,0 +1,159 @@
+ $args
+ */
+ public function run(array $args): int
+ {
+ $viewsRoot = null;
+ $paths = [];
+
+ for ($i = 0; $i < count($args); ++$i) {
+ $arg = $args[$i];
+
+ if ($arg === '--views') {
+ $viewsRoot = $args[++$i] ?? '';
+ } elseif (str_starts_with($arg, '--views=')) {
+ $viewsRoot = substr($arg, strlen('--views='));
+ } elseif (str_starts_with($arg, '--')) {
+ $this->error(sprintf('Unknown option "%s".', $arg));
+
+ return 1;
+ } else {
+ $paths[] = $arg;
+
+ continue;
+ }
+
+ if ($viewsRoot === '') {
+ $this->error('Option "--views" requires a directory.');
+
+ return 1;
+ }
+ }
+
+ if ($viewsRoot !== null) {
+ if (!is_dir($viewsRoot)) {
+ $this->error(sprintf('Views directory "%s" not found.', $viewsRoot));
+
+ return 1;
+ }
+
+ if ($paths === []) {
+ $paths = [$viewsRoot];
+ }
+ }
+
+ $linter = new TemplateLinter($viewsRoot);
+
+ if ($paths === [] || $paths === ['-']) {
+ return $this->lintStdin($paths === ['-'], $linter);
+ }
+
+ $files = [];
+
+ foreach ($paths as $path) {
+ if (is_file($path)) {
+ $files[] = $path;
+ } elseif (is_dir($path)) {
+ $files = array_merge($files, $this->findTemplates($path));
+ } else {
+ $this->error(sprintf('Path "%s" not found.', $path));
+
+ return 1;
+ }
+ }
+
+ if ($files === []) {
+ $this->error('No templates found (.html, .php).');
+
+ return 1;
+ }
+
+ $failedFiles = 0;
+
+ foreach ($files as $file) {
+ if (!$this->reportResult($file, $linter->lint((string) file_get_contents($file))->errors)) {
+ ++$failedFiles;
+ }
+ }
+
+ $this->line();
+ $this->line(sprintf(
+ 'Linted %d template%s: %s.',
+ count($files),
+ count($files) === 1 ? '' : 's',
+ $failedFiles === 0 ? 'no errors found' : sprintf('%d file%s with errors', $failedFiles, $failedFiles === 1 ? '' : 's'),
+ ));
+
+ return $failedFiles === 0 ? 0 : 1;
+ }
+
+ private function lintStdin(bool $explicit, TemplateLinter $linter): int
+ {
+ $source = !$explicit && $this->stdinIsInteractive() ? '' : $this->readStdin();
+
+ if (trim($source) === '') {
+ $this->error('Usage: pesto lint [--views ] [...] (or pipe a template via stdin)');
+
+ return 1;
+ }
+
+ return $this->reportResult('', $linter->lint($source)->errors) ? 0 : 1;
+ }
+
+ /**
+ * Prints the lint result for one template. Returns true when it passed.
+ *
+ * @param list $errors
+ */
+ private function reportResult(string $label, array $errors): bool
+ {
+ if ($errors === []) {
+ $this->line(' ✓ '.$label, self::COLOR_GREEN);
+
+ return true;
+ }
+
+ $this->line(' ✗ '.$label, self::COLOR_RED);
+
+ foreach ($errors as $error) {
+ $this->line(' - '.$error);
+ }
+
+ return false;
+ }
+
+ /**
+ * @return list
+ */
+ private function findTemplates(string $directory): array
+ {
+ $files = [];
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
+ );
+
+ /** @var \SplFileInfo $fileInfo */
+ foreach ($iterator as $fileInfo) {
+ if (in_array(strtolower($fileInfo->getExtension()), self::TEMPLATE_EXTENSIONS, true)) {
+ $files[] = $fileInfo->getPathname();
+ }
+ }
+
+ sort($files);
+
+ return $files;
+ }
+}
diff --git a/src/Lint/LintResult.php b/src/Lint/LintResult.php
new file mode 100644
index 0000000..4321059
--- /dev/null
+++ b/src/Lint/LintResult.php
@@ -0,0 +1,22 @@
+ $errors
+ */
+ public function __construct(
+ public ?string $compiled,
+ public array $errors,
+ ) {
+ }
+
+ public function isValid(): bool
+ {
+ return $this->errors === [];
+ }
+}
diff --git a/src/Lint/TemplateLinter.php b/src/Lint/TemplateLinter.php
new file mode 100644
index 0000000..53adbf5
--- /dev/null
+++ b/src/Lint/TemplateLinter.php
@@ -0,0 +1,243 @@
+compile($source);
+ } catch (\Throwable $e) {
+ return new LintResult(null, ['Compilation failed: '.$e->getMessage()]);
+ }
+
+ $errors = [];
+
+ $unclosedLine = $this->findUnclosedExpressionLine($source);
+
+ if ($unclosedLine !== null) {
+ $errors[] = sprintf('Unclosed "{{" expression on line %d: missing matching "}}".', $unclosedLine);
+ }
+
+ if (trim($source) !== '' && trim($compiled) === '') {
+ $errors[] = 'Template compiles to empty output: the HTML parser discarded the content (usually an unterminated attribute quote or tag).';
+
+ return new LintResult($compiled, $errors);
+ }
+
+ foreach ($this->findUnprocessedDirectives($compiled) as $directive) {
+ $location = $this->formatLines($this->directiveLines($source, $directive));
+
+ $errors[] = str_ends_with($directive, 'else') || str_ends_with($directive, 'elseif')
+ ? sprintf('Orphan "%s" directive%s: it must be an immediate sibling of a "php-if" element.', $directive, $location)
+ : sprintf('Unprocessed "%s" directive%s.', $directive, $location);
+ }
+
+ $syntaxError = $this->checkPhpSyntax($compiled);
+
+ if ($syntaxError !== null) {
+ $errors[] = $this->mapSyntaxErrorToSource($syntaxError, $source);
+ }
+
+ return new LintResult($compiled, [...$errors, ...$this->checkPartialReferences($source)]);
+ }
+
+ /**
+ * Verifies php-partial references against the views root, mirroring
+ * FileSystemLoader::getPath(). Skipped when no views root is set.
+ *
+ * @return list
+ */
+ private function checkPartialReferences(string $source): array
+ {
+ if ($this->viewsRoot === null) {
+ return [];
+ }
+
+ $source = $this->stripComments($source);
+ $errors = [];
+
+ preg_match_all(self::PARTIAL_REFERENCE_PATTERN, $source, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
+
+ foreach ($matches as $match) {
+ $name = $match[2][0];
+
+ if (!is_file($this->viewsRoot.'/'.$name)) {
+ $errors[] = sprintf(
+ 'Partial "%s" not found in views directory "%s" on line %d.',
+ $name,
+ $this->viewsRoot,
+ $this->lineAt($source, $match[0][1]),
+ );
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * A "{{" without a following "}}" never gets closed. Pairs are consumed
+ * left to right, mirroring the compiler's non-greedy matching.
+ */
+ private function findUnclosedExpressionLine(string $source): ?int
+ {
+ $pos = 0;
+
+ while (($start = strpos($source, '{{', $pos)) !== false) {
+ $end = strpos($source, '}}', $start + 2);
+
+ if ($end === false) {
+ return $this->lineAt($source, $start);
+ }
+
+ $pos = $end + 2;
+ }
+
+ return null;
+ }
+
+ /**
+ * Blanks out HTML comments, keeping newlines so offsets stay on the
+ * same lines.
+ */
+ private function stripComments(string $source): string
+ {
+ return (string) preg_replace_callback(
+ '//s',
+ fn (array $match) => str_repeat("\n", substr_count($match[0], "\n")),
+ $source,
+ );
+ }
+
+ private function lineAt(string $source, int $offset): int
+ {
+ return $offset === 0 ? 1 : substr_count($source, "\n", 0, $offset) + 1;
+ }
+
+ /**
+ * Source lines where the directive attribute appears. HTML comments are
+ * blanked out (newlines kept) so mentions inside them don't count.
+ *
+ * @return list
+ */
+ private function directiveLines(string $source, string $directive): array
+ {
+ $source = $this->stripComments($source);
+
+ preg_match_all('/'.preg_quote($directive, '/').'(?![a-z])/', $source, $matches, PREG_OFFSET_CAPTURE);
+
+ $lines = array_map(
+ fn (array $match) => $this->lineAt($source, $match[1]),
+ $matches[0],
+ );
+
+ return array_values(array_unique($lines));
+ }
+
+ /**
+ * @param list $lines
+ */
+ private function formatLines(array $lines): string
+ {
+ return match (count($lines)) {
+ 0 => '',
+ 1 => ' on line '.$lines[0],
+ default => ' on lines '.implode(', ', $lines),
+ };
+ }
+
+ /**
+ * The serializer preserves newlines, so a line in the compiled output
+ * corresponds to the same line in the source.
+ */
+ private function mapSyntaxErrorToSource(string $message, string $source): string
+ {
+ if (!preg_match('/ in compiled template on line (\d+)$/', $message, $matches)) {
+ return $message;
+ }
+
+ $line = (int) $matches[1];
+ $sourceLines = explode("\n", $source);
+
+ if (!isset($sourceLines[$line - 1])) {
+ return $message;
+ }
+
+ $snippet = trim($sourceLines[$line - 1]);
+
+ if (strlen($snippet) > 80) {
+ $snippet = substr($snippet, 0, 80).'…';
+ }
+
+ return (string) preg_replace(
+ '/ in compiled template on line \d+$/',
+ sprintf(' on line %d: %s', $line, $snippet),
+ $message,
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function findUnprocessedDirectives(string $compiled): array
+ {
+ preg_match_all(self::UNPROCESSED_DIRECTIVE_PATTERN, $compiled, $matches);
+
+ return array_values(array_unique($matches[0]));
+ }
+
+ /**
+ * Lints the compiled PHP with `php -l` fed through stdin.
+ */
+ private function checkPhpSyntax(string $code): ?string
+ {
+ $process = proc_open(
+ [PHP_BINARY, '-l'],
+ [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $pipes,
+ );
+
+ if (!is_resource($process)) {
+ return null;
+ }
+
+ fwrite($pipes[0], $code);
+ fclose($pipes[0]);
+
+ $stdout = (string) stream_get_contents($pipes[1]);
+ $stderr = (string) stream_get_contents($pipes[2]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+
+ if (proc_close($process) === 0) {
+ return null;
+ }
+
+ $message = trim($stderr !== '' ? $stderr : $stdout);
+ $message = strtok($message, "\n");
+ $message = $message === false ? 'PHP syntax error in compiled template.' : $message;
+
+ return str_replace(' in Standard input code', ' in compiled template', $message);
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index c8fe0f3..87841e9 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -11,6 +11,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
{
public const string TEMPLATE_PATH = __DIR__.'/fixtures/templates';
public const string CACHE_PATH = __DIR__.'/fixtures/cache';
+ public const string VIEWS_PATH = __DIR__.'/fixtures/views';
public function assertCompiledEquals(CompilerPass $pass, string $expected, string $html): void
{
diff --git a/tests/Unit/Compiler/Pass/ValidationPassTest.php b/tests/Unit/Compiler/Pass/ValidationPassTest.php
new file mode 100644
index 0000000..3d54667
--- /dev/null
+++ b/tests/Unit/Compiler/Pass/ValidationPassTest.php
@@ -0,0 +1,37 @@
+no directives left
');
+
+ (new ValidationPass())->compile($pesto);
+
+ $this->assertStringContainsString('no directives left', $pesto->getCompiledTemplate());
+ }
+
+ public function test_throws_listing_every_leftover_directive(): void
+ {
+ $pesto = new Pesto('a
');
+
+ try {
+ (new ValidationPass())->compile($pesto);
+ $this->fail('Expected CompilerException was not thrown.');
+ } catch (CompilerException $e) {
+ $this->assertStringContainsString('Orphan "p:elseif" directive on ', $e->getMessage());
+ $this->assertStringContainsString('Unprocessed "php-with" directive on ', $e->getMessage());
+ }
+ }
+}
diff --git a/tests/Unit/Compiler/PestoCompilerTest.php b/tests/Unit/Compiler/PestoCompilerTest.php
new file mode 100644
index 0000000..f2ec32b
--- /dev/null
+++ b/tests/Unit/Compiler/PestoCompilerTest.php
@@ -0,0 +1,61 @@
+compile('{{ $item }} ');
+
+ $this->assertStringContainsString('', $compiled);
+ }
+
+ public function test_throws_on_unclosed_expression(): void
+ {
+ $this->expectException(CompilerException::class);
+ $this->expectExceptionMessage('Unclosed "{{" expression on line 2');
+
+ (new PestoCompiler())->compile("");
+ }
+
+ public function test_throws_on_orphan_else_directive(): void
+ {
+ $this->expectException(CompilerException::class);
+ $this->expectExceptionMessage('Orphan "php-else" directive on ');
+
+ (new PestoCompiler())->compile('
Yes
x No
');
+ }
+
+ public function test_throws_on_unprocessed_with_directive(): void
+ {
+ $this->expectException(CompilerException::class);
+ $this->expectExceptionMessage('Unprocessed "php-with" directive on ');
+
+ (new PestoCompiler())->compile('');
+ }
+
+ public function test_escaped_expressions_do_not_trigger_validation(): void
+ {
+ $compiled = (new PestoCompiler())->compile('@{{ vueBinding }}
');
+
+ $this->assertStringContainsString('{{ vueBinding }}', $compiled);
+ }
+
+ public function test_validation_can_be_disabled(): void
+ {
+ $compiler = new PestoCompiler(validate: false);
+
+ $compiled = $compiler->compile('{{ $name }
orphan
');
+
+ $this->assertStringContainsString('php-else', $compiled);
+ }
+}
diff --git a/tests/Unit/Console/ApplicationTest.php b/tests/Unit/Console/ApplicationTest.php
new file mode 100644
index 0000000..a9440b1
--- /dev/null
+++ b/tests/Unit/Console/ApplicationTest.php
@@ -0,0 +1,63 @@
+stdout = fopen('php://memory', 'w+');
+ $this->stderr = fopen('php://memory', 'w+');
+ $this->stdin = fopen('php://memory', 'w+');
+ }
+
+ public function test_shows_help_without_arguments(): void
+ {
+ $exitCode = (new Application($this->stdout, $this->stderr, $this->stdin))->run(['pesto']);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertStringContainsString('Usage:', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_short_compile_alias_dispatches_to_compile(): void
+ {
+ $exitCode = (new Application($this->stdout, $this->stderr, $this->stdin))->run(['pesto', '-c']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Usage: pesto compile', $this->getStreamContents($this->stderr));
+ }
+
+ public function test_unknown_command_fails(): void
+ {
+ $exitCode = (new Application($this->stdout, $this->stderr, $this->stdin))->run(['pesto', 'unknown']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Unknown command "unknown"', $this->getStreamContents($this->stderr));
+ }
+
+ /**
+ * @param resource $stream
+ */
+ private function getStreamContents($stream): string
+ {
+ rewind($stream);
+
+ return (string) stream_get_contents($stream);
+ }
+}
diff --git a/tests/Unit/Console/CompileCommandTest.php b/tests/Unit/Console/CompileCommandTest.php
new file mode 100644
index 0000000..e3eb4d1
--- /dev/null
+++ b/tests/Unit/Console/CompileCommandTest.php
@@ -0,0 +1,105 @@
+stdout = fopen('php://memory', 'w+');
+ $this->stderr = fopen('php://memory', 'w+');
+ $this->stdin = fopen('php://memory', 'w+');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->cleanupTemporaryTemplate();
+ }
+
+ public function test_compile_template_to_stdout(): void
+ {
+ $name = $this->createTemporaryTemplate('compile.php', '{{ $item }} ');
+
+ $exitCode = (new CompileCommand($this->stdout, $this->stderr, $this->stdin))
+ ->run([self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(0, $exitCode);
+
+ $output = $this->getStreamContents($this->stdout);
+ $this->assertStringContainsString('', $output);
+ $this->assertStringContainsString('$__pesto->output($item', $output);
+ }
+
+ public function test_compile_template_from_stdin(): void
+ {
+ fwrite($this->stdin, '{{ $name }}
');
+ rewind($this->stdin);
+
+ $exitCode = (new CompileCommand($this->stdout, $this->stderr, $this->stdin))->run([]);
+
+ $this->assertSame(0, $exitCode);
+
+ $output = $this->getStreamContents($this->stdout);
+ $this->assertStringContainsString('', $output);
+ $this->assertStringContainsString('$__pesto->output($name', $output);
+ }
+
+ public function test_refuses_invalid_template(): void
+ {
+ $name = $this->createTemporaryTemplate('invalid.php', '{{ $name }
');
+
+ $exitCode = (new CompileCommand($this->stdout, $this->stderr, $this->stdin))
+ ->run([self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertSame('', $this->getStreamContents($this->stdout));
+
+ $errorOutput = $this->getStreamContents($this->stderr);
+ $this->assertStringContainsString('failed validation', $errorOutput);
+ $this->assertStringContainsString('Unclosed "{{" expression', $errorOutput);
+ $this->assertStringContainsString('syntax error', $errorOutput);
+ }
+
+ public function test_fails_without_template_path_or_stdin(): void
+ {
+ $exitCode = (new CompileCommand($this->stdout, $this->stderr, $this->stdin))->run([]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Usage: pesto compile', $this->getStreamContents($this->stderr));
+ }
+
+ public function test_fails_with_missing_template(): void
+ {
+ $exitCode = (new CompileCommand($this->stdout, $this->stderr, $this->stdin))
+ ->run([self::TEMPLATE_PATH.'/does-not-exist.php']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('not found', $this->getStreamContents($this->stderr));
+ }
+
+ /**
+ * @param resource $stream
+ */
+ private function getStreamContents($stream): string
+ {
+ rewind($stream);
+
+ return (string) stream_get_contents($stream);
+ }
+}
diff --git a/tests/Unit/Console/LintCommandTest.php b/tests/Unit/Console/LintCommandTest.php
new file mode 100644
index 0000000..d7f52e1
--- /dev/null
+++ b/tests/Unit/Console/LintCommandTest.php
@@ -0,0 +1,189 @@
+stdout = fopen('php://memory', 'w+');
+ $this->stderr = fopen('php://memory', 'w+');
+ $this->stdin = fopen('php://memory', 'w+');
+ }
+
+ protected function tearDown(): void
+ {
+ $this->cleanupTemporaryTemplate();
+ }
+
+ public function test_lint_valid_template(): void
+ {
+ $name = $this->createTemporaryTemplate('valid.php', '{{ $item }} ');
+
+ $exitCode = $this->runLint([self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertStringContainsString('no errors found', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_lint_reports_php_syntax_error(): void
+ {
+ $name = $this->createTemporaryTemplate('syntax.php', 'Broken
');
+
+ $exitCode = $this->runLint([self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('syntax error', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_lint_reports_orphan_else_directive(): void
+ {
+ $name = $this->createTemporaryTemplate('orphan.html', <<<'HTML'
+ Yes
+ separator
+ No
+ HTML);
+
+ $exitCode = $this->runLint([self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Orphan "php-else" directive', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_lint_reports_content_discarded_by_parser(): void
+ {
+ $name = $this->createTemporaryTemplate('swallowed.html', 'getStreamContents($this->stdout));
+ }
+
+ public function test_lint_directory(): void
+ {
+ $this->createTemporaryTemplate('one.php', '
Yes
');
+ $this->createTemporaryTemplate('two.html', '{{ $text | upper }}
');
+
+ $exitCode = $this->runLint([self::TEMPLATE_PATH]);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertStringContainsString('Linted 2 templates', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_lint_template_from_stdin(): void
+ {
+ fwrite($this->stdin, '{{ $name }}
');
+ rewind($this->stdin);
+
+ $exitCode = $this->runLint([]);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertStringContainsString('', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_lint_invalid_template_from_stdin(): void
+ {
+ fwrite($this->stdin, 'Broken
');
+ rewind($this->stdin);
+
+ $exitCode = $this->runLint(['-']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('syntax error', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_views_option_lints_whole_directory(): void
+ {
+ $exitCode = $this->runLint(['--views', self::VIEWS_PATH]);
+
+ $this->assertSame(0, $exitCode);
+ $this->assertStringContainsString('Linted 3 templates: no errors found', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_views_option_reports_missing_partial(): void
+ {
+ $name = $this->createTemporaryTemplate('view.php', 'x
');
+
+ $exitCode = $this->runLint(['--views='.self::VIEWS_PATH, self::TEMPLATE_PATH.'/'.$name]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Partial "layouts/missing.php" not found', $this->getStreamContents($this->stdout));
+ }
+
+ public function test_views_option_requires_a_directory(): void
+ {
+ $exitCode = $this->runLint(['--views']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('"--views" requires a directory', $this->getStreamContents($this->stderr));
+ }
+
+ public function test_unknown_option_fails(): void
+ {
+ $exitCode = $this->runLint(['--nope', self::VIEWS_PATH]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Unknown option "--nope"', $this->getStreamContents($this->stderr));
+ }
+
+ public function test_fails_without_paths_or_stdin(): void
+ {
+ $exitCode = $this->runLint([]);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('Usage: pesto lint', $this->getStreamContents($this->stderr));
+ }
+
+ public function test_fails_with_missing_path(): void
+ {
+ $exitCode = $this->runLint([self::TEMPLATE_PATH.'/missing-dir']);
+
+ $this->assertSame(1, $exitCode);
+ $this->assertStringContainsString('not found', $this->getStreamContents($this->stderr));
+ }
+
+ /**
+ * @param array $args
+ */
+ private function runLint(array $args): int
+ {
+ return (new LintCommand($this->stdout, $this->stderr, $this->stdin))->run($args);
+ }
+
+ /**
+ * @param resource $stream
+ */
+ private function getStreamContents($stream): string
+ {
+ rewind($stream);
+
+ return (string) stream_get_contents($stream);
+ }
+}
diff --git a/tests/Unit/Lint/TemplateLinterTest.php b/tests/Unit/Lint/TemplateLinterTest.php
new file mode 100644
index 0000000..6fe678a
--- /dev/null
+++ b/tests/Unit/Lint/TemplateLinterTest.php
@@ -0,0 +1,167 @@
+lint('{{ $item }} ');
+
+ $this->assertTrue($result->isValid());
+ $this->assertStringContainsString('', (string) $result->compiled);
+ }
+
+ public function test_valid_layout_with_slots_and_filters(): void
+ {
+ $result = (new TemplateLinter())->lint(<<<'HTML'
+
+
+
+ {{ $title | upper }}
+
+
+
+ {{ $main | slot }}
+
+
+
+ HTML);
+
+ $this->assertTrue($result->isValid());
+ }
+
+ public function test_valid_view_with_partial_slots_and_chained_filters(): void
+ {
+ $result = (new TemplateLinter())->lint(<<<'HTML'
+
+
+ {{ $about | title | trim }}
+
+
+ {{ $item->label | upper }}
+
+
+ HTML);
+
+ $this->assertTrue($result->isValid());
+ }
+
+ public function test_existing_partial_reference_passes_with_views_root(): void
+ {
+ $result = (new TemplateLinter(self::VIEWS_PATH))
+ ->lint((string) file_get_contents(self::VIEWS_PATH.'/home.php'));
+
+ $this->assertTrue($result->isValid());
+ }
+
+ public function test_reports_missing_partial_reference_with_views_root(): void
+ {
+ $result = (new TemplateLinter(self::VIEWS_PATH))->lint(<<<'HTML'
+
+ x
+
+ HTML);
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString(
+ 'Partial "layouts/missing.php" not found in views directory',
+ implode("\n", $result->errors),
+ );
+ $this->assertStringContainsString('on line 2', implode("\n", $result->errors));
+ }
+
+ public function test_partial_references_are_not_checked_without_views_root(): void
+ {
+ $result = (new TemplateLinter())->lint('x
');
+
+ $this->assertTrue($result->isValid());
+ }
+
+ public function test_reports_broken_expression_in_php_with(): void
+ {
+ $result = (new TemplateLinter())->lint('x ');
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('on line 1', implode("\n", $result->errors));
+ }
+
+ public function test_reports_php_syntax_error(): void
+ {
+ $result = (new TemplateLinter())->lint('Broken
');
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('syntax error', implode("\n", $result->errors));
+ }
+
+ public function test_reports_unclosed_expression(): void
+ {
+ $result = (new TemplateLinter())->lint('{{ $name }
');
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('Unclosed "{{" expression', implode("\n", $result->errors));
+ }
+
+ public function test_reports_content_discarded_by_parser(): void
+ {
+ $result = (new TemplateLinter())->lint('errors));
+ }
+
+ public function test_reports_orphan_else_directive(): void
+ {
+ $result = (new TemplateLinter())->lint('
Yes
x No
');
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('Orphan "php-else" directive on line 1', implode("\n", $result->errors));
+ }
+
+ public function test_directive_mentions_in_html_comments_are_not_located(): void
+ {
+ $result = (new TemplateLinter())->lint(<<<'HTML'
+
+ Yes
+
+ Orphan
+ HTML);
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('Orphan "php-else" directive on line 4', implode("\n", $result->errors));
+ }
+
+ public function test_syntax_error_reports_source_line_and_snippet(): void
+ {
+ $result = (new TemplateLinter())->lint(<<<'HTML'
+
+ first
+ broken
+ last
+
+ HTML);
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('on line 3: broken ', implode("\n", $result->errors));
+ }
+
+ public function test_unclosed_expression_reports_line(): void
+ {
+ $result = (new TemplateLinter())->lint(<<<'HTML'
+
+
{{ $ok }}
+
{{ $name }
+
+ HTML);
+
+ $this->assertFalse($result->isValid());
+ $this->assertStringContainsString('Unclosed "{{" expression on line 3', implode("\n", $result->errors));
+ }
+}
diff --git a/tests/fixtures/lint-showcase.php b/tests/fixtures/lint-showcase.php
new file mode 100644
index 0000000..62991bb
--- /dev/null
+++ b/tests/fixtures/lint-showcase.php
@@ -0,0 +1,26 @@
+
+
+
+
+
Broken condition
+
+
+
Yes
+
+
Orphan else
+
+
+
separator
+
Orphan elseif
+
+
+
+
+
+
{{ $title }
+
+
diff --git a/tests/fixtures/views/home.php b/tests/fixtures/views/home.php
new file mode 100644
index 0000000..a13a549
--- /dev/null
+++ b/tests/fixtures/views/home.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ {{ $heading | capitalize }}
+
+ {{ $item->label | upper }}
+
+
+
diff --git a/tests/fixtures/views/layouts/app.php b/tests/fixtures/views/layouts/app.php
new file mode 100644
index 0000000..9d75d13
--- /dev/null
+++ b/tests/fixtures/views/layouts/app.php
@@ -0,0 +1,13 @@
+
+
+
+ {{ $title | upper }}
+
+
+
+
+ {{ $main | slot }}
+
+
+
+
diff --git a/tests/fixtures/views/partials/nav.php b/tests/fixtures/views/partials/nav.php
new file mode 100644
index 0000000..50b0510
--- /dev/null
+++ b/tests/fixtures/views/partials/nav.php
@@ -0,0 +1,4 @@
+
+ Home
+ {{ $about | title | trim }}
+