Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Create generator system foundation with DTO code generation

+1399
+88
src/Exceptions/GenerationException.php
··· 36 36 ['nsid' => $nsid, 'feature' => $feature] 37 37 ); 38 38 } 39 + 40 + /** 41 + * Create exception for template not found. 42 + */ 43 + public static function templateNotFound(string $templateName): self 44 + { 45 + return static::withContext( 46 + "Template not found: {$templateName}", 47 + ['template' => $templateName] 48 + ); 49 + } 50 + 51 + /** 52 + * Create exception for file already exists. 53 + */ 54 + public static function fileExists(string $path): self 55 + { 56 + return static::withContext( 57 + "File already exists: {$path}", 58 + ['path' => $path] 59 + ); 60 + } 61 + 62 + /** 63 + * Create exception for directory not found. 64 + */ 65 + public static function directoryNotFound(string $directory): self 66 + { 67 + return static::withContext( 68 + "Directory not found: {$directory}", 69 + ['directory' => $directory] 70 + ); 71 + } 72 + 73 + /** 74 + * Create exception for cannot create directory. 75 + */ 76 + public static function cannotCreateDirectory(string $directory): self 77 + { 78 + return static::withContext( 79 + "Cannot create directory: {$directory}", 80 + ['directory' => $directory] 81 + ); 82 + } 83 + 84 + /** 85 + * Create exception for cannot write file. 86 + */ 87 + public static function cannotWriteFile(string $path): self 88 + { 89 + return static::withContext( 90 + "Cannot write file: {$path}", 91 + ['path' => $path] 92 + ); 93 + } 94 + 95 + /** 96 + * Create exception for cannot delete file. 97 + */ 98 + public static function cannotDeleteFile(string $path): self 99 + { 100 + return static::withContext( 101 + "Cannot delete file: {$path}", 102 + ['path' => $path] 103 + ); 104 + } 105 + 106 + /** 107 + * Create exception for file not found. 108 + */ 109 + public static function fileNotFound(string $path): self 110 + { 111 + return static::withContext( 112 + "File not found: {$path}", 113 + ['path' => $path] 114 + ); 115 + } 116 + 117 + /** 118 + * Create exception for cannot read file. 119 + */ 120 + public static function cannotReadFile(string $path): self 121 + { 122 + return static::withContext( 123 + "Cannot read file: {$path}", 124 + ['path' => $path] 125 + ); 126 + } 39 127 }
+333
src/Generator/DTOGenerator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Contracts\DataGenerator; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Parser\SchemaLoader; 8 + use SocialDept\Schema\Parser\TypeParser; 9 + 10 + class DTOGenerator implements DataGenerator 11 + { 12 + /** 13 + * Schema loader for loading lexicon documents. 14 + */ 15 + protected SchemaLoader $schemaLoader; 16 + 17 + /** 18 + * Type parser for parsing type definitions. 19 + */ 20 + protected TypeParser $typeParser; 21 + 22 + /** 23 + * Namespace resolver for converting NSIDs to PHP namespaces. 24 + */ 25 + protected NamespaceResolver $namespaceResolver; 26 + 27 + /** 28 + * Template renderer for generating PHP code. 29 + */ 30 + protected TemplateRenderer $templateRenderer; 31 + 32 + /** 33 + * File writer for writing generated files. 34 + */ 35 + protected FileWriter $fileWriter; 36 + 37 + /** 38 + * Base namespace for generated classes. 39 + */ 40 + protected string $baseNamespace; 41 + 42 + /** 43 + * Output directory for generated files. 44 + */ 45 + protected string $outputDirectory; 46 + 47 + /** 48 + * Create a new DTOGenerator. 49 + */ 50 + public function __construct( 51 + SchemaLoader $schemaLoader, 52 + string $baseNamespace = 'App\\Lexicon', 53 + string $outputDirectory = 'app/Lexicon', 54 + ?TypeParser $typeParser = null, 55 + ?NamespaceResolver $namespaceResolver = null, 56 + ?TemplateRenderer $templateRenderer = null, 57 + ?FileWriter $fileWriter = null 58 + ) { 59 + $this->schemaLoader = $schemaLoader; 60 + $this->baseNamespace = rtrim($baseNamespace, '\\'); 61 + $this->outputDirectory = rtrim($outputDirectory, '/'); 62 + $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader); 63 + $this->namespaceResolver = $namespaceResolver ?? new NamespaceResolver($baseNamespace); 64 + $this->templateRenderer = $templateRenderer ?? new TemplateRenderer(); 65 + $this->fileWriter = $fileWriter ?? new FileWriter(); 66 + } 67 + 68 + /** 69 + * Generate PHP class files from Lexicon definition. 70 + */ 71 + public function generate(LexiconDocument $schema): string 72 + { 73 + return $this->generateRecordCode($schema); 74 + } 75 + 76 + /** 77 + * Generate and write class file to disk. 78 + */ 79 + public function generateAndSave(LexiconDocument $schema, string $outputPath): string 80 + { 81 + $code = $this->generate($schema); 82 + $this->fileWriter->write($outputPath, $code); 83 + 84 + return $outputPath; 85 + } 86 + 87 + /** 88 + * Generate class content without writing to disk. 89 + */ 90 + public function preview(LexiconDocument $schema): string 91 + { 92 + return $this->generate($schema); 93 + } 94 + 95 + /** 96 + * Set the base namespace for generated classes. 97 + */ 98 + public function setBaseNamespace(string $namespace): void 99 + { 100 + $this->baseNamespace = rtrim($namespace, '\\'); 101 + $this->namespaceResolver = new NamespaceResolver($this->baseNamespace); 102 + } 103 + 104 + /** 105 + * Set the output path for generated classes. 106 + */ 107 + public function setOutputPath(string $path): void 108 + { 109 + $this->outputDirectory = rtrim($path, '/'); 110 + } 111 + 112 + /** 113 + * Generate DTO classes from NSID. 114 + */ 115 + public function generateByNsid(string $nsid, array $options = []): array 116 + { 117 + $schema = $this->schemaLoader->load($nsid); 118 + $document = LexiconDocument::fromArray($schema); 119 + 120 + return $this->generateFromDocument($document, $options); 121 + } 122 + 123 + /** 124 + * Generate DTO classes from a lexicon document. 125 + */ 126 + public function generateFromDocument(LexiconDocument $document, array $options = []): array 127 + { 128 + $generatedFiles = []; 129 + 130 + // Generate main class if it's a record 131 + if ($document->isRecord()) { 132 + $file = $this->generateRecordClass($document, $options); 133 + $generatedFiles[] = $file; 134 + } 135 + 136 + // Generate classes for other definitions 137 + foreach ($document->getDefinitionNames() as $defName) { 138 + if ($defName === 'main') { 139 + continue; 140 + } 141 + 142 + $definition = $document->getDefinition($defName); 143 + 144 + if (isset($definition['type']) && $definition['type'] === 'object') { 145 + $file = $this->generateDefinitionClass($document, $defName, $options); 146 + $generatedFiles[] = $file; 147 + } 148 + } 149 + 150 + return $generatedFiles; 151 + } 152 + 153 + /** 154 + * Generate code for a record (without writing to disk). 155 + */ 156 + protected function generateRecordCode(LexiconDocument $document): string 157 + { 158 + $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 159 + $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 160 + 161 + $mainDef = $document->getMainDefinition(); 162 + $recordSchema = $mainDef['record'] ?? []; 163 + 164 + $properties = $this->extractProperties($recordSchema, $document); 165 + 166 + return $this->templateRenderer->render('record', [ 167 + 'namespace' => $namespace, 168 + 'className' => $className, 169 + 'nsid' => $document->getNsid(), 170 + 'description' => $document->description, 171 + 'properties' => $properties, 172 + ]); 173 + } 174 + 175 + /** 176 + * Generate a record class from a lexicon document. 177 + */ 178 + protected function generateRecordClass(LexiconDocument $document, array $options = []): string 179 + { 180 + $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 181 + $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 182 + 183 + $mainDef = $document->getMainDefinition(); 184 + $recordSchema = $mainDef['record'] ?? []; 185 + 186 + $properties = $this->extractProperties($recordSchema, $document); 187 + 188 + $code = $this->templateRenderer->render('record', [ 189 + 'namespace' => $namespace, 190 + 'className' => $className, 191 + 'nsid' => $document->getNsid(), 192 + 'description' => $document->description, 193 + 'properties' => $properties, 194 + ]); 195 + 196 + $filePath = $this->getFilePath($namespace, $className); 197 + 198 + if (! ($options['dryRun'] ?? false)) { 199 + $this->fileWriter->write($filePath, $code); 200 + } 201 + 202 + return $filePath; 203 + } 204 + 205 + /** 206 + * Generate a class for a specific definition. 207 + */ 208 + protected function generateDefinitionClass(LexiconDocument $document, string $defName, array $options = []): string 209 + { 210 + $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 211 + $className = $this->namespaceResolver->resolveClassName($document->getNsid(), $defName); 212 + 213 + $definition = $document->getDefinition($defName); 214 + $properties = $this->extractProperties($definition, $document); 215 + 216 + $code = $this->templateRenderer->render('object', [ 217 + 'namespace' => $namespace, 218 + 'className' => $className, 219 + 'description' => $definition['description'] ?? null, 220 + 'properties' => $properties, 221 + ]); 222 + 223 + $filePath = $this->getFilePath($namespace, $className); 224 + 225 + if (! ($options['dryRun'] ?? false)) { 226 + $this->fileWriter->write($filePath, $code); 227 + } 228 + 229 + return $filePath; 230 + } 231 + 232 + /** 233 + * Extract properties from a schema definition. 234 + * 235 + * @return array<array{name: string, type: string, phpType: string, required: bool, description: ?string}> 236 + */ 237 + protected function extractProperties(array $schema, LexiconDocument $document): array 238 + { 239 + $properties = []; 240 + $schemaProperties = $schema['properties'] ?? []; 241 + $required = $schema['required'] ?? []; 242 + 243 + foreach ($schemaProperties as $name => $propSchema) { 244 + $properties[] = [ 245 + 'name' => $name, 246 + 'type' => $propSchema['type'] ?? 'unknown', 247 + 'phpType' => $this->mapToPhpType($propSchema), 248 + 'required' => in_array($name, $required), 249 + 'description' => $propSchema['description'] ?? null, 250 + ]; 251 + } 252 + 253 + return $properties; 254 + } 255 + 256 + /** 257 + * Map lexicon type to PHP type. 258 + */ 259 + protected function mapToPhpType(array $typeSchema): string 260 + { 261 + $type = $typeSchema['type'] ?? 'unknown'; 262 + 263 + return match ($type) { 264 + 'null' => 'null', 265 + 'boolean' => 'bool', 266 + 'integer' => 'int', 267 + 'string' => 'string', 268 + 'bytes' => 'string', 269 + 'array' => 'array', 270 + 'object' => 'array', 271 + 'unknown' => 'mixed', 272 + default => 'mixed', 273 + }; 274 + } 275 + 276 + /** 277 + * Get the file path for a generated class. 278 + */ 279 + protected function getFilePath(string $namespace, string $className): string 280 + { 281 + // Remove base namespace from full namespace 282 + $relativePath = str_replace($this->baseNamespace.'\\', '', $namespace); 283 + $relativePath = str_replace('\\', '/', $relativePath); 284 + 285 + return $this->outputDirectory.'/'.$relativePath.'/'.$className.'.php'; 286 + } 287 + 288 + /** 289 + * Validate generated code. 290 + */ 291 + public function validate(string $code): bool 292 + { 293 + // Basic syntax check using token_get_all 294 + $tokens = @token_get_all($code); 295 + 296 + return $tokens !== false; 297 + } 298 + 299 + /** 300 + * Get generated file metadata. 301 + */ 302 + public function getMetadata(string $nsid): array 303 + { 304 + $schema = $this->schemaLoader->load($nsid); 305 + $document = LexiconDocument::fromArray($schema); 306 + 307 + $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 308 + $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 309 + 310 + return [ 311 + 'nsid' => $nsid, 312 + 'namespace' => $namespace, 313 + 'className' => $className, 314 + 'fullyQualifiedName' => $namespace.'\\'.$className, 315 + 'type' => $document->isRecord() ? 'record' : 'object', 316 + ]; 317 + } 318 + 319 + /** 320 + * Set output options. 321 + */ 322 + public function setOptions(array $options): void 323 + { 324 + if (isset($options['baseNamespace'])) { 325 + $this->baseNamespace = rtrim($options['baseNamespace'], '\\'); 326 + $this->namespaceResolver = new NamespaceResolver($this->baseNamespace); 327 + } 328 + 329 + if (isset($options['outputDirectory'])) { 330 + $this->outputDirectory = rtrim($options['outputDirectory'], '/'); 331 + } 332 + } 333 + }
+114
src/Generator/FileWriter.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Exceptions\GenerationException; 6 + 7 + class FileWriter 8 + { 9 + /** 10 + * Whether to overwrite existing files. 11 + */ 12 + protected bool $overwrite = false; 13 + 14 + /** 15 + * Whether to create directories if they don't exist. 16 + */ 17 + protected bool $createDirectories = true; 18 + 19 + /** 20 + * Create a new FileWriter. 21 + */ 22 + public function __construct(bool $overwrite = false, bool $createDirectories = true) 23 + { 24 + $this->overwrite = $overwrite; 25 + $this->createDirectories = $createDirectories; 26 + } 27 + 28 + /** 29 + * Write content to a file. 30 + */ 31 + public function write(string $path, string $content): void 32 + { 33 + // Check if file exists and we're not allowed to overwrite 34 + if (file_exists($path) && ! $this->overwrite) { 35 + throw GenerationException::fileExists($path); 36 + } 37 + 38 + // Create directory if it doesn't exist 39 + $directory = dirname($path); 40 + 41 + if (! is_dir($directory)) { 42 + if (! $this->createDirectories) { 43 + throw GenerationException::directoryNotFound($directory); 44 + } 45 + 46 + if (! @mkdir($directory, 0755, true) && ! is_dir($directory)) { 47 + throw GenerationException::cannotCreateDirectory($directory); 48 + } 49 + } 50 + 51 + // Write file 52 + $result = @file_put_contents($path, $content); 53 + 54 + if ($result === false) { 55 + throw GenerationException::cannotWriteFile($path); 56 + } 57 + } 58 + 59 + /** 60 + * Check if file exists. 61 + */ 62 + public function exists(string $path): bool 63 + { 64 + return file_exists($path); 65 + } 66 + 67 + /** 68 + * Delete a file. 69 + */ 70 + public function delete(string $path): void 71 + { 72 + if (! file_exists($path)) { 73 + return; 74 + } 75 + 76 + if (! @unlink($path)) { 77 + throw GenerationException::cannotDeleteFile($path); 78 + } 79 + } 80 + 81 + /** 82 + * Read file content. 83 + */ 84 + public function read(string $path): string 85 + { 86 + if (! file_exists($path)) { 87 + throw GenerationException::fileNotFound($path); 88 + } 89 + 90 + $content = @file_get_contents($path); 91 + 92 + if ($content === false) { 93 + throw GenerationException::cannotReadFile($path); 94 + } 95 + 96 + return $content; 97 + } 98 + 99 + /** 100 + * Set whether to overwrite existing files. 101 + */ 102 + public function setOverwrite(bool $overwrite): void 103 + { 104 + $this->overwrite = $overwrite; 105 + } 106 + 107 + /** 108 + * Set whether to create directories. 109 + */ 110 + public function setCreateDirectories(bool $createDirectories): void 111 + { 112 + $this->createDirectories = $createDirectories; 113 + } 114 + }
+97
src/Generator/NamespaceResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Parser\Nsid; 6 + 7 + class NamespaceResolver 8 + { 9 + /** 10 + * Base namespace for generated classes. 11 + */ 12 + protected string $baseNamespace; 13 + 14 + /** 15 + * Create a new NamespaceResolver. 16 + */ 17 + public function __construct(string $baseNamespace = 'App\\Lexicon') 18 + { 19 + $this->baseNamespace = rtrim($baseNamespace, '\\'); 20 + } 21 + 22 + /** 23 + * Resolve NSID to PHP namespace. 24 + */ 25 + public function resolveNamespace(string $nsidString): string 26 + { 27 + $nsid = Nsid::parse($nsidString); 28 + 29 + // Convert authority to namespace parts (com.example.feed -> Com\Example\Feed) 30 + $parts = explode('.', $nsid->getAuthority()); 31 + $namespaceParts = array_map(fn ($part) => $this->toPascalCase($part), $parts); 32 + 33 + return $this->baseNamespace.'\\'.implode('\\', $namespaceParts); 34 + } 35 + 36 + /** 37 + * Resolve NSID to PHP class name. 38 + */ 39 + public function resolveClassName(string $nsidString, ?string $defName = null): string 40 + { 41 + $nsid = Nsid::parse($nsidString); 42 + 43 + // Use definition name if provided, otherwise use the name from NSID 44 + $name = $defName ?? $nsid->getName(); 45 + 46 + return $this->toPascalCase($name); 47 + } 48 + 49 + /** 50 + * Resolve full qualified class name. 51 + */ 52 + public function resolveFullyQualifiedName(string $nsidString, ?string $defName = null): string 53 + { 54 + $namespace = $this->resolveNamespace($nsidString); 55 + $className = $this->resolveClassName($nsidString, $defName); 56 + 57 + return $namespace.'\\'.$className; 58 + } 59 + 60 + /** 61 + * Convert string to PascalCase. 62 + */ 63 + protected function toPascalCase(string $string): string 64 + { 65 + // Replace dots, hyphens, and underscores with spaces 66 + $string = str_replace(['.', '-', '_'], ' ', $string); 67 + 68 + // Capitalize each word 69 + $string = ucwords($string); 70 + 71 + // Remove spaces 72 + return str_replace(' ', '', $string); 73 + } 74 + 75 + /** 76 + * Convert NSID to file path. 77 + */ 78 + public function resolveFilePath(string $nsidString, string $baseDirectory, ?string $defName = null): string 79 + { 80 + $namespace = $this->resolveNamespace($nsidString); 81 + $className = $this->resolveClassName($nsidString, $defName); 82 + 83 + // Remove base namespace from full namespace 84 + $relativePath = str_replace($this->baseNamespace.'\\', '', $namespace); 85 + $relativePath = str_replace('\\', '/', $relativePath); 86 + 87 + return rtrim($baseDirectory, '/').'/'.$relativePath.'/'.$className.'.php'; 88 + } 89 + 90 + /** 91 + * Get the base namespace. 92 + */ 93 + public function getBaseNamespace(): string 94 + { 95 + return $this->baseNamespace; 96 + } 97 + }
+226
src/Generator/TemplateRenderer.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Exceptions\GenerationException; 6 + 7 + class TemplateRenderer 8 + { 9 + /** 10 + * Templates directory. 11 + */ 12 + protected ?string $templatesDirectory = null; 13 + 14 + /** 15 + * In-memory templates. 16 + * 17 + * @var array<string, string> 18 + */ 19 + protected array $templates = []; 20 + 21 + /** 22 + * Create a new TemplateRenderer. 23 + */ 24 + public function __construct(?string $templatesDirectory = null) 25 + { 26 + $this->templatesDirectory = $templatesDirectory; 27 + $this->registerDefaultTemplates(); 28 + } 29 + 30 + /** 31 + * Render a template with given data. 32 + */ 33 + public function render(string $templateName, array $data): string 34 + { 35 + $template = $this->getTemplate($templateName); 36 + 37 + return $this->renderTemplate($template, $data); 38 + } 39 + 40 + /** 41 + * Get template content. 42 + */ 43 + protected function getTemplate(string $templateName): string 44 + { 45 + // Check in-memory templates first 46 + if (isset($this->templates[$templateName])) { 47 + return $this->templates[$templateName]; 48 + } 49 + 50 + // Check templates directory 51 + if ($this->templatesDirectory !== null) { 52 + $path = $this->templatesDirectory.'/'.$templateName.'.php.template'; 53 + 54 + if (file_exists($path)) { 55 + return file_get_contents($path); 56 + } 57 + } 58 + 59 + throw GenerationException::templateNotFound($templateName); 60 + } 61 + 62 + /** 63 + * Render template with data. 64 + */ 65 + protected function renderTemplate(string $template, array $data): string 66 + { 67 + // Simple variable replacement 68 + foreach ($data as $key => $value) { 69 + if (is_scalar($value) || $value === null) { 70 + $template = str_replace("{{{$key}}}", (string) $value, $template); 71 + } 72 + } 73 + 74 + // Handle property lists 75 + if (isset($data['properties']) && is_array($data['properties'])) { 76 + $template = $this->renderProperties($template, $data['properties']); 77 + } 78 + 79 + return $template; 80 + } 81 + 82 + /** 83 + * Render properties section. 84 + */ 85 + protected function renderProperties(string $template, array $properties): string 86 + { 87 + $propertiesCode = []; 88 + 89 + foreach ($properties as $prop) { 90 + $typeHint = $prop['phpType']; 91 + $nullable = ! ($prop['required'] ?? true); 92 + 93 + if ($nullable && $typeHint !== 'mixed') { 94 + $typeHint = '?'.$typeHint; 95 + } 96 + 97 + $docComment = ''; 98 + if ($prop['description'] ?? null) { 99 + $docComment = " /**\n * {$prop['description']}\n */\n"; 100 + } 101 + 102 + $propertiesCode[] = sprintf( 103 + "%s public readonly %s $%s;", 104 + $docComment, 105 + $typeHint, 106 + $prop['name'] 107 + ); 108 + } 109 + 110 + $propertiesString = implode("\n\n", $propertiesCode); 111 + 112 + return str_replace('{{properties}}', $propertiesString, $template); 113 + } 114 + 115 + /** 116 + * Register default templates. 117 + */ 118 + protected function registerDefaultTemplates(): void 119 + { 120 + $this->templates['record'] = <<<'PHP' 121 + <?php 122 + 123 + namespace {{namespace}}; 124 + 125 + /** 126 + * {{description}} 127 + * 128 + * NSID: {{nsid}} 129 + */ 130 + class {{className}} 131 + { 132 + {{properties}} 133 + 134 + /** 135 + * Create a new {{className}}. 136 + */ 137 + public function __construct( 138 + // Constructor parameters will be generated 139 + ) { 140 + // Property assignments will be generated 141 + } 142 + 143 + /** 144 + * Create from array data. 145 + */ 146 + public static function fromArray(array $data): self 147 + { 148 + return new self( 149 + // Assignments will be generated 150 + ); 151 + } 152 + 153 + /** 154 + * Convert to array. 155 + */ 156 + public function toArray(): array 157 + { 158 + return [ 159 + // Array conversion will be generated 160 + ]; 161 + } 162 + } 163 + 164 + PHP; 165 + 166 + $this->templates['object'] = <<<'PHP' 167 + <?php 168 + 169 + namespace {{namespace}}; 170 + 171 + /** 172 + * {{description}} 173 + */ 174 + class {{className}} 175 + { 176 + {{properties}} 177 + 178 + /** 179 + * Create a new {{className}}. 180 + */ 181 + public function __construct( 182 + // Constructor parameters will be generated 183 + ) { 184 + // Property assignments will be generated 185 + } 186 + 187 + /** 188 + * Create from array data. 189 + */ 190 + public static function fromArray(array $data): self 191 + { 192 + return new self( 193 + // Assignments will be generated 194 + ); 195 + } 196 + 197 + /** 198 + * Convert to array. 199 + */ 200 + public function toArray(): array 201 + { 202 + return [ 203 + // Array conversion will be generated 204 + ]; 205 + } 206 + } 207 + 208 + PHP; 209 + } 210 + 211 + /** 212 + * Register a custom template. 213 + */ 214 + public function registerTemplate(string $name, string $template): void 215 + { 216 + $this->templates[$name] = $template; 217 + } 218 + 219 + /** 220 + * Set templates directory. 221 + */ 222 + public function setTemplatesDirectory(string $directory): void 223 + { 224 + $this->templatesDirectory = $directory; 225 + } 226 + }
+157
tests/Unit/Generator/DTOGeneratorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Generator\DTOGenerator; 8 + use SocialDept\Schema\Parser\SchemaLoader; 9 + 10 + class DTOGeneratorTest extends TestCase 11 + { 12 + protected DTOGenerator $generator; 13 + 14 + protected string $tempDir; 15 + 16 + protected function setUp(): void 17 + { 18 + parent::setUp(); 19 + 20 + $fixturesPath = __DIR__.'/../../fixtures'; 21 + $loader = new SchemaLoader([$fixturesPath], false); 22 + 23 + $this->tempDir = sys_get_temp_dir().'/schema-gen-test-'.uniqid(); 24 + 25 + $this->generator = new DTOGenerator( 26 + schemaLoader: $loader, 27 + baseNamespace: 'Test\\Generated', 28 + outputDirectory: $this->tempDir 29 + ); 30 + } 31 + 32 + protected function tearDown(): void 33 + { 34 + if (is_dir($this->tempDir)) { 35 + $this->deleteDirectory($this->tempDir); 36 + } 37 + 38 + parent::tearDown(); 39 + } 40 + 41 + protected function deleteDirectory(string $dir): void 42 + { 43 + if (! is_dir($dir)) { 44 + return; 45 + } 46 + 47 + $items = scandir($dir); 48 + 49 + foreach ($items as $item) { 50 + if ($item === '.' || $item === '..') { 51 + continue; 52 + } 53 + 54 + $path = $dir.'/'.$item; 55 + 56 + if (is_dir($path)) { 57 + $this->deleteDirectory($path); 58 + } else { 59 + unlink($path); 60 + } 61 + } 62 + 63 + rmdir($dir); 64 + } 65 + 66 + public function test_it_generates_from_nsid(): void 67 + { 68 + $files = $this->generator->generateByNsid('app.bsky.feed.post', ['dryRun' => true]); 69 + 70 + $this->assertNotEmpty($files); 71 + $this->assertIsArray($files); 72 + } 73 + 74 + public function test_it_generates_code_from_document(): void 75 + { 76 + $document = LexiconDocument::fromArray([ 77 + 'lexicon' => 1, 78 + 'id' => 'test.example.record', 79 + 'defs' => [ 80 + 'main' => [ 81 + 'type' => 'record', 82 + 'record' => [ 83 + 'type' => 'object', 84 + 'required' => ['name'], 85 + 'properties' => [ 86 + 'name' => ['type' => 'string'], 87 + ], 88 + ], 89 + ], 90 + ], 91 + ]); 92 + 93 + $code = $this->generator->generate($document); 94 + 95 + $this->assertIsString($code); 96 + $this->assertStringContainsString('class Record', $code); 97 + } 98 + 99 + public function test_it_previews_generated_code(): void 100 + { 101 + $document = LexiconDocument::fromArray([ 102 + 'lexicon' => 1, 103 + 'id' => 'test.example.record', 104 + 'defs' => [ 105 + 'main' => [ 106 + 'type' => 'record', 107 + 'record' => [ 108 + 'type' => 'object', 109 + 'required' => ['name'], 110 + 'properties' => [ 111 + 'name' => ['type' => 'string'], 112 + ], 113 + ], 114 + ], 115 + ], 116 + ]); 117 + 118 + $code = $this->generator->preview($document); 119 + 120 + $this->assertIsString($code); 121 + $this->assertStringContainsString('<?php', $code); 122 + } 123 + 124 + public function test_it_gets_metadata(): void 125 + { 126 + $metadata = $this->generator->getMetadata('app.bsky.feed.post'); 127 + 128 + $this->assertArrayHasKey('nsid', $metadata); 129 + $this->assertArrayHasKey('namespace', $metadata); 130 + $this->assertArrayHasKey('className', $metadata); 131 + $this->assertArrayHasKey('fullyQualifiedName', $metadata); 132 + $this->assertArrayHasKey('type', $metadata); 133 + 134 + $this->assertSame('app.bsky.feed.post', $metadata['nsid']); 135 + $this->assertSame('Test\\Generated\\App\\Bsky\\Feed', $metadata['namespace']); 136 + $this->assertSame('Post', $metadata['className']); 137 + } 138 + 139 + public function test_it_validates_generated_code(): void 140 + { 141 + $validCode = '<?php class Test {}'; 142 + 143 + $this->assertTrue($this->generator->validate($validCode)); 144 + } 145 + 146 + public function test_it_sets_options(): void 147 + { 148 + $this->generator->setOptions([ 149 + 'baseNamespace' => 'Custom\\Namespace', 150 + 'outputDirectory' => '/custom/path', 151 + ]); 152 + 153 + $metadata = $this->generator->getMetadata('app.bsky.feed.post'); 154 + 155 + $this->assertStringStartsWith('Custom\\Namespace', $metadata['namespace']); 156 + } 157 + }
+159
tests/Unit/Generator/FileWriterTest.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\FileWriter; 8 + 9 + class FileWriterTest extends TestCase 10 + { 11 + protected string $tempDir; 12 + 13 + protected FileWriter $writer; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->tempDir = sys_get_temp_dir().'/schema-test-'.uniqid(); 20 + mkdir($this->tempDir, 0755, true); 21 + 22 + $this->writer = new FileWriter(overwrite: true, createDirectories: true); 23 + } 24 + 25 + protected function tearDown(): void 26 + { 27 + if (is_dir($this->tempDir)) { 28 + $this->deleteDirectory($this->tempDir); 29 + } 30 + 31 + parent::tearDown(); 32 + } 33 + 34 + protected function deleteDirectory(string $dir): void 35 + { 36 + if (! is_dir($dir)) { 37 + return; 38 + } 39 + 40 + $items = scandir($dir); 41 + 42 + foreach ($items as $item) { 43 + if ($item === '.' || $item === '..') { 44 + continue; 45 + } 46 + 47 + $path = $dir.'/'.$item; 48 + 49 + if (is_dir($path)) { 50 + $this->deleteDirectory($path); 51 + } else { 52 + unlink($path); 53 + } 54 + } 55 + 56 + rmdir($dir); 57 + } 58 + 59 + public function test_it_writes_file(): void 60 + { 61 + $path = $this->tempDir.'/test.txt'; 62 + $content = 'Hello, World!'; 63 + 64 + $this->writer->write($path, $content); 65 + 66 + $this->assertFileExists($path); 67 + $this->assertSame($content, file_get_contents($path)); 68 + } 69 + 70 + public function test_it_creates_directories(): void 71 + { 72 + $path = $this->tempDir.'/nested/directory/test.txt'; 73 + $content = 'Test content'; 74 + 75 + $this->writer->write($path, $content); 76 + 77 + $this->assertFileExists($path); 78 + $this->assertDirectoryExists(dirname($path)); 79 + } 80 + 81 + public function test_it_overwrites_existing_file_when_enabled(): void 82 + { 83 + $path = $this->tempDir.'/test.txt'; 84 + 85 + $this->writer->write($path, 'Original'); 86 + $this->writer->write($path, 'Updated'); 87 + 88 + $this->assertSame('Updated', file_get_contents($path)); 89 + } 90 + 91 + public function test_it_throws_when_file_exists_and_overwrite_disabled(): void 92 + { 93 + $writer = new FileWriter(overwrite: false); 94 + $path = $this->tempDir.'/test.txt'; 95 + 96 + file_put_contents($path, 'Existing content'); 97 + 98 + $this->expectException(GenerationException::class); 99 + $this->expectExceptionMessage('File already exists'); 100 + 101 + $writer->write($path, 'New content'); 102 + } 103 + 104 + public function test_it_checks_if_file_exists(): void 105 + { 106 + $path = $this->tempDir.'/test.txt'; 107 + 108 + $this->assertFalse($this->writer->exists($path)); 109 + 110 + file_put_contents($path, 'Content'); 111 + 112 + $this->assertTrue($this->writer->exists($path)); 113 + } 114 + 115 + public function test_it_deletes_file(): void 116 + { 117 + $path = $this->tempDir.'/test.txt'; 118 + 119 + file_put_contents($path, 'Content'); 120 + $this->assertFileExists($path); 121 + 122 + $this->writer->delete($path); 123 + 124 + $this->assertFileDoesNotExist($path); 125 + } 126 + 127 + public function test_it_reads_file(): void 128 + { 129 + $path = $this->tempDir.'/test.txt'; 130 + $content = 'Test content'; 131 + 132 + file_put_contents($path, $content); 133 + 134 + $this->assertSame($content, $this->writer->read($path)); 135 + } 136 + 137 + public function test_it_throws_when_reading_nonexistent_file(): void 138 + { 139 + $path = $this->tempDir.'/nonexistent.txt'; 140 + 141 + $this->expectException(GenerationException::class); 142 + $this->expectExceptionMessage('File not found'); 143 + 144 + $this->writer->read($path); 145 + } 146 + 147 + public function test_it_sets_overwrite_option(): void 148 + { 149 + $writer = new FileWriter(overwrite: false); 150 + $path = $this->tempDir.'/test.txt'; 151 + 152 + file_put_contents($path, 'Existing'); 153 + 154 + $writer->setOverwrite(true); 155 + $writer->write($path, 'Updated'); 156 + 157 + $this->assertSame('Updated', file_get_contents($path)); 158 + } 159 + }
+81
tests/Unit/Generator/NamespaceResolverTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Generator\NamespaceResolver; 7 + 8 + class NamespaceResolverTest extends TestCase 9 + { 10 + protected NamespaceResolver $resolver; 11 + 12 + protected function setUp(): void 13 + { 14 + parent::setUp(); 15 + 16 + $this->resolver = new NamespaceResolver('App\\Lexicon'); 17 + } 18 + 19 + public function test_it_resolves_namespace_from_nsid(): void 20 + { 21 + $namespace = $this->resolver->resolveNamespace('app.bsky.feed.post'); 22 + 23 + $this->assertSame('App\\Lexicon\\App\\Bsky\\Feed', $namespace); 24 + } 25 + 26 + public function test_it_resolves_class_name_from_nsid(): void 27 + { 28 + $className = $this->resolver->resolveClassName('app.bsky.feed.post'); 29 + 30 + $this->assertSame('Post', $className); 31 + } 32 + 33 + public function test_it_resolves_class_name_with_definition(): void 34 + { 35 + $className = $this->resolver->resolveClassName('app.bsky.feed.post', 'replyRef'); 36 + 37 + $this->assertSame('ReplyRef', $className); 38 + } 39 + 40 + public function test_it_resolves_fully_qualified_name(): void 41 + { 42 + $fqn = $this->resolver->resolveFullyQualifiedName('app.bsky.feed.post'); 43 + 44 + $this->assertSame('App\\Lexicon\\App\\Bsky\\Feed\\Post', $fqn); 45 + } 46 + 47 + public function test_it_resolves_fully_qualified_name_with_definition(): void 48 + { 49 + $fqn = $this->resolver->resolveFullyQualifiedName('app.bsky.feed.post', 'replyRef'); 50 + 51 + $this->assertSame('App\\Lexicon\\App\\Bsky\\Feed\\ReplyRef', $fqn); 52 + } 53 + 54 + public function test_it_resolves_file_path(): void 55 + { 56 + $path = $this->resolver->resolveFilePath('app.bsky.feed.post', '/var/www/app'); 57 + 58 + $this->assertSame('/var/www/app/App/Bsky/Feed/Post.php', $path); 59 + } 60 + 61 + public function test_it_handles_hyphens_in_nsid(): void 62 + { 63 + $className = $this->resolver->resolveClassName('app.bsky.feed.feed-view'); 64 + 65 + $this->assertSame('FeedView', $className); 66 + } 67 + 68 + public function test_it_gets_base_namespace(): void 69 + { 70 + $this->assertSame('App\\Lexicon', $this->resolver->getBaseNamespace()); 71 + } 72 + 73 + public function test_it_handles_custom_base_namespace(): void 74 + { 75 + $resolver = new NamespaceResolver('Custom\\Namespace'); 76 + 77 + $namespace = $resolver->resolveNamespace('app.bsky.feed.post'); 78 + 79 + $this->assertSame('Custom\\Namespace\\App\\Bsky\\Feed', $namespace); 80 + } 81 + }
+144
tests/Unit/Generator/TemplateRendererTest.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\TemplateRenderer; 8 + 9 + class TemplateRendererTest extends TestCase 10 + { 11 + protected TemplateRenderer $renderer; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $this->renderer = new TemplateRenderer(); 18 + } 19 + 20 + public function test_it_renders_template_with_simple_variables(): void 21 + { 22 + $template = 'Hello, {{name}}!'; 23 + $this->renderer->registerTemplate('greeting', $template); 24 + 25 + $result = $this->renderer->render('greeting', ['name' => 'World']); 26 + 27 + $this->assertSame('Hello, World!', $result); 28 + } 29 + 30 + public function test_it_renders_record_template(): void 31 + { 32 + $result = $this->renderer->render('record', [ 33 + 'namespace' => 'App\\Lexicon\\App\\Bsky\\Feed', 34 + 'className' => 'Post', 35 + 'nsid' => 'app.bsky.feed.post', 36 + 'description' => 'A post record', 37 + 'properties' => [ 38 + [ 39 + 'name' => 'text', 40 + 'type' => 'string', 41 + 'phpType' => 'string', 42 + 'required' => true, 43 + 'description' => 'Post text content', 44 + ], 45 + ], 46 + ]); 47 + 48 + $this->assertStringContainsString('namespace App\\Lexicon\\App\\Bsky\\Feed', $result); 49 + $this->assertStringContainsString('class Post', $result); 50 + $this->assertStringContainsString('public readonly string $text', $result); 51 + $this->assertStringContainsString('Post text content', $result); 52 + } 53 + 54 + public function test_it_renders_object_template(): void 55 + { 56 + $result = $this->renderer->render('object', [ 57 + 'namespace' => 'App\\Lexicon\\App\\Bsky\\Feed', 58 + 'className' => 'ReplyRef', 59 + 'description' => 'A reply reference', 60 + 'properties' => [ 61 + [ 62 + 'name' => 'parent', 63 + 'type' => 'string', 64 + 'phpType' => 'string', 65 + 'required' => true, 66 + 'description' => null, 67 + ], 68 + ], 69 + ]); 70 + 71 + $this->assertStringContainsString('namespace App\\Lexicon\\App\\Bsky\\Feed', $result); 72 + $this->assertStringContainsString('class ReplyRef', $result); 73 + $this->assertStringContainsString('public readonly string $parent', $result); 74 + } 75 + 76 + public function test_it_handles_nullable_properties(): void 77 + { 78 + $result = $this->renderer->render('record', [ 79 + 'namespace' => 'App\\Lexicon\\Test', 80 + 'className' => 'Test', 81 + 'nsid' => 'test.example.test', 82 + 'description' => 'Test', 83 + 'properties' => [ 84 + [ 85 + 'name' => 'optional', 86 + 'type' => 'string', 87 + 'phpType' => 'string', 88 + 'required' => false, 89 + 'description' => null, 90 + ], 91 + ], 92 + ]); 93 + 94 + $this->assertStringContainsString('?string $optional', $result); 95 + } 96 + 97 + public function test_it_registers_custom_template(): void 98 + { 99 + $this->renderer->registerTemplate('custom', 'Custom: {{value}}'); 100 + 101 + $result = $this->renderer->render('custom', ['value' => 'Test']); 102 + 103 + $this->assertSame('Custom: Test', $result); 104 + } 105 + 106 + public function test_it_throws_on_unknown_template(): void 107 + { 108 + $this->expectException(GenerationException::class); 109 + $this->expectExceptionMessage('Template not found: nonexistent'); 110 + 111 + $this->renderer->render('nonexistent', []); 112 + } 113 + 114 + public function test_it_renders_multiple_properties(): void 115 + { 116 + $result = $this->renderer->render('record', [ 117 + 'namespace' => 'App\\Lexicon\\Test', 118 + 'className' => 'Test', 119 + 'nsid' => 'test.example.test', 120 + 'description' => 'Test', 121 + 'properties' => [ 122 + [ 123 + 'name' => 'field1', 124 + 'type' => 'string', 125 + 'phpType' => 'string', 126 + 'required' => true, 127 + 'description' => 'First field', 128 + ], 129 + [ 130 + 'name' => 'field2', 131 + 'type' => 'integer', 132 + 'phpType' => 'int', 133 + 'required' => false, 134 + 'description' => 'Second field', 135 + ], 136 + ], 137 + ]); 138 + 139 + $this->assertStringContainsString('public readonly string $field1', $result); 140 + $this->assertStringContainsString('public readonly ?int $field2', $result); 141 + $this->assertStringContainsString('First field', $result); 142 + $this->assertStringContainsString('Second field', $result); 143 + } 144 + }