Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Create stub template system with publishable stubs

Changed files
+411
src
Generator
stubs
tests
Unit
+165
src/Generator/StubRenderer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Exceptions\GenerationException; 6 + 7 + class StubRenderer 8 + { 9 + /** 10 + * Path to stub files. 11 + */ 12 + protected string $stubPath; 13 + 14 + /** 15 + * Cached stub contents. 16 + * 17 + * @var array<string, string> 18 + */ 19 + protected array $stubs = []; 20 + 21 + /** 22 + * Create a new StubRenderer. 23 + */ 24 + public function __construct(?string $stubPath = null) 25 + { 26 + $this->stubPath = $stubPath ?? $this->getDefaultStubPath(); 27 + } 28 + 29 + /** 30 + * Render a stub with variables. 31 + * 32 + * @param array<string, mixed> $variables 33 + */ 34 + public function render(string $stub, array $variables): string 35 + { 36 + $template = $this->loadStub($stub); 37 + 38 + return $this->replaceVariables($template, $variables); 39 + } 40 + 41 + /** 42 + * Load a stub file. 43 + */ 44 + protected function loadStub(string $name): string 45 + { 46 + if (isset($this->stubs[$name])) { 47 + return $this->stubs[$name]; 48 + } 49 + 50 + $path = $this->getStubPath($name); 51 + 52 + if (! file_exists($path)) { 53 + throw GenerationException::templateNotFound($name); 54 + } 55 + 56 + $content = file_get_contents($path); 57 + 58 + if ($content === false) { 59 + throw GenerationException::cannotReadFile($path); 60 + } 61 + 62 + $this->stubs[$name] = $content; 63 + 64 + return $content; 65 + } 66 + 67 + /** 68 + * Replace variables in template. 69 + * 70 + * @param array<string, mixed> $variables 71 + */ 72 + protected function replaceVariables(string $template, array $variables): string 73 + { 74 + $result = $template; 75 + 76 + foreach ($variables as $key => $value) { 77 + // Convert value to string 78 + $stringValue = $this->valueToString($value); 79 + 80 + // Replace {{ key }} with value 81 + $result = str_replace('{{ '.$key.' }}', $stringValue, $result); 82 + } 83 + 84 + // Remove any remaining unreplaced variables 85 + $result = preg_replace('/\{\{\s*\w+\s*\}\}/', '', $result); 86 + 87 + return $result; 88 + } 89 + 90 + /** 91 + * Convert a value to string for replacement. 92 + */ 93 + protected function valueToString(mixed $value): string 94 + { 95 + if (is_array($value)) { 96 + return implode("\n", array_filter($value)); 97 + } 98 + 99 + if (is_bool($value)) { 100 + return $value ? 'true' : 'false'; 101 + } 102 + 103 + if ($value === null) { 104 + return ''; 105 + } 106 + 107 + return (string) $value; 108 + } 109 + 110 + /** 111 + * Get the path for a stub file. 112 + */ 113 + protected function getStubPath(string $name): string 114 + { 115 + // Check for published stubs first (in Laravel app) 116 + $publishedPath = base_path('stubs/schema/'.$name.'.stub'); 117 + if (file_exists($publishedPath)) { 118 + return $publishedPath; 119 + } 120 + 121 + // Fall back to package stubs 122 + return $this->stubPath.'/'.$name.'.stub'; 123 + } 124 + 125 + /** 126 + * Get default stub path. 127 + */ 128 + protected function getDefaultStubPath(): string 129 + { 130 + return __DIR__.'/../../stubs'; 131 + } 132 + 133 + /** 134 + * Clear cached stubs. 135 + */ 136 + public function clearCache(): void 137 + { 138 + $this->stubs = []; 139 + } 140 + 141 + /** 142 + * Set custom stub path. 143 + */ 144 + public function setStubPath(string $path): void 145 + { 146 + $this->stubPath = $path; 147 + $this->clearCache(); 148 + } 149 + 150 + /** 151 + * Get available stubs. 152 + * 153 + * @return array<string> 154 + */ 155 + public function getAvailableStubs(): array 156 + { 157 + $stubs = []; 158 + 159 + foreach (glob($this->stubPath.'/*.stub') as $file) { 160 + $stubs[] = basename($file, '.stub'); 161 + } 162 + 163 + return $stubs; 164 + } 165 + }
+15
stubs/class.stub
··· 1 + <?php 2 + 3 + namespace {{ namespace }}; 4 + 5 + {{ imports }} 6 + 7 + {{ docBlock }} 8 + class {{ className }}{{ extends }}{{ implements }} 9 + { 10 + {{ properties }} 11 + 12 + {{ constructor }} 13 + 14 + {{ methods }} 15 + }
+6
stubs/constructor.stub
··· 1 + {{ docBlock }} 2 + public function __construct( 3 + {{ parameters }} 4 + ) { 5 + {{ body }} 6 + }
+5
stubs/method.stub
··· 1 + {{ docBlock }} 2 + {{ visibility }}{{ static }} function {{ name }}({{ parameters }}){{ returnType }} 3 + { 4 + {{ body }} 5 + }
+2
stubs/property.stub
··· 1 + {{ docBlock }} 2 + {{ visibility }}{{ static }}{{ readonly }} {{ type }} ${{ name }}{{ default }};
+218
tests/Unit/Generator/StubRendererTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Exceptions\GenerationException; 7 + use SocialDept\Schema\Generator\StubRenderer; 8 + 9 + class StubRendererTest extends TestCase 10 + { 11 + protected StubRenderer $renderer; 12 + 13 + protected string $stubPath; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->stubPath = __DIR__.'/../../fixtures/stubs'; 20 + @mkdir($this->stubPath, 0755, true); 21 + 22 + $this->renderer = new StubRenderer($this->stubPath); 23 + } 24 + 25 + protected function tearDown(): void 26 + { 27 + // Clean up test stubs 28 + if (is_dir($this->stubPath)) { 29 + foreach (glob($this->stubPath.'/*.stub') as $file) { 30 + @unlink($file); 31 + } 32 + @rmdir($this->stubPath); 33 + } 34 + 35 + parent::tearDown(); 36 + } 37 + 38 + public function test_it_renders_simple_stub(): void 39 + { 40 + file_put_contents($this->stubPath.'/simple.stub', 'Hello {{ name }}!'); 41 + 42 + $result = $this->renderer->render('simple', ['name' => 'World']); 43 + 44 + $this->assertSame('Hello World!', $result); 45 + } 46 + 47 + public function test_it_renders_multiple_variables(): void 48 + { 49 + file_put_contents( 50 + $this->stubPath.'/multiple.stub', 51 + 'namespace {{ namespace }}; 52 + 53 + class {{ className }} 54 + { 55 + }' 56 + ); 57 + 58 + $result = $this->renderer->render('multiple', [ 59 + 'namespace' => 'App\\Models', 60 + 'className' => 'Post', 61 + ]); 62 + 63 + $this->assertStringContainsString('namespace App\\Models;', $result); 64 + $this->assertStringContainsString('class Post', $result); 65 + } 66 + 67 + public function test_it_handles_array_values(): void 68 + { 69 + file_put_contents( 70 + $this->stubPath.'/array.stub', 71 + 'class Test 72 + { 73 + {{ properties }} 74 + }' 75 + ); 76 + 77 + $result = $this->renderer->render('array', [ 78 + 'properties' => [ 79 + ' public string $name;', 80 + ' public int $age;', 81 + ], 82 + ]); 83 + 84 + $this->assertStringContainsString('public string $name;', $result); 85 + $this->assertStringContainsString('public int $age;', $result); 86 + } 87 + 88 + public function test_it_handles_empty_values(): void 89 + { 90 + file_put_contents($this->stubPath.'/empty.stub', 'Hello{{ suffix }}'); 91 + 92 + $result = $this->renderer->render('empty', ['suffix' => '']); 93 + 94 + $this->assertSame('Hello', $result); 95 + } 96 + 97 + public function test_it_handles_null_values(): void 98 + { 99 + file_put_contents($this->stubPath.'/null.stub', 'Hello{{ suffix }}'); 100 + 101 + $result = $this->renderer->render('null', ['suffix' => null]); 102 + 103 + $this->assertSame('Hello', $result); 104 + } 105 + 106 + public function test_it_removes_unreplaced_variables(): void 107 + { 108 + file_put_contents($this->stubPath.'/unreplaced.stub', 'Hello {{ name }}{{ extra }}'); 109 + 110 + $result = $this->renderer->render('unreplaced', ['name' => 'World']); 111 + 112 + $this->assertSame('Hello World', $result); 113 + $this->assertStringNotContainsString('{{ extra }}', $result); 114 + } 115 + 116 + public function test_it_caches_stub_contents(): void 117 + { 118 + file_put_contents($this->stubPath.'/cached.stub', 'Original'); 119 + 120 + // First render 121 + $result1 = $this->renderer->render('cached', []); 122 + 123 + // Change file content 124 + file_put_contents($this->stubPath.'/cached.stub', 'Modified'); 125 + 126 + // Second render should still use cached version 127 + $result2 = $this->renderer->render('cached', []); 128 + 129 + $this->assertSame('Original', $result1); 130 + $this->assertSame('Original', $result2); 131 + } 132 + 133 + public function test_it_can_clear_cache(): void 134 + { 135 + file_put_contents($this->stubPath.'/clearable.stub', 'Original'); 136 + 137 + // First render 138 + $result1 = $this->renderer->render('clearable', []); 139 + 140 + // Change file and clear cache 141 + file_put_contents($this->stubPath.'/clearable.stub', 'Modified'); 142 + $this->renderer->clearCache(); 143 + 144 + // Should load new version 145 + $result2 = $this->renderer->render('clearable', []); 146 + 147 + $this->assertSame('Original', $result1); 148 + $this->assertSame('Modified', $result2); 149 + } 150 + 151 + public function test_it_throws_when_stub_not_found(): void 152 + { 153 + $this->expectException(GenerationException::class); 154 + $this->expectExceptionMessage('Template not found: nonexistent'); 155 + 156 + $this->renderer->render('nonexistent', []); 157 + } 158 + 159 + public function test_it_can_set_custom_stub_path(): void 160 + { 161 + $customPath = __DIR__.'/../../fixtures/custom-stubs'; 162 + @mkdir($customPath, 0755, true); 163 + file_put_contents($customPath.'/custom.stub', 'Custom stub'); 164 + 165 + $this->renderer->setStubPath($customPath); 166 + $result = $this->renderer->render('custom', []); 167 + 168 + $this->assertSame('Custom stub', $result); 169 + 170 + // Cleanup 171 + @unlink($customPath.'/custom.stub'); 172 + @rmdir($customPath); 173 + } 174 + 175 + public function test_it_prefers_published_stubs(): void 176 + { 177 + // This test would require a full Laravel app context 178 + // For now, we just test that the method checks for published stubs 179 + file_put_contents($this->stubPath.'/package.stub', 'Package version'); 180 + 181 + $result = $this->renderer->render('package', []); 182 + 183 + $this->assertSame('Package version', $result); 184 + } 185 + 186 + public function test_it_lists_available_stubs(): void 187 + { 188 + file_put_contents($this->stubPath.'/stub1.stub', 'Stub 1'); 189 + file_put_contents($this->stubPath.'/stub2.stub', 'Stub 2'); 190 + file_put_contents($this->stubPath.'/stub3.stub', 'Stub 3'); 191 + 192 + $stubs = $this->renderer->getAvailableStubs(); 193 + 194 + $this->assertContains('stub1', $stubs); 195 + $this->assertContains('stub2', $stubs); 196 + $this->assertContains('stub3', $stubs); 197 + } 198 + 199 + public function test_it_handles_boolean_values(): void 200 + { 201 + file_put_contents($this->stubPath.'/boolean.stub', 'Active: {{ active }}'); 202 + 203 + $result1 = $this->renderer->render('boolean', ['active' => true]); 204 + $result2 = $this->renderer->render('boolean', ['active' => false]); 205 + 206 + $this->assertSame('Active: true', $result1); 207 + $this->assertSame('Active: false', $result2); 208 + } 209 + 210 + public function test_it_handles_numeric_values(): void 211 + { 212 + file_put_contents($this->stubPath.'/numeric.stub', 'Count: {{ count }}'); 213 + 214 + $result = $this->renderer->render('numeric', ['count' => 42]); 215 + 216 + $this->assertSame('Count: 42', $result); 217 + } 218 + }