+ */
+ 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 }}
+