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): ?>
  • 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

    b
    '); + + 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("
    \n

    {{ \$name }

    \n
    "); + } + + 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('
    no partial
    '); + } + + 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 }} + + +
    {{ $header | slot }}
    +
    {{ $main | slot }}
    +
    {{ $year | date:'Y' }}
    + + + HTML); + + $this->assertTrue($result->isValid()); + } + + public function test_valid_view_with_partial_slots_and_chained_filters(): void + { + $result = (new TemplateLinter())->lint(<<<'HTML' + + 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' +
    + +
    + 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(''); + + $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

    + + +
    No partial here
    + + +

    {{ $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 @@ + 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 }} + + +
    {{ $header | slot }}
    + +
    {{ $main | slot }}
    + +
    {{ $year | date:'Y' }}
    + + 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 @@ +