Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Implement naming conventions and type mapper

Changed files
+927
src
tests
+171
src/Generator/NamingConverter.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Parser\Nsid; 6 + 7 + class NamingConverter 8 + { 9 + /** 10 + * Base namespace for generated classes. 11 + */ 12 + protected string $baseNamespace; 13 + 14 + /** 15 + * Create a new NamingConverter. 16 + */ 17 + public function __construct(string $baseNamespace = 'App\\Lexicon') 18 + { 19 + $this->baseNamespace = rtrim($baseNamespace, '\\'); 20 + } 21 + 22 + /** 23 + * Convert NSID to fully qualified class name. 24 + */ 25 + public function nsidToClassName(string $nsidString): string 26 + { 27 + $nsid = Nsid::parse($nsidString); 28 + $namespace = $this->nsidToNamespace($nsidString); 29 + $className = $this->toClassName($nsid->getName()); 30 + 31 + return $namespace.'\\'.$className; 32 + } 33 + 34 + /** 35 + * Convert NSID to namespace. 36 + */ 37 + public function nsidToNamespace(string $nsidString): string 38 + { 39 + $nsid = Nsid::parse($nsidString); 40 + 41 + // Split authority into parts (e.g., "app.bsky" -> ["app", "bsky"]) 42 + $authorityParts = array_reverse(explode('.', $nsid->getAuthority())); 43 + 44 + // Convert each part to PascalCase 45 + $namespaceParts = array_map( 46 + fn ($part) => $this->toPascalCase($part), 47 + $authorityParts 48 + ); 49 + 50 + return $this->baseNamespace.'\\'.implode('\\', $namespaceParts); 51 + } 52 + 53 + /** 54 + * Get class name from NSID name part. 55 + */ 56 + public function toClassName(string $name): string 57 + { 58 + // Split on dots (e.g., "feed.post" -> "FeedPost") 59 + $parts = explode('.', $name); 60 + 61 + $className = implode('', array_map( 62 + fn ($part) => $this->toPascalCase($part), 63 + $parts 64 + )); 65 + 66 + return $className; 67 + } 68 + 69 + /** 70 + * Convert to PascalCase. 71 + */ 72 + public function toPascalCase(string $string): string 73 + { 74 + // Split on hyphens, underscores, or existing camelCase boundaries 75 + $words = preg_split('/[-_\s]+|(?=[A-Z])/', $string); 76 + 77 + if ($words === false) { 78 + $words = [$string]; 79 + } 80 + 81 + // Capitalize first letter of each word 82 + $words = array_map(fn ($word) => ucfirst(strtolower($word)), $words); 83 + 84 + return implode('', $words); 85 + } 86 + 87 + /** 88 + * Convert to camelCase. 89 + */ 90 + public function toCamelCase(string $string): string 91 + { 92 + $pascalCase = $this->toPascalCase($string); 93 + 94 + return lcfirst($pascalCase); 95 + } 96 + 97 + /** 98 + * Convert to snake_case. 99 + */ 100 + public function toSnakeCase(string $string): string 101 + { 102 + // Insert underscore before capital letters, except at the start 103 + $snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $string); 104 + 105 + if ($snake === null) { 106 + return strtolower($string); 107 + } 108 + 109 + return strtolower($snake); 110 + } 111 + 112 + /** 113 + * Convert to kebab-case. 114 + */ 115 + public function toKebabCase(string $string): string 116 + { 117 + return str_replace('_', '-', $this->toSnakeCase($string)); 118 + } 119 + 120 + /** 121 + * Pluralize a word (simple English rules). 122 + */ 123 + public function pluralize(string $word): string 124 + { 125 + if (str_ends_with($word, 'y')) { 126 + return substr($word, 0, -1).'ies'; 127 + } 128 + 129 + if (str_ends_with($word, 's') || str_ends_with($word, 'x') || str_ends_with($word, 'ch') || str_ends_with($word, 'sh')) { 130 + return $word.'es'; 131 + } 132 + 133 + return $word.'s'; 134 + } 135 + 136 + /** 137 + * Singularize a word (simple English rules). 138 + */ 139 + public function singularize(string $word): string 140 + { 141 + if (str_ends_with($word, 'ies')) { 142 + return substr($word, 0, -3).'y'; 143 + } 144 + 145 + if (str_ends_with($word, 'ses') || str_ends_with($word, 'xes') || str_ends_with($word, 'ches') || str_ends_with($word, 'shes')) { 146 + return substr($word, 0, -2); 147 + } 148 + 149 + if (str_ends_with($word, 's') && ! str_ends_with($word, 'ss')) { 150 + return substr($word, 0, -1); 151 + } 152 + 153 + return $word; 154 + } 155 + 156 + /** 157 + * Get the base namespace. 158 + */ 159 + public function getBaseNamespace(): string 160 + { 161 + return $this->baseNamespace; 162 + } 163 + 164 + /** 165 + * Set the base namespace. 166 + */ 167 + public function setBaseNamespace(string $namespace): void 168 + { 169 + $this->baseNamespace = rtrim($namespace, '\\'); 170 + } 171 + }
+296
src/Generator/TypeMapper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + class TypeMapper 6 + { 7 + /** 8 + * Naming converter instance. 9 + */ 10 + protected NamingConverter $naming; 11 + 12 + /** 13 + * Create a new TypeMapper. 14 + */ 15 + public function __construct(?NamingConverter $naming = null) 16 + { 17 + $this->naming = $naming ?? new NamingConverter; 18 + } 19 + 20 + /** 21 + * Map lexicon type to PHP type. 22 + * 23 + * @param array<string, mixed> $definition 24 + */ 25 + public function toPhpType(array $definition, bool $nullable = false): string 26 + { 27 + $type = $definition['type'] ?? 'unknown'; 28 + 29 + $phpType = match ($type) { 30 + 'string' => 'string', 31 + 'integer' => 'int', 32 + 'boolean' => 'bool', 33 + 'number' => 'float', 34 + 'array' => $this->mapArrayType($definition), 35 + 'object' => $this->mapObjectType($definition), 36 + 'blob' => '\\SocialDept\\Schema\\Data\\BlobReference', 37 + 'bytes' => 'string', 38 + 'cid-link' => 'string', 39 + 'unknown' => 'mixed', 40 + 'ref' => $this->mapRefType($definition), 41 + 'union' => $this->mapUnionType($definition), 42 + default => 'mixed', 43 + }; 44 + 45 + if ($nullable && $phpType !== 'mixed') { 46 + return '?'.$phpType; 47 + } 48 + 49 + return $phpType; 50 + } 51 + 52 + /** 53 + * Map lexicon type to PHPDoc type. 54 + * 55 + * @param array<string, mixed> $definition 56 + */ 57 + public function toPhpDocType(array $definition, bool $nullable = false): string 58 + { 59 + $type = $definition['type'] ?? 'unknown'; 60 + 61 + $docType = match ($type) { 62 + 'string' => 'string', 63 + 'integer' => 'int', 64 + 'boolean' => 'bool', 65 + 'number' => 'float', 66 + 'array' => $this->mapArrayDocType($definition), 67 + 'object' => $this->mapObjectDocType($definition), 68 + 'blob' => '\\SocialDept\\Schema\\Data\\BlobReference', 69 + 'bytes' => 'string', 70 + 'cid-link' => 'string', 71 + 'unknown' => 'mixed', 72 + 'ref' => $this->mapRefDocType($definition), 73 + 'union' => $this->mapUnionDocType($definition), 74 + default => 'mixed', 75 + }; 76 + 77 + if ($nullable && $docType !== 'mixed') { 78 + return $docType.'|null'; 79 + } 80 + 81 + return $docType; 82 + } 83 + 84 + /** 85 + * Map array type. 86 + * 87 + * @param array<string, mixed> $definition 88 + */ 89 + protected function mapArrayType(array $definition): string 90 + { 91 + return 'array'; 92 + } 93 + 94 + /** 95 + * Map array type for PHPDoc. 96 + * 97 + * @param array<string, mixed> $definition 98 + */ 99 + protected function mapArrayDocType(array $definition): string 100 + { 101 + if (! isset($definition['items'])) { 102 + return 'array'; 103 + } 104 + 105 + $itemType = $this->toPhpDocType($definition['items']); 106 + 107 + return "array<{$itemType}>"; 108 + } 109 + 110 + /** 111 + * Map object type. 112 + * 113 + * @param array<string, mixed> $definition 114 + */ 115 + protected function mapObjectType(array $definition): string 116 + { 117 + return 'array'; 118 + } 119 + 120 + /** 121 + * Map object type for PHPDoc. 122 + * 123 + * @param array<string, mixed> $definition 124 + */ 125 + protected function mapObjectDocType(array $definition): string 126 + { 127 + if (! isset($definition['properties'])) { 128 + return 'array'; 129 + } 130 + 131 + // Build array shape annotation 132 + $properties = []; 133 + foreach ($definition['properties'] as $key => $propDef) { 134 + $propType = $this->toPhpDocType($propDef); 135 + $properties[] = "{$key}: {$propType}"; 136 + } 137 + 138 + if (empty($properties)) { 139 + return 'array'; 140 + } 141 + 142 + return 'array{'.implode(', ', $properties).'}'; 143 + } 144 + 145 + /** 146 + * Map reference type. 147 + * 148 + * @param array<string, mixed> $definition 149 + */ 150 + protected function mapRefType(array $definition): string 151 + { 152 + if (! isset($definition['ref'])) { 153 + return 'mixed'; 154 + } 155 + 156 + // Convert NSID reference to class name 157 + return '\\'.$this->naming->nsidToClassName($definition['ref']); 158 + } 159 + 160 + /** 161 + * Map reference type for PHPDoc. 162 + * 163 + * @param array<string, mixed> $definition 164 + */ 165 + protected function mapRefDocType(array $definition): string 166 + { 167 + return $this->mapRefType($definition); 168 + } 169 + 170 + /** 171 + * Map union type. 172 + * 173 + * @param array<string, mixed> $definition 174 + */ 175 + protected function mapUnionType(array $definition): string 176 + { 177 + // For runtime type hints, unions of different types must be 'mixed' 178 + return 'mixed'; 179 + } 180 + 181 + /** 182 + * Map union type for PHPDoc. 183 + * 184 + * @param array<string, mixed> $definition 185 + */ 186 + protected function mapUnionDocType(array $definition): string 187 + { 188 + if (! isset($definition['refs'])) { 189 + return 'mixed'; 190 + } 191 + 192 + $types = array_map( 193 + fn ($ref) => '\\'.$this->naming->nsidToClassName($ref), 194 + $definition['refs'] 195 + ); 196 + 197 + return implode('|', $types); 198 + } 199 + 200 + /** 201 + * Check if type is nullable based on definition. 202 + * 203 + * @param array<string, mixed> $definition 204 + */ 205 + public function isNullable(array $definition, array $required = []): bool 206 + { 207 + // Check if explicitly marked as required 208 + if (isset($definition['required']) && $definition['required'] === true) { 209 + return false; 210 + } 211 + 212 + // Check if in required array 213 + if (! empty($required)) { 214 + return false; 215 + } 216 + 217 + // Default to nullable for optional fields 218 + return true; 219 + } 220 + 221 + /** 222 + * Get default value for a type. 223 + * 224 + * @param array<string, mixed> $definition 225 + */ 226 + public function getDefaultValue(array $definition): ?string 227 + { 228 + if (! array_key_exists('default', $definition)) { 229 + return null; 230 + } 231 + 232 + $default = $definition['default']; 233 + 234 + if ($default === null) { 235 + return 'null'; 236 + } 237 + 238 + if (is_string($default)) { 239 + return "'".addslashes($default)."'"; 240 + } 241 + 242 + if (is_bool($default)) { 243 + return $default ? 'true' : 'false'; 244 + } 245 + 246 + if (is_numeric($default)) { 247 + return (string) $default; 248 + } 249 + 250 + if (is_array($default)) { 251 + return '[]'; 252 + } 253 + 254 + return null; 255 + } 256 + 257 + /** 258 + * Check if type needs use statement. 259 + * 260 + * @param array<string, mixed> $definition 261 + */ 262 + public function needsUseStatement(array $definition): bool 263 + { 264 + $type = $definition['type'] ?? 'unknown'; 265 + 266 + return in_array($type, ['ref', 'blob']); 267 + } 268 + 269 + /** 270 + * Get use statements for type. 271 + * 272 + * @param array<string, mixed> $definition 273 + * @return array<string> 274 + */ 275 + public function getUseStatements(array $definition): array 276 + { 277 + $type = $definition['type'] ?? 'unknown'; 278 + 279 + if ($type === 'blob') { 280 + return ['SocialDept\\Schema\\Data\\BlobReference']; 281 + } 282 + 283 + if ($type === 'ref' && isset($definition['ref'])) { 284 + return [$this->naming->nsidToClassName($definition['ref'])]; 285 + } 286 + 287 + if ($type === 'union' && isset($definition['refs'])) { 288 + return array_map( 289 + fn ($ref) => $this->naming->nsidToClassName($ref), 290 + $definition['refs'] 291 + ); 292 + } 293 + 294 + return []; 295 + } 296 + }
+171
tests/Unit/Generator/NamingConverterTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Generator\NamingConverter; 7 + 8 + class NamingConverterTest extends TestCase 9 + { 10 + protected NamingConverter $converter; 11 + 12 + protected function setUp(): void 13 + { 14 + parent::setUp(); 15 + 16 + $this->converter = new NamingConverter('App\\Lexicon'); 17 + } 18 + 19 + public function test_it_converts_nsid_to_class_name(): void 20 + { 21 + $className = $this->converter->nsidToClassName('app.bsky.feed.post'); 22 + 23 + $this->assertSame('App\\Lexicon\\Feed\\Bsky\\App\\Post', $className); 24 + } 25 + 26 + public function test_it_converts_nsid_to_namespace(): void 27 + { 28 + $namespace = $this->converter->nsidToNamespace('app.bsky.feed.post'); 29 + 30 + $this->assertSame('App\\Lexicon\\Feed\\Bsky\\App', $namespace); 31 + } 32 + 33 + public function test_it_handles_multi_part_names(): void 34 + { 35 + $className = $this->converter->toClassName('feed.post'); 36 + 37 + $this->assertSame('FeedPost', $className); 38 + } 39 + 40 + public function test_it_handles_single_part_names(): void 41 + { 42 + $className = $this->converter->toClassName('post'); 43 + 44 + $this->assertSame('Post', $className); 45 + } 46 + 47 + public function test_it_converts_to_pascal_case(): void 48 + { 49 + $this->assertSame('HelloWorld', $this->converter->toPascalCase('hello_world')); 50 + $this->assertSame('HelloWorld', $this->converter->toPascalCase('hello-world')); 51 + $this->assertSame('HelloWorld', $this->converter->toPascalCase('hello world')); 52 + $this->assertSame('HelloWorld', $this->converter->toPascalCase('helloWorld')); 53 + $this->assertSame('Foo', $this->converter->toPascalCase('foo')); 54 + } 55 + 56 + public function test_it_converts_to_camel_case(): void 57 + { 58 + $this->assertSame('helloWorld', $this->converter->toCamelCase('hello_world')); 59 + $this->assertSame('helloWorld', $this->converter->toCamelCase('hello-world')); 60 + $this->assertSame('helloWorld', $this->converter->toCamelCase('hello world')); 61 + $this->assertSame('helloWorld', $this->converter->toCamelCase('HelloWorld')); 62 + $this->assertSame('foo', $this->converter->toCamelCase('foo')); 63 + } 64 + 65 + public function test_it_converts_to_snake_case(): void 66 + { 67 + $this->assertSame('hello_world', $this->converter->toSnakeCase('HelloWorld')); 68 + $this->assertSame('hello_world', $this->converter->toSnakeCase('helloWorld')); 69 + $this->assertSame('foo', $this->converter->toSnakeCase('foo')); 70 + $this->assertSame('foo_bar_baz', $this->converter->toSnakeCase('FooBarBaz')); 71 + } 72 + 73 + public function test_it_converts_to_kebab_case(): void 74 + { 75 + $this->assertSame('hello-world', $this->converter->toKebabCase('HelloWorld')); 76 + $this->assertSame('hello-world', $this->converter->toKebabCase('helloWorld')); 77 + $this->assertSame('foo', $this->converter->toKebabCase('foo')); 78 + $this->assertSame('foo-bar-baz', $this->converter->toKebabCase('FooBarBaz')); 79 + } 80 + 81 + public function test_it_pluralizes_words(): void 82 + { 83 + $this->assertSame('posts', $this->converter->pluralize('post')); 84 + $this->assertSame('categories', $this->converter->pluralize('category')); 85 + $this->assertSame('boxes', $this->converter->pluralize('box')); 86 + $this->assertSame('churches', $this->converter->pluralize('church')); 87 + $this->assertSame('bushes', $this->converter->pluralize('bush')); 88 + $this->assertSame('postses', $this->converter->pluralize('posts')); // 'posts' ends with 's', gets 'es' 89 + } 90 + 91 + public function test_it_singularizes_words(): void 92 + { 93 + $this->assertSame('post', $this->converter->singularize('posts')); 94 + $this->assertSame('category', $this->converter->singularize('categories')); 95 + $this->assertSame('box', $this->converter->singularize('boxes')); 96 + $this->assertSame('church', $this->converter->singularize('churches')); 97 + $this->assertSame('bush', $this->converter->singularize('bushes')); 98 + $this->assertSame('post', $this->converter->singularize('post')); // Already singular 99 + } 100 + 101 + public function test_it_handles_complex_nsid(): void 102 + { 103 + $className = $this->converter->nsidToClassName('com.atproto.repo.getRecord'); 104 + 105 + $this->assertSame('App\\Lexicon\\Repo\\Atproto\\Com\\GetRecord', $className); 106 + } 107 + 108 + public function test_it_gets_base_namespace(): void 109 + { 110 + $namespace = $this->converter->getBaseNamespace(); 111 + 112 + $this->assertSame('App\\Lexicon', $namespace); 113 + } 114 + 115 + public function test_it_sets_base_namespace(): void 116 + { 117 + $this->converter->setBaseNamespace('Custom\\Namespace'); 118 + 119 + $this->assertSame('Custom\\Namespace', $this->converter->getBaseNamespace()); 120 + } 121 + 122 + public function test_it_strips_trailing_slash_from_namespace(): void 123 + { 124 + $converter = new NamingConverter('App\\Lexicon\\'); 125 + 126 + $this->assertSame('App\\Lexicon', $converter->getBaseNamespace()); 127 + } 128 + 129 + public function test_it_handles_three_part_authority(): void 130 + { 131 + $className = $this->converter->nsidToClassName('com.example.api.getUser'); 132 + 133 + $this->assertSame('App\\Lexicon\\Api\\Example\\Com\\GetUser', $className); 134 + } 135 + 136 + public function test_it_handles_hyphens_in_names(): void 137 + { 138 + $className = $this->converter->toClassName('my-post'); 139 + 140 + $this->assertSame('MyPost', $className); 141 + } 142 + 143 + public function test_it_handles_underscores_in_names(): void 144 + { 145 + $className = $this->converter->toClassName('my_post'); 146 + 147 + $this->assertSame('MyPost', $className); 148 + } 149 + 150 + public function test_namespace_parts_are_reversed(): void 151 + { 152 + // app.bsky.feed should become Feed\Bsky\App (reversed) 153 + $namespace = $this->converter->nsidToNamespace('app.bsky.feed.post'); 154 + 155 + $this->assertStringContainsString('Feed\\Bsky\\App', $namespace); 156 + } 157 + 158 + public function test_it_handles_single_letter_parts(): void 159 + { 160 + $pascalCase = $this->converter->toPascalCase('a'); 161 + 162 + $this->assertSame('A', $pascalCase); 163 + } 164 + 165 + public function test_it_handles_numbers_in_names(): void 166 + { 167 + $className = $this->converter->toClassName('post2'); 168 + 169 + $this->assertSame('Post2', $className); 170 + } 171 + }
+289
tests/Unit/Generator/TypeMapperTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Generator\NamingConverter; 7 + use SocialDept\Schema\Generator\TypeMapper; 8 + 9 + class TypeMapperTest extends TestCase 10 + { 11 + protected TypeMapper $mapper; 12 + 13 + protected function setUp(): void 14 + { 15 + parent::setUp(); 16 + 17 + $naming = new NamingConverter('App\\Lexicon'); 18 + $this->mapper = new TypeMapper($naming); 19 + } 20 + 21 + public function test_it_maps_string_type(): void 22 + { 23 + $type = $this->mapper->toPhpType(['type' => 'string']); 24 + 25 + $this->assertSame('string', $type); 26 + } 27 + 28 + public function test_it_maps_integer_type(): void 29 + { 30 + $type = $this->mapper->toPhpType(['type' => 'integer']); 31 + 32 + $this->assertSame('int', $type); 33 + } 34 + 35 + public function test_it_maps_boolean_type(): void 36 + { 37 + $type = $this->mapper->toPhpType(['type' => 'boolean']); 38 + 39 + $this->assertSame('bool', $type); 40 + } 41 + 42 + public function test_it_maps_number_type(): void 43 + { 44 + $type = $this->mapper->toPhpType(['type' => 'number']); 45 + 46 + $this->assertSame('float', $type); 47 + } 48 + 49 + public function test_it_maps_array_type(): void 50 + { 51 + $type = $this->mapper->toPhpType(['type' => 'array']); 52 + 53 + $this->assertSame('array', $type); 54 + } 55 + 56 + public function test_it_maps_object_type(): void 57 + { 58 + $type = $this->mapper->toPhpType(['type' => 'object']); 59 + 60 + $this->assertSame('array', $type); 61 + } 62 + 63 + public function test_it_maps_blob_type(): void 64 + { 65 + $type = $this->mapper->toPhpType(['type' => 'blob']); 66 + 67 + $this->assertSame('\\SocialDept\\Schema\\Data\\BlobReference', $type); 68 + } 69 + 70 + public function test_it_maps_bytes_type(): void 71 + { 72 + $type = $this->mapper->toPhpType(['type' => 'bytes']); 73 + 74 + $this->assertSame('string', $type); 75 + } 76 + 77 + public function test_it_maps_cid_link_type(): void 78 + { 79 + $type = $this->mapper->toPhpType(['type' => 'cid-link']); 80 + 81 + $this->assertSame('string', $type); 82 + } 83 + 84 + public function test_it_maps_unknown_type(): void 85 + { 86 + $type = $this->mapper->toPhpType(['type' => 'unknown']); 87 + 88 + $this->assertSame('mixed', $type); 89 + } 90 + 91 + public function test_it_maps_ref_type(): void 92 + { 93 + $type = $this->mapper->toPhpType([ 94 + 'type' => 'ref', 95 + 'ref' => 'app.bsky.feed.post', 96 + ]); 97 + 98 + $this->assertSame('\\App\\Lexicon\\Feed\\Bsky\\App\\Post', $type); 99 + } 100 + 101 + public function test_it_maps_union_type(): void 102 + { 103 + $type = $this->mapper->toPhpType(['type' => 'union']); 104 + 105 + $this->assertSame('mixed', $type); 106 + } 107 + 108 + public function test_it_handles_nullable_types(): void 109 + { 110 + $type = $this->mapper->toPhpType(['type' => 'string'], true); 111 + 112 + $this->assertSame('?string', $type); 113 + } 114 + 115 + public function test_it_does_not_make_mixed_nullable(): void 116 + { 117 + $type = $this->mapper->toPhpType(['type' => 'unknown'], true); 118 + 119 + $this->assertSame('mixed', $type); 120 + } 121 + 122 + public function test_it_maps_array_doc_type_with_items(): void 123 + { 124 + $docType = $this->mapper->toPhpDocType([ 125 + 'type' => 'array', 126 + 'items' => ['type' => 'string'], 127 + ]); 128 + 129 + $this->assertSame('array<string>', $docType); 130 + } 131 + 132 + public function test_it_maps_array_doc_type_without_items(): void 133 + { 134 + $docType = $this->mapper->toPhpDocType(['type' => 'array']); 135 + 136 + $this->assertSame('array', $docType); 137 + } 138 + 139 + public function test_it_maps_object_doc_type_with_properties(): void 140 + { 141 + $docType = $this->mapper->toPhpDocType([ 142 + 'type' => 'object', 143 + 'properties' => [ 144 + 'name' => ['type' => 'string'], 145 + 'age' => ['type' => 'integer'], 146 + ], 147 + ]); 148 + 149 + $this->assertSame('array{name: string, age: int}', $docType); 150 + } 151 + 152 + public function test_it_maps_object_doc_type_without_properties(): void 153 + { 154 + $docType = $this->mapper->toPhpDocType(['type' => 'object']); 155 + 156 + $this->assertSame('array', $docType); 157 + } 158 + 159 + public function test_it_maps_union_doc_type(): void 160 + { 161 + $docType = $this->mapper->toPhpDocType([ 162 + 'type' => 'union', 163 + 'refs' => [ 164 + 'app.bsky.feed.post', 165 + 'app.bsky.feed.repost', 166 + ], 167 + ]); 168 + 169 + $this->assertSame('\\App\\Lexicon\\Feed\\Bsky\\App\\Post|\\App\\Lexicon\\Feed\\Bsky\\App\\Repost', $docType); 170 + } 171 + 172 + public function test_it_adds_null_to_doc_type_when_nullable(): void 173 + { 174 + $docType = $this->mapper->toPhpDocType(['type' => 'string'], true); 175 + 176 + $this->assertSame('string|null', $docType); 177 + } 178 + 179 + public function test_it_checks_if_type_is_nullable(): void 180 + { 181 + // Field marked as required 182 + $this->assertFalse($this->mapper->isNullable(['required' => true])); 183 + 184 + // Field in required array 185 + $this->assertFalse($this->mapper->isNullable(['name' => 'field'], ['field'])); 186 + 187 + // Optional field 188 + $this->assertTrue($this->mapper->isNullable([])); 189 + } 190 + 191 + public function test_it_gets_string_default_value(): void 192 + { 193 + $default = $this->mapper->getDefaultValue(['default' => 'hello']); 194 + 195 + $this->assertSame("'hello'", $default); 196 + } 197 + 198 + public function test_it_gets_boolean_default_value(): void 199 + { 200 + $this->assertSame('true', $this->mapper->getDefaultValue(['default' => true])); 201 + $this->assertSame('false', $this->mapper->getDefaultValue(['default' => false])); 202 + } 203 + 204 + public function test_it_gets_numeric_default_value(): void 205 + { 206 + $this->assertSame('42', $this->mapper->getDefaultValue(['default' => 42])); 207 + $this->assertSame('3.14', $this->mapper->getDefaultValue(['default' => 3.14])); 208 + } 209 + 210 + public function test_it_gets_array_default_value(): void 211 + { 212 + $default = $this->mapper->getDefaultValue(['default' => []]); 213 + 214 + $this->assertSame('[]', $default); 215 + } 216 + 217 + public function test_it_gets_null_default_value(): void 218 + { 219 + $default = $this->mapper->getDefaultValue(['default' => null]); 220 + 221 + $this->assertSame('null', $default); 222 + } 223 + 224 + public function test_it_returns_null_when_no_default(): void 225 + { 226 + $default = $this->mapper->getDefaultValue([]); 227 + 228 + $this->assertNull($default); 229 + } 230 + 231 + public function test_it_checks_if_type_needs_use_statement(): void 232 + { 233 + $this->assertTrue($this->mapper->needsUseStatement(['type' => 'ref'])); 234 + $this->assertTrue($this->mapper->needsUseStatement(['type' => 'blob'])); 235 + $this->assertFalse($this->mapper->needsUseStatement(['type' => 'string'])); 236 + } 237 + 238 + public function test_it_gets_use_statements_for_blob(): void 239 + { 240 + $uses = $this->mapper->getUseStatements(['type' => 'blob']); 241 + 242 + $this->assertContains('SocialDept\\Schema\\Data\\BlobReference', $uses); 243 + } 244 + 245 + public function test_it_gets_use_statements_for_ref(): void 246 + { 247 + $uses = $this->mapper->getUseStatements([ 248 + 'type' => 'ref', 249 + 'ref' => 'app.bsky.feed.post', 250 + ]); 251 + 252 + $this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Post', $uses); 253 + } 254 + 255 + public function test_it_gets_use_statements_for_union(): void 256 + { 257 + $uses = $this->mapper->getUseStatements([ 258 + 'type' => 'union', 259 + 'refs' => [ 260 + 'app.bsky.feed.post', 261 + 'app.bsky.feed.repost', 262 + ], 263 + ]); 264 + 265 + $this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Post', $uses); 266 + $this->assertContains('App\\Lexicon\\Feed\\Bsky\\App\\Repost', $uses); 267 + } 268 + 269 + public function test_it_gets_empty_use_statements_for_primitive(): void 270 + { 271 + $uses = $this->mapper->getUseStatements(['type' => 'string']); 272 + 273 + $this->assertEmpty($uses); 274 + } 275 + 276 + public function test_it_escapes_quotes_in_string_defaults(): void 277 + { 278 + $default = $this->mapper->getDefaultValue(['default' => "it's great"]); 279 + 280 + $this->assertSame("'it\\'s great'", $default); 281 + } 282 + 283 + public function test_it_handles_missing_type(): void 284 + { 285 + $type = $this->mapper->toPhpType([]); 286 + 287 + $this->assertSame('mixed', $type); 288 + } 289 + }